use std::fmt;
use std::str::FromStr;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use crate::git::Stamp;
pub const SCHEMA_VERSION: u32 = 1;
pub const LEASE_MINUTES: u32 = 15;
pub const MEMORY_MAX_BYTES: usize = 512;
pub const STALE_AFTER: u64 = 50;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Meta {
pub prefix: String,
pub schema_version: u32,
pub created_at: Timestamp,
#[serde(default = "default_lease_minutes")]
pub lease_minutes: u32,
#[serde(default = "default_stale_after")]
pub stale_after: u64,
}
fn default_lease_minutes() -> u32 {
LEASE_MINUTES
}
fn default_stale_after() -> u64 {
STALE_AFTER
}
impl Meta {
pub fn lease(&self) -> jiff::SignedDuration {
jiff::SignedDuration::from_mins(i64::from(self.lease_minutes))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
#[clap(rename_all = "snake_case")]
pub enum Status {
Open,
InProgress,
Closed,
Deferred,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Task,
Bug,
Feature,
Chore,
#[serde(alias = "epic")]
#[clap(alias = "epic")]
Milestone,
Spike,
Decision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
#[clap(rename_all = "snake_case")]
pub enum Resolution {
Done,
Dropped,
}
impl fmt::Display for Resolution {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Resolution::Done => "done",
Resolution::Dropped => "dropped",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Note {
pub at: Timestamp,
pub author: String,
pub text: String,
pub commit: String,
pub branch: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Stamps {
pub created: Stamp,
pub closed: Option<Stamp>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Issue {
pub id: String,
pub title: String,
pub body: String,
#[serde(rename = "type")]
pub kind: Kind,
pub status: Status,
pub priority: u8,
pub labels: Vec<String>,
pub parent: Option<String>,
pub blocked_by: Vec<String>,
pub related: Vec<String>,
pub assignee: Option<String>,
pub lease_expires: Option<Timestamp>,
pub defer_until: Option<Timestamp>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub closed_at: Option<Timestamp>,
pub close_reason: Option<String>,
#[serde(default)]
pub resolution: Option<Resolution>,
pub notes: Vec<Note>,
pub stamps: Stamps,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Memory {
pub slug: String,
pub text: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub stamp: Stamp,
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Status::Open => "open",
Status::InProgress => "in_progress",
Status::Closed => "closed",
Status::Deferred => "deferred",
})
}
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Kind::Task => "task",
Kind::Bug => "bug",
Kind::Feature => "feature",
Kind::Chore => "chore",
Kind::Milestone => "milestone",
Kind::Spike => "spike",
Kind::Decision => "decision",
})
}
}
impl FromStr for Status {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"open" => Ok(Status::Open),
"in_progress" => Ok(Status::InProgress),
"closed" => Ok(Status::Closed),
"deferred" => Ok(Status::Deferred),
other => Err(format!("unknown status: {other}")),
}
}
}
pub fn canonical<T: Serialize>(value: &T) -> Vec<u8> {
let mut bytes = serde_json::to_vec_pretty(value).expect("records serialize");
bytes.push(b'\n');
bytes
}
pub fn new_id(prefix: &str) -> String {
let n = rand::random::<u32>() & 0xff_ffff;
format!("{prefix}-{n:06x}")
}
impl Issue {
pub fn touch(&mut self) {
self.updated_at = Timestamp::now();
}
pub fn close(&mut self, reason: String, resolution: Resolution, stamp: &Stamp) {
let now = Timestamp::now();
self.status = Status::Closed;
self.closed_at = Some(now);
self.close_reason = Some(reason);
self.resolution = Some(resolution);
self.stamps.closed = Some(stamp.clone());
self.lease_expires = None;
self.updated_at = now;
}
pub fn claim(&mut self, actor: &str, lease: jiff::SignedDuration) {
let now = Timestamp::now();
self.status = Status::InProgress;
self.assignee = Some(actor.to_string());
self.lease_expires = Some(now + lease);
self.defer_until = None;
self.updated_at = now;
}
pub fn unclaim(&mut self) {
self.status = Status::Open;
self.assignee = None;
self.lease_expires = None;
self.touch();
}
pub fn held_by_other(&self, actor: &str, now: Timestamp) -> bool {
self.status == Status::InProgress
&& self.assignee.as_deref() != Some(actor)
&& self.lease_expires.is_some_and(|t| t > now)
}
pub fn reopen(&mut self) {
self.status = Status::Open;
self.closed_at = None;
self.close_reason = None;
self.resolution = None;
self.stamps.closed = None;
self.defer_until = None;
self.touch();
}
}
pub fn check_slug(slug: &str) -> Result<(), String> {
let ok = !slug.is_empty()
&& slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if ok {
Ok(())
} else {
Err(format!(
"slug must be lowercase letters, digits and hyphens: {slug}"
))
}
}
pub fn parse_when(s: &str) -> Result<Timestamp, String> {
if let Ok(t) = s.parse::<Timestamp>() {
return Ok(t);
}
let date: jiff::civil::Date = s
.parse()
.map_err(|_| format!("not a timestamp or a date: {s}"))?;
date.in_tz("UTC")
.map(|z| z.timestamp())
.map_err(|e| e.to_string())
}
#[cfg(test)]
pub fn test_issue(id: &str) -> Issue {
let now = Timestamp::now();
let stamp = Stamp {
commit: "none".into(),
branch: "main".into(),
};
Issue {
id: id.into(),
title: "t".into(),
body: String::new(),
kind: Kind::Task,
status: Status::Open,
priority: 2,
labels: vec![],
parent: None,
blocked_by: vec![],
related: vec![],
assignee: None,
lease_expires: None,
defer_until: None,
created_at: now,
updated_at: now,
closed_at: None,
close_reason: None,
resolution: None,
notes: vec![],
stamps: Stamps {
created: stamp,
closed: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_bytes_are_stable_and_newline_terminated() {
let issue = test_issue("t-000001");
let a = canonical(&issue);
let b = canonical(&issue);
assert_eq!(a, b);
assert!(a.ends_with(b"}\n"));
assert!(a.starts_with(b"{\n \"id\": \"t-000001\",\n"));
}
#[test]
fn a_record_written_as_epic_loads_as_a_milestone() {
let mut issue = test_issue("t-000001");
issue.kind = Kind::Milestone;
let text = String::from_utf8(canonical(&issue)).unwrap();
assert!(text.contains("\"type\": \"milestone\""), "{text}");
let old = text.replace("\"milestone\"", "\"epic\"");
let back: Issue = serde_json::from_str(&old).unwrap();
assert_eq!(back.kind, Kind::Milestone);
}
#[test]
fn ids_have_the_prefix_and_six_hex_digits() {
let id = new_id("foam");
let (prefix, hex) = id.rsplit_once('-').unwrap();
assert_eq!(prefix, "foam");
assert_eq!(hex.len(), 6);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn when_accepts_dates_and_timestamps() {
assert_eq!(
parse_when("2026-10-01").unwrap().to_string(),
"2026-10-01T00:00:00Z"
);
assert!(parse_when("2026-10-01T12:00:00Z").is_ok());
assert!(parse_when("tomorrow").is_err());
}
}