Skip to main content

chub_core/
annotations.rs

1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::chub_dir;
7
8/// Maximum annotation length in characters. Notes exceeding this are truncated.
9const MAX_ANNOTATION_LENGTH: usize = 4000;
10
11/// The kind of annotation — classifies what the agent learned.
12#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
13#[serde(rename_all = "lowercase")]
14pub enum AnnotationKind {
15    /// General observation (default).
16    #[default]
17    Note,
18    /// Undocumented bug, broken param, or misleading example.
19    Issue,
20    /// Workaround that resolved an issue.
21    Fix,
22    /// Team convention or validated pattern.
23    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/// A personal annotation stored at `~/.chub/annotations/<id>.json`.
48///
49/// **Overwrite semantics**: writing a new annotation for the same entry ID replaces the previous
50/// one entirely. There is no history. Use team annotations (`.chub/annotations/<id>.yaml`) if
51/// you need an append-based history with multiple authors.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Annotation {
54    pub id: String,
55    pub note: String,
56    #[serde(default)]
57    pub kind: AnnotationKind,
58    /// Severity level for issue annotations: "high", "medium", or "low". Only used when
59    /// kind=issue; ignored for other kinds.
60    #[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
82/// Sanitize annotation text: truncate to max length.
83pub 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
94/// Write a personal annotation. **Overwrites** any existing annotation for this entry.
95/// Severity is only stored when kind=Issue; it is ignored for other kinds.
96pub 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        // 4000 chars + " [truncated]"
173        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        // 2000 emoji characters (each 4 bytes) — exceeds 4000 byte limit
180        // but should not panic since we count chars, not bytes
181        let emoji_note: String = "🦀".repeat(5000);
182        let result = sanitize_note(&emoji_note);
183        assert!(result.ends_with(" [truncated]"));
184        // The first 4000 chars should all be 🦀
185        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        // CJK characters are 3 bytes each in UTF-8
192        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        // Mix of 1-byte ASCII and 4-byte emoji
221        let mut note = String::new();
222        for _ in 0..2500 {
223            note.push('a');
224            note.push('🎉');
225        }
226        // 5000 chars total > 4000 limit, should truncate safely
227        let result = sanitize_note(&note);
228        assert!(result.ends_with(" [truncated]"));
229        // Verify no panic and correct char count
230        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}