--- title: 'Extending niflowr: wrap a new tool' output: rmarkdown::html_vignette: toc: yes toc_depth: 2.0 css: albers.css includes: in_header: albers-header.html params: family: teal preset: homage resource_files: - albers.css - albers.js - albers-header.html vignette: | %\VignetteIndexEntry{Extending niflowr: wrap a new tool} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} if (requireNamespace("ggplot2", quietly = TRUE) && requireNamespace("albersdown", quietly = TRUE)) ggplot2::theme_set(albersdown::theme_albers(family = params$family, preset = params$preset)) knitr::opts_chunk$set( collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE ) library(niflowr) ``` ```{r albers-classes, echo=FALSE, results='asis'} cat(sprintf( paste0( '' ), params$family, params$preset )) ``` Eventually you will hit a tool niflowr does not ship a wrapper for — yesterday's preprint, your lab's in-house segmenter, a flag the upstream spec never bothered to expose. Adding it is small work: write a JSON spec, call a generator, ship a wrapper. This vignette walks through the loop end-to-end using FreeSurfer's SynthStrip as the worked example. ## The mental model Every wrapper in niflowr is built from three artefacts: | Artefact | Where it lives | Who writes it | |:---------|:---------------|:--------------| | **Spec** — a JSON description of inputs, outputs, and CLI rendering | `inst/specs/.json` | You | | **Wrapper** — an `ni_*()` R function | `R/wrappers_auto.R` | `tools/gen_wrappers.R` | | **Profile** — how the binary is run (native / docker / apptainer) | `niflowr.yml` | The user, at project level | The spec is the source of truth. Wrappers and golden command-line fixtures are regenerated from it. Profiles are kept separate so that the same spec can run natively in CI, in a Docker container on a laptop, and under Apptainer on a cluster — without changes to the spec itself. ## First win: a 12-line spec Let's wrap `mri_synthstrip` with the bare minimum: the input volume and the brain-mask output. We'll write the spec to a tempfile so the vignette can run end-to-end without touching the package source. ```{r write-min-spec} spec_path <- tempfile(fileext = ".json") writeLines(c( '{', ' "spec_version": "0.1.0",', ' "id": "demo.synthstrip",', ' "title": "SynthStrip (demo)",', ' "command": "mri_synthstrip",', ' "inputs": {', ' "in_file": {"type": "file", "required": true,', ' "cli": {"argstr": "-i %s"}},', ' "mask_file": {"type": "file",', ' "cli": {"argstr": "-m %s"}}', ' },', ' "outputs": {', ' "mask_file": {"type": "file",', ' "path": {"from_input": "mask_file"},', ' "must_exist": false}', ' },', ' "runtime": {"profile": "freesurfer"}', '}' ), spec_path) ``` Now load it and build a call: ```{r min-call} spec <- ni_spec_read(spec_path) call <- ni_call(spec, in_file = system.file("DESCRIPTION", package = "niflowr"), mask_file = "/tmp/T1_mask.nii.gz", .engine = "native" ) ni_cmd(call) ``` That's it. The spec file plus `ni_spec_read()` plus `ni_call()` is enough to render a safe, validated command-line vector. No shell strings, no `paste0` — niflowr keeps the command and its arguments separate so they can never collide with shell metacharacters. A `dry_run` confirms the wiring without invoking the binary: ```{r min-dry-run} ni_run(call, dry_run = TRUE, echo = FALSE) ``` ## Anatomy of a spec The minimal spec hides most of the moving parts. Here is what an input definition can carry: ```json "frac": { "type": "double", "desc": "Fractional intensity threshold", "default": 0.5, "validate": {"min": 0, "max": 1}, "cli": {"argstr": "-f %.2f"}, "constraints": {"xor": ["robust"]} } ``` The four blocks that matter: - **`type`** — one of `file`, `dir`, `string`, `int`, `double`, `bool`, `flag`, `enum`, `list`. Drives R coercion and the wrapper's `@param` documentation. - **`validate`** — fail fast at `ni_call()`-time. `exists` (file/dir must be present), `min`/`max` (numeric range), `regex` (string pattern). Errors here surface as clean `cli::cli_abort()` messages instead of mysterious crashes inside the binary. - **`cli.argstr`** — a `printf`-style format string. `%s` for files / strings, `%d` for ints, `%.2f` etc. for doubles. Flags use a literal switch like `-g` (no `%`). - **`constraints`** — `xor` (mutually exclusive) and `requires` (co-required). Validated at call time. ### Positional vs. switched arguments Most CLIs mix the two. niflowr handles both with the same shape: ```json "in_file": {"type": "file", "required": true, "cli": {"argstr": "%s", "position": 0}}, "out_file": {"type": "file", "cli": {"argstr": "%s", "position": 1}}, "frac": {"type": "double", "cli": {"argstr": "-f %.2f"}} ``` `position` orders positional args (negative values count from the end — useful for trailing arguments like `mri_watershed in.mgz out.mgz`). Switched arguments render in alphabetical order before positionals, so the shape is deterministic and golden-testable. ### Output paths come from inputs Outputs declare *where* the artefact lands so downstream code (targets, BIDS-aware tools, `ni_result$outputs`) can find it: | Pattern | Use when | |:--------|:---------| | `{"from_input": "out_file"}` | the user passes the path explicitly | | `{"template": "{sd}/{sid}/mri/aseg.mgz"}` | the path is computed from other inputs (glue interpolation) | | `{"static": "results.txt"}` | the tool always writes to a fixed name in cwd | Set `must_exist: false` when the output is optional (e.g. SynthStrip's `-m` and `-d` only produce files when the user requested them). ### Runtime profile ```json "runtime": {"profile": "freesurfer"} ``` The profile name is a *contract*, not a binding. Users wire profile names to docker images / apptainer SIFs / native paths in their own `niflowr.yml`. Pick a profile name that matches the binary's natural home: `freesurfer`, `fsl`, `ants`, `afni`, or a new one. If you invent a new profile, document the docker image users will likely point it at. ## Object zoo Three classes carry your call through the system: | Class | Constructor | What it holds | |:------|:------------|:--------------| | `ni_spec` | `ni_spec_read()` | Parsed spec — inputs, outputs, command, runtime | | `ni_call` | `ni_call()` | Spec + bound input values, validated, ready to render | | `ni_result` | `ni_run()` | Exit status, captured stdout/stderr, resolved output paths, provenance | You'll write specs and read results. The `ni_call` in the middle is what `dry_run`, `ni_cmd()`, and the golden-fixture harness all operate on. ## Promoting a spec from local to bundled Working from a tempfile is fine for one-off experiments. To ship the wrapper as part of a package (your fork of niflowr, or a downstream package that depends on it), promote the spec through the package development loop: **1. Drop the spec in `inst/specs/`.** Naming convention is `..json`, with the spec `id` matching the filename: ``` inst/specs/freesurfer.synthstrip.json ``` **2. Lint it.** This catches shell metacharacters, positional collisions, malformed flags, and constraint references that point at inputs that don't exist: ```{r lint, eval = FALSE} ni_lint_specs(strict = TRUE) ``` In `fix = TRUE` mode the linter rewrites the safe cases in place (`> %s` → `stdout_to`, dropped duplicate positions, normalised constraint arrays). **3. Regenerate the wrapper.** This produces `R/wrappers_auto.R` from *all* specs in `inst/specs/` — your new one and the existing 300+: ```sh Rscript tools/gen_wrappers.R ``` A wrapper named `ni_freesurfer_synthstrip()` now exists, with one `@param` per input and `dry_run` / `echo` / `.cwd` / `.engine` / `.profile` controls. **4. Update NAMESPACE and man pages.** ```sh Rscript -e 'roxygen2::roxygenise(".")' ``` **5. Refresh the golden command-line fixture.** This is the regression test that protects against accidental CLI changes — every spec gets a deterministic input/output snapshot: ```sh Rscript tools/gen_golden_cmdline.R ``` **6. Write a test.** Mirror the pattern from `tests/testthat/test-fastsurfer.R` or `tests/testthat/test-freesurfer-synthstrip.R`: load the spec, build a call with synthetic temp files, assert the rendered `cmd$args`, and finish with a `dry_run` smoke test. ```{r test-pattern, eval = FALSE} test_that("freesurfer.synthstrip renders core CLI args", { in_file <- withr::local_tempfile(fileext = ".nii.gz") file.create(in_file) call <- ni_call("freesurfer.synthstrip", in_file = in_file, mask_file = "/tmp/mask.nii.gz", gpu = TRUE ) cmd <- ni_cmd(call) expect_equal(cmd$command, "mri_synthstrip") expect_true(all(c("-i", in_file) %in% cmd$args)) expect_true(all(c("-m", "/tmp/mask.nii.gz") %in% cmd$args)) expect_true("-g" %in% cmd$args) }) ``` That's the entire loop. The bundled `inst/specs/freesurfer.synthstrip.json` is the full version of the example we built above — about 70 lines, covering `-i`, `-o`, `-m`, `-d`, `-b`, `-g`, `-t`, `--no-csf`, and `--model`. Read it next to this vignette to see the moving parts in their natural habitat. ## Avoid these traps A few things the linter will catch but are easier to get right the first time: - **Don't put `%` placeholders on `flag` types.** A flag is presence / absence — its argstr is the literal switch (`-g`, not `-g %s`). - **Don't redirect with shell metacharacters.** `"argstr": "> %s"` is illegal because niflowr never invokes a shell. Use `cli.stdout_to: "out_file"` instead — the runner handles redirection itself. - **Don't use the same `position` for two required inputs.** Use distinct integers, or negative positions for trailing args. - **Validate at the boundary.** Numeric ranges, regex patterns, and `exists: true` are far cheaper than a half-completed run that crashes inside the binary. ## Next steps - The schema lives at `inst/schema/niflowr-spec-0.1.0.json` — every field above is documented there. - Existing specs in `inst/specs/` are the best reference. `fsl.bet.json` shows `xor` constraints in anger; `fastsurfer.run.json` shows `template` output paths; `freesurfer.synthmorph_register.json` shows trailing positionals via negative `position`. - See `vignette("niflowr")` for the user-facing workflow, including container profiles and `{targets}` integration.