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(Some("seed".into()), None)
146 .expect("snapshot seed");
147 let target = ContextTarget::file(path).expect("file target");
148 let blob = ContextBlob::new(vec![Annotation::new(
149 AnnotationScope::File,
150 AnnotationKind::Invariant,
151 content.to_string(),
152 Vec::new(),
153 "test@example.com".to_string(),
154 1_700_000_000,
155 None,
156 Some(state.state_id),
157 )]);
158 let root = repo
159 .set_context_blob(None, &target, &blob)
160 .expect("store context blob");
161 repo.put_state_attachment(&StateAttachment {
162 state_id: state.state_id,
163 body: StateAttachmentBody::Context(root),
164 attribution: Attribution::human(Principal::new("test", "test@example.com")),
165 created_at: chrono::Utc::now(),
166 supersedes: None,
167 })
168 .expect("attach context");
169 state
170 }
171
172 fn report_with(paths: &[&str]) -> DiffReport {
173 let changes = paths
174 .iter()
175 .map(|path| FileChange {
176 path: (*path).to_string(),
177 kind: "modified".to_string(),
178 ..FileChange::default()
179 })
180 .collect();
181 DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None)
182 }
183
184 fn annotation_content(report: &DiffReport, path: &str) -> Option<String> {
185 report.context.as_ref().and_then(|entries| {
186 entries.iter().find_map(|entry| {
187 (entry.path == path)
188 .then(|| entry.annotations.first().map(|a| a.content.clone()))
189 .flatten()
190 })
191 })
192 }
193
194 #[test]
195 fn context_set_attachment_rides_diff_for_changed_path() {
196 let temp = TempDir::new().expect("tempdir");
197 let repo = Repository::init_default(temp.path()).expect("init");
198 let state = annotate_state(&repo, "lib.rs", "must stay lowercase");
199 let mut report = report_with(&["lib.rs"]);
200
201 attach_show_context(&repo, &mut report, &state, &[]).expect("attach");
202
203 assert_eq!(
204 annotation_content(&report, "lib.rs").as_deref(),
205 Some("must stay lowercase")
206 );
207 }
208
209 #[test]
210 fn context_set_attachment_rides_clean_diff_when_unanchored() {
211 let temp = TempDir::new().expect("tempdir");
212 let repo = Repository::init_default(temp.path()).expect("init");
213 let state = annotate_state(&repo, "lib.rs", "visible without a file change");
214 let mut report = report_with(&[]);
215
216 attach_show_context(&repo, &mut report, &state, &[]).expect("attach");
217
218 assert_eq!(
219 annotation_content(&report, "lib.rs").as_deref(),
220 Some("visible without a file change")
221 );
222 }
223
224 #[test]
225 fn path_filter_on_clean_tree_looks_up_requested_paths() {
226 let temp = TempDir::new().expect("tempdir");
227 let repo = Repository::init_default(temp.path()).expect("init");
228 let state = annotate_state(&repo, "lib.rs", "requested path still rides");
229 let mut matching = report_with(&[]);
230 attach_show_context(&repo, &mut matching, &state, &["lib.rs".to_string()])
231 .expect("attach matching filter");
232 assert_eq!(
233 annotation_content(&matching, "lib.rs").as_deref(),
234 Some("requested path still rides")
235 );
236
237 let mut other = report_with(&[]);
238 attach_show_context(&repo, &mut other, &state, &["other.rs".to_string()])
239 .expect("attach other filter");
240 assert!(
241 other
242 .context
243 .as_ref()
244 .is_none_or(|entries| entries.is_empty()),
245 "unrelated path filter must stay quiet"
246 );
247 }
248
249 #[test]
250 fn path_filter_does_not_dump_unrelated_annotations() {
251 let temp = TempDir::new().expect("tempdir");
252 let repo = Repository::init_default(temp.path()).expect("init");
253 let state = annotate_state(&repo, "lib.rs", "not this path");
254 let mut report = report_with(&[]);
255
256 attach_show_context(&repo, &mut report, &state, &["other.rs".to_string()]).expect("attach");
257
258 assert!(
259 report
260 .context
261 .as_ref()
262 .is_none_or(|entries| entries.is_empty()),
263 "filtered empty change set must not list every annotation"
264 );
265 }
266
267 #[test]
268 fn summarize_context_truncates_on_char_boundary_not_byte_index() {
269 let first_line = format!("{}中中", "a".repeat(83));
270 assert!(first_line.len() > 88);
271 assert!(!first_line.is_char_boundary(85));
272 let summary = summarize_context(&format!("{first_line}\nsecond line"));
273 assert_eq!(summary, first_line);
274 }
275
276 #[test]
277 fn summarize_context_char_cap_truncates_multibyte_line() {
278 let first_line = format!("{}中中中", "a".repeat(86));
279 assert!(first_line.chars().count() > 88);
280 let summary = summarize_context(&first_line);
281 let expected = format!("{}...", "a".repeat(85));
282 assert_eq!(summary, expected);
283 }
284
285 #[test]
286 fn summarize_context_ascii_truncation_unchanged() {
287 let line = "b".repeat(90);
288 let summary = summarize_context(&line);
289 assert_eq!(summary, format!("{}...", "b".repeat(85)));
290 }
291}