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, skip_serializing_if = "Vec::is_empty")]
162 pub activity: Vec<Activity>,
163 #[serde(default = "one")]
165 pub next_comment: u64,
166 pub created: DateTime<Utc>,
168 pub updated: DateTime<Utc>,
170}
171
172fn one() -> u64 {
173 1
174}
175
176impl Ticket {
177 pub fn new(id: impl Into<String>, title: impl Into<String>, now: DateTime<Utc>) -> Self {
179 Ticket {
180 version: FORMAT_VERSION,
181 id: id.into(),
182 title: title.into(),
183 body: String::new(),
184 priority: None,
185 labels: Vec::new(),
186 assignees: Vec::new(),
187 relations: Vec::new(),
188 attachments: Vec::new(),
189 comments: Vec::new(),
190 activity: Vec::new(),
191 next_comment: 1,
192 created: now,
193 updated: now,
194 }
195 }
196
197 pub fn add_comment(
199 &mut self,
200 author: impl Into<String>,
201 body: impl Into<String>,
202 now: DateTime<Utc>,
203 ) -> String {
204 let id = crate::id::comment_id(self.next_comment);
205 self.next_comment += 1;
206 self.comments.push(Comment {
207 id: id.clone(),
208 author: author.into(),
209 body: body.into(),
210 created: now,
211 edited: None,
212 });
213 self.updated = now;
214 id
215 }
216
217 pub fn log_activity(
219 &mut self,
220 actor: impl Into<String>,
221 kind: impl Into<String>,
222 detail: impl Into<String>,
223 now: DateTime<Utc>,
224 ) {
225 self.activity.push(Activity {
226 ts: now,
227 actor: actor.into(),
228 kind: kind.into(),
229 detail: detail.into(),
230 });
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct Relation {
237 pub kind: RelationKind,
239 pub target: String,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "kebab-case")]
246pub enum RelationKind {
247 Blocks,
249 BlockedBy,
251 Parent,
253 Child,
255 Relates,
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261pub struct Comment {
262 pub id: String,
264 pub author: String,
266 pub body: String,
268 pub created: DateTime<Utc>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub edited: Option<DateTime<Utc>>,
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct Activity {
280 pub ts: DateTime<Utc>,
282 pub actor: String,
284 pub kind: String,
288 #[serde(default, skip_serializing_if = "String::is_empty")]
291 pub detail: String,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct Attachment {
301 pub name: String,
303 pub path: String,
305 pub source: AttachmentSource,
307 pub size: u64,
309 pub mime: String,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "lowercase")]
316pub enum AttachmentSource {
317 Media,
319 Repo,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct Identity {
330 pub id: String,
332 pub display_name: String,
334 pub kind: IdentityKind,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
340#[serde(rename_all = "lowercase")]
341pub enum IdentityKind {
342 #[default]
344 Human,
345 Agent,
347}
348
349pub const LABEL_PALETTE: &[&str] = &[
356 "#CC785C", "#6C7BA8", "#7E9B7A", "#61AAF2", "#BF4D43", "#D4A27F", "#9A7AA0", "#EBDBBC", "#666663", ];
366
367pub fn next_label_color(existing: &[LabelDef]) -> String {
370 let used: std::collections::HashSet<&str> =
371 existing.iter().filter_map(|l| l.color.as_deref()).collect();
372 for c in LABEL_PALETTE {
373 if !used.contains(c) {
374 return (*c).to_string();
375 }
376 }
377 LABEL_PALETTE[existing.len() % LABEL_PALETTE.len()].to_string()
378}
379
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub struct Definitions {
384 pub version: u32,
386 #[serde(default)]
388 pub labels: Vec<LabelDef>,
389 #[serde(default)]
391 pub priorities: Vec<String>,
392}
393
394impl Definitions {
395 pub fn seed() -> Self {
397 Definitions {
398 version: FORMAT_VERSION,
399 labels: vec![
400 LabelDef::new("blocked", Some(LABEL_PALETTE[4])),
401 LabelDef::new("needs-review", Some(LABEL_PALETTE[3])),
402 LabelDef::new("agent", Some(LABEL_PALETTE[6])),
403 ],
404 priorities: vec![
405 "low".into(),
406 "medium".into(),
407 "high".into(),
408 "urgent".into(),
409 ],
410 }
411 }
412}
413
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct LabelDef {
417 pub name: String,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub color: Option<String>,
422 #[serde(default, skip_serializing_if = "String::is_empty")]
424 pub description: String,
425}
426
427impl LabelDef {
428 pub fn new(name: impl Into<String>, color: Option<&str>) -> Self {
430 LabelDef {
431 name: name.into(),
432 color: color.map(|c| c.to_string()),
433 description: String::new(),
434 }
435 }
436}
437
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
444pub struct Settings {
445 pub version: u32,
447 #[serde(default)]
449 pub daemon: DaemonSettings,
450 #[serde(default = "default_max_attachment_mb")]
453 pub max_attachment_mb: u64,
454}
455
456fn default_max_attachment_mb() -> u64 {
457 50
458}
459
460impl Default for Settings {
461 fn default() -> Self {
462 Settings {
463 version: FORMAT_VERSION,
464 daemon: DaemonSettings::default(),
465 max_attachment_mb: default_max_attachment_mb(),
466 }
467 }
468}
469
470#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472pub struct DaemonSettings {
473 pub port: u16,
475 #[serde(default)]
477 pub expose: Exposure,
478}
479
480impl Default for DaemonSettings {
481 fn default() -> Self {
482 DaemonSettings {
483 port: DEFAULT_PORT,
484 expose: Exposure::default(),
485 }
486 }
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
491#[serde(rename_all = "lowercase")]
492pub enum Exposure {
493 #[default]
495 None,
496 Tailscale,
498 Proxy,
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use chrono::TimeZone;
506
507 fn fixed() -> DateTime<Utc> {
508 Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
509 }
510
511 #[test]
512 fn board_has_default_lists() {
513 let b = Board::new("Demo", fixed());
514 assert_eq!(b.lists.len(), 4);
515 assert_eq!(b.lists[2].id, "in-progress");
516 assert_eq!(b.next_ticket, 1);
517 }
518
519 #[test]
520 fn ticket_omits_empty_fields_and_has_no_type_or_tags() {
521 let t = Ticket::new("T-1", "Hello", fixed());
522 let json = serde_json::to_string(&t).unwrap();
523 assert!(!json.contains("labels"));
525 assert!(!json.contains("assignees"));
526 assert!(!json.contains("\"body\""));
527 assert!(!json.contains("\"type\""));
529 assert!(!json.contains("tags"));
530 }
531
532 #[test]
533 fn label_color_auto_picks_unused() {
534 let existing = vec![LabelDef::new("a", Some(LABEL_PALETTE[0]))];
535 let picked = next_label_color(&existing);
536 assert_eq!(picked, LABEL_PALETTE[1]);
537 }
538
539 #[test]
540 fn comment_allocation_is_monotonic() {
541 let mut t = Ticket::new("T-1", "Hello", fixed());
542 let a = t.add_comment("me", "first", fixed());
543 let b = t.add_comment("me", "second", fixed());
544 assert_eq!(a, "c-1");
545 assert_eq!(b, "c-2");
546 assert_eq!(t.next_comment, 3);
547 }
548
549 #[test]
550 fn relation_kind_is_kebab_case() {
551 let r = Relation {
552 kind: RelationKind::BlockedBy,
553 target: "T-2".into(),
554 };
555 assert_eq!(
556 serde_json::to_string(&r).unwrap(),
557 r#"{"kind":"blocked-by","target":"T-2"}"#
558 );
559 }
560}