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
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum Starter {
53 #[default]
55 Standard,
56 ListsOnly,
58 Empty,
60}
61
62impl Board {
63 pub fn new(name: impl Into<String>, now: DateTime<Utc>) -> Self {
65 Board {
66 version: FORMAT_VERSION,
67 id: Uuid::new_v4().to_string(),
68 name: name.into(),
69 description: String::new(),
70 lists: default_lists(),
71 next_ticket: 1,
72 created: now,
73 updated: now,
74 }
75 }
76
77 pub fn empty(name: impl Into<String>, now: DateTime<Utc>) -> Self {
79 let mut b = Board::new(name, now);
80 b.lists.clear();
81 b
82 }
83
84 pub fn list(&self, id: &str) -> Option<&List> {
86 self.lists.iter().find(|l| l.id == id)
87 }
88
89 pub fn list_mut(&mut self, id: &str) -> Option<&mut List> {
91 self.lists.iter_mut().find(|l| l.id == id)
92 }
93
94 pub fn locate_card(&self, ticket_id: &str) -> Option<(String, usize)> {
96 for list in &self.lists {
97 if let Some(idx) = list.cards.iter().position(|c| c == ticket_id) {
98 return Some((list.id.clone(), idx));
99 }
100 }
101 None
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct List {
108 pub id: String,
110 pub name: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub color: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub wip_limit: Option<u32>,
118 #[serde(default)]
120 pub cards: Vec<String>,
121}
122
123impl List {
124 pub fn new(name: impl Into<String>) -> Self {
126 let name = name.into();
127 List {
128 id: slug(&name),
129 name,
130 color: None,
131 wip_limit: None,
132 cards: Vec::new(),
133 }
134 }
135}
136
137fn default_lists() -> Vec<List> {
139 ["Backlog", "Todo", "In Progress", "Done"]
140 .into_iter()
141 .map(List::new)
142 .collect()
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct Ticket {
152 pub version: u32,
154 pub id: String,
156 pub title: String,
158 #[serde(default, skip_serializing_if = "String::is_empty")]
160 pub body: String,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub priority: Option<String>,
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub labels: Vec<String>,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub assignees: Vec<String>,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub relations: Vec<Relation>,
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub attachments: Vec<Attachment>,
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub comments: Vec<Comment>,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub activity: Vec<Activity>,
183 #[serde(default = "one")]
185 pub next_comment: u64,
186 pub created: DateTime<Utc>,
188 pub updated: DateTime<Utc>,
190}
191
192fn one() -> u64 {
193 1
194}
195
196impl Ticket {
197 pub fn new(id: impl Into<String>, title: impl Into<String>, now: DateTime<Utc>) -> Self {
199 Ticket {
200 version: FORMAT_VERSION,
201 id: id.into(),
202 title: title.into(),
203 body: String::new(),
204 priority: None,
205 labels: Vec::new(),
206 assignees: Vec::new(),
207 relations: Vec::new(),
208 attachments: Vec::new(),
209 comments: Vec::new(),
210 activity: Vec::new(),
211 next_comment: 1,
212 created: now,
213 updated: now,
214 }
215 }
216
217 pub fn add_comment(
219 &mut self,
220 author: impl Into<String>,
221 body: impl Into<String>,
222 now: DateTime<Utc>,
223 ) -> String {
224 let id = crate::id::comment_id(self.next_comment);
225 self.next_comment += 1;
226 self.comments.push(Comment {
227 id: id.clone(),
228 author: author.into(),
229 body: body.into(),
230 created: now,
231 edited: None,
232 });
233 self.updated = now;
234 id
235 }
236
237 pub fn log_activity(
239 &mut self,
240 actor: impl Into<String>,
241 kind: impl Into<String>,
242 detail: impl Into<String>,
243 now: DateTime<Utc>,
244 ) {
245 self.activity.push(Activity {
246 ts: now,
247 actor: actor.into(),
248 kind: kind.into(),
249 detail: detail.into(),
250 });
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct Relation {
257 pub kind: RelationKind,
259 pub target: String,
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "kebab-case")]
266pub enum RelationKind {
267 Blocks,
269 BlockedBy,
271 Parent,
273 Child,
275 Relates,
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281pub struct Comment {
282 pub id: String,
284 pub author: String,
286 pub body: String,
288 pub created: DateTime<Utc>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub edited: Option<DateTime<Utc>>,
293}
294
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299pub struct Activity {
300 pub ts: DateTime<Utc>,
302 pub actor: String,
304 pub kind: String,
308 #[serde(default, skip_serializing_if = "String::is_empty")]
311 pub detail: String,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct Attachment {
321 pub name: String,
323 pub path: String,
325 pub source: AttachmentSource,
327 pub size: u64,
329 pub mime: String,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "lowercase")]
336pub enum AttachmentSource {
337 Media,
339 Repo,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct Identity {
350 pub id: String,
352 pub display_name: String,
354 pub kind: IdentityKind,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
360#[serde(rename_all = "lowercase")]
361pub enum IdentityKind {
362 #[default]
364 Human,
365 Agent,
367}
368
369pub const LABEL_PALETTE: &[&str] = &[
376 "#CC785C", "#6C7BA8", "#7E9B7A", "#61AAF2", "#BF4D43", "#D4A27F", "#9A7AA0", "#EBDBBC", "#666663", ];
386
387pub fn next_label_color(existing: &[LabelDef]) -> String {
390 let used: std::collections::HashSet<&str> =
391 existing.iter().filter_map(|l| l.color.as_deref()).collect();
392 for c in LABEL_PALETTE {
393 if !used.contains(c) {
394 return (*c).to_string();
395 }
396 }
397 LABEL_PALETTE[existing.len() % LABEL_PALETTE.len()].to_string()
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403pub struct Definitions {
404 pub version: u32,
406 #[serde(default)]
408 pub labels: Vec<LabelDef>,
409 #[serde(default)]
411 pub priorities: Vec<String>,
412}
413
414impl Definitions {
415 pub fn seed() -> Self {
417 Definitions {
418 version: FORMAT_VERSION,
419 labels: vec![
420 LabelDef::new("blocked", Some(LABEL_PALETTE[4])),
421 LabelDef::new("needs-review", Some(LABEL_PALETTE[3])),
422 LabelDef::new("agent", Some(LABEL_PALETTE[6])),
423 ],
424 priorities: vec![
425 "low".into(),
426 "medium".into(),
427 "high".into(),
428 "urgent".into(),
429 ],
430 }
431 }
432}
433
434#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
436pub struct LabelDef {
437 pub name: String,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub color: Option<String>,
442 #[serde(default, skip_serializing_if = "String::is_empty")]
444 pub description: String,
445}
446
447impl LabelDef {
448 pub fn new(name: impl Into<String>, color: Option<&str>) -> Self {
450 LabelDef {
451 name: name.into(),
452 color: color.map(|c| c.to_string()),
453 description: String::new(),
454 }
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464pub struct Settings {
465 pub version: u32,
467 #[serde(default)]
469 pub daemon: DaemonSettings,
470 #[serde(default = "default_max_attachment_mb")]
473 pub max_attachment_mb: u64,
474}
475
476fn default_max_attachment_mb() -> u64 {
477 50
478}
479
480impl Default for Settings {
481 fn default() -> Self {
482 Settings {
483 version: FORMAT_VERSION,
484 daemon: DaemonSettings::default(),
485 max_attachment_mb: default_max_attachment_mb(),
486 }
487 }
488}
489
490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492pub struct DaemonSettings {
493 pub port: u16,
495 #[serde(default)]
497 pub expose: Exposure,
498 #[serde(default)]
501 pub autoserve: bool,
502 #[serde(default = "default_idle_timeout")]
504 pub idle_timeout_secs: u64,
505}
506
507fn default_idle_timeout() -> u64 {
508 900
509}
510
511impl Default for DaemonSettings {
512 fn default() -> Self {
513 DaemonSettings {
514 port: DEFAULT_PORT,
515 expose: Exposure::default(),
516 autoserve: false,
517 idle_timeout_secs: default_idle_timeout(),
518 }
519 }
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
524#[serde(rename_all = "lowercase")]
525pub enum Exposure {
526 #[default]
528 None,
529 Tailscale,
531 Proxy,
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use chrono::TimeZone;
539
540 fn fixed() -> DateTime<Utc> {
541 Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
542 }
543
544 #[test]
545 fn board_has_default_lists() {
546 let b = Board::new("Demo", fixed());
547 assert_eq!(b.lists.len(), 4);
548 assert_eq!(b.lists[2].id, "in-progress");
549 assert_eq!(b.next_ticket, 1);
550 }
551
552 #[test]
553 fn ticket_omits_empty_fields_and_has_no_type_or_tags() {
554 let t = Ticket::new("T-1", "Hello", fixed());
555 let json = serde_json::to_string(&t).unwrap();
556 assert!(!json.contains("labels"));
558 assert!(!json.contains("assignees"));
559 assert!(!json.contains("\"body\""));
560 assert!(!json.contains("\"type\""));
562 assert!(!json.contains("tags"));
563 }
564
565 #[test]
566 fn label_color_auto_picks_unused() {
567 let existing = vec![LabelDef::new("a", Some(LABEL_PALETTE[0]))];
568 let picked = next_label_color(&existing);
569 assert_eq!(picked, LABEL_PALETTE[1]);
570 }
571
572 #[test]
573 fn comment_allocation_is_monotonic() {
574 let mut t = Ticket::new("T-1", "Hello", fixed());
575 let a = t.add_comment("me", "first", fixed());
576 let b = t.add_comment("me", "second", fixed());
577 assert_eq!(a, "c-1");
578 assert_eq!(b, "c-2");
579 assert_eq!(t.next_comment, 3);
580 }
581
582 #[test]
583 fn relation_kind_is_kebab_case() {
584 let r = Relation {
585 kind: RelationKind::BlockedBy,
586 target: "T-2".into(),
587 };
588 assert_eq!(
589 serde_json::to_string(&r).unwrap(),
590 r#"{"kind":"blocked-by","target":"T-2"}"#
591 );
592 }
593}