//! Core ticket data model. //! //! 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"`, `"closed"`, /// `"archived"`, `"backlog"`. #[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 ticket will not be completed (cancelled, superseded, won't-fix). /// /// Closed tickets count as resolved for dependency purposes — a dependent /// ticket whose dependency is `closed` becomes unblocked. They are /// excluded from normal `nbd list` output. /// /// Use `nbd update --status closed` to set this status, or pass /// `--filter status=closed` (or `--all`) to make them visible in listings. Closed, /// The ticket was completed and soft-deleted from the active view. /// /// Set by `nbd archive `. Archived tickets count as resolved for /// dependency purposes — a dependent ticket whose dependency is `archived` /// becomes unblocked. They are excluded from normal `nbd list` output. /// /// Use `--filter status=archived` or `--all` to make them visible again. Archived, /// The ticket is created but intentionally deferred. /// /// Backlog tickets are excluded from `nbd list`, `nbd ready`, and /// `nbd next` by default. Unlike `done`, `closed`, and `archived`, they /// do **not** count as resolved for dependency purposes — a dependent /// ticket whose dependency is `backlog` is still blocked. /// /// Use `--filter status=backlog` or `--all` to make them visible. Backlog, } /// 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. /// /// The `id` field is **not** stored in the JSON file — the filename stem is /// the sole source of truth. After deserialising, callers in [`crate::store`] /// inject the correct id from the filename. /// /// 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"`. /// /// Not serialised to JSON — the filename stem is the canonical source of /// truth and this field is populated by [`crate::store`] at read time. #[serde(skip)] 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) }