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
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
342/// Create a label. If `color` is `None`, auto-pick the first unused palette color.
343pub 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
364/// Set a label's color. Errors if the label is not defined.
365pub 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
378/// Delete a label definition and strip it from every ticket.
379pub 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
394/// List identities: the saved registry merged with human authors discovered from
395/// git (so contributors show up without manual setup).
396pub 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
414/// Create or update an identity's display name (and optionally its kind).
415pub 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(&registry)?;
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(&registry)?;
438        Ok(ident)
439    }
440}
441
442/// Remove an identity from the saved registry. Git-derived humans that aren't in
443/// the registry can't be removed (they're re-discovered from history); this is
444/// meant for pruning agent identities and manual overrides.
445pub 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(&registry)?;
451    }
452    Ok(())
453}
454
455/// Attach a file to a ticket. If a file with identical content is already tracked
456/// in the repo, it is referenced in place (no copy); otherwise the bytes are
457/// stored under `.wipe/media/`. Returns the created [`Attachment`].
458pub 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    // Prefer referencing an already-tracked file with identical content.
472    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
510/// Detach a file from a ticket by its `path`. The underlying file is left in place
511/// (it may be shared or tracked in the repo).
512pub 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
534/// Sanitize a file name to a safe, stable form for on-disk storage.
535fn 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        // Moved event carries the destination list's display name, not its id.
771        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        // Removing the label/assignee logs the inverse events.
776        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        // Identical bytes -> reference the tracked repo file (no copy).
838        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        // Novel bytes -> copied into .wipe/media/.
852        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}