diff --git a/nbd/.nbd/tickets/460caf.json b/nbd/.nbd/tickets/460caf.json index 57bcd56..33c8626 100644 --- a/nbd/.nbd/tickets/460caf.json +++ b/nbd/.nbd/tickets/460caf.json @@ -2,7 +2,7 @@ "title": "Multiple file format support (md, toml, jsonb)", "body": "Add `--ftype` flag to `create` and `update` to write tickets in markdown, TOML, or binary JSON (CBOR) in addition to the existing JSON format. Format is detected from file extension on read.\n\n## Motivation\n\nMarkdown format lets agents write long-form ticket bodies with full markdown syntax, and makes tickets human-readable in a file browser. TOML is a natural config format. CBOR offers compact binary storage.\n\n## Approach\n\n### New crate dependencies (Cargo.toml)\nEvaluate and add:\n- `toml` — TOML serialisation (likely `toml = \"0.8\"`)\n- `serde_yml` or `serde_yaml` — YAML frontmatter (for `.md` files)\n- `ciborium` — CBOR binary JSON (`.jsonb`)\n\n### ticket.rs\nNo changes needed — `Ticket` already derives `Serialize`/`Deserialize`.\n\n### store.rs\nNew enum `FileFormat { Json, Markdown, Toml, Jsonb }`.\n\nNew function `detect_format(path: &Path) -> FileFormat`:\n- `.json` → `Json`\n- `.md` → `Markdown`\n- `.toml` → `Toml`\n- `.jsonb` → `Jsonb`\n- Unknown → `Json` (fallback)\n\nUpdate `ticket_path(root, id, format)` to use the format-appropriate extension. This is a breaking change to the function signature — update all callers.\n\nUpdate `read_ticket(root, id)`:\n1. Try each known extension in order until a file is found.\n2. Read the file and dispatch to the format-appropriate deserialiser.\n\nSerialisation helpers (private):\n- `serialize_json(ticket) -> String`\n- `serialize_toml(ticket) -> String`\n- `serialize_markdown(ticket) -> String` — TOML frontmatter (`+++` delimiters) with body as file content\n- `serialize_jsonb(ticket) -> Vec`\n\nDeserialization helpers (private):\n- `deserialize_markdown(bytes) -> Result` — parse frontmatter + body\n\nUpdate `list_tickets` to scan for `*.json`, `*.md`, `*.toml`, `*.jsonb` files.\n\nUpdate `write_ticket` to accept `format: FileFormat` and write in the appropriate format.\n\n### main.rs\nAdd `--ftype [json|md|toml|jsonb]` option (default `json`) to `create` and `update`.\nConversion on `update --ftype`: read old file, write new format, delete old file (if extension changed).\n\n## Markdown format (TOML frontmatter)\n```\n+++\nid = \"a3f9c2\"\ntitle = \"Fix login bug\"\npriority = 8\nstatus = \"in_progress\"\nticket_type = \"bug\"\ndependencies = [\"b7d41e\"]\n+++\n\nLong-form body text goes here. Supports full markdown.\n```\n\n## Tests\n- Unit tests: roundtrip each format (JSON already tested).\n- Integration tests: `nbd create --ftype md` creates a `.md` file; `nbd read` finds and parses it.\n- Integration test: `nbd update --ftype toml` converts format and removes old file.\n\n## Files touched\n- `Cargo.toml` — new dependencies\n- `src/store.rs` — format detection, multi-format read/write, updated `list_tickets`\n- `src/main.rs` — `--ftype` flags\n- `src/tests.rs` — format roundtrip tests\n- `tests/integration.rs` — format integration tests\n- `README.md` — document `--ftype`\n- `docs/ARCHITECTURE.md` — update storage layout section", "priority": 5, - "status": "todo", + "status": "done", "dependencies": [], "ticket_type": "feature" } \ No newline at end of file diff --git a/nbd/Cargo.lock b/nbd/Cargo.lock index 4c3d153..393fbac 100644 --- a/nbd/Cargo.lock +++ b/nbd/Cargo.lock @@ -225,6 +225,33 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.5.60" @@ -286,6 +313,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "equivalent" version = "1.0.2" @@ -418,6 +451,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -532,10 +576,12 @@ name = "nbd" version = "0.1.0" dependencies = [ "async-std", + "ciborium", "clap", "serde", "serde_json", "tempfile", + "toml", ] [[package]] @@ -695,6 +741,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "slab" version = "0.4.12" @@ -742,6 +797,47 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -902,6 +998,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -990,6 +1095,26 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/nbd/Cargo.toml b/nbd/Cargo.toml index 715d0a6..ab64fdc 100644 --- a/nbd/Cargo.toml +++ b/nbd/Cargo.toml @@ -14,6 +14,8 @@ clap = { version = "4", features = ["derive"] } async-std = { version = "1", features = ["attributes"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" +ciborium = "0.2" [dev-dependencies] tempfile = "3" diff --git a/nbd/README.md b/nbd/README.md index 9bc0026..125f89c 100644 --- a/nbd/README.md +++ b/nbd/README.md @@ -21,6 +21,16 @@ Each ticket is a JSON file named `{id}.json`, where `id` is a unique | `ticket_type` | `project` \| `feature` \| `task` \| `bug` | `task` | | `dependencies` | list of ticket IDs | `[]` | +Tickets can be stored in multiple formats, selected at creation time with +`--ftype`: + +| Format | Extension | Description | +|---|---|---| +| `json` | `.json` | Pretty-printed JSON (default) | +| `md` | `.md` | Markdown body with TOML frontmatter | +| `toml` | `.toml` | TOML | +| `jsonb` | `.jsonb` | CBOR binary | + All commands accept `--json` for machine-readable output. ## Usage @@ -40,6 +50,8 @@ Analogous to `git init` — safe to run multiple times. ```sh nbd create --title "Fix login bug" --priority 8 --type bug nbd create --title "Add rate limiting" --body "Protect public endpoints" --deps a3f9c2 +nbd create --title "Long-form spec" --body "# Section\n\nDetails..." --ftype md +nbd create --title "Config ticket" --ftype toml ``` ### Read a ticket @@ -80,11 +92,13 @@ depends on an archived ticket becomes unblocked. ### Update a ticket Only the flags you supply are changed; all other fields retain their current -values. +values. Use `--ftype` to convert to a different storage format (the old file +is removed automatically). ```sh nbd update a3f9c2 --status in_progress nbd update a3f9c2 --priority 9 --type bug +nbd update a3f9c2 --ftype md # convert to markdown format ``` ### Find the next ticket to work on diff --git a/nbd/src/main.rs b/nbd/src/main.rs index 3521ff6..e3a56fe 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -24,8 +24,8 @@ mod tests; use clap::{Parser, Subcommand}; use crate::store::{ - ensure_tickets_dir, find_nbd_root, list_tickets, migrate_tickets, read_ticket, resolve_id, - write_ticket, + detect_format, ensure_tickets_dir, find_nbd_root, find_ticket_path, list_tickets, + migrate_tickets, read_ticket, resolve_id, write_ticket, FileFormat, }; use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType}; @@ -75,6 +75,10 @@ enum Commands { /// Comma-separated list of dependency ticket IDs (e.g. `a3f9c2,b7d41e`). #[arg(long)] deps: Option, + + /// File format to use for storage: `json`, `md`, `toml`, or `jsonb` (default: `json`). + #[arg(long = "ftype", default_value = "json")] + ftype: String, }, /// Print a single ticket by ID. @@ -218,6 +222,13 @@ enum Commands { /// New comma-separated dependency IDs (replaces the existing list). #[arg(long)] deps: Option, + + /// Convert to a different file format: `json`, `md`, `toml`, or `jsonb`. + /// + /// When specified, the ticket is re-serialised in the new format and + /// the old file is removed if the extension differs. + #[arg(long = "ftype")] + ftype: Option, }, } @@ -242,7 +253,20 @@ async fn dispatch(cli: Cli) -> store::Result<()> { status, ticket_type, deps, - } => cmd_create(title, body, priority, status, ticket_type, deps, cli.json).await, + ftype, + } => { + cmd_create( + title, + body, + priority, + status, + ticket_type, + deps, + ftype, + cli.json, + ) + .await + } Commands::Init => cmd_init(cli.json).await, @@ -268,6 +292,7 @@ async fn dispatch(cli: Cli) -> store::Result<()> { status, ticket_type, deps, + ftype, } => { cmd_update( id, @@ -277,6 +302,7 @@ async fn dispatch(cli: Cli) -> store::Result<()> { status, ticket_type, deps, + ftype, cli.json, ) .await @@ -326,6 +352,19 @@ fn parse_ticket_type(s: &str) -> store::Result { } } +/// Parse a [`FileFormat`] from its string representation. +/// +/// Accepts `"json"`, `"md"`, `"toml"`, and `"jsonb"`. +/// +/// # Errors +/// +/// Returns an error if `s` does not match a known format. +fn parse_file_format(s: &str) -> store::Result { + FileFormat::from_str(s).ok_or_else(|| { + format!("unknown file format '{s}'; expected 'json', 'md', 'toml', or 'jsonb'").into() + }) +} + /// Split a comma-separated dependency string into a `Vec`. /// /// Returns an empty `Vec` when `deps` is `None` or an empty string. @@ -503,7 +542,8 @@ async fn cmd_migrate(filter_args: Vec, dry_run: bool, json: bool) -> sto /// Create a new ticket, persist it, and print it. /// /// Generates a fresh ID, validates `priority` and all dependency IDs, then -/// writes the ticket to `.nbd/tickets/{id}.json`. +/// writes the ticket to `.nbd/tickets/{id}.{ext}` using the chosen format. +#[allow(clippy::too_many_arguments)] async fn cmd_create( title: String, body: String, @@ -511,11 +551,13 @@ async fn cmd_create( status: String, ticket_type: String, deps: Option, + ftype: String, json: bool, ) -> store::Result<()> { validate_priority(priority) .map_err(|e| -> Box { e.into() })?; + let format = parse_file_format(&ftype)?; let root = find_nbd_root()?; ensure_tickets_dir(&root).await?; @@ -530,7 +572,7 @@ async fn cmd_create( ticket.ticket_type = parse_ticket_type(&ticket_type)?; ticket.dependencies = dependencies; - write_ticket(&root, &ticket).await?; + write_ticket(&root, &ticket, format).await?; if json { display::print_ticket_json(&ticket); @@ -599,13 +641,16 @@ async fn cmd_list(filter_args: Vec, all: bool, json: bool) -> store::Res /// Archive a ticket by setting its status to [`Status::Closed`] and printing it. /// /// The ticket is preserved on disk but excluded from normal `nbd list` output. -/// This is syntactic sugar for `nbd update --status closed`. +/// The file is re-written in its existing format. This is syntactic sugar for +/// `nbd update --status closed`. async fn cmd_archive(id: String, json: bool) -> store::Result<()> { let root = find_nbd_root()?; let id = resolve_id(&root, &id).await?; + let existing_path = find_ticket_path(&root, &id).await?; + let format = detect_format(&existing_path); let mut ticket = read_ticket(&root, &id).await?; ticket.status = Status::Closed; - write_ticket(&root, &ticket).await?; + write_ticket(&root, &ticket, format).await?; if json { display::print_ticket_json(&ticket); @@ -637,8 +682,11 @@ fn cmd_claude_md(json: bool) -> store::Result<()> { /// Update the specified fields of an existing ticket, persist it, and print it. /// /// Only the flags explicitly passed on the command line are applied; all other -/// fields keep their current values. `id` may be a full 6-character ID or a +/// fields keep their current values. `id` may be a full 6-character ID or a /// unique prefix. +/// +/// When `ftype` is supplied and differs from the existing format, the ticket +/// is written in the new format and the old file is removed. #[allow(clippy::too_many_arguments)] async fn cmd_update( id: String, @@ -648,10 +696,16 @@ async fn cmd_update( status: Option, ticket_type: Option, deps: Option, + ftype: Option, json: bool, ) -> store::Result<()> { let root = find_nbd_root()?; let id = resolve_id(&root, &id).await?; + + // Detect the existing file's format so we can preserve or replace it. + let existing_path = find_ticket_path(&root, &id).await?; + let old_format = detect_format(&existing_path); + let mut ticket = read_ticket(&root, &id).await?; if let Some(t) = title { @@ -677,7 +731,17 @@ async fn cmd_update( ticket.dependencies = dependencies; } - write_ticket(&root, &ticket).await?; + let new_format = match ftype { + Some(ref s) => parse_file_format(s)?, + None => old_format, + }; + + write_ticket(&root, &ticket, new_format).await?; + + // Remove the old file when the format changed (different extension = different path). + if new_format != old_format { + async_std::fs::remove_file(&existing_path).await?; + } if json { display::print_ticket_json(&ticket); diff --git a/nbd/src/store.rs b/nbd/src/store.rs index c632584..3da5009 100644 --- a/nbd/src/store.rs +++ b/nbd/src/store.rs @@ -1,17 +1,27 @@ //! File I/O and directory traversal for ticket storage. //! -//! Tickets are stored as `.json` files inside `.nbd/tickets/` relative to the -//! project root. The root is discovered by walking up from the current working -//! directory until a `.nbd/` directory is found, mirroring how `git` locates -//! `.git/`. +//! Tickets are stored in `.nbd/tickets/` relative to the project root. +//! Each ticket is a single file named `{id}.{ext}`, where the extension +//! depends on the [`FileFormat`]: +//! +//! | Format | Extension | Description | +//! |---|---|---| +//! | [`FileFormat::Json`] | `.json` | Pretty-printed JSON (default) | +//! | [`FileFormat::Markdown`] | `.md` | Markdown body with TOML frontmatter | +//! | [`FileFormat::Toml`] | `.toml` | TOML | +//! | [`FileFormat::Jsonb`] | `.jsonb` | CBOR binary | +//! +//! The root is discovered by walking up from the current working directory +//! until a `.nbd/` directory is found, mirroring how `git` locates `.git/`. use std::path::{Path, PathBuf}; use async_std::fs; use async_std::prelude::*; +use serde::{Deserialize, Serialize}; use crate::filter::TicketFilter; -use crate::ticket::Ticket; +use crate::ticket::{Status, Ticket, TicketType}; /// Convenience alias for fallible operations in this module. /// @@ -20,6 +30,207 @@ use crate::ticket::Ticket; /// can be returned with `?` without additional wrapping. pub type Result = std::result::Result>; +/// Convert a string message into a boxed error, with an unambiguous type. +fn msg_err(s: String) -> Box { + s.into() +} + +// ── FileFormat ──────────────────────────────────────────────────────────────── + +/// The on-disk serialisation format for a ticket file. +/// +/// The format is determined by the file extension when reading, and must be +/// supplied explicitly when writing a new file. `Json` is the default. +/// +/// # Examples +/// +/// ``` +/// use nbd::store::FileFormat; +/// assert_eq!(FileFormat::Json.extension(), "json"); +/// assert_eq!(FileFormat::Markdown.extension(), "md"); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FileFormat { + /// Pretty-printed JSON (`.json`). The original and default format. + #[default] + Json, + /// Markdown file with TOML frontmatter (`.md`). + /// + /// The ticket body is stored as the file content after the closing `+++` + /// delimiter; all other fields live in the TOML frontmatter block. + Markdown, + /// TOML (`.toml`). All ticket fields except `id` are stored as TOML. + Toml, + /// CBOR binary encoding (`.jsonb`). Compact binary alternative to JSON. + Jsonb, +} + +impl FileFormat { + /// The file extension associated with this format (without a leading dot). + pub fn extension(self) -> &'static str { + match self { + FileFormat::Json => "json", + FileFormat::Markdown => "md", + FileFormat::Toml => "toml", + FileFormat::Jsonb => "jsonb", + } + } + + /// Parse a [`FileFormat`] from a user-supplied string. + /// + /// Accepts `"json"`, `"md"`, `"toml"`, and `"jsonb"`. + /// Returns `None` for unrecognised values. + pub fn from_str(s: &str) -> Option { + match s { + "json" => Some(FileFormat::Json), + "md" => Some(FileFormat::Markdown), + "toml" => Some(FileFormat::Toml), + "jsonb" => Some(FileFormat::Jsonb), + _ => None, + } + } +} + +impl std::fmt::Display for FileFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.extension()) + } +} + +/// File extensions recognised as ticket files, tried in this order on reads. +const KNOWN_EXTENSIONS: &[&str] = &["json", "md", "toml", "jsonb"]; + +/// Detect the [`FileFormat`] from a file path's extension. +/// +/// Falls back to [`FileFormat::Json`] for absent or unrecognised extensions. +pub fn detect_format(path: &Path) -> FileFormat { + match path.extension().and_then(|e| e.to_str()) { + Some("json") => FileFormat::Json, + Some("md") => FileFormat::Markdown, + Some("toml") => FileFormat::Toml, + Some("jsonb") => FileFormat::Jsonb, + _ => FileFormat::Json, + } +} + +// ── Markdown frontmatter helper ─────────────────────────────────────────────── + +/// Ticket metadata stored in TOML frontmatter for `.md` files. +/// +/// The `body` field is intentionally absent here: in the markdown format the +/// body is the file content that follows the closing `+++` delimiter. +#[derive(Serialize, Deserialize)] +struct MarkdownFrontmatter { + title: String, + priority: u8, + status: Status, + ticket_type: TicketType, + dependencies: Vec, +} + +// ── Serialisation helpers ───────────────────────────────────────────────────── + +/// Serialise `ticket` to a pretty-printed JSON string. +fn serialize_json(ticket: &Ticket) -> Result { + Ok(serde_json::to_string_pretty(ticket)?) +} + +/// Serialise `ticket` to a TOML string. +fn serialize_toml(ticket: &Ticket) -> Result { + Ok(toml::to_string(ticket)?) +} + +/// Serialise `ticket` to a markdown document with TOML frontmatter. +/// +/// Format: +/// ```text +/// +++ +/// title = "..." +/// priority = 5 +/// ... +/// +++ +/// Body content here. +/// ``` +fn serialize_markdown(ticket: &Ticket) -> Result { + let fm = MarkdownFrontmatter { + title: ticket.title.clone(), + priority: ticket.priority, + status: ticket.status.clone(), + ticket_type: ticket.ticket_type.clone(), + dependencies: ticket.dependencies.clone(), + }; + let toml_str = toml::to_string(&fm)?; + Ok(format!("+++\n{toml_str}+++\n{}", ticket.body)) +} + +/// Serialise `ticket` to CBOR binary. +fn serialize_jsonb(ticket: &Ticket) -> Result> { + let mut buf = Vec::new(); + ciborium::ser::into_writer(ticket, &mut buf) + .map_err(|e| msg_err(format!("CBOR serialization error: {e}")))?; + Ok(buf) +} + +// ── Deserialisation helpers ─────────────────────────────────────────────────── + +/// Deserialise a ticket from JSON bytes. +fn deserialize_json(bytes: &[u8]) -> Result { + Ok(serde_json::from_slice(bytes)?) +} + +/// Deserialise a ticket from TOML bytes. +fn deserialize_toml(bytes: &[u8]) -> Result { + let s = std::str::from_utf8(bytes)?; + Ok(toml::from_str(s)?) +} + +/// Deserialise a ticket from a markdown document with TOML frontmatter. +/// +/// The document must begin with `+++\n`, contain a closing `\n+++\n`, and +/// have TOML key-value pairs between the two delimiters. Everything after the +/// closing delimiter becomes the ticket body. +fn deserialize_markdown(bytes: &[u8]) -> Result { + let content = std::str::from_utf8(bytes)?; + + let after_open = content + .strip_prefix("+++\n") + .ok_or("markdown ticket must start with '+++\\n'")?; + + let (fm_str, body) = after_open + .split_once("\n+++\n") + .ok_or("markdown ticket is missing a closing '+++' delimiter")?; + + let fm: MarkdownFrontmatter = toml::from_str(fm_str)?; + + Ok(Ticket { + id: String::new(), + title: fm.title, + body: body.to_string(), + priority: fm.priority, + status: fm.status, + ticket_type: fm.ticket_type, + dependencies: fm.dependencies, + }) +} + +/// Deserialise a ticket from CBOR binary bytes. +fn deserialize_jsonb(bytes: &[u8]) -> Result { + ciborium::de::from_reader(bytes) + .map_err(|e| msg_err(format!("CBOR deserialization error: {e}"))) +} + +/// Deserialise `bytes` using the detected `format`. +fn deserialize_by_format(bytes: &[u8], format: FileFormat) -> Result { + match format { + FileFormat::Json => deserialize_json(bytes), + FileFormat::Markdown => deserialize_markdown(bytes), + FileFormat::Toml => deserialize_toml(bytes), + FileFormat::Jsonb => deserialize_jsonb(bytes), + } +} + +// ── Directory helpers ───────────────────────────────────────────────────────── + /// Walk upward from `start` until a directory containing `.nbd/` is found. /// /// Returns the first ancestor path (inclusive of `start`) that contains a @@ -88,37 +299,73 @@ pub async fn ensure_tickets_dir(root: &Path) -> Result<()> { Ok(()) } -/// Return the path to a specific ticket's JSON file. +// ── Path helpers ────────────────────────────────────────────────────────────── + +/// Return the path to a specific ticket file for the given `format`. /// /// This is a pure path computation — it does not check whether the file /// exists. -pub fn ticket_path(root: &Path, id: &str) -> PathBuf { - tickets_dir(root).join(format!("{id}.json")) +pub fn ticket_path(root: &Path, id: &str, format: FileFormat) -> PathBuf { + tickets_dir(root).join(format!("{id}.{}", format.extension())) +} + +/// Find and return the actual on-disk path of a ticket, trying each known +/// extension in order. +/// +/// # Errors +/// +/// Returns an error if no file with a known extension exists for `id`. +pub async fn find_ticket_path(root: &Path, id: &str) -> Result { + let dir = tickets_dir(root); + for ext in KNOWN_EXTENSIONS { + let path = dir.join(format!("{id}.{ext}")); + if path.is_file() { + return Ok(path); + } + } + Err(format!("ticket '{id}' not found").into()) } -/// Serialise `ticket` as pretty-printed JSON and write it to -/// `.nbd/tickets/{id}.json`. +// ── CRUD operations ─────────────────────────────────────────────────────────── + +/// Serialise `ticket` and write it to `.nbd/tickets/{id}.{ext}` using +/// `format` to determine both the extension and the serialisation. /// -/// Overwrites any existing file with the same ID. The tickets directory must +/// Overwrites any existing file at the same path. The tickets directory must /// already exist; call [`ensure_tickets_dir`] before calling this for a new /// ticket. /// /// # Errors /// -/// Returns an error if JSON serialisation fails or if the file cannot be -/// written. -pub async fn write_ticket(root: &Path, ticket: &Ticket) -> Result<()> { - let path = ticket_path(root, &ticket.id); - let json = serde_json::to_string_pretty(ticket)?; - fs::write(path, json).await?; +/// Returns an error if serialisation fails or if the file cannot be written. +pub async fn write_ticket(root: &Path, ticket: &Ticket, format: FileFormat) -> Result<()> { + let path = ticket_path(root, &ticket.id, format); + match format { + FileFormat::Json => { + let json = serialize_json(ticket)?; + fs::write(path, json).await?; + } + FileFormat::Markdown => { + let content = serialize_markdown(ticket)?; + fs::write(path, content).await?; + } + FileFormat::Toml => { + let content = serialize_toml(ticket)?; + fs::write(path, content).await?; + } + FileFormat::Jsonb => { + let bytes = serialize_jsonb(ticket)?; + fs::write(path, bytes).await?; + } + } Ok(()) } /// Resolve a full 6-character ticket ID from an exact ID or a unique prefix. /// -/// If `id_or_prefix` is already an exact match for a ticket on disk, it is -/// returned unchanged. Otherwise all ticket filenames whose stem starts with -/// `id_or_prefix` are collected and: +/// If `id_or_prefix` is already an exact match for a ticket on disk (in any +/// supported format), it is returned unchanged. Otherwise all ticket filenames +/// whose stem starts with `id_or_prefix` are collected and: /// /// - **0 matches** → error: `"no ticket found matching '{prefix}'"` /// - **1 match** → the full 6-character ID is returned @@ -131,12 +378,16 @@ pub async fn write_ticket(root: &Path, ticket: &Ticket) -> Result<()> { pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result { let dir = tickets_dir(root); - // Fast path: if exactly 6 chars and the file exists, return it directly. - if id_or_prefix.len() == 6 && dir.join(format!("{id_or_prefix}.json")).is_file() { - return Ok(id_or_prefix.to_string()); + // Fast path: if exactly 6 chars, check all known extensions. + if id_or_prefix.len() == 6 { + for ext in KNOWN_EXTENSIONS { + if dir.join(format!("{id_or_prefix}.{ext}")).is_file() { + return Ok(id_or_prefix.to_string()); + } + } } - // Scan directory for prefix matches. + // Scan directory for prefix matches across all known formats. if !dir.is_dir() { return Err(format!("no ticket found matching '{id_or_prefix}'").into()); } @@ -146,12 +397,17 @@ pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result { while let Some(entry) = entries.next().await { let entry = entry?; - let path = entry.path(); - if path.extension().is_none_or(|ext| ext != "json") { + // Construct a std::path::PathBuf so it's compatible with Path helpers. + let path: PathBuf = dir.join(entry.file_name()); + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + if !KNOWN_EXTENSIONS.contains(&ext) { continue; } if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { - if stem.starts_with(id_or_prefix) { + if stem.starts_with(id_or_prefix) && !matches.contains(&stem.to_string()) { matches.push(stem.to_string()); } } @@ -173,27 +429,31 @@ pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result { /// Read and deserialise the ticket with the given `id` from disk. /// -/// The `id` is not stored inside the JSON file; it is injected from the -/// `id` parameter after deserialisation, making the filename stem the -/// authoritative source of truth. +/// Extensions are tried in the order defined by [`KNOWN_EXTENSIONS`]. The +/// first matching file is used. The `id` is not stored inside the file; it +/// is injected from the `id` parameter after deserialisation, making the +/// filename stem the authoritative source of truth. /// /// # Errors /// -/// Returns a descriptive error message if the ticket file is not found. +/// Returns a descriptive error message if no file is found for `id`. /// Propagates any other I/O or deserialisation error unchanged. pub async fn read_ticket(root: &Path, id: &str) -> Result { - let path = ticket_path(root, id); - match fs::read(&path).await { - Ok(bytes) => { - let mut ticket: Ticket = serde_json::from_slice(&bytes)?; - ticket.id = id.to_string(); - Ok(ticket) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - Err(format!("ticket '{id}' not found").into()) + let dir = tickets_dir(root); + for ext in KNOWN_EXTENSIONS { + let path = dir.join(format!("{id}.{ext}")); + match fs::read(&path).await { + Ok(bytes) => { + let format = detect_format(&path); + let mut ticket = deserialize_by_format(&bytes, format)?; + ticket.id = id.to_string(); + return Ok(ticket); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), } - Err(e) => Err(e.into()), } + Err(format!("ticket '{id}' not found").into()) } /// Report produced by [`migrate_tickets`]. @@ -214,19 +474,17 @@ pub struct MigrateReport { /// Re-serialise every ticket file through the current serde schema. /// -/// For each `*.json` file in the tickets directory: +/// For each ticket file in `.nbd/tickets/` (any supported format): /// - Deserialise into [`Ticket`], injecting the `id` from the filename stem. /// - If the ticket does not match `filter`, count it as skipped and continue. -/// - Re-serialise to the current pretty-printed JSON schema. +/// - Re-serialise in the same format. /// - 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. +/// *would* have changed. The function always returns `Ok` — individual file +/// errors are collected in [`MigrateReport::errors`] rather than aborting early. /// /// # Errors /// @@ -251,8 +509,14 @@ pub async fn migrate_tickets( 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") { + // Construct a std::path::PathBuf so it's compatible with Path helpers. + let path: PathBuf = dir.join(entry.file_name()); + + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + if !KNOWN_EXTENSIONS.contains(&ext) { continue; } @@ -275,7 +539,8 @@ pub async fn migrate_tickets( } }; - let mut ticket: Ticket = match serde_json::from_slice(&raw) { + let format = detect_format(&path); + let mut ticket: Ticket = match deserialize_by_format(&raw, format) { Ok(t) => t, Err(e) => { report.errors.push((filename, e.to_string())); @@ -290,21 +555,44 @@ pub async fn migrate_tickets( continue; } - let new_json = match serde_json::to_string_pretty(&ticket) { - Ok(s) => s, - Err(e) => { - report.errors.push((filename, e.to_string())); - continue; - } + // Re-serialise in the same format to normalise the schema. + let new_bytes: Vec = match format { + FileFormat::Json => match serialize_json(&ticket) { + Ok(s) => s.into_bytes(), + Err(e) => { + report.errors.push((filename, e.to_string())); + continue; + } + }, + FileFormat::Markdown => match serialize_markdown(&ticket) { + Ok(s) => s.into_bytes(), + Err(e) => { + report.errors.push((filename, e.to_string())); + continue; + } + }, + FileFormat::Toml => match serialize_toml(&ticket) { + Ok(s) => s.into_bytes(), + Err(e) => { + report.errors.push((filename, e.to_string())); + continue; + } + }, + FileFormat::Jsonb => match serialize_jsonb(&ticket) { + Ok(b) => b, + 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 { + if let Err(e) = fs::write(&path, &new_bytes).await { report.errors.push((filename, e.to_string())); continue; } @@ -315,41 +603,53 @@ pub async fn migrate_tickets( Ok(report) } -/// Read every `*.json` file in the tickets directory and return them sorted by +/// Read every ticket file in the tickets directory and return them sorted by /// priority descending (highest priority first). /// -/// If the tickets directory does not exist yet (e.g. no tickets have been -/// created), an empty `Vec` is returned rather than an error. +/// All supported file formats (`.json`, `.md`, `.toml`, `.jsonb`) are scanned. +/// If the tickets directory does not exist, an empty `Vec` is returned. /// /// # Errors /// -/// Returns an error if reading the directory listing fails, if any ticket file -/// cannot be read, or if any ticket's JSON cannot be deserialised. +/// Returns an error if the directory listing fails, if any ticket file cannot +/// be read, or if any ticket's content cannot be deserialised. pub async fn list_tickets(root: &Path) -> Result> { let dir = tickets_dir(root); - // If the tickets directory doesn't exist there are simply no tickets yet. if !dir.is_dir() { return Ok(Vec::new()); } let mut entries = fs::read_dir(&dir).await?; let mut tickets = Vec::new(); + // Guard against the same logical ticket appearing in multiple formats. + let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); while let Some(entry) = entries.next().await { let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "json") { - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or("ticket filename has no valid stem")? - .to_string(); - let bytes = fs::read(&path).await?; - let mut ticket: Ticket = serde_json::from_slice(&bytes)?; - ticket.id = stem; - tickets.push(ticket); + // Construct a std::path::PathBuf so it's compatible with Path helpers. + let path: PathBuf = dir.join(entry.file_name()); + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + if !KNOWN_EXTENSIONS.contains(&ext) { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or("ticket filename has no valid stem")? + .to_string(); + if !seen_ids.insert(stem.clone()) { + // Same ID already loaded from a different-extension file; skip. + continue; } + let bytes = fs::read(&path).await?; + let format = detect_format(&path); + let mut ticket = deserialize_by_format(&bytes, format)?; + ticket.id = stem; + tickets.push(ticket); } // Highest priority value first; ties preserve filesystem order. diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index 68c1b6d..43d0cf5 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -172,7 +172,7 @@ mod store { use crate::store::{ ensure_tickets_dir, find_nbd_root_from, list_tickets, read_ticket, resolve_id, ticket_path, - tickets_dir, write_ticket, + tickets_dir, write_ticket, FileFormat, }; use crate::ticket::{Status, Ticket, TicketType}; @@ -198,7 +198,9 @@ mod store { ticket_type: TicketType::Bug, }; - write_ticket(&root, &ticket).await.unwrap(); + write_ticket(&root, &ticket, FileFormat::Json) + .await + .unwrap(); let restored = read_ticket(&root, "a3f9c2").await.unwrap(); assert_eq!(restored.id, ticket.id); @@ -216,9 +218,11 @@ mod store { 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).await.unwrap(); + write_ticket(&root, &ticket, FileFormat::Json) + .await + .unwrap(); - let path = ticket_path(&root, "c0ffee"); + let path = ticket_path(&root, "c0ffee", FileFormat::Json); let contents = async_std::fs::read_to_string(&path).await.unwrap(); let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap(); assert!( @@ -276,8 +280,8 @@ mod store { t1.priority = 7; let mut t2 = Ticket::new("id2222".to_string(), "Second".to_string()); t2.priority = 3; - write_ticket(&root, &t1).await.unwrap(); - write_ticket(&root, &t2).await.unwrap(); + 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); @@ -313,9 +317,9 @@ mod store { let mut mid = Ticket::new("id0003".to_string(), "Mid priority".to_string()); mid.priority = 5; - write_ticket(&root, &low).await.unwrap(); - write_ticket(&root, &high).await.unwrap(); - write_ticket(&root, &mid).await.unwrap(); + 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); @@ -336,7 +340,9 @@ mod store { 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).await.unwrap(); + write_ticket(&root, &ticket, FileFormat::Json) + .await + .unwrap(); let resolved = resolve_id(&root, "a3f9c2").await.unwrap(); assert_eq!(resolved, "a3f9c2"); @@ -348,7 +354,9 @@ mod store { 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).await.unwrap(); + write_ticket(&root, &ticket, FileFormat::Json) + .await + .unwrap(); let resolved = resolve_id(&root, "a3f").await.unwrap(); assert_eq!(resolved, "a3f9c2"); @@ -375,8 +383,8 @@ mod store { 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).await.unwrap(); - write_ticket(&root, &t2).await.unwrap(); + 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()); @@ -436,12 +444,120 @@ mod store { drop(tmp); } - /// `ticket_path` returns the expected `.nbd/tickets/{id}.json` path. + /// Writing in TOML format and reading back produces an identical ticket. + #[async_std::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. + #[async_std::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. + #[async_std::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"); - let path = ticket_path(root, "a3f9c2"); - assert_eq!(path, Path::new("/tmp/project/.nbd/tickets/a3f9c2.json")); + 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. @@ -458,7 +574,9 @@ mod store { /// Tests for [`crate::store::migrate_tickets`]. mod migrate { use crate::filter::TicketFilter; - use crate::store::{ensure_tickets_dir, migrate_tickets, tickets_dir, write_ticket}; + 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) { @@ -504,8 +622,8 @@ mod migrate { 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(); + 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 diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index 74285d4..ec5279e 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -1255,6 +1255,275 @@ fn next_json_includes_id_field() { assert_eq!(id.len(), 6, "id should be 6 characters"); } +// ── --ftype format tests ────────────────────────────────────────────────────── + +/// `nbd create --ftype md` writes a `.md` file; `nbd read` finds and parses it. +#[test] +fn create_ftype_md_writes_md_file() { + let env = TestEnv::new(); + let output = env.run(&[ + "create", + "--title", + "Markdown ticket", + "--ftype", + "md", + "--json", + ]); + assert!( + output.status.success(), + "create --ftype md failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); + let id = parsed["id"].as_str().unwrap().to_string(); + + // The .md file must exist; no .json should be created. + assert!( + env.root + .join(".nbd") + .join("tickets") + .join(format!("{id}.md")) + .is_file(), + ".md file should exist" + ); + assert!( + !env.root + .join(".nbd") + .join("tickets") + .join(format!("{id}.json")) + .is_file(), + ".json file should NOT exist" + ); + + // `nbd read` should find the ticket via auto-detection. + let read = env.run(&["read", &id, "--json"]); + assert!(read.status.success(), "read should succeed for .md ticket"); + let read_out: serde_json::Value = + serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); + assert_eq!(read_out["title"], "Markdown ticket"); +} + +/// `nbd create --ftype toml` writes a `.toml` file readable by `nbd read`. +#[test] +fn create_ftype_toml_writes_toml_file() { + let env = TestEnv::new(); + let output = env.run(&[ + "create", + "--title", + "TOML ticket", + "--ftype", + "toml", + "--json", + ]); + assert!( + output.status.success(), + "create --ftype toml failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let id = parsed["id"].as_str().unwrap().to_string(); + + assert!( + env.root + .join(".nbd") + .join("tickets") + .join(format!("{id}.toml")) + .is_file(), + ".toml file should exist" + ); + + let read = env.run(&["read", &id, "--json"]); + assert!(read.status.success()); + let read_out: serde_json::Value = + serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); + assert_eq!(read_out["title"], "TOML ticket"); +} + +/// `nbd create --ftype jsonb` writes a `.jsonb` file readable by `nbd read`. +#[test] +fn create_ftype_jsonb_writes_jsonb_file() { + let env = TestEnv::new(); + let output = env.run(&[ + "create", + "--title", + "CBOR ticket", + "--ftype", + "jsonb", + "--json", + ]); + assert!( + output.status.success(), + "create --ftype jsonb failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let id = parsed["id"].as_str().unwrap().to_string(); + + assert!( + env.root + .join(".nbd") + .join("tickets") + .join(format!("{id}.jsonb")) + .is_file(), + ".jsonb file should exist" + ); + + let read = env.run(&["read", &id, "--json"]); + assert!(read.status.success()); + let read_out: serde_json::Value = + serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); + assert_eq!(read_out["title"], "CBOR ticket"); +} + +/// `nbd list` shows tickets in all formats. +#[test] +fn list_shows_mixed_format_tickets() { + let env = TestEnv::new(); + + env.create(&["--title", "JSON ticket"]); + env.run(&["create", "--title", "MD ticket", "--ftype", "md", "--json"]); + env.run(&[ + "create", + "--title", + "TOML ticket", + "--ftype", + "toml", + "--json", + ]); + + let output = env.run(&["list", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 3, "list should show all three tickets"); + let titles: Vec<&str> = arr.iter().map(|v| v["title"].as_str().unwrap()).collect(); + assert!(titles.contains(&"JSON ticket")); + assert!(titles.contains(&"MD ticket")); + assert!(titles.contains(&"TOML ticket")); +} + +/// `nbd update --ftype toml` converts a JSON ticket to TOML and removes +/// the old `.json` file. +#[test] +fn update_ftype_converts_format_and_removes_old_file() { + let env = TestEnv::new(); + let id = env.create(&["--title", "Convert me"]); + + // Confirm the .json file exists before conversion. + let json_path = env + .root + .join(".nbd") + .join("tickets") + .join(format!("{id}.json")); + assert!(json_path.is_file(), ".json should exist initially"); + + // Convert to TOML. + let update = env.run(&["update", &id, "--ftype", "toml", "--json"]); + assert!( + update.status.success(), + "update --ftype toml failed: {}", + String::from_utf8_lossy(&update.stderr) + ); + + // Old .json must be gone; .toml must exist. + assert!(!json_path.is_file(), "old .json should be removed"); + let toml_path = env + .root + .join(".nbd") + .join("tickets") + .join(format!("{id}.toml")); + assert!(toml_path.is_file(), "new .toml should exist"); + + // The ticket should still be readable. + let read = env.run(&["read", &id, "--json"]); + assert!(read.status.success()); + let read_out: serde_json::Value = + serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); + assert_eq!(read_out["title"], "Convert me"); +} + +/// `nbd update` without `--ftype` preserves the original format. +#[test] +fn update_without_ftype_preserves_format() { + let env = TestEnv::new(); + let output = env.run(&[ + "create", + "--title", + "Stay TOML", + "--ftype", + "toml", + "--json", + ]); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let id = parsed["id"].as_str().unwrap().to_string(); + + // Update status only — no --ftype. + let update = env.run(&["update", &id, "--status", "in_progress"]); + assert!(update.status.success()); + + // The .toml file should still exist (not converted to .json). + let toml_path = env + .root + .join(".nbd") + .join("tickets") + .join(format!("{id}.toml")); + assert!(toml_path.is_file(), ".toml should still exist after update"); + let json_path = env + .root + .join(".nbd") + .join("tickets") + .join(format!("{id}.json")); + assert!(!json_path.is_file(), ".json should not appear after update"); +} + +/// Markdown ticket body is preserved through a read/write cycle. +#[test] +fn markdown_body_roundtrip() { + let env = TestEnv::new(); + let body = "## Overview\n\nThis ticket has a **markdown** body."; + let output = env.run(&[ + "create", + "--title", + "MD body test", + "--body", + body, + "--ftype", + "md", + "--json", + ]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let id = parsed["id"].as_str().unwrap().to_string(); + + let read = env.run(&["read", &id, "--json"]); + assert!(read.status.success()); + let read_out: serde_json::Value = + serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); + assert_eq!(read_out["body"].as_str().unwrap(), body); +} + +/// `nbd create --ftype badformat` exits non-zero with a helpful message. +#[test] +fn create_unknown_ftype_exits_nonzero() { + let env = TestEnv::new(); + let output = env.run(&["create", "--title", "Bad", "--ftype", "xml"]); + assert!( + !output.status.success(), + "unknown ftype should exit non-zero" + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("xml") || stderr.contains("format"), + "error should mention format, got: {stderr}" + ); +} + // ── nbd claude-md tests ─────────────────────────────────────────────────────── /// `nbd claude-md` exits zero and stdout is non-empty.