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,288 changes: 1,288 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4.5.0", features = ["derive"] }

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
Expand Down Expand Up @@ -44,3 +45,12 @@ string_lit_as_bytes = "deny"

# 6. numerical foot-guns
float_arithmetic = "deny"

[dev-dependencies]
rstest = "0.18.0"
cucumber = "0.20.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"], default-features = false }

[[test]]
name = "cucumber"
harness = false
11 changes: 11 additions & 0 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,17 @@ The behaviour of each subcommand is clearly defined:
viewer. Visualising the graph is invaluable for understanding and debugging
complex projects.

### 8.4 Design Decisions

The CLI is implemented using clap's derive API in `src/cli.rs`. Clap's
`default_value_t` attribute marks `Build` as the default subcommand, so invoking
`netsuke` with no explicit command still triggers a build. CLI execution and
dispatch live in `src/runner.rs`, keeping `main.rs` focused on parsing. The
working directory flag uses `-C` to mirror Ninja's convention, ensuring command
line arguments map directly onto the underlying build tool. Error scenarios are
validated using clap's `ErrorKind` enumeration in unit tests and via Cucumber
steps for behavioural coverage.

## Section 9: Implementation Roadmap and Strategic Recommendations

This final section outlines a strategic plan for implementing Netsuke, along
Expand Down
4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ compilation pipeline from parsing to execution.

- [ ] **CLI and Manifest Parsing:**

- [ ] Implement the initial clap CLI structure for the build command and
- [x] Implement the initial clap CLI structure for the build command and
global options (--file, --directory, --jobs), as defined in the design
document.
document. *(done)*

- [ ] Define the core Abstract Syntax Tree (AST) data structures
(NetsukeManifest, Rule, Target, StringOrList, Recipe) in `src/ast.rs`.
Expand Down
92 changes: 92 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Command line interface definition using clap.
//!
//! This module defines the [`Cli`] structure and its subcommands.
//! It mirrors the design described in `docs/netsuke-design.md`.

use clap::{Parser, Subcommand};
use std::path::PathBuf;

/// Maximum number of jobs accepted by the CLI.
const MAX_JOBS: usize = 64;

fn parse_jobs(s: &str) -> Result<usize, String> {
let value: usize = s
.parse()
.map_err(|_| format!("{s} is not a valid number"))?;
if (1..=MAX_JOBS).contains(&value) {
Ok(value)
} else {
Err(format!("jobs must be between 1 and {MAX_JOBS}"))
}
}

/// A modern, friendly build system that uses YAML and Jinja, powered by Ninja.
#[derive(Debug, Parser)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
/// Path to the Netsuke manifest file to use.
#[arg(short, long, value_name = "FILE", default_value = "Netsukefile")]
pub file: PathBuf,

/// Change to this directory before doing anything.
#[arg(short = 'C', long, value_name = "DIR")]
pub directory: Option<PathBuf>,

/// Set the number of parallel build jobs.
#[arg(short, long, value_name = "N", value_parser = parse_jobs)]
pub jobs: Option<usize>,

#[command(subcommand)]
pub command: Option<Commands>,
}

impl Cli {
/// Parse command-line arguments, providing `build` as the default command.
#[must_use]
pub fn parse_with_default() -> Self {
Self::parse().with_default_command()
}

/// Parse the provided arguments, applying the default command when needed.
///
/// # Panics
///
/// Panics if argument parsing fails.
#[must_use]
pub fn parse_from_with_default<I, T>(args: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
Self::try_parse_from(args)
.unwrap_or_else(|e| panic!("CLI parsing failed: {e}"))
.with_default_command()
}

/// Apply the default command if none was specified.
#[must_use]
fn with_default_command(mut self) -> Self {
if self.command.is_none() {
self.command = Some(Commands::Build {
targets: Vec::new(),
});
}
self
}
}

/// Available top-level commands for Netsuke.
#[derive(Debug, Subcommand, PartialEq, Eq, Clone)]
pub enum Commands {
/// Build specified targets (or default targets if none are given) [default].
Build {
/// A list of specific targets to build.
targets: Vec<String>,
},

/// Remove build artifacts and intermediate files.
Clean,

/// Display the build dependency graph in DOT format for visualization.
Graph,
}
7 changes: 7 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Netsuke core library.
//!
//! Currently this library only exposes the command line interface
//! definitions used by the binary and tests.

pub mod cli;
pub mod runner;
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use netsuke::{cli::Cli, runner};

fn main() {
// Placeholder entry point for future CLI implementation.
let cli = Cli::parse_with_default();
runner::run(cli);
}
23 changes: 23 additions & 0 deletions src/runner.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//! CLI execution and command dispatch logic.
//!
//! This module keeps [`main`] minimal by providing a single entry point that
//! handles command execution. It currently prints which command was invoked.

use crate::cli::{Cli, Commands};

/// Execute the parsed [`Cli`] commands.
pub fn run(cli: Cli) {
match cli.command.unwrap_or(Commands::Build {
targets: Vec::new(),
}) {
Commands::Build { targets } => {
println!("Building targets: {targets:?}");
}
Commands::Clean => {
println!("Clean requested");
}
Commands::Graph => {
println!("Graph requested");
}
}
}
42 changes: 42 additions & 0 deletions tests/cli_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Unit tests for CLI argument parsing and validation.
//!
//! This module exercises the command-line interface defined in [`netsuke::cli`]
//! using `rstest` for parameterised coverage of success and error scenarios.
use clap::Parser;
use clap::error::ErrorKind;
use netsuke::cli::{Cli, Commands};
use rstest::rstest;
use std::path::PathBuf;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
#[rstest]
#[case(vec!["netsuke"], PathBuf::from("Netsukefile"), None, None, Commands::Build { targets: Vec::new() })]
#[case(
vec!["netsuke", "--file", "alt.yml", "-C", "work", "-j", "4", "build", "a", "b"],
PathBuf::from("alt.yml"),
Some(PathBuf::from("work")),
Some(4),
Commands::Build { targets: vec!["a".into(), "b".into()] },
)]
fn parse_cli(
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
#[case] argv: Vec<&str>,
#[case] file: PathBuf,
#[case] directory: Option<PathBuf>,
#[case] jobs: Option<usize>,
#[case] expected_cmd: Commands,
) {
let cli = Cli::parse_from_with_default(argv.clone());
assert_eq!(cli.file, file);
assert_eq!(cli.directory, directory);
assert_eq!(cli.jobs, jobs);
assert_eq!(cli.command.expect("command should be set"), expected_cmd);
}

#[rstest]
#[case(vec!["netsuke", "unknowncmd"], ErrorKind::InvalidSubcommand)]
#[case(vec!["netsuke", "--file"], ErrorKind::InvalidValue)]
#[case(vec!["netsuke", "-j", "notanumber"], ErrorKind::ValueValidation)]
#[case(vec!["netsuke", "--file", "alt.yml", "-C"], ErrorKind::InvalidValue)]
fn parse_cli_errors(#[case] argv: Vec<&str>, #[case] expected_error: ErrorKind) {
let err = Cli::try_parse_from(argv).expect_err("unexpected success");
assert_eq!(err.kind(), expected_error);
}
14 changes: 14 additions & 0 deletions tests/cucumber.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
use cucumber::World;
Comment thread
leynos marked this conversation as resolved.

#[derive(Debug, Default, World)]
pub struct CliWorld {
pub cli: Option<netsuke::cli::Cli>,
pub cli_error: Option<String>,
}

mod steps;

#[tokio::main]
async fn main() {
CliWorld::run("tests/features").await;
}
59 changes: 59 additions & 0 deletions tests/features/cli.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
Feature: CLI parsing
Comment thread
leynos marked this conversation as resolved.

Comment thread
leynos marked this conversation as resolved.
Scenario: Build is the default command
When the CLI is parsed with ""
Then parsing succeeds
And the command is build

Scenario: Clean command runs
When the CLI is parsed with "-C work clean"
Then parsing succeeds
And the command is clean
And the working directory is "work"

Scenario: Graph command with jobs
When the CLI is parsed with "-j 2 graph"
Then parsing succeeds
And the command is graph
And the job count is 2

Scenario: Manifest file can be overridden
When the CLI is parsed with "--file alt.yml build target"
Then parsing succeeds
And the manifest path is "alt.yml"
And the first target is "target"

Scenario: Unknown command fails
When the CLI is parsed with invalid arguments "unknown"
Then an error should be returned
And the error message should contain "unknown"

Scenario: Missing file argument value
When the CLI is parsed with invalid arguments "--file"
Then an error should be returned
And the error message should contain "--file"

Scenario: Directory flag sets working directory
When the CLI is parsed with "-C work build"
Then parsing succeeds
And the working directory is "work"

Scenario: Jobs flag sets parallelism
When the CLI is parsed with "-j 4"
Then parsing succeeds
And the job count is 4

Scenario: Missing directory argument value
When the CLI is parsed with invalid arguments "-C"
Then an error should be returned
And the error message should contain "--directory"

Scenario: Missing jobs argument value
When the CLI is parsed with invalid arguments "-j"
Then an error should be returned
And the error message should contain "--jobs"

Scenario: Non-numeric jobs value
When the CLI is parsed with invalid arguments "-j notanumber"
Then an error should be returned
And the error message should contain "notanumber"
Loading