diff --git a/Cargo.lock b/Cargo.lock index 60aa3eb5..d54581da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -670,6 +670,7 @@ dependencies = [ name = "netsuke" version = "0.1.0" dependencies = [ + "anyhow", "clap", "cucumber", "rstest", diff --git a/Cargo.toml b/Cargo.toml index b515e0eb..4844fe69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ clap = { version = "4.5.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_yml = "0.0.12" semver = { version = "1", features = ["serde"] } +anyhow = "1" [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 4c372223..09c52c01 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -595,15 +595,19 @@ concise while ensuring forward compatibility. Targets also accept optional 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. +artefacts. Convenience functions in `src/manifest.rs` load a manifest from a +string or a file path, returning `anyhow::Result` for straightforward error +handling. ### 3.5 Testing Unit tests in `tests/ast_tests.rs` and behavioural scenarios in `tests/features/manifest.feature` exercise the deserialization logic. They assert that manifests fail to parse when unknown fields are present, and that a -minimal manifest round-trips correctly. This suite guards against regressions -as the schema evolves. +minimal manifest round-trips correctly. A collection of sample manifests under +`tests/data` cover both valid and invalid permutations of the schema. These +fixtures are loaded by the tests to ensure real-world YAML files behave as +expected. This suite guards against regressions as the schema evolves. ## Section 4: Dynamic Builds with the Jinja Templating Engine diff --git a/docs/roadmap.md b/docs/roadmap.md index 66918610..be11f2a4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,16 +24,16 @@ compilation pipeline from parsing to execution. #[serde(deny_unknown_fields)] to enable serde_yml parsing. *(done)* - - [ ] Implement parsing for the netsuke_version field and validate it using - the semver crate. + - [x] Implement parsing for the netsuke_version field and validate it using + the semver crate. *(done)* - [x] Support `phony` and `always` boolean flags on targets. *(done)* - [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. + - [x] Implement the YAML parsing logic to deserialize a static Netsukefile + into the NetsukeManifest AST. *(done)* - [ ] **Intermediate Representation (IR) and Validation:** diff --git a/docs/rust-doctest-dry-guide.md b/docs/rust-doctest-dry-guide.md index 7c4594bc..1e65b163 100644 --- a/docs/rust-doctest-dry-guide.md +++ b/docs/rust-doctest-dry-guide.md @@ -645,7 +645,7 @@ mastering doctests: [^11]: Compile_fail doc test ignored in cfg(test) - help - The Rust Programming Language Forum, accessed on July 15, 2025, - accessed on July 15, 2025, + accessed on July 15, 2025 [^12]: quote_doctest - Rust - [Docs.rs](http://Docs.rs), accessed on July 15, 2025, @@ -654,7 +654,7 @@ mastering doctests: [^14]: rust - How can I conditionally execute a module-level doctest based …, accessed on July 15, 2025, - Why have doctests?, accessed on July 15, 2025, + Why have doctests?, accessed on July 15, 2025 [^15]: How do you write your doc tests? : r/rust - Reddit, accessed on July 15, 2025, diff --git a/src/lib.rs b/src/lib.rs index 50e14883..fb2272ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,9 @@ //! Netsuke core library. //! -//! Currently this library only exposes the command line interface -//! definitions used by the binary and tests. +//! This library provides the command line interface definitions and +//! helper functions for parsing `Netsukefile` manifests. pub mod ast; pub mod cli; +pub mod manifest; pub mod runner; diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 00000000..0ebfb894 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,56 @@ +//! Manifest loading helpers. +//! +//! This module provides convenience functions for parsing a static +//! `Netsukefile` into the [`crate::ast::NetsukeManifest`] structure. +//! They wrap `serde_yml` and add basic file handling. + +use crate::ast::NetsukeManifest; +use anyhow::{Context, Result}; +use std::{fs, path::Path}; + +/// Parse a YAML string into a [`NetsukeManifest`]. +/// +/// # Examples +/// +/// ``` +/// use netsuke::manifest::from_str; +/// +/// let yaml = r#" +/// netsuke_version: 1.0.0 +/// targets: +/// - name: a +/// recipe: +/// kind: command +/// command: echo hi +/// "#; +/// let manifest = from_str(yaml).expect("parse"); +/// assert_eq!(manifest.targets.len(), 1); +/// ``` +/// +/// # Errors +/// +/// Returns an error if the YAML is malformed or fails validation. +pub fn from_str(yaml: &str) -> Result { + serde_yml::from_str::(yaml).context("YAML parse error") +} + +/// Load a [`NetsukeManifest`] from the given file path. +/// +/// # Examples +/// +/// ```no_run +/// use netsuke::manifest::from_path; +/// +/// let manifest = from_path("Netsukefile"); +/// assert!(manifest.is_ok()); +/// ``` +/// +/// # Errors +/// +/// Returns an error if the file cannot be read or the YAML fails to parse. +pub fn from_path(path: impl AsRef) -> Result { + let path_ref = path.as_ref(); + let data = fs::read_to_string(path_ref) + .with_context(|| format!("Failed to read {}", path_ref.display()))?; + from_str(&data) +} diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index 33e156c1..2c1633ce 100644 --- a/tests/ast_tests.rs +++ b/tests/ast_tests.rs @@ -1,12 +1,12 @@ //! Unit tests for Netsuke manifest AST deserialisation. -use netsuke::ast::*; +use netsuke::{ast::*, manifest}; 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) +/// Convenience wrapper around the library manifest parser for tests. +fn parse_manifest(yaml: &str) -> anyhow::Result { + manifest::from_str(yaml) } #[rstest] @@ -18,7 +18,7 @@ targets: kind: command command: "echo hi""#; - let manifest: NetsukeManifest = serde_yml::from_str(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); assert_eq!( manifest.netsuke_version, @@ -48,12 +48,12 @@ fn missing_required_fields() { kind: command command: "echo hi" "#; - assert!(serde_yml::from_str::(yaml).is_err()); + assert!(manifest::from_str(yaml).is_err()); let yaml = r#" netsuke_version: "1.0.0" "#; - assert!(serde_yml::from_str::(yaml).is_err()); + assert!(manifest::from_str(yaml).is_err()); let yaml = r#" netsuke_version: "1.0.0" @@ -62,7 +62,7 @@ fn missing_required_fields() { kind: command command: "echo hi" "#; - assert!(serde_yml::from_str::(yaml).is_err()); + assert!(manifest::from_str(yaml).is_err()); } #[test] @@ -76,7 +76,7 @@ fn unknown_fields() { command: "echo hi" extra: 42 "#; - assert!(serde_yml::from_str::(yaml).is_err()); + assert!(manifest::from_str(yaml).is_err()); let yaml = r#" netsuke_version: "1.0.0" @@ -87,7 +87,7 @@ fn unknown_fields() { command: "echo hi" unexpected: true "#; - assert!(serde_yml::from_str::(yaml).is_err()); + assert!(manifest::from_str(yaml).is_err()); } #[test] @@ -96,7 +96,7 @@ fn empty_lists_and_maps() { netsuke_version: "1.0.0" targets: [] "#; - let manifest = serde_yml::from_str::(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); assert!(manifest.targets.is_empty()); let yaml = r#" @@ -105,7 +105,7 @@ fn empty_lists_and_maps() { - name: hello recipe: {} "#; - assert!(serde_yml::from_str::(yaml).is_err()); + assert!(manifest::from_str(yaml).is_err()); } #[test] @@ -118,7 +118,7 @@ fn string_or_list_variants() { kind: command command: "echo hi" "#; - let manifest = serde_yml::from_str::(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); let first = manifest.targets.first().expect("target"); if let StringOrList::String(name) = &first.name { assert_eq!(name, "hello"); @@ -136,7 +136,7 @@ fn string_or_list_variants() { kind: command command: "echo hi" "#; - let manifest = serde_yml::from_str::(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); let first = manifest.targets.first().expect("target"); if let StringOrList::List(names) = &first.name { assert_eq!(names, &vec!["hello".to_string(), "world".to_string()]); @@ -152,7 +152,7 @@ fn string_or_list_variants() { kind: command command: "echo hi" "#; - let manifest = serde_yml::from_str::(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); let first = manifest.targets.first().expect("target"); if let StringOrList::List(names) = &first.name { assert!(names.is_empty()); @@ -178,7 +178,7 @@ fn optional_fields() { kind: rule rule: compile "#; - let manifest = serde_yml::from_str::(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); let rule = manifest.rules.first().expect("rule"); assert_eq!(rule.description.as_deref(), Some("Compile")); match &rule.deps { @@ -199,7 +199,7 @@ fn optional_fields() { kind: rule rule: compile "#; - let manifest = serde_yml::from_str::(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); let rule = manifest.rules.first().expect("rule"); assert!(rule.description.is_none()); assert!(matches!(rule.deps, StringOrList::Empty)); @@ -244,7 +244,7 @@ fn phony_and_always_flags() { phony: true always: true "#; - let manifest = serde_yml::from_str::(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); let target = manifest.targets.first().expect("target"); assert!(target.phony); assert!(target.always); @@ -257,7 +257,7 @@ fn phony_and_always_flags() { kind: command command: rm -rf build "#; - let manifest = serde_yml::from_str::(yaml).expect("parse"); + let manifest = manifest::from_str(yaml).expect("parse"); let target = manifest.targets.first().expect("target"); assert!(!target.phony); assert!(!target.always); @@ -358,3 +358,42 @@ fn multiple_actions_are_marked_phony() { assert!(!action.always); } } + +#[test] +fn load_manifest_from_file() { + let manifest = manifest::from_path("tests/data/minimal.yml").expect("load"); + assert_eq!( + manifest.netsuke_version, + Version::parse("1.0.0").expect("ver") + ); +} + +#[test] +fn load_manifest_missing_file() { + let result = manifest::from_path("tests/data/missing.yml"); + assert!(result.is_err()); +} + +#[rstest] +#[case("minimal.yml", "hello")] +#[case("phony.yml", "clean")] +#[case("rules.yml", "hello.o")] +fn parse_example_manifests(#[case] file: &str, #[case] first_target: &str) { + let path = format!("tests/data/{file}"); + let manifest = manifest::from_path(&path).expect("load"); + let first = manifest.targets.first().expect("targets"); + match &first.name { + StringOrList::String(name) => assert_eq!(name, first_target), + other => panic!("Expected String variant, got: {other:?}"), + } +} + +#[rstest] +#[case("unknown_field.yml")] +#[case("invalid_version.yml")] +#[case("missing_recipe.yml")] +#[case("action_invalid.yml")] +fn invalid_manifests_fail(#[case] file: &str) { + let path = format!("tests/data/{file}"); + assert!(manifest::from_path(&path).is_err()); +} diff --git a/tests/data/invalid_version.yml b/tests/data/invalid_version.yml new file mode 100644 index 00000000..05766db2 --- /dev/null +++ b/tests/data/invalid_version.yml @@ -0,0 +1,6 @@ +netsuke_version: "1" +targets: + - name: hi + recipe: + kind: command + command: "echo hi" diff --git a/tests/data/missing_recipe.yml b/tests/data/missing_recipe.yml new file mode 100644 index 00000000..f4cb0355 --- /dev/null +++ b/tests/data/missing_recipe.yml @@ -0,0 +1,4 @@ +netsuke_version: "1.0.0" +targets: + - name: hi + diff --git a/tests/data/rules.yml b/tests/data/rules.yml new file mode 100644 index 00000000..fc356e59 --- /dev/null +++ b/tests/data/rules.yml @@ -0,0 +1,13 @@ +netsuke_version: "1.0.0" +rules: + - name: compile + recipe: + kind: command + command: "cc -c $in -o $out" +targets: + - name: hello.o + sources: hello.c + recipe: + kind: rule + rule: compile + diff --git a/tests/data/unknown_field.yml b/tests/data/unknown_field.yml new file mode 100644 index 00000000..8c9d07f6 --- /dev/null +++ b/tests/data/unknown_field.yml @@ -0,0 +1,7 @@ +netsuke_version: "1.0.0" +extra: field +targets: + - name: hi + recipe: + kind: command + command: "echo hi" diff --git a/tests/features/manifest.feature b/tests/features/manifest.feature index 7482cc28..a4d6ec22 100644 --- a/tests/features/manifest.feature +++ b/tests/features/manifest.feature @@ -17,3 +17,20 @@ Feature: Manifest parsing Scenario: Invalid action fails to parse When the manifest file "tests/data/action_invalid.yml" is parsed Then parsing the manifest fails + + Scenario: Manifest with rules parses correctly + When the manifest file "tests/data/rules.yml" is parsed + Then the first rule name is "compile" + And the first target name is "hello.o" + + Scenario: Unknown field fails to parse + When the manifest file "tests/data/unknown_field.yml" is parsed + Then parsing the manifest fails + + Scenario: Invalid version fails to parse + When the manifest file "tests/data/invalid_version.yml" is parsed + Then parsing the manifest fails + + Scenario: Missing recipe fails to parse + When the manifest file "tests/data/missing_recipe.yml" is parsed + Then parsing the manifest fails diff --git a/tests/steps/manifest_steps.rs b/tests/steps/manifest_steps.rs index fcff4887..5f2ae08a 100644 --- a/tests/steps/manifest_steps.rs +++ b/tests/steps/manifest_steps.rs @@ -2,8 +2,7 @@ use crate::CliWorld; use cucumber::{then, when}; -use netsuke::ast::{NetsukeManifest, StringOrList}; -use std::fs; +use netsuke::{ast::StringOrList, manifest}; #[expect( clippy::needless_pass_by_value, @@ -11,15 +10,7 @@ use std::fs; )] #[when(expr = "the manifest file {string} is parsed")] fn parse_manifest(world: &mut CliWorld, path: String) { - let yaml = match fs::read_to_string(&path) { - Ok(content) => content, - Err(e) => { - world.manifest = None; - world.manifest_error = Some(format!("Failed to read {path}: {e}")); - return; - } - }; - match serde_yml::from_str::(&yaml) { + match manifest::from_path(&path) { Ok(manifest) => { world.manifest = Some(manifest); world.manifest_error = None; @@ -80,3 +71,14 @@ fn first_action_phony(world: &mut CliWorld) { fn manifest_parse_error(world: &mut CliWorld) { assert!(world.manifest_error.is_some(), "expected parse error"); } + +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[then(expr = "the first rule name is {string}")] +fn first_rule_name(world: &mut CliWorld, name: String) { + let manifest = world.manifest.as_ref().expect("manifest"); + let rule = manifest.rules.first().expect("rules"); + assert_eq!(rule.name, name); +}