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    /// Next forum-thread counter; `F-<next_thread>` is the next thread ID.
44    #[serde(default = "one")]
45    pub next_thread: u64,
46    /// When the board was created.
47    pub created: DateTime<Utc>,
48    /// When the board was last modified.
49    pub updated: DateTime<Utc>,
50}
51
52/// What to pre-populate a new board with (chosen during `wipe init`).
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Starter {
56    /// Default lists (Backlog/Todo/In Progress/Done) and default labels.
57    #[default]
58    Standard,
59    /// Default lists, but no labels.
60    ListsOnly,
61    /// No lists and no labels - a blank board.
62    Empty,
63}
64
65impl Board {
66    /// Create a fresh board with the default set of lists.
67    pub fn new(name: impl Into<String>, now: DateTime<Utc>) -> Self {
68        Board {
69            version: FORMAT_VERSION,
70            id: Uuid::new_v4().to_string(),
71            name: name.into(),
72            description: String::new(),
73            lists: default_lists(),
74            next_ticket: 1,
75            next_thread: 1,
76            created: now,
77            updated: now,
78        }
79    }
80
81    /// Create a board with no lists (used by the "empty" starter).
82    pub fn empty(name: impl Into<String>, now: DateTime<Utc>) -> Self {
83        let mut b = Board::new(name, now);
84        b.lists.clear();
85        b
86    }
87
88    /// Find a list by ID.
89    pub fn list(&self, id: &str) -> Option<&List> {
90        self.lists.iter().find(|l| l.id == id)
91    }
92
93    /// Find a list by ID (mutable).
94    pub fn list_mut(&mut self, id: &str) -> Option<&mut List> {
95        self.lists.iter_mut().find(|l| l.id == id)
96    }
97
98    /// Return `(list_id, index)` of the list currently containing `ticket_id`.
99    pub fn locate_card(&self, ticket_id: &str) -> Option<(String, usize)> {
100        for list in &self.lists {
101            if let Some(idx) = list.cards.iter().position(|c| c == ticket_id) {
102                return Some((list.id.clone(), idx));
103            }
104        }
105        None
106    }
107}
108
109/// A list (column) on the board. `cards` is the ordered set of ticket IDs it holds.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct List {
112    /// Stable list ID (kebab-case slug of the original name).
113    pub id: String,
114    /// Display name.
115    pub name: String,
116    /// Optional UI color (hex or token).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub color: Option<String>,
119    /// Optional work-in-progress limit.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub wip_limit: Option<u32>,
122    /// Ordered ticket IDs contained in this list.
123    #[serde(default)]
124    pub cards: Vec<String>,
125}
126
127impl List {
128    /// Create an empty list from a display name.
129    pub fn new(name: impl Into<String>) -> Self {
130        let name = name.into();
131        List {
132            id: slug(&name),
133            name,
134            color: None,
135            wip_limit: None,
136            cards: Vec::new(),
137        }
138    }
139}
140
141/// The default lists created by `wipe init`.
142fn default_lists() -> Vec<List> {
143    ["Backlog", "Todo", "In Progress", "Done"]
144        .into_iter()
145        .map(List::new)
146        .collect()
147}
148
149// ---------------------------------------------------------------------------
150// tickets/T-###.json
151// ---------------------------------------------------------------------------
152
153/// A ticket (card). Stored as its own file; comments are inline and short.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct Ticket {
156    /// On-disk format version.
157    pub version: u32,
158    /// Ticket ID, e.g. `T-23`.
159    pub id: String,
160    /// Short title.
161    pub title: String,
162    /// Long-form body (Markdown allowed inside the JSON string).
163    #[serde(default, skip_serializing_if = "String::is_empty")]
164    pub body: String,
165    /// Priority (references a name in `definitions.json`).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub priority: Option<String>,
168    /// Applied label names (the only categorization mechanism).
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub labels: Vec<String>,
171    /// Assignee identities (git-style `Name <email>` or agent IDs).
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub assignees: Vec<String>,
174    /// Relations to other tickets.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub relations: Vec<Relation>,
177    /// Attached media/files (stored under `.wipe/media/` or referenced in-repo).
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub attachments: Vec<Attachment>,
180    /// Inline comment thread.
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub comments: Vec<Comment>,
183    /// Activity log (moves, label/assignee/priority changes, attachments). Shown
184    /// interleaved with comments in the ticket's activity timeline.
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub activity: Vec<Activity>,
187    /// Next comment counter for this ticket.
188    #[serde(default = "one")]
189    pub next_comment: u64,
190    /// When the ticket was created.
191    pub created: DateTime<Utc>,
192    /// When the ticket was last modified.
193    pub updated: DateTime<Utc>,
194}
195
196fn one() -> u64 {
197    1
198}
199
200impl Ticket {
201    /// Create a new ticket with the given ID and title.
202    pub fn new(id: impl Into<String>, title: impl Into<String>, now: DateTime<Utc>) -> Self {
203        Ticket {
204            version: FORMAT_VERSION,
205            id: id.into(),
206            title: title.into(),
207            body: String::new(),
208            priority: None,
209            labels: Vec::new(),
210            assignees: Vec::new(),
211            relations: Vec::new(),
212            attachments: Vec::new(),
213            comments: Vec::new(),
214            activity: Vec::new(),
215            next_comment: 1,
216            created: now,
217            updated: now,
218        }
219    }
220
221    /// Append a comment, allocating the next comment ID. Returns the new comment ID.
222    pub fn add_comment(
223        &mut self,
224        author: impl Into<String>,
225        body: impl Into<String>,
226        now: DateTime<Utc>,
227    ) -> String {
228        let id = crate::id::comment_id(self.next_comment);
229        self.next_comment += 1;
230        self.comments.push(Comment {
231            id: id.clone(),
232            author: author.into(),
233            body: body.into(),
234            created: now,
235            edited: None,
236        });
237        self.updated = now;
238        id
239    }
240
241    /// Append an activity event. `detail` may be empty when `kind` is self-explanatory.
242    pub fn log_activity(
243        &mut self,
244        actor: impl Into<String>,
245        kind: impl Into<String>,
246        detail: impl Into<String>,
247        now: DateTime<Utc>,
248    ) {
249        self.activity.push(Activity {
250            ts: now,
251            actor: actor.into(),
252            kind: kind.into(),
253            detail: detail.into(),
254        });
255    }
256}
257
258/// A relation from one ticket to another.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct Relation {
261    /// Kind of relation.
262    pub kind: RelationKind,
263    /// Target ticket ID.
264    pub target: String,
265}
266
267/// The kind of a [`Relation`].
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "kebab-case")]
270pub enum RelationKind {
271    /// This ticket blocks the target.
272    Blocks,
273    /// This ticket is blocked by the target.
274    BlockedBy,
275    /// The target is a parent of this ticket.
276    Parent,
277    /// The target is a child of this ticket.
278    Child,
279    /// A soft relationship.
280    Relates,
281}
282
283/// An inline comment on a ticket.
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct Comment {
286    /// Comment ID, e.g. `c-7`.
287    pub id: String,
288    /// Author identity (git `Name <email>` or agent ID).
289    pub author: String,
290    /// Comment body (Markdown allowed).
291    pub body: String,
292    /// When the comment was posted.
293    pub created: DateTime<Utc>,
294    /// When the comment was last edited, if ever.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub edited: Option<DateTime<Utc>>,
297}
298
299/// A recorded change to a ticket, shown in the activity timeline alongside
300/// comments. Kept deliberately small and pre-classified so any front-end can
301/// render a phrase from `kind` + `detail` without re-deriving it from diffs.
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct Activity {
304    /// When it happened.
305    pub ts: DateTime<Utc>,
306    /// Who did it (git `Name <email>` or agent ID).
307    pub actor: String,
308    /// Event kind: one of `created`, `moved`, `renamed`, `edited`, `priority`,
309    /// `label-added`, `label-removed`, `assigned`, `unassigned`, `attached`,
310    /// `detached`.
311    pub kind: String,
312    /// Event-specific detail (destination list, label, assignee, attachment
313    /// name, priority value). Empty when the `kind` alone says everything.
314    #[serde(default, skip_serializing_if = "String::is_empty")]
315    pub detail: String,
316}
317
318/// A file attached to a ticket.
319///
320/// `path` is always **repo-relative**. Depending on `source` it either points at a
321/// file already tracked in the repository (no copy is made) or at a file copied
322/// into `.wipe/media/`. This avoids duplicating files that already live in the repo.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct Attachment {
325    /// Display file name.
326    pub name: String,
327    /// Repo-relative path to the file.
328    pub path: String,
329    /// Where the file lives.
330    pub source: AttachmentSource,
331    /// File size in bytes.
332    pub size: u64,
333    /// MIME type (best-effort, from the file extension).
334    pub mime: String,
335}
336
337/// Where an [`Attachment`]'s bytes live.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "lowercase")]
340pub enum AttachmentSource {
341    /// Copied into `.wipe/media/`.
342    Media,
343    /// References a file already tracked in the repository.
344    Repo,
345}
346
347// ---------------------------------------------------------------------------
348// forum/<id>.json  - the project's git-tracked discussion forum
349// ---------------------------------------------------------------------------
350
351/// A forum thread: a root [`Post`] plus its nested reply tree. Stored as one file
352/// per thread under `.wipe/forum/<id>.json`, so replies to different threads never
353/// conflict and deleting a thread is deleting a file.
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct Thread {
356    /// On-disk format version.
357    pub version: u32,
358    /// Thread ID, e.g. `F-1` (also the ID of its root post).
359    pub id: String,
360    /// Thread title (the headline of the root post).
361    pub title: String,
362    /// The root post and, nested within it, the whole reply tree.
363    pub root: Post,
364    /// When the thread was created.
365    pub created: DateTime<Utc>,
366    /// When anything in the thread last changed.
367    pub updated: DateTime<Utc>,
368}
369
370/// A single forum post: the root of a thread, or a reply at any depth. IDs are
371/// dotted and self-describing (`F-1`, `F-1.1`, `F-1.1.2`), so the tree is legible
372/// from the ID alone and a subtree is "every ID under this prefix".
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub struct Post {
375    /// Dotted post ID (`F-1`, `F-1.2`, `F-1.2.1`).
376    pub id: String,
377    /// Author identity (git `Name <email>` or agent ID), same pool as tickets.
378    pub author: String,
379    /// Message body (Markdown allowed).
380    pub body: String,
381    /// Labels, drawn from the same board label pool as tickets.
382    #[serde(default, skip_serializing_if = "Vec::is_empty")]
383    pub labels: Vec<String>,
384    /// Attached media/files (same storage as ticket attachments).
385    #[serde(default, skip_serializing_if = "Vec::is_empty")]
386    pub attachments: Vec<Attachment>,
387    /// Free-form references (ticket IDs like `T-3`, other post IDs, or URLs).
388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
389    pub refs: Vec<String>,
390    /// When the post was created.
391    pub created: DateTime<Utc>,
392    /// When the post was last edited, if ever.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub edited: Option<DateTime<Utc>>,
395    /// Next child index; the next reply gets `<id>.<next_reply>` (never reused).
396    #[serde(default = "one")]
397    pub next_reply: u64,
398    /// Direct replies (each may have its own replies, forming the tree).
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub replies: Vec<Post>,
401}
402
403impl Post {
404    /// Create a fresh post with no replies.
405    pub fn new(
406        id: impl Into<String>,
407        author: impl Into<String>,
408        body: impl Into<String>,
409        now: DateTime<Utc>,
410    ) -> Self {
411        Post {
412            id: id.into(),
413            author: author.into(),
414            body: body.into(),
415            labels: Vec::new(),
416            attachments: Vec::new(),
417            refs: Vec::new(),
418            created: now,
419            edited: None,
420            next_reply: 1,
421            replies: Vec::new(),
422        }
423    }
424
425    /// Find a post by ID anywhere in this subtree (including `self`).
426    pub fn find(&self, id: &str) -> Option<&Post> {
427        if self.id == id {
428            return Some(self);
429        }
430        self.replies.iter().find_map(|r| r.find(id))
431    }
432
433    /// Find a post by ID anywhere in this subtree (mutable).
434    pub fn find_mut(&mut self, id: &str) -> Option<&mut Post> {
435        if self.id == id {
436            return Some(self);
437        }
438        self.replies.iter_mut().find_map(|r| r.find_mut(id))
439    }
440
441    /// Remove the direct child with `id` (not recursive). Returns true if removed.
442    pub fn remove_child(&mut self, id: &str) -> bool {
443        let before = self.replies.len();
444        self.replies.retain(|r| r.id != id);
445        if self.replies.len() != before {
446            return true;
447        }
448        self.replies.iter_mut().any(|r| r.remove_child(id))
449    }
450
451    /// Visit every post in this subtree (pre-order) with its depth (root = 0).
452    pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a Post, usize)) {
453        f(self, depth);
454        for r in &self.replies {
455            r.walk(depth + 1, f);
456        }
457    }
458}
459
460// ---------------------------------------------------------------------------
461// identities.json
462// ---------------------------------------------------------------------------
463
464/// A person or agent that can be assigned to tickets and author comments.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466pub struct Identity {
467    /// Stable identity key (e.g. a git email, or an agent slug like `claude`).
468    pub id: String,
469    /// Editable display name.
470    pub display_name: String,
471    /// Whether this identity is a human or an agent.
472    pub kind: IdentityKind,
473}
474
475/// Kind of an [`Identity`].
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
477#[serde(rename_all = "lowercase")]
478pub enum IdentityKind {
479    /// A human contributor (typically discovered from git).
480    #[default]
481    Human,
482    /// An AI agent.
483    Agent,
484}
485
486// ---------------------------------------------------------------------------
487// definitions.json
488// ---------------------------------------------------------------------------
489
490/// The label color palette. New labels are auto-assigned the first unused entry;
491/// colors can also be changed later. Matches the label set in `docs/DESIGN.md`.
492pub const LABEL_PALETTE: &[&str] = &[
493    "#CC785C", // terracotta
494    "#6C7BA8", // indigo
495    "#7E9B7A", // sage
496    "#61AAF2", // sky
497    "#BF4D43", // clay
498    "#D4A27F", // kraft
499    "#9A7AA0", // plum
500    "#3E9C93", // teal
501    "#E0A33B", // amber
502    "#C77C93", // rose
503    "#4F7A55", // forest
504    "#9E8FC2", // lavender
505    "#B0455A", // ruby
506    "#8A9A5B", // olive
507    "#7C8AA0", // steel
508    "#8C6A54", // cocoa
509    "#EBDBBC", // manilla
510    "#666663", // slate
511];
512
513/// Pick the first palette color not already used by an existing label; if every
514/// palette color is in use, cycle deterministically by count.
515pub fn next_label_color(existing: &[LabelDef]) -> String {
516    let used: std::collections::HashSet<&str> =
517        existing.iter().filter_map(|l| l.color.as_deref()).collect();
518    for c in LABEL_PALETTE {
519        if !used.contains(c) {
520            return (*c).to_string();
521        }
522    }
523    LABEL_PALETTE[existing.len() % LABEL_PALETTE.len()].to_string()
524}
525
526/// Project-wide registries: labels and priorities. (Tickets are categorized by
527/// labels only; there is no separate "type" or "tags" concept.)
528#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
529pub struct Definitions {
530    /// On-disk format version.
531    pub version: u32,
532    /// Defined labels.
533    #[serde(default)]
534    pub labels: Vec<LabelDef>,
535    /// Allowed priorities, ordered from lowest to highest.
536    #[serde(default)]
537    pub priorities: Vec<String>,
538}
539
540impl Definitions {
541    /// A sensible default set of definitions for a new board.
542    pub fn seed() -> Self {
543        Definitions {
544            version: FORMAT_VERSION,
545            labels: vec![
546                LabelDef::new("blocked", Some(LABEL_PALETTE[4])),
547                LabelDef::new("needs-review", Some(LABEL_PALETTE[3])),
548                LabelDef::new("agent", Some(LABEL_PALETTE[6])),
549            ],
550            priorities: vec![
551                "low".into(),
552                "medium".into(),
553                "high".into(),
554                "urgent".into(),
555            ],
556        }
557    }
558}
559
560/// A label definition.
561#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
562pub struct LabelDef {
563    /// Label name (unique within the board).
564    pub name: String,
565    /// Optional UI color.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub color: Option<String>,
568    /// Optional description.
569    #[serde(default, skip_serializing_if = "String::is_empty")]
570    pub description: String,
571}
572
573impl LabelDef {
574    /// Create a label with a name and optional color.
575    pub fn new(name: impl Into<String>, color: Option<&str>) -> Self {
576        LabelDef {
577            name: name.into(),
578            color: color.map(|c| c.to_string()),
579            description: String::new(),
580        }
581    }
582}
583
584// ---------------------------------------------------------------------------
585// settings.json
586// ---------------------------------------------------------------------------
587
588/// Project settings, including how the local daemon is exposed.
589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
590pub struct Settings {
591    /// On-disk format version.
592    pub version: u32,
593    /// Local daemon settings.
594    #[serde(default)]
595    pub daemon: DaemonSettings,
596    /// Maximum size, in MB, for a single attachment upload. Defaults to 50 MB to
597    /// match git/GitHub's soft warning threshold; larger uploads are rejected.
598    #[serde(default = "default_max_attachment_mb")]
599    pub max_attachment_mb: u64,
600}
601
602fn default_max_attachment_mb() -> u64 {
603    50
604}
605
606impl Default for Settings {
607    fn default() -> Self {
608        Settings {
609            version: FORMAT_VERSION,
610            daemon: DaemonSettings::default(),
611            max_attachment_mb: default_max_attachment_mb(),
612        }
613    }
614}
615
616/// Configuration for the local daemon that serves the human UX.
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618pub struct DaemonSettings {
619    /// Port to listen on.
620    pub port: u16,
621    /// How the daemon is exposed beyond localhost.
622    #[serde(default)]
623    pub expose: Exposure,
624    /// When true, the daemon shuts itself down after `idle_timeout_secs` with no
625    /// connected UI clients, so it leaves no background overhead when not viewed.
626    #[serde(default)]
627    pub autoserve: bool,
628    /// Idle timeout (seconds) used when auto-serving / `--idle` is active.
629    #[serde(default = "default_idle_timeout")]
630    pub idle_timeout_secs: u64,
631}
632
633fn default_idle_timeout() -> u64 {
634    900
635}
636
637impl Default for DaemonSettings {
638    fn default() -> Self {
639        DaemonSettings {
640            port: DEFAULT_PORT,
641            expose: Exposure::default(),
642            autoserve: false,
643            idle_timeout_secs: default_idle_timeout(),
644        }
645    }
646}
647
648/// How the local daemon is reachable.
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
650#[serde(rename_all = "lowercase")]
651pub enum Exposure {
652    /// Localhost only.
653    #[default]
654    None,
655    /// Advertised over a Tailscale network.
656    Tailscale,
657    /// Behind a user-provided reverse proxy.
658    Proxy,
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use chrono::TimeZone;
665
666    fn fixed() -> DateTime<Utc> {
667        Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
668    }
669
670    #[test]
671    fn board_has_default_lists() {
672        let b = Board::new("Demo", fixed());
673        assert_eq!(b.lists.len(), 4);
674        assert_eq!(b.lists[2].id, "in-progress");
675        assert_eq!(b.next_ticket, 1);
676    }
677
678    #[test]
679    fn ticket_omits_empty_fields_and_has_no_type_or_tags() {
680        let t = Ticket::new("T-1", "Hello", fixed());
681        let json = serde_json::to_string(&t).unwrap();
682        // Empty vecs and empty body are skipped for clean diffs.
683        assert!(!json.contains("labels"));
684        assert!(!json.contains("assignees"));
685        assert!(!json.contains("\"body\""));
686        // Type and tags no longer exist.
687        assert!(!json.contains("\"type\""));
688        assert!(!json.contains("tags"));
689    }
690
691    #[test]
692    fn label_color_auto_picks_unused() {
693        let existing = vec![LabelDef::new("a", Some(LABEL_PALETTE[0]))];
694        let picked = next_label_color(&existing);
695        assert_eq!(picked, LABEL_PALETTE[1]);
696    }
697
698    #[test]
699    fn comment_allocation_is_monotonic() {
700        let mut t = Ticket::new("T-1", "Hello", fixed());
701        let a = t.add_comment("me", "first", fixed());
702        let b = t.add_comment("me", "second", fixed());
703        assert_eq!(a, "c-1");
704        assert_eq!(b, "c-2");
705        assert_eq!(t.next_comment, 3);
706    }
707
708    #[test]
709    fn relation_kind_is_kebab_case() {
710        let r = Relation {
711            kind: RelationKind::BlockedBy,
712            target: "T-2".into(),
713        };
714        assert_eq!(
715            serde_json::to_string(&r).unwrap(),
716            r#"{"kind":"blocked-by","target":"T-2"}"#
717        );
718    }
719}