diff --git a/.github/release-staging.toml b/.github/release-staging.toml index 8094b7baf..527427aeb 100644 --- a/.github/release-staging.toml +++ b/.github/release-staging.toml @@ -22,6 +22,33 @@ destination = "LICENSE" output = "license_path" required = true +# These files are generated by build.rs from Cli::command(), so each released +# archive carries completion data that matches the binary it contains. +[[common.artefacts]] +source = "target/generated-completions/{target}/release/netsuke.bash" +destination = "completions/bash/netsuke" +required = true + +[[common.artefacts]] +source = "target/generated-completions/{target}/release/netsuke.elv" +destination = "completions/elvish/netsuke.elv" +required = true + +[[common.artefacts]] +source = "target/generated-completions/{target}/release/netsuke.fish" +destination = "completions/fish/netsuke.fish" +required = true + +[[common.artefacts]] +source = "target/generated-completions/{target}/release/_netsuke.ps1" +destination = "completions/powershell/_netsuke.ps1" +required = true + +[[common.artefacts]] +source = "target/generated-completions/{target}/release/_netsuke" +destination = "completions/zsh/_netsuke" +required = true + [targets.linux-x86_64] platform = "linux" arch = "x86_64" diff --git a/Cargo.lock b/Cargo.lock index e5215dd8a..2f5a4574e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,6 +358,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.55" @@ -1560,6 +1569,7 @@ dependencies = [ "cap-primitives 3.4.4", "cap-std 3.4.4", "clap", + "clap_complete", "clap_mangen", "digest 0.11.3", "fluent-bundle", @@ -1609,6 +1619,7 @@ dependencies = [ "toml 0.8.23", "tracing", "tracing-subscriber", + "unicode-width 0.2.1", "ureq", "url", "wait-timeout", diff --git a/Cargo.toml b/Cargo.toml index 2e39d0774..bd5128e6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ name = "netsuke" path = "src/main.rs" [package.metadata.ortho_config] -root_type = "netsuke::cli::CliConfig" +root_type = "netsuke::cli::ReleaseHelpCli" locales = [ "ar", "cs", @@ -132,10 +132,12 @@ wait-timeout = "0.2" url = "^2.5.0" ortho_config = { version = "0.9.0", features = ["serde_json"] } sys-locale = "0.3.2" +unicode-width = "0.2.1" [build-dependencies] cap-std = "3.4.4" clap = { version = "4.5.0", features = ["derive"] } +clap_complete = "4.5.0" clap_mangen = "0.3.0" ortho_config = { version = "0.9.0", features = ["serde_json"] } serde = { version = "1", features = ["derive"] } diff --git a/build.rs b/build.rs index 1ddc40615..bad63cd41 100644 --- a/build.rs +++ b/build.rs @@ -1,13 +1,16 @@ //! Build script for Netsuke. //! -//! This script performs two main tasks: +//! This script performs three main tasks: //! - Generate the CLI manual page into `target/generated-man//` for release //! packaging. +//! - Generate Bash, Elvish, Fish, PowerShell, and Zsh completion files into +//! `target/generated-completions//` from the same Clap command tree. //! - Audit localization keys declared in `src/localization/keys.rs` against the Fluent bundles //! in `locales/*/messages.ftl`, failing the build if any declared key is missing from a //! locale. use cap_std::{ambient_authority, fs::Dir}; use clap::CommandFactory; +use clap_complete::aot::{Shell, generate_to}; use clap_mangen::Man; use std::{ env, @@ -119,10 +122,10 @@ fn manual_date() -> String { clippy::disallowed_methods, reason = "TARGET and PROFILE are set by Cargo for the build script alone; nothing else knows the triple and profile being built, so they cannot be passed in" )] -fn out_dir_for_target_profile() -> PathBuf { +fn out_dir_for_target_profile(artefact: &str) -> PathBuf { let target = env::var("TARGET").unwrap_or_else(|_| "unknown-target".into()); let profile = env::var("PROFILE").unwrap_or_else(|_| "unknown-profile".into()); - PathBuf::from(format!("target/generated-man/{target}/{profile}")) + PathBuf::from(format!("target/{artefact}/{target}/{profile}")) } fn write_man_page(data: &[u8], dir: &Path, page_name: &str) -> std::io::Result { @@ -144,6 +147,7 @@ fn write_man_page(data: &[u8], dir: &Path, page_name: &str) -> std::io::Result

Result<(), Box> { Ok(()) } +fn generate_completions(out_dir: &Path) -> Result<(), Box> { + let working_dir = Dir::open_ambient_dir(".", ambient_authority())?; + working_dir.create_dir_all(out_dir)?; + let cli_command = cli::Cli::command(); + let name = cli_command + .get_bin_name() + .unwrap_or_else(|| cli_command.get_name()) + .to_owned(); + + for shell in [ + Shell::Bash, + Shell::Elvish, + Shell::Fish, + Shell::PowerShell, + Shell::Zsh, + ] { + let mut completion_command = cli::Cli::command(); + generate_to(shell, &mut completion_command, &name, out_dir)?; + } + + // Publish the directory so tests can inspect the exact generated artefacts + // rather than recreating the generator's path and file-name conventions. + println!( + "cargo:rustc-env=NETSUKE_GENERATED_COMPLETIONS_DIR={}", + out_dir.display() + ); + Ok(()) +} fn main() -> Result<(), Box> { emit_rerun_directives(); build_l10n_audit::audit_localization_keys()?; - let out_dir = out_dir_for_target_profile(); - generate_man_page(&out_dir) + generate_man_page(&out_dir_for_target_profile("generated-man"))?; + generate_completions(&out_dir_for_target_profile("generated-completions")) } diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 34b9456ec..856bec9b8 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -26,6 +26,73 @@ as the durable architecture record. [adr-003-cli]: adr-003-agent-consistent-human-first-cli.md +## Ninja child-process APIs and help-runner boundary + +The public Ninja process helpers are re-exported from `netsuke::runner`. +`CommandEnv` is an explicit, composable set of child-process overrides: +`CommandEnv::inherit()` leaves the parent environment in place, +`with_var` overrides one variable, and `with_path` replaces the child's +`PATH`. The parent process is never mutated. `NinjaBuildRequest` and +`NinjaToolRequest` borrow the program, CLI settings, generated build file, +target list or tool name, and `CommandEnv` needed for one invocation. + +The legacy `run_ninja` and `run_ninja_tool` helpers retain their existing +signatures and inherit the parent environment. Callers that need an isolated +child use `run_ninja_with` or `run_ninja_tool_with` with one of the request +types. Keep environment selection at this process boundary: do not add +process-wide environment mutation to callers or tests. + +`netsuke help targets` is deliberately a different runner path. The dispatch +layer routes `HelpTopic::Targets` to `src/runner/help.rs`, which resolves and +runs the manifest loading, expansion, and rendering stages, then always builds +and validates a `BuildGraph` before rendering the deterministic +action-then-target catalogue. An invalid graph aborts before the catalogue is +rendered. It must not generate a Ninja file, call a Ninja subprocess, execute a +recipe, or create build outputs. Its Jinja environment is a restricted, +side-effect-free query surface. It allowlists only the lexical path filters +`basename`, `dirname`, `with_suffix`, and `relative_to`, the collection filters +`uniq`, `flatten`, and `group_by`, and the clock-independent `timedelta` +function. It rejects `env()` and `glob()`, file tests, filesystem metadata +filters such as `size` and `linecount`, `hash`, `digest`, `contents`, `realpath`, +and `expanduser`, executable discovery through `which` and +`command_available`, network and command helpers (`fetch`, `shell`, and +`grep`), and the clock-dependent `now()` function. Normal build manifest +rendering still registers the full standard library; this restriction applies +only to query rendering. + +The query allowlist has one owner: `register_manifest_query`. Query loading +does not construct `StdlibConfig`; the registration function composes the +allowlist directly. Reuse its lexical path, collection, and time registration +helpers only when a helper's result depends on template inputs rather than the +host. Do not add a host-observing helper to the shared query registration path; +assess and record any future allowlist change here. The no-topic and +named-command help paths render clap help directly and do not load a manifest. +Keep future help topics within this boundary rather than coupling read-only +inspection to `runner::process`. + +### Help-target query telemetry + +`src/runner/help_telemetry.rs` is the observability boundary around the pure +manifest and catalogue query within `netsuke help targets`. +`instrument_help_targets` wraps that query and records the fixed metrics +`netsuke_runner_help_targets_total` and +`netsuke_runner_help_targets_duration_seconds`. It also opens the +`runner.help_targets` span and emits a bounded `Completed help targets query` +event when the query finishes. The command boundary in `src/runner/help.rs` +owns status reporting and rendering after the query succeeds. + +Telemetry labels use only the fixed `outcome` values `success` and `error`, and +the fixed `error_category` values `none`, `manifest_not_found`, and `other`. +The wrapper never records manifest-controlled names, descriptions, paths, or +other details. Metric descriptions are registered once per process, through a +`Once`, so repeated queries do not re-register them. + +Telemetry tests use `metrics::with_local_recorder` with a +`metrics_util::DebuggingRecorder`, together with the local tracing subscriber +capture helper. They assert the counter, duration sample, and completion event +for a successful fixture query, a missing-manifest failure, and an invalid +manifest failure classified as the non-`RunnerError` `other` category. + ## Localization `src/locale_catalogues.rs` is the authoritative registry of shipped catalogues. @@ -783,9 +850,14 @@ the policy, rejects tracked drift, and scans every tracked Markdown file. ## Release help tooling -Release builds generate help artefacts explicitly with `cargo-orthohelp`, -rather than from `build.rs`. The build script remains responsible for the -localization key audit only. Release automation installs the pinned tool with: +Release builds generate their manual and PowerShell help explicitly with +`cargo-orthohelp`, rather than consuming the ordinary-build help artefacts from +`build.rs`. The metadata root is `netsuke::cli::ReleaseHelpCli`, which combines +`CliConfig` field metadata with the Clap command surface, including +`help targets`, so the release manual and PowerShell help remain aligned with +the CLI. During ordinary Cargo builds, `build.rs` generates the local manual +page and shell completions, and audits the localization keys. Release +automation installs the pinned tool with: ```bash cargo install cargo-orthohelp --version 0.9.0 --locked @@ -808,6 +880,13 @@ PowerShell external help under date from `SOURCE_DATE_EPOCH`, falling back to `1970-01-01` when unset or invalid. +Shell completions are generated separately by `build.rs` from +`Cli::command()` for Bash, Elvish, Fish, PowerShell, and Zsh. Release staging +copies these portable completion sidecars into each standalone archive under +`completions//`. They remain separate files for users to copy into the +completion location documented by their shell; package installation does not +claim to install them. + Keep `[package.metadata.ortho_config]` in `Cargo.toml` aligned with the CLI when adding, renaming, or removing user-facing options. Changes to CLI documentation metadata should be covered by `rstest` workflow/script contract diff --git a/docs/execplans/issue-551-add-target-descriptions-and-netsuke-help-targets.md b/docs/execplans/issue-551-add-target-descriptions-and-netsuke-help-targets.md new file mode 100644 index 000000000..ed2be2acf --- /dev/null +++ b/docs/execplans/issue-551-add-target-descriptions-and-netsuke-help-targets.md @@ -0,0 +1,294 @@ +# Add optional target descriptions and `netsuke help targets` + +This ExecPlan (execution plan) is a living document. The sections `Constraints`, +`Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, `Decision Log`, +and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Status: COMPLETE + +## Purpose / big picture + +Netsuke currently supports descriptions only on reusable rules. Those +descriptions feed Ninja progress output, but targets and actions have no +discovery metadata. This plan adds an optional `description` field to targets +(and, by inheritance, actions) and exposes the rendered target and action +catalogue through a new `netsuke help targets` subcommand. The command loads, +expands, renders, and validates the selected manifest without invoking Ninja, +then prints the available targets and actions with their descriptions. + +The discovery query uses a restricted, side-effect-free Jinja surface. It +allowlists only the lexical path filters `basename`, `dirname`, `with_suffix`, +and `relative_to`, the collection filters `uniq`, `flatten`, and `group_by`, +and the clock-independent `timedelta` function. It rejects `env()` and `glob()`, +file tests, filesystem metadata filters such as `size` and `linecount`, `hash`, +`digest`, `contents`, `realpath`, and `expanduser`, executable discovery through +`which` and `command_available`, network and command helpers (`fetch`, `shell`, +and `grep`), and the clock-dependent `now()` function. This keeps manifest +inspection from disclosing host state, reading file contents, fetching data, +executing commands, or writing caches. Normal build manifest rendering retains +the full standard library; the restriction applies only to query rendering. + +A user can verify the change by writing a manifest with an action and a target +that carry `description`, then running `netsuke help targets` and observing the +two catalogue sections with aligned name/description columns and a localized +default marker such as `[★ default]` on manifest defaults. + +## Constraints + +- Traditional AST/render/expansion open-source repo layering must be respected: + `src/ast/mod.rs`, `src/manifest/render.rs`, `src/manifest/expand.rs`. +- `description` must be optional on every target and action; duplicate or + unknown fields must remain validation errors. +- A target description is discovery metadata and must not silently replace a + referenced rule description used for Ninja progress. +- Existing manifests must remain valid and retain their current execution + output. +- The help command performs no recipes and creates no build outputs. +- The build-time `build_l10n_audit` must pass: every key declared in + `src/localization/keys.rs` must exist in every `locales/*/messages.ftl` with + matching interpolation variables. +- No file may exceed 400 lines (AGENTS.md), and every module must begin with a + `//!` comment. +- en-GB-oxendict spelling and grammar in comments and docs. +- Polonius borrow-checker rules must be respected; never rewrite tagged sites. + +## Tolerances (exception triggers) + +- Scope: if implementation requires changes to more than ~40 files or a + substantial new dependency, stop and escalate. +- Interface: if a public API signature must change beyond the planned + `Target::description` field and the new `Commands::Help` variant, stop and + escalate. +- Dependencies: if a new external dependency is required, stop and escalate. +- Iterations: if a gate still fails after 3 attempts without a fix, stop and + escalate. +- Ambiguity: if a design choice materially affects the outcome, stop and + present options. + +## Risks + +- Risk: clap's implicit `help` subcommand collides with the new `Commands::Help` + variant. Severity: high Likelihood: high Mitigation: call + `.disable_help_subcommand(true)` in the command-building path; verify + `netsuke help` still matches `--help` via `tests/novice_flow_smoke_tests.rs`. +- Risk: the l10n audit rejects the build when only some locales receive the new + keys. Severity: high Likelihood: high Mitigation: add all eight new keys to + every one of the 35 `locales/*/messages.ftl` files in the same commit as + `keys.rs`. +- Risk: snapshot tests for CLI help (`help_en_us`, `help_es_es`) change because + the `help` subcommand now carries a custom about line. Severity: medium + Likelihood: high Mitigation: regenerate and accept the snapshots as part of + Phase 2/3. +- Risk: the `main` entry point and config merge treat `Commands::Help` like a + build command. Severity: medium Likelihood: low Mitigation: `resolve_command` + already clones unknown variants; verify with the full test suite. + +## Progress + +- [x] (2026-08-09) Reconnaissance: read AST, render, expand, CLI parser, + cli_l10n, runner dispatch/graph, status pipeline, l10n keys/audit, + result_json, output_prefs, BDD infrastructure. +- [x] (2026-08-09) Phase 1: `Target::description` through AST, render, and + expansion; parser/actions/render/expand tests pass. +- [x] (2026-08-09) Phase 2: `Commands::Help`/`HelpTopic`, `help.rs` handler, + text/JSON renderers, l10n keys in all 35 locales, dispatch wiring. +- [x] (2026-08-09) Phase 3: help_tests snapshots (text/accessible/es-ES/JSON), + runner_help_targets_tests, BDD CLI+full-process scenarios, regenerated + help_en_us/help_es_es snapshots. +- [x] (2026-08-09) Phase 4: users-guide updated (schema field, distinction + from rule descriptions, subcommand list, worked example + tested-example + and its test); CliConfig/Clap integration keeps ordinary-build `build.rs` + artefacts (the `target/generated-man/...` man page and + `target/generated-completions/...` Bash, Elvish, Fish, PowerShell, and Zsh + completions) and release `cargo-orthohelp` artefacts under + `target/orthohelp/...` aligned with the `help targets` command. Release + staging and workflow contracts cover the manual, PowerShell help, and + completion sidecars. +- [x] (2026-08-09) All gates green: check-fmt, lint (rustdoc/clippy/Whitaker), + nextest (1936), doctests, markdownlint, spelling, nixie. Committed as + four atomic commits. +- [x] (2026-08-09) CodeRabbit `--agent` review: 0 findings. +- [x] (2026-08-09) Branch renamed to + `issue-551-add-target-descriptions-and-netsuke-help-targets`, pushed, + PR opened: . +- [x] (2026-08-14, `7fc57169`) Documented the restricted, side-effect-free Jinja + surface for `netsuke help targets` in the migration, users', developers', + and CLI design guides. +- [x] (2026-08-14, `2891032c`, `ba6df590`, `eb76034b`) Routed target help + through a restricted manifest query path, escaped terminal control + characters in text output, and hardened catalogue coverage for the query + invariants. +- [x] (2026-08-14, `90a7d4c3`, `5ca08e30`) Clarified that target and action + descriptions remain discovery metadata and do not replace rule + descriptions in Ninja progress; added and localized the dedicated nested + `targets` help synopsis. +- [x] (2026-08-14) Documented the complete query-mode allowlist, its excluded + host-observing helpers, and the full standard library retained by normal + manifest rendering. +- [x] (2026-08-15, `c53a2a92`, `1e4b4d07`) Isolated help-query dependencies + and separated the pure manifest/catalogue query from status reporting, + telemetry, and rendering at the command boundary. +- [x] (2026-08-15, `5a436620`, `0125986e`, `17b81845`, `74099972`) Added and + documented bounded help-target query telemetry, then consolidated its + tests and tightened its fixed labels and redaction contract. +- [x] (2026-08-16, `0b5bb249`, `5ad71492`) Hardened rendered-graph and default + validation, including a localized, terminal-safe invalid-default + diagnostic across the shipped locales. + +## Surprises & discoveries + +- Observation: clap's implicit `help` pseudo-subcommand already appears in the + CLI help snapshots as + `help Print this message or the help of the given subcommand(s)`, so the + snapshot change is contained to the description line. Evidence: + `src/snapshots/cli/netsuke__cli__parser__tests__help_en_us.snap`. Impact: + Phase 2 must regenerate these snapshots. +- Observation: the l10n audit compares interpolation variables against the + English source. The catalogue keys introduce no variables, while + `runner.manifest.default_not_declared` intentionally uses `$default`, which + every locale must retain. Evidence: `build_l10n_audit/compare.rs`. Impact: + keep the eight new keys and their interpolation variables aligned across all + shipped locales. +- Observation: `test_support::localizer::locale_localizer` does not affect the + library's own global `LOCALIZER` static inside unit-test binaries (the crate + is compiled twice). Unit tests must set the localizer directly via + `crate::localization::set_localizer_for_tests`. Evidence: the localized + snapshot stayed English until the unit test installed the localizer through + the library's own API. Impact: unit snapshot tests use the library-local + localizer installer. +- Observation [type:docstyle]: The + `cli_localization::tracing_tests::a_resolved_locale_reports_requested_and_effective_tags` + test is a PRE-EXISTING flake on the base commit (reproduced with `git stash` + on 487f77e, ~2/3 failure rate). Root cause: `tracing` caches callsite interest + from the first subscriber to register it; the `Dispatch::none()` default in + the test binary returns `Interest::never()`, poisoning the callsite for the + process when a no-op thread touches it first. A global TRACE-hinted + subscriber was tried but did not fully fix it and added risk, so the change + was reverted. Impact: gates may intermittently fail on this test; re-run the + suite when it hits (it passes in isolation and with `--test-threads=1`). + Fixing the infrastructure properly is a separate concern from issue #551. +- Observation: the target-help query path uses a dedicated localized + synopsis for the nested `targets` help topic rather than the catalogue's + section heading. Evidence: `5ca08e30`. Impact: keep the + `cli.help.targets.about` key separate from `actions_heading` and + `targets_heading`. + +## Decision log + +- Decision: follow the issue's supplied coding plan exactly, phase by phase. + Rationale: the plan has already been reviewed and accepted as requirements. + Date/Author: 2026-08-09 / Claude. +- Decision: create the execplan under + `docs/execplans/issue-551-add-target-descriptions-and-netsuke-help-targets.md` + (derived from the current branch name as instructed). Rationale: AGENTS.md + names the plan file from the current branch. Date/Author: 2026-08-09 / Claude. + +## Outcomes & retrospective + +The issue's acceptance criteria are met: the AST, rendered manifest, and +catalogue carry target/action descriptions; parser, validation, render, and +expansion coverage exists; `netsuke help targets` is snapshot-tested in text, +accessible, localized, and JSON modes; alternate manifest selection is tested; +the users guide documents the schema field and the subcommand. Ordinary +`build.rs` output includes a man page and Bash, Elvish, Fish, PowerShell, and +Zsh completion files. Release `cargo-orthohelp` output includes the manual and +Windows PowerShell help, with `man_page_contract_tests.rs`, +`release_staging_tests.rs`, and `workflow_build_and_package.rs` covering those +artefacts and the completion sidecars. + +The follow-up additionally isolates discovery rendering from impure template +helpers, keeps terminal text safe, preserves rule descriptions as the source +of Ninja progress text, and supplies the nested help synopsis in all 35 +shipped locales. These outcomes are recorded by reachable commits `2891032c`, +`ba6df590`, `eb76034b`, `90a7d4c3`, and `5ca08e30`. Later reachable commits +`c53a2a92`, `1e4b4d07`, `5a436620`, `0125986e`, `5ad71492`, and `74099972` +separate and instrument the pure query boundary, harden its dependencies and +telemetry, and localize the invalid-default diagnostic. + +Lessons learned: + +- The `tracing` callsite interest cache makes capture-based tracing tests + flaky under parallel execution; this is a pre-existing issue on the base + commit and was left untouched (see Surprises & Discoveries). +- `test_support` localizer helpers target a separate crate instance in + unit-test binaries; unit tests must install the library's own localizer. +- `make fmt` runs `mdformat-all`, which reformats files outside the + `check-fmt` gate; those changes were reverted to keep the PR focused. + +## Context and orientation + +This repository is a Rust CLI (`netsuke`) that parses YAML+Jinja manifests and +generates Ninja build files. Key files and modules for this task: + +- `src/ast/mod.rs` — `NetsukeManifest`, `Target`, `Rule`, `Recipe`. `Target` has + `deny_unknown_fields`; actions are `Vec` deserialized by + `deserialize_actions`, which forces `phony = true`. +- `src/manifest/mod.rs` — `from_str_named` pipeline: YAML parse, vars + registration, `expand_foreach`, serde deserialize, `render_manifest`. +- `src/manifest/render.rs` — `render_manifest`, `render_rule`, `render_target`; + `render_str_with` renders Jinja in a string against a context. +- `src/manifest/expand.rs` — `expand_foreach`; `foreach`/`when` clone the whole + entry map, so a `description` key flows through unmodified. +- `src/cli/parser.rs` — `Cli`, `Commands` enum, `parse_with_localizer_from`. +- `src/cli_l10n.rs` — `localize_command`, `Subcommand` enum, key helpers. +- `src/runner/dispatch.rs` — `execute` matches `Commands` variants. +- `src/runner/graph.rs` — pattern for an in-process handler using + `load_manifest_with_stage_reporting` and `BuildGraph::from_manifest`. +- `src/status.rs` / `src/status_pipeline.rs` — `PipelineStage`, + `StatusReporter`, + `report_pipeline_stage`. +- `src/localization/keys.rs` — Fluent key registry (`define_keys!`). The + build-time audit requires every key in every locale. +- `src/result_json.rs` / `src/json_envelope.rs` — versioned JSON document + envelope. +- `src/output_prefs.rs` / `src/theme.rs` — theme/accessibility resolution. +- `tests/novice_flow_smoke_tests.rs` — `netsuke help` must match `--help`. +- `tests/runner_graph_tests.rs` — model for the new integration test module. +- `tests/features/cli.feature` + `tests/bdd/steps/cli.rs` — BDD CLI parsing. +- `tests/bdd/steps/manifest_command.rs` + helpers — full-process subcommand + BDD pattern. + +## Plan of work + +- Phase 1: add `pub description: Option` to `Target` in `src/ast/mod.rs` + with `#[serde(default)]` and a Rustdoc comment mirroring `Rule::description`; + render it in `render_target` through `render_str_with` exactly like + `render_rule`; leave `expand.rs` untouched because `foreach`/`when` clone the + entry map. Extend `tests/ast_tests/parsing.rs` (present, absent, + duplicate/unknown rejection), `tests/ast_tests/actions.rs` (action carries + description, stays phony), add a render test proving Jinja resolution against + `vars`, and add `src/manifest/expand_test_cases/` cases proving a description + survives `foreach` and is dropped by `when` filtering. +- Phase 2: add `Commands::Help(HelpArgs)` with `topic: Option` and + `HelpTopic::Targets`; call `.disable_help_subcommand(true)` in + `localize_command`/`parse_with_localizer_from`; add a dispatch arm routing + `HelpTopic::Targets` to a new `help::handle_help_targets`; for the no-topic + case rebuild the localized command and render long help; accept existing + subcommand names as topics. Create `src/runner/help.rs` following the + `graph.rs` pattern. Add a deterministic listing model and text/JSON + renderers. Add l10n keys and all locale translations. +- Phase 3: snapshot tests under `src/runner/help_tests.rs`, integration tests + in `tests/runner_help_targets_tests.rs`, BDD scenarios in + `tests/features/cli.feature` + `tests/bdd/steps/cli.rs`, and a full-process + BDD scenario. Regenerate `help_en_us`/`help_es_es` snapshots. +- Phase 4: document the field and the subcommand in `docs/users-guide.md`; + integrate CliConfig with Clap so ordinary-build `build.rs` man and + `target/generated-completions/...` Bash, Elvish, Fish, PowerShell, and Zsh + completion artefacts, plus release `cargo-orthohelp` manual and Windows + PowerShell help under `target/orthohelp/...`, expose the same `help targets` + command surface. Release staging and workflow contracts cover these outputs. + +## Validation and acceptance + +- Run `make check-fmt`, `make lint`, and `make test` (via `scrutineer`) before + each commit. +- `cargo nextest run` focused tests: `tests/ast_tests`, `expand_tests`, + `runner_help*`, `novice_flow_smoke_tests`, `man_page_contract_tests`, + `release_help_script_tests`. +- `netsuke help targets` on a fixture with actions, targets, defaults, and a + missing description prints both sections with a localized marker such as + `[★ default]` (or `[* default]` in accessible output) and an empty + description column for the missing case. +- `netsuke --json help targets` emits a JSON envelope with + `command: "help-targets"`. +- `netsuke help` and `netsuke --help` both succeed and print the long help. diff --git a/docs/netsuke-cli-design-document.md b/docs/netsuke-cli-design-document.md index 1f70ba2a8..6670b9149 100644 --- a/docs/netsuke-cli-design-document.md +++ b/docs/netsuke-cli-design-document.md @@ -59,6 +59,30 @@ example should appear in documentation: for instance, creating a minimal Netsukefile and running `netsuke` to show how quickly the tool produces a result. This immediate feedback is crucial for a positive first impression. +### Discover manifest operations + +The CLI also provides a read-only manifest catalogue for discovery. Authors +may add an optional `description` to a target or action; these values describe +the operation in `netsuke help targets` and do not replace a referenced rule's +description for Ninja progress output. The topic honours the selected +manifest and the normal output preferences, including localization, +accessibility, and `--json`. + +`netsuke help targets` loads, expands, renders, and validates the manifest, +then prints actions followed by targets. It does not invoke Ninja, run recipes, +or create build outputs. Rendering uses a restricted, side-effect-free Jinja +surface. Queries allow only the lexical path filters `basename`, `dirname`, +`with_suffix`, and `relative_to`, the collection filters `uniq`, `flatten`, and +`group_by`, and the clock-independent `timedelta` function. Queries reject +`env()` and `glob()`, file tests, filesystem metadata filters such as `size` and +`linecount`, `hash`, `digest`, `contents`, `realpath`, and `expanduser`, +executable discovery through `which` and `command_available`, network and +command helpers (`fetch`, `shell`, and `grep`), and the clock-dependent `now()` +function. This keeps discovery useful in an unfamiliar project without making +help a build operation. Normal build manifest rendering retains the full +standard library; the restriction applies only to query rendering. Existing +manifests remain compatible when they omit the optional descriptions. + Intuitive **defaults** further contribute to a smooth UX. As noted, if no subcommand is given, `netsuke build` is assumed by default. Similarly, common options have sensible defaults: by default, Netsuke looks for `Netsukefile` in diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 6d4c7a41f..6f035e61f 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -196,6 +196,11 @@ level keys. The E-R diagram below summarizes the structure of a `Netsukefile` and the relationships between its components. +For screen readers: `NETSUKE_MANIFEST` contains reusable `RULE` definitions and +`TARGET` entries, including actions. A `TARGET` has optional `description` +metadata for target and action discovery through `netsuke help targets`; this +is distinct from `RULE.description`, which supplies Ninja progress text. + ```mermaid erDiagram NETSUKE_MANIFEST { @@ -219,6 +224,7 @@ erDiagram StringOrList deps StringOrList order_only_deps map vars + string description bool phony bool always } @@ -239,6 +245,8 @@ erDiagram RECIPE }o--|| STRING_OR_LIST : uses ``` +Figure 1: Entity-relationship view of the `Netsukefile` manifest. + ### 2.3 Defining `rules` Each entry in the `rules` list is a mapping that defines a reusable action. @@ -344,10 +352,10 @@ rule: - `script`: A multi-line script passed to the interpreter. When present, it is defined using the YAML `|` block style. -- `description`: A planned, target-local status string. When present on a target - or action, it overrides the referenced rule description for the concrete - build edge. This lets selected conditional actions explain what they are - doing without embedding `echo` statements in recipes. +- `description`: Optional discovery metadata for a target or action. It is + rendered through the normal manifest context and displayed by + `netsuke help targets`. It does not affect Ninja progress output: that stays + driven by the referenced rule's `description`. - `env`: A planned mapping of environment variables to apply when this target or action runs. Target-level values override rule-level values after the rule is @@ -599,14 +607,18 @@ splitting and reduces the need for `shell_escape` in ordinary recipes. #### Execution feedback -The existing `description` field is the right primitive for normal status text. -Netsuke should extend it to targets and actions, and should use the selected -edge's description when emitting Ninja progress. Conditional branch-selection -messages belong in Netsuke's verbose diagnostics, not in mandatory recipe -output: - -- In normal output, the selected action's `description` explains the task being - run. +Rule descriptions are the source of normal Ninja progress text. Target and +action descriptions are discovery metadata displayed by `netsuke help targets`; +they do not affect Ninja progress output. Ninja progress comes exclusively from +the referenced `Rule::description`. Planned target/action environment mappings +remain separate future work under roadmap item 3.14.9. Conditional +branch-selection messages belong in Netsuke's verbose diagnostics, not in +mandatory recipe output: + +- In `netsuke help targets`, target and action `description` values explain the + operations available in the manifest. +- In normal Ninja output, the referenced rule's `description` explains the task + being run. - In verbose output, Netsuke reports why manifest-time conditional branches were included or skipped. - Netsuke should not add generic mutually exclusive `debug`, `info`, or `warn` @@ -663,24 +675,25 @@ An Architecture Decision Record documents the migration rationale and compatibility results; no further action is required beyond monitoring upstream releases. -### 3.2 Core Data Structures (`ast.rs`) +### 3.2 Core Data Structures (`ast/mod.rs`) The Rust structs that `serde_saphyr` deserializes into form the Abstract Syntax Tree (AST) of the build manifest. These structs must precisely mirror the YAML schema defined in Section 2. They will be defined in a dedicated module, -`src/ast.rs`, and annotated with `#[derive(Deserialize)]` (and `Debug`) to +`src/ast/mod.rs`, and annotated with `#[derive(Deserialize)]` (and `Debug`) to enable automatic deserialization and easy debugging. -The authoritative live AST contract is [src/ast.rs](../src/ast.rs). Fields and -types marked `FUTURE` in the snippet below are forward-looking API sketches. In -particular, `Rule.env`, `Target.description`, `Target.env`, `Recipe::Exec`, -`ExecRecipe`, `EnvValue`, and `EnvOperation` describe the intended schema once -the roadmap tasks land; they are not assertions about the current codebase. +The authoritative live AST contract is +[src/ast/mod.rs](../src/ast/mod.rs). Fields and types marked `FUTURE` in the +snippet below are forward-looking API sketches. +`Target.description` is implemented optional discovery metadata; the remaining +forward-looking fields describe the intended schema once the roadmap tasks +land and are not assertions about the current codebase. Rust ```rust -// In src/ast.rs +// In src/ast/mod.rs use serde::Deserialize; use std::collections::HashMap; @@ -693,6 +706,9 @@ pub struct NetsukeManifest { #[serde(default)] pub vars: HashMap, + #[serde(default)] + pub macros: Vec, + #[serde(default)] pub rules: Vec, @@ -712,7 +728,7 @@ pub struct Rule { #[serde(flatten)] pub recipe: Recipe, pub description: Option, - // FUTURE: planned Rule.env extension; not present in src/ast.rs yet. + // FUTURE: planned Rule.env extension; not present in src/ast/mod.rs yet. #[serde(default)] pub env: HashMap, #[serde(default)] @@ -727,7 +743,7 @@ pub enum Recipe { Command { command: StringOrList }, Script { script: String }, Rule { rule: StringOrList }, - // FUTURE: planned Recipe::Exec extension; not present in src/ast.rs yet. + // FUTURE: planned Recipe::Exec extension; not present in src/ast/mod.rs yet. Exec { exec: ExecRecipe }, } @@ -758,10 +774,11 @@ pub struct Target { #[serde(default)] pub vars: HashMap, - // FUTURE: planned Target.description extension; not present in src/ast.rs yet. + /// Optional discovery metadata shown by `netsuke help targets`. + #[serde(default)] pub description: Option, - // FUTURE: planned Target.env extension; not present in src/ast.rs yet. + // FUTURE: planned Target.env extension; not present in src/ast/mod.rs yet. #[serde(default)] pub env: HashMap, @@ -812,7 +829,7 @@ and `as_single` build on it. Path conversion deliberately does not live here. The AST models the manifest's surface syntax, in which `sources`, `deps` and `order_only_deps` are plain strings; only manifest-to-IR lowering decides they name files on disk, so `src/ir/from_manifest_support.rs::to_paths` performs -that interpretation at the boundary. Keeping `camino` out of `src/ast.rs` +that interpretation at the boundary. Keeping `camino` out of `src/ast/mod.rs` stops filesystem concerns leaking into the domain model. #### Example Manifest and AST @@ -850,6 +867,7 @@ let ast = NetsukeManifest { deps: StringOrList::Empty, order_only_deps: StringOrList::Empty, vars: HashMap::new(), + description: None, phony: false, always: false, }], @@ -895,7 +913,7 @@ parsing and template evaluation cleanly separated. ### 3.4 Design Decisions -The AST structures are implemented in `src/ast.rs` and derive `Deserialize`. +The AST structures are implemented in `src/ast/mod.rs` and derive `Deserialize`. Unknown fields are rejected to surface user errors early. `StringOrList` provides a default `Empty` variant, so optional lists are trivial to represent. The manifest version is parsed using the `semver` crate to validate that it @@ -1987,13 +2005,13 @@ This transformation involves several steps: FUTURE: - Roadmap tasks `3.14.9` and `3.14.11` will extend `ir::Action`, action - registration, and the `actions` map with target-level `description` and - `env` behaviour. Target-level `description` and `env` values will override - or extend the referenced rule for the concrete action. Env-aware action - hashing will include resolved environment bindings alongside the recipe and - file set so otherwise identical actions remain distinct when their execution - environment differs. + Roadmap task `3.14.9` will extend `ir::Action`, action registration, and the + `actions` map with target-level `env` behaviour. Target-level `description` + remains discovery-only metadata: it is not part of the IR and does not + replace the referenced rule's description for Ninja progress. Env-aware + action hashing will include resolved environment bindings alongside the + recipe and file set so otherwise identical actions remain distinct when their + execution environment differs. 4. **Graph Validation:** As the graph is constructed, perform validation checks. This includes ensuring that every rule referenced by a target exists in the @@ -2934,9 +2952,13 @@ manual flag repetition. The CLI definition doubles as the source for user documentation. Release automation now calls `cargo-orthohelp` explicitly through -`scripts/generate-release-help.sh`; ordinary Cargo builds no longer write help -artefacts. The build script remains in place only for the localization key -audit against Fluent bundles. +`scripts/generate-release-help.sh`; ordinary Cargo builds do not supply the +release manual page or PowerShell help. `cargo-orthohelp` remains the release +source for those artefacts. Separately, `build.rs` generates Bash, Elvish, Fish, +PowerShell, and Zsh completion assets from `Cli::command()`. The completion +files are staged as portable shell-completion sidecars under +`completions//` in release archives. The build script also performs the +localization key audit against Fluent bundles. Manual pages are generated under `target/orthohelp//release/man/man1/netsuke.1`. Windows targets also @@ -2966,7 +2988,9 @@ staged `man_path` output into the shared `linux-packages` composite. The resulting `.deb` and `.rpm` archives both declare a runtime dependency on `ninja-build`. Windows and macOS builds use the same staging composite from `leynos/shared-actions`; Windows staging also carries the PowerShell help files -as release artefacts alongside the MSI package. The composite shells out to a +as release artefacts alongside the MSI package. Every standalone release +archive also carries the generated shell completion sidecars under +`completions//`. The composite shells out to a Cyclopts-driven script that reads the `.github/release-staging.toml` configuration (Tom's Obvious, Minimal Language (TOML)), merges the `[common]` configuration with the target-specific overrides, and copies the configured diff --git a/docs/roadmap.md b/docs/roadmap.md index 44e74354f..fafa7f7b5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -254,18 +254,20 @@ and agents. - [ ] 3.14.11. Surface selected conditional actions without recipe `echo`. Requires 3.14.2 and 3.14.4. See [netsuke-design.md §2.6](netsuke-design.md#26-planned-recipe-ergonomics-and-execution-feedback). - - [ ] Add target/action `description` support and let it override referenced - rule descriptions for the concrete edge. - - [ ] Report selected action descriptions in normal Ninja progress output. + - [x] Add target/action `description` as discovery metadata and list it with + `netsuke help targets`. + - [x] Keep target/action descriptions as discovery metadata; they do not + override the referenced rule description for Ninja progress. + - [x] Keep normal Ninja progress sourced from the referenced rule description; + do not report target/action descriptions there. - [ ] In verbose mode, report why manifest-time `when` branches were included or skipped. - [ ] Do not add generic `debug`, `info`, or `warn` manifest keys unless a later diagnostics design defines severity semantics. - - Note: `description` currently exists only on `Rule` and IR `Action` - (populated from the rule). `Target` in `src/ast.rs` uses - `#[serde(deny_unknown_fields)]`, so a target/action `description` is - rejected today; the struct must gain the field before the override - behaviour can be implemented. + - Note: target/action `description` is discovery metadata rendered by + `netsuke help targets`; Ninja progress remains sourced from the referenced + rule's `description`. Target/action environment mappings remain future work + under 3.14.9. ### 3.15. Canonical CLI redesign diff --git a/docs/users-guide.md b/docs/users-guide.md index 56e8343bb..42955b857 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -73,11 +73,20 @@ Because successive pre-releases share that numeric version, installing a later pre-release MSI replaces the existing installation for that version series rather than installing alongside it. -SHA-256 checksum files accompany standalone binaries and staged help and -licence files. Installer packages do not have checksum sidecars in -v0.1.0-beta1. Windows PowerShell help files are published beside each MSI as +SHA-256 checksum files accompany standalone binaries and staged help, +completion, and licence files. Installer packages do not have checksum sidecars +in v0.1.0-beta1. Windows PowerShell help files are published beside each MSI as sidecar artefacts rather than embedded in the installer. +Each standalone release archive also contains generated shell completion +sidecars under `completions//` for Bash, Elvish, Fish, PowerShell, and +Zsh. These files are portable and separate from the executable and installer +payloads. To use one, extract the matching archive and copy the file for the +chosen shell into that shell's normal completion directory, or load it through +the shell's documented completion mechanism. The package installation +commands above do not install completion files; completion directory names and +activation steps vary by shell and platform. + Install the current source checkout with Cargo. The clone supplies both the pinned nightly toolchain and `RUSTFLAGS=-Zpolonius=next`, so neither is given here — unlike the registry install above, which runs outside a checkout: @@ -296,6 +305,13 @@ A rule or target must provide exactly one recipe: Rules may also provide `description`, text used for Ninja's progress display. +Targets and actions may also provide `description`, but with a different +purpose: a target or action description is discovery metadata surfaced by +`netsuke help targets` (see +[Generate and inspect artefacts](#generate-and-inspect-artefacts)). It does +not affect Ninja progress output, which stays driven by the referenced rule's +`description`. + A `command` list runs its entries in declaration order and stops at the first non-zero exit, so entries share the fail-fast behaviour of a handwritten `&&` chain. The command field is a `StringOrList`: a scalar remains one shell @@ -376,6 +392,10 @@ A target supports these fields: and `glob` restriction above applies here too. - `phony`: marks a logical target that does not represent a file. - `always`: forces the recipe to run whenever the target is requested. +- `description`: an optional human-readable summary of the public operation + the target performs. It is discovery metadata shown by `netsuke help + targets`; it never replaces a referenced rule's `description` in Ninja + progress output. `name`, `sources`, `deps`, and `order_only_deps` accept either one string or a list of strings. @@ -708,6 +728,10 @@ The commands are: Ninja manifest is the only content written to stdout; use `--output ` to write it to a file instead. In JSON mode (`--json`) the manifest is carried in the result document's `result.content` field instead. +- `help [TOPIC]`: print the top-level help, or the help for a named topic. + With no topic, it matches `--help`. `help targets` prints the target and + action catalogue for the selected manifest (see + [Generate and inspect artefacts](#generate-and-inspect-artefacts)). Running `netsuke` without a subcommand is the same as `netsuke build` with no explicit targets. A bare target such as `netsuke hello` is not accepted; use @@ -834,6 +858,38 @@ manifest to that file and leaves stdout empty. `clean` removes file outputs tracked by Ninja. Phony targets and actions do not represent files and are not removed. +`help targets` prints the target and action catalogue for the selected +manifest — actions first, then targets — with a localized default marker such +as `[★ default]` (or `[* default]` in accessible output) on manifest defaults +and an empty description column for entries without a `description`: + + + +```sh +netsuke help targets +``` + +The command loads, expands, renders, and validates the manifest through the +same structural stages as a build, but performs no recipes and creates no +build outputs. Rendering uses a restricted, side-effect-free Jinja surface. +Queries allow only the lexical path filters `basename`, `dirname`, +`with_suffix`, and `relative_to`, the collection filters `uniq`, `flatten`, and +`group_by`, and the clock-independent `timedelta` function. Queries reject +`env()` and `glob()`, file tests, filesystem metadata filters such as `size` and +`linecount`, `hash`, `digest`, `contents`, `realpath`, and `expanduser`, +executable discovery through `which` and `command_available`, network and +command helpers (`fetch`, `shell`, and `grep`), and the clock-dependent `now()` +function. Normal build manifest rendering retains the full standard library; +this restriction applies only to query rendering. It honours the usual +manifest-selection options (`--file`, +`-C/--directory`) and the normal colour, accessibility, locale, and JSON-output +conventions; with `--json` the catalogue is emitted as a versioned JSON +document whose `result.command` is `help-targets`. + +The standard-library reference describes the full helper set available while +rendering a normal build manifest. The query allowlist above is the deliberate +exception for `netsuke help targets`. + ## Configure Netsuke Configuration precedence, from lowest to highest, is: diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 6e537f2ef..649696eff 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -1,9 +1,13 @@ # Migrating to v0.1.0 -This guide signposts the child-environment additions arriving in the v0.1.0 -beta series: the injectable child environment (`CommandEnv`) and the named -Ninja request types. Existing callers compile unchanged; every addition is -opt-in. +This guide signposts the v0.1.0 beta additions: the injectable child +environment (`CommandEnv`), the named Ninja request types, and target/action +discovery through `description` and `netsuke help targets`. Existing manifests +remain compatible, and callers of the unchanged convenience wrappers compile +unchanged. +Rust callers that construct `Target` with a struct literal must add the new +`description` field (set it to `None` or `Some(...)`); deserialized manifests +remain compatible, and every other addition is opt-in. ## Netsuke is a build tool, not a library @@ -16,7 +20,8 @@ on it is conditional on tracking those changes. ## At-a-glance changes -Table: v0.1.0 child-environment API additions and their impact + +Table: documented v0.1.0 additions, including `netsuke help targets`, and their impact | Area | Impact | Where to read more | | --- | --- | --- | @@ -25,6 +30,7 @@ Table: v0.1.0 child-environment API additions and their impact | Request types | New `netsuke::runner::NinjaBuildRequest` and `netsuke::runner::NinjaToolRequest` name the program, build file, targets or tool, a child environment, and a required `stderr_mode: StderrMode` policy for the `*_with` run functions. | [Users' guide](users-guide.md) | | Glob expansion | Parent-relative patterns such as `glob('../shared/*.h')` now expand. Metadata checks use a capability rooted at the pattern's longest literal directory prefix; missing or non-directory prefixes return no matches, and unresolvable symlink matches are skipped. | [Users' guide](users-guide.md) and [ADR-010](adr-010-scope-glob-capability-to-literal-prefix.md) | | Command recipes | Existing scalar `command` recipes are unchanged. New YAML command lists are opt-in and run in declaration order with fail-fast semantics. | [Rules and recipes](users-guide.md#rules-and-recipes) | +| Manifest discovery | Optional target/action `description` values are shown by the new `netsuke help targets` command. Manifests without them and existing build output are unchanged. | [Users' guide](users-guide.md) | ## Nothing to change for existing callers @@ -62,6 +68,36 @@ Both request types borrow their fields, so one `CommandEnv` and one `Cli` can serve several invocations. Worked examples live in the users' guide's "Drive Ninja with an explicit environment" section. +## Discover targets and actions + +Target and action `description` values are optional discovery metadata. Adding +them does not change manifest compatibility, Ninja progress text, or build +execution: Ninja progress continues to use the referenced rule's +`description`. Existing manifests without these fields remain valid. + +Use the new command to inspect the selected manifest: + +```sh +netsuke help targets +``` + +The command honours the usual manifest-selection options, including `--file` +and `-C/--directory`. It loads, expands, renders, and validates the manifest +through a restricted, side-effect-free Jinja surface, then prints actions and +targets without running recipes or creating build outputs. Queries allow only +the lexical path filters `basename`, `dirname`, `with_suffix`, and +`relative_to`, the collection filters `uniq`, `flatten`, and `group_by`, and +the clock-independent `timedelta` function. Queries reject `env()` and +`glob()`, file tests, filesystem metadata filters such as `size` and +`linecount`, `hash`, `digest`, `contents`, `realpath`, and `expanduser`, +executable discovery through `which` and `command_available`, network and +command helpers (`fetch`, `shell`, and `grep`), and the clock-dependent `now()` +function. Normal build manifest rendering retains the full standard library; +this restriction applies only to query rendering. Add `--json` to receive the +versioned JSON result document; its +`result.command` is `help-targets`. The command and the new descriptions are +beta-series additions and remain subject to the stability caveat above. + ## Diagnostics Ninja subprocess spans and warn events carry two bounded fields, diff --git a/dylint.toml b/dylint.toml index d5a067c10..25a56d73a 100644 --- a/dylint.toml +++ b/dylint.toml @@ -35,6 +35,7 @@ excluded_paths = [ "bdd_tests::bdd::steps::advanced_usage", "bdd_tests::bdd::steps::conditional_manifest", "bdd_tests::bdd::steps::fs", + "bdd_tests::bdd::steps::help_targets", "bdd_tests::bdd::steps::manifest_command", "bdd_tests::bdd::steps::process", "bdd_tests::bdd::steps::progress_output", @@ -89,6 +90,7 @@ excluded_crates = [ "release_help_script_tests", "release_staging_tests", "runner_graph_tests", + "runner_help_targets_tests", "runner_tests", "runner_tool_subcommands_tests", "stdlib_which_tests", diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 47bbeafa0..3f59007a8 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = إخراج رسم اعتماديات البناء. cli.subcommand.graph.long_about = إسقاط ملف بيانات Netsuke بعد تحليله إلى رسم بناء قياسي وكتابته بصيغة Graphviz DOT، أو صفحة HTML مكتفية بذاتها عند استخدام `--html`. استخدم `--output <ملف>` للكتابة إلى ملف؛ و`-` يكتب إلى المخرج القياسي. cli.subcommand.generate.about = توليد ملف بيانات Ninja دون تنفيذ Ninja. cli.subcommand.generate.long_about = كتابة ملف بيانات Ninja المولَّد إلى المخرج القياسي أو إلى ملف يُختار بـ `--output`. +cli.subcommand.help.about = اطبع التعليمات العامة، أو التعليمات لموضوع محدد. +cli.subcommand.help.long_about = بدون موضوع، يطابق هذا `--help`. استخدم `help targets` لطباعة كتالوج الأهداف والإجراءات للملف المحدد. + +# Help catalogue headings and markers. +cli.help.actions_heading = الإجراءات: +cli.help.targets_heading = الأهداف: +cli.help.targets.about = سرد الأهداف والإجراءات في الملف المحدد. +cli.help.default_marker = الافتراضي # نص المساعدة لخيارات الأمر الفرعي build. cli.subcommand.build.flag.targets.help = الأهداف المطلوب بناؤها (تُستخدم افتراضيات ملف البيانات عند الإغفال). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = مسار ملف البيانات «{ $path }» لي runner.manifest.directory_utf8 = مسار دليل ملف البيانات «{ $path }» ليس UTF-8 صالحًا. runner.manifest.directory_label = الدليل `{ $directory }` runner.manifest.current_directory_label = الدليل الحالي +runner.manifest.default_not_declared = الافتراضي للبيان «{ $default }» لا يسمّي إجراءً أو هدفًا معلنًا. runner.context.network_policy = تعذّر بناء سياسة الشبكة. runner.context.load_manifest = تعذّر تحميل ملف البيانات من { $path }. runner.context.serialise_manifest = تعذّرت سَلسَلة ملف البيانات. @@ -149,7 +158,7 @@ manifest.glob.invalid_pattern = نمط glob غير صالح «{ $pattern }»: { manifest.glob.unknown_pattern_error = خطأ نمط غير معروف. manifest.glob.io_failed = فشل glob للنمط «{ $pattern }»: { $detail }. manifest.glob.unknown_io_error = خطأ إدخال/إخراج غير معروف. -manifest.command_list_empty = يجب ألّا تكون قائمة الأوامر فارغة؛ قدِّم سلسلة أمر أو قائمة غير فارغة. +manifest.command_list_empty = يجب ألّا يكون الحقل «command» فارغًا: قدِّم سلسلة أمر أو قائمة غير فارغة. # أخطاء التمثيل الوسيط. ir.rule_not_found = تعذّر العثور على القاعدة «{ $rule }» التي يشير إليها الهدف «{ $target }». @@ -368,6 +377,7 @@ status.tool.clean = التنظيف status.tool.graph = الرسم status.tool.graph_html = الرسم (HTML) status.tool.generate = التوليد +status.tool.help_targets = فهرس الأهداف # نصوص عرض الرسم بصيغة HTML. graph.html.title = رسم بناء Netsuke diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 4e852fdac..2c9cf8ee5 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Vypsat graf závislostí sestavení. Výchozí form cli.subcommand.graph.long_about = Převést načtený manifest Netsuke na kanonický graf sestavení a zapsat jej jako Graphviz DOT, případně s přepínačem `--html` jako samostatnou stránku HTML. Zápis do souboru zajistí `--output `; `-` zapisuje na standardní výstup. cli.subcommand.generate.about = Vytvořit manifest Ninja bez spuštění Ninji. cli.subcommand.generate.long_about = Zapsat vytvořený manifest Ninja na standardní výstup nebo do souboru zvoleného přepínačem `--output`. +cli.subcommand.help.about = Vytisknout nápovědu na nejvyšší úrovni, nebo nápovědu pro pojmenované téma. +cli.subcommand.help.long_about = Bez tématu odpovídá příkazu `--help`. Pomocí `help targets` vytisknete katalog cílů a akcí pro vybraný soubor. + +# Help catalogue headings and markers. +cli.help.actions_heading = Akce: +cli.help.targets_heading = Cíle: +cli.help.targets.about = Vypsat cíle a akce ve vybraném manifestu. +cli.help.default_marker = výchozí # Text nápovědy přepínačů podpříkazu build. cli.subcommand.build.flag.targets.help = Cíle k sestavení (při vynechání se použijí výchozí cíle z manifestu). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Cesta k manifestu „{ $path }“ není platné UTF- runner.manifest.directory_utf8 = Cesta k adresáři manifestu „{ $path }“ není platné UTF-8. runner.manifest.directory_label = adresář `{ $directory }` runner.manifest.current_directory_label = aktuální adresář +runner.manifest.default_not_declared = Výchozí položka manifestu „{ $default }“ neoznačuje deklarovanou akci ani cíl. runner.context.network_policy = Síťovou zásadu se nepodařilo sestavit. runner.context.load_manifest = Manifest v { $path } se nepodařilo načíst. runner.context.serialise_manifest = Manifest se nepodařilo serializovat. @@ -368,6 +377,7 @@ status.tool.clean = Vyčištění status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generování +status.tool.help_targets = Katalog cílů # Texty vykreslování grafu do HTML. graph.html.title = Graf sestavení Netsuke diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 5cd1c875c..5efbe8e86 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Allbynnu graff dibyniaethau'r adeiladu. DOT yw'r ff cli.subcommand.graph.long_about = Taflunio'r maniffest Netsuke a ddadansoddwyd yn graff adeiladu canonaidd a'i ysgrifennu fel Graphviz DOT, neu fel tudalen HTML hunangynhwysol gyda `--html`. Defnyddiwch `--output ` i ysgrifennu i ffeil; mae `-` yn ysgrifennu i'r allbwn safonol. cli.subcommand.generate.about = Creu'r maniffest Ninja heb redeg Ninja. cli.subcommand.generate.long_about = Ysgrifennu'r maniffest Ninja a gynhyrchwyd i'r allbwn safonol, neu i ffeil a ddewisir gyda `--output`. +cli.subcommand.help.about = Argraffwch cymorth lefel uchaf, neu cymorth ar gyfer pwnc a enwir. +cli.subcommand.help.long_about = Heb bwnc, mae hyn yn cyfateb i `--help`. Defnyddiwch `help targets` i argraffu catalog targedau a gweithredoedd ar gyfer y ffeil a ddewiswyd. + +# Help catalogue headings and markers. +cli.help.actions_heading = Gweithredoedd: +cli.help.targets_heading = Targedau: +cli.help.targets.about = Rhestru targedau a gweithredoedd yn y maniffest a ddewiswyd. +cli.help.default_marker = diofyn # Testun cymorth dewisiadau'r is-orchymyn build. cli.subcommand.build.flag.targets.help = Y targedau i'w hadeiladu (defnyddir rhagosodiadau'r maniffest os hepgorir hwy). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Nid yw llwybr y maniffest ‘{ $path }’ yn UTF-8 d runner.manifest.directory_utf8 = Nid yw llwybr cyfeiriadur y maniffest ‘{ $path }’ yn UTF-8 dilys. runner.manifest.directory_label = cyfeiriadur `{ $directory }` runner.manifest.current_directory_label = y cyfeiriadur cyfredol +runner.manifest.default_not_declared = Nid yw rhagosodiad y maniffest '{ $default }' yn enwi gweithred neu darged datganedig. runner.context.network_policy = Methwyd â llunio'r polisi rhwydwaith. runner.context.load_manifest = Methwyd â llwytho'r maniffest o { $path }. runner.context.serialise_manifest = Methwyd â chyfresoli'r maniffest. @@ -368,6 +377,7 @@ status.tool.clean = Glanhau status.tool.graph = Graff status.tool.graph_html = Graff (HTML) status.tool.generate = Cynhyrchu +status.tool.help_targets = Cymorth targedau # Testunau rendrwr HTML y graff. graph.html.title = Graff adeiladu Netsuke diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index 610a479ce..e830dff73 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Udskriv byggegrafen over afhængigheder. Standardfo cli.subcommand.graph.long_about = Omsæt det indlæste Netsuke-manifest til en kanonisk byggegraf, og skriv den som Graphviz DOT eller som en selvstændig HTML-side med `--html`. Brug `--output ` for at skrive til en fil; `-` skriver til stdout. cli.subcommand.generate.about = Generér Ninja-manifestet uden at køre Ninja. cli.subcommand.generate.long_about = Skriv det genererede Ninja-manifest til stdout eller til en fil valgt med `--output`. +cli.subcommand.help.about = Udskriv hjælpen på øverste niveau eller hjælp til et navngivet emne. +cli.subcommand.help.long_about = Uden emne svarer dette til `--help`. Brug `help targets` til at udskrive kataloget over mål og handlinger for den valgte fil. + +# Help catalogue headings and markers. +cli.help.actions_heading = Handlinger: +cli.help.targets_heading = Mål: +cli.help.targets.about = Vis mål og handlinger i det valgte manifest. +cli.help.default_marker = standard # Hjælpetekst til tilvalg for underkommandoen build. cli.subcommand.build.flag.targets.help = Mål, der skal bygges (bruger manifestets standardmål, hvis udeladt). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Manifeststien "{ $path }" er ikke gyldig UTF-8. runner.manifest.directory_utf8 = Stien til manifestmappen "{ $path }" er ikke gyldig UTF-8. runner.manifest.directory_label = mappen `{ $directory }` runner.manifest.current_directory_label = den aktuelle mappe +runner.manifest.default_not_declared = Manifestets standardværdi '{ $default }' angiver ikke en erklæret handling eller et mål. runner.context.network_policy = Netværkspolitikken kunne ikke opbygges. runner.context.load_manifest = Manifestet i { $path } kunne ikke indlæses. runner.context.serialise_manifest = Manifestet kunne ikke serialiseres. @@ -368,6 +377,7 @@ status.tool.clean = Oprydning status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generering +status.tool.help_targets = Hjælp til mål # Tekster til HTML-gengivelsen af grafen. graph.html.title = Netsuke-byggegraf diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 0314f12e1..6ab8578dd 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Den Build-Abhängigkeitsgraphen ausgeben. Standardf cli.subcommand.graph.long_about = Das eingelesene Netsuke-Manifest in einen kanonischen Build-Graphen überführen und als Graphviz-DOT ausgeben oder mit `--html` als eigenständige HTML-Seite. Mit `--output ` in eine Datei schreiben; `-` schreibt nach stdout. cli.subcommand.generate.about = Das Ninja-Manifest erzeugen, ohne Ninja auszuführen. cli.subcommand.generate.long_about = Das erzeugte Ninja-Manifest nach stdout schreiben oder in eine mit `--output` gewählte Datei. +cli.subcommand.help.about = Die Hilfe auf oberster Ebene oder die Hilfe für ein benanntes Thema anzeigen. +cli.subcommand.help.long_about = Ohne Thema entspricht dies `--help`. Verwenden Sie `help targets`, um den Ziel- und Aktionskatalog für die ausgewählte Datei anzuzeigen. + +# Help catalogue headings and markers. +cli.help.actions_heading = Aktionen: +cli.help.targets_heading = Ziele: +cli.help.targets.about = Ziele und Aktionen im ausgewählten Manifest auflisten. +cli.help.default_marker = Standard # Hilfetext für Optionen des Unterbefehls build. cli.subcommand.build.flag.targets.help = Zu bauende Ziele (ohne Angabe gelten die Standardziele des Manifests). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Der Manifestpfad „{ $path }“ ist kein gültiges runner.manifest.directory_utf8 = Der Pfad des Manifestverzeichnisses „{ $path }“ ist kein gültiges UTF-8. runner.manifest.directory_label = Verzeichnis `{ $directory }` runner.manifest.current_directory_label = das aktuelle Verzeichnis +runner.manifest.default_not_declared = Der Manifest-Standardwert '{ $default }' bezeichnet keine deklarierte Aktion oder kein Ziel. runner.context.network_policy = Die Netzwerkrichtlinie konnte nicht erstellt werden. runner.context.load_manifest = Das Manifest unter { $path } konnte nicht geladen werden. runner.context.serialise_manifest = Das Manifest konnte nicht serialisiert werden. @@ -368,6 +377,7 @@ status.tool.clean = Bereinigung status.tool.graph = Graph status.tool.graph_html = Graph (HTML) status.tool.generate = Erzeugung +status.tool.help_targets = Zielhilfe # Zeichenketten des HTML-Graph-Renderers. graph.html.title = Netsuke-Build-Graph diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index f6413b904..8c8eb1128 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Εξαγωγή του γραφήματος εξαρ cli.subcommand.graph.long_about = Προβολή του αναλυμένου δηλωτικού Netsuke σε κανονικό γράφημα δόμησης και εγγραφή του ως Graphviz DOT ή, με την επιλογή `--html`, ως αυτοτελής σελίδα HTML. Χρησιμοποιήστε `--output <ΑΡΧΕΙΟ>` για εγγραφή σε αρχείο· το `-` γράφει στην τυπική έξοδο. cli.subcommand.generate.about = Δημιουργία του δηλωτικού Ninja χωρίς εκτέλεση του Ninja. cli.subcommand.generate.long_about = Εγγραφή του παραγόμενου δηλωτικού Ninja στην τυπική έξοδο ή σε αρχείο που επιλέγεται με `--output`. +cli.subcommand.help.about = Εκτυπώστε τη βοήθεια ανώτατου επιπέδου ή τη βοήθεια για ένα ονομασμένο θέμα. +cli.subcommand.help.long_about = Χωρίς θέμα, αυτό ταιριάζει με το `--help`. Χρησιμοποιήστε το `help targets` για να εκτυπώσετε τον κατάλογο στόχων και ενεργειών για το επιλεγμένο αρχείο. + +# Help catalogue headings and markers. +cli.help.actions_heading = Ενέργειες: +cli.help.targets_heading = Στόχοι: +cli.help.targets.about = Παράθεση στόχων και ενεργειών στο επιλεγμένο δηλωτικό. +cli.help.default_marker = προεπιλογή # Κείμενο βοήθειας για τις επιλογές της υποεντολής build. cli.subcommand.build.flag.targets.help = Στόχοι προς δόμηση (αν παραλειφθούν, χρησιμοποιούνται οι προεπιλογές του δηλωτικού). @@ -77,6 +85,7 @@ runner.manifest.path_utf8 = Η διαδρομή δηλωτικού «{ $path }» runner.manifest.directory_utf8 = Η διαδρομή του καταλόγου δηλωτικού «{ $path }» δεν είναι έγκυρο UTF-8. runner.manifest.directory_label = κατάλογος `{ $directory }` runner.manifest.current_directory_label = ο τρέχων κατάλογος +runner.manifest.default_not_declared = Η προεπιλογή του δηλωτικού '{ $default }' δεν ονομάζει δηλωμένη ενέργεια ή στόχο. runner.context.network_policy = Δεν ήταν δυνατή η κατασκευή της πολιτικής δικτύου. runner.context.load_manifest = Δεν ήταν δυνατή η φόρτωση του δηλωτικού από { $path }. runner.context.serialise_manifest = Δεν ήταν δυνατή η σειριοποίηση του δηλωτικού. @@ -369,6 +378,7 @@ status.tool.clean = Καθαρισμός status.tool.graph = Γράφημα status.tool.graph_html = Γράφημα (HTML) status.tool.generate = Δημιουργία +status.tool.help_targets = Βοήθεια στόχων # Κείμενα της απόδοσης του γραφήματος σε HTML. graph.html.title = Γράφημα δόμησης του Netsuke diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 279abc6ee..1fc815be5 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Emit the build dependency graph. Default format is cli.subcommand.graph.long_about = Project the parsed Netsuke manifest into a canonical build graph and write it as Graphviz DOT, or as a self-contained HTML page with `--html`. Use `--output ` to write to a file; `-` writes to stdout. cli.subcommand.generate.about = Generate the Ninja manifest without running Ninja. cli.subcommand.generate.long_about = Write the generated Ninja manifest to stdout, or to a file selected with `--output`. +cli.subcommand.help.about = Print the top-level help, or the help for a named topic. +cli.subcommand.help.long_about = With no topic this matches `--help`. Use `help targets` to print the target and action catalogue for the selected manifest. + +# Help catalogue headings and markers. +cli.help.actions_heading = Actions: +cli.help.targets_heading = Targets: +cli.help.targets.about = List targets and actions in the selected manifest. +cli.help.default_marker = default # Build subcommand flag help text. cli.subcommand.build.flag.targets.help = Targets to build (uses manifest defaults if omitted). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Manifest path '{ $path }' is not valid UTF-8. runner.manifest.directory_utf8 = Manifest directory path '{ $path }' is not valid UTF-8. runner.manifest.directory_label = directory `{ $directory }` runner.manifest.current_directory_label = the current directory +runner.manifest.default_not_declared = manifest default '{ $default }' does not name a declared action or target. runner.context.network_policy = Failed to build the network policy. runner.context.load_manifest = Failed to load manifest at { $path }. runner.context.serialise_manifest = Failed to serialise manifest. @@ -368,6 +377,7 @@ status.tool.clean = Clean status.tool.graph = Graph status.tool.graph_html = Graph (HTML) status.tool.generate = Generate +status.tool.help_targets = Target help # Graph HTML renderer strings. graph.html.title = Netsuke build graph diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index add74180e..2a53bbd85 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Emit the build dependency graph. Default format is cli.subcommand.graph.long_about = Project the parsed Netsuke manifest into a canonical build graph and write it as Graphviz DOT, or as a self-contained HTML page with `--html`. Use `--output ` to write to a file; `-` writes to stdout. cli.subcommand.generate.about = Generate the Ninja manifest without running Ninja. cli.subcommand.generate.long_about = Write the generated Ninja manifest to stdout, or to a file selected with `--output`. +cli.subcommand.help.about = Print the top-level help, or the help for a named topic. +cli.subcommand.help.long_about = With no topic this matches `--help`. Use `help targets` to print the target and action catalogue for the selected manifest. + +# Help catalogue headings and markers. +cli.help.actions_heading = Actions: +cli.help.targets_heading = Targets: +cli.help.targets.about = List targets and actions in the selected manifest. +cli.help.default_marker = default # Build subcommand flag help text. cli.subcommand.build.flag.targets.help = Targets to build (uses manifest defaults if omitted). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Manifest path '{ $path }' is not valid UTF-8. runner.manifest.directory_utf8 = Manifest directory path '{ $path }' is not valid UTF-8. runner.manifest.directory_label = directory `{ $directory }` runner.manifest.current_directory_label = the current directory +runner.manifest.default_not_declared = manifest default '{ $default }' does not name a declared action or target. runner.context.network_policy = Failed to build the network policy. runner.context.load_manifest = Failed to load manifest at { $path }. runner.context.serialise_manifest = Failed to serialise manifest. @@ -368,6 +377,7 @@ status.tool.clean = Clean status.tool.graph = Graph status.tool.graph_html = Graph (HTML) status.tool.generate = Generate +status.tool.help_targets = Target help # Graph HTML renderer strings. graph.html.title = Netsuke build graph diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index ea92ca583..e35fa515c 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Emitir el grafo de dependencias de compilación. El cli.subcommand.graph.long_about = Proyectar el manifiesto de Netsuke analizado en un grafo de compilación canónico y escribirlo como Graphviz DOT, o como página HTML autónoma con `--html`. Use `--output ` para escribir en un archivo; `-` escribe en stdout. cli.subcommand.generate.about = Generar el manifiesto de Ninja sin ejecutar Ninja. cli.subcommand.generate.long_about = Escribir el manifiesto de Ninja generado en stdout o en el archivo elegido con `--output`. +cli.subcommand.help.about = Imprima la ayuda de nivel superior o la ayuda de un tema determinado. +cli.subcommand.help.long_about = Sin tema, esto coincide con `--help`. Use `help targets` para imprimir el catálogo de objetivos y acciones del archivo seleccionado. + +# Help catalogue headings and markers. +cli.help.actions_heading = Acciones: +cli.help.targets_heading = Objetivos: +cli.help.targets.about = Enumerar objetivos y acciones en el manifiesto seleccionado. +cli.help.default_marker = predeterminado # Texto de ayuda de las opciones del subcomando build. cli.subcommand.build.flag.targets.help = Objetivos que se van a compilar (si se omite, usa los predeterminados del manifiesto). @@ -77,6 +85,7 @@ runner.manifest.path_utf8 = La ruta del manifiesto '{ $path }' no es UTF-8 váli runner.manifest.directory_utf8 = La ruta del directorio del manifiesto '{ $path }' no es UTF-8 válido. runner.manifest.directory_label = directorio `{ $directory }` runner.manifest.current_directory_label = el directorio actual +runner.manifest.default_not_declared = El valor predeterminado del manifiesto '{ $default }' no nombra una acción ni un objetivo declarado. runner.context.network_policy = No se pudo construir la política de red. runner.context.load_manifest = No se pudo cargar el manifiesto en { $path }. runner.context.serialise_manifest = No se pudo serializar el manifiesto. @@ -369,6 +378,7 @@ status.tool.clean = Limpieza status.tool.graph = Grafo status.tool.graph_html = Grafo (HTML) status.tool.generate = Generación +status.tool.help_targets = Ayuda de objetivos # Cadenas del representador HTML del grafo. graph.html.title = Grafo de compilación de Netsuke diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 685d5ad58..57ac96f60 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Emite el grafo de dependencias de compilación. El cli.subcommand.graph.long_about = Proyecta el manifiesto Netsuke en un grafo canónico y lo escribe en formato Graphviz DOT, o como página HTML autocontenida con `--html`. Use `--output ` para escribir a un archivo; `-` escribe en stdout. cli.subcommand.generate.about = Genera el manifiesto Ninja sin ejecutar Ninja. cli.subcommand.generate.long_about = Escribe el manifiesto Ninja generado en stdout o en el archivo seleccionado con `--output`. +cli.subcommand.help.about = Imprima la ayuda de nivel superior o la ayuda de un tema determinado. +cli.subcommand.help.long_about = Sin tema, esto coincide con `--help`. Use `help targets` para imprimir el catálogo de objetivos y acciones del archivo seleccionado. + +# Help catalogue headings and markers. +cli.help.actions_heading = Acciones: +cli.help.targets_heading = Objetivos: +cli.help.targets.about = Enumerar objetivos y acciones en el manifiesto seleccionado. +cli.help.default_marker = predeterminado # Texto de ayuda para opciones del subcomando build. cli.subcommand.build.flag.targets.help = Objetivos a compilar (usa los predeterminados del manifiesto si se omite). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = La ruta del manifiesto '{ $path }' no es UTF-8 váli runner.manifest.directory_utf8 = La ruta del directorio del manifiesto '{ $path }' no es UTF-8 válida. runner.manifest.directory_label = directorio `{ $directory }` runner.manifest.current_directory_label = el directorio actual +runner.manifest.default_not_declared = El valor predeterminado del manifiesto '{ $default }' no nombra una acción ni un objetivo declarado. runner.context.network_policy = No se pudo construir la política de red. runner.context.load_manifest = No se pudo cargar el manifiesto en { $path }. runner.context.serialise_manifest = No se pudo serializar el manifiesto. @@ -368,6 +377,7 @@ status.tool.clean = Limpieza status.tool.graph = Grafo status.tool.graph_html = Grafo (HTML) status.tool.generate = Generar +status.tool.help_targets = Ayuda de objetivos # Cadenas del renderizador HTML del grafo. graph.html.title = Grafo de compilación de Netsuke diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index b393f4a05..b866d87e9 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = چاپ گراف وابستگی‌های ساخت. cli.subcommand.graph.long_about = تصویرکردن مانیفست تجزیه‌شدهٔ Netsuke به یک گراف ساخت متعارف و نوشتن آن به شکل Graphviz DOT، یا با `--html` به شکل یک صفحهٔ HTML خودبسنده. برای نوشتن در پرونده از `--output <پرونده>` استفاده کنید؛ `-` در خروجی استاندارد می‌نویسد. cli.subcommand.generate.about = تولید مانیفست Ninja بدون اجرای Ninja. cli.subcommand.generate.long_about = نوشتن مانیفست Ninja تولیدشده در خروجی استاندارد یا در پرونده‌ای که با `--output` برگزیده می‌شود. +cli.subcommand.help.about = راهنمای سطح بالا یا راهنمای یک موضوع مشخص را چاپ کنید. +cli.subcommand.help.long_about = بدون موضوع، این با `--help` یکسان است. از `help targets` برای چاپ فهرست اهداف و اقدامات پرونده انتخاب‌شده استفاده کنید. + +# Help catalogue headings and markers. +cli.help.actions_heading = اقدامات: +cli.help.targets_heading = اهداف: +cli.help.targets.about = فهرست کردن اهداف و اقدامات در پروندهٔ انتخاب‌شده. +cli.help.default_marker = پیش‌فرض # متن راهنمای گزینه‌های زیرفرمان build. cli.subcommand.build.flag.targets.help = هدف‌هایی که باید ساخته شوند (در صورت نیامدن، پیش‌فرض‌های مانیفست به کار می‌روند). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = مسیر مانیفست «{ $path }» ‏UTF-8 مع runner.manifest.directory_utf8 = مسیر شاخهٔ مانیفست «{ $path }» ‏UTF-8 معتبر نیست. runner.manifest.directory_label = شاخهٔ `{ $directory }` runner.manifest.current_directory_label = شاخهٔ کنونی +runner.manifest.default_not_declared = پیش‌فرض مانیفست «{ $default }» نام یک کنش یا هدف اعلام‌شده نیست. runner.context.network_policy = ساخت سیاست شبکه ممکن نشد. runner.context.load_manifest = بارگذاری مانیفست از { $path } ممکن نشد. runner.context.serialise_manifest = تبدیل مانیفست به داده‌های پیاپی ممکن نشد. @@ -368,6 +377,7 @@ status.tool.clean = پاک‌سازی status.tool.graph = گراف status.tool.graph_html = گراف (HTML) status.tool.generate = تولید +status.tool.help_targets = راهنمای اهداف # رشته‌های نمایش گراف به شکل HTML. graph.html.title = گراف ساخت Netsuke diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index 5867e496f..232015b8b 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Tulosta koonnin riippuvuusgraafi. Oletusmuoto on DO cli.subcommand.graph.long_about = Muunna luettu Netsuke-manifesti kanoniseksi koontigraafiksi ja kirjoita se Graphviz DOT -muodossa tai `--html`-valitsimella itsenäisenä HTML-sivuna. Kirjoita tiedostoon valitsimella `--output `; `-` kirjoittaa vakiotulosteeseen. cli.subcommand.generate.about = Luo Ninja-manifesti suorittamatta Ninjaa. cli.subcommand.generate.long_about = Kirjoita luotu Ninja-manifesti vakiotulosteeseen tai valitsimella `--output` valittuun tiedostoon. +cli.subcommand.help.about = Tulosta ylimmän tason ohje tai nimetyn aiheen ohje. +cli.subcommand.help.long_about = Ilman aihetta tämä vastaa `--help`-komentoa. Käytä `help targets` tulostaaksesi valitun tiedoston kohde- ja toimintaluettelon. + +# Help catalogue headings and markers. +cli.help.actions_heading = Toiminnot: +cli.help.targets_heading = Kohteet: +cli.help.targets.about = Luettele valitun tiedoston kohteet ja toiminnot. +cli.help.default_marker = oletus # build-alikomennon valitsimien ohjeteksti. cli.subcommand.build.flag.targets.help = Koostettavat kohteet (jos puuttuu, käytetään manifestin oletuskohteita). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Manifestipolku ”{ $path }” ei ole kelvollista UT runner.manifest.directory_utf8 = Manifestihakemiston polku ”{ $path }” ei ole kelvollista UTF-8:aa. runner.manifest.directory_label = hakemisto `{ $directory }` runner.manifest.current_directory_label = nykyinen hakemisto +runner.manifest.default_not_declared = Manifestin oletus '{ $default }' ei nimeä ilmoitettua toimintoa tai kohdetta. runner.context.network_policy = Verkkokäytäntöä ei voitu muodostaa. runner.context.load_manifest = Manifestia ei voitu ladata polusta { $path }. runner.context.serialise_manifest = Manifestia ei voitu sarjallistaa. @@ -368,6 +377,7 @@ status.tool.clean = Siivous status.tool.graph = Graafi status.tool.graph_html = Graafi (HTML) status.tool.generate = Luonti +status.tool.help_targets = Kohdeohje # Graafin HTML-hahmonnuksen tekstit. graph.html.title = Netsuken koontigraafi diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index a629f6261..3d123486e 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Émettre le graphe de dépendances de compilation. cli.subcommand.graph.long_about = Projeter le manifeste Netsuke analysé en un graphe de compilation canonique et l'écrire au format Graphviz DOT, ou en page HTML autonome avec `--html`. Utilisez `--output ` pour écrire dans un fichier ; `-` écrit sur la sortie standard. cli.subcommand.generate.about = Générer le manifeste Ninja sans exécuter Ninja. cli.subcommand.generate.long_about = Écrire le manifeste Ninja généré sur la sortie standard, ou dans un fichier choisi avec `--output`. +cli.subcommand.help.about = Afficher l'aide de premier niveau, ou l'aide d'un sujet nommé. +cli.subcommand.help.long_about = Sans sujet, ceci correspond à `--help`. Utilisez `help targets` pour afficher le catalogue des cibles et actions du fichier sélectionné. + +# Help catalogue headings and markers. +cli.help.actions_heading = Actions : +cli.help.targets_heading = Cibles : +cli.help.targets.about = Lister les cibles et actions du manifeste sélectionné. +cli.help.default_marker = par défaut # Texte d'aide des options de la sous-commande build. cli.subcommand.build.flag.targets.help = Cibles à compiler (utilise celles du manifeste si omis). @@ -77,6 +85,7 @@ runner.manifest.path_utf8 = Le chemin de manifeste « { $path } » n'est pas de runner.manifest.directory_utf8 = Le chemin du répertoire de manifeste « { $path } » n'est pas de l'UTF-8 valide. runner.manifest.directory_label = répertoire `{ $directory }` runner.manifest.current_directory_label = le répertoire courant +runner.manifest.default_not_declared = La valeur par défaut du manifeste '{ $default }' ne désigne aucune action ni cible déclarée. runner.context.network_policy = Impossible de construire la politique réseau. runner.context.load_manifest = Impossible de charger le manifeste depuis { $path }. runner.context.serialise_manifest = Impossible de sérialiser le manifeste. @@ -369,6 +378,7 @@ status.tool.clean = Nettoyage status.tool.graph = Graphe status.tool.graph_html = Graphe (HTML) status.tool.generate = Génération +status.tool.help_targets = Aide sur les cibles # Chaînes du moteur de rendu HTML du graphe. graph.html.title = Graphe de compilation Netsuke diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index cde202b08..6a9244e99 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Cuir a-mach graf eisimeileachd an togail. Is e DOT cli.subcommand.graph.long_about = Tilg am foirm-liosta Netsuke a chaidh a pharsadh gu graf togail bun-riaghailteach agus sgrìobh e mar Graphviz DOT, no mar dhuilleag HTML fhèin-chuimseach le `--html`. Cleachd `--output ` gus sgrìobhadh gu faidhle; sgrìobhaidh `-` don às-chur àbhaisteach. cli.subcommand.generate.about = Dèan am foirm-liosta Ninja gun a bhith a' ruith Ninja. cli.subcommand.generate.long_about = Sgrìobh am foirm-liosta Ninja a chaidh a dhèanamh don às-chur àbhaisteach, no gu faidhle a thaghar le `--output`. +cli.subcommand.help.about = Clò-bhuail an cuideachadh aig an ìre as àirde, no an cuideachadh airson cuspair ainmichte. +cli.subcommand.help.long_about = Às aonais cuspair, tha seo a' freagairt ri `--help`. Cleachd `help targets` gus catalog nan targaidean agus nan gnìomhan airson an fhaidhle a thaghadh a chlò-bhualadh. + +# Help catalogue headings and markers. +cli.help.actions_heading = Gnìomhan: +cli.help.targets_heading = Targaidean: +cli.help.targets.about = Dèan liosta de na targaidean agus na gnìomhan anns an fhoirm-liosta a chaidh a thaghadh. +cli.help.default_marker = bunaiteach # Teacsa taice roghainnean an fho-àithne build. cli.subcommand.build.flag.targets.help = Na targaidean ri thogail (thèid bun-roghainnean an fhoirm-liosta a chleachdadh mura h-eil gin ann). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Chan eil slighe an fhoirm-liosta “{ $path }” na runner.manifest.directory_utf8 = Chan eil slighe pasgan an fhoirm-liosta “{ $path }” na UTF-8 dhligheach. runner.manifest.directory_label = pasgan `{ $directory }` runner.manifest.current_directory_label = am pasgan làithreach +runner.manifest.default_not_declared = Chan eil bun-roghainn a’ mhanifest '{ $default }' ag ainmeachadh gnìomh no targaid dhearbhaichte. runner.context.network_policy = Cha b' urrainnear poileasaidh an lìonraidh a thogail. runner.context.load_manifest = Cha b' urrainnear am foirm-liosta a luchdachadh o { $path }. runner.context.serialise_manifest = Cha b' urrainnear am foirm-liosta a shreathachadh. @@ -368,6 +377,7 @@ status.tool.clean = Glanadh status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Dèanamh +status.tool.help_targets = Cuideachadh thargaidean # Sreangan reandaraiche HTML a' ghraf. graph.html.title = Graf togail Netsuke diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index ec19b5843..b24c85a39 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = פלט גרף התלויות של הבנייה. ת cli.subcommand.graph.long_about = הטלת המניפסט המנותח של Netsuke לגרף בנייה קנוני וכתיבתו כ‑Graphviz DOT, או כדף HTML עצמאי עם `--html`. השתמשו ב‑`--output <קובץ>` לכתיבה לקובץ; `-` כותב לפלט התקני. cli.subcommand.generate.about = יצירת מניפסט Ninja בלי להריץ את Ninja. cli.subcommand.generate.long_about = כתיבת מניפסט Ninja שנוצר לפלט התקני או לקובץ שנבחר באמצעות `--output`. +cli.subcommand.help.about = הדפיסו את העזרה ברמה העליונה, או את העזרה עבור נושא בעל שם. +cli.subcommand.help.long_about = ללא נושא, זה תואם את `--help`. השתמשו ב-`help targets` כדי להדפיס את קטלוג היעדים והפעולות עבור הקובץ שנבחר. + +# Help catalogue headings and markers. +cli.help.actions_heading = פעולות: +cli.help.targets_heading = יעדים: +cli.help.targets.about = הצגת רשימת היעדים והפעולות במניפסט שנבחר. +cli.help.default_marker = ברירת מחדל # טקסט העזרה של אפשרויות פקודת המשנה build. cli.subcommand.build.flag.targets.help = היעדים שיש לבנות (בהשמטה נעשה שימוש בברירות המחדל של המניפסט). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = נתיב המניפסט „{ $path }” אינו UTF runner.manifest.directory_utf8 = נתיב ספריית המניפסט „{ $path }” אינו UTF-8 תקין. runner.manifest.directory_label = הספרייה `{ $directory }` runner.manifest.current_directory_label = הספרייה הנוכחית +runner.manifest.default_not_declared = ברירת המחדל של המניפסט '{ $default }' אינה מציינת פעולה או יעד מוצהרים. runner.context.network_policy = לא ניתן היה לבנות את מדיניות הרשת. runner.context.load_manifest = לא ניתן היה לטעון את המניפסט מ‑{ $path }. runner.context.serialise_manifest = לא ניתן היה לבצע סריאליזציה למניפסט. @@ -368,6 +377,7 @@ status.tool.clean = ניקוי status.tool.graph = גרף status.tool.graph_html = גרף (HTML) status.tool.generate = יצירה +status.tool.help_targets = עזרת יעדים # מחרוזות עיבוד הגרף ל‑HTML. graph.html.title = גרף הבנייה של Netsuke diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index b380c2036..96c2a6661 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = बिल्ड की निर्भरता ग cli.subcommand.graph.long_about = विश्लेषित Netsuke मैनिफ़ेस्ट को मानक बिल्ड ग्राफ़ में प्रक्षिप्त करें और उसे Graphviz DOT के रूप में लिखें, अथवा `--html` के साथ स्वतः पूर्ण HTML पृष्ठ के रूप में। फ़ाइल में लिखने हेतु `--output <फ़ाइल>` का प्रयोग करें; `-` मानक निर्गम पर लिखता है। cli.subcommand.generate.about = Ninja चलाए बिना Ninja मैनिफ़ेस्ट बनाएँ। cli.subcommand.generate.long_about = बनाया गया Ninja मैनिफ़ेस्ट मानक निर्गम पर लिखें, अथवा `--output` से चुनी गई फ़ाइल में। +cli.subcommand.help.about = शीर्ष-स्तरीय सहायता, या किसी नामित विषय की सहायता प्रिंट करें। +cli.subcommand.help.long_about = बिना विषय के यह `--help` से मेल खाता है। चयनित मैनिफेस्ट के लिए लक्ष्य और क्रिया सूची प्रिंट करने हेतु `help targets` का उपयोग करें। + +# Help catalogue headings and markers. +cli.help.actions_heading = क्रियाएँ: +cli.help.targets_heading = लक्ष्य: +cli.help.targets.about = चयनित मैनिफेस्ट में लक्ष्यों और क्रियाओं की सूची बनाएँ। +cli.help.default_marker = डिफ़ॉल्ट # build उपआदेश के विकल्पों का सहायता पाठ। cli.subcommand.build.flag.targets.help = बनाए जाने वाले लक्ष्य (न बताए जाने पर मैनिफ़ेस्ट के डिफ़ॉल्ट लिए जाते हैं)। @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = मैनिफ़ेस्ट पथ “{ $path } runner.manifest.directory_utf8 = मैनिफ़ेस्ट की निर्देशिका का पथ “{ $path }” मान्य UTF-8 नहीं है। runner.manifest.directory_label = निर्देशिका `{ $directory }` runner.manifest.current_directory_label = वर्तमान निर्देशिका +runner.manifest.default_not_declared = मैनिफ़ेस्ट डिफ़ॉल्ट '{ $default }' किसी घोषित क्रिया या लक्ष्य का नाम नहीं है। runner.context.network_policy = नेटवर्क नीति नहीं बनाई जा सकी। runner.context.load_manifest = { $path } से मैनिफ़ेस्ट नहीं लादा जा सका। runner.context.serialise_manifest = मैनिफ़ेस्ट का क्रमांकन नहीं हो सका। @@ -368,6 +377,7 @@ status.tool.clean = सफ़ाई status.tool.graph = ग्राफ़ status.tool.graph_html = ग्राफ़ (HTML) status.tool.generate = उत्पादन +status.tool.help_targets = लक्ष्य सहायता # ग्राफ़ के HTML प्रस्तुतीकरण के पाठ। graph.html.title = Netsuke का बिल्ड ग्राफ़ diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index fa93f43ea..02504cc68 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Az építési függőségi gráf kiírása. Az alap cli.subcommand.graph.long_about = A beolvasott Netsuke-jegyzék kanonikus építési gráffá alakítása és kiírása Graphviz DOT formátumban, illetve a `--html` kapcsolóval önálló HTML-oldalként. Fájlba íráshoz használja az `--output ` kapcsolót; a `-` a szabványos kimenetre ír. cli.subcommand.generate.about = A Ninja-jegyzék előállítása a Ninja futtatása nélkül. cli.subcommand.generate.long_about = Az előállított Ninja-jegyzék kiírása a szabványos kimenetre vagy az `--output` kapcsolóval megadott fájlba. +cli.subcommand.help.about = A felső szintű súgó vagy a megnevezett téma súgójának kiírása. +cli.subcommand.help.long_about = Téma nélkül ez a `--help`-nek felel meg. A `help targets` paranccsal nyomtathatja ki a kiválasztott fájl cél- és műveletkatalógusát. + +# Help catalogue headings and markers. +cli.help.actions_heading = Műveletek: +cli.help.targets_heading = Célok: +cli.help.targets.about = A kiválasztott fájl céljainak és műveleteinek listázása. +cli.help.default_marker = alapértelmezett # A build alparancs kapcsolóinak súgószövege. cli.subcommand.build.flag.targets.help = Az építendő célok (elhagyásuk esetén a jegyzék alapértelmezett céljai). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = A(z) „{ $path }” jegyzékútvonal nem érvényes runner.manifest.directory_utf8 = A jegyzék könyvtárának útvonala („{ $path }”) nem érvényes UTF-8. runner.manifest.directory_label = a(z) `{ $directory }` könyvtár runner.manifest.current_directory_label = az aktuális könyvtár +runner.manifest.default_not_declared = A(z) '{ $default }' jegyzék-alapértelmezés nem nevez meg deklarált műveletet vagy célt. runner.context.network_policy = A hálózati szabályt nem sikerült felépíteni. runner.context.load_manifest = A jegyzéket nem sikerült betölteni innen: { $path }. runner.context.serialise_manifest = A jegyzéket nem sikerült sorosítani. @@ -368,6 +377,7 @@ status.tool.clean = Tisztítás status.tool.graph = Gráf status.tool.graph_html = Gráf (HTML) status.tool.generate = Előállítás +status.tool.help_targets = Célsúgó # A gráf HTML-megjelenítésének szövegei. graph.html.title = Netsuke építési gráf diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index 4cb266021..d37ae60f9 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Keluarkan graf ketergantungan build. Format bawaann cli.subcommand.graph.long_about = Proyeksikan manifes Netsuke yang telah diurai menjadi graf build kanonis dan tulis sebagai Graphviz DOT, atau sebagai halaman HTML mandiri dengan `--html`. Gunakan `--output ` untuk menulis ke berkas; `-` menulis ke keluaran standar. cli.subcommand.generate.about = Hasilkan manifes Ninja tanpa menjalankan Ninja. cli.subcommand.generate.long_about = Tulis manifes Ninja yang dihasilkan ke keluaran standar atau ke berkas yang dipilih dengan `--output`. +cli.subcommand.help.about = Cetak bantuan tingkat atas, atau bantuan untuk topik bernama. +cli.subcommand.help.long_about = Tanpa topik, ini sama dengan `--help`. Gunakan `help targets` untuk mencetak katalog target dan tindakan untuk berkas yang dipilih. + +# Help catalogue headings and markers. +cli.help.actions_heading = Tindakan: +cli.help.targets_heading = Target: +cli.help.targets.about = Mencetak target dan tindakan dalam berkas yang dipilih. +cli.help.default_marker = bawaan # Teks bantuan untuk opsi subperintah build. cli.subcommand.build.flag.targets.help = Target yang akan dibangun (jika dihilangkan, memakai bawaan dari manifes). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Jalur manifes "{ $path }" bukan UTF-8 yang sah. runner.manifest.directory_utf8 = Jalur direktori manifes "{ $path }" bukan UTF-8 yang sah. runner.manifest.directory_label = direktori `{ $directory }` runner.manifest.current_directory_label = direktori saat ini +runner.manifest.default_not_declared = Nilai bawaan manifes '{ $default }' tidak menamai tindakan atau target yang dinyatakan. runner.context.network_policy = Kebijakan jaringan tidak dapat dibangun. runner.context.load_manifest = Manifes di { $path } tidak dapat dimuat. runner.context.serialise_manifest = Manifes tidak dapat diserialkan. @@ -368,6 +377,7 @@ status.tool.clean = Pembersihan status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Pembuatan +status.tool.help_targets = Bantuan target # Teks perender HTML untuk graf. graph.html.title = Graf build Netsuke diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index 730d32970..eab3286b6 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Emetti il grafo delle dipendenze di build. Il forma cli.subcommand.graph.long_about = Proietta il manifest Netsuke analizzato in un grafo di build canonico e scrivilo come Graphviz DOT, oppure come pagina HTML autonoma con `--html`. Usa `--output ` per scrivere su file; `-` scrive su stdout. cli.subcommand.generate.about = Genera il manifest Ninja senza eseguire Ninja. cli.subcommand.generate.long_about = Scrivi il manifest Ninja generato su stdout oppure nel file scelto con `--output`. +cli.subcommand.help.about = Stampa la guida di primo livello o la guida per un argomento specificato. +cli.subcommand.help.long_about = Senza argomento, corrisponde a `--help`. Usa `help targets` per stampare il catalogo di target e azioni per il file selezionato. + +# Help catalogue headings and markers. +cli.help.actions_heading = Azioni: +cli.help.targets_heading = Target: +cli.help.targets.about = Elenca target e azioni nel file selezionato. +cli.help.default_marker = predefinito # Testo di aiuto delle opzioni del sottocomando build. cli.subcommand.build.flag.targets.help = Target da compilare (se omesso usa quelli predefiniti del manifest). @@ -77,6 +85,7 @@ runner.manifest.path_utf8 = Il percorso del manifest «{ $path }» non è UTF-8 runner.manifest.directory_utf8 = Il percorso della directory del manifest «{ $path }» non è UTF-8 valido. runner.manifest.directory_label = directory `{ $directory }` runner.manifest.current_directory_label = la directory corrente +runner.manifest.default_not_declared = Il valore predefinito del manifest '{ $default }' non indica un'azione o un target dichiarato. runner.context.network_policy = Impossibile costruire il criterio di rete. runner.context.load_manifest = Impossibile caricare il manifest in { $path }. runner.context.serialise_manifest = Impossibile serializzare il manifest. @@ -369,6 +378,7 @@ status.tool.clean = Pulizia status.tool.graph = Grafo status.tool.graph_html = Grafo (HTML) status.tool.generate = Generazione +status.tool.help_targets = Guida ai target # Stringhe del renderer HTML del grafo. graph.html.title = Grafo di build di Netsuke diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index fdfc9868d..47db7c293 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = ビルドの依存グラフを出力します。既 cli.subcommand.graph.long_about = 解析済みの Netsuke マニフェストを正準形のビルドグラフに射影し、Graphviz DOT として、または `--html` を指定した場合は自己完結型の HTML ページとして書き出します。ファイルへ書き出すには `--output <ファイル>` を使い、`-` を指定すると標準出力に書き出します。 cli.subcommand.generate.about = Ninja を実行せずに Ninja マニフェストを生成します。 cli.subcommand.generate.long_about = 生成した Ninja マニフェストを標準出力、または `--output` で選んだファイルに書き出します。 +cli.subcommand.help.about = 最上位のヘルプ、または指定されたトピックのヘルプを表示します。 +cli.subcommand.help.long_about = トピックなしの場合、これは `--help` と同じです。選択したファイルのターゲットとアクションのカタログを表示するには `help targets` を使用します。 + +# Help catalogue headings and markers. +cli.help.actions_heading = アクション: +cli.help.targets_heading = ターゲット: +cli.help.targets.about = 選択したファイルのターゲットとアクションを一覧表示します。 +cli.help.default_marker = 既定 # build サブコマンドのオプションのヘルプ文。 cli.subcommand.build.flag.targets.help = ビルドするターゲット(省略時はマニフェストの既定値を使用)。 @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = マニフェストのパス「{ $path }」は有効 runner.manifest.directory_utf8 = マニフェストのディレクトリーパス「{ $path }」は有効な UTF-8 ではありません。 runner.manifest.directory_label = ディレクトリー `{ $directory }` runner.manifest.current_directory_label = 現在のディレクトリー +runner.manifest.default_not_declared = マニフェストの既定値 '{ $default }' は、宣言されたアクションまたはターゲットを指していません。 runner.context.network_policy = ネットワークポリシーを構築できませんでした。 runner.context.load_manifest = { $path } のマニフェストを読み込めませんでした。 runner.context.serialise_manifest = マニフェストを直列化できませんでした。 @@ -368,6 +377,7 @@ status.tool.clean = クリーン status.tool.graph = グラフ status.tool.graph_html = グラフ(HTML) status.tool.generate = 生成 +status.tool.help_targets = ターゲットヘルプ # グラフの HTML 描画に使う文言。 graph.html.title = Netsuke のビルドグラフ diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 2973b31fc..e24c185c6 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = 빌드 의존성 그래프를 출력합니다. 기 cli.subcommand.graph.long_about = 해석한 Netsuke 매니페스트를 정규 빌드 그래프로 투영해 Graphviz DOT으로, 또는 `--html`을 지정하면 자체 완결형 HTML 페이지로 씁니다. 파일로 쓰려면 `--output <파일>`을 사용하고, `-`는 표준 출력으로 씁니다. cli.subcommand.generate.about = Ninja를 실행하지 않고 Ninja 매니페스트를 생성합니다. cli.subcommand.generate.long_about = 생성한 Ninja 매니페스트를 표준 출력이나 `--output`으로 고른 파일에 씁니다. +cli.subcommand.help.about = 최상위 도움말 또는 지정된 주제에 대한 도움말을 출력합니다. +cli.subcommand.help.long_about = 주제가 없으면 `--help`와 동일합니다. 선택한 매니페스트의 대상 및 작업 카탈로그를 출력하려면 `help targets`를 사용하세요. + +# Help catalogue headings and markers. +cli.help.actions_heading = 작업: +cli.help.targets_heading = 대상: +cli.help.targets.about = 선택한 매니페스트의 대상 및 작업을 나열합니다. +cli.help.default_marker = 기본값 # build 하위 명령 옵션의 도움말. cli.subcommand.build.flag.targets.help = 빌드할 대상입니다(생략하면 매니페스트의 기본값을 사용). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = 매니페스트 경로 '{ $path }'은(는) 올바른 runner.manifest.directory_utf8 = 매니페스트 디렉터리 경로 '{ $path }'은(는) 올바른 UTF-8이 아닙니다. runner.manifest.directory_label = `{ $directory }` 디렉터리 runner.manifest.current_directory_label = 현재 디렉터리 +runner.manifest.default_not_declared = 매니페스트 기본값 '{ $default }'이(가) 선언된 작업 또는 대상을 가리키지 않습니다. runner.context.network_policy = 네트워크 정책을 구성하지 못했습니다. runner.context.load_manifest = { $path }의 매니페스트를 불러오지 못했습니다. runner.context.serialise_manifest = 매니페스트를 직렬화하지 못했습니다. @@ -368,6 +377,7 @@ status.tool.clean = 정리 status.tool.graph = 그래프 status.tool.graph_html = 그래프(HTML) status.tool.generate = 생성 +status.tool.help_targets = 대상 도움말 # 그래프 HTML 렌더러의 문구. graph.html.title = Netsuke 빌드 그래프 diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 3c1e98bd0..8c623d0f0 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Skriv ut avhengighetsgrafen for byggingen. Standard cli.subcommand.graph.long_about = Overfør det innleste Netsuke-manifestet til en kanonisk byggegraf og skriv den som Graphviz DOT, eller som en frittstående HTML-side med `--html`. Bruk `--output ` for å skrive til en fil; `-` skriver til stdout. cli.subcommand.generate.about = Lag Ninja-manifestet uten å kjøre Ninja. cli.subcommand.generate.long_about = Skriv det genererte Ninja-manifestet til stdout eller til en fil valgt med `--output`. +cli.subcommand.help.about = Skriv ut hjelpen på øverste nivå, eller hjelpen for et navngitt emne. +cli.subcommand.help.long_about = Uten emne tilsvarer dette `--help`. Bruk `help targets` for å skrive ut katalogen over mål og handlinger for det valgte manifestet. + +# Help catalogue headings and markers. +cli.help.actions_heading = Handlinger: +cli.help.targets_heading = Mål: +cli.help.targets.about = List opp mål og handlinger i det valgte manifestet. +cli.help.default_marker = standard # Hjelpetekst for valg til underkommandoen build. cli.subcommand.build.flag.targets.help = Mål som skal bygges (bruker standardmålene fra manifestet hvis utelatt). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Manifeststien «{ $path }» er ikke gyldig UTF-8. runner.manifest.directory_utf8 = Stien til manifestkatalogen «{ $path }» er ikke gyldig UTF-8. runner.manifest.directory_label = katalogen `{ $directory }` runner.manifest.current_directory_label = gjeldende katalog +runner.manifest.default_not_declared = Manifestets standardverdi '{ $default }' navngir ingen erklært handling eller mål. runner.context.network_policy = Nettverksregelen kunne ikke bygges. runner.context.load_manifest = Manifestet i { $path } kunne ikke lastes inn. runner.context.serialise_manifest = Manifestet kunne ikke serialiseres. @@ -368,6 +377,7 @@ status.tool.clean = Opprydding status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generering +status.tool.help_targets = Målhjelp # Tekster for HTML-gjengivelsen av grafen. graph.html.title = Netsuke-byggegraf diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index d402bacfa..c77b47bf4 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Geef de afhankelijkheidsgraaf van de bouw. De stand cli.subcommand.graph.long_about = Zet het ingelezen Netsuke-manifest om in een canonieke bouwgraaf en schrijf die weg als Graphviz DOT, of met `--html` als zelfstandige HTML-pagina. Gebruik `--output ` om naar een bestand te schrijven; `-` schrijft naar stdout. cli.subcommand.generate.about = Genereer het Ninja-manifest zonder Ninja uit te voeren. cli.subcommand.generate.long_about = Schrijf het gegenereerde Ninja-manifest naar stdout of naar een bestand dat met `--output` is gekozen. +cli.subcommand.help.about = Druk de hulp op het hoogste niveau af, of de hulp voor een genoemd onderwerp. +cli.subcommand.help.long_about = Zonder onderwerp komt dit overeen met `--help`. Gebruik `help targets` om de catalogus van doelen en acties voor het geselecteerde manifest af te drukken. + +# Help catalogue headings and markers. +cli.help.actions_heading = Acties: +cli.help.targets_heading = Doelen: +cli.help.targets.about = Doelen en acties in het geselecteerde manifest weergeven. +cli.help.default_marker = standaard # Helptekst voor opties van de subopdracht build. cli.subcommand.build.flag.targets.help = Te bouwen doelen (gebruikt de standaarddoelen uit het manifest als dit ontbreekt). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Het manifestpad ‘{ $path }’ is geen geldige UTF- runner.manifest.directory_utf8 = Het pad van de manifestmap ‘{ $path }’ is geen geldige UTF-8. runner.manifest.directory_label = map `{ $directory }` runner.manifest.current_directory_label = de huidige map +runner.manifest.default_not_declared = De manifeststandaard '{ $default }' benoemt geen gedeclareerde actie of doel. runner.context.network_policy = Het netwerkbeleid kon niet worden opgebouwd. runner.context.load_manifest = Het manifest in { $path } kon niet worden geladen. runner.context.serialise_manifest = Het manifest kon niet worden geserialiseerd. @@ -368,6 +377,7 @@ status.tool.clean = Opruimen status.tool.graph = Graaf status.tool.graph_html = Graaf (HTML) status.tool.generate = Genereren +status.tool.help_targets = Doelhulp # Teksten van de HTML-weergave van de graaf. graph.html.title = Netsuke-bouwgraaf diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index 77c4fe8e4..5441cfff7 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Wypisz graf zależności budowania. Domyślnym form cli.subcommand.graph.long_about = Przekształć wczytany manifest Netsuke w kanoniczny graf budowania i zapisz go jako Graphviz DOT albo — z opcją `--html` — jako samodzielną stronę HTML. Użyj `--output `, aby zapisać do pliku; `-` zapisuje na standardowe wyjście. cli.subcommand.generate.about = Wygeneruj manifest Ninja bez uruchamiania Ninji. cli.subcommand.generate.long_about = Zapisz wygenerowany manifest Ninja na standardowe wyjście albo do pliku wybranego opcją `--output`. +cli.subcommand.help.about = Wyświetl pomoc najwyższego poziomu lub pomoc dla nazwanego tematu. +cli.subcommand.help.long_about = Bez tematu odpowiada to `--help`. Użyj `help targets`, aby wyświetlić katalog celów i akcji dla wybranego manifestu. + +# Help catalogue headings and markers. +cli.help.actions_heading = Akcje: +cli.help.targets_heading = Cele: +cli.help.targets.about = Wyświetl cele i akcje w wybranym manifeście. +cli.help.default_marker = domyślny # Tekst pomocy opcji podpolecenia build. cli.subcommand.build.flag.targets.help = Cele do zbudowania (w razie pominięcia używa celów domyślnych z manifestu). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Ścieżka manifestu „{ $path }” nie jest prawid runner.manifest.directory_utf8 = Ścieżka katalogu manifestu „{ $path }” nie jest prawidłowym UTF-8. runner.manifest.directory_label = katalog `{ $directory }` runner.manifest.current_directory_label = bieżący katalog +runner.manifest.default_not_declared = Domyślna wartość manifestu '{ $default }' nie wskazuje zadeklarowanej akcji ani celu. runner.context.network_policy = Nie udało się zbudować zasad sieciowych. runner.context.load_manifest = Nie udało się wczytać manifestu z { $path }. runner.context.serialise_manifest = Nie udało się zserializować manifestu. @@ -368,6 +377,7 @@ status.tool.clean = Czyszczenie status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generowanie +status.tool.help_targets = Pomoc dotycząca celów # Teksty renderera HTML grafu. graph.html.title = Graf budowania Netsuke diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 2ced9ce27..80162d11b 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Emitir o grafo de dependências do build. O formato cli.subcommand.graph.long_about = Projetar o manifesto do Netsuke analisado em um grafo de build canônico e gravá-lo como Graphviz DOT ou como página HTML autocontida com `--html`. Use `--output ` para gravar em um arquivo; `-` grava na stdout. cli.subcommand.generate.about = Gerar o manifesto do Ninja sem executar o Ninja. cli.subcommand.generate.long_about = Gravar o manifesto do Ninja gerado na stdout ou no arquivo escolhido com `--output`. +cli.subcommand.help.about = Imprimir a ajuda de nível superior ou a ajuda de um tópico nomeado. +cli.subcommand.help.long_about = Sem tópico, isto corresponde a `--help`. Use `help targets` para imprimir o catálogo de alvos e ações do manifesto selecionado. + +# Help catalogue headings and markers. +cli.help.actions_heading = Ações: +cli.help.targets_heading = Alvos: +cli.help.targets.about = Listar alvos e ações no manifesto selecionado. +cli.help.default_marker = padrão # Texto de ajuda das opções do subcomando build. cli.subcommand.build.flag.targets.help = Alvos a compilar (se omitido, usa os padrões do manifesto). @@ -77,6 +85,7 @@ runner.manifest.path_utf8 = O caminho do manifesto "{ $path }" não é UTF-8 vá runner.manifest.directory_utf8 = O caminho do diretório do manifesto "{ $path }" não é UTF-8 válido. runner.manifest.directory_label = diretório `{ $directory }` runner.manifest.current_directory_label = o diretório atual +runner.manifest.default_not_declared = O padrão do manifesto '{ $default }' não nomeia uma ação ou um alvo declarado. runner.context.network_policy = Não foi possível construir a política de rede. runner.context.load_manifest = Não foi possível carregar o manifesto em { $path }. runner.context.serialise_manifest = Não foi possível serializar o manifesto. @@ -369,6 +378,7 @@ status.tool.clean = Limpeza status.tool.graph = Grafo status.tool.graph_html = Grafo (HTML) status.tool.generate = Geração +status.tool.help_targets = Ajuda sobre alvos # Textos do renderizador HTML do grafo. graph.html.title = Grafo de build do Netsuke diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 394a77930..5d864daed 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Emitir o grafo de dependências de compilação. O cli.subcommand.graph.long_about = Projetar o manifesto do Netsuke analisado num grafo de compilação canónico e escrevê-lo como Graphviz DOT, ou como página HTML autónoma com `--html`. Use `--output ` para escrever num ficheiro; `-` escreve no stdout. cli.subcommand.generate.about = Gerar o manifesto Ninja sem executar o Ninja. cli.subcommand.generate.long_about = Escrever o manifesto Ninja gerado no stdout ou num ficheiro escolhido com `--output`. +cli.subcommand.help.about = Imprimir a ajuda de nível superior ou a ajuda de um tópico nomeado. +cli.subcommand.help.long_about = Sem tópico, isto corresponde a `--help`. Use `help targets` para imprimir o catálogo de alvos e ações do manifesto selecionado. + +# Help catalogue headings and markers. +cli.help.actions_heading = Ações: +cli.help.targets_heading = Alvos: +cli.help.targets.about = Listar alvos e ações no manifesto selecionado. +cli.help.default_marker = predefinição # Texto de ajuda das opções do subcomando build. cli.subcommand.build.flag.targets.help = Alvos a compilar (se omitido, usa os predefinidos do manifesto). @@ -77,6 +85,7 @@ runner.manifest.path_utf8 = O caminho do manifesto «{ $path }» não é UTF-8 v runner.manifest.directory_utf8 = O caminho da pasta do manifesto «{ $path }» não é UTF-8 válido. runner.manifest.directory_label = pasta `{ $directory }` runner.manifest.current_directory_label = a pasta atual +runner.manifest.default_not_declared = A predefinição do manifesto '{ $default }' não designa uma ação ou alvo declarado. runner.context.network_policy = Não foi possível construir a política de rede. runner.context.load_manifest = Não foi possível carregar o manifesto em { $path }. runner.context.serialise_manifest = Não foi possível serializar o manifesto. @@ -369,6 +378,7 @@ status.tool.clean = Limpeza status.tool.graph = Grafo status.tool.graph_html = Grafo (HTML) status.tool.generate = Geração +status.tool.help_targets = Ajuda sobre alvos # Cadeias do representador HTML do grafo. graph.html.title = Grafo de compilação do Netsuke diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 9cc7901b1..d9ff226ca 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Emite graful dependențelor de construire. Formatul cli.subcommand.graph.long_about = Proiectează manifestul Netsuke analizat într-un graf de construire canonic și îl scrie ca Graphviz DOT sau, cu `--html`, ca pagină HTML de sine stătătoare. Folosiți `--output ` pentru a scrie într-un fișier; `-` scrie la ieșirea standard. cli.subcommand.generate.about = Generează manifestul Ninja fără a rula Ninja. cli.subcommand.generate.long_about = Scrie manifestul Ninja generat la ieșirea standard sau într-un fișier ales cu `--output`. +cli.subcommand.help.about = Afișează ajutorul de nivel superior sau ajutorul pentru un subiect specificat. +cli.subcommand.help.long_about = Fără subiect, acest lucru corespunde cu `--help`. Folosiți `help targets` pentru a afișa catalogul de ținte și acțiuni pentru fișierul selectat. + +# Help catalogue headings and markers. +cli.help.actions_heading = Acțiuni: +cli.help.targets_heading = Ținte: +cli.help.targets.about = Listează țintele și acțiunile din fișierul selectat. +cli.help.default_marker = implicit # Textul de ajutor pentru opțiunile subcomenzii build. cli.subcommand.build.flag.targets.help = Țintele de construit (dacă lipsesc, se folosesc cele implicite din manifest). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Calea manifestului „{ $path }” nu este UTF-8 val runner.manifest.directory_utf8 = Calea directorului manifestului „{ $path }” nu este UTF-8 valid. runner.manifest.directory_label = directorul `{ $directory }` runner.manifest.current_directory_label = directorul curent +runner.manifest.default_not_declared = Valoarea implicită a manifestului '{ $default }' nu denumește o acțiune sau o țintă declarată. runner.context.network_policy = Politica de rețea nu a putut fi construită. runner.context.load_manifest = Manifestul din { $path } nu a putut fi încărcat. runner.context.serialise_manifest = Manifestul nu a putut fi serializat. @@ -368,6 +377,7 @@ status.tool.clean = Curățare status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generare +status.tool.help_targets = Catalogul țintelor # Textele redării grafului în HTML. graph.html.title = Graful de construire Netsuke diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index ca9ccd562..3d3c20518 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Вывести граф зависимостей с cli.subcommand.graph.long_about = Преобразовать разобранный манифест Netsuke в канонический граф сборки и записать его в формате Graphviz DOT либо, с параметром `--html`, как самостоятельную HTML-страницу. Используйте `--output <ФАЙЛ>` для записи в файл; `-` выводит в стандартный поток. cli.subcommand.generate.about = Создать манифест Ninja, не запуская Ninja. cli.subcommand.generate.long_about = Записать созданный манифест Ninja в стандартный поток вывода либо в файл, выбранный параметром `--output`. +cli.subcommand.help.about = Вывести справку верхнего уровня или справку по указанной теме. +cli.subcommand.help.long_about = Без темы это соответствует `--help`. Используйте `help targets`, чтобы вывести каталог целей и действий для выбранного файла. + +# Help catalogue headings and markers. +cli.help.actions_heading = Действия: +cli.help.targets_heading = Цели: +cli.help.targets.about = Вывести список целей и действий в выбранном файле. +cli.help.default_marker = по умолчанию # Текст справки для параметров подкоманды build. cli.subcommand.build.flag.targets.help = Цели для сборки (если не указаны, берутся цели манифеста по умолчанию). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Путь к манифесту «{ $path }» не я runner.manifest.directory_utf8 = Путь к каталогу манифеста «{ $path }» не является корректным UTF-8. runner.manifest.directory_label = каталог `{ $directory }` runner.manifest.current_directory_label = текущий каталог +runner.manifest.default_not_declared = Значение по умолчанию в манифесте '{ $default }' не называет объявленное действие или цель. runner.context.network_policy = Не удалось построить сетевую политику. runner.context.load_manifest = Не удалось загрузить манифест по пути { $path }. runner.context.serialise_manifest = Не удалось сериализовать манифест. @@ -368,6 +377,7 @@ status.tool.clean = Очистка status.tool.graph = Граф status.tool.graph_html = Граф (HTML) status.tool.generate = Генерация +status.tool.help_targets = Справка по целям # Строки HTML-представления графа. graph.html.title = Граф сборки Netsuke diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index ad1126a0f..ee0b824ad 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Skriv ut byggets beroendegraf. Standardformatet är cli.subcommand.graph.long_about = Projicera det tolkade Netsuke-manifestet till en kanonisk bygggraf och skriv den som Graphviz DOT, eller som en fristående HTML-sida med `--html`. Använd `--output ` för att skriva till en fil; `-` skriver till stdout. cli.subcommand.generate.about = Skapa Ninja-manifestet utan att köra Ninja. cli.subcommand.generate.long_about = Skriv det skapade Ninja-manifestet till stdout eller till en fil som väljs med `--output`. +cli.subcommand.help.about = Skriv ut hjälpen på den översta nivån eller hjälpen för ett namngivet ämne. +cli.subcommand.help.long_about = Utan ämne motsvarar detta `--help`. Använd `help targets` för att skriva ut katalogen över mål och åtgärder för den valda filen. + +# Help catalogue headings and markers. +cli.help.actions_heading = Åtgärder: +cli.help.targets_heading = Mål: +cli.help.targets.about = Lista mål och åtgärder i det valda manifestet. +cli.help.default_marker = standard # Hjälptext för flaggor till underkommandot build. cli.subcommand.build.flag.targets.help = Mål som ska byggas (använder manifestets standardmål om det utelämnas). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Manifestsökvägen ”{ $path }” är inte giltig U runner.manifest.directory_utf8 = Sökvägen till manifestkatalogen ”{ $path }” är inte giltig UTF-8. runner.manifest.directory_label = katalogen `{ $directory }` runner.manifest.current_directory_label = den aktuella katalogen +runner.manifest.default_not_declared = Manifestets standardvärde '{ $default }' anger ingen deklarerad åtgärd eller något mål. runner.context.network_policy = Nätverkspolicyn kunde inte byggas. runner.context.load_manifest = Manifestet i { $path } kunde inte läsas in. runner.context.serialise_manifest = Manifestet kunde inte serialiseras. @@ -368,6 +377,7 @@ status.tool.clean = Rensning status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generering +status.tool.help_targets = Målhjälp # Texter för HTML-renderingen av grafen. graph.html.title = Netsuke-bygggraf diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 5afd113ae..741f246e1 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = แสดงกราฟการพึ่งพา cli.subcommand.graph.long_about = ฉายไฟล์รายการ Netsuke ที่แจงแล้วให้เป็นกราฟการสร้างมาตรฐาน แล้วเขียนเป็น Graphviz DOT หรือเขียนเป็นหน้า HTML ที่สมบูรณ์ในตัวเมื่อใช้ `--html` ใช้ `--output <ไฟล์>` เพื่อเขียนลงไฟล์ ส่วน `-` จะเขียนไปยังเอาต์พุตมาตรฐาน cli.subcommand.generate.about = สร้างไฟล์รายการ Ninja โดยไม่เรียกใช้ Ninja cli.subcommand.generate.long_about = เขียนไฟล์รายการ Ninja ที่สร้างขึ้นไปยังเอาต์พุตมาตรฐาน หรือไปยังไฟล์ที่เลือกด้วย `--output` +cli.subcommand.help.about = พิมพ์ความช่วยเหลือระดับบนสุด หรือความช่วยเหลือสำหรับหัวข้อที่ระบุชื่อ +cli.subcommand.help.long_about = หากไม่มีหัวข้อ คำสั่งนี้จะเหมือนกับ `--help` ใช้ `help targets` เพื่อพิมพ์แคตตาล็อกเป้าหมายและการดำเนินการสำหรับไฟล์รายการที่เลือก + +# Help catalogue headings and markers. +cli.help.actions_heading = การดำเนินการ: +cli.help.targets_heading = เป้าหมาย: +cli.help.targets.about = แสดงรายการเป้าหมายและการดำเนินการในไฟล์รายการที่เลือก +cli.help.default_marker = ค่าเริ่มต้น # ข้อความช่วยเหลือของตัวเลือกในคำสั่งย่อย build cli.subcommand.build.flag.targets.help = เป้าหมายที่จะสร้าง (หากละไว้ จะใช้ค่าโดยปริยายของไฟล์รายการ) @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = เส้นทางไฟล์รายการ runner.manifest.directory_utf8 = เส้นทางไดเรกทอรีของไฟล์รายการ “{ $path }” ไม่ใช่ UTF-8 ที่ถูกต้อง runner.manifest.directory_label = ไดเรกทอรี `{ $directory }` runner.manifest.current_directory_label = ไดเรกทอรีปัจจุบัน +runner.manifest.default_not_declared = ค่าเริ่มต้นของรายการ '{ $default }' ไม่ได้ระบุการดำเนินการหรือเป้าหมายที่ประกาศไว้ runner.context.network_policy = สร้างนโยบายเครือข่ายไม่สำเร็จ runner.context.load_manifest = โหลดไฟล์รายการที่ { $path } ไม่สำเร็จ runner.context.serialise_manifest = ทำให้ไฟล์รายการเป็นลำดับข้อมูลไม่สำเร็จ @@ -368,6 +377,7 @@ status.tool.clean = การล้าง status.tool.graph = กราฟ status.tool.graph_html = กราฟ (HTML) status.tool.generate = การสร้างไฟล์ +status.tool.help_targets = ความช่วยเหลือเป้าหมาย # ข้อความของตัวแสดงกราฟเป็น HTML graph.html.title = กราฟการสร้างของ Netsuke diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 8af7246e7..2398f6844 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Derleme bağımlılık çizgesini yaz. Varsayılan cli.subcommand.graph.long_about = Ayrıştırılan Netsuke bildirimini kurallı bir derleme çizgesine dönüştür ve Graphviz DOT olarak ya da `--html` ile kendi kendine yeten bir HTML sayfası olarak yaz. Dosyaya yazmak için `--output ` kullanın; `-` standart çıktıya yazar. cli.subcommand.generate.about = Ninja'yı çalıştırmadan Ninja bildirimini üret. cli.subcommand.generate.long_about = Üretilen Ninja bildirimini standart çıktıya ya da `--output` ile seçilen dosyaya yaz. +cli.subcommand.help.about = Üst düzey yardımı veya adlandırılmış bir konunun yardımını yazdır. +cli.subcommand.help.long_about = Konu olmadan bu, `--help` ile aynıdır. Seçilen bildirim için hedef ve eylem kataloğunu yazdırmak üzere `help targets` komutunu kullanın. + +# Help catalogue headings and markers. +cli.help.actions_heading = Eylemler: +cli.help.targets_heading = Hedefler: +cli.help.targets.about = Seçilen bildirimdeki hedef ve eylemleri listele. +cli.help.default_marker = varsayılan # build alt komutunun seçenekleri için yardım metni. cli.subcommand.build.flag.targets.help = Derlenecek hedefler (belirtilmezse bildirimdeki varsayılanlar kullanılır). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = "{ $path }" bildirim yolu geçerli UTF-8 değil. runner.manifest.directory_utf8 = "{ $path }" bildirim dizini yolu geçerli UTF-8 değil. runner.manifest.directory_label = `{ $directory }` dizini runner.manifest.current_directory_label = geçerli dizin +runner.manifest.default_not_declared = '{ $default }' bildirim varsayılanı, bildirilmiş bir eylem veya hedefi adlandırmıyor. runner.context.network_policy = Ağ ilkesi oluşturulamadı. runner.context.load_manifest = { $path } konumundaki bildirim yüklenemedi. runner.context.serialise_manifest = Bildirim serileştirilemedi. @@ -368,6 +377,7 @@ status.tool.clean = Temizleme status.tool.graph = Çizge status.tool.graph_html = Çizge (HTML) status.tool.generate = Üretme +status.tool.help_targets = Hedef yardımı # Çizgenin HTML gösterimindeki metinler. graph.html.title = Netsuke derleme çizgesi diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 260d0188d..8b3137433 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Вивести граф залежностей зб cli.subcommand.graph.long_about = Перетворити розібраний маніфест Netsuke на канонічний граф збирання та записати його у форматі Graphviz DOT або, з параметром `--html`, як самостійну сторінку HTML. Використайте `--output <ФАЙЛ>`, щоб записати у файл; `-` виводить у стандартний потік. cli.subcommand.generate.about = Створити маніфест Ninja, не запускаючи Ninja. cli.subcommand.generate.long_about = Записати створений маніфест Ninja у стандартний потік виводу або у файл, вибраний параметром `--output`. +cli.subcommand.help.about = Друкувати довідку верхнього рівня або довідку для вказаної теми. +cli.subcommand.help.long_about = Без теми це відповідає `--help`. Використовуйте `help targets`, щоб надрукувати каталог цілей і дій для вибраного маніфесту. + +# Help catalogue headings and markers. +cli.help.actions_heading = Дії: +cli.help.targets_heading = Цілі: +cli.help.targets.about = Вивести список цілей і дій у вибраному маніфесті. +cli.help.default_marker = за замовчуванням # Текст довідки для параметрів підкоманди build. cli.subcommand.build.flag.targets.help = Цілі для збирання (якщо не вказано, беруться типові цілі маніфесту). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Шлях до маніфесту «{ $path }» не runner.manifest.directory_utf8 = Шлях до каталогу маніфесту «{ $path }» не є коректним UTF-8. runner.manifest.directory_label = каталог `{ $directory }` runner.manifest.current_directory_label = поточний каталог +runner.manifest.default_not_declared = Типове значення маніфесту '{ $default }' не називає оголошену дію або ціль. runner.context.network_policy = Не вдалося побудувати мережеву політику. runner.context.load_manifest = Не вдалося завантажити маніфест за шляхом { $path }. runner.context.serialise_manifest = Не вдалося серіалізувати маніфест. @@ -368,6 +377,7 @@ status.tool.clean = Очищення status.tool.graph = Граф status.tool.graph_html = Граф (HTML) status.tool.generate = Генерація +status.tool.help_targets = Довідка цілей # Рядки HTML-подання графа. graph.html.title = Граф збирання Netsuke diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index 06a083ba7..c1e9327e0 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = Xuất đồ thị phụ thuộc của quá trình cli.subcommand.graph.long_about = Chiếu tệp kê khai Netsuke đã phân tích thành đồ thị dựng chuẩn tắc rồi ghi ở định dạng Graphviz DOT, hoặc thành trang HTML độc lập với `--html`. Dùng `--output ` để ghi ra tệp; `-` ghi ra đầu ra chuẩn. cli.subcommand.generate.about = Tạo tệp kê khai Ninja mà không chạy Ninja. cli.subcommand.generate.long_about = Ghi tệp kê khai Ninja đã tạo ra đầu ra chuẩn hoặc ra tệp được chọn bằng `--output`. +cli.subcommand.help.about = In trợ giúp cấp cao nhất hoặc trợ giúp cho một chủ đề cụ thể. +cli.subcommand.help.long_about = Không có chủ đề, lệnh này tương ứng với `--help`. Dùng `help targets` để in danh mục mục tiêu và hành động cho tệp kê khai đã chọn. + +# Help catalogue headings and markers. +cli.help.actions_heading = Hành động: +cli.help.targets_heading = Mục tiêu: +cli.help.targets.about = Liệt kê mục tiêu và hành động trong tệp kê khai đã chọn. +cli.help.default_marker = mặc định # Văn bản trợ giúp cho tuỳ chọn của lệnh con build. cli.subcommand.build.flag.targets.help = Các đích cần dựng (nếu bỏ trống sẽ dùng đích mặc định của tệp kê khai). @@ -76,6 +84,7 @@ runner.manifest.path_utf8 = Đường dẫn tệp kê khai “{ $path }” khôn runner.manifest.directory_utf8 = Đường dẫn thư mục tệp kê khai “{ $path }” không phải UTF-8 hợp lệ. runner.manifest.directory_label = thư mục `{ $directory }` runner.manifest.current_directory_label = thư mục hiện tại +runner.manifest.default_not_declared = Giá trị mặc định của tệp kê khai '{ $default }' không nêu hành động hoặc mục tiêu đã khai báo. runner.context.network_policy = Không dựng được chính sách mạng. runner.context.load_manifest = Không nạp được tệp kê khai tại { $path }. runner.context.serialise_manifest = Không tuần tự hoá được tệp kê khai. @@ -368,6 +377,7 @@ status.tool.clean = Dọn dẹp status.tool.graph = Đồ thị status.tool.graph_html = Đồ thị (HTML) status.tool.generate = Tạo +status.tool.help_targets = Trợ giúp mục tiêu # Chuỗi của bộ kết xuất đồ thị sang HTML. graph.html.title = Đồ thị dựng của Netsuke diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index dc92f76fa..abaa9f664 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = 输出构建依赖图。默认格式为 DOT。 cli.subcommand.graph.long_about = 将解析后的 Netsuke 清单投影为规范的构建图,并写为 Graphviz DOT;使用 `--html` 时写为独立的 HTML 页面。使用 `--output <文件>` 写入文件;`-` 写入标准输出。 cli.subcommand.generate.about = 生成 Ninja 清单但不运行 Ninja。 cli.subcommand.generate.long_about = 将生成的 Ninja 清单写入标准输出,或写入用 `--output` 选定的文件。 +cli.subcommand.help.about = 打印顶层帮助,或打印指定主题的帮助。 +cli.subcommand.help.long_about = 没有主题时,此命令等价于 `--help`。使用 `help targets` 打印所选清单的目标和动作目录。 + +# Help catalogue headings and markers. +cli.help.actions_heading = 动作: +cli.help.targets_heading = 目标: +cli.help.targets.about = 列出所选清单中的目标和动作。 +cli.help.default_marker = 默认 # build 子命令选项的帮助文本。 cli.subcommand.build.flag.targets.help = 要构建的目标(省略时使用清单中的默认目标)。 @@ -75,6 +83,7 @@ runner.manifest.path_utf8 = 清单路径“{ $path }”不是有效的 UTF-8。 runner.manifest.directory_utf8 = 清单目录路径“{ $path }”不是有效的 UTF-8。 runner.manifest.directory_label = 目录 `{ $directory }` runner.manifest.current_directory_label = 当前目录 +runner.manifest.default_not_declared = 清单默认值“{ $default }”未指定已声明的动作或目标。 runner.context.network_policy = 无法构建网络策略。 runner.context.load_manifest = 无法加载 { $path } 处的清单。 runner.context.serialise_manifest = 无法序列化清单。 @@ -367,6 +376,7 @@ status.tool.clean = 清理 status.tool.graph = 图 status.tool.graph_html = 图(HTML) status.tool.generate = 生成 +status.tool.help_targets = 目标帮助 # 图的 HTML 渲染文案。 graph.html.title = Netsuke 构建图 diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 663e321b9..b30bfe583 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -32,6 +32,14 @@ cli.subcommand.graph.about = 輸出建置相依性圖。預設格式為 DOT。 cli.subcommand.graph.long_about = 將剖析後的 Netsuke 資訊清單投影為正規的建置圖,並寫成 Graphviz DOT;加上 `--html` 時則寫成自足的 HTML 頁面。使用 `--output <檔案>` 寫入檔案;`-` 會寫入標準輸出。 cli.subcommand.generate.about = 產生 Ninja 資訊清單但不執行 Ninja。 cli.subcommand.generate.long_about = 將產生的 Ninja 資訊清單寫入標準輸出,或寫入以 `--output` 選定的檔案。 +cli.subcommand.help.about = 列印頂層說明,或列印指定主題的說明。 +cli.subcommand.help.long_about = 沒有主題時,此命令等同於 `--help`。使用 `help targets` 列印所選資訊清單的目標和動作目錄。 + +# Help catalogue headings and markers. +cli.help.actions_heading = 動作: +cli.help.targets_heading = 目標: +cli.help.targets.about = 列出所選資訊清單中的目標和動作。 +cli.help.default_marker = 預設 # build 子命令選項的說明文字。 cli.subcommand.build.flag.targets.help = 要建置的目標(省略時採用資訊清單的預設值)。 @@ -75,6 +83,7 @@ runner.manifest.path_utf8 = 資訊清單路徑「{ $path }」不是有效的 UTF runner.manifest.directory_utf8 = 資訊清單目錄路徑「{ $path }」不是有效的 UTF-8。 runner.manifest.directory_label = 目錄 `{ $directory }` runner.manifest.current_directory_label = 目前的目錄 +runner.manifest.default_not_declared = 資訊清單預設值「{ $default }」未指定已宣告的動作或目標。 runner.context.network_policy = 無法建立網路原則。 runner.context.load_manifest = 無法載入 { $path } 的資訊清單。 runner.context.serialise_manifest = 無法序列化資訊清單。 @@ -367,6 +376,7 @@ status.tool.clean = 清理 status.tool.graph = 圖 status.tool.graph_html = 圖(HTML) status.tool.generate = 產生 +status.tool.help_targets = 目標說明 # 圖的 HTML 算繪文字。 graph.html.title = Netsuke 建置圖 diff --git a/scripts/generate-release-help.sh b/scripts/generate-release-help.sh index ae2128281..ba874031f 100755 --- a/scripts/generate-release-help.sh +++ b/scripts/generate-release-help.sh @@ -178,6 +178,7 @@ if target_is_windows "$target"; then --out-dir "$out_dir" \ --locale "$locale" \ --ps-module-name "$module_name" \ + --ps-split-subcommands true \ --ensure-en-us true require_file "$out_dir/powershell/$module_name/$module_name.psm1" \ diff --git a/src/ast.rs b/src/ast/mod.rs similarity index 67% rename from src/ast.rs rename to src/ast/mod.rs index bc540a7b1..9b51e4416 100644 --- a/src/ast.rs +++ b/src/ast/mod.rs @@ -35,6 +35,10 @@ use std::collections::HashMap; #[cfg(kani)] use std::{collections::hash_map::DefaultHasher, hash::BuildHasherDefault}; +mod string_or_list; + +pub use string_or_list::StringOrList; + /// Map type for `vars` blocks, preserving JSON values produced by the YAML /// parser. #[cfg(not(kani))] @@ -251,150 +255,14 @@ pub struct Target { /// Force the recipe to run even if the outputs are up to date. #[serde(default)] pub always: bool, -} - -/// A helper for fields that accept either a single string or a list of -/// strings. -/// -/// It mirrors YAML syntax where a scalar or sequence is allowed. Empty values -/// deserialize to `StringOrList::Empty`. -/// -/// ```yaml -/// # Scalar -/// name: hello -/// # Sequence -/// name: -/// - hello -/// - world -/// ``` -#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)] -#[serde(untagged)] -pub enum StringOrList { - /// No value provided. - #[default] - Empty, - /// A single string item. - String(String), - /// A list of string items. - List(Vec), -} - -impl StringOrList { - /// Apply `f` to each contained string, collecting the results. - /// - /// `Empty` yields an empty vector, `String` a single-element vector, and - /// `List` one element per item. - /// - /// # Examples - /// - /// ``` - /// use netsuke::ast::StringOrList; - /// - /// let single = StringOrList::String("hello".into()); - /// assert_eq!(single.map_each(str::len), vec![5]); - /// assert!(StringOrList::Empty.map_each(str::len).is_empty()); - /// ``` - #[must_use] - pub fn map_each(&self, f: F) -> Vec - where - F: Fn(&str) -> T, - { - match self { - Self::Empty => Vec::new(), - Self::String(s) => vec![f(s)], - // Indexed iteration keeps the Kani harnesses in - // `crate::ir::from_manifest_verification` tractable; an iterator - // chain here defeats their loop unwinding bounds. - Self::List(v) => { - let mut mapped = Vec::with_capacity(v.len()); - let mut index = 0; - while let Some(value) = v.get(index) { - mapped.push(f(value)); - index += 1; - } - mapped - } - } - } - - /// Collect the contained strings into owned `String`s. - /// - /// # Examples - /// - /// ``` - /// use netsuke::ast::StringOrList; - /// - /// let rule = StringOrList::String("cc".into()); - /// assert_eq!(rule.to_string_vec(), vec!["cc".to_owned()]); - /// ``` - #[must_use] - pub fn to_string_vec(&self) -> Vec { - self.map_each(str::to_owned) - } - - /// Return the sole contained string, if exactly one is present. - /// - /// A `String` value or a one-element `List` yields `Some`; anything else - /// yields `None`. - /// - /// # Examples - /// - /// ``` - /// use netsuke::ast::StringOrList; - /// - /// assert_eq!(StringOrList::String("cc".into()).as_single(), Some("cc")); - /// assert_eq!( - /// StringOrList::List(vec!["a".into(), "b".into()]).as_single(), - /// None, - /// ); - /// ``` - #[must_use] - pub fn as_single(&self) -> Option<&str> { - match self { - Self::String(s) => Some(s), - Self::List(v) if v.len() == 1 => v.first().map(String::as_str), - _ => None, - } - } - /// Whether the value carries no string content. - /// - /// `Empty` and an empty `List` both yield `true`; a `String` (even an - /// empty string) and a non-empty `List` yield `false`. + /// Optional human-friendly summary of the public operation this target + /// performs. /// - /// # Examples - /// - /// ``` - /// use netsuke::ast::StringOrList; - /// - /// assert!(StringOrList::Empty.is_empty_content()); - /// assert!(StringOrList::List(Vec::new()).is_empty_content()); - /// assert!(!StringOrList::String(String::new()).is_empty_content()); - /// ``` - #[must_use] - pub const fn is_empty_content(&self) -> bool { - match self { - Self::Empty => true, - Self::String(_) => false, - Self::List(v) => v.is_empty(), - } - } -} - -impl From<&str> for StringOrList { - fn from(value: &str) -> Self { - Self::String(value.to_owned()) - } -} - -impl From for StringOrList { - fn from(value: String) -> Self { - Self::String(value) - } -} - -impl From> for StringOrList { - fn from(value: Vec) -> Self { - Self::List(value) - } + /// Unlike [`Rule::description`], which explains work while Ninja executes + /// a recipe, a target description is discovery metadata for humans: it is + /// surfaced by `netsuke help targets` and never replaces a referenced rule + /// description in Ninja progress output. + #[serde(default)] + pub description: Option, } diff --git a/src/ast/string_or_list.rs b/src/ast/string_or_list.rs new file mode 100644 index 000000000..8373d6feb --- /dev/null +++ b/src/ast/string_or_list.rs @@ -0,0 +1,149 @@ +//! Manifest values that accept either one string or an ordered list. + +use serde::{Deserialize, Serialize}; + +/// A helper for fields that accept either a single string or a list of +/// strings. +/// +/// It mirrors YAML syntax where a scalar or sequence is allowed. Empty values +/// deserialize to `StringOrList::Empty`. +/// +/// ```yaml +/// # Scalar +/// name: hello +/// # Sequence +/// name: +/// - hello +/// - world +/// ``` +#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)] +#[serde(untagged)] +pub enum StringOrList { + /// No value provided. + #[default] + Empty, + /// A single string item. + String(String), + /// A list of string items. + List(Vec), +} + +impl StringOrList { + /// Apply `f` to each contained string, collecting the results. + /// + /// `Empty` yields an empty vector, `String` a single-element vector, and + /// `List` one element per item. + /// + /// # Examples + /// + /// ``` + /// use netsuke::ast::StringOrList; + /// + /// let single = StringOrList::String("hello".into()); + /// assert_eq!(single.map_each(str::len), vec![5]); + /// assert!(StringOrList::Empty.map_each(str::len).is_empty()); + /// ``` + #[must_use] + pub fn map_each(&self, f: F) -> Vec + where + F: Fn(&str) -> T, + { + match self { + Self::Empty => Vec::new(), + Self::String(s) => vec![f(s)], + // Indexed iteration keeps the Kani harnesses in + // `crate::ir::from_manifest_verification` tractable; an iterator + // chain here defeats their loop unwinding bounds. + Self::List(v) => { + let mut mapped = Vec::with_capacity(v.len()); + let mut index = 0; + while let Some(value) = v.get(index) { + mapped.push(f(value)); + index += 1; + } + mapped + } + } + } + + /// Collect the contained strings into owned `String`s. + /// + /// # Examples + /// + /// ``` + /// use netsuke::ast::StringOrList; + /// + /// let rule = StringOrList::String("cc".into()); + /// assert_eq!(rule.to_string_vec(), vec!["cc".to_owned()]); + /// ``` + #[must_use] + pub fn to_string_vec(&self) -> Vec { + self.map_each(str::to_owned) + } + + /// Return the sole contained string, if exactly one is present. + /// + /// A `String` value or a one-element `List` yields `Some`; anything else + /// yields `None`. + /// + /// # Examples + /// + /// ``` + /// use netsuke::ast::StringOrList; + /// + /// assert_eq!(StringOrList::String("cc".into()).as_single(), Some("cc")); + /// assert_eq!( + /// StringOrList::List(vec!["a".into(), "b".into()]).as_single(), + /// None, + /// ); + /// ``` + #[must_use] + pub fn as_single(&self) -> Option<&str> { + match self { + Self::String(s) => Some(s), + Self::List(v) if v.len() == 1 => v.first().map(String::as_str), + _ => None, + } + } + + /// Whether the value carries no string content. + /// + /// `Empty` and an empty `List` both yield `true`; a `String` (even an + /// empty string) and a non-empty `List` yield `false`. + /// + /// # Examples + /// + /// ``` + /// use netsuke::ast::StringOrList; + /// + /// assert!(StringOrList::Empty.is_empty_content()); + /// assert!(StringOrList::List(Vec::new()).is_empty_content()); + /// assert!(!StringOrList::String(String::new()).is_empty_content()); + /// ``` + #[must_use] + pub const fn is_empty_content(&self) -> bool { + match self { + Self::Empty => true, + Self::String(_) => false, + Self::List(v) => v.is_empty(), + } + } +} + +impl From<&str> for StringOrList { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From for StringOrList { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From> for StringOrList { + fn from(value: Vec) -> Self { + Self::List(value) + } +} diff --git a/src/cli/build_support.rs b/src/cli/build_support.rs index c523eefe1..37a718ea3 100644 --- a/src/cli/build_support.rs +++ b/src/cli/build_support.rs @@ -8,6 +8,7 @@ use ortho_config::OrthoError; use std::sync::Arc; mod config; +mod help; mod parser; mod parsing; diff --git a/src/cli/help.rs b/src/cli/help.rs new file mode 100644 index 000000000..fa82494ab --- /dev/null +++ b/src/cli/help.rs @@ -0,0 +1,35 @@ +//! Help topic data types for the `netsuke help` subcommand. +//! +//! Kept out of `parser.rs` so that module stays within the repository's +//! 400-line budget. The `Cli` command re-exports these types for clap. + +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; + +/// Arguments accepted by the `help` command. +/// +/// The optional topic selects the help artefact to render. With no topic the +/// command prints the top-level long help, matching `--help`. +#[derive(Debug, Args, PartialEq, Eq, Clone, Serialize, Deserialize, Default)] +pub struct HelpArgs { + /// Help topic to print; omitting it prints the top-level help. + #[command(subcommand)] + pub topic: Option, +} + +/// Help topics accepted by the `netsuke help` command. +#[derive(Debug, Subcommand, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HelpTopic { + /// Print the target and action catalogue for the selected manifest. + Targets, + + /// Print the help for the `build` command. + Build, + /// Print the help for the `clean` command. + Clean, + /// Print the help for the `graph` command. + Graph, + /// Print the help for the `generate` command. + Generate, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d8df8d584..1a6fa1442 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -13,9 +13,11 @@ mod constants; mod diag; mod discovery; mod environment; +mod help; mod merge; mod parser; mod parsing; +mod release_help; #[cfg(test)] pub(crate) mod test_support; @@ -23,11 +25,13 @@ pub(crate) mod test_support; pub use config::{AccessibilityPolicy, CliConfig, ColourPolicy, EmojiPolicy, ProgressPolicy}; pub use diag::{resolve_merged_json, resolve_merged_json_with_env}; pub use discovery::{EnvProvider as ConfigEnvProvider, StdEnvProvider as ConfigStdEnvProvider}; +pub use help::{HelpArgs, HelpTopic}; pub use merge::{merge_with_config, merge_with_config_and_env}; pub use parser::{ BuildArgs, Cli, Commands, GraphArgs, json_hint_from_args, locale_hint_from_args, parse_with_localizer_from, }; +pub use release_help::ReleaseHelpCli; /// Maximum number of jobs accepted by the CLI. pub(super) const MAX_JOBS: usize = 64; diff --git a/src/cli/parser.rs b/src/cli/parser.rs index b1e10970c..f54efd96c 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -24,6 +24,7 @@ use std::path::PathBuf; use std::sync::Arc; use super::config::CliConfig; +use super::help::HelpArgs; use super::parsing::{ parse_accessibility_policy, parse_color_policy, parse_emoji_policy, parse_host_pattern, parse_jobs, parse_locale, parse_progress_policy, parse_scheme, @@ -79,7 +80,14 @@ pub(super) fn validation_message( /// A modern, friendly build system that uses YAML and Jinja, powered by Ninja. #[derive(Debug, Parser, Serialize, Deserialize)] -#[command(name = "netsuke", author, version, about, long_about = None)] +#[command( + name = "netsuke", + author, + version, + about, + long_about = None, + disable_help_subcommand = true +)] pub struct Cli { /// Path to the Netsuke manifest file to use. #[arg( @@ -297,6 +305,12 @@ pub enum Commands { #[arg(long, value_name = "FILE")] output: Option, }, + + /// Print the top-level help, or the help for a named topic such as `help targets`. + /// + /// With no topic this matches `--help`. `help targets` renders the + /// target and action catalogue for the selected manifest. + Help(HelpArgs), } /// Parse CLI arguments with localized clap output. diff --git a/src/cli/parser_tests.rs b/src/cli/parser_tests.rs index 416e50747..4cb1a2c4c 100644 --- a/src/cli/parser_tests.rs +++ b/src/cli/parser_tests.rs @@ -48,3 +48,44 @@ fn localized_help_snapshots_include_config_flag( assert_snapshot!(snapshot_name, normalized_help); }); } + +/// Verifies `netsuke help --help` localizes its nested topic descriptions. +#[rstest] +#[case::en_us( + "en-US", + [ + "List targets and actions in the selected manifest.", + "Build targets defined in the manifest", + "Remove build artefacts via Ninja", + "Emit the build dependency graph", + "Generate the Ninja manifest without running Ninja", + ] +)] +#[case::es_es( + "es-ES", + [ + "Enumerar objetivos y acciones en el manifiesto seleccionado.", + "Compila objetivos definidos en el manifiesto", + "Elimina artefactos de compilación mediante Ninja", + "Emite el grafo de dependencias de compilación", + "Genera el manifiesto Ninja sin ejecutar Ninja", + ] +)] +fn localized_help_topics_include_localized_descriptions( + #[case] locale: &str, + #[case] expected_descriptions: [&str; 5], +) { + let localizer = build_localizer(Some(locale)); + let mut command = localize_command(Cli::command(), localizer.as_ref()); + let help = command + .find_subcommand_mut("help") + .expect("help subcommand should exist"); + let rendered_help = normalize_fluent_isolates(&help.render_long_help().to_string()); + + for description in expected_descriptions { + assert!( + rendered_help.contains(description), + "localized help topics for {locale} should contain {description:?}: {rendered_help}" + ); + } +} diff --git a/src/cli/release_help.rs b/src/cli/release_help.rs new file mode 100644 index 000000000..8f2f7cc8e --- /dev/null +++ b/src/cli/release_help.rs @@ -0,0 +1,125 @@ +//! Release-help metadata derived from Netsuke's configuration and CLI models. +//! +//! `cargo-orthohelp` consumes [`ReleaseHelpCli`] rather than [`CliConfig`] +//! directly. The adapter retains the configuration-field metadata generated +//! for `CliConfig` and adds the Clap subcommands that users can invoke. + +use clap::CommandFactory; +use ortho_config::docs::{DocMetadata, OrthoConfigDocs}; + +use super::{Cli, CliConfig}; +use crate::localization::keys; + +/// Documentation root used by release help generators. +/// +/// ``` +/// use netsuke::cli::ReleaseHelpCli; +/// use ortho_config::docs::OrthoConfigDocs; +/// +/// let metadata = ReleaseHelpCli::get_doc_metadata(); +/// assert!(metadata.subcommands.iter().any(|command| command.app_name == "help")); +/// ``` +pub struct ReleaseHelpCli; + +impl OrthoConfigDocs for ReleaseHelpCli { + fn get_doc_metadata() -> DocMetadata { + let mut metadata = CliConfig::get_doc_metadata(); + keys::CLI_ABOUT.clone_into(&mut metadata.about_id); + metadata.subcommands = documented_clap_subcommands(&metadata); + metadata + } +} + +fn documented_clap_subcommands(root: &DocMetadata) -> Vec { + Cli::command() + .get_subcommands() + .filter_map(|command| { + release_help_about_key(command.get_name()) + .map(|about_id| documented_subcommand(root, command.get_name(), about_id)) + }) + .collect() +} + +fn documented_subcommand(root: &DocMetadata, name: &str, about_id: &str) -> DocMetadata { + DocMetadata { + ir_version: root.ir_version.clone(), + app_name: name.to_owned(), + bin_name: Some(name.to_owned()), + about_id: about_id.to_owned(), + synopsis_id: None, + sections: root.sections.clone(), + fields: Vec::new(), + subcommands: Vec::new(), + windows: None, + } +} + +fn release_help_about_key(name: &str) -> Option<&'static str> { + match name { + "build" => Some(keys::CLI_SUBCOMMAND_BUILD_ABOUT), + "clean" => Some(keys::CLI_SUBCOMMAND_CLEAN_ABOUT), + "graph" => Some(keys::CLI_SUBCOMMAND_GRAPH_ABOUT), + "generate" => Some(keys::CLI_SUBCOMMAND_GENERATE_ABOUT), + // The long description is the release artefact's only representation + // of nested help topics, so retain the `help targets` invocation. + "help" => Some(keys::CLI_SUBCOMMAND_HELP_LONG_ABOUT), + _ => None, + } +} + +#[cfg(test)] +mod tests { + //! Tests for release-help metadata assembled from the Clap command tree. + + use super::*; + use anyhow::{Context, Result, ensure}; + + #[test] + fn metadata_documents_help_targets_through_the_help_subcommand() { + let metadata = ReleaseHelpCli::get_doc_metadata(); + let help = metadata + .subcommands + .iter() + .find(|command| command.app_name == "help") + .expect("Clap help command should be present in release metadata"); + + assert_eq!(help.about_id, keys::CLI_SUBCOMMAND_HELP_LONG_ABOUT); + assert_eq!( + metadata + .subcommands + .iter() + .map(|command| command.app_name.as_str()) + .collect::>(), + ["build", "clean", "graph", "generate", "help"] + ); + } + + #[test] + fn cargo_metadata_selects_the_clap_documentation_adapter() { + assert!( + include_str!("../../Cargo.toml") + .contains("root_type = \"netsuke::cli::ReleaseHelpCli\""), + "cargo-orthohelp should load the metadata that includes Clap subcommands" + ); + } + + #[test] + fn release_help_metadata_localizes_the_help_targets_description() -> Result<()> { + let metadata = ReleaseHelpCli::get_doc_metadata(); + let help = metadata + .subcommands + .iter() + .find(|command| command.app_name == "help") + .context("release help metadata should include the help command")?; + let localizer = crate::cli_localization::build_localizer(Some("en-US")); + let description = localizer + .lookup(&help.about_id, None) + .context("release help metadata should resolve its help description")?; + + ensure!( + description.contains("help targets"), + "release help description should document the targets topic: {description}" + ); + Ok(()) + } +} diff --git a/src/cli_l10n.rs b/src/cli_l10n.rs index 625b3b84a..6ec414bd0 100644 --- a/src/cli_l10n.rs +++ b/src/cli_l10n.rs @@ -115,11 +115,40 @@ fn localize_subcommands(command: &mut Command, localizer: &dyn Localizer) { // Localise subcommand argument help text. updated = localize_arguments(updated, localizer, known); + updated = localize_help_topics(updated, localizer, known); *subcommand = updated; } } +/// Localise the topics nested beneath the `help` subcommand. +fn localize_help_topics( + mut command: Command, + localizer: &dyn Localizer, + subcommand: Option, +) -> Command { + if !matches!(subcommand, Some(Subcommand::Help)) { + return command; + } + + for topic in command.get_subcommands_mut() { + let known = HelpTopicName::from_name(topic.get_name()); + let mut updated = std::mem::take(topic); + if let Some(localized) = localize_field( + localizer, + known.map(help_topic_about_key), + updated + .get_about() + .map(|s: &clap::builder::StyledStr| s.to_string()), + ) { + updated = updated.about(localized); + } + *topic = updated; + } + + command +} + /// The set of known CLI subcommands. /// /// Replaces raw `&str` subcommand-name parameters in localisation helpers to @@ -130,6 +159,7 @@ enum Subcommand { Clean, Graph, Generate, + Help, } impl Subcommand { @@ -139,18 +169,41 @@ impl Subcommand { "clean" => Some(Self::Clean), "graph" => Some(Self::Graph), "generate" => Some(Self::Generate), + "help" => Some(Self::Help), _ => None, } } } +/// The topics nested under the `help` subcommand. +#[derive(Clone, Copy)] +enum HelpTopicName { + Targets, + Subcommand(Subcommand), +} + +impl HelpTopicName { + fn from_name(name: &str) -> Option { + if name == "targets" { + return Some(Self::Targets); + } + + Subcommand::from_name(name).and_then(|subcommand| match subcommand { + Subcommand::Build | Subcommand::Clean | Subcommand::Graph | Subcommand::Generate => { + Some(Self::Subcommand(subcommand)) + } + Subcommand::Help => None, + }) + } +} + fn flag_help_key(arg_id: &str, subcommand: Option) -> Option<&'static str> { match subcommand { None => top_level_flag_help_key(arg_id), Some(Subcommand::Build) => build_flag_help_key(arg_id), Some(Subcommand::Graph) => graph_flag_help_key(arg_id), Some(Subcommand::Generate) => generate_flag_help_key(arg_id), - Some(Subcommand::Clean) => None, + Some(Subcommand::Clean | Subcommand::Help) => None, } } @@ -205,6 +258,7 @@ const fn subcommand_about_key(subcommand: Subcommand) -> &'static str { Subcommand::Clean => keys::CLI_SUBCOMMAND_CLEAN_ABOUT, Subcommand::Graph => keys::CLI_SUBCOMMAND_GRAPH_ABOUT, Subcommand::Generate => keys::CLI_SUBCOMMAND_GENERATE_ABOUT, + Subcommand::Help => keys::CLI_SUBCOMMAND_HELP_ABOUT, } } @@ -214,6 +268,14 @@ const fn subcommand_long_about_key(subcommand: Subcommand) -> &'static str { Subcommand::Clean => keys::CLI_SUBCOMMAND_CLEAN_LONG_ABOUT, Subcommand::Graph => keys::CLI_SUBCOMMAND_GRAPH_LONG_ABOUT, Subcommand::Generate => keys::CLI_SUBCOMMAND_GENERATE_LONG_ABOUT, + Subcommand::Help => keys::CLI_SUBCOMMAND_HELP_LONG_ABOUT, + } +} + +const fn help_topic_about_key(topic: HelpTopicName) -> &'static str { + match topic { + HelpTopicName::Targets => keys::CLI_HELP_TARGETS_ABOUT, + HelpTopicName::Subcommand(subcommand) => subcommand_about_key(subcommand), } } @@ -273,3 +335,29 @@ pub fn json_hint_from_args(args: &[OsString]) -> Option { } None } + +#[cfg(test)] +mod tests { + //! Unit tests for CLI localization helper routing. + + use super::*; + use rstest::rstest; + + #[rstest] + #[case("targets", Some(keys::CLI_HELP_TARGETS_ABOUT))] + #[case("build", Some(keys::CLI_SUBCOMMAND_BUILD_ABOUT))] + #[case("clean", Some(keys::CLI_SUBCOMMAND_CLEAN_ABOUT))] + #[case("graph", Some(keys::CLI_SUBCOMMAND_GRAPH_ABOUT))] + #[case("generate", Some(keys::CLI_SUBCOMMAND_GENERATE_ABOUT))] + #[case("help", None)] + #[case("unknown", None)] + fn help_topic_names_map_to_supported_about_keys( + #[case] name: &str, + #[case] expected: Option<&str>, + ) { + assert_eq!( + HelpTopicName::from_name(name).map(help_topic_about_key), + expected + ); + } +} diff --git a/src/ir/from_manifest.rs b/src/ir/from_manifest.rs index 5a84a1cdc..5cbd3ccc3 100644 --- a/src/ir/from_manifest.rs +++ b/src/ir/from_manifest.rs @@ -53,10 +53,8 @@ impl BuildGraph { /// Rules are stored verbatim and expanded later when targets reference /// them. This allows each target's input and output paths to be embedded in /// the resulting command, meaning identical rule definitions may yield - /// distinct actions once interpolated. Should the manifest schema ever - /// permit targets to override recipe fields such as `command` or - /// `description`, those target-level values take precedence over the rule's - /// defaults. + /// distinct actions once interpolated. Target descriptions remain discovery + /// metadata and never take part in recipe resolution or Ninja progress. fn process_rules(manifest: &NetsukeManifest, rule_map: &mut IrHashMap>) { for rule in &manifest.rules { rule_map.insert(rule.name.clone(), Arc::new(rule.clone())); @@ -86,9 +84,9 @@ impl BuildGraph { Recipe::Rule { rule } => { let target_name = get_target_display_name(&outputs); let tmpl = resolve_rule(rule, rule_map, &target_name)?; - // Future schema versions may allow targets to override - // recipe or description fields. If so, those values will - // take precedence over the rule template. + // Target descriptions are deliberately omitted: rule + // descriptions remain the sole source of Ninja progress + // text. register_action( actions, tmpl.recipe.clone(), diff --git a/src/localization/keys.rs b/src/localization/keys.rs index b91622878..6e79dc5d0 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -37,6 +37,12 @@ define_keys! { CLI_SUBCOMMAND_GRAPH_LONG_ABOUT => "cli.subcommand.graph.long_about", CLI_SUBCOMMAND_GENERATE_ABOUT => "cli.subcommand.generate.about", CLI_SUBCOMMAND_GENERATE_LONG_ABOUT => "cli.subcommand.generate.long_about", + CLI_SUBCOMMAND_HELP_ABOUT => "cli.subcommand.help.about", + CLI_SUBCOMMAND_HELP_LONG_ABOUT => "cli.subcommand.help.long_about", + CLI_HELP_ACTIONS_HEADING => "cli.help.actions_heading", + CLI_HELP_TARGETS_HEADING => "cli.help.targets_heading", + CLI_HELP_TARGETS_ABOUT => "cli.help.targets.about", + CLI_HELP_DEFAULT_MARKER => "cli.help.default_marker", CLI_SUBCOMMAND_BUILD_FLAG_TARGETS_HELP => "cli.subcommand.build.flag.targets.help", CLI_SUBCOMMAND_GRAPH_FLAG_HTML_HELP => "cli.subcommand.graph.flag.html.help", CLI_SUBCOMMAND_GRAPH_FLAG_OUTPUT_HELP => "cli.subcommand.graph.flag.output.help", @@ -66,6 +72,7 @@ define_keys! { RUNNER_MANIFEST_DIR_UTF8 => "runner.manifest.directory_utf8", RUNNER_MANIFEST_DIRECTORY => "runner.manifest.directory_label", RUNNER_MANIFEST_CURRENT_DIRECTORY => "runner.manifest.current_directory_label", + RUNNER_MANIFEST_DEFAULT_NOT_DECLARED => "runner.manifest.default_not_declared", RUNNER_CONTEXT_NETWORK_POLICY => "runner.context.network_policy", RUNNER_CONTEXT_LOAD_MANIFEST => "runner.context.load_manifest", RUNNER_CONTEXT_SERIALISE_MANIFEST => "runner.context.serialise_manifest", @@ -325,6 +332,7 @@ define_keys! { STATUS_TOOL_GRAPH => "status.tool.graph", STATUS_TOOL_GRAPH_HTML => "status.tool.graph_html", STATUS_TOOL_GENERATE => "status.tool.generate", + STATUS_TOOL_HELP_TARGETS => "status.tool.help_targets", GRAPH_HTML_TITLE => "graph.html.title", GRAPH_HTML_HEADING => "graph.html.heading", GRAPH_HTML_DESCRIPTION => "graph.html.description", diff --git a/src/main.rs b/src/main.rs index 9287dc428..4ba8c077a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,11 @@ fn run_with_args( Err(code) => return code, }; + if is_informational_help(&parsed_cli) { + settle_startup_diagnostics(&startup_writer, startup_mode); + return run_cli(&parsed_cli, system_locale, startup_mode); + } + let mode = match resolve_json_mode_or_exit(&parsed_cli, &matches, startup_mode) { Ok(mode) => mode, Err(code) => { @@ -100,14 +105,29 @@ fn run_with_args( Err(code) => return code, }; let runtime_mode = DiagMode::from_json_enabled(merged_cli.json); - configure_runtime(&merged_cli, system_locale, runtime_mode); - let output_mode = - output_mode::resolve(merged_cli.accessibility_override(), Some(merged_cli.color)); + run_cli(&merged_cli, system_locale, runtime_mode) +} + +const fn is_informational_help(cli: &cli::Cli) -> bool { + matches!( + &cli.command, + Some(cli::Commands::Help(args)) + if !matches!(args.topic.as_ref(), Some(cli::HelpTopic::Targets)) + ) +} + +fn run_cli( + cli: &cli::Cli, + system_locale: &impl locale_resolution::SystemLocale, + runtime_mode: DiagMode, +) -> ExitCode { + configure_runtime(cli, system_locale, runtime_mode); + let output_mode = output_mode::resolve(cli.accessibility_override(), Some(cli.color)); let prefs = output_prefs::resolve_from_theme( - merged_cli.theme_preference(), - ThemeContext::new(None, Some(merged_cli.color), output_mode), + cli.theme_preference(), + ThemeContext::new(None, Some(cli.color), output_mode), ); - match runner::run(&merged_cli, prefs) { + match runner::run(cli, prefs) { Ok(()) => ExitCode::SUCCESS, Err(err) => handle_runner_error(err, prefs, runtime_mode), } diff --git a/src/manifest/env_reader.rs b/src/manifest/env_reader.rs index 581aaa2a1..bc991ac7b 100644 --- a/src/manifest/env_reader.rs +++ b/src/manifest/env_reader.rs @@ -74,6 +74,12 @@ pub fn process_env_reader() -> EnvReader { Arc::new(move |key| env.raw(key).map_err(EnvReadError::from)) } +/// Construct a reader that prevents template queries from disclosing host +/// environment values. +pub(super) fn disabled_env_reader() -> EnvReader { + Arc::new(|_| Err(EnvReadError::NotPresent)) +} + /// Resolve `name` through `read_env`, mapping failures to Jinja errors. /// /// Failures are traced with only a bounded `failure_kind`, and the localized diff --git a/src/manifest/expand_test_cases/description_cases.rs b/src/manifest/expand_test_cases/description_cases.rs new file mode 100644 index 000000000..997b995fa --- /dev/null +++ b/src/manifest/expand_test_cases/description_cases.rs @@ -0,0 +1,121 @@ +//! Expansion cases proving a target or action `description` key survives +//! `foreach` expansion and is dropped together with filtered entries. + +use super::*; +use anyhow::{Context, Result}; +use minijinja::Environment; +use rstest::rstest; + +#[rstest] +#[case::targets("targets")] +#[case::actions("actions")] +fn expand_static_entry_preserves_description(#[case] section: &str) -> Result<()> { + let env = Environment::new(); + let yaml = format!( + "{section}: + - name: report + description: Build the report + command: echo report" + ); + let mut doc: ManifestValue = serde_saphyr::from_str(&yaml)?; + expand_foreach(&mut doc, &env)?; + let entries = section_entries(&doc, section)?; + anyhow::ensure!(entries.len() == 1, "expected one {section} entry"); + let map = entries + .first() + .and_then(ManifestValue::as_object) + .with_context(|| format!("{section} entry map"))?; + let description = map + .get("description") + .and_then(ManifestValue::as_str) + .with_context(|| format!("{section} description"))?; + anyhow::ensure!( + description == "Build the report", + "description should be carried through expansion: {description}" + ); + Ok(()) +} + +#[rstest] +#[case::targets("targets")] +#[case::actions("actions")] +fn expand_foreach_descriptions_are_rendered_with_item(#[case] section: &str) -> Result<()> { + let env = Environment::new(); + // The `foreach` list is a local sequence, so expansion clones the whole + // entry map including the `description` key with its `{{ item }}` template. + let yaml = format!( + "{section}: + - name: report-{{{{ item }}}} + description: Build the {{{{ item }}}} report + command: echo {{{{ item }}}} + foreach: + - weekly + - monthly + - annual" + ); + let mut doc: ManifestValue = serde_saphyr::from_str(&yaml)?; + expand_foreach(&mut doc, &env)?; + let entries = section_entries(&doc, section)?; + anyhow::ensure!( + entries.len() == 3, + "expected three expanded {section} entries" + ); + let descriptions: Result> = entries + .iter() + .map(|entry| { + entry + .as_object() + .and_then(|map| map.get("description")) + .and_then(ManifestValue::as_str) + .map(str::to_owned) + .with_context(|| format!("{section} description")) + }) + .collect(); + let expected = vec![ + "Build the {{ item }} report".to_owned(), + "Build the {{ item }} report".to_owned(), + "Build the {{ item }} report".to_owned(), + ]; + anyhow::ensure!( + descriptions? == expected, + "description templates should survive expansion for later rendering" + ); + Ok(()) +} + +#[rstest] +#[case::targets("targets")] +#[case::actions("actions")] +fn expand_when_filter_drops_description_with_the_entry(#[case] section: &str) -> Result<()> { + let env = Environment::new(); + let yaml = format!( + "{section}: + - name: skipped + description: Should vanish + command: echo skipped + when: 'false' + - name: kept + description: Should remain + command: echo kept" + ); + let mut doc: ManifestValue = serde_saphyr::from_str(&yaml)?; + expand_foreach(&mut doc, &env)?; + let entries = section_entries(&doc, section)?; + anyhow::ensure!( + entries.len() == 1, + "expected exactly one kept {section} entry" + ); + let map = entries + .first() + .and_then(ManifestValue::as_object) + .with_context(|| format!("{section} entry map"))?; + let description = map + .get("description") + .and_then(ManifestValue::as_str) + .with_context(|| format!("{section} description"))?; + anyhow::ensure!( + description == "Should remain", + "kept {section} description should survive: {description}" + ); + Ok(()) +} diff --git a/src/manifest/expand_test_cases/property_cases.rs b/src/manifest/expand_test_cases/property_cases.rs index 40f83c127..976d97f56 100644 --- a/src/manifest/expand_test_cases/property_cases.rs +++ b/src/manifest/expand_test_cases/property_cases.rs @@ -14,6 +14,7 @@ use serde_json::json; fn foreach_doc(section: &str, items: &[String], when: Option<&str>) -> ManifestValue { let mut entry = json!({ "name": "literal", + "description": "Build {{ item }}", "command": "echo hi", "foreach": items, }); @@ -92,6 +93,32 @@ proptest! { } } + /// Every `foreach` clone keeps its discovery metadata so final rendering + /// can resolve the same item-specific description as the target name. + #[test] + fn foreach_preserves_description_templates(items in item_names(10)) { + let env = Environment::new(); + for section in ["targets", "actions"] { + let mut doc = foreach_doc(section, &items, None); + expand_foreach(&mut doc, &env) + .map_err(|e| TestCaseError::fail(format!("expansion failed: {e}")))?; + let descriptions: Result, TestCaseError> = expanded_entries(&doc, section)? + .iter() + .map(|entry| { + entry + .get("description") + .and_then(ManifestValue::as_str) + .map(str::to_owned) + .ok_or_else(|| TestCaseError::fail("expanded description missing")) + }) + .collect(); + prop_assert_eq!( + descriptions?, + vec!["Build {{ item }}".to_owned(); items.len()] + ); + } + } + /// No expanded entry retains a `foreach` key. #[test] fn foreach_key_is_removed_from_all_entries(items in keep_skip_items(10)) { diff --git a/src/manifest/expand_tests.rs b/src/manifest/expand_tests.rs index 016d58495..6b63cfb31 100644 --- a/src/manifest/expand_tests.rs +++ b/src/manifest/expand_tests.rs @@ -10,6 +10,9 @@ mod action_condition_cases; #[path = "expand_test_cases/condition_cases.rs"] mod condition_cases; +#[path = "expand_test_cases/description_cases.rs"] +mod description_cases; + #[path = "expand_test_cases/foreach_property_cases.rs"] mod foreach_property_cases; diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index 8ea3d6954..5c3a84e8e 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -27,7 +27,7 @@ use crate::{ localization::{self, keys}, stdlib::{NetworkPolicy, StdlibConfig}, }; -use anyhow::{Context, Result}; +use anyhow::Result; use minijinja::{Environment, UndefinedBehavior, value::Value}; use serde::de::Error as _; use std::{path::Path, sync::Arc}; @@ -45,6 +45,7 @@ mod glob; mod hints; mod jinja_macros; mod parse_with_config; +mod query; mod render; /// JSON representation of a manifest node after YAML and Jinja evaluation. @@ -60,9 +61,11 @@ pub use glob::glob_paths; pub(crate) use expand::expand_foreach; pub use parse_with_config::from_str_with_env_and_config; +pub(crate) use query::from_path_for_manifest_query; pub use render::render_manifest; use self::{env_reader::env_var_with, jinja_macros::register_manifest_macros}; +#[cfg(test)] use workspace::open_manifest_workspace; /// Stages in the manifest-loading sub-pipeline. @@ -100,12 +103,19 @@ fn notify_stage( struct ManifestParse<'a> { /// Name reported in diagnostics. name: &'a ManifestName, - /// Optional stdlib configuration override. - stdlib_config: Option, + /// Optional stdlib registration configuration. + stdlib_registration: Option, /// Environment reader backing the `env()` helper. env_reader: &'a EnvReader, } +/// Selects the stdlib surface available while rendering a manifest. +enum StdlibRegistration { + /// The complete stdlib used for a normal build manifest. + Full(Box), + /// The read-only stdlib used to inspect manifest discovery metadata. + ManifestQuery, +} fn from_str_named( yaml: &str, parse: ManifestParse<'_>, @@ -113,7 +123,7 @@ fn from_str_named( ) -> Result { let ManifestParse { name, - stdlib_config, + stdlib_registration, env_reader, } = parse; notify_stage(on_stage, ManifestLoadStage::InitialYamlParsing); @@ -135,8 +145,13 @@ fn from_str_named( glob::record_expansion(&expansion); Ok(expansion.into_paths()) }); - let _stdlib_state = match stdlib_config { - Some(config) => crate::stdlib::register_with_config(&mut jinja, config), + let _stdlib_state = match stdlib_registration { + Some(StdlibRegistration::Full(config)) => { + crate::stdlib::register_with_config(&mut jinja, *config) + } + Some(StdlibRegistration::ManifestQuery) => { + Ok(crate::stdlib::register_manifest_query(&mut jinja)) + } None => crate::stdlib::register(&mut jinja), }?; @@ -281,7 +296,7 @@ pub fn from_str_with_env(yaml: &str, env_reader: &EnvReader) -> Result, policy: NetworkPolicy, env_reader: &EnvReader, - mut on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, + on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, ) -> Result { - notify_stage(&mut on_stage, ManifestLoadStage::ManifestIngestion); - let path_ref = path.as_ref(); - let workspace = open_manifest_workspace(path_ref)?; - let data = workspace - .dir - .read_to_string(&workspace.manifest_file) - .with_context(|| { - localization::message(keys::MANIFEST_READ_FAILED) - .with_arg("path", path_ref.display().to_string()) - })?; - let name = ManifestName::new(path_ref.display().to_string()); - let config = StdlibConfig::new(workspace.dir)? - .with_workspace_root_path(workspace.root)? - .with_network_policy(policy); - from_str_named( - &data, - ManifestParse { - name: &name, - stdlib_config: Some(config), - env_reader, - }, - &mut on_stage, - ) + query::from_path_with_policy_and_env(path, policy, env_reader, on_stage) } mod env_reader; diff --git a/src/manifest/parse_with_config.rs b/src/manifest/parse_with_config.rs index 36141d695..0f217f39a 100644 --- a/src/manifest/parse_with_config.rs +++ b/src/manifest/parse_with_config.rs @@ -6,7 +6,7 @@ //! as `command_available` resolution without mutating the process //! environment. -use super::{EnvReader, ManifestName, ManifestParse, from_str_named}; +use super::{EnvReader, ManifestName, ManifestParse, StdlibRegistration, from_str_named}; use crate::{ast::NetsukeManifest, stdlib::StdlibConfig}; use anyhow::Result; @@ -65,7 +65,7 @@ pub fn from_str_with_env_and_config( yaml, ManifestParse { name: &ManifestName::new("Netsukefile"), - stdlib_config: Some(stdlib_config), + stdlib_registration: Some(StdlibRegistration::Full(Box::new(stdlib_config))), env_reader, }, &mut None, diff --git a/src/manifest/query.rs b/src/manifest/query.rs new file mode 100644 index 000000000..3e78656df --- /dev/null +++ b/src/manifest/query.rs @@ -0,0 +1,84 @@ +//! Workspace-backed manifest loading for builds and discovery queries. +//! +//! This module owns the capability-scoped filesystem boundary shared by normal +//! manifest loading and `netsuke help targets`. The latter selects a restricted +//! stdlib registration so it can render discovery metadata without allowing +//! network requests, cache writes, or command execution. + +use super::{ + EnvReader, ManifestLoadStage, ManifestName, ManifestParse, NetsukeManifest, StdlibConfig, + StdlibRegistration, env_reader::disabled_env_reader, from_str_named, notify_stage, + workspace::open_manifest_workspace, +}; +use crate::{localization, localization::keys, stdlib::NetworkPolicy}; +use anyhow::{Context, Result}; +use std::path::Path; + +/// Load a manifest for a side-effect-free discovery query. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be read, rendered, or parsed, or if +/// it invokes an impure template helper. +pub(crate) fn from_path_for_manifest_query( + path: impl AsRef, + on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, +) -> Result { + let env_reader = disabled_env_reader(); + from_path_with_registration(path, &env_reader, on_stage, ManifestLoadMode::ManifestQuery) +} + +/// Load a manifest with the full stdlib and an explicit network policy. +pub(super) fn from_path_with_policy_and_env( + path: impl AsRef, + policy: NetworkPolicy, + env_reader: &EnvReader, + on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, +) -> Result { + from_path_with_registration(path, env_reader, on_stage, ManifestLoadMode::Full(policy)) +} + +/// Select the standard-library boundary for a manifest load. +enum ManifestLoadMode { + /// A normal build load with a configured network policy. + Full(NetworkPolicy), + /// A metadata-only load that must not construct an ambient stdlib config. + ManifestQuery, +} + +/// Read a manifest and render it with the selected stdlib registration. +fn from_path_with_registration( + path: impl AsRef, + env_reader: &EnvReader, + mut on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, + mode: ManifestLoadMode, +) -> Result { + notify_stage(&mut on_stage, ManifestLoadStage::ManifestIngestion); + let path_ref = path.as_ref(); + let workspace = open_manifest_workspace(path_ref)?; + let data = workspace + .dir + .read_to_string(&workspace.manifest_file) + .with_context(|| { + localization::message(keys::MANIFEST_READ_FAILED) + .with_arg("path", path_ref.display().to_string()) + })?; + let name = ManifestName::new(path_ref.display().to_string()); + let stdlib_registration = match mode { + ManifestLoadMode::Full(policy) => StdlibRegistration::Full(Box::new( + StdlibConfig::new(workspace.dir)? + .with_workspace_root_path(workspace.root)? + .with_network_policy(policy), + )), + ManifestLoadMode::ManifestQuery => StdlibRegistration::ManifestQuery, + }; + from_str_named( + &data, + ManifestParse { + name: &name, + stdlib_registration: Some(stdlib_registration), + env_reader, + }, + &mut on_stage, + ) +} diff --git a/src/manifest/render.rs b/src/manifest/render.rs index 54caced25..59b7040de 100644 --- a/src/manifest/render.rs +++ b/src/manifest/render.rs @@ -39,41 +39,59 @@ pub fn render_manifest( } fn render_rule(rule: &mut crate::ast::Rule, env: &Environment, vars: &Vars) -> Result<()> { - if let Some(desc) = &mut rule.description { - *desc = render_str_with(env, desc, vars, || "render rule description".into())?; - } - match &mut rule.recipe { - Recipe::Command { command } => { - render_recipe_string_or_list(command, env, vars, || "render rule command".into())?; - } - Recipe::Script { script } => { - *script = render_str_with(env, script, vars, || "render rule script".into())?; - } - Recipe::Rule { rule: r } => render_string_or_list(r, env, vars)?, - } + render_description(&mut rule.description, env, vars, "rule")?; + render_recipe(&mut rule.recipe, env, vars, "rule")?; Ok(()) } fn render_target(target: &mut Target, env: &Environment) -> Result<()> { render_vars(&mut target.vars, env)?; + render_description(&mut target.description, env, &target.vars, "target")?; render_string_or_list(&mut target.name, env, &target.vars)?; render_string_or_list(&mut target.sources, env, &target.vars)?; render_string_or_list(&mut target.deps, env, &target.vars)?; render_string_or_list(&mut target.order_only_deps, env, &target.vars)?; - match &mut target.recipe { + render_recipe(&mut target.recipe, env, &target.vars, "target")?; + Ok(()) +} + +/// Render an optional target or rule description against its context. +/// +/// The `subject` selects the error-context wording ("rule" or "target") so +/// that diagnostics keep naming the manifest entry being rendered. +fn render_description( + description: &mut Option, + env: &Environment, + vars: &Vars, + subject: &str, +) -> Result<()> { + if let Some(desc) = description { + *desc = render_str_with(env, desc, vars, || format!("render {subject} description"))?; + } + Ok(()) +} + +/// Render a target or rule recipe against its context. +/// +/// The `subject` selects the error-context wording ("rule" or "target") so +/// that diagnostics keep naming the manifest entry being rendered. A command +/// recipe is rendered through [`render_recipe_string_or_list`] so the `ins`/`outs` +/// placeholders stay available; a rule-reference recipe reuses +/// [`render_string_or_list`]. +fn render_recipe(recipe: &mut Recipe, env: &Environment, vars: &Vars, subject: &str) -> Result<()> { + match recipe { Recipe::Command { command } => { - render_recipe_string_or_list(command, env, &target.vars, || { - "render target command".into() + render_recipe_string_or_list(command, env, vars, || { + format!("render {subject} command") })?; } Recipe::Script { script } => { - *script = render_str_with(env, script, &target.vars, || "render target script".into())?; + *script = render_str_with(env, script, vars, || format!("render {subject} script"))?; } - Recipe::Rule { rule } => render_string_or_list(rule, env, &target.vars)?, + Recipe::Rule { rule } => render_string_or_list(rule, env, vars)?, } Ok(()) } - fn render_vars(vars: &mut Vars, env: &Environment) -> Result<()> { let snapshot = vars.clone(); for (key, value) in vars.iter_mut() { @@ -183,196 +201,8 @@ fn render_str_with( } #[cfg(test)] -mod tests { - //! Unit tests for manifest template rendering. - use super::*; - use crate::ast::Rule; - use minijinja::Environment; - use semver::Version; - - fn sample_manifest() -> Result { - let mut target_vars = Vars::new(); - target_vars.insert("greet".into(), ManifestValue::String("hello".into())); - target_vars.insert("subject".into(), ManifestValue::String("world".into())); - target_vars.insert( - "message".into(), - ManifestValue::String("{{ greet }} {{ subject }}".into()), - ); - - let target = Target { - name: StringOrList::String("{{ message }}!".into()), - recipe: Recipe::Command { - command: "{{ message }}".into(), - }, - sources: StringOrList::List(vec!["{{ subject }}.txt".into()]), - deps: StringOrList::Empty, - order_only_deps: StringOrList::List(vec!["{{ subject }}.meta".into()]), - vars: target_vars, - phony: false, - always: false, - }; - - let rule = Rule { - name: "example".into(), - recipe: Recipe::Command { - command: "{{ 2 + 2 }}".into(), - }, - description: Some("{{ 1 + 1 }}".into()), - }; - - let mut manifest_vars = Vars::new(); - manifest_vars.insert( - "message".into(), - ManifestValue::String("hello world".into()), - ); - - Ok(NetsukeManifest { - netsuke_version: Version::parse("1.0.0")?, - vars: manifest_vars, - macros: Vec::new(), - rules: vec![rule], - actions: Vec::new(), - targets: vec![target], - defaults: Vec::new(), - }) - } - - #[expect(clippy::panic, reason = "panic for clearer test failures")] - fn expect_var(vars: &Vars, key: impl AsRef) -> &str { - let key_ref = key.as_ref(); - let Some(value) = vars.get(key_ref).and_then(|value| value.as_str()) else { - panic!("expected rendered var '{key_ref}'"); - }; - value - } - - #[expect(clippy::panic, reason = "panic for clearer test failures")] - fn expect_string(value: &StringOrList, label: impl std::fmt::Display) -> &str { - match value { - StringOrList::String(item) => item, - other => panic!("expected {label} as string, got {other:?}"), - } - } - - #[expect(clippy::panic, reason = "panic for clearer test failures")] - fn expect_list(value: &StringOrList, label: impl std::fmt::Display) -> &[String] { - match value { - StringOrList::List(items) => items, - other => panic!("expected {label} as list, got {other:?}"), - } - } - - #[expect(clippy::panic, reason = "panic for clearer test failures")] - fn expect_command(recipe: &Recipe, label: impl std::fmt::Display) -> &str { - match recipe { - Recipe::Command { command } => match command { - StringOrList::String(item) => item, - other => panic!("expected {label} command as a scalar, got {other:?}"), - }, - other => panic!("expected {label} command recipe, got {other:?}"), - } - } - - fn assert_rendered_target(target: &Target) { - assert_eq!(expect_var(&target.vars, "message"), "hello world"); - assert_eq!(expect_string(&target.name, "target name"), "hello world!"); - assert_eq!( - expect_list(&target.sources, "target sources"), - ["world.txt"] - ); - assert_eq!(expect_command(&target.recipe, "target"), "hello world"); - assert_eq!( - expect_list(&target.order_only_deps, "order-only deps"), - ["world.meta"] - ); - } - - fn assert_rendered_rule(rule: &Rule) { - assert_eq!(rule.description.as_deref(), Some("2")); - match &rule.recipe { - Recipe::Command { command } => assert_eq!(command.as_single(), Some("4")), - other => panic!("expected command recipe, got {other:?}"), - } - } - - #[test] - fn render_manifest_renders_targets_and_rules() -> Result<()> { - let env = Environment::new(); - let manifest = sample_manifest()?; - let rendered = render_manifest(manifest, &env)?; - let rendered_target = rendered - .targets - .first() - .context("rendered target missing")?; - assert_rendered_target(rendered_target); - let rendered_rule = rendered.rules.first().context("rendered rule missing")?; - assert_rendered_rule(rendered_rule); - Ok(()) - } - - #[test] - fn command_list_renders_each_entry_with_ins_outs_placeholders() -> Result<()> { - let env = Environment::new(); - let manifest = NetsukeManifest { - netsuke_version: Version::parse("1.0.0")?, - vars: Vars::new(), - macros: Vec::new(), - rules: vec![Rule { - name: "check".into(), - recipe: Recipe::Command { - command: StringOrList::List(vec![ - "echo {{ 1 + 1 }}".into(), - "{{ ins }}".into(), - "{{ outs }}".into(), - ]), - }, - description: None, - }], - actions: Vec::new(), - targets: Vec::new(), - defaults: Vec::new(), - }; - let rendered = render_manifest(manifest, &env)?; - let rule = rendered.rules.first().context("rendered rule missing")?; - let Recipe::Command { command } = &rule.recipe else { - anyhow::bail!("expected command recipe, got {:?}", rule.recipe); - }; - anyhow::ensure!( - command.to_string_vec() == ["echo 2", crate::ir::INS_TOKEN, crate::ir::OUTS_TOKEN], - "unexpected rendered command list: {command:?}" - ); - Ok(()) - } - - #[test] - fn command_list_render_failure_names_the_failing_entry() -> Result<()> { - let env = Environment::new(); - let manifest = NetsukeManifest { - netsuke_version: Version::parse("1.0.0")?, - vars: Vars::new(), - macros: Vec::new(), - rules: vec![Rule { - name: "check".into(), - recipe: Recipe::Command { - command: StringOrList::List(vec!["echo ok".into(), "echo {{ 1 + }}".into()]), - }, - description: None, - }], - actions: Vec::new(), - targets: Vec::new(), - defaults: Vec::new(), - }; - let error = render_manifest(manifest, &env) - .err() - .context("expected the malformed entry to fail rendering")?; - let report = format!("{error:#}"); - anyhow::ensure!( - report.contains("render rule command entry 2"), - "error should name the failing list position, got: {report}" - ); - Ok(()) - } -} +#[path = "render_tests.rs"] +mod tests; #[cfg(test)] #[path = "render_command_list_tests.rs"] diff --git a/src/manifest/render_tests.rs b/src/manifest/render_tests.rs new file mode 100644 index 000000000..a487f3049 --- /dev/null +++ b/src/manifest/render_tests.rs @@ -0,0 +1,274 @@ +//! Unit tests for manifest template rendering. + +use super::*; +use crate::ast::Rule; +use minijinja::Environment; +use semver::Version; + +fn sample_manifest() -> Result { + let mut target_vars = Vars::new(); + target_vars.insert("greet".into(), ManifestValue::String("hello".into())); + target_vars.insert("subject".into(), ManifestValue::String("world".into())); + target_vars.insert( + "message".into(), + ManifestValue::String("{{ greet }} {{ subject }}".into()), + ); + + let target = Target { + name: StringOrList::String("{{ message }}!".into()), + recipe: Recipe::Command { + command: "{{ message }}".into(), + }, + sources: StringOrList::List(vec!["{{ subject }}.txt".into()]), + deps: StringOrList::Empty, + order_only_deps: StringOrList::List(vec!["{{ subject }}.meta".into()]), + vars: target_vars, + phony: false, + always: false, + description: Some("{{ message }}".into()), + }; + + let rule = Rule { + name: "example".into(), + recipe: Recipe::Command { + command: "{{ 2 + 2 }}".into(), + }, + description: Some("{{ 1 + 1 }}".into()), + }; + + let mut manifest_vars = Vars::new(); + manifest_vars.insert( + "message".into(), + ManifestValue::String("hello world".into()), + ); + + Ok(NetsukeManifest { + netsuke_version: Version::parse("1.0.0")?, + vars: manifest_vars, + macros: Vec::new(), + rules: vec![rule], + actions: Vec::new(), + targets: vec![target], + defaults: Vec::new(), + }) +} + +#[expect(clippy::panic, reason = "panic for clearer test failures")] +fn expect_var(vars: &Vars, key: impl AsRef) -> &str { + let key_ref = key.as_ref(); + let Some(value) = vars.get(key_ref).and_then(|value| value.as_str()) else { + panic!("expected rendered var '{key_ref}'"); + }; + value +} + +#[expect(clippy::panic, reason = "panic for clearer test failures")] +fn expect_string(value: &StringOrList, label: impl std::fmt::Display) -> &str { + match value { + StringOrList::String(item) => item, + other => panic!("expected {label} as string, got {other:?}"), + } +} + +#[expect(clippy::panic, reason = "panic for clearer test failures")] +fn expect_list(value: &StringOrList, label: impl std::fmt::Display) -> &[String] { + match value { + StringOrList::List(items) => items, + other => panic!("expected {label} as list, got {other:?}"), + } +} + +#[expect(clippy::panic, reason = "panic for clearer test failures")] +fn expect_command(recipe: &Recipe, label: impl std::fmt::Display) -> &str { + match recipe { + Recipe::Command { command } => match command { + StringOrList::String(item) => item, + other => panic!("expected {label} command as a scalar, got {other:?}"), + }, + other => panic!("expected {label} command recipe, got {other:?}"), + } +} + +fn expect_script(recipe: &Recipe, label: impl std::fmt::Display) -> Result<&str> { + match recipe { + Recipe::Script { script } => Ok(script), + other => anyhow::bail!("expected {label} script recipe, got {other:?}"), + } +} + +fn expect_rule_ref(recipe: &Recipe, label: impl std::fmt::Display) -> Result<&StringOrList> { + match recipe { + Recipe::Rule { rule } => Ok(rule), + other => anyhow::bail!("expected {label} rule-reference recipe, got {other:?}"), + } +} + +fn assert_rendered_target(target: &Target) { + assert_eq!(expect_var(&target.vars, "message"), "hello world"); + assert_eq!( + target.description.as_deref(), + Some("hello world"), + "target description should be rendered through the target vars" + ); + assert_eq!(expect_string(&target.name, "target name"), "hello world!"); + assert_eq!( + expect_list(&target.sources, "target sources"), + ["world.txt"] + ); + assert_eq!(expect_command(&target.recipe, "target"), "hello world"); + assert_eq!( + expect_list(&target.order_only_deps, "order-only deps"), + ["world.meta"] + ); +} + +fn assert_rendered_rule(rule: &Rule) { + assert_eq!(rule.description.as_deref(), Some("2")); + match &rule.recipe { + Recipe::Command { command } => assert_eq!(command.as_single(), Some("4")), + other => panic!("expected command recipe, got {other:?}"), + } +} + +#[test] +fn render_manifest_renders_targets_and_rules() -> Result<()> { + let env = Environment::new(); + let manifest = sample_manifest()?; + let rendered = render_manifest(manifest, &env)?; + let rendered_target = rendered + .targets + .first() + .context("rendered target missing")?; + assert_rendered_target(rendered_target); + let rendered_rule = rendered.rules.first().context("rendered rule missing")?; + assert_rendered_rule(rendered_rule); + Ok(()) +} + +#[test] +fn command_list_renders_each_entry_with_ins_outs_placeholders() -> Result<()> { + let env = Environment::new(); + let manifest = NetsukeManifest { + netsuke_version: Version::parse("1.0.0")?, + vars: Vars::new(), + macros: Vec::new(), + rules: vec![Rule { + name: "check".into(), + recipe: Recipe::Command { + command: StringOrList::List(vec![ + "echo {{ 1 + 1 }}".into(), + "{{ ins }}".into(), + "{{ outs }}".into(), + ]), + }, + description: None, + }], + actions: Vec::new(), + targets: Vec::new(), + defaults: Vec::new(), + }; + let rendered = render_manifest(manifest, &env)?; + let rule = rendered.rules.first().context("rendered rule missing")?; + let Recipe::Command { command } = &rule.recipe else { + anyhow::bail!("expected command recipe, got {:?}", rule.recipe); + }; + anyhow::ensure!( + command.to_string_vec() == ["echo 2", crate::ir::INS_TOKEN, crate::ir::OUTS_TOKEN], + "unexpected rendered command list: {command:?}" + ); + Ok(()) +} + +#[test] +fn command_list_render_failure_names_the_failing_entry() -> Result<()> { + let env = Environment::new(); + let manifest = NetsukeManifest { + netsuke_version: Version::parse("1.0.0")?, + vars: Vars::new(), + macros: Vec::new(), + rules: vec![Rule { + name: "check".into(), + recipe: Recipe::Command { + command: StringOrList::List(vec!["echo ok".into(), "echo {{ 1 + }}".into()]), + }, + description: None, + }], + actions: Vec::new(), + targets: Vec::new(), + defaults: Vec::new(), + }; + let error = render_manifest(manifest, &env) + .err() + .context("expected the malformed entry to fail rendering")?; + let report = format!("{error:#}"); + anyhow::ensure!( + report.contains("render rule command entry 2"), + "error should name the failing list position, got: {report}" + ); + Ok(()) +} + +fn assert_rendered_script_and_rule_recipes(rendered: &NetsukeManifest) -> Result<()> { + let rendered_target = rendered + .targets + .first() + .context("rendered script target missing")?; + anyhow::ensure!( + expect_script(&rendered_target.recipe, "rendered script target")? == "echo world", + "expected rendered script target recipe to equal 'echo world'" + ); + let rendered_rule = rendered + .rules + .first() + .context("rendered rule-reference rule missing")?; + anyhow::ensure!( + expect_list( + expect_rule_ref(&rendered_rule.recipe, "rendered rule reference")?, + "rule reference names", + ) == ["base"], + "expected rendered rule-reference names to equal ['base']" + ); + Ok(()) +} + +#[test] +fn render_manifest_renders_script_and_rule_ref_recipes() -> Result<()> { + let mut target_vars = Vars::new(); + target_vars.insert("subject".into(), ManifestValue::String("world".into())); + let target = Target { + name: StringOrList::String("scripted".into()), + recipe: Recipe::Script { + script: "echo {{ subject }}".into(), + }, + sources: StringOrList::Empty, + deps: StringOrList::Empty, + order_only_deps: StringOrList::Empty, + vars: target_vars, + phony: false, + always: false, + description: None, + }; + let rule = Rule { + name: "delegating".into(), + recipe: Recipe::Rule { + rule: StringOrList::List(vec!["{{ rule_name }}".into()]), + }, + description: None, + }; + let mut manifest_vars = Vars::new(); + manifest_vars.insert("rule_name".into(), ManifestValue::String("base".into())); + + let manifest = NetsukeManifest { + netsuke_version: Version::parse("1.0.0")?, + vars: manifest_vars, + macros: Vec::new(), + rules: vec![rule], + actions: Vec::new(), + targets: vec![target], + defaults: Vec::new(), + }; + + let rendered = render_manifest(manifest, &minijinja::Environment::new())?; + assert_rendered_script_and_rule_recipes(&rendered)?; + Ok(()) +} diff --git a/src/manifest/tests/workspace.rs b/src/manifest/tests/workspace.rs index ec915d149..8231d0dd2 100644 --- a/src/manifest/tests/workspace.rs +++ b/src/manifest/tests/workspace.rs @@ -1,6 +1,7 @@ //! Tests covering manifest workspace resolution and filesystem helpers. use super::super::{ - EnvReadError, EnvReader, from_path_with_policy_and_env, open_manifest_workspace, + EnvReadError, EnvReader, from_path_for_manifest_query, from_path_with_policy_and_env, + open_manifest_workspace, }; use crate::ast::Recipe; use crate::stdlib::NetworkPolicy; @@ -215,3 +216,89 @@ fn from_path_uses_manifest_directory_for_caches() -> AnyResult<()> { Ok(()) } + +/// Discovery queries must reject helpers that could cause side effects or +/// disclose host data before a catalogue is rendered. +#[rstest] +#[case::fetch("{{ fetch('https://example.invalid', cache=true) }}", "fetch")] +#[case::shell("{{ 'ignored' | shell('printf side-effect') }}", "shell")] +#[case::grep("{{ 'ignored' | grep('ignored') }}", "grep")] +#[case::env("{{ env('PATH') }}", "env")] +#[case::glob("{{ glob('*') }}", "glob")] +#[case::expanduser("{{ '~' | expanduser }}", "expanduser")] +#[case::contents("{{ 'secret.txt' | contents }}", "contents")] +#[case::realpath("{{ 'secret.txt' | realpath }}", "realpath")] +#[case::size("{{ 'secret.txt' | size }}", "size")] +#[case::linecount("{{ 'secret.txt' | linecount }}", "linecount")] +#[case::hash("{{ 'secret.txt' | hash }}", "hash")] +#[case::digest("{{ 'secret.txt' | digest }}", "digest")] +#[case::file_test("{{ 'secret.txt' is file }}", "file")] +#[case::which("{{ which('sh') }}", "which")] +#[case::command_available("{{ command_available('sh') }}", "command_available")] +fn manifest_query_rejects_restricted_template_helpers( + #[case] expression: &str, + #[case] helper: &str, +) -> AnyResult<()> { + let temp = tempdir().context("create manifest-query workspace")?; + let manifest_path = temp.path().join("Netsukefile"); + test_fs::write(temp.path().join("secret.txt"), QUERY_SECRET)?; + let manifest = format!( + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: discovery\n", + " description: >-\n", + " {}\n", + " command: echo discovery\n", + ), + expression, + ); + test_fs::write(&manifest_path, manifest)?; + + let error = from_path_for_manifest_query(&manifest_path, None) + .expect_err("manifest query should reject restricted template helpers"); + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains(helper)), + "query should name its rejected helper: {error:?}" + ); + ensure!( + !error + .chain() + .any(|cause| cause.to_string().contains(QUERY_SECRET)), + "a query error must not disclose local file contents: {error:?}" + ); + ensure!( + !temp.path().join(".netsuke").exists(), + "a rejected query must not create a fetch cache" + ); + Ok(()) +} +#[test] +fn manifest_query_rejects_clock_dependent_template_helpers() -> AnyResult<()> { + let temp = tempdir().context("create clock-free manifest-query workspace")?; + let manifest_path = temp.path().join("Netsukefile"); + test_fs::write( + &manifest_path, + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: discovery\n", + " description: \"{{ now() }}\"\n", + " command: echo discovery\n", + ), + )?; + + let error = from_path_for_manifest_query(&manifest_path, None) + .expect_err("manifest query should reject the clock-dependent now helper"); + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains("unknown function: now")), + "query should reject the unavailable now helper: {error:?}" + ); + Ok(()) +} + +const QUERY_SECRET: &str = "help-query-secret"; diff --git a/src/runner/dispatch.rs b/src/runner/dispatch.rs index 45451cff7..1d6799a53 100644 --- a/src/runner/dispatch.rs +++ b/src/runner/dispatch.rs @@ -1,10 +1,10 @@ //! Dispatch parsed commands and emit their successful JSON result documents. use super::{ - ExecutionContext, NinjaToolSpec, generate_ninja, graph, handle_build, handle_ninja_tool, + ExecutionContext, NinjaToolSpec, generate_ninja, graph, handle_build, handle_ninja_tool, help, process, resolve_output_path, }; -use crate::cli::{BuildArgs, Cli, Commands}; +use crate::cli::{BuildArgs, Cli, Commands, HelpArgs, HelpTopic}; use crate::localization::keys; use crate::result_json; use anyhow::{Context, Result}; @@ -15,6 +15,22 @@ pub(super) fn execute(cli: &Cli, command: Commands, context: &ExecutionContext<' Commands::Generate { output } => execute_generate(cli, output.as_ref(), context), Commands::Clean => execute_clean(cli, context), Commands::Graph(args) => graph::handle_graph(cli, &args, context.reporter), + Commands::Help(args) => execute_help(cli, &args, context.reporter), + } +} + +pub(super) fn execute_help( + cli: &Cli, + args: &HelpArgs, + reporter: &dyn crate::status::StatusReporter, +) -> Result<()> { + match args.topic.as_ref() { + None => help::render_root_help(), + Some(HelpTopic::Targets) => help::handle_help_targets(cli, reporter), + Some(HelpTopic::Build) => help::render_subcommand_help("build"), + Some(HelpTopic::Clean) => help::render_subcommand_help("clean"), + Some(HelpTopic::Graph) => help::render_subcommand_help("graph"), + Some(HelpTopic::Generate) => help::render_subcommand_help("generate"), } } diff --git a/src/runner/help.rs b/src/runner/help.rs new file mode 100644 index 000000000..e2a5b80e8 --- /dev/null +++ b/src/runner/help.rs @@ -0,0 +1,276 @@ +//! Dispatch and rendering for the `netsuke help` subcommand. +//! +//! The `help targets` topic loads, expands, renders, and validates the selected +//! manifest without invoking Ninja, then prints a deterministic catalogue of +//! actions and targets with their descriptions. The no-topic and +//! subcommand-name topics render clap's localized help text instead. + +use anyhow::{Context, Result}; +use clap::CommandFactory; +use serde::Serialize; +use std::borrow::Cow; +use unicode_width::UnicodeWidthStr; + +use crate::cli::Cli; +use crate::cli_l10n::localize_command; +use crate::json_envelope::{GeneratorInfo, SCHEMA_VERSION}; +use crate::localization::{self, keys}; +use crate::output_mode; +use crate::output_prefs::{self, OutputPrefs}; +use crate::status::{LocalizationKey, PipelineStage, StatusReporter, report_pipeline_stage}; +use crate::theme::ThemeContext; + +use super::process; +use telemetry::instrument_help_targets; + +#[path = "help_query.rs"] +mod query; +#[path = "help_telemetry.rs"] +mod telemetry; + +#[cfg(test)] +use query::build_catalogue; +use query::{HelpEntry, HelpTargetsQueryFailure, query_help_targets}; + +/// Render the `help targets` catalogue to stdout without invoking Ninja. +/// +/// The manifest is loaded, expanded, rendered, and validated through the same +/// pipeline stages as a real build, but with impure template helpers disabled. +/// The IR is built only to validate the rendered manifest, and no recipe is +/// executed and no build output created. +/// +/// # Errors +/// +/// Returns an error when the manifest cannot be resolved, loaded, rendered, or +/// validated, or when the catalogue cannot be serialized. +pub(super) fn handle_help_targets(cli: &Cli, reporter: &dyn StatusReporter) -> Result<()> { + let query = + match instrument_help_targets(|| query_help_targets(cli).map_err(anyhow::Error::new)) { + Ok(query) => query, + Err(error) => { + report_query_failure_stages(reporter, &error); + return Err(error); + } + }; + report_query_stages(reporter, &query.stages); + report_pipeline_stage(reporter, PipelineStage::IrGenerationValidation, None); + let status_key: LocalizationKey = keys::STATUS_TOOL_HELP_TARGETS.into(); + report_pipeline_stage(reporter, PipelineStage::GraphRendering, Some(status_key)); + if cli.json { + let rendered = render_json(&query.entries)?; + process::write_text_stdout(&rendered)?; + } else { + let rendered = render_text(&query.entries, resolved_prefs(cli)); + process::write_text_stdout(&rendered)?; + } + reporter.report_complete(status_key); + Ok(()) +} + +/// Emit the loading stages returned by the pure catalogue query. +fn report_query_stages(reporter: &dyn StatusReporter, stages: &[PipelineStage]) { + for stage in stages { + report_pipeline_stage(reporter, *stage, None); + } +} + +/// Emit stages accumulated before a pure catalogue query failed. +fn report_query_failure_stages(reporter: &dyn StatusReporter, error: &anyhow::Error) { + if let Some(failure) = error.downcast_ref::() { + report_query_stages(reporter, &failure.stages); + } +} + +/// Render the localized top-level long help, matching `--help`. +/// +/// # Errors +/// +/// Returns an error when the help text cannot be written to stdout. +pub(super) fn render_root_help() -> Result<()> { + let localizer = localization::localizer(); + let mut command = localize_command(Cli::command(), localizer.as_ref()); + let text = command.render_long_help().to_string(); + process::write_text_stdout(&text) +} + +/// Render the localized long help for a named subcommand. +/// +/// # Errors +/// +/// Returns an error when the subcommand is unknown or the help text cannot be +/// written to stdout. +pub(super) fn render_subcommand_help(name: &str) -> Result<()> { + let localizer = localization::localizer(); + let mut command = localize_command(Cli::command(), localizer.as_ref()); + let subcommand = command + .find_subcommand_mut(name) + .with_context(|| format!("unknown subcommand '{name}'"))?; + let text = subcommand.render_long_help().to_string(); + process::write_text_stdout(&text) +} + +/// Resolve the same output preferences the rest of the CLI uses, so emoji and +/// accessibility settings drive the catalogue's marker glyph. +fn resolved_prefs(cli: &Cli) -> OutputPrefs { + let mode = output_mode::resolve(cli.accessibility_override(), Some(cli.color)); + output_prefs::resolve_from_theme( + cli.theme_preference(), + ThemeContext::new(None, Some(cli.color), mode), + ) +} + +/// Render the text catalogue: an "Actions:" section followed by a "Targets:" +/// section, with aligned name and description columns and a localized default +/// marker. A missing description leaves the entry visible without a description +/// column. Empty sections are omitted. +fn render_text(entries: &[HelpEntry], prefs: OutputPrefs) -> String { + let actions: Vec<&HelpEntry> = entries.iter().filter(|entry| entry.is_action).collect(); + let targets: Vec<&HelpEntry> = entries.iter().filter(|entry| !entry.is_action).collect(); + let mut out = String::new(); + render_section(&mut out, &actions, keys::CLI_HELP_ACTIONS_HEADING, prefs); + if !actions.is_empty() && !targets.is_empty() { + out.push('\n'); + } + render_section(&mut out, &targets, keys::CLI_HELP_TARGETS_HEADING, prefs); + out +} + +fn render_section( + out: &mut String, + entries: &[&HelpEntry], + heading_key: &'static str, + prefs: OutputPrefs, +) { + if entries.is_empty() { + return; + } + out.push_str(&localization::message(heading_key).to_string()); + out.push('\n'); + let display_names: Vec> = entries + .iter() + .map(|entry| terminal_safe(&entry.name)) + .collect(); + let width = display_names + .iter() + .map(|name| UnicodeWidthStr::width(name.as_ref())) + .max() + .unwrap_or(0); + let marker = default_marker(prefs); + for (entry, name) in entries.iter().zip(display_names) { + let name_width = UnicodeWidthStr::width(name.as_ref()); + out.push_str(" "); + out.push_str(&name); + out.push_str(&" ".repeat(width.saturating_sub(name_width))); + if let Some(description) = entry.description.as_deref() { + out.push_str(" "); + out.push_str(&terminal_safe(description)); + } + if entry.is_default { + out.push(' '); + out.push_str(&marker); + } + out.push('\n'); + } +} + +/// Render manifest-controlled text safely for a terminal. +/// +/// Catalogue names and descriptions can contain arbitrary rendered template +/// values. Keep printable Unicode intact, while making every control +/// character visible so a manifest cannot inject terminal controls or rows. +fn terminal_safe(input: &str) -> Cow<'_, str> { + if !input.chars().any(is_terminal_control) { + return Cow::Borrowed(input); + } + + let mut escaped = String::with_capacity(input.len()); + for character in input.chars() { + match character { + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + control if is_terminal_control(control) => escaped.extend(control.escape_unicode()), + printable => escaped.push(printable), + } + } + Cow::Owned(escaped) +} + +/// Return whether a character can control terminal display or reading order. +const fn is_terminal_control(character: char) -> bool { + matches!( + character, + '\0'..='\u{001F}' + | '\u{007F}'..='\u{009F}' + | '\u{061C}' + | '\u{200E}' + | '\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2066}'..='\u{2069}' + ) +} + +/// The localized default marker, pairing a theme glyph with a translated label +/// so the meaning never depends on the glyph alone. +fn default_marker(prefs: OutputPrefs) -> String { + let glyph = if prefs.emoji_allowed() { "★" } else { "*" }; + let label = localization::message(keys::CLI_HELP_DEFAULT_MARKER).to_string(); + format!("[{glyph} {label}]") +} + +/// Versioned JSON catalogue document, mirroring `crate::result_json`'s +/// envelope shape while carrying the listing payload instead of free text. +#[derive(Debug, Serialize)] +struct HelpTargetsDocument<'a> { + schema_version: u32, + generator: GeneratorInfo, + result: HelpTargetsResult<'a>, +} + +#[derive(Debug, Serialize)] +struct HelpTargetsResult<'a> { + command: &'static str, + actions: Vec>, + targets: Vec>, +} + +#[derive(Debug, Serialize)] +struct HelpEntryJson<'a> { + name: &'a str, + description: Option<&'a str>, + default: bool, +} + +fn render_json(entries: &[HelpEntry]) -> Result { + serde_json::to_string_pretty(&HelpTargetsDocument { + schema_version: SCHEMA_VERSION, + generator: GeneratorInfo::current(), + result: HelpTargetsResult { + command: "help-targets", + actions: json_entries(entries.iter().filter(|entry| entry.is_action)), + targets: json_entries(entries.iter().filter(|entry| !entry.is_action)), + }, + }) + .context("serialize help targets catalogue") +} + +fn json_entries<'entry>( + entries: impl Iterator, +) -> Vec> { + entries + .into_iter() + .map(|entry| HelpEntryJson { + name: entry.name.as_str(), + description: entry.description.as_deref(), + default: entry.is_default, + }) + .collect() +} + +#[cfg(test)] +#[path = "help_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "help_telemetry_tests.rs"] +mod telemetry_tests; diff --git a/src/runner/help_query.rs b/src/runner/help_query.rs new file mode 100644 index 000000000..3f2a4721c --- /dev/null +++ b/src/runner/help_query.rs @@ -0,0 +1,173 @@ +//! Pure manifest loading and catalogue construction for `netsuke help targets`. + +use anyhow::{Context, Result, bail, ensure}; +use std::{collections::HashSet, error::Error as StdError, fmt, sync::Arc}; + +use crate::ast::{NetsukeManifest, Target}; +use crate::cli::Cli; +use crate::ir::BuildGraph; +use crate::localization::{self, keys}; +use crate::status::PipelineStage; + +use super::super::RunnerError; +use super::super::path_helpers::{ensure_manifest_exists, resolve_manifest_path}; +use super::terminal_safe; + +/// One catalogue row: a single resolved target name with its metadata. +pub(super) struct HelpEntry { + pub(super) name: String, + pub(super) description: Option>, + pub(super) is_action: bool, + pub(super) is_default: bool, +} + +/// The pure result of loading, validating, and cataloguing a help manifest. +pub(super) struct HelpTargetsQuery { + pub(super) entries: Vec, + pub(super) stages: Vec, +} + +/// A query failure with stages for the command boundary to report. +#[derive(Debug)] +pub(super) struct HelpTargetsQueryFailure { + pub(super) error: anyhow::Error, + pub(super) stages: Vec, +} + +impl fmt::Display for HelpTargetsQueryFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.error.fmt(formatter) + } +} + +impl StdError for HelpTargetsQueryFailure { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.error.as_ref()) + } +} + +/// Load, validate, and catalogue manifest discovery metadata without effects. +pub(super) fn query_help_targets( + cli: &Cli, +) -> std::result::Result { + let mut stages = Vec::new(); + let result = query_entries(cli, &mut stages); + match result { + Ok(entries) => Ok(HelpTargetsQuery { entries, stages }), + Err(error) => Err(HelpTargetsQueryFailure { error, stages }), + } +} + +fn query_entries(cli: &Cli, stages: &mut Vec) -> Result> { + let manifest_path = resolve_manifest_path(cli)?; + if let Err(error) = ensure_manifest_exists(cli, &manifest_path) { + record_missing_manifest_stage(&error, stages); + return Err(error); + } + let manifest = load_manifest_for_query(&manifest_path, stages)?; + reject_terminal_controls_in_target_names(&manifest)?; + + // Building the IR validates the rendered manifest (duplicate outputs, + // missing rules, cycles) exactly as a real build would, without generating + // Ninja or executing any recipe. + BuildGraph::from_manifest(&manifest) + .context(localization::message(keys::RUNNER_CONTEXT_BUILD_GRAPH))?; + + let entries = build_catalogue(&manifest); + validate_defaults(&manifest.defaults, &entries)?; + Ok(entries) +} + +/// Reject control-bearing target names before IR errors can interpolate them. +/// +/// The help query returns validation diagnostics directly to the terminal. +/// Catalogue rendering escapes controls, but graph validation happens first and +/// can include a target name in its localized errors. Reject such names before +/// that boundary rather than allowing them to influence a diagnostic. +fn reject_terminal_controls_in_target_names(manifest: &NetsukeManifest) -> Result<()> { + let targets = manifest.actions.iter().chain(&manifest.targets); + if targets + .flat_map(|target| target.name.to_string_vec()) + .any(|name| name.chars().any(super::is_terminal_control)) + { + bail!("help targets cannot validate a target name with terminal control characters"); + } + Ok(()) +} + +fn record_missing_manifest_stage(error: &anyhow::Error, stages: &mut Vec) { + if error + .downcast_ref::() + .is_some_and(|runner_error| matches!(runner_error, RunnerError::ManifestNotFound { .. })) + { + stages.push(PipelineStage::ManifestIngestion); + } +} + +/// Flatten the rendered manifest into a deterministic catalogue in declaration +/// order: actions first, then targets. A multi-name entry yields one row per +/// name, each carrying the same description and default status. +pub(super) fn build_catalogue(manifest: &NetsukeManifest) -> Vec { + let mut entries = Vec::new(); + let defaults: HashSet<&str> = manifest.defaults.iter().map(String::as_str).collect(); + for target in &manifest.actions { + append_target_entries(&mut entries, target, true, &defaults); + } + for target in &manifest.targets { + append_target_entries(&mut entries, target, false, &defaults); + } + entries +} + +fn validate_defaults(defaults: &[String], entries: &[HelpEntry]) -> Result<()> { + let names: HashSet<&str> = entries.iter().map(|entry| entry.name.as_str()).collect(); + for default in defaults { + let safe_default = terminal_safe(default); + ensure!( + names.contains(default.as_str()), + localization::message(keys::RUNNER_MANIFEST_DEFAULT_NOT_DECLARED) + .with_arg("default", safe_default.as_ref()) + ); + } + Ok(()) +} + +fn append_target_entries( + entries: &mut Vec, + target: &Target, + is_action: bool, + defaults: &HashSet<&str>, +) { + let description = target.description.as_deref().map(Arc::::from); + for name in target.name.to_string_vec() { + entries.push(HelpEntry { + is_default: defaults.contains(name.as_str()), + name, + description: description.clone(), + is_action, + }); + } +} + +/// Load a manifest for a no-side-effect metadata query and retain its stages. +fn load_manifest_for_query( + manifest_path: &camino::Utf8PathBuf, + stages: &mut Vec, +) -> Result { + let mut on_stage = |stage| stages.push(pipeline_stage(stage)); + crate::manifest::from_path_for_manifest_query(manifest_path.as_std_path(), Some(&mut on_stage)) + .with_context(|| { + localization::message(keys::RUNNER_CONTEXT_LOAD_MANIFEST) + .with_arg("path", manifest_path.as_str()) + }) +} + +/// Map manifest-loading events to data that the command boundary can report. +const fn pipeline_stage(stage: crate::manifest::ManifestLoadStage) -> PipelineStage { + match stage { + crate::manifest::ManifestLoadStage::ManifestIngestion => PipelineStage::ManifestIngestion, + crate::manifest::ManifestLoadStage::InitialYamlParsing => PipelineStage::InitialYamlParsing, + crate::manifest::ManifestLoadStage::TemplateExpansion => PipelineStage::TemplateExpansion, + crate::manifest::ManifestLoadStage::FinalRendering => PipelineStage::FinalRendering, + } +} diff --git a/src/runner/help_telemetry.rs b/src/runner/help_telemetry.rs new file mode 100644 index 000000000..aa1bee965 --- /dev/null +++ b/src/runner/help_telemetry.rs @@ -0,0 +1,74 @@ +//! Bounded observability for the `netsuke help targets` query boundary. +//! +//! The catalogue consumes manifest-controlled names and descriptions, so this +//! module records only fixed operation outcomes and error categories. + +use anyhow::Result; +use metrics::{counter, describe_counter, describe_histogram, histogram}; +use std::{sync::Once, time::Instant}; +use tracing::{field, info}; + +use super::super::RunnerError; + +pub(super) const HELP_TARGETS_TOTAL: &str = "netsuke_runner_help_targets_total"; +pub(super) const HELP_TARGETS_DURATION: &str = "netsuke_runner_help_targets_duration_seconds"; + +/// Record bounded telemetry around the complete `help targets` query. +pub(super) fn instrument_help_targets(query: impl FnOnce() -> Result) -> Result { + describe_help_targets_metrics(); + let span = tracing::info_span!( + "runner.help_targets", + outcome = field::Empty, + error_category = field::Empty, + ); + let _guard = span.enter(); + let started = Instant::now(); + let result = query(); + let (outcome, error_category) = match &result { + Ok(_) => ("success", "none"), + Err(error) => ("error", help_targets_error_category(error)), + }; + span.record("outcome", outcome); + span.record("error_category", error_category); + info!(outcome, error_category, "Completed help targets query"); + counter!( + HELP_TARGETS_TOTAL, + "outcome" => outcome, + "error_category" => error_category, + ) + .increment(1); + histogram!( + HELP_TARGETS_DURATION, + "outcome" => outcome, + "error_category" => error_category, + ) + .record(started.elapsed()); + result +} + +/// Classify catalogue failures without exposing manifest-controlled detail. +fn help_targets_error_category(error: &anyhow::Error) -> &'static str { + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + "manifest_not_found" + } else { + "other" + } +} + +/// Describe the stable, bounded help-targets metrics once per process. +fn describe_help_targets_metrics() { + static DESCRIBE: Once = Once::new(); + DESCRIBE.call_once(|| { + describe_counter!( + HELP_TARGETS_TOTAL, + "Counts help-target catalogue queries by bounded outcome and error category." + ); + describe_histogram!( + HELP_TARGETS_DURATION, + "Measures complete help-target catalogue query duration in seconds by bounded outcome and error category." + ); + }); +} diff --git a/src/runner/help_telemetry_tests.rs b/src/runner/help_telemetry_tests.rs new file mode 100644 index 000000000..2b1d3efde --- /dev/null +++ b/src/runner/help_telemetry_tests.rs @@ -0,0 +1,240 @@ +//! Telemetry coverage for the `netsuke help targets` orchestration boundary. + +use super::telemetry::{HELP_TARGETS_DURATION, HELP_TARGETS_TOTAL}; +use super::*; +use crate::cli::Cli; +use crate::localization::set_localizer_for_tests; +use crate::status::SilentReporter; +use crate::test_tracing_capture::with_test_subscriber; +use anyhow::{Context, Result, ensure}; +use camino::Utf8Path; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use metrics_util::MetricKind; +use metrics_util::debugging::{DebugValue, DebuggingRecorder}; +use std::sync::Arc; +use tempfile::TempDir; +use test_support::localizer_test_lock; +use tracing_subscriber::filter::LevelFilter; + +const MANIFEST: &str = r#"netsuke_version: "1.0.0" +actions: + - name: inspect + command: "true" +targets: [] +"#; + +const INVALID_MANIFEST: &str = "targets:\n\t- name: broken\n"; + +/// One drained metrics snapshot; the debugging snapshotter empties histogram +/// samples on read, so each test collects it exactly once. +type Snapshot = Vec<( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, +)>; + +/// The fixed result labels expected for one help-targets telemetry scenario. +struct ExpectedHelpTargetsTelemetry { + outcome: &'static str, + error_category: &'static str, + succeeds: bool, +} + +/// Run an operation under a local metrics recorder and return its result and +/// metrics snapshot without installing a process-wide recorder. +fn recorded(operation: impl FnOnce() -> T) -> (T, Snapshot) { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let result = metrics::with_local_recorder(&recorder, operation); + (result, snapshotter.snapshot().into_vec()) +} + +/// Build a CLI pointing at a capability-written manifest fixture. +fn help_targets_fixture() -> Result<(TempDir, Cli)> { + help_targets_fixture_with_manifest(MANIFEST) +} + +/// Build a CLI pointing at a capability-written manifest fixture with `manifest`. +fn help_targets_fixture_with_manifest(manifest: &str) -> Result<(TempDir, Cli)> { + let temp = TempDir::new().context("create help telemetry workspace")?; + let root = Utf8Path::from_path(temp.path()).context("telemetry workspace path is UTF-8")?; + let workspace = Dir::open_ambient_dir(root, ambient_authority()) + .context("open help telemetry workspace")?; + workspace + .write("Netsukefile", manifest) + .context("write help telemetry manifest")?; + let manifest_path = root.join("Netsukefile").into_std_path_buf(); + Ok(( + temp, + Cli { + file: manifest_path, + ..Cli::default() + }, + )) +} + +/// Find one metric with the exact bounded outcome and error-category labels. +fn metric_value<'snapshot>( + snapshot: &'snapshot Snapshot, + kind: MetricKind, + name: &str, + expected: &ExpectedHelpTargetsTelemetry, +) -> Option<&'snapshot DebugValue> { + snapshot + .iter() + .find_map(|(key, _unit, _description, value)| { + if key.kind() != kind || key.key().name() != name { + return None; + } + let labels: Vec<(&str, &str)> = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect(); + let matches = labels.len() == 2 + && labels.contains(&("outcome", expected.outcome)) + && labels.contains(&("error_category", expected.error_category)); + matches.then_some(value) + }) +} + +#[test] +fn metric_value_rejects_metrics_with_extra_labels() { + let expected = ExpectedHelpTargetsTelemetry { + outcome: "success", + error_category: "none", + succeeds: true, + }; + let snapshot = vec![( + metrics_util::CompositeKey::new( + MetricKind::Counter, + metrics::Key::from_parts( + HELP_TARGETS_TOTAL, + vec![ + metrics::Label::new("outcome", "success"), + metrics::Label::new("error_category", "none"), + metrics::Label::new("operation", "query"), + ], + ), + ), + None, + None, + DebugValue::Counter(1), + )]; + + assert!( + metric_value( + &snapshot, + MetricKind::Counter, + HELP_TARGETS_TOTAL, + &expected + ) + .is_none() + ); +} + +/// Assert the complete bounded telemetry contract for one help-targets query. +fn assert_help_targets_telemetry( + cli: &Cli, + expected: &ExpectedHelpTargetsTelemetry, + scenario: &str, +) -> Result<()> { + let _lock = localizer_test_lock().map_err(|error| anyhow::anyhow!("{error}"))?; + let _guard = set_localizer_for_tests(Arc::from(crate::cli_localization::build_localizer( + Some("en-US"), + ))); + let ((result, events), snapshot) = recorded(|| { + with_test_subscriber(LevelFilter::INFO, |captured| { + let result = handle_help_targets(cli, &SilentReporter); + (result, captured.snapshot()) + }) + }); + + match (expected.succeeds, result) { + (true, Ok(())) | (false, Err(_)) => {} + (true, Err(error)) => { + anyhow::bail!("{scenario} should succeed: {error:?}"); + } + (false, Ok(())) => { + anyhow::bail!("{scenario} should fail"); + } + } + + let counter = metric_value(&snapshot, MetricKind::Counter, HELP_TARGETS_TOTAL, expected); + ensure!( + matches!(counter, Some(DebugValue::Counter(1))), + "{scenario} should record one counter for outcome={:?}, error_category={:?}: {snapshot:?}", + expected.outcome, + expected.error_category, + ); + + let duration = metric_value( + &snapshot, + MetricKind::Histogram, + HELP_TARGETS_DURATION, + expected, + ); + ensure!( + matches!(duration, Some(DebugValue::Histogram(samples)) if samples.len() == 1), + "{scenario} should record one duration sample for outcome={:?}, error_category={:?}: {snapshot:?}", + expected.outcome, + expected.error_category, + ); + ensure!( + events + .iter() + .any(|event| event.contains("Completed help targets query") + && event.contains(&format!("outcome=\"{}\"", expected.outcome)) + && event.contains(&format!("error_category=\"{}\"", expected.error_category))), + "{scenario} should emit a completion event for outcome={:?}, error_category={:?}: {events:?}; metrics: {snapshot:?}", + expected.outcome, + expected.error_category, + ); + Ok(()) +} + +#[test] +fn help_targets_records_bounded_success_telemetry() -> Result<()> { + let (_temp, cli) = help_targets_fixture()?; + assert_help_targets_telemetry( + &cli, + &ExpectedHelpTargetsTelemetry { + outcome: "success", + error_category: "none", + succeeds: true, + }, + "successful help targets", + ) +} + +#[test] +fn help_targets_records_manifest_failure_telemetry() -> Result<()> { + let cli = Cli { + file: "missing-help-telemetry-manifest.yml".into(), + ..Cli::default() + }; + assert_help_targets_telemetry( + &cli, + &ExpectedHelpTargetsTelemetry { + outcome: "error", + error_category: "manifest_not_found", + succeeds: false, + }, + "missing manifest help targets", + ) +} + +#[test] +fn help_targets_records_other_failure_telemetry() -> Result<()> { + let (_temp, cli) = help_targets_fixture_with_manifest(INVALID_MANIFEST)?; + assert_help_targets_telemetry( + &cli, + &ExpectedHelpTargetsTelemetry { + outcome: "error", + error_category: "other", + succeeds: false, + }, + "invalid manifest help targets", + ) +} diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs new file mode 100644 index 000000000..db8c2db77 --- /dev/null +++ b/src/runner/help_tests.rs @@ -0,0 +1,350 @@ +//! Unit snapshot tests for the `netsuke help targets` renderer. +//! +//! The fixture manifest mirrors the issue's suggested shape: actions and +//! targets with descriptions, manifest defaults, and one entry whose +//! description is missing so the empty-column representation is pinned. + +use super::*; +use crate::ast::{NetsukeManifest, Target}; +use crate::cli_localization::build_localizer; +use crate::localization::set_localizer_for_tests; +use crate::manifest; +use crate::snapshot_test_support::{snapshot_settings, theme_prefs}; +use crate::theme::ThemePreference; +use anyhow::{Context, Result}; +use insta::assert_snapshot; +use proptest::prelude::*; +use semver::Version; +use std::sync::{Arc, Mutex, TryLockError, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; +use test_support::fluent::normalize_fluent_isolates; +use test_support::{localizer::LOCALIZER_TEST_LOCK, localizer_test_lock}; + +/// Parse the fixed fixture manifest used by the catalogue snapshots. +fn fixture_manifest() -> Result { + let yaml = r#"netsuke_version: "1.0.0" +actions: + - name: lint + description: Run rustdoc, Clippy, and Whitaker + command: cargo clippy --all-targets --all-features -- -D warnings + - name: test + description: Run unit, behavioural, UI, and documentation tests + command: cargo test + - name: undocumented + command: echo hi +targets: + - name: target/release/catnap + description: Build the optimized release binary + command: cargo build --release + - name: plain + command: echo plain +defaults: + - lint + - test +"#; + manifest::from_str(yaml) +} + +/// Acquire the localizer test lock, recovering from poisoning the way the +/// test-support fixtures do, so one failing snapshot cannot cascade into the +/// tests that follow it. +fn localizer_lock() -> std::sync::MutexGuard<'static, ()> { + localizer_test_lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Probe the localizer lock until the contention assertion can report a result. +fn localizer_lock_is_available_before_timeout() -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if localizer_lock_is_available() { + return true; + } + thread::sleep(Duration::from_millis(10)); + } + localizer_lock_is_available() +} + +/// Return whether the localizer test lock can be acquired without blocking. +fn localizer_lock_is_available() -> bool { + let lock = LOCALIZER_TEST_LOCK.get_or_init(|| Mutex::new(())); + match lock.try_lock() { + Ok(guard) => { + drop(guard); + true + } + Err(TryLockError::Poisoned(error)) => { + drop(error.into_inner()); + true + } + Err(TryLockError::WouldBlock) => false, + } +} + +/// Send the bounded contention result without waiting for the test receiver. +fn send_localizer_lock_result(sender: &mpsc::SyncSender) -> Result<()> { + sender + .try_send(localizer_lock_is_available_before_timeout()) + .map_err(|error| { + anyhow::anyhow!("localizer contender could not report its result: {error}") + }) +} + +/// Run one catalogue snapshot: install the locale, render through the closure, +/// and bind the snapshot assertion. +/// +/// The assertion name is passed at runtime, so all four snapshot tests share +/// this setup while keeping their distinct snapshot files. +fn catalogue_snapshot( + locale: &str, + snapshot_name: &str, + render: impl FnOnce(&NetsukeManifest) -> Result, +) -> Result<()> { + let manifest = fixture_manifest()?; + let rendered = render_catalogue_with_locale(locale, &manifest, render)?; + snapshot_settings("help_targets").bind(|| { + assert_snapshot!(snapshot_name, rendered); + }); + Ok(()) +} + +/// Render a catalogue while holding the localizer lock only for its global +/// localization dependency. +fn render_catalogue_with_locale( + locale: &str, + manifest: &NetsukeManifest, + render: impl FnOnce(&NetsukeManifest) -> Result, +) -> Result { + let _lock = localizer_lock(); + let _guard = set_localizer_for_tests(Arc::from(build_localizer(Some(locale)))); + render(manifest) +} + +#[test] +fn catalogue_rendering_releases_localizer_lock_before_snapshot_work() -> Result<()> { + let manifest = fixture_manifest()?; + let rendered = render_catalogue_with_locale("en-US", &manifest, |parsed_manifest| { + render_json(&build_catalogue(parsed_manifest)) + })?; + let (acquired, confirmed) = mpsc::sync_channel(1); + thread::scope(|scope| -> Result<()> { + let contender = scope.spawn(|| send_localizer_lock_result(&acquired)); + let contender_acquired = confirmed + .recv_timeout(Duration::from_secs(5)) + .context("localizer contender should acquire the lock before snapshot work")?; + contender + .join() + .map_err(|_| anyhow::anyhow!("localizer contender should complete"))??; + anyhow::ensure!( + contender_acquired, + "localizer contender should acquire the lock before snapshot work" + ); + Ok(()) + })?; + anyhow::ensure!( + rendered.contains("\"command\": \"help-targets\""), + "rendered catalogue should remain available after localizer contention" + ); + Ok(()) +} + +#[test] +fn text_catalogue_snapshot() -> Result<()> { + catalogue_snapshot("en-US", "text_catalogue", |manifest| { + Ok(normalize_fluent_isolates(&render_text( + &build_catalogue(manifest), + theme_prefs(ThemePreference::Unicode), + ))) + }) +} + +#[test] +fn accessible_catalogue_snapshot() -> Result<()> { + catalogue_snapshot("en-US", "accessible_catalogue", |manifest| { + Ok(normalize_fluent_isolates(&render_text( + &build_catalogue(manifest), + theme_prefs(ThemePreference::Ascii), + ))) + }) +} + +#[test] +fn localized_catalogue_snapshot() -> Result<()> { + catalogue_snapshot("es-ES", "localized_catalogue_es_es", |manifest| { + Ok(normalize_fluent_isolates(&render_text( + &build_catalogue(manifest), + theme_prefs(ThemePreference::Unicode), + ))) + }) +} + +#[test] +fn json_catalogue_snapshot() -> Result<()> { + catalogue_snapshot("en-US", "json_catalogue", |manifest| { + render_json(&build_catalogue(manifest)) + }) +} + +#[test] +fn text_catalogue_escapes_terminal_control_characters() -> Result<()> { + let mut manifest = fixture_manifest()?; + let action = manifest + .actions + .first_mut() + .context("help target fixture should contain an action")?; + action.name = crate::ast::StringOrList::String( + "line\nnext\t\u{001B}[31mred\u{009B}m\u{202E}reordered".to_owned(), + ); + action.description = Some("description\r\nwith\tcontrols\u{0007}\u{202E}".to_owned()); + + let output = render_text( + &build_catalogue(&manifest), + theme_prefs(ThemePreference::Unicode), + ); + + anyhow::ensure!( + output.contains("line\\nnext\\t\\u{1b}[31mred\\u{9b}m\\u{202e}reordered"), + "name controls should be visible escapes: {output:?}" + ); + anyhow::ensure!( + output.contains("description\\r\\nwith\\tcontrols\\u{7}\\u{202e}"), + "description controls should be visible escapes: {output:?}" + ); + anyhow::ensure!( + !output.contains('\r') + && !output.contains('\u{001B}') + && !output.contains('\u{009B}') + && !output.contains('\u{202E}'), + "text output must not contain terminal control characters: {output:?}" + ); + anyhow::ensure!( + output.lines().count() == 8, + "escaped newlines must not create additional catalogue rows: {output:?}" + ); + Ok(()) +} + +#[test] +fn text_catalogue_escapes_cc_range_boundaries() -> Result<()> { + let mut manifest = fixture_manifest()?; + let action = manifest + .actions + .first_mut() + .context("help target fixture should contain an action")?; + action.name = crate::ast::StringOrList::String( + "start\0unit\u{001F}delete\u{007F}application\u{009F}end".to_owned(), + ); + + let output = render_text( + &build_catalogue(&manifest), + theme_prefs(ThemePreference::Unicode), + ); + + for escaped in ["\\u{0}", "\\u{1f}", "\\u{7f}", "\\u{9f}"] { + anyhow::ensure!( + output.contains(escaped), + "text catalogue should escape Cc boundary {escaped}: {output:?}" + ); + } + for control in ['\0', '\u{001F}', '\u{007F}', '\u{009F}'] { + anyhow::ensure!( + !output.contains(control), + "text catalogue must not contain Cc boundary {control:?}: {output:?}" + ); + } + Ok(()) +} + +/// Generate target metadata with at least one name, allowing actions and +/// targets to exercise scalar/list flattening through the same catalogue path. +fn target_metadata() -> impl Strategy, Option)> { + ( + proptest::collection::vec("[a-z]{1,8}", 1..4), + prop_oneof![Just(None), "[A-Za-z ]{0,20}".prop_map(Some)], + ) +} + +/// Build a simple target because catalogue construction depends only on names, +/// descriptions, and action categorization. +fn catalogue_target(names: Vec, description: Option, phony: bool) -> Target { + Target { + name: crate::ast::StringOrList::List(names), + recipe: crate::ast::Recipe::Command { + command: crate::ast::StringOrList::String("true".to_owned()), + }, + sources: crate::ast::StringOrList::Empty, + deps: crate::ast::StringOrList::Empty, + order_only_deps: crate::ast::StringOrList::Empty, + vars: crate::ast::Vars::default(), + phony, + always: false, + description, + } +} + +proptest! { + /// Catalogue construction preserves declaration order, expands every name, + /// retains metadata, and marks each alias selected by `defaults`. + #[test] + fn catalogue_preserves_order_names_metadata_and_defaults( + actions in proptest::collection::vec(target_metadata(), 0..5), + targets in proptest::collection::vec(target_metadata(), 0..5), + default_flags in proptest::collection::vec(any::(), 0..64), + ) { + let declared_names: Vec = actions + .iter() + .chain(&targets) + .flat_map(|(names, _)| names.iter().cloned()) + .collect(); + let defaults = if declared_names.is_empty() { + Vec::new() + } else { + declared_names + .iter() + .zip(default_flags) + .filter(|(_, is_default)| *is_default) + .map(|(name, _)| name.clone()) + .collect() + }; + let manifest = NetsukeManifest { + netsuke_version: Version::new(1, 0, 0), + vars: crate::ast::Vars::default(), + macros: Vec::new(), + rules: Vec::new(), + actions: actions + .iter() + .cloned() + .map(|(names, description)| catalogue_target(names, description, true)) + .collect(), + targets: targets + .iter() + .cloned() + .map(|(names, description)| catalogue_target(names, description, false)) + .collect(), + defaults: defaults.clone(), + }; + let default_names = &defaults; + let expected: Vec<(String, Option, bool, bool)> = actions + .iter() + .map(|(names, description)| (names, description, true)) + .chain(targets.iter().map(|(names, description)| (names, description, false))) + .flat_map(|(names, description, is_action)| { + names.iter().cloned().map(move |name| { + let is_default = default_names.contains(&name); + (name, description.clone(), is_action, is_default) + }) + }) + .collect(); + let actual: Vec<(String, Option, bool, bool)> = build_catalogue(&manifest) + .into_iter() + .map(|entry| ( + entry.name, + entry.description.as_deref().map(str::to_owned), + entry.is_action, + entry.is_default, + )) + .collect(); + + prop_assert_eq!(actual, expected); + } +} diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 085df3233..967d831c3 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -1,10 +1,7 @@ -//! CLI execution and command dispatch logic. +//! CLI execution and command dispatch. //! -//! This module keeps `main` minimal by providing a single entry point that -//! handles command execution. It now delegates build requests to the Ninja -//! subprocess, streaming its output back to the user. The executable defaults -//! to `ninja` and may be overridden with `NETSUKE_NINJA` for systems that use a -//! different binary name or require a full path. +//! Provides execution orchestration; build work streams through Ninja (default +//! `ninja`, overridable with `NETSUKE_NINJA`). mod dispatch; mod error; @@ -20,6 +17,7 @@ use crate::status::{LocalizationKey, PipelineStage, StatusReporter, report_pipel use crate::{ir::BuildGraph, manifest, ninja_gen}; use anyhow::{Context, Result}; use camino::Utf8PathBuf; +use std::borrow::Cow; use std::io::{self, IsTerminal}; use std::path::Path; use tracing::{debug, info}; @@ -40,6 +38,7 @@ pub const NINJA_PROGRAM: &str = "ninja"; pub const NINJA_ENV: &str = "NETSUKE_NINJA"; mod graph; +mod help; mod path_helpers; mod process; #[cfg(doctest)] @@ -55,12 +54,10 @@ use path_helpers::{ensure_manifest_exists_or_error, resolve_manifest_path, resol struct ExecutionContext<'a> { reporter: &'a dyn StatusReporter, progress_enabled: bool, - /// Resolved Ninja executable, passed unchanged to [`std::process::Command::new`]. + /// Resolved Ninja executable passed unchanged to [`std::process::Command::new`]. /// - /// UTF-8 conversion is confined to `NETSUKE_NINJA` resolution - /// (`process::resolve_ninja_program`); this field must stay a native - /// [`Path`] and must not be converted to a `String`, so that non-UTF-8 - /// executable paths on platforms that allow them remain usable. + /// Keep a native [`Path`]: only `NETSUKE_NINJA` resolution performs UTF-8 + /// conversion, preserving valid non-UTF-8 executable paths. ninja_program: &'a Path, } @@ -85,9 +82,7 @@ impl NinjaContent { } } -/// Target list passed through to Ninja. -/// An empty slice means “use the defaults” emitted by IR generation -/// (default targets). +/// Target list passed through to Ninja; an empty slice uses IR defaults. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BuildTargets<'a>(&'a [String]); impl<'a> BuildTargets<'a> { @@ -119,19 +114,25 @@ impl Default for BuildTargets<'_> { /// /// Returns an error if manifest generation or the Ninja process fails. pub fn run(cli: &Cli, prefs: OutputPrefs) -> Result<()> { - let program = process::resolve_ninja_program(); - run_with_ninja_program(cli, prefs, &program) + run_with_ninja_program_resolver(cli, prefs, None, process::resolve_ninja_program) } -/// Execute parsed commands with an explicitly selected Ninja executable. -/// -/// This is the injected process-program boundary used by adapters and tests -/// that must not mutate the process environment to select Ninja. +/// Execute parsed commands with a Ninja executable selected by the caller. /// /// # Errors /// /// Returns an error if manifest generation or the selected Ninja process fails. pub fn run_with_ninja_program(cli: &Cli, prefs: OutputPrefs, program: &Path) -> Result<()> { + run_with_ninja_program_resolver(cli, prefs, Some(program), || program.to_path_buf()) +} + +/// Dispatch a command after resolving Ninja only for commands that require it. +fn run_with_ninja_program_resolver( + cli: &Cli, + prefs: OutputPrefs, + configured_program: Option<&Path>, + resolve_program: impl FnOnce() -> std::path::PathBuf, +) -> Result<()> { let mode = output_mode::resolve(cli.accessibility_override(), Some(cli.color)); let progress_enabled = cli.progress_enabled() && !cli.json; let stdout_is_tty = std::io::stdout().is_terminal(); @@ -146,10 +147,15 @@ pub fn run_with_ninja_program(cli: &Cli, prefs: OutputPrefs, program: &Path) -> let command = cli.command.clone().unwrap_or(Commands::Build(BuildArgs { targets: Vec::new(), })); + if let Commands::Help(args) = &command { + return dispatch::execute_help(cli, args, reporter.as_ref()); + } + let ninja_program = + configured_program.map_or_else(|| Cow::Owned(resolve_program()), Cow::Borrowed); let context = ExecutionContext { reporter: reporter.as_ref(), progress_enabled, - ninja_program: program, + ninja_program: ninja_program.as_ref(), }; dispatch::execute(cli, command, &context) } @@ -263,11 +269,7 @@ struct NinjaToolSpec<'a> { key: LocalizationKey, } -/// Execute a Ninja tool (e.g., `ninja -t clean`) using a temporary build file. -/// -/// Generates the Ninja manifest to a temporary file, then invokes Ninja with -/// `-t ` while preserving the CLI settings (working directory and job -/// count). +/// Execute a Ninja tool using a temporary build file and CLI settings. /// /// # Errors /// @@ -316,10 +318,7 @@ fn handle_ninja_tool( Ok(()) } -/// Generate the Ninja manifest string from the Netsuke manifest referenced by `cli`. -/// -/// Reports manifest and graph/synthesis pipeline stages via the provided -/// [`StatusReporter`]. +/// Generate Ninja from the manifest referenced by `cli` and report pipeline stages. /// /// # Errors /// diff --git a/src/runner/path_helpers.rs b/src/runner/path_helpers.rs index 8c74b4b94..f36831c79 100644 --- a/src/runner/path_helpers.rs +++ b/src/runner/path_helpers.rs @@ -6,9 +6,11 @@ use crate::cli::Cli; use crate::localization::{self, keys}; use crate::status::{PipelineStage, StatusReporter, report_pipeline_stage}; -use anyhow::{Result, anyhow}; -use camino::Utf8PathBuf; +use anyhow::{Context, Result, anyhow}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; use std::borrow::Cow; +use std::io::{self, ErrorKind}; use std::path::Path; use super::RunnerError; @@ -78,11 +80,32 @@ pub(super) fn ensure_manifest_exists_or_error( reporter: &dyn StatusReporter, manifest_path: &Utf8PathBuf, ) -> Result<()> { - if manifest_path.as_std_path().exists() { - return Ok(()); + let result = ensure_manifest_exists(cli, manifest_path); + if result + .as_ref() + .err() + .and_then(|error| error.downcast_ref::()) + .is_some_and(|error| matches!(error, RunnerError::ManifestNotFound { .. })) + { + report_pipeline_stage(reporter, PipelineStage::ManifestIngestion, None); + } + result +} + +/// Verify the selected manifest exists without emitting command status. +/// +/// Commands that need to separate a pure manifest query from status reporting +/// reuse this check, while the normal build path retains its ingestion report. +pub(super) fn ensure_manifest_exists(cli: &Cli, manifest_path: &Utf8PathBuf) -> Result<()> { + match manifest_metadata(manifest_path) { + Ok(()) => return Ok(()), + Err(error) if error.kind() != ErrorKind::NotFound => { + return Err(error) + .with_context(|| format!("inspect manifest metadata at {manifest_path}")); + } + Err(_) => {} } - report_pipeline_stage(reporter, PipelineStage::ManifestIngestion, None); // `resolve_manifest_path()` validates that `file_name()` is Some. let manifest_name = manifest_path .file_name() @@ -116,3 +139,23 @@ pub(super) fn ensure_manifest_exists_or_error( } .into()) } + +/// Inspect the selected manifest through a capability-scoped directory handle. +/// +/// The explicit metadata result preserves permission and other I/O failures; +/// callers may map only a genuine missing path to the user-facing diagnostic. +fn manifest_metadata(manifest_path: &Utf8Path) -> io::Result<()> { + let parent = manifest_path + .parent() + .filter(|path| !path.as_str().is_empty()) + .unwrap_or_else(|| Utf8Path::new(".")); + let directory = Dir::open_ambient_dir(parent, ambient_authority())?; + let name = manifest_path.file_name().ok_or_else(|| { + io::Error::new( + ErrorKind::InvalidInput, + format!("manifest path {manifest_path} has no file name"), + ) + })?; + directory.metadata(Utf8Path::new(name))?; + Ok(()) +} diff --git a/src/runner/tests.rs b/src/runner/tests.rs index 7bd9c488a..bb68dad16 100644 --- a/src/runner/tests.rs +++ b/src/runner/tests.rs @@ -1,9 +1,13 @@ //! Unit tests for runner path resolution, predicate helpers, and core helpers. use super::*; +use crate::cli::{HelpArgs, HelpTopic}; +use anyhow::{Result, ensure}; use rstest::rstest; +use std::cell::Cell; use std::path::Path; use std::path::PathBuf; +use test_support::{localizer_test_lock, set_en_localizer}; #[rstest] #[case(None, "out.ninja", "out.ninja")] @@ -21,3 +25,30 @@ fn resolve_output_path_respects_directory( let resolved = resolve_output_path(&cli, Path::new(input)); assert_eq!(resolved.as_ref(), Path::new(expected)); } + +#[test] +fn help_targets_bypasses_ninja_program_resolution() -> Result<()> { + let _lock = localizer_test_lock().map_err(|error| anyhow::anyhow!("{error}"))?; + let _guard = set_en_localizer(); + let cli = Cli { + file: PathBuf::from("missing-help-targets-manifest.yml"), + command: Some(Commands::Help(HelpArgs { + topic: Some(HelpTopic::Targets), + })), + ..Cli::default() + }; + let resolver_called = Cell::new(false); + + let result = + run_with_ninja_program_resolver(&cli, crate::output_prefs::resolve(None), None, || { + resolver_called.set(true); + PathBuf::from("ninja") + }); + + ensure!(result.is_err(), "missing help manifest should fail"); + ensure!( + !resolver_called.get(), + "help targets must not resolve the Ninja program" + ); + Ok(()) +} diff --git a/src/snapshots/cli/netsuke__cli__parser__tests__help_en_us.snap b/src/snapshots/cli/netsuke__cli__parser__tests__help_en_us.snap index e72238aa1..b2754d902 100644 --- a/src/snapshots/cli/netsuke__cli__parser__tests__help_en_us.snap +++ b/src/snapshots/cli/netsuke__cli__parser__tests__help_en_us.snap @@ -1,5 +1,6 @@ --- source: src/cli/parser_tests.rs +assertion_line: 48 expression: normalized_help --- Netsuke transforms YAML + Jinja manifests into reproducible Ninja graphs and runs Ninja with safe defaults. @@ -11,7 +12,7 @@ Commands: clean Remove build artefacts via Ninja. graph Emit the build dependency graph. Default format is DOT. generate Generate the Ninja manifest without running Ninja. - help Print this message or the help of the given subcommand(s) + help Print the top-level help, or the help for a named topic. Options: -f, --file diff --git a/src/snapshots/cli/netsuke__cli__parser__tests__help_es_es.snap b/src/snapshots/cli/netsuke__cli__parser__tests__help_es_es.snap index 3fc83ab29..d15bd685b 100644 --- a/src/snapshots/cli/netsuke__cli__parser__tests__help_es_es.snap +++ b/src/snapshots/cli/netsuke__cli__parser__tests__help_es_es.snap @@ -1,5 +1,6 @@ --- source: src/cli/parser_tests.rs +assertion_line: 48 expression: normalized_help --- Netsuke transforma manifiestos YAML + Jinja en grafos Ninja reproducibles y ejecuta Ninja con valores seguros. @@ -11,7 +12,7 @@ Commands: clean Elimina artefactos de compilación mediante Ninja. graph Emite el grafo de dependencias de compilación. El formato predeterminado es DOT. generate Genera el manifiesto Ninja sin ejecutar Ninja. - help Print this message or the help of the given subcommand(s) + help Imprima la ayuda de nivel superior o la ayuda de un tema determinado. Options: -f, --file diff --git a/src/snapshots/help_targets/netsuke__runner__help__tests__accessible_catalogue.snap b/src/snapshots/help_targets/netsuke__runner__help__tests__accessible_catalogue.snap new file mode 100644 index 000000000..2bd7462f6 --- /dev/null +++ b/src/snapshots/help_targets/netsuke__runner__help__tests__accessible_catalogue.snap @@ -0,0 +1,13 @@ +--- +source: src/runner/help_tests.rs +assertion_line: 80 +expression: rendered +--- +Actions: + lint Run rustdoc, Clippy, and Whitaker [* default] + test Run unit, behavioural, UI, and documentation tests [* default] + undocumented + +Targets: + target/release/catnap Build the optimized release binary + plain diff --git a/src/snapshots/help_targets/netsuke__runner__help__tests__json_catalogue.snap b/src/snapshots/help_targets/netsuke__runner__help__tests__json_catalogue.snap new file mode 100644 index 000000000..ee59a9bc4 --- /dev/null +++ b/src/snapshots/help_targets/netsuke__runner__help__tests__json_catalogue.snap @@ -0,0 +1,44 @@ +--- +source: src/runner/help_tests.rs +assertion_line: 103 +expression: rendered +--- +{ + "schema_version": 1, + "generator": { + "name": "netsuke", + "version": "0.1.0-beta1" + }, + "result": { + "command": "help-targets", + "actions": [ + { + "name": "lint", + "description": "Run rustdoc, Clippy, and Whitaker", + "default": true + }, + { + "name": "test", + "description": "Run unit, behavioural, UI, and documentation tests", + "default": true + }, + { + "name": "undocumented", + "description": null, + "default": false + } + ], + "targets": [ + { + "name": "target/release/catnap", + "description": "Build the optimized release binary", + "default": false + }, + { + "name": "plain", + "description": null, + "default": false + } + ] + } +} diff --git a/src/snapshots/help_targets/netsuke__runner__help__tests__localized_catalogue_es_es.snap b/src/snapshots/help_targets/netsuke__runner__help__tests__localized_catalogue_es_es.snap new file mode 100644 index 000000000..342328e2a --- /dev/null +++ b/src/snapshots/help_targets/netsuke__runner__help__tests__localized_catalogue_es_es.snap @@ -0,0 +1,13 @@ +--- +source: src/runner/help_tests.rs +assertion_line: 92 +expression: rendered +--- +Acciones: + lint Run rustdoc, Clippy, and Whitaker [★ predeterminado] + test Run unit, behavioural, UI, and documentation tests [★ predeterminado] + undocumented + +Objetivos: + target/release/catnap Build the optimized release binary + plain diff --git a/src/snapshots/help_targets/netsuke__runner__help__tests__text_catalogue.snap b/src/snapshots/help_targets/netsuke__runner__help__tests__text_catalogue.snap new file mode 100644 index 000000000..59d4a8bfd --- /dev/null +++ b/src/snapshots/help_targets/netsuke__runner__help__tests__text_catalogue.snap @@ -0,0 +1,13 @@ +--- +source: src/runner/help_tests.rs +assertion_line: 68 +expression: rendered +--- +Actions: + lint Run rustdoc, Clippy, and Whitaker [★ default] + test Run unit, behavioural, UI, and documentation tests [★ default] + undocumented + +Targets: + target/release/catnap Build the optimized release binary + plain diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 60092b3c1..48584f87d 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -26,6 +26,7 @@ pub use config::{ pub use network::{ HostPatternError, NetworkPolicy, NetworkPolicyConfigError, NetworkPolicyViolation, }; +pub(crate) use register::register_manifest_query; pub use register::{register, register_with_config, value_from_bytes}; use std::{ diff --git a/src/stdlib/path/filters.rs b/src/stdlib/path/filters.rs index b5a9b097a..0b5d7fe7e 100644 --- a/src/stdlib/path/filters.rs +++ b/src/stdlib/path/filters.rs @@ -27,7 +27,12 @@ fn register_expanduser(env: &mut Environment<'_>, home_directory: HomeDirectory) }); } -pub(crate) fn register_filters(env: &mut Environment<'_>, home_directory: HomeDirectory) { +/// Register path filters that transform strings without inspecting the host. +/// +/// This deliberately limited surface is shared by manifest discovery queries. +/// Add a filter here only when it is entirely lexical; filters that inspect the +/// filesystem or environment belong exclusively in [`register_filters`]. +fn register_lexical_filters(env: &mut Environment<'_>) { env.add_filter("basename", |raw: String| -> Result { Ok(path_utils::basename(Utf8Path::new(&raw))) }); @@ -53,6 +58,15 @@ pub(crate) fn register_filters(env: &mut Environment<'_>, home_directory: HomeDi path_utils::relative_to(Utf8Path::new(&raw), Utf8Path::new(&root)) }, ); +} + +/// Register path filters safe for manifest discovery queries. +pub(crate) fn register_query_filters(env: &mut Environment<'_>) { + register_lexical_filters(env); +} + +pub(crate) fn register_filters(env: &mut Environment<'_>, home_directory: HomeDirectory) { + register_lexical_filters(env); env.add_filter("realpath", |raw: String| -> Result { path_utils::canonicalize_any(Utf8Path::new(&raw)).map(camino::Utf8PathBuf::into_string) }); diff --git a/src/stdlib/path/mod.rs b/src/stdlib/path/mod.rs index 5c82addc9..a25c7ce8e 100644 --- a/src/stdlib/path/mod.rs +++ b/src/stdlib/path/mod.rs @@ -12,5 +12,5 @@ mod home_metrics_tests; #[cfg(test)] mod home_tests; -pub(crate) use filters::register_filters; +pub(crate) use filters::{register_filters, register_query_filters}; pub(crate) use fs_utils::file_type_matches; diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index 588dd1987..c95e89af2 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -15,7 +15,10 @@ use camino::Utf8Path; #[cfg(unix)] use cap_std::fs::FileTypeExt; use cap_std::{ambient_authority, fs, fs_utf8::Dir}; -use minijinja::{Environment, Error, value::Value}; +use minijinja::{ + Environment, Error, ErrorKind, State, + value::{Kwargs, Value}, +}; use std::sync::Arc; use crate::localization::{self, keys}; @@ -92,6 +95,32 @@ pub fn register_with_config( config: StdlibConfig, ) -> anyhow::Result { let state = StdlibState::default(); + register_read_only_helpers(env, &config); + time::register_functions(env); + let impure = state.impure_flag(); + let (network_config, command_config) = config.into_components(); + network::register_functions(env, Arc::clone(&impure), network_config); + command::register(env, impure, command_config); + Ok(state) +} + +/// Register helpers suitable for manifest queries that must avoid side effects. +/// +/// The registration preserves only lexical path filters, collection helpers, +/// and clock-independent time helpers. It rejects helpers that inspect the +/// host, perform I/O, or invoke commands so consumers can render discovery +/// metadata without disclosing host state. +/// +pub(crate) fn register_manifest_query(env: &mut Environment<'_>) -> StdlibState { + let state = StdlibState::default(); + register_query_helpers(env); + time::register_query_functions(env); + register_disabled_query_helpers(env); + state +} + +/// Register helpers that do not execute a command or make a network request. +fn register_read_only_helpers(env: &mut Environment<'_>, config: &StdlibConfig) { register_file_tests(env); path::register_filters(env, config.home_directory().clone()); collections::register_filters(env); @@ -105,12 +134,63 @@ pub fn register_with_config( WhichConfig::new(which_cwd, which_path, which_skip_dirs, which_cache_capacity) .with_pathext_override(config.pathext_override().cloned()); which::register(env, which_config); - let impure = state.impure_flag(); - let (network_config, command_config) = config.into_components(); - network::register_functions(env, Arc::clone(&impure), network_config); - command::register(env, impure, command_config); - time::register_functions(env); - Ok(state) +} + +/// Register the allowlisted helpers for manifest discovery queries. +fn register_query_helpers(env: &mut Environment<'_>) { + path::register_query_filters(env); + collections::register_filters(env); +} + +/// Register deliberate failures for helpers excluded from manifest queries. +fn register_disabled_query_helpers(env: &mut Environment<'_>) { + env.add_function("env", |_variable: String| -> Result { + Err(manifest_query_operation_error("env")) + }); + env.add_function("glob", |_pattern: String| -> Result { + Err(manifest_query_operation_error("glob")) + }); + env.add_function( + "fetch", + |_url: String, _kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error("fetch")) + }, + ); + env.add_filter( + "shell", + |_state: &State, + _value: Value, + _command: String, + _options: Option| + -> Result { Err(manifest_query_operation_error("shell")) }, + ); + env.add_filter( + "grep", + |_state: &State, + _value: Value, + _pattern: String, + _flags: Option, + _options: Option| + -> Result { Err(manifest_query_operation_error("grep")) }, + ); + env.add_filter( + "contents", + |_value: String, _encoding: Option| -> Result { + Err(manifest_query_operation_error("contents")) + }, + ); +} + +/// Explain why a restricted helper is unavailable while querying a manifest. +fn manifest_query_operation_error(operation: &str) -> Error { + Error::new( + ErrorKind::InvalidOperation, + format!( + "{operation} is disabled while rendering `netsuke help targets`; \ + manifest queries permit only non-disclosing, side-effect-free \ + template helpers" + ), + ) } /// Convert UTF-8 or fall back to bytes for byte-oriented network helpers. diff --git a/src/stdlib/time/mod.rs b/src/stdlib/time/mod.rs index 029d7fc0f..b3a14aeea 100644 --- a/src/stdlib/time/mod.rs +++ b/src/stdlib/time/mod.rs @@ -34,6 +34,11 @@ const OFFSET_FMT: &[FormatItem<'static>] = /// Register time helpers with the environment. pub(crate) fn register_functions(env: &mut Environment<'_>) { env.add_function("now", |kwargs: Kwargs| now(&kwargs)); + register_query_functions(env); +} + +/// Register time helpers whose output does not depend on the current clock. +pub(crate) fn register_query_functions(env: &mut Environment<'_>) { env.add_function("timedelta", |kwargs: Kwargs| timedelta(&kwargs)); } diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index d6b36d97a..fc0856db6 100644 --- a/tests/ast_tests.rs +++ b/tests/ast_tests.rs @@ -5,6 +5,9 @@ #[path = "ast_tests/actions.rs"] mod actions; + +#[path = "ast_tests/descriptions.rs"] +mod descriptions; #[path = "ast_tests/macros.rs"] mod macros; #[path = "ast_tests/manifest_files.rs"] diff --git a/tests/ast_tests/actions.rs b/tests/ast_tests/actions.rs index 2245f5325..96edb47ab 100644 --- a/tests/ast_tests/actions.rs +++ b/tests/ast_tests/actions.rs @@ -70,6 +70,29 @@ fn actions_behaviour( Ok(()) } +#[test] +fn action_carries_description_and_stays_phony() -> Result<()> { + let yaml = r#" + netsuke_version: "1.0.0" + actions: + - name: lint + description: "Run rustdoc, Clippy, and Whitaker" + command: "cargo clippy" + targets: + - name: done + command: "true" + "#; + let manifest = parse_manifest(yaml)?; + let action = manifest.actions.first().context("expected action entry")?; + ensure!( + action.description.as_deref() == Some("Run rustdoc, Clippy, and Whitaker"), + "unexpected action description: {:?}", + action.description + ); + ensure!(action.phony, "actions should stay phony with a description"); + Ok(()) +} + #[test] fn multiple_actions_are_marked_phony() -> Result<()> { let yaml = r#" diff --git a/tests/ast_tests/descriptions.rs b/tests/ast_tests/descriptions.rs new file mode 100644 index 000000000..72ee3c5a1 --- /dev/null +++ b/tests/ast_tests/descriptions.rs @@ -0,0 +1,76 @@ +//! Tests for the optional target `description` field: present, absent, and +//! rejection of duplicate or unknown metadata fields alongside it. + +use anyhow::{Context, Result, ensure}; + +use super::support::parse_manifest; + +#[test] +fn target_description_is_optional() -> Result<()> { + { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + description: "Build the hello binary" + command: "echo hi" + "#; + let manifest = parse_manifest(yaml)?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!( + target.description.as_deref() == Some("Build the hello binary"), + "unexpected target description: {:?}", + target.description + ); + } + + { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + command: "echo hi" + "#; + let manifest = parse_manifest(yaml)?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!(target.description.is_none(), "description should be absent"); + } + Ok(()) +} + +#[test] +fn description_duplicates_and_unknown_fields_are_rejected() -> Result<()> { + { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + description: "first" + description: "second" + command: "echo hi" + "#; + let error = parse_manifest(yaml).expect_err("duplicate target description should fail"); + ensure!( + format!("{error:?}").contains("description"), + "duplicate-description diagnostic should name the field: {error:?}" + ); + } + + { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + description: "Build it" + explanation: "unknown metadata" + command: "echo hi" + "#; + let error = parse_manifest(yaml) + .expect_err("unknown target field alongside description should fail"); + ensure!( + format!("{error:?}").contains("explanation"), + "unknown-field diagnostic should name the field: {error:?}" + ); + } + Ok(()) +} diff --git a/tests/bdd/steps/cli.rs b/tests/bdd/steps/cli.rs index 344541427..97de684ff 100644 --- a/tests/bdd/steps/cli.rs +++ b/tests/bdd/steps/cli.rs @@ -10,7 +10,7 @@ use crate::bdd::helpers::parse_store::store_parse_outcome; use crate::bdd::helpers::tokens::build_tokens; use crate::bdd::types::{CliArgs, ErrorFragment, JobCount, PathString, TargetName, UrlString}; use anyhow::{Context, Result, bail}; -use netsuke::cli::{Cli, Commands}; +use netsuke::cli::{Cli, Commands, HelpTopic}; use netsuke::cli_localization; use netsuke::locale_resolution; use rstest_bdd_macros::then; @@ -138,8 +138,8 @@ mod cli_verify; use cli_verify::{ ExpectedCommand, verify_cli_policy_allows, verify_cli_policy_rejects, verify_command, verify_error_contains, verify_error_returned, verify_first_target, verify_generate_output_path, - verify_graph_html_set, verify_graph_output_path, verify_job_count, verify_manifest_path, - verify_parsing_succeeded, verify_working_directory, + verify_graph_html_set, verify_graph_output_path, verify_help_topic, verify_job_count, + verify_manifest_path, verify_parsing_succeeded, verify_working_directory, }; // --------------------------------------------------------------------------- @@ -175,6 +175,21 @@ fn the_command_is_generate(world: &TestWorld) -> Result<()> { verify_command(world, ExpectedCommand::Generate) } +#[then] +fn the_command_is_help(world: &TestWorld) -> Result<()> { + verify_command(world, ExpectedCommand::Help) +} + +#[then] +fn the_help_topic_is_targets(world: &TestWorld) -> Result<()> { + verify_help_topic(world, Some(&HelpTopic::Targets)) +} + +#[then] +fn the_help_has_no_topic(world: &TestWorld) -> Result<()> { + verify_help_topic(world, None) +} + #[then("the manifest path is {path:string}")] fn manifest_path(world: &TestWorld, path: PathString) -> Result<()> { verify_manifest_path(world, &path) diff --git a/tests/bdd/steps/cli_verify.rs b/tests/bdd/steps/cli_verify.rs index 366d97af8..93e252b75 100644 --- a/tests/bdd/steps/cli_verify.rs +++ b/tests/bdd/steps/cli_verify.rs @@ -10,7 +10,7 @@ use crate::bdd::fixtures::{RefCellOptionExt, TestWorld}; use crate::bdd::helpers::assertions::normalize_fluent_isolates; use crate::bdd::types::{ErrorFragment, JobCount, PathString, TargetName, UrlString}; use anyhow::{Context, Result, bail, ensure}; -use netsuke::cli::Commands; +use netsuke::cli::{Commands, HelpTopic}; use std::path::PathBuf; /// Expected CLI command variants for verification. @@ -20,6 +20,7 @@ pub(super) enum ExpectedCommand { Clean, Graph, Generate, + Help, } impl ExpectedCommand { @@ -35,6 +36,7 @@ impl ExpectedCommand { | (Self::Clean, Commands::Clean) | (Self::Graph, Commands::Graph(_)) | (Self::Generate, Commands::Generate { .. }) + | (Self::Help, Commands::Help(_)) ) } @@ -45,10 +47,24 @@ impl ExpectedCommand { Self::Clean => "clean", Self::Graph => "graph", Self::Generate => "generate", + Self::Help => "help", } } } +pub(super) fn verify_help_topic(world: &TestWorld, expected: Option<&HelpTopic>) -> Result<()> { + let command = get_command(world)?; + let Commands::Help(args) = &command else { + bail!("expected help command, got {command:?}"); + }; + ensure!( + args.topic.as_ref() == expected, + "expected help topic {expected:?}, got {:?}", + args.topic + ); + Ok(()) +} + pub(super) fn verify_command(world: &TestWorld, expected: ExpectedCommand) -> Result<()> { let command = get_command(world)?; ensure!( diff --git a/tests/bdd/steps/help_targets.rs b/tests/bdd/steps/help_targets.rs new file mode 100644 index 000000000..630ab6c59 --- /dev/null +++ b/tests/bdd/steps/help_targets.rs @@ -0,0 +1,44 @@ +//! Step definitions for the `netsuke help targets` full-process scenarios. + +use crate::bdd::fixtures::TestWorld; +use crate::bdd::steps::manifest_command::manifest_command_helpers::run_netsuke_and_store; +use anyhow::{Context, Result}; +use rstest_bdd_macros::{given, when}; +use std::fs; + +#[given("a Netsuke workspace with described actions and targets")] +fn described_actions_and_targets_workspace(world: &TestWorld) -> Result<()> { + let temp = tempfile::tempdir().context("create temp dir for described workspace")?; + let manifest = temp.path().join("Netsukefile"); + fs::write( + &manifest, + r#"netsuke_version: "1.0.0" +actions: + - name: lint + description: Run rustdoc, Clippy, and Whitaker + command: cargo clippy + - name: test + description: Run unit, behavioural, UI, and documentation tests + command: cargo test +targets: + - name: target/release/catnap + description: Build the optimized release binary + command: cargo build --release +defaults: + - lint + - test +"#, + ) + .with_context(|| format!("write manifest to {}", manifest.display()))?; + *world.temp_dir.borrow_mut() = Some(temp); + world.run_status.clear(); + world.run_error.clear(); + world.command_stdout.clear(); + world.command_stderr.clear(); + Ok(()) +} + +#[when("the netsuke help targets subcommand is run")] +fn run_help_targets_subcommand(world: &TestWorld) -> Result<()> { + run_netsuke_and_store(world, &["help", "targets"]) +} diff --git a/tests/bdd/steps/manifest_command.rs b/tests/bdd/steps/manifest_command.rs index bef358fc7..d490bdb1e 100644 --- a/tests/bdd/steps/manifest_command.rs +++ b/tests/bdd/steps/manifest_command.rs @@ -29,7 +29,7 @@ impl fmt::Display for OutputType { } #[path = "manifest_command_helpers.rs"] -mod manifest_command_helpers; +pub(super) mod manifest_command_helpers; use manifest_command_helpers::{ assert_file_existence, assert_output_contains, assert_output_not_contains, build_netsuke_command, create_directory_in_workspace, get_temp_path, netsuke_executable, diff --git a/tests/bdd/steps/manifest_command_helpers.rs b/tests/bdd/steps/manifest_command_helpers.rs index 628fc1851..d32f950bb 100644 --- a/tests/bdd/steps/manifest_command_helpers.rs +++ b/tests/bdd/steps/manifest_command_helpers.rs @@ -166,7 +166,7 @@ pub(super) fn build_netsuke_command( } /// Run netsuke with the given arguments and store the result. -pub(super) fn run_netsuke_and_store(world: &TestWorld, args: &[&str]) -> Result<()> { +pub(crate) fn run_netsuke_and_store(world: &TestWorld, args: &[&str]) -> Result<()> { let mut cmd = build_netsuke_command(world, args)?; let output = cmd.output().context("run netsuke command")?; diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 2556627b5..3f67d55aa 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -25,6 +25,7 @@ mod configuration_preferences; mod documentation_examples; #[cfg(unix)] mod fs; +mod help_targets; mod ir; mod json_diagnostics; mod locale_resolution; diff --git a/tests/completion_contract_tests.rs b/tests/completion_contract_tests.rs new file mode 100644 index 000000000..4fd69d3f3 --- /dev/null +++ b/tests/completion_contract_tests.rs @@ -0,0 +1,67 @@ +//! Contract tests for shell completions generated from Netsuke's Clap command tree. + +use anyhow::{Context, Result, ensure}; +use clap::CommandFactory; +use netsuke::cli::Cli; +use rstest::rstest; +use std::path::{Path, PathBuf}; +use test_support::fs as test_fs; + +/// Directory published by `build.rs` after generating the completion files. +const GENERATED_COMPLETIONS_DIR: &str = env!("NETSUKE_GENERATED_COMPLETIONS_DIR"); + +/// Resolve the generated completion directory against the package root. +fn generated_completions_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(GENERATED_COMPLETIONS_DIR) +} + +/// Collect the command and option terms every generated completion must expose. +fn cli_completion_terms() -> Vec { + let command = Cli::command(); + let mut terms = command + .get_subcommands() + .map(|subcommand| subcommand.get_name().to_owned()) + .collect::>(); + if let Some(help_command) = command + .get_subcommands() + .find(|subcommand| subcommand.get_name() == "help") + { + terms.extend( + help_command + .get_subcommands() + .map(|topic| topic.get_name().to_owned()), + ); + } + terms.extend( + command + .get_arguments() + .filter_map(|argument| argument.get_long().map(ToOwned::to_owned)), + ); + terms +} + +#[rstest] +#[case("netsuke.bash")] +#[case("netsuke.elv")] +#[case("netsuke.fish")] +#[case("_netsuke.ps1")] +#[case("_netsuke")] +fn generated_completion_exposes_the_clap_command_tree(#[case] file_name: &str) -> Result<()> { + let path = generated_completions_dir().join(file_name); + let completion = test_fs::read_to_string(&path) + .with_context(|| format!("read generated completion {}", path.display()))?; + + for topic in ["help", "targets"] { + ensure!( + completion.contains(topic), + "generated completion {file_name} should expose the {topic:?} help topic: {completion}" + ); + } + for term in cli_completion_terms() { + ensure!( + completion.contains(&term), + "generated completion {file_name} should expose {term:?}: {completion}" + ); + } + Ok(()) +} diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 0863a272f..74d2bd2a5 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -28,6 +28,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-first-build-commands", "guide-first-build-manifest", "guide-foreach-manifest", + "guide-help-targets", "guide-json-command", "guide-json-output", "guide-macro-manifest", @@ -288,6 +289,22 @@ fn directory_and_utility_command_examples_run() -> Result<()> { Ok(()) } +#[test] +fn help_targets_example_lists_described_targets() -> Result<()> { + let example = documented_example("guide-help-targets")?; + ensure!( + example.body == "netsuke help targets\n", + "help targets example drifted" + ); + let workspace = manifest_workspace("guide-first-build-manifest")?; + let run = run_netsuke_in(workspace.path(), &["--locale", "en-US", "help", "targets"])?; + assert_success(&run, "help targets example")?; + ensure!( + normalize_fluent_isolates(&run.stdout).contains("Targets:"), + "help targets should print the Targets section" + ); + Ok(()) +} #[test] fn project_configuration_example_is_accepted() -> Result<()> { let example = documented_example("guide-project-config")?; diff --git a/tests/features/cli.feature b/tests/features/cli.feature index e15d5ff46..a9e1ddda9 100644 --- a/tests/features/cli.feature +++ b/tests/features/cli.feature @@ -140,3 +140,29 @@ Feature: CLI parsing Then parsing succeeds And the command is build And the working directory is "work dir" + + Scenario: Help command with targets topic + When the CLI is parsed with "help targets" + Then parsing succeeds + And the command is help + And the help topic is targets + + Scenario: Help command with targets topic and alternate manifest + When the CLI is parsed with "--file alt.yml help targets" + Then parsing succeeds + And the command is help + And the help topic is targets + And the manifest path is "alt.yml" + + Scenario: Help command with targets topic and working directory + When the CLI is parsed with "-C work help targets" + Then parsing succeeds + And the command is help + And the help topic is targets + And the working directory is "work" + + Scenario: Bare help command has no topic + When the CLI is parsed with "help" + Then parsing succeeds + And the command is help + And the help has no topic diff --git a/tests/features/help_targets.feature b/tests/features/help_targets.feature new file mode 100644 index 000000000..cc0a96475 --- /dev/null +++ b/tests/features/help_targets.feature @@ -0,0 +1,10 @@ +Feature: Help targets subcommand + + Scenario: Help targets prints described actions and targets + Given a Netsuke workspace with described actions and targets + When the netsuke help targets subcommand is run + Then the command should succeed + And stdout should contain "Actions:" + And stdout should contain "Targets:" + And stdout should contain "Run rustdoc, Clippy, and Whitaker" + And stdout should contain "Build the optimized release binary" \ No newline at end of file diff --git a/tests/ir_from_manifest_tests.rs b/tests/ir_from_manifest_tests.rs index 9f26a1bfa..fd8f7edd7 100644 --- a/tests/ir_from_manifest_tests.rs +++ b/tests/ir_from_manifest_tests.rs @@ -12,7 +12,7 @@ use camino::Utf8PathBuf; use netsuke::{ ast::Recipe, ir::{BuildGraph, IrGenError}, - manifest, + manifest, ninja_gen, }; use rstest::rstest; @@ -269,6 +269,33 @@ fn manifest_deps_do_not_contribute_to_recipe_inputs() -> Result<()> { Ok(()) } +#[rstest] +fn target_descriptions_do_not_replace_rule_progress_text() -> Result<()> { + let yaml = concat!( + "netsuke_version: '1.0.0'\n", + "rules:\n", + " - name: compile\n", + " description: Rule progress text\n", + " command: echo compile\n", + "targets:\n", + " - name: out/app\n", + " description: Target discovery metadata\n", + " rule: compile\n", + ); + let manifest = manifest::from_str(yaml)?; + let graph = BuildGraph::from_manifest(&manifest).context("generate graph")?; + let ninja = ninja_gen::generate(&graph).context("generate Ninja manifest")?; + + ensure!( + ninja.contains("description = Rule progress text"), + "Ninja progress should use the referenced rule description: {ninja}" + ); + ensure!( + !ninja.contains("Target discovery metadata"), + "target discovery metadata must not appear in Ninja progress: {ninja}" + ); + Ok(()) +} #[derive(Debug)] enum ExpectedError { DuplicateOutput(Vec), diff --git a/tests/man_page_contract_tests.rs b/tests/man_page_contract_tests.rs index f31c967b4..7d97cd5f5 100644 --- a/tests/man_page_contract_tests.rs +++ b/tests/man_page_contract_tests.rs @@ -121,3 +121,20 @@ fn manual_page_source_is_stamped_with_the_command_name() -> Result<()> { ); Ok(()) } + +#[test] +fn manual_page_documents_the_help_targets_topic() -> Result<()> { + let path = generated_man_page(); + let page = test_fs::read_to_string(&path) + .with_context(|| format!("read generated manual page {}", path.display()))?; + + ensure!( + page.contains("netsuke\\-help(1)"), + "manual page should list the help command: {page}" + ); + ensure!( + page.contains("help targets"), + "manual page should document the targets topic: {page}" + ); + Ok(()) +} diff --git a/tests/novice_flow_smoke_tests.rs b/tests/novice_flow_smoke_tests.rs index d2ddab565..0e39e6a4e 100644 --- a/tests/novice_flow_smoke_tests.rs +++ b/tests/novice_flow_smoke_tests.rs @@ -6,6 +6,8 @@ #[cfg(unix)] use anyhow::bail; use anyhow::{Context, Result, ensure}; +use camino::Utf8Path; +use cap_std::{ambient_authority, fs_utf8::Dir}; use rstest::rstest; use std::path::Path; #[cfg(unix)] @@ -153,6 +155,29 @@ fn help_entry_points_are_novice_friendly(#[case] args: &[&str]) -> Result<()> { Ok(()) } +#[rstest] +#[case::root(&["help"])] +#[case::build(&["help", "build"])] +fn informational_help_ignores_malformed_project_config(#[case] args: &[&str]) -> Result<()> { + let workspace = tempdir().context("create informational-help workspace")?; + let workspace_path = + Utf8Path::from_path(workspace.path()).context("temporary path should be UTF-8")?; + let workspace_dir = Dir::open_ambient_dir(workspace_path, ambient_authority()) + .context("open informational-help workspace")?; + workspace_dir + .write(".netsuke.toml", b"not valid TOML") + .context("write malformed project config")?; + + let output = run_netsuke(workspace.path(), args, None)?; + + ensure!( + output.success, + "informational help should bypass configuration errors: {}", + output.stderr + ); + Ok(()) +} + #[test] fn localized_help_still_flows_through_cli_localization() -> Result<()> { let output = run_netsuke(Path::new("."), &["--locale", "es-ES", "--help"], None)?; diff --git a/tests/release_help_script_tests.rs b/tests/release_help_script_tests.rs index d563e8bd0..c1984336a 100644 --- a/tests/release_help_script_tests.rs +++ b/tests/release_help_script_tests.rs @@ -106,6 +106,10 @@ fn generates_powershell_help_for_windows_target( log.contains("--ps-module-name CustomNetsuke"), "PowerShell module name should be pinned, got {log}" ); + ensure!( + log.contains("--ps-split-subcommands true"), + "PowerShell help should include documented CLI subcommands, got {log}" + ); let ps_module = fs::read_to_string( fixture .out_dir diff --git a/tests/release_staging_tests.rs b/tests/release_staging_tests.rs index 53df4adbd..a0696bca4 100644 --- a/tests/release_staging_tests.rs +++ b/tests/release_staging_tests.rs @@ -90,6 +90,48 @@ fn release_staging_omits_build_script_help_sources(#[case] removed: &str) -> Res Ok(()) } +#[rstest] +#[case( + "target/generated-completions/{target}/release/netsuke.bash", + "completions/bash/netsuke" +)] +#[case( + "target/generated-completions/{target}/release/netsuke.elv", + "completions/elvish/netsuke.elv" +)] +#[case( + "target/generated-completions/{target}/release/netsuke.fish", + "completions/fish/netsuke.fish" +)] +#[case( + "target/generated-completions/{target}/release/_netsuke.ps1", + "completions/powershell/_netsuke.ps1" +)] +#[case( + "target/generated-completions/{target}/release/_netsuke", + "completions/zsh/_netsuke" +)] +fn release_staging_declares_generated_completion_sidecars( + #[case] source: &str, + #[case] destination: &str, +) -> Result<()> { + let config = staging_config()?; + let artefacts = config + .get("common") + .and_then(|common| common.get("artefacts")) + .and_then(Value::as_array) + .context("common release artefacts should be an array")?; + let staged = artefacts.iter().any(|artefact| { + artefact.get("source").and_then(Value::as_str) == Some(source) + && artefact.get("destination").and_then(Value::as_str) == Some(destination) + }); + ensure!( + staged, + "release staging should include {source} at {destination}: {artefacts:?}" + ); + Ok(()) +} + #[rstest] #[case("x86_64-unknown-linux-gnu")] #[case("aarch64-unknown-linux-gnu")] diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs new file mode 100644 index 000000000..990ed947b --- /dev/null +++ b/tests/runner_help_targets_tests.rs @@ -0,0 +1,236 @@ +//! Integration tests for the in-process `netsuke help targets` subcommand. +//! +//! Shared fixtures and rejection scenarios remain here, while catalogue +//! rendering scenarios live in the cohesive [`catalogue`] child module. + +use anyhow::{Context, Result, ensure}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use netsuke::cli::{Cli, Commands, HelpArgs, HelpTopic}; +use netsuke::output_prefs; +use netsuke::runner::run; +use rstest::{fixture, rstest}; +use test_support::{fluent::normalize_fluent_isolates, localizer_test_lock, set_en_localizer}; + +#[path = "runner_help_targets_tests/catalogue.rs"] +mod catalogue; +mod fixtures; + +use fixtures::create_test_manifest; + +/// Write a manifest with actions, targets, defaults, and one entry whose +/// description is missing, so both catalogue sections are exercised. +fn write_help_targets_manifest(temp: &tempfile::TempDir) -> Result { + let temp_path = Utf8Path::from_path(temp.path()).context("temporary path should be UTF-8")?; + let manifest_path = temp_path.join("Netsukefile"); + let workspace = Dir::open_ambient_dir(temp_path, ambient_authority()) + .context("open help-targets fixture directory")?; + workspace + .write( + "Netsukefile", + r#"netsuke_version: "1.0.0" +actions: + - name: lint + description: Run rustdoc, Clippy, and Whitaker + command: touch lint-ran + - name: test + description: Run unit, behavioural, UI, and documentation tests + command: touch test-ran +targets: + - name: target/release/catnap + description: Build the optimized release binary + command: touch release-ran + - name: plain + command: touch plain-ran +defaults: + - lint + - test +"#, + ) + .with_context(|| format!("write manifest to {}", manifest_path.as_str()))?; + Ok(manifest_path) +} + +#[fixture] +fn help_targets_manifest() -> Result<(tempfile::TempDir, Utf8PathBuf)> { + let temp = tempfile::tempdir().context("create help-targets fixture directory")?; + let manifest_path = write_help_targets_manifest(&temp)?; + Ok((temp, manifest_path)) +} + +fn run_help_targets(cli: &Cli) -> Result<()> { + let _lock = localizer_test_lock().map_err(|e| anyhow::anyhow!("{e}"))?; + let _guard = set_en_localizer(); + run(cli, output_prefs::resolve(None)).context("running help targets subcommand") +} + +fn assert_help_targets_rejects_manifest( + fixture_name: &str, + manifest: &[u8], + expected_error: &str, +) -> Result<()> { + let temp = + tempfile::tempdir().with_context(|| format!("create {fixture_name} fixture directory"))?; + let temp_path = Utf8Path::from_path(temp.path()) + .with_context(|| format!("{fixture_name} temporary path should be UTF-8"))?; + let manifest_path = temp_path.join("Netsukefile"); + let workspace = Dir::open_ambient_dir(temp_path, ambient_authority()) + .with_context(|| format!("open {fixture_name} fixture directory"))?; + workspace + .write("Netsukefile", manifest) + .with_context(|| format!("write {fixture_name} manifest"))?; + let cli = Cli { + file: manifest_path.into_std_path_buf(), + command: Some(Commands::Help(HelpArgs { + topic: Some(HelpTopic::Targets), + })), + ..Cli::default() + }; + let Err(error) = run_help_targets(&cli) else { + anyhow::bail!("{fixture_name} manifest should fail help targets"); + }; + ensure!( + error + .chain() + .any(|cause| normalize_fluent_isolates(&cause.to_string()).contains(expected_error)), + "error should contain {expected_error:?}: {error:?}" + ); + Ok(()) +} + +fn assert_help_targets_rejects_unsafe_manifest( + fixture_name: &str, + manifest: &[u8], + expected_diagnostic: &str, + forbidden_raw_text: &str, +) -> Result<()> { + let temp = + tempfile::tempdir().with_context(|| format!("create {fixture_name} fixture directory"))?; + let temp_path = Utf8Path::from_path(temp.path()) + .with_context(|| format!("{fixture_name} temporary path should be UTF-8"))?; + let manifest_path = temp_path.join("Netsukefile"); + let workspace = Dir::open_ambient_dir(temp_path, ambient_authority()) + .with_context(|| format!("open {fixture_name} fixture directory"))?; + workspace + .write("Netsukefile", manifest) + .with_context(|| format!("write {fixture_name} manifest"))?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .with_context(|| format!("run help targets against {fixture_name} manifest"))?; + ensure!( + !output.status.success(), + "{fixture_name} manifest should fail help targets" + ); + let stderr = normalize_fluent_isolates(&String::from_utf8_lossy(&output.stderr)); + ensure!( + stderr.contains(expected_diagnostic), + "{fixture_name} diagnostic should contain {expected_diagnostic:?}: {stderr}" + ); + ensure!( + !stderr.contains(forbidden_raw_text), + "{fixture_name} diagnostic must not emit raw {forbidden_raw_text:?}: {stderr:?}" + ); + Ok(()) +} + +#[rstest] +fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { + let temp = tempfile::tempdir().context("temp dir")?; + let temp_path = Utf8Path::from_path(temp.path()).context("temporary path should be UTF-8")?; + let workspace = Dir::open_ambient_dir(temp_path, ambient_authority()) + .context("open invalid-manifest fixture directory")?; + let data = Dir::open_ambient_dir("tests/data", ambient_authority()) + .context("open invalid manifest fixture directory")?; + let manifest_path = temp_path.join("Netsukefile"); + data.copy("invalid_version.yml", &workspace, "Netsukefile") + .with_context(|| format!("copy invalid manifest to {}", manifest_path.as_str()))?; + let cli = Cli { + file: manifest_path.into_std_path_buf(), + command: Some(Commands::Help(HelpArgs { + topic: Some(HelpTopic::Targets), + })), + ..Cli::default() + }; + let error = run_help_targets(&cli).expect_err("invalid manifest should fail help targets"); + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains("Manifest parse failed.")), + "error should identify the manifest parsing failure: {error:?}" + ); + Ok(()) +} + +#[test] +fn help_targets_rejects_valid_manifest_with_missing_rule() -> Result<()> { + assert_help_targets_rejects_manifest( + "missing-rule", + b"netsuke_version: \"1.0.0\"\ntargets:\n - name: out/app\n rule: missing\n", + "was not found", + ) +} + +#[test] +fn help_targets_rejects_control_bearing_target_names_before_graph_validation() -> Result<()> { + assert_help_targets_rejects_unsafe_manifest( + "unsafe-target", + b"netsuke_version: \"1.0.0\"\ntargets:\n - name: \"out/\\u001b[2Japp\"\n rule: missing\n", + "terminal control characters", + "\u{001b}", + ) +} + +#[test] +fn help_targets_rejects_unknown_manifest_default() -> Result<()> { + assert_help_targets_rejects_manifest( + "unknown-default", + b"netsuke_version: \"1.0.0\"\nactions:\n - name: lint\n command: cargo clippy\ntargets: []\ndefaults:\n - missing\n", + "default 'missing'", + ) +} + +#[test] +fn help_targets_escapes_manifest_defaults_in_diagnostics() -> Result<()> { + assert_help_targets_rejects_manifest( + "unsafe-default", + b"netsuke_version: \"1.0.0\"\nactions:\n - name: lint\n command: cargo clippy\ntargets: []\ndefaults:\n - \"bad\\nINJECTED\"\n", + r"default 'bad\nINJECTED'", + ) +} + +#[test] +fn help_targets_does_not_emit_raw_manifest_controls_in_diagnostics() -> Result<()> { + assert_help_targets_rejects_unsafe_manifest( + "unsafe-default", + b"netsuke_version: \"1.0.0\"\nactions:\n - name: lint\n command: cargo clippy\ntargets: []\ndefaults:\n - \"bad\\nINJECTED\"\n", + r"default 'bad\nINJECTED'", + "default 'bad\nINJECTED'", + ) +} + +#[rstest] +fn plain_help_matches_minimal_workspace() -> Result<()> { + let (temp, manifest_path) = create_test_manifest()?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .current_dir(temp.path()) + .arg("--file") + .arg(manifest_path) + .arg("help") + .output() + .context("run plain help against minimal workspace")?; + ensure!( + output.status.success(), + "plain help should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + ensure!( + stdout.contains("Usage: netsuke") && stdout.contains("Commands:"), + "plain help should render root command text: {stdout}" + ); + Ok(()) +} diff --git a/tests/runner_help_targets_tests/catalogue.rs b/tests/runner_help_targets_tests/catalogue.rs new file mode 100644 index 000000000..ea8cf259a --- /dev/null +++ b/tests/runner_help_targets_tests/catalogue.rs @@ -0,0 +1,372 @@ +//! Catalogue-rendering scenarios for `netsuke help targets`. + +use super::*; +use netsuke::{ir::BuildGraph, manifest, ninja_gen}; +use serde_json::Value; + +fn assert_fixture_recipes_not_run(workspace: &Dir) -> Result<()> { + for output in ["lint-ran", "test-ran", "release-ran", "plain-ran"] { + ensure!( + workspace.open(output).is_err(), + "help targets must not execute the recipe that creates {output}" + ); + } + ensure!( + workspace.open(".netsuke").is_err(), + "help targets must not create a build-output directory" + ); + Ok(()) +} + +fn assert_foreach_help_catalogue(manifest_path: &Utf8Path) -> Result<()> { + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .arg("--file") + .arg(manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run help targets against foreach manifest")?; + ensure!( + output.status.success(), + "help targets should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in [ + "report-weekly", + "Build the weekly report", + "report-monthly", + "Build the monthly report", + "check-unit", + "Run unit", + "check-integration", + "Run integration", + ] { + ensure!( + stdout.contains(expected), + "catalogue should render foreach description {expected:?}: {stdout}" + ); + } + Ok(()) +} + +#[rstest] +fn help_targets_prints_actions_and_targets( + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, Utf8PathBuf)>, +) -> Result<()> { + let (_temp, manifest_path) = fixture?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .env("NETSUKE_NINJA", "/definitely-not-a-ninja-binary") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run netsuke help targets")?; + ensure!( + output.status.success(), + "help targets should succeed without starting Ninja; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + ensure!( + stdout.contains("Actions:") && stdout.contains("Targets:"), + "catalogue should carry both sections: {stdout}" + ); + ensure!( + stdout.contains("Run rustdoc, Clippy, and Whitaker"), + "description should be rendered: {stdout}" + ); + ensure!( + stdout.contains("plain") + && !stdout + .lines() + .any(|line| line.contains("plain") && line.contains("Build the")), + "an undocumented entry should still be listed without a description: {stdout}" + ); + Ok(()) +} + +#[rstest] +fn help_targets_accessible_output_marks_defaults_without_recipes( + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, Utf8PathBuf)>, +) -> Result<()> { + let (temp, manifest_path) = fixture?; + let temp_path = Utf8Path::from_path(temp.path()).context("temporary path should be UTF-8")?; + let workspace = Dir::open_ambient_dir(temp_path, ambient_authority()) + .context("open accessible help-targets fixture directory")?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .current_dir(temp_path) + .arg("--accessibility") + .arg("on") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run accessible netsuke help targets")?; + ensure!( + output.status.success(), + "accessible help targets should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + ensure!( + stdout.contains("[* default]"), + "accessible catalogue should use the ASCII default marker: {stdout}" + ); + assert_fixture_recipes_not_run(&workspace) +} + +#[rstest] +fn help_targets_localizes_output_without_recipes( + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, Utf8PathBuf)>, +) -> Result<()> { + let (temp, manifest_path) = fixture?; + let temp_path = Utf8Path::from_path(temp.path()).context("temporary path should be UTF-8")?; + let workspace = Dir::open_ambient_dir(temp_path, ambient_authority()) + .context("open localized help-targets fixture directory")?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .current_dir(temp_path) + .arg("--locale") + .arg("es-ES") + .arg("--emoji") + .arg("always") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run localized netsuke help targets")?; + ensure!( + output.status.success(), + "localized help targets should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in ["Acciones:", "Objetivos:", "[★ predeterminado]"] { + ensure!( + stdout.contains(expected), + "localized catalogue should contain {expected:?}: {stdout}" + ); + } + assert_fixture_recipes_not_run(&workspace) +} + +#[rstest] +fn help_targets_json_reports_command_identifier( + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, Utf8PathBuf)>, +) -> Result<()> { + let (temp, manifest_path) = fixture?; + let temp_path = Utf8Path::from_path(temp.path()).context("temporary path should be UTF-8")?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .current_dir(temp_path) + .arg("--json") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run netsuke --json help targets")?; + ensure!( + output.status.success(), + "help targets --json should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).context("stdout should be valid UTF-8")?; + let result: Value = + serde_json::from_str(&stdout).context("stdout should be one JSON document")?; + ensure!( + result.pointer("/result/command").and_then(Value::as_str) == Some("help-targets"), + "JSON result should identify the help-targets command: {result}" + ); + ensure!( + result + .pointer("/result/actions") + .and_then(Value::as_array) + .is_some_and(|actions| actions + .iter() + .any(|entry| { entry.pointer("/name").and_then(Value::as_str) == Some("lint") })), + "JSON result should list the lint action: {result}" + ); + ensure!( + result + .pointer("/result/targets") + .and_then(Value::as_array) + .is_some_and(|targets| targets.iter().any(|entry| { + entry.pointer("/name").and_then(Value::as_str) == Some("target/release/catnap") + })), + "JSON result should list the release target: {result}" + ); + Ok(()) +} + +#[rstest] +fn help_targets_honours_directory_flag( + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, Utf8PathBuf)>, +) -> Result<()> { + let (temp, _manifest_path) = fixture?; + let temp_path = Utf8Path::from_path(temp.path()).context("temporary path should be UTF-8")?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .arg("-C") + .arg(temp_path) + .arg("help") + .arg("targets") + .output() + .context("run netsuke -C

help targets")?; + ensure!( + output.status.success(), + "help targets with -C should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + ensure!( + stdout.contains("Actions:") && stdout.contains("Targets:"), + "catalogue should carry both sections: {stdout}" + ); + ensure!( + stdout.contains("lint") && stdout.contains("target/release/catnap"), + "catalogue should list the fixture names: {stdout}" + ); + Ok(()) +} + +#[rstest] +fn help_targets_renders_foreach_descriptions_without_changing_rule_progress() -> Result<()> { + let temp = tempfile::tempdir().context("create foreach help-targets workspace")?; + let temp_path = Utf8Path::from_path(temp.path()).context("temporary path should be UTF-8")?; + let manifest_path = temp_path.join("Netsukefile"); + let workspace = Dir::open_ambient_dir(temp_path, ambient_authority()) + .context("open foreach help-targets fixture directory")?; + workspace + .write( + "Netsukefile", + r#"netsuke_version: "1.0.0" +rules: + - name: render-report + description: Render reports through the shared rule + command: touch $out +actions: + - name: check-{{ item }} + description: Run {{ item }} + command: touch action-{{ item }} + foreach: + - unit + - integration +targets: + - name: report-{{ item }} + description: Build the {{ item }} report + rule: render-report + foreach: + - weekly + - monthly +"#, + ) + .context("write foreach manifest")?; + assert_foreach_help_catalogue(&manifest_path)?; + let manifest = manifest::from_path(&manifest_path)?; + let graph = BuildGraph::from_manifest(&manifest).context("generate foreach graph")?; + let ninja = ninja_gen::generate(&graph).context("generate foreach Ninja manifest")?; + ensure!( + ninja.contains("description = Render reports through the shared rule"), + "Ninja should retain the rule progress description: {ninja}" + ); + ensure!( + !ninja.contains("Build the weekly report") && !ninja.contains("Build the monthly report"), + "target discovery descriptions must not replace Ninja progress: {ninja}" + ); + ensure!( + workspace.open("action-unit").is_err() + && workspace.open("action-integration").is_err() + && workspace.open("report-weekly").is_err() + && workspace.open("report-monthly").is_err(), + "help targets must not execute action or target recipes" + ); + Ok(()) +} + +#[rstest] +fn help_targets_rejects_impure_description_without_creating_outputs() -> Result<()> { + let temp = tempfile::tempdir().context("create impure-query help-targets workspace")?; + let temp_path = Utf8Path::from_path(temp.path()).context("temporary path should be UTF-8")?; + let manifest_path = temp_path.join("Netsukefile"); + let workspace = Dir::open_ambient_dir(temp_path, ambient_authority()) + .context("open impure-query help-targets fixture directory")?; + workspace + .write( + "Netsukefile", + r#"netsuke_version: "1.0.0" +actions: + - name: query-environment + description: "{{ env('PATH') }}" + command: touch lint-ran +targets: + - name: generated-file + description: Generate the file + command: touch release-ran +defaults: + - query-environment +"#, + ) + .context("write impure-query manifest")?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .current_dir(temp_path) + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run help targets against impure description")?; + ensure!( + !output.status.success(), + "help targets must reject a manifest query that reads the environment" + ); + ensure!( + output.stdout.is_empty(), + "failed help targets must not write a partial catalogue: {}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + ensure!( + stderr.contains("Deserializing and rendering manifest values") + && stderr.contains("Failed to load manifest") + && !stderr.contains("Building and validating dependency graph"), + "disabled helper should reject the manifest while descriptions render: {stderr}" + ); + ensure!( + workspace.open("lint-ran").is_err() && workspace.open("release-ran").is_err(), + "rejected help targets must not execute manifest recipes" + ); + ensure!( + workspace.open(".netsuke").is_err(), + "rejected help targets must not create a build-output directory" + ); + Ok(()) +} + +#[rstest] +#[case("build", "Usage: build")] +#[case("clean", "Usage: clean")] +#[case("graph", "Usage: graph")] +#[case("generate", "Usage: generate")] +fn nested_help_topics_render_at_the_command_boundary( + #[case] topic: &str, + #[case] expected: &str, +) -> Result<()> { + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .arg("help") + .arg(topic) + .output() + .with_context(|| format!("run help topic {topic}"))?; + ensure!( + output.status.success(), + "help topic {topic} should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + ensure!( + stdout.contains(expected), + "help topic {topic} should render its command text: {stdout}" + ); + Ok(()) +} diff --git a/tests/workflow_build_and_package.rs b/tests/workflow_build_and_package.rs index eabf50a76..0afd6bc57 100644 --- a/tests/workflow_build_and_package.rs +++ b/tests/workflow_build_and_package.rs @@ -274,6 +274,11 @@ fn behavioural_staging_runs_for_every_platform(#[case] step_name: &str) { #[case("target/orthohelp/{target}/release/powershell/Netsuke/Netsuke.psd1")] #[case("target/orthohelp/{target}/release/powershell/Netsuke/en-US/Netsuke-help.xml")] #[case("target/orthohelp/{target}/release/powershell/Netsuke/en-US/about_Netsuke.help.txt")] +#[case("target/generated-completions/{target}/release/netsuke.bash")] +#[case("target/generated-completions/{target}/release/netsuke.elv")] +#[case("target/generated-completions/{target}/release/netsuke.fish")] +#[case("target/generated-completions/{target}/release/_netsuke.ps1")] +#[case("target/generated-completions/{target}/release/_netsuke")] fn release_staging_declares_orthohelp_outputs(#[case] expected_source: &str) -> Result<()> { let config = staging_config()?; let sources = artefact_sources(&config)?;