From 63ed915b04ccb63c5e6e513afa66e13638ab3aa6 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 9 Aug 2026 20:20:59 +0200 Subject: [PATCH 01/61] Add optional description field to manifest targets Targets now accept an optional `description` field that is rendered through the normal Jinja pipeline, mirroring `Rule::description`. Actions inherit the field because they deserialize as phony targets. The description is discovery metadata surfaced by `netsuke help targets`; it never replaces a referenced rule description in Ninja progress output. The `foreach` and `when` expansion clones whole entry maps, so the description key is carried through each iteration and dropped with filtered entries without extra logic. Parser, action, render, and expansion tests cover present, absent, rendered, and malformed descriptions. Co-Authored-By: Claude --- src/ast.rs | 10 ++ .../expand_test_cases/description_cases.rs | 121 ++++++++++++++++++ src/manifest/expand_tests.rs | 3 + src/manifest/render.rs | 11 ++ tests/ast_tests.rs | 3 + tests/ast_tests/actions.rs | 23 ++++ tests/ast_tests/descriptions.rs | 73 +++++++++++ 7 files changed, 244 insertions(+) create mode 100644 src/manifest/expand_test_cases/description_cases.rs create mode 100644 tests/ast_tests/descriptions.rs diff --git a/src/ast.rs b/src/ast.rs index bc540a7b1..f76e2f576 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -251,6 +251,16 @@ pub struct Target { /// Force the recipe to run even if the outputs are up to date. #[serde(default)] pub always: bool, + + /// Optional human-friendly summary of the public operation this target + /// performs. + /// + /// 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, } /// A helper for fields that accept either a single string or a list of 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_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/render.rs b/src/manifest/render.rs index 54caced25..67af25a81 100644 --- a/src/manifest/render.rs +++ b/src/manifest/render.rs @@ -56,6 +56,11 @@ fn render_rule(rule: &mut crate::ast::Rule, env: &Environment, vars: &Vars) -> R fn render_target(target: &mut Target, env: &Environment) -> Result<()> { render_vars(&mut target.vars, env)?; + if let Some(desc) = &mut target.description { + *desc = render_str_with(env, desc, &target.vars, || { + "render target description".into() + })?; + } 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)?; @@ -210,6 +215,7 @@ mod tests { vars: target_vars, phony: false, always: false, + description: Some("{{ message }}".into()), }; let rule = Rule { @@ -275,6 +281,11 @@ mod tests { 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"), 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..7ea6b52e8 --- /dev/null +++ b/tests/ast_tests/descriptions.rs @@ -0,0 +1,73 @@ +//! 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" + "#; + ensure!( + parse_manifest(yaml).is_err(), + "duplicate target description should fail" + ); + } + + { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + description: "Build it" + explanation: "unknown metadata" + command: "echo hi" + "#; + ensure!( + parse_manifest(yaml).is_err(), + "unknown target field alongside description should fail" + ); + } + Ok(()) +} From 02792a1f487cdff9db00af42fc727050e618e630 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 9 Aug 2026 20:21:16 +0200 Subject: [PATCH 02/61] Add netsuke help targets subcommand Introduce a `help` command that owns the name previously held by clap's implicit pseudo-subcommand, with a `targets` topic plus the existing subcommand names as topics. `help` with no topic renders the same localized long help as `--help`; `help targets` loads, expands, renders, and validates the selected manifest without invoking Ninja, then prints a deterministic catalogue of actions and targets with their descriptions. The catalogue is sourced from the rendered manifest in declaration order, rendered as aligned text columns or a versioned JSON document, and honours the normal colour, accessibility, locale, and `--file`/`--directory` conventions. New Fluent keys cover the subcommand copy, catalogue headings, the default marker, and the pipeline tool label, with translations in every locale. The parser and l10n Subcommand types move HelpArgs/HelpTopic into their own module so parser.rs stays within the repository's 400-line budget. Co-Authored-By: Claude --- locales/ar/messages.ftl | 8 ++ locales/cs/messages.ftl | 8 ++ locales/cy/messages.ftl | 8 ++ locales/da/messages.ftl | 8 ++ locales/de/messages.ftl | 8 ++ locales/el/messages.ftl | 8 ++ locales/en-GB/messages.ftl | 8 ++ locales/en-US/messages.ftl | 8 ++ locales/es-419/messages.ftl | 8 ++ locales/es-ES/messages.ftl | 8 ++ locales/fa/messages.ftl | 8 ++ locales/fi/messages.ftl | 8 ++ locales/fr/messages.ftl | 8 ++ locales/gd/messages.ftl | 8 ++ locales/he/messages.ftl | 8 ++ locales/hi/messages.ftl | 8 ++ locales/hu/messages.ftl | 8 ++ locales/id/messages.ftl | 8 ++ locales/it/messages.ftl | 8 ++ locales/ja/messages.ftl | 8 ++ locales/ko/messages.ftl | 8 ++ locales/nb/messages.ftl | 8 ++ locales/nl/messages.ftl | 8 ++ locales/pl/messages.ftl | 8 ++ locales/pt-BR/messages.ftl | 8 ++ locales/pt-PT/messages.ftl | 8 ++ locales/ro/messages.ftl | 8 ++ locales/ru/messages.ftl | 8 ++ locales/sv/messages.ftl | 8 ++ locales/th/messages.ftl | 8 ++ locales/tr/messages.ftl | 8 ++ locales/uk/messages.ftl | 8 ++ locales/vi/messages.ftl | 8 ++ locales/zh-Hans/messages.ftl | 8 ++ locales/zh-Hant/messages.ftl | 8 ++ src/cli/help.rs | 35 +++++ src/cli/mod.rs | 2 + src/cli/parser.rs | 34 ++++- src/cli_l10n.rs | 6 +- src/localization/keys.rs | 6 + src/runner/dispatch.rs | 16 ++- src/runner/help.rs | 253 +++++++++++++++++++++++++++++++++++ src/runner/mod.rs | 1 + 43 files changed, 629 insertions(+), 4 deletions(-) create mode 100644 src/cli/help.rs create mode 100644 src/runner/help.rs diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 47bbeafa0..dfd27453f 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = الافتراضي # نص المساعدة لخيارات الأمر الفرعي build. cli.subcommand.build.flag.targets.help = الأهداف المطلوب بناؤها (تُستخدم افتراضيات ملف البيانات عند الإغفال). @@ -368,6 +375,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..1f83b6fe7 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -32,6 +32,13 @@ 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 = Vytiskne 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.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). @@ -368,6 +375,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 = Nápověda 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..68d8088a4 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -32,6 +32,13 @@ 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 help lefel uchaf, neu help 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.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). @@ -368,6 +375,7 @@ status.tool.clean = Glanhau status.tool.graph = Graff status.tool.graph_html = Graff (HTML) status.tool.generate = Cynhyrchu +status.tool.help_targets = Help 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..2d26c052c 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -32,6 +32,13 @@ 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ælpen for 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.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). @@ -368,6 +375,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 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..6e5f6bb94 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -32,6 +32,13 @@ 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 = Zeigt die Hilfe auf oberster Ebene oder die Hilfe für ein benanntes Thema. +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.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). @@ -368,6 +375,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..172f1ace1 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = προεπιλογή # Κείμενο βοήθειας για τις επιλογές της υποεντολής build. cli.subcommand.build.flag.targets.help = Στόχοι προς δόμηση (αν παραλειφθούν, χρησιμοποιούνται οι προεπιλογές του δηλωτικού). @@ -369,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/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 279abc6ee..8ba65a347 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = default # Build subcommand flag help text. cli.subcommand.build.flag.targets.help = Targets to build (uses manifest defaults if omitted). @@ -368,6 +375,7 @@ status.tool.clean = Clean status.tool.graph = Graph status.tool.graph_html = Graph (HTML) status.tool.generate = Generate +status.tool.help_targets = Help targets # 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..a8f5a0c13 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = default # Build subcommand flag help text. cli.subcommand.build.flag.targets.help = Targets to build (uses manifest defaults if omitted). @@ -368,6 +375,7 @@ status.tool.clean = Clean status.tool.graph = Graph status.tool.graph_html = Graph (HTML) status.tool.generate = Generate +status.tool.help_targets = Help targets # 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..243e0bbf4 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -32,6 +32,13 @@ 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 = Imprime 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.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). @@ -369,6 +376,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..f1e9f79a3 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -32,6 +32,13 @@ 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 = Imprime 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.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). @@ -368,6 +375,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..82369e798 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = پیش‌فرض # متن راهنمای گزینه‌های زیرفرمان build. cli.subcommand.build.flag.targets.help = هدف‌هایی که باید ساخته شوند (در صورت نیامدن، پیش‌فرض‌های مانیفست به کار می‌روند). @@ -368,6 +375,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..c4856ce6c 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = oletus # build-alikomennon valitsimien ohjeteksti. cli.subcommand.build.flag.targets.help = Koostettavat kohteet (jos puuttuu, käytetään manifestin oletuskohteita). @@ -368,6 +375,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..4b4abcc9f 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -32,6 +32,13 @@ 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 = Affiche 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.default_marker = 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). @@ -369,6 +376,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 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..5a1eb6fd4 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -32,6 +32,13 @@ 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.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). @@ -368,6 +375,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..8425b3e51 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = ברירת מחדל # טקסט העזרה של אפשרויות פקודת המשנה build. cli.subcommand.build.flag.targets.help = היעדים שיש לבנות (בהשמטה נעשה שימוש בברירות המחדל של המניפסט). @@ -368,6 +375,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..48d5f6224 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = डिफ़ॉल्ट # build उपआदेश के विकल्पों का सहायता पाठ। cli.subcommand.build.flag.targets.help = बनाए जाने वाले लक्ष्य (न बताए जाने पर मैनिफ़ेस्ट के डिफ़ॉल्ट लिए जाते हैं)। @@ -368,6 +375,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..2325bb352 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -32,6 +32,13 @@ 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 = Kiírja a felső szintű súgót, vagy a megnevezett téma súgóját. +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.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). @@ -368,6 +375,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..e03bb2f93 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -32,6 +32,13 @@ 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 file yang dipilih. + +# Help catalogue headings and markers. +cli.help.actions_heading = Tindakan: +cli.help.targets_heading = Target: +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). @@ -368,6 +375,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..cf59d6302 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -32,6 +32,13 @@ 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 nominato. +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.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). @@ -369,6 +376,7 @@ status.tool.clean = Pulizia status.tool.graph = Grafo status.tool.graph_html = Grafo (HTML) status.tool.generate = Generazione +status.tool.help_targets = Guida 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..4626ecf9c 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = 既定 # build サブコマンドのオプションのヘルプ文。 cli.subcommand.build.flag.targets.help = ビルドするターゲット(省略時はマニフェストの既定値を使用)。 @@ -368,6 +375,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..8f2c3fd49 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = 기본값 # build 하위 명령 옵션의 도움말. cli.subcommand.build.flag.targets.help = 빌드할 대상입니다(생략하면 매니페스트의 기본값을 사용). @@ -368,6 +375,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..be0d9e119 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -32,6 +32,13 @@ 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 den valgte filen. + +# Help catalogue headings and markers. +cli.help.actions_heading = Handlinger: +cli.help.targets_heading = Mål: +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). @@ -368,6 +375,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..e7e128ec5 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -32,6 +32,13 @@ 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 bestand af te drukken. + +# Help catalogue headings and markers. +cli.help.actions_heading = Acties: +cli.help.targets_heading = Doelen: +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). @@ -368,6 +375,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..875ca08a2 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -32,6 +32,13 @@ 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świetla 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 pliku. + +# Help catalogue headings and markers. +cli.help.actions_heading = Akcje: +cli.help.targets_heading = Cele: +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). @@ -368,6 +375,7 @@ status.tool.clean = Czyszczenie status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generowanie +status.tool.help_targets = Pomoc 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..45b9a3b3b 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -32,6 +32,13 @@ 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 = Imprime 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 arquivo selecionado. + +# Help catalogue headings and markers. +cli.help.actions_heading = Ações: +cli.help.targets_heading = Alvos: +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). @@ -369,6 +376,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 de 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..3351e01ec 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -32,6 +32,13 @@ 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 = Imprime 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 acções do ficheiro selecionado. + +# Help catalogue headings and markers. +cli.help.actions_heading = Acções: +cli.help.targets_heading = Alvos: +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). @@ -369,6 +376,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 de 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..898f1ce46 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -32,6 +32,13 @@ 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 numit. +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.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). @@ -368,6 +375,7 @@ status.tool.clean = Curățare status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generare +status.tool.help_targets = Ajutor ținte # 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..84bbc37ec 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = по умолчанию # Текст справки для параметров подкоманды build. cli.subcommand.build.flag.targets.help = Цели для сборки (если не указаны, берутся цели манифеста по умолчанию). @@ -368,6 +375,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..29a4b5b53 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -32,6 +32,13 @@ 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.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). @@ -368,6 +375,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..944a57d2e 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = ค่าเริ่มต้น # ข้อความช่วยเหลือของตัวเลือกในคำสั่งย่อย build cli.subcommand.build.flag.targets.help = เป้าหมายที่จะสร้าง (หากละไว้ จะใช้ค่าโดยปริยายของไฟล์รายการ) @@ -368,6 +375,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..55f024f86 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -32,6 +32,13 @@ 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ır. +cli.subcommand.help.long_about = Konu olmadan bu, `--help` ile aynıdır. Seçilen dosya 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.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). @@ -368,6 +375,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..0a5b6d595 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = за замовчуванням # Текст довідки для параметрів підкоманди build. cli.subcommand.build.flag.targets.help = Цілі для збирання (якщо не вказано, беруться типові цілі маніфесту). @@ -368,6 +375,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..09f7c50bb 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -32,6 +32,13 @@ 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 đã chọn. + +# Help catalogue headings and markers. +cli.help.actions_heading = Hành động: +cli.help.targets_heading = Mục tiêu: +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). @@ -368,6 +375,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..88188bbb3 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = 默认 # build 子命令选项的帮助文本。 cli.subcommand.build.flag.targets.help = 要构建的目标(省略时使用清单中的默认目标)。 @@ -367,6 +374,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..3f4e96d19 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -32,6 +32,13 @@ 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.default_marker = 預設 # build 子命令選項的說明文字。 cli.subcommand.build.flag.targets.help = 要建置的目標(省略時採用資訊清單的預設值)。 @@ -367,6 +374,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/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..d9b74bc1c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -13,6 +13,7 @@ mod constants; mod diag; mod discovery; mod environment; +mod help; mod merge; mod parser; mod parsing; @@ -23,6 +24,7 @@ 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, diff --git a/src/cli/parser.rs b/src/cli/parser.rs index b1e10970c..768d28e62 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -34,6 +34,25 @@ pub use crate::cli_l10n::{json_hint_from_args, locale_hint_from_args}; use crate::host_pattern::HostPattern; use crate::theme::ThemePreference; + +//! Clap-facing parser types and localisation helpers. +//! +//! This module owns the runtime-visible [`Cli`] struct and all associated +//! Clap definitions ([`BuildArgs`], [`Commands`]). It also provides +//! [`parse_with_localizer_from`], which localises the Clap command, installs +//! localisation-aware [`LocalizedValueParser`] instances for every typed +//! argument, and returns `(Cli, ArgMatches)` for downstream processing. +//! +//! **Pipeline position:** parsing layer. +//! +//! - Receives raw `OsStr` arguments from the process entry point. +//! - Delegates value validation to [`super::parsing`] helpers. +//! - Returns a `Cli`/`ArgMatches` pair consumed by [`super::merge`]. +//! +//! [`LocalizedValueParser`]: self::LocalizedValueParser +}; +pub use crate::cli_l10n::{json_hint_from_args, locale_hint_from_args}; + #[derive(Clone)] struct LocalizedValueParser { localizer: Arc, @@ -79,7 +98,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 +323,12 @@ pub enum Commands { #[arg(long, value_name = "FILE")] output: Option, }, + + /// Print the top-level help, or the help for a named topic. + /// + /// 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_l10n.rs b/src/cli_l10n.rs index 625b3b84a..405e15c0e 100644 --- a/src/cli_l10n.rs +++ b/src/cli_l10n.rs @@ -130,6 +130,7 @@ enum Subcommand { Clean, Graph, Generate, + Help, } impl Subcommand { @@ -139,6 +140,7 @@ impl Subcommand { "clean" => Some(Self::Clean), "graph" => Some(Self::Graph), "generate" => Some(Self::Generate), + "help" => Some(Self::Help), _ => None, } } @@ -150,7 +152,7 @@ fn flag_help_key(arg_id: &str, subcommand: Option) -> Option<&'stati 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 +207,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 +217,7 @@ 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, } } diff --git a/src/localization/keys.rs b/src/localization/keys.rs index b91622878..9882e3531 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -37,6 +37,11 @@ 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_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", @@ -325,6 +330,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/runner/dispatch.rs b/src/runner/dispatch.rs index 45451cff7..a9990a3f0 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,18 @@ 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), + } +} + +fn execute_help(cli: &Cli, args: &HelpArgs, context: &ExecutionContext<'_>) -> Result<()> { + match args.topic { + None => help::render_root_help(), + Some(HelpTopic::Targets) => help::handle_help_targets(cli, context.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..082ff89fa --- /dev/null +++ b/src/runner/help.rs @@ -0,0 +1,253 @@ +//! 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 tracing::info; + +use crate::ast::{NetsukeManifest, Target}; +use crate::cli::Cli; +use crate::cli_l10n::localize_command; +use crate::ir::BuildGraph; +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::path_helpers::{ensure_manifest_exists_or_error, resolve_manifest_path}; +use super::{load_manifest_with_stage_reporting, process}; + +/// One catalogue row: a single resolved target name with its metadata. +struct HelpEntry { + name: String, + description: Option, + is_action: bool, + is_default: bool, +} + +/// 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; 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<()> { + info!( + target: "netsuke::subcommand", + subcommand = "help-targets", + "Rendering target and action catalogue" + ); + let manifest_path = resolve_manifest_path(cli)?; + ensure_manifest_exists_or_error(cli, reporter, &manifest_path)?; + let policy = cli + .network_policy() + .context(localization::message(keys::RUNNER_CONTEXT_NETWORK_POLICY))?; + let manifest = load_manifest_with_stage_reporting(&manifest_path, policy, reporter)?; + + report_pipeline_stage(reporter, PipelineStage::IrGenerationValidation, None); + // 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 status_key: LocalizationKey = keys::STATUS_TOOL_HELP_TARGETS.into(); + report_pipeline_stage(reporter, PipelineStage::GraphRendering, Some(status_key)); + + let entries = build_catalogue(&manifest); + if cli.json { + let rendered = render_json(&entries).context("serialize help targets catalogue")?; + process::write_text_stdout(&rendered)?; + } else { + let rendered = render_text(&entries, resolved_prefs(cli)); + process::write_text_stdout(&rendered)?; + } + reporter.report_complete(status_key); + Ok(()) +} + +/// 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) +} + +/// 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. +fn build_catalogue(manifest: &NetsukeManifest) -> Vec { + let mut entries = Vec::new(); + for target in &manifest.actions { + append_target_entries(&mut entries, target, true, &manifest.defaults); + } + for target in &manifest.targets { + append_target_entries(&mut entries, target, false, &manifest.defaults); + } + entries +} + +fn append_target_entries( + entries: &mut Vec, + target: &Target, + is_action: bool, + defaults: &[String], +) { + for name in target.name.to_string_vec() { + entries.push(HelpEntry { + is_default: defaults.iter().any(|default| default == &name), + name, + description: target.description.clone(), + is_action, + }); + } +} + +/// 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 stays an empty column so the entry is never +/// hidden. 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 width = entries + .iter() + .map(|entry| entry.name.len()) + .max() + .unwrap_or(0); + let marker = default_marker(prefs); + for entry in entries { + let name_column = format!(" {: 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, true), + targets: json_entries(entries, false), + }, + }) + .context("serialize help targets catalogue") +} + +fn json_entries(entries: &[HelpEntry], is_action: bool) -> Vec> { + entries + .iter() + .filter(|entry| entry.is_action == is_action) + .map(|entry| HelpEntryJson { + name: &entry.name, + description: entry.description.as_deref(), + default: entry.is_default, + }) + .collect() +} + +#[cfg(test)] +#[path = "help_tests.rs"] +mod tests; diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 085df3233..9c298131f 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -40,6 +40,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)] From 3706808cc4ec7c1f2a6812a94edae83e0540f681 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 9 Aug 2026 20:21:37 +0200 Subject: [PATCH 03/61] Cover help targets with snapshot, integration, and BDD tests Add unit snapshot tests for the catalogue renderer covering standard text, accessible ASCII, localized Spanish, and JSON output. Add in-process and subprocess integration tests for the dispatch, `--file`/`-C` selection, the JSON envelope's `help-targets` command identifier, and error handling. Add BDD scenarios for parsing `help targets` with alternate manifests and directories, a bare `help` command, and a full-process run asserting the described names appear. Regenerate the CLI help snapshots for the new `help` subcommand line. Co-Authored-By: Claude --- src/runner/help_tests.rs | 114 ++++++++++ ...tsuke__cli__parser__tests__help_en_us.snap | 3 +- ...tsuke__cli__parser__tests__help_es_es.snap | 3 +- ...er__help__tests__accessible_catalogue.snap | 13 ++ ...__runner__help__tests__json_catalogue.snap | 44 ++++ ...elp__tests__localized_catalogue_es_es.snap | 13 ++ ...__runner__help__tests__text_catalogue.snap | 13 ++ tests/bdd/steps/cli.rs | 21 +- tests/bdd/steps/cli_verify.rs | 31 ++- tests/bdd/steps/help_targets.rs | 44 ++++ tests/bdd/steps/manifest_command.rs | 2 +- tests/bdd/steps/manifest_command_helpers.rs | 2 +- tests/bdd/steps/mod.rs | 1 + tests/features/cli.feature | 26 +++ tests/features/help_targets.feature | 10 + tests/runner_help_targets_tests.rs | 196 ++++++++++++++++++ 16 files changed, 528 insertions(+), 8 deletions(-) create mode 100644 src/runner/help_tests.rs create mode 100644 src/snapshots/help_targets/netsuke__runner__help__tests__accessible_catalogue.snap create mode 100644 src/snapshots/help_targets/netsuke__runner__help__tests__json_catalogue.snap create mode 100644 src/snapshots/help_targets/netsuke__runner__help__tests__localized_catalogue_es_es.snap create mode 100644 src/snapshots/help_targets/netsuke__runner__help__tests__text_catalogue.snap create mode 100644 tests/bdd/steps/help_targets.rs create mode 100644 tests/features/help_targets.feature create mode 100644 tests/runner_help_targets_tests.rs diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs new file mode 100644 index 000000000..0205d104d --- /dev/null +++ b/src/runner/help_tests.rs @@ -0,0 +1,114 @@ +//! 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::cli_localization::build_localizer; +use crate::localization; +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::Result; +use insta::assert_snapshot; +use std::sync::Arc; +use test_support::fluent::normalize_fluent_isolates; +use test_support::localizer_test_lock; + +/// Parse the fixed fixture manifest and flatten it into catalogue entries. +fn fixture_entries() -> 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 +"#; + let manifest = manifest::from_str(yaml)?; + Ok(build_catalogue(&manifest)) +} + +/// 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) +} + +/// Install the English localizer into the library's own global handle. +/// +/// The test-support helpers install into a separate crate instance in unit-test +/// binaries, so unit tests must set the library localizer directly. +fn en_localizer() -> localization::LocalizerGuard { + set_localizer_for_tests(Arc::from(build_localizer(Some("en-US")))) +} + +#[test] +fn text_catalogue_snapshot() -> Result<()> { + let _lock = localizer_lock(); + let _guard = en_localizer(); + let entries = fixture_entries()?; + let rendered = normalize_fluent_isolates(&render_text( + &entries, + theme_prefs(ThemePreference::Unicode), + )); + snapshot_settings("help_targets").bind(|| { + assert_snapshot!("text_catalogue", rendered); + }); + Ok(()) +} + +#[test] +fn accessible_catalogue_snapshot() -> Result<()> { + let _lock = localizer_lock(); + let _guard = en_localizer(); + let entries = fixture_entries()?; + let rendered = + normalize_fluent_isolates(&render_text(&entries, theme_prefs(ThemePreference::Ascii))); + snapshot_settings("help_targets").bind(|| { + assert_snapshot!("accessible_catalogue", rendered); + }); + Ok(()) +} + +#[test] +fn localized_catalogue_snapshot() -> Result<()> { + let _lock = localizer_lock(); + let _guard = set_localizer_for_tests(Arc::from(build_localizer(Some("es-ES")))); + let entries = fixture_entries()?; + let rendered = normalize_fluent_isolates(&render_text( + &entries, + theme_prefs(ThemePreference::Unicode), + )); + snapshot_settings("help_targets").bind(|| { + assert_snapshot!("localized_catalogue_es_es", rendered); + }); + Ok(()) +} + +#[test] +fn json_catalogue_snapshot() -> Result<()> { + let _lock = localizer_lock(); + let _guard = en_localizer(); + let entries = fixture_entries()?; + let rendered = render_json(&entries)?; + snapshot_settings("help_targets").bind(|| { + assert_snapshot!("json_catalogue", rendered); + }); + 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..caa478611 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 Imprime 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..4d161f6c1 --- /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..a06ca711c --- /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..f897ec2ab --- /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/tests/bdd/steps/cli.rs b/tests/bdd/steps/cli.rs index 344541427..4ee7e3ad5 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_has_no_topic, 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, &HelpTopic::Targets) +} + +#[then] +fn the_help_has_no_topic(world: &TestWorld) -> Result<()> { + verify_help_has_no_topic(world) +} + #[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..239252a78 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,37 @@ impl ExpectedCommand { Self::Clean => "clean", Self::Graph => "graph", Self::Generate => "generate", + Self::Help => "help", } } } +pub(super) fn verify_help_topic(world: &TestWorld, expected: &HelpTopic) -> Result<()> { + let command = get_command(world)?; + let Commands::Help(args) = &command else { + bail!("expected help command, got {command:?}"); + }; + ensure!( + args.topic.as_ref() == Some(expected), + "expected help topic {expected:?}, got {:?}", + args.topic + ); + Ok(()) +} + +pub(super) fn verify_help_has_no_topic(world: &TestWorld) -> Result<()> { + let command = get_command(world)?; + let Commands::Help(args) = &command else { + bail!("expected help command, got {command:?}"); + }; + ensure!( + args.topic.is_none(), + "expected bare help command, got topic {:?}", + 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/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/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs new file mode 100644 index 000000000..29bfc362a --- /dev/null +++ b/tests/runner_help_targets_tests.rs @@ -0,0 +1,196 @@ +//! Integration tests for the in-process `netsuke help targets` subcommand. +//! +//! The `help targets` subcommand loads, expands, renders, and validates the +//! selected manifest without invoking Ninja, then prints the target and action +//! catalogue. These tests verify the dispatch works without Ninja installed, +//! honours `--file` and `-C/--directory`, and emits the expected JSON envelope +//! in `--json` mode. + +use anyhow::{Context, Result, ensure}; +use netsuke::cli::{Cli, Commands, HelpArgs, HelpTopic}; +use netsuke::output_prefs; +use netsuke::runner::run; +use rstest::rstest; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use test_support::{localizer_test_lock, set_en_localizer}; + +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(dir: &Path) -> Result { + let manifest_path = dir.join("Netsukefile"); + std::fs::write( + &manifest_path, + 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 +targets: + - name: target/release/catnap + description: Build the optimized release binary + command: cargo build --release + - name: plain + command: echo plain +defaults: + - lint + - test +"#, + ) + .with_context(|| format!("write manifest to {}", manifest_path.display()))?; + Ok(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") +} + +#[rstest] +fn help_targets_prints_actions_and_targets() -> Result<()> { + let temp = tempfile::tempdir().context("temp dir")?; + let manifest_path = write_help_targets_manifest(temp.path())?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run netsuke help targets")?; + ensure!( + output.status.success(), + "help targets 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("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_json_reports_command_identifier() -> Result<()> { + let temp = tempfile::tempdir().context("temp dir")?; + let manifest_path = write_help_targets_manifest(temp.path())?; + 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() -> Result<()> { + let temp = tempfile::tempdir().context("temp dir")?; + write_help_targets_manifest(temp.path())?; + 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_with_invalid_manifest_reports_error() -> Result<()> { + let temp = tempfile::tempdir().context("temp dir")?; + let manifest_path = temp.path().join("Netsukefile"); + std::fs::copy("tests/data/invalid_version.yml", &manifest_path) + .with_context(|| format!("copy invalid manifest to {}", manifest_path.display()))?; + let cli = Cli { + file: manifest_path, + command: Some(Commands::Help(HelpArgs { + topic: Some(HelpTopic::Targets), + })), + ..Cli::default() + }; + let Err(_) = run_help_targets(&cli) else { + anyhow::bail!("expected help targets to fail with invalid manifest"); + }; + Ok(()) +} + +#[rstest] +fn plain_help_matches_minimal_workspace() -> Result<()> { + let (temp, manifest_path) = create_test_manifest()?; + let cli = Cli { + file: manifest_path, + directory: Some(temp.path().to_path_buf()), + command: Some(Commands::Help(HelpArgs { topic: None })), + ..Cli::default() + }; + run_help_targets(&cli)?; + Ok(()) +} From 537955f59e19f01430ec70425bf931199e59942d Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 9 Aug 2026 20:21:57 +0200 Subject: [PATCH 04/61] Document target descriptions and the help targets command Explain in the users guide that targets and actions accept an optional `description` used as discovery metadata by `netsuke help targets`, distinct from a rule description that drives Ninja progress. Add the `help` command to the subcommand list and a worked example under artefact inspection, wired as a tested example. Record the Whitaker exemptions for the new integration test crate and BDD steps module, and add the living execplan for this work. Co-Authored-By: Claude --- docs/execplans/fef13161.md | 207 ++++++++++++++++++++++++++ docs/users-guide.md | 32 ++++ dylint.toml | 2 + tests/documentation_examples_tests.rs | 17 +++ 4 files changed, 258 insertions(+) create mode 100644 docs/execplans/fef13161.md diff --git a/docs/execplans/fef13161.md b/docs/execplans/fef13161.md new file mode 100644 index 000000000..f6789a8bf --- /dev/null +++ b/docs/execplans/fef13161.md @@ -0,0 +1,207 @@ +# 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: IN PROGRESS + +## 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. + +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 `[default]` +marker on manifest defaults. + +## Constraints + +- Traditional AST/render/expansion open-source repo layering must be respected: + `src/ast.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 six new keys to + every `locales/*/messages.ftl` 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 34 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); man page and PowerShell help pick up `help` automatically. +- [ ] Run full gates (check-fmt done; nextest, lint, markdownlint, spelling, + nixie), then commit phases. +- [ ] Rename branch, push, create draft PR, run CodeRabbit. + +## 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, so the new keys must introduce no `$` variables to keep all + locale translations simple. Evidence: `build_l10n_audit/compare.rs`. Impact: + keep all six new keys free of Fluent variables. +- 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: + `cli_localization::tracing_tests::a_resolved_locale_reports_ requested_and_effective_tags` + 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. + +## 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/fef13161.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 + +To be completed at the end of the work. + +## 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.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.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`; + confirm man page and PowerShell help pick up the command auto-matically. + +## 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 `[default]` marker 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/users-guide.md b/docs/users-guide.md index 56e8343bb..fc5674906 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -296,6 +296,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 +383,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 +719,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 +849,23 @@ 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 `[default]` marker 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 exactly like a +build, but performs no recipes and creates no build outputs. 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`. + ## Configure Netsuke Configuration precedence, from lowest to highest, is: 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/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 0863a272f..af1ba41c4 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(), &["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")?; From 539cb340be532d3064227f13176c3d705018ccf7 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 9 Aug 2026 20:27:32 +0200 Subject: [PATCH 05/61] Mark the issue-551 execplan complete Record the completed phases, green gates, CodeRabbit result, and the draft PR link, plus the retrospective notes on the pre-existing tracing-test flake and the unit-test localizer quirk. Co-Authored-By: Claude --- docs/execplans/fef13161.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/execplans/fef13161.md b/docs/execplans/fef13161.md index f6789a8bf..437e431de 100644 --- a/docs/execplans/fef13161.md +++ b/docs/execplans/fef13161.md @@ -4,7 +4,7 @@ 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: IN PROGRESS +Status: COMPLETE ## Purpose / big picture @@ -85,9 +85,13 @@ marker on manifest defaults. - [x] (2026-08-09) Phase 4: users-guide updated (schema field, distinction from rule descriptions, subcommand list, worked example + tested-example and its test); man page and PowerShell help pick up `help` automatically. -- [ ] Run full gates (check-fmt done; nextest, lint, markdownlint, spelling, - nixie), then commit phases. -- [ ] Rename branch, push, create draft PR, run CodeRabbit. +- [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, + draft PR created: https://github.com/leynos/netsuke/pull/555. ## Surprises & discoveries @@ -131,7 +135,23 @@ marker on manifest defaults. ## Outcomes & retrospective -To be completed at the end of the work. +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; and the man +page plus PowerShell help pick up the new command surface automatically +through clap derivation (no shell completions exist to update). + +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 From af9750c536a12ec2bf1a69cb681629bd598d7eb6 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 9 Aug 2026 20:28:56 +0200 Subject: [PATCH 06/61] Wrap the PR link in the execplan to satisfy markdownlint Co-Authored-By: Claude --- docs/execplans/fef13161.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/execplans/fef13161.md b/docs/execplans/fef13161.md index 437e431de..c9d8c5cb9 100644 --- a/docs/execplans/fef13161.md +++ b/docs/execplans/fef13161.md @@ -91,7 +91,7 @@ marker on manifest defaults. - [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, - draft PR created: https://github.com/leynos/netsuke/pull/555. + draft PR created: . ## Surprises & discoveries From e9648578f89c730e0f2acea521066edd8ce5688a Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 9 Aug 2026 20:37:14 +0200 Subject: [PATCH 07/61] Merge the help-topic BDD verification helpers Replace the separate `verify_help_topic` and `verify_help_has_no_topic` helpers with a single `verify_help_topic` that takes an `Option<&HelpTopic>` so both the bare-help and topic-bearing steps share one assertion path. The step functions and feature scenarios are unchanged. Co-Authored-By: Claude --- tests/bdd/steps/cli.rs | 8 ++++---- tests/bdd/steps/cli_verify.rs | 17 ++--------------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/tests/bdd/steps/cli.rs b/tests/bdd/steps/cli.rs index 4ee7e3ad5..97de684ff 100644 --- a/tests/bdd/steps/cli.rs +++ b/tests/bdd/steps/cli.rs @@ -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_help_has_no_topic, verify_help_topic, - 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, }; // --------------------------------------------------------------------------- @@ -182,12 +182,12 @@ fn the_command_is_help(world: &TestWorld) -> Result<()> { #[then] fn the_help_topic_is_targets(world: &TestWorld) -> Result<()> { - verify_help_topic(world, &HelpTopic::Targets) + verify_help_topic(world, Some(&HelpTopic::Targets)) } #[then] fn the_help_has_no_topic(world: &TestWorld) -> Result<()> { - verify_help_has_no_topic(world) + verify_help_topic(world, None) } #[then("the manifest path is {path:string}")] diff --git a/tests/bdd/steps/cli_verify.rs b/tests/bdd/steps/cli_verify.rs index 239252a78..93e252b75 100644 --- a/tests/bdd/steps/cli_verify.rs +++ b/tests/bdd/steps/cli_verify.rs @@ -52,32 +52,19 @@ impl ExpectedCommand { } } -pub(super) fn verify_help_topic(world: &TestWorld, expected: &HelpTopic) -> Result<()> { +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() == Some(expected), + args.topic.as_ref() == expected, "expected help topic {expected:?}, got {:?}", args.topic ); Ok(()) } -pub(super) fn verify_help_has_no_topic(world: &TestWorld) -> Result<()> { - let command = get_command(world)?; - let Commands::Help(args) = &command else { - bail!("expected help command, got {command:?}"); - }; - ensure!( - args.topic.is_none(), - "expected bare help command, got topic {:?}", - args.topic - ); - Ok(()) -} - pub(super) fn verify_command(world: &TestWorld, expected: ExpectedCommand) -> Result<()> { let command = get_command(world)?; ensure!( From 6d2c34d3617243068f89e863700cfddd882bf862 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 9 Aug 2026 20:46:33 +0200 Subject: [PATCH 08/61] Deduplicate the help catalogue snapshot test setup Extract the shared localizer-lock, locale-install, fixture, render, and snapshot-assert sequence behind a single `catalogue_snapshot` helper that takes the locale, runtime snapshot name, and a renderer closure. The four snapshot tests become thin wrappers, and the now-unused `en_localizer` helper is removed. Snapshot names, contents, and rendering behaviour are unchanged. Co-Authored-By: Claude --- src/runner/help_tests.rs | 79 ++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 44 deletions(-) diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index 0205d104d..e06943ff7 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -6,7 +6,6 @@ use super::*; use crate::cli_localization::build_localizer; -use crate::localization; use crate::localization::set_localizer_for_tests; use crate::manifest; use crate::snapshot_test_support::{snapshot_settings, theme_prefs}; @@ -50,65 +49,57 @@ fn localizer_lock() -> std::sync::MutexGuard<'static, ()> { localizer_test_lock().unwrap_or_else(std::sync::PoisonError::into_inner) } -/// Install the English localizer into the library's own global handle. +/// Run one catalogue snapshot: install the locale, render through the closure, +/// and bind the snapshot assertion. /// -/// The test-support helpers install into a separate crate instance in unit-test -/// binaries, so unit tests must set the library localizer directly. -fn en_localizer() -> localization::LocalizerGuard { - set_localizer_for_tests(Arc::from(build_localizer(Some("en-US")))) -} - -#[test] -fn text_catalogue_snapshot() -> Result<()> { +/// 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(&[HelpEntry]) -> Result, +) -> Result<()> { let _lock = localizer_lock(); - let _guard = en_localizer(); + let _guard = set_localizer_for_tests(Arc::from(build_localizer(Some(locale)))); let entries = fixture_entries()?; - let rendered = normalize_fluent_isolates(&render_text( - &entries, - theme_prefs(ThemePreference::Unicode), - )); + let rendered = render(&entries)?; snapshot_settings("help_targets").bind(|| { - assert_snapshot!("text_catalogue", rendered); + assert_snapshot!(snapshot_name, rendered); }); Ok(()) } +#[test] +fn text_catalogue_snapshot() -> Result<()> { + catalogue_snapshot("en-US", "text_catalogue", |entries| { + Ok(normalize_fluent_isolates(&render_text( + entries, + theme_prefs(ThemePreference::Unicode), + ))) + }) +} + #[test] fn accessible_catalogue_snapshot() -> Result<()> { - let _lock = localizer_lock(); - let _guard = en_localizer(); - let entries = fixture_entries()?; - let rendered = - normalize_fluent_isolates(&render_text(&entries, theme_prefs(ThemePreference::Ascii))); - snapshot_settings("help_targets").bind(|| { - assert_snapshot!("accessible_catalogue", rendered); - }); - Ok(()) + catalogue_snapshot("en-US", "accessible_catalogue", |entries| { + Ok(normalize_fluent_isolates(&render_text( + entries, + theme_prefs(ThemePreference::Ascii), + ))) + }) } #[test] fn localized_catalogue_snapshot() -> Result<()> { - let _lock = localizer_lock(); - let _guard = set_localizer_for_tests(Arc::from(build_localizer(Some("es-ES")))); - let entries = fixture_entries()?; - let rendered = normalize_fluent_isolates(&render_text( - &entries, - theme_prefs(ThemePreference::Unicode), - )); - snapshot_settings("help_targets").bind(|| { - assert_snapshot!("localized_catalogue_es_es", rendered); - }); - Ok(()) + catalogue_snapshot("es-ES", "localized_catalogue_es_es", |entries| { + Ok(normalize_fluent_isolates(&render_text( + entries, + theme_prefs(ThemePreference::Unicode), + ))) + }) } #[test] fn json_catalogue_snapshot() -> Result<()> { - let _lock = localizer_lock(); - let _guard = en_localizer(); - let entries = fixture_entries()?; - let rendered = render_json(&entries)?; - snapshot_settings("help_targets").bind(|| { - assert_snapshot!("json_catalogue", rendered); - }); - Ok(()) + catalogue_snapshot("en-US", "json_catalogue", render_json) } From 1d0ccea21eab4cf9ec3bfcfa494169d8bdc5b764 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 10 Aug 2026 11:10:00 +0200 Subject: [PATCH 09/61] Extract manifest render description and recipe helpers Reduce the cyclomatic complexity of render_rule and render_target by extracting their shared rendering into private helpers: - render_description renders an optional description through render_str_with, keeping the exact "render rule description" and "render target description" error contexts. - render_recipe renders a Command, Script, or Rule-reference recipe, preserving the existing error contexts and the ins/outs placeholder handling in render_recipe_str_with. Both callers keep their current operation order, template contexts, manifest semantics, and rendered output. A focused unit test covers the Script and Rule-reference recipe branches through render_manifest, which the existing Command-only test did not exercise. --- src/manifest/render.rs | 138 ++++++++++++++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 23 deletions(-) diff --git a/src/manifest/render.rs b/src/manifest/render.rs index 67af25a81..ab9e8a68e 100644 --- a/src/manifest/render.rs +++ b/src/manifest/render.rs @@ -39,46 +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)?; - if let Some(desc) = &mut target.description { - *desc = render_str_with(env, desc, &target.vars, || { - "render target description".into() - })?; - } + 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() { @@ -383,6 +396,85 @@ mod tests { ); Ok(()) } + + #[expect(clippy::panic, reason = "panic for clearer test failures")] + fn expect_script(recipe: &Recipe, label: impl std::fmt::Display) -> &str { + match recipe { + Recipe::Script { script } => script, + other => panic!("expected {label} script recipe, got {other:?}"), + } + } + + #[expect(clippy::panic, reason = "panic for clearer test failures")] + fn expect_rule_ref(recipe: &Recipe, label: impl std::fmt::Display) -> &StringOrList { + match recipe { + Recipe::Rule { rule } => rule, + other => panic!("expected {label} rule-reference recipe, got {other:?}"), + } + } + + #[expect(clippy::panic, reason = "panic for clearer test failures")] + fn assert_rendered_script_and_rule_recipes(rendered: &NetsukeManifest) { + let Some(rendered_target) = rendered.targets.first() else { + panic!("rendered script target missing"); + }; + assert_eq!( + expect_script(&rendered_target.recipe, "rendered script target"), + "echo world" + ); + let Some(rendered_rule) = rendered.rules.first() else { + panic!("rendered rule-reference rule missing"); + }; + assert_eq!( + expect_list( + expect_rule_ref(&rendered_rule.recipe, "rendered rule reference"), + "rule reference names", + ), + ["base"] + ); + } + + #[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(()) + } } #[cfg(test)] From e72dbf244a2882c95ba85c49e5f3c7c3bec68950 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 10 Aug 2026 19:39:46 +0200 Subject: [PATCH 10/61] Align help targets columns by Unicode display width The catalogue name column was padded from byte length, so wide and combining characters misaligned the description column. Measure display width with unicode-width and append padding manually. --- Cargo.lock | 1 + Cargo.toml | 1 + src/runner/help.rs | 10 +++++++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5215dd8a..8001f2e46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1609,6 +1609,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..3605c8468 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,7 @@ 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" diff --git a/src/runner/help.rs b/src/runner/help.rs index 082ff89fa..85cf8e55a 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -9,6 +9,7 @@ use anyhow::{Context, Result}; use clap::CommandFactory; use serde::Serialize; use tracing::info; +use unicode_width::UnicodeWidthStr; use crate::ast::{NetsukeManifest, Target}; use crate::cli::Cli; @@ -174,13 +175,16 @@ fn render_section( out.push('\n'); let width = entries .iter() - .map(|entry| entry.name.len()) + .map(|entry| UnicodeWidthStr::width(entry.name.as_str())) .max() .unwrap_or(0); let marker = default_marker(prefs); for entry in entries { - let name_column = format!(" {: Date: Wed, 12 Aug 2026 00:01:07 +0200 Subject: [PATCH 11/61] Avoid trailing spaces in help catalogues Render the description-column separator only when a description is present, so undocumented catalogue entries stay visible without emitting whitespace. --- src/runner/help.rs | 2 +- .../netsuke__runner__help__tests__accessible_catalogue.snap | 2 +- ...netsuke__runner__help__tests__localized_catalogue_es_es.snap | 2 +- .../netsuke__runner__help__tests__text_catalogue.snap | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/runner/help.rs b/src/runner/help.rs index 85cf8e55a..3f876ff13 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -184,8 +184,8 @@ fn render_section( out.push_str(" "); out.push_str(&entry.name); out.push_str(&" ".repeat(width.saturating_sub(name_width))); - out.push_str(" "); if let Some(description) = &entry.description { + out.push_str(" "); out.push_str(description); } if entry.is_default { 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 index 4d161f6c1..2bd7462f6 100644 --- a/src/snapshots/help_targets/netsuke__runner__help__tests__accessible_catalogue.snap +++ b/src/snapshots/help_targets/netsuke__runner__help__tests__accessible_catalogue.snap @@ -6,7 +6,7 @@ expression: rendered Actions: lint Run rustdoc, Clippy, and Whitaker [* default] test Run unit, behavioural, UI, and documentation tests [* default] - undocumented + undocumented Targets: target/release/catnap Build the optimized release binary 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 index a06ca711c..342328e2a 100644 --- 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 @@ -6,7 +6,7 @@ expression: rendered Acciones: lint Run rustdoc, Clippy, and Whitaker [★ predeterminado] test Run unit, behavioural, UI, and documentation tests [★ predeterminado] - undocumented + undocumented Objetivos: target/release/catnap Build the optimized release binary 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 index f897ec2ab..59d4a8bfd 100644 --- a/src/snapshots/help_targets/netsuke__runner__help__tests__text_catalogue.snap +++ b/src/snapshots/help_targets/netsuke__runner__help__tests__text_catalogue.snap @@ -6,7 +6,7 @@ expression: rendered Actions: lint Run rustdoc, Clippy, and Whitaker [★ default] test Run unit, behavioural, UI, and documentation tests [★ default] - undocumented + undocumented Targets: target/release/catnap Build the optimized release binary From 0df3475dc1137600e189dec2e405c77c82678a3d Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 00:33:26 +0200 Subject: [PATCH 12/61] Resolve help catalogue review findings Keep informational help independent of project configuration and validate catalogue defaults before reporting success. Align documentation, localisation, tests, and capability-scoped fixtures with that contract. --- docs/execplans/fef13161.md | 6 +- docs/netsuke-design.md | 8 +- docs/users-guide.md | 5 +- locales/cy/messages.ftl | 4 +- locales/da/messages.ftl | 4 +- locales/en-GB/messages.ftl | 2 +- locales/en-US/messages.ftl | 2 +- locales/es-419/messages.ftl | 2 +- locales/es-ES/messages.ftl | 2 +- locales/fr/messages.ftl | 4 +- locales/pl/messages.ftl | 2 +- locales/pt-BR/messages.ftl | 4 +- locales/pt-PT/messages.ftl | 4 +- locales/ro/messages.ftl | 2 +- locales/ru/messages.ftl | 2 +- locales/tr/messages.ftl | 2 +- src/ir/from_manifest.rs | 12 ++- src/main.rs | 32 +++++-- src/runner/dispatch.rs | 2 +- src/runner/help.rs | 16 +++- ...tsuke__cli__parser__tests__help_es_es.snap | 2 +- tests/ast_tests/descriptions.rs | 11 ++- tests/novice_flow_smoke_tests.rs | 25 ++++++ tests/runner_help_targets_tests.rs | 88 +++++++++++++++---- 24 files changed, 175 insertions(+), 68 deletions(-) diff --git a/docs/execplans/fef13161.md b/docs/execplans/fef13161.md index c9d8c5cb9..a2be6cd22 100644 --- a/docs/execplans/fef13161.md +++ b/docs/execplans/fef13161.md @@ -18,8 +18,8 @@ then prints the available targets and actions with their descriptions. 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 `[default]` -marker on manifest defaults. +two catalogue sections with aligned name/description columns and a localized +default marker such as `[★ default]` on manifest defaults. ## Constraints @@ -210,7 +210,7 @@ generates Ninja build files. Key files and modules for this task: `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`; - confirm man page and PowerShell help pick up the command auto-matically. + confirm man page and PowerShell help pick up the command automatically. ## Validation and acceptance diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 6d4c7a41f..e93e35f86 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -344,10 +344,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 diff --git a/docs/users-guide.md b/docs/users-guide.md index fc5674906..9c3ba0b54 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -850,8 +850,9 @@ manifest to that file and leaves stdout empty. represent files and are not removed. `help targets` prints the target and action catalogue for the selected -manifest — actions first, then targets — with a `[default]` marker on manifest -defaults and an empty description column for entries without a `description`: +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`: diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 68d8088a4..e840d3c83 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -32,7 +32,7 @@ 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 help lefel uchaf, neu help ar gyfer pwnc a enwir. +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. @@ -375,7 +375,7 @@ status.tool.clean = Glanhau status.tool.graph = Graff status.tool.graph_html = Graff (HTML) status.tool.generate = Cynhyrchu -status.tool.help_targets = Help targedau +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 2d26c052c..e4dff513e 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -32,7 +32,7 @@ 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ælpen for et navngivet emne. +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. @@ -375,7 +375,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 mål +status.tool.help_targets = Hjælp til mål # Tekster til HTML-gengivelsen af grafen. graph.html.title = Netsuke-byggegraf diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 8ba65a347..6661dd15c 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -375,7 +375,7 @@ status.tool.clean = Clean status.tool.graph = Graph status.tool.graph_html = Graph (HTML) status.tool.generate = Generate -status.tool.help_targets = Help targets +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 a8f5a0c13..feb8bbbf0 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -375,7 +375,7 @@ status.tool.clean = Clean status.tool.graph = Graph status.tool.graph_html = Graph (HTML) status.tool.generate = Generate -status.tool.help_targets = Help targets +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 243e0bbf4..1c1c9f576 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -32,7 +32,7 @@ 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 = Imprime la ayuda de nivel superior o la ayuda de un tema determinado. +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. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index f1e9f79a3..b89989d06 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -32,7 +32,7 @@ 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 = Imprime la ayuda de nivel superior o la ayuda de un tema determinado. +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. diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index 4b4abcc9f..933fff83d 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -32,7 +32,7 @@ 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 = Affiche l'aide de premier niveau, ou l'aide d'un sujet nommé. +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. @@ -376,7 +376,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 cibles +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/pl/messages.ftl b/locales/pl/messages.ftl index 875ca08a2..a8b4ceb04 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -375,7 +375,7 @@ status.tool.clean = Czyszczenie status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generowanie -status.tool.help_targets = Pomoc celów +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 45b9a3b3b..4a83af6ee 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -32,7 +32,7 @@ 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 = Imprime a ajuda de nível superior ou a ajuda de um tópico nomeado. +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 arquivo selecionado. # Help catalogue headings and markers. @@ -376,7 +376,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 de alvos +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 3351e01ec..d26d6d641 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -32,7 +32,7 @@ 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 = Imprime a ajuda de nível superior ou a ajuda de um tópico nomeado. +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 acções do ficheiro selecionado. # Help catalogue headings and markers. @@ -376,7 +376,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 de alvos +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 898f1ce46..57037d5e4 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -32,7 +32,7 @@ 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 numit. +cli.subcommand.help.about = Afișați ajutorul de nivel superior sau ajutorul pentru un subiect numit. 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. diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 84bbc37ec..82fa124a0 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -32,7 +32,7 @@ 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.about = Вывести справку верхнего уровня или справку по указанной теме. cli.subcommand.help.long_about = Без темы это соответствует `--help`. Используйте `help targets`, чтобы вывести каталог целей и действий для выбранного файла. # Help catalogue headings and markers. diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 55f024f86..280335507 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -32,7 +32,7 @@ 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ır. +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 dosya için hedef ve eylem kataloğunu yazdırmak üzere `help targets` komutunu kullanın. # Help catalogue headings and markers. 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/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/runner/dispatch.rs b/src/runner/dispatch.rs index a9990a3f0..4f83290b5 100644 --- a/src/runner/dispatch.rs +++ b/src/runner/dispatch.rs @@ -20,7 +20,7 @@ pub(super) fn execute(cli: &Cli, command: Commands, context: &ExecutionContext<' } fn execute_help(cli: &Cli, args: &HelpArgs, context: &ExecutionContext<'_>) -> Result<()> { - match args.topic { + match args.topic.as_ref() { None => help::render_root_help(), Some(HelpTopic::Targets) => help::handle_help_targets(cli, context.reporter), Some(HelpTopic::Build) => help::render_subcommand_help("build"), diff --git a/src/runner/help.rs b/src/runner/help.rs index 3f876ff13..33ed3b756 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -5,7 +5,7 @@ //! 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 anyhow::{Context, Result, ensure}; use clap::CommandFactory; use serde::Serialize; use tracing::info; @@ -63,10 +63,10 @@ pub(super) fn handle_help_targets(cli: &Cli, reporter: &dyn StatusReporter) -> R BuildGraph::from_manifest(&manifest) .context(localization::message(keys::RUNNER_CONTEXT_BUILD_GRAPH))?; + let entries = build_catalogue(&manifest); + validate_defaults(&manifest.defaults, &entries)?; let status_key: LocalizationKey = keys::STATUS_TOOL_HELP_TARGETS.into(); report_pipeline_stage(reporter, PipelineStage::GraphRendering, Some(status_key)); - - let entries = build_catalogue(&manifest); if cli.json { let rendered = render_json(&entries).context("serialize help targets catalogue")?; process::write_text_stdout(&rendered)?; @@ -120,6 +120,16 @@ fn build_catalogue(manifest: &NetsukeManifest) -> Vec { entries } +fn validate_defaults(defaults: &[String], entries: &[HelpEntry]) -> Result<()> { + for default in defaults { + ensure!( + entries.iter().any(|entry| entry.name == *default), + "manifest default '{default}' does not name a declared action or target" + ); + } + Ok(()) +} + fn append_target_entries( entries: &mut Vec, target: &Target, 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 caa478611..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 @@ -12,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 Imprime la ayuda de nivel superior o la ayuda de un tema determinado. + help Imprima la ayuda de nivel superior o la ayuda de un tema determinado. Options: -f, --file diff --git a/tests/ast_tests/descriptions.rs b/tests/ast_tests/descriptions.rs index 7ea6b52e8..72ee3c5a1 100644 --- a/tests/ast_tests/descriptions.rs +++ b/tests/ast_tests/descriptions.rs @@ -49,9 +49,10 @@ fn description_duplicates_and_unknown_fields_are_rejected() -> Result<()> { description: "second" command: "echo hi" "#; + let error = parse_manifest(yaml).expect_err("duplicate target description should fail"); ensure!( - parse_manifest(yaml).is_err(), - "duplicate target description should fail" + format!("{error:?}").contains("description"), + "duplicate-description diagnostic should name the field: {error:?}" ); } @@ -64,9 +65,11 @@ fn description_duplicates_and_unknown_fields_are_rejected() -> Result<()> { explanation: "unknown metadata" command: "echo hi" "#; + let error = parse_manifest(yaml) + .expect_err("unknown target field alongside description should fail"); ensure!( - parse_manifest(yaml).is_err(), - "unknown target field alongside description should fail" + format!("{error:?}").contains("explanation"), + "unknown-field diagnostic should name the field: {error:?}" ); } 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/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index 29bfc362a..f34755e36 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -7,12 +7,14 @@ //! in `--json` mode. use anyhow::{Context, Result, ensure}; +use camino::Utf8Path; +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::rstest; +use rstest::{fixture, rstest}; use serde_json::Value; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use test_support::{localizer_test_lock, set_en_localizer}; mod fixtures; @@ -20,11 +22,15 @@ 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(dir: &Path) -> Result { - let manifest_path = dir.join("Netsukefile"); - std::fs::write( - &manifest_path, - r#"netsuke_version: "1.0.0" +fn write_help_targets_manifest(temp: &tempfile::TempDir) -> Result { + let manifest_path = temp.path().join("Netsukefile"); + 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 help-targets fixture directory")?; + workspace + .write( + "Netsukefile", + r#"netsuke_version: "1.0.0" actions: - name: lint description: Run rustdoc, Clippy, and Whitaker @@ -42,11 +48,18 @@ defaults: - lint - test "#, - ) - .with_context(|| format!("write manifest to {}", manifest_path.display()))?; + ) + .with_context(|| format!("write manifest to {}", manifest_path.display()))?; Ok(manifest_path) } +#[fixture] +fn help_targets_manifest() -> Result<(tempfile::TempDir, PathBuf)> { + 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(); @@ -54,9 +67,10 @@ fn run_help_targets(cli: &Cli) -> Result<()> { } #[rstest] -fn help_targets_prints_actions_and_targets() -> Result<()> { - let temp = tempfile::tempdir().context("temp dir")?; - let manifest_path = write_help_targets_manifest(temp.path())?; +fn help_targets_prints_actions_and_targets( + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, PathBuf)>, +) -> Result<()> { + let (_temp, manifest_path) = fixture?; let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") .arg("--file") .arg(&manifest_path) @@ -89,9 +103,10 @@ fn help_targets_prints_actions_and_targets() -> Result<()> { } #[rstest] -fn help_targets_json_reports_command_identifier() -> Result<()> { - let temp = tempfile::tempdir().context("temp dir")?; - let manifest_path = write_help_targets_manifest(temp.path())?; +fn help_targets_json_reports_command_identifier( + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, PathBuf)>, +) -> Result<()> { + let (temp, manifest_path) = fixture?; let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") .current_dir(temp.path()) .arg("--json") @@ -136,9 +151,10 @@ fn help_targets_json_reports_command_identifier() -> Result<()> { } #[rstest] -fn help_targets_honours_directory_flag() -> Result<()> { - let temp = tempfile::tempdir().context("temp dir")?; - write_help_targets_manifest(temp.path())?; +fn help_targets_honours_directory_flag( + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, PathBuf)>, +) -> Result<()> { + let (temp, _manifest_path) = fixture?; let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") .arg("-C") .arg(temp.path()) @@ -166,8 +182,13 @@ fn help_targets_honours_directory_flag() -> Result<()> { #[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"); - std::fs::copy("tests/data/invalid_version.yml", &manifest_path) + data.copy("invalid_version.yml", &workspace, "Netsukefile") .with_context(|| format!("copy invalid manifest to {}", manifest_path.display()))?; let cli = Cli { file: manifest_path, @@ -182,6 +203,35 @@ fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { Ok(()) } +#[test] +fn help_targets_rejects_unknown_manifest_default() -> Result<()> { + let (temp, manifest_path) = help_targets_manifest()?; + 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 unknown-default fixture directory")?; + workspace + .write( + "Netsukefile", + b"netsuke_version: \"1.0.0\"\nactions:\n - name: lint\n command: cargo clippy\ntargets: []\ndefaults:\n - missing\n", + ) + .context("write unknown-default manifest")?; + let cli = Cli { + file: manifest_path, + command: Some(Commands::Help(HelpArgs { + topic: Some(HelpTopic::Targets), + })), + ..Cli::default() + }; + let error = run_help_targets(&cli).expect_err("unknown manifest default should fail"); + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains("default 'missing'")), + "error should identify the unknown default: {error:?}" + ); + Ok(()) +} + #[rstest] fn plain_help_matches_minimal_workspace() -> Result<()> { let (temp, manifest_path) = create_test_manifest()?; From 05249f5da72e31b4266554b65663947010027b20 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 00:48:17 +0200 Subject: [PATCH 13/61] Correct missing description comment --- src/runner/help.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runner/help.rs b/src/runner/help.rs index 33ed3b756..49be47c2f 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -158,8 +158,8 @@ fn resolved_prefs(cli: &Cli) -> OutputPrefs { /// 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 stays an empty column so the entry is never -/// hidden. Empty sections are omitted. +/// 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(); From 47bfe48b9c02a798799b8f99e82c852b3cd0ba49 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 01:05:34 +0200 Subject: [PATCH 14/61] Localize nested help topics Render localized descriptions for the topics under `netsuke help` and prove the output in English and Spanish. Borrow catalogue descriptions and index defaults once to avoid repeated description cloning and linear lookups. --- src/cli/parser_tests.rs | 41 ++++++++++++++++++++++++++ src/cli_l10n.rs | 62 ++++++++++++++++++++++++++++++++++++++++ src/runner/help.rs | 60 ++++++++++++++++++++------------------ src/runner/help_tests.rs | 29 ++++++++++--------- 4 files changed, 150 insertions(+), 42 deletions(-) diff --git a/src/cli/parser_tests.rs b/src/cli/parser_tests.rs index 416e50747..cf3313317 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", + [ + "Targets:", + "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", + [ + "Objetivos:", + "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_l10n.rs b/src/cli_l10n.rs index 405e15c0e..3b2b578d7 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 @@ -146,6 +175,29 @@ impl Subcommand { } } +/// The topics nested under the `help` subcommand. +#[derive(Clone, Copy)] +enum HelpTopicName { + Targets, + Build, + Clean, + Graph, + Generate, +} + +impl HelpTopicName { + fn from_name(name: &str) -> Option { + match name { + "targets" => Some(Self::Targets), + "build" => Some(Self::Build), + "clean" => Some(Self::Clean), + "graph" => Some(Self::Graph), + "generate" => Some(Self::Generate), + _ => None, + } + } +} + fn flag_help_key(arg_id: &str, subcommand: Option) -> Option<&'static str> { match subcommand { None => top_level_flag_help_key(arg_id), @@ -221,6 +273,16 @@ const fn subcommand_long_about_key(subcommand: Subcommand) -> &'static str { } } +const fn help_topic_about_key(topic: HelpTopicName) -> &'static str { + match topic { + HelpTopicName::Targets => keys::CLI_HELP_TARGETS_HEADING, + HelpTopicName::Build => keys::CLI_SUBCOMMAND_BUILD_ABOUT, + HelpTopicName::Clean => keys::CLI_SUBCOMMAND_CLEAN_ABOUT, + HelpTopicName::Graph => keys::CLI_SUBCOMMAND_GRAPH_ABOUT, + HelpTopicName::Generate => keys::CLI_SUBCOMMAND_GENERATE_ABOUT, + } +} + /// Inspect raw arguments and extract the `--locale` value when present. /// /// When multiple `--locale` flags are provided, the last one is used. diff --git a/src/runner/help.rs b/src/runner/help.rs index 49be47c2f..48451e7cc 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -8,6 +8,7 @@ use anyhow::{Context, Result, ensure}; use clap::CommandFactory; use serde::Serialize; +use std::collections::HashSet; use tracing::info; use unicode_width::UnicodeWidthStr; @@ -26,9 +27,9 @@ use super::path_helpers::{ensure_manifest_exists_or_error, resolve_manifest_path use super::{load_manifest_with_stage_reporting, process}; /// One catalogue row: a single resolved target name with its metadata. -struct HelpEntry { +struct HelpEntry<'a> { name: String, - description: Option, + description: Option<&'a str>, is_action: bool, is_default: bool, } @@ -68,7 +69,7 @@ pub(super) fn handle_help_targets(cli: &Cli, reporter: &dyn StatusReporter) -> R 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(&entries).context("serialize help targets catalogue")?; + let rendered = render_json(entries).context("serialize help targets catalogue")?; process::write_text_stdout(&rendered)?; } else { let rendered = render_text(&entries, resolved_prefs(cli)); @@ -109,38 +110,40 @@ pub(super) fn render_subcommand_help(name: &str) -> Result<()> { /// 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. -fn build_catalogue(manifest: &NetsukeManifest) -> Vec { +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, &manifest.defaults); + append_target_entries(&mut entries, target, true, &defaults); } for target in &manifest.targets { - append_target_entries(&mut entries, target, false, &manifest.defaults); + append_target_entries(&mut entries, target, false, &defaults); } entries } -fn validate_defaults(defaults: &[String], entries: &[HelpEntry]) -> Result<()> { +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 { ensure!( - entries.iter().any(|entry| entry.name == *default), + names.contains(default.as_str()), "manifest default '{default}' does not name a declared action or target" ); } Ok(()) } -fn append_target_entries( - entries: &mut Vec, - target: &Target, +fn append_target_entries<'a>( + entries: &mut Vec>, + target: &'a Target, is_action: bool, - defaults: &[String], + defaults: &HashSet<&str>, ) { for name in target.name.to_string_vec() { entries.push(HelpEntry { - is_default: defaults.iter().any(|default| default == &name), + is_default: defaults.contains(name.as_str()), name, - description: target.description.clone(), + description: target.description.as_deref(), is_action, }); } @@ -160,9 +163,9 @@ fn resolved_prefs(cli: &Cli) -> OutputPrefs { /// 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(); +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() { @@ -174,7 +177,7 @@ fn render_text(entries: &[HelpEntry], prefs: OutputPrefs) -> String { fn render_section( out: &mut String, - entries: &[&HelpEntry], + entries: &[&HelpEntry<'_>], heading_key: &'static str, prefs: OutputPrefs, ) { @@ -194,7 +197,7 @@ fn render_section( out.push_str(" "); out.push_str(&entry.name); out.push_str(&" ".repeat(width.saturating_sub(name_width))); - if let Some(description) = &entry.description { + if let Some(description) = entry.description { out.push_str(" "); out.push_str(description); } @@ -232,31 +235,32 @@ struct HelpTargetsResult<'a> { #[derive(Debug, Serialize)] struct HelpEntryJson<'a> { - name: &'a str, + name: String, description: Option<&'a str>, default: bool, } -fn render_json(entries: &[HelpEntry]) -> Result { +fn render_json(entries: Vec>) -> Result { + let (actions, targets): (Vec<_>, Vec<_>) = + entries.into_iter().partition(|entry| entry.is_action); serde_json::to_string_pretty(&HelpTargetsDocument { schema_version: SCHEMA_VERSION, generator: GeneratorInfo::current(), result: HelpTargetsResult { command: "help-targets", - actions: json_entries(entries, true), - targets: json_entries(entries, false), + actions: json_entries(actions), + targets: json_entries(targets), }, }) .context("serialize help targets catalogue") } -fn json_entries(entries: &[HelpEntry], is_action: bool) -> Vec> { +fn json_entries(entries: Vec>) -> Vec> { entries - .iter() - .filter(|entry| entry.is_action == is_action) + .into_iter() .map(|entry| HelpEntryJson { - name: &entry.name, - description: entry.description.as_deref(), + name: entry.name, + description: entry.description, default: entry.is_default, }) .collect() diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index e06943ff7..61a67798d 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -16,8 +16,8 @@ use std::sync::Arc; use test_support::fluent::normalize_fluent_isolates; use test_support::localizer_test_lock; -/// Parse the fixed fixture manifest and flatten it into catalogue entries. -fn fixture_entries() -> Result> { +/// 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 @@ -38,8 +38,7 @@ defaults: - lint - test "#; - let manifest = manifest::from_str(yaml)?; - Ok(build_catalogue(&manifest)) + manifest::from_str(yaml) } /// Acquire the localizer test lock, recovering from poisoning the way the @@ -57,12 +56,12 @@ fn localizer_lock() -> std::sync::MutexGuard<'static, ()> { fn catalogue_snapshot( locale: &str, snapshot_name: &str, - render: impl FnOnce(&[HelpEntry]) -> Result, + render: impl FnOnce(&NetsukeManifest) -> Result, ) -> Result<()> { let _lock = localizer_lock(); let _guard = set_localizer_for_tests(Arc::from(build_localizer(Some(locale)))); - let entries = fixture_entries()?; - let rendered = render(&entries)?; + let manifest = fixture_manifest()?; + let rendered = render(&manifest)?; snapshot_settings("help_targets").bind(|| { assert_snapshot!(snapshot_name, rendered); }); @@ -71,9 +70,9 @@ fn catalogue_snapshot( #[test] fn text_catalogue_snapshot() -> Result<()> { - catalogue_snapshot("en-US", "text_catalogue", |entries| { + catalogue_snapshot("en-US", "text_catalogue", |manifest| { Ok(normalize_fluent_isolates(&render_text( - entries, + &build_catalogue(manifest), theme_prefs(ThemePreference::Unicode), ))) }) @@ -81,9 +80,9 @@ fn text_catalogue_snapshot() -> Result<()> { #[test] fn accessible_catalogue_snapshot() -> Result<()> { - catalogue_snapshot("en-US", "accessible_catalogue", |entries| { + catalogue_snapshot("en-US", "accessible_catalogue", |manifest| { Ok(normalize_fluent_isolates(&render_text( - entries, + &build_catalogue(manifest), theme_prefs(ThemePreference::Ascii), ))) }) @@ -91,9 +90,9 @@ fn accessible_catalogue_snapshot() -> Result<()> { #[test] fn localized_catalogue_snapshot() -> Result<()> { - catalogue_snapshot("es-ES", "localized_catalogue_es_es", |entries| { + catalogue_snapshot("es-ES", "localized_catalogue_es_es", |manifest| { Ok(normalize_fluent_isolates(&render_text( - entries, + &build_catalogue(manifest), theme_prefs(ThemePreference::Unicode), ))) }) @@ -101,5 +100,7 @@ fn localized_catalogue_snapshot() -> Result<()> { #[test] fn json_catalogue_snapshot() -> Result<()> { - catalogue_snapshot("en-US", "json_catalogue", render_json) + catalogue_snapshot("en-US", "json_catalogue", |manifest| { + render_json(build_catalogue(manifest)) + }) } From 1522b6d09eb837732f3a2321f6f84c7d37d8f602 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 04:02:15 +0200 Subject: [PATCH 15/61] Correct documentation for target descriptions Clarify the current PR publication state and preserve the exact flaky test identifier in the completed execution plan. Align design and roadmap text with the implemented discovery metadata and Ninja rule-progress contracts. --- docs/execplans/fef13161.md | 10 +++++----- docs/netsuke-design.md | 20 ++++++++++++-------- docs/roadmap.md | 15 ++++++++------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/docs/execplans/fef13161.md b/docs/execplans/fef13161.md index a2be6cd22..39e27a1f4 100644 --- a/docs/execplans/fef13161.md +++ b/docs/execplans/fef13161.md @@ -91,7 +91,7 @@ default marker such as `[★ default]` on manifest defaults. - [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, - draft PR created: . + PR opened: . ## Surprises & discoveries @@ -112,10 +112,10 @@ default marker such as `[★ default]` on manifest defaults. 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: - `cli_localization::tracing_tests::a_resolved_locale_reports_ requested_and_effective_tags` - 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 +- 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 diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index e93e35f86..22fb7fd5f 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -599,14 +599,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` diff --git a/docs/roadmap.md b/docs/roadmap.md index 44e74354f..843cb4df6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -254,18 +254,19 @@ 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. + - [x] Add target/action `description` as discovery metadata and list it with + `netsuke help targets`. + - [ ] Add a future target/action description override for the referenced rule + description on a concrete edge, if selected-action progress requires it. - [ ] Report selected action descriptions in normal Ninja progress output. - [ ] 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 From 05df1a7e5a5a29b9429988b443524fea09a67bcf Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 04:07:30 +0200 Subject: [PATCH 16/61] Correct Polish and Portuguese help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the Polish imperative form and Portuguese post-Acordo Ortográfico spellings in the localized `help` command descriptions and heading. --- locales/pl/messages.ftl | 2 +- locales/pt-PT/messages.ftl | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index a8b4ceb04..9a10a9a0d 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -32,7 +32,7 @@ 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świetla pomoc najwyższego poziomu lub pomoc dla nazwanego tematu. +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 pliku. # Help catalogue headings and markers. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index d26d6d641..84b8b47b3 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -33,10 +33,10 @@ cli.subcommand.graph.long_about = Projetar o manifesto do Netsuke analisado num 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 acções do ficheiro selecionado. +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 ficheiro selecionado. # Help catalogue headings and markers. -cli.help.actions_heading = Acções: +cli.help.actions_heading = Ações: cli.help.targets_heading = Alvos: cli.help.default_marker = predefinição From 0528ee90f0d88e70c690a15bd01fbf5ddfa72b78 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 04:37:24 +0200 Subject: [PATCH 17/61] Refactor help topic localization mapping Represent subcommand help topics through `Subcommand` so their names and localized about keys have one shared mapping. Cover supported and rejected help-topic names without changing the command surface. --- src/cli_l10n.rs | 52 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/cli_l10n.rs b/src/cli_l10n.rs index 3b2b578d7..253f61ea4 100644 --- a/src/cli_l10n.rs +++ b/src/cli_l10n.rs @@ -179,22 +179,21 @@ impl Subcommand { #[derive(Clone, Copy)] enum HelpTopicName { Targets, - Build, - Clean, - Graph, - Generate, + Subcommand(Subcommand), } impl HelpTopicName { fn from_name(name: &str) -> Option { - match name { - "targets" => Some(Self::Targets), - "build" => Some(Self::Build), - "clean" => Some(Self::Clean), - "graph" => Some(Self::Graph), - "generate" => Some(Self::Generate), - _ => None, + 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, + }) } } @@ -276,10 +275,7 @@ const fn subcommand_long_about_key(subcommand: Subcommand) -> &'static str { const fn help_topic_about_key(topic: HelpTopicName) -> &'static str { match topic { HelpTopicName::Targets => keys::CLI_HELP_TARGETS_HEADING, - HelpTopicName::Build => keys::CLI_SUBCOMMAND_BUILD_ABOUT, - HelpTopicName::Clean => keys::CLI_SUBCOMMAND_CLEAN_ABOUT, - HelpTopicName::Graph => keys::CLI_SUBCOMMAND_GRAPH_ABOUT, - HelpTopicName::Generate => keys::CLI_SUBCOMMAND_GENERATE_ABOUT, + HelpTopicName::Subcommand(subcommand) => subcommand_about_key(subcommand), } } @@ -339,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_HEADING))] + #[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 + ); + } +} From 59204ccf042aa34da81a8dfc02fc65ba1619b61f Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 04:43:33 +0200 Subject: [PATCH 18/61] Document target discovery and runner boundaries --- docs/developers-guide.md | 26 +++++++++++++++++++++++ docs/execplans/fef13161.md | 5 +++-- docs/netsuke-cli-design-document.md | 15 ++++++++++++++ docs/v0-1-0-migration-guide.md | 32 ++++++++++++++++++++++++----- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 34b9456ec..9c8ba61d2 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -26,6 +26,32 @@ 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, rendering, and IR-validation stages to +produce a deterministic action-then-target catalogue. It may validate a +`BuildGraph`, but it must not generate a Ninja file, call a Ninja subprocess, +execute a recipe, or create build outputs. 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`. + ## Localization `src/locale_catalogues.rs` is the authoritative registry of shipped catalogues. diff --git a/docs/execplans/fef13161.md b/docs/execplans/fef13161.md index 39e27a1f4..bdf496514 100644 --- a/docs/execplans/fef13161.md +++ b/docs/execplans/fef13161.md @@ -220,8 +220,9 @@ generates Ninja build files. Key files and modules for this task: `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 `[default]` marker and an - empty description column for the missing case. + 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..3128ef732 100644 --- a/docs/netsuke-cli-design-document.md +++ b/docs/netsuke-cli-design-document.md @@ -59,6 +59,21 @@ 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. This keeps discovery useful in an unfamiliar project +without making help a build operation. 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/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 6e537f2ef..07f60f63a 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -1,9 +1,9 @@ # 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 callers +compile unchanged; every addition is opt-in. ## Netsuke is a build tool, not a library @@ -16,7 +16,7 @@ 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 +25,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 +63,27 @@ 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, +then prints actions and targets without running recipes or creating build +outputs. 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, From 18ff2d701176fbab1154996bd857178646054553 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 04:56:57 +0200 Subject: [PATCH 19/61] Document side-effect-free help rendering --- docs/developers-guide.md | 10 ++++++---- docs/netsuke-cli-design-document.md | 9 ++++++--- docs/users-guide.md | 14 ++++++++------ docs/v0-1-0-migration-guide.md | 8 +++++--- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 9c8ba61d2..8c0da610d 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -47,10 +47,12 @@ layer routes `HelpTopic::Targets` to `src/runner/help.rs`, which resolves and runs the manifest loading, expansion, rendering, and IR-validation stages to produce a deterministic action-then-target catalogue. It may validate a `BuildGraph`, but it must not generate a Ninja file, call a Ninja subprocess, -execute a recipe, or create build outputs. 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`. +execute a recipe, or create build outputs. Its Jinja environment is a +restricted, side-effect-free query surface: expressions invoking `fetch`, +`shell`, or `grep` are rejected rather than executed. 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`. ## Localization diff --git a/docs/netsuke-cli-design-document.md b/docs/netsuke-cli-design-document.md index 3128ef732..d5897a523 100644 --- a/docs/netsuke-cli-design-document.md +++ b/docs/netsuke-cli-design-document.md @@ -70,9 +70,12 @@ 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. This keeps discovery useful in an unfamiliar project -without making help a build operation. Existing manifests remain compatible -when they omit the optional descriptions. +or create build outputs. Rendering uses a restricted, side-effect-free Jinja +surface: expressions invoking `fetch`, `shell`, or `grep` are rejected rather +than executed. This keeps discovery useful in an unfamiliar project without +making help a build operation. Existing manifests remain compatible when they +omit the optional descriptions; the helper restriction applies only to this +inspection path. Intuitive **defaults** further contribute to a smooth UX. As noted, if no subcommand is given, `netsuke build` is assumed by default. Similarly, common diff --git a/docs/users-guide.md b/docs/users-guide.md index 9c3ba0b54..b3e69450f 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -860,12 +860,14 @@ and an empty description column for entries without a `description`: netsuke help targets ``` -The command loads, expands, renders, and validates the manifest exactly like a -build, but performs no recipes and creates no build outputs. 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 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: +expressions invoking `fetch`, `shell`, or `grep` are rejected rather than +executed. 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`. ## Configure Netsuke diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 07f60f63a..34e8ad205 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -78,9 +78,11 @@ netsuke help targets ``` The command honours the usual manifest-selection options, including `--file` -and `-C/--directory`. It loads, expands, renders, and validates the manifest, -then prints actions and targets without running recipes or creating build -outputs. Add `--json` to receive the versioned JSON result document; its +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. Expressions +invoking `fetch`, `shell`, or `grep` are rejected rather than executed by this +command. 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. From 2303ce75822d03ace6dd178b25af1252bb478540 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 05:03:39 +0200 Subject: [PATCH 20/61] Harden help target rendering (#551) Render help catalogues through a restricted manifest-query path so discovery cannot fetch, execute commands, or write caches. Escape terminal control characters at text output while retaining raw JSON values. Add end-to-end, IR, and property coverage for foreach descriptions, rule progress isolation, catalogue invariants, and the restricted template surface. --- .../expand_test_cases/property_cases.rs | 27 ++++ src/manifest/mod.rs | 55 ++++---- src/manifest/query.rs | 77 ++++++++++ src/manifest/tests/workspace.rs | 43 +++++- src/runner/help.rs | 79 +++++++++-- src/runner/help_tests.rs | 133 +++++++++++++++++- src/stdlib/mod.rs | 1 + src/stdlib/register.rs | 77 +++++++++- tests/ir_from_manifest_tests.rs | 29 +++- tests/runner_help_targets_tests.rs | 71 +++++++++- 10 files changed, 537 insertions(+), 55 deletions(-) create mode 100644 src/manifest/query.rs 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/mod.rs b/src/manifest/mod.rs index 8ea3d6954..341526341 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}; @@ -44,6 +44,7 @@ mod expand; mod glob; mod hints; mod jinja_macros; +mod query; mod parse_with_config; mod render; @@ -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(StdlibConfig), + /// The read-only stdlib used to inspect manifest discovery metadata. + ManifestQuery(StdlibConfig), +} 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(config)) => Ok( + crate::stdlib::register_manifest_query_with_config(&mut jinja, &config), + ), 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/query.rs b/src/manifest/query.rs new file mode 100644 index 000000000..85b9ed214 --- /dev/null +++ b/src/manifest/query.rs @@ -0,0 +1,77 @@ +//! 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, from_str_named, notify_stage, process_env_reader, + 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 { + from_path_with_registration( + path, + &process_env_reader(), + on_stage, + StdlibRegistration::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, |config| { + StdlibRegistration::Full(config.with_network_policy(policy)) + }) +} + +/// 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)>, + register_stdlib: impl FnOnce(StdlibConfig) -> StdlibRegistration, +) -> 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 = register_stdlib( + StdlibConfig::new(workspace.dir)?.with_workspace_root_path(workspace.root)?, + ); + from_str_named( + &data, + ManifestParse { + name: &name, + stdlib_registration: Some(config), + env_reader, + }, + &mut on_stage, + ) +} diff --git a/src/manifest/tests/workspace.rs b/src/manifest/tests/workspace.rs index ec915d149..e96ff9e24 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,43 @@ fn from_path_uses_manifest_directory_for_caches() -> AnyResult<()> { Ok(()) } + +/// Discovery queries must reject helpers that could perform I/O before any +/// network request, cache write, or command execution occurs. +#[rstest] +#[case::fetch("{{ fetch('https://example.invalid', cache=true) }}", "fetch")] +#[case::shell("{{ 'ignored' | shell('printf side-effect') }}", "shell")] +#[case::grep("{{ 'ignored' | grep('ignored') }}", "grep")] +fn manifest_query_rejects_impure_template_helpers( + #[case] expression: &str, + #[case] helper: &str, +) -> AnyResult<()> { + let temp = tempdir().context("create manifest-query workspace")?; + let manifest_path = temp.path().join("Netsukefile"); + 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 side-effecting template helpers"); + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains(&format!("{helper} is disabled"))), + "query should name its rejected helper: {error:?}" + ); + ensure!( + !temp.path().join(".netsuke").exists(), + "a rejected query must not create a fetch cache" + ); + Ok(()) +} diff --git a/src/runner/help.rs b/src/runner/help.rs index 48451e7cc..c764d123e 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result, ensure}; use clap::CommandFactory; use serde::Serialize; -use std::collections::HashSet; +use std::{borrow::Cow, collections::HashSet}; use tracing::info; use unicode_width::UnicodeWidthStr; @@ -24,7 +24,7 @@ use crate::status::{LocalizationKey, PipelineStage, StatusReporter, report_pipel use crate::theme::ThemeContext; use super::path_helpers::{ensure_manifest_exists_or_error, resolve_manifest_path}; -use super::{load_manifest_with_stage_reporting, process}; +use super::process; /// One catalogue row: a single resolved target name with its metadata. struct HelpEntry<'a> { @@ -37,8 +37,9 @@ struct HelpEntry<'a> { /// 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; the IR is built only to validate the -/// rendered manifest, and no recipe is executed and no build output created. +/// 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 /// @@ -52,10 +53,7 @@ pub(super) fn handle_help_targets(cli: &Cli, reporter: &dyn StatusReporter) -> R ); let manifest_path = resolve_manifest_path(cli)?; ensure_manifest_exists_or_error(cli, reporter, &manifest_path)?; - let policy = cli - .network_policy() - .context(localization::message(keys::RUNNER_CONTEXT_NETWORK_POLICY))?; - let manifest = load_manifest_with_stage_reporting(&manifest_path, policy, reporter)?; + let manifest = load_manifest_for_query_with_stage_reporting(&manifest_path, reporter)?; report_pipeline_stage(reporter, PipelineStage::IrGenerationValidation, None); // Building the IR validates the rendered manifest (duplicate outputs, @@ -186,20 +184,24 @@ fn render_section( } out.push_str(&localization::message(heading_key).to_string()); out.push('\n'); - let width = entries + let display_names: Vec> = entries .iter() - .map(|entry| UnicodeWidthStr::width(entry.name.as_str())) + .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 in entries { - let name_width = UnicodeWidthStr::width(entry.name.as_str()); + for (entry, name) in entries.iter().zip(display_names) { + let name_width = UnicodeWidthStr::width(name.as_ref()); out.push_str(" "); - out.push_str(&entry.name); + out.push_str(&name); out.push_str(&" ".repeat(width.saturating_sub(name_width))); if let Some(description) = entry.description { out.push_str(" "); - out.push_str(description); + out.push_str(&terminal_safe(description)); } if entry.is_default { out.push(' '); @@ -209,6 +211,55 @@ fn render_section( } } +/// 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(char::is_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 control.is_control() => escaped.extend(control.escape_default()), + printable => escaped.push(printable), + } + } + Cow::Owned(escaped) +} + +/// Load a manifest for a no-side-effect metadata query while reporting stages. +fn load_manifest_for_query_with_stage_reporting( + manifest_path: &camino::Utf8PathBuf, + reporter: &dyn StatusReporter, +) -> Result { + let mut on_stage = |stage| match stage { + crate::manifest::ManifestLoadStage::ManifestIngestion => { + report_pipeline_stage(reporter, PipelineStage::ManifestIngestion, None); + } + crate::manifest::ManifestLoadStage::InitialYamlParsing => { + report_pipeline_stage(reporter, PipelineStage::InitialYamlParsing, None); + } + crate::manifest::ManifestLoadStage::TemplateExpansion => { + report_pipeline_stage(reporter, PipelineStage::TemplateExpansion, None); + } + crate::manifest::ManifestLoadStage::FinalRendering => { + report_pipeline_stage(reporter, PipelineStage::FinalRendering, None); + } + }; + 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()) + }) +} + /// 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 { diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index 61a67798d..141226384 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -10,8 +10,10 @@ 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::Result; +use anyhow::{Context, Result}; use insta::assert_snapshot; +use proptest::prelude::*; +use semver::Version; use std::sync::Arc; use test_support::fluent::normalize_fluent_isolates; use test_support::localizer_test_lock; @@ -104,3 +106,132 @@ fn json_catalogue_snapshot() -> Result<()> { 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".to_owned()); + action.description = Some("description\r\nwith\tcontrols\u{0007}".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"), + "name controls should be visible escapes: {output:?}" + ); + anyhow::ensure!( + output.contains("description\\r\\nwith\\tcontrols\\u{7}"), + "description controls should be visible escapes: {output:?}" + ); + anyhow::ensure!( + !output.contains('\r') && !output.contains('\u{001B}') && !output.contains('\u{009B}'), + "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(()) +} + +/// 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: "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.map(str::to_owned), + entry.is_action, + entry.is_default, + )) + .collect(); + + prop_assert_eq!(actual, expected); + } +} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 60092b3c1..35c0810a9 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_with_config; pub use register::{register, register_with_config, value_from_bytes}; use std::{ diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index 588dd1987..cd4f6aa63 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,35 @@ pub fn register_with_config( config: StdlibConfig, ) -> anyhow::Result { let state = StdlibState::default(); + register_read_only_helpers(env, &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); + Ok(state) +} + +/// Register helpers suitable for manifest queries that must not cause I/O. +/// +/// The registration preserves pure rendering helpers, including date, path, +/// collection, and executable-discovery helpers. It replaces `fetch`, +/// `shell`, and `grep` with explicit errors so consumers can render discovery +/// metadata without opening network connections, writing fetch caches, or +/// executing commands. +/// +pub(crate) fn register_manifest_query_with_config( + env: &mut Environment<'_>, + config: &StdlibConfig, +) -> StdlibState { + let state = StdlibState::default(); + register_read_only_helpers(env, config); + register_disabled_impure_helpers(env); + state +} + +/// Register helpers that do not execute a command, make a network request, or +/// mutate the manifest workspace. +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 +137,45 @@ 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 deliberate failures for stdlib helpers that have side effects. +fn register_disabled_impure_helpers(env: &mut Environment<'_>) { + 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")) }, + ); +} + +/// Explain why an impure 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 side-effect-free template helpers" + ), + ) } /// Convert UTF-8 or fall back to bytes for byte-oriented network helpers. 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/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index f34755e36..0ebe8cfe1 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -9,9 +9,13 @@ use anyhow::{Context, Result, ensure}; use camino::Utf8Path; use cap_std::{ambient_authority, fs_utf8::Dir}; -use netsuke::cli::{Cli, Commands, HelpArgs, HelpTopic}; use netsuke::output_prefs; use netsuke::runner::run; +use netsuke::{ + cli::{Cli, Commands, HelpArgs, HelpTopic}, + ir::BuildGraph, + manifest, ninja_gen, +}; use rstest::{fixture, rstest}; use serde_json::Value; use std::path::PathBuf; @@ -179,6 +183,71 @@ fn help_targets_honours_directory_flag( Ok(()) } +#[rstest] +fn help_targets_renders_foreach_descriptions_without_changing_rule_progress() -> Result<()> { + let temp = tempfile::tempdir().context("create foreach help-targets workspace")?; + let manifest_path = temp.path().join("Netsukefile"); + 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 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 +targets: + - name: report-{{ item }} + description: Build the {{ item }} report + rule: render-report + foreach: + - weekly + - monthly +"#, + ) + .context("write foreach manifest")?; + + 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", + ] { + ensure!( + stdout.contains(expected), + "catalogue should render foreach description {expected:?}: {stdout}" + ); + } + + 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}" + ); + Ok(()) +} + #[rstest] fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { let temp = tempfile::tempdir().context("temp dir")?; From 1e8c6eea15b862dccdb32a66b6952fa5a7b7805f Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 05:12:18 +0200 Subject: [PATCH 21/61] Reconcile target discovery documentation and translations (#551) Clarify that target and action descriptions remain discovery metadata and never replace rule descriptions in Ninja progress. Add the localized `cli.help.targets_about` synopsis to every shipped locale. --- docs/roadmap.md | 7 ++++--- locales/ar/messages.ftl | 1 + locales/cs/messages.ftl | 1 + locales/cy/messages.ftl | 1 + locales/da/messages.ftl | 1 + locales/de/messages.ftl | 1 + locales/el/messages.ftl | 1 + locales/en-GB/messages.ftl | 1 + locales/en-US/messages.ftl | 1 + locales/es-419/messages.ftl | 1 + locales/es-ES/messages.ftl | 1 + locales/fa/messages.ftl | 1 + locales/fi/messages.ftl | 1 + locales/fr/messages.ftl | 1 + locales/gd/messages.ftl | 1 + locales/he/messages.ftl | 1 + locales/hi/messages.ftl | 1 + locales/hu/messages.ftl | 1 + locales/id/messages.ftl | 1 + locales/it/messages.ftl | 1 + locales/ja/messages.ftl | 1 + locales/ko/messages.ftl | 1 + locales/nb/messages.ftl | 1 + locales/nl/messages.ftl | 1 + locales/pl/messages.ftl | 1 + locales/pt-BR/messages.ftl | 1 + locales/pt-PT/messages.ftl | 1 + locales/ro/messages.ftl | 1 + locales/ru/messages.ftl | 1 + locales/sv/messages.ftl | 1 + locales/th/messages.ftl | 1 + locales/tr/messages.ftl | 1 + locales/uk/messages.ftl | 1 + locales/vi/messages.ftl | 1 + locales/zh-Hans/messages.ftl | 1 + locales/zh-Hant/messages.ftl | 1 + 36 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 843cb4df6..fafa7f7b5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -256,9 +256,10 @@ and agents. [netsuke-design.md §2.6](netsuke-design.md#26-planned-recipe-ergonomics-and-execution-feedback). - [x] Add target/action `description` as discovery metadata and list it with `netsuke help targets`. - - [ ] Add a future target/action description override for the referenced rule - description on a concrete edge, if selected-action progress requires it. - - [ ] Report selected action descriptions in normal Ninja progress output. + - [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 diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index dfd27453f..e8d7bbad3 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = بدون موضوع، يطابق هذا `--help # Help catalogue headings and markers. cli.help.actions_heading = الإجراءات: cli.help.targets_heading = الأهداف: +cli.help.targets_about = سرد الأهداف والإجراءات في الملف المحدد. cli.help.default_marker = الافتراضي # نص المساعدة لخيارات الأمر الفرعي build. diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 1f83b6fe7..d0142f985 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Bez tématu odpovídá příkazu `--help`. Pomo # 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. diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index e840d3c83..2d6bc3ee8 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Heb bwnc, mae hyn yn cyfateb i `--help`. Defnyd # 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. diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index e4dff513e..1bd2797dd 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Uden emne svarer dette til `--help`. Brug `help # 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. diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 6e5f6bb94..e09208bb3 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Ohne Thema entspricht dies `--help`. Verwenden # 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. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 172f1ace1..65048760f 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Χωρίς θέμα, αυτό ταιριάζε # Help catalogue headings and markers. cli.help.actions_heading = Ενέργειες: cli.help.targets_heading = Στόχοι: +cli.help.targets_about = Παράθεση στόχων και ενεργειών στο επιλεγμένο δηλωτικό. cli.help.default_marker = προεπιλογή # Κείμενο βοήθειας για τις επιλογές της υποεντολής build. diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 6661dd15c..a61dd63df 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = With no topic this matches `--help`. Use `help # 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. diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index feb8bbbf0..fcc11213b 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = With no topic this matches `--help`. Use `help # 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. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 1c1c9f576..06ab7e020 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Sin tema, esto coincide con `--help`. Use `help # 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. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index b89989d06..96c82abca 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Sin tema, esto coincide con `--help`. Use `help # 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. diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index 82369e798..b9887c98e 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = بدون موضوع، این با `--help` یک # Help catalogue headings and markers. cli.help.actions_heading = اقدامات: cli.help.targets_heading = اهداف: +cli.help.targets_about = فهرست کردن اهداف و اقدامات در پروندهٔ انتخاب‌شده. cli.help.default_marker = پیش‌فرض # متن راهنمای گزینه‌های زیرفرمان build. diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index c4856ce6c..b51cc3b9d 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Ilman aihetta tämä vastaa `--help`-komentoa. # 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. diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index 933fff83d..be2213e3f 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Sans sujet, ceci correspond à `--help`. Utilis # 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 = défaut # Texte d'aide des options de la sous-commande build. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 5a1eb6fd4..975163cfd 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Às aonais cuspair, tha seo a' freagairt ri `-- # 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. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 8425b3e51..b9df78d4d 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = ללא נושא, זה תואם את `--help`. # Help catalogue headings and markers. cli.help.actions_heading = פעולות: cli.help.targets_heading = יעדים: +cli.help.targets_about = הצגת רשימת היעדים והפעולות במניפסט שנבחר. cli.help.default_marker = ברירת מחדל # טקסט העזרה של אפשרויות פקודת המשנה build. diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index 48d5f6224..ad9dc8862 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = बिना विषय के यह `--help # Help catalogue headings and markers. cli.help.actions_heading = क्रियाएँ: cli.help.targets_heading = लक्ष्य: +cli.help.targets_about = चयनित मैनिफेस्ट में लक्ष्यों और क्रियाओं की सूची बनाएँ। cli.help.default_marker = डिफ़ॉल्ट # build उपआदेश के विकल्पों का सहायता पाठ। diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 2325bb352..0f137cb1f 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Téma nélkül ez a `--help`-nek felel meg. A ` # 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. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index e03bb2f93..f75d85d39 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Tanpa topik, ini sama dengan `--help`. Gunakan # Help catalogue headings and markers. cli.help.actions_heading = Tindakan: cli.help.targets_heading = Target: +cli.help.targets_about = Mencetak target dan tindakan dalam file yang dipilih. cli.help.default_marker = bawaan # Teks bantuan untuk opsi subperintah build. diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index cf59d6302..eec2c5d73 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Senza argomento, corrisponde a `--help`. Usa `h # 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. diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index 4626ecf9c..532b7ddeb 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = トピックなしの場合、これは `--help # Help catalogue headings and markers. cli.help.actions_heading = アクション: cli.help.targets_heading = ターゲット: +cli.help.targets_about = 選択したファイルのターゲットとアクションを一覧表示します。 cli.help.default_marker = 既定 # build サブコマンドのオプションのヘルプ文。 diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 8f2c3fd49..6809045b3 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = 주제가 없으면 `--help`와 동일합니다 # Help catalogue headings and markers. cli.help.actions_heading = 작업: cli.help.targets_heading = 대상: +cli.help.targets_about = 선택한 파일의 대상 및 작업을 나열합니다. cli.help.default_marker = 기본값 # build 하위 명령 옵션의 도움말. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index be0d9e119..e86be5b49 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Uten emne tilsvarer dette `--help`. Bruk `help # 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. diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index e7e128ec5..a967f0e2b 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Zonder onderwerp komt dit overeen met `--help`. # 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 bestand weergeven. cli.help.default_marker = standaard # Helptekst voor opties van de subopdracht build. diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index 9a10a9a0d..9f4c3538a 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Bez tematu odpowiada to `--help`. Użyj `help t # 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 pliku. cli.help.default_marker = domyślny # Tekst pomocy opcji podpolecenia build. diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 4a83af6ee..093556715 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Sem tópico, isto corresponde a `--help`. Use ` # 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 arquivo selecionado. cli.help.default_marker = padrão # Texto de ajuda das opções do subcomando build. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 84b8b47b3..ea295c805 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Sem tópico, isto corresponde a `--help`. Use ` # 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 ficheiro selecionado. cli.help.default_marker = predefinição # Texto de ajuda das opções do subcomando build. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 57037d5e4..b66104a60 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Fără subiect, acest lucru corespunde cu `--he # 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. diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 82fa124a0..ab583e30c 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Без темы это соответствуе # Help catalogue headings and markers. cli.help.actions_heading = Действия: cli.help.targets_heading = Цели: +cli.help.targets_about = Вывести список целей и действий в выбранном файле. cli.help.default_marker = по умолчанию # Текст справки для параметров подкоманды build. diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index 29a4b5b53..7102a5f04 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Utan ämne motsvarar detta `--help`. Använd `h # 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. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 944a57d2e..555952fb9 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = หากไม่มีหัวข้อ ค # Help catalogue headings and markers. cli.help.actions_heading = การดำเนินการ: cli.help.targets_heading = เป้าหมาย: +cli.help.targets_about = แสดงรายการเป้าหมายและการดำเนินการในไฟล์ที่เลือก cli.help.default_marker = ค่าเริ่มต้น # ข้อความช่วยเหลือของตัวเลือกในคำสั่งย่อย build diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 280335507..d565bd9da 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Konu olmadan bu, `--help` ile aynıdır. Seçil # Help catalogue headings and markers. cli.help.actions_heading = Eylemler: cli.help.targets_heading = Hedefler: +cli.help.targets_about = Seçilen dosyadaki hedef ve eylemleri listele. cli.help.default_marker = varsayılan # build alt komutunun seçenekleri için yardım metni. diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 0a5b6d595..b1ac82561 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Без теми це відповідає `--he # Help catalogue headings and markers. cli.help.actions_heading = Дії: cli.help.targets_heading = Цілі: +cli.help.targets_about = Вивести список цілей і дій у вибраному файлі. cli.help.default_marker = за замовчуванням # Текст довідки для параметрів підкоманди build. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index 09f7c50bb..183052a23 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = Không có chủ đề, lệnh này tương ứ # 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. diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index 88188bbb3..adad11773 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = 没有主题时,此命令等价于 `--help` # Help catalogue headings and markers. cli.help.actions_heading = 操作: cli.help.targets_heading = 目标: +cli.help.targets_about = 列出所选清单中的目标和操作。 cli.help.default_marker = 默认 # build 子命令选项的帮助文本。 diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 3f4e96d19..5978b69d7 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -38,6 +38,7 @@ cli.subcommand.help.long_about = 沒有主題時,此命令等同於 `--help` # Help catalogue headings and markers. cli.help.actions_heading = 操作: cli.help.targets_heading = 目標: +cli.help.targets_about = 列出所選資訊清單中的目標和操作。 cli.help.default_marker = 預設 # build 子命令選項的說明文字。 From 55c11934f409a7caad389f6dbe9f78b36f44e6b0 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 05:16:18 +0200 Subject: [PATCH 22/61] Describe the targets help topic (#551) Use a dedicated localized synopsis for the nested `targets` help topic rather than reusing the catalogue section heading. Keep localized help assertions aligned with the translated output. --- src/cli/parser_tests.rs | 4 ++-- src/cli_l10n.rs | 4 ++-- src/localization/keys.rs | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cli/parser_tests.rs b/src/cli/parser_tests.rs index cf3313317..4cb1a2c4c 100644 --- a/src/cli/parser_tests.rs +++ b/src/cli/parser_tests.rs @@ -54,7 +54,7 @@ fn localized_help_snapshots_include_config_flag( #[case::en_us( "en-US", [ - "Targets:", + "List targets and actions in the selected manifest.", "Build targets defined in the manifest", "Remove build artefacts via Ninja", "Emit the build dependency graph", @@ -64,7 +64,7 @@ fn localized_help_snapshots_include_config_flag( #[case::es_es( "es-ES", [ - "Objetivos:", + "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", diff --git a/src/cli_l10n.rs b/src/cli_l10n.rs index 253f61ea4..6ec414bd0 100644 --- a/src/cli_l10n.rs +++ b/src/cli_l10n.rs @@ -274,7 +274,7 @@ const fn subcommand_long_about_key(subcommand: Subcommand) -> &'static str { const fn help_topic_about_key(topic: HelpTopicName) -> &'static str { match topic { - HelpTopicName::Targets => keys::CLI_HELP_TARGETS_HEADING, + HelpTopicName::Targets => keys::CLI_HELP_TARGETS_ABOUT, HelpTopicName::Subcommand(subcommand) => subcommand_about_key(subcommand), } } @@ -344,7 +344,7 @@ mod tests { use rstest::rstest; #[rstest] - #[case("targets", Some(keys::CLI_HELP_TARGETS_HEADING))] + #[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))] diff --git a/src/localization/keys.rs b/src/localization/keys.rs index 9882e3531..63bc3f24e 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -41,6 +41,7 @@ define_keys! { 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", From 8f181da57d77a34896d314b2f7f653b43689258f Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 12:09:24 +0200 Subject: [PATCH 23/61] Document v0.1.0 help and localisation updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broaden the migration table caption, record the post-314f12b query and help follow-up in the living exec plan, and rename the targets help synopsis key across all shipped locales. Update Traditional Chinese wording to use 動作. --- docs/execplans/fef13161.md | 32 ++++++++++++++++++++++++++++++-- locales/ar/messages.ftl | 2 +- locales/cs/messages.ftl | 2 +- locales/cy/messages.ftl | 2 +- locales/da/messages.ftl | 2 +- locales/de/messages.ftl | 2 +- locales/el/messages.ftl | 2 +- locales/en-GB/messages.ftl | 2 +- locales/en-US/messages.ftl | 2 +- locales/es-419/messages.ftl | 2 +- locales/es-ES/messages.ftl | 2 +- locales/fa/messages.ftl | 2 +- locales/fi/messages.ftl | 2 +- locales/fr/messages.ftl | 2 +- locales/gd/messages.ftl | 2 +- locales/he/messages.ftl | 2 +- locales/hi/messages.ftl | 2 +- locales/hu/messages.ftl | 2 +- locales/id/messages.ftl | 2 +- locales/it/messages.ftl | 2 +- locales/ja/messages.ftl | 2 +- locales/ko/messages.ftl | 2 +- locales/nb/messages.ftl | 2 +- locales/nl/messages.ftl | 2 +- locales/pl/messages.ftl | 2 +- locales/pt-BR/messages.ftl | 2 +- locales/pt-PT/messages.ftl | 2 +- locales/ro/messages.ftl | 2 +- locales/ru/messages.ftl | 2 +- locales/sv/messages.ftl | 2 +- locales/th/messages.ftl | 2 +- locales/tr/messages.ftl | 2 +- locales/uk/messages.ftl | 2 +- locales/vi/messages.ftl | 2 +- locales/zh-Hans/messages.ftl | 2 +- locales/zh-Hant/messages.ftl | 4 ++-- 36 files changed, 66 insertions(+), 38 deletions(-) diff --git a/docs/execplans/fef13161.md b/docs/execplans/fef13161.md index bdf496514..ac19e5b1d 100644 --- a/docs/execplans/fef13161.md +++ b/docs/execplans/fef13161.md @@ -16,6 +16,9 @@ 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 so +manifest inspection cannot fetch data, execute commands, or write caches. + 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 @@ -61,7 +64,8 @@ default marker such as `[★ default]` on manifest defaults. `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 six new keys to - every `locales/*/messages.ftl` in the same commit as `keys.rs`. + 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 @@ -78,7 +82,7 @@ default marker such as `[★ default]` on manifest defaults. - [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 34 locales, dispatch wiring. + 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. @@ -92,6 +96,18 @@ default marker such as `[★ default]` on manifest defaults. - [x] (2026-08-09) Branch renamed to `issue-551-add-target-descriptions-and-netsuke-help-targets`, pushed, PR opened: . +- [x] (2026-08-12, `d524941`) Documented the restricted, side-effect-free Jinja + surface for `netsuke help targets` in the migration, users', developers', + and CLI design guides. +- [x] (2026-08-12, `e5edb0d`) Routed target help through a restricted manifest + query path, escaped terminal control characters in text output, and added + end-to-end, IR, and property coverage for the query and catalogue + invariants. +- [x] (2026-08-12, `e9efae6`) Clarified that target and action descriptions + remain discovery metadata and do not replace rule descriptions in Ninja + progress; added `cli.help.targets_about` to all 35 shipped locales. +- [x] (2026-08-12, `625e93f`) Used a dedicated localized synopsis for the nested + `targets` help topic and aligned the localized help assertions with it. ## Surprises & discoveries @@ -123,6 +139,10 @@ default marker such as `[★ default]` on manifest defaults. 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 post-`314f12b` query path uses a dedicated localized + synopsis for the nested `targets` help topic rather than the catalogue's + section heading. Evidence: `625e93f`. Impact: keep the `targets_about` key + separate from `actions_heading` and `targets_heading`. ## Decision log @@ -143,6 +163,14 @@ the users guide documents the schema field and the subcommand; and the man page plus PowerShell help pick up the new command surface automatically through clap derivation (no shell completions exist to update). +The post-`314f12b` 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 from commits `d524941`, +`e5edb0d`, `e9efae6`, and `625e93f`; the current history does not record a new +full-gate run after those commits, so no additional gate result is claimed +here. + Lessons learned: - The `tracing` callsite interest cache makes capture-based tracing tests diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index e8d7bbad3..184a9c450 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = بدون موضوع، يطابق هذا `--help # Help catalogue headings and markers. cli.help.actions_heading = الإجراءات: cli.help.targets_heading = الأهداف: -cli.help.targets_about = سرد الأهداف والإجراءات في الملف المحدد. +cli.help.targets.about = سرد الأهداف والإجراءات في الملف المحدد. cli.help.default_marker = الافتراضي # نص المساعدة لخيارات الأمر الفرعي build. diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index d0142f985..8dabc406c 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Bez tématu odpovídá příkazu `--help`. Pomo # 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.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. diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 2d6bc3ee8..a719e906c 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Heb bwnc, mae hyn yn cyfateb i `--help`. Defnyd # 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.targets.about = Rhestru targedau a gweithredoedd yn y maniffest a ddewiswyd. cli.help.default_marker = diofyn # Testun cymorth dewisiadau'r is-orchymyn build. diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index 1bd2797dd..e13e4cde5 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Uden emne svarer dette til `--help`. Brug `help # 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.targets.about = Vis mål og handlinger i det valgte manifest. cli.help.default_marker = standard # Hjælpetekst til tilvalg for underkommandoen build. diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index e09208bb3..65d85f9c7 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Ohne Thema entspricht dies `--help`. Verwenden # 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.targets.about = Ziele und Aktionen im ausgewählten Manifest auflisten. cli.help.default_marker = Standard # Hilfetext für Optionen des Unterbefehls build. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 65048760f..05d60be99 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Χωρίς θέμα, αυτό ταιριάζε # Help catalogue headings and markers. cli.help.actions_heading = Ενέργειες: cli.help.targets_heading = Στόχοι: -cli.help.targets_about = Παράθεση στόχων και ενεργειών στο επιλεγμένο δηλωτικό. +cli.help.targets.about = Παράθεση στόχων και ενεργειών στο επιλεγμένο δηλωτικό. cli.help.default_marker = προεπιλογή # Κείμενο βοήθειας για τις επιλογές της υποεντολής build. diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index a61dd63df..331f7aa38 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = With no topic this matches `--help`. Use `help # 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.targets.about = List targets and actions in the selected manifest. cli.help.default_marker = default # Build subcommand flag help text. diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index fcc11213b..65c8f42af 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = With no topic this matches `--help`. Use `help # 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.targets.about = List targets and actions in the selected manifest. cli.help.default_marker = default # Build subcommand flag help text. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 06ab7e020..3479dedd8 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Sin tema, esto coincide con `--help`. Use `help # 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.targets.about = Enumerar objetivos y acciones en el manifiesto seleccionado. cli.help.default_marker = predeterminado # Texto de ayuda de las opciones del subcomando build. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 96c82abca..256475998 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Sin tema, esto coincide con `--help`. Use `help # 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.targets.about = Enumerar objetivos y acciones en el manifiesto seleccionado. cli.help.default_marker = predeterminado # Texto de ayuda para opciones del subcomando build. diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index b9887c98e..c801d232f 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = بدون موضوع، این با `--help` یک # Help catalogue headings and markers. cli.help.actions_heading = اقدامات: cli.help.targets_heading = اهداف: -cli.help.targets_about = فهرست کردن اهداف و اقدامات در پروندهٔ انتخاب‌شده. +cli.help.targets.about = فهرست کردن اهداف و اقدامات در پروندهٔ انتخاب‌شده. cli.help.default_marker = پیش‌فرض # متن راهنمای گزینه‌های زیرفرمان build. diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index b51cc3b9d..4a7ec87d8 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Ilman aihetta tämä vastaa `--help`-komentoa. # 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.targets.about = Luettele valitun tiedoston kohteet ja toiminnot. cli.help.default_marker = oletus # build-alikomennon valitsimien ohjeteksti. diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index be2213e3f..b8ff2208b 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Sans sujet, ceci correspond à `--help`. Utilis # 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.targets.about = Lister les cibles et actions du manifeste sélectionné. cli.help.default_marker = défaut # Texte d'aide des options de la sous-commande build. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 975163cfd..e77127bda 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Às aonais cuspair, tha seo a' freagairt ri `-- # 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.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. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index b9df78d4d..3109cf84b 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = ללא נושא, זה תואם את `--help`. # Help catalogue headings and markers. cli.help.actions_heading = פעולות: cli.help.targets_heading = יעדים: -cli.help.targets_about = הצגת רשימת היעדים והפעולות במניפסט שנבחר. +cli.help.targets.about = הצגת רשימת היעדים והפעולות במניפסט שנבחר. cli.help.default_marker = ברירת מחדל # טקסט העזרה של אפשרויות פקודת המשנה build. diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index ad9dc8862..eb3c9e367 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = बिना विषय के यह `--help # Help catalogue headings and markers. cli.help.actions_heading = क्रियाएँ: cli.help.targets_heading = लक्ष्य: -cli.help.targets_about = चयनित मैनिफेस्ट में लक्ष्यों और क्रियाओं की सूची बनाएँ। +cli.help.targets.about = चयनित मैनिफेस्ट में लक्ष्यों और क्रियाओं की सूची बनाएँ। cli.help.default_marker = डिफ़ॉल्ट # build उपआदेश के विकल्पों का सहायता पाठ। diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 0f137cb1f..c8d6e3b35 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Téma nélkül ez a `--help`-nek felel meg. A ` # 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.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. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index f75d85d39..b83bf0d96 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Tanpa topik, ini sama dengan `--help`. Gunakan # Help catalogue headings and markers. cli.help.actions_heading = Tindakan: cli.help.targets_heading = Target: -cli.help.targets_about = Mencetak target dan tindakan dalam file yang dipilih. +cli.help.targets.about = Mencetak target dan tindakan dalam file yang dipilih. cli.help.default_marker = bawaan # Teks bantuan untuk opsi subperintah build. diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index eec2c5d73..d01e17e80 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Senza argomento, corrisponde a `--help`. Usa `h # 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.targets.about = Elenca target e azioni nel file selezionato. cli.help.default_marker = predefinito # Testo di aiuto delle opzioni del sottocomando build. diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index 532b7ddeb..b81a3872b 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = トピックなしの場合、これは `--help # Help catalogue headings and markers. cli.help.actions_heading = アクション: cli.help.targets_heading = ターゲット: -cli.help.targets_about = 選択したファイルのターゲットとアクションを一覧表示します。 +cli.help.targets.about = 選択したファイルのターゲットとアクションを一覧表示します。 cli.help.default_marker = 既定 # build サブコマンドのオプションのヘルプ文。 diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 6809045b3..edccb6aae 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = 주제가 없으면 `--help`와 동일합니다 # Help catalogue headings and markers. cli.help.actions_heading = 작업: cli.help.targets_heading = 대상: -cli.help.targets_about = 선택한 파일의 대상 및 작업을 나열합니다. +cli.help.targets.about = 선택한 파일의 대상 및 작업을 나열합니다. cli.help.default_marker = 기본값 # build 하위 명령 옵션의 도움말. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index e86be5b49..801b47a17 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Uten emne tilsvarer dette `--help`. Bruk `help # 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.targets.about = List opp mål og handlinger i det valgte manifestet. cli.help.default_marker = standard # Hjelpetekst for valg til underkommandoen build. diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index a967f0e2b..7a46fdaf3 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Zonder onderwerp komt dit overeen met `--help`. # 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 bestand weergeven. +cli.help.targets.about = Doelen en acties in het geselecteerde bestand weergeven. cli.help.default_marker = standaard # Helptekst voor opties van de subopdracht build. diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index 9f4c3538a..0474c32e2 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Bez tematu odpowiada to `--help`. Użyj `help t # 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 pliku. +cli.help.targets.about = Wyświetl cele i akcje w wybranym pliku. cli.help.default_marker = domyślny # Tekst pomocy opcji podpolecenia build. diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 093556715..563357fd3 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Sem tópico, isto corresponde a `--help`. Use ` # 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 arquivo selecionado. +cli.help.targets.about = Listar alvos e ações no arquivo selecionado. cli.help.default_marker = padrão # Texto de ajuda das opções do subcomando build. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index ea295c805..ba82fa767 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Sem tópico, isto corresponde a `--help`. Use ` # 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 ficheiro selecionado. +cli.help.targets.about = Listar alvos e ações no ficheiro selecionado. cli.help.default_marker = predefinição # Texto de ajuda das opções do subcomando build. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index b66104a60..6f3e88d94 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Fără subiect, acest lucru corespunde cu `--he # 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.targets.about = Listează țintele și acțiunile din fișierul selectat. cli.help.default_marker = implicit # Textul de ajutor pentru opțiunile subcomenzii build. diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index ab583e30c..74808c3b0 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Без темы это соответствуе # Help catalogue headings and markers. cli.help.actions_heading = Действия: cli.help.targets_heading = Цели: -cli.help.targets_about = Вывести список целей и действий в выбранном файле. +cli.help.targets.about = Вывести список целей и действий в выбранном файле. cli.help.default_marker = по умолчанию # Текст справки для параметров подкоманды build. diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index 7102a5f04..70e973e67 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Utan ämne motsvarar detta `--help`. Använd `h # 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.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. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 555952fb9..52f130e87 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = หากไม่มีหัวข้อ ค # Help catalogue headings and markers. cli.help.actions_heading = การดำเนินการ: cli.help.targets_heading = เป้าหมาย: -cli.help.targets_about = แสดงรายการเป้าหมายและการดำเนินการในไฟล์ที่เลือก +cli.help.targets.about = แสดงรายการเป้าหมายและการดำเนินการในไฟล์ที่เลือก cli.help.default_marker = ค่าเริ่มต้น # ข้อความช่วยเหลือของตัวเลือกในคำสั่งย่อย build diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index d565bd9da..c8028374f 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Konu olmadan bu, `--help` ile aynıdır. Seçil # Help catalogue headings and markers. cli.help.actions_heading = Eylemler: cli.help.targets_heading = Hedefler: -cli.help.targets_about = Seçilen dosyadaki hedef ve eylemleri listele. +cli.help.targets.about = Seçilen dosyadaki hedef ve eylemleri listele. cli.help.default_marker = varsayılan # build alt komutunun seçenekleri için yardım metni. diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index b1ac82561..67f2b2dd0 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Без теми це відповідає `--he # Help catalogue headings and markers. cli.help.actions_heading = Дії: cli.help.targets_heading = Цілі: -cli.help.targets_about = Вивести список цілей і дій у вибраному файлі. +cli.help.targets.about = Вивести список цілей і дій у вибраному файлі. cli.help.default_marker = за замовчуванням # Текст довідки для параметрів підкоманди build. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index 183052a23..d56ad6412 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Không có chủ đề, lệnh này tương ứ # 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.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. diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index adad11773..d6afabfcd 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = 没有主题时,此命令等价于 `--help` # Help catalogue headings and markers. cli.help.actions_heading = 操作: cli.help.targets_heading = 目标: -cli.help.targets_about = 列出所选清单中的目标和操作。 +cli.help.targets.about = 列出所选清单中的目标和操作。 cli.help.default_marker = 默认 # build 子命令选项的帮助文本。 diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 5978b69d7..de2ad3a75 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -36,9 +36,9 @@ cli.subcommand.help.about = 列印頂層說明,或列印指定主題的說明 cli.subcommand.help.long_about = 沒有主題時,此命令等同於 `--help`。使用 `help targets` 列印所選清單的目標和操作目錄。 # Help catalogue headings and markers. -cli.help.actions_heading = 操作: +cli.help.actions_heading = 動作: cli.help.targets_heading = 目標: -cli.help.targets_about = 列出所選資訊清單中的目標和操作。 +cli.help.targets.about = 列出所選資訊清單中的目標和動作。 cli.help.default_marker = 預設 # build 子命令選項的說明文字。 From 0991671cc32faa6572cffc7ce3f02e0ea88ee7d6 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 12:13:41 +0200 Subject: [PATCH 24/61] Use dotted help localisation key in execplan Keep the post-change record aligned with the renamed cli.help.targets.about key. --- docs/execplans/fef13161.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/execplans/fef13161.md b/docs/execplans/fef13161.md index ac19e5b1d..c4ac9598d 100644 --- a/docs/execplans/fef13161.md +++ b/docs/execplans/fef13161.md @@ -105,7 +105,7 @@ default marker such as `[★ default]` on manifest defaults. invariants. - [x] (2026-08-12, `e9efae6`) Clarified that target and action descriptions remain discovery metadata and do not replace rule descriptions in Ninja - progress; added `cli.help.targets_about` to all 35 shipped locales. + progress; added `cli.help.targets.about` to all 35 shipped locales. - [x] (2026-08-12, `625e93f`) Used a dedicated localized synopsis for the nested `targets` help topic and aligned the localized help assertions with it. @@ -141,8 +141,9 @@ default marker such as `[★ default]` on manifest defaults. Fixing the infrastructure properly is a separate concern from issue #551. - Observation: the post-`314f12b` query path uses a dedicated localized synopsis for the nested `targets` help topic rather than the catalogue's - section heading. Evidence: `625e93f`. Impact: keep the `targets_about` key - separate from `actions_heading` and `targets_heading`. + section heading. Evidence: `625e93f`. Impact: keep the + `cli.help.targets.about` key separate from `actions_heading` and + `targets_heading`. ## Decision log From 952281831ea15a3d7accbd3179786f7be204cc32 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 12:22:27 +0200 Subject: [PATCH 25/61] Harden targets help queries (#551) Keep query rendering deterministic, escape bidi controls, and retain capability-scoped UTF-8 fixture paths. Propagate render test failures and prove that help rejects IR-invalid manifests. --- src/localization/keys.rs | 2 +- src/manifest/render.rs | 63 +++++++++++++++-------------- src/manifest/tests/workspace.rs | 26 ++++++++++++ src/runner/help.rs | 13 +++++- src/runner/help_tests.rs | 16 +++++--- src/stdlib/register.rs | 14 +++---- src/stdlib/time/mod.rs | 5 +++ tests/runner_help_targets_tests.rs | 64 ++++++++++++++++++++++-------- 8 files changed, 139 insertions(+), 64 deletions(-) diff --git a/src/localization/keys.rs b/src/localization/keys.rs index 63bc3f24e..fe74184b3 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -41,7 +41,7 @@ define_keys! { 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_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", diff --git a/src/manifest/render.rs b/src/manifest/render.rs index ab9e8a68e..c27b8fc8c 100644 --- a/src/manifest/render.rs +++ b/src/manifest/render.rs @@ -292,6 +292,19 @@ mod tests { } } + 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!( @@ -397,41 +410,27 @@ mod tests { Ok(()) } - #[expect(clippy::panic, reason = "panic for clearer test failures")] - fn expect_script(recipe: &Recipe, label: impl std::fmt::Display) -> &str { - match recipe { - Recipe::Script { script } => script, - other => panic!("expected {label} script recipe, got {other:?}"), - } - } - - #[expect(clippy::panic, reason = "panic for clearer test failures")] - fn expect_rule_ref(recipe: &Recipe, label: impl std::fmt::Display) -> &StringOrList { - match recipe { - Recipe::Rule { rule } => rule, - other => panic!("expected {label} rule-reference recipe, got {other:?}"), - } - } - - #[expect(clippy::panic, reason = "panic for clearer test failures")] - fn assert_rendered_script_and_rule_recipes(rendered: &NetsukeManifest) { - let Some(rendered_target) = rendered.targets.first() else { - panic!("rendered script target missing"); - }; - assert_eq!( - expect_script(&rendered_target.recipe, "rendered script target"), - "echo world" + 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 Some(rendered_rule) = rendered.rules.first() else { - panic!("rendered rule-reference rule missing"); - }; - assert_eq!( + 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"), + expect_rule_ref(&rendered_rule.recipe, "rendered rule reference")?, "rule reference names", - ), - ["base"] + ) == ["base"], + "expected rendered rule-reference names to equal ['base']" ); + Ok(()) } #[test] @@ -472,7 +471,7 @@ mod tests { }; let rendered = render_manifest(manifest, &minijinja::Environment::new())?; - assert_rendered_script_and_rule_recipes(&rendered); + assert_rendered_script_and_rule_recipes(&rendered)?; Ok(()) } } diff --git a/src/manifest/tests/workspace.rs b/src/manifest/tests/workspace.rs index e96ff9e24..71fb219f3 100644 --- a/src/manifest/tests/workspace.rs +++ b/src/manifest/tests/workspace.rs @@ -256,3 +256,29 @@ fn manifest_query_rejects_impure_template_helpers( ); 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(()) +} diff --git a/src/runner/help.rs b/src/runner/help.rs index c764d123e..d4fa08249 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -217,7 +217,7 @@ fn render_section( /// 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(char::is_control) { + if !input.chars().any(is_terminal_control) { return Cow::Borrowed(input); } @@ -227,13 +227,22 @@ fn terminal_safe(input: &str) -> Cow<'_, str> { '\n' => escaped.push_str("\\n"), '\r' => escaped.push_str("\\r"), '\t' => escaped.push_str("\\t"), - control if control.is_control() => escaped.extend(control.escape_default()), + 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 { + character.is_control() + || matches!( + character, + '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' + ) +} + /// Load a manifest for a no-side-effect metadata query while reporting stages. fn load_manifest_for_query_with_stage_reporting( manifest_path: &camino::Utf8PathBuf, diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index 141226384..88678949d 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -114,9 +114,10 @@ fn text_catalogue_escapes_terminal_control_characters() -> Result<()> { .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".to_owned()); - action.description = Some("description\r\nwith\tcontrols\u{0007}".to_owned()); + 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), @@ -124,15 +125,18 @@ fn text_catalogue_escapes_terminal_control_characters() -> Result<()> { ); anyhow::ensure!( - output.contains("line\\nnext\\t\\u{1b}[31mred\\u{9b}m"), + 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}"), + 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('\r') + && !output.contains('\u{001B}') + && !output.contains('\u{009B}') + && !output.contains('\u{202E}'), "text output must not contain terminal control characters: {output:?}" ); anyhow::ensure!( diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index cd4f6aa63..28735f87c 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -96,6 +96,7 @@ pub fn register_with_config( ) -> 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); @@ -103,13 +104,12 @@ pub fn register_with_config( Ok(state) } -/// Register helpers suitable for manifest queries that must not cause I/O. +/// Register helpers suitable for manifest queries that must avoid side effects. /// /// The registration preserves pure rendering helpers, including date, path, -/// collection, and executable-discovery helpers. It replaces `fetch`, +/// collection, and executable-discovery helpers. It replaces `fetch`, /// `shell`, and `grep` with explicit errors so consumers can render discovery -/// metadata without opening network connections, writing fetch caches, or -/// executing commands. +/// metadata without network access, cache writes, or command execution. /// pub(crate) fn register_manifest_query_with_config( env: &mut Environment<'_>, @@ -117,12 +117,13 @@ pub(crate) fn register_manifest_query_with_config( ) -> StdlibState { let state = StdlibState::default(); register_read_only_helpers(env, config); + time::register_query_functions(env); register_disabled_impure_helpers(env); state } -/// Register helpers that do not execute a command, make a network request, or -/// mutate the manifest workspace. +/// Register query helpers that avoid side effects while retaining filesystem +/// capabilities for file tests, path helpers, and `which`. fn register_read_only_helpers(env: &mut Environment<'_>, config: &StdlibConfig) { register_file_tests(env); path::register_filters(env, config.home_directory().clone()); @@ -137,7 +138,6 @@ fn register_read_only_helpers(env: &mut Environment<'_>, config: &StdlibConfig) WhichConfig::new(which_cwd, which_path, which_skip_dirs, which_cache_capacity) .with_pathext_override(config.pathext_override().cloned()); which::register(env, which_config); - time::register_functions(env); } /// Register deliberate failures for stdlib helpers that have side effects. 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/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index 0ebe8cfe1..d6fbeeadf 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -7,7 +7,7 @@ //! in `--json` mode. use anyhow::{Context, Result, ensure}; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; use netsuke::output_prefs; use netsuke::runner::run; @@ -18,7 +18,6 @@ use netsuke::{ }; use rstest::{fixture, rstest}; use serde_json::Value; -use std::path::PathBuf; use test_support::{localizer_test_lock, set_en_localizer}; mod fixtures; @@ -26,9 +25,9 @@ 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 manifest_path = temp.path().join("Netsukefile"); +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 @@ -53,12 +52,12 @@ defaults: - test "#, ) - .with_context(|| format!("write manifest to {}", manifest_path.display()))?; + .with_context(|| format!("write manifest to {}", manifest_path.as_str()))?; Ok(manifest_path) } #[fixture] -fn help_targets_manifest() -> Result<(tempfile::TempDir, PathBuf)> { +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)) @@ -72,7 +71,7 @@ fn run_help_targets(cli: &Cli) -> Result<()> { #[rstest] fn help_targets_prints_actions_and_targets( - #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, PathBuf)>, + #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, Utf8PathBuf)>, ) -> Result<()> { let (_temp, manifest_path) = fixture?; let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") @@ -108,11 +107,12 @@ fn help_targets_prints_actions_and_targets( #[rstest] fn help_targets_json_reports_command_identifier( - #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, PathBuf)>, + #[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()) + .current_dir(temp_path) .arg("--json") .arg("--file") .arg(&manifest_path) @@ -156,12 +156,13 @@ fn help_targets_json_reports_command_identifier( #[rstest] fn help_targets_honours_directory_flag( - #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, PathBuf)>, + #[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(temp_path) .arg("help") .arg("targets") .output() @@ -186,8 +187,8 @@ fn help_targets_honours_directory_flag( #[rstest] fn help_targets_renders_foreach_descriptions_without_changing_rule_progress() -> Result<()> { let temp = tempfile::tempdir().context("create foreach help-targets workspace")?; - let manifest_path = temp.path().join("Netsukefile"); 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 @@ -256,11 +257,11 @@ fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { .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"); + let manifest_path = temp_path.join("Netsukefile"); data.copy("invalid_version.yml", &workspace, "Netsukefile") - .with_context(|| format!("copy invalid manifest to {}", manifest_path.display()))?; + .with_context(|| format!("copy invalid manifest to {}", manifest_path.as_str()))?; let cli = Cli { - file: manifest_path, + file: manifest_path.into_std_path_buf(), command: Some(Commands::Help(HelpArgs { topic: Some(HelpTopic::Targets), })), @@ -272,6 +273,37 @@ fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { Ok(()) } +#[test] +fn help_targets_rejects_valid_manifest_with_missing_rule() -> Result<()> { + let temp = tempfile::tempdir().context("create missing-rule fixture directory")?; + 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 missing-rule fixture directory")?; + workspace + .write( + "Netsukefile", + b"netsuke_version: \"1.0.0\"\ntargets:\n - name: out/app\n rule: missing\n", + ) + .context("write missing-rule manifest")?; + 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("missing manifest rule should fail help targets"); + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains("was not found")), + "IR validation should report the missing rule: {error:?}" + ); + Ok(()) +} + #[test] fn help_targets_rejects_unknown_manifest_default() -> Result<()> { let (temp, manifest_path) = help_targets_manifest()?; @@ -285,7 +317,7 @@ fn help_targets_rejects_unknown_manifest_default() -> Result<()> { ) .context("write unknown-default manifest")?; let cli = Cli { - file: manifest_path, + file: manifest_path.into_std_path_buf(), command: Some(Commands::Help(HelpArgs { topic: Some(HelpTopic::Targets), })), From 5616757417420874501880d98a235822cb99bcb7 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 12:26:28 +0200 Subject: [PATCH 26/61] Rename target descriptions execplan (#551) Align the completed ExecPlan filename with the issue branch so its purpose is discoverable without relying on an opaque identifier. --- ...issue-551-add-target-descriptions-and-netsuke-help-targets.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/execplans/{fef13161.md => issue-551-add-target-descriptions-and-netsuke-help-targets.md} (100%) diff --git a/docs/execplans/fef13161.md b/docs/execplans/issue-551-add-target-descriptions-and-netsuke-help-targets.md similarity index 100% rename from docs/execplans/fef13161.md rename to docs/execplans/issue-551-add-target-descriptions-and-netsuke-help-targets.md From 9c3e775f80d4f16147ba4cf132a9ad8c03d921dc Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 18:47:29 +0200 Subject: [PATCH 27/61] Polish localized help copy --- locales/cs/messages.ftl | 2 +- locales/de/messages.ftl | 2 +- locales/el/messages.ftl | 2 +- locales/he/messages.ftl | 2 +- locales/hu/messages.ftl | 2 +- locales/id/messages.ftl | 4 ++-- locales/ro/messages.ftl | 6 +++--- locales/zh-Hans/messages.ftl | 4 ++-- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 8dabc406c..ca6c3a8f0 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -32,7 +32,7 @@ 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 = Vytiskne nápovědu na nejvyšší úrovni, nebo nápovědu pro pojmenované téma. +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. diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 65d85f9c7..e1521fbde 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -32,7 +32,7 @@ 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 = Zeigt die Hilfe auf oberster Ebene oder die Hilfe für ein benanntes Thema. +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. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 05d60be99..0d0a74299 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -32,7 +32,7 @@ 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.about = Εκτύπωση της βοήθειας ανώτατου επιπέδου ή της βοήθειας για ένα ονομασμένο θέμα. cli.subcommand.help.long_about = Χωρίς θέμα, αυτό ταιριάζει με το `--help`. Χρησιμοποιήστε το `help targets` για να εκτυπώσετε τον κατάλογο στόχων και ενεργειών για το επιλεγμένο αρχείο. # Help catalogue headings and markers. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 3109cf84b..2b84924aa 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -32,7 +32,7 @@ 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.about = הדפסת העזרה ברמה העליונה או העזרה עבור נושא בעל שם. cli.subcommand.help.long_about = ללא נושא, זה תואם את `--help`. השתמש ב-`help targets` כדי להדפיס את קטלוג היעדים והפעולות עבור הקובץ שנבחר. # Help catalogue headings and markers. diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index c8d6e3b35..7341a318b 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -32,7 +32,7 @@ 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 = Kiírja a felső szintű súgót, vagy a megnevezett téma súgóját. +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. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index b83bf0d96..bd49ff49b 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -33,12 +33,12 @@ cli.subcommand.graph.long_about = Proyeksikan manifes Netsuke yang telah diurai 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 file yang dipilih. +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 file yang dipilih. +cli.help.targets.about = Mencetak target dan tindakan dalam berkas yang dipilih. cli.help.default_marker = bawaan # Teks bantuan untuk opsi subperintah build. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 6f3e88d94..ca77a0b8f 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -32,7 +32,7 @@ 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șați ajutorul de nivel superior sau ajutorul pentru un subiect numit. +cli.subcommand.help.about = Afișează ajutorul de nivel superior sau ajutorul pentru un subiect numit. 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. @@ -351,8 +351,8 @@ stdlib.register.dir_non_utf8 = Directorul curent conține componente care nu sun # Raportarea stării în modul de ieșire accesibil. status.state.pending = în așteptare status.state.running = în desfășurare -status.state.done = finalizată -status.state.failed = eșuată +status.state.done = finalizat +status.state.failed = eșuat status.stage.label = Etapa { $current }/{ $total }: { $description } status.stage.summary = [{ $state }] { $label } status.stage.summary_with_task = [{ $state }] { $label } ({ $task_progress }) diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index d6afabfcd..b4e52ce70 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -36,9 +36,9 @@ cli.subcommand.help.about = 打印顶层帮助,或打印指定主题的帮助 cli.subcommand.help.long_about = 没有主题时,此命令等价于 `--help`。使用 `help targets` 打印所选清单的目标和操作目录。 # Help catalogue headings and markers. -cli.help.actions_heading = 操作: +cli.help.actions_heading = 动作: cli.help.targets_heading = 目标: -cli.help.targets.about = 列出所选清单中的目标和操作。 +cli.help.targets.about = 列出所选清单中的目标和动作。 cli.help.default_marker = 默认 # build 子命令选项的帮助文本。 From 16ade1b2d3e618c1b6d20148033227346180f6bf Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 18:49:12 +0200 Subject: [PATCH 28/61] Correct locale review wording --- docs/v0-1-0-migration-guide.md | 1 + locales/el/messages.ftl | 2 +- locales/he/messages.ftl | 2 +- locales/ro/messages.ftl | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 34e8ad205..9f1efc5e5 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -16,6 +16,7 @@ on it is conditional on tracking those changes. ## At-a-glance changes + Table: documented v0.1.0 additions, including `netsuke help targets`, and their impact | Area | Impact | Where to read more | diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 0d0a74299..0ac0f05d5 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -32,7 +32,7 @@ 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.about = Εκτυπώστε τη βοήθεια ανώτατου επιπέδου ή τη βοήθεια για ένα ονομασμένο θέμα. cli.subcommand.help.long_about = Χωρίς θέμα, αυτό ταιριάζει με το `--help`. Χρησιμοποιήστε το `help targets` για να εκτυπώσετε τον κατάλογο στόχων και ενεργειών για το επιλεγμένο αρχείο. # Help catalogue headings and markers. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 2b84924aa..63639ca2b 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -32,7 +32,7 @@ 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.about = הדפיסו את העזרה ברמה העליונה, או את העזרה עבור נושא בעל שם. cli.subcommand.help.long_about = ללא נושא, זה תואם את `--help`. השתמש ב-`help targets` כדי להדפיס את קטלוג היעדים והפעולות עבור הקובץ שנבחר. # Help catalogue headings and markers. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index ca77a0b8f..2b07f05f0 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -351,8 +351,8 @@ stdlib.register.dir_non_utf8 = Directorul curent conține componente care nu sun # Raportarea stării în modul de ieșire accesibil. status.state.pending = în așteptare status.state.running = în desfășurare -status.state.done = finalizat -status.state.failed = eșuat +status.state.done = finalizată +status.state.failed = eșuată status.stage.label = Etapa { $current }/{ $total }: { $description } status.stage.summary = [{ $state }] { $label } status.stage.summary_with_task = [{ $state }] { $label } ({ $task_progress }) From e38ffd423a2f1bd172f6d955907ef2caa1177fd5 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 19:00:52 +0200 Subject: [PATCH 29/61] Document restricted help-targets rendering (#551) Describe the query-only blocking of `env()`, `contents`, `fetch`, `shell`, and `grep` across the user, developer, migration, CLI design, and completed ExecPlan documents. Record that normal build manifest rendering remains unchanged. --- docs/developers-guide.md | 12 +++++++----- ...d-target-descriptions-and-netsuke-help-targets.md | 7 +++++-- docs/netsuke-cli-design-document.md | 11 ++++++----- docs/users-guide.md | 6 ++++-- docs/v0-1-0-migration-guide.md | 8 +++++--- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8c0da610d..9e442c75a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -48,11 +48,13 @@ runs the manifest loading, expansion, rendering, and IR-validation stages to produce a deterministic action-then-target catalogue. It may validate a `BuildGraph`, but 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: expressions invoking `fetch`, -`shell`, or `grep` are rejected rather than executed. 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`. +restricted, side-effect-free query surface: query expressions invoking +`env()`, the `contents` filter, `fetch`, `shell`, or `grep` are rejected rather +than executed. This restriction applies only to query rendering; normal build +manifest rendering remains unchanged. 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`. ## Localization 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 index c4ac9598d..de838eb82 100644 --- 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 @@ -16,8 +16,11 @@ 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 so -manifest inspection cannot fetch data, execute commands, or write caches. +The discovery query uses a restricted, side-effect-free Jinja surface. It +blocks `env()`, the `contents` filter, `fetch`, `shell`, and `grep`, so manifest +inspection cannot disclose host environment or file contents, fetch data, +execute commands, or write caches. Normal build manifest rendering remains +unchanged. 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 diff --git a/docs/netsuke-cli-design-document.md b/docs/netsuke-cli-design-document.md index d5897a523..a2b138725 100644 --- a/docs/netsuke-cli-design-document.md +++ b/docs/netsuke-cli-design-document.md @@ -71,11 +71,12 @@ 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: expressions invoking `fetch`, `shell`, or `grep` are rejected rather -than executed. This keeps discovery useful in an unfamiliar project without -making help a build operation. Existing manifests remain compatible when they -omit the optional descriptions; the helper restriction applies only to this -inspection path. +surface: query expressions invoking `env()`, the `contents` filter, `fetch`, +`shell`, or `grep` are rejected rather than executed. This keeps discovery +useful in an unfamiliar project without making help a build operation. The +restriction applies only to query rendering; normal build manifest rendering +remains unchanged. 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 diff --git a/docs/users-guide.md b/docs/users-guide.md index b3e69450f..63c3b5bf7 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -863,8 +863,10 @@ 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: -expressions invoking `fetch`, `shell`, or `grep` are rejected rather than -executed. It honours the usual manifest-selection options (`--file`, +query expressions invoking `env()`, the `contents` filter, `fetch`, `shell`, +or `grep` are rejected rather than executed. This restriction applies only to +query rendering; normal build manifest rendering remains unchanged. 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`. diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 9f1efc5e5..0f2a2d729 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -81,9 +81,11 @@ 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. Expressions -invoking `fetch`, `shell`, or `grep` are rejected rather than executed by this -command. Add `--json` to receive the versioned JSON result document; its +targets without running recipes or creating build outputs. Query expressions +invoking `env()`, the `contents` filter, `fetch`, `shell`, or `grep` are +rejected rather than executed by this command. This restriction applies only +to query rendering; normal build manifest rendering remains unchanged. 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. From 3ea3f382775c17989e3d15b2330cbf1e6a396644 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 19:09:56 +0200 Subject: [PATCH 30/61] Restrict help query data disclosure (#551) Reject environment and file-content template helpers when rendering the target catalogue, while preserving their behaviour for normal builds. --- src/manifest/env_reader.rs | 6 ++++ src/manifest/query.rs | 5 +-- src/manifest/tests/workspace.rs | 64 ++++++++++++++++++++++++++++++--- src/stdlib/register.rs | 25 +++++++++---- 4 files changed, 86 insertions(+), 14 deletions(-) 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/query.rs b/src/manifest/query.rs index 85b9ed214..4413d376d 100644 --- a/src/manifest/query.rs +++ b/src/manifest/query.rs @@ -7,7 +7,7 @@ use super::{ EnvReader, ManifestLoadStage, ManifestName, ManifestParse, NetsukeManifest, StdlibConfig, - StdlibRegistration, from_str_named, notify_stage, process_env_reader, + StdlibRegistration, env_reader::disabled_env_reader, from_str_named, notify_stage, workspace::open_manifest_workspace, }; use crate::{localization, localization::keys, stdlib::NetworkPolicy}; @@ -24,9 +24,10 @@ 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, - &process_env_reader(), + &env_reader, on_stage, StdlibRegistration::ManifestQuery, ) diff --git a/src/manifest/tests/workspace.rs b/src/manifest/tests/workspace.rs index 71fb219f3..b37489472 100644 --- a/src/manifest/tests/workspace.rs +++ b/src/manifest/tests/workspace.rs @@ -217,18 +217,21 @@ fn from_path_uses_manifest_directory_for_caches() -> AnyResult<()> { Ok(()) } -/// Discovery queries must reject helpers that could perform I/O before any -/// network request, cache write, or command execution occurs. +/// 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")] -fn manifest_query_rejects_impure_template_helpers( +#[case::env("{{ env('PATH') }}", "env")] +#[case::contents("{{ 'secret.txt' | contents }}", "contents")] +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", @@ -243,20 +246,69 @@ fn manifest_query_rejects_impure_template_helpers( test_fs::write(&manifest_path, manifest)?; let error = from_path_for_manifest_query(&manifest_path, None) - .expect_err("manifest query should reject side-effecting template helpers"); + .expect_err("manifest query should reject restricted template helpers"); ensure!( error .chain() - .any(|cause| cause.to_string().contains(&format!("{helper} is disabled"))), + .any(|cause| cause.to_string().contains(helper)), "query should name its rejected helper: {error:?}" ); + ensure!( + !error.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(()) } +/// 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::contents("{{ 'secret.txt' | contents }}", "contents")] +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.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")?; @@ -282,3 +334,5 @@ fn manifest_query_rejects_clock_dependent_template_helpers() -> AnyResult<()> { ); Ok(()) } + +const QUERY_SECRET: &str = "help-query-secret"; diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index 28735f87c..01341a64e 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -108,8 +108,9 @@ pub fn register_with_config( /// /// The registration preserves pure rendering helpers, including date, path, /// collection, and executable-discovery helpers. It replaces `fetch`, -/// `shell`, and `grep` with explicit errors so consumers can render discovery -/// metadata without network access, cache writes, or command execution. +/// `shell`, `grep`, and `contents` with explicit errors so consumers can +/// render discovery metadata without network access, cache writes, command +/// execution, or host file-content disclosure. /// pub(crate) fn register_manifest_query_with_config( env: &mut Environment<'_>, @@ -118,7 +119,7 @@ pub(crate) fn register_manifest_query_with_config( let state = StdlibState::default(); register_read_only_helpers(env, config); time::register_query_functions(env); - register_disabled_impure_helpers(env); + register_disabled_query_helpers(env); state } @@ -140,8 +141,11 @@ fn register_read_only_helpers(env: &mut Environment<'_>, config: &StdlibConfig) which::register(env, which_config); } -/// Register deliberate failures for stdlib helpers that have side effects. -fn register_disabled_impure_helpers(env: &mut Environment<'_>) { +/// 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( "fetch", |_url: String, _kwargs: Kwargs| -> Result { @@ -165,15 +169,22 @@ fn register_disabled_impure_helpers(env: &mut Environment<'_>) { _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 an impure helper is unavailable while querying a manifest. +/// 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 side-effect-free template helpers" + manifest queries permit only non-disclosing, side-effect-free \ + template helpers" ), ) } From e455aa3500712b913e2c64f9c900da939e545d5b Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 22:45:57 +0200 Subject: [PATCH 31/61] Clarify target help diagnostics (#551) Use manifest terminology in Dutch help text and prove an invalid manifest causes the expected parsing failure rather than an unrelated error. --- locales/nl/messages.ftl | 2 +- tests/runner_help_targets_tests.rs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 7a46fdaf3..a9b50b08a 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -38,7 +38,7 @@ cli.subcommand.help.long_about = Zonder onderwerp komt dit overeen met `--help`. # 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 bestand weergeven. +cli.help.targets.about = Doelen en acties in het geselecteerde manifest weergeven. cli.help.default_marker = standaard # Helptekst voor opties van de subopdracht build. diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index d6fbeeadf..e05e81c3c 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -267,9 +267,13 @@ fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { })), ..Cli::default() }; - let Err(_) = run_help_targets(&cli) else { - anyhow::bail!("expected help targets to fail with invalid manifest"); - }; + 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(()) } From e5afbff7abb4e0947c807caf2a05680c80335228 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 22:48:11 +0200 Subject: [PATCH 32/61] Refine Czech and Romanian help status (#551) Use idiomatic prepositions in the localized target-help status labels. --- locales/cs/messages.ftl | 2 +- locales/ro/messages.ftl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index ca6c3a8f0..5fee5712a 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -376,7 +376,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 = Nápověda cílů +status.tool.help_targets = Nápověda k cílům # Texty vykreslování grafu do HTML. graph.html.title = Graf sestavení Netsuke diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 2b07f05f0..5a44d772a 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -376,7 +376,7 @@ status.tool.clean = Curățare status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generare -status.tool.help_targets = Ajutor ținte +status.tool.help_targets = Ajutor pentru ținte # Textele redării grafului în HTML. graph.html.title = Graful de construire Netsuke From 8bff44c28c24d22ec612ca418f336556e0f469d6 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 01:40:33 +0200 Subject: [PATCH 33/61] Deduplicate help-target rejection tests (#551) Centralize capability-scoped fixture setup and error-chain checks so the missing-rule and unknown-default tests retain the same assertions without duplicating their setup. --- tests/runner_help_targets_tests.rs | 96 ++++++++++++++---------------- 1 file changed, 44 insertions(+), 52 deletions(-) diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index e05e81c3c..afc87a548 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -69,6 +69,40 @@ fn run_help_targets(cli: &Cli) -> Result<()> { 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| cause.to_string().contains(expected_error)), + "error should contain {expected_error:?}: {error:?}" + ); + Ok(()) +} + #[rstest] fn help_targets_prints_actions_and_targets( #[from(help_targets_manifest)] fixture: Result<(tempfile::TempDir, Utf8PathBuf)>, @@ -279,62 +313,20 @@ fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { #[test] fn help_targets_rejects_valid_manifest_with_missing_rule() -> Result<()> { - let temp = tempfile::tempdir().context("create missing-rule fixture directory")?; - 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 missing-rule fixture directory")?; - workspace - .write( - "Netsukefile", - b"netsuke_version: \"1.0.0\"\ntargets:\n - name: out/app\n rule: missing\n", - ) - .context("write missing-rule manifest")?; - 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("missing manifest rule should fail help targets"); - ensure!( - error - .chain() - .any(|cause| cause.to_string().contains("was not found")), - "IR validation should report the missing rule: {error:?}" - ); - Ok(()) + 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_unknown_manifest_default() -> Result<()> { - let (temp, manifest_path) = help_targets_manifest()?; - 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 unknown-default fixture directory")?; - workspace - .write( - "Netsukefile", - b"netsuke_version: \"1.0.0\"\nactions:\n - name: lint\n command: cargo clippy\ntargets: []\ndefaults:\n - missing\n", - ) - .context("write unknown-default manifest")?; - 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("unknown manifest default should fail"); - ensure!( - error - .chain() - .any(|cause| cause.to_string().contains("default 'missing'")), - "error should identify the unknown default: {error:?}" - ); - Ok(()) + 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'", + ) } #[rstest] From 6b5ed7d7698dd63adf84b01997844701716f928b Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 01:45:43 +0200 Subject: [PATCH 34/61] Document target discovery descriptions --- docs/netsuke-design.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 22fb7fd5f..29fd159a1 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -219,6 +219,7 @@ erDiagram StringOrList deps StringOrList order_only_deps map vars + string description bool phony bool always } @@ -676,10 +677,10 @@ schema defined in Section 2. They will be defined in a dedicated module, 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. +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 @@ -762,7 +763,8 @@ 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. From edc82a64f7debf7838902b0fdbbd8e2bcc8e6042 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 01:54:54 +0200 Subject: [PATCH 35/61] Document help-target query boundaries (#551) Document the complete `netsuke help targets` Jinja allowlist and its host-observing exclusions in the developer, user, CLI, and migration guides. Record the registration boundary and the normal-build full-stdlib guarantee in the issue-551 ExecPlan and developer guide. --- docs/developers-guide.md | 27 ++++++++++++++----- ...t-descriptions-and-netsuke-help-targets.md | 17 +++++++++--- docs/netsuke-cli-design-document.md | 17 +++++++----- docs/users-guide.md | 20 ++++++++++---- docs/v0-1-0-migration-guide.md | 16 +++++++---- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 9e442c75a..86010b4d4 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -48,13 +48,26 @@ runs the manifest loading, expansion, rendering, and IR-validation stages to produce a deterministic action-then-target catalogue. It may validate a `BuildGraph`, but 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: query expressions invoking -`env()`, the `contents` filter, `fetch`, `shell`, or `grep` are rejected rather -than executed. This restriction applies only to query rendering; normal build -manifest rendering remains unchanged. 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`. +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`. ## Localization 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 index de838eb82..575d380a4 100644 --- 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 @@ -17,10 +17,16 @@ 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 -blocks `env()`, the `contents` filter, `fetch`, `shell`, and `grep`, so manifest -inspection cannot disclose host environment or file contents, fetch data, -execute commands, or write caches. Normal build manifest rendering remains -unchanged. +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 @@ -111,6 +117,9 @@ default marker such as `[★ default]` on manifest defaults. progress; added `cli.help.targets.about` to all 35 shipped locales. - [x] (2026-08-12, `625e93f`) Used a dedicated localized synopsis for the nested `targets` help topic and aligned the localized help assertions with it. +- [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. ## Surprises & discoveries diff --git a/docs/netsuke-cli-design-document.md b/docs/netsuke-cli-design-document.md index a2b138725..6670b9149 100644 --- a/docs/netsuke-cli-design-document.md +++ b/docs/netsuke-cli-design-document.md @@ -71,12 +71,17 @@ 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: query expressions invoking `env()`, the `contents` filter, `fetch`, -`shell`, or `grep` are rejected rather than executed. This keeps discovery -useful in an unfamiliar project without making help a build operation. The -restriction applies only to query rendering; normal build manifest rendering -remains unchanged. Existing manifests remain compatible when they omit the -optional descriptions. +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 diff --git a/docs/users-guide.md b/docs/users-guide.md index 63c3b5bf7..9c93ac4e5 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -862,15 +862,25 @@ 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: -query expressions invoking `env()`, the `contents` filter, `fetch`, `shell`, -or `grep` are rejected rather than executed. This restriction applies only to -query rendering; normal build manifest rendering remains unchanged. It -honours the usual manifest-selection options (`--file`, +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 0f2a2d729..27ecb8a63 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -81,11 +81,17 @@ 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. Query expressions -invoking `env()`, the `contents` filter, `fetch`, `shell`, or `grep` are -rejected rather than executed by this command. This restriction applies only -to query rendering; normal build manifest rendering remains unchanged. Add -`--json` to receive the versioned JSON result document; its +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. From e869dbcd7c2db5e9474c29358e28d494b1a603b7 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 02:02:00 +0200 Subject: [PATCH 36/61] Restrict target-help manifest queries (#551) Render discovery metadata with an explicitly safe helper surface that cannot read host state, execute commands, or perform network work. Prove foreach action descriptions render through the catalogue without executing their recipes. --- src/manifest/mod.rs | 12 +++++------ src/manifest/query.rs | 34 ++++++++++++++++++------------ src/manifest/tests/workspace.rs | 20 ++++++++++++++++++ src/stdlib/mod.rs | 2 +- src/stdlib/path/filters.rs | 16 +++++++++++++- src/stdlib/path/mod.rs | 2 +- src/stdlib/register.rs | 28 +++++++++++++----------- tests/runner_help_targets_tests.rs | 15 +++++++++++++ 8 files changed, 94 insertions(+), 35 deletions(-) diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index 341526341..ed42813c6 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -112,9 +112,9 @@ struct ManifestParse<'a> { /// Selects the stdlib surface available while rendering a manifest. enum StdlibRegistration { /// The complete stdlib used for a normal build manifest. - Full(StdlibConfig), + Full(Box), /// The read-only stdlib used to inspect manifest discovery metadata. - ManifestQuery(StdlibConfig), + ManifestQuery, } fn from_str_named( yaml: &str, @@ -147,11 +147,11 @@ fn from_str_named( }); let _stdlib_state = match stdlib_registration { Some(StdlibRegistration::Full(config)) => { - crate::stdlib::register_with_config(&mut jinja, config) + crate::stdlib::register_with_config(&mut jinja, *config) + } + Some(StdlibRegistration::ManifestQuery) => { + Ok(crate::stdlib::register_manifest_query(&mut jinja)) } - Some(StdlibRegistration::ManifestQuery(config)) => Ok( - crate::stdlib::register_manifest_query_with_config(&mut jinja, &config), - ), None => crate::stdlib::register(&mut jinja), }?; diff --git a/src/manifest/query.rs b/src/manifest/query.rs index 4413d376d..3e78656df 100644 --- a/src/manifest/query.rs +++ b/src/manifest/query.rs @@ -25,12 +25,7 @@ pub(crate) fn from_path_for_manifest_query( on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, ) -> Result { let env_reader = disabled_env_reader(); - from_path_with_registration( - path, - &env_reader, - on_stage, - StdlibRegistration::ManifestQuery, - ) + from_path_with_registration(path, &env_reader, on_stage, ManifestLoadMode::ManifestQuery) } /// Load a manifest with the full stdlib and an explicit network policy. @@ -40,9 +35,15 @@ pub(super) fn from_path_with_policy_and_env( env_reader: &EnvReader, on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, ) -> Result { - from_path_with_registration(path, env_reader, on_stage, |config| { - StdlibRegistration::Full(config.with_network_policy(policy)) - }) + 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. @@ -50,7 +51,7 @@ fn from_path_with_registration( path: impl AsRef, env_reader: &EnvReader, mut on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, - register_stdlib: impl FnOnce(StdlibConfig) -> StdlibRegistration, + mode: ManifestLoadMode, ) -> Result { notify_stage(&mut on_stage, ManifestLoadStage::ManifestIngestion); let path_ref = path.as_ref(); @@ -63,14 +64,19 @@ fn from_path_with_registration( .with_arg("path", path_ref.display().to_string()) })?; let name = ManifestName::new(path_ref.display().to_string()); - let config = register_stdlib( - StdlibConfig::new(workspace.dir)?.with_workspace_root_path(workspace.root)?, - ); + 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(config), + stdlib_registration: Some(stdlib_registration), env_reader, }, &mut on_stage, diff --git a/src/manifest/tests/workspace.rs b/src/manifest/tests/workspace.rs index b37489472..16a2a93ee 100644 --- a/src/manifest/tests/workspace.rs +++ b/src/manifest/tests/workspace.rs @@ -224,7 +224,17 @@ fn from_path_uses_manifest_directory_for_caches() -> AnyResult<()> { #[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, @@ -270,7 +280,17 @@ fn manifest_query_rejects_restricted_template_helpers( #[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, diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 35c0810a9..48584f87d 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -26,7 +26,7 @@ pub use config::{ pub use network::{ HostPatternError, NetworkPolicy, NetworkPolicyConfigError, NetworkPolicyViolation, }; -pub(crate) use register::register_manifest_query_with_config; +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 01341a64e..c95e89af2 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -106,25 +106,20 @@ pub fn register_with_config( /// Register helpers suitable for manifest queries that must avoid side effects. /// -/// The registration preserves pure rendering helpers, including date, path, -/// collection, and executable-discovery helpers. It replaces `fetch`, -/// `shell`, `grep`, and `contents` with explicit errors so consumers can -/// render discovery metadata without network access, cache writes, command -/// execution, or host file-content disclosure. +/// 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_with_config( - env: &mut Environment<'_>, - config: &StdlibConfig, -) -> StdlibState { +pub(crate) fn register_manifest_query(env: &mut Environment<'_>) -> StdlibState { let state = StdlibState::default(); - register_read_only_helpers(env, config); + register_query_helpers(env); time::register_query_functions(env); register_disabled_query_helpers(env); state } -/// Register query helpers that avoid side effects while retaining filesystem -/// capabilities for file tests, path helpers, and `which`. +/// 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()); @@ -141,11 +136,20 @@ fn register_read_only_helpers(env: &mut Environment<'_>, config: &StdlibConfig) which::register(env, which_config); } +/// 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 { diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index afc87a548..13a7cb5fa 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -233,6 +233,13 @@ 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 @@ -262,6 +269,10 @@ targets: "Build the weekly report", "report-monthly", "Build the monthly report", + "check-unit", + "Run unit", + "check-integration", + "Run integration", ] { ensure!( stdout.contains(expected), @@ -280,6 +291,10 @@ targets: !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(), + "help targets must not execute action recipes" + ); Ok(()) } From 7feba4a98aef24f17f8360691c71b083a3ed65f9 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 02:21:34 +0200 Subject: [PATCH 37/61] Fix migration guide spacing (#551) Remove the duplicate blank line so the documented v0.1.0 changes pass the Markdown lint contract after the rebase. --- docs/v0-1-0-migration-guide.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 27ecb8a63..32363fd28 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -64,7 +64,6 @@ 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 From a002fffcb7b84d5793b364e182e1b8fdc2af21e3 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 14:35:12 +0200 Subject: [PATCH 38/61] Harden target-help catalogue checks (#551) Exercise accessibility, localisation, and rejected query rendering through the command boundary without allowing recipes or build outputs. Correct catalogue terminology and keep the design and execution plan factual about the remaining release-help integration. --- ...t-descriptions-and-netsuke-help-targets.md | 22 ++- docs/netsuke-design.md | 1 + locales/cs/messages.ftl | 2 +- locales/ro/messages.ftl | 4 +- tests/runner_help_targets_tests.rs | 148 +++++++++++++++++- 5 files changed, 162 insertions(+), 15 deletions(-) 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 index 575d380a4..436d4dfc6 100644 --- 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 @@ -97,7 +97,9 @@ default marker such as `[★ default]` on manifest defaults. 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); man page and PowerShell help pick up `help` automatically. + and its test); build.rs Clap man generation can include `help`, while + release man and PowerShell artefacts require follow-on `CliConfig`/Clap + integration. No shell completion artefacts exist to update. - [x] (2026-08-09) All gates green: check-fmt, lint (rustdoc/clippy/Whitaker), nextest (1936), doctests, markdownlint, spelling, nixie. Committed as four atomic commits. @@ -162,9 +164,10 @@ default marker such as `[★ default]` on manifest defaults. - 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/fef13161.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. +- 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 @@ -172,9 +175,10 @@ 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; and the man -page plus PowerShell help pick up the new command surface automatically -through clap derivation (no shell completions exist to update). +the users guide documents the schema field and the subcommand. The build.rs +Clap man generation can include the new command surface, while release man +and PowerShell artefacts require follow-on `CliConfig`/Clap integration; no +shell completion artefacts exist to update. The post-`314f12b` follow-up additionally isolates discovery rendering from impure template helpers, keeps terminal text safe, preserves rule descriptions @@ -251,7 +255,9 @@ generates Ninja build files. Key files and modules for this task: `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`; - confirm man page and PowerShell help pick up the command automatically. + confirm build.rs Clap man generation can include the command, and record + the follow-on `CliConfig`/Clap integration needed for release man and + PowerShell artefacts. No shell completion artefacts exist to update. ## Validation and acceptance diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 29fd159a1..af305a58e 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -856,6 +856,7 @@ let ast = NetsukeManifest { deps: StringOrList::Empty, order_only_deps: StringOrList::Empty, vars: HashMap::new(), + description: None, phony: false, always: false, }], diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 5fee5712a..eedc7cc09 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -376,7 +376,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 = Nápověda k cílům +status.tool.help_targets = Katalog cílů # Texty vykreslování grafu do HTML. graph.html.title = Graf sestavení Netsuke diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 5a44d772a..f47e88ad8 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -32,7 +32,7 @@ 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 numit. +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. @@ -376,7 +376,7 @@ status.tool.clean = Curățare status.tool.graph = Graf status.tool.graph_html = Graf (HTML) status.tool.generate = Generare -status.tool.help_targets = Ajutor pentru ținte +status.tool.help_targets = Catalogul țintelor # Textele redării grafului în HTML. graph.html.title = Graful de construire Netsuke diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index 13a7cb5fa..3b00dfca6 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -37,16 +37,16 @@ fn write_help_targets_manifest(temp: &tempfile::TempDir) -> Result actions: - name: lint description: Run rustdoc, Clippy, and Whitaker - command: cargo clippy --all-targets --all-features -- -D warnings + command: touch lint-ran - name: test description: Run unit, behavioural, UI, and documentation tests - command: cargo test + command: touch test-ran targets: - name: target/release/catnap description: Build the optimized release binary - command: cargo build --release + command: touch release-ran - name: plain - command: echo plain + command: touch plain-ran defaults: - lint - test @@ -69,6 +69,20 @@ fn run_help_targets(cli: &Cli) -> Result<()> { run(cli, output_prefs::resolve(None)).context("running help targets subcommand") } +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_help_targets_rejects_manifest( fixture_name: &str, manifest: &[u8], @@ -139,6 +153,72 @@ fn help_targets_prints_actions_and_targets( 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)>, @@ -298,6 +378,66 @@ targets: 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] fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { let temp = tempfile::tempdir().context("temp dir")?; From 793b718f0b820a60e5d1c2f67f7d6ada7eeaa9f2 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 15:47:11 +0200 Subject: [PATCH 39/61] Extract foreach catalogue assertion (#551) Keep the integration scenario focused on manifest setup and graph semantics by moving its command-level catalogue assertions into a private helper. --- tests/runner_help_targets_tests.rs | 61 ++++++++++++++++-------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index 3b00dfca6..e753250c3 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -83,6 +83,38 @@ fn assert_fixture_recipes_not_run(workspace: &Dir) -> Result<()> { 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(()) +} + fn assert_help_targets_rejects_manifest( fixture_name: &str, manifest: &[u8], @@ -331,34 +363,7 @@ targets: ) .context("write foreach manifest")?; - 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}" - ); - } + assert_foreach_help_catalogue(&manifest_path)?; let manifest = manifest::from_path(&manifest_path)?; let graph = BuildGraph::from_manifest(&manifest).context("generate foreach graph")?; From 4133b0e0c4c7252543ee7ff6614a5c945cc4b344 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 20:55:56 +0200 Subject: [PATCH 40/61] Align release help with target catalogue (#551) Expose the Clap command surface through release-help metadata so the manual and PowerShell artefacts document `help targets`. Harden target-help diagnostics against manifest control characters and exercise root, topic, and no-Ninja command boundaries. Split the catalogue integration scenarios into a focused child module. --- Cargo.toml | 2 +- docs/developers-guide.md | 8 +- ...t-descriptions-and-netsuke-help-targets.md | 19 +- scripts/generate-release-help.sh | 1 + src/cli/mod.rs | 2 + src/cli/release_help.rs | 104 +++++ src/runner/help.rs | 3 +- tests/release_help_script_tests.rs | 4 + tests/runner_help_targets_tests.rs | 427 +++--------------- tests/runner_help_targets_tests/catalogue.rs | 369 +++++++++++++++ 10 files changed, 567 insertions(+), 372 deletions(-) create mode 100644 src/cli/release_help.rs create mode 100644 tests/runner_help_targets_tests/catalogue.rs diff --git a/Cargo.toml b/Cargo.toml index 3605c8468..4e89bda5a 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", diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 86010b4d4..692bac451 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -827,8 +827,12 @@ 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: +rather than 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. The build script remains +responsible for the localization key audit only. Release automation installs +the pinned tool with: ```bash cargo install cargo-orthohelp --version 0.9.0 --locked 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 index 436d4dfc6..0e5f3f072 100644 --- 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 @@ -97,9 +97,9 @@ default marker such as `[★ default]` on manifest defaults. 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); build.rs Clap man generation can include `help`, while - release man and PowerShell artefacts require follow-on `CliConfig`/Clap - integration. No shell completion artefacts exist to update. + and its test); CliConfig/Clap integration keeps build.rs and release man + and PowerShell artefacts aligned with the `help targets` command. No + shell completion artefacts exist to update. - [x] (2026-08-09) All gates green: check-fmt, lint (rustdoc/clippy/Whitaker), nextest (1936), doctests, markdownlint, spelling, nixie. Committed as four atomic commits. @@ -175,10 +175,9 @@ 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. The build.rs -Clap man generation can include the new command surface, while release man -and PowerShell artefacts require follow-on `CliConfig`/Clap integration; no -shell completion artefacts exist to update. +the users guide documents the schema field and the subcommand. CliConfig/Clap +integration keeps build.rs and release man and PowerShell artefacts aligned +with the new command surface; no shell completion artefacts exist to update. The post-`314f12b` follow-up additionally isolates discovery rendering from impure template helpers, keeps terminal text safe, preserves rule descriptions @@ -255,9 +254,9 @@ generates Ninja build files. Key files and modules for this task: `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`; - confirm build.rs Clap man generation can include the command, and record - the follow-on `CliConfig`/Clap integration needed for release man and - PowerShell artefacts. No shell completion artefacts exist to update. + integrate CliConfig with Clap so build.rs, release man, and PowerShell + artefacts expose the same `help targets` command surface. No shell completion + artefacts exist to update. ## Validation and acceptance 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/cli/mod.rs b/src/cli/mod.rs index d9b74bc1c..1a6fa1442 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -17,6 +17,7 @@ mod help; mod merge; mod parser; mod parsing; +mod release_help; #[cfg(test)] pub(crate) mod test_support; @@ -30,6 +31,7 @@ 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/release_help.rs b/src/cli/release_help.rs new file mode 100644 index 000000000..c063a9210 --- /dev/null +++ b/src/cli/release_help.rs @@ -0,0 +1,104 @@ +//! 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::*; + + #[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" + ); + } +} diff --git a/src/runner/help.rs b/src/runner/help.rs index d4fa08249..4785278b8 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -123,9 +123,10 @@ fn build_catalogue(manifest: &NetsukeManifest) -> Vec> { 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()), - "manifest default '{default}' does not name a declared action or target" + "manifest default '{safe_default}' does not name a declared action or target" ); } Ok(()) 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/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index e753250c3..7115ff235 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -1,26 +1,21 @@ //! Integration tests for the in-process `netsuke help targets` subcommand. //! -//! The `help targets` subcommand loads, expands, renders, and validates the -//! selected manifest without invoking Ninja, then prints the target and action -//! catalogue. These tests verify the dispatch works without Ninja installed, -//! honours `--file` and `-C/--directory`, and emits the expected JSON envelope -//! in `--json` mode. +//! 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 netsuke::{ - cli::{Cli, Commands, HelpArgs, HelpTopic}, - ir::BuildGraph, - manifest, ninja_gen, -}; use rstest::{fixture, rstest}; -use serde_json::Value; use test_support::{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 @@ -69,52 +64,6 @@ fn run_help_targets(cli: &Cli) -> Result<()> { run(cli, output_prefs::resolve(None)).context("running help targets subcommand") } -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(()) -} - fn assert_help_targets_rejects_manifest( fixture_name: &str, manifest: &[u8], @@ -149,300 +98,6 @@ fn assert_help_targets_rejects_manifest( 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") - .arg("--file") - .arg(&manifest_path) - .arg("help") - .arg("targets") - .output() - .context("run netsuke help targets")?; - ensure!( - output.status.success(), - "help targets 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("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(), - "help targets must not execute action 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] fn help_targets_with_invalid_manifest_reports_error() -> Result<()> { let temp = tempfile::tempdir().context("temp dir")?; @@ -489,15 +144,71 @@ fn help_targets_rejects_unknown_manifest_default() -> Result<()> { ) } +#[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<()> { + let temp = tempfile::tempdir().context("create unsafe-default diagnostic fixture directory")?; + let temp_path = Utf8Path::from_path(temp.path()) + .context("unsafe-default diagnostic 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 unsafe-default diagnostic fixture directory")?; + workspace + .write( + "Netsukefile", + b"netsuke_version: \"1.0.0\"\nactions:\n - name: lint\n command: cargo clippy\ntargets: []\ndefaults:\n - \"bad\\nINJECTED\"\n", + ) + .context("write unsafe-default diagnostic manifest")?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run help targets against unsafe default")?; + ensure!( + !output.status.success(), + "unsafe default should make help targets fail validation" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + ensure!( + stderr.contains(r"default 'bad\nINJECTED'"), + "diagnostic should show escaped manifest controls: {stderr}" + ); + ensure!( + !stderr.contains("default 'bad\nINJECTED'"), + "diagnostic must not emit a raw manifest newline: {stderr}" + ); + Ok(()) +} + #[rstest] fn plain_help_matches_minimal_workspace() -> Result<()> { let (temp, manifest_path) = create_test_manifest()?; - let cli = Cli { - file: manifest_path, - directory: Some(temp.path().to_path_buf()), - command: Some(Commands::Help(HelpArgs { topic: None })), - ..Cli::default() - }; - run_help_targets(&cli)?; + 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..12fa6d1e5 --- /dev/null +++ b/tests/runner_help_targets_tests/catalogue.rs @@ -0,0 +1,369 @@ +//! 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(), + "help targets must not execute action 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(()) +} From c840edbeb9b82f7ac0b214c9a641cc7e6c6484bd Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 14 Aug 2026 21:01:00 +0200 Subject: [PATCH 41/61] Keep terminal control detection const-compatible (#551) Replace `char::is_control` with direct Unicode Cc ranges so the Kani toolchain can compile target-help rendering. Cover every Cc range boundary through the catalogue renderer. --- src/runner/help.rs | 15 ++++++++++----- src/runner/help_tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/runner/help.rs b/src/runner/help.rs index 4785278b8..eddd3429a 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -237,11 +237,16 @@ fn terminal_safe(input: &str) -> Cow<'_, str> { /// Return whether a character can control terminal display or reading order. const fn is_terminal_control(character: char) -> bool { - character.is_control() - || matches!( - character, - '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' - ) + matches!( + character, + '\0'..='\u{001F}' + | '\u{007F}'..='\u{009F}' + | '\u{061C}' + | '\u{200E}' + | '\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2066}'..='\u{2069}' + ) } /// Load a manifest for a no-side-effect metadata query while reporting stages. diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index 88678949d..c4a85b6c7 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -146,6 +146,37 @@ fn text_catalogue_escapes_terminal_control_characters() -> Result<()> { 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)> { From 252aefd7558dcec259b2746bac103122800a01a6 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 00:54:04 +0200 Subject: [PATCH 42/61] Document release shell completion sidecars Describe the generated Bash, Elvish, Fish, PowerShell, and Zsh completion files, their `completions//` archive layout, and manual copy guidance. Clarify that `build.rs` generates completions while `cargo-orthohelp` remains the release source for manual and PowerShell help. --- docs/developers-guide.md | 7 +++++++ docs/netsuke-design.md | 12 +++++++++--- docs/users-guide.md | 15 ++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 692bac451..e11df30d6 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -855,6 +855,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/netsuke-design.md b/docs/netsuke-design.md index af305a58e..9810b157c 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2942,8 +2942,12 @@ 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. +artefacts. `cargo-orthohelp` remains the release source for the manual page and +PowerShell help. 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 @@ -2973,7 +2977,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/users-guide.md b/docs/users-guide.md index 9c93ac4e5..241ddfc1b 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: From 2de05cb2b04f15ebb7d9680824a38cf77c643420 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 01:07:45 +0200 Subject: [PATCH 43/61] Generate release shell completions (#551) Generate five shell completion formats from `Cli::command()` and stage them with every release archive. Cover the generated command tree, manual-page topic, PowerShell generator metadata, and staging contract so `help targets` cannot drift from the generated help surfaces. --- .github/release-staging.toml | 27 ++++++++++++ Cargo.lock | 10 +++++ Cargo.toml | 1 + build.rs | 40 ++++++++++++++--- docs/netsuke-design.md | 6 +-- src/cli/parser.rs | 2 +- src/cli/release_help.rs | 21 +++++++++ tests/completion_contract_tests.rs | 67 +++++++++++++++++++++++++++++ tests/man_page_contract_tests.rs | 17 ++++++++ tests/release_staging_tests.rs | 42 ++++++++++++++++++ tests/workflow_build_and_package.rs | 5 +++ 11 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 tests/completion_contract_tests.rs 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 8001f2e46..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", diff --git a/Cargo.toml b/Cargo.toml index 4e89bda5a..bd5128e6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,7 @@ 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..c5b0efdb9 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 { @@ -205,9 +208,36 @@ fn generate_man_page(out_dir: &Path) -> Result<(), Box> { Ok(()) } +fn generate_completions(out_dir: &Path) -> Result<(), Box> { + fs::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/netsuke-design.md b/docs/netsuke-design.md index 9810b157c..2ec46de98 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2941,9 +2941,9 @@ 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. `cargo-orthohelp` remains the release source for the manual page and -PowerShell help. Separately, `build.rs` generates Bash, Elvish, Fish, +`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 diff --git a/src/cli/parser.rs b/src/cli/parser.rs index 768d28e62..c2a3b0bd6 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -324,7 +324,7 @@ pub enum Commands { output: Option, }, - /// Print the top-level help, or the help for a named topic. + /// 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. diff --git a/src/cli/release_help.rs b/src/cli/release_help.rs index c063a9210..8f2f7cc8e 100644 --- a/src/cli/release_help.rs +++ b/src/cli/release_help.rs @@ -72,6 +72,7 @@ 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() { @@ -101,4 +102,24 @@ mod tests { "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/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/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/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/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)?; From a34b13f645c230103683a79cb3364904b13025d9 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 01:25:40 +0200 Subject: [PATCH 44/61] Clarify help target validation (#551) Document that `help targets` always validates the rendered build graph and correct the design record's discovery-only description contract. Extend the foreach regression to prove catalogue rendering executes neither action nor rule-backed target recipes. --- docs/developers-guide.md | 30 ++++++++++--------- ...t-descriptions-and-netsuke-help-targets.md | 27 +++++++++++------ docs/netsuke-design.md | 21 ++++++++----- tests/runner_help_targets_tests/catalogue.rs | 7 +++-- 4 files changed, 53 insertions(+), 32 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e11df30d6..1726db243 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -44,13 +44,14 @@ 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, rendering, and IR-validation stages to -produce a deterministic action-then-target catalogue. It may validate a -`BuildGraph`, but 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` +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 @@ -826,13 +827,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 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. 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 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 index 0e5f3f072..19d4f1f5d 100644 --- 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 @@ -97,9 +97,13 @@ default marker such as `[★ default]` on manifest defaults. 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 build.rs and release man - and PowerShell artefacts aligned with the `help targets` command. No - shell completion artefacts exist to update. + 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. @@ -175,9 +179,12 @@ 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. CliConfig/Clap -integration keeps build.rs and release man and PowerShell artefacts aligned -with the new command surface; no shell completion artefacts exist to update. +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 post-`314f12b` follow-up additionally isolates discovery rendering from impure template helpers, keeps terminal text safe, preserves rule descriptions @@ -254,9 +261,11 @@ generates Ninja build files. Key files and modules for this task: `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 build.rs, release man, and PowerShell - artefacts expose the same `help targets` command surface. No shell completion - artefacts exist to update. + 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 diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 2ec46de98..58f3f8722 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 { @@ -240,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. @@ -1994,13 +2001,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 diff --git a/tests/runner_help_targets_tests/catalogue.rs b/tests/runner_help_targets_tests/catalogue.rs index 12fa6d1e5..ea8cf259a 100644 --- a/tests/runner_help_targets_tests/catalogue.rs +++ b/tests/runner_help_targets_tests/catalogue.rs @@ -276,8 +276,11 @@ targets: "target discovery descriptions must not replace Ninja progress: {ninja}" ); ensure!( - workspace.open("action-unit").is_err() && workspace.open("action-integration").is_err(), - "help targets must not execute action recipes" + 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(()) } From 4ede9969eb9933305790308df2e091ea4b1450fe Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 13:52:52 +0200 Subject: [PATCH 45/61] Isolate help target query dependencies (#551) Preserve manifest I/O failures during preflight validation and bypass Ninja program resolution for help commands. Limit the process-wide localizer lock to catalogue rendering, before snapshot assertion work begins. --- src/runner/dispatch.rs | 10 ++++++--- src/runner/help_tests.rs | 44 ++++++++++++++++++++++++++++++++++---- src/runner/mod.rs | 21 +++++++++++++++--- src/runner/path_helpers.rs | 35 ++++++++++++++++++++++++++---- src/runner/tests.rs | 31 +++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 14 deletions(-) diff --git a/src/runner/dispatch.rs b/src/runner/dispatch.rs index 4f83290b5..1d6799a53 100644 --- a/src/runner/dispatch.rs +++ b/src/runner/dispatch.rs @@ -15,14 +15,18 @@ 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), + Commands::Help(args) => execute_help(cli, &args, context.reporter), } } -fn execute_help(cli: &Cli, args: &HelpArgs, context: &ExecutionContext<'_>) -> Result<()> { +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, context.reporter), + 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"), diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index c4a85b6c7..09284db7e 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -14,7 +14,9 @@ use anyhow::{Context, Result}; use insta::assert_snapshot; use proptest::prelude::*; use semver::Version; -use std::sync::Arc; +use std::sync::{Arc, mpsc}; +use std::thread; +use std::time::Duration; use test_support::fluent::normalize_fluent_isolates; use test_support::localizer_test_lock; @@ -60,16 +62,50 @@ fn catalogue_snapshot( snapshot_name: &str, render: impl FnOnce(&NetsukeManifest) -> Result, ) -> Result<()> { - let _lock = localizer_lock(); - let _guard = set_localizer_for_tests(Arc::from(build_localizer(Some(locale)))); let manifest = fixture_manifest()?; - let rendered = render(&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(0); + let contender = thread::spawn(move || { + let _lock = localizer_lock(); + acquired.send(()).ok(); + }); + 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!( + 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| { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 9c298131f..1d525f599 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -20,6 +20,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}; @@ -120,8 +121,7 @@ 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. @@ -133,6 +133,16 @@ pub fn run(cli: &Cli, prefs: OutputPrefs) -> Result<()> { /// /// 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(); @@ -147,10 +157,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) } diff --git a/src/runner/path_helpers.rs b/src/runner/path_helpers.rs index 8c74b4b94..c372eadd9 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,8 +80,13 @@ pub(super) fn ensure_manifest_exists_or_error( reporter: &dyn StatusReporter, manifest_path: &Utf8PathBuf, ) -> Result<()> { - if manifest_path.as_std_path().exists() { - return Ok(()); + 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); @@ -116,3 +123,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(()) +} From 19b44751df7b9efc867c05a6a85766e58cef51ae Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 18:04:07 +0200 Subject: [PATCH 46/61] Instrument help target queries (#551) Record bounded outcomes and durations at the catalogue-query boundary so operators can distinguish successful and failed manifest queries without exposing manifest-controlled values. Correct the ExecPlan's evidence to use reachable commits from the current branch history. --- ...t-descriptions-and-netsuke-help-targets.md | 37 ++-- src/runner/help.rs | 13 ++ src/runner/help_telemetry.rs | 71 +++++++ src/runner/help_telemetry_tests.rs | 177 ++++++++++++++++++ 4 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 src/runner/help_telemetry.rs create mode 100644 src/runner/help_telemetry_tests.rs 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 index 19d4f1f5d..cded82534 100644 --- 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 @@ -111,17 +111,18 @@ default marker such as `[★ default]` on manifest defaults. - [x] (2026-08-09) Branch renamed to `issue-551-add-target-descriptions-and-netsuke-help-targets`, pushed, PR opened: . -- [x] (2026-08-12, `d524941`) Documented the restricted, side-effect-free Jinja +- [x] (2026-08-14, `099f70ac`) Documented the restricted, side-effect-free Jinja surface for `netsuke help targets` in the migration, users', developers', and CLI design guides. -- [x] (2026-08-12, `e5edb0d`) Routed target help through a restricted manifest - query path, escaped terminal control characters in text output, and added - end-to-end, IR, and property coverage for the query and catalogue - invariants. -- [x] (2026-08-12, `e9efae6`) Clarified that target and action descriptions - remain discovery metadata and do not replace rule descriptions in Ninja - progress; added `cli.help.targets.about` to all 35 shipped locales. -- [x] (2026-08-12, `625e93f`) Used a dedicated localized synopsis for the nested +- [x] (2026-08-14, `98e6f95e`, `5d39023e`, `61afb7a6`) Routed target help through + a restricted manifest query path, escaped terminal control characters in + text output, and added end-to-end, IR, and property coverage for the + query and catalogue invariants. +- [x] (2026-08-14, `49fb6bfe`, `0106a692`) Clarified that target and action + descriptions remain discovery metadata and do not replace rule + descriptions in Ninja progress; added `cli.help.targets.about` to all 35 + shipped locales. +- [x] (2026-08-14, `0106a692`) Used a dedicated localized synopsis for the nested `targets` help topic and aligned the localized help assertions with it. - [x] (2026-08-14) Documented the complete query-mode allowlist, its excluded host-observing helpers, and the full standard library retained by normal @@ -157,9 +158,9 @@ default marker such as `[★ default]` on manifest defaults. 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 post-`314f12b` query path uses a dedicated localized +- 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: `625e93f`. Impact: keep the + section heading. Evidence: `0106a692`. Impact: keep the `cli.help.targets.about` key separate from `actions_heading` and `targets_heading`. @@ -186,13 +187,13 @@ 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 post-`314f12b` 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 from commits `d524941`, -`e5edb0d`, `e9efae6`, and `625e93f`; the current history does not record a new -full-gate run after those commits, so no additional gate result is claimed -here. +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 `98e6f95e`, +`5d39023e`, `61afb7a6`, `49fb6bfe`, and `0106a692`. Commits `036dc331` and +`9f1f8603` subsequently tightened BuildGraph validation, query dependencies, +and localization-lock scoping; their full gate runs passed. Lessons learned: diff --git a/src/runner/help.rs b/src/runner/help.rs index eddd3429a..dfc67693a 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -25,6 +25,10 @@ use crate::theme::ThemeContext; use super::path_helpers::{ensure_manifest_exists_or_error, resolve_manifest_path}; use super::process; +use telemetry::instrument_help_targets; + +#[path = "help_telemetry.rs"] +mod telemetry; /// One catalogue row: a single resolved target name with its metadata. struct HelpEntry<'a> { @@ -46,6 +50,11 @@ struct HelpEntry<'a> { /// 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<()> { + instrument_help_targets(|| handle_help_targets_inner(cli, reporter)) +} + +/// Run the catalogue query without crossing the observability boundary. +fn handle_help_targets_inner(cli: &Cli, reporter: &dyn StatusReporter) -> Result<()> { info!( target: "netsuke::subcommand", subcommand = "help-targets", @@ -335,3 +344,7 @@ fn json_entries(entries: Vec>) -> Vec> { #[cfg(test)] #[path = "help_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "help_telemetry_tests.rs"] +mod telemetry_tests; diff --git a/src/runner/help_telemetry.rs b/src/runner/help_telemetry.rs new file mode 100644 index 000000000..99e2535c6 --- /dev/null +++ b/src/runner/help_telemetry.rs @@ -0,0 +1,71 @@ +//! 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.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..daccf6a78 --- /dev/null +++ b/src/runner/help_telemetry_tests.rs @@ -0,0 +1,177 @@ +//! 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: [] +"#; + +/// 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, +)>; + +/// 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)> { + 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 the counter value for one bounded outcome/error category pair. +fn counter_value(snapshot: &Snapshot, outcome: &str, error_category: &str) -> Option { + snapshot + .iter() + .find_map(|(key, _unit, _description, value)| { + if key.kind() != MetricKind::Counter || key.key().name() != HELP_TARGETS_TOTAL { + return None; + } + let labels: Vec<(&str, &str)> = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect(); + let matches = labels.contains(&("outcome", outcome)) + && labels.contains(&("error_category", error_category)); + match value { + DebugValue::Counter(count) if matches => Some(*count), + _ => None, + } + }) +} + +/// Count recorded duration samples for one bounded outcome/error pair. +fn duration_sample_count(snapshot: &Snapshot, outcome: &str, error_category: &str) -> usize { + snapshot + .iter() + .find_map(|(key, _unit, _description, value)| { + if key.kind() != MetricKind::Histogram || key.key().name() != HELP_TARGETS_DURATION { + return None; + } + let labels: Vec<(&str, &str)> = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect(); + let matches = labels.contains(&("outcome", outcome)) + && labels.contains(&("error_category", error_category)); + match value { + DebugValue::Histogram(samples) if matches => Some(samples.len()), + _ => None, + } + }) + .unwrap_or_default() +} + +#[test] +fn help_targets_records_bounded_success_telemetry() -> 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 (_temp, cli) = help_targets_fixture()?; + let ((result, events), snapshot) = recorded(|| { + with_test_subscriber(LevelFilter::INFO, |captured| { + let result = handle_help_targets(&cli, &SilentReporter); + (result, captured.snapshot()) + }) + }); + + result?; + ensure!( + counter_value(&snapshot, "success", "none") == Some(1), + "successful help targets should increment the bounded counter" + ); + ensure!( + duration_sample_count(&snapshot, "success", "none") == 1, + "successful help targets should record one duration sample" + ); + ensure!( + events + .iter() + .any(|event| event.contains("Completed help targets query") + && event.contains("outcome=\"success\"") + && event.contains("error_category=\"none\"")), + "successful help targets should emit a bounded completion event: {events:?}" + ); + Ok(()) +} + +#[test] +fn help_targets_records_manifest_failure_telemetry() -> 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 cli = Cli { + file: "missing-help-telemetry-manifest.yml".into(), + ..Cli::default() + }; + let ((result, events), snapshot) = recorded(|| { + with_test_subscriber(LevelFilter::INFO, |captured| { + let result = handle_help_targets(&cli, &SilentReporter); + (result, captured.snapshot()) + }) + }); + + ensure!(result.is_err(), "missing manifest should fail help targets"); + ensure!( + counter_value(&snapshot, "error", "manifest_not_found") == Some(1), + "missing manifest should increment the bounded failure counter" + ); + ensure!( + duration_sample_count(&snapshot, "error", "manifest_not_found") == 1, + "missing manifest should record one duration sample" + ); + ensure!( + events + .iter() + .any(|event| event.contains("Completed help targets query") + && event.contains("outcome=\"error\"") + && event.contains("error_category=\"manifest_not_found\"")), + "failed help targets should emit a bounded completion event: {events:?}" + ); + Ok(()) +} From 7497b697f92429446ac18bf6fdf5fd9feb4adb89 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 20:19:34 +0200 Subject: [PATCH 47/61] Document help-target query telemetry Describe the orchestration boundary, bounded metrics and labels, redaction contract, one-time registration, and local recorder/subscriber tests for `netsuke help targets`. --- docs/developers-guide.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 1726db243..fffcf873b 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -70,6 +70,27 @@ 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 complete +`netsuke help targets` orchestration. `instrument_help_targets` wraps the +manifest 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. + +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 both a successful fixture query and a missing-manifest failure. + ## Localization `src/locale_catalogues.rs` is the authoritative registry of shipped catalogues. From bd2b2da9111cae846ff6f3edbb10064e448cf247 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 20:45:20 +0200 Subject: [PATCH 48/61] Separate help target query effects (#551) Move manifest loading, validation, and catalogue construction behind a fallible result that carries only catalogue and stage data. Keep telemetry, status reporting, and rendering at the command boundary so inspection stays query-safe while failed query stages are still reported. Cover the non-RunnerError telemetry category and document the bounded metric contract for contributors. --- docs/developers-guide.md | 12 ++- src/runner/help.rs | 159 ++++++++-------------------- src/runner/help_query.rs | 164 +++++++++++++++++++++++++++++ src/runner/help_telemetry.rs | 5 +- src/runner/help_telemetry_tests.rs | 43 +++++++- src/runner/help_tests.rs | 7 +- src/runner/path_helpers.rs | 18 +++- 7 files changed, 280 insertions(+), 128 deletions(-) create mode 100644 src/runner/help_query.rs diff --git a/docs/developers-guide.md b/docs/developers-guide.md index fffcf873b..856bec9b8 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -72,13 +72,14 @@ inspection to `runner::process`. ### Help-target query telemetry -`src/runner/help_telemetry.rs` is the observability boundary around the complete -`netsuke help targets` orchestration. `instrument_help_targets` wraps the -manifest query and records the fixed metrics +`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. +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`. @@ -89,7 +90,8 @@ other details. Metric descriptions are registered once per process, through a 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 both a successful fixture query and a missing-manifest failure. +for a successful fixture query, a missing-manifest failure, and an invalid +manifest failure classified as the non-`RunnerError` `other` category. ## Localization diff --git a/src/runner/help.rs b/src/runner/help.rs index dfc67693a..2329d1b3a 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -5,17 +5,14 @@ //! actions and targets with their descriptions. The no-topic and //! subcommand-name topics render clap's localized help text instead. -use anyhow::{Context, Result, ensure}; +use anyhow::{Context, Result}; use clap::CommandFactory; use serde::Serialize; -use std::{borrow::Cow, collections::HashSet}; -use tracing::info; +use std::borrow::Cow; use unicode_width::UnicodeWidthStr; -use crate::ast::{NetsukeManifest, Target}; use crate::cli::Cli; use crate::cli_l10n::localize_command; -use crate::ir::BuildGraph; use crate::json_envelope::{GeneratorInfo, SCHEMA_VERSION}; use crate::localization::{self, keys}; use crate::output_mode; @@ -23,20 +20,17 @@ use crate::output_prefs::{self, OutputPrefs}; use crate::status::{LocalizationKey, PipelineStage, StatusReporter, report_pipeline_stage}; use crate::theme::ThemeContext; -use super::path_helpers::{ensure_manifest_exists_or_error, resolve_manifest_path}; use super::process; use telemetry::instrument_help_targets; +#[path = "help_query.rs"] +mod query; #[path = "help_telemetry.rs"] mod telemetry; -/// One catalogue row: a single resolved target name with its metadata. -struct HelpEntry<'a> { - name: String, - description: Option<&'a str>, - is_action: bool, - is_default: bool, -} +#[cfg(test)] +use query::build_catalogue; +use query::{HelpEntry, HelpTargetsQueryFailure, query_help_targets}; /// Render the `help targets` catalogue to stdout without invoking Ninja. /// @@ -50,42 +44,43 @@ struct HelpEntry<'a> { /// 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<()> { - instrument_help_targets(|| handle_help_targets_inner(cli, reporter)) -} - -/// Run the catalogue query without crossing the observability boundary. -fn handle_help_targets_inner(cli: &Cli, reporter: &dyn StatusReporter) -> Result<()> { - info!( - target: "netsuke::subcommand", - subcommand = "help-targets", - "Rendering target and action catalogue" - ); - let manifest_path = resolve_manifest_path(cli)?; - ensure_manifest_exists_or_error(cli, reporter, &manifest_path)?; - let manifest = load_manifest_for_query_with_stage_reporting(&manifest_path, reporter)?; - + 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); - // 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)?; 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(entries).context("serialize help targets catalogue")?; + let rendered = render_json(&query.entries).context("serialize help targets catalogue")?; process::write_text_stdout(&rendered)?; } else { - let rendered = render_text(&entries, resolved_prefs(cli)); + 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 @@ -114,49 +109,6 @@ pub(super) fn render_subcommand_help(name: &str) -> Result<()> { process::write_text_stdout(&text) } -/// 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. -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()), - "manifest default '{safe_default}' does not name a declared action or target" - ); - } - Ok(()) -} - -fn append_target_entries<'a>( - entries: &mut Vec>, - target: &'a Target, - is_action: bool, - defaults: &HashSet<&str>, -) { - for name in target.name.to_string_vec() { - entries.push(HelpEntry { - is_default: defaults.contains(name.as_str()), - name, - description: target.description.as_deref(), - is_action, - }); - } -} - /// 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 { @@ -171,9 +123,9 @@ fn resolved_prefs(cli: &Cli) -> OutputPrefs { /// 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(); +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() { @@ -185,7 +137,7 @@ fn render_text(entries: &[HelpEntry<'_>], prefs: OutputPrefs) -> String { fn render_section( out: &mut String, - entries: &[&HelpEntry<'_>], + entries: &[&HelpEntry], heading_key: &'static str, prefs: OutputPrefs, ) { @@ -209,7 +161,7 @@ fn render_section( out.push_str(" "); out.push_str(&name); out.push_str(&" ".repeat(width.saturating_sub(name_width))); - if let Some(description) = entry.description { + if let Some(description) = entry.description.as_deref() { out.push_str(" "); out.push_str(&terminal_safe(description)); } @@ -258,32 +210,6 @@ const fn is_terminal_control(character: char) -> bool { ) } -/// Load a manifest for a no-side-effect metadata query while reporting stages. -fn load_manifest_for_query_with_stage_reporting( - manifest_path: &camino::Utf8PathBuf, - reporter: &dyn StatusReporter, -) -> Result { - let mut on_stage = |stage| match stage { - crate::manifest::ManifestLoadStage::ManifestIngestion => { - report_pipeline_stage(reporter, PipelineStage::ManifestIngestion, None); - } - crate::manifest::ManifestLoadStage::InitialYamlParsing => { - report_pipeline_stage(reporter, PipelineStage::InitialYamlParsing, None); - } - crate::manifest::ManifestLoadStage::TemplateExpansion => { - report_pipeline_stage(reporter, PipelineStage::TemplateExpansion, None); - } - crate::manifest::ManifestLoadStage::FinalRendering => { - report_pipeline_stage(reporter, PipelineStage::FinalRendering, None); - } - }; - 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()) - }) -} - /// 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 { @@ -315,9 +241,8 @@ struct HelpEntryJson<'a> { default: bool, } -fn render_json(entries: Vec>) -> Result { - let (actions, targets): (Vec<_>, Vec<_>) = - entries.into_iter().partition(|entry| entry.is_action); +fn render_json(entries: &[HelpEntry]) -> Result { + let (actions, targets): (Vec<_>, Vec<_>) = entries.iter().partition(|entry| entry.is_action); serde_json::to_string_pretty(&HelpTargetsDocument { schema_version: SCHEMA_VERSION, generator: GeneratorInfo::current(), @@ -330,12 +255,12 @@ fn render_json(entries: Vec>) -> Result { .context("serialize help targets catalogue") } -fn json_entries(entries: Vec>) -> Vec> { +fn json_entries(entries: Vec<&HelpEntry>) -> Vec> { entries .into_iter() .map(|entry| HelpEntryJson { - name: entry.name, - description: entry.description, + name: entry.name.clone(), + description: entry.description.as_deref(), default: entry.is_default, }) .collect() diff --git a/src/runner/help_query.rs b/src/runner/help_query.rs new file mode 100644 index 000000000..ce166d207 --- /dev/null +++ b/src/runner/help_query.rs @@ -0,0 +1,164 @@ +//! Pure manifest loading and catalogue construction for `netsuke help targets`. + +use anyhow::{Context, Result, 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)?; + + // 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) +} + +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. +#[cfg(test)] +pub(super) fn build_catalogue(manifest: &NetsukeManifest) -> Vec { + build_catalogue_inner(manifest) +} + +#[cfg(not(test))] +fn build_catalogue(manifest: &NetsukeManifest) -> Vec { + build_catalogue_inner(manifest) +} + +fn build_catalogue_inner(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()), + "manifest default '{safe_default}' does not name a declared action or target" + ); + } + 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 index 99e2535c6..aa1bee965 100644 --- a/src/runner/help_telemetry.rs +++ b/src/runner/help_telemetry.rs @@ -48,7 +48,10 @@ pub(super) fn instrument_help_targets(query: impl FnOnce() -> Result) -> R /// Classify catalogue failures without exposing manifest-controlled detail. fn help_targets_error_category(error: &anyhow::Error) -> &'static str { - if error.downcast_ref::().is_some() { + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { "manifest_not_found" } else { "other" diff --git a/src/runner/help_telemetry_tests.rs b/src/runner/help_telemetry_tests.rs index daccf6a78..2ff2f2a13 100644 --- a/src/runner/help_telemetry_tests.rs +++ b/src/runner/help_telemetry_tests.rs @@ -23,6 +23,8 @@ actions: 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<( @@ -43,12 +45,17 @@ fn recorded(operation: impl FnOnce() -> T) -> (T, Snapshot) { /// 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) + .write("Netsukefile", manifest) .context("write help telemetry manifest")?; let manifest_path = root.join("Netsukefile").into_std_path_buf(); Ok(( @@ -175,3 +182,37 @@ fn help_targets_records_manifest_failure_telemetry() -> Result<()> { ); Ok(()) } + +#[test] +fn help_targets_records_other_failure_telemetry() -> 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 (_temp, cli) = help_targets_fixture_with_manifest(INVALID_MANIFEST)?; + let ((result, events), snapshot) = recorded(|| { + with_test_subscriber(LevelFilter::INFO, |captured| { + let result = handle_help_targets(&cli, &SilentReporter); + (result, captured.snapshot()) + }) + }); + + ensure!(result.is_err(), "invalid manifest should fail help targets"); + ensure!( + counter_value(&snapshot, "error", "other") == Some(1), + "invalid manifest should increment the bounded other-failure counter" + ); + ensure!( + duration_sample_count(&snapshot, "error", "other") == 1, + "invalid manifest should record one duration sample" + ); + ensure!( + events + .iter() + .any(|event| event.contains("Completed help targets query") + && event.contains("outcome=\"error\"") + && event.contains("error_category=\"other\"")), + "invalid manifest should emit a bounded completion event: {events:?}" + ); + Ok(()) +} diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index 09284db7e..fcbc6313e 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -5,6 +5,7 @@ //! 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; @@ -86,7 +87,7 @@ fn render_catalogue_with_locale( 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)) + render_json(&build_catalogue(parsed_manifest)) })?; let (acquired, confirmed) = mpsc::sync_channel(0); let contender = thread::spawn(move || { @@ -139,7 +140,7 @@ fn localized_catalogue_snapshot() -> Result<()> { #[test] fn json_catalogue_snapshot() -> Result<()> { catalogue_snapshot("en-US", "json_catalogue", |manifest| { - render_json(build_catalogue(manifest)) + render_json(&build_catalogue(manifest)) }) } @@ -297,7 +298,7 @@ proptest! { .into_iter() .map(|entry| ( entry.name, - entry.description.map(str::to_owned), + entry.description.as_deref().map(str::to_owned), entry.is_action, entry.is_default, )) diff --git a/src/runner/path_helpers.rs b/src/runner/path_helpers.rs index c372eadd9..f36831c79 100644 --- a/src/runner/path_helpers.rs +++ b/src/runner/path_helpers.rs @@ -80,6 +80,23 @@ pub(super) fn ensure_manifest_exists_or_error( reporter: &dyn StatusReporter, manifest_path: &Utf8PathBuf, ) -> Result<()> { + 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 => { @@ -89,7 +106,6 @@ pub(super) fn ensure_manifest_exists_or_error( Err(_) => {} } - report_pipeline_stage(reporter, PipelineStage::ManifestIngestion, None); // `resolve_manifest_path()` validates that `file_name()` is Some. let manifest_name = manifest_path .file_name() From a3220ea14302981abdf472b5cdc0a3765992bc49 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 21:13:23 +0200 Subject: [PATCH 49/61] Repair post-rebase module layout (#551) Preserve the intended manifest-query tests while repairing replayed fixtures. Split the AST value type and rendering tests into focused modules so the rebased branch satisfies the repository's module-size policy without changing the public AST path. --- src/{ast.rs => ast/mod.rs} | 150 +---------------- src/ast/string_or_list.rs | 149 +++++++++++++++++ src/manifest/render.rs | 276 +------------------------------- src/manifest/render_tests.rs | 274 +++++++++++++++++++++++++++++++ src/manifest/tests/workspace.rs | 56 ------- src/runner/help_tests.rs | 2 +- 6 files changed, 430 insertions(+), 477 deletions(-) rename src/{ast.rs => ast/mod.rs} (68%) create mode 100644 src/ast/string_or_list.rs create mode 100644 src/manifest/render_tests.rs diff --git a/src/ast.rs b/src/ast/mod.rs similarity index 68% rename from src/ast.rs rename to src/ast/mod.rs index f76e2f576..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))] @@ -262,149 +266,3 @@ pub struct Target { #[serde(default)] pub description: Option, } - -/// 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/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/manifest/render.rs b/src/manifest/render.rs index c27b8fc8c..59b7040de 100644 --- a/src/manifest/render.rs +++ b/src/manifest/render.rs @@ -201,280 +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, - 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(()) - } -} +#[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 16a2a93ee..2f46ee038 100644 --- a/src/manifest/tests/workspace.rs +++ b/src/manifest/tests/workspace.rs @@ -217,62 +217,6 @@ 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.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(()) -} /// Discovery queries must reject helpers that could cause side effects or /// disclose host data before a catalogue is rendered. #[rstest] diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index fcbc6313e..62d273f1c 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -229,7 +229,7 @@ fn catalogue_target(names: Vec, description: Option, phony: bool Target { name: crate::ast::StringOrList::List(names), recipe: crate::ast::Recipe::Command { - command: "true".to_owned(), + command: crate::ast::StringOrList::String("true".to_owned()), }, sources: crate::ast::StringOrList::Empty, deps: crate::ast::StringOrList::Empty, From 6a6d0031f98698dd947a84c050f648f38c1ceb0a Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 22:26:08 +0200 Subject: [PATCH 50/61] Consolidate help-target telemetry tests (#551) Share the localizer, recorder, and bounded-telemetry assertions across the success, manifest-not-found, and other-failure scenarios while preserving their distinct fixtures and expected labels. --- src/runner/help_telemetry_tests.rs | 190 +++++++++++++---------------- 1 file changed, 88 insertions(+), 102 deletions(-) diff --git a/src/runner/help_telemetry_tests.rs b/src/runner/help_telemetry_tests.rs index 2ff2f2a13..a83ec8b8b 100644 --- a/src/runner/help_telemetry_tests.rs +++ b/src/runner/help_telemetry_tests.rs @@ -34,6 +34,13 @@ type Snapshot = Vec<( 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) { @@ -67,34 +74,17 @@ fn help_targets_fixture_with_manifest(manifest: &str) -> Result<(TempDir, Cli)> )) } -/// Find the counter value for one bounded outcome/error category pair. -fn counter_value(snapshot: &Snapshot, outcome: &str, error_category: &str) -> Option { - snapshot - .iter() - .find_map(|(key, _unit, _description, value)| { - if key.kind() != MetricKind::Counter || key.key().name() != HELP_TARGETS_TOTAL { - return None; - } - let labels: Vec<(&str, &str)> = key - .key() - .labels() - .map(|label| (label.key(), label.value())) - .collect(); - let matches = labels.contains(&("outcome", outcome)) - && labels.contains(&("error_category", error_category)); - match value { - DebugValue::Counter(count) if matches => Some(*count), - _ => None, - } - }) -} - -/// Count recorded duration samples for one bounded outcome/error pair. -fn duration_sample_count(snapshot: &Snapshot, outcome: &str, error_category: &str) -> usize { +/// 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() != MetricKind::Histogram || key.key().name() != HELP_TARGETS_DURATION { + if key.kind() != kind || key.key().name() != name { return None; } let labels: Vec<(&str, &str)> = key @@ -102,117 +92,113 @@ fn duration_sample_count(snapshot: &Snapshot, outcome: &str, error_category: &st .labels() .map(|label| (label.key(), label.value())) .collect(); - let matches = labels.contains(&("outcome", outcome)) - && labels.contains(&("error_category", error_category)); - match value { - DebugValue::Histogram(samples) if matches => Some(samples.len()), - _ => None, - } + let matches = labels.contains(&("outcome", expected.outcome)) + && labels.contains(&("error_category", expected.error_category)); + matches.then_some(value) }) - .unwrap_or_default() } -#[test] -fn help_targets_records_bounded_success_telemetry() -> Result<()> { +/// 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 (_temp, cli) = help_targets_fixture()?; let ((result, events), snapshot) = recorded(|| { with_test_subscriber(LevelFilter::INFO, |captured| { - let result = handle_help_targets(&cli, &SilentReporter); + let result = handle_help_targets(cli, &SilentReporter); (result, captured.snapshot()) }) }); - result?; + 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!( - counter_value(&snapshot, "success", "none") == Some(1), - "successful help targets should increment the bounded counter" + 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!( - duration_sample_count(&snapshot, "success", "none") == 1, - "successful help targets should record one duration sample" + 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("outcome=\"success\"") - && event.contains("error_category=\"none\"")), - "successful help targets should emit a bounded completion event: {events:?}" + && 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 _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 cli = Cli { file: "missing-help-telemetry-manifest.yml".into(), ..Cli::default() }; - let ((result, events), snapshot) = recorded(|| { - with_test_subscriber(LevelFilter::INFO, |captured| { - let result = handle_help_targets(&cli, &SilentReporter); - (result, captured.snapshot()) - }) - }); - - ensure!(result.is_err(), "missing manifest should fail help targets"); - ensure!( - counter_value(&snapshot, "error", "manifest_not_found") == Some(1), - "missing manifest should increment the bounded failure counter" - ); - ensure!( - duration_sample_count(&snapshot, "error", "manifest_not_found") == 1, - "missing manifest should record one duration sample" - ); - ensure!( - events - .iter() - .any(|event| event.contains("Completed help targets query") - && event.contains("outcome=\"error\"") - && event.contains("error_category=\"manifest_not_found\"")), - "failed help targets should emit a bounded completion event: {events:?}" - ); - Ok(()) + 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 _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 (_temp, cli) = help_targets_fixture_with_manifest(INVALID_MANIFEST)?; - let ((result, events), snapshot) = recorded(|| { - with_test_subscriber(LevelFilter::INFO, |captured| { - let result = handle_help_targets(&cli, &SilentReporter); - (result, captured.snapshot()) - }) - }); - - ensure!(result.is_err(), "invalid manifest should fail help targets"); - ensure!( - counter_value(&snapshot, "error", "other") == Some(1), - "invalid manifest should increment the bounded other-failure counter" - ); - ensure!( - duration_sample_count(&snapshot, "error", "other") == 1, - "invalid manifest should record one duration sample" - ); - ensure!( - events - .iter() - .any(|event| event.contains("Completed help targets query") - && event.contains("outcome=\"error\"") - && event.contains("error_category=\"other\"")), - "invalid manifest should emit a bounded completion event: {events:?}" - ); - Ok(()) + assert_help_targets_telemetry( + &cli, + &ExpectedHelpTargetsTelemetry { + outcome: "error", + error_category: "other", + succeeds: false, + }, + "invalid manifest help targets", + ) } From ba3d7f07c0a59eeaabce07e7ec2d27d271bd6537 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 00:32:53 +0200 Subject: [PATCH 51/61] Clarify target compatibility documentation --- ...t-descriptions-and-netsuke-help-targets.md | 6 +- docs/netsuke-design.md | 176 +++++++++++++++++- docs/v0-1-0-migration-guide.md | 8 +- 3 files changed, 175 insertions(+), 15 deletions(-) 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 index cded82534..a4a94f9e5 100644 --- 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 @@ -36,7 +36,7 @@ default marker such as `[★ default]` on manifest defaults. ## Constraints - Traditional AST/render/expansion open-source repo layering must be respected: - `src/ast.rs`, `src/manifest/render.rs`, `src/manifest/expand.rs`. + `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 @@ -210,7 +210,7 @@ Lessons learned: 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.rs` — `NetsukeManifest`, `Target`, `Rule`, `Recipe`. `Target` has +- `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 @@ -240,7 +240,7 @@ generates Ninja build files. Key files and modules for this task: ## Plan of work -- Phase 1: add `pub description: Option` to `Target` in `src/ast.rs` +- 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 diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 58f3f8722..c2de9ecc3 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -675,16 +675,172 @@ 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/mod.rs`, and annotated with `#[derive(Deserialize)]` (and `Debug`) to +enable automatic deserialization and easy debugging. + +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/mod.rs + +use serde::Deserialize; +use std::collections::HashMap; + +/// Represents the top-level structure of a Netsukefile file. +#[serde(deny_unknown_fields)] +pub struct NetsukeManifest { + pub netsuke_version: Version, + + #[serde(default)] + pub vars: HashMap, + + #[serde(default)] + pub rules: Vec, + + #[serde(default)] + pub actions: Vec, + + pub targets: Vec, + + #[serde(default)] + pub defaults: Vec, +} + +/// Represents a reusable command template. +#[serde(deny_unknown_fields)] +pub struct Rule { + pub name: String, + #[serde(flatten)] + pub recipe: Recipe, + pub description: Option, + // FUTURE: planned Rule.env extension; not present in src/ast/mod.rs yet. + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub deps: StringOrList, + // Additional fields like 'pool' or 'restat' can be added here + // to map to more advanced Ninja features. +} + +/// A union of execution styles for both rules and targets. +#[serde(untagged)] +pub enum Recipe { + Command { command: StringOrList }, + Script { script: String }, + Rule { rule: StringOrList }, + // FUTURE: planned Recipe::Exec extension; not present in src/ast/mod.rs yet. + Exec { exec: ExecRecipe }, +} + +/// FUTURE: A structured command recipe that avoids shell word splitting. +#[serde(deny_unknown_fields)] +pub struct ExecRecipe { + pub program: String, + #[serde(default)] + pub args: Vec, +} + +/// Represents a single build target or edge in the dependency graph. +#[serde(deny_unknown_fields)] +pub struct Target { + pub name: StringOrList, + #[serde(flatten)] + pub recipe: Recipe, + + #[serde(default)] + pub sources: StringOrList, + + #[serde(default)] + pub deps: StringOrList, + + #[serde(default)] + pub order_only_deps: StringOrList, + + #[serde(default)] + pub vars: HashMap, + + /// Optional discovery metadata shown by `netsuke help targets`. + #[serde(default)] + pub description: Option, + + // FUTURE: planned Target.env extension; not present in src/ast/mod.rs yet. + #[serde(default)] + pub env: HashMap, + + /// Run this target when requested even if a file with the same name exists. + #[serde(default)] + pub phony: bool, + + /// Run this target on every invocation regardless of timestamps. + #[serde(default)] + pub always: bool, +} + +/// FUTURE: Environment variable operations applied to a recipe invocation. +#[serde(untagged)] +pub enum EnvValue { + Value(String), + Operation(EnvOperation), +} + +#[serde(deny_unknown_fields)] +pub struct EnvOperation { + pub value: Option, + pub default: Option, + pub prepend: Option, + pub append: Option, + pub unset: Option, +} + +/// An enum to handle fields that can be either a single string or a list of strings. +#[serde(untagged)] +pub enum StringOrList { + #[default] + Empty, + String(String), + List(Vec), +} +``` + +*Note: The* `StringOrList` *enum with* `#[serde(untagged)]` *preserves whether +the manifest supplied one string or an ordered list. The same type represents +command recipes, sources, dependencies, order-only dependencies, and rule +selectors; command lists are executed in order, while path-like fields are +interpreted only at the manifest-to-IR boundary.* + +`StringOrList` owns the conversions that only need to know its own shape: +`map_each` applies a function to every contained string, and `to_string_vec` +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/mod.rs` +stops filesystem concerns leaking into the domain model. + +### 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. +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. @@ -692,7 +848,7 @@ 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; @@ -724,7 +880,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)] @@ -739,7 +895,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 }, } @@ -774,7 +930,7 @@ pub struct Target { #[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, @@ -825,7 +981,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 @@ -909,7 +1065,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 diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 32363fd28..49f0fb72c 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -2,8 +2,12 @@ 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 callers -compile unchanged; every addition is opt-in. +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(...)`); deserialised manifests +remain compatible, and every other addition is opt-in. ## Netsuke is a build tool, not a library From 5711980924f4a7480227092ab4910196324fe72a Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 00:48:25 +0200 Subject: [PATCH 52/61] Harden localized help diagnostics (#551) Localize invalid manifest-default errors without exposing terminal controls, and retain deterministic assertions across the command and documentation examples. Avoid unnecessary help-catalogue JSON name allocations and simplify its test-visible catalogue builder. --- docs/v0-1-0-migration-guide.md | 2 +- locales/ar/messages.ftl | 5 +++-- locales/cs/messages.ftl | 1 + locales/cy/messages.ftl | 1 + locales/da/messages.ftl | 1 + locales/de/messages.ftl | 1 + locales/el/messages.ftl | 1 + locales/en-GB/messages.ftl | 1 + locales/en-US/messages.ftl | 1 + locales/es-419/messages.ftl | 1 + locales/es-ES/messages.ftl | 1 + locales/fa/messages.ftl | 1 + locales/fi/messages.ftl | 1 + locales/fr/messages.ftl | 3 ++- locales/gd/messages.ftl | 1 + locales/he/messages.ftl | 3 ++- locales/hi/messages.ftl | 1 + locales/hu/messages.ftl | 1 + locales/id/messages.ftl | 1 + locales/it/messages.ftl | 5 +++-- locales/ja/messages.ftl | 1 + locales/ko/messages.ftl | 5 +++-- locales/nb/messages.ftl | 3 ++- locales/nl/messages.ftl | 3 ++- locales/pl/messages.ftl | 1 + locales/pt-BR/messages.ftl | 1 + locales/pt-PT/messages.ftl | 1 + locales/ro/messages.ftl | 1 + locales/ru/messages.ftl | 1 + locales/sv/messages.ftl | 1 + locales/th/messages.ftl | 1 + locales/tr/messages.ftl | 1 + locales/uk/messages.ftl | 5 +++-- locales/vi/messages.ftl | 3 ++- locales/zh-Hans/messages.ftl | 3 ++- locales/zh-Hant/messages.ftl | 3 ++- src/localization/keys.rs | 1 + src/manifest/tests/workspace.rs | 4 +++- src/runner/help.rs | 15 ++++++++------- src/runner/help_query.rs | 13 ++----------- tests/documentation_examples_tests.rs | 2 +- tests/runner_help_targets_tests.rs | 6 +++--- 42 files changed, 69 insertions(+), 39 deletions(-) diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 49f0fb72c..649696eff 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -6,7 +6,7 @@ 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(...)`); deserialised manifests +`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 diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 184a9c450..3f59007a8 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -84,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 = تعذّرت سَلسَلة ملف البيانات. @@ -157,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 }». @@ -376,7 +377,7 @@ status.tool.clean = التنظيف status.tool.graph = الرسم status.tool.graph_html = الرسم (HTML) status.tool.generate = التوليد -status.tool.help_targets = مساعدة الأهداف +status.tool.help_targets = فهرس الأهداف # نصوص عرض الرسم بصيغة HTML. graph.html.title = رسم بناء Netsuke diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index eedc7cc09..2c9cf8ee5 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -84,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. diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index a719e906c..5efbe8e86 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -84,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. diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index e13e4cde5..f14d1f99e 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -84,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 = Manifeststandarden '{ $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. diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index e1521fbde..6ab8578dd 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -84,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. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 0ac0f05d5..f3f827d9e 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -85,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 = Δεν ήταν δυνατή η σειριοποίηση του δηλωτικού. diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 331f7aa38..1fc815be5 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -84,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. diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index 65c8f42af..2a53bbd85 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -84,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. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 3479dedd8..6a52c385b 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -85,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 destino 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. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 256475998..968c4d58f 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -84,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 destino 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. diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index c801d232f..b866d87e9 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -84,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 = تبدیل مانیفست به داده‌های پیاپی ممکن نشد. diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index 4a7ec87d8..b4717225c 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -84,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 = Luettelon 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. diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index b8ff2208b..3d123486e 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -39,7 +39,7 @@ cli.subcommand.help.long_about = Sans sujet, ceci correspond à `--help`. Utilis 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 = défaut +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). @@ -85,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. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index e77127bda..74eb40c71 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -84,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’ mhanifeast '{ $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. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 63639ca2b..b24c85a39 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -33,7 +33,7 @@ cli.subcommand.graph.long_about = הטלת המניפסט המנותח של Nets 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` כדי להדפיס את קטלוג היעדים והפעולות עבור הקובץ שנבחר. +cli.subcommand.help.long_about = ללא נושא, זה תואם את `--help`. השתמשו ב-`help targets` כדי להדפיס את קטלוג היעדים והפעולות עבור הקובץ שנבחר. # Help catalogue headings and markers. cli.help.actions_heading = פעולות: @@ -84,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 = לא ניתן היה לבצע סריאליזציה למניפסט. diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index eb3c9e367..98a2a12d8 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -84,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 = मैनिफ़ेस्ट का क्रमांकन नहीं हो सका। diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 7341a318b..02504cc68 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -84,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. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index bd49ff49b..d37ae60f9 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -84,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. diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index d01e17e80..eab3286b6 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -32,7 +32,7 @@ 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 nominato. +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. @@ -85,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. @@ -377,7 +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 target +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 b81a3872b..47db7c293 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -84,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 = マニフェストを直列化できませんでした。 diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index edccb6aae..e24c185c6 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -33,12 +33,12 @@ cli.subcommand.graph.long_about = 해석한 Netsuke 매니페스트를 정규 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`를 사용하세요. +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.targets.about = 선택한 매니페스트의 대상 및 작업을 나열합니다. cli.help.default_marker = 기본값 # build 하위 명령 옵션의 도움말. @@ -84,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 = 매니페스트를 직렬화하지 못했습니다. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 801b47a17..dbb1ee908 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -33,7 +33,7 @@ cli.subcommand.graph.long_about = Overfør det innleste Netsuke-manifestet til e 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 den valgte filen. +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: @@ -84,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 = Manifeststandarden '{ $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. diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index a9b50b08a..c77b47bf4 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -33,7 +33,7 @@ cli.subcommand.graph.long_about = Zet het ingelezen Netsuke-manifest om in een c 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 bestand af te drukken. +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: @@ -84,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. diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index 0474c32e2..72e4ed3e6 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -84,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. diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 563357fd3..be1dbfcc3 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -85,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 destino 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. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index ba82fa767..f61a99d31 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -85,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. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index f47e88ad8..d9ff226ca 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -84,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. diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 74808c3b0..3d3c20518 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -84,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 = Не удалось сериализовать манифест. diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index 70e973e67..ee0b824ad 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -84,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. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 52f130e87..b779e9822 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -84,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 = ทำให้ไฟล์รายการเป็นลำดับข้อมูลไม่สำเร็จ diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index c8028374f..155ddc084 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -84,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. diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 67f2b2dd0..0066ed936 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -33,12 +33,12 @@ cli.subcommand.graph.long_about = Перетворити розібраний м 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`, щоб надрукувати каталог цілей і дій для вибраного файлу. +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.targets.about = Вивести список цілей і дій у вибраному маніфесті. cli.help.default_marker = за замовчуванням # Текст довідки для параметрів підкоманди build. @@ -84,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 = Не вдалося серіалізувати маніфест. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index d56ad6412..c1e9327e0 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -33,7 +33,7 @@ cli.subcommand.graph.long_about = Chiếu tệp kê khai Netsuke đã phân tíc 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 đã chọn. +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: @@ -84,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. diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index b4e52ce70..abaa9f664 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -33,7 +33,7 @@ cli.subcommand.graph.long_about = 将解析后的 Netsuke 清单投影为规范 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` 打印所选清单的目标和操作目录。 +cli.subcommand.help.long_about = 没有主题时,此命令等价于 `--help`。使用 `help targets` 打印所选清单的目标和动作目录。 # Help catalogue headings and markers. cli.help.actions_heading = 动作: @@ -83,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 = 无法序列化清单。 diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index de2ad3a75..b30bfe583 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -33,7 +33,7 @@ cli.subcommand.graph.long_about = 將剖析後的 Netsuke 資訊清單投影為 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` 列印所選清單的目標和操作目錄。 +cli.subcommand.help.long_about = 沒有主題時,此命令等同於 `--help`。使用 `help targets` 列印所選資訊清單的目標和動作目錄。 # Help catalogue headings and markers. cli.help.actions_heading = 動作: @@ -83,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 = 無法序列化資訊清單。 diff --git a/src/localization/keys.rs b/src/localization/keys.rs index fe74184b3..6e79dc5d0 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -72,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", diff --git a/src/manifest/tests/workspace.rs b/src/manifest/tests/workspace.rs index 2f46ee038..8231d0dd2 100644 --- a/src/manifest/tests/workspace.rs +++ b/src/manifest/tests/workspace.rs @@ -264,7 +264,9 @@ fn manifest_query_rejects_restricted_template_helpers( "query should name its rejected helper: {error:?}" ); ensure!( - !error.to_string().contains(QUERY_SECRET), + !error + .chain() + .any(|cause| cause.to_string().contains(QUERY_SECRET)), "a query error must not disclose local file contents: {error:?}" ); ensure!( diff --git a/src/runner/help.rs b/src/runner/help.rs index 2329d1b3a..e2a5b80e8 100644 --- a/src/runner/help.rs +++ b/src/runner/help.rs @@ -57,7 +57,7 @@ pub(super) fn handle_help_targets(cli: &Cli, reporter: &dyn StatusReporter) -> R 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).context("serialize help targets catalogue")?; + let rendered = render_json(&query.entries)?; process::write_text_stdout(&rendered)?; } else { let rendered = render_text(&query.entries, resolved_prefs(cli)); @@ -236,30 +236,31 @@ struct HelpTargetsResult<'a> { #[derive(Debug, Serialize)] struct HelpEntryJson<'a> { - name: String, + name: &'a str, description: Option<&'a str>, default: bool, } fn render_json(entries: &[HelpEntry]) -> Result { - let (actions, targets): (Vec<_>, Vec<_>) = entries.iter().partition(|entry| entry.is_action); serde_json::to_string_pretty(&HelpTargetsDocument { schema_version: SCHEMA_VERSION, generator: GeneratorInfo::current(), result: HelpTargetsResult { command: "help-targets", - actions: json_entries(actions), - targets: json_entries(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(entries: Vec<&HelpEntry>) -> Vec> { +fn json_entries<'entry>( + entries: impl Iterator, +) -> Vec> { entries .into_iter() .map(|entry| HelpEntryJson { - name: entry.name.clone(), + name: entry.name.as_str(), description: entry.description.as_deref(), default: entry.is_default, }) diff --git a/src/runner/help_query.rs b/src/runner/help_query.rs index ce166d207..e4afe5bf5 100644 --- a/src/runner/help_query.rs +++ b/src/runner/help_query.rs @@ -89,17 +89,7 @@ fn record_missing_manifest_stage(error: &anyhow::Error, stages: &mut Vec Vec { - build_catalogue_inner(manifest) -} - -#[cfg(not(test))] -fn build_catalogue(manifest: &NetsukeManifest) -> Vec { - build_catalogue_inner(manifest) -} - -fn build_catalogue_inner(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 { @@ -117,7 +107,8 @@ fn validate_defaults(defaults: &[String], entries: &[HelpEntry]) -> Result<()> { let safe_default = terminal_safe(default); ensure!( names.contains(default.as_str()), - "manifest default '{safe_default}' does not name a declared action or target" + localization::message(keys::RUNNER_MANIFEST_DEFAULT_NOT_DECLARED) + .with_arg("default", safe_default.as_ref()) ); } Ok(()) diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index af1ba41c4..74d2bd2a5 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -297,7 +297,7 @@ fn help_targets_example_lists_described_targets() -> Result<()> { "help targets example drifted" ); let workspace = manifest_workspace("guide-first-build-manifest")?; - let run = run_netsuke_in(workspace.path(), &["help", "targets"])?; + 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:"), diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index 7115ff235..5cb4c9744 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -10,7 +10,7 @@ use netsuke::cli::{Cli, Commands, HelpArgs, HelpTopic}; use netsuke::output_prefs; use netsuke::runner::run; use rstest::{fixture, rstest}; -use test_support::{localizer_test_lock, set_en_localizer}; +use test_support::{fluent::normalize_fluent_isolates, localizer_test_lock, set_en_localizer}; #[path = "runner_help_targets_tests/catalogue.rs"] mod catalogue; @@ -92,7 +92,7 @@ fn assert_help_targets_rejects_manifest( ensure!( error .chain() - .any(|cause| cause.to_string().contains(expected_error)), + .any(|cause| normalize_fluent_isolates(&cause.to_string()).contains(expected_error)), "error should contain {expected_error:?}: {error:?}" ); Ok(()) @@ -178,7 +178,7 @@ fn help_targets_does_not_emit_raw_manifest_controls_in_diagnostics() -> Result<( !output.status.success(), "unsafe default should make help targets fail validation" ); - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = normalize_fluent_isolates(&String::from_utf8_lossy(&output.stderr)); ensure!( stderr.contains(r"default 'bad\nINJECTED'"), "diagnostic should show escaped manifest controls: {stderr}" From fc26f724b4d74d0c71ed174f5bcd43b0a978dbe6 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 03:05:12 +0200 Subject: [PATCH 53/61] Tighten telemetry labels and locale text (#551) Require help-target telemetry metrics to carry exactly the two bounded labels, and cover the rejection of any future extra label. Use consistent manifest and target terminology in the affected translations. --- locales/da/messages.ftl | 2 +- locales/el/messages.ftl | 2 +- locales/es-419/messages.ftl | 2 +- locales/es-ES/messages.ftl | 2 +- locales/fi/messages.ftl | 2 +- locales/gd/messages.ftl | 2 +- locales/nb/messages.ftl | 2 +- locales/pl/messages.ftl | 4 ++-- locales/pt-BR/messages.ftl | 6 ++--- locales/pt-PT/messages.ftl | 4 ++-- locales/th/messages.ftl | 2 +- locales/uk/messages.ftl | 2 +- src/runner/help_telemetry_tests.rs | 38 +++++++++++++++++++++++++++++- 13 files changed, 53 insertions(+), 17 deletions(-) diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index f14d1f99e..e830dff73 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -84,7 +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 = Manifeststandarden '{ $default }' angiver ikke en erklæret handling eller et mål. +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. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index f3f827d9e..8c8eb1128 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -85,7 +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.manifest.default_not_declared = Η προεπιλογή του δηλωτικού '{ $default }' δεν ονομάζει δηλωμένη ενέργεια ή στόχο. runner.context.network_policy = Δεν ήταν δυνατή η κατασκευή της πολιτικής δικτύου. runner.context.load_manifest = Δεν ήταν δυνατή η φόρτωση του δηλωτικού από { $path }. runner.context.serialise_manifest = Δεν ήταν δυνατή η σειριοποίηση του δηλωτικού. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 6a52c385b..e35fa515c 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -85,7 +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 destino declarado. +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. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 968c4d58f..57ac96f60 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -84,7 +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 destino declarado. +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. diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index b4717225c..232015b8b 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -84,7 +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 = Luettelon oletus '{ $default }' ei nimeä ilmoitettua toimintoa tai kohdetta. +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. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 74eb40c71..6a9244e99 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -84,7 +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’ mhanifeast '{ $default }' ag ainmeachadh gnìomh no targaid dhearbhaichte. +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. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index dbb1ee908..8c623d0f0 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -84,7 +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 = Manifeststandarden '{ $default }' navngir ingen erklært handling eller mål. +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. diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index 72e4ed3e6..5441cfff7 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -33,12 +33,12 @@ cli.subcommand.graph.long_about = Przekształć wczytany manifest Netsuke w kano 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 pliku. +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 pliku. +cli.help.targets.about = Wyświetl cele i akcje w wybranym manifeście. cli.help.default_marker = domyślny # Tekst pomocy opcji podpolecenia build. diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index be1dbfcc3..80162d11b 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -33,12 +33,12 @@ cli.subcommand.graph.long_about = Projetar o manifesto do Netsuke analisado em u 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 arquivo selecionado. +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 arquivo selecionado. +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. @@ -85,7 +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 destino declarado. +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. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index f61a99d31..5d864daed 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -33,12 +33,12 @@ cli.subcommand.graph.long_about = Projetar o manifesto do Netsuke analisado num 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 ficheiro selecionado. +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 ficheiro selecionado. +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. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index b779e9822..c88c51db8 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -84,7 +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.manifest.default_not_declared = ค่าเริ่มต้นของรายการ '{ $default }' ไม่ได้ระบุการดำเนินการหรือเป้าหมายที่ประกาศไว้ runner.context.network_policy = สร้างนโยบายเครือข่ายไม่สำเร็จ runner.context.load_manifest = โหลดไฟล์รายการที่ { $path } ไม่สำเร็จ runner.context.serialise_manifest = ทำให้ไฟล์รายการเป็นลำดับข้อมูลไม่สำเร็จ diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 0066ed936..8b3137433 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -32,7 +32,7 @@ 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.about = Друкувати довідку верхнього рівня або довідку для вказаної теми. cli.subcommand.help.long_about = Без теми це відповідає `--help`. Використовуйте `help targets`, щоб надрукувати каталог цілей і дій для вибраного маніфесту. # Help catalogue headings and markers. diff --git a/src/runner/help_telemetry_tests.rs b/src/runner/help_telemetry_tests.rs index a83ec8b8b..2b1d3efde 100644 --- a/src/runner/help_telemetry_tests.rs +++ b/src/runner/help_telemetry_tests.rs @@ -92,12 +92,48 @@ fn metric_value<'snapshot>( .labels() .map(|label| (label.key(), label.value())) .collect(); - let matches = labels.contains(&("outcome", expected.outcome)) + 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, From 0ba3fd85c4fef2e75f07ae8a35425f3de5995de9 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 03:11:55 +0200 Subject: [PATCH 54/61] Refresh help targets ExecPlan (#551) Record the final query, telemetry, and localisation follow-ups with reachable commit references so the completed plan matches the implemented feature. --- ...t-descriptions-and-netsuke-help-targets.md | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) 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 index a4a94f9e5..ed2be2acf 100644 --- 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 @@ -72,7 +72,7 @@ default marker such as `[★ default]` on manifest defaults. `.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 six new keys to + 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 @@ -111,22 +111,29 @@ default marker such as `[★ default]` on manifest defaults. - [x] (2026-08-09) Branch renamed to `issue-551-add-target-descriptions-and-netsuke-help-targets`, pushed, PR opened: . -- [x] (2026-08-14, `099f70ac`) Documented the restricted, side-effect-free Jinja +- [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, `98e6f95e`, `5d39023e`, `61afb7a6`) Routed target help through - a restricted manifest query path, escaped terminal control characters in - text output, and added end-to-end, IR, and property coverage for the - query and catalogue invariants. -- [x] (2026-08-14, `49fb6bfe`, `0106a692`) Clarified that target and action +- [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 `cli.help.targets.about` to all 35 - shipped locales. -- [x] (2026-08-14, `0106a692`) Used a dedicated localized synopsis for the nested - `targets` help topic and aligned the localized help assertions with it. + 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 @@ -137,9 +144,11 @@ default marker such as `[★ default]` on manifest defaults. `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, so the new keys must introduce no `$` variables to keep all - locale translations simple. Evidence: `build_l10n_audit/compare.rs`. Impact: - keep all six new keys free of Fluent variables. + 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 @@ -160,7 +169,7 @@ default marker such as `[★ default]` on manifest defaults. 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: `0106a692`. Impact: keep the + section heading. Evidence: `5ca08e30`. Impact: keep the `cli.help.targets.about` key separate from `actions_heading` and `targets_heading`. @@ -190,10 +199,11 @@ 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 `98e6f95e`, -`5d39023e`, `61afb7a6`, `49fb6bfe`, and `0106a692`. Commits `036dc331` and -`9f1f8603` subsequently tightened BuildGraph validation, query dependencies, -and localization-lock scoping; their full gate runs passed. +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: From 30a4037c37d24d78d29f0cda54b674479c2a8388 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 04:44:24 +0200 Subject: [PATCH 55/61] Repair rebase integration gaps (#551) Preserve the build-script composition boundary, completion generation, and help command after replaying the target-description branch onto `main`. --- build.rs | 4 +++- docs/netsuke-design.md | 7 ++++++- locales/hi/messages.ftl | 2 +- locales/th/messages.ftl | 4 ++-- locales/tr/messages.ftl | 4 ++-- src/cli/build_support.rs | 1 + src/cli/parser.rs | 20 +------------------- src/runner/help_tests.rs | 5 +++-- 8 files changed, 19 insertions(+), 28 deletions(-) diff --git a/build.rs b/build.rs index c5b0efdb9..bad63cd41 100644 --- a/build.rs +++ b/build.rs @@ -147,6 +147,7 @@ fn write_man_page(data: &[u8], dir: &Path, page_name: &str) -> std::io::Result

Result<(), Box> { } fn generate_completions(out_dir: &Path) -> Result<(), Box> { - fs::create_dir_all(out_dir)?; + 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() diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index c2de9ecc3..8a3b1c098 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -675,7 +675,6 @@ 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/mod.rs`) The Rust structs that `serde_saphyr` deserializes into form the Abstract Syntax @@ -707,6 +706,9 @@ pub struct NetsukeManifest { #[serde(default)] pub vars: HashMap, + #[serde(default)] + pub macros: Vec, + #[serde(default)] pub rules: Vec, @@ -861,6 +863,9 @@ pub struct NetsukeManifest { #[serde(default)] pub vars: HashMap, + #[serde(default)] + pub macros: Vec, + #[serde(default)] pub rules: Vec, diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index 98a2a12d8..96c2a6661 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -84,7 +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.manifest.default_not_declared = मैनिफ़ेस्ट डिफ़ॉल्ट '{ $default }' किसी घोषित क्रिया या लक्ष्य का नाम नहीं है। runner.context.network_policy = नेटवर्क नीति नहीं बनाई जा सकी। runner.context.load_manifest = { $path } से मैनिफ़ेस्ट नहीं लादा जा सका। runner.context.serialise_manifest = मैनिफ़ेस्ट का क्रमांकन नहीं हो सका। diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index c88c51db8..741f246e1 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -33,12 +33,12 @@ cli.subcommand.graph.long_about = ฉายไฟล์รายการ Netsuk 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` เพื่อพิมพ์แคตตาล็อกเป้าหมายและการดำเนินการสำหรับไฟล์ที่เลือก +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.targets.about = แสดงรายการเป้าหมายและการดำเนินการในไฟล์รายการที่เลือก cli.help.default_marker = ค่าเริ่มต้น # ข้อความช่วยเหลือของตัวเลือกในคำสั่งย่อย build diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 155ddc084..2398f6844 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -33,12 +33,12 @@ cli.subcommand.graph.long_about = Ayrıştırılan Netsuke bildirimini kurallı 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 dosya için hedef ve eylem kataloğunu yazdırmak üzere `help targets` komutunu kullanın. +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 dosyadaki hedef ve eylemleri listele. +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. 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/parser.rs b/src/cli/parser.rs index c2a3b0bd6..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, @@ -34,25 +35,6 @@ pub use crate::cli_l10n::{json_hint_from_args, locale_hint_from_args}; use crate::host_pattern::HostPattern; use crate::theme::ThemePreference; - -//! Clap-facing parser types and localisation helpers. -//! -//! This module owns the runtime-visible [`Cli`] struct and all associated -//! Clap definitions ([`BuildArgs`], [`Commands`]). It also provides -//! [`parse_with_localizer_from`], which localises the Clap command, installs -//! localisation-aware [`LocalizedValueParser`] instances for every typed -//! argument, and returns `(Cli, ArgMatches)` for downstream processing. -//! -//! **Pipeline position:** parsing layer. -//! -//! - Receives raw `OsStr` arguments from the process entry point. -//! - Delegates value validation to [`super::parsing`] helpers. -//! - Returns a `Cli`/`ArgMatches` pair consumed by [`super::merge`]. -//! -//! [`LocalizedValueParser`]: self::LocalizedValueParser -}; -pub use crate::cli_l10n::{json_hint_from_args, locale_hint_from_args}; - #[derive(Clone)] struct LocalizedValueParser { localizer: Arc, diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index 62d273f1c..54ba880c0 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -94,12 +94,13 @@ fn catalogue_rendering_releases_localizer_lock_before_snapshot_work() -> Result< let _lock = localizer_lock(); acquired.send(()).ok(); }); - confirmed + let contender_acquired = confirmed .recv_timeout(Duration::from_secs(5)) - .context("localizer contender should acquire the lock before snapshot work")?; + .context("localizer contender should acquire the lock before snapshot work"); contender .join() .map_err(|_| anyhow::anyhow!("localizer contender should complete"))?; + contender_acquired?; anyhow::ensure!( rendered.contains("\"command\": \"help-targets\""), "rendered catalogue should remain available after localizer contention" From a11b52bcc9adeecc7015e253cdcd943e5eb71c5b Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 13:11:54 +0200 Subject: [PATCH 56/61] Bound help contention test (#551) Avoid blocking the contender thread when a localizer-lock assertion times out, and clarify the matching command-help sentence. --- docs/users-guide.md | 2 +- src/runner/help_tests.rs | 70 +++++++++++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/docs/users-guide.md b/docs/users-guide.md index 241ddfc1b..42955b857 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -729,7 +729,7 @@ The commands are: 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 + 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)). diff --git a/src/runner/help_tests.rs b/src/runner/help_tests.rs index 54ba880c0..db8c2db77 100644 --- a/src/runner/help_tests.rs +++ b/src/runner/help_tests.rs @@ -15,11 +15,11 @@ use anyhow::{Context, Result}; use insta::assert_snapshot; use proptest::prelude::*; use semver::Version; -use std::sync::{Arc, mpsc}; +use std::sync::{Arc, Mutex, TryLockError, mpsc}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use test_support::fluent::normalize_fluent_isolates; -use test_support::localizer_test_lock; +use test_support::{localizer::LOCALIZER_TEST_LOCK, localizer_test_lock}; /// Parse the fixed fixture manifest used by the catalogue snapshots. fn fixture_manifest() -> Result { @@ -53,6 +53,43 @@ 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. /// @@ -89,18 +126,21 @@ fn catalogue_rendering_releases_localizer_lock_before_snapshot_work() -> Result< let rendered = render_catalogue_with_locale("en-US", &manifest, |parsed_manifest| { render_json(&build_catalogue(parsed_manifest)) })?; - let (acquired, confirmed) = mpsc::sync_channel(0); - let contender = thread::spawn(move || { - let _lock = localizer_lock(); - acquired.send(()).ok(); - }); - 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"))?; - contender_acquired?; + 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" From 59c7cb15e5f77bc60ef917bcc81fa9f60a6983b1 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 13:20:03 +0200 Subject: [PATCH 57/61] Reject unsafe help target names (#551) Prevent graph-validation diagnostics from interpolating terminal controls from manifest target names, and pin the command-boundary behaviour. --- src/runner/help_query.rs | 20 +++++++++++++++- tests/runner_help_targets_tests.rs | 37 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/runner/help_query.rs b/src/runner/help_query.rs index e4afe5bf5..3f2a4721c 100644 --- a/src/runner/help_query.rs +++ b/src/runner/help_query.rs @@ -1,6 +1,6 @@ //! Pure manifest loading and catalogue construction for `netsuke help targets`. -use anyhow::{Context, Result, ensure}; +use anyhow::{Context, Result, bail, ensure}; use std::{collections::HashSet, error::Error as StdError, fmt, sync::Arc}; use crate::ast::{NetsukeManifest, Target}; @@ -65,6 +65,7 @@ fn query_entries(cli: &Cli, stages: &mut Vec) -> Result) -> Result 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::() diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index 5cb4c9744..c554d8e6f 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -135,6 +135,43 @@ fn help_targets_rejects_valid_manifest_with_missing_rule() -> Result<()> { ) } +#[test] +fn help_targets_rejects_control_bearing_target_names_before_graph_validation() -> Result<()> { + let temp = tempfile::tempdir().context("create unsafe-target diagnostic fixture directory")?; + let temp_path = Utf8Path::from_path(temp.path()) + .context("unsafe-target diagnostic 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 unsafe-target diagnostic fixture directory")?; + workspace + .write( + "Netsukefile", + b"netsuke_version: \"1.0.0\"\ntargets:\n - name: \"out/\\u001b[2Japp\"\n rule: missing\n", + ) + .context("write unsafe-target diagnostic manifest")?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run help targets against unsafe target")?; + ensure!( + !output.status.success(), + "unsafe target should make help targets fail validation" + ); + let stderr = normalize_fluent_isolates(&String::from_utf8_lossy(&output.stderr)); + ensure!( + stderr.contains("terminal control characters"), + "diagnostic should reject terminal controls before graph validation: {stderr}" + ); + ensure!( + !stderr.contains('\u{001b}'), + "diagnostic must not emit a raw terminal escape: {stderr:?}" + ); + Ok(()) +} + #[test] fn help_targets_rejects_unknown_manifest_default() -> Result<()> { assert_help_targets_rejects_manifest( From 6a74574444b3677fffa8deb44a13c3bc6b1e2125 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 13:28:37 +0200 Subject: [PATCH 58/61] Repair manifest configuration rebase (#551) Preserve the configurable normal-build parser while routing its injected stdlib configuration through the query-aware registration boundary. --- src/manifest/mod.rs | 2 +- src/manifest/parse_with_config.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index ed42813c6..5c3a84e8e 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -44,8 +44,8 @@ mod expand; mod glob; mod hints; mod jinja_macros; -mod query; mod parse_with_config; +mod query; mod render; /// JSON representation of a manifest node after YAML and Jinja evaluation. 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, From 1903b78f4cb5e684bc5df3748003733d4cfc6730 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 20:35:16 +0200 Subject: [PATCH 59/61] Deduplicate help diagnostic tests (#551) Share the subprocess diagnostic assertion path while retaining the distinct manifest inputs and escaping expectations. --- tests/runner_help_targets_tests.rs | 117 +++++++++++++---------------- 1 file changed, 51 insertions(+), 66 deletions(-) diff --git a/tests/runner_help_targets_tests.rs b/tests/runner_help_targets_tests.rs index c554d8e6f..990ed947b 100644 --- a/tests/runner_help_targets_tests.rs +++ b/tests/runner_help_targets_tests.rs @@ -98,6 +98,45 @@ fn assert_help_targets_rejects_manifest( 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")?; @@ -137,39 +176,12 @@ fn help_targets_rejects_valid_manifest_with_missing_rule() -> Result<()> { #[test] fn help_targets_rejects_control_bearing_target_names_before_graph_validation() -> Result<()> { - let temp = tempfile::tempdir().context("create unsafe-target diagnostic fixture directory")?; - let temp_path = Utf8Path::from_path(temp.path()) - .context("unsafe-target diagnostic 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 unsafe-target diagnostic fixture directory")?; - workspace - .write( - "Netsukefile", - b"netsuke_version: \"1.0.0\"\ntargets:\n - name: \"out/\\u001b[2Japp\"\n rule: missing\n", - ) - .context("write unsafe-target diagnostic manifest")?; - let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") - .arg("--file") - .arg(&manifest_path) - .arg("help") - .arg("targets") - .output() - .context("run help targets against unsafe target")?; - ensure!( - !output.status.success(), - "unsafe target should make help targets fail validation" - ); - let stderr = normalize_fluent_isolates(&String::from_utf8_lossy(&output.stderr)); - ensure!( - stderr.contains("terminal control characters"), - "diagnostic should reject terminal controls before graph validation: {stderr}" - ); - ensure!( - !stderr.contains('\u{001b}'), - "diagnostic must not emit a raw terminal escape: {stderr:?}" - ); - Ok(()) + 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] @@ -192,39 +204,12 @@ fn help_targets_escapes_manifest_defaults_in_diagnostics() -> Result<()> { #[test] fn help_targets_does_not_emit_raw_manifest_controls_in_diagnostics() -> Result<()> { - let temp = tempfile::tempdir().context("create unsafe-default diagnostic fixture directory")?; - let temp_path = Utf8Path::from_path(temp.path()) - .context("unsafe-default diagnostic 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 unsafe-default diagnostic fixture directory")?; - workspace - .write( - "Netsukefile", - b"netsuke_version: \"1.0.0\"\nactions:\n - name: lint\n command: cargo clippy\ntargets: []\ndefaults:\n - \"bad\\nINJECTED\"\n", - ) - .context("write unsafe-default diagnostic manifest")?; - let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") - .arg("--file") - .arg(&manifest_path) - .arg("help") - .arg("targets") - .output() - .context("run help targets against unsafe default")?; - ensure!( - !output.status.success(), - "unsafe default should make help targets fail validation" - ); - let stderr = normalize_fluent_isolates(&String::from_utf8_lossy(&output.stderr)); - ensure!( - stderr.contains(r"default 'bad\nINJECTED'"), - "diagnostic should show escaped manifest controls: {stderr}" - ); - ensure!( - !stderr.contains("default 'bad\nINJECTED'"), - "diagnostic must not emit a raw manifest newline: {stderr}" - ); - Ok(()) + 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] From 43e6681ab6c940fd4b2f71562844de7185e4aa36 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 17 Aug 2026 00:21:25 +0200 Subject: [PATCH 60/61] Keep runner module within review limit (#551) Condense redundant internal documentation after the rebase combines the help-query resolver with upstream runner changes. --- src/runner/mod.rs | 37 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 1d525f599..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; @@ -57,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, } @@ -87,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> { @@ -124,10 +117,7 @@ pub fn run(cli: &Cli, prefs: OutputPrefs) -> Result<()> { 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 /// @@ -279,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 /// @@ -332,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 /// From c7f65f6951f3d63484b9d2f7fc48a6382030b43c Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 17 Aug 2026 00:24:14 +0200 Subject: [PATCH 61/61] Remove duplicated AST design section --- docs/netsuke-design.md | 157 ----------------------------------------- 1 file changed, 157 deletions(-) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 8a3b1c098..6f035e61f 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -832,163 +832,6 @@ 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/mod.rs` stops filesystem concerns leaking into the domain model. -### 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/mod.rs`, and annotated with `#[derive(Deserialize)]` (and `Debug`) to -enable automatic deserialization and easy debugging. - -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/mod.rs - -use serde::Deserialize; -use std::collections::HashMap; - -/// Represents the top-level structure of a Netsukefile file. -#[serde(deny_unknown_fields)] -pub struct NetsukeManifest { - pub netsuke_version: Version, - - #[serde(default)] - pub vars: HashMap, - - #[serde(default)] - pub macros: Vec, - - #[serde(default)] - pub rules: Vec, - - #[serde(default)] - pub actions: Vec, - - pub targets: Vec, - - #[serde(default)] - pub defaults: Vec, -} - -/// Represents a reusable command template. -#[serde(deny_unknown_fields)] -pub struct Rule { - pub name: String, - #[serde(flatten)] - pub recipe: Recipe, - pub description: Option, - // FUTURE: planned Rule.env extension; not present in src/ast/mod.rs yet. - #[serde(default)] - pub env: HashMap, - #[serde(default)] - pub deps: StringOrList, - // Additional fields like 'pool' or 'restat' can be added here - // to map to more advanced Ninja features. -} - -/// A union of execution styles for both rules and targets. -#[serde(untagged)] -pub enum Recipe { - Command { command: StringOrList }, - Script { script: String }, - Rule { rule: StringOrList }, - // FUTURE: planned Recipe::Exec extension; not present in src/ast/mod.rs yet. - Exec { exec: ExecRecipe }, -} - -/// FUTURE: A structured command recipe that avoids shell word splitting. -#[serde(deny_unknown_fields)] -pub struct ExecRecipe { - pub program: String, - #[serde(default)] - pub args: Vec, -} - -/// Represents a single build target or edge in the dependency graph. -#[serde(deny_unknown_fields)] -pub struct Target { - pub name: StringOrList, - #[serde(flatten)] - pub recipe: Recipe, - - #[serde(default)] - pub sources: StringOrList, - - #[serde(default)] - pub deps: StringOrList, - - #[serde(default)] - pub order_only_deps: StringOrList, - - #[serde(default)] - pub vars: HashMap, - - /// Optional discovery metadata shown by `netsuke help targets`. - #[serde(default)] - pub description: Option, - - // FUTURE: planned Target.env extension; not present in src/ast/mod.rs yet. - #[serde(default)] - pub env: HashMap, - - /// Run this target when requested even if a file with the same name exists. - #[serde(default)] - pub phony: bool, - - /// Run this target on every invocation regardless of timestamps. - #[serde(default)] - pub always: bool, -} - -/// FUTURE: Environment variable operations applied to a recipe invocation. -#[serde(untagged)] -pub enum EnvValue { - Value(String), - Operation(EnvOperation), -} - -#[serde(deny_unknown_fields)] -pub struct EnvOperation { - pub value: Option, - pub default: Option, - pub prepend: Option, - pub append: Option, - pub unset: Option, -} - -/// An enum to handle fields that can be either a single string or a list of strings. -#[serde(untagged)] -pub enum StringOrList { - #[default] - Empty, - String(String), - List(Vec), -} -``` - -*Note: The* `StringOrList` *enum with* `#[serde(untagged)]` *preserves whether -the manifest supplied one string or an ordered list. The same type represents -command recipes, sources, dependencies, order-only dependencies, and rule -selectors; command lists are executed in order, while path-like fields are -interpreted only at the manifest-to-IR boundary.* - -`StringOrList` owns the conversions that only need to know its own shape: -`map_each` applies a function to every contained string, and `to_string_vec` -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/mod.rs` -stops filesystem concerns leaking into the domain model. - #### Example Manifest and AST The following minimal Netsukefile shows how the derived structures behave when