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(store: &Store, spec: NewTicket, now: DateTime<Utc>) -> Result<Ticket> {
39 let mut board = store.load_board()?;
40
41 let list_id = match spec.list {
42 Some(l) => {
43 if board.list(&l).is_none() {
44 return Err(Error::ListNotFound(l));
45 }
46 l
47 }
48 None => board
49 .lists
50 .first()
51 .map(|l| l.id.clone())
52 .ok_or_else(|| Error::msg("board has no lists"))?,
53 };
54
55 let id = ticket_id(board.next_ticket);
56 board.next_ticket += 1;
57
58 let mut ticket = Ticket::new(id.clone(), spec.title, now);
59 ticket.body = spec.body.unwrap_or_default();
60 ticket.priority = spec.priority;
61 ticket.labels = spec.labels;
62 ticket.assignees = spec.assignees;
63
64 board
65 .list_mut(&list_id)
66 .expect("checked above")
67 .cards
68 .push(id.clone());
69 board.updated = now;
70
71 store.save_ticket(&ticket)?;
72 store.save_board(&board)?;
73 Ok(ticket)
74}
75
76pub fn move_ticket(
79 store: &Store,
80 ticket_id: &str,
81 to_list: &str,
82 position: Option<usize>,
83 now: DateTime<Utc>,
84) -> Result<()> {
85 let _ = store.load_ticket(ticket_id)?;
87 let mut board = store.load_board()?;
88 if board.list(to_list).is_none() {
89 return Err(Error::ListNotFound(to_list.to_string()));
90 }
91
92 for list in &mut board.lists {
94 list.cards.retain(|c| c != ticket_id);
95 }
96
97 let dest = board.list_mut(to_list).expect("checked above");
98 let pos = position.unwrap_or(dest.cards.len()).min(dest.cards.len());
99 dest.cards.insert(pos, ticket_id.to_string());
100 board.updated = now;
101
102 let mut ticket = store.load_ticket(ticket_id)?;
104 ticket.updated = now;
105 store.save_ticket(&ticket)?;
106 store.save_board(&board)?;
107 Ok(())
108}
109
110pub fn delete_ticket(store: &Store, ticket_id: &str, now: DateTime<Utc>) -> Result<()> {
112 store.delete_ticket(ticket_id)?; let mut board = store.load_board()?;
114 for list in &mut board.lists {
115 list.cards.retain(|c| c != ticket_id);
116 }
117 board.updated = now;
118 store.save_board(&board)?;
119 Ok(())
120}
121
122pub fn add_comment(
124 store: &Store,
125 ticket_id: &str,
126 author: &str,
127 body: &str,
128 now: DateTime<Utc>,
129) -> Result<String> {
130 let mut ticket = store.load_ticket(ticket_id)?;
131 let id = ticket.add_comment(author, body, now);
132 store.save_ticket(&ticket)?;
133 Ok(id)
134}
135
136pub fn add_list(store: &Store, name: &str, now: DateTime<Utc>) -> Result<String> {
138 let mut board = store.load_board()?;
139 let mut id = slug(name);
140 if board.list(&id).is_some() {
142 let mut n = 2;
143 loop {
144 let candidate = format!("{id}-{n}");
145 if board.list(&candidate).is_none() {
146 id = candidate;
147 break;
148 }
149 n += 1;
150 }
151 }
152 let mut list = List::new(name);
153 list.id = id.clone();
154 board.lists.push(list);
155 board.updated = now;
156 store.save_board(&board)?;
157 Ok(id)
158}
159
160pub fn remove_list(store: &Store, list_id: &str, force: bool, now: DateTime<Utc>) -> Result<()> {
163 let mut board = store.load_board()?;
164 let idx = board
165 .lists
166 .iter()
167 .position(|l| l.id == list_id)
168 .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
169
170 let cards = board.lists[idx].cards.clone();
171 if !cards.is_empty() && !force {
172 return Err(Error::msg(format!(
173 "list `{list_id}` still holds {} ticket(s); pass --force to delete them too",
174 cards.len()
175 )));
176 }
177 for id in cards {
178 let _ = store.delete_ticket(&id);
180 }
181 board.lists.remove(idx);
182 board.updated = now;
183 store.save_board(&board)?;
184 Ok(())
185}
186
187pub fn move_list(store: &Store, list_id: &str, to_index: usize, now: DateTime<Utc>) -> Result<()> {
189 let mut board = store.load_board()?;
190 let from = board
191 .lists
192 .iter()
193 .position(|l| l.id == list_id)
194 .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
195 let list = board.lists.remove(from);
196 let to = to_index.min(board.lists.len());
197 board.lists.insert(to, list);
198 board.updated = now;
199 store.save_board(&board)?;
200 Ok(())
201}
202
203pub fn rename_list(store: &Store, list_id: &str, name: &str, now: DateTime<Utc>) -> Result<()> {
205 let mut board = store.load_board()?;
206 let list = board
207 .list_mut(list_id)
208 .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
209 list.name = name.to_string();
210 board.updated = now;
211 store.save_board(&board)?;
212 Ok(())
213}
214
215pub type ListView = (String, Vec<Ticket>);
217
218pub fn board_view(store: &Store) -> Result<(Board, Vec<ListView>)> {
220 let board = store.load_board()?;
221 let mut out = Vec::with_capacity(board.lists.len());
222 for list in &board.lists {
223 let mut tickets = Vec::with_capacity(list.cards.len());
224 for id in &list.cards {
225 if let Ok(t) = store.load_ticket(id) {
227 tickets.push(t);
228 }
229 }
230 out.push((list.id.clone(), tickets));
231 }
232 Ok((board, out))
233}
234
235#[derive(Debug, Default, Clone)]
238pub struct TicketPatch {
239 pub title: Option<String>,
241 pub body: Option<String>,
243 pub priority: Option<Option<String>>,
245 pub labels: Option<Vec<String>>,
247 pub assignees: Option<Vec<String>>,
249}
250
251pub fn update_ticket(
253 store: &Store,
254 id: &str,
255 patch: TicketPatch,
256 now: DateTime<Utc>,
257) -> Result<Ticket> {
258 let mut t = store.load_ticket(id)?;
259 if let Some(v) = patch.title {
260 t.title = v;
261 }
262 if let Some(v) = patch.body {
263 t.body = v;
264 }
265 if let Some(v) = patch.priority {
266 t.priority = v;
267 }
268 if let Some(v) = patch.labels {
269 t.labels = v;
270 }
271 if let Some(v) = patch.assignees {
272 t.assignees = v;
273 }
274 t.updated = now;
275 store.save_ticket(&t)?;
276 Ok(t)
277}
278
279pub fn create_label(
281 store: &Store,
282 name: &str,
283 color: Option<String>,
284 description: Option<String>,
285) -> Result<LabelDef> {
286 let mut defs = store.load_definitions()?;
287 if defs.labels.iter().any(|l| l.name == name) {
288 return Err(Error::msg(format!("label `{name}` already exists")));
289 }
290 let color = color.or_else(|| Some(next_label_color(&defs.labels)));
291 let label = LabelDef {
292 name: name.to_string(),
293 color,
294 description: description.unwrap_or_default(),
295 };
296 defs.labels.push(label.clone());
297 store.save_definitions(&defs)?;
298 Ok(label)
299}
300
301pub fn set_label_color(store: &Store, name: &str, color: &str) -> Result<LabelDef> {
303 let mut defs = store.load_definitions()?;
304 let label = defs
305 .labels
306 .iter_mut()
307 .find(|l| l.name == name)
308 .ok_or_else(|| Error::msg(format!("label `{name}` not found")))?;
309 label.color = Some(color.to_string());
310 let out = label.clone();
311 store.save_definitions(&defs)?;
312 Ok(out)
313}
314
315pub fn delete_label(store: &Store, name: &str, now: DateTime<Utc>) -> Result<()> {
317 let mut defs = store.load_definitions()?;
318 defs.labels.retain(|l| l.name != name);
319 store.save_definitions(&defs)?;
320 for id in store.ticket_ids()? {
321 let mut t = store.load_ticket(&id)?;
322 if t.labels.iter().any(|l| l == name) {
323 t.labels.retain(|l| l != name);
324 t.updated = now;
325 store.save_ticket(&t)?;
326 }
327 }
328 Ok(())
329}
330
331pub fn list_identities(store: &Store) -> Result<Vec<Identity>> {
334 let mut registry = store.load_identities()?;
335 if git::is_repo(store.root()) {
336 if let Ok(authors) = git::authors(store.root()) {
337 for (name, email) in authors {
338 if !registry.iter().any(|i| i.id == email) {
339 registry.push(Identity {
340 id: email,
341 display_name: name,
342 kind: IdentityKind::Human,
343 });
344 }
345 }
346 }
347 }
348 Ok(registry)
349}
350
351pub fn upsert_identity(
353 store: &Store,
354 id: &str,
355 display_name: &str,
356 kind: Option<IdentityKind>,
357) -> Result<Identity> {
358 let mut registry = store.load_identities()?;
359 if let Some(existing) = registry.iter_mut().find(|i| i.id == id) {
360 existing.display_name = display_name.to_string();
361 if let Some(k) = kind {
362 existing.kind = k;
363 }
364 let out = existing.clone();
365 store.save_identities(®istry)?;
366 Ok(out)
367 } else {
368 let ident = Identity {
369 id: id.to_string(),
370 display_name: display_name.to_string(),
371 kind: kind.unwrap_or_default(),
372 };
373 registry.push(ident.clone());
374 store.save_identities(®istry)?;
375 Ok(ident)
376 }
377}
378
379pub fn add_attachment(
383 store: &Store,
384 ticket_id: &str,
385 name: &str,
386 bytes: &[u8],
387 mime: &str,
388 now: DateTime<Utc>,
389) -> Result<Attachment> {
390 let mut ticket = store.load_ticket(ticket_id)?;
391 let root = store.root();
392 let hash = git::blob_hash(bytes);
393
394 let existing = if git::is_repo(root) {
396 git::tracked_blobs(root)
397 .ok()
398 .and_then(|blobs| blobs.into_iter().find(|(h, _)| *h == hash).map(|(_, p)| p))
399 } else {
400 None
401 };
402
403 let attachment = if let Some(path) = existing {
404 Attachment {
405 name: name.to_string(),
406 path,
407 source: AttachmentSource::Repo,
408 size: bytes.len() as u64,
409 mime: mime.to_string(),
410 }
411 } else {
412 let file_name = format!("{}-{}", &hash[..8.min(hash.len())], sanitize(name));
413 fs::create_dir_all(store.media_dir())?;
414 fs::write(store.media_dir().join(&file_name), bytes)?;
415 Attachment {
416 name: name.to_string(),
417 path: format!(".wipe/media/{file_name}"),
418 source: AttachmentSource::Media,
419 size: bytes.len() as u64,
420 mime: mime.to_string(),
421 }
422 };
423
424 if !ticket.attachments.iter().any(|a| a.path == attachment.path) {
425 ticket.attachments.push(attachment.clone());
426 ticket.updated = now;
427 store.save_ticket(&ticket)?;
428 }
429 Ok(attachment)
430}
431
432pub fn remove_attachment(
435 store: &Store,
436 ticket_id: &str,
437 path: &str,
438 now: DateTime<Utc>,
439) -> Result<()> {
440 let mut ticket = store.load_ticket(ticket_id)?;
441 ticket.attachments.retain(|a| a.path != path);
442 ticket.updated = now;
443 store.save_ticket(&ticket)?;
444 Ok(())
445}
446
447fn sanitize(name: &str) -> String {
449 let base = std::path::Path::new(name)
450 .file_name()
451 .and_then(|n| n.to_str())
452 .unwrap_or(name);
453 let cleaned: String = base
454 .chars()
455 .map(|c| {
456 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
457 c
458 } else {
459 '-'
460 }
461 })
462 .collect();
463 let trimmed = cleaned.trim_matches('-');
464 if trimmed.is_empty() {
465 "file".to_string()
466 } else {
467 trimmed.to_string()
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use chrono::TimeZone;
475
476 fn now() -> DateTime<Utc> {
477 Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
478 }
479
480 fn project() -> (tempfile::TempDir, Store) {
481 let dir = tempfile::tempdir().unwrap();
482 let store = Store::init(dir.path(), "Test", now()).unwrap();
483 (dir, store)
484 }
485
486 #[test]
487 fn create_places_on_first_list_and_allocates_ids() {
488 let (_d, s) = project();
489 let t1 = create_ticket(
490 &s,
491 NewTicket {
492 title: "A".into(),
493 ..Default::default()
494 },
495 now(),
496 )
497 .unwrap();
498 let t2 = create_ticket(
499 &s,
500 NewTicket {
501 title: "B".into(),
502 ..Default::default()
503 },
504 now(),
505 )
506 .unwrap();
507 assert_eq!(t1.id, "T-1");
508 assert_eq!(t2.id, "T-2");
509 let board = s.load_board().unwrap();
510 assert_eq!(board.lists[0].cards, vec!["T-1", "T-2"]);
511 assert_eq!(board.next_ticket, 3);
512 }
513
514 #[test]
515 fn move_relocates_card() {
516 let (_d, s) = project();
517 create_ticket(
518 &s,
519 NewTicket {
520 title: "A".into(),
521 ..Default::default()
522 },
523 now(),
524 )
525 .unwrap();
526 move_ticket(&s, "T-1", "done", None, now()).unwrap();
527 let board = s.load_board().unwrap();
528 assert!(board.list("backlog").unwrap().cards.is_empty());
529 assert_eq!(board.list("done").unwrap().cards, vec!["T-1"]);
530 }
531
532 #[test]
533 fn move_to_unknown_list_errors() {
534 let (_d, s) = project();
535 create_ticket(
536 &s,
537 NewTicket {
538 title: "A".into(),
539 ..Default::default()
540 },
541 now(),
542 )
543 .unwrap();
544 assert!(matches!(
545 move_ticket(&s, "T-1", "nope", None, now()),
546 Err(Error::ListNotFound(_))
547 ));
548 }
549
550 #[test]
551 fn delete_removes_file_and_card() {
552 let (_d, s) = project();
553 create_ticket(
554 &s,
555 NewTicket {
556 title: "A".into(),
557 ..Default::default()
558 },
559 now(),
560 )
561 .unwrap();
562 delete_ticket(&s, "T-1", now()).unwrap();
563 assert!(matches!(
564 s.load_ticket("T-1"),
565 Err(Error::TicketNotFound(_))
566 ));
567 let board = s.load_board().unwrap();
568 assert!(board.lists.iter().all(|l| l.cards.is_empty()));
569 }
570
571 #[test]
572 fn list_lifecycle() {
573 let (_d, s) = project();
574 let id = add_list(&s, "In Review", now()).unwrap();
575 assert_eq!(id, "in-review");
576 rename_list(&s, &id, "Review", now()).unwrap();
577 move_list(&s, &id, 0, now()).unwrap();
578 let board = s.load_board().unwrap();
579 assert_eq!(board.lists[0].id, "in-review");
580 assert_eq!(board.lists[0].name, "Review");
581 remove_list(&s, &id, false, now()).unwrap();
582 assert!(s.load_board().unwrap().list("in-review").is_none());
583 }
584
585 #[test]
586 fn remove_nonempty_list_requires_force() {
587 let (_d, s) = project();
588 create_ticket(
589 &s,
590 NewTicket {
591 title: "A".into(),
592 ..Default::default()
593 },
594 now(),
595 )
596 .unwrap();
597 assert!(remove_list(&s, "backlog", false, now()).is_err());
598 remove_list(&s, "backlog", true, now()).unwrap();
599 assert!(matches!(
600 s.load_ticket("T-1"),
601 Err(Error::TicketNotFound(_))
602 ));
603 }
604
605 #[test]
606 fn update_ticket_applies_patch() {
607 let (_d, s) = project();
608 create_ticket(
609 &s,
610 NewTicket {
611 title: "A".into(),
612 ..Default::default()
613 },
614 now(),
615 )
616 .unwrap();
617 let patch = TicketPatch {
618 title: Some("A2".into()),
619 priority: Some(Some("high".into())),
620 labels: Some(vec!["blocked".into()]),
621 assignees: Some(vec!["ada@example.com".into()]),
622 ..Default::default()
623 };
624 let t = update_ticket(&s, "T-1", patch, now()).unwrap();
625 assert_eq!(t.title, "A2");
626 assert_eq!(t.priority.as_deref(), Some("high"));
627 assert_eq!(t.labels, vec!["blocked"]);
628 let cleared = update_ticket(
629 &s,
630 "T-1",
631 TicketPatch {
632 priority: Some(None),
633 ..Default::default()
634 },
635 now(),
636 )
637 .unwrap();
638 assert_eq!(cleared.priority, None);
639 }
640
641 #[test]
642 fn identity_upsert_and_list() {
643 let (_d, s) = project();
644 upsert_identity(&s, "claude", "Claude", Some(IdentityKind::Agent)).unwrap();
645 let ids = list_identities(&s).unwrap();
646 let agent = ids.iter().find(|i| i.id == "claude").unwrap();
647 assert_eq!(agent.display_name, "Claude");
648 assert_eq!(agent.kind, IdentityKind::Agent);
649 }
650
651 #[test]
652 fn attachment_prefers_repo_reference_over_copy() {
653 use std::process::Command;
654 let dir = tempfile::tempdir().unwrap();
655 let root = dir.path();
656 let git = |args: &[&str]| {
657 assert!(Command::new("git")
658 .arg("-C")
659 .arg(root)
660 .args(args)
661 .output()
662 .unwrap()
663 .status
664 .success());
665 };
666 git(&["init", "-q"]);
667 git(&["config", "user.email", "t@example.com"]);
668 git(&["config", "user.name", "Tester"]);
669 let s = Store::init(root, "Att", now()).unwrap();
670 create_ticket(
671 &s,
672 NewTicket {
673 title: "A".into(),
674 ..Default::default()
675 },
676 now(),
677 )
678 .unwrap();
679
680 std::fs::write(root.join("logo.png"), b"PNGDATA").unwrap();
681 git(&["add", "logo.png"]);
682 git(&["commit", "-q", "-m", "add logo"]);
683
684 let a = add_attachment(&s, "T-1", "logo.png", b"PNGDATA", "image/png", now()).unwrap();
686 assert_eq!(a.source, AttachmentSource::Repo);
687 assert_eq!(a.path, "logo.png");
688
689 let b = add_attachment(&s, "T-1", "new.txt", b"hello world", "text/plain", now()).unwrap();
691 assert_eq!(b.source, AttachmentSource::Media);
692 assert!(b.path.starts_with(".wipe/media/"));
693 assert!(root.join(&b.path).exists());
694 }
695}