From 8d7ee96b99631cdc3e1e4a6e029cca9f5234a208 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sat, 21 Feb 2026 21:25:52 -0800 Subject: [PATCH] feat(nbd): implement storage layer (Phase 3) Add async-std file I/O and directory traversal in store.rs: - find_nbd_root / find_nbd_root_from: walk up from cwd to locate .nbd/ - tickets_dir, ticket_path: pure path helpers - ensure_tickets_dir: create .nbd/tickets/ on first use - write_ticket / read_ticket: JSON serialisation round-trip - list_tickets: read all *.json files, sort by priority descending Add 8 unit tests covering write/read round-trip, missing-ticket error, priority-sorted list, empty directory, grandparent traversal, traversal failure, and path helpers. Co-Authored-By: Claude Sonnet 4.6 --- nbd/PLAN.md | 17 ++--- nbd/src/store.rs | 161 +++++++++++++++++++++++++++++++++++++++++++++++ nbd/src/tests.rs | 148 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 318 insertions(+), 8 deletions(-) diff --git a/nbd/PLAN.md b/nbd/PLAN.md index ff59323..f79b04b 100644 --- a/nbd/PLAN.md +++ b/nbd/PLAN.md @@ -58,14 +58,15 @@ Define the core types. File I/O and directory traversal using `async-std`. -- [ ] Implement `find_nbd_root() -> Result`: walk from cwd upward until `.nbd/` is found; error with helpful message if not found -- [ ] Implement `tickets_dir(root: &Path) -> PathBuf`: returns `root/.nbd/tickets/` -- [ ] Implement `ensure_tickets_dir(root: &Path) -> Result<()>`: creates `.nbd/tickets/` if missing (used only by `create`) -- [ ] Implement `ticket_path(root: &Path, id: &str) -> PathBuf`: returns `.nbd/tickets/{id}.json` -- [ ] Implement `write_ticket(root: &Path, ticket: &Ticket) -> Result<()>`: serialize to JSON, write file -- [ ] Implement `read_ticket(root: &Path, id: &str) -> Result`: read file, deserialize; error if not found -- [ ] Implement `list_tickets(root: &Path) -> Result>`: read all `*.json` from tickets dir, deserialize all, sort by priority descending -- [ ] Unit tests: roundtrip write/read with tempdir, list returns all tickets, traversal finds `.nbd/` in grandparent dir +- [x] Implement `find_nbd_root() -> Result`: walk from cwd upward until `.nbd/` is found; error with helpful message if not found +- [x] Implement `find_nbd_root_from(start: &Path) -> Result`: testable variant that accepts a starting path +- [x] Implement `tickets_dir(root: &Path) -> PathBuf`: returns `root/.nbd/tickets/` +- [x] Implement `ensure_tickets_dir(root: &Path) -> Result<()>`: creates `.nbd/tickets/` if missing (used only by `create`) +- [x] Implement `ticket_path(root: &Path, id: &str) -> PathBuf`: returns `.nbd/tickets/{id}.json` +- [x] Implement `write_ticket(root: &Path, ticket: &Ticket) -> Result<()>`: serialize to JSON, write file +- [x] Implement `read_ticket(root: &Path, id: &str) -> Result`: read file, deserialize; error if not found +- [x] Implement `list_tickets(root: &Path) -> Result>`: read all `*.json` from tickets dir, deserialize all, sort by priority descending +- [x] Unit tests: roundtrip write/read with tempdir, list returns all tickets, traversal finds `.nbd/` in grandparent dir --- diff --git a/nbd/src/store.rs b/nbd/src/store.rs index fd5e561..8542dcc 100644 --- a/nbd/src/store.rs +++ b/nbd/src/store.rs @@ -4,3 +4,164 @@ //! 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/`. + +use std::path::{Path, PathBuf}; + +use async_std::fs; +use async_std::prelude::*; + +use crate::ticket::Ticket; + +/// 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>; + +/// 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(()) +} + +/// Return the path to a specific ticket's JSON file. +/// +/// 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")) +} + +/// Serialise `ticket` as pretty-printed JSON and write it to +/// `.nbd/tickets/{id}.json`. +/// +/// Overwrites any existing file with the same ID. The tickets directory must +/// already exist; call [`ensure_tickets_dir`] before calling this for a new +/// ticket. +/// +/// # Errors +/// +/// Returns an error if JSON serialisation fails or if the file cannot be +/// written. +pub async fn write_ticket(root: &Path, ticket: &Ticket) -> Result<()> { + let path = ticket_path(root, &ticket.id); + let json = serde_json::to_string_pretty(ticket)?; + fs::write(path, json).await?; + Ok(()) +} + +/// Read and deserialise the ticket with the given `id` from disk. +/// +/// # Errors +/// +/// Returns a descriptive error message if the ticket file is not found. +/// Propagates any other I/O or deserialisation error unchanged. +pub async fn read_ticket(root: &Path, id: &str) -> Result { + let path = ticket_path(root, id); + match fs::read(&path).await { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err(format!("ticket '{id}' not found").into()) + } + Err(e) => Err(e.into()), + } +} + +/// Read every `*.json` 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. +/// +/// # 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. +pub async fn list_tickets(root: &Path) -> Result> { + let dir = tickets_dir(root); + + // If the tickets directory doesn't exist there are simply no tickets yet. + if !dir.is_dir() { + return Ok(Vec::new()); + } + + let mut entries = fs::read_dir(&dir).await?; + let mut tickets = Vec::new(); + + while let Some(entry) = entries.next().await { + let entry = entry?; + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "json") { + let bytes = fs::read(&path).await?; + let ticket: Ticket = serde_json::from_slice(&bytes)?; + tickets.push(ticket); + } + } + + // Highest priority value first; ties preserve filesystem order. + tickets.sort_by(|a, b| b.priority.cmp(&a.priority)); + Ok(tickets) +} diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index 6900428..c9e0ed2 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -140,3 +140,151 @@ mod ticket { ); } } + +// ── store module ────────────────────────────────────────────────────────────── + +/// Tests for [`crate::store`]. +mod store { + use std::path::Path; + + use crate::store::{ + ensure_tickets_dir, find_nbd_root_from, list_tickets, read_ticket, ticket_path, + tickets_dir, write_ticket, + }; + use crate::ticket::{Status, Ticket, TicketType}; + + /// Helper: create a temporary directory with `.nbd/tickets/` already set up. + async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_path_buf(); + ensure_tickets_dir(&root).await.unwrap(); + (tmp, root) + } + + /// Writing a ticket and reading it back produces an identical value. + #[async_std::test] + async fn write_and_read_roundtrip() { + let (tmp, root) = setup_store().await; + let ticket = Ticket { + id: "a3f9c2".to_string(), + title: "Fix login bug".to_string(), + body: "Users cannot log in with email addresses containing +".to_string(), + priority: 8, + status: Status::InProgress, + dependencies: vec!["b7d41e".to_string()], + ticket_type: TicketType::Bug, + }; + + write_ticket(&root, &ticket).await.unwrap(); + let restored = read_ticket(&root, "a3f9c2").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); + } + + /// Reading a non-existent ticket produces an error that mentions the ID. + #[async_std::test] + async fn read_missing_ticket_errors() { + let (tmp, root) = setup_store().await; + let result = read_ticket(&root, "ffffff").await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("ffffff"), + "error message should mention the ticket ID, got: {msg}" + ); + drop(tmp); + } + + /// `list_tickets` returns all written tickets sorted by priority descending. + #[async_std::test] + async fn list_returns_all_sorted_by_priority() { + let (tmp, root) = setup_store().await; + + let mut low = Ticket::new("id0001".to_string(), "Low priority".to_string()); + low.priority = 2; + let mut high = Ticket::new("id0002".to_string(), "High priority".to_string()); + high.priority = 9; + let mut mid = Ticket::new("id0003".to_string(), "Mid priority".to_string()); + mid.priority = 5; + + write_ticket(&root, &low).await.unwrap(); + write_ticket(&root, &high).await.unwrap(); + write_ticket(&root, &mid).await.unwrap(); + + let tickets = list_tickets(&root).await.unwrap(); + assert_eq!(tickets.len(), 3); + assert_eq!( + tickets[0].priority, 9, + "first ticket should have highest priority" + ); + assert_eq!(tickets[1].priority, 5); + assert_eq!( + tickets[2].priority, 2, + "last ticket should have lowest priority" + ); + drop(tmp); + } + + /// `list_tickets` returns an empty vec when the tickets directory is absent. + #[async_std::test] + async fn list_empty_when_no_tickets_dir() { + let tmp = tempfile::tempdir().unwrap(); + // Create `.nbd/` but not `.nbd/tickets/`. + std::fs::create_dir(tmp.path().join(".nbd")).unwrap(); + let root = tmp.path().to_path_buf(); + + let tickets = list_tickets(&root).await.unwrap(); + assert!(tickets.is_empty()); + drop(tmp); + } + + /// `find_nbd_root_from` finds `.nbd/` located in a grandparent directory. + #[test] + fn traversal_finds_nbd_in_grandparent() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Place `.nbd/` at the temp root. + std::fs::create_dir(root.join(".nbd")).unwrap(); + + // Start traversal from a deeply nested subdirectory. + let grandchild = root.join("a").join("b"); + std::fs::create_dir_all(&grandchild).unwrap(); + + let found = find_nbd_root_from(&grandchild).unwrap(); + assert_eq!(found, root); + drop(tmp); + } + + /// `find_nbd_root_from` returns an error when no `.nbd/` directory exists. + #[test] + fn traversal_errors_when_no_nbd_dir() { + let tmp = tempfile::tempdir().unwrap(); + let result = find_nbd_root_from(tmp.path()); + assert!(result.is_err()); + drop(tmp); + } + + /// `ticket_path` returns the expected `.nbd/tickets/{id}.json` path. + #[test] + fn ticket_path_is_correct() { + let root = Path::new("/tmp/project"); + let path = ticket_path(root, "a3f9c2"); + assert_eq!(path, Path::new("/tmp/project/.nbd/tickets/a3f9c2.json")); + } + + /// `tickets_dir` returns the expected `.nbd/tickets/` path. + #[test] + fn tickets_dir_is_correct() { + let root = Path::new("/tmp/project"); + let dir = tickets_dir(root); + assert_eq!(dir, Path::new("/tmp/project/.nbd/tickets")); + } +}