You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vibed/nbd/tests/integration.rs

2200 lines
73 KiB
Rust

//! 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 <id>` 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 <id> --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 B at the top level (the goal) and A
/// indented below it with `└──` (the prerequisite).
#[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();
// B should appear before A (B is the goal, A is the prerequisite).
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_b < pos_a,
"goal (B) should appear before prerequisite (A)"
);
// A's line should use `└──`.
let line_a = stdout.lines().find(|l| l.contains(&id_a)).unwrap();
assert!(
line_a.contains("└──"),
"prerequisite should use └── connector: {line_a}"
);
}
/// `nbd graph <id>` for ticket B (which depends on A) shows B and its
/// dependency A, 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_b]);
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains(&id_b), "goal should be in subtree");
assert!(stdout.contains(&id_a), "dependency 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 (B depends on A)"
);
assert_eq!(edges[0]["from"].as_str().unwrap(), id_b);
assert_eq!(edges[0]["to"].as_str().unwrap(), id_a);
}
/// `nbd graph <id> --json` returns only nodes and edges reachable from <id>
/// via dependency edges (the goal and its transitive prerequisites).
#[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
// Starting from id_b (the goal): subtree includes id_b and its dependency id_a.
let output = env.run(&["graph", &id_b, "--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_b.as_str()));
assert!(node_ids.contains(&id_a.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 <id> --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 <id> --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}"
);
}
// ── nbd --version tests ───────────────────────────────────────────────────────
/// `nbd --version` exits 0 and stdout contains the semver and a git SHA.
#[test]
fn version_flag_exits_zero_with_semver() {
let tmp = tempfile::tempdir().expect("tempdir");
let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd"))
.arg("--version")
.current_dir(tmp.path())
.output()
.expect("failed to spawn nbd");
assert!(
output.status.success(),
"--version should exit 0, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
// Should contain the package version (semver).
assert!(
stdout.contains("0.1.0"),
"--version should include semver: {stdout}"
);
// Should contain a '+' separator between semver and git SHA.
assert!(
stdout.contains('+'),
"--version should contain '+' separator: {stdout}"
);
}