diff --git a/AGENTS.md b/AGENTS.md index 70ab26b..6b26a18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,10 @@ Run commands from the repository root: - `cargo build`: compile the `isu` binary. - `./scripts/install.sh` or `make install`: build release and copy `isu` into a PATH directory. -- `cargo run -- issue list`: run the CLI locally. -- `cargo run -- issue create --title "Bug" --body "Details"`: create an issue for the detected repo. -- `cargo run -- issue close 1`: mark an issue closed while preserving it in the store. -- `cargo run -- issue delete 1`: delete an issue by numeric ID. +- `cargo run -- list`: run the CLI locally. +- `cargo run -- create --title "Bug" --body "Details"`: create an issue for the detected repo. +- `cargo run -- close 1`: mark an issue closed while preserving it in the store. +- `cargo run -- delete 1`: delete an issue by numeric ID. - `cargo test`: run unit tests. - `cargo fmt`: format Rust source. @@ -31,7 +31,7 @@ Use `--format json` for machine-readable command output. Use standard Rust style and let `cargo fmt` own formatting. Prefer small functions with explicit error context via `anyhow::Context` where filesystem or parsing failures can occur. -Name commands and flags to stay close to Tangled CLI issue commands where practical: `issue create`, `issue list`, `issue show`, `issue close`, `issue delete`, `--repo`, `--title`, `--body`, `--label`, and `--assign`. +Name commands and flags to stay close to Tangled CLI issue commands where practical: `create`, `list`, `show`, `close`, `delete`, `--repo`, `--title`, `--body`, `--label`, and `--assign`. ## Testing Guidelines diff --git a/README.md b/README.md index f851c37..1e89ec6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # isu -`isu` is a Rust CLI issue tracker with a Tangled-shaped issue command API. +`isu` is a Rust CLI issue tracker with Tangled-shaped issue flags. ## Install @@ -19,15 +19,15 @@ ISU_INSTALL_DIR="$HOME/.local/bin" ./scripts/install.sh ## Usage ```sh -isu issue create --title "Fix parser" --body "Handle empty input" --label bug -isu issue list -isu issue show 1 -isu issue close 1 -isu issue delete 1 -isu issue list --format json +isu create --title "Fix parser" --body "Handle empty input" --label bug +isu list +isu show 1 +isu close 1 +isu delete 1 +isu list --format json ``` -The CLI accepts Tangled-like issue flags including `--repo`, `--title`, `--body`, `--label`, `--assign`, `--state`, `--author`, and `--assigned`. `isu issue list` defaults to open issues; use `--state all` to include closed issues. +The CLI accepts Tangled-like issue flags including `--repo`, `--title`, `--body`, `--label`, `--assign`, `--state`, `--author`, and `--assigned`. `isu list` defaults to open issues; use `--state all` to include closed issues. ## Storage and Repository Detection diff --git a/src/main.rs b/src/main.rs index 00733c5..5e934ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,18 +11,6 @@ use std::process::Command as ProcessCommand; #[derive(Debug, Parser)] #[command(name = "isu", version, about = "A local CLI issue tracker")] struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Issue commands - Issue(IssueArgs), -} - -#[derive(Debug, Args)] -struct IssueArgs { /// Config file path override; used by isu as the issue store path #[arg(long, global = true)] config: Option, @@ -48,7 +36,7 @@ struct IssueArgs { no_color: bool, #[command(subcommand)] - command: IssueCommand, + command: Command, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -58,7 +46,7 @@ enum OutputFormat { } #[derive(Debug, Subcommand)] -enum IssueCommand { +enum Command { /// List issues List(ListArgs), @@ -131,18 +119,15 @@ struct CloseArgs { fn main() -> Result<()> { let cli = Cli::parse(); - - match cli.command { - Command::Issue(args) => run_issue(args), - } + run(cli) } -fn run_issue(args: IssueArgs) -> Result<()> { - let context = RepoContext::detect(args.config.as_deref(), args.profile.as_deref())?; +fn run(cli: Cli) -> Result<()> { + let context = RepoContext::detect(cli.config.as_deref(), cli.profile.as_deref())?; let mut store = IssueStore::load(&context.store_path)?; - match args.command { - IssueCommand::Create(create_args) => { + match cli.command { + Command::Create(create_args) => { let issue = store.create(NewIssue { source_uri: None, repo: create_args.repo.or_else(|| context.repo_name.clone()), @@ -155,9 +140,9 @@ fn run_issue(args: IssueArgs) -> Result<()> { }); let issue = issue.clone(); store.save(&context.store_path)?; - print_issues(&[issue], args.format, args.quiet) + print_issues(&[issue], cli.format, cli.quiet) } - IssueCommand::List(list_args) => { + Command::List(list_args) => { let filter = IssueFilter { repo: list_args.repo.or_else(|| context.repo_name.clone()), state: Some(list_args.state.unwrap_or_else(|| "open".to_string())), @@ -166,24 +151,24 @@ fn run_issue(args: IssueArgs) -> Result<()> { assigned: list_args.assigned, }; let issues = store.list(&filter).into_iter().cloned().collect::>(); - print_issues(&issues, args.format, args.quiet) + print_issues(&issues, cli.format, cli.quiet) } - IssueCommand::Show(show_args) => { + Command::Show(show_args) => { let id = parse_issue_id(&show_args.id)?; let issue = store.get(id)?.clone(); - print_issue_detail(&issue, args.format, args.quiet) + print_issue_detail(&issue, cli.format, cli.quiet) } - IssueCommand::Close(close_args) => { + Command::Close(close_args) => { let id = parse_issue_id(&close_args.id)?; let issue = store.close(id)?.clone(); store.save(&context.store_path)?; - print_issue_detail(&issue, args.format, args.quiet) + print_issue_detail(&issue, cli.format, cli.quiet) } - IssueCommand::Delete(delete_args) => { + Command::Delete(delete_args) => { let id = parse_issue_id(&delete_args.id)?; let issue = store.delete(id)?; store.save(&context.store_path)?; - print_deleted(&issue, args.format, args.quiet) + print_deleted(&issue, cli.format, cli.quiet) } } } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..0f8c743 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,66 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn isu() -> Command { + Command::new(env!("CARGO_BIN_EXE_isu")) +} + +fn temp_store() -> PathBuf { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time is before UNIX epoch") + .as_nanos(); + std::env::temp_dir().join(format!("isu-cli-{}-{now}.json", std::process::id())) +} + +#[test] +fn accepts_top_level_issue_commands() { + let store = temp_store(); + + let create_output = isu() + .args([ + "create", + "--config", + store.to_str().expect("store path is valid UTF-8"), + "--title", + "Top level command", + ]) + .output() + .expect("failed to run isu create"); + assert!( + create_output.status.success(), + "create failed: {}", + String::from_utf8_lossy(&create_output.stderr) + ); + + let list_output = isu() + .args([ + "list", + "--config", + store.to_str().expect("store path is valid UTF-8"), + "--format", + "json", + ]) + .output() + .expect("failed to run isu list"); + assert!( + list_output.status.success(), + "list failed: {}", + String::from_utf8_lossy(&list_output.stderr) + ); + assert!(String::from_utf8_lossy(&list_output.stdout).contains("Top level command")); + + let _ = fs::remove_file(store); +} + +#[test] +fn rejects_issue_command_wrapper() { + let output = isu() + .args(["issue", "list"]) + .output() + .expect("failed to run removed wrapper command"); + + assert!(!output.status.success()); +}