//! File I/O and directory traversal for ticket storage. //! //! 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 serde::{Deserialize, Serialize}; use tokio::fs; use crate::filter::TicketFilter; use crate::ticket::{Status, Ticket, TicketType}; /// Convenience alias for fallible operations in this module. /// /// The error type is a heap-allocated trait object so that both `io::Error` /// and `serde_json::Error` (and any other `std::error::Error` implementor) /// 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 /// `.nbd/` subdirectory, or an error if the filesystem root is reached without /// finding one. /// /// This is the low-level, testable variant. Most callers should use /// [`find_nbd_root`], which starts from the current working directory. /// /// # Errors /// /// Returns an error if no `.nbd/` directory exists in `start` or any of its /// ancestors. pub fn find_nbd_root_from(start: &Path) -> Result { let mut dir = start; loop { if dir.join(".nbd").is_dir() { return Ok(dir.to_path_buf()); } match dir.parent() { Some(parent) => dir = parent, None => break, } } Err( "could not find .nbd/ directory; create `.nbd/tickets/` in your project root to initialise" .into(), ) } /// Walk upward from the current working directory until a `.nbd/` directory is /// found. /// /// This is the primary entry point used by all CLI commands. Internally /// delegates to [`find_nbd_root_from`]. /// /// # Errors /// /// Returns an error if the current working directory cannot be determined, or /// if no `.nbd/` directory exists in it or any of its ancestors. pub fn find_nbd_root() -> Result { let cwd = std::env::current_dir()?; find_nbd_root_from(&cwd) } /// Return the path to the tickets directory within a project root. /// /// This is a pure path computation — it does not check whether the directory /// exists. pub fn tickets_dir(root: &Path) -> PathBuf { root.join(".nbd").join("tickets") } /// Create `.nbd/tickets/` under `root` if it does not already exist. /// /// All intermediate directories (including `.nbd/`) are created as needed. /// This should only be called by the `create` command; other commands can /// assume the directory already exists. /// /// # Errors /// /// Propagates any I/O error returned by the filesystem. pub async fn ensure_tickets_dir(root: &Path) -> Result<()> { let dir = tickets_dir(root); fs::create_dir_all(dir).await?; Ok(()) } // ── 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, 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()) } // ── 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 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 (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 /// - **2+ matches** → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."` /// /// # Errors /// /// Returns an error if no match is found, the prefix is ambiguous, or the /// tickets directory cannot be read. pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result { let dir = tickets_dir(root); // 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 across all known formats. if !dir.is_dir() { return Err(format!("no ticket found matching '{id_or_prefix}'").into()); } let mut entries = fs::read_dir(&dir).await?; let mut matches: Vec = Vec::new(); while let Some(entry) = entries.next_entry().await? { // 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) && !matches.contains(&stem.to_string()) { matches.push(stem.to_string()); } } } match matches.len() { 0 => Err(format!("no ticket found matching '{id_or_prefix}'").into()), 1 => Ok(matches.remove(0)), _ => { matches.sort(); Err(format!( "ambiguous prefix '{id_or_prefix}' matches: {}", matches.join(", ") ) .into()) } } } /// Read and deserialise the ticket with the given `id` from disk. /// /// 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 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 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(format!("ticket '{id}' not found").into()) } /// Report produced by [`migrate_tickets`]. /// /// Summarises how many ticket files were updated, were already current, /// were skipped by the filter, or could not be parsed. #[derive(Debug)] pub struct MigrateReport { /// Number of files that were re-serialised (had stale content). pub updated: usize, /// Number of files that were already in the current schema format. pub already_current: usize, /// Number of files excluded by the caller-supplied [`TicketFilter`]. pub skipped: usize, /// Files that could not be deserialised. Each entry is `(filename, error)`. pub errors: Vec<(String, String)>, } /// Re-serialise every ticket file through the current serde schema. /// /// For each 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 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. /// /// When `dry_run` is `true`, no files are written; the report describes what /// *would* have changed. The function always returns `Ok` — individual file /// errors are collected in [`MigrateReport::errors`] rather than aborting early. /// /// # Errors /// /// Returns an error only if the tickets directory itself cannot be read. pub async fn migrate_tickets( root: &Path, dry_run: bool, filter: &TicketFilter, ) -> Result { let dir = tickets_dir(root); let mut report = MigrateReport { updated: 0, already_current: 0, skipped: 0, errors: Vec::new(), }; if !dir.is_dir() { return Ok(report); } let mut entries = fs::read_dir(&dir).await?; while let Some(entry) = entries.next_entry().await? { // 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 filename = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_string(); let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); let raw = match fs::read(&path).await { Ok(b) => b, Err(e) => { report.errors.push((filename, e.to_string())); continue; } }; let 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())); continue; } }; ticket.id = stem; // Apply caller-supplied filter; skip non-matching tickets. if !filter.matches(&ticket) { report.skipped += 1; 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; } }, }; if raw == new_bytes { report.already_current += 1; } else if dry_run { report.updated += 1; } else { if let Err(e) = fs::write(&path, &new_bytes).await { report.errors.push((filename, e.to_string())); continue; } report.updated += 1; } } Ok(report) } /// Read every ticket file in the tickets directory and return them sorted by /// priority descending (highest priority first). /// /// 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 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 !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_entry().await? { // 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. tickets.sort_by(|a, b| b.priority.cmp(&a.priority)); Ok(tickets) } // ── Turso (libsql) cache ────────────────────────────────────────────────────── /// Open (or create) the libsql cache database at `.nbd/cache.db`. /// /// Runs the schema migration on every open so the table exists when needed. /// The returned [`libsql::Connection`] is ready for queries. /// /// # Errors /// /// Returns an error if the database cannot be opened or the migration fails. async fn open_cache(root: &Path) -> Result { let db_path = root.join(".nbd").join("cache.db"); let db = libsql::Builder::new_local(db_path) .build() .await .map_err(|e| msg_err(format!("libsql open error: {e}")))?; let conn = db .connect() .map_err(|e| msg_err(format!("libsql connect error: {e}")))?; conn.execute( "CREATE TABLE IF NOT EXISTS tickets ( id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL )", (), ) .await .map_err(|e| msg_err(format!("libsql migrate error: {e}")))?; Ok(conn) } /// Like [`list_tickets`] but uses a libsql cache in `.nbd/cache.db` to skip /// re-reading files whose modification time has not changed. /// /// ## Cache strategy /// /// 1. Scan `.nbd/tickets/` for files and their mtimes. /// 2. For each file: if the cache row exists and the mtime matches, deserialise /// the cached JSON; otherwise read the file, parse it, and upsert the cache. /// 3. Delete rows for IDs that no longer exist on disk. /// 4. Return tickets sorted by priority descending. /// /// Falls back to [`list_tickets`] if the cache cannot be opened or any cache /// operation fails, so the caller always gets a result. /// /// # Errors /// /// Only returns an error if [`list_tickets`] itself fails (the fallback path). pub async fn list_tickets_cached(root: &Path) -> Result> { match list_tickets_cached_inner(root).await { Ok(tickets) => Ok(tickets), Err(_) => list_tickets(root).await, } } /// Inner implementation of the cached list; errors cause a fallback to the /// uncached path in [`list_tickets_cached`]. async fn list_tickets_cached_inner(root: &Path) -> Result> { let dir = tickets_dir(root); if !dir.is_dir() { return Ok(Vec::new()); } let conn = open_cache(root).await?; // Scan the directory, collecting (id, path, mtime_secs) for each ticket file. let mut on_disk: Vec<(String, PathBuf, i64)> = Vec::new(); let mut read_dir = fs::read_dir(&dir).await?; while let Some(entry) = read_dir.next_entry().await? { 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 = match path.file_stem().and_then(|s| s.to_str()) { Some(s) => s.to_string(), None => continue, }; let meta = fs::metadata(&path).await?; let mtime = meta .modified() .ok() .and_then(|t| { t.duration_since(std::time::UNIX_EPOCH) .ok() .map(|d| d.as_millis() as i64) }) .unwrap_or(0); on_disk.push((stem, path, mtime)); } // Deduplicate: keep only the first occurrence of each ID (same order as list_tickets). let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); on_disk.retain(|(id, _, _)| seen_ids.insert(id.clone())); let disk_ids: std::collections::HashSet = on_disk.iter().map(|(id, _, _)| id.clone()).collect(); let mut tickets: Vec = Vec::with_capacity(on_disk.len()); for (id, path, mtime) in &on_disk { // Query the cache for this id. let cached_json: Option = { let mut rows = conn .query( "SELECT json FROM tickets WHERE id = ?1 AND mtime = ?2", libsql::params![id.as_str(), *mtime], ) .await .map_err(|e| msg_err(format!("cache query error: {e}")))?; if let Some(row) = rows .next() .await .map_err(|e| msg_err(format!("cache row error: {e}")))? { Some( row.get::(0) .map_err(|e| msg_err(format!("cache column error: {e}")))?, ) } else { None } }; let mut ticket = if let Some(json) = cached_json { serde_json::from_str::(&json) .map_err(|e| msg_err(format!("cache deserialise error: {e}")))? } else { // Cache miss: read file and upsert. let bytes = fs::read(path).await?; let format = detect_format(path); let t = deserialize_by_format(&bytes, format)?; // Store normalised JSON in the cache. let json = serde_json::to_string(&t)?; conn.execute( "INSERT OR REPLACE INTO tickets (id, json, mtime) VALUES (?1, ?2, ?3)", libsql::params![id.as_str(), json.as_str(), *mtime], ) .await .map_err(|e| msg_err(format!("cache upsert error: {e}")))?; t }; ticket.id = id.clone(); tickets.push(ticket); } // Remove stale rows for IDs no longer on disk. // We fetch all cached IDs and delete anything not in `disk_ids`. let mut rows = conn .query("SELECT id FROM tickets", ()) .await .map_err(|e| msg_err(format!("cache scan error: {e}")))?; let mut stale: Vec = Vec::new(); while let Some(row) = rows .next() .await .map_err(|e| msg_err(format!("cache row error: {e}")))? { let cached_id: String = row .get::(0) .map_err(|e| msg_err(format!("cache column error: {e}")))?; if !disk_ids.contains(&cached_id) { stale.push(cached_id); } } for id in stale { conn.execute( "DELETE FROM tickets WHERE id = ?1", libsql::params![id.as_str()], ) .await .map_err(|e| msg_err(format!("cache delete error: {e}")))?; } tickets.sort_by(|a, b| b.priority.cmp(&a.priority)); Ok(tickets) }