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
299 .iter()
300 .filter(|l| !t.labels.contains(l))
301 .cloned()
302 .collect();
303 let removed: Vec<String> = t
304 .labels
305 .iter()
306 .filter(|l| !v.contains(l))
307 .cloned()
308 .collect();
309 for l in added {
310 t.log_activity(actor, "label-added", l, now);
311 }
312 for l in removed {
313 t.log_activity(actor, "label-removed", l, now);
314 }
315 t.labels = v;
316 }
317 if let Some(v) = patch.assignees {
318 let added: Vec<String> = v
319 .iter()
320 .filter(|a| !t.assignees.contains(a))
321 .cloned()
322 .collect();
323 let removed: Vec<String> = t
324 .assignees
325 .iter()
326 .filter(|a| !v.contains(a))
327 .cloned()
328 .collect();
329 for a in added {
330 t.log_activity(actor, "assigned", a, now);
331 }
332 for a in removed {
333 t.log_activity(actor, "unassigned", a, now);
334 }
335 t.assignees = v;
336 }
337 t.updated = now;
338 store.save_ticket(&t)?;
339 Ok(t)
340}
341
342pub fn create_label(
344 store: &Store,
345 name: &str,
346 color: Option<String>,
347 description: Option<String>,
348) -> Result<LabelDef> {
349 let mut defs = store.load_definitions()?;
350 if defs.labels.iter().any(|l| l.name == name) {
351 return Err(Error::msg(format!("label `{name}` already exists")));
352 }
353 let color = color.or_else(|| Some(next_label_color(&defs.labels)));
354 let label = LabelDef {
355 name: name.to_string(),
356 color,
357 description: description.unwrap_or_default(),
358 };
359 defs.labels.push(label.clone());
360 store.save_definitions(&defs)?;
361 Ok(label)
362}
363
364pub fn set_label_color(store: &Store, name: &str, color: &str) -> Result<LabelDef> {
366 let mut defs = store.load_definitions()?;
367 let label = defs
368 .labels
369 .iter_mut()
370 .find(|l| l.name == name)
371 .ok_or_else(|| Error::msg(format!("label `{name}` not found")))?;
372 label.color = Some(color.to_string());
373 let out = label.clone();
374 store.save_definitions(&defs)?;
375 Ok(out)
376}
377
378pub fn delete_label(store: &Store, name: &str, now: DateTime<Utc>) -> Result<()> {
380 let mut defs = store.load_definitions()?;
381 defs.labels.retain(|l| l.name != name);
382 store.save_definitions(&defs)?;
383 for id in store.ticket_ids()? {
384 let mut t = store.load_ticket(&id)?;
385 if t.labels.iter().any(|l| l == name) {
386 t.labels.retain(|l| l != name);
387 t.updated = now;
388 store.save_ticket(&t)?;
389 }
390 }
391 Ok(())
392}
393
394pub fn list_identities(store: &Store) -> Result<Vec<Identity>> {
397 let mut registry = store.load_identities()?;
398 if git::is_repo(store.root()) {
399 if let Ok(authors) = git::authors(store.root()) {
400 for (name, email) in authors {
401 if !registry.iter().any(|i| i.id == email) {
402 registry.push(Identity {
403 id: email,
404 display_name: name,
405 kind: IdentityKind::Human,
406 });
407 }
408 }
409 }
410 }
411 Ok(registry)
412}
413
414pub fn upsert_identity(
416 store: &Store,
417 id: &str,
418 display_name: &str,
419 kind: Option<IdentityKind>,
420) -> Result<Identity> {
421 let mut registry = store.load_identities()?;
422 if let Some(existing) = registry.iter_mut().find(|i| i.id == id) {
423 existing.display_name = display_name.to_string();
424 if let Some(k) = kind {
425 existing.kind = k;
426 }
427 let out = existing.clone();
428 store.save_identities(®istry)?;
429 Ok(out)
430 } else {
431 let ident = Identity {
432 id: id.to_string(),
433 display_name: display_name.to_string(),
434 kind: kind.unwrap_or_default(),
435 };
436 registry.push(ident.clone());
437 store.save_identities(®istry)?;
438 Ok(ident)
439 }
440}
441
442pub fn delete_identity(store: &Store, id: &str) -> Result<()> {
446 let mut registry = store.load_identities()?;
447 let before = registry.len();
448 registry.retain(|i| i.id != id);
449 if registry.len() != before {
450 store.save_identities(®istry)?;
451 }
452 Ok(())
453}
454
455pub fn add_attachment(
459 store: &Store,
460 ticket_id: &str,
461 name: &str,
462 bytes: &[u8],
463 mime: &str,
464 actor: &str,
465 now: DateTime<Utc>,
466) -> Result<Attachment> {
467 let mut ticket = store.load_ticket(ticket_id)?;
468 let root = store.root();
469 let hash = git::blob_hash(bytes);
470
471 let existing = if git::is_repo(root) {
473 git::tracked_blobs(root)
474 .ok()
475 .and_then(|blobs| blobs.into_iter().find(|(h, _)| *h == hash).map(|(_, p)| p))
476 } else {
477 None
478 };
479
480 let attachment = if let Some(path) = existing {
481 Attachment {
482 name: name.to_string(),
483 path,
484 source: AttachmentSource::Repo,
485 size: bytes.len() as u64,
486 mime: mime.to_string(),
487 }
488 } else {
489 let file_name = format!("{}-{}", &hash[..8.min(hash.len())], sanitize(name));
490 fs::create_dir_all(store.media_dir())?;
491 fs::write(store.media_dir().join(&file_name), bytes)?;
492 Attachment {
493 name: name.to_string(),
494 path: format!(".wipe/media/{file_name}"),
495 source: AttachmentSource::Media,
496 size: bytes.len() as u64,
497 mime: mime.to_string(),
498 }
499 };
500
501 if !ticket.attachments.iter().any(|a| a.path == attachment.path) {
502 ticket.attachments.push(attachment.clone());
503 ticket.log_activity(actor, "attached", attachment.name.clone(), now);
504 ticket.updated = now;
505 store.save_ticket(&ticket)?;
506 }
507 Ok(attachment)
508}
509
510pub fn remove_attachment(
513 store: &Store,
514 ticket_id: &str,
515 path: &str,
516 actor: &str,
517 now: DateTime<Utc>,
518) -> Result<()> {
519 let mut ticket = store.load_ticket(ticket_id)?;
520 let name = ticket
521 .attachments
522 .iter()
523 .find(|a| a.path == path)
524 .map(|a| a.name.clone());
525 ticket.attachments.retain(|a| a.path != path);
526 if let Some(name) = name {
527 ticket.log_activity(actor, "detached", name, now);
528 }
529 ticket.updated = now;
530 store.save_ticket(&ticket)?;
531 Ok(())
532}
533
534fn sanitize(name: &str) -> String {
536 let base = std::path::Path::new(name)
537 .file_name()
538 .and_then(|n| n.to_str())
539 .unwrap_or(name);
540 let cleaned: String = base
541 .chars()
542 .map(|c| {
543 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
544 c
545 } else {
546 '-'
547 }
548 })
549 .collect();
550 let trimmed = cleaned.trim_matches('-');
551 if trimmed.is_empty() {
552 "file".to_string()
553 } else {
554 trimmed.to_string()
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use chrono::TimeZone;
562
563 fn now() -> DateTime<Utc> {
564 Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
565 }
566
567 fn project() -> (tempfile::TempDir, Store) {
568 let dir = tempfile::tempdir().unwrap();
569 let store = Store::init(dir.path(), "Test", now()).unwrap();
570 (dir, store)
571 }
572
573 #[test]
574 fn create_places_on_first_list_and_allocates_ids() {
575 let (_d, s) = project();
576 let t1 = create_ticket(
577 &s,
578 NewTicket {
579 title: "A".into(),
580 ..Default::default()
581 },
582 "tester",
583 now(),
584 )
585 .unwrap();
586 let t2 = create_ticket(
587 &s,
588 NewTicket {
589 title: "B".into(),
590 ..Default::default()
591 },
592 "tester",
593 now(),
594 )
595 .unwrap();
596 assert_eq!(t1.id, "T-1");
597 assert_eq!(t2.id, "T-2");
598 let board = s.load_board().unwrap();
599 assert_eq!(board.lists[0].cards, vec!["T-1", "T-2"]);
600 assert_eq!(board.next_ticket, 3);
601 }
602
603 #[test]
604 fn move_relocates_card() {
605 let (_d, s) = project();
606 create_ticket(
607 &s,
608 NewTicket {
609 title: "A".into(),
610 ..Default::default()
611 },
612 "tester",
613 now(),
614 )
615 .unwrap();
616 move_ticket(&s, "T-1", "done", None, "tester", now()).unwrap();
617 let board = s.load_board().unwrap();
618 assert!(board.list("backlog").unwrap().cards.is_empty());
619 assert_eq!(board.list("done").unwrap().cards, vec!["T-1"]);
620 }
621
622 #[test]
623 fn move_to_unknown_list_errors() {
624 let (_d, s) = project();
625 create_ticket(
626 &s,
627 NewTicket {
628 title: "A".into(),
629 ..Default::default()
630 },
631 "tester",
632 now(),
633 )
634 .unwrap();
635 assert!(matches!(
636 move_ticket(&s, "T-1", "nope", None, "tester", now()),
637 Err(Error::ListNotFound(_))
638 ));
639 }
640
641 #[test]
642 fn delete_removes_file_and_card() {
643 let (_d, s) = project();
644 create_ticket(
645 &s,
646 NewTicket {
647 title: "A".into(),
648 ..Default::default()
649 },
650 "tester",
651 now(),
652 )
653 .unwrap();
654 delete_ticket(&s, "T-1", now()).unwrap();
655 assert!(matches!(
656 s.load_ticket("T-1"),
657 Err(Error::TicketNotFound(_))
658 ));
659 let board = s.load_board().unwrap();
660 assert!(board.lists.iter().all(|l| l.cards.is_empty()));
661 }
662
663 #[test]
664 fn list_lifecycle() {
665 let (_d, s) = project();
666 let id = add_list(&s, "In Review", now()).unwrap();
667 assert_eq!(id, "in-review");
668 rename_list(&s, &id, "Review", now()).unwrap();
669 move_list(&s, &id, 0, now()).unwrap();
670 let board = s.load_board().unwrap();
671 assert_eq!(board.lists[0].id, "in-review");
672 assert_eq!(board.lists[0].name, "Review");
673 remove_list(&s, &id, false, now()).unwrap();
674 assert!(s.load_board().unwrap().list("in-review").is_none());
675 }
676
677 #[test]
678 fn remove_nonempty_list_requires_force() {
679 let (_d, s) = project();
680 create_ticket(
681 &s,
682 NewTicket {
683 title: "A".into(),
684 ..Default::default()
685 },
686 "tester",
687 now(),
688 )
689 .unwrap();
690 assert!(remove_list(&s, "backlog", false, now()).is_err());
691 remove_list(&s, "backlog", true, now()).unwrap();
692 assert!(matches!(
693 s.load_ticket("T-1"),
694 Err(Error::TicketNotFound(_))
695 ));
696 }
697
698 #[test]
699 fn update_ticket_applies_patch() {
700 let (_d, s) = project();
701 create_ticket(
702 &s,
703 NewTicket {
704 title: "A".into(),
705 ..Default::default()
706 },
707 "tester",
708 now(),
709 )
710 .unwrap();
711 let patch = TicketPatch {
712 title: Some("A2".into()),
713 priority: Some(Some("high".into())),
714 labels: Some(vec!["blocked".into()]),
715 assignees: Some(vec!["ada@example.com".into()]),
716 ..Default::default()
717 };
718 let t = update_ticket(&s, "T-1", patch, "tester", now()).unwrap();
719 assert_eq!(t.title, "A2");
720 assert_eq!(t.priority.as_deref(), Some("high"));
721 assert_eq!(t.labels, vec!["blocked"]);
722 let cleared = update_ticket(
723 &s,
724 "T-1",
725 TicketPatch {
726 priority: Some(None),
727 ..Default::default()
728 },
729 "tester",
730 now(),
731 )
732 .unwrap();
733 assert_eq!(cleared.priority, None);
734 }
735
736 #[test]
737 fn activity_is_logged_for_create_move_and_patch() {
738 let (_d, s) = project();
739 create_ticket(
740 &s,
741 NewTicket {
742 title: "A".into(),
743 ..Default::default()
744 },
745 "ada",
746 now(),
747 )
748 .unwrap();
749 move_ticket(&s, "T-1", "done", None, "ada", now()).unwrap();
750 update_ticket(
751 &s,
752 "T-1",
753 TicketPatch {
754 labels: Some(vec!["blocked".into()]),
755 assignees: Some(vec!["ada@example.com".into()]),
756 priority: Some(Some("high".into())),
757 ..Default::default()
758 },
759 "ada",
760 now(),
761 )
762 .unwrap();
763
764 let t = s.load_ticket("T-1").unwrap();
765 let kinds: Vec<&str> = t.activity.iter().map(|a| a.kind.as_str()).collect();
766 assert_eq!(
767 kinds,
768 vec!["created", "moved", "priority", "label-added", "assigned"]
769 );
770 let moved = t.activity.iter().find(|a| a.kind == "moved").unwrap();
772 assert_eq!(moved.detail, "Done");
773 assert!(t.activity.iter().all(|a| a.actor == "ada"));
774
775 update_ticket(
777 &s,
778 "T-1",
779 TicketPatch {
780 labels: Some(vec![]),
781 assignees: Some(vec![]),
782 ..Default::default()
783 },
784 "ada",
785 now(),
786 )
787 .unwrap();
788 let t = s.load_ticket("T-1").unwrap();
789 assert!(t.activity.iter().any(|a| a.kind == "label-removed"));
790 assert!(t.activity.iter().any(|a| a.kind == "unassigned"));
791 }
792
793 #[test]
794 fn identity_upsert_and_list() {
795 let (_d, s) = project();
796 upsert_identity(&s, "claude", "Claude", Some(IdentityKind::Agent)).unwrap();
797 let ids = list_identities(&s).unwrap();
798 let agent = ids.iter().find(|i| i.id == "claude").unwrap();
799 assert_eq!(agent.display_name, "Claude");
800 assert_eq!(agent.kind, IdentityKind::Agent);
801 }
802
803 #[test]
804 fn attachment_prefers_repo_reference_over_copy() {
805 use std::process::Command;
806 let dir = tempfile::tempdir().unwrap();
807 let root = dir.path();
808 let git = |args: &[&str]| {
809 assert!(Command::new("git")
810 .arg("-C")
811 .arg(root)
812 .args(args)
813 .output()
814 .unwrap()
815 .status
816 .success());
817 };
818 git(&["init", "-q"]);
819 git(&["config", "user.email", "t@example.com"]);
820 git(&["config", "user.name", "Tester"]);
821 let s = Store::init(root, "Att", now()).unwrap();
822 create_ticket(
823 &s,
824 NewTicket {
825 title: "A".into(),
826 ..Default::default()
827 },
828 "tester",
829 now(),
830 )
831 .unwrap();
832
833 std::fs::write(root.join("logo.png"), b"PNGDATA").unwrap();
834 git(&["add", "logo.png"]);
835 git(&["commit", "-q", "-m", "add logo"]);
836
837 let a = add_attachment(
839 &s,
840 "T-1",
841 "logo.png",
842 b"PNGDATA",
843 "image/png",
844 "tester",
845 now(),
846 )
847 .unwrap();
848 assert_eq!(a.source, AttachmentSource::Repo);
849 assert_eq!(a.path, "logo.png");
850
851 let b = add_attachment(
853 &s,
854 "T-1",
855 "new.txt",
856 b"hello world",
857 "text/plain",
858 "tester",
859 now(),
860 )
861 .unwrap();
862 assert_eq!(b.source, AttachmentSource::Media);
863 assert!(b.path.starts_with(".wipe/media/"));
864 assert!(root.join(&b.path).exists());
865 }
866}