Skip to main content

code_moniker_workspace/notes/
model.rs

1use 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}