1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema;
4
5#[derive(Debug, Clone)]
7pub struct SentimentsQuery {
8 pub file: Option<String>,
9}
10
11#[derive(Debug, Clone, serde::Serialize)]
13pub struct SentimentEntry {
14 pub feeling: String,
15 pub detail: String,
16 pub commit: String,
17 pub timestamp: String,
18 pub summary: String,
19}
20
21#[derive(Debug, Clone, serde::Serialize)]
23pub struct SentimentsOutput {
24 pub schema: String,
25 pub sentiments: Vec<SentimentEntry>,
26}
27
28pub fn query_sentiments(
40 git: &dyn GitOps,
41 query: &SentimentsQuery,
42) -> Result<SentimentsOutput, GitError> {
43 let shas = match &query.file {
44 Some(file) => git.log_for_file(file)?,
45 None => git.list_annotated_commits(1000)?,
46 };
47
48 let mut sentiments = Vec::new();
49
50 for sha in &shas {
51 let note = match git.note_read(sha)? {
52 Some(n) => n,
53 None => continue,
54 };
55
56 let annotation = match schema::parse_annotation(¬e) {
57 Ok(a) => a,
58 Err(e) => {
59 tracing::debug!("skipping malformed annotation for {sha}: {e}");
60 continue;
61 }
62 };
63
64 for w in &annotation.wisdom {
67 let feeling = match w.category {
68 crate::schema::v3::WisdomCategory::Gotcha => "worry",
69 crate::schema::v3::WisdomCategory::UnfinishedThread => "uncertainty",
70 crate::schema::v3::WisdomCategory::Insight => "confidence",
71 crate::schema::v3::WisdomCategory::DeadEnd => "frustration",
72 };
73 sentiments.push(SentimentEntry {
74 feeling: feeling.to_string(),
75 detail: w.content.clone(),
76 commit: annotation.commit.clone(),
77 timestamp: annotation.timestamp.clone(),
78 summary: annotation.summary.clone(),
79 });
80 }
81 }
82
83 sentiments.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
85
86 Ok(SentimentsOutput {
87 schema: "chronicle-sentiments/v1".to_string(),
88 sentiments,
89 })
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::schema::v2;
96
97 struct MockGitOps {
98 file_log: Vec<String>,
99 annotated_commits: Vec<String>,
100 notes: std::collections::HashMap<String, String>,
101 }
102
103 impl GitOps for MockGitOps {
104 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
105 Ok(vec![])
106 }
107 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
108 Ok(self.notes.get(commit).cloned())
109 }
110 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
111 Ok(())
112 }
113 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
114 Ok(self.notes.contains_key(commit))
115 }
116 fn file_at_commit(
117 &self,
118 _path: &std::path::Path,
119 _commit: &str,
120 ) -> Result<String, GitError> {
121 Ok(String::new())
122 }
123 fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
124 Ok(crate::git::CommitInfo {
125 sha: "abc123".to_string(),
126 message: "test".to_string(),
127 author_name: "test".to_string(),
128 author_email: "test@test.com".to_string(),
129 timestamp: "2025-01-01T00:00:00Z".to_string(),
130 parent_shas: vec![],
131 })
132 }
133 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
134 Ok("abc123".to_string())
135 }
136 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
137 Ok(None)
138 }
139 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
140 Ok(())
141 }
142 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
143 Ok(self.file_log.clone())
144 }
145 fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
146 Ok(self.annotated_commits.clone())
147 }
148 }
149
150 fn make_v2_with_sentiments(
151 commit: &str,
152 timestamp: &str,
153 sentiments: Vec<v2::Sentiment>,
154 ) -> String {
155 let ann = v2::Annotation {
156 schema: "chronicle/v2".to_string(),
157 commit: commit.to_string(),
158 timestamp: timestamp.to_string(),
159 narrative: v2::Narrative {
160 summary: "Test summary".to_string(),
161 motivation: None,
162 rejected_alternatives: vec![],
163 follow_up: None,
164 files_changed: vec!["src/main.rs".to_string()],
165 sentiments,
166 },
167 decisions: vec![],
168 markers: vec![],
169 effort: None,
170 provenance: v2::Provenance {
171 source: v2::ProvenanceSource::Live,
172 author: None,
173 derived_from: vec![],
174 notes: None,
175 },
176 };
177 serde_json::to_string(&ann).unwrap()
178 }
179
180 #[test]
181 fn test_sentiments_collected_from_annotations() {
182 let note = make_v2_with_sentiments(
183 "commit1",
184 "2025-01-01T00:00:00Z",
185 vec![
186 v2::Sentiment {
187 feeling: "confidence".to_string(),
188 detail: "This approach is well-tested".to_string(),
189 },
190 v2::Sentiment {
191 feeling: "worry".to_string(),
192 detail: "Performance might degrade under load".to_string(),
193 },
194 ],
195 );
196
197 let mut notes = std::collections::HashMap::new();
198 notes.insert("commit1".to_string(), note);
199
200 let git = MockGitOps {
201 file_log: vec![],
202 annotated_commits: vec!["commit1".to_string()],
203 notes,
204 };
205
206 let result = query_sentiments(&git, &SentimentsQuery { file: None }).unwrap();
207 assert_eq!(result.schema, "chronicle-sentiments/v1");
208 assert_eq!(result.sentiments.len(), 2);
209 assert_eq!(result.sentiments[0].feeling, "confidence");
210 assert_eq!(result.sentiments[1].feeling, "worry");
211 assert_eq!(result.sentiments[0].summary, "Test summary");
212 }
213
214 #[test]
215 fn test_sentiments_filtered_by_file() {
216 let note = make_v2_with_sentiments(
217 "commit1",
218 "2025-01-01T00:00:00Z",
219 vec![v2::Sentiment {
220 feeling: "curiosity".to_string(),
221 detail: "Interesting edge case".to_string(),
222 }],
223 );
224
225 let mut notes = std::collections::HashMap::new();
226 notes.insert("commit1".to_string(), note);
227
228 let git = MockGitOps {
229 file_log: vec!["commit1".to_string()],
230 annotated_commits: vec![],
231 notes,
232 };
233
234 let result = query_sentiments(
235 &git,
236 &SentimentsQuery {
237 file: Some("src/main.rs".to_string()),
238 },
239 )
240 .unwrap();
241 assert_eq!(result.sentiments.len(), 1);
242 assert_eq!(result.sentiments[0].feeling, "confidence"); }
244
245 #[test]
246 fn test_sentiments_empty_when_no_annotations() {
247 let git = MockGitOps {
248 file_log: vec!["commit1".to_string()],
249 annotated_commits: vec![],
250 notes: std::collections::HashMap::new(),
251 };
252
253 let result = query_sentiments(
254 &git,
255 &SentimentsQuery {
256 file: Some("src/main.rs".to_string()),
257 },
258 )
259 .unwrap();
260 assert!(result.sentiments.is_empty());
261 }
262
263 #[test]
264 fn test_sentiments_newest_first() {
265 let note1 = make_v2_with_sentiments(
266 "commit1",
267 "2025-01-01T00:00:00Z",
268 vec![v2::Sentiment {
269 feeling: "doubt".to_string(),
270 detail: "Not sure about this".to_string(),
271 }],
272 );
273 let note2 = make_v2_with_sentiments(
274 "commit2",
275 "2025-01-02T00:00:00Z",
276 vec![v2::Sentiment {
277 feeling: "confidence".to_string(),
278 detail: "This works well".to_string(),
279 }],
280 );
281
282 let mut notes = std::collections::HashMap::new();
283 notes.insert("commit1".to_string(), note1);
284 notes.insert("commit2".to_string(), note2);
285
286 let git = MockGitOps {
287 file_log: vec![],
288 annotated_commits: vec!["commit1".to_string(), "commit2".to_string()],
289 notes,
290 };
291
292 let result = query_sentiments(&git, &SentimentsQuery { file: None }).unwrap();
293 assert_eq!(result.sentiments.len(), 2);
294 assert_eq!(result.sentiments[0].feeling, "confidence");
295 assert_eq!(result.sentiments[0].timestamp, "2025-01-02T00:00:00Z");
296 assert_eq!(result.sentiments[1].feeling, "uncertainty"); }
298
299 #[test]
300 fn test_sentiments_output_serializable() {
301 let output = SentimentsOutput {
302 schema: "chronicle-sentiments/v1".to_string(),
303 sentiments: vec![SentimentEntry {
304 feeling: "worry".to_string(),
305 detail: "Edge case not covered".to_string(),
306 commit: "abc123".to_string(),
307 timestamp: "2025-01-01T00:00:00Z".to_string(),
308 summary: "Added error handling".to_string(),
309 }],
310 };
311
312 let json = serde_json::to_string(&output).unwrap();
313 assert!(json.contains("chronicle-sentiments/v1"));
314 assert!(json.contains("worry"));
315 }
316}