chronicle/annotate/
staging.rs1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::chronicle_error;
6use crate::error::Result;
7use snafu::ResultExt;
8
9#[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
18fn staged_notes_path(git_dir: &Path) -> PathBuf {
20 git_dir.join(STAGED_NOTES_FILE)
21}
22
23pub 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
40pub fn append_staged(git_dir: &Path, text: &str) -> Result<()> {
42 let path = staged_notes_path(git_dir);
43
44 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(¬es).context(chronicle_error::JsonSnafu)?;
56 std::fs::write(&path, json).context(chronicle_error::IoSnafu)?;
57
58 Ok(())
59}
60
61pub 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
70pub 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 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(¬es);
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}