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    /// Next comment counter for this ticket.
160    #[serde(default = "one")]
161    pub next_comment: u64,
162    /// When the ticket was created.
163    pub created: DateTime<Utc>,
164    /// When the ticket was last modified.
165    pub updated: DateTime<Utc>,
166}
167
168fn one() -> u64 {
169    1
170}
171
172impl Ticket {
173    /// Create a new ticket with the given ID and title.
174    pub fn new(id: impl Into<String>, title: impl Into<String>, now: DateTime<Utc>) -> Self {
175        Ticket {
176            version: FORMAT_VERSION,
177            id: id.into(),
178            title: title.into(),
179            body: String::new(),
180            priority: None,
181            labels: Vec::new(),
182            assignees: Vec::new(),
183            relations: Vec::new(),
184            attachments: Vec::new(),
185            comments: Vec::new(),
186            next_comment: 1,
187            created: now,
188            updated: now,
189        }
190    }
191
192    /// Append a comment, allocating the next comment ID. Returns the new comment ID.
193    pub fn add_comment(
194        &mut self,
195        author: impl Into<String>,
196        body: impl Into<String>,
197        now: DateTime<Utc>,
198    ) -> String {
199        let id = crate::id::comment_id(self.next_comment);
200        self.next_comment += 1;
201        self.comments.push(Comment {
202            id: id.clone(),
203            author: author.into(),
204            body: body.into(),
205            created: now,
206            edited: None,
207        });
208        self.updated = now;
209        id
210    }
211}
212
213/// A relation from one ticket to another.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct Relation {
216    /// Kind of relation.
217    pub kind: RelationKind,
218    /// Target ticket ID.
219    pub target: String,
220}
221
222/// The kind of a [`Relation`].
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "kebab-case")]
225pub enum RelationKind {
226    /// This ticket blocks the target.
227    Blocks,
228    /// This ticket is blocked by the target.
229    BlockedBy,
230    /// The target is a parent of this ticket.
231    Parent,
232    /// The target is a child of this ticket.
233    Child,
234    /// A soft relationship.
235    Relates,
236}
237
238/// An inline comment on a ticket.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct Comment {
241    /// Comment ID, e.g. `c-7`.
242    pub id: String,
243    /// Author identity (git `Name <email>` or agent ID).
244    pub author: String,
245    /// Comment body (Markdown allowed).
246    pub body: String,
247    /// When the comment was posted.
248    pub created: DateTime<Utc>,
249    /// When the comment was last edited, if ever.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub edited: Option<DateTime<Utc>>,
252}
253
254/// A file attached to a ticket.
255///
256/// `path` is always **repo-relative**. Depending on `source` it either points at a
257/// file already tracked in the repository (no copy is made) or at a file copied
258/// into `.wipe/media/`. This avoids duplicating files that already live in the repo.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct Attachment {
261    /// Display file name.
262    pub name: String,
263    /// Repo-relative path to the file.
264    pub path: String,
265    /// Where the file lives.
266    pub source: AttachmentSource,
267    /// File size in bytes.
268    pub size: u64,
269    /// MIME type (best-effort, from the file extension).
270    pub mime: String,
271}
272
273/// Where an [`Attachment`]'s bytes live.
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum AttachmentSource {
277    /// Copied into `.wipe/media/`.
278    Media,
279    /// References a file already tracked in the repository.
280    Repo,
281}
282
283// ---------------------------------------------------------------------------
284// identities.json
285// ---------------------------------------------------------------------------
286
287/// A person or agent that can be assigned to tickets and author comments.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct Identity {
290    /// Stable identity key (e.g. a git email, or an agent slug like `claude`).
291    pub id: String,
292    /// Editable display name.
293    pub display_name: String,
294    /// Whether this identity is a human or an agent.
295    pub kind: IdentityKind,
296}
297
298/// Kind of an [`Identity`].
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
300#[serde(rename_all = "lowercase")]
301pub enum IdentityKind {
302    /// A human contributor (typically discovered from git).
303    #[default]
304    Human,
305    /// An AI agent.
306    Agent,
307}
308
309// ---------------------------------------------------------------------------
310// definitions.json
311// ---------------------------------------------------------------------------
312
313/// The label color palette. New labels are auto-assigned the first unused entry;
314/// colors can also be changed later. Matches the label set in `docs/DESIGN.md`.
315pub const LABEL_PALETTE: &[&str] = &[
316    "#CC785C", // terracotta
317    "#6C7BA8", // indigo
318    "#7E9B7A", // sage
319    "#61AAF2", // sky
320    "#BF4D43", // clay
321    "#D4A27F", // kraft
322    "#9A7AA0", // plum
323    "#EBDBBC", // manilla
324    "#666663", // slate
325];
326
327/// Pick the first palette color not already used by an existing label; if every
328/// palette color is in use, cycle deterministically by count.
329pub fn next_label_color(existing: &[LabelDef]) -> String {
330    let used: std::collections::HashSet<&str> =
331        existing.iter().filter_map(|l| l.color.as_deref()).collect();
332    for c in LABEL_PALETTE {
333        if !used.contains(c) {
334            return (*c).to_string();
335        }
336    }
337    LABEL_PALETTE[existing.len() % LABEL_PALETTE.len()].to_string()
338}
339
340/// Project-wide registries: labels and priorities. (Tickets are categorized by
341/// labels only; there is no separate "type" or "tags" concept.)
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct Definitions {
344    /// On-disk format version.
345    pub version: u32,
346    /// Defined labels.
347    #[serde(default)]
348    pub labels: Vec<LabelDef>,
349    /// Allowed priorities, ordered from lowest to highest.
350    #[serde(default)]
351    pub priorities: Vec<String>,
352}
353
354impl Definitions {
355    /// A sensible default set of definitions for a new board.
356    pub fn seed() -> Self {
357        Definitions {
358            version: FORMAT_VERSION,
359            labels: vec![
360                LabelDef::new("blocked", Some(LABEL_PALETTE[4])),
361                LabelDef::new("needs-review", Some(LABEL_PALETTE[3])),
362                LabelDef::new("agent", Some(LABEL_PALETTE[6])),
363            ],
364            priorities: vec![
365                "low".into(),
366                "medium".into(),
367                "high".into(),
368                "urgent".into(),
369            ],
370        }
371    }
372}
373
374/// A label definition.
375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376pub struct LabelDef {
377    /// Label name (unique within the board).
378    pub name: String,
379    /// Optional UI color.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub color: Option<String>,
382    /// Optional description.
383    #[serde(default, skip_serializing_if = "String::is_empty")]
384    pub description: String,
385}
386
387impl LabelDef {
388    /// Create a label with a name and optional color.
389    pub fn new(name: impl Into<String>, color: Option<&str>) -> Self {
390        LabelDef {
391            name: name.into(),
392            color: color.map(|c| c.to_string()),
393            description: String::new(),
394        }
395    }
396}
397
398// ---------------------------------------------------------------------------
399// settings.json
400// ---------------------------------------------------------------------------
401
402/// Project settings, including how the local daemon is exposed.
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
404pub struct Settings {
405    /// On-disk format version.
406    pub version: u32,
407    /// Local daemon settings.
408    #[serde(default)]
409    pub daemon: DaemonSettings,
410    /// Maximum size, in MB, for a single attachment upload. Defaults to 50 MB to
411    /// match git/GitHub's soft warning threshold; larger uploads are rejected.
412    #[serde(default = "default_max_attachment_mb")]
413    pub max_attachment_mb: u64,
414}
415
416fn default_max_attachment_mb() -> u64 {
417    50
418}
419
420impl Default for Settings {
421    fn default() -> Self {
422        Settings {
423            version: FORMAT_VERSION,
424            daemon: DaemonSettings::default(),
425            max_attachment_mb: default_max_attachment_mb(),
426        }
427    }
428}
429
430/// Configuration for the local daemon that serves the human UX.
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432pub struct DaemonSettings {
433    /// Port to listen on.
434    pub port: u16,
435    /// How the daemon is exposed beyond localhost.
436    #[serde(default)]
437    pub expose: Exposure,
438}
439
440impl Default for DaemonSettings {
441    fn default() -> Self {
442        DaemonSettings {
443            port: DEFAULT_PORT,
444            expose: Exposure::default(),
445        }
446    }
447}
448
449/// How the local daemon is reachable.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
451#[serde(rename_all = "lowercase")]
452pub enum Exposure {
453    /// Localhost only.
454    #[default]
455    None,
456    /// Advertised over a Tailscale network.
457    Tailscale,
458    /// Behind a user-provided reverse proxy.
459    Proxy,
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use chrono::TimeZone;
466
467    fn fixed() -> DateTime<Utc> {
468        Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
469    }
470
471    #[test]
472    fn board_has_default_lists() {
473        let b = Board::new("Demo", fixed());
474        assert_eq!(b.lists.len(), 4);
475        assert_eq!(b.lists[2].id, "in-progress");
476        assert_eq!(b.next_ticket, 1);
477    }
478
479    #[test]
480    fn ticket_omits_empty_fields_and_has_no_type_or_tags() {
481        let t = Ticket::new("T-1", "Hello", fixed());
482        let json = serde_json::to_string(&t).unwrap();
483        // Empty vecs and empty body are skipped for clean diffs.
484        assert!(!json.contains("labels"));
485        assert!(!json.contains("assignees"));
486        assert!(!json.contains("\"body\""));
487        // Type and tags no longer exist.
488        assert!(!json.contains("\"type\""));
489        assert!(!json.contains("tags"));
490    }
491
492    #[test]
493    fn label_color_auto_picks_unused() {
494        let existing = vec![LabelDef::new("a", Some(LABEL_PALETTE[0]))];
495        let picked = next_label_color(&existing);
496        assert_eq!(picked, LABEL_PALETTE[1]);
497    }
498
499    #[test]
500    fn comment_allocation_is_monotonic() {
501        let mut t = Ticket::new("T-1", "Hello", fixed());
502        let a = t.add_comment("me", "first", fixed());
503        let b = t.add_comment("me", "second", fixed());
504        assert_eq!(a, "c-1");
505        assert_eq!(b, "c-2");
506        assert_eq!(t.next_comment, 3);
507    }
508
509    #[test]
510    fn relation_kind_is_kebab_case() {
511        let r = Relation {
512            kind: RelationKind::BlockedBy,
513            target: "T-2".into(),
514        };
515        assert_eq!(
516            serde_json::to_string(&r).unwrap(),
517            r#"{"kind":"blocked-by","target":"T-2"}"#
518        );
519    }
520}