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.
2402 lines
80 KiB
Rust
2402 lines
80 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}"
|
|
);
|
|
}
|
|
|
|
// ── Scoped next / ready tests ─────────────────────────────────────────────────
|
|
|
|
/// `nbd next <id>` returns the highest-priority ready dep within the subtree,
|
|
/// not the scoping ticket itself and not any unrelated ticket.
|
|
///
|
|
/// Graph: P → [A, B], A → [C]. C is done. So:
|
|
/// - A is ready (C is done)
|
|
/// - B is ready (no deps)
|
|
/// - P is blocked (A and B not done)
|
|
/// `nbd next P` with A at priority 8 and B at priority 3 should return A.
|
|
#[test]
|
|
fn test_next_scoped_by_id() {
|
|
let env = TestEnv::new();
|
|
|
|
// Create leaf ticket C (done).
|
|
let c_id = env.create(&["--title", "C", "--priority", "5", "--type", "task"]);
|
|
env.run(&["update", &c_id, "--status", "done"]);
|
|
|
|
// Create A (depends on C, priority 8).
|
|
let a_id = env.create(&[
|
|
"--title",
|
|
"A",
|
|
"--priority",
|
|
"8",
|
|
"--type",
|
|
"task",
|
|
"--deps",
|
|
&c_id,
|
|
]);
|
|
|
|
// Create B (no deps, priority 3).
|
|
let b_id = env.create(&["--title", "B", "--priority", "3", "--type", "task"]);
|
|
|
|
// Create unrelated ticket U that should never appear.
|
|
env.create(&["--title", "Unrelated", "--priority", "10", "--type", "task"]);
|
|
|
|
// Create P (project, depends on A and B).
|
|
let deps = format!("{a_id},{b_id}");
|
|
let p_id = env.create(&[
|
|
"--title",
|
|
"P",
|
|
"--priority",
|
|
"5",
|
|
"--type",
|
|
"project",
|
|
"--deps",
|
|
&deps,
|
|
]);
|
|
|
|
// `nbd next P --json` should return A (highest-priority ready dep of P).
|
|
let output = env.run(&["next", &p_id, "--json"]);
|
|
assert!(
|
|
output.status.success(),
|
|
"next scoped 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("--json output should be valid JSON");
|
|
|
|
let next_id = parsed["next"]["id"]
|
|
.as_str()
|
|
.expect("next should be non-null");
|
|
assert_eq!(
|
|
next_id, a_id,
|
|
"next scoped to P should return A, got {next_id}"
|
|
);
|
|
|
|
// P itself and Unrelated must not appear.
|
|
assert_ne!(next_id, p_id, "scoping ticket must not be returned");
|
|
}
|
|
|
|
/// `nbd ready <id>` returns all ready deps within the subtree of `<id>`.
|
|
///
|
|
/// Graph: P → [A, B], A → [C]. C is done. B has no deps.
|
|
/// - A is ready (C done)
|
|
/// - B is ready (no deps)
|
|
/// `nbd ready P` should return exactly [A, B].
|
|
#[test]
|
|
fn test_ready_scoped_by_id() {
|
|
let env = TestEnv::new();
|
|
|
|
let c_id = env.create(&["--title", "C", "--priority", "5"]);
|
|
env.run(&["update", &c_id, "--status", "done"]);
|
|
|
|
let a_id = env.create(&["--title", "A", "--priority", "7", "--deps", &c_id]);
|
|
let b_id = env.create(&["--title", "B", "--priority", "4"]);
|
|
|
|
// Unrelated ticket with high priority — must not appear.
|
|
env.create(&["--title", "Unrelated", "--priority", "10"]);
|
|
|
|
let deps = format!("{a_id},{b_id}");
|
|
let p_id = env.create(&["--title", "P", "--type", "project", "--deps", &deps]);
|
|
|
|
let output = env.run(&["ready", &p_id, "--json"]);
|
|
assert!(
|
|
output.status.success(),
|
|
"ready scoped 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("--json output should be valid JSON");
|
|
|
|
let arr = parsed
|
|
.as_array()
|
|
.expect("ready --json should return an array");
|
|
let ids: Vec<&str> = arr.iter().map(|v| v["id"].as_str().unwrap()).collect();
|
|
|
|
assert!(
|
|
ids.contains(&a_id.as_str()),
|
|
"A should be in scoped ready list"
|
|
);
|
|
assert!(
|
|
ids.contains(&b_id.as_str()),
|
|
"B should be in scoped ready list"
|
|
);
|
|
assert!(
|
|
!ids.contains(&p_id.as_str()),
|
|
"scoping ticket P must not appear in results"
|
|
);
|
|
// Unrelated should not appear.
|
|
assert_eq!(
|
|
ids.len(),
|
|
2,
|
|
"exactly A and B should be ready in subtree of P, got: {ids:?}"
|
|
);
|
|
}
|
|
|
|
/// `nbd next <id>` returns `null` when all deps of the scoping ticket are done.
|
|
#[test]
|
|
fn test_next_scoped_no_ready() {
|
|
let env = TestEnv::new();
|
|
|
|
let a_id = env.create(&["--title", "A", "--priority", "8"]);
|
|
let b_id = env.create(&["--title", "B", "--priority", "5"]);
|
|
env.run(&["update", &a_id, "--status", "done"]);
|
|
env.run(&["update", &b_id, "--status", "done"]);
|
|
|
|
let deps = format!("{a_id},{b_id}");
|
|
let p_id = env.create(&["--title", "P", "--type", "project", "--deps", &deps]);
|
|
|
|
let output = env.run(&["next", &p_id, "--json"]);
|
|
assert!(
|
|
output.status.success(),
|
|
"next scoped (no 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("--json output should be valid JSON");
|
|
|
|
assert!(
|
|
parsed["next"].is_null(),
|
|
"next should be null when all deps are done, got: {}",
|
|
parsed["next"]
|
|
);
|
|
}
|
|
|
|
/// `nbd ready <id> --filter` narrows within the scoped subtree.
|
|
///
|
|
/// Graph: P → [A (bug), B (task)]. Both ready.
|
|
/// `nbd ready P --filter type=bug` should return only A.
|
|
#[test]
|
|
fn test_ready_scoped_with_filter() {
|
|
let env = TestEnv::new();
|
|
|
|
let a_id = env.create(&["--title", "A", "--priority", "6", "--type", "bug"]);
|
|
let b_id = env.create(&["--title", "B", "--priority", "6", "--type", "task"]);
|
|
|
|
// Unrelated bug — must not appear even though it matches the filter.
|
|
env.create(&[
|
|
"--title",
|
|
"Unrelated bug",
|
|
"--priority",
|
|
"9",
|
|
"--type",
|
|
"bug",
|
|
]);
|
|
|
|
let deps = format!("{a_id},{b_id}");
|
|
let p_id = env.create(&["--title", "P", "--type", "project", "--deps", &deps]);
|
|
|
|
let output = env.run(&["ready", &p_id, "--filter", "type=bug", "--json"]);
|
|
assert!(
|
|
output.status.success(),
|
|
"ready scoped+filtered 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("--json output should be valid JSON");
|
|
|
|
let arr = parsed
|
|
.as_array()
|
|
.expect("ready --json should return an array");
|
|
let ids: Vec<&str> = arr.iter().map(|v| v["id"].as_str().unwrap()).collect();
|
|
|
|
assert_eq!(ids.len(), 1, "only A (bug) should match; got: {ids:?}");
|
|
assert_eq!(ids[0], a_id, "matched ticket should be A");
|
|
}
|