Skip to main content

verbs/diff/
context.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Attach live context annotations to an existing [`DiffReport`].
3//!
4//! `context set` writes a Context state-attachment. `diff --context` must
5//! populate the existing report fields from that attachment — the same
6//! store `context get` reads — without a second view RPC.
7
8use 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
19/// Fill `report.context` and `report.broader_guidance` from one Context
20/// attachment snapshot.
21///
22/// Selection:
23/// - changed report paths (including a rename `old_path`) when present;
24/// - otherwise the requested `path_filters` on a clean / filtered-empty
25///   tree, so `diff --context -- lib.rs` still rides;
26/// - otherwise every active file annotation (unfiltered clean tree).
27pub 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
73/// HEAD / current-state used by worktree diffs.
74pub 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}