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