//! Integration tests for `nbd`. //! //! Tests full command flows (create → read → list → update) against a real //! temporary directory and verifies that directory traversal correctly locates //! `.nbd/` when the binary is run from a nested subdirectory. use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; // ── Test environment helper ─────────────────────────────────────────────────── /// A temporary project environment with `.nbd/tickets/` already initialised. struct TestEnv { /// Keep `TempDir` alive — dropping it would delete the directory. _tmp: TempDir, /// The project root (the directory that contains `.nbd/`). pub root: PathBuf, } impl TestEnv { /// Create a fresh temporary environment with `.nbd/tickets/` ready. fn new() -> Self { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let root = tmp.path().to_path_buf(); fs::create_dir_all(root.join(".nbd").join("tickets")) .expect("failed to create .nbd/tickets/"); TestEnv { _tmp: tmp, root } } /// Spawn `nbd` with `args`, using `dir` as the working directory. fn run_from(&self, dir: &Path, args: &[&str]) -> std::process::Output { std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) .args(args) .current_dir(dir) .output() .expect("failed to spawn nbd") } /// Spawn `nbd` with `args` from the project root. fn run(&self, args: &[&str]) -> std::process::Output { self.run_from(&self.root, args) } /// Run `nbd create --json` with the given extra arguments and return the /// ticket ID extracted from JSON stdout. fn create(&self, extra_args: &[&str]) -> String { let mut args = vec!["create"]; args.extend_from_slice(extra_args); args.push("--json"); let output = self.run(&args); assert!( output.status.success(), "create failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("create --json output should be valid JSON"); parsed["id"] .as_str() .expect("create --json output should have 'id' field") .to_string() } } // ── Tests ───────────────────────────────────────────────────────────────────── /// `create` then `read` produces a ticket with the expected field values. #[test] fn create_and_read_roundtrip() { let env = TestEnv::new(); let id = env.create(&[ "--title", "Fix login bug", "--body", "Users cannot log in with email addresses containing +", "--priority", "8", "--type", "bug", ]); let read = env.run(&["read", &id]); assert!( read.status.success(), "read failed: {}", String::from_utf8_lossy(&read.stderr) ); let stdout = String::from_utf8(read.stdout).unwrap(); assert!(stdout.contains(&id), "output should contain the ID"); assert!( stdout.contains("Fix login bug"), "output should contain the title" ); assert!( stdout.contains("Users cannot log in"), "output should contain the body" ); assert!(stdout.contains('8'), "output should contain the priority"); assert!(stdout.contains("bug"), "output should contain the type"); } /// `list` displays all previously created tickets. #[test] fn list_shows_created_tickets() { let env = TestEnv::new(); env.create(&["--title", "First ticket"]); env.create(&["--title", "Second ticket"]); let list = env.run(&["list"]); assert!(list.status.success()); let stdout = String::from_utf8(list.stdout).unwrap(); assert!( stdout.contains("First ticket"), "list should contain first ticket" ); assert!( stdout.contains("Second ticket"), "list should contain second ticket" ); } /// `update` changes only the specified fields; others are preserved. #[test] fn update_merges_correctly() { let env = TestEnv::new(); let id = env.create(&[ "--title", "Original title", "--priority", "5", "--type", "task", ]); // Update only the status; title and priority should be unchanged. let update = env.run(&["update", &id, "--status", "in_progress"]); assert!( update.status.success(), "update failed: {}", String::from_utf8_lossy(&update.stderr) ); let read = env.run(&["read", &id]); let stdout = String::from_utf8(read.stdout).unwrap(); assert!( stdout.contains("in_progress"), "status should be updated to in_progress" ); assert!( stdout.contains("Original title"), "title should be unchanged" ); assert!(stdout.contains('5'), "priority should be unchanged"); } /// `read` works when executed from a nested subdirectory (traversal test). #[test] fn traversal_from_subdir() { let env = TestEnv::new(); let id = env.create(&["--title", "Traversal test"]); // Create a nested subdirectory two levels deep. let subdir = env.root.join("sub").join("dir"); fs::create_dir_all(&subdir).unwrap(); let read = env.run_from(&subdir, &["read", &id]); assert!( read.status.success(), "traversal failed: {}", String::from_utf8_lossy(&read.stderr) ); let stdout = String::from_utf8(read.stdout).unwrap(); assert!( stdout.contains("Traversal test"), "output should contain the title" ); } /// `read` with an unknown ID exits non-zero and mentions the ID in stderr. #[test] fn error_on_unknown_id() { let env = TestEnv::new(); let result = env.run(&["read", "ffffff"]); assert!( !result.status.success(), "reading an unknown ID should exit non-zero" ); let stderr = String::from_utf8(result.stderr).unwrap(); assert!( stderr.contains("ffffff"), "error message should mention the ticket ID, got: {stderr}" ); } /// `create --json` outputs valid JSON containing the correct field values. #[test] fn create_with_json_flag() { let env = TestEnv::new(); let output = env.run(&[ "create", "--title", "JSON test", "--priority", "7", "--json", ]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("--json output should be valid JSON"); assert_eq!(parsed["title"], "JSON test"); assert_eq!(parsed["priority"], 7); } /// `list --json` outputs a valid JSON array with one object per ticket. #[test] fn list_with_json_flag() { let env = TestEnv::new(); env.create(&["--title", "First"]); env.create(&["--title", "Second"]); let output = env.run(&["list", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("--json list output should be valid JSON"); assert!(parsed.is_array(), "output should be a JSON array"); assert_eq!( parsed.as_array().unwrap().len(), 2, "array should contain exactly two tickets" ); } /// `create` writes a JSON file that does NOT contain an `"id"` key. #[test] fn created_file_omits_id_field() { let env = TestEnv::new(); let id = env.create(&["--title", "ID key check"]); // Read the raw file bytes and check the JSON. let ticket_file = env .root .join(".nbd") .join("tickets") .join(format!("{id}.json")); let contents = fs::read_to_string(&ticket_file).expect("ticket file should exist"); let parsed: serde_json::Value = serde_json::from_str(&contents).expect("ticket file should be valid JSON"); assert!( parsed.get("id").is_none(), "written ticket file must not contain an 'id' key, got: {contents}" ); } /// `read --json` outputs the correct id even though the file has no `id` field. #[test] fn read_json_contains_correct_id() { let env = TestEnv::new(); let id = env.create(&["--title", "ID injection check"]); let output = env.run(&["read", &id, "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("--json read output should be valid JSON"); assert_eq!( parsed["id"], id, "read --json output must contain the correct id" ); } /// `migrate` re-writes old-format files that contain a stale `"id"` key. #[test] fn migrate_rewrites_old_format_files() { let env = TestEnv::new(); // Manually write an old-format ticket file with "id" in the JSON body. let old_json = r#"{"id":"abcdef","title":"Old format ticket","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; let ticket_file = env.root.join(".nbd").join("tickets").join("abcdef.json"); fs::write(&ticket_file, old_json).unwrap(); let output = env.run(&["migrate"]); assert!( output.status.success(), "migrate failed: {}", String::from_utf8_lossy(&output.stderr) ); // Verify the file no longer contains the "id" key. let contents = fs::read_to_string(&ticket_file).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap(); assert!( parsed.get("id").is_none(), "migrated file must not contain 'id', got: {contents}" ); } /// `migrate --dry-run` does not modify files. #[test] fn migrate_dry_run_does_not_write() { let env = TestEnv::new(); let old_json = r#"{"id":"112233","title":"Dry run test","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; let ticket_file = env.root.join(".nbd").join("tickets").join("112233.json"); fs::write(&ticket_file, old_json).unwrap(); let output = env.run(&["migrate", "--dry-run"]); assert!(output.status.success()); // File must remain unchanged. let contents = fs::read_to_string(&ticket_file).unwrap(); assert_eq!(contents, old_json, "dry-run must not modify files"); } /// `migrate` exits zero even when some ticket files cannot be parsed. #[test] fn migrate_exits_zero_on_parse_errors() { let env = TestEnv::new(); let bad_json = b"{ not valid json at all }"; let ticket_file = env.root.join(".nbd").join("tickets").join("badbad.json"); fs::write(&ticket_file, bad_json).unwrap(); let output = env.run(&["migrate"]); assert!( output.status.success(), "migrate should exit zero even with parse errors" ); // File must remain unchanged. let contents = fs::read(&ticket_file).unwrap(); assert_eq!( contents.as_slice(), bad_json, "errored file must be left unchanged" ); } /// `migrate --json` outputs valid JSON with the expected keys. #[test] fn migrate_with_json_flag() { let env = TestEnv::new(); // Write one old-format and one valid ticket. env.create(&["--title", "Normal ticket"]); let old_json = r#"{"id":"xxyyzz","title":"Legacy","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; let ticket_file = env.root.join(".nbd").join("tickets").join("xxyyzz.json"); fs::write(&ticket_file, old_json).unwrap(); let output = env.run(&["migrate", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("--json output should be valid JSON"); assert!( parsed.get("updated").is_some(), "JSON should have 'updated' key" ); assert!( parsed.get("already_current").is_some(), "JSON should have 'already_current' key" ); assert!( parsed.get("errors").is_some(), "JSON should have 'errors' key" ); assert_eq!(parsed["updated"], 1, "one file should be migrated"); assert_eq!(parsed["already_current"], 1, "one file should be current"); } /// `read` accepts a unique prefix instead of a full 6-char ID. #[test] fn read_with_prefix() { let env = TestEnv::new(); let id = env.create(&["--title", "Prefix read test"]); // Use a 3-char prefix. let prefix = &id[..3]; let read = env.run(&["read", prefix]); assert!( read.status.success(), "read with prefix failed: {}", String::from_utf8_lossy(&read.stderr) ); let stdout = String::from_utf8(read.stdout).unwrap(); assert!( stdout.contains("Prefix read test"), "output should contain the title" ); } /// `update` accepts a unique prefix instead of a full 6-char ID. #[test] fn update_with_prefix() { let env = TestEnv::new(); let id = env.create(&["--title", "Prefix update test"]); let prefix = &id[..3]; let update = env.run(&["update", prefix, "--status", "done"]); assert!( update.status.success(), "update with prefix failed: {}", String::from_utf8_lossy(&update.stderr) ); let read = env.run(&["read", &id, "--json"]); let stdout = String::from_utf8(read.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(parsed["status"], "done", "status should be updated to done"); } /// An ambiguous prefix exits non-zero with an informative message. #[test] fn ambiguous_prefix_exits_nonzero() { let env = TestEnv::new(); // We can't reliably generate two IDs that share a 3-char prefix, but we // can manually write two ticket files whose names share a prefix. let ticket_dir = env.root.join(".nbd").join("tickets"); let json = r#"{"title":"A","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; fs::write(ticket_dir.join("ff0001.json"), json).unwrap(); fs::write(ticket_dir.join("ff0002.json"), json).unwrap(); let result = env.run(&["read", "ff0"]); assert!( !result.status.success(), "ambiguous prefix should exit non-zero" ); let stderr = String::from_utf8(result.stderr).unwrap(); assert!( stderr.contains("ambiguous"), "error should say ambiguous, got: {stderr}" ); } /// `nbd init` creates `.nbd/tickets/` in a fresh directory. #[test] fn init_creates_tickets_dir() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let root = tmp.path(); let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) .args(["init"]) .current_dir(root) .output() .expect("failed to spawn nbd"); assert!( output.status.success(), "init failed: {}", String::from_utf8_lossy(&output.stderr) ); assert!( root.join(".nbd").join("tickets").is_dir(), ".nbd/tickets/ should be created" ); } /// `nbd init` is idempotent — running it twice succeeds. #[test] fn init_is_idempotent() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let root = tmp.path(); for _ in 0..2 { let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) .args(["init"]) .current_dir(root) .output() .expect("failed to spawn nbd"); assert!( output.status.success(), "init failed on repeat: {}", String::from_utf8_lossy(&output.stderr) ); } assert!(root.join(".nbd").join("tickets").is_dir()); } /// `nbd init --json` outputs valid JSON with a `root` field. #[test] fn init_with_json_flag() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let root = tmp.path(); let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) .args(["init", "--json"]) .current_dir(root) .output() .expect("failed to spawn nbd"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("--json output should be valid JSON"); assert!( parsed.get("root").is_some(), "JSON should have 'root' field, got: {stdout}" ); } /// `nbd ready` returns only unblocked, non-done tickets. #[test] fn ready_returns_unblocked_tickets() { let env = TestEnv::new(); // A: no deps, todo → ready let a = env.create(&["--title", "Ticket A"]); // B: depends on A, todo → not ready env.run(&["create", "--title", "Ticket B", "--deps", &a, "--json"]); // C: no deps, done → not ready (already done) let c = env.create(&["--title", "Ticket C"]); env.run(&["update", &c, "--status", "done"]); let output = env.run(&["ready", "--json"]); assert!( output.status.success(), "ready failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); let arr = parsed.as_array().expect("should be array"); assert_eq!(arr.len(), 1, "only A should be ready"); assert_eq!(arr[0]["title"], "Ticket A"); } /// `nbd ready` includes ticket B after its dependency A is marked done. #[test] fn ready_updates_after_dep_done() { let env = TestEnv::new(); let a = env.create(&["--title", "Dep A"]); env.run(&["create", "--title", "Blocked B", "--deps", &a, "--json"]); // Before marking A done: only A is ready. let before = env.run(&["ready", "--json"]); let before_str = String::from_utf8(before.stdout).unwrap(); let before_parsed: serde_json::Value = serde_json::from_str(&before_str).unwrap(); assert_eq!(before_parsed.as_array().unwrap().len(), 1); // Mark A done. env.run(&["update", &a, "--status", "done"]); // Now B is ready. let after = env.run(&["ready", "--json"]); let after_str = String::from_utf8(after.stdout).unwrap(); let after_parsed: serde_json::Value = serde_json::from_str(&after_str).unwrap(); let arr = after_parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only B should be ready now"); assert_eq!(arr[0]["title"], "Blocked B"); } /// `nbd ready` returns an empty array when no tickets are actionable. #[test] fn ready_empty_when_all_done() { let env = TestEnv::new(); let id = env.create(&["--title", "Finished"]); env.run(&["update", &id, "--status", "done"]); let output = env.run(&["ready", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!( parsed.as_array().unwrap().len(), 0, "no ready tickets when all are done" ); } /// `update --deps` replaces the dependency list. #[test] fn update_deps_replaces_list() { let env = TestEnv::new(); // Create two tickets to use as deps. let dep1 = env.create(&["--title", "Dep one"]); let dep2 = env.create(&["--title", "Dep two"]); let main_id = env.create(&["--title", "Main ticket"]); // Add dep1 as a dependency. let update = env.run(&["update", &main_id, "--deps", &dep1]); assert!( update.status.success(), "update with deps failed: {}", String::from_utf8_lossy(&update.stderr) ); // Replace with dep2. let update2 = env.run(&["update", &main_id, "--deps", &dep2]); assert!(update2.status.success()); let read = env.run(&["read", &main_id, "--json"]); let stdout = String::from_utf8(read.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let deps = parsed["dependencies"].as_array().unwrap(); assert_eq!(deps.len(), 1, "should have exactly one dependency"); assert_eq!(deps[0], dep2, "dependency should be dep2"); } // ── --filter tests ──────────────────────────────────────────────────────────── /// `nbd list --filter type=bug` shows only bug tickets. #[test] fn list_filter_by_type() { let env = TestEnv::new(); env.create(&["--title", "Bug ticket", "--type", "bug"]); env.create(&["--title", "Task ticket", "--type", "task"]); let output = env.run(&["list", "--filter", "type=bug", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the bug ticket should appear"); assert_eq!(arr[0]["ticket_type"], "bug"); } /// `nbd list --filter status=in_progress` shows only in_progress tickets. #[test] fn list_filter_by_status() { let env = TestEnv::new(); let id = env.create(&["--title", "Active ticket"]); env.create(&["--title", "Todo ticket"]); env.run(&["update", &id, "--status", "in_progress"]); let output = env.run(&["list", "--filter", "status=in_progress", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only in_progress ticket should appear"); assert_eq!(arr[0]["status"], "in_progress"); } /// Same key repeated is ORed: `--filter status=todo --filter status=in_progress` /// shows both todo and in_progress tickets. #[test] fn list_filter_same_key_ored() { let env = TestEnv::new(); let id = env.create(&["--title", "Active"]); env.create(&["--title", "Todo"]); let done_id = env.create(&["--title", "Done"]); env.run(&["update", &id, "--status", "in_progress"]); env.run(&["update", &done_id, "--status", "done"]); let output = env.run(&[ "list", "--filter", "status=todo", "--filter", "status=in_progress", "--json", ]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!( arr.len(), 2, "todo and in_progress should appear; done excluded" ); let statuses: Vec<&str> = arr.iter().map(|v| v["status"].as_str().unwrap()).collect(); assert!(statuses.contains(&"todo"), "todo should be in results"); assert!( statuses.contains(&"in_progress"), "in_progress should be in results" ); } /// Different keys are ANDed: `--filter type=bug --filter status=todo` shows /// only bug tickets with status todo. #[test] fn list_filter_different_keys_anded() { let env = TestEnv::new(); env.create(&["--title", "Bug todo", "--type", "bug"]); let bug_active = env.create(&["--title", "Bug active", "--type", "bug"]); env.create(&["--title", "Task todo", "--type", "task"]); env.run(&["update", &bug_active, "--status", "in_progress"]); let output = env.run(&[ "list", "--filter", "type=bug", "--filter", "status=todo", "--json", ]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only bug+todo should appear"); assert_eq!(arr[0]["title"], "Bug todo"); } /// `nbd list --filter title=*login*` matches by glob on the title field. #[test] fn list_filter_title_glob() { let env = TestEnv::new(); env.create(&["--title", "Fix login button"]); env.create(&["--title", "Add rate limiting"]); let output = env.run(&["list", "--filter", "title=*login*", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the login ticket should match"); assert_eq!(arr[0]["title"], "Fix login button"); } /// `nbd list --filter status=*` wildcard matches all statuses. #[test] fn list_filter_status_wildcard() { let env = TestEnv::new(); let id = env.create(&["--title", "A"]); env.create(&["--title", "B"]); env.run(&["update", &id, "--status", "done"]); let output = env.run(&["list", "--filter", "status=*", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 2, "wildcard should match all statuses"); } /// `nbd list --filter badformat` (no `=`) exits non-zero. #[test] fn list_filter_bad_format_exits_nonzero() { let env = TestEnv::new(); let output = env.run(&["list", "--filter", "badformat"]); assert!( !output.status.success(), "missing '=' in filter should exit non-zero" ); } /// `nbd list --filter unknown=foo` (unknown key) exits non-zero. #[test] fn list_filter_unknown_key_exits_nonzero() { let env = TestEnv::new(); let output = env.run(&["list", "--filter", "colour=red"]); assert!( !output.status.success(), "unknown filter key should exit non-zero" ); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( stderr.contains("colour"), "error should mention the unknown key, got: {stderr}" ); } /// `nbd ready --filter type=bug` returns only ready bug tickets. #[test] fn ready_filter_by_type() { let env = TestEnv::new(); env.create(&["--title", "Ready bug", "--type", "bug"]); env.create(&["--title", "Ready task", "--type", "task"]); let output = env.run(&["ready", "--filter", "type=bug", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the bug ticket should appear in ready"); assert_eq!(arr[0]["ticket_type"], "bug"); } /// `nbd ready --filter priority=8` returns only ready tickets with priority 8. #[test] fn ready_filter_by_priority() { let env = TestEnv::new(); env.create(&["--title", "High prio", "--priority", "8"]); env.create(&["--title", "Normal prio", "--priority", "5"]); let output = env.run(&["ready", "--filter", "priority=8", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only priority-8 ticket should appear"); assert_eq!(arr[0]["priority"], 8); } /// `nbd migrate --filter status=todo --dry-run --json` reports skipped count /// for tickets that do not match the filter. #[test] fn migrate_filter_skipped_in_json() { let env = TestEnv::new(); env.create(&["--title", "Todo ticket"]); let id2 = env.create(&["--title", "Active ticket"]); env.run(&["update", &id2, "--status", "in_progress"]); let output = env.run(&["migrate", "--filter", "status=todo", "--dry-run", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("migrate --json should produce valid JSON"); assert!( parsed.get("skipped").is_some(), "JSON should have 'skipped' key" ); assert_eq!( parsed["skipped"], 1, "one ticket (in_progress) should be skipped" ); } /// `nbd migrate --json` always includes a `skipped` key (even when zero). #[test] fn migrate_json_always_has_skipped_key() { let env = TestEnv::new(); env.create(&["--title", "Some ticket"]); let output = env.run(&["migrate", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert!( parsed.get("skipped").is_some(), "JSON should always contain 'skipped' key" ); assert_eq!(parsed["skipped"], 0); } /// `nbd ready --filter badformat` exits non-zero. #[test] fn ready_filter_bad_format_exits_nonzero() { let env = TestEnv::new(); let output = env.run(&["ready", "--filter", "noequals"]); assert!( !output.status.success(), "bad filter format on ready should exit non-zero" ); } // ── done-exclusion default behaviour ────────────────────────────────────────── /// `nbd list` without any filter excludes done tickets. #[test] fn list_excludes_done_by_default() { let env = TestEnv::new(); let todo_id = env.create(&["--title", "Todo ticket"]); let done_id = env.create(&["--title", "Done ticket"]); env.run(&["update", &done_id, "--status", "done"]); let output = env.run(&["list", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the todo ticket should appear"); assert_eq!(arr[0]["id"], todo_id); } /// `nbd list --filter status=done` shows only done tickets. #[test] fn list_filter_status_done_shows_only_done() { let env = TestEnv::new(); env.create(&["--title", "Todo ticket"]); let done_id = env.create(&["--title", "Done ticket"]); env.run(&["update", &done_id, "--status", "done"]); let output = env.run(&["list", "--filter", "status=done", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the done ticket should appear"); assert_eq!(arr[0]["id"], done_id); } /// `nbd list --filter status=*` includes done tickets. #[test] fn list_filter_status_wildcard_includes_done() { let env = TestEnv::new(); env.create(&["--title", "Todo ticket"]); let done_id = env.create(&["--title", "Done ticket"]); env.run(&["update", &done_id, "--status", "done"]); let output = env.run(&["list", "--filter", "status=*", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 2, "both tickets should appear with status=*"); } /// `nbd list --filter type=bug` excludes done bug tickets. #[test] fn list_filter_type_still_excludes_done() { let env = TestEnv::new(); env.create(&["--title", "Bug todo", "--type", "bug"]); let done_bug = env.create(&["--title", "Bug done", "--type", "bug"]); env.create(&["--title", "Task todo", "--type", "task"]); env.run(&["update", &done_bug, "--status", "done"]); let output = env.run(&["list", "--filter", "type=bug", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only non-done bug ticket should appear"); assert_eq!(arr[0]["title"], "Bug todo"); } /// `nbd list --filter type=bug --filter status=*` includes all bug tickets. #[test] fn list_filter_type_and_status_wildcard_includes_done_bugs() { let env = TestEnv::new(); env.create(&["--title", "Bug todo", "--type", "bug"]); let done_bug = env.create(&["--title", "Bug done", "--type", "bug"]); env.run(&["update", &done_bug, "--status", "done"]); let output = env.run(&[ "list", "--filter", "type=bug", "--filter", "status=*", "--json", ]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!( arr.len(), 2, "both bug tickets should appear when status=* is set" ); } // ── nbd archive tests ───────────────────────────────────────────────────────── /// `nbd archive ` sets the ticket status to `archived`. #[test] fn archive_sets_status_archived() { let env = TestEnv::new(); let id = env.create(&["--title", "Archive me"]); let output = env.run(&["archive", &id, "--json"]); assert!( output.status.success(), "archive failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); assert_eq!( parsed["status"], "archived", "archive should set status to archived" ); } /// `nbd list` does not show archived tickets by default. #[test] fn list_excludes_archived_by_default() { let env = TestEnv::new(); let todo_id = env.create(&["--title", "Active ticket"]); let archived_id = env.create(&["--title", "Archived ticket"]); env.run(&["archive", &archived_id]); let output = env.run(&["list", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the active ticket should appear"); assert_eq!(arr[0]["id"], todo_id); } /// `nbd list` does not show closed tickets by default. #[test] fn list_excludes_closed_by_default() { let env = TestEnv::new(); let todo_id = env.create(&["--title", "Active ticket"]); let closed_id = env.create(&["--title", "Closed ticket"]); env.run(&["update", &closed_id, "--status", "closed"]); let output = env.run(&["list", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the active ticket should appear"); assert_eq!(arr[0]["id"], todo_id); } /// `nbd list --all` includes archived and closed tickets. #[test] fn list_all_includes_archived_and_closed() { let env = TestEnv::new(); env.create(&["--title", "Active ticket"]); let archived_id = env.create(&["--title", "Archived ticket"]); env.run(&["archive", &archived_id]); let closed_id = env.create(&["--title", "Closed ticket"]); env.run(&["update", &closed_id, "--status", "closed"]); let output = env.run(&["list", "--all", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!( arr.len(), 3, "--all should show all tickets including archived and closed" ); } /// `nbd list --filter status=archived` shows only archived tickets. #[test] fn list_filter_status_archived_shows_only_archived() { let env = TestEnv::new(); env.create(&["--title", "Active ticket"]); let archived_id = env.create(&["--title", "Archived ticket"]); env.run(&["archive", &archived_id]); let output = env.run(&["list", "--filter", "status=archived", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the archived ticket should appear"); assert_eq!(arr[0]["id"], archived_id); } /// `nbd list --filter status=closed` shows only closed tickets. #[test] fn list_filter_status_closed_shows_only_closed() { let env = TestEnv::new(); env.create(&["--title", "Active ticket"]); let closed_id = env.create(&["--title", "Closed ticket"]); env.run(&["update", &closed_id, "--status", "closed"]); let output = env.run(&["list", "--filter", "status=closed", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the closed ticket should appear"); assert_eq!(arr[0]["id"], closed_id); } /// `nbd archive` with a prefix ID resolves and archives the ticket. #[test] fn archive_accepts_prefix() { let env = TestEnv::new(); let id = env.create(&["--title", "Prefix archive"]); let prefix = &id[..3]; let output = env.run(&["archive", prefix, "--json"]); assert!( output.status.success(), "archive with prefix failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(parsed["status"], "archived"); } /// An archived dependency unblocks dependent tickets (archived counts as resolved). #[test] fn archived_dep_unblocks_dependent() { let env = TestEnv::new(); let dep = env.create(&["--title", "Dep ticket"]); env.run(&["create", "--title", "Dependent", "--deps", &dep, "--json"]); // Archive the dependency. env.run(&["archive", &dep]); // The dependent should now be ready. let output = env.run(&["ready", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!( arr.len(), 1, "dependent should be ready after dep is archived" ); assert_eq!(arr[0]["title"], "Dependent"); } /// A closed dependency unblocks dependent tickets (closed counts as resolved). #[test] fn closed_dep_unblocks_dependent() { let env = TestEnv::new(); let dep = env.create(&["--title", "Dep ticket"]); env.run(&["create", "--title", "Dependent", "--deps", &dep, "--json"]); // Close (won't-fix) the dependency. env.run(&["update", &dep, "--status", "closed"]); // The dependent should now be ready. let output = env.run(&["ready", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!( arr.len(), 1, "dependent should be ready after dep is closed" ); assert_eq!(arr[0]["title"], "Dependent"); } /// `nbd ready` does not include archived tickets. #[test] fn ready_excludes_archived_tickets() { let env = TestEnv::new(); let id = env.create(&["--title", "Will be archived"]); env.run(&["archive", &id]); let output = env.run(&["ready", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 0, "archived tickets should not appear in ready"); } /// `nbd ready` does not include closed tickets. #[test] fn ready_excludes_closed_tickets() { let env = TestEnv::new(); let id = env.create(&["--title", "Will be closed"]); env.run(&["update", &id, "--status", "closed"]); let output = env.run(&["ready", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 0, "closed tickets should not appear in ready"); } // ── nbd next tests ──────────────────────────────────────────────────────────── /// `nbd next --json` returns the highest-priority ready ticket. /// /// Setup: A (pri 5, no deps), B (pri 8, dep A → blocked), C (pri 7, no deps). /// Expected: C is the highest-priority ready ticket (B is blocked by A). #[test] fn next_returns_highest_priority_ready() { let env = TestEnv::new(); let a = env.create(&["--title", "Ticket A", "--priority", "5"]); env.run(&[ "create", "--title", "Ticket B", "--priority", "8", "--deps", &a, "--json", ]); env.create(&["--title", "Ticket C", "--priority", "7"]); let output = env.run(&["next", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("next --json should be valid JSON"); assert!(parsed.get("next").is_some(), "should have 'next' key"); let ticket = &parsed["next"]; assert!(!ticket.is_null(), "next should not be null"); assert_eq!( ticket["title"], "Ticket C", "C has the highest priority among ready tickets" ); } /// After marking the dependency done, a previously blocked ticket becomes next. #[test] fn next_updates_after_dep_done() { let env = TestEnv::new(); let a = env.create(&["--title", "Dep A", "--priority", "5"]); env.run(&[ "create", "--title", "Blocked B", "--priority", "8", "--deps", &a, "--json", ]); // Before A is done: A is next (priority 5, the only ready ticket). let before = env.run(&["next", "--json"]); let before_str = String::from_utf8(before.stdout).unwrap(); let before_parsed: serde_json::Value = serde_json::from_str(&before_str).unwrap(); assert_eq!(before_parsed["next"]["title"], "Dep A"); // Mark A done. env.run(&["update", &a, "--status", "done"]); // Now B (priority 8) is next. let after = env.run(&["next", "--json"]); let after_str = String::from_utf8(after.stdout).unwrap(); let after_parsed: serde_json::Value = serde_json::from_str(&after_str).unwrap(); assert_eq!(after_parsed["next"]["title"], "Blocked B"); } /// When all tickets are done, `nbd next --json` returns `{"next": null}`. #[test] fn next_null_when_all_done() { let env = TestEnv::new(); let id = env.create(&["--title", "Only ticket"]); env.run(&["update", &id, "--status", "done"]); let output = env.run(&["next", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert!( parsed["next"].is_null(), "next should be null when all are done" ); } /// `nbd next` without `--json` prints "No ready tickets." and exits 0 when nothing is ready. #[test] fn next_no_json_prints_message_when_empty() { let env = TestEnv::new(); let id = env.create(&["--title", "Finished"]); env.run(&["update", &id, "--status", "done"]); let output = env.run(&["next"]); assert!( output.status.success(), "should exit 0 even with no ready tickets" ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( stdout.contains("No ready tickets."), "should print 'No ready tickets.', got: {stdout}" ); } /// `nbd next --filter type=bug --json` returns the highest-priority ready bug, /// even when a higher-priority non-bug ticket exists. #[test] fn next_filter_by_type() { let env = TestEnv::new(); // Task with priority 9 (highest overall). env.create(&["--title", "High task", "--priority", "9", "--type", "task"]); // Bug with priority 8. env.create(&["--title", "High bug", "--priority", "8", "--type", "bug"]); let output = env.run(&["next", "--filter", "type=bug", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!( parsed["next"]["ticket_type"], "bug", "filter should restrict to bug tickets" ); assert_eq!(parsed["next"]["title"], "High bug"); } /// `nbd next --json` output object always contains an `"id"` field. #[test] fn next_json_includes_id_field() { let env = TestEnv::new(); env.create(&["--title", "Has ID"]); let output = env.run(&["next", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let ticket = &parsed["next"]; assert!(!ticket.is_null(), "should find a ready ticket"); assert!( ticket.get("id").is_some(), "ticket object should contain 'id' field" ); let id = ticket["id"].as_str().unwrap(); assert_eq!(id.len(), 6, "id should be 6 characters"); } // ── --ftype format tests ────────────────────────────────────────────────────── /// `nbd create --ftype md` writes a `.md` file; `nbd read` finds and parses it. #[test] fn create_ftype_md_writes_md_file() { let env = TestEnv::new(); let output = env.run(&[ "create", "--title", "Markdown ticket", "--ftype", "md", "--json", ]); assert!( output.status.success(), "create --ftype md failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); let id = parsed["id"].as_str().unwrap().to_string(); // The .md file must exist; no .json should be created. assert!( env.root .join(".nbd") .join("tickets") .join(format!("{id}.md")) .is_file(), ".md file should exist" ); assert!( !env.root .join(".nbd") .join("tickets") .join(format!("{id}.json")) .is_file(), ".json file should NOT exist" ); // `nbd read` should find the ticket via auto-detection. let read = env.run(&["read", &id, "--json"]); assert!(read.status.success(), "read should succeed for .md ticket"); let read_out: serde_json::Value = serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); assert_eq!(read_out["title"], "Markdown ticket"); } /// `nbd create --ftype toml` writes a `.toml` file readable by `nbd read`. #[test] fn create_ftype_toml_writes_toml_file() { let env = TestEnv::new(); let output = env.run(&[ "create", "--title", "TOML ticket", "--ftype", "toml", "--json", ]); assert!( output.status.success(), "create --ftype toml failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let id = parsed["id"].as_str().unwrap().to_string(); assert!( env.root .join(".nbd") .join("tickets") .join(format!("{id}.toml")) .is_file(), ".toml file should exist" ); let read = env.run(&["read", &id, "--json"]); assert!(read.status.success()); let read_out: serde_json::Value = serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); assert_eq!(read_out["title"], "TOML ticket"); } /// `nbd create --ftype jsonb` writes a `.jsonb` file readable by `nbd read`. #[test] fn create_ftype_jsonb_writes_jsonb_file() { let env = TestEnv::new(); let output = env.run(&[ "create", "--title", "CBOR ticket", "--ftype", "jsonb", "--json", ]); assert!( output.status.success(), "create --ftype jsonb failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let id = parsed["id"].as_str().unwrap().to_string(); assert!( env.root .join(".nbd") .join("tickets") .join(format!("{id}.jsonb")) .is_file(), ".jsonb file should exist" ); let read = env.run(&["read", &id, "--json"]); assert!(read.status.success()); let read_out: serde_json::Value = serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); assert_eq!(read_out["title"], "CBOR ticket"); } /// `nbd list` shows tickets in all formats. #[test] fn list_shows_mixed_format_tickets() { let env = TestEnv::new(); env.create(&["--title", "JSON ticket"]); env.run(&["create", "--title", "MD ticket", "--ftype", "md", "--json"]); env.run(&[ "create", "--title", "TOML ticket", "--ftype", "toml", "--json", ]); let output = env.run(&["list", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 3, "list should show all three tickets"); let titles: Vec<&str> = arr.iter().map(|v| v["title"].as_str().unwrap()).collect(); assert!(titles.contains(&"JSON ticket")); assert!(titles.contains(&"MD ticket")); assert!(titles.contains(&"TOML ticket")); } /// `nbd update --ftype toml` converts a JSON ticket to TOML and removes /// the old `.json` file. #[test] fn update_ftype_converts_format_and_removes_old_file() { let env = TestEnv::new(); let id = env.create(&["--title", "Convert me"]); // Confirm the .json file exists before conversion. let json_path = env .root .join(".nbd") .join("tickets") .join(format!("{id}.json")); assert!(json_path.is_file(), ".json should exist initially"); // Convert to TOML. let update = env.run(&["update", &id, "--ftype", "toml", "--json"]); assert!( update.status.success(), "update --ftype toml failed: {}", String::from_utf8_lossy(&update.stderr) ); // Old .json must be gone; .toml must exist. assert!(!json_path.is_file(), "old .json should be removed"); let toml_path = env .root .join(".nbd") .join("tickets") .join(format!("{id}.toml")); assert!(toml_path.is_file(), "new .toml should exist"); // The ticket should still be readable. let read = env.run(&["read", &id, "--json"]); assert!(read.status.success()); let read_out: serde_json::Value = serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); assert_eq!(read_out["title"], "Convert me"); } /// `nbd update` without `--ftype` preserves the original format. #[test] fn update_without_ftype_preserves_format() { let env = TestEnv::new(); let output = env.run(&[ "create", "--title", "Stay TOML", "--ftype", "toml", "--json", ]); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let id = parsed["id"].as_str().unwrap().to_string(); // Update status only — no --ftype. let update = env.run(&["update", &id, "--status", "in_progress"]); assert!(update.status.success()); // The .toml file should still exist (not converted to .json). let toml_path = env .root .join(".nbd") .join("tickets") .join(format!("{id}.toml")); assert!(toml_path.is_file(), ".toml should still exist after update"); let json_path = env .root .join(".nbd") .join("tickets") .join(format!("{id}.json")); assert!(!json_path.is_file(), ".json should not appear after update"); } /// Markdown ticket body is preserved through a read/write cycle. #[test] fn markdown_body_roundtrip() { let env = TestEnv::new(); let body = "## Overview\n\nThis ticket has a **markdown** body."; let output = env.run(&[ "create", "--title", "MD body test", "--body", body, "--ftype", "md", "--json", ]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let id = parsed["id"].as_str().unwrap().to_string(); let read = env.run(&["read", &id, "--json"]); assert!(read.status.success()); let read_out: serde_json::Value = serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); assert_eq!(read_out["body"].as_str().unwrap(), body); } /// `nbd create --ftype badformat` exits non-zero with a helpful message. #[test] fn create_unknown_ftype_exits_nonzero() { let env = TestEnv::new(); let output = env.run(&["create", "--title", "Bad", "--ftype", "xml"]); assert!( !output.status.success(), "unknown ftype should exit non-zero" ); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( stderr.contains("xml") || stderr.contains("format"), "error should mention format, got: {stderr}" ); } // ── nbd claude-md tests ─────────────────────────────────────────────────────── /// `nbd claude-md` exits zero and stdout is non-empty. #[test] fn claude_md_exits_zero_with_output() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) .args(["claude-md"]) .current_dir(tmp.path()) .output() .expect("failed to spawn nbd"); assert!( output.status.success(), "claude-md should exit zero, stderr: {}", String::from_utf8_lossy(&output.stderr) ); assert!( !output.stdout.is_empty(), "claude-md stdout should be non-empty" ); } /// `nbd claude-md` stdout contains key strings (`nbd`, `--json`). #[test] fn claude_md_contains_key_content() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) .args(["claude-md"]) .current_dir(tmp.path()) .output() .expect("failed to spawn nbd"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( stdout.contains("nbd"), "output should mention 'nbd', got: {stdout}" ); assert!( stdout.contains("--json"), "output should mention '--json', got: {stdout}" ); } /// `nbd claude-md --json` exits zero and stdout is valid JSON with a `snippet` key. #[test] fn claude_md_json_flag_produces_valid_json() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) .args(["claude-md", "--json"]) .current_dir(tmp.path()) .output() .expect("failed to spawn nbd"); assert!( output.status.success(), "claude-md --json should exit zero, stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("claude-md --json output should be valid JSON"); assert!( parsed.get("snippet").is_some(), "JSON should have a 'snippet' key, got: {stdout}" ); let snippet = parsed["snippet"].as_str().unwrap(); assert!(!snippet.is_empty(), "snippet value should be non-empty"); } /// `nbd claude-md` works even from a directory with no `.nbd/` store. #[test] fn claude_md_works_without_nbd_store() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); // Deliberately do NOT create a .nbd/ directory. let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) .args(["claude-md"]) .current_dir(tmp.path()) .output() .expect("failed to spawn nbd"); assert!( output.status.success(), "claude-md should succeed even without a .nbd/ store, stderr: {}", String::from_utf8_lossy(&output.stderr) ); assert!( !output.stdout.is_empty(), "claude-md should produce output even without .nbd/" ); } /// `nbd next --filter priority=99 --json` returns `{"next": null}` when no /// ticket has the requested priority. #[test] fn next_filter_no_match_returns_null() { let env = TestEnv::new(); env.create(&["--title", "Normal ticket", "--priority", "5"]); let output = env.run(&["next", "--filter", "priority=99", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert!( parsed["next"].is_null(), "no ticket has priority 99 so next should be null" ); } // ── graph tests ─────────────────────────────────────────────────────────────── /// `nbd graph` on an empty store exits 0 and produces an empty (or /// whitespace-only) line. #[test] fn graph_empty_store() { let env = TestEnv::new(); let output = env.run(&["graph"]); assert!( output.status.success(), "graph on empty store should exit 0: {}", String::from_utf8_lossy(&output.stderr) ); } /// `nbd graph` with two independent tickets (no deps) shows both IDs with no /// box-drawing indentation on either line. #[test] fn graph_all_no_deps() { let env = TestEnv::new(); let id_a = env.create(&["--title", "Alpha"]); let id_b = env.create(&["--title", "Beta"]); let output = env.run(&["graph"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains(&id_a), "output should contain id_a"); assert!(stdout.contains(&id_b), "output should contain id_b"); // Neither ticket should have a connector (they are both roots). let line_a = stdout.lines().find(|l| l.contains(&id_a)).unwrap(); let line_b = stdout.lines().find(|l| l.contains(&id_b)).unwrap(); assert!( !line_a.contains("├──") && !line_a.contains("└──"), "root ticket should have no connector: {line_a}" ); assert!( !line_b.contains("├──") && !line_b.contains("└──"), "root ticket should have no connector: {line_b}" ); } /// When B depends on A, `nbd graph` shows A at the top level and B indented /// below it with `└──`. #[test] fn graph_chain() { let env = TestEnv::new(); let id_a = env.create(&["--title", "Alpha"]); let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); let output = env.run(&["graph"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); // A should appear before B. let pos_a = stdout.find(&id_a).expect("id_a not found"); let pos_b = stdout.find(&id_b).expect("id_b not found"); assert!(pos_a < pos_b, "root (A) should appear before dependent (B)"); // B's line should use `└──`. let line_b = stdout.lines().find(|l| l.contains(&id_b)).unwrap(); assert!( line_b.contains("└──"), "child should use └── connector: {line_b}" ); } /// `nbd graph ` for ticket A shows A and its dependents but not unrelated /// tickets. #[test] fn graph_single_ticket_subtree() { let env = TestEnv::new(); let id_a = env.create(&["--title", "Alpha"]); let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); let id_c = env.create(&["--title", "Gamma"]); // unrelated let output = env.run(&["graph", &id_a]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains(&id_a), "root should be in subtree"); assert!(stdout.contains(&id_b), "dependent should be in subtree"); assert!( !stdout.contains(&id_c), "unrelated ticket should be absent: {stdout}" ); } /// `nbd graph --json` produces valid JSON with `nodes` and `edges` arrays. #[test] fn graph_json_output() { let env = TestEnv::new(); let id_a = env.create(&["--title", "Alpha"]); let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); let output = env.run(&["graph", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("--json should produce valid JSON"); let nodes = parsed["nodes"] .as_array() .expect("nodes should be an array"); let edges = parsed["edges"] .as_array() .expect("edges should be an array"); let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); assert!( node_ids.contains(&id_a.as_str()), "nodes should include id_a" ); assert!( node_ids.contains(&id_b.as_str()), "nodes should include id_b" ); assert_eq!(edges.len(), 1, "should have exactly one edge (A blocks B)"); assert_eq!(edges[0]["from"].as_str().unwrap(), id_a); assert_eq!(edges[0]["to"].as_str().unwrap(), id_b); } /// `nbd graph --json` returns only nodes and edges reachable from . #[test] fn graph_json_subtree() { let env = TestEnv::new(); let id_a = env.create(&["--title", "Alpha"]); let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); let id_c = env.create(&["--title", "Gamma"]); // unrelated let output = env.run(&["graph", &id_a, "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let nodes = parsed["nodes"].as_array().unwrap(); let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); assert!(node_ids.contains(&id_a.as_str())); assert!(node_ids.contains(&id_b.as_str())); assert!( !node_ids.contains(&id_c.as_str()), "unrelated ticket should be excluded from JSON subtree" ); } /// `nbd graph --filter type=bug` only includes bug tickets and their dependents. #[test] fn graph_filter() { let env = TestEnv::new(); let id_bug = env.create(&["--title", "A bug", "--type", "bug"]); let id_task = env.create(&["--title", "A task", "--type", "task"]); // unrelated let output = env.run(&["graph", "--filter", "type=bug"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains(&id_bug), "bug ticket should be visible"); assert!( !stdout.contains(&id_task), "task ticket should be excluded: {stdout}" ); } /// `nbd graph <3-char-prefix>` resolves to the correct ticket using prefix /// matching. #[test] fn graph_partial_id() { let env = TestEnv::new(); let id_a = env.create(&["--title", "Alpha"]); let prefix = &id_a[..3]; let output = env.run(&["graph", prefix]); assert!( output.status.success(), "graph with prefix should succeed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( stdout.contains(&id_a), "resolved ticket should appear in output" ); } // ── nbd backlog status tests ────────────────────────────────────────────────── /// `nbd create --status backlog` creates a ticket with status `backlog`. #[test] fn create_with_backlog_status() { let env = TestEnv::new(); let output = env.run(&[ "create", "--title", "Deferred task", "--status", "backlog", "--json", ]); assert!( output.status.success(), "create --status backlog failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); assert_eq!(parsed["status"], "backlog", "status should be backlog"); } /// `nbd list` does not show backlog tickets by default. #[test] fn list_excludes_backlog_by_default() { let env = TestEnv::new(); let active_id = env.create(&["--title", "Active ticket"]); env.run(&[ "create", "--title", "Backlog ticket", "--status", "backlog", "--json", ]); let output = env.run(&["list", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the active ticket should appear"); assert_eq!(arr[0]["id"], active_id); } /// `nbd list --all` includes backlog tickets. #[test] fn list_all_includes_backlog() { let env = TestEnv::new(); env.create(&["--title", "Active ticket"]); env.run(&[ "create", "--title", "Backlog ticket", "--status", "backlog", "--json", ]); let output = env.run(&["list", "--all", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!( arr.len(), 2, "--all should show all tickets including backlog" ); } /// `nbd list --filter status=backlog` shows only backlog tickets. #[test] fn list_filter_status_backlog_shows_only_backlog() { let env = TestEnv::new(); env.create(&["--title", "Active ticket"]); let backlog_output = env.run(&[ "create", "--title", "Backlog ticket", "--status", "backlog", "--json", ]); let backlog_stdout = String::from_utf8(backlog_output.stdout).unwrap(); let backlog_parsed: serde_json::Value = serde_json::from_str(&backlog_stdout).unwrap(); let backlog_id = backlog_parsed["id"].as_str().unwrap().to_string(); let output = env.run(&["list", "--filter", "status=backlog", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1, "only the backlog ticket should appear"); assert_eq!(arr[0]["id"], backlog_id); } /// `nbd ready` does not include backlog tickets. #[test] fn ready_excludes_backlog_tickets() { let env = TestEnv::new(); env.run(&[ "create", "--title", "Backlog item", "--status", "backlog", "--json", ]); let output = env.run(&["ready", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 0, "backlog tickets should not appear in ready"); } /// A backlog dependency still blocks a dependent ticket (backlog is NOT resolved). #[test] fn backlog_dep_blocks_dependent() { let env = TestEnv::new(); let dep_output = env.run(&[ "create", "--title", "Backlog dep", "--status", "backlog", "--json", ]); let dep_stdout = String::from_utf8(dep_output.stdout).unwrap(); let dep_parsed: serde_json::Value = serde_json::from_str(&dep_stdout).unwrap(); let dep_id = dep_parsed["id"].as_str().unwrap().to_string(); env.run(&[ "create", "--title", "Dependent", "--deps", &dep_id, "--json", ]); // The dependent should NOT be ready because its dep is backlog (not resolved). let output = env.run(&["ready", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!( arr.len(), 0, "dependent should be blocked when dependency is backlog" ); } /// `nbd next --json` does not return backlog tickets. #[test] fn next_excludes_backlog_tickets() { let env = TestEnv::new(); env.run(&[ "create", "--title", "Backlog item", "--status", "backlog", "--json", ]); let output = env.run(&["next", "--json"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert!( parsed["next"].is_null(), "backlog ticket should not appear as next: {stdout}" ); } // ── update diff output tests ────────────────────────────────────────────────── /// `nbd update --status in_progress` (no `--json`) prints `- status:` and /// `+ status:` lines showing what changed. #[test] fn update_no_json_prints_diff() { let env = TestEnv::new(); let id = env.create(&["--title", "Diff test"]); let output = env.run(&["update", &id, "--status", "in_progress"]); assert!( output.status.success(), "update failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( stdout.contains("- status:"), "should show old status line: {stdout}" ); assert!( stdout.contains("+ status:"), "should show new status line: {stdout}" ); assert!(stdout.contains("todo"), "should show old value: {stdout}"); assert!( stdout.contains("in_progress"), "should show new value: {stdout}" ); } /// `nbd update --json` still prints full JSON (no diff). #[test] fn update_with_json_flag_prints_full_ticket() { let env = TestEnv::new(); let id = env.create(&["--title", "JSON update test"]); let output = env.run(&["update", &id, "--status", "in_progress", "--json"]); assert!( output.status.success(), "update --json failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); // Must be valid JSON containing the updated ticket. let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("--json output should be valid JSON"); assert_eq!(parsed["status"], "in_progress"); assert_eq!(parsed["title"], "JSON update test"); // Should NOT contain diff markers. assert!( !stdout.contains("- status:"), "JSON output should not contain diff markers: {stdout}" ); } /// `nbd update` with no changes prints `(no changes)`. #[test] fn update_no_changes_prints_no_changes() { let env = TestEnv::new(); let id = env.create(&["--title", "Unchanged"]); // Update with no field changes (supply same status). let output = env.run(&["update", &id, "--status", "todo"]); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( stdout.contains("(no changes)"), "should print '(no changes)' when nothing changed: {stdout}" ); }