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