diff --git a/nbd/.nbd/tickets/0f51af.json b/nbd/.nbd/tickets/0f51af.json index e5073ac..7f5e740 100644 --- a/nbd/.nbd/tickets/0f51af.json +++ b/nbd/.nbd/tickets/0f51af.json @@ -1,9 +1,8 @@ { - "id": "0f51af", "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`:\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` 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, - "status": "todo", + "status": "done", "dependencies": [ "d1634a" ], diff --git a/nbd/README.md b/nbd/README.md index 50e187a..66f5dde 100644 --- a/nbd/README.md +++ b/nbd/README.md @@ -64,6 +64,18 @@ nbd update a3f9c2 --status in_progress 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 ```sh diff --git a/nbd/src/display.rs b/nbd/src/display.rs index 0d43b43..1a8f532 100644 --- a/nbd/src/display.rs +++ b/nbd/src/display.rs @@ -7,6 +7,7 @@ //! in command handlers. The corresponding `format_*` functions return a //! `String` and are provided primarily for testing and composition. +use crate::store::MigrateReport; use crate::ticket::{Status, Ticket, TicketType}; // ── 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]) { 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 = 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)); +} diff --git a/nbd/src/main.rs b/nbd/src/main.rs index ffb3efc..577120c 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -13,7 +13,9 @@ mod tests; 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}; // ── CLI definition ──────────────────────────────────────────────────────────── @@ -73,6 +75,17 @@ enum Commands { /// List all tickets sorted by priority (highest first). 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. /// /// Only the flags you supply are changed; all other fields retain their @@ -130,6 +143,8 @@ async fn dispatch(cli: Cli) -> store::Result<()> { deps, } => 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::List => cmd_list(cli.json).await, @@ -227,6 +242,24 @@ async fn validate_deps(root: &std::path::Path, deps: &[String]) -> store::Result // ── 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. /// /// Generates a fresh ID, validates `priority` and all dependency IDs, then diff --git a/nbd/src/store.rs b/nbd/src/store.rs index a2d5f85..3ff2f16 100644 --- a/nbd/src/store.rs +++ b/nbd/src/store.rs @@ -138,6 +138,111 @@ pub async fn read_ticket(root: &Path, id: &str) -> Result { } } +/// 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 { + 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 /// priority descending (highest priority first). /// diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index 2b81917..f8174ef 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -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 ──────────────────────────────────────────────────────────── /// Tests for [`crate::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}; /// Build a fully-populated ticket for use in display tests. @@ -532,4 +669,56 @@ mod display { 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, + 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"); + } } diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index 42501a3..aab5596 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -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. #[test] fn update_deps_replaces_list() {