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