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/// the repo's VCS (git, Mercurial, …) so contributors show up without manual setup.
396pub 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
410/// Resolve the acting identity for an authored action, VCS-agnostic and honoring
411/// the user's default-identity preference. Precedence:
412/// explicit → (prefer-default) → VCS user → board default → global default →
413/// system user → `"unknown"`. Session/env overrides are layered on by the CLI.
414pub 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    // Never attribute to "unknown": fall back to the OS user, then to "human".
442    crate::vcs::system_user().unwrap_or_else(|| "human".to_string())
443}
444
445/// Create or update an identity's display name (and optionally its kind).
446pub 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(&registry)?;
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(&registry)?;
469        Ok(ident)
470    }
471}
472
473/// Remove an identity from the saved registry. Git-derived humans that aren't in
474/// the registry can't be removed (they're re-discovered from history); this is
475/// meant for pruning agent identities and manual overrides.
476pub 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(&registry)?;
482    }
483    Ok(())
484}
485
486/// Stage bytes as an [`Attachment`] without binding it to any ticket/post: if a
487/// file with identical content is already tracked in the repo it is referenced in
488/// place (no copy); otherwise the bytes are written under `.wipe/media/`. Shared by
489/// ticket and forum attachments.
490pub 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    // Prefer referencing an already-tracked file with identical content.
495    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
525/// Attach a file to a ticket. If a file with identical content is already tracked
526/// in the repo, it is referenced in place (no copy); otherwise the bytes are
527/// stored under `.wipe/media/`. Returns the created [`Attachment`].
528pub 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
549/// Detach a file from a ticket by its `path`. The underlying file is left in place
550/// (it may be shared or tracked in the repo).
551pub 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
573/// Sanitize a file name to a safe, stable form for on-disk storage.
574fn 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        // Moved event carries the destination list's display name, not its id.
810        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        // Removing the label/assignee logs the inverse events.
815        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        // Identical bytes -> reference the tracked repo file (no copy).
877        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        // Novel bytes -> copied into .wipe/media/.
891        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}