1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::id::slug;
13
14pub const FORMAT_VERSION: u32 = 1;
17
18pub const DEFAULT_PORT: u16 = 6737;
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct Board {
30 pub version: u32,
32 pub id: String,
34 pub name: String,
36 #[serde(default, skip_serializing_if = "String::is_empty")]
38 pub description: String,
39 pub lists: Vec<List>,
41 pub next_ticket: u64,
43 pub created: DateTime<Utc>,
45 pub updated: DateTime<Utc>,
47}
48
49impl Board {
50 pub fn new(name: impl Into<String>, now: DateTime<Utc>) -> Self {
52 Board {
53 version: FORMAT_VERSION,
54 id: Uuid::new_v4().to_string(),
55 name: name.into(),
56 description: String::new(),
57 lists: default_lists(),
58 next_ticket: 1,
59 created: now,
60 updated: now,
61 }
62 }
63
64 pub fn list(&self, id: &str) -> Option<&List> {
66 self.lists.iter().find(|l| l.id == id)
67 }
68
69 pub fn list_mut(&mut self, id: &str) -> Option<&mut List> {
71 self.lists.iter_mut().find(|l| l.id == id)
72 }
73
74 pub fn locate_card(&self, ticket_id: &str) -> Option<(String, usize)> {
76 for list in &self.lists {
77 if let Some(idx) = list.cards.iter().position(|c| c == ticket_id) {
78 return Some((list.id.clone(), idx));
79 }
80 }
81 None
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct List {
88 pub id: String,
90 pub name: String,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub color: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub wip_limit: Option<u32>,
98 #[serde(default)]
100 pub cards: Vec<String>,
101}
102
103impl List {
104 pub fn new(name: impl Into<String>) -> Self {
106 let name = name.into();
107 List {
108 id: slug(&name),
109 name,
110 color: None,
111 wip_limit: None,
112 cards: Vec::new(),
113 }
114 }
115}
116
117fn default_lists() -> Vec<List> {
119 ["Backlog", "Todo", "In Progress", "Done"]
120 .into_iter()
121 .map(List::new)
122 .collect()
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct Ticket {
132 pub version: u32,
134 pub id: String,
136 pub title: String,
138 #[serde(default, skip_serializing_if = "String::is_empty")]
140 pub body: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub priority: Option<String>,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub labels: Vec<String>,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub assignees: Vec<String>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub relations: Vec<Relation>,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub attachments: Vec<Attachment>,
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub comments: Vec<Comment>,
159 #[serde(default = "one")]
161 pub next_comment: u64,
162 pub created: DateTime<Utc>,
164 pub updated: DateTime<Utc>,
166}
167
168fn one() -> u64 {
169 1
170}
171
172impl Ticket {
173 pub fn new(id: impl Into<String>, title: impl Into<String>, now: DateTime<Utc>) -> Self {
175 Ticket {
176 version: FORMAT_VERSION,
177 id: id.into(),
178 title: title.into(),
179 body: String::new(),
180 priority: None,
181 labels: Vec::new(),
182 assignees: Vec::new(),
183 relations: Vec::new(),
184 attachments: Vec::new(),
185 comments: Vec::new(),
186 next_comment: 1,
187 created: now,
188 updated: now,
189 }
190 }
191
192 pub fn add_comment(
194 &mut self,
195 author: impl Into<String>,
196 body: impl Into<String>,
197 now: DateTime<Utc>,
198 ) -> String {
199 let id = crate::id::comment_id(self.next_comment);
200 self.next_comment += 1;
201 self.comments.push(Comment {
202 id: id.clone(),
203 author: author.into(),
204 body: body.into(),
205 created: now,
206 edited: None,
207 });
208 self.updated = now;
209 id
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct Relation {
216 pub kind: RelationKind,
218 pub target: String,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "kebab-case")]
225pub enum RelationKind {
226 Blocks,
228 BlockedBy,
230 Parent,
232 Child,
234 Relates,
236}
237
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct Comment {
241 pub id: String,
243 pub author: String,
245 pub body: String,
247 pub created: DateTime<Utc>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub edited: Option<DateTime<Utc>>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct Attachment {
261 pub name: String,
263 pub path: String,
265 pub source: AttachmentSource,
267 pub size: u64,
269 pub mime: String,
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum AttachmentSource {
277 Media,
279 Repo,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct Identity {
290 pub id: String,
292 pub display_name: String,
294 pub kind: IdentityKind,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
300#[serde(rename_all = "lowercase")]
301pub enum IdentityKind {
302 #[default]
304 Human,
305 Agent,
307}
308
309pub const LABEL_PALETTE: &[&str] = &[
316 "#CC785C", "#6C7BA8", "#7E9B7A", "#61AAF2", "#BF4D43", "#D4A27F", "#9A7AA0", "#EBDBBC", "#666663", ];
326
327pub fn next_label_color(existing: &[LabelDef]) -> String {
330 let used: std::collections::HashSet<&str> =
331 existing.iter().filter_map(|l| l.color.as_deref()).collect();
332 for c in LABEL_PALETTE {
333 if !used.contains(c) {
334 return (*c).to_string();
335 }
336 }
337 LABEL_PALETTE[existing.len() % LABEL_PALETTE.len()].to_string()
338}
339
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct Definitions {
344 pub version: u32,
346 #[serde(default)]
348 pub labels: Vec<LabelDef>,
349 #[serde(default)]
351 pub priorities: Vec<String>,
352}
353
354impl Definitions {
355 pub fn seed() -> Self {
357 Definitions {
358 version: FORMAT_VERSION,
359 labels: vec![
360 LabelDef::new("blocked", Some(LABEL_PALETTE[4])),
361 LabelDef::new("needs-review", Some(LABEL_PALETTE[3])),
362 LabelDef::new("agent", Some(LABEL_PALETTE[6])),
363 ],
364 priorities: vec![
365 "low".into(),
366 "medium".into(),
367 "high".into(),
368 "urgent".into(),
369 ],
370 }
371 }
372}
373
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376pub struct LabelDef {
377 pub name: String,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub color: Option<String>,
382 #[serde(default, skip_serializing_if = "String::is_empty")]
384 pub description: String,
385}
386
387impl LabelDef {
388 pub fn new(name: impl Into<String>, color: Option<&str>) -> Self {
390 LabelDef {
391 name: name.into(),
392 color: color.map(|c| c.to_string()),
393 description: String::new(),
394 }
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
404pub struct Settings {
405 pub version: u32,
407 #[serde(default)]
409 pub daemon: DaemonSettings,
410 #[serde(default = "default_max_attachment_mb")]
413 pub max_attachment_mb: u64,
414}
415
416fn default_max_attachment_mb() -> u64 {
417 50
418}
419
420impl Default for Settings {
421 fn default() -> Self {
422 Settings {
423 version: FORMAT_VERSION,
424 daemon: DaemonSettings::default(),
425 max_attachment_mb: default_max_attachment_mb(),
426 }
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432pub struct DaemonSettings {
433 pub port: u16,
435 #[serde(default)]
437 pub expose: Exposure,
438}
439
440impl Default for DaemonSettings {
441 fn default() -> Self {
442 DaemonSettings {
443 port: DEFAULT_PORT,
444 expose: Exposure::default(),
445 }
446 }
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
451#[serde(rename_all = "lowercase")]
452pub enum Exposure {
453 #[default]
455 None,
456 Tailscale,
458 Proxy,
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use chrono::TimeZone;
466
467 fn fixed() -> DateTime<Utc> {
468 Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
469 }
470
471 #[test]
472 fn board_has_default_lists() {
473 let b = Board::new("Demo", fixed());
474 assert_eq!(b.lists.len(), 4);
475 assert_eq!(b.lists[2].id, "in-progress");
476 assert_eq!(b.next_ticket, 1);
477 }
478
479 #[test]
480 fn ticket_omits_empty_fields_and_has_no_type_or_tags() {
481 let t = Ticket::new("T-1", "Hello", fixed());
482 let json = serde_json::to_string(&t).unwrap();
483 assert!(!json.contains("labels"));
485 assert!(!json.contains("assignees"));
486 assert!(!json.contains("\"body\""));
487 assert!(!json.contains("\"type\""));
489 assert!(!json.contains("tags"));
490 }
491
492 #[test]
493 fn label_color_auto_picks_unused() {
494 let existing = vec![LabelDef::new("a", Some(LABEL_PALETTE[0]))];
495 let picked = next_label_color(&existing);
496 assert_eq!(picked, LABEL_PALETTE[1]);
497 }
498
499 #[test]
500 fn comment_allocation_is_monotonic() {
501 let mut t = Ticket::new("T-1", "Hello", fixed());
502 let a = t.add_comment("me", "first", fixed());
503 let b = t.add_comment("me", "second", fixed());
504 assert_eq!(a, "c-1");
505 assert_eq!(b, "c-2");
506 assert_eq!(t.next_comment, 3);
507 }
508
509 #[test]
510 fn relation_kind_is_kebab_case() {
511 let r = Relation {
512 kind: RelationKind::BlockedBy,
513 target: "T-2".into(),
514 };
515 assert_eq!(
516 serde_json::to_string(&r).unwrap(),
517 r#"{"kind":"blocked-by","target":"T-2"}"#
518 );
519 }
520}