Skip to main content

chronicle/annotate/
staging.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::chronicle_error;
6use crate::error::Result;
7use snafu::ResultExt;
8
9/// A single staged note captured during work.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct StagedNote {
12    pub timestamp: String,
13    pub text: String,
14}
15
16const STAGED_NOTES_FILE: &str = "chronicle/staged-notes.json";
17
18/// Resolve the staged notes file path from a .git directory.
19fn staged_notes_path(git_dir: &Path) -> PathBuf {
20    git_dir.join(STAGED_NOTES_FILE)
21}
22
23/// Read all staged notes. Returns empty vec if no staged notes exist.
24pub fn read_staged(git_dir: &Path) -> Result<Vec<StagedNote>> {
25    let path = staged_notes_path(git_dir);
26    if !path.exists() {
27        return Ok(Vec::new());
28    }
29
30    let content = std::fs::read_to_string(&path).context(chronicle_error::IoSnafu)?;
31    if content.trim().is_empty() {
32        return Ok(Vec::new());
33    }
34
35    let notes: Vec<StagedNote> =
36        serde_json::from_str(&content).context(chronicle_error::JsonSnafu)?;
37    Ok(notes)
38}
39
40/// Append a new note to the staging area.
41pub fn append_staged(git_dir: &Path, text: &str) -> Result<()> {
42    let path = staged_notes_path(git_dir);
43
44    // Ensure the chronicle directory exists
45    if let Some(parent) = path.parent() {
46        std::fs::create_dir_all(parent).context(chronicle_error::IoSnafu)?;
47    }
48
49    let mut notes = read_staged(git_dir)?;
50    notes.push(StagedNote {
51        timestamp: chrono::Utc::now().to_rfc3339(),
52        text: text.to_string(),
53    });
54
55    let json = serde_json::to_string_pretty(&notes).context(chronicle_error::JsonSnafu)?;
56    std::fs::write(&path, json).context(chronicle_error::IoSnafu)?;
57
58    Ok(())
59}
60
61/// Clear all staged notes.
62pub fn clear_staged(git_dir: &Path) -> Result<()> {
63    let path = staged_notes_path(git_dir);
64    if path.exists() {
65        std::fs::remove_file(&path).context(chronicle_error::IoSnafu)?;
66    }
67    Ok(())
68}
69
70/// Format staged notes as a provenance notes string.
71pub fn format_for_provenance(notes: &[StagedNote]) -> String {
72    notes
73        .iter()
74        .map(|n| format!("[{}] {}", n.timestamp, n.text))
75        .collect::<Vec<_>>()
76        .join("\n")
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use tempfile::TempDir;
83
84    fn setup_git_dir() -> TempDir {
85        let tmp = TempDir::new().unwrap();
86        std::fs::create_dir_all(tmp.path().join("chronicle")).unwrap();
87        tmp
88    }
89
90    #[test]
91    fn test_read_empty_staging() {
92        let tmp = setup_git_dir();
93        let notes = read_staged(tmp.path()).unwrap();
94        assert!(notes.is_empty());
95    }
96
97    #[test]
98    fn test_append_and_read() {
99        let tmp = setup_git_dir();
100
101        append_staged(tmp.path(), "Tried approach X, didn't work").unwrap();
102        append_staged(tmp.path(), "Approach Y works better").unwrap();
103
104        let notes = read_staged(tmp.path()).unwrap();
105        assert_eq!(notes.len(), 2);
106        assert_eq!(notes[0].text, "Tried approach X, didn't work");
107        assert_eq!(notes[1].text, "Approach Y works better");
108        assert!(!notes[0].timestamp.is_empty());
109    }
110
111    #[test]
112    fn test_clear_staged() {
113        let tmp = setup_git_dir();
114
115        append_staged(tmp.path(), "Some note").unwrap();
116        assert!(!read_staged(tmp.path()).unwrap().is_empty());
117
118        clear_staged(tmp.path()).unwrap();
119        assert!(read_staged(tmp.path()).unwrap().is_empty());
120    }
121
122    #[test]
123    fn test_clear_nonexistent_is_ok() {
124        let tmp = setup_git_dir();
125        // Should not error when no file exists
126        clear_staged(tmp.path()).unwrap();
127    }
128
129    #[test]
130    fn test_format_for_provenance() {
131        let notes = vec![
132            StagedNote {
133                timestamp: "2025-01-01T00:00:00Z".to_string(),
134                text: "Tried X".to_string(),
135            },
136            StagedNote {
137                timestamp: "2025-01-01T00:01:00Z".to_string(),
138                text: "Y worked".to_string(),
139            },
140        ];
141
142        let formatted = format_for_provenance(&notes);
143        assert!(formatted.contains("[2025-01-01T00:00:00Z] Tried X"));
144        assert!(formatted.contains("[2025-01-01T00:01:00Z] Y worked"));
145        assert!(formatted.contains('\n'));
146    }
147}