|
|
//! 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 <id> --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 <id>`. 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<String>,
|
|
|
/// 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)
|
|
|
}
|