Skip to main content

verbs/
context_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure context query/mutate planning helpers (no store/repo I/O).
3//!
4//! RecoveryAdvice, ObjectStore, and worktree reads stay in the CLI.
5
6use objects::object::{
7    Annotation, AnnotationScope, AnnotationStatus, ContextSuggestionTier, ContextTarget,
8};
9
10// ---------------------------------------------------------------------------
11// Status / suggestion labels
12// ---------------------------------------------------------------------------
13
14/// Machine/human status token for an annotation lifecycle state.
15pub fn annotation_status_label(status: AnnotationStatus) -> &'static str {
16    match status {
17        AnnotationStatus::Active => "active",
18        AnnotationStatus::Superseded => "superseded",
19        AnnotationStatus::Deleted => "deleted",
20    }
21}
22
23/// Stable machine token for a suggestion tier (`medium` / `high`).
24pub fn suggestion_tier_token(tier: &ContextSuggestionTier) -> &'static str {
25    match tier {
26        ContextSuggestionTier::Medium => "medium",
27        ContextSuggestionTier::High => "high",
28    }
29}
30
31/// Human-facing suggestion tier phrase for text output.
32pub fn suggestion_tier_human_label(tier: &ContextSuggestionTier) -> &'static str {
33    match tier {
34        ContextSuggestionTier::Medium => "may benefit",
35        ContextSuggestionTier::High => "recommended",
36    }
37}
38
39// ---------------------------------------------------------------------------
40// Annotation list filters
41// ---------------------------------------------------------------------------
42
43/// Whether a single annotation passes list/get filters.
44///
45/// Scope must already be parsed by the caller (CLI maps parse errors to advice).
46pub fn annotation_passes_filters(
47    annotation: &Annotation,
48    scope_filter: Option<&AnnotationScope>,
49    tag_filter: Option<&str>,
50    include_superseded: bool,
51) -> bool {
52    if annotation.status == AnnotationStatus::Deleted
53        || (!include_superseded && annotation.status == AnnotationStatus::Superseded)
54    {
55        return false;
56    }
57    if let Some(scope) = scope_filter
58        && !annotation.scope.matches(scope)
59    {
60        return false;
61    }
62    if let Some(tag) = tag_filter {
63        let Some(current) = annotation.current_revision() else {
64            return false;
65        };
66        if !current.tags.iter().any(|candidate| candidate == tag) {
67            return false;
68        }
69    }
70    true
71}
72
73/// Filter annotations by optional scope/tag and superseded inclusion.
74pub fn filter_annotations<'a>(
75    annotations: &'a [Annotation],
76    scope_filter: Option<&AnnotationScope>,
77    tag_filter: Option<&str>,
78    include_superseded: bool,
79) -> Vec<&'a Annotation> {
80    annotations
81        .iter()
82        .filter(|annotation| {
83            annotation_passes_filters(annotation, scope_filter, tag_filter, include_superseded)
84        })
85        .collect()
86}
87
88/// Count annotations still in [`AnnotationStatus::Active`].
89pub fn count_active_annotations(annotations: &[Annotation]) -> usize {
90    annotations
91        .iter()
92        .filter(|annotation| annotation.status == AnnotationStatus::Active)
93        .count()
94}
95
96// ---------------------------------------------------------------------------
97// Target / audit pure keys
98// ---------------------------------------------------------------------------
99
100/// `(kind, label)` pair for a context target (stable machine kind tokens).
101pub fn context_target_kind_and_label(target: &ContextTarget) -> (&'static str, String) {
102    match target {
103        ContextTarget::File { path } => ("file", path.clone()),
104        ContextTarget::State { state_id } => ("state", state_id.to_string_full()),
105    }
106}
107
108/// Target key used when grouping audit signatures (path or full change id).
109pub fn audit_target_key(target: &ContextTarget) -> String {
110    match target {
111        ContextTarget::File { path } => path.clone(),
112        ContextTarget::State { state_id } => state_id.to_string_full(),
113    }
114}
115
116/// Staleness-map key matching `repo::staleness::check_context_staleness`.
117pub fn audit_staleness_key(target: &ContextTarget, annotation: &Annotation) -> String {
118    match target {
119        ContextTarget::File { path } => format!("{path}:{}", annotation.scope),
120        ContextTarget::State { state_id } => {
121            format!(
122                "state:{}:{}",
123                state_id.to_string_full(),
124                annotation.annotation_id
125            )
126        }
127    }
128}
129
130/// Count signature groups that appear more than once (duplicate annotations).
131pub fn audit_duplicate_count(signature_counts: impl IntoIterator<Item = u32>) -> u32 {
132    signature_counts
133        .into_iter()
134        .filter(|count| *count > 1)
135        .count() as u32
136}
137
138// ---------------------------------------------------------------------------
139// Mutate validation (empty body, rm selector, supersede rules)
140// ---------------------------------------------------------------------------
141
142/// Missing annotation body source (`-m` / `--file`).
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum ContextContentPlanError {
145    /// Neither message nor file was supplied.
146    Required,
147}
148
149impl ContextContentPlanError {
150    pub fn kind(self) -> &'static str {
151        match self {
152            Self::Required => "context_content_required",
153        }
154    }
155}
156
157/// Require a content source for set/edit/supersede.
158///
159/// Does not inspect body text emptiness — only whether a source flag was provided.
160pub fn plan_annotation_content_source(
161    has_message: bool,
162    has_file: bool,
163) -> Result<(), ContextContentPlanError> {
164    if has_message || has_file {
165        Ok(())
166    } else {
167        Err(ContextContentPlanError::Required)
168    }
169}
170
171/// Invalid `context rm` selector combinations.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum ContextRmPlanError {
174    /// Neither `--all` nor `--scope` was supplied.
175    ScopeRequired,
176}
177
178impl ContextRmPlanError {
179    pub fn kind(self) -> &'static str {
180        match self {
181            Self::ScopeRequired => "context_remove_scope_required",
182        }
183    }
184}
185
186/// Plan remove: when not removing all, a scope must be present.
187pub fn plan_context_rm(all: bool, scope_present: bool) -> Result<(), ContextRmPlanError> {
188    if !all && !scope_present {
189        Err(ContextRmPlanError::ScopeRequired)
190    } else {
191        Ok(())
192    }
193}
194
195/// Supersede keeps the original target when neither path nor state override is set.
196pub fn supersede_reuses_original_target(path: Option<&str>, state: Option<&str>) -> bool {
197    path.is_none() && state.is_none()
198}
199
200/// Supersede keeps the original scope when `--scope` is omitted.
201pub fn supersede_reuses_original_scope(scope: Option<&str>) -> bool {
202    scope.is_none()
203}
204
205/// Prefer non-empty override tags; otherwise keep the current revision's tags.
206pub fn next_annotation_tags(current: &[String], override_tags: Vec<String>) -> Vec<String> {
207    if override_tags.is_empty() {
208        current.to_vec()
209    } else {
210        override_tags
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use objects::object::{Annotation, AnnotationKind, AnnotationScope, AnnotationStatus};
217
218    use super::*;
219
220    fn sample_annotation(
221        scope: AnnotationScope,
222        tags: Vec<String>,
223        status: AnnotationStatus,
224    ) -> Annotation {
225        let mut annotation = Annotation::new(
226            scope,
227            AnnotationKind::Rationale,
228            "body".into(),
229            tags,
230            "Test <t@example.com>".into(),
231            0,
232            None,
233            None,
234        );
235        annotation.status = status;
236        annotation
237    }
238
239    #[test]
240    fn status_and_tier_labels() {
241        assert_eq!(annotation_status_label(AnnotationStatus::Active), "active");
242        assert_eq!(
243            annotation_status_label(AnnotationStatus::Superseded),
244            "superseded"
245        );
246        assert_eq!(
247            suggestion_tier_token(&ContextSuggestionTier::Medium),
248            "medium"
249        );
250        assert_eq!(suggestion_tier_token(&ContextSuggestionTier::High), "high");
251        assert_eq!(
252            suggestion_tier_human_label(&ContextSuggestionTier::Medium),
253            "may benefit"
254        );
255        assert_eq!(
256            suggestion_tier_human_label(&ContextSuggestionTier::High),
257            "recommended"
258        );
259    }
260
261    #[test]
262    fn list_filters_status_scope_and_tag() {
263        let active = sample_annotation(
264            AnnotationScope::File,
265            vec!["a".into()],
266            AnnotationStatus::Active,
267        );
268        let superseded = sample_annotation(
269            AnnotationScope::File,
270            vec!["a".into()],
271            AnnotationStatus::Superseded,
272        );
273        let tagged = sample_annotation(
274            AnnotationScope::Lines(1, 2),
275            vec!["hot".into()],
276            AnnotationStatus::Active,
277        );
278
279        assert!(annotation_passes_filters(&active, None, None, false));
280        assert!(!annotation_passes_filters(&superseded, None, None, false));
281        assert!(annotation_passes_filters(&superseded, None, None, true));
282
283        assert!(!annotation_passes_filters(
284            &active,
285            Some(&AnnotationScope::Lines(1, 2)),
286            None,
287            false
288        ));
289        assert!(annotation_passes_filters(
290            &tagged,
291            Some(&AnnotationScope::Lines(1, 2)),
292            Some("hot"),
293            false
294        ));
295        assert!(!annotation_passes_filters(
296            &tagged,
297            None,
298            Some("cold"),
299            false
300        ));
301
302        let pool = [active.clone(), superseded, tagged];
303        let filtered = filter_annotations(&pool, None, Some("a"), false);
304        assert_eq!(filtered.len(), 1);
305        assert_eq!(filtered[0].annotation_id, active.annotation_id);
306        assert_eq!(count_active_annotations(&[active.clone(), active]), 2);
307    }
308
309    #[test]
310    fn content_and_rm_plans() {
311        assert!(plan_annotation_content_source(true, false).is_ok());
312        assert!(plan_annotation_content_source(false, true).is_ok());
313        assert_eq!(
314            plan_annotation_content_source(false, false),
315            Err(ContextContentPlanError::Required)
316        );
317        assert_eq!(
318            ContextContentPlanError::Required.kind(),
319            "context_content_required"
320        );
321
322        assert!(plan_context_rm(true, false).is_ok());
323        assert!(plan_context_rm(false, true).is_ok());
324        assert_eq!(
325            plan_context_rm(false, false),
326            Err(ContextRmPlanError::ScopeRequired)
327        );
328        assert_eq!(
329            ContextRmPlanError::ScopeRequired.kind(),
330            "context_remove_scope_required"
331        );
332    }
333
334    #[test]
335    fn supersede_and_edit_rules() {
336        assert!(supersede_reuses_original_target(None, None));
337        assert!(!supersede_reuses_original_target(Some("p"), None));
338        assert!(!supersede_reuses_original_target(None, Some("s")));
339        assert!(supersede_reuses_original_scope(None));
340        assert!(!supersede_reuses_original_scope(Some("file")));
341
342        assert_eq!(
343            next_annotation_tags(&["keep".into()], vec![]),
344            vec!["keep".to_string()]
345        );
346        assert_eq!(
347            next_annotation_tags(&["keep".into()], vec!["new".into()]),
348            vec!["new".to_string()]
349        );
350    }
351
352    #[test]
353    fn audit_duplicate_count_and_keys() {
354        assert_eq!(audit_duplicate_count([1, 1, 2, 3]), 2);
355        assert_eq!(audit_duplicate_count(std::iter::empty::<u32>()), 0);
356
357        let file = ContextTarget::file("src/a.rs").expect("file target");
358        let ann = sample_annotation(AnnotationScope::File, vec![], AnnotationStatus::Active);
359        assert_eq!(audit_target_key(&file), "src/a.rs");
360        assert_eq!(
361            audit_staleness_key(&file, &ann),
362            format!("src/a.rs:{}", ann.scope)
363        );
364        let (kind, label) = context_target_kind_and_label(&file);
365        assert_eq!(kind, "file");
366        assert_eq!(label, "src/a.rs");
367    }
368}