code_moniker_workspace/notes/
model.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
4#[serde(transparent)]
5pub struct NoteId(pub String);
6
7impl NoteId {
8 pub fn new(value: impl Into<String>) -> Self {
9 Self(value.into())
10 }
11
12 pub fn as_str(&self) -> &str {
13 &self.0
14 }
15}
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum NoteKind {
20 Note,
21 Todo,
22 Gotcha,
23 Request,
24}
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum NoteStatus {
29 Pending,
30 Ongoing,
31 Done,
32}
33
34impl NoteStatus {
35 pub fn parse(value: &str) -> anyhow::Result<Self> {
36 match value {
37 "pending" => Ok(Self::Pending),
38 "ongoing" => Ok(Self::Ongoing),
39 "done" => Ok(Self::Done),
40 _ => anyhow::bail!("unknown note status `{value}`"),
41 }
42 }
43
44 pub fn as_str(self) -> &'static str {
45 match self {
46 Self::Pending => "pending",
47 Self::Ongoing => "ongoing",
48 Self::Done => "done",
49 }
50 }
51
52 pub fn can_transition_to(self, target: Self) -> bool {
53 matches!(
54 (self, target),
55 (Self::Pending, Self::Ongoing)
56 | (Self::Pending, Self::Done)
57 | (Self::Ongoing, Self::Pending)
58 | (Self::Ongoing, Self::Done)
59 | (Self::Done, Self::Ongoing)
60 ) || self == target
61 }
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
65#[serde(rename_all = "snake_case")]
66pub enum NoteAuthor {
67 User,
68 Agent,
69}
70
71impl NoteKind {
72 pub fn parse(value: &str) -> anyhow::Result<Self> {
73 match value {
74 "note" => Ok(Self::Note),
75 "todo" => Ok(Self::Todo),
76 "gotcha" => Ok(Self::Gotcha),
77 "request" => Ok(Self::Request),
78 _ => anyhow::bail!("unknown note kind `{value}`"),
79 }
80 }
81
82 pub fn as_str(self) -> &'static str {
83 match self {
84 Self::Note => "note",
85 Self::Todo => "todo",
86 Self::Gotcha => "gotcha",
87 Self::Request => "request",
88 }
89 }
90}
91
92impl NoteAuthor {
93 pub fn parse(value: &str) -> anyhow::Result<Self> {
94 match value {
95 "user" => Ok(Self::User),
96 "agent" => Ok(Self::Agent),
97 _ => anyhow::bail!("unknown note author `{value}`"),
98 }
99 }
100
101 pub fn as_str(self) -> &'static str {
102 match self {
103 Self::User => "user",
104 Self::Agent => "agent",
105 }
106 }
107}
108
109#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
110#[serde(deny_unknown_fields)]
111pub struct Note {
112 pub id: NoteId,
113 pub moniker: String,
114 pub kind: NoteKind,
115 pub status: NoteStatus,
116 pub title: String,
117 pub body: String,
118 pub created_by: NoteAuthor,
119 pub created_at: String,
120 pub updated_at: String,
121}
122
123impl Note {
124 pub fn transition_to(
125 &mut self,
126 target: NoteStatus,
127 updated_at: impl Into<String>,
128 ) -> Result<(), TransitionError> {
129 if !self.status.can_transition_to(target) {
130 return Err(TransitionError {
131 from: self.status,
132 to: target,
133 });
134 }
135 self.status = target;
136 self.updated_at = updated_at.into();
137 Ok(())
138 }
139}
140
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub struct TransitionError {
143 pub from: NoteStatus,
144 pub to: NoteStatus,
145}
146
147impl std::fmt::Display for TransitionError {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 write!(
150 f,
151 "invalid note status transition: {:?} -> {:?}",
152 self.from, self.to
153 )
154 }
155}
156
157impl std::error::Error for TransitionError {}
158
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct ResolvedNote {
161 pub note: Note,
162 pub resolution: NoteResolution,
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub enum NoteResolution {
167 Resolved {
168 target_label: String,
169 target_file: String,
170 target_slice: Option<(u32, u32)>,
171 },
172 Orphan,
173}
174
175impl NoteResolution {
176 pub fn is_orphan(&self) -> bool {
177 matches!(self, Self::Orphan)
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn status_transitions_are_controlled() {
187 assert!(NoteStatus::Pending.can_transition_to(NoteStatus::Ongoing));
188 assert!(NoteStatus::Pending.can_transition_to(NoteStatus::Done));
189 assert!(NoteStatus::Ongoing.can_transition_to(NoteStatus::Pending));
190 assert!(NoteStatus::Ongoing.can_transition_to(NoteStatus::Done));
191 assert!(NoteStatus::Done.can_transition_to(NoteStatus::Ongoing));
192 assert!(!NoteStatus::Done.can_transition_to(NoteStatus::Pending));
193 }
194
195 #[test]
196 fn note_transition_rejects_done_to_pending() {
197 let mut note = Note {
198 id: NoteId::new("note_1"),
199 moniker: "code+moniker://./lang:rs/module:example".to_string(),
200 kind: NoteKind::Todo,
201 status: NoteStatus::Done,
202 title: "title".to_string(),
203 body: "body".to_string(),
204 created_by: NoteAuthor::User,
205 created_at: "2026-06-02T00:00:00Z".to_string(),
206 updated_at: "2026-06-02T00:00:00Z".to_string(),
207 };
208
209 let error = note
210 .transition_to(NoteStatus::Pending, "2026-06-02T01:00:00Z")
211 .unwrap_err();
212
213 assert_eq!(error.from, NoteStatus::Done);
214 assert_eq!(error.to, NoteStatus::Pending);
215 assert_eq!(note.status, NoteStatus::Done);
216 assert_eq!(note.updated_at, "2026-06-02T00:00:00Z");
217 }
218
219 #[test]
220 fn note_transition_updates_status_and_timestamp() {
221 let mut note = Note {
222 id: NoteId::new("note_1"),
223 moniker: "code+moniker://./lang:rs/module:example".to_string(),
224 kind: NoteKind::Todo,
225 status: NoteStatus::Pending,
226 title: "title".to_string(),
227 body: "body".to_string(),
228 created_by: NoteAuthor::User,
229 created_at: "2026-06-02T00:00:00Z".to_string(),
230 updated_at: "2026-06-02T00:00:00Z".to_string(),
231 };
232
233 note.transition_to(NoteStatus::Ongoing, "2026-06-02T01:00:00Z")
234 .expect("transition");
235
236 assert_eq!(note.status, NoteStatus::Ongoing);
237 assert_eq!(note.updated_at, "2026-06-02T01:00:00Z");
238 }
239}