You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

145 lines
5.1 KiB
Rust

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! 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"`.
#[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 has been archived (soft-deleted).
///
/// Closed tickets are preserved on disk but excluded from normal listings.
/// Use `nbd archive <id>` to close a ticket, or pass `--filter status=closed`
/// (or `--all`) to make them visible again.
Closed,
}
/// 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 010, 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
/// `09` and `af`.
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)
}