Skip to main content

chronicle/read/
sentiments.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema;
4
5/// Query parameters for sentiments lookup.
6#[derive(Debug, Clone)]
7pub struct SentimentsQuery {
8    pub file: Option<String>,
9}
10
11/// A sentiment entry extracted from wisdom entries.
12#[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/// Output of a sentiments query.
22#[derive(Debug, Clone, serde::Serialize)]
23pub struct SentimentsOutput {
24    pub schema: String,
25    pub sentiments: Vec<SentimentEntry>,
26}
27
28/// Collect sentiments from annotations across the repository.
29///
30/// In v3, sentiments are migrated into wisdom entries (gotcha for worry/unease,
31/// unfinished_thread for uncertainty/doubt, insight for others). This function
32/// reconstructs sentiment-like entries from wisdom entries by inferring the
33/// feeling from the category.
34///
35/// 1. Determine which commits to examine
36/// 2. For each commit, parse annotation via `parse_annotation`
37/// 3. Map wisdom entries back to sentiment-like entries
38/// 4. Return newest-first
39pub 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(&note) {
57            Ok(a) => a,
58            Err(e) => {
59                tracing::debug!("skipping malformed annotation for {sha}: {e}");
60                continue;
61            }
62        };
63
64        // In v3, sentiments were migrated into wisdom entries.
65        // Map wisdom categories back to feeling-like labels for display.
66        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    // Already newest-first from git log order, but sort to be sure
84    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"); // v2 "curiosity" -> migration -> Insight (default) -> reader -> "confidence"
243    }
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"); // v2 "doubt" -> migration -> UnfinishedThread -> reader -> "uncertainty"
297    }
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}