//! 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::("\"todo\"").unwrap(), Status::Todo ); assert_eq!( serde_json::from_str::("\"in_progress\"").unwrap(), Status::InProgress ); assert_eq!( serde_json::from_str::("\"done\"").unwrap(), Status::Done ); assert_eq!( serde_json::from_str::("\"closed\"").unwrap(), Status::Closed ); assert_eq!( serde_json::from_str::("\"archived\"").unwrap(), Status::Archived ); assert_eq!( serde_json::from_str::("\"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::("\"project\"").unwrap(), TicketType::Project ); assert_eq!( serde_json::from_str::("\"bug\"").unwrap(), TicketType::Bug ); } /// `validate_priority` accepts values 0–10 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 = (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}" ); } }