Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 7 additions & 3 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
4 changes: 2 additions & 2 deletions docs/rust-doctest-dry-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
<https://users.rust-lang.org/t/compile-fail-doc-test-ignored-in-cfg-test/124927>
accessed on July 15, 2025,
accessed on July 15, 2025
<https://users.rust-lang.org/t/test-setup-for-doctests/50426>
[^12]: quote_doctest - Rust - [Docs.rs](http://Docs.rs), accessed on July 15,
2025, <https://docs.rs/quote-doctest>
Expand All @@ -654,7 +654,7 @@ mastering doctests:
[^14]: rust - How can I conditionally execute a module-level doctest based …,
accessed on July 15, 2025,
<https://stackoverflow.com/questions/50312190/how-can-i-conditionally-execute-a-module-level-doctest-based-on-a-feature-flag>
Why have doctests?, accessed on July 15, 2025,
Why have doctests?, accessed on July 15, 2025
<https://stackoverflow.com/questions/38292741/how-would-one-achieve-conditional-compilation-with-rust-projects-that-have-docte>
[^15]: How do you write your doc tests? : r/rust - Reddit, accessed on July 15,
2025,
Expand Down
5 changes: 3 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
56 changes: 56 additions & 0 deletions src/manifest.rs
Original file line number Diff line number Diff line change
@@ -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<NetsukeManifest> {
serde_yml::from_str::<NetsukeManifest>(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<Path>) -> Result<NetsukeManifest> {
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)
}
77 changes: 58 additions & 19 deletions tests/ast_tests.rs
Original file line number Diff line number Diff line change
@@ -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<NetsukeManifest, serde_yml::Error> {
serde_yml::from_str::<NetsukeManifest>(yaml)
/// Convenience wrapper around the library manifest parser for tests.
fn parse_manifest(yaml: &str) -> anyhow::Result<NetsukeManifest> {
manifest::from_str(yaml)
}

#[rstest]
Expand All @@ -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,
Expand Down Expand Up @@ -48,12 +48,12 @@ fn missing_required_fields() {
kind: command
command: "echo hi"
"#;
assert!(serde_yml::from_str::<NetsukeManifest>(yaml).is_err());
assert!(manifest::from_str(yaml).is_err());

let yaml = r#"
netsuke_version: "1.0.0"
"#;
assert!(serde_yml::from_str::<NetsukeManifest>(yaml).is_err());
assert!(manifest::from_str(yaml).is_err());

let yaml = r#"
netsuke_version: "1.0.0"
Expand All @@ -62,7 +62,7 @@ fn missing_required_fields() {
kind: command
command: "echo hi"
"#;
assert!(serde_yml::from_str::<NetsukeManifest>(yaml).is_err());
assert!(manifest::from_str(yaml).is_err());
}

#[test]
Expand All @@ -76,7 +76,7 @@ fn unknown_fields() {
command: "echo hi"
extra: 42
"#;
assert!(serde_yml::from_str::<NetsukeManifest>(yaml).is_err());
assert!(manifest::from_str(yaml).is_err());

let yaml = r#"
netsuke_version: "1.0.0"
Expand All @@ -87,7 +87,7 @@ fn unknown_fields() {
command: "echo hi"
unexpected: true
"#;
assert!(serde_yml::from_str::<NetsukeManifest>(yaml).is_err());
assert!(manifest::from_str(yaml).is_err());
}

#[test]
Expand All @@ -96,7 +96,7 @@ fn empty_lists_and_maps() {
netsuke_version: "1.0.0"
targets: []
"#;
let manifest = serde_yml::from_str::<NetsukeManifest>(yaml).expect("parse");
let manifest = manifest::from_str(yaml).expect("parse");
assert!(manifest.targets.is_empty());

let yaml = r#"
Expand All @@ -105,7 +105,7 @@ fn empty_lists_and_maps() {
- name: hello
recipe: {}
"#;
assert!(serde_yml::from_str::<NetsukeManifest>(yaml).is_err());
assert!(manifest::from_str(yaml).is_err());
}

#[test]
Expand All @@ -118,7 +118,7 @@ fn string_or_list_variants() {
kind: command
command: "echo hi"
"#;
let manifest = serde_yml::from_str::<NetsukeManifest>(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");
Expand All @@ -136,7 +136,7 @@ fn string_or_list_variants() {
kind: command
command: "echo hi"
"#;
let manifest = serde_yml::from_str::<NetsukeManifest>(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()]);
Expand All @@ -152,7 +152,7 @@ fn string_or_list_variants() {
kind: command
command: "echo hi"
"#;
let manifest = serde_yml::from_str::<NetsukeManifest>(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());
Expand All @@ -178,7 +178,7 @@ fn optional_fields() {
kind: rule
rule: compile
"#;
let manifest = serde_yml::from_str::<NetsukeManifest>(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 {
Expand All @@ -199,7 +199,7 @@ fn optional_fields() {
kind: rule
rule: compile
"#;
let manifest = serde_yml::from_str::<NetsukeManifest>(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));
Expand Down Expand Up @@ -244,7 +244,7 @@ fn phony_and_always_flags() {
phony: true
always: true
"#;
let manifest = serde_yml::from_str::<NetsukeManifest>(yaml).expect("parse");
let manifest = manifest::from_str(yaml).expect("parse");
let target = manifest.targets.first().expect("target");
assert!(target.phony);
assert!(target.always);
Expand All @@ -257,7 +257,7 @@ fn phony_and_always_flags() {
kind: command
command: rm -rf build
"#;
let manifest = serde_yml::from_str::<NetsukeManifest>(yaml).expect("parse");
let manifest = manifest::from_str(yaml).expect("parse");
let target = manifest.targets.first().expect("target");
assert!(!target.phony);
assert!(!target.always);
Expand Down Expand Up @@ -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());
}
6 changes: 6 additions & 0 deletions tests/data/invalid_version.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
netsuke_version: "1"
targets:
- name: hi
recipe:
kind: command
command: "echo hi"
4 changes: 4 additions & 0 deletions tests/data/missing_recipe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
netsuke_version: "1.0.0"
targets:
- name: hi

13 changes: 13 additions & 0 deletions tests/data/rules.yml
Original file line number Diff line number Diff line change
@@ -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

7 changes: 7 additions & 0 deletions tests/data/unknown_field.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
netsuke_version: "1.0.0"
extra: field
targets:
- name: hi
recipe:
kind: command
command: "echo hi"
17 changes: 17 additions & 0 deletions tests/features/manifest.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading