feat(nbd): implement nbd migrate command [0f51af]

Adds `nbd migrate` to bring ticket files on disk into conformance with
the current serde schema. Re-serialises every *.json file through the
current Ticket model — removing stale fields (e.g. old \"id\" key),
adding new fields with their defaults, and normalising formatting.

- store: MigrateReport struct and migrate_tickets() function
- display: format/print_migrate_report and _json variants
- main: Migrate command with --dry-run flag and cmd_migrate handler
- 6 unit tests (rewrites old format, already-current, dry-run, invalid JSON, empty store, no tickets dir)
- 4 integration tests (rewrite, dry-run, parse error tolerance, --json output)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent f1715d18eb
commit df71fc9e09

@ -1,9 +1,8 @@
{ {
"id": "0f51af",
"title": "nbd migrate command", "title": "nbd migrate command",
"body": "Add `nbd migrate` to bring all ticket files on disk into conformance with the current schema. This is the standard mechanism for handling any schema change — field removals, field additions, renames, or type changes.\n\n## Motivation\n\nSchema changes (like removing `id` from the JSON body, adding new fields with defaults, or renaming a field) leave existing ticket files in an old format. `nbd migrate` re-serialises every ticket through the current serde schema, which:\n- **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise).\n- **Adds** new fields with their `#[serde(default)]` values.\n- **Normalises** any formatting differences (e.g. key order, whitespace).\n\nThe current immediate use case is scrubbing the `\"id\"` key from all existing `.json` files after the id-from-filename schema change.\n\n## Design principles\n\n- **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically).\n- **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end.\n- **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error.\n- **Dry-run available.** `--dry-run` prints what would change without writing.\n\n## Approach\n\n### main.rs\n\nAdd `Migrate` variant to `Commands`:\n```\nMigrate {\n /// Print changes without writing them.\n #[arg(long)]\n dry_run: bool,\n}\n```\n\nImplement `cmd_migrate(dry_run: bool) -> store::Result<()>`:\n1. `find_nbd_root()`\n2. Call `store::migrate_tickets(&root, dry_run).await`\n3. Print a summary: `Migrated N tickets (M errors)`.\n\n### store.rs\n\nAdd `migrate_tickets(root: &Path, dry_run: bool) -> Result<MigrateReport>`:\n1. `fs::read_dir(tickets_dir(root))`\n2. For each `*.json`, `*.md`, `*.toml`, `*.jsonb` file (all supported formats — the ones that exist now and future ones added by the multi-format feature):\n a. Read the raw bytes.\n b. Attempt to deserialise into current `Ticket`, injecting `id` from filename.\n c. Re-serialise to the current schema (same format as the original file's extension).\n d. Compare raw bytes. If unchanged, skip (count as already-current).\n e. If changed and `dry_run`: print `would update {filename}`, do not write.\n f. If changed and not `dry_run`: write the new bytes to the same path.\n g. If deserialise fails: record the error, leave the file untouched.\n3. Return `MigrateReport { updated: usize, already_current: usize, errors: Vec<(String, String)> }`.\n\n```rust\npub struct MigrateReport {\n pub updated: usize,\n pub already_current: usize,\n pub errors: Vec<(String, String)>, // (filename, error message)\n}\n```\n\n### display.rs\n\nAdd `print_migrate_report(report: &MigrateReport)`:\n```\nMigrated 3 tickets.\nCurrent 5 tickets (already up to date).\nErrors 1 ticket could not be migrated:\n bad_ticket.json: trailing comma at line 4\n```\n\nWhen `--json`, serialise `MigrateReport` directly (derive `Serialize`).\n\n## How schema changes use this\n\nFor **field removal** (e.g. removing `id` from JSON):\n- Old files have `\"id\": \"...\"` → on deserialise, serde ignores it (unknown field).\n- Re-serialise → `id` is absent (since `#[serde(skip)]`).\n- File bytes differ → `migrate` rewrites.\n\nFor **field addition** (e.g. adding `tags: Vec<String>` later):\n- New field in `Ticket` gets `#[serde(default)]`.\n- Old files lack `tags` → deserialise gives `vec![]`.\n- Re-serialise → `\"tags\": []` is written.\n- File bytes differ → `migrate` rewrites.\n\n## Tests\n\nUnit tests (`src/tests.rs`):\n- `migrate_tickets` on a store with old-format files (containing `\"id\"`) rewrites them without `id`.\n- `migrate_tickets` on an already-current store returns `updated: 0`, `already_current: N`.\n- `migrate_tickets --dry-run` does not modify files on disk.\n- A file with invalid JSON is counted in `errors` and left unchanged.\n\nIntegration tests (`tests/integration.rs`):\n- Create tickets with old code (inject `id` manually into JSON), run `nbd migrate`, verify `id` is gone from files.\n- `nbd migrate --dry-run` reports changes but does not modify files.\n- `nbd migrate` exits zero even when some tickets error (but prints error summary).\n- `nbd migrate --json` outputs a valid JSON object with `updated`, `already_current`, `errors` fields.\n\n## Files touched\n- `src/main.rs` — `Migrate` command, `cmd_migrate`\n- `src/store.rs` — `migrate_tickets`, `MigrateReport`\n- `src/display.rs` — `print_migrate_report`\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests\n- `README.md` — document `nbd migrate`", "body": "Add `nbd migrate` to bring all ticket files on disk into conformance with the current schema. This is the standard mechanism for handling any schema change — field removals, field additions, renames, or type changes.\n\n## Motivation\n\nSchema changes (like removing `id` from the JSON body, adding new fields with defaults, or renaming a field) leave existing ticket files in an old format. `nbd migrate` re-serialises every ticket through the current serde schema, which:\n- **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise).\n- **Adds** new fields with their `#[serde(default)]` values.\n- **Normalises** any formatting differences (e.g. key order, whitespace).\n\nThe current immediate use case is scrubbing the `\"id\"` key from all existing `.json` files after the id-from-filename schema change.\n\n## Design principles\n\n- **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically).\n- **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end.\n- **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error.\n- **Dry-run available.** `--dry-run` prints what would change without writing.\n\n## Approach\n\n### main.rs\n\nAdd `Migrate` variant to `Commands`:\n```\nMigrate {\n /// Print changes without writing them.\n #[arg(long)]\n dry_run: bool,\n}\n```\n\nImplement `cmd_migrate(dry_run: bool) -> store::Result<()>`:\n1. `find_nbd_root()`\n2. Call `store::migrate_tickets(&root, dry_run).await`\n3. Print a summary: `Migrated N tickets (M errors)`.\n\n### store.rs\n\nAdd `migrate_tickets(root: &Path, dry_run: bool) -> Result<MigrateReport>`:\n1. `fs::read_dir(tickets_dir(root))`\n2. For each `*.json`, `*.md`, `*.toml`, `*.jsonb` file (all supported formats — the ones that exist now and future ones added by the multi-format feature):\n a. Read the raw bytes.\n b. Attempt to deserialise into current `Ticket`, injecting `id` from filename.\n c. Re-serialise to the current schema (same format as the original file's extension).\n d. Compare raw bytes. If unchanged, skip (count as already-current).\n e. If changed and `dry_run`: print `would update {filename}`, do not write.\n f. If changed and not `dry_run`: write the new bytes to the same path.\n g. If deserialise fails: record the error, leave the file untouched.\n3. Return `MigrateReport { updated: usize, already_current: usize, errors: Vec<(String, String)> }`.\n\n```rust\npub struct MigrateReport {\n pub updated: usize,\n pub already_current: usize,\n pub errors: Vec<(String, String)>, // (filename, error message)\n}\n```\n\n### display.rs\n\nAdd `print_migrate_report(report: &MigrateReport)`:\n```\nMigrated 3 tickets.\nCurrent 5 tickets (already up to date).\nErrors 1 ticket could not be migrated:\n bad_ticket.json: trailing comma at line 4\n```\n\nWhen `--json`, serialise `MigrateReport` directly (derive `Serialize`).\n\n## How schema changes use this\n\nFor **field removal** (e.g. removing `id` from JSON):\n- Old files have `\"id\": \"...\"` → on deserialise, serde ignores it (unknown field).\n- Re-serialise → `id` is absent (since `#[serde(skip)]`).\n- File bytes differ → `migrate` rewrites.\n\nFor **field addition** (e.g. adding `tags: Vec<String>` later):\n- New field in `Ticket` gets `#[serde(default)]`.\n- Old files lack `tags` → deserialise gives `vec![]`.\n- Re-serialise → `\"tags\": []` is written.\n- File bytes differ → `migrate` rewrites.\n\n## Tests\n\nUnit tests (`src/tests.rs`):\n- `migrate_tickets` on a store with old-format files (containing `\"id\"`) rewrites them without `id`.\n- `migrate_tickets` on an already-current store returns `updated: 0`, `already_current: N`.\n- `migrate_tickets --dry-run` does not modify files on disk.\n- A file with invalid JSON is counted in `errors` and left unchanged.\n\nIntegration tests (`tests/integration.rs`):\n- Create tickets with old code (inject `id` manually into JSON), run `nbd migrate`, verify `id` is gone from files.\n- `nbd migrate --dry-run` reports changes but does not modify files.\n- `nbd migrate` exits zero even when some tickets error (but prints error summary).\n- `nbd migrate --json` outputs a valid JSON object with `updated`, `already_current`, `errors` fields.\n\n## Files touched\n- `src/main.rs` — `Migrate` command, `cmd_migrate`\n- `src/store.rs` — `migrate_tickets`, `MigrateReport`\n- `src/display.rs` — `print_migrate_report`\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests\n- `README.md` — document `nbd migrate`",
"priority": 9, "priority": 9,
"status": "todo", "status": "done",
"dependencies": [ "dependencies": [
"d1634a" "d1634a"
], ],

@ -64,6 +64,18 @@ nbd update a3f9c2 --status in_progress
nbd update a3f9c2 --priority 9 --type bug nbd update a3f9c2 --priority 9 --type bug
``` ```
### Migrate ticket files
Re-serialise all ticket files through the current schema. Use this after
upgrading `nbd` to remove stale fields, add new fields with defaults, and
normalise formatting.
```sh
nbd migrate
nbd migrate --dry-run # preview changes without writing
nbd migrate --json # machine-readable summary
```
## Running ## Running
```sh ```sh

@ -7,6 +7,7 @@
//! in command handlers. The corresponding `format_*` functions return a //! in command handlers. The corresponding `format_*` functions return a
//! `String` and are provided primarily for testing and composition. //! `String` and are provided primarily for testing and composition.
use crate::store::MigrateReport;
use crate::ticket::{Status, Ticket, TicketType}; use crate::ticket::{Status, Ticket, TicketType};
// ── Column widths for the summary list table ───────────────────────────────── // ── Column widths for the summary list table ─────────────────────────────────
@ -190,3 +191,65 @@ pub fn format_list_json(tickets: &[Ticket]) -> String {
pub fn print_list_json(tickets: &[Ticket]) { pub fn print_list_json(tickets: &[Ticket]) {
println!("{}", format_list_json(tickets)); println!("{}", format_list_json(tickets));
} }
/// Format a [`MigrateReport`] as a human-readable summary.
///
/// ```text
/// Migrated 3 tickets.
/// Current 5 tickets (already up to date).
/// Errors 1 ticket could not be migrated:
/// bad_ticket.json: trailing comma at line 4
/// ```
pub fn format_migrate_report(report: &MigrateReport) -> String {
let mut out = format!(
"Migrated {} ticket{}.\nCurrent {} ticket{} (already up to date).",
report.updated,
if report.updated == 1 { "" } else { "s" },
report.already_current,
if report.already_current == 1 { "" } else { "s" },
);
if !report.errors.is_empty() {
out.push_str(&format!(
"\nErrors {} ticket{} could not be migrated:",
report.errors.len(),
if report.errors.len() == 1 { "" } else { "s" },
));
for (filename, msg) in &report.errors {
out.push_str(&format!("\n {filename}: {msg}"));
}
}
out
}
/// Print a [`MigrateReport`] as a human-readable summary to stdout.
pub fn print_migrate_report(report: &MigrateReport) {
println!("{}", format_migrate_report(report));
}
/// Format a [`MigrateReport`] as a JSON object.
///
/// The JSON has three keys: `updated`, `already_current`, and `errors`.
/// `errors` is an array of objects with `filename` and `message` fields.
pub fn format_migrate_report_json(report: &MigrateReport) -> String {
let errors: Vec<serde_json::Value> = report
.errors
.iter()
.map(|(filename, msg)| {
serde_json::json!({
"filename": filename,
"message": msg,
})
})
.collect();
let value = serde_json::json!({
"updated": report.updated,
"already_current": report.already_current,
"errors": errors,
});
serde_json::to_string_pretty(&value).expect("migrate report serialisation must not fail")
}
/// Print a [`MigrateReport`] as a JSON object to stdout.
pub fn print_migrate_report_json(report: &MigrateReport) {
println!("{}", format_migrate_report_json(report));
}

@ -13,7 +13,9 @@ mod tests;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use crate::store::{ensure_tickets_dir, find_nbd_root, list_tickets, read_ticket, write_ticket}; use crate::store::{
ensure_tickets_dir, find_nbd_root, list_tickets, migrate_tickets, read_ticket, write_ticket,
};
use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType}; use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType};
// ── CLI definition ──────────────────────────────────────────────────────────── // ── CLI definition ────────────────────────────────────────────────────────────
@ -73,6 +75,17 @@ enum Commands {
/// List all tickets sorted by priority (highest first). /// List all tickets sorted by priority (highest first).
List, List,
/// Re-serialise all ticket files through the current schema.
///
/// Brings existing files into conformance with the current data model:
/// removes stale fields, adds new fields with their defaults, and
/// normalises formatting. Exits zero even when some files have errors.
Migrate {
/// Print what would change without writing any files.
#[arg(long)]
dry_run: bool,
},
/// Update fields of an existing ticket and print the result. /// Update fields of an existing ticket and print the result.
/// ///
/// Only the flags you supply are changed; all other fields retain their /// Only the flags you supply are changed; all other fields retain their
@ -130,6 +143,8 @@ async fn dispatch(cli: Cli) -> store::Result<()> {
deps, deps,
} => cmd_create(title, body, priority, status, ticket_type, deps, cli.json).await, } => cmd_create(title, body, priority, status, ticket_type, deps, cli.json).await,
Commands::Migrate { dry_run } => cmd_migrate(dry_run, cli.json).await,
Commands::Read { id } => cmd_read(id, cli.json).await, Commands::Read { id } => cmd_read(id, cli.json).await,
Commands::List => cmd_list(cli.json).await, Commands::List => cmd_list(cli.json).await,
@ -227,6 +242,24 @@ async fn validate_deps(root: &std::path::Path, deps: &[String]) -> store::Result
// ── Command handlers ────────────────────────────────────────────────────────── // ── Command handlers ──────────────────────────────────────────────────────────
/// Re-serialise all ticket files through the current schema and print a summary.
///
/// When `dry_run` is `true`, describe what *would* change without writing any
/// files. The command exits zero even when individual files fail to parse —
/// those errors are included in the summary.
async fn cmd_migrate(dry_run: bool, json: bool) -> store::Result<()> {
let root = find_nbd_root()?;
let report = migrate_tickets(&root, dry_run).await?;
if json {
display::print_migrate_report_json(&report);
} else {
display::print_migrate_report(&report);
}
Ok(())
}
/// Create a new ticket, persist it, and print it. /// Create a new ticket, persist it, and print it.
/// ///
/// Generates a fresh ID, validates `priority` and all dependency IDs, then /// Generates a fresh ID, validates `priority` and all dependency IDs, then

@ -138,6 +138,111 @@ pub async fn read_ticket(root: &Path, id: &str) -> Result<Ticket> {
} }
} }
/// Report produced by [`migrate_tickets`].
///
/// Summarises how many ticket files were updated, were already current, or
/// could not be parsed.
#[derive(Debug)]
pub struct MigrateReport {
/// Number of files that were re-serialised (had stale content).
pub updated: usize,
/// Number of files that were already in the current schema format.
pub already_current: usize,
/// Files that could not be deserialised. Each entry is `(filename, error)`.
pub errors: Vec<(String, String)>,
}
/// Re-serialise every ticket file through the current serde schema.
///
/// For each `*.json` file in the tickets directory:
/// - Deserialise into [`Ticket`], injecting the `id` from the filename stem.
/// - Re-serialise to the current pretty-printed JSON schema.
/// - If the bytes differ, write the new content (unless `dry_run` is `true`).
/// - If the bytes are identical, count the file as already current.
/// - If deserialisation fails, record the error and leave the file untouched.
///
/// The function always returns `Ok` — individual file errors are collected in
/// [`MigrateReport::errors`] rather than aborting early.
///
/// When `dry_run` is `true`, no files are written; the report describes what
/// *would* have changed.
///
/// # Errors
///
/// Returns an error only if the tickets directory itself cannot be read.
pub async fn migrate_tickets(root: &Path, dry_run: bool) -> Result<MigrateReport> {
let dir = tickets_dir(root);
let mut report = MigrateReport {
updated: 0,
already_current: 0,
errors: Vec::new(),
};
if !dir.is_dir() {
return Ok(report);
}
let mut entries = fs::read_dir(&dir).await?;
while let Some(entry) = entries.next().await {
let entry = entry?;
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let raw = match fs::read(&path).await {
Ok(b) => b,
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
};
let mut ticket: Ticket = match serde_json::from_slice(&raw) {
Ok(t) => t,
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
};
ticket.id = stem;
let new_json = match serde_json::to_string_pretty(&ticket) {
Ok(s) => s,
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
};
let new_bytes = new_json.as_bytes();
if raw == new_bytes {
report.already_current += 1;
} else if dry_run {
report.updated += 1;
} else {
if let Err(e) = fs::write(&path, new_bytes).await {
report.errors.push((filename, e.to_string()));
continue;
}
report.updated += 1;
}
}
Ok(report)
}
/// Read every `*.json` file in the tickets directory and return them sorted by /// Read every `*.json` file in the tickets directory and return them sorted by
/// priority descending (highest priority first). /// priority descending (highest priority first).
/// ///

@ -380,11 +380,148 @@ mod store {
} }
} }
// ── migrate ───────────────────────────────────────────────────────────────────
/// Tests for [`crate::store::migrate_tickets`].
mod migrate {
use crate::store::{ensure_tickets_dir, migrate_tickets, tickets_dir, write_ticket};
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.
#[async_std::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"}"#;
async_std::fs::write(dir.join("aabbcc.json"), old_json)
.await
.unwrap();
let report = migrate_tickets(&root, false).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 = async_std::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`.
#[async_std::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).await.unwrap();
write_ticket(&root, &t2).await.unwrap();
let report = migrate_tickets(&root, false).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.
#[async_std::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"}"#;
async_std::fs::write(dir.join("ccddee.json"), old_json)
.await
.unwrap();
let report = migrate_tickets(&root, true).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 = async_std::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.
#[async_std::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 }";
async_std::fs::write(dir.join("badbad.json"), bad_json)
.await
.unwrap();
let report = migrate_tickets(&root, false).await.unwrap();
assert_eq!(report.errors.len(), 1);
assert!(report.errors[0].0.contains("badbad.json"));
// File must remain unchanged.
let contents = async_std::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.
#[async_std::test]
async fn empty_store_returns_empty_report() {
let (tmp, root) = setup_store().await;
let report = migrate_tickets(&root, false).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.
#[async_std::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).await.unwrap();
assert_eq!(report.updated, 0);
assert_eq!(report.already_current, 0);
assert!(report.errors.is_empty());
drop(tmp);
}
}
// ── display module ──────────────────────────────────────────────────────────── // ── display module ────────────────────────────────────────────────────────────
/// Tests for [`crate::display`]. /// Tests for [`crate::display`].
mod display { mod display {
use crate::display::{format_list, format_list_json, format_ticket, format_ticket_json}; use crate::display::{
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}; use crate::ticket::{Status, Ticket, TicketType};
/// Build a fully-populated ticket for use in display tests. /// Build a fully-populated ticket for use in display tests.
@ -532,4 +669,56 @@ mod display {
assert!(parsed.is_array()); assert!(parsed.is_array());
assert!(parsed.as_array().unwrap().is_empty()); 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,
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,
errors: vec![],
};
let output = format_migrate_report(&report);
assert!(
!output.contains("Errors"),
"should not show Errors when none"
);
}
/// `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,
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");
}
} }

@ -282,6 +282,106 @@ fn read_json_contains_correct_id() {
); );
} }
/// `migrate` re-writes old-format files that contain a stale `"id"` key.
#[test]
fn migrate_rewrites_old_format_files() {
let env = TestEnv::new();
// Manually write an old-format ticket file with "id" in the JSON body.
let old_json = r#"{"id":"abcdef","title":"Old format ticket","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let ticket_file = env.root.join(".nbd").join("tickets").join("abcdef.json");
fs::write(&ticket_file, old_json).unwrap();
let output = env.run(&["migrate"]);
assert!(
output.status.success(),
"migrate failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Verify the file no longer contains the "id" key.
let contents = fs::read_to_string(&ticket_file).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert!(
parsed.get("id").is_none(),
"migrated file must not contain 'id', got: {contents}"
);
}
/// `migrate --dry-run` does not modify files.
#[test]
fn migrate_dry_run_does_not_write() {
let env = TestEnv::new();
let old_json = r#"{"id":"112233","title":"Dry run test","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let ticket_file = env.root.join(".nbd").join("tickets").join("112233.json");
fs::write(&ticket_file, old_json).unwrap();
let output = env.run(&["migrate", "--dry-run"]);
assert!(output.status.success());
// File must remain unchanged.
let contents = fs::read_to_string(&ticket_file).unwrap();
assert_eq!(contents, old_json, "dry-run must not modify files");
}
/// `migrate` exits zero even when some ticket files cannot be parsed.
#[test]
fn migrate_exits_zero_on_parse_errors() {
let env = TestEnv::new();
let bad_json = b"{ not valid json at all }";
let ticket_file = env.root.join(".nbd").join("tickets").join("badbad.json");
fs::write(&ticket_file, bad_json).unwrap();
let output = env.run(&["migrate"]);
assert!(
output.status.success(),
"migrate should exit zero even with parse errors"
);
// File must remain unchanged.
let contents = fs::read(&ticket_file).unwrap();
assert_eq!(
contents.as_slice(),
bad_json,
"errored file must be left unchanged"
);
}
/// `migrate --json` outputs valid JSON with the expected keys.
#[test]
fn migrate_with_json_flag() {
let env = TestEnv::new();
// Write one old-format and one valid ticket.
env.create(&["--title", "Normal ticket"]);
let old_json = r#"{"id":"xxyyzz","title":"Legacy","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let ticket_file = env.root.join(".nbd").join("tickets").join("xxyyzz.json");
fs::write(&ticket_file, old_json).unwrap();
let output = env.run(&["migrate", "--json"]);
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("--json output should be valid JSON");
assert!(
parsed.get("updated").is_some(),
"JSON should have 'updated' key"
);
assert!(
parsed.get("already_current").is_some(),
"JSON should have 'already_current' key"
);
assert!(
parsed.get("errors").is_some(),
"JSON should have 'errors' key"
);
assert_eq!(parsed["updated"], 1, "one file should be migrated");
assert_eq!(parsed["already_current"], 1, "one file should be current");
}
/// `update --deps` replaces the dependency list. /// `update --deps` replaces the dependency list.
#[test] #[test]
fn update_deps_replaces_list() { fn update_deps_replaces_list() {

Loading…
Cancel
Save