1use anyhow::Result;
9use objects::{
10 object::{
11 Annotation, AnnotationStatus, ContentHash, ContextTarget, State, StateAttachmentBody,
12 },
13 store::ObjectStore,
14};
15use repo::{ChangedPathFilters, Repository, StateAttachmentKind};
16
17use super::types::{ContextSnippet, DiffReport, FileContextEntry};
18
19pub fn attach_show_context(
28 repo: &Repository,
29 report: &mut DiffReport,
30 state: &State,
31 path_filters: &[String],
32) -> Result<()> {
33 let Some(context_root) = context_root_for_state(repo, state)? else {
34 report.context = Some(Vec::new());
35 report.broader_guidance = Some(Vec::new());
36 return Ok(());
37 };
38
39 let mut change_paths: Vec<String> = report
40 .changes
41 .iter()
42 .flat_map(|change| std::iter::once(change.path.clone()).chain(change.old_path.clone()))
43 .collect();
44 change_paths.sort();
45 change_paths.dedup();
46 let filters = ChangedPathFilters::try_from_paths(path_filters)?;
47
48 let listed = repo.list_context_entries(&context_root, None)?;
49 let mut file_entries = Vec::new();
50 let mut broader_guidance = Vec::new();
51 for entry in listed {
52 match entry.target {
53 ContextTarget::File { path } => {
54 if !file_path_requested(&path, &change_paths, &filters) {
55 continue;
56 }
57 let annotations = active_snippets(&entry.blob.annotations);
58 if !annotations.is_empty() {
59 file_entries.push(FileContextEntry { path, annotations });
60 }
61 }
62 ContextTarget::State { state_id } if state_id == state.state_id => {
63 broader_guidance = active_snippets(&entry.blob.annotations);
64 }
65 ContextTarget::State { .. } => {}
66 }
67 }
68 report.context = Some(file_entries);
69 report.broader_guidance = Some(broader_guidance);
70 Ok(())
71}
72
73pub fn worktree_context_state(repo: &Repository) -> Result<Option<State>> {
75 if let Some(state) = repo.current_state()? {
76 return Ok(Some(state));
77 }
78 let Some(id) = repo.head()? else {
79 return Ok(None);
80 };
81 Ok(repo.store().get_state(&id)?)
82}
83
84pub(crate) fn summarize_context(content: &str) -> String {
85 let first_line = content
86 .lines()
87 .find(|line| !line.trim().is_empty())
88 .unwrap_or("");
89 let char_count = first_line.chars().count();
90 if char_count <= 88 {
91 first_line.to_string()
92 } else {
93 format!("{}...", first_line.chars().take(85).collect::<String>())
94 }
95}
96
97fn context_root_for_state(repo: &Repository, state: &State) -> Result<Option<ContentHash>> {
98 Ok(repo
99 .latest_state_attachment(&state.state_id, StateAttachmentKind::Context)?
100 .and_then(|attachment| match attachment.body {
101 StateAttachmentBody::Context(hash) => Some(hash),
102 _ => None,
103 }))
104}
105
106fn file_path_requested(path: &str, change_paths: &[String], filters: &ChangedPathFilters) -> bool {
107 if !change_paths.is_empty() {
108 return change_paths.iter().any(|changed| changed == path);
109 }
110 filters.is_empty() || filters.matches(path)
111}
112
113fn active_snippets(annotations: &[Annotation]) -> Vec<ContextSnippet> {
114 annotations
115 .iter()
116 .filter(|annotation| annotation.status == AnnotationStatus::Active)
117 .filter_map(|annotation| {
118 annotation
119 .current_revision()
120 .map(|revision| ContextSnippet {
121 annotation_id: annotation.annotation_id.clone(),
122 kind: revision.kind.to_string(),
123 content: summarize_context(&revision.content),
124 revision_count: annotation.revisions.len(),
125 })
126 })
127 .collect()
128}
129
130#[cfg(test)]
131mod tests {
132 use objects::object::{
133 Annotation, AnnotationKind, AnnotationScope, Attribution, ContextBlob, ContextTarget,
134 Principal, StateAttachment, StateAttachmentBody,
135 };
136 use repo::Repository;
137 use tempfile::TempDir;
138
139 use super::{attach_show_context, summarize_context};
140 use crate::diff::types::{DiffReport, FileChange};
141
142 fn annotate_state(repo: &Repository, path: &str, content: &str) -> objects::object::State {
143 std::fs::write(repo.root().join(path), "seed\n").expect("write seed file");
144 let state = repo
145 .snapshot_with_attribution(
146 Some("seed".into()),
147 None,
148 Attribution::human(Principal::new("Test", "test@example.com")),
149 )
150 .expect("snapshot seed");
151 let target = ContextTarget::file(path).expect("file target");
152 let blob = ContextBlob::new(vec![Annotation::new(
153 AnnotationScope::File,
154 AnnotationKind::Invariant,
155 content.to_string(),
156 Vec::new(),
157 "test@example.com".to_string(),
158 1_700_000_000,
159 None,
160 Some(state.state_id),
161 )]);
162 let root = repo
163 .set_context_blob(None, &target, &blob)
164 .expect("store context blob");
165 repo.put_state_attachment(&StateAttachment {
166 state_id: state.state_id,
167 body: StateAttachmentBody::Context(root),
168 attribution: Attribution::human(Principal::new("test", "test@example.com")),
169 created_at: chrono::Utc::now(),
170 supersedes: None,
171 })
172 .expect("attach context");
173 state
174 }
175
176 fn report_with(paths: &[&str]) -> DiffReport {
177 let changes = paths
178 .iter()
179 .map(|path| FileChange {
180 path: (*path).to_string(),
181 kind: "modified".to_string(),
182 ..FileChange::default()
183 })
184 .collect();
185 DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None)
186 }
187
188 fn annotation_content(report: &DiffReport, path: &str) -> Option<String> {
189 report.context.as_ref().and_then(|entries| {
190 entries.iter().find_map(|entry| {
191 (entry.path == path)
192 .then(|| entry.annotations.first().map(|a| a.content.clone()))
193 .flatten()
194 })
195 })
196 }
197
198 #[test]
199 fn context_set_attachment_rides_diff_for_changed_path() {
200 let temp = TempDir::new().expect("tempdir");
201 let repo = Repository::init_default(temp.path()).expect("init");
202 let state = annotate_state(&repo, "lib.rs", "must stay lowercase");
203 let mut report = report_with(&["lib.rs"]);
204
205 attach_show_context(&repo, &mut report, &state, &[]).expect("attach");
206
207 assert_eq!(
208 annotation_content(&report, "lib.rs").as_deref(),
209 Some("must stay lowercase")
210 );
211 }
212
213 #[test]
214 fn context_set_attachment_rides_clean_diff_when_unanchored() {
215 let temp = TempDir::new().expect("tempdir");
216 let repo = Repository::init_default(temp.path()).expect("init");
217 let state = annotate_state(&repo, "lib.rs", "visible without a file change");
218 let mut report = report_with(&[]);
219
220 attach_show_context(&repo, &mut report, &state, &[]).expect("attach");
221
222 assert_eq!(
223 annotation_content(&report, "lib.rs").as_deref(),
224 Some("visible without a file change")
225 );
226 }
227
228 #[test]
229 fn path_filter_on_clean_tree_looks_up_requested_paths() {
230 let temp = TempDir::new().expect("tempdir");
231 let repo = Repository::init_default(temp.path()).expect("init");
232 let state = annotate_state(&repo, "lib.rs", "requested path still rides");
233 let mut matching = report_with(&[]);
234 attach_show_context(&repo, &mut matching, &state, &["lib.rs".to_string()])
235 .expect("attach matching filter");
236 assert_eq!(
237 annotation_content(&matching, "lib.rs").as_deref(),
238 Some("requested path still rides")
239 );
240
241 let mut other = report_with(&[]);
242 attach_show_context(&repo, &mut other, &state, &["other.rs".to_string()])
243 .expect("attach other filter");
244 assert!(
245 other
246 .context
247 .as_ref()
248 .is_none_or(|entries| entries.is_empty()),
249 "unrelated path filter must stay quiet"
250 );
251 }
252
253 #[test]
254 fn path_filter_does_not_dump_unrelated_annotations() {
255 let temp = TempDir::new().expect("tempdir");
256 let repo = Repository::init_default(temp.path()).expect("init");
257 let state = annotate_state(&repo, "lib.rs", "not this path");
258 let mut report = report_with(&[]);
259
260 attach_show_context(&repo, &mut report, &state, &["other.rs".to_string()]).expect("attach");
261
262 assert!(
263 report
264 .context
265 .as_ref()
266 .is_none_or(|entries| entries.is_empty()),
267 "filtered empty change set must not list every annotation"
268 );
269 }
270
271 #[test]
272 fn summarize_context_truncates_on_char_boundary_not_byte_index() {
273 let first_line = format!("{}中中", "a".repeat(83));
274 assert!(first_line.len() > 88);
275 assert!(!first_line.is_char_boundary(85));
276 let summary = summarize_context(&format!("{first_line}\nsecond line"));
277 assert_eq!(summary, first_line);
278 }
279
280 #[test]
281 fn summarize_context_char_cap_truncates_multibyte_line() {
282 let first_line = format!("{}中中中", "a".repeat(86));
283 assert!(first_line.chars().count() > 88);
284 let summary = summarize_context(&first_line);
285 let expected = format!("{}...", "a".repeat(85));
286 assert_eq!(summary, expected);
287 }
288
289 #[test]
290 fn summarize_context_ascii_truncation_unchanged() {
291 let line = "b".repeat(90);
292 let summary = summarize_context(&line);
293 assert_eq!(summary, format!("{}...", "b".repeat(85)));
294 }
295}