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            objects::object::VisibilityTier::Public,
235        );
236        annotation.status = status;
237        annotation
238    }
239
240    #[test]
241    fn status_and_tier_labels() {
242        assert_eq!(annotation_status_label(AnnotationStatus::Active), "active");
243        assert_eq!(
244            annotation_status_label(AnnotationStatus::Superseded),
245            "superseded"
246        );
247        assert_eq!(
248            suggestion_tier_token(&ContextSuggestionTier::Medium),
249            "medium"
250        );
251        assert_eq!(suggestion_tier_token(&ContextSuggestionTier::High), "high");
252        assert_eq!(
253            suggestion_tier_human_label(&ContextSuggestionTier::Medium),
254            "may benefit"
255        );
256        assert_eq!(
257            suggestion_tier_human_label(&ContextSuggestionTier::High),
258            "recommended"
259        );
260    }
261
262    #[test]
263    fn list_filters_status_scope_and_tag() {
264        let active = sample_annotation(
265            AnnotationScope::File,
266            vec!["a".into()],
267            AnnotationStatus::Active,
268        );
269        let superseded = sample_annotation(
270            AnnotationScope::File,
271            vec!["a".into()],
272            AnnotationStatus::Superseded,
273        );
274        let tagged = sample_annotation(
275            AnnotationScope::Lines(1, 2),
276            vec!["hot".into()],
277            AnnotationStatus::Active,
278        );
279
280        assert!(annotation_passes_filters(&active, None, None, false));
281        assert!(!annotation_passes_filters(&superseded, None, None, false));
282        assert!(annotation_passes_filters(&superseded, None, None, true));
283
284        assert!(!annotation_passes_filters(
285            &active,
286            Some(&AnnotationScope::Lines(1, 2)),
287            None,
288            false
289        ));
290        assert!(annotation_passes_filters(
291            &tagged,
292            Some(&AnnotationScope::Lines(1, 2)),
293            Some("hot"),
294            false
295        ));
296        assert!(!annotation_passes_filters(
297            &tagged,
298            None,
299            Some("cold"),
300            false
301        ));
302
303        let pool = [active.clone(), superseded, tagged];
304        let filtered = filter_annotations(&pool, None, Some("a"), false);
305        assert_eq!(filtered.len(), 1);
306        assert_eq!(filtered[0].annotation_id, active.annotation_id);
307        assert_eq!(count_active_annotations(&[active.clone(), active]), 2);
308    }
309
310    #[test]
311    fn content_and_rm_plans() {
312        assert!(plan_annotation_content_source(true, false).is_ok());
313        assert!(plan_annotation_content_source(false, true).is_ok());
314        assert_eq!(
315            plan_annotation_content_source(false, false),
316            Err(ContextContentPlanError::Required)
317        );
318        assert_eq!(
319            ContextContentPlanError::Required.kind(),
320            "context_content_required"
321        );
322
323        assert!(plan_context_rm(true, false).is_ok());
324        assert!(plan_context_rm(false, true).is_ok());
325        assert_eq!(
326            plan_context_rm(false, false),
327            Err(ContextRmPlanError::ScopeRequired)
328        );
329        assert_eq!(
330            ContextRmPlanError::ScopeRequired.kind(),
331            "context_remove_scope_required"
332        );
333    }
334
335    #[test]
336    fn supersede_and_edit_rules() {
337        assert!(supersede_reuses_original_target(None, None));
338        assert!(!supersede_reuses_original_target(Some("p"), None));
339        assert!(!supersede_reuses_original_target(None, Some("s")));
340        assert!(supersede_reuses_original_scope(None));
341        assert!(!supersede_reuses_original_scope(Some("file")));
342
343        assert_eq!(
344            next_annotation_tags(&["keep".into()], vec![]),
345            vec!["keep".to_string()]
346        );
347        assert_eq!(
348            next_annotation_tags(&["keep".into()], vec!["new".into()]),
349            vec!["new".to_string()]
350        );
351    }
352
353    #[test]
354    fn audit_duplicate_count_and_keys() {
355        assert_eq!(audit_duplicate_count([1, 1, 2, 3]), 2);
356        assert_eq!(audit_duplicate_count(std::iter::empty::<u32>()), 0);
357
358        let file = ContextTarget::file("src/a.rs").expect("file target");
359        let ann = sample_annotation(AnnotationScope::File, vec![], AnnotationStatus::Active);
360        assert_eq!(audit_target_key(&file), "src/a.rs");
361        assert_eq!(
362            audit_staleness_key(&file, &ann),
363            format!("src/a.rs:{}", ann.scope)
364        );
365        let (kind, label) = context_target_kind_and_label(&file);
366        assert_eq!(kind, "file");
367        assert_eq!(label, "src/a.rs");
368    }
369}