@ -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 < 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.
///
/// 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 ( ) ) )
}
/// Serialise `ticket` as pretty-printed JSON and write it to
/// `.nbd/tickets/{id}.json`.
/// Find and return the actual on-disk path of a ticket, trying each known
/// extension in order.
///
/// # Errors
///
/// Overwrites any existing file with the same ID. The tickets directory must
/// 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 ( ) )
}
// ── 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 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 ) ? ;
/// 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 < String > {
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 ( ) {
// 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<String> {
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<String> {
/// 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 < 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 {
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 ( ) ;
Ok ( ticket )
return Ok ( ticket ) ;
}
Err ( e ) if e . kind ( ) = = std ::io ::ErrorKind ::NotFound = > {
Err ( format! ( "ticket '{id}' not found" ) . into ( ) )
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 ,
// Re-serialise in the same format to normalise the schema.
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 ) = > {
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,42 +603,54 @@ 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 < Vec < Ticket > > {
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 < String > = 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" ) {
// 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 mut ticket : Ticket = serde_json ::from_slice ( & bytes ) ? ;
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.
tickets . sort_by ( | a , b | b . priority . cmp ( & a . priority ) ) ;