1use std::fs;
7
8use chrono::{DateTime, Utc};
9
10use crate::error::{Error, Result};
11use crate::git;
12use crate::id::{slug, ticket_id};
13use crate::model::{
14 next_label_color, Attachment, AttachmentSource, Board, Identity, IdentityKind, LabelDef, List,
15 Ticket,
16};
17use crate::store::Store;
18
19#[derive(Debug, Default, Clone)]
21pub struct NewTicket {
22 pub title: String,
24 pub body: Option<String>,
26 pub priority: Option<String>,
28 pub list: Option<String>,
30 pub labels: Vec<String>,
32 pub assignees: Vec<String>,
34}
35
36pub fn create_ticket(
39 store: &Store,
40 spec: NewTicket,
41 actor: &str,
42 now: DateTime<Utc>,
43) -> Result<Ticket> {
44 let mut board = store.load_board()?;
45
46 let list_id = match spec.list {
47 Some(l) => {
48 if board.list(&l).is_none() {
49 return Err(Error::ListNotFound(l));
50 }
51 l
52 }
53 None => board
54 .lists
55 .first()
56 .map(|l| l.id.clone())
57 .ok_or_else(|| Error::msg("board has no lists"))?,
58 };
59
60 let id = ticket_id(board.next_ticket);
61 board.next_ticket += 1;
62
63 let mut ticket = Ticket::new(id.clone(), spec.title, now);
64 ticket.body = spec.body.unwrap_or_default();
65 ticket.priority = spec.priority;
66 ticket.labels = spec.labels;
67 ticket.assignees = spec.assignees;
68 ticket.log_activity(actor, "created", "", now);
69
70 board
71 .list_mut(&list_id)
72 .expect("checked above")
73 .cards
74 .push(id.clone());
75 board.updated = now;
76
77 store.save_ticket(&ticket)?;
78 store.save_board(&board)?;
79 Ok(ticket)
80}
81
82pub fn move_ticket(
85 store: &Store,
86 ticket_id: &str,
87 to_list: &str,
88 position: Option<usize>,
89 actor: &str,
90 now: DateTime<Utc>,
91) -> Result<()> {
92 let _ = store.load_ticket(ticket_id)?;
94 let mut board = store.load_board()?;
95 let dest_name = match board.list(to_list) {
96 Some(l) => l.name.clone(),
97 None => return Err(Error::ListNotFound(to_list.to_string())),
98 };
99
100 let from_list = board
102 .lists
103 .iter()
104 .find(|l| l.cards.iter().any(|c| c == ticket_id))
105 .map(|l| l.id.clone());
106
107 for list in &mut board.lists {
109 list.cards.retain(|c| c != ticket_id);
110 }
111
112 let dest = board.list_mut(to_list).expect("checked above");
113 let pos = position.unwrap_or(dest.cards.len()).min(dest.cards.len());
114 dest.cards.insert(pos, ticket_id.to_string());
115 board.updated = now;
116
117 let mut ticket = store.load_ticket(ticket_id)?;
120 ticket.updated = now;
121 if from_list.as_deref() != Some(to_list) {
122 ticket.log_activity(actor, "moved", dest_name, now);
123 }
124 store.save_ticket(&ticket)?;
125 store.save_board(&board)?;
126 Ok(())
127}
128
129pub fn delete_ticket(store: &Store, ticket_id: &str, now: DateTime<Utc>) -> Result<()> {
131 store.delete_ticket(ticket_id)?; let mut board = store.load_board()?;
133 for list in &mut board.lists {
134 list.cards.retain(|c| c != ticket_id);
135 }
136 board.updated = now;
137 store.save_board(&board)?;
138 Ok(())
139}
140
141pub fn add_comment(
143 store: &Store,
144 ticket_id: &str,
145 author: &str,
146 body: &str,
147 now: DateTime<Utc>,
148) -> Result<String> {
149 let mut ticket = store.load_ticket(ticket_id)?;
150 let id = ticket.add_comment(author, body, now);
151 store.save_ticket(&ticket)?;
152 Ok(id)
153}
154
155pub fn add_list(store: &Store, name: &str, now: DateTime<Utc>) -> Result<String> {
157 let mut board = store.load_board()?;
158 let mut id = slug(name);
159 if board.list(&id).is_some() {
161 let mut n = 2;
162 loop {
163 let candidate = format!("{id}-{n}");
164 if board.list(&candidate).is_none() {
165 id = candidate;
166 break;
167 }
168 n += 1;
169 }
170 }
171 let mut list = List::new(name);
172 list.id = id.clone();
173 board.lists.push(list);
174 board.updated = now;
175 store.save_board(&board)?;
176 Ok(id)
177}
178
179pub fn remove_list(store: &Store, list_id: &str, force: bool, now: DateTime<Utc>) -> Result<()> {
182 let mut board = store.load_board()?;
183 let idx = board
184 .lists
185 .iter()
186 .position(|l| l.id == list_id)
187 .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
188
189 let cards = board.lists[idx].cards.clone();
190 if !cards.is_empty() && !force {
191 return Err(Error::msg(format!(
192 "list `{list_id}` still holds {} ticket(s); pass --force to delete them too",
193 cards.len()
194 )));
195 }
196 for id in cards {
197 let _ = store.delete_ticket(&id);
199 }
200 board.lists.remove(idx);
201 board.updated = now;
202 store.save_board(&board)?;
203 Ok(())
204}
205
206pub fn move_list(store: &Store, list_id: &str, to_index: usize, now: DateTime<Utc>) -> Result<()> {
208 let mut board = store.load_board()?;
209 let from = board
210 .lists
211 .iter()
212 .position(|l| l.id == list_id)
213 .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
214 let list = board.lists.remove(from);
215 let to = to_index.min(board.lists.len());
216 board.lists.insert(to, list);
217 board.updated = now;
218 store.save_board(&board)?;
219 Ok(())
220}
221
222pub fn rename_list(store: &Store, list_id: &str, name: &str, now: DateTime<Utc>) -> Result<()> {
224 let mut board = store.load_board()?;
225 let list = board
226 .list_mut(list_id)
227 .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
228 list.name = name.to_string();
229 board.updated = now;
230 store.save_board(&board)?;
231 Ok(())
232}
233
234pub type ListView = (String, Vec<Ticket>);
236
237pub fn board_view(store: &Store) -> Result<(Board, Vec<ListView>)> {
239 let board = store.load_board()?;
240 let mut out = Vec::with_capacity(board.lists.len());
241 for list in &board.lists {
242 let mut tickets = Vec::with_capacity(list.cards.len());
243 for id in &list.cards {
244 if let Ok(t) = store.load_ticket(id) {
246 tickets.push(t);
247 }
248 }
249 out.push((list.id.clone(), tickets));
250 }
251 Ok((board, out))
252}
253
254#[derive(Debug, Default, Clone)]
257pub struct TicketPatch {
258 pub title: Option<String>,
260 pub body: Option<String>,
262 pub priority: Option<Option<String>>,
264 pub labels: Option<Vec<String>>,
266 pub assignees: Option<Vec<String>>,
268}
269
270pub fn update_ticket(
272 store: &Store,
273 id: &str,
274 patch: TicketPatch,
275 actor: &str,
276 now: DateTime<Utc>,
277) -> Result<Ticket> {
278 let mut t = store.load_ticket(id)?;
279 if let Some(v) = patch.title {
280 if v != t.title {
281 t.log_activity(actor, "renamed", v.clone(), now);
282 }
283 t.title = v;
284 }
285 if let Some(v) = patch.body {
286 if v != t.body {
287 t.log_activity(actor, "edited", "", now);
288 }
289 t.body = v;
290 }
291 if let Some(v) = patch.priority {
292 if v != t.priority {
293 t.log_activity(actor, "priority", v.clone().unwrap_or_default(), now);
294 }
295 t.priority = v;
296 }
297 if let Some(v) = patch.labels {
298 let added: Vec<String> = v.iter().filter(|l| !t.labels.contains(l)).cloned().collect();
299 let removed: Vec<String> = t.labels.iter().filter(|l| !v.contains(l)).cloned().collect();
300 for l in added {
301 t.log_activity(actor, "label-added", l, now);
302 }
303 for l in removed {
304 t.log_activity(actor, "label-removed", l, now);
305 }
306 t.labels = v;
307 }
308 if let Some(v) = patch.assignees {
309 let added: Vec<String> = v
310 .iter()
311 .filter(|a| !t.assignees.contains(a))
312 .cloned()
313 .collect();
314 let removed: Vec<String> = t
315 .assignees
316 .iter()
317 .filter(|a| !v.contains(a))
318 .cloned()
319 .collect();
320 for a in added {
321 t.log_activity(actor, "assigned", a, now);
322 }
323 for a in removed {
324 t.log_activity(actor, "unassigned", a, now);
325 }
326 t.assignees = v;
327 }
328 t.updated = now;
329 store.save_ticket(&t)?;
330 Ok(t)
331}
332
333pub fn create_label(
335 store: &Store,
336 name: &str,
337 color: Option<String>,
338 description: Option<String>,
339) -> Result<LabelDef> {
340 let mut defs = store.load_definitions()?;
341 if defs.labels.iter().any(|l| l.name == name) {
342 return Err(Error::msg(format!("label `{name}` already exists")));
343 }
344 let color = color.or_else(|| Some(next_label_color(&defs.labels)));
345 let label = LabelDef {
346 name: name.to_string(),
347 color,
348 description: description.unwrap_or_default(),
349 };
350 defs.labels.push(label.clone());
351 store.save_definitions(&defs)?;
352 Ok(label)
353}
354
355pub fn set_label_color(store: &Store, name: &str, color: &str) -> Result<LabelDef> {
357 let mut defs = store.load_definitions()?;
358 let label = defs
359 .labels
360 .iter_mut()
361 .find(|l| l.name == name)
362 .ok_or_else(|| Error::msg(format!("label `{name}` not found")))?;
363 label.color = Some(color.to_string());
364 let out = label.clone();
365 store.save_definitions(&defs)?;
366 Ok(out)
367}
368
369pub fn delete_label(store: &Store, name: &str, now: DateTime<Utc>) -> Result<()> {
371 let mut defs = store.load_definitions()?;
372 defs.labels.retain(|l| l.name != name);
373 store.save_definitions(&defs)?;
374 for id in store.ticket_ids()? {
375 let mut t = store.load_ticket(&id)?;
376 if t.labels.iter().any(|l| l == name) {
377 t.labels.retain(|l| l != name);
378 t.updated = now;
379 store.save_ticket(&t)?;
380 }
381 }
382 Ok(())
383}
384
385pub fn list_identities(store: &Store) -> Result<Vec<Identity>> {
388 let mut registry = store.load_identities()?;
389 if git::is_repo(store.root()) {
390 if let Ok(authors) = git::authors(store.root()) {
391 for (name, email) in authors {
392 if !registry.iter().any(|i| i.id == email) {
393 registry.push(Identity {
394 id: email,
395 display_name: name,
396 kind: IdentityKind::Human,
397 });
398 }
399 }
400 }
401 }
402 Ok(registry)
403}
404
405pub fn upsert_identity(
407 store: &Store,
408 id: &str,
409 display_name: &str,
410 kind: Option<IdentityKind>,
411) -> Result<Identity> {
412 let mut registry = store.load_identities()?;
413 if let Some(existing) = registry.iter_mut().find(|i| i.id == id) {
414 existing.display_name = display_name.to_string();
415 if let Some(k) = kind {
416 existing.kind = k;
417 }
418 let out = existing.clone();
419 store.save_identities(®istry)?;
420 Ok(out)
421 } else {
422 let ident = Identity {
423 id: id.to_string(),
424 display_name: display_name.to_string(),
425 kind: kind.unwrap_or_default(),
426 };
427 registry.push(ident.clone());
428 store.save_identities(®istry)?;
429 Ok(ident)
430 }
431}
432
433pub fn delete_identity(store: &Store, id: &str) -> Result<()> {
437 let mut registry = store.load_identities()?;
438 let before = registry.len();
439 registry.retain(|i| i.id != id);
440 if registry.len() != before {
441 store.save_identities(®istry)?;
442 }
443 Ok(())
444}
445
446pub fn add_attachment(
450 store: &Store,
451 ticket_id: &str,
452 name: &str,
453 bytes: &[u8],
454 mime: &str,
455 actor: &str,
456 now: DateTime<Utc>,
457) -> Result<Attachment> {
458 let mut ticket = store.load_ticket(ticket_id)?;
459 let root = store.root();
460 let hash = git::blob_hash(bytes);
461
462 let existing = if git::is_repo(root) {
464 git::tracked_blobs(root)
465 .ok()
466 .and_then(|blobs| blobs.into_iter().find(|(h, _)| *h == hash).map(|(_, p)| p))
467 } else {
468 None
469 };
470
471 let attachment = if let Some(path) = existing {
472 Attachment {
473 name: name.to_string(),
474 path,
475 source: AttachmentSource::Repo,
476 size: bytes.len() as u64,
477 mime: mime.to_string(),
478 }
479 } else {
480 let file_name = format!("{}-{}", &hash[..8.min(hash.len())], sanitize(name));
481 fs::create_dir_all(store.media_dir())?;
482 fs::write(store.media_dir().join(&file_name), bytes)?;
483 Attachment {
484 name: name.to_string(),
485 path: format!(".wipe/media/{file_name}"),
486 source: AttachmentSource::Media,
487 size: bytes.len() as u64,
488 mime: mime.to_string(),
489 }
490 };
491
492 if !ticket.attachments.iter().any(|a| a.path == attachment.path) {
493 ticket.attachments.push(attachment.clone());
494 ticket.log_activity(actor, "attached", attachment.name.clone(), now);
495 ticket.updated = now;
496 store.save_ticket(&ticket)?;
497 }
498 Ok(attachment)
499}
500
501pub fn remove_attachment(
504 store: &Store,
505 ticket_id: &str,
506 path: &str,
507 actor: &str,
508 now: DateTime<Utc>,
509) -> Result<()> {
510 let mut ticket = store.load_ticket(ticket_id)?;
511 let name = ticket
512 .attachments
513 .iter()
514 .find(|a| a.path == path)
515 .map(|a| a.name.clone());
516 ticket.attachments.retain(|a| a.path != path);
517 if let Some(name) = name {
518 ticket.log_activity(actor, "detached", name, now);
519 }
520 ticket.updated = now;
521 store.save_ticket(&ticket)?;
522 Ok(())
523}
524
525fn sanitize(name: &str) -> String {
527 let base = std::path::Path::new(name)
528 .file_name()
529 .and_then(|n| n.to_str())
530 .unwrap_or(name);
531 let cleaned: String = base
532 .chars()
533 .map(|c| {
534 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
535 c
536 } else {
537 '-'
538 }
539 })
540 .collect();
541 let trimmed = cleaned.trim_matches('-');
542 if trimmed.is_empty() {
543 "file".to_string()
544 } else {
545 trimmed.to_string()
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use chrono::TimeZone;
553
554 fn now() -> DateTime<Utc> {
555 Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
556 }
557
558 fn project() -> (tempfile::TempDir, Store) {
559 let dir = tempfile::tempdir().unwrap();
560 let store = Store::init(dir.path(), "Test", now()).unwrap();
561 (dir, store)
562 }
563
564 #[test]
565 fn create_places_on_first_list_and_allocates_ids() {
566 let (_d, s) = project();
567 let t1 = create_ticket(
568 &s,
569 NewTicket {
570 title: "A".into(),
571 ..Default::default()
572 },
573 "tester",
574 now(),
575 )
576 .unwrap();
577 let t2 = create_ticket(
578 &s,
579 NewTicket {
580 title: "B".into(),
581 ..Default::default()
582 },
583 "tester",
584 now(),
585 )
586 .unwrap();
587 assert_eq!(t1.id, "T-1");
588 assert_eq!(t2.id, "T-2");
589 let board = s.load_board().unwrap();
590 assert_eq!(board.lists[0].cards, vec!["T-1", "T-2"]);
591 assert_eq!(board.next_ticket, 3);
592 }
593
594 #[test]
595 fn move_relocates_card() {
596 let (_d, s) = project();
597 create_ticket(
598 &s,
599 NewTicket {
600 title: "A".into(),
601 ..Default::default()
602 },
603 "tester",
604 now(),
605 )
606 .unwrap();
607 move_ticket(&s, "T-1", "done", None, "tester", now()).unwrap();
608 let board = s.load_board().unwrap();
609 assert!(board.list("backlog").unwrap().cards.is_empty());
610 assert_eq!(board.list("done").unwrap().cards, vec!["T-1"]);
611 }
612
613 #[test]
614 fn move_to_unknown_list_errors() {
615 let (_d, s) = project();
616 create_ticket(
617 &s,
618 NewTicket {
619 title: "A".into(),
620 ..Default::default()
621 },
622 "tester",
623 now(),
624 )
625 .unwrap();
626 assert!(matches!(
627 move_ticket(&s, "T-1", "nope", None, "tester", now()),
628 Err(Error::ListNotFound(_))
629 ));
630 }
631
632 #[test]
633 fn delete_removes_file_and_card() {
634 let (_d, s) = project();
635 create_ticket(
636 &s,
637 NewTicket {
638 title: "A".into(),
639 ..Default::default()
640 },
641 "tester",
642 now(),
643 )
644 .unwrap();
645 delete_ticket(&s, "T-1", now()).unwrap();
646 assert!(matches!(
647 s.load_ticket("T-1"),
648 Err(Error::TicketNotFound(_))
649 ));
650 let board = s.load_board().unwrap();
651 assert!(board.lists.iter().all(|l| l.cards.is_empty()));
652 }
653
654 #[test]
655 fn list_lifecycle() {
656 let (_d, s) = project();
657 let id = add_list(&s, "In Review", now()).unwrap();
658 assert_eq!(id, "in-review");
659 rename_list(&s, &id, "Review", now()).unwrap();
660 move_list(&s, &id, 0, now()).unwrap();
661 let board = s.load_board().unwrap();
662 assert_eq!(board.lists[0].id, "in-review");
663 assert_eq!(board.lists[0].name, "Review");
664 remove_list(&s, &id, false, now()).unwrap();
665 assert!(s.load_board().unwrap().list("in-review").is_none());
666 }
667
668 #[test]
669 fn remove_nonempty_list_requires_force() {
670 let (_d, s) = project();
671 create_ticket(
672 &s,
673 NewTicket {
674 title: "A".into(),
675 ..Default::default()
676 },
677 "tester",
678 now(),
679 )
680 .unwrap();
681 assert!(remove_list(&s, "backlog", false, now()).is_err());
682 remove_list(&s, "backlog", true, now()).unwrap();
683 assert!(matches!(
684 s.load_ticket("T-1"),
685 Err(Error::TicketNotFound(_))
686 ));
687 }
688
689 #[test]
690 fn update_ticket_applies_patch() {
691 let (_d, s) = project();
692 create_ticket(
693 &s,
694 NewTicket {
695 title: "A".into(),
696 ..Default::default()
697 },
698 "tester",
699 now(),
700 )
701 .unwrap();
702 let patch = TicketPatch {
703 title: Some("A2".into()),
704 priority: Some(Some("high".into())),
705 labels: Some(vec!["blocked".into()]),
706 assignees: Some(vec!["ada@example.com".into()]),
707 ..Default::default()
708 };
709 let t = update_ticket(&s, "T-1", patch, "tester", now()).unwrap();
710 assert_eq!(t.title, "A2");
711 assert_eq!(t.priority.as_deref(), Some("high"));
712 assert_eq!(t.labels, vec!["blocked"]);
713 let cleared = update_ticket(
714 &s,
715 "T-1",
716 TicketPatch {
717 priority: Some(None),
718 ..Default::default()
719 },
720 "tester",
721 now(),
722 )
723 .unwrap();
724 assert_eq!(cleared.priority, None);
725 }
726
727 #[test]
728 fn activity_is_logged_for_create_move_and_patch() {
729 let (_d, s) = project();
730 create_ticket(
731 &s,
732 NewTicket {
733 title: "A".into(),
734 ..Default::default()
735 },
736 "ada",
737 now(),
738 )
739 .unwrap();
740 move_ticket(&s, "T-1", "done", None, "ada", now()).unwrap();
741 update_ticket(
742 &s,
743 "T-1",
744 TicketPatch {
745 labels: Some(vec!["blocked".into()]),
746 assignees: Some(vec!["ada@example.com".into()]),
747 priority: Some(Some("high".into())),
748 ..Default::default()
749 },
750 "ada",
751 now(),
752 )
753 .unwrap();
754
755 let t = s.load_ticket("T-1").unwrap();
756 let kinds: Vec<&str> = t.activity.iter().map(|a| a.kind.as_str()).collect();
757 assert_eq!(
758 kinds,
759 vec!["created", "moved", "priority", "label-added", "assigned"]
760 );
761 let moved = t.activity.iter().find(|a| a.kind == "moved").unwrap();
763 assert_eq!(moved.detail, "Done");
764 assert!(t.activity.iter().all(|a| a.actor == "ada"));
765
766 update_ticket(
768 &s,
769 "T-1",
770 TicketPatch {
771 labels: Some(vec![]),
772 assignees: Some(vec![]),
773 ..Default::default()
774 },
775 "ada",
776 now(),
777 )
778 .unwrap();
779 let t = s.load_ticket("T-1").unwrap();
780 assert!(t.activity.iter().any(|a| a.kind == "label-removed"));
781 assert!(t.activity.iter().any(|a| a.kind == "unassigned"));
782 }
783
784 #[test]
785 fn identity_upsert_and_list() {
786 let (_d, s) = project();
787 upsert_identity(&s, "claude", "Claude", Some(IdentityKind::Agent)).unwrap();
788 let ids = list_identities(&s).unwrap();
789 let agent = ids.iter().find(|i| i.id == "claude").unwrap();
790 assert_eq!(agent.display_name, "Claude");
791 assert_eq!(agent.kind, IdentityKind::Agent);
792 }
793
794 #[test]
795 fn attachment_prefers_repo_reference_over_copy() {
796 use std::process::Command;
797 let dir = tempfile::tempdir().unwrap();
798 let root = dir.path();
799 let git = |args: &[&str]| {
800 assert!(Command::new("git")
801 .arg("-C")
802 .arg(root)
803 .args(args)
804 .output()
805 .unwrap()
806 .status
807 .success());
808 };
809 git(&["init", "-q"]);
810 git(&["config", "user.email", "t@example.com"]);
811 git(&["config", "user.name", "Tester"]);
812 let s = Store::init(root, "Att", now()).unwrap();
813 create_ticket(
814 &s,
815 NewTicket {
816 title: "A".into(),
817 ..Default::default()
818 },
819 "tester",
820 now(),
821 )
822 .unwrap();
823
824 std::fs::write(root.join("logo.png"), b"PNGDATA").unwrap();
825 git(&["add", "logo.png"]);
826 git(&["commit", "-q", "-m", "add logo"]);
827
828 let a =
830 add_attachment(&s, "T-1", "logo.png", b"PNGDATA", "image/png", "tester", now()).unwrap();
831 assert_eq!(a.source, AttachmentSource::Repo);
832 assert_eq!(a.path, "logo.png");
833
834 let b = add_attachment(&s, "T-1", "new.txt", b"hello world", "text/plain", "tester", now())
836 .unwrap();
837 assert_eq!(b.source, AttachmentSource::Media);
838 assert!(b.path.starts_with(".wipe/media/"));
839 assert!(root.join(&b.path).exists());
840 }
841}