Skip to main content

wipe_core/
ops.rs

1//! High-level, transactional board operations shared by every front-end
2//! (CLI, daemon, desktop). Each function loads what it needs through [`Store`],
3//! mutates the in-memory model, and writes it back deterministically. Keeping
4//! these here means the mutation rules live in exactly one place.
5
6use 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/// Specification for a new ticket. Only `title` is required.
20#[derive(Debug, Default, Clone)]
21pub struct NewTicket {
22    /// Short title.
23    pub title: String,
24    /// Optional long-form body.
25    pub body: Option<String>,
26    /// Optional priority.
27    pub priority: Option<String>,
28    /// Target list ID; defaults to the board's first list.
29    pub list: Option<String>,
30    /// Labels to apply.
31    pub labels: Vec<String>,
32    /// Assignees.
33    pub assignees: Vec<String>,
34}
35
36/// Create a ticket, allocate its ID, place it on a list, and persist both the
37/// ticket file and the board. Returns the created ticket.
38pub 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
82/// Move a ticket to `to_list` at an optional 0-based `position` (appended if
83/// `None`). Removes it from whatever list currently holds it.
84pub 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    // Ensure the ticket exists.
93    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    // Where is it now? (skip logging a no-op move within the same list)
101    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    // Remove from current list (if present).
108    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    // Touch the ticket so its own `updated` reflects the move; log it as activity
118    // only when the containing list actually changed.
119    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
129/// Delete a ticket file and remove its card from the board.
130pub fn delete_ticket(store: &Store, ticket_id: &str, now: DateTime<Utc>) -> Result<()> {
131    store.delete_ticket(ticket_id)?; // errors if missing
132    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
141/// Append a comment to a ticket. Returns the new comment ID.
142pub 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
155/// Add a new list to the end of the board. Returns the new list's ID.
156pub 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    // Ensure the slug is unique.
160    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
179/// Remove a list. Fails if the list still holds cards, unless `force` is set (in
180/// which case the contained tickets are also deleted).
181pub 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        // Ignore missing files; the goal state is "gone".
198        let _ = store.delete_ticket(&id);
199    }
200    board.lists.remove(idx);
201    board.updated = now;
202    store.save_board(&board)?;
203    Ok(())
204}
205
206/// Reorder a list to a new 0-based index.
207pub 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
222/// Rename a list's display name (its ID stays stable).
223pub 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
234/// One list's ID paired with the tickets currently on it, in card order.
235pub type ListView = (String, Vec<Ticket>);
236
237/// Load the whole board as an ordered sequence of `(list_id, tickets)`.
238pub 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            // Skip dangling references rather than failing the whole view.
245            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/// A partial update to a ticket. `None` fields are left unchanged. For `priority`,
255/// an inner `Some(None)` clears the value.
256#[derive(Debug, Default, Clone)]
257pub struct TicketPatch {
258    /// New title.
259    pub title: Option<String>,
260    /// New body.
261    pub body: Option<String>,
262    /// Set/clear the priority.
263    pub priority: Option<Option<String>>,
264    /// Replace the label set.
265    pub labels: Option<Vec<String>>,
266    /// Replace the assignee set.
267    pub assignees: Option<Vec<String>>,
268}
269
270/// Apply a [`TicketPatch`] and persist. Returns the updated ticket.
271pub 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
333/// Create a label. If `color` is `None`, auto-pick the first unused palette color.
334pub 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
355/// Set a label's color. Errors if the label is not defined.
356pub 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
369/// Delete a label definition and strip it from every ticket.
370pub 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
385/// List identities: the saved registry merged with human authors discovered from
386/// git (so contributors show up without manual setup).
387pub 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
405/// Create or update an identity's display name (and optionally its kind).
406pub 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(&registry)?;
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(&registry)?;
429        Ok(ident)
430    }
431}
432
433/// Remove an identity from the saved registry. Git-derived humans that aren't in
434/// the registry can't be removed (they're re-discovered from history); this is
435/// meant for pruning agent identities and manual overrides.
436pub 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(&registry)?;
442    }
443    Ok(())
444}
445
446/// Attach a file to a ticket. If a file with identical content is already tracked
447/// in the repo, it is referenced in place (no copy); otherwise the bytes are
448/// stored under `.wipe/media/`. Returns the created [`Attachment`].
449pub 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    // Prefer referencing an already-tracked file with identical content.
463    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
501/// Detach a file from a ticket by its `path`. The underlying file is left in place
502/// (it may be shared or tracked in the repo).
503pub 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
525/// Sanitize a file name to a safe, stable form for on-disk storage.
526fn 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        // Moved event carries the destination list's display name, not its id.
762        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        // Removing the label/assignee logs the inverse events.
767        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        // Identical bytes -> reference the tracked repo file (no copy).
829        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        // Novel bytes -> copied into .wipe/media/.
835        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}