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