diff --git a/docs/behavioural-testing-in-rust-with-cucumber.md b/docs/behavioural-testing-in-rust-with-cucumber.md index 27a5b888..c49f15bb 100644 --- a/docs/behavioural-testing-in-rust-with-cucumber.md +++ b/docs/behavioural-testing-in-rust-with-cucumber.md @@ -1164,12 +1164,12 @@ aligned with what is needed. accessed on July 14, 2025, -[^31]: Cucumber in cucumber – Rust – [Docs.rs](http://Docs.rs) — accessed on 14 - July 2025 — +[^31]: Cucumber in cucumber – [Docs.rs](http://Docs.rs) — accessed on + 14 July 2025 — -[^32]: CLI (command-line interface) - Cucumber Rust Book, accessed on - 14 July 2025, +[^32]: CLI (command–line interface) - Cucumber Rust Book, accessed + on 14 July 2025, [^33]: Continuous Integration - Cucumber, accessed on 14 July 2025, diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index b5707908..4c372223 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -177,7 +177,7 @@ level keys. the sources it depends on, and the rule used to produce it. This corresponds to a Ninja `build` statement.[^3] -- `steps`: A secondary list of build targets. Any target placed here is +- `actions`: A secondary list of build targets. Any target placed here is treated as `{ phony: true, always: false }` by default. - `defaults`: An optional list of target names to be built when Netsuke is @@ -193,7 +193,7 @@ erDiagram string netsuke_version map vars list rules - list steps + list actions list targets list defaults } @@ -223,7 +223,7 @@ erDiagram enum value } NETSUKE_MANIFEST ||--o{ RULE : contains - NETSUKE_MANIFEST ||--o{ TARGET : has_steps + NETSUKE_MANIFEST ||--o{ TARGET : has_actions NETSUKE_MANIFEST ||--o{ TARGET : has_targets RULE }o--|| RECIPE : uses TARGET }o--|| RECIPE : uses @@ -274,7 +274,7 @@ Each entry in the `rules` list is a mapping that defines a reusable action. ### 2.4 Defining `targets` Each entry in `targets` defines a build edge; placing a target in the optional -`steps` list instead marks it as `phony: true` with `always` left `false`. +`actions` list instead marks it as `phony: true` with `always` left `false`. - `name`: The primary output file or files for this build step. This can be a single string or a list of strings. @@ -403,7 +403,7 @@ deserialization and easy debugging. Rust -````rust +```rust // In src/ast.rs use serde::Deserialize; @@ -421,7 +421,7 @@ pub struct NetsukeManifest { pub rules: Vec, #[serde(default)] - pub steps: Vec, + pub actions: Vec, pub targets: Vec, @@ -519,7 +519,7 @@ let ast = NetsukeManifest { netsuke_version: Version::parse("1.0.0").unwrap(), vars: HashMap::new(), rules: vec![], - steps: vec![], + actions: vec![], targets: vec![Target { name: StringOrList::String("hello".into()), recipe: Recipe::Command { @@ -551,7 +551,7 @@ targets: - name: my_app sources: "{{ glob('src/*.c') }}" rule: compile -```` +``` The value of `sources`, `{{ glob('src/*.c') }}`, is not a valid YAML string from the perspective of a strict parser. Attempting to deserialize this @@ -590,10 +590,12 @@ 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 follows semantic versioning rules. Global and target variable maps now share the `HashMap` type for consistency. This keeps YAML manifests -concise while ensuring forward compatibility. -Targets also accept optional `phony` and `always` booleans. They default to -`false`, making it explicit when a step should run regardless of file -timestamps. +concise while ensuring forward compatibility. Targets also accept optional +`phony` and `always` booleans. They default to `false`, making it explicit when +an action should run regardless of file timestamps. Targets listed in the +`actions` section are deserialised using a custom helper so they are always +treated as `phony` tasks. This ensures preparation actions never generate build +artefacts. ### 3.5 Testing @@ -1003,14 +1005,14 @@ structures to the Ninja file syntax. Code snippet - ````ninja + ```ninja # Generated from an ir::Action rule cc command = gcc -c -o $out $in description = CC $out depfile = $out.d deps = gcc ```ninja - ```` + ``` 3. **Write Build Edges:** Iterate through the `graph.targets` map. For each `ir::BuildEdge`, write a corresponding Ninja `build` statement. This diff --git a/docs/roadmap.md b/docs/roadmap.md index ff72c40e..66918610 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,8 +29,8 @@ compilation pipeline from parsing to execution. - [x] Support `phony` and `always` boolean flags on targets. *(done)* - - [ ] Parse the actions steps list, treating each entry as a target with - phony: true. + - [x] Parse the actions list, treating each entry as a target with + phony: true. *(done)* - [ ] Implement the YAML parsing logic to deserialize a static Netsukefile into the NetsukeManifest AST. diff --git a/src/ast.rs b/src/ast.rs index 70f3141a..55320c6e 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -18,7 +18,18 @@ //! ``` use semver::Version; -use serde::Deserialize; +use serde::{Deserialize, de::Deserializer}; + +fn deserialize_actions<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let mut actions = Vec::::deserialize(deserializer)?; + for action in &mut actions { + action.phony = true; + } + Ok(actions) +} use std::collections::HashMap; /// Top-level manifest structure parsed from a `Netsukefile`. @@ -28,7 +39,7 @@ use std::collections::HashMap; /// /// ```yaml /// netsuke_version: "1.0.0" -/// steps: [] +/// actions: [] /// targets: /// - name: hello /// recipe: @@ -58,9 +69,10 @@ pub struct NetsukeManifest { #[serde(default)] pub rules: Vec, - /// Optional top-level steps executed before normal targets. - #[serde(default)] - pub steps: Vec, + /// Optional setup actions executed before normal targets. Each action is + /// implicitly marked as `phony` during deserialisation. + #[serde(default, deserialize_with = "deserialize_actions")] + pub actions: Vec, /// Primary build targets. pub targets: Vec, diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index 3c659448..33e156c1 100644 --- a/tests/ast_tests.rs +++ b/tests/ast_tests.rs @@ -4,6 +4,11 @@ use netsuke::ast::*; use rstest::rstest; use semver::Version; +/// Convenience wrapper around `serde_yml::from_str` for manifest tests. +fn parse_manifest(yaml: &str) -> Result { + serde_yml::from_str::(yaml) +} + #[rstest] fn parse_minimal_manifest() { let yaml = r#"netsuke_version: "1.0.0" @@ -34,7 +39,6 @@ targets: panic!("Expected command recipe, got: {:?}", first.recipe); } } - #[test] fn missing_required_fields() { let yaml = r#" @@ -201,17 +205,31 @@ fn optional_fields() { assert!(matches!(rule.deps, StringOrList::Empty)); } -#[test] -fn invalid_enum_variants() { - let yaml = r#" - netsuke_version: "1.0.0" - targets: - - name: hello - recipe: - kind: not_a_kind - command: "echo hi" - "#; - assert!(serde_yml::from_str::(yaml).is_err()); +#[rstest] +#[case::invalid_enum_variant( + r#" + netsuke_version: "1.0.0" + targets: + - name: hello + recipe: + kind: not_a_kind + command: "echo hi" +"# +)] +#[case::actions_missing_recipe( + r#" + netsuke_version: "1.0.0" + actions: + - name: setup + targets: + - name: done + recipe: + kind: command + command: "true" +"# +)] +fn parsing_failures(#[case] yaml: &str) { + assert!(parse_manifest(yaml).is_err()); } #[test] @@ -244,3 +262,99 @@ fn phony_and_always_flags() { assert!(!target.phony); assert!(!target.always); } + +#[rstest] +#[case::default_flags( + r#" + netsuke_version: "1.0.0" + actions: + - name: setup + recipe: + kind: command + command: "echo hi" + targets: + - name: done + recipe: + kind: command + command: "true" +"#, + true, + false +)] +#[case::explicit_phony_false( + r#" + netsuke_version: "1.0.0" + actions: + - name: setup + recipe: + kind: command + command: "echo hi" + phony: false + targets: + - name: done + recipe: + kind: command + command: "true" +"#, + true, + false +)] +#[case::explicit_always_true( + r#" + netsuke_version: "1.0.0" + actions: + - name: setup + recipe: + kind: command + command: "echo hi" + always: true + targets: + - name: done + recipe: + kind: command + command: "true" +"#, + true, + true +)] +fn actions_behavior( + #[case] yaml: &str, + #[case] expected_phony: bool, + #[case] expected_always: bool, +) { + let manifest = parse_manifest(yaml).expect("parse"); + let action = manifest.actions.first().expect("action"); + assert_eq!(action.phony, expected_phony); + assert_eq!(action.always, expected_always); +} + +#[test] +fn multiple_actions_are_marked_phony() { + let yaml = r#" + netsuke_version: "1.0.0" + actions: + - name: setup + recipe: + kind: command + command: "echo hi" + - name: build + recipe: + kind: command + command: "make build" + - name: test + recipe: + kind: command + command: "cargo test" + targets: + - name: done + recipe: + kind: command + command: "true" + "#; + let manifest = parse_manifest(yaml).expect("parse"); + assert_eq!(manifest.actions.len(), 3); + for action in &manifest.actions { + assert!(action.phony); + assert!(!action.always); + } +} diff --git a/tests/data/action_invalid.yml b/tests/data/action_invalid.yml new file mode 100644 index 00000000..2690d38a --- /dev/null +++ b/tests/data/action_invalid.yml @@ -0,0 +1,10 @@ +# This fixture is intentionally invalid. It lacks keys like `recipe` and +# `phony` so the loader should fail when parsing it. +netsuke_version: "1.0.0" +actions: + - name: setup +targets: + - name: done + recipe: + kind: command + command: "true" diff --git a/tests/data/actions.yml b/tests/data/actions.yml new file mode 100644 index 00000000..14e0e1f5 --- /dev/null +++ b/tests/data/actions.yml @@ -0,0 +1,11 @@ +netsuke_version: "1.0.0" +actions: + - name: setup + recipe: + kind: command + command: "echo hi" +targets: + - name: done + recipe: + kind: command + command: "true" diff --git a/tests/features/manifest.feature b/tests/features/manifest.feature index 8176e057..7482cc28 100644 --- a/tests/features/manifest.feature +++ b/tests/features/manifest.feature @@ -9,3 +9,11 @@ Feature: Manifest parsing When the manifest file "tests/data/phony.yml" is parsed Then the first target is phony And the first target is always rebuilt + + Scenario: Actions are always treated as phony + When the manifest file "tests/data/actions.yml" is parsed + Then the first action is phony + + Scenario: Invalid action fails to parse + When the manifest file "tests/data/action_invalid.yml" is parsed + Then parsing the manifest fails diff --git a/tests/steps/manifest_steps.rs b/tests/steps/manifest_steps.rs index 8f0775f4..fcff4887 100644 --- a/tests/steps/manifest_steps.rs +++ b/tests/steps/manifest_steps.rs @@ -68,3 +68,15 @@ fn first_target_always(world: &mut CliWorld) { let first = manifest.targets.first().expect("targets"); assert!(first.always); } + +#[then("the first action is phony")] +fn first_action_phony(world: &mut CliWorld) { + let manifest = world.manifest.as_ref().expect("manifest"); + let first = manifest.actions.first().expect("actions"); + assert!(first.phony); +} + +#[then("parsing the manifest fails")] +fn manifest_parse_error(world: &mut CliWorld) { + assert!(world.manifest_error.is_some(), "expected parse error"); +}