1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::chub_dir;
7
8const MAX_ANNOTATION_LENGTH: usize = 4000;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
13#[serde(rename_all = "lowercase")]
14pub enum AnnotationKind {
15 #[default]
17 Note,
18 Issue,
20 Fix,
22 Practice,
24}
25
26impl AnnotationKind {
27 pub fn as_str(&self) -> &'static str {
28 match self {
29 AnnotationKind::Note => "note",
30 AnnotationKind::Issue => "issue",
31 AnnotationKind::Fix => "fix",
32 AnnotationKind::Practice => "practice",
33 }
34 }
35
36 pub fn parse(s: &str) -> Option<Self> {
37 match s.to_lowercase().as_str() {
38 "note" => Some(AnnotationKind::Note),
39 "issue" => Some(AnnotationKind::Issue),
40 "fix" => Some(AnnotationKind::Fix),
41 "practice" => Some(AnnotationKind::Practice),
42 _ => None,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Annotation {
54 pub id: String,
55 pub note: String,
56 #[serde(default)]
57 pub kind: AnnotationKind,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub severity: Option<String>,
62 #[serde(rename = "updatedAt")]
63 pub updated_at: String,
64}
65
66fn annotations_dir() -> PathBuf {
67 chub_dir().join("annotations")
68}
69
70fn annotation_path(entry_id: &str) -> PathBuf {
71 let safe = crate::util::sanitize_entry_id(entry_id);
72 annotations_dir().join(format!("{}.json", safe))
73}
74
75pub fn read_annotation(entry_id: &str) -> Option<Annotation> {
76 let path = annotation_path(entry_id);
77 fs::read_to_string(&path)
78 .ok()
79 .and_then(|s| serde_json::from_str(&s).ok())
80}
81
82pub fn sanitize_note(note: &str) -> String {
84 let trimmed = note.trim();
85 if trimmed.chars().count() <= MAX_ANNOTATION_LENGTH {
86 trimmed.to_string()
87 } else {
88 let mut s: String = trimmed.chars().take(MAX_ANNOTATION_LENGTH).collect();
89 s.push_str(" [truncated]");
90 s
91 }
92}
93
94pub fn write_annotation(
97 entry_id: &str,
98 note: &str,
99 kind: AnnotationKind,
100 severity: Option<String>,
101) -> Annotation {
102 let dir = annotations_dir();
103 let _ = fs::create_dir_all(&dir);
104
105 let note = sanitize_note(note);
106 let updated_at = crate::util::now_iso8601();
107
108 let data = Annotation {
109 id: entry_id.to_string(),
110 note,
111 kind: kind.clone(),
112 severity: if kind == AnnotationKind::Issue {
113 severity
114 } else {
115 None
116 },
117 updated_at,
118 };
119
120 let json = serde_json::to_string_pretty(&data).unwrap_or_default();
121 let _ = crate::util::atomic_write(&annotation_path(entry_id), json.as_bytes());
122
123 data
124}
125
126pub fn clear_annotation(entry_id: &str) -> bool {
127 fs::remove_file(annotation_path(entry_id)).is_ok()
128}
129
130pub fn list_annotations() -> Vec<Annotation> {
131 let dir = annotations_dir();
132 let files = match fs::read_dir(&dir) {
133 Ok(entries) => entries,
134 Err(_) => return vec![],
135 };
136
137 files
138 .filter_map(|e| e.ok())
139 .filter(|e| {
140 e.path()
141 .extension()
142 .map(|ext| ext == "json")
143 .unwrap_or(false)
144 })
145 .filter_map(|e| {
146 fs::read_to_string(e.path())
147 .ok()
148 .and_then(|s| serde_json::from_str::<Annotation>(&s).ok())
149 })
150 .collect()
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn sanitize_note_trims_whitespace() {
159 assert_eq!(sanitize_note(" hello "), "hello");
160 }
161
162 #[test]
163 fn sanitize_note_short_ascii_unchanged() {
164 let note = "This is a short note.";
165 assert_eq!(sanitize_note(note), note);
166 }
167
168 #[test]
169 fn sanitize_note_truncates_long_ascii() {
170 let long = "a".repeat(5000);
171 let result = sanitize_note(&long);
172 assert_eq!(result.chars().count(), 4000 + " [truncated]".len());
174 assert!(result.ends_with(" [truncated]"));
175 }
176
177 #[test]
178 fn sanitize_note_multi_byte_no_panic() {
179 let emoji_note: String = "🦀".repeat(5000);
182 let result = sanitize_note(&emoji_note);
183 assert!(result.ends_with(" [truncated]"));
184 let crab_part: String = result.chars().take(4000).collect();
186 assert_eq!(crab_part, "🦀".repeat(4000));
187 }
188
189 #[test]
190 fn sanitize_note_cjk_no_panic() {
191 let cjk_note: String = "漢".repeat(5000);
193 let result = sanitize_note(&cjk_note);
194 assert!(result.ends_with(" [truncated]"));
195 let cjk_part: String = result.chars().take(4000).collect();
196 assert_eq!(cjk_part, "漢".repeat(4000));
197 }
198
199 #[test]
200 fn sanitize_note_exact_limit_not_truncated() {
201 let exact = "a".repeat(MAX_ANNOTATION_LENGTH);
202 let result = sanitize_note(&exact);
203 assert_eq!(result.len(), MAX_ANNOTATION_LENGTH);
204 assert!(!result.contains("[truncated]"));
205 }
206
207 #[test]
208 fn sanitize_note_one_over_limit_truncated() {
209 let over = "a".repeat(MAX_ANNOTATION_LENGTH + 1);
210 let result = sanitize_note(&over);
211 assert!(result.ends_with(" [truncated]"));
212 assert_eq!(
213 result.chars().count(),
214 MAX_ANNOTATION_LENGTH + " [truncated]".len()
215 );
216 }
217
218 #[test]
219 fn sanitize_note_mixed_multibyte_and_ascii() {
220 let mut note = String::new();
222 for _ in 0..2500 {
223 note.push('a');
224 note.push('🎉');
225 }
226 let result = sanitize_note(¬e);
228 assert!(result.ends_with(" [truncated]"));
229 let content_chars = result.chars().count() - " [truncated]".len();
231 assert_eq!(content_chars, MAX_ANNOTATION_LENGTH);
232 }
233
234 #[test]
235 fn annotation_kind_parse_roundtrip() {
236 for kind in [
237 AnnotationKind::Note,
238 AnnotationKind::Issue,
239 AnnotationKind::Fix,
240 AnnotationKind::Practice,
241 ] {
242 let s = kind.as_str();
243 let parsed = AnnotationKind::parse(s).unwrap();
244 assert_eq!(parsed, kind);
245 }
246 }
247
248 #[test]
249 fn annotation_kind_parse_case_insensitive() {
250 assert_eq!(AnnotationKind::parse("NOTE"), Some(AnnotationKind::Note));
251 assert_eq!(AnnotationKind::parse("Issue"), Some(AnnotationKind::Issue));
252 assert_eq!(AnnotationKind::parse("FIX"), Some(AnnotationKind::Fix));
253 }
254
255 #[test]
256 fn annotation_kind_parse_invalid() {
257 assert_eq!(AnnotationKind::parse("unknown"), None);
258 assert_eq!(AnnotationKind::parse(""), None);
259 }
260}