Skip to main content

wipe_core/
model.rs

1//! The wipe domain model.
2//!
3//! These types map 1:1 onto the JSON files under `.wipe/`. Field order is
4//! significant: `serde_json` serializes struct fields in declaration order, and we
5//! rely on that (plus `Vec` ordering and no hash maps) to keep on-disk output
6//! deterministic. Optional/empty fields are skipped so diffs stay minimal.
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::id::slug;
13
14/// On-disk format version. Bumped when the JSON schema changes in a
15/// backwards-incompatible way; every top-level file carries it for migration.
16pub const FORMAT_VERSION: u32 = 1;
17
18/// Default port the local daemon listens on when the user hasn't chosen one.
19pub const DEFAULT_PORT: u16 = 6737;
20
21// ---------------------------------------------------------------------------
22// board.json
23// ---------------------------------------------------------------------------
24
25/// The board - the top-level object of a project. Holds ordered [`List`]s whose
26/// `cards` reference ticket IDs. Ticket *content* lives in separate files under
27/// `tickets/`, so moving a card and editing a ticket never touch the same file.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct Board {
30    /// On-disk format version.
31    pub version: u32,
32    /// Stable unique board ID (UUID v4).
33    pub id: String,
34    /// Human-readable board name.
35    pub name: String,
36    /// Optional longer description (Markdown allowed).
37    #[serde(default, skip_serializing_if = "String::is_empty")]
38    pub description: String,
39    /// Ordered lists (columns) of the board.
40    pub lists: Vec<List>,
41    /// Next ticket counter; `T-<next_ticket>` is the next ID to allocate.
42    pub next_ticket: u64,
43    /// When the board was created.
44    pub created: DateTime<Utc>,
45    /// When the board was last modified.
46    pub updated: DateTime<Utc>,
47}
48
49/// What to pre-populate a new board with (chosen during `wipe init`).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum Starter {
53    /// Default lists (Backlog/Todo/In Progress/Done) and default labels.
54    #[default]
55    Standard,
56    /// Default lists, but no labels.
57    ListsOnly,
58    /// No lists and no labels - a blank board.
59    Empty,
60}
61
62impl Board {
63    /// Create a fresh board with the default set of lists.
64    pub fn new(name: impl Into<String>, now: DateTime<Utc>) -> Self {
65        Board {
66            version: FORMAT_VERSION,
67            id: Uuid::new_v4().to_string(),
68            name: name.into(),
69            description: String::new(),
70            lists: default_lists(),
71            next_ticket: 1,
72            created: now,
73            updated: now,
74        }
75    }
76
77    /// Create a board with no lists (used by the "empty" starter).
78    pub fn empty(name: impl Into<String>, now: DateTime<Utc>) -> Self {
79        let mut b = Board::new(name, now);
80        b.lists.clear();
81        b
82    }
83
84    /// Find a list by ID.
85    pub fn list(&self, id: &str) -> Option<&List> {
86        self.lists.iter().find(|l| l.id == id)
87    }
88
89    /// Find a list by ID (mutable).
90    pub fn list_mut(&mut self, id: &str) -> Option<&mut List> {
91        self.lists.iter_mut().find(|l| l.id == id)
92    }
93
94    /// Return `(list_id, index)` of the list currently containing `ticket_id`.
95    pub fn locate_card(&self, ticket_id: &str) -> Option<(String, usize)> {
96        for list in &self.lists {
97            if let Some(idx) = list.cards.iter().position(|c| c == ticket_id) {
98                return Some((list.id.clone(), idx));
99            }
100        }
101        None
102    }
103}
104
105/// A list (column) on the board. `cards` is the ordered set of ticket IDs it holds.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct List {
108    /// Stable list ID (kebab-case slug of the original name).
109    pub id: String,
110    /// Display name.
111    pub name: String,
112    /// Optional UI color (hex or token).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub color: Option<String>,
115    /// Optional work-in-progress limit.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub wip_limit: Option<u32>,
118    /// Ordered ticket IDs contained in this list.
119    #[serde(default)]
120    pub cards: Vec<String>,
121}
122
123impl List {
124    /// Create an empty list from a display name.
125    pub fn new(name: impl Into<String>) -> Self {
126        let name = name.into();
127        List {
128            id: slug(&name),
129            name,
130            color: None,
131            wip_limit: None,
132            cards: Vec::new(),
133        }
134    }
135}
136
137/// The default lists created by `wipe init`.
138fn default_lists() -> Vec<List> {
139    ["Backlog", "Todo", "In Progress", "Done"]
140        .into_iter()
141        .map(List::new)
142        .collect()
143}
144
145// ---------------------------------------------------------------------------
146// tickets/T-###.json
147// ---------------------------------------------------------------------------
148
149/// A ticket (card). Stored as its own file; comments are inline and short.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct Ticket {
152    /// On-disk format version.
153    pub version: u32,
154    /// Ticket ID, e.g. `T-23`.
155    pub id: String,
156    /// Short title.
157    pub title: String,
158    /// Long-form body (Markdown allowed inside the JSON string).
159    #[serde(default, skip_serializing_if = "String::is_empty")]
160    pub body: String,
161    /// Priority (references a name in `definitions.json`).
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub priority: Option<String>,
164    /// Applied label names (the only categorization mechanism).
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub labels: Vec<String>,
167    /// Assignee identities (git-style `Name <email>` or agent IDs).
168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169    pub assignees: Vec<String>,
170    /// Relations to other tickets.
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub relations: Vec<Relation>,
173    /// Attached media/files (stored under `.wipe/media/` or referenced in-repo).
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub attachments: Vec<Attachment>,
176    /// Inline comment thread.
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub comments: Vec<Comment>,
179    /// Activity log (moves, label/assignee/priority changes, attachments). Shown
180    /// interleaved with comments in the ticket's activity timeline.
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub activity: Vec<Activity>,
183    /// Next comment counter for this ticket.
184    #[serde(default = "one")]
185    pub next_comment: u64,
186    /// When the ticket was created.
187    pub created: DateTime<Utc>,
188    /// When the ticket was last modified.
189    pub updated: DateTime<Utc>,
190}
191
192fn one() -> u64 {
193    1
194}
195
196impl Ticket {
197    /// Create a new ticket with the given ID and title.
198    pub fn new(id: impl Into<String>, title: impl Into<String>, now: DateTime<Utc>) -> Self {
199        Ticket {
200            version: FORMAT_VERSION,
201            id: id.into(),
202            title: title.into(),
203            body: String::new(),
204            priority: None,
205            labels: Vec::new(),
206            assignees: Vec::new(),
207            relations: Vec::new(),
208            attachments: Vec::new(),
209            comments: Vec::new(),
210            activity: Vec::new(),
211            next_comment: 1,
212            created: now,
213            updated: now,
214        }
215    }
216
217    /// Append a comment, allocating the next comment ID. Returns the new comment ID.
218    pub fn add_comment(
219        &mut self,
220        author: impl Into<String>,
221        body: impl Into<String>,
222        now: DateTime<Utc>,
223    ) -> String {
224        let id = crate::id::comment_id(self.next_comment);
225        self.next_comment += 1;
226        self.comments.push(Comment {
227            id: id.clone(),
228            author: author.into(),
229            body: body.into(),
230            created: now,
231            edited: None,
232        });
233        self.updated = now;
234        id
235    }
236
237    /// Append an activity event. `detail` may be empty when `kind` is self-explanatory.
238    pub fn log_activity(
239        &mut self,
240        actor: impl Into<String>,
241        kind: impl Into<String>,
242        detail: impl Into<String>,
243        now: DateTime<Utc>,
244    ) {
245        self.activity.push(Activity {
246            ts: now,
247            actor: actor.into(),
248            kind: kind.into(),
249            detail: detail.into(),
250        });
251    }
252}
253
254/// A relation from one ticket to another.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct Relation {
257    /// Kind of relation.
258    pub kind: RelationKind,
259    /// Target ticket ID.
260    pub target: String,
261}
262
263/// The kind of a [`Relation`].
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "kebab-case")]
266pub enum RelationKind {
267    /// This ticket blocks the target.
268    Blocks,
269    /// This ticket is blocked by the target.
270    BlockedBy,
271    /// The target is a parent of this ticket.
272    Parent,
273    /// The target is a child of this ticket.
274    Child,
275    /// A soft relationship.
276    Relates,
277}
278
279/// An inline comment on a ticket.
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281pub struct Comment {
282    /// Comment ID, e.g. `c-7`.
283    pub id: String,
284    /// Author identity (git `Name <email>` or agent ID).
285    pub author: String,
286    /// Comment body (Markdown allowed).
287    pub body: String,
288    /// When the comment was posted.
289    pub created: DateTime<Utc>,
290    /// When the comment was last edited, if ever.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub edited: Option<DateTime<Utc>>,
293}
294
295/// A recorded change to a ticket, shown in the activity timeline alongside
296/// comments. Kept deliberately small and pre-classified so any front-end can
297/// render a phrase from `kind` + `detail` without re-deriving it from diffs.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299pub struct Activity {
300    /// When it happened.
301    pub ts: DateTime<Utc>,
302    /// Who did it (git `Name <email>` or agent ID).
303    pub actor: String,
304    /// Event kind: one of `created`, `moved`, `renamed`, `edited`, `priority`,
305    /// `label-added`, `label-removed`, `assigned`, `unassigned`, `attached`,
306    /// `detached`.
307    pub kind: String,
308    /// Event-specific detail (destination list, label, assignee, attachment
309    /// name, priority value). Empty when the `kind` alone says everything.
310    #[serde(default, skip_serializing_if = "String::is_empty")]
311    pub detail: String,
312}
313
314/// A file attached to a ticket.
315///
316/// `path` is always **repo-relative**. Depending on `source` it either points at a
317/// file already tracked in the repository (no copy is made) or at a file copied
318/// into `.wipe/media/`. This avoids duplicating files that already live in the repo.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct Attachment {
321    /// Display file name.
322    pub name: String,
323    /// Repo-relative path to the file.
324    pub path: String,
325    /// Where the file lives.
326    pub source: AttachmentSource,
327    /// File size in bytes.
328    pub size: u64,
329    /// MIME type (best-effort, from the file extension).
330    pub mime: String,
331}
332
333/// Where an [`Attachment`]'s bytes live.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "lowercase")]
336pub enum AttachmentSource {
337    /// Copied into `.wipe/media/`.
338    Media,
339    /// References a file already tracked in the repository.
340    Repo,
341}
342
343// ---------------------------------------------------------------------------
344// identities.json
345// ---------------------------------------------------------------------------
346
347/// A person or agent that can be assigned to tickets and author comments.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct Identity {
350    /// Stable identity key (e.g. a git email, or an agent slug like `claude`).
351    pub id: String,
352    /// Editable display name.
353    pub display_name: String,
354    /// Whether this identity is a human or an agent.
355    pub kind: IdentityKind,
356}
357
358/// Kind of an [`Identity`].
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
360#[serde(rename_all = "lowercase")]
361pub enum IdentityKind {
362    /// A human contributor (typically discovered from git).
363    #[default]
364    Human,
365    /// An AI agent.
366    Agent,
367}
368
369// ---------------------------------------------------------------------------
370// definitions.json
371// ---------------------------------------------------------------------------
372
373/// The label color palette. New labels are auto-assigned the first unused entry;
374/// colors can also be changed later. Matches the label set in `docs/DESIGN.md`.
375pub const LABEL_PALETTE: &[&str] = &[
376    "#CC785C", // terracotta
377    "#6C7BA8", // indigo
378    "#7E9B7A", // sage
379    "#61AAF2", // sky
380    "#BF4D43", // clay
381    "#D4A27F", // kraft
382    "#9A7AA0", // plum
383    "#EBDBBC", // manilla
384    "#666663", // slate
385];
386
387/// Pick the first palette color not already used by an existing label; if every
388/// palette color is in use, cycle deterministically by count.
389pub fn next_label_color(existing: &[LabelDef]) -> String {
390    let used: std::collections::HashSet<&str> =
391        existing.iter().filter_map(|l| l.color.as_deref()).collect();
392    for c in LABEL_PALETTE {
393        if !used.contains(c) {
394            return (*c).to_string();
395        }
396    }
397    LABEL_PALETTE[existing.len() % LABEL_PALETTE.len()].to_string()
398}
399
400/// Project-wide registries: labels and priorities. (Tickets are categorized by
401/// labels only; there is no separate "type" or "tags" concept.)
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403pub struct Definitions {
404    /// On-disk format version.
405    pub version: u32,
406    /// Defined labels.
407    #[serde(default)]
408    pub labels: Vec<LabelDef>,
409    /// Allowed priorities, ordered from lowest to highest.
410    #[serde(default)]
411    pub priorities: Vec<String>,
412}
413
414impl Definitions {
415    /// A sensible default set of definitions for a new board.
416    pub fn seed() -> Self {
417        Definitions {
418            version: FORMAT_VERSION,
419            labels: vec![
420                LabelDef::new("blocked", Some(LABEL_PALETTE[4])),
421                LabelDef::new("needs-review", Some(LABEL_PALETTE[3])),
422                LabelDef::new("agent", Some(LABEL_PALETTE[6])),
423            ],
424            priorities: vec![
425                "low".into(),
426                "medium".into(),
427                "high".into(),
428                "urgent".into(),
429            ],
430        }
431    }
432}
433
434/// A label definition.
435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
436pub struct LabelDef {
437    /// Label name (unique within the board).
438    pub name: String,
439    /// Optional UI color.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub color: Option<String>,
442    /// Optional description.
443    #[serde(default, skip_serializing_if = "String::is_empty")]
444    pub description: String,
445}
446
447impl LabelDef {
448    /// Create a label with a name and optional color.
449    pub fn new(name: impl Into<String>, color: Option<&str>) -> Self {
450        LabelDef {
451            name: name.into(),
452            color: color.map(|c| c.to_string()),
453            description: String::new(),
454        }
455    }
456}
457
458// ---------------------------------------------------------------------------
459// settings.json
460// ---------------------------------------------------------------------------
461
462/// Project settings, including how the local daemon is exposed.
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464pub struct Settings {
465    /// On-disk format version.
466    pub version: u32,
467    /// Local daemon settings.
468    #[serde(default)]
469    pub daemon: DaemonSettings,
470    /// Maximum size, in MB, for a single attachment upload. Defaults to 50 MB to
471    /// match git/GitHub's soft warning threshold; larger uploads are rejected.
472    #[serde(default = "default_max_attachment_mb")]
473    pub max_attachment_mb: u64,
474}
475
476fn default_max_attachment_mb() -> u64 {
477    50
478}
479
480impl Default for Settings {
481    fn default() -> Self {
482        Settings {
483            version: FORMAT_VERSION,
484            daemon: DaemonSettings::default(),
485            max_attachment_mb: default_max_attachment_mb(),
486        }
487    }
488}
489
490/// Configuration for the local daemon that serves the human UX.
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492pub struct DaemonSettings {
493    /// Port to listen on.
494    pub port: u16,
495    /// How the daemon is exposed beyond localhost.
496    #[serde(default)]
497    pub expose: Exposure,
498    /// When true, the daemon shuts itself down after `idle_timeout_secs` with no
499    /// connected UI clients, so it leaves no background overhead when not viewed.
500    #[serde(default)]
501    pub autoserve: bool,
502    /// Idle timeout (seconds) used when auto-serving / `--idle` is active.
503    #[serde(default = "default_idle_timeout")]
504    pub idle_timeout_secs: u64,
505}
506
507fn default_idle_timeout() -> u64 {
508    900
509}
510
511impl Default for DaemonSettings {
512    fn default() -> Self {
513        DaemonSettings {
514            port: DEFAULT_PORT,
515            expose: Exposure::default(),
516            autoserve: false,
517            idle_timeout_secs: default_idle_timeout(),
518        }
519    }
520}
521
522/// How the local daemon is reachable.
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
524#[serde(rename_all = "lowercase")]
525pub enum Exposure {
526    /// Localhost only.
527    #[default]
528    None,
529    /// Advertised over a Tailscale network.
530    Tailscale,
531    /// Behind a user-provided reverse proxy.
532    Proxy,
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use chrono::TimeZone;
539
540    fn fixed() -> DateTime<Utc> {
541        Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
542    }
543
544    #[test]
545    fn board_has_default_lists() {
546        let b = Board::new("Demo", fixed());
547        assert_eq!(b.lists.len(), 4);
548        assert_eq!(b.lists[2].id, "in-progress");
549        assert_eq!(b.next_ticket, 1);
550    }
551
552    #[test]
553    fn ticket_omits_empty_fields_and_has_no_type_or_tags() {
554        let t = Ticket::new("T-1", "Hello", fixed());
555        let json = serde_json::to_string(&t).unwrap();
556        // Empty vecs and empty body are skipped for clean diffs.
557        assert!(!json.contains("labels"));
558        assert!(!json.contains("assignees"));
559        assert!(!json.contains("\"body\""));
560        // Type and tags no longer exist.
561        assert!(!json.contains("\"type\""));
562        assert!(!json.contains("tags"));
563    }
564
565    #[test]
566    fn label_color_auto_picks_unused() {
567        let existing = vec![LabelDef::new("a", Some(LABEL_PALETTE[0]))];
568        let picked = next_label_color(&existing);
569        assert_eq!(picked, LABEL_PALETTE[1]);
570    }
571
572    #[test]
573    fn comment_allocation_is_monotonic() {
574        let mut t = Ticket::new("T-1", "Hello", fixed());
575        let a = t.add_comment("me", "first", fixed());
576        let b = t.add_comment("me", "second", fixed());
577        assert_eq!(a, "c-1");
578        assert_eq!(b, "c-2");
579        assert_eq!(t.next_comment, 3);
580    }
581
582    #[test]
583    fn relation_kind_is_kebab_case() {
584        let r = Relation {
585            kind: RelationKind::BlockedBy,
586            target: "T-2".into(),
587        };
588        assert_eq!(
589            serde_json::to_string(&r).unwrap(),
590            r#"{"kind":"blocked-by","target":"T-2"}"#
591        );
592    }
593}