use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::id::slug;
pub const FORMAT_VERSION: u32 = 1;
pub const DEFAULT_PORT: u16 = 6737;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Board {
pub version: u32,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
pub lists: Vec<List>,
pub next_ticket: u64,
#[serde(default = "one")]
pub next_thread: u64,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Starter {
#[default]
Standard,
ListsOnly,
Empty,
}
impl Board {
pub fn new(name: impl Into<String>, now: DateTime<Utc>) -> Self {
Board {
version: FORMAT_VERSION,
id: Uuid::new_v4().to_string(),
name: name.into(),
description: String::new(),
lists: default_lists(),
next_ticket: 1,
next_thread: 1,
created: now,
updated: now,
}
}
pub fn empty(name: impl Into<String>, now: DateTime<Utc>) -> Self {
let mut b = Board::new(name, now);
b.lists.clear();
b
}
pub fn list(&self, id: &str) -> Option<&List> {
self.lists.iter().find(|l| l.id == id)
}
pub fn list_mut(&mut self, id: &str) -> Option<&mut List> {
self.lists.iter_mut().find(|l| l.id == id)
}
pub fn locate_card(&self, ticket_id: &str) -> Option<(String, usize)> {
for list in &self.lists {
if let Some(idx) = list.cards.iter().position(|c| c == ticket_id) {
return Some((list.id.clone(), idx));
}
}
None
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct List {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wip_limit: Option<u32>,
#[serde(default)]
pub cards: Vec<String>,
}
impl List {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
List {
id: slug(&name),
name,
color: None,
wip_limit: None,
cards: Vec::new(),
}
}
}
fn default_lists() -> Vec<List> {
["Backlog", "Todo", "In Progress", "Done"]
.into_iter()
.map(List::new)
.collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Ticket {
pub version: u32,
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assignees: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relations: Vec<Relation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub comments: Vec<Comment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub activity: Vec<Activity>,
#[serde(default = "one")]
pub next_comment: u64,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
fn one() -> u64 {
1
}
impl Ticket {
pub fn new(id: impl Into<String>, title: impl Into<String>, now: DateTime<Utc>) -> Self {
Ticket {
version: FORMAT_VERSION,
id: id.into(),
title: title.into(),
body: String::new(),
priority: None,
labels: Vec::new(),
assignees: Vec::new(),
relations: Vec::new(),
attachments: Vec::new(),
comments: Vec::new(),
activity: Vec::new(),
next_comment: 1,
created: now,
updated: now,
}
}
pub fn add_comment(
&mut self,
author: impl Into<String>,
body: impl Into<String>,
now: DateTime<Utc>,
) -> String {
let id = crate::id::comment_id(self.next_comment);
self.next_comment += 1;
self.comments.push(Comment {
id: id.clone(),
author: author.into(),
body: body.into(),
created: now,
edited: None,
});
self.updated = now;
id
}
pub fn log_activity(
&mut self,
actor: impl Into<String>,
kind: impl Into<String>,
detail: impl Into<String>,
now: DateTime<Utc>,
) {
self.activity.push(Activity {
ts: now,
actor: actor.into(),
kind: kind.into(),
detail: detail.into(),
});
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Relation {
pub kind: RelationKind,
pub target: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RelationKind {
Blocks,
BlockedBy,
Parent,
Child,
Relates,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Comment {
pub id: String,
pub author: String,
pub body: String,
pub created: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edited: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Activity {
pub ts: DateTime<Utc>,
pub actor: String,
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attachment {
pub name: String,
pub path: String,
pub source: AttachmentSource,
pub size: u64,
pub mime: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AttachmentSource {
Media,
Repo,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Thread {
pub version: u32,
pub id: String,
pub title: String,
pub root: Post,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Post {
pub id: String,
pub author: String,
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
pub created: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edited: Option<DateTime<Utc>>,
#[serde(default = "one")]
pub next_reply: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replies: Vec<Post>,
}
impl Post {
pub fn new(
id: impl Into<String>,
author: impl Into<String>,
body: impl Into<String>,
now: DateTime<Utc>,
) -> Self {
Post {
id: id.into(),
author: author.into(),
body: body.into(),
labels: Vec::new(),
attachments: Vec::new(),
refs: Vec::new(),
created: now,
edited: None,
next_reply: 1,
replies: Vec::new(),
}
}
pub fn find(&self, id: &str) -> Option<&Post> {
if self.id == id {
return Some(self);
}
self.replies.iter().find_map(|r| r.find(id))
}
pub fn find_mut(&mut self, id: &str) -> Option<&mut Post> {
if self.id == id {
return Some(self);
}
self.replies.iter_mut().find_map(|r| r.find_mut(id))
}
pub fn remove_child(&mut self, id: &str) -> bool {
let before = self.replies.len();
self.replies.retain(|r| r.id != id);
if self.replies.len() != before {
return true;
}
self.replies.iter_mut().any(|r| r.remove_child(id))
}
pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a Post, usize)) {
f(self, depth);
for r in &self.replies {
r.walk(depth + 1, f);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Identity {
pub id: String,
pub display_name: String,
pub kind: IdentityKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IdentityKind {
#[default]
Human,
Agent,
}
pub const LABEL_PALETTE: &[&str] = &[
"#CC785C", "#6C7BA8", "#7E9B7A", "#61AAF2", "#BF4D43", "#D4A27F", "#9A7AA0", "#3E9C93", "#E0A33B", "#C77C93", "#4F7A55", "#9E8FC2", "#B0455A", "#8A9A5B", "#7C8AA0", "#8C6A54", "#EBDBBC", "#666663", ];
pub fn next_label_color(existing: &[LabelDef]) -> String {
let used: std::collections::HashSet<&str> =
existing.iter().filter_map(|l| l.color.as_deref()).collect();
for c in LABEL_PALETTE {
if !used.contains(c) {
return (*c).to_string();
}
}
LABEL_PALETTE[existing.len() % LABEL_PALETTE.len()].to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Definitions {
pub version: u32,
#[serde(default)]
pub labels: Vec<LabelDef>,
#[serde(default)]
pub priorities: Vec<String>,
}
impl Definitions {
pub fn seed() -> Self {
Definitions {
version: FORMAT_VERSION,
labels: vec![
LabelDef::new("blocked", Some(LABEL_PALETTE[4])),
LabelDef::new("needs-review", Some(LABEL_PALETTE[3])),
LabelDef::new("agent", Some(LABEL_PALETTE[6])),
],
priorities: vec![
"low".into(),
"medium".into(),
"high".into(),
"urgent".into(),
],
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LabelDef {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
}
impl LabelDef {
pub fn new(name: impl Into<String>, color: Option<&str>) -> Self {
LabelDef {
name: name.into(),
color: color.map(|c| c.to_string()),
description: String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
pub version: u32,
#[serde(default)]
pub daemon: DaemonSettings,
#[serde(default = "default_max_attachment_mb")]
pub max_attachment_mb: u64,
}
fn default_max_attachment_mb() -> u64 {
50
}
impl Default for Settings {
fn default() -> Self {
Settings {
version: FORMAT_VERSION,
daemon: DaemonSettings::default(),
max_attachment_mb: default_max_attachment_mb(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DaemonSettings {
pub port: u16,
#[serde(default)]
pub expose: Exposure,
#[serde(default)]
pub autoserve: bool,
#[serde(default = "default_idle_timeout")]
pub idle_timeout_secs: u64,
}
fn default_idle_timeout() -> u64 {
900
}
impl Default for DaemonSettings {
fn default() -> Self {
DaemonSettings {
port: DEFAULT_PORT,
expose: Exposure::default(),
autoserve: false,
idle_timeout_secs: default_idle_timeout(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Exposure {
#[default]
None,
Tailscale,
Proxy,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixed() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
}
#[test]
fn board_has_default_lists() {
let b = Board::new("Demo", fixed());
assert_eq!(b.lists.len(), 4);
assert_eq!(b.lists[2].id, "in-progress");
assert_eq!(b.next_ticket, 1);
}
#[test]
fn ticket_omits_empty_fields_and_has_no_type_or_tags() {
let t = Ticket::new("T-1", "Hello", fixed());
let json = serde_json::to_string(&t).unwrap();
assert!(!json.contains("labels"));
assert!(!json.contains("assignees"));
assert!(!json.contains("\"body\""));
assert!(!json.contains("\"type\""));
assert!(!json.contains("tags"));
}
#[test]
fn label_color_auto_picks_unused() {
let existing = vec![LabelDef::new("a", Some(LABEL_PALETTE[0]))];
let picked = next_label_color(&existing);
assert_eq!(picked, LABEL_PALETTE[1]);
}
#[test]
fn comment_allocation_is_monotonic() {
let mut t = Ticket::new("T-1", "Hello", fixed());
let a = t.add_comment("me", "first", fixed());
let b = t.add_comment("me", "second", fixed());
assert_eq!(a, "c-1");
assert_eq!(b, "c-2");
assert_eq!(t.next_comment, 3);
}
#[test]
fn relation_kind_is_kebab_case() {
let r = Relation {
kind: RelationKind::BlockedBy,
target: "T-2".into(),
};
assert_eq!(
serde_json::to_string(&r).unwrap(),
r#"{"kind":"blocked-by","target":"T-2"}"#
);
}
}