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