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 #[serde(default = "one")]
45 pub next_thread: u64,
46 pub created: DateTime<Utc>,
48 pub updated: DateTime<Utc>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Starter {
56 #[default]
58 Standard,
59 ListsOnly,
61 Empty,
63}
64
65impl Board {
66 pub fn new(name: impl Into<String>, now: DateTime<Utc>) -> Self {
68 Board {
69 version: FORMAT_VERSION,
70 id: Uuid::new_v4().to_string(),
71 name: name.into(),
72 description: String::new(),
73 lists: default_lists(),
74 next_ticket: 1,
75 next_thread: 1,
76 created: now,
77 updated: now,
78 }
79 }
80
81 pub fn empty(name: impl Into<String>, now: DateTime<Utc>) -> Self {
83 let mut b = Board::new(name, now);
84 b.lists.clear();
85 b
86 }
87
88 pub fn list(&self, id: &str) -> Option<&List> {
90 self.lists.iter().find(|l| l.id == id)
91 }
92
93 pub fn list_mut(&mut self, id: &str) -> Option<&mut List> {
95 self.lists.iter_mut().find(|l| l.id == id)
96 }
97
98 pub fn locate_card(&self, ticket_id: &str) -> Option<(String, usize)> {
100 for list in &self.lists {
101 if let Some(idx) = list.cards.iter().position(|c| c == ticket_id) {
102 return Some((list.id.clone(), idx));
103 }
104 }
105 None
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct List {
112 pub id: String,
114 pub name: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub color: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub wip_limit: Option<u32>,
122 #[serde(default)]
124 pub cards: Vec<String>,
125}
126
127impl List {
128 pub fn new(name: impl Into<String>) -> Self {
130 let name = name.into();
131 List {
132 id: slug(&name),
133 name,
134 color: None,
135 wip_limit: None,
136 cards: Vec::new(),
137 }
138 }
139}
140
141fn default_lists() -> Vec<List> {
143 ["Backlog", "Todo", "In Progress", "Done"]
144 .into_iter()
145 .map(List::new)
146 .collect()
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct Ticket {
156 pub version: u32,
158 pub id: String,
160 pub title: String,
162 #[serde(default, skip_serializing_if = "String::is_empty")]
164 pub body: String,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub priority: Option<String>,
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub labels: Vec<String>,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub assignees: Vec<String>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub relations: Vec<Relation>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub attachments: Vec<Attachment>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub comments: Vec<Comment>,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub activity: Vec<Activity>,
187 #[serde(default = "one")]
189 pub next_comment: u64,
190 pub created: DateTime<Utc>,
192 pub updated: DateTime<Utc>,
194}
195
196fn one() -> u64 {
197 1
198}
199
200impl Ticket {
201 pub fn new(id: impl Into<String>, title: impl Into<String>, now: DateTime<Utc>) -> Self {
203 Ticket {
204 version: FORMAT_VERSION,
205 id: id.into(),
206 title: title.into(),
207 body: String::new(),
208 priority: None,
209 labels: Vec::new(),
210 assignees: Vec::new(),
211 relations: Vec::new(),
212 attachments: Vec::new(),
213 comments: Vec::new(),
214 activity: Vec::new(),
215 next_comment: 1,
216 created: now,
217 updated: now,
218 }
219 }
220
221 pub fn add_comment(
223 &mut self,
224 author: impl Into<String>,
225 body: impl Into<String>,
226 now: DateTime<Utc>,
227 ) -> String {
228 let id = crate::id::comment_id(self.next_comment);
229 self.next_comment += 1;
230 self.comments.push(Comment {
231 id: id.clone(),
232 author: author.into(),
233 body: body.into(),
234 created: now,
235 edited: None,
236 });
237 self.updated = now;
238 id
239 }
240
241 pub fn log_activity(
243 &mut self,
244 actor: impl Into<String>,
245 kind: impl Into<String>,
246 detail: impl Into<String>,
247 now: DateTime<Utc>,
248 ) {
249 self.activity.push(Activity {
250 ts: now,
251 actor: actor.into(),
252 kind: kind.into(),
253 detail: detail.into(),
254 });
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct Relation {
261 pub kind: RelationKind,
263 pub target: String,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "kebab-case")]
270pub enum RelationKind {
271 Blocks,
273 BlockedBy,
275 Parent,
277 Child,
279 Relates,
281}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct Comment {
286 pub id: String,
288 pub author: String,
290 pub body: String,
292 pub created: DateTime<Utc>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub edited: Option<DateTime<Utc>>,
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct Activity {
304 pub ts: DateTime<Utc>,
306 pub actor: String,
308 pub kind: String,
312 #[serde(default, skip_serializing_if = "String::is_empty")]
315 pub detail: String,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct Attachment {
325 pub name: String,
327 pub path: String,
329 pub source: AttachmentSource,
331 pub size: u64,
333 pub mime: String,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "lowercase")]
340pub enum AttachmentSource {
341 Media,
343 Repo,
345}
346
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct Thread {
356 pub version: u32,
358 pub id: String,
360 pub title: String,
362 pub root: Post,
364 pub created: DateTime<Utc>,
366 pub updated: DateTime<Utc>,
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub struct Post {
375 pub id: String,
377 pub author: String,
379 pub body: String,
381 #[serde(default, skip_serializing_if = "Vec::is_empty")]
383 pub labels: Vec<String>,
384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
386 pub attachments: Vec<Attachment>,
387 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub refs: Vec<String>,
390 pub created: DateTime<Utc>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub edited: Option<DateTime<Utc>>,
395 #[serde(default = "one")]
397 pub next_reply: u64,
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 pub replies: Vec<Post>,
401}
402
403impl Post {
404 pub fn new(
406 id: impl Into<String>,
407 author: impl Into<String>,
408 body: impl Into<String>,
409 now: DateTime<Utc>,
410 ) -> Self {
411 Post {
412 id: id.into(),
413 author: author.into(),
414 body: body.into(),
415 labels: Vec::new(),
416 attachments: Vec::new(),
417 refs: Vec::new(),
418 created: now,
419 edited: None,
420 next_reply: 1,
421 replies: Vec::new(),
422 }
423 }
424
425 pub fn find(&self, id: &str) -> Option<&Post> {
427 if self.id == id {
428 return Some(self);
429 }
430 self.replies.iter().find_map(|r| r.find(id))
431 }
432
433 pub fn find_mut(&mut self, id: &str) -> Option<&mut Post> {
435 if self.id == id {
436 return Some(self);
437 }
438 self.replies.iter_mut().find_map(|r| r.find_mut(id))
439 }
440
441 pub fn remove_child(&mut self, id: &str) -> bool {
443 let before = self.replies.len();
444 self.replies.retain(|r| r.id != id);
445 if self.replies.len() != before {
446 return true;
447 }
448 self.replies.iter_mut().any(|r| r.remove_child(id))
449 }
450
451 pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a Post, usize)) {
453 f(self, depth);
454 for r in &self.replies {
455 r.walk(depth + 1, f);
456 }
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466pub struct Identity {
467 pub id: String,
469 pub display_name: String,
471 pub kind: IdentityKind,
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
477#[serde(rename_all = "lowercase")]
478pub enum IdentityKind {
479 #[default]
481 Human,
482 Agent,
484}
485
486pub const LABEL_PALETTE: &[&str] = &[
493 "#CC785C", "#6C7BA8", "#7E9B7A", "#61AAF2", "#BF4D43", "#D4A27F", "#9A7AA0", "#3E9C93", "#E0A33B", "#C77C93", "#4F7A55", "#9E8FC2", "#B0455A", "#8A9A5B", "#7C8AA0", "#8C6A54", "#EBDBBC", "#666663", ];
512
513pub fn next_label_color(existing: &[LabelDef]) -> String {
516 let used: std::collections::HashSet<&str> =
517 existing.iter().filter_map(|l| l.color.as_deref()).collect();
518 for c in LABEL_PALETTE {
519 if !used.contains(c) {
520 return (*c).to_string();
521 }
522 }
523 LABEL_PALETTE[existing.len() % LABEL_PALETTE.len()].to_string()
524}
525
526#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
529pub struct Definitions {
530 pub version: u32,
532 #[serde(default)]
534 pub labels: Vec<LabelDef>,
535 #[serde(default)]
537 pub priorities: Vec<String>,
538}
539
540impl Definitions {
541 pub fn seed() -> Self {
543 Definitions {
544 version: FORMAT_VERSION,
545 labels: vec![
546 LabelDef::new("blocked", Some(LABEL_PALETTE[4])),
547 LabelDef::new("needs-review", Some(LABEL_PALETTE[3])),
548 LabelDef::new("agent", Some(LABEL_PALETTE[6])),
549 ],
550 priorities: vec![
551 "low".into(),
552 "medium".into(),
553 "high".into(),
554 "urgent".into(),
555 ],
556 }
557 }
558}
559
560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
562pub struct LabelDef {
563 pub name: String,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub color: Option<String>,
568 #[serde(default, skip_serializing_if = "String::is_empty")]
570 pub description: String,
571}
572
573impl LabelDef {
574 pub fn new(name: impl Into<String>, color: Option<&str>) -> Self {
576 LabelDef {
577 name: name.into(),
578 color: color.map(|c| c.to_string()),
579 description: String::new(),
580 }
581 }
582}
583
584#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
590pub struct Settings {
591 pub version: u32,
593 #[serde(default)]
595 pub daemon: DaemonSettings,
596 #[serde(default = "default_max_attachment_mb")]
599 pub max_attachment_mb: u64,
600}
601
602fn default_max_attachment_mb() -> u64 {
603 50
604}
605
606impl Default for Settings {
607 fn default() -> Self {
608 Settings {
609 version: FORMAT_VERSION,
610 daemon: DaemonSettings::default(),
611 max_attachment_mb: default_max_attachment_mb(),
612 }
613 }
614}
615
616#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618pub struct DaemonSettings {
619 pub port: u16,
621 #[serde(default)]
623 pub expose: Exposure,
624 #[serde(default)]
627 pub autoserve: bool,
628 #[serde(default = "default_idle_timeout")]
630 pub idle_timeout_secs: u64,
631}
632
633fn default_idle_timeout() -> u64 {
634 900
635}
636
637impl Default for DaemonSettings {
638 fn default() -> Self {
639 DaemonSettings {
640 port: DEFAULT_PORT,
641 expose: Exposure::default(),
642 autoserve: false,
643 idle_timeout_secs: default_idle_timeout(),
644 }
645 }
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
650#[serde(rename_all = "lowercase")]
651pub enum Exposure {
652 #[default]
654 None,
655 Tailscale,
657 Proxy,
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use chrono::TimeZone;
665
666 fn fixed() -> DateTime<Utc> {
667 Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
668 }
669
670 #[test]
671 fn board_has_default_lists() {
672 let b = Board::new("Demo", fixed());
673 assert_eq!(b.lists.len(), 4);
674 assert_eq!(b.lists[2].id, "in-progress");
675 assert_eq!(b.next_ticket, 1);
676 }
677
678 #[test]
679 fn ticket_omits_empty_fields_and_has_no_type_or_tags() {
680 let t = Ticket::new("T-1", "Hello", fixed());
681 let json = serde_json::to_string(&t).unwrap();
682 assert!(!json.contains("labels"));
684 assert!(!json.contains("assignees"));
685 assert!(!json.contains("\"body\""));
686 assert!(!json.contains("\"type\""));
688 assert!(!json.contains("tags"));
689 }
690
691 #[test]
692 fn label_color_auto_picks_unused() {
693 let existing = vec![LabelDef::new("a", Some(LABEL_PALETTE[0]))];
694 let picked = next_label_color(&existing);
695 assert_eq!(picked, LABEL_PALETTE[1]);
696 }
697
698 #[test]
699 fn comment_allocation_is_monotonic() {
700 let mut t = Ticket::new("T-1", "Hello", fixed());
701 let a = t.add_comment("me", "first", fixed());
702 let b = t.add_comment("me", "second", fixed());
703 assert_eq!(a, "c-1");
704 assert_eq!(b, "c-2");
705 assert_eq!(t.next_comment, 3);
706 }
707
708 #[test]
709 fn relation_kind_is_kebab_case() {
710 let r = Relation {
711 kind: RelationKind::BlockedBy,
712 target: "T-2".into(),
713 };
714 assert_eq!(
715 serde_json::to_string(&r).unwrap(),
716 r#"{"kind":"blocked-by","target":"T-2"}"#
717 );
718 }
719}