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 stage_media(store: &Store, name: &str, bytes: &[u8], mime: &str) -> Result<Attachment> {
460 let root = store.root();
461 let hash = git::blob_hash(bytes);
462
463 let existing = if git::is_repo(root) {
465 git::tracked_blobs(root)
466 .ok()
467 .and_then(|blobs| blobs.into_iter().find(|(h, _)| *h == hash).map(|(_, p)| p))
468 } else {
469 None
470 };
471
472 if let Some(path) = existing {
473 Ok(Attachment {
474 name: name.to_string(),
475 path,
476 source: AttachmentSource::Repo,
477 size: bytes.len() as u64,
478 mime: mime.to_string(),
479 })
480 } else {
481 let file_name = format!("{}-{}", &hash[..8.min(hash.len())], sanitize(name));
482 fs::create_dir_all(store.media_dir())?;
483 fs::write(store.media_dir().join(&file_name), bytes)?;
484 Ok(Attachment {
485 name: name.to_string(),
486 path: format!(".wipe/media/{file_name}"),
487 source: AttachmentSource::Media,
488 size: bytes.len() as u64,
489 mime: mime.to_string(),
490 })
491 }
492}
493
494pub fn add_attachment(
498 store: &Store,
499 ticket_id: &str,
500 name: &str,
501 bytes: &[u8],
502 mime: &str,
503 actor: &str,
504 now: DateTime<Utc>,
505) -> Result<Attachment> {
506 let mut ticket = store.load_ticket(ticket_id)?;
507 let attachment = stage_media(store, name, bytes, mime)?;
508
509 if !ticket.attachments.iter().any(|a| a.path == attachment.path) {
510 ticket.attachments.push(attachment.clone());
511 ticket.log_activity(actor, "attached", attachment.name.clone(), now);
512 ticket.updated = now;
513 store.save_ticket(&ticket)?;
514 }
515 Ok(attachment)
516}
517
518pub fn remove_attachment(
521 store: &Store,
522 ticket_id: &str,
523 path: &str,
524 actor: &str,
525 now: DateTime<Utc>,
526) -> Result<()> {
527 let mut ticket = store.load_ticket(ticket_id)?;
528 let name = ticket
529 .attachments
530 .iter()
531 .find(|a| a.path == path)
532 .map(|a| a.name.clone());
533 ticket.attachments.retain(|a| a.path != path);
534 if let Some(name) = name {
535 ticket.log_activity(actor, "detached", name, now);
536 }
537 ticket.updated = now;
538 store.save_ticket(&ticket)?;
539 Ok(())
540}
541
542fn sanitize(name: &str) -> String {
544 let base = std::path::Path::new(name)
545 .file_name()
546 .and_then(|n| n.to_str())
547 .unwrap_or(name);
548 let cleaned: String = base
549 .chars()
550 .map(|c| {
551 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
552 c
553 } else {
554 '-'
555 }
556 })
557 .collect();
558 let trimmed = cleaned.trim_matches('-');
559 if trimmed.is_empty() {
560 "file".to_string()
561 } else {
562 trimmed.to_string()
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use chrono::TimeZone;
570
571 fn now() -> DateTime<Utc> {
572 Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
573 }
574
575 fn project() -> (tempfile::TempDir, Store) {
576 let dir = tempfile::tempdir().unwrap();
577 let store = Store::init(dir.path(), "Test", now()).unwrap();
578 (dir, store)
579 }
580
581 #[test]
582 fn create_places_on_first_list_and_allocates_ids() {
583 let (_d, s) = project();
584 let t1 = create_ticket(
585 &s,
586 NewTicket {
587 title: "A".into(),
588 ..Default::default()
589 },
590 "tester",
591 now(),
592 )
593 .unwrap();
594 let t2 = create_ticket(
595 &s,
596 NewTicket {
597 title: "B".into(),
598 ..Default::default()
599 },
600 "tester",
601 now(),
602 )
603 .unwrap();
604 assert_eq!(t1.id, "T-1");
605 assert_eq!(t2.id, "T-2");
606 let board = s.load_board().unwrap();
607 assert_eq!(board.lists[0].cards, vec!["T-1", "T-2"]);
608 assert_eq!(board.next_ticket, 3);
609 }
610
611 #[test]
612 fn move_relocates_card() {
613 let (_d, s) = project();
614 create_ticket(
615 &s,
616 NewTicket {
617 title: "A".into(),
618 ..Default::default()
619 },
620 "tester",
621 now(),
622 )
623 .unwrap();
624 move_ticket(&s, "T-1", "done", None, "tester", now()).unwrap();
625 let board = s.load_board().unwrap();
626 assert!(board.list("backlog").unwrap().cards.is_empty());
627 assert_eq!(board.list("done").unwrap().cards, vec!["T-1"]);
628 }
629
630 #[test]
631 fn move_to_unknown_list_errors() {
632 let (_d, s) = project();
633 create_ticket(
634 &s,
635 NewTicket {
636 title: "A".into(),
637 ..Default::default()
638 },
639 "tester",
640 now(),
641 )
642 .unwrap();
643 assert!(matches!(
644 move_ticket(&s, "T-1", "nope", None, "tester", now()),
645 Err(Error::ListNotFound(_))
646 ));
647 }
648
649 #[test]
650 fn delete_removes_file_and_card() {
651 let (_d, s) = project();
652 create_ticket(
653 &s,
654 NewTicket {
655 title: "A".into(),
656 ..Default::default()
657 },
658 "tester",
659 now(),
660 )
661 .unwrap();
662 delete_ticket(&s, "T-1", now()).unwrap();
663 assert!(matches!(
664 s.load_ticket("T-1"),
665 Err(Error::TicketNotFound(_))
666 ));
667 let board = s.load_board().unwrap();
668 assert!(board.lists.iter().all(|l| l.cards.is_empty()));
669 }
670
671 #[test]
672 fn list_lifecycle() {
673 let (_d, s) = project();
674 let id = add_list(&s, "In Review", now()).unwrap();
675 assert_eq!(id, "in-review");
676 rename_list(&s, &id, "Review", now()).unwrap();
677 move_list(&s, &id, 0, now()).unwrap();
678 let board = s.load_board().unwrap();
679 assert_eq!(board.lists[0].id, "in-review");
680 assert_eq!(board.lists[0].name, "Review");
681 remove_list(&s, &id, false, now()).unwrap();
682 assert!(s.load_board().unwrap().list("in-review").is_none());
683 }
684
685 #[test]
686 fn remove_nonempty_list_requires_force() {
687 let (_d, s) = project();
688 create_ticket(
689 &s,
690 NewTicket {
691 title: "A".into(),
692 ..Default::default()
693 },
694 "tester",
695 now(),
696 )
697 .unwrap();
698 assert!(remove_list(&s, "backlog", false, now()).is_err());
699 remove_list(&s, "backlog", true, now()).unwrap();
700 assert!(matches!(
701 s.load_ticket("T-1"),
702 Err(Error::TicketNotFound(_))
703 ));
704 }
705
706 #[test]
707 fn update_ticket_applies_patch() {
708 let (_d, s) = project();
709 create_ticket(
710 &s,
711 NewTicket {
712 title: "A".into(),
713 ..Default::default()
714 },
715 "tester",
716 now(),
717 )
718 .unwrap();
719 let patch = TicketPatch {
720 title: Some("A2".into()),
721 priority: Some(Some("high".into())),
722 labels: Some(vec!["blocked".into()]),
723 assignees: Some(vec!["ada@example.com".into()]),
724 ..Default::default()
725 };
726 let t = update_ticket(&s, "T-1", patch, "tester", now()).unwrap();
727 assert_eq!(t.title, "A2");
728 assert_eq!(t.priority.as_deref(), Some("high"));
729 assert_eq!(t.labels, vec!["blocked"]);
730 let cleared = update_ticket(
731 &s,
732 "T-1",
733 TicketPatch {
734 priority: Some(None),
735 ..Default::default()
736 },
737 "tester",
738 now(),
739 )
740 .unwrap();
741 assert_eq!(cleared.priority, None);
742 }
743
744 #[test]
745 fn activity_is_logged_for_create_move_and_patch() {
746 let (_d, s) = project();
747 create_ticket(
748 &s,
749 NewTicket {
750 title: "A".into(),
751 ..Default::default()
752 },
753 "ada",
754 now(),
755 )
756 .unwrap();
757 move_ticket(&s, "T-1", "done", None, "ada", now()).unwrap();
758 update_ticket(
759 &s,
760 "T-1",
761 TicketPatch {
762 labels: Some(vec!["blocked".into()]),
763 assignees: Some(vec!["ada@example.com".into()]),
764 priority: Some(Some("high".into())),
765 ..Default::default()
766 },
767 "ada",
768 now(),
769 )
770 .unwrap();
771
772 let t = s.load_ticket("T-1").unwrap();
773 let kinds: Vec<&str> = t.activity.iter().map(|a| a.kind.as_str()).collect();
774 assert_eq!(
775 kinds,
776 vec!["created", "moved", "priority", "label-added", "assigned"]
777 );
778 let moved = t.activity.iter().find(|a| a.kind == "moved").unwrap();
780 assert_eq!(moved.detail, "Done");
781 assert!(t.activity.iter().all(|a| a.actor == "ada"));
782
783 update_ticket(
785 &s,
786 "T-1",
787 TicketPatch {
788 labels: Some(vec![]),
789 assignees: Some(vec![]),
790 ..Default::default()
791 },
792 "ada",
793 now(),
794 )
795 .unwrap();
796 let t = s.load_ticket("T-1").unwrap();
797 assert!(t.activity.iter().any(|a| a.kind == "label-removed"));
798 assert!(t.activity.iter().any(|a| a.kind == "unassigned"));
799 }
800
801 #[test]
802 fn identity_upsert_and_list() {
803 let (_d, s) = project();
804 upsert_identity(&s, "claude", "Claude", Some(IdentityKind::Agent)).unwrap();
805 let ids = list_identities(&s).unwrap();
806 let agent = ids.iter().find(|i| i.id == "claude").unwrap();
807 assert_eq!(agent.display_name, "Claude");
808 assert_eq!(agent.kind, IdentityKind::Agent);
809 }
810
811 #[test]
812 fn attachment_prefers_repo_reference_over_copy() {
813 use std::process::Command;
814 let dir = tempfile::tempdir().unwrap();
815 let root = dir.path();
816 let git = |args: &[&str]| {
817 assert!(Command::new("git")
818 .arg("-C")
819 .arg(root)
820 .args(args)
821 .output()
822 .unwrap()
823 .status
824 .success());
825 };
826 git(&["init", "-q"]);
827 git(&["config", "user.email", "t@example.com"]);
828 git(&["config", "user.name", "Tester"]);
829 let s = Store::init(root, "Att", now()).unwrap();
830 create_ticket(
831 &s,
832 NewTicket {
833 title: "A".into(),
834 ..Default::default()
835 },
836 "tester",
837 now(),
838 )
839 .unwrap();
840
841 std::fs::write(root.join("logo.png"), b"PNGDATA").unwrap();
842 git(&["add", "logo.png"]);
843 git(&["commit", "-q", "-m", "add logo"]);
844
845 let a = add_attachment(
847 &s,
848 "T-1",
849 "logo.png",
850 b"PNGDATA",
851 "image/png",
852 "tester",
853 now(),
854 )
855 .unwrap();
856 assert_eq!(a.source, AttachmentSource::Repo);
857 assert_eq!(a.path, "logo.png");
858
859 let b = add_attachment(
861 &s,
862 "T-1",
863 "new.txt",
864 b"hello world",
865 "text/plain",
866 "tester",
867 now(),
868 )
869 .unwrap();
870 assert_eq!(b.source, AttachmentSource::Media);
871 assert!(b.path.starts_with(".wipe/media/"));
872 assert!(root.join(&b.path).exists());
873 }
874}