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.

1699 lines
65 KiB
Rust

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! Unit tests for all `nbd` modules.
//!
//! Each module's behaviour is tested in isolation here. File I/O tests use
//! temporary directories provided by the `tempfile` crate so they leave no
//! state behind.
// ── ticket module ────────────────────────────────────────────────────────────
/// Tests for [`crate::ticket`].
mod ticket {
use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType};
/// `Ticket::new` produces a ticket with the expected id, title, and defaults.
#[test]
fn new_sets_defaults() {
let t = Ticket::new("a3f9c2".to_string(), "Fix login bug".to_string());
assert_eq!(t.id, "a3f9c2");
assert_eq!(t.title, "Fix login bug");
assert_eq!(t.body, "");
assert_eq!(t.priority, 5);
assert_eq!(t.status, Status::Todo);
assert!(t.dependencies.is_empty());
assert_eq!(t.ticket_type, TicketType::Task);
}
/// A `Ticket` serialises to JSON and deserialises back with all fields intact
/// except `id`, which is not included in JSON (it is injected from the filename
/// by the store layer).
#[test]
fn ticket_roundtrip() {
let original = Ticket {
id: "b7d41e".to_string(),
title: "Add rate limiting".to_string(),
body: "Limit to 100 req/s".to_string(),
priority: 8,
status: Status::InProgress,
dependencies: vec!["a3f9c2".to_string()],
ticket_type: TicketType::Feature,
};
let json = serde_json::to_string(&original).expect("serialisation failed");
// id is skipped — the JSON must not contain it.
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
parsed.get("id").is_none(),
"serialised JSON must not contain 'id', got: {json}"
);
let restored: Ticket = serde_json::from_str(&json).expect("deserialisation failed");
// id is populated by the store from the filename, so after a raw JSON
// roundtrip it will be the default empty string.
assert_eq!(
restored.id, "",
"id should be empty after raw JSON roundtrip"
);
assert_eq!(restored.title, original.title);
assert_eq!(restored.body, original.body);
assert_eq!(restored.priority, original.priority);
assert_eq!(restored.status, original.status);
assert_eq!(restored.dependencies, original.dependencies);
assert_eq!(restored.ticket_type, original.ticket_type);
}
/// `Status` variants serialise to the expected lowercase snake_case strings.
#[test]
fn status_serialises_to_snake_case() {
assert_eq!(serde_json::to_string(&Status::Todo).unwrap(), "\"todo\"");
assert_eq!(
serde_json::to_string(&Status::InProgress).unwrap(),
"\"in_progress\""
);
assert_eq!(serde_json::to_string(&Status::Done).unwrap(), "\"done\"");
assert_eq!(
serde_json::to_string(&Status::Closed).unwrap(),
"\"closed\""
);
assert_eq!(
serde_json::to_string(&Status::Archived).unwrap(),
"\"archived\""
);
assert_eq!(
serde_json::to_string(&Status::Backlog).unwrap(),
"\"backlog\""
);
}
/// `Status` deserialises correctly from lowercase snake_case strings.
#[test]
fn status_deserialises_from_snake_case() {
assert_eq!(
serde_json::from_str::<Status>("\"todo\"").unwrap(),
Status::Todo
);
assert_eq!(
serde_json::from_str::<Status>("\"in_progress\"").unwrap(),
Status::InProgress
);
assert_eq!(
serde_json::from_str::<Status>("\"done\"").unwrap(),
Status::Done
);
assert_eq!(
serde_json::from_str::<Status>("\"closed\"").unwrap(),
Status::Closed
);
assert_eq!(
serde_json::from_str::<Status>("\"archived\"").unwrap(),
Status::Archived
);
assert_eq!(
serde_json::from_str::<Status>("\"backlog\"").unwrap(),
Status::Backlog
);
}
/// `TicketType` variants serialise to the expected lowercase strings.
#[test]
fn ticket_type_serialises_to_lowercase() {
assert_eq!(
serde_json::to_string(&TicketType::Project).unwrap(),
"\"project\""
);
assert_eq!(
serde_json::to_string(&TicketType::Feature).unwrap(),
"\"feature\""
);
assert_eq!(
serde_json::to_string(&TicketType::Task).unwrap(),
"\"task\""
);
assert_eq!(serde_json::to_string(&TicketType::Bug).unwrap(), "\"bug\"");
}
/// `TicketType` deserialises correctly from lowercase strings.
#[test]
fn ticket_type_deserialises_from_lowercase() {
assert_eq!(
serde_json::from_str::<TicketType>("\"project\"").unwrap(),
TicketType::Project
);
assert_eq!(
serde_json::from_str::<TicketType>("\"bug\"").unwrap(),
TicketType::Bug
);
}
/// `validate_priority` accepts values 010 and rejects values above 10.
#[test]
fn priority_validation() {
assert!(validate_priority(0).is_ok());
assert!(validate_priority(5).is_ok());
assert!(validate_priority(10).is_ok());
assert!(validate_priority(11).is_err());
assert!(validate_priority(255).is_err());
}
/// `generate_id` returns a 6-character lowercase hex string.
#[test]
fn generated_id_is_six_hex_chars() {
let id = generate_id();
assert_eq!(id.len(), 6, "id length must be 6, got {id:?}");
assert!(
id.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
"id must be lowercase hex, got {id:?}"
);
}
/// Calling `generate_id` multiple times produces distinct values.
#[test]
fn generated_ids_are_unique() {
let ids: Vec<String> = (0..20).map(|_| generate_id()).collect();
let unique: std::collections::HashSet<&String> = ids.iter().collect();
// Allow at most 1 collision across 20 draws from a 16M space.
assert!(
unique.len() >= 19,
"too many collisions among generated IDs: {ids:?}"
);
}
}
// ── store module ──────────────────────────────────────────────────────────────
/// Tests for [`crate::store`].
mod store {
use std::path::Path;
use crate::store::{
ensure_tickets_dir, find_nbd_root_from, list_tickets, read_ticket, resolve_id, ticket_path,
tickets_dir, write_ticket, FileFormat,
};
use crate::ticket::{Status, Ticket, TicketType};
/// Helper: create a temporary directory with `.nbd/tickets/` already set up.
async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
ensure_tickets_dir(&root).await.unwrap();
(tmp, root)
}
/// Writing a ticket and reading it back produces an identical value.
#[tokio::test]
async fn write_and_read_roundtrip() {
let (tmp, root) = setup_store().await;
let ticket = Ticket {
id: "a3f9c2".to_string(),
title: "Fix login bug".to_string(),
body: "Users cannot log in with email addresses containing +".to_string(),
priority: 8,
status: Status::InProgress,
dependencies: vec!["b7d41e".to_string()],
ticket_type: TicketType::Bug,
};
write_ticket(&root, &ticket, FileFormat::Json)
.await
.unwrap();
let restored = read_ticket(&root, "a3f9c2").await.unwrap();
assert_eq!(restored.id, ticket.id);
assert_eq!(restored.title, ticket.title);
assert_eq!(restored.body, ticket.body);
assert_eq!(restored.priority, ticket.priority);
assert_eq!(restored.status, ticket.status);
assert_eq!(restored.dependencies, ticket.dependencies);
assert_eq!(restored.ticket_type, ticket.ticket_type);
drop(tmp);
}
/// `write_ticket` does not include the `id` key in the JSON file.
#[tokio::test]
async fn write_ticket_omits_id_from_json() {
let (tmp, root) = setup_store().await;
let ticket = Ticket::new("c0ffee".to_string(), "Check JSON".to_string());
write_ticket(&root, &ticket, FileFormat::Json)
.await
.unwrap();
let path = ticket_path(&root, "c0ffee", FileFormat::Json);
let contents = tokio::fs::read_to_string(&path).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert!(
parsed.get("id").is_none(),
"written JSON must not contain the 'id' key, got: {contents}"
);
drop(tmp);
}
/// `read_ticket` injects the id from its parameter even when the file has no `id` field.
#[tokio::test]
async fn read_ticket_injects_id_from_parameter() {
let (tmp, root) = setup_store().await;
// Write a JSON file that has no "id" key (the new format).
let json = r#"{"title":"No id field","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let dir = tickets_dir(&root);
tokio::fs::write(dir.join("abcdef.json"), json)
.await
.unwrap();
let ticket = read_ticket(&root, "abcdef").await.unwrap();
assert_eq!(ticket.id, "abcdef");
assert_eq!(ticket.title, "No id field");
drop(tmp);
}
/// `read_ticket` ignores any `id` key present in the JSON body (old format),
/// and instead uses the id passed as the parameter.
#[tokio::test]
async fn read_ticket_ignores_id_in_json_body() {
let (tmp, root) = setup_store().await;
// Simulate an old-format file that still has "id" in the JSON body.
let json = r#"{"id":"wrongid","title":"Old format","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let dir = tickets_dir(&root);
tokio::fs::write(dir.join("aabbcc.json"), json)
.await
.unwrap();
let ticket = read_ticket(&root, "aabbcc").await.unwrap();
assert_eq!(
ticket.id, "aabbcc",
"id should come from the filename parameter, not the JSON body"
);
drop(tmp);
}
/// `list_tickets` injects the correct id from each filename stem.
#[tokio::test]
async fn list_tickets_injects_id_from_filename() {
let (tmp, root) = setup_store().await;
let mut t1 = Ticket::new("id1111".to_string(), "First".to_string());
t1.priority = 7;
let mut t2 = Ticket::new("id2222".to_string(), "Second".to_string());
t2.priority = 3;
write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
let tickets = list_tickets(&root).await.unwrap();
assert_eq!(tickets.len(), 2);
// Sorted highest priority first.
assert_eq!(tickets[0].id, "id1111");
assert_eq!(tickets[1].id, "id2222");
drop(tmp);
}
/// Reading a non-existent ticket produces an error that mentions the ID.
#[tokio::test]
async fn read_missing_ticket_errors() {
let (tmp, root) = setup_store().await;
let result = read_ticket(&root, "ffffff").await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("ffffff"),
"error message should mention the ticket ID, got: {msg}"
);
drop(tmp);
}
/// `list_tickets` returns all written tickets sorted by priority descending.
#[tokio::test]
async fn list_returns_all_sorted_by_priority() {
let (tmp, root) = setup_store().await;
let mut low = Ticket::new("id0001".to_string(), "Low priority".to_string());
low.priority = 2;
let mut high = Ticket::new("id0002".to_string(), "High priority".to_string());
high.priority = 9;
let mut mid = Ticket::new("id0003".to_string(), "Mid priority".to_string());
mid.priority = 5;
write_ticket(&root, &low, FileFormat::Json).await.unwrap();
write_ticket(&root, &high, FileFormat::Json).await.unwrap();
write_ticket(&root, &mid, FileFormat::Json).await.unwrap();
let tickets = list_tickets(&root).await.unwrap();
assert_eq!(tickets.len(), 3);
assert_eq!(
tickets[0].priority, 9,
"first ticket should have highest priority"
);
assert_eq!(tickets[1].priority, 5);
assert_eq!(
tickets[2].priority, 2,
"last ticket should have lowest priority"
);
drop(tmp);
}
/// `resolve_id` returns the full ID when given an exact 6-char match.
#[tokio::test]
async fn resolve_id_exact_match() {
let (tmp, root) = setup_store().await;
let ticket = Ticket::new("a3f9c2".to_string(), "Exact".to_string());
write_ticket(&root, &ticket, FileFormat::Json)
.await
.unwrap();
let resolved = resolve_id(&root, "a3f9c2").await.unwrap();
assert_eq!(resolved, "a3f9c2");
drop(tmp);
}
/// `resolve_id` resolves a unique 3-char prefix to the full ID.
#[tokio::test]
async fn resolve_id_prefix_match() {
let (tmp, root) = setup_store().await;
let ticket = Ticket::new("a3f9c2".to_string(), "Prefix".to_string());
write_ticket(&root, &ticket, FileFormat::Json)
.await
.unwrap();
let resolved = resolve_id(&root, "a3f").await.unwrap();
assert_eq!(resolved, "a3f9c2");
drop(tmp);
}
/// `resolve_id` returns an error for an unknown prefix.
#[tokio::test]
async fn resolve_id_not_found() {
let (tmp, root) = setup_store().await;
let result = resolve_id(&root, "zzz").await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("zzz"),
"error should mention the prefix, got: {msg}"
);
drop(tmp);
}
/// `resolve_id` returns an error listing all matches for an ambiguous prefix.
#[tokio::test]
async fn resolve_id_ambiguous_prefix() {
let (tmp, root) = setup_store().await;
let t1 = Ticket::new("aabbcc".to_string(), "First".to_string());
let t2 = Ticket::new("aaddee".to_string(), "Second".to_string());
write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
let result = resolve_id(&root, "aa").await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("ambiguous"),
"error should say ambiguous, got: {msg}"
);
assert!(
msg.contains("aabbcc"),
"error should list first match, got: {msg}"
);
assert!(
msg.contains("aaddee"),
"error should list second match, got: {msg}"
);
drop(tmp);
}
/// `list_tickets` returns an empty vec when the tickets directory is absent.
#[tokio::test]
async fn list_empty_when_no_tickets_dir() {
let tmp = tempfile::tempdir().unwrap();
// Create `.nbd/` but not `.nbd/tickets/`.
std::fs::create_dir(tmp.path().join(".nbd")).unwrap();
let root = tmp.path().to_path_buf();
let tickets = list_tickets(&root).await.unwrap();
assert!(tickets.is_empty());
drop(tmp);
}
/// `find_nbd_root_from` finds `.nbd/` located in a grandparent directory.
#[test]
fn traversal_finds_nbd_in_grandparent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Place `.nbd/` at the temp root.
std::fs::create_dir(root.join(".nbd")).unwrap();
// Start traversal from a deeply nested subdirectory.
let grandchild = root.join("a").join("b");
std::fs::create_dir_all(&grandchild).unwrap();
let found = find_nbd_root_from(&grandchild).unwrap();
assert_eq!(found, root);
drop(tmp);
}
/// `find_nbd_root_from` returns an error when no `.nbd/` directory exists.
#[test]
fn traversal_errors_when_no_nbd_dir() {
let tmp = tempfile::tempdir().unwrap();
let result = find_nbd_root_from(tmp.path());
assert!(result.is_err());
drop(tmp);
}
/// Writing in TOML format and reading back produces an identical ticket.
#[tokio::test]
async fn write_and_read_roundtrip_toml() {
let (tmp, root) = setup_store().await;
let ticket = Ticket {
id: "aa1122".to_string(),
title: "TOML ticket".to_string(),
body: "Body text for TOML".to_string(),
priority: 7,
status: crate::ticket::Status::InProgress,
dependencies: vec!["bb2233".to_string()],
ticket_type: crate::ticket::TicketType::Feature,
};
write_ticket(&root, &ticket, FileFormat::Toml)
.await
.unwrap();
// The `.toml` file must exist; the `.json` file must not.
assert!(ticket_path(&root, "aa1122", FileFormat::Toml).is_file());
assert!(!ticket_path(&root, "aa1122", FileFormat::Json).is_file());
let restored = read_ticket(&root, "aa1122").await.unwrap();
assert_eq!(restored.id, ticket.id);
assert_eq!(restored.title, ticket.title);
assert_eq!(restored.body, ticket.body);
assert_eq!(restored.priority, ticket.priority);
assert_eq!(restored.status, ticket.status);
assert_eq!(restored.dependencies, ticket.dependencies);
assert_eq!(restored.ticket_type, ticket.ticket_type);
drop(tmp);
}
/// Writing in Markdown format and reading back produces an identical ticket.
#[tokio::test]
async fn write_and_read_roundtrip_markdown() {
let (tmp, root) = setup_store().await;
let ticket = Ticket {
id: "cc3344".to_string(),
title: "Markdown ticket".to_string(),
body: "# Header\n\nBody paragraph.".to_string(),
priority: 6,
status: crate::ticket::Status::Todo,
dependencies: vec![],
ticket_type: crate::ticket::TicketType::Bug,
};
write_ticket(&root, &ticket, FileFormat::Markdown)
.await
.unwrap();
assert!(ticket_path(&root, "cc3344", FileFormat::Markdown).is_file());
assert!(!ticket_path(&root, "cc3344", FileFormat::Json).is_file());
let restored = read_ticket(&root, "cc3344").await.unwrap();
assert_eq!(restored.id, ticket.id);
assert_eq!(restored.title, ticket.title);
assert_eq!(restored.body, ticket.body);
assert_eq!(restored.priority, ticket.priority);
assert_eq!(restored.status, ticket.status);
assert_eq!(restored.dependencies, ticket.dependencies);
assert_eq!(restored.ticket_type, ticket.ticket_type);
drop(tmp);
}
/// Writing in CBOR format and reading back produces an identical ticket.
#[tokio::test]
async fn write_and_read_roundtrip_jsonb() {
let (tmp, root) = setup_store().await;
let ticket = Ticket {
id: "ee5566".to_string(),
title: "CBOR ticket".to_string(),
body: "Binary body".to_string(),
priority: 4,
status: crate::ticket::Status::Done,
dependencies: vec!["ff6677".to_string(), "aa0011".to_string()],
ticket_type: crate::ticket::TicketType::Task,
};
write_ticket(&root, &ticket, FileFormat::Jsonb)
.await
.unwrap();
assert!(ticket_path(&root, "ee5566", FileFormat::Jsonb).is_file());
assert!(!ticket_path(&root, "ee5566", FileFormat::Json).is_file());
let restored = read_ticket(&root, "ee5566").await.unwrap();
assert_eq!(restored.id, ticket.id);
assert_eq!(restored.title, ticket.title);
assert_eq!(restored.body, ticket.body);
assert_eq!(restored.priority, ticket.priority);
assert_eq!(restored.status, ticket.status);
assert_eq!(restored.dependencies, ticket.dependencies);
assert_eq!(restored.ticket_type, ticket.ticket_type);
drop(tmp);
}
/// `ticket_path` returns the correct path for each file format.
#[test]
fn ticket_path_is_correct() {
let root = Path::new("/tmp/project");
assert_eq!(
ticket_path(root, "a3f9c2", FileFormat::Json),
Path::new("/tmp/project/.nbd/tickets/a3f9c2.json")
);
assert_eq!(
ticket_path(root, "a3f9c2", FileFormat::Markdown),
Path::new("/tmp/project/.nbd/tickets/a3f9c2.md")
);
assert_eq!(
ticket_path(root, "a3f9c2", FileFormat::Toml),
Path::new("/tmp/project/.nbd/tickets/a3f9c2.toml")
);
assert_eq!(
ticket_path(root, "a3f9c2", FileFormat::Jsonb),
Path::new("/tmp/project/.nbd/tickets/a3f9c2.jsonb")
);
}
/// `tickets_dir` returns the expected `.nbd/tickets/` path.
#[test]
fn tickets_dir_is_correct() {
let root = Path::new("/tmp/project");
let dir = tickets_dir(root);
assert_eq!(dir, Path::new("/tmp/project/.nbd/tickets"));
}
}
// ── migrate ───────────────────────────────────────────────────────────────────
/// Tests for [`crate::store::migrate_tickets`].
mod migrate {
use crate::filter::TicketFilter;
use crate::store::{
ensure_tickets_dir, migrate_tickets, tickets_dir, write_ticket, FileFormat,
};
use crate::ticket::Ticket;
async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
ensure_tickets_dir(&root).await.unwrap();
(tmp, root)
}
/// `migrate_tickets` rewrites old-format files that contain a stale `"id"` key.
#[tokio::test]
async fn rewrites_old_format_with_id_field() {
let (tmp, root) = setup_store().await;
let dir = tickets_dir(&root);
// Write a file with the legacy "id" key.
let old_json = r#"{"id":"aabbcc","title":"Old","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
tokio::fs::write(dir.join("aabbcc.json"), old_json)
.await
.unwrap();
let report = migrate_tickets(&root, false, &TicketFilter::default())
.await
.unwrap();
assert_eq!(report.updated, 1);
assert_eq!(report.already_current, 0);
assert!(report.errors.is_empty());
// Verify the file no longer contains the "id" key.
let contents = tokio::fs::read_to_string(dir.join("aabbcc.json"))
.await
.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}"
);
drop(tmp);
}
/// `migrate_tickets` on a store with already-current files returns `updated: 0`.
#[tokio::test]
async fn already_current_files_not_rewritten() {
let (tmp, root) = setup_store().await;
let t1 = Ticket::new("id0001".to_string(), "First".to_string());
let t2 = Ticket::new("id0002".to_string(), "Second".to_string());
write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
let report = migrate_tickets(&root, false, &TicketFilter::default())
.await
.unwrap();
assert_eq!(report.updated, 0);
assert_eq!(report.already_current, 2);
assert!(report.errors.is_empty());
drop(tmp);
}
/// `migrate_tickets` with `dry_run: true` does not write files.
#[tokio::test]
async fn dry_run_does_not_write() {
let (tmp, root) = setup_store().await;
let dir = tickets_dir(&root);
let old_json = r#"{"id":"ccddee","title":"DryRun","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
tokio::fs::write(dir.join("ccddee.json"), old_json)
.await
.unwrap();
let report = migrate_tickets(&root, true, &TicketFilter::default())
.await
.unwrap();
assert_eq!(
report.updated, 1,
"dry_run should still count as would-update"
);
assert!(report.errors.is_empty());
// File must remain unchanged.
let contents = tokio::fs::read_to_string(dir.join("ccddee.json"))
.await
.unwrap();
assert_eq!(
contents, old_json,
"dry_run must not modify the file on disk"
);
drop(tmp);
}
/// Invalid JSON files are counted in errors and left untouched.
#[tokio::test]
async fn invalid_json_counted_in_errors() {
let (tmp, root) = setup_store().await;
let dir = tickets_dir(&root);
let bad_json = b"{ this is not valid json }";
tokio::fs::write(dir.join("badbad.json"), bad_json)
.await
.unwrap();
let report = migrate_tickets(&root, false, &TicketFilter::default())
.await
.unwrap();
assert_eq!(report.errors.len(), 1);
assert!(report.errors[0].0.contains("badbad.json"));
// File must remain unchanged.
let contents = tokio::fs::read(&dir.join("badbad.json")).await.unwrap();
assert_eq!(contents.as_slice(), bad_json);
drop(tmp);
}
/// `migrate_tickets` on an empty store returns an empty report.
#[tokio::test]
async fn empty_store_returns_empty_report() {
let (tmp, root) = setup_store().await;
let report = migrate_tickets(&root, false, &TicketFilter::default())
.await
.unwrap();
assert_eq!(report.updated, 0);
assert_eq!(report.already_current, 0);
assert!(report.errors.is_empty());
drop(tmp);
}
/// `migrate_tickets` returns an empty report when the tickets directory does not exist.
#[tokio::test]
async fn no_tickets_dir_returns_empty_report() {
let tmp = tempfile::tempdir().unwrap();
// Create `.nbd/` but not `.nbd/tickets/`.
std::fs::create_dir(tmp.path().join(".nbd")).unwrap();
let root = tmp.path().to_path_buf();
let report = migrate_tickets(&root, false, &TicketFilter::default())
.await
.unwrap();
assert_eq!(report.updated, 0);
assert_eq!(report.already_current, 0);
assert!(report.errors.is_empty());
drop(tmp);
}
}
// ── filter module ────────────────────────────────────────────────────────────
/// Tests for [`crate::filter`].
mod filter {
use crate::filter::{glob_matches, parse_filters, TicketFilter};
use crate::ticket::{Status, Ticket, TicketType};
fn make_ticket(status: Status, ticket_type: TicketType, priority: u8, title: &str) -> Ticket {
let mut t = Ticket::new("aabbcc".to_string(), title.to_string());
t.status = status;
t.ticket_type = ticket_type;
t.priority = priority;
t
}
// ── parse_filters ──────────────────────────────────────────────────────
/// `parse_filters` returns an error for a string that contains no `=`.
#[test]
fn parse_filters_rejects_no_equals() {
let args = vec!["statusbad".to_string()];
let result = parse_filters(&args);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("key=value"),
"error should mention expected format, got: {msg}"
);
}
/// `parse_filters` returns an error for an unknown key.
#[test]
fn parse_filters_rejects_unknown_key() {
let args = vec!["colour=red".to_string()];
let result = parse_filters(&args);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("colour"),
"error should mention the unknown key, got: {msg}"
);
}
/// `parse_filters` treats everything after the first `=` as the value.
#[test]
fn parse_filters_value_contains_equals() {
let args = vec!["title=foo=bar".to_string()];
let filter = parse_filters(&args).expect("should succeed");
assert_eq!(filter.title, ["foo=bar"]);
}
/// `parse_filters` correctly populates the `status` and `ticket_type` vecs.
#[test]
fn parse_filters_basic() {
let args = vec!["status=todo".to_string(), "type=bug".to_string()];
let filter = parse_filters(&args).expect("should succeed");
assert_eq!(filter.status, ["todo"]);
assert_eq!(filter.ticket_type, ["bug"]);
assert!(filter.priority.is_empty());
assert!(filter.title.is_empty());
}
// ── glob_matches ──────────────────────────────────────────────────────
/// `*` matches any non-empty string.
#[test]
fn glob_star_matches_anything() {
assert!(glob_matches("*", "anything"));
}
/// `*` matches the empty string.
#[test]
fn glob_star_matches_empty() {
assert!(glob_matches("*", ""));
}
/// An exact pattern matches identical input.
#[test]
fn glob_exact_match() {
assert!(glob_matches("todo", "todo"));
}
/// An exact pattern does not match a different string.
#[test]
fn glob_exact_no_match() {
assert!(!glob_matches("todo", "done"));
}
/// `*command*` matches a string that contains "command".
#[test]
fn glob_contains_match() {
assert!(glob_matches("*command*", "add command here"));
}
/// `*command*` does not match a string that lacks "command".
#[test]
fn glob_contains_no_match() {
assert!(!glob_matches("*command*", "no match"));
}
/// `in_*` matches a string that starts with "in_".
#[test]
fn glob_prefix_match() {
assert!(glob_matches("in_*", "in_progress"));
}
/// `in_*` does not match a string that does not start with "in_".
#[test]
fn glob_prefix_no_match() {
assert!(!glob_matches("in_*", "todo"));
}
// ── TicketFilter::matches ─────────────────────────────────────────────
/// An empty filter matches every ticket.
#[test]
fn empty_filter_matches_everything() {
let filter = TicketFilter::default();
let ticket = make_ticket(Status::Todo, TicketType::Bug, 5, "Any title");
assert!(filter.matches(&ticket));
}
/// Different keys are ANDed: both must match.
#[test]
fn different_keys_are_anded() {
// Filter: type=bug AND status=todo
let args = vec!["type=bug".to_string(), "status=todo".to_string()];
let filter = parse_filters(&args).unwrap();
// Matches: bug + todo
let ticket = make_ticket(Status::Todo, TicketType::Bug, 5, "A bug");
assert!(filter.matches(&ticket));
// Doesn't match: feature + todo (wrong type)
let ticket2 = make_ticket(Status::Todo, TicketType::Feature, 5, "A feature");
assert!(!filter.matches(&ticket2));
// Doesn't match: bug + done (wrong status)
let ticket3 = make_ticket(Status::Done, TicketType::Bug, 5, "Done bug");
assert!(!filter.matches(&ticket3));
}
/// Same key with multiple values are ORed: either match passes.
#[test]
fn same_key_is_ored() {
// Filter: status=todo OR status=in_progress
let args = vec!["status=todo".to_string(), "status=in_progress".to_string()];
let filter = parse_filters(&args).unwrap();
let todo_ticket = make_ticket(Status::Todo, TicketType::Task, 5, "Todo");
let inprogress_ticket = make_ticket(Status::InProgress, TicketType::Task, 5, "In progress");
let done_ticket = make_ticket(Status::Done, TicketType::Task, 5, "Done");
assert!(filter.matches(&todo_ticket));
assert!(filter.matches(&inprogress_ticket));
assert!(!filter.matches(&done_ticket));
}
// ── TicketFilter::is_empty ─────────────────────────────────────────────
/// `is_empty` returns `true` for a default filter.
#[test]
fn is_empty_true_when_no_filters() {
let filter = TicketFilter::default();
assert!(filter.is_empty());
}
/// `is_empty` returns `false` when any filter is set.
#[test]
fn is_empty_false_when_filter_set() {
let mut filter = TicketFilter::default();
filter.status.push("todo".to_string());
assert!(!filter.is_empty());
}
// ── TicketFilter::has_status_filter ───────────────────────────────────
/// `has_status_filter` returns `false` for a default filter.
#[test]
fn has_status_filter_false_by_default() {
let filter = TicketFilter::default();
assert!(!filter.has_status_filter());
}
/// `has_status_filter` returns `true` when a status pattern is present.
#[test]
fn has_status_filter_true_when_status_set() {
let args = vec!["status=todo".to_string()];
let filter = parse_filters(&args).unwrap();
assert!(filter.has_status_filter());
}
/// `has_status_filter` returns `false` when only non-status filters are set.
#[test]
fn has_status_filter_false_when_only_type_set() {
let args = vec!["type=bug".to_string()];
let filter = parse_filters(&args).unwrap();
assert!(!filter.has_status_filter());
}
// ── TicketFilter::matches_status ──────────────────────────────────────
/// `matches_status` returns `true` when the ticket's status matches a pattern.
#[test]
fn matches_status_matches_exact() {
let args = vec!["status=todo".to_string()];
let filter = parse_filters(&args).unwrap();
let ticket = make_ticket(Status::Todo, TicketType::Task, 5, "A ticket");
assert!(filter.matches_status(&ticket));
}
/// `matches_status` returns `false` when the ticket's status does not match.
#[test]
fn matches_status_no_match() {
let args = vec!["status=done".to_string()];
let filter = parse_filters(&args).unwrap();
let ticket = make_ticket(Status::Todo, TicketType::Task, 5, "A ticket");
assert!(!filter.matches_status(&ticket));
}
/// `matches_status` with `status=*` matches any status including `closed` and `backlog`.
#[test]
fn matches_status_wildcard_matches_all() {
let args = vec!["status=*".to_string()];
let filter = parse_filters(&args).unwrap();
assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T")));
assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T")));
assert!(filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T")));
assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T")));
assert!(filter.matches_status(&make_ticket(Status::Backlog, TicketType::Task, 5, "T")));
}
/// `matches_status` with `status=backlog` matches only backlog tickets.
#[test]
fn matches_status_backlog_pattern() {
let args = vec!["status=backlog".to_string()];
let filter = parse_filters(&args).unwrap();
assert!(!filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T")));
assert!(!filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T")));
assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T")));
assert!(!filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T")));
assert!(filter.matches_status(&make_ticket(Status::Backlog, TicketType::Task, 5, "T")));
}
/// `matches_status` with `status=closed` matches only closed tickets.
#[test]
fn matches_status_closed_pattern() {
let args = vec!["status=closed".to_string()];
let filter = parse_filters(&args).unwrap();
assert!(!filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T")));
assert!(!filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T")));
assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T")));
assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T")));
}
/// `matches_status` ORs multiple status patterns.
#[test]
fn matches_status_ored_patterns() {
let args = vec!["status=todo".to_string(), "status=in_progress".to_string()];
let filter = parse_filters(&args).unwrap();
assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T")));
assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T")));
assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T")));
}
// ── TicketFilter::matches_except_status ───────────────────────────────
/// `matches_except_status` with an empty filter always returns `true`.
#[test]
fn matches_except_status_empty_filter_always_true() {
let filter = TicketFilter::default();
let ticket = make_ticket(Status::Done, TicketType::Bug, 5, "Any");
assert!(filter.matches_except_status(&ticket));
}
/// `matches_except_status` matches on type while ignoring status.
#[test]
fn matches_except_status_checks_type_not_status() {
let args = vec!["type=bug".to_string()];
let filter = parse_filters(&args).unwrap();
// Done bug: type matches even though status is done.
let done_bug = make_ticket(Status::Done, TicketType::Bug, 5, "Bug");
assert!(filter.matches_except_status(&done_bug));
// Todo task: type does not match.
let todo_task = make_ticket(Status::Todo, TicketType::Task, 5, "Task");
assert!(!filter.matches_except_status(&todo_task));
}
/// `matches_except_status` a status-only filter always returns `true`.
#[test]
fn matches_except_status_status_only_filter_returns_true() {
let args = vec!["status=done".to_string()];
let filter = parse_filters(&args).unwrap();
// matches_except_status ignores the status group.
let todo_ticket = make_ticket(Status::Todo, TicketType::Task, 5, "T");
assert!(filter.matches_except_status(&todo_ticket));
}
}
// ── graph module ──────────────────────────────────────────────────────────────
/// Tests for [`crate::graph`].
mod graph {
use crate::graph::TicketGraph;
use crate::ticket::Ticket;
/// Build a ticket with a given ID, title, and dependency list.
fn make_ticket(id: &str, deps: &[&str]) -> Ticket {
let mut t = Ticket::new(id.to_string(), format!("Ticket {id}"));
t.dependencies = deps.iter().map(|d| d.to_string()).collect();
t
}
/// `build` on an empty slice produces a graph with no nodes.
#[test]
fn build_empty() {
let graph = TicketGraph::build(&[]);
assert!(graph.roots().is_empty());
assert!(graph.subtree("anything").is_empty());
}
/// Two tickets with no dependencies are both roots.
#[test]
fn roots_no_deps() {
let tickets = vec![make_ticket("aaaaaa", &[]), make_ticket("bbbbbb", &[])];
let graph = TicketGraph::build(&tickets);
let roots = graph.roots();
assert_eq!(roots.len(), 2);
let ids: Vec<&str> = roots.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"aaaaaa"));
assert!(ids.contains(&"bbbbbb"));
}
/// When B depends on A, only B is a root (B is the goal; A is a prerequisite).
#[test]
fn roots_with_chain() {
let a = make_ticket("aaaaaa", &[]);
let b = make_ticket("bbbbbb", &["aaaaaa"]);
let tickets = vec![a, b];
let graph = TicketGraph::build(&tickets);
let roots = graph.roots();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].id, "bbbbbb");
}
/// Roots are sorted by priority descending.
#[test]
fn roots_sorted_by_priority() {
let mut lo = make_ticket("aaaaaa", &[]);
lo.priority = 2;
let mut hi = make_ticket("bbbbbb", &[]);
hi.priority = 8;
let tickets = vec![lo, hi];
let graph = TicketGraph::build(&tickets);
let roots = graph.roots();
assert_eq!(roots[0].id, "bbbbbb", "highest priority root first");
assert_eq!(roots[1].id, "aaaaaa");
}
/// `subtree` on a linear chain A → B → C (B depends on A, C depends on B)
/// returns all three IDs when starting from C (the top-level goal).
#[test]
fn subtree_linear_chain() {
let a = make_ticket("aaaaaa", &[]);
let b = make_ticket("bbbbbb", &["aaaaaa"]);
let c = make_ticket("cccccc", &["bbbbbb"]);
let tickets = vec![a, b, c];
let graph = TicketGraph::build(&tickets);
let sub = graph.subtree("cccccc");
assert_eq!(sub.len(), 3, "subtree should include all three tickets");
assert!(sub.contains(&"aaaaaa"));
assert!(sub.contains(&"bbbbbb"));
assert!(sub.contains(&"cccccc"));
}
/// `subtree` on a leaf node (no dependencies) returns just that ID.
#[test]
fn subtree_leaf() {
let a = make_ticket("aaaaaa", &[]);
let b = make_ticket("bbbbbb", &["aaaaaa"]);
let tickets = vec![a, b];
let graph = TicketGraph::build(&tickets);
let sub = graph.subtree("aaaaaa");
assert_eq!(sub, vec!["aaaaaa"]);
}
/// `subtree` on an unknown ID returns an empty vec.
#[test]
fn subtree_unknown_id() {
let tickets = vec![make_ticket("aaaaaa", &[])];
let graph = TicketGraph::build(&tickets);
assert!(graph.subtree("ffffff").is_empty());
}
/// `subtree` does not infinite-loop when the data contains a cycle.
#[test]
fn subtree_cycle_safe() {
let mut a = make_ticket("aaaaaa", &["bbbbbb"]);
a.dependencies = vec!["bbbbbb".to_string()];
let mut b = make_ticket("bbbbbb", &["aaaaaa"]);
b.dependencies = vec!["aaaaaa".to_string()];
let tickets = vec![a, b];
let graph = TicketGraph::build(&tickets);
// Must not hang; both IDs should appear exactly once.
let sub = graph.subtree("aaaaaa");
assert!(sub.contains(&"aaaaaa"));
assert!(sub.contains(&"bbbbbb"));
assert_eq!(sub.len(), 2, "each ID should appear exactly once");
}
/// `to_json_value` includes all tickets as nodes and all in-graph edges.
#[test]
fn to_json_value_nodes_and_edges() {
let a = make_ticket("aaaaaa", &[]);
let b = make_ticket("bbbbbb", &["aaaaaa"]);
let c = make_ticket("cccccc", &["aaaaaa"]);
let tickets = vec![a, b, c];
let graph = TicketGraph::build(&tickets);
let json = graph.to_json_value();
let nodes = json["nodes"].as_array().unwrap();
let edges = json["edges"].as_array().unwrap();
assert_eq!(nodes.len(), 3, "all three tickets should be nodes");
assert_eq!(edges.len(), 2, "two edges: aaaaaa→bbbbbb and aaaaaa→cccccc");
let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect();
assert!(node_ids.contains(&"aaaaaa"));
assert!(node_ids.contains(&"bbbbbb"));
assert!(node_ids.contains(&"cccccc"));
// Every edge should have "to" (dependency/prerequisite) = "aaaaaa".
for edge in edges {
assert_eq!(edge["to"].as_str().unwrap(), "aaaaaa");
}
}
/// `to_json_value` on an empty graph returns empty `nodes` and `edges`.
#[test]
fn to_json_value_empty() {
let graph = TicketGraph::build(&[]);
let json = graph.to_json_value();
assert!(json["nodes"].as_array().unwrap().is_empty());
assert!(json["edges"].as_array().unwrap().is_empty());
}
/// `to_subtree_json_value` limits nodes and edges to the reachable subtree.
#[test]
fn to_subtree_json_value_scoped() {
let a = make_ticket("aaaaaa", &[]);
let b = make_ticket("bbbbbb", &["aaaaaa"]); // depends on a
let c = make_ticket("cccccc", &[]); // unrelated
let tickets = vec![a, b, c];
let graph = TicketGraph::build(&tickets);
// Starting from bbbbbb (the goal): subtree includes bbbbbb + its dependency aaaaaa.
let json = graph.to_subtree_json_value("bbbbbb");
let nodes = json["nodes"].as_array().unwrap();
let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect();
assert!(node_ids.contains(&"bbbbbb"), "root should be included");
assert!(
node_ids.contains(&"aaaaaa"),
"dependency should be included"
);
assert!(
!node_ids.contains(&"cccccc"),
"unrelated ticket should be excluded"
);
}
/// Dangling dependency references (IDs not in the graph) are silently ignored.
#[test]
fn dangling_deps_ignored() {
let mut a = make_ticket("aaaaaa", &[]);
// "ffffff" does not exist in the graph.
a.dependencies = vec!["ffffff".to_string()];
let tickets = vec![a];
let graph = TicketGraph::build(&tickets);
// aaaaaa has no in-graph dependencies, so it should be a root.
let roots = graph.roots();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].id, "aaaaaa");
// The JSON should show an empty dependencies list.
let json = graph.to_json_value();
let deps = json["nodes"][0]["dependencies"].as_array().unwrap();
assert!(deps.is_empty());
}
}
// ── display graph rendering ───────────────────────────────────────────────────
/// Tests for the graph rendering functions in [`crate::display`].
mod display_graph {
use crate::display::{format_graph, format_subtree};
use crate::graph::TicketGraph;
use crate::ticket::Ticket;
fn make_ticket(id: &str, deps: &[&str]) -> Ticket {
let mut t = Ticket::new(id.to_string(), format!("Ticket {id}"));
t.dependencies = deps.iter().map(|d| d.to_string()).collect();
t
}
/// A single ticket with no deps renders as one line containing its ID,
/// status, and title, with no box-drawing characters.
#[test]
fn single_ticket_no_deps() {
let tickets = vec![make_ticket("aaaaaa", &[])];
let graph = TicketGraph::build(&tickets);
let out = format_graph(&graph);
assert!(out.contains("aaaaaa"), "should contain ID");
assert!(out.contains("[todo]"), "should contain status");
assert!(out.contains("Ticket aaaaaa"), "should contain title");
assert!(!out.contains("├──"), "should have no branch connectors");
assert!(!out.contains("└──"), "should have no branch connectors");
}
/// A two-ticket chain (B depends on A) renders B at the top level (the goal)
/// and A indented below it with `└──` (the prerequisite).
#[test]
fn two_ticket_chain() {
let a = make_ticket("aaaaaa", &[]);
let b = make_ticket("bbbbbb", &["aaaaaa"]);
let tickets = vec![a, b];
let graph = TicketGraph::build(&tickets);
let out = format_graph(&graph);
// B should appear before A (B is the goal, A is the prerequisite).
let pos_a = out.find("aaaaaa").expect("aaaaaa should appear");
let pos_b = out.find("bbbbbb").expect("bbbbbb should appear");
assert!(
pos_b < pos_a,
"goal (bbbbbb) should appear before prerequisite (aaaaaa)"
);
// A's line should use the └── connector.
assert!(out.contains("└──"), "last (only) child should use └──");
assert!(!out.contains("├──"), "only child should not use ├──");
}
/// When a goal depends on two prerequisites, the first uses `├──` and the last `└──`.
#[test]
fn branching_goal() {
let a = make_ticket("aaaaaa", &[]);
let b = make_ticket("bbbbbb", &[]);
// cccccc depends on both aaaaaa and bbbbbb, so it renders with two children.
let c = make_ticket("cccccc", &["aaaaaa", "bbbbbb"]);
let tickets = vec![a, b, c];
let graph = TicketGraph::build(&tickets);
let out = format_graph(&graph);
assert!(out.contains("├──"), "non-last child should use ├──");
assert!(out.contains("└──"), "last child should use └──");
}
/// `format_subtree` for a goal includes that goal and its dependencies,
/// not unrelated tickets.
#[test]
fn subtree_excludes_unrelated() {
let a = make_ticket("aaaaaa", &[]);
let b = make_ticket("bbbbbb", &["aaaaaa"]);
let c = make_ticket("cccccc", &[]); // unrelated
let tickets = vec![a, b, c];
let graph = TicketGraph::build(&tickets);
// Starting from bbbbbb (the goal): renders bbbbbb and its dependency aaaaaa.
let out = format_subtree(&graph, "bbbbbb");
assert!(out.contains("bbbbbb"), "goal should be present");
assert!(out.contains("aaaaaa"), "dependency should be present");
assert!(!out.contains("cccccc"), "unrelated ticket should be absent");
}
/// `format_subtree` on an unknown ID returns an empty string.
#[test]
fn subtree_unknown_id_empty() {
let tickets = vec![make_ticket("aaaaaa", &[])];
let graph = TicketGraph::build(&tickets);
assert!(format_subtree(&graph, "ffffff").is_empty());
}
/// When the data contains a cycle, the repeated node is marked with `*`
/// and the output is finite (no infinite loop).
///
/// A pure cycle (A depends on B, B depends on A) has no roots, so we use
/// `format_subtree` to trigger rendering from one of the cyclic nodes.
#[test]
fn cycle_labelled() {
let mut a = make_ticket("aaaaaa", &[]);
a.dependencies = vec!["bbbbbb".to_string()];
let mut b = make_ticket("bbbbbb", &[]);
b.dependencies = vec!["aaaaaa".to_string()];
let tickets = vec![a, b];
let graph = TicketGraph::build(&tickets);
// format_subtree drives rendering from "aaaaaa"; when it tries to
// revisit "aaaaaa" via bbbbbb's dependency edge, it should mark with *.
let out = format_subtree(&graph, "aaaaaa");
assert!(
out.contains(" *"),
"repeated node should be marked with *: {out}"
);
}
/// An empty graph renders as an empty string.
#[test]
fn empty_graph() {
let graph = TicketGraph::build(&[]);
assert!(format_graph(&graph).is_empty());
}
}
// ── display module ────────────────────────────────────────────────────────────
/// Tests for [`crate::display`].
mod display {
use crate::display::{
format_diff, format_list, format_list_json, format_migrate_report,
format_migrate_report_json, format_ticket, format_ticket_json,
};
use crate::store::MigrateReport;
use crate::ticket::{Status, Ticket, TicketType};
/// Build a fully-populated ticket for use in display tests.
fn sample_ticket() -> Ticket {
Ticket {
id: "a3f9c2".to_string(),
title: "Fix login bug".to_string(),
body: "Users cannot log in with email addresses containing +".to_string(),
priority: 8,
status: Status::InProgress,
dependencies: vec!["b7d41e".to_string(), "c9e823".to_string()],
ticket_type: TicketType::Bug,
}
}
/// `format_ticket` includes all field values in its output.
#[test]
fn format_ticket_contains_all_fields() {
let t = sample_ticket();
let output = format_ticket(&t);
assert!(output.contains("a3f9c2"), "should contain ID");
assert!(output.contains("Fix login bug"), "should contain title");
assert!(
output.contains("Users cannot log in"),
"should contain body"
);
assert!(output.contains('8'), "should contain priority");
assert!(output.contains("in_progress"), "should contain status");
assert!(output.contains("bug"), "should contain ticket type");
assert!(output.contains("b7d41e"), "should contain first dependency");
assert!(
output.contains("c9e823"),
"should contain second dependency"
);
}
/// `format_ticket` renders dependency IDs as a TOML array.
#[test]
fn format_ticket_joins_dependencies() {
let t = sample_ticket();
let output = format_ticket(&t);
assert!(
output.contains("b7d41e") && output.contains("c9e823"),
"dependencies should appear in frontmatter: {output}"
);
// TOML array format: dependencies = ["b7d41e", "c9e823"]
assert!(
output.contains("dependencies"),
"dependencies key should appear: {output}"
);
}
/// `format_ticket` renders a `dependencies` key even when there are none.
#[test]
fn format_ticket_empty_dependencies() {
let t = Ticket::new("aaaaaa".to_string(), "No deps".to_string());
let output = format_ticket(&t);
assert!(
output.contains("dependencies"),
"dependencies key should always appear in frontmatter"
);
}
/// `format_ticket_json` produces valid, parseable JSON containing key fields.
///
/// Even though [`crate::ticket::Ticket::id`] is annotated with `#[serde(skip)]`
/// (to avoid storing it in files), the display layer re-injects `id` so that
/// CLI `--json` output is self-contained for machine consumers.
#[test]
fn format_ticket_json_is_valid_json() {
let t = sample_ticket();
let output = format_ticket_json(&t);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert_eq!(parsed["id"], "a3f9c2");
assert_eq!(parsed["title"], "Fix login bug");
assert_eq!(parsed["priority"], 8);
assert_eq!(parsed["status"], "in_progress");
assert_eq!(parsed["ticket_type"], "bug");
}
/// `format_list` includes a header row with all column names.
#[test]
fn format_list_shows_header() {
let output = format_list(&[]);
assert!(output.contains("ID"), "header should contain ID");
assert!(output.contains("PRI"), "header should contain PRI");
assert!(output.contains("TYPE"), "header should contain TYPE");
assert!(output.contains("STATUS"), "header should contain STATUS");
assert!(output.contains("TITLE"), "header should contain TITLE");
}
/// `format_list` includes every ticket's key fields.
#[test]
fn format_list_contains_all_tickets() {
let mut t1 = Ticket::new("id0001".to_string(), "First ticket".to_string());
t1.priority = 9;
let mut t2 = Ticket::new("id0002".to_string(), "Second ticket".to_string());
t2.priority = 3;
let tickets = vec![t1, t2];
let output = format_list(&tickets);
assert!(output.contains("id0001"), "should contain first ID");
assert!(
output.contains("First ticket"),
"should contain first title"
);
assert!(output.contains("id0002"), "should contain second ID");
assert!(
output.contains("Second ticket"),
"should contain second title"
);
}
/// `format_list` renders the correct status and type strings.
#[test]
fn format_list_renders_status_and_type_strings() {
let t = sample_ticket(); // status: in_progress, type: bug
let output = format_list(&[t]);
assert!(
output.contains("in_progress"),
"should render status string"
);
assert!(output.contains("bug"), "should render type string");
}
/// `format_list_json` produces a valid JSON array with one object per ticket,
/// with `id` explicitly included in each object for machine consumers.
#[test]
fn format_list_json_is_valid_json_array() {
let tickets = vec![
Ticket::new("id0001".to_string(), "First".to_string()),
Ticket::new("id0002".to_string(), "Second".to_string()),
];
let output = format_list_json(&tickets);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert!(parsed.is_array(), "output should be a JSON array");
assert_eq!(parsed.as_array().unwrap().len(), 2);
assert_eq!(parsed[0]["id"], "id0001");
assert_eq!(parsed[1]["id"], "id0002");
}
/// `format_list_json` returns an empty JSON array for an empty slice.
#[test]
fn format_list_json_empty_slice() {
let output = format_list_json(&[]);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert!(parsed.is_array());
assert!(parsed.as_array().unwrap().is_empty());
}
/// `format_migrate_report` includes counts for all three categories.
#[test]
fn format_migrate_report_contains_counts() {
let report = MigrateReport {
updated: 3,
already_current: 5,
skipped: 0,
errors: vec![("bad.json".to_string(), "parse error".to_string())],
};
let output = format_migrate_report(&report);
assert!(output.contains('3'), "should mention updated count");
assert!(output.contains('5'), "should mention already_current count");
assert!(output.contains("bad.json"), "should mention error filename");
assert!(
output.contains("parse error"),
"should mention error message"
);
}
/// `format_migrate_report` with no errors omits the Errors line.
#[test]
fn format_migrate_report_no_errors() {
let report = MigrateReport {
updated: 1,
already_current: 2,
skipped: 0,
errors: vec![],
};
let output = format_migrate_report(&report);
assert!(
!output.contains("Errors"),
"should not show Errors when none"
);
}
/// `format_migrate_report` shows a Skipped line when `skipped > 0`.
#[test]
fn format_migrate_report_shows_skipped_when_nonzero() {
let report = MigrateReport {
updated: 1,
already_current: 2,
skipped: 3,
errors: vec![],
};
let output = format_migrate_report(&report);
assert!(
output.contains("Skipped"),
"should show Skipped line when skipped > 0"
);
assert!(output.contains('3'), "should include skipped count");
}
/// `format_migrate_report` omits the Skipped line when `skipped == 0`.
#[test]
fn format_migrate_report_omits_skipped_when_zero() {
let report = MigrateReport {
updated: 1,
already_current: 2,
skipped: 0,
errors: vec![],
};
let output = format_migrate_report(&report);
assert!(
!output.contains("Skipped"),
"should not show Skipped when skipped == 0"
);
}
/// `format_migrate_report_json` includes a `skipped` key.
#[test]
fn format_migrate_report_json_includes_skipped() {
let report = MigrateReport {
updated: 1,
already_current: 2,
skipped: 4,
errors: vec![],
};
let output = format_migrate_report_json(&report);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert_eq!(parsed["skipped"], 4, "JSON should contain skipped count");
}
/// `format_migrate_report_json` produces valid JSON with the expected keys.
#[test]
fn format_migrate_report_json_is_valid() {
let report = MigrateReport {
updated: 2,
already_current: 4,
skipped: 0,
errors: vec![("err.json".to_string(), "bad".to_string())],
};
let output = format_migrate_report_json(&report);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert_eq!(parsed["updated"], 2);
assert_eq!(parsed["already_current"], 4);
assert!(parsed["errors"].is_array());
assert_eq!(parsed["errors"].as_array().unwrap().len(), 1);
assert_eq!(parsed["errors"][0]["filename"], "err.json");
assert_eq!(parsed["errors"][0]["message"], "bad");
}
// ── format_diff ───────────────────────────────────────────────────────────
/// `format_diff` returns `"(no changes)"` when old and new are identical.
#[test]
fn format_diff_no_changes() {
let t = sample_ticket();
let output = format_diff(&t, &t);
assert_eq!(output, "(no changes)");
}
/// `format_diff` shows `- old` and `+ new` lines only for changed fields.
#[test]
fn format_diff_shows_changed_fields_only() {
let old = sample_ticket(); // status: in_progress
let mut new = sample_ticket();
new.status = Status::Done;
let output = format_diff(&old, &new);
assert!(
output.contains("- status:"),
"should show old status line: {output}"
);
assert!(
output.contains("+ status:"),
"should show new status line: {output}"
);
assert!(
output.contains("in_progress"),
"should show old status value: {output}"
);
assert!(
output.contains("done"),
"should show new status value: {output}"
);
// Unchanged fields must not appear.
assert!(
!output.contains("title:"),
"unchanged title should not appear: {output}"
);
assert!(
!output.contains("priority:"),
"unchanged priority should not appear: {output}"
);
}
/// `format_diff` shows multiple changed fields when several differ.
#[test]
fn format_diff_multiple_changed_fields() {
let old = sample_ticket();
let mut new = sample_ticket();
new.status = Status::Done;
new.priority = 3;
let output = format_diff(&old, &new);
assert!(output.contains("status:"), "status diff should appear");
assert!(output.contains("priority:"), "priority diff should appear");
// title unchanged — must not appear.
assert!(
!output.contains("title:"),
"unchanged title should not appear: {output}"
);
}
/// `format_diff` renders dependency changes as comma-separated lists.
#[test]
fn format_diff_dependencies_comma_separated() {
let mut old = sample_ticket(); // deps: b7d41e, c9e823
let mut new = sample_ticket();
old.dependencies = vec!["aaaaaa".to_string()];
new.dependencies = vec!["bbbbbb".to_string(), "cccccc".to_string()];
let output = format_diff(&old, &new);
assert!(
output.contains("dependencies:"),
"dependencies diff should appear: {output}"
);
assert!(output.contains("aaaaaa"), "old dep should appear: {output}");
assert!(
output.contains("bbbbbb, cccccc"),
"new deps should be comma-separated: {output}"
);
}
}