diff --git a/nbd/PLAN.md b/nbd/PLAN.md index f60cb15..ff59323 100644 --- a/nbd/PLAN.md +++ b/nbd/PLAN.md @@ -30,13 +30,13 @@ Set up the crate structure with no logic yet. Define the core types. -- [ ] Define `Status` enum: `Todo`, `InProgress`, `Done` (default: `Todo`) +- [x] Define `Status` enum: `Todo`, `InProgress`, `Done` (default: `Todo`) - Derive: `Serialize`, `Deserialize`, `Debug`, `Clone`, `PartialEq` - Serialize to/from lowercase strings (`"todo"`, `"in_progress"`, `"done"`) -- [ ] Define `TicketType` enum: `Project`, `Feature`, `Task`, `Bug` (default: `Task`) +- [x] Define `TicketType` enum: `Project`, `Feature`, `Task`, `Bug` (default: `Task`) - Derive same traits - Serialize to/from lowercase strings -- [ ] Define `Ticket` struct: +- [x] Define `Ticket` struct: ``` id: String // 6-char hex, e.g. "a3f9c2" title: String @@ -47,10 +47,10 @@ Define the core types. ticket_type: TicketType // field name avoids keyword collision ``` - Derive: `Serialize`, `Deserialize`, `Debug`, `Clone` -- [ ] Implement `Ticket::new(id, title)` constructor with all defaults -- [ ] Implement priority validation (error if > 10) -- [ ] Implement ID generation: 3 random bytes → 6 hex chars (use `std::collections::hash_map::RandomState` or similar; no external crate needed for MVP) -- [ ] Unit tests: serialization roundtrip, priority validation, ID format (6 hex chars, unique across N calls) +- [x] Implement `Ticket::new(id, title)` constructor with all defaults +- [x] Implement priority validation (error if > 10) +- [x] Implement ID generation: 3 random bytes → 6 hex chars (use `std::collections::hash_map::RandomState` or similar; no external crate needed for MVP) +- [x] Unit tests: serialization roundtrip, priority validation, ID format (6 hex chars, unique across N calls) --- diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index e942e91..6900428 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -3,3 +3,140 @@ //! Each module's behaviour is tested in isolation here. File I/O tests use //! temporary directories provided by the `tempfile` crate so they leave no //! state behind. + +// ── ticket module ──────────────────────────────────────────────────────────── + +/// Tests for [`crate::ticket`]. +mod ticket { + use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType}; + + /// `Ticket::new` produces a ticket with the expected id, title, and defaults. + #[test] + fn new_sets_defaults() { + let t = Ticket::new("a3f9c2".to_string(), "Fix login bug".to_string()); + assert_eq!(t.id, "a3f9c2"); + assert_eq!(t.title, "Fix login bug"); + assert_eq!(t.body, ""); + assert_eq!(t.priority, 5); + assert_eq!(t.status, Status::Todo); + assert!(t.dependencies.is_empty()); + assert_eq!(t.ticket_type, TicketType::Task); + } + + /// A `Ticket` serialises to JSON and deserialises back to an identical value. + #[test] + fn ticket_roundtrip() { + let original = Ticket { + id: "b7d41e".to_string(), + title: "Add rate limiting".to_string(), + body: "Limit to 100 req/s".to_string(), + priority: 8, + status: Status::InProgress, + dependencies: vec!["a3f9c2".to_string()], + ticket_type: TicketType::Feature, + }; + + let json = serde_json::to_string(&original).expect("serialisation failed"); + let restored: Ticket = serde_json::from_str(&json).expect("deserialisation failed"); + + assert_eq!(restored.id, original.id); + assert_eq!(restored.title, original.title); + assert_eq!(restored.body, original.body); + assert_eq!(restored.priority, original.priority); + assert_eq!(restored.status, original.status); + assert_eq!(restored.dependencies, original.dependencies); + assert_eq!(restored.ticket_type, original.ticket_type); + } + + /// `Status` variants serialise to the expected lowercase snake_case strings. + #[test] + fn status_serialises_to_snake_case() { + assert_eq!(serde_json::to_string(&Status::Todo).unwrap(), "\"todo\""); + assert_eq!( + serde_json::to_string(&Status::InProgress).unwrap(), + "\"in_progress\"" + ); + assert_eq!(serde_json::to_string(&Status::Done).unwrap(), "\"done\""); + } + + /// `Status` deserialises correctly from lowercase snake_case strings. + #[test] + fn status_deserialises_from_snake_case() { + assert_eq!( + serde_json::from_str::("\"todo\"").unwrap(), + Status::Todo + ); + assert_eq!( + serde_json::from_str::("\"in_progress\"").unwrap(), + Status::InProgress + ); + assert_eq!( + serde_json::from_str::("\"done\"").unwrap(), + Status::Done + ); + } + + /// `TicketType` variants serialise to the expected lowercase strings. + #[test] + fn ticket_type_serialises_to_lowercase() { + assert_eq!( + serde_json::to_string(&TicketType::Project).unwrap(), + "\"project\"" + ); + assert_eq!( + serde_json::to_string(&TicketType::Feature).unwrap(), + "\"feature\"" + ); + assert_eq!( + serde_json::to_string(&TicketType::Task).unwrap(), + "\"task\"" + ); + assert_eq!(serde_json::to_string(&TicketType::Bug).unwrap(), "\"bug\""); + } + + /// `TicketType` deserialises correctly from lowercase strings. + #[test] + fn ticket_type_deserialises_from_lowercase() { + assert_eq!( + serde_json::from_str::("\"project\"").unwrap(), + TicketType::Project + ); + assert_eq!( + serde_json::from_str::("\"bug\"").unwrap(), + TicketType::Bug + ); + } + + /// `validate_priority` accepts values 0–10 and rejects values above 10. + #[test] + fn priority_validation() { + assert!(validate_priority(0).is_ok()); + assert!(validate_priority(5).is_ok()); + assert!(validate_priority(10).is_ok()); + assert!(validate_priority(11).is_err()); + assert!(validate_priority(255).is_err()); + } + + /// `generate_id` returns a 6-character lowercase hex string. + #[test] + fn generated_id_is_six_hex_chars() { + let id = generate_id(); + assert_eq!(id.len(), 6, "id length must be 6, got {id:?}"); + assert!( + id.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')), + "id must be lowercase hex, got {id:?}" + ); + } + + /// Calling `generate_id` multiple times produces distinct values. + #[test] + fn generated_ids_are_unique() { + let ids: Vec = (0..20).map(|_| generate_id()).collect(); + let unique: std::collections::HashSet<&String> = ids.iter().collect(); + // Allow at most 1 collision across 20 draws from a 16M space. + assert!( + unique.len() >= 19, + "too many collisions among generated IDs: {ids:?}" + ); + } +} diff --git a/nbd/src/ticket.rs b/nbd/src/ticket.rs index a80d2d7..6951a14 100644 --- a/nbd/src/ticket.rs +++ b/nbd/src/ticket.rs @@ -2,3 +2,129 @@ //! //! Defines the [`Ticket`] struct along with the [`Status`] and [`TicketType`] //! enums that describe a ticket's lifecycle state and category. +//! +//! ID generation ([`generate_id`]) and priority validation +//! ([`validate_priority`]) are also provided here. + +use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hasher}; + +use serde::{Deserialize, Serialize}; + +/// The lifecycle status of a ticket. +/// +/// Serializes to/from lowercase snake_case strings so that JSON files are +/// human-readable: `"todo"`, `"in_progress"`, `"done"`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "snake_case")] +pub enum Status { + /// The ticket has not been started yet. + #[default] + Todo, + /// The ticket is actively being worked on. + InProgress, + /// The ticket has been completed. + Done, +} + +/// The category of a ticket. +/// +/// Serializes to/from lowercase strings: `"project"`, `"feature"`, `"task"`, +/// `"bug"`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "lowercase")] +pub enum TicketType { + /// A high-level grouping of related work. + Project, + /// A new capability or user-facing behaviour to be added. + Feature, + /// A discrete, self-contained unit of work. + #[default] + Task, + /// A defect or unintended behaviour to be fixed. + Bug, +} + +/// A single work ticket. +/// +/// Tickets are identified by a 6-character lowercase hex string and stored as +/// JSON files at `.nbd/tickets/{id}.json` relative to the project root. +/// +/// Use [`Ticket::new`] to create a ticket with sensible defaults, then +/// customise individual fields before persisting with `store::write_ticket`. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Ticket { + /// Unique 6-character lowercase hex identifier, e.g. `"a3f9c2"`. + pub id: String, + /// Short, human-readable summary of the work to be done. + pub title: String, + /// Optional long-form description. Empty string when not provided. + pub body: String, + /// Relative importance on a scale of 0–10, inclusive. Default is `5`. + /// Higher values indicate greater urgency or value. + pub priority: u8, + /// Current lifecycle phase of the ticket. + pub status: Status, + /// IDs of tickets that must be completed before this one can start. + pub dependencies: Vec, + /// Broad category of the work described by this ticket. + pub ticket_type: TicketType, +} + +impl Ticket { + /// Create a new ticket with the given `id` and `title`. + /// + /// All remaining fields are initialised to their defaults: + /// - `body` — empty string + /// - `priority` — `5` + /// - `status` — [`Status::Todo`] + /// - `dependencies` — empty `Vec` + /// - `ticket_type` — [`TicketType::Task`] + pub fn new(id: String, title: String) -> Self { + Ticket { + id, + title, + body: String::new(), + priority: 5, + status: Status::default(), + dependencies: Vec::new(), + ticket_type: TicketType::default(), + } + } +} + +/// Validate that `priority` is within the allowed range of `0..=10`. +/// +/// Returns `Ok(())` when valid, or an `Err` with a descriptive message when +/// the value exceeds `10`. +pub fn validate_priority(priority: u8) -> Result<(), String> { + if priority > 10 { + Err(format!( + "priority must be between 0 and 10 inclusive, got {priority}" + )) + } else { + Ok(()) + } +} + +/// Generate a random 6-character lowercase hex ticket ID. +/// +/// Internally uses [`RandomState`], which is seeded with OS randomness on +/// every construction, to produce 3 pseudo-random bytes that are formatted as +/// a 6-character hex string. +/// +/// The result is always exactly 6 characters and contains only the characters +/// `0–9` and `a–f`. +pub fn generate_id() -> String { + // RandomState::new() is seeded with OS randomness on each call, so the + // resulting hasher produces different output every time even with the + // same input data. + let state = RandomState::new(); + let mut hasher = state.build_hasher(); + // Write a fixed byte so the hasher runs through its full compression + // function rather than relying solely on the finalisation step. + hasher.write_u8(0); + let hash = hasher.finish(); + // Mask to 24 bits (3 bytes) → 6 hex characters. + format!("{:06x}", hash & 0x00FF_FFFF) +}