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
8 changes: 4 additions & 4 deletions docs/behavioural-testing-in-rust-with-cucumber.md
Original file line number Diff line number Diff line change
Expand Up @@ -1164,12 +1164,12 @@ aligned with what is needed.
accessed on July 14, 2025,
<https://medium.com/@realtalkdev/common-challenges-in-cucumber-testing-and-how-to-overcome-them-dc95fffb43c8>

[^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 —
<https://docs.rs/cucumber/latest/cucumber/struct.Cucumber.html>

[^32]: CLI (command-line interface) - Cucumber Rust Book, accessed on
14 July 2025, <https://cucumber-rs.github.io/cucumber/main/cli.html>
[^32]: CLI (commandline interface) - Cucumber Rust Book, accessed
on 14 July 2025, <https://cucumber-rs.github.io/cucumber/main/cli.html>

[^33]: Continuous Integration - Cucumber, accessed on 14 July 2025,
<https://cucumber.io/docs/guides/continuous-integration>
Expand Down
30 changes: 16 additions & 14 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -193,7 +193,7 @@ erDiagram
string netsuke_version
map vars
list rules
list steps
list actions
list targets
list defaults
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -403,7 +403,7 @@ deserialization and easy debugging.

Rust

````rust
```rust
// In src/ast.rs

use serde::Deserialize;
Expand All @@ -421,7 +421,7 @@ pub struct NetsukeManifest {
pub rules: Vec<Rule>,

#[serde(default)]
pub steps: Vec<Target>,
pub actions: Vec<Target>,

pub targets: Vec<Target>,

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String, String>` 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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 17 additions & 5 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@
//! ```

use semver::Version;
use serde::Deserialize;
use serde::{Deserialize, de::Deserializer};

fn deserialize_actions<'de, D>(deserializer: D) -> Result<Vec<Target>, D::Error>
where
D: Deserializer<'de>,
{
let mut actions = Vec::<Target>::deserialize(deserializer)?;
for action in &mut actions {
action.phony = true;
}
Ok(actions)
}
use std::collections::HashMap;

/// Top-level manifest structure parsed from a `Netsukefile`.
Expand All @@ -28,7 +39,7 @@ use std::collections::HashMap;
///
/// ```yaml
/// netsuke_version: "1.0.0"
/// steps: []
/// actions: []
/// targets:
/// - name: hello
/// recipe:
Expand Down Expand Up @@ -58,9 +69,10 @@ pub struct NetsukeManifest {
#[serde(default)]
pub rules: Vec<Rule>,

/// Optional top-level steps executed before normal targets.
#[serde(default)]
pub steps: Vec<Target>,
/// 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<Target>,

/// Primary build targets.
pub targets: Vec<Target>,
Expand Down
138 changes: 126 additions & 12 deletions tests/ast_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NetsukeManifest, serde_yml::Error> {
serde_yml::from_str::<NetsukeManifest>(yaml)
}

#[rstest]
fn parse_minimal_manifest() {
let yaml = r#"netsuke_version: "1.0.0"
Expand Down Expand Up @@ -34,7 +39,6 @@ targets:
panic!("Expected command recipe, got: {:?}", first.recipe);
}
}

#[test]
fn missing_required_fields() {
let yaml = r#"
Expand Down Expand Up @@ -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::<NetsukeManifest>(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]
Expand Down Expand Up @@ -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);
}
}
10 changes: 10 additions & 0 deletions tests/data/action_invalid.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
11 changes: 11 additions & 0 deletions tests/data/actions.yml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions tests/features/manifest.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions tests/steps/manifest_steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}