Skip to content

Commit fe28d0c

Browse files
authored
Implement initial CLI (#20)
* Add clap-based CLI with tests * Add CLI error handling tests * Refactor CLI parsing and tests * Expand CLI behavioural coverage * Refine CLI parsing and tests * Refactor apply_cli to parse arguments once * Use expect for lint suppression
1 parent 3bc4a07 commit fe28d0c

File tree

13 files changed

+1695
-3
lines changed

13 files changed

+1695
-3
lines changed

Cargo.lock

Lines changed: 1288 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ version = "0.1.0"
44
edition = "2024"
55

66
[dependencies]
7+
clap = { version = "4.5.0", features = ["derive"] }
78

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

4546
# 6. numerical foot-guns
4647
float_arithmetic = "deny"
48+
49+
[dev-dependencies]
50+
rstest = "0.18.0"
51+
cucumber = "0.20.0"
52+
tokio = { version = "1", features = ["macros", "rt-multi-thread"], default-features = false }
53+
54+
[[test]]
55+
name = "cucumber"
56+
harness = false

docs/netsuke-design.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,17 @@ The behaviour of each subcommand is clearly defined:
12621262
viewer. Visualising the graph is invaluable for understanding and debugging
12631263
complex projects.
12641264

1265+
### 8.4 Design Decisions
1266+
1267+
The CLI is implemented using clap's derive API in `src/cli.rs`. Clap's
1268+
`default_value_t` attribute marks `Build` as the default subcommand, so invoking
1269+
`netsuke` with no explicit command still triggers a build. CLI execution and
1270+
dispatch live in `src/runner.rs`, keeping `main.rs` focused on parsing. The
1271+
working directory flag uses `-C` to mirror Ninja's convention, ensuring command
1272+
line arguments map directly onto the underlying build tool. Error scenarios are
1273+
validated using clap's `ErrorKind` enumeration in unit tests and via Cucumber
1274+
steps for behavioural coverage.
1275+
12651276
## Section 9: Implementation Roadmap and Strategic Recommendations
12661277

12671278
This final section outlines a strategic plan for implementing Netsuke, along

docs/roadmap.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ compilation pipeline from parsing to execution.
1212

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

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

1919
- [ ] Define the core Abstract Syntax Tree (AST) data structures
2020
(NetsukeManifest, Rule, Target, StringOrList, Recipe) in `src/ast.rs`.

src/cli.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! Command line interface definition using clap.
2+
//!
3+
//! This module defines the [`Cli`] structure and its subcommands.
4+
//! It mirrors the design described in `docs/netsuke-design.md`.
5+
6+
use clap::{Parser, Subcommand};
7+
use std::path::PathBuf;
8+
9+
/// Maximum number of jobs accepted by the CLI.
10+
const MAX_JOBS: usize = 64;
11+
12+
fn parse_jobs(s: &str) -> Result<usize, String> {
13+
let value: usize = s
14+
.parse()
15+
.map_err(|_| format!("{s} is not a valid number"))?;
16+
if (1..=MAX_JOBS).contains(&value) {
17+
Ok(value)
18+
} else {
19+
Err(format!("jobs must be between 1 and {MAX_JOBS}"))
20+
}
21+
}
22+
23+
/// A modern, friendly build system that uses YAML and Jinja, powered by Ninja.
24+
#[derive(Debug, Parser)]
25+
#[command(author, version, about, long_about = None)]
26+
pub struct Cli {
27+
/// Path to the Netsuke manifest file to use.
28+
#[arg(short, long, value_name = "FILE", default_value = "Netsukefile")]
29+
pub file: PathBuf,
30+
31+
/// Change to this directory before doing anything.
32+
#[arg(short = 'C', long, value_name = "DIR")]
33+
pub directory: Option<PathBuf>,
34+
35+
/// Set the number of parallel build jobs.
36+
#[arg(short, long, value_name = "N", value_parser = parse_jobs)]
37+
pub jobs: Option<usize>,
38+
39+
#[command(subcommand)]
40+
pub command: Option<Commands>,
41+
}
42+
43+
impl Cli {
44+
/// Parse command-line arguments, providing `build` as the default command.
45+
#[must_use]
46+
pub fn parse_with_default() -> Self {
47+
Self::parse().with_default_command()
48+
}
49+
50+
/// Parse the provided arguments, applying the default command when needed.
51+
///
52+
/// # Panics
53+
///
54+
/// Panics if argument parsing fails.
55+
#[must_use]
56+
pub fn parse_from_with_default<I, T>(args: I) -> Self
57+
where
58+
I: IntoIterator<Item = T>,
59+
T: Into<std::ffi::OsString> + Clone,
60+
{
61+
Self::try_parse_from(args)
62+
.unwrap_or_else(|e| panic!("CLI parsing failed: {e}"))
63+
.with_default_command()
64+
}
65+
66+
/// Apply the default command if none was specified.
67+
#[must_use]
68+
fn with_default_command(mut self) -> Self {
69+
if self.command.is_none() {
70+
self.command = Some(Commands::Build {
71+
targets: Vec::new(),
72+
});
73+
}
74+
self
75+
}
76+
}
77+
78+
/// Available top-level commands for Netsuke.
79+
#[derive(Debug, Subcommand, PartialEq, Eq, Clone)]
80+
pub enum Commands {
81+
/// Build specified targets (or default targets if none are given) [default].
82+
Build {
83+
/// A list of specific targets to build.
84+
targets: Vec<String>,
85+
},
86+
87+
/// Remove build artifacts and intermediate files.
88+
Clean,
89+
90+
/// Display the build dependency graph in DOT format for visualization.
91+
Graph,
92+
}

src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! Netsuke core library.
2+
//!
3+
//! Currently this library only exposes the command line interface
4+
//! definitions used by the binary and tests.
5+
6+
pub mod cli;
7+
pub mod runner;

src/main.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
use netsuke::{cli::Cli, runner};
2+
13
fn main() {
2-
// Placeholder entry point for future CLI implementation.
4+
let cli = Cli::parse_with_default();
5+
runner::run(cli);
36
}

src/runner.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//! CLI execution and command dispatch logic.
2+
//!
3+
//! This module keeps [`main`] minimal by providing a single entry point that
4+
//! handles command execution. It currently prints which command was invoked.
5+
6+
use crate::cli::{Cli, Commands};
7+
8+
/// Execute the parsed [`Cli`] commands.
9+
pub fn run(cli: Cli) {
10+
match cli.command.unwrap_or(Commands::Build {
11+
targets: Vec::new(),
12+
}) {
13+
Commands::Build { targets } => {
14+
println!("Building targets: {targets:?}");
15+
}
16+
Commands::Clean => {
17+
println!("Clean requested");
18+
}
19+
Commands::Graph => {
20+
println!("Graph requested");
21+
}
22+
}
23+
}

tests/cli_tests.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//! Unit tests for CLI argument parsing and validation.
2+
//!
3+
//! This module exercises the command-line interface defined in [`netsuke::cli`]
4+
//! using `rstest` for parameterised coverage of success and error scenarios.
5+
use clap::Parser;
6+
use clap::error::ErrorKind;
7+
use netsuke::cli::{Cli, Commands};
8+
use rstest::rstest;
9+
use std::path::PathBuf;
10+
11+
#[rstest]
12+
#[case(vec!["netsuke"], PathBuf::from("Netsukefile"), None, None, Commands::Build { targets: Vec::new() })]
13+
#[case(
14+
vec!["netsuke", "--file", "alt.yml", "-C", "work", "-j", "4", "build", "a", "b"],
15+
PathBuf::from("alt.yml"),
16+
Some(PathBuf::from("work")),
17+
Some(4),
18+
Commands::Build { targets: vec!["a".into(), "b".into()] },
19+
)]
20+
fn parse_cli(
21+
#[case] argv: Vec<&str>,
22+
#[case] file: PathBuf,
23+
#[case] directory: Option<PathBuf>,
24+
#[case] jobs: Option<usize>,
25+
#[case] expected_cmd: Commands,
26+
) {
27+
let cli = Cli::parse_from_with_default(argv.clone());
28+
assert_eq!(cli.file, file);
29+
assert_eq!(cli.directory, directory);
30+
assert_eq!(cli.jobs, jobs);
31+
assert_eq!(cli.command.expect("command should be set"), expected_cmd);
32+
}
33+
34+
#[rstest]
35+
#[case(vec!["netsuke", "unknowncmd"], ErrorKind::InvalidSubcommand)]
36+
#[case(vec!["netsuke", "--file"], ErrorKind::InvalidValue)]
37+
#[case(vec!["netsuke", "-j", "notanumber"], ErrorKind::ValueValidation)]
38+
#[case(vec!["netsuke", "--file", "alt.yml", "-C"], ErrorKind::InvalidValue)]
39+
fn parse_cli_errors(#[case] argv: Vec<&str>, #[case] expected_error: ErrorKind) {
40+
let err = Cli::try_parse_from(argv).expect_err("unexpected success");
41+
assert_eq!(err.kind(), expected_error);
42+
}

tests/cucumber.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
use cucumber::World;
2+
3+
#[derive(Debug, Default, World)]
4+
pub struct CliWorld {
5+
pub cli: Option<netsuke::cli::Cli>,
6+
pub cli_error: Option<String>,
7+
}
8+
9+
mod steps;
10+
11+
#[tokio::main]
12+
async fn main() {
13+
CliWorld::run("tests/features").await;
14+
}

0 commit comments

Comments
 (0)