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
33 changes: 17 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
- **Clarity over cleverness.** Be concise, but favour explicit over terse or
obscure idioms. Prefer code that's easy to follow.
- **Use functions and composition.** Avoid repetition by extracting reusable
logic. Prefer generators or comprehensions, and declarative code to imperative
repetition when readable.
logic. Prefer generators or comprehensions, and declarative code to
imperative repetition when readable.
- **Small, meaningful functions.** Functions must be small, clear in purpose,
single responsibility, and obey command/query segregation.
- **Clear commit messages.** Commit messages should be descriptive, explaining
Expand All @@ -25,12 +25,13 @@
("-ize" / "-yse" / "-our") spelling and grammar, with the exception of
references to external APIs.
- **Illustrate with clear examples.** Function documentation must include clear
examples demonstrating the usage and outcome of the function. Test documentation
should omit examples where the example serves only to reiterate the test logic.
- **Keep file size managable.** No single code file may be longer than 400 lines.
Long switch statements or dispatch tables should be broken up by feature and
constituents colocated with targets. Large blocks of test data should be moved
to external data files.
examples demonstrating the usage and outcome of the function. Test
documentation should omit examples where the example serves only to reiterate
the test logic.
- **Keep file size managable.** No single code file may be longer than 400
lines. Long switch statements or dispatch tables should be broken up by
feature and constituents colocated with targets. Large blocks of test data
should be moved to external data files.

## Documentation Maintenance

Expand All @@ -42,8 +43,8 @@
relevant file(s) in the `docs/` directory to reflect the latest state.
**Ensure the documentation remains accurate and current.**
- Documentation must use en-GB-oxendict ("-ize" / "-yse" / "-our") spelling
and grammar. (EXCEPTION: the naming of the "LICENSE" file, which
is to be left unchanged for community consistency.)
and grammar. (EXCEPTION: the naming of the "LICENSE" file, which is to be
left unchanged for community consistency.)

## Change Quality & Committing

Expand Down Expand Up @@ -153,19 +154,19 @@ project:
specified in `Cargo.toml` must use SemVer-compatible caret requirements
(e.g., `some-crate = "1.2.3"`). This is Cargo's default and allows for safe,
non-breaking updates to minor and patch versions while preventing breaking
changes from new major versions. This approach is critical for ensuring
build stability and reproducibility.
changes from new major versions. This approach is critical for ensuring build
stability and reproducibility.
- **Prohibit unstable version specifiers.** The use of wildcard (`*`) or
open-ended inequality (`>=`) version requirements is strictly forbidden
as they introduce unacceptable risk and unpredictability. Tilde requirements
open-ended inequality (`>=`) version requirements is strictly forbidden, as
they introduce unacceptable risk and unpredictability. Tilde requirements
(`~`) should only be used where a dependency must be locked to patch-level
updates for a specific, documented reason.

### Error Handling

- **Prefer semantic error enums**. Derive `std::error::Error` (via the
`thiserror` crate) for any condition the caller might inspect, retry, or
map to an HTTP status.
`thiserror` crate) for any condition the caller might inspect, retry, or map
to an HTTP status.
- **Use an *opaque* error only at the app boundary**. Use `eyre::Report` for
human-readable logs; these should not be exposed in public APIs.
- **Never export the opaque type from a library**. Convert to domain enums at
Expand Down
12 changes: 12 additions & 0 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,18 @@ default my_app

\`\`\`

### 5.5 Design Decisions

The IR structures defined in `src/ir.rs` are minimal containers that mirror
Ninja's conceptual model while remaining backend-agnostic. `BuildGraph`
collects all `Action`s and `BuildEdge`s in hash maps keyed by stable strings and
`PathBuf`s so the graph can be deterministically traversed for snapshot tests.
Actions hold the parsed `Recipe` and
optional execution metadata. `BuildEdge` connects inputs to outputs using an
action identifier and carries the `phony` and `always` flags verbatim from the
manifest. No Ninja specific placeholders are stored in the IR to keep the
representation portable.

## Section 6: Process Management and Secure Execution

The final stage of a Netsuke build involves executing commands. While Netsuke
Expand Down
4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ compilation pipeline from parsing to execution.

- [ ] **Intermediate Representation (IR) and Validation:**

- [ ] Define the IR data structures (BuildGraph, Action, BuildEdge) in
`src/ir.rs`, keeping it backend-agnostic as per the design.
- [x] Define the IR data structures (BuildGraph, Action, BuildEdge) in
`src/ir.rs`, keeping it backend-agnostic as per the design. *(done)*

- [ ] Implement the ir::from_manifest transformation logic to convert the
AST into the BuildGraph IR.
Expand Down
4 changes: 2 additions & 2 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ pub struct Rule {
///
/// The variant is selected using the `kind` field in the manifest. Each variant
/// corresponds to a different way of specifying how a command should run.
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Recipe {
/// A single shell command.
Expand Down Expand Up @@ -172,7 +172,7 @@ pub struct Target {
/// - hello
/// - world
/// ```
#[derive(Debug, Deserialize, Default)]
#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
#[serde(untagged)]
pub enum StringOrList {
/// No value provided.
Expand Down
70 changes: 70 additions & 0 deletions src/ir.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Intermediate Representation structures.
//!
//! This module defines the backend-agnostic build graph used by Netsuke after
//! validation. The IR mirrors the conceptual model of Ninja without embedding
//! any Ninja-specific syntax.
//!
//! # Examples
//!
//! ```
//! use netsuke::ir::{Action, BuildGraph, BuildEdge};
//! use netsuke::ast::Recipe;
//! use std::path::PathBuf;
//!
//! let action = Action {
//! recipe: Recipe::Command { command: "echo hi".into() },
//! description: None,
//! depfile: None,
//! deps_format: None,
//! pool: None,
//! restat: false,
//! };
//! let mut graph = BuildGraph::default();
//! graph.actions.insert("a".into(), action);
//! graph.default_targets.push(PathBuf::from("hello"));
//! ```
//
use crate::ast::Recipe;
use std::collections::HashMap;
use std::path::PathBuf;

/// The complete, static build graph.
#[derive(Debug, Default, Clone)]
pub struct BuildGraph {
/// All unique actions in the build keyed by a stable hash.
pub actions: HashMap<String, Action>,
/// All target files to be built keyed by output path.
pub targets: HashMap<PathBuf, BuildEdge>,
/// Targets built when no explicit target is requested.
pub default_targets: Vec<PathBuf>,
}

/// A reusable command analogous to a Ninja rule.
#[derive(Debug, Clone, PartialEq)]
pub struct Action {
pub recipe: Recipe,
pub description: Option<String>,
pub depfile: Option<String>,
pub deps_format: Option<String>,
pub pool: Option<String>,
pub restat: bool,
}

/// A single build statement connecting inputs to outputs.
#[derive(Debug, Clone, PartialEq)]
pub struct BuildEdge {
/// Identifier of the [`Action`] used for this edge.
pub action_id: String,
/// Explicit inputs that trigger a rebuild when changed.
pub inputs: Vec<PathBuf>,
/// Outputs explicitly generated by the command.
pub explicit_outputs: Vec<PathBuf>,
/// Outputs implicitly generated by the command (Ninja `|`).
pub implicit_outputs: Vec<PathBuf>,
/// Order-only dependencies that do not trigger rebuilds (Ninja `||`).
pub order_only_deps: Vec<PathBuf>,
/// Always run the command even if the output exists.
pub phony: bool,
/// Run the command on every invocation regardless of timestamps.
pub always: bool,
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@

pub mod ast;
pub mod cli;
pub mod ir;
pub mod manifest;
pub mod runner;
1 change: 1 addition & 0 deletions tests/cucumber.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub struct CliWorld {
pub cli_error: Option<String>,
pub manifest: Option<netsuke::ast::NetsukeManifest>,
pub manifest_error: Option<String>,
pub build_graph: Option<netsuke::ir::BuildGraph>,
}

mod steps;
Expand Down
8 changes: 8 additions & 0 deletions tests/features/ir.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Feature: BuildGraph

Scenario: New BuildGraph is empty
When a new BuildGraph is created
Then the graph has 0 actions
And the graph has 0 targets
And the graph has 0 default targets

112 changes: 112 additions & 0 deletions tests/ir_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! Unit tests for IR structures.

use netsuke::ast::Recipe;
use netsuke::ir::{Action, BuildEdge, BuildGraph};
use rstest::rstest;
use std::path::PathBuf;

#[rstest]
fn build_graph_default_is_empty() {
let graph = BuildGraph::default();
assert!(graph.actions.is_empty());
assert!(graph.targets.is_empty());
assert!(graph.default_targets.is_empty());
}
Comment thread
leynos marked this conversation as resolved.

#[rstest]
fn create_action_and_edge() {
let action = Action {
recipe: Recipe::Command {
command: "echo".into(),
},
description: Some("desc".into()),
depfile: Some("$out.d".into()),
deps_format: Some("gcc".into()),
pool: None,
restat: false,
};
let edge = BuildEdge {
action_id: "id".into(),
inputs: vec![PathBuf::from("in")],
explicit_outputs: vec![PathBuf::from("out")],
implicit_outputs: Vec::new(),
order_only_deps: Vec::new(),
phony: false,
always: true,
};
let mut graph = BuildGraph::default();
graph.actions.insert("id".into(), action);
graph.targets.insert(PathBuf::from("out"), edge);
assert_eq!(graph.actions.len(), 1);
assert_eq!(graph.targets.len(), 1);
}

#[test]
fn build_graph_duplicate_action_ids() {
let mut graph = BuildGraph::default();
let action1 = Action {
recipe: Recipe::Command {
command: "one".into(),
},
description: None,
depfile: None,
deps_format: None,
pool: None,
restat: false,
};
let action2 = Action {
recipe: Recipe::Command {
command: "two".into(),
},
description: None,
depfile: None,
deps_format: None,
pool: None,
restat: false,
};
let prev = graph.actions.insert("a".into(), action1);
assert!(prev.is_none());
let prev = graph.actions.insert("a".into(), action2);
assert!(prev.is_some());
assert_eq!(graph.actions.len(), 1);
if let Recipe::Command { command } = &graph.actions.get("a").expect("action").recipe {
assert_eq!(command, "two");
} else {
panic!("unexpected recipe type");
}
}

#[test]
fn build_graph_duplicate_targets() {
let mut graph = BuildGraph::default();
let edge1 = BuildEdge {
action_id: "a".into(),
inputs: vec![PathBuf::from("in")],
explicit_outputs: vec![PathBuf::from("out")],
implicit_outputs: Vec::new(),
order_only_deps: Vec::new(),
phony: false,
always: false,
};
let edge2 = BuildEdge {
action_id: "a".into(),
inputs: vec![PathBuf::from("in")],
explicit_outputs: vec![PathBuf::from("out")],
implicit_outputs: Vec::new(),
order_only_deps: Vec::new(),
phony: false,
always: true,
};
let prev = graph.targets.insert(PathBuf::from("out"), edge1);
assert!(prev.is_none());
let prev = graph.targets.insert(PathBuf::from("out"), edge2);
assert!(prev.is_some());
assert_eq!(graph.targets.len(), 1);
assert!(
graph
.targets
.get(&PathBuf::from("out"))
.expect("edge")
.always
);
}
28 changes: 28 additions & 0 deletions tests/steps/ir_steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! Step definitions for `BuildGraph` scenarios.

use crate::CliWorld;
use cucumber::{then, when};
use netsuke::ir::BuildGraph;

#[when("a new BuildGraph is created")]
fn create_graph(world: &mut CliWorld) {
world.build_graph = Some(BuildGraph::default());
}

#[then(expr = "the graph has {int} actions")]
fn graph_actions(world: &mut CliWorld, count: usize) {
let g = world.build_graph.as_ref().expect("graph");
assert_eq!(g.actions.len(), count);
}

#[then(expr = "the graph has {int} targets")]
fn graph_targets(world: &mut CliWorld, count: usize) {
let g = world.build_graph.as_ref().expect("graph");
assert_eq!(g.targets.len(), count);
}

#[then(expr = "the graph has {int} default targets")]
fn graph_defaults(world: &mut CliWorld, count: usize) {
let g = world.build_graph.as_ref().expect("graph");
assert_eq!(g.default_targets.len(), count);
}
1 change: 1 addition & 0 deletions tests/steps/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
mod cli_steps;
mod ir_steps;
mod manifest_steps;