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(store: &Store, spec: NewTicket, now: DateTime<Utc>) -> Result<Ticket> {
39    let mut board = store.load_board()?;
40
41    let list_id = match spec.list {
42        Some(l) => {
43            if board.list(&l).is_none() {
44                return Err(Error::ListNotFound(l));
45            }
46            l
47        }
48        None => board
49            .lists
50            .first()
51            .map(|l| l.id.clone())
52            .ok_or_else(|| Error::msg("board has no lists"))?,
53    };
54
55    let id = ticket_id(board.next_ticket);
56    board.next_ticket += 1;
57
58    let mut ticket = Ticket::new(id.clone(), spec.title, now);
59    ticket.body = spec.body.unwrap_or_default();
60    ticket.priority = spec.priority;
61    ticket.labels = spec.labels;
62    ticket.assignees = spec.assignees;
63
64    board
65        .list_mut(&list_id)
66        .expect("checked above")
67        .cards
68        .push(id.clone());
69    board.updated = now;
70
71    store.save_ticket(&ticket)?;
72    store.save_board(&board)?;
73    Ok(ticket)
74}
75
76/// Move a ticket to `to_list` at an optional 0-based `position` (appended if
77/// `None`). Removes it from whatever list currently holds it.
78pub fn move_ticket(
79    store: &Store,
80    ticket_id: &str,
81    to_list: &str,
82    position: Option<usize>,
83    now: DateTime<Utc>,
84) -> Result<()> {
85    // Ensure the ticket exists.
86    let _ = store.load_ticket(ticket_id)?;
87    let mut board = store.load_board()?;
88    if board.list(to_list).is_none() {
89        return Err(Error::ListNotFound(to_list.to_string()));
90    }
91
92    // Remove from current list (if present).
93    for list in &mut board.lists {
94        list.cards.retain(|c| c != ticket_id);
95    }
96
97    let dest = board.list_mut(to_list).expect("checked above");
98    let pos = position.unwrap_or(dest.cards.len()).min(dest.cards.len());
99    dest.cards.insert(pos, ticket_id.to_string());
100    board.updated = now;
101
102    // Touch the ticket so its own `updated` reflects the move.
103    let mut ticket = store.load_ticket(ticket_id)?;
104    ticket.updated = now;
105    store.save_ticket(&ticket)?;
106    store.save_board(&board)?;
107    Ok(())
108}
109
110/// Delete a ticket file and remove its card from the board.
111pub fn delete_ticket(store: &Store, ticket_id: &str, now: DateTime<Utc>) -> Result<()> {
112    store.delete_ticket(ticket_id)?; // errors if missing
113    let mut board = store.load_board()?;
114    for list in &mut board.lists {
115        list.cards.retain(|c| c != ticket_id);
116    }
117    board.updated = now;
118    store.save_board(&board)?;
119    Ok(())
120}
121
122/// Append a comment to a ticket. Returns the new comment ID.
123pub fn add_comment(
124    store: &Store,
125    ticket_id: &str,
126    author: &str,
127    body: &str,
128    now: DateTime<Utc>,
129) -> Result<String> {
130    let mut ticket = store.load_ticket(ticket_id)?;
131    let id = ticket.add_comment(author, body, now);
132    store.save_ticket(&ticket)?;
133    Ok(id)
134}
135
136/// Add a new list to the end of the board. Returns the new list's ID.
137pub fn add_list(store: &Store, name: &str, now: DateTime<Utc>) -> Result<String> {
138    let mut board = store.load_board()?;
139    let mut id = slug(name);
140    // Ensure the slug is unique.
141    if board.list(&id).is_some() {
142        let mut n = 2;
143        loop {
144            let candidate = format!("{id}-{n}");
145            if board.list(&candidate).is_none() {
146                id = candidate;
147                break;
148            }
149            n += 1;
150        }
151    }
152    let mut list = List::new(name);
153    list.id = id.clone();
154    board.lists.push(list);
155    board.updated = now;
156    store.save_board(&board)?;
157    Ok(id)
158}
159
160/// Remove a list. Fails if the list still holds cards, unless `force` is set (in
161/// which case the contained tickets are also deleted).
162pub fn remove_list(store: &Store, list_id: &str, force: bool, now: DateTime<Utc>) -> Result<()> {
163    let mut board = store.load_board()?;
164    let idx = board
165        .lists
166        .iter()
167        .position(|l| l.id == list_id)
168        .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
169
170    let cards = board.lists[idx].cards.clone();
171    if !cards.is_empty() && !force {
172        return Err(Error::msg(format!(
173            "list `{list_id}` still holds {} ticket(s); pass --force to delete them too",
174            cards.len()
175        )));
176    }
177    for id in cards {
178        // Ignore missing files; the goal state is "gone".
179        let _ = store.delete_ticket(&id);
180    }
181    board.lists.remove(idx);
182    board.updated = now;
183    store.save_board(&board)?;
184    Ok(())
185}
186
187/// Reorder a list to a new 0-based index.
188pub fn move_list(store: &Store, list_id: &str, to_index: usize, now: DateTime<Utc>) -> Result<()> {
189    let mut board = store.load_board()?;
190    let from = board
191        .lists
192        .iter()
193        .position(|l| l.id == list_id)
194        .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
195    let list = board.lists.remove(from);
196    let to = to_index.min(board.lists.len());
197    board.lists.insert(to, list);
198    board.updated = now;
199    store.save_board(&board)?;
200    Ok(())
201}
202
203/// Rename a list's display name (its ID stays stable).
204pub fn rename_list(store: &Store, list_id: &str, name: &str, now: DateTime<Utc>) -> Result<()> {
205    let mut board = store.load_board()?;
206    let list = board
207        .list_mut(list_id)
208        .ok_or_else(|| Error::ListNotFound(list_id.to_string()))?;
209    list.name = name.to_string();
210    board.updated = now;
211    store.save_board(&board)?;
212    Ok(())
213}
214
215/// One list's ID paired with the tickets currently on it, in card order.
216pub type ListView = (String, Vec<Ticket>);
217
218/// Load the whole board as an ordered sequence of `(list_id, tickets)`.
219pub fn board_view(store: &Store) -> Result<(Board, Vec<ListView>)> {
220    let board = store.load_board()?;
221    let mut out = Vec::with_capacity(board.lists.len());
222    for list in &board.lists {
223        let mut tickets = Vec::with_capacity(list.cards.len());
224        for id in &list.cards {
225            // Skip dangling references rather than failing the whole view.
226            if let Ok(t) = store.load_ticket(id) {
227                tickets.push(t);
228            }
229        }
230        out.push((list.id.clone(), tickets));
231    }
232    Ok((board, out))
233}
234
235/// A partial update to a ticket. `None` fields are left unchanged. For `priority`,
236/// an inner `Some(None)` clears the value.
237#[derive(Debug, Default, Clone)]
238pub struct TicketPatch {
239    /// New title.
240    pub title: Option<String>,
241    /// New body.
242    pub body: Option<String>,
243    /// Set/clear the priority.
244    pub priority: Option<Option<String>>,
245    /// Replace the label set.
246    pub labels: Option<Vec<String>>,
247    /// Replace the assignee set.
248    pub assignees: Option<Vec<String>>,
249}
250
251/// Apply a [`TicketPatch`] and persist. Returns the updated ticket.
252pub fn update_ticket(
253    store: &Store,
254    id: &str,
255    patch: TicketPatch,
256    now: DateTime<Utc>,
257) -> Result<Ticket> {
258    let mut t = store.load_ticket(id)?;
259    if let Some(v) = patch.title {
260        t.title = v;
261    }
262    if let Some(v) = patch.body {
263        t.body = v;
264    }
265    if let Some(v) = patch.priority {
266        t.priority = v;
267    }
268    if let Some(v) = patch.labels {
269        t.labels = v;
270    }
271    if let Some(v) = patch.assignees {
272        t.assignees = v;
273    }
274    t.updated = now;
275    store.save_ticket(&t)?;
276    Ok(t)
277}
278
279/// Create a label. If `color` is `None`, auto-pick the first unused palette color.
280pub fn create_label(
281    store: &Store,
282    name: &str,
283    color: Option<String>,
284    description: Option<String>,
285) -> Result<LabelDef> {
286    let mut defs = store.load_definitions()?;
287    if defs.labels.iter().any(|l| l.name == name) {
288        return Err(Error::msg(format!("label `{name}` already exists")));
289    }
290    let color = color.or_else(|| Some(next_label_color(&defs.labels)));
291    let label = LabelDef {
292        name: name.to_string(),
293        color,
294        description: description.unwrap_or_default(),
295    };
296    defs.labels.push(label.clone());
297    store.save_definitions(&defs)?;
298    Ok(label)
299}
300
301/// Set a label's color. Errors if the label is not defined.
302pub fn set_label_color(store: &Store, name: &str, color: &str) -> Result<LabelDef> {
303    let mut defs = store.load_definitions()?;
304    let label = defs
305        .labels
306        .iter_mut()
307        .find(|l| l.name == name)
308        .ok_or_else(|| Error::msg(format!("label `{name}` not found")))?;
309    label.color = Some(color.to_string());
310    let out = label.clone();
311    store.save_definitions(&defs)?;
312    Ok(out)
313}
314
315/// Delete a label definition and strip it from every ticket.
316pub fn delete_label(store: &Store, name: &str, now: DateTime<Utc>) -> Result<()> {
317    let mut defs = store.load_definitions()?;
318    defs.labels.retain(|l| l.name != name);
319    store.save_definitions(&defs)?;
320    for id in store.ticket_ids()? {
321        let mut t = store.load_ticket(&id)?;
322        if t.labels.iter().any(|l| l == name) {
323            t.labels.retain(|l| l != name);
324            t.updated = now;
325            store.save_ticket(&t)?;
326        }
327    }
328    Ok(())
329}
330
331/// List identities: the saved registry merged with human authors discovered from
332/// git (so contributors show up without manual setup).
333pub fn list_identities(store: &Store) -> Result<Vec<Identity>> {
334    let mut registry = store.load_identities()?;
335    if git::is_repo(store.root()) {
336        if let Ok(authors) = git::authors(store.root()) {
337            for (name, email) in authors {
338                if !registry.iter().any(|i| i.id == email) {
339                    registry.push(Identity {
340                        id: email,
341                        display_name: name,
342                        kind: IdentityKind::Human,
343                    });
344                }
345            }
346        }
347    }
348    Ok(registry)
349}
350
351/// Create or update an identity's display name (and optionally its kind).
352pub fn upsert_identity(
353    store: &Store,
354    id: &str,
355    display_name: &str,
356    kind: Option<IdentityKind>,
357) -> Result<Identity> {
358    let mut registry = store.load_identities()?;
359    if let Some(existing) = registry.iter_mut().find(|i| i.id == id) {
360        existing.display_name = display_name.to_string();
361        if let Some(k) = kind {
362            existing.kind = k;
363        }
364        let out = existing.clone();
365        store.save_identities(&registry)?;
366        Ok(out)
367    } else {
368        let ident = Identity {
369            id: id.to_string(),
370            display_name: display_name.to_string(),
371            kind: kind.unwrap_or_default(),
372        };
373        registry.push(ident.clone());
374        store.save_identities(&registry)?;
375        Ok(ident)
376    }
377}
378
379/// Attach a file to a ticket. If a file with identical content is already tracked
380/// in the repo, it is referenced in place (no copy); otherwise the bytes are
381/// stored under `.wipe/media/`. Returns the created [`Attachment`].
382pub fn add_attachment(
383    store: &Store,
384    ticket_id: &str,
385    name: &str,
386    bytes: &[u8],
387    mime: &str,
388    now: DateTime<Utc>,
389) -> Result<Attachment> {
390    let mut ticket = store.load_ticket(ticket_id)?;
391    let root = store.root();
392    let hash = git::blob_hash(bytes);
393
394    // Prefer referencing an already-tracked file with identical content.
395    let existing = if git::is_repo(root) {
396        git::tracked_blobs(root)
397            .ok()
398            .and_then(|blobs| blobs.into_iter().find(|(h, _)| *h == hash).map(|(_, p)| p))
399    } else {
400        None
401    };
402
403    let attachment = if let Some(path) = existing {
404        Attachment {
405            name: name.to_string(),
406            path,
407            source: AttachmentSource::Repo,
408            size: bytes.len() as u64,
409            mime: mime.to_string(),
410        }
411    } else {
412        let file_name = format!("{}-{}", &hash[..8.min(hash.len())], sanitize(name));
413        fs::create_dir_all(store.media_dir())?;
414        fs::write(store.media_dir().join(&file_name), bytes)?;
415        Attachment {
416            name: name.to_string(),
417            path: format!(".wipe/media/{file_name}"),
418            source: AttachmentSource::Media,
419            size: bytes.len() as u64,
420            mime: mime.to_string(),
421        }
422    };
423
424    if !ticket.attachments.iter().any(|a| a.path == attachment.path) {
425        ticket.attachments.push(attachment.clone());
426        ticket.updated = now;
427        store.save_ticket(&ticket)?;
428    }
429    Ok(attachment)
430}
431
432/// Detach a file from a ticket by its `path`. The underlying file is left in place
433/// (it may be shared or tracked in the repo).
434pub fn remove_attachment(
435    store: &Store,
436    ticket_id: &str,
437    path: &str,
438    now: DateTime<Utc>,
439) -> Result<()> {
440    let mut ticket = store.load_ticket(ticket_id)?;
441    ticket.attachments.retain(|a| a.path != path);
442    ticket.updated = now;
443    store.save_ticket(&ticket)?;
444    Ok(())
445}
446
447/// Sanitize a file name to a safe, stable form for on-disk storage.
448fn sanitize(name: &str) -> String {
449    let base = std::path::Path::new(name)
450        .file_name()
451        .and_then(|n| n.to_str())
452        .unwrap_or(name);
453    let cleaned: String = base
454        .chars()
455        .map(|c| {
456            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
457                c
458            } else {
459                '-'
460            }
461        })
462        .collect();
463    let trimmed = cleaned.trim_matches('-');
464    if trimmed.is_empty() {
465        "file".to_string()
466    } else {
467        trimmed.to_string()
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use chrono::TimeZone;
475
476    fn now() -> DateTime<Utc> {
477        Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
478    }
479
480    fn project() -> (tempfile::TempDir, Store) {
481        let dir = tempfile::tempdir().unwrap();
482        let store = Store::init(dir.path(), "Test", now()).unwrap();
483        (dir, store)
484    }
485
486    #[test]
487    fn create_places_on_first_list_and_allocates_ids() {
488        let (_d, s) = project();
489        let t1 = create_ticket(
490            &s,
491            NewTicket {
492                title: "A".into(),
493                ..Default::default()
494            },
495            now(),
496        )
497        .unwrap();
498        let t2 = create_ticket(
499            &s,
500            NewTicket {
501                title: "B".into(),
502                ..Default::default()
503            },
504            now(),
505        )
506        .unwrap();
507        assert_eq!(t1.id, "T-1");
508        assert_eq!(t2.id, "T-2");
509        let board = s.load_board().unwrap();
510        assert_eq!(board.lists[0].cards, vec!["T-1", "T-2"]);
511        assert_eq!(board.next_ticket, 3);
512    }
513
514    #[test]
515    fn move_relocates_card() {
516        let (_d, s) = project();
517        create_ticket(
518            &s,
519            NewTicket {
520                title: "A".into(),
521                ..Default::default()
522            },
523            now(),
524        )
525        .unwrap();
526        move_ticket(&s, "T-1", "done", None, now()).unwrap();
527        let board = s.load_board().unwrap();
528        assert!(board.list("backlog").unwrap().cards.is_empty());
529        assert_eq!(board.list("done").unwrap().cards, vec!["T-1"]);
530    }
531
532    #[test]
533    fn move_to_unknown_list_errors() {
534        let (_d, s) = project();
535        create_ticket(
536            &s,
537            NewTicket {
538                title: "A".into(),
539                ..Default::default()
540            },
541            now(),
542        )
543        .unwrap();
544        assert!(matches!(
545            move_ticket(&s, "T-1", "nope", None, now()),
546            Err(Error::ListNotFound(_))
547        ));
548    }
549
550    #[test]
551    fn delete_removes_file_and_card() {
552        let (_d, s) = project();
553        create_ticket(
554            &s,
555            NewTicket {
556                title: "A".into(),
557                ..Default::default()
558            },
559            now(),
560        )
561        .unwrap();
562        delete_ticket(&s, "T-1", now()).unwrap();
563        assert!(matches!(
564            s.load_ticket("T-1"),
565            Err(Error::TicketNotFound(_))
566        ));
567        let board = s.load_board().unwrap();
568        assert!(board.lists.iter().all(|l| l.cards.is_empty()));
569    }
570
571    #[test]
572    fn list_lifecycle() {
573        let (_d, s) = project();
574        let id = add_list(&s, "In Review", now()).unwrap();
575        assert_eq!(id, "in-review");
576        rename_list(&s, &id, "Review", now()).unwrap();
577        move_list(&s, &id, 0, now()).unwrap();
578        let board = s.load_board().unwrap();
579        assert_eq!(board.lists[0].id, "in-review");
580        assert_eq!(board.lists[0].name, "Review");
581        remove_list(&s, &id, false, now()).unwrap();
582        assert!(s.load_board().unwrap().list("in-review").is_none());
583    }
584
585    #[test]
586    fn remove_nonempty_list_requires_force() {
587        let (_d, s) = project();
588        create_ticket(
589            &s,
590            NewTicket {
591                title: "A".into(),
592                ..Default::default()
593            },
594            now(),
595        )
596        .unwrap();
597        assert!(remove_list(&s, "backlog", false, now()).is_err());
598        remove_list(&s, "backlog", true, now()).unwrap();
599        assert!(matches!(
600            s.load_ticket("T-1"),
601            Err(Error::TicketNotFound(_))
602        ));
603    }
604
605    #[test]
606    fn update_ticket_applies_patch() {
607        let (_d, s) = project();
608        create_ticket(
609            &s,
610            NewTicket {
611                title: "A".into(),
612                ..Default::default()
613            },
614            now(),
615        )
616        .unwrap();
617        let patch = TicketPatch {
618            title: Some("A2".into()),
619            priority: Some(Some("high".into())),
620            labels: Some(vec!["blocked".into()]),
621            assignees: Some(vec!["ada@example.com".into()]),
622            ..Default::default()
623        };
624        let t = update_ticket(&s, "T-1", patch, now()).unwrap();
625        assert_eq!(t.title, "A2");
626        assert_eq!(t.priority.as_deref(), Some("high"));
627        assert_eq!(t.labels, vec!["blocked"]);
628        let cleared = update_ticket(
629            &s,
630            "T-1",
631            TicketPatch {
632                priority: Some(None),
633                ..Default::default()
634            },
635            now(),
636        )
637        .unwrap();
638        assert_eq!(cleared.priority, None);
639    }
640
641    #[test]
642    fn identity_upsert_and_list() {
643        let (_d, s) = project();
644        upsert_identity(&s, "claude", "Claude", Some(IdentityKind::Agent)).unwrap();
645        let ids = list_identities(&s).unwrap();
646        let agent = ids.iter().find(|i| i.id == "claude").unwrap();
647        assert_eq!(agent.display_name, "Claude");
648        assert_eq!(agent.kind, IdentityKind::Agent);
649    }
650
651    #[test]
652    fn attachment_prefers_repo_reference_over_copy() {
653        use std::process::Command;
654        let dir = tempfile::tempdir().unwrap();
655        let root = dir.path();
656        let git = |args: &[&str]| {
657            assert!(Command::new("git")
658                .arg("-C")
659                .arg(root)
660                .args(args)
661                .output()
662                .unwrap()
663                .status
664                .success());
665        };
666        git(&["init", "-q"]);
667        git(&["config", "user.email", "t@example.com"]);
668        git(&["config", "user.name", "Tester"]);
669        let s = Store::init(root, "Att", now()).unwrap();
670        create_ticket(
671            &s,
672            NewTicket {
673                title: "A".into(),
674                ..Default::default()
675            },
676            now(),
677        )
678        .unwrap();
679
680        std::fs::write(root.join("logo.png"), b"PNGDATA").unwrap();
681        git(&["add", "logo.png"]);
682        git(&["commit", "-q", "-m", "add logo"]);
683
684        // Identical bytes -> reference the tracked repo file (no copy).
685        let a = add_attachment(&s, "T-1", "logo.png", b"PNGDATA", "image/png", now()).unwrap();
686        assert_eq!(a.source, AttachmentSource::Repo);
687        assert_eq!(a.path, "logo.png");
688
689        // Novel bytes -> copied into .wipe/media/.
690        let b = add_attachment(&s, "T-1", "new.txt", b"hello world", "text/plain", now()).unwrap();
691        assert_eq!(b.source, AttachmentSource::Media);
692        assert!(b.path.starts_with(".wipe/media/"));
693        assert!(root.join(&b.path).exists());
694    }
695}