feat(nbd): add multi-format ticket storage (md, toml, jsonb) [460caf]

Tickets can now be stored in four formats, selected with --ftype:
  json  (.json) — pretty-printed JSON, default, unchanged
  md    (.md)   — Markdown body with TOML frontmatter
  toml  (.toml) — full TOML
  jsonb (.jsonb) — CBOR binary via ciborium

Changes:
- store.rs: FileFormat enum, detect_format(), find_ticket_path(),
  per-format serialize/deserialize helpers; read_ticket/list_tickets/
  resolve_id/migrate_tickets all scan all known extensions
- main.rs: --ftype on create (default "json") and update (optional,
  converts format and removes old file); archive/update preserve
  existing format when --ftype is absent
- tests.rs: update write_ticket/ticket_path call sites; add TOML,
  Markdown, and CBOR roundtrip unit tests
- integration.rs: 8 new format tests covering create, list, update
  conversion, format preservation, body roundtrip, unknown-ftype error

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

@ -2,7 +2,7 @@
"title": "Multiple file format support (md, toml, jsonb)", "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<u8>`\n\nDeserialization helpers (private):\n- `deserialize_markdown(bytes) -> Result<Ticket>` — 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 <id> --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", "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<u8>`\n\nDeserialization helpers (private):\n- `deserialize_markdown(bytes) -> Result<Ticket>` — 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 <id> --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, "priority": 5,
"status": "todo", "status": "done",
"dependencies": [], "dependencies": [],
"ticket_type": "feature" "ticket_type": "feature"
} }

125
nbd/Cargo.lock generated

@ -225,6 +225,33 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 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]] [[package]]
name = "clap" name = "clap"
version = "4.5.60" version = "4.5.60"
@ -286,6 +313,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -418,6 +451,17 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -532,10 +576,12 @@ name = "nbd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-std", "async-std",
"ciborium",
"clap", "clap",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"toml",
] ]
[[package]] [[package]]
@ -695,6 +741,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -742,6 +797,47 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -902,6 +998,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
@ -990,6 +1095,26 @@ dependencies = [
"wasmparser", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"

@ -14,6 +14,8 @@ clap = { version = "4", features = ["derive"] }
async-std = { version = "1", features = ["attributes"] } async-std = { version = "1", features = ["attributes"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8"
ciborium = "0.2"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

@ -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` | | `ticket_type` | `project` \| `feature` \| `task` \| `bug` | `task` |
| `dependencies` | list of ticket IDs | `[]` | | `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. All commands accept `--json` for machine-readable output.
## Usage ## Usage
@ -40,6 +50,8 @@ Analogous to `git init` — safe to run multiple times.
```sh ```sh
nbd create --title "Fix login bug" --priority 8 --type bug 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 "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 ### Read a ticket
@ -80,11 +92,13 @@ depends on an archived ticket becomes unblocked.
### Update a ticket ### Update a ticket
Only the flags you supply are changed; all other fields retain their current 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 ```sh
nbd update a3f9c2 --status in_progress nbd update a3f9c2 --status in_progress
nbd update a3f9c2 --priority 9 --type bug nbd update a3f9c2 --priority 9 --type bug
nbd update a3f9c2 --ftype md # convert to markdown format
``` ```
### Find the next ticket to work on ### Find the next ticket to work on

@ -24,8 +24,8 @@ mod tests;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use crate::store::{ use crate::store::{
ensure_tickets_dir, find_nbd_root, list_tickets, migrate_tickets, read_ticket, resolve_id, detect_format, ensure_tickets_dir, find_nbd_root, find_ticket_path, list_tickets,
write_ticket, migrate_tickets, read_ticket, resolve_id, write_ticket, FileFormat,
}; };
use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType}; 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`). /// Comma-separated list of dependency ticket IDs (e.g. `a3f9c2,b7d41e`).
#[arg(long)] #[arg(long)]
deps: Option<String>, deps: Option<String>,
/// 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. /// Print a single ticket by ID.
@ -218,6 +222,13 @@ enum Commands {
/// New comma-separated dependency IDs (replaces the existing list). /// New comma-separated dependency IDs (replaces the existing list).
#[arg(long)] #[arg(long)]
deps: Option<String>, deps: Option<String>,
/// 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<String>,
}, },
} }
@ -242,7 +253,20 @@ async fn dispatch(cli: Cli) -> store::Result<()> {
status, status,
ticket_type, ticket_type,
deps, 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, Commands::Init => cmd_init(cli.json).await,
@ -268,6 +292,7 @@ async fn dispatch(cli: Cli) -> store::Result<()> {
status, status,
ticket_type, ticket_type,
deps, deps,
ftype,
} => { } => {
cmd_update( cmd_update(
id, id,
@ -277,6 +302,7 @@ async fn dispatch(cli: Cli) -> store::Result<()> {
status, status,
ticket_type, ticket_type,
deps, deps,
ftype,
cli.json, cli.json,
) )
.await .await
@ -326,6 +352,19 @@ fn parse_ticket_type(s: &str) -> store::Result<TicketType> {
} }
} }
/// 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> {
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<String>`. /// Split a comma-separated dependency string into a `Vec<String>`.
/// ///
/// Returns an empty `Vec` when `deps` is `None` or an empty string. /// Returns an empty `Vec` when `deps` is `None` or an empty string.
@ -503,7 +542,8 @@ async fn cmd_migrate(filter_args: Vec<String>, dry_run: bool, json: bool) -> sto
/// 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
/// 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( async fn cmd_create(
title: String, title: String,
body: String, body: String,
@ -511,11 +551,13 @@ async fn cmd_create(
status: String, status: String,
ticket_type: String, ticket_type: String,
deps: Option<String>, deps: Option<String>,
ftype: String,
json: bool, json: bool,
) -> store::Result<()> { ) -> store::Result<()> {
validate_priority(priority) validate_priority(priority)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?; .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let format = parse_file_format(&ftype)?;
let root = find_nbd_root()?; let root = find_nbd_root()?;
ensure_tickets_dir(&root).await?; ensure_tickets_dir(&root).await?;
@ -530,7 +572,7 @@ async fn cmd_create(
ticket.ticket_type = parse_ticket_type(&ticket_type)?; ticket.ticket_type = parse_ticket_type(&ticket_type)?;
ticket.dependencies = dependencies; ticket.dependencies = dependencies;
write_ticket(&root, &ticket).await?; write_ticket(&root, &ticket, format).await?;
if json { if json {
display::print_ticket_json(&ticket); display::print_ticket_json(&ticket);
@ -599,13 +641,16 @@ async fn cmd_list(filter_args: Vec<String>, all: bool, json: bool) -> store::Res
/// Archive a ticket by setting its status to [`Status::Closed`] and printing it. /// 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. /// The ticket is preserved on disk but excluded from normal `nbd list` output.
/// This is syntactic sugar for `nbd update <id> --status closed`. /// The file is re-written in its existing format. This is syntactic sugar for
/// `nbd update <id> --status closed`.
async fn cmd_archive(id: String, json: bool) -> store::Result<()> { async fn cmd_archive(id: String, json: bool) -> store::Result<()> {
let root = find_nbd_root()?; let root = find_nbd_root()?;
let id = resolve_id(&root, &id).await?; 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?; let mut ticket = read_ticket(&root, &id).await?;
ticket.status = Status::Closed; ticket.status = Status::Closed;
write_ticket(&root, &ticket).await?; write_ticket(&root, &ticket, format).await?;
if json { if json {
display::print_ticket_json(&ticket); display::print_ticket_json(&ticket);
@ -639,6 +684,9 @@ fn cmd_claude_md(json: bool) -> store::Result<()> {
/// Only the flags explicitly passed on the command line are applied; all other /// 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. /// 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)] #[allow(clippy::too_many_arguments)]
async fn cmd_update( async fn cmd_update(
id: String, id: String,
@ -648,10 +696,16 @@ async fn cmd_update(
status: Option<String>, status: Option<String>,
ticket_type: Option<String>, ticket_type: Option<String>,
deps: Option<String>, deps: Option<String>,
ftype: Option<String>,
json: bool, json: bool,
) -> store::Result<()> { ) -> store::Result<()> {
let root = find_nbd_root()?; let root = find_nbd_root()?;
let id = resolve_id(&root, &id).await?; 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?; let mut ticket = read_ticket(&root, &id).await?;
if let Some(t) = title { if let Some(t) = title {
@ -677,7 +731,17 @@ async fn cmd_update(
ticket.dependencies = dependencies; 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 { if json {
display::print_ticket_json(&ticket); display::print_ticket_json(&ticket);

@ -1,17 +1,27 @@
//! File I/O and directory traversal for ticket storage. //! File I/O and directory traversal for ticket storage.
//! //!
//! Tickets are stored as `.json` files inside `.nbd/tickets/` relative to the //! Tickets are stored in `.nbd/tickets/` relative to the project root.
//! project root. The root is discovered by walking up from the current working //! Each ticket is a single file named `{id}.{ext}`, where the extension
//! directory until a `.nbd/` directory is found, mirroring how `git` locates //! depends on the [`FileFormat`]:
//! `.git/`. //!
//! | 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 std::path::{Path, PathBuf};
use async_std::fs; use async_std::fs;
use async_std::prelude::*; use async_std::prelude::*;
use serde::{Deserialize, Serialize};
use crate::filter::TicketFilter; use crate::filter::TicketFilter;
use crate::ticket::Ticket; use crate::ticket::{Status, Ticket, TicketType};
/// Convenience alias for fallible operations in this module. /// Convenience alias for fallible operations in this module.
/// ///
@ -20,6 +30,207 @@ use crate::ticket::Ticket;
/// can be returned with `?` without additional wrapping. /// can be returned with `?` without additional wrapping.
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>; pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
/// Convert a string message into a boxed error, with an unambiguous type.
fn msg_err(s: String) -> Box<dyn std::error::Error + Send + Sync> {
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<Self> {
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<String>,
}
// ── Serialisation helpers ─────────────────────────────────────────────────────
/// Serialise `ticket` to a pretty-printed JSON string.
fn serialize_json(ticket: &Ticket) -> Result<String> {
Ok(serde_json::to_string_pretty(ticket)?)
}
/// Serialise `ticket` to a TOML string.
fn serialize_toml(ticket: &Ticket) -> Result<String> {
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<String> {
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<Vec<u8>> {
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<Ticket> {
Ok(serde_json::from_slice(bytes)?)
}
/// Deserialise a ticket from TOML bytes.
fn deserialize_toml(bytes: &[u8]) -> Result<Ticket> {
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<Ticket> {
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<Ticket> {
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<Ticket> {
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. /// Walk upward from `start` until a directory containing `.nbd/` is found.
/// ///
/// Returns the first ancestor path (inclusive of `start`) that contains a /// 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(()) 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 /// This is a pure path computation — it does not check whether the file
/// exists. /// exists.
pub fn ticket_path(root: &Path, id: &str) -> PathBuf { pub fn ticket_path(root: &Path, id: &str, format: FileFormat) -> PathBuf {
tickets_dir(root).join(format!("{id}.json")) 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<PathBuf> {
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 // ── CRUD operations ───────────────────────────────────────────────────────────
/// `.nbd/tickets/{id}.json`.
/// 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 /// already exist; call [`ensure_tickets_dir`] before calling this for a new
/// ticket. /// ticket.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if JSON serialisation fails or if the file cannot be /// Returns an error if serialisation fails or if the file cannot be written.
/// written. pub async fn write_ticket(root: &Path, ticket: &Ticket, format: FileFormat) -> Result<()> {
pub async fn write_ticket(root: &Path, ticket: &Ticket) -> Result<()> { let path = ticket_path(root, &ticket.id, format);
let path = ticket_path(root, &ticket.id); match format {
let json = serde_json::to_string_pretty(ticket)?; FileFormat::Json => {
let json = serialize_json(ticket)?;
fs::write(path, json).await?; 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(()) Ok(())
} }
/// Resolve a full 6-character ticket ID from an exact ID or a unique prefix. /// 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 /// If `id_or_prefix` is already an exact match for a ticket on disk (in any
/// returned unchanged. Otherwise all ticket filenames whose stem starts with /// supported format), it is returned unchanged. Otherwise all ticket filenames
/// `id_or_prefix` are collected and: /// whose stem starts with `id_or_prefix` are collected and:
/// ///
/// - **0 matches** → error: `"no ticket found matching '{prefix}'"` /// - **0 matches** → error: `"no ticket found matching '{prefix}'"`
/// - **1 match** → the full 6-character ID is returned /// - **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<String> { pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result<String> {
let dir = tickets_dir(root); let dir = tickets_dir(root);
// Fast path: if exactly 6 chars and the file exists, return it directly. // Fast path: if exactly 6 chars, check all known extensions.
if id_or_prefix.len() == 6 && dir.join(format!("{id_or_prefix}.json")).is_file() { 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()); 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() { if !dir.is_dir() {
return Err(format!("no ticket found matching '{id_or_prefix}'").into()); 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<String> {
while let Some(entry) = entries.next().await { while let Some(entry) = entries.next().await {
let entry = entry?; let entry = entry?;
let path = entry.path(); // Construct a std::path::PathBuf so it's compatible with Path helpers.
if path.extension().is_none_or(|ext| ext != "json") { 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; continue;
} }
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { 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()); matches.push(stem.to_string());
} }
} }
@ -173,27 +429,31 @@ pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result<String> {
/// Read and deserialise the ticket with the given `id` from disk. /// 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 /// Extensions are tried in the order defined by [`KNOWN_EXTENSIONS`]. The
/// `id` parameter after deserialisation, making the filename stem the /// first matching file is used. The `id` is not stored inside the file; it
/// authoritative source of truth. /// is injected from the `id` parameter after deserialisation, making the
/// filename stem the authoritative source of truth.
/// ///
/// # Errors /// # 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. /// Propagates any other I/O or deserialisation error unchanged.
pub async fn read_ticket(root: &Path, id: &str) -> Result<Ticket> { pub async fn read_ticket(root: &Path, id: &str) -> Result<Ticket> {
let path = ticket_path(root, id); let dir = tickets_dir(root);
for ext in KNOWN_EXTENSIONS {
let path = dir.join(format!("{id}.{ext}"));
match fs::read(&path).await { match fs::read(&path).await {
Ok(bytes) => { Ok(bytes) => {
let mut ticket: Ticket = serde_json::from_slice(&bytes)?; let format = detect_format(&path);
let mut ticket = deserialize_by_format(&bytes, format)?;
ticket.id = id.to_string(); ticket.id = id.to_string();
Ok(ticket) return Ok(ticket);
} }
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(format!("ticket '{id}' not found").into()) Err(e) => return Err(e.into()),
} }
Err(e) => Err(e.into()),
} }
Err(format!("ticket '{id}' not found").into())
} }
/// Report produced by [`migrate_tickets`]. /// Report produced by [`migrate_tickets`].
@ -214,19 +474,17 @@ pub struct MigrateReport {
/// Re-serialise every ticket file through the current serde schema. /// 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. /// - Deserialise into [`Ticket`], injecting the `id` from the filename stem.
/// - If the ticket does not match `filter`, count it as skipped and continue. /// - 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 differ, write the new content (unless `dry_run` is `true`).
/// - If the bytes are identical, count the file as already current. /// - If the bytes are identical, count the file as already current.
/// - If deserialisation fails, record the error and leave the file untouched. /// - 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 /// 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 /// # Errors
/// ///
@ -251,8 +509,14 @@ pub async fn migrate_tickets(
let mut entries = fs::read_dir(&dir).await?; let mut entries = fs::read_dir(&dir).await?;
while let Some(entry) = entries.next().await { while let Some(entry) = entries.next().await {
let entry = entry?; let entry = entry?;
let path = entry.path(); // Construct a std::path::PathBuf so it's compatible with Path helpers.
if path.extension().is_none_or(|ext| ext != "json") { 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; 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, Ok(t) => t,
Err(e) => { Err(e) => {
report.errors.push((filename, e.to_string())); report.errors.push((filename, e.to_string()));
@ -290,21 +555,44 @@ pub async fn migrate_tickets(
continue; continue;
} }
let new_json = match serde_json::to_string_pretty(&ticket) { // Re-serialise in the same format to normalise the schema.
Ok(s) => s, let new_bytes: Vec<u8> = 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) => { Err(e) => {
report.errors.push((filename, e.to_string())); report.errors.push((filename, e.to_string()));
continue; continue;
} }
},
}; };
let new_bytes = new_json.as_bytes();
if raw == new_bytes { if raw == new_bytes {
report.already_current += 1; report.already_current += 1;
} else if dry_run { } else if dry_run {
report.updated += 1; report.updated += 1;
} else { } 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())); report.errors.push((filename, e.to_string()));
continue; continue;
} }
@ -315,42 +603,54 @@ pub async fn migrate_tickets(
Ok(report) 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). /// priority descending (highest priority first).
/// ///
/// If the tickets directory does not exist yet (e.g. no tickets have been /// All supported file formats (`.json`, `.md`, `.toml`, `.jsonb`) are scanned.
/// created), an empty `Vec` is returned rather than an error. /// If the tickets directory does not exist, an empty `Vec` is returned.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if reading the directory listing fails, if any ticket file /// Returns an error if the directory listing fails, if any ticket file cannot
/// cannot be read, or if any ticket's JSON cannot be deserialised. /// be read, or if any ticket's content cannot be deserialised.
pub async fn list_tickets(root: &Path) -> Result<Vec<Ticket>> { pub async fn list_tickets(root: &Path) -> Result<Vec<Ticket>> {
let dir = tickets_dir(root); let dir = tickets_dir(root);
// If the tickets directory doesn't exist there are simply no tickets yet.
if !dir.is_dir() { if !dir.is_dir() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let mut entries = fs::read_dir(&dir).await?; let mut entries = fs::read_dir(&dir).await?;
let mut tickets = Vec::new(); let mut tickets = Vec::new();
// Guard against the same logical ticket appearing in multiple formats.
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
while let Some(entry) = entries.next().await { while let Some(entry) = entries.next().await {
let entry = entry?; let entry = entry?;
let path = entry.path(); // Construct a std::path::PathBuf so it's compatible with Path helpers.
if path.extension().is_some_and(|ext| ext == "json") { 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 let stem = path
.file_stem() .file_stem()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.ok_or("ticket filename has no valid stem")? .ok_or("ticket filename has no valid stem")?
.to_string(); .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 bytes = fs::read(&path).await?;
let mut ticket: Ticket = serde_json::from_slice(&bytes)?; let format = detect_format(&path);
let mut ticket = deserialize_by_format(&bytes, format)?;
ticket.id = stem; ticket.id = stem;
tickets.push(ticket); tickets.push(ticket);
} }
}
// Highest priority value first; ties preserve filesystem order. // Highest priority value first; ties preserve filesystem order.
tickets.sort_by(|a, b| b.priority.cmp(&a.priority)); tickets.sort_by(|a, b| b.priority.cmp(&a.priority));

@ -172,7 +172,7 @@ mod store {
use crate::store::{ use crate::store::{
ensure_tickets_dir, find_nbd_root_from, list_tickets, read_ticket, resolve_id, ticket_path, 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}; use crate::ticket::{Status, Ticket, TicketType};
@ -198,7 +198,9 @@ mod store {
ticket_type: TicketType::Bug, 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(); let restored = read_ticket(&root, "a3f9c2").await.unwrap();
assert_eq!(restored.id, ticket.id); assert_eq!(restored.id, ticket.id);
@ -216,9 +218,11 @@ mod store {
async fn write_ticket_omits_id_from_json() { async fn write_ticket_omits_id_from_json() {
let (tmp, root) = setup_store().await; let (tmp, root) = setup_store().await;
let ticket = Ticket::new("c0ffee".to_string(), "Check JSON".to_string()); 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 contents = async_std::fs::read_to_string(&path).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert!( assert!(
@ -276,8 +280,8 @@ mod store {
t1.priority = 7; t1.priority = 7;
let mut t2 = Ticket::new("id2222".to_string(), "Second".to_string()); let mut t2 = Ticket::new("id2222".to_string(), "Second".to_string());
t2.priority = 3; t2.priority = 3;
write_ticket(&root, &t1).await.unwrap(); write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
write_ticket(&root, &t2).await.unwrap(); write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
let tickets = list_tickets(&root).await.unwrap(); let tickets = list_tickets(&root).await.unwrap();
assert_eq!(tickets.len(), 2); assert_eq!(tickets.len(), 2);
@ -313,9 +317,9 @@ mod store {
let mut mid = Ticket::new("id0003".to_string(), "Mid priority".to_string()); let mut mid = Ticket::new("id0003".to_string(), "Mid priority".to_string());
mid.priority = 5; mid.priority = 5;
write_ticket(&root, &low).await.unwrap(); write_ticket(&root, &low, FileFormat::Json).await.unwrap();
write_ticket(&root, &high).await.unwrap(); write_ticket(&root, &high, FileFormat::Json).await.unwrap();
write_ticket(&root, &mid).await.unwrap(); write_ticket(&root, &mid, FileFormat::Json).await.unwrap();
let tickets = list_tickets(&root).await.unwrap(); let tickets = list_tickets(&root).await.unwrap();
assert_eq!(tickets.len(), 3); assert_eq!(tickets.len(), 3);
@ -336,7 +340,9 @@ mod store {
async fn resolve_id_exact_match() { async fn resolve_id_exact_match() {
let (tmp, root) = setup_store().await; let (tmp, root) = setup_store().await;
let ticket = Ticket::new("a3f9c2".to_string(), "Exact".to_string()); 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(); let resolved = resolve_id(&root, "a3f9c2").await.unwrap();
assert_eq!(resolved, "a3f9c2"); assert_eq!(resolved, "a3f9c2");
@ -348,7 +354,9 @@ mod store {
async fn resolve_id_prefix_match() { async fn resolve_id_prefix_match() {
let (tmp, root) = setup_store().await; let (tmp, root) = setup_store().await;
let ticket = Ticket::new("a3f9c2".to_string(), "Prefix".to_string()); 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(); let resolved = resolve_id(&root, "a3f").await.unwrap();
assert_eq!(resolved, "a3f9c2"); assert_eq!(resolved, "a3f9c2");
@ -375,8 +383,8 @@ mod store {
let (tmp, root) = setup_store().await; let (tmp, root) = setup_store().await;
let t1 = Ticket::new("aabbcc".to_string(), "First".to_string()); let t1 = Ticket::new("aabbcc".to_string(), "First".to_string());
let t2 = Ticket::new("aaddee".to_string(), "Second".to_string()); let t2 = Ticket::new("aaddee".to_string(), "Second".to_string());
write_ticket(&root, &t1).await.unwrap(); write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
write_ticket(&root, &t2).await.unwrap(); write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
let result = resolve_id(&root, "aa").await; let result = resolve_id(&root, "aa").await;
assert!(result.is_err()); assert!(result.is_err());
@ -436,12 +444,120 @@ mod store {
drop(tmp); 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] #[test]
fn ticket_path_is_correct() { fn ticket_path_is_correct() {
let root = Path::new("/tmp/project"); let root = Path::new("/tmp/project");
let path = ticket_path(root, "a3f9c2"); assert_eq!(
assert_eq!(path, Path::new("/tmp/project/.nbd/tickets/a3f9c2.json")); 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. /// `tickets_dir` returns the expected `.nbd/tickets/` path.
@ -458,7 +574,9 @@ mod store {
/// Tests for [`crate::store::migrate_tickets`]. /// Tests for [`crate::store::migrate_tickets`].
mod migrate { mod migrate {
use crate::filter::TicketFilter; 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; use crate::ticket::Ticket;
async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) { async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) {
@ -504,8 +622,8 @@ mod migrate {
let (tmp, root) = setup_store().await; let (tmp, root) = setup_store().await;
let t1 = Ticket::new("id0001".to_string(), "First".to_string()); let t1 = Ticket::new("id0001".to_string(), "First".to_string());
let t2 = Ticket::new("id0002".to_string(), "Second".to_string()); let t2 = Ticket::new("id0002".to_string(), "Second".to_string());
write_ticket(&root, &t1).await.unwrap(); write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
write_ticket(&root, &t2).await.unwrap(); write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
let report = migrate_tickets(&root, false, &TicketFilter::default()) let report = migrate_tickets(&root, false, &TicketFilter::default())
.await .await

@ -1255,6 +1255,275 @@ fn next_json_includes_id_field() {
assert_eq!(id.len(), 6, "id should be 6 characters"); 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 <id> --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 tests ───────────────────────────────────────────────────────
/// `nbd claude-md` exits zero and stdout is non-empty. /// `nbd claude-md` exits zero and stdout is non-empty.

Loading…
Cancel
Save