feat(nbd): implement ticket data model (Phase 2)

Define Status and TicketType enums with serde snake_case/lowercase
serialization, Ticket struct with all fields, Ticket::new constructor,
validate_priority, and generate_id using RandomState entropy. Add 9
unit tests covering roundtrips, enum serialization, priority validation,
and ID format/uniqueness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 2698c6ea84
commit 142a1898e2

@ -30,13 +30,13 @@ Set up the crate structure with no logic yet.
Define the core types. 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` - Derive: `Serialize`, `Deserialize`, `Debug`, `Clone`, `PartialEq`
- Serialize to/from lowercase strings (`"todo"`, `"in_progress"`, `"done"`) - 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 - Derive same traits
- Serialize to/from lowercase strings - Serialize to/from lowercase strings
- [ ] Define `Ticket` struct: - [x] Define `Ticket` struct:
``` ```
id: String // 6-char hex, e.g. "a3f9c2" id: String // 6-char hex, e.g. "a3f9c2"
title: String title: String
@ -47,10 +47,10 @@ Define the core types.
ticket_type: TicketType // field name avoids keyword collision ticket_type: TicketType // field name avoids keyword collision
``` ```
- Derive: `Serialize`, `Deserialize`, `Debug`, `Clone` - Derive: `Serialize`, `Deserialize`, `Debug`, `Clone`
- [ ] Implement `Ticket::new(id, title)` constructor with all defaults - [x] Implement `Ticket::new(id, title)` constructor with all defaults
- [ ] Implement priority validation (error if > 10) - [x] 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) - [x] 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] Unit tests: serialization roundtrip, priority validation, ID format (6 hex chars, unique across N calls)
--- ---

@ -3,3 +3,140 @@
//! Each module's behaviour is tested in isolation here. File I/O tests use //! 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 //! temporary directories provided by the `tempfile` crate so they leave no
//! state behind. //! 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::<Status>("\"todo\"").unwrap(),
Status::Todo
);
assert_eq!(
serde_json::from_str::<Status>("\"in_progress\"").unwrap(),
Status::InProgress
);
assert_eq!(
serde_json::from_str::<Status>("\"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::<TicketType>("\"project\"").unwrap(),
TicketType::Project
);
assert_eq!(
serde_json::from_str::<TicketType>("\"bug\"").unwrap(),
TicketType::Bug
);
}
/// `validate_priority` accepts values 010 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<String> = (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:?}"
);
}
}

@ -2,3 +2,129 @@
//! //!
//! Defines the [`Ticket`] struct along with the [`Status`] and [`TicketType`] //! Defines the [`Ticket`] struct along with the [`Status`] and [`TicketType`]
//! enums that describe a ticket's lifecycle state and category. //! 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 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)
}

Loading…
Cancel
Save