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 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 142a1898e2
commit 8d7ee96b99

@ -58,14 +58,15 @@ Define the core types.
File I/O and directory traversal using `async-std`. File I/O and directory traversal using `async-std`.
- [ ] Implement `find_nbd_root() -> Result<PathBuf>`: walk from cwd upward until `.nbd/` is found; error with helpful message if not found - [x] Implement `find_nbd_root() -> Result<PathBuf>`: 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/` - [x] Implement `find_nbd_root_from(start: &Path) -> Result<PathBuf>`: testable variant that accepts a starting path
- [ ] Implement `ensure_tickets_dir(root: &Path) -> Result<()>`: creates `.nbd/tickets/` if missing (used only by `create`) - [x] Implement `tickets_dir(root: &Path) -> PathBuf`: returns `root/.nbd/tickets/`
- [ ] Implement `ticket_path(root: &Path, id: &str) -> PathBuf`: returns `.nbd/tickets/{id}.json` - [x] Implement `ensure_tickets_dir(root: &Path) -> Result<()>`: creates `.nbd/tickets/` if missing (used only by `create`)
- [ ] Implement `write_ticket(root: &Path, ticket: &Ticket) -> Result<()>`: serialize to JSON, write file - [x] Implement `ticket_path(root: &Path, id: &str) -> PathBuf`: returns `.nbd/tickets/{id}.json`
- [ ] Implement `read_ticket(root: &Path, id: &str) -> Result<Ticket>`: read file, deserialize; error if not found - [x] Implement `write_ticket(root: &Path, ticket: &Ticket) -> Result<()>`: serialize to JSON, write file
- [ ] Implement `list_tickets(root: &Path) -> Result<Vec<Ticket>>`: read all `*.json` from tickets dir, deserialize all, sort by priority descending - [x] Implement `read_ticket(root: &Path, id: &str) -> Result<Ticket>`: read file, deserialize; error if not found
- [ ] Unit tests: roundtrip write/read with tempdir, list returns all tickets, traversal finds `.nbd/` in grandparent dir - [x] Implement `list_tickets(root: &Path) -> Result<Vec<Ticket>>`: 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
--- ---

@ -4,3 +4,164 @@
//! project root. The root is discovered by walking up from the current working //! project root. The root is discovered by walking up from the current working
//! directory until a `.nbd/` directory is found, mirroring how `git` locates //! directory until a `.nbd/` directory is found, mirroring how `git` locates
//! `.git/`. //! `.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<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
/// 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<PathBuf> {
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<PathBuf> {
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<Ticket> {
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<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();
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)
}

@ -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"));
}
}

Loading…
Cancel
Save