Skip to main content

agm_core/loader/
profile.rs

1//! Load profile resolution and filter expression evaluation.
2//!
3//! Implements the filter mini-language used in `load_profiles` entries and
4//! the built-in `debug` profile.
5
6use std::collections::HashSet;
7
8use crate::graph::build::build_graph;
9use crate::graph::query::transitive_deps;
10use crate::model::execution::ExecutionStatus;
11use crate::model::file::AgmFile;
12use crate::model::node::Node;
13
14use super::filter::filter_node;
15use super::mode::LoadMode;
16
17// ---------------------------------------------------------------------------
18// LoadError
19// ---------------------------------------------------------------------------
20
21/// Errors that can occur during profile-based loading.
22#[derive(Debug, Clone, PartialEq, thiserror::Error)]
23pub enum LoadError {
24    #[error("unknown load profile: {name:?}")]
25    UnknownProfile { name: String },
26    #[error("no load profiles defined in file header")]
27    NoProfilesDefined,
28    #[error("default_load profile {name:?} not found in load_profiles")]
29    DefaultProfileNotFound { name: String },
30}
31
32// ---------------------------------------------------------------------------
33// FilterExpr
34// ---------------------------------------------------------------------------
35
36/// A single filter predicate in a profile filter expression.
37#[derive(Debug, Clone, PartialEq)]
38pub(crate) enum FilterExpr {
39    Wildcard,
40    PriorityIn(Vec<String>),
41    TypeIn(Vec<String>),
42    ExecutionStatusIn(Vec<String>),
43    TagsIn(Vec<String>),
44    CodeIsPresent,
45    Unrecognized(String),
46}
47
48// ---------------------------------------------------------------------------
49// FilterSet
50// ---------------------------------------------------------------------------
51
52/// A parsed set of filter expressions along with any parse-time warnings.
53#[derive(Debug, Clone, PartialEq)]
54pub(crate) struct FilterSet {
55    pub exprs: Vec<FilterExpr>,
56    pub warnings: Vec<String>,
57}
58
59// ---------------------------------------------------------------------------
60// parse_filter
61// ---------------------------------------------------------------------------
62
63/// Parses a filter string into a [`FilterSet`].
64///
65/// Clauses are separated by " AND " (case-insensitive). Unrecognized clauses
66/// are collected as warnings rather than errors.
67pub(crate) fn parse_filter(filter: &str) -> FilterSet {
68    let clauses = split_on_and(filter);
69    let mut exprs = Vec::new();
70    let mut warnings = Vec::new();
71
72    for clause in clauses {
73        let clause = clause.trim();
74        if clause.eq_ignore_ascii_case("*") || clause.eq_ignore_ascii_case("wildcard") {
75            exprs.push(FilterExpr::Wildcard);
76        } else if clause.eq_ignore_ascii_case("code is present")
77            || clause.eq_ignore_ascii_case("code_is_present")
78        {
79            exprs.push(FilterExpr::CodeIsPresent);
80        } else if let Some(expr) = try_parse_field_in(clause) {
81            exprs.push(expr);
82        } else {
83            warnings.push(format!("unrecognized filter clause: {clause:?}"));
84            exprs.push(FilterExpr::Unrecognized(clause.to_owned()));
85        }
86    }
87
88    FilterSet { exprs, warnings }
89}
90
91// ---------------------------------------------------------------------------
92// split_on_and
93// ---------------------------------------------------------------------------
94
95/// Splits `s` on " AND " (case-insensitive) and returns the parts.
96pub(crate) fn split_on_and(s: &str) -> Vec<&str> {
97    // We cannot use str::split with a pattern for case-insensitive matching,
98    // so we scan manually.
99    let sep = " and ";
100    let lower = s.to_lowercase();
101    let mut parts = Vec::new();
102    let mut start = 0usize;
103
104    let bytes = lower.as_bytes();
105    let sep_bytes = sep.as_bytes();
106    let sep_len = sep_bytes.len();
107
108    let mut i = 0usize;
109    while i + sep_len <= bytes.len() {
110        if bytes[i..i + sep_len].eq_ignore_ascii_case(sep_bytes) {
111            parts.push(&s[start..i]);
112            start = i + sep_len;
113            i = start;
114        } else {
115            i += 1;
116        }
117    }
118    parts.push(&s[start..]);
119    parts
120}
121
122// ---------------------------------------------------------------------------
123// try_parse_field_in
124// ---------------------------------------------------------------------------
125
126/// Attempts to parse a `field in [val1, val2, ...]` clause.
127///
128/// Returns `Some(FilterExpr)` for known fields (`priority`, `type`,
129/// `execution_status`, `tags`). Returns `None` for unrecognized patterns.
130pub(crate) fn try_parse_field_in(clause: &str) -> Option<FilterExpr> {
131    // Expected format: `<field> in [<val1>, <val2>, ...]` (case-insensitive keyword)
132    let lower = clause.to_lowercase();
133    let in_pos = lower.find(" in [")?;
134    let field = clause[..in_pos].trim().to_lowercase();
135
136    let after_in = clause[in_pos + 5..].trim(); // skip " in ["
137    let closing = after_in.rfind(']')?;
138    let values_str = &after_in[..closing];
139
140    let values: Vec<String> = values_str
141        .split(',')
142        .map(|v| v.trim().to_lowercase())
143        .filter(|v| !v.is_empty())
144        .collect();
145
146    match field.as_str() {
147        "priority" => Some(FilterExpr::PriorityIn(values)),
148        "type" => Some(FilterExpr::TypeIn(values)),
149        "execution_status" => Some(FilterExpr::ExecutionStatusIn(values)),
150        "tags" => Some(FilterExpr::TagsIn(values)),
151        _ => None,
152    }
153}
154
155// ---------------------------------------------------------------------------
156// matches_filter
157// ---------------------------------------------------------------------------
158
159/// Returns `true` if `node` matches all expressions in `filter_set`.
160///
161/// - [`FilterExpr::Wildcard`] always matches.
162/// - [`FilterExpr::Unrecognized`] passes through (evaluates to `true`).
163/// - [`FilterExpr::TagsIn`] matches if ANY listed tag is in `node.tags`.
164/// - All other expressions must match for the node to be included.
165pub(crate) fn matches_filter(node: &Node, filter_set: &FilterSet) -> bool {
166    filter_set.exprs.iter().all(|expr| match expr {
167        FilterExpr::Wildcard => true,
168        FilterExpr::Unrecognized(_) => true,
169        FilterExpr::CodeIsPresent => node.code.is_some() || node.code_blocks.is_some(),
170        FilterExpr::PriorityIn(values) => {
171            let Some(p) = &node.priority else {
172                return false;
173            };
174            values.contains(&p.to_string().to_lowercase())
175        }
176        FilterExpr::TypeIn(values) => values.contains(&node.node_type.to_string().to_lowercase()),
177        FilterExpr::ExecutionStatusIn(values) => {
178            let Some(s) = &node.execution_status else {
179                return false;
180            };
181            values.contains(&s.to_string().to_lowercase())
182        }
183        FilterExpr::TagsIn(values) => {
184            let Some(tags) = &node.tags else { return false };
185            values.iter().any(|v| tags.contains(v))
186        }
187    })
188}
189
190// ---------------------------------------------------------------------------
191// resolve_and_apply  (Sub-task 4)
192// ---------------------------------------------------------------------------
193
194/// Resolves the named profile (or the file's `default_load`) and applies it.
195///
196/// Steps:
197/// 1. If `profile_name` is `None`, use `header.default_load`. If there is no
198///    `default_load`, return a clone of the full file unchanged.
199/// 2. The built-in `"debug"` profile is handled by [`apply_debug_profile`].
200/// 3. Otherwise, look up the profile in `header.load_profiles` and apply
201///    its filter expression, keeping matched nodes in Operational mode.
202pub fn resolve_and_apply(file: &AgmFile, profile_name: Option<&str>) -> Result<AgmFile, LoadError> {
203    // Step 1: determine effective profile name.
204    let effective_name: String = match profile_name {
205        Some(name) => name.to_owned(),
206        None => match &file.header.default_load {
207            Some(dl) => dl.clone(),
208            // No explicit request and no default → return file unchanged (Full).
209            None => return Ok(file.clone()),
210        },
211    };
212
213    // Step 2: built-in debug profile.
214    if effective_name.eq_ignore_ascii_case("debug") {
215        return Ok(apply_debug_profile(file));
216    }
217
218    // Step 3: look up in load_profiles.
219    let profiles = file
220        .header
221        .load_profiles
222        .as_ref()
223        .ok_or(LoadError::NoProfilesDefined)?;
224
225    let profile = profiles.get(&effective_name).ok_or_else(|| {
226        // Distinguish between a bad default_load vs an explicitly unknown name.
227        if profile_name.is_none() {
228            // The effective name came from default_load.
229            LoadError::DefaultProfileNotFound {
230                name: effective_name.clone(),
231            }
232        } else {
233            LoadError::UnknownProfile {
234                name: effective_name.clone(),
235            }
236        }
237    })?;
238
239    // Parse and evaluate the filter.
240    let filter_set = parse_filter(&profile.filter);
241    if !filter_set.warnings.is_empty() {
242        for w in &filter_set.warnings {
243            eprintln!("agm loader warning: {w}");
244        }
245    }
246
247    let nodes: Vec<Node> = file
248        .nodes
249        .iter()
250        .filter(|n| matches_filter(n, &filter_set))
251        .map(|n| filter_node(n, LoadMode::Operational))
252        .collect();
253
254    Ok(AgmFile {
255        header: file.header.clone(),
256        nodes,
257    })
258}
259
260// ---------------------------------------------------------------------------
261// apply_debug_profile  (Sub-task 4)
262// ---------------------------------------------------------------------------
263
264/// Applies the built-in `debug` profile.
265///
266/// Selects nodes with `execution_status` of `Failed` or `Blocked`, then
267/// adds all of their transitive dependencies. All selected nodes are returned
268/// in Executable mode.
269#[must_use]
270pub fn apply_debug_profile(file: &AgmFile) -> AgmFile {
271    let graph = build_graph(file);
272
273    // Collect failed/blocked node IDs.
274    let primary: Vec<String> = file
275        .nodes
276        .iter()
277        .filter(|n| {
278            matches!(
279                &n.execution_status,
280                Some(ExecutionStatus::Failed) | Some(ExecutionStatus::Blocked)
281            )
282        })
283        .map(|n| n.id.clone())
284        .collect();
285
286    // Collect transitive deps of each primary node.
287    let mut selected: HashSet<String> = primary.iter().cloned().collect();
288    for id in &primary {
289        let deps = transitive_deps(&graph, id);
290        selected.extend(deps);
291    }
292
293    let nodes: Vec<Node> = file
294        .nodes
295        .iter()
296        .filter(|n| selected.contains(&n.id))
297        .map(|n| filter_node(n, LoadMode::Executable))
298        .collect();
299
300    AgmFile {
301        header: file.header.clone(),
302        nodes,
303    }
304}
305
306// ---------------------------------------------------------------------------
307// Tests
308// ---------------------------------------------------------------------------
309
310#[cfg(test)]
311mod tests {
312    use std::collections::BTreeMap;
313
314    use crate::model::code::{CodeAction, CodeBlock};
315    use crate::model::execution::ExecutionStatus;
316    use crate::model::fields::{NodeType, Priority, Span};
317    use crate::model::file::{AgmFile, Header, LoadProfile};
318    use crate::model::node::Node;
319
320    use super::*;
321
322    // -----------------------------------------------------------------------
323    // Test helpers
324    // -----------------------------------------------------------------------
325
326    fn minimal_header() -> Header {
327        Header {
328            agm: "1.0".to_owned(),
329            package: "test.pkg".to_owned(),
330            version: "0.1.0".to_owned(),
331            title: None,
332            owner: None,
333            imports: None,
334            default_load: None,
335            description: None,
336            tags: None,
337            status: None,
338            load_profiles: None,
339            target_runtime: None,
340        }
341    }
342
343    fn make_node(id: &str) -> Node {
344        Node {
345            id: id.to_owned(),
346            node_type: NodeType::Facts,
347            summary: format!("node {id}"),
348            priority: None,
349            stability: None,
350            confidence: None,
351            status: None,
352            depends: None,
353            related_to: None,
354            replaces: None,
355            conflicts: None,
356            see_also: None,
357            items: None,
358            steps: None,
359            fields: None,
360            input: None,
361            output: None,
362            detail: None,
363            rationale: None,
364            tradeoffs: None,
365            resolution: None,
366            examples: None,
367            notes: None,
368            code: None,
369            code_blocks: None,
370            verify: None,
371            agent_context: None,
372            target: None,
373            execution_status: None,
374            executed_by: None,
375            executed_at: None,
376            execution_log: None,
377            retry_count: None,
378            parallel_groups: None,
379            memory: None,
380            scope: None,
381            applies_when: None,
382            valid_from: None,
383            valid_until: None,
384            tags: None,
385            aliases: None,
386            keywords: None,
387            extra_fields: BTreeMap::new(),
388            span: Span::new(1, 1),
389        }
390    }
391
392    fn make_file(nodes: Vec<Node>) -> AgmFile {
393        AgmFile {
394            header: minimal_header(),
395            nodes,
396        }
397    }
398
399    // -----------------------------------------------------------------------
400    // parse_filter tests
401    // -----------------------------------------------------------------------
402
403    #[test]
404    fn test_parse_filter_wildcard_returns_wildcard_expr() {
405        let fs = parse_filter("*");
406        assert_eq!(fs.exprs, vec![FilterExpr::Wildcard]);
407        assert!(fs.warnings.is_empty());
408    }
409
410    #[test]
411    fn test_parse_filter_priority_in_returns_priority_expr() {
412        let fs = parse_filter("priority in [critical, high]");
413        assert_eq!(
414            fs.exprs,
415            vec![FilterExpr::PriorityIn(vec![
416                "critical".to_owned(),
417                "high".to_owned()
418            ])]
419        );
420        assert!(fs.warnings.is_empty());
421    }
422
423    #[test]
424    fn test_parse_filter_type_in_returns_type_expr() {
425        let fs = parse_filter("type in [workflow, rules]");
426        assert_eq!(
427            fs.exprs,
428            vec![FilterExpr::TypeIn(vec![
429                "workflow".to_owned(),
430                "rules".to_owned()
431            ])]
432        );
433    }
434
435    #[test]
436    fn test_parse_filter_execution_status_in_returns_status_expr() {
437        let fs = parse_filter("execution_status in [failed, blocked]");
438        assert_eq!(
439            fs.exprs,
440            vec![FilterExpr::ExecutionStatusIn(vec![
441                "failed".to_owned(),
442                "blocked".to_owned()
443            ])]
444        );
445    }
446
447    #[test]
448    fn test_parse_filter_and_conjunction_parses_multiple_exprs() {
449        let fs = parse_filter("priority in [critical] AND type in [workflow]");
450        assert_eq!(fs.exprs.len(), 2);
451        assert!(matches!(&fs.exprs[0], FilterExpr::PriorityIn(_)));
452        assert!(matches!(&fs.exprs[1], FilterExpr::TypeIn(_)));
453    }
454
455    #[test]
456    fn test_parse_filter_and_is_case_insensitive() {
457        let fs = parse_filter("priority in [critical] and type in [workflow]");
458        assert_eq!(fs.exprs.len(), 2);
459    }
460
461    #[test]
462    fn test_parse_filter_unrecognized_clause_produces_warning() {
463        let fs = parse_filter("is_experimental");
464        assert_eq!(fs.exprs.len(), 1);
465        assert!(matches!(&fs.exprs[0], FilterExpr::Unrecognized(_)));
466        assert!(!fs.warnings.is_empty());
467    }
468
469    // -----------------------------------------------------------------------
470    // matches_filter tests
471    // -----------------------------------------------------------------------
472
473    #[test]
474    fn test_matches_filter_wildcard_always_matches() {
475        let node = make_node("n");
476        let fs = parse_filter("*");
477        assert!(matches_filter(&node, &fs));
478    }
479
480    #[test]
481    fn test_matches_filter_priority_in_matches_node_with_matching_priority() {
482        let mut node = make_node("n");
483        node.priority = Some(Priority::Critical);
484        let fs = parse_filter("priority in [critical]");
485        assert!(matches_filter(&node, &fs));
486    }
487
488    #[test]
489    fn test_matches_filter_priority_in_rejects_node_with_wrong_priority() {
490        let mut node = make_node("n");
491        node.priority = Some(Priority::Low);
492        let fs = parse_filter("priority in [critical]");
493        assert!(!matches_filter(&node, &fs));
494    }
495
496    #[test]
497    fn test_matches_filter_type_in_matches_correct_type() {
498        let mut node = make_node("n");
499        node.node_type = NodeType::Workflow;
500        let fs = parse_filter("type in [workflow]");
501        assert!(matches_filter(&node, &fs));
502    }
503
504    #[test]
505    fn test_matches_filter_code_is_present_matches_node_with_code() {
506        let mut node = make_node("n");
507        node.code = Some(CodeBlock {
508            lang: None,
509            target: None,
510            action: CodeAction::Full,
511            body: "echo hi".to_owned(),
512            anchor: None,
513            old: None,
514        });
515        let fs = parse_filter("code is present");
516        assert!(matches_filter(&node, &fs));
517    }
518
519    #[test]
520    fn test_matches_filter_tags_in_matches_if_any_tag_present() {
521        let mut node = make_node("n");
522        node.tags = Some(vec!["auth".to_owned(), "api".to_owned()]);
523        let fs = parse_filter("tags in [auth]");
524        assert!(matches_filter(&node, &fs));
525    }
526
527    #[test]
528    fn test_matches_filter_conjunction_requires_all_exprs() {
529        let mut node = make_node("n");
530        node.priority = Some(Priority::Critical);
531        node.node_type = NodeType::Rules; // doesn't match "workflow"
532        let fs = parse_filter("priority in [critical] AND type in [workflow]");
533        assert!(!matches_filter(&node, &fs));
534    }
535
536    #[test]
537    fn test_matches_filter_unrecognized_passes_through() {
538        let node = make_node("n");
539        let fs = FilterSet {
540            exprs: vec![FilterExpr::Unrecognized("whatever".to_owned())],
541            warnings: vec![],
542        };
543        assert!(matches_filter(&node, &fs));
544    }
545
546    // -----------------------------------------------------------------------
547    // resolve_and_apply tests
548    // -----------------------------------------------------------------------
549
550    #[test]
551    fn test_resolve_and_apply_unknown_profile_returns_error() {
552        let file = make_file(vec![]);
553        let result = resolve_and_apply(&file, Some("nonexistent"));
554        assert!(matches!(result, Err(LoadError::NoProfilesDefined)));
555    }
556
557    #[test]
558    fn test_resolve_and_apply_no_profiles_defined_returns_error() {
559        let mut file = make_file(vec![]);
560        // Has a default_load but no load_profiles map.
561        file.header.default_load = Some("ops".to_owned());
562        let result = resolve_and_apply(&file, None);
563        assert!(matches!(result, Err(LoadError::NoProfilesDefined)));
564    }
565
566    #[test]
567    fn test_resolve_and_apply_default_load_not_found_returns_error() {
568        let mut file = make_file(vec![]);
569        file.header.default_load = Some("missing_profile".to_owned());
570        let mut profiles = BTreeMap::new();
571        profiles.insert(
572            "other".to_owned(),
573            LoadProfile {
574                filter: "*".to_owned(),
575                estimated_tokens: None,
576            },
577        );
578        file.header.load_profiles = Some(profiles);
579        let result = resolve_and_apply(&file, None);
580        assert!(matches!(
581            result,
582            Err(LoadError::DefaultProfileNotFound { .. })
583        ));
584    }
585
586    #[test]
587    fn test_resolve_and_apply_no_default_returns_full_file() {
588        let node = make_node("a");
589        let file = make_file(vec![node]);
590        let result = resolve_and_apply(&file, None).unwrap();
591        assert_eq!(result.nodes.len(), 1);
592    }
593
594    #[test]
595    fn test_resolve_and_apply_filter_by_priority_keeps_matching_nodes() {
596        let mut crit = make_node("crit");
597        crit.priority = Some(Priority::Critical);
598        let low = make_node("low");
599        // low.priority stays None
600
601        let mut profiles = BTreeMap::new();
602        profiles.insert(
603            "critical_only".to_owned(),
604            LoadProfile {
605                filter: "priority in [critical]".to_owned(),
606                estimated_tokens: None,
607            },
608        );
609        let mut file = make_file(vec![crit, low]);
610        file.header.load_profiles = Some(profiles);
611
612        let result = resolve_and_apply(&file, Some("critical_only")).unwrap();
613        assert_eq!(result.nodes.len(), 1);
614        assert_eq!(result.nodes[0].id, "crit");
615    }
616
617    #[test]
618    fn test_resolve_and_apply_filter_by_type_keeps_matching_nodes() {
619        let mut wf = make_node("wf");
620        wf.node_type = NodeType::Workflow;
621        let facts = make_node("facts");
622
623        let mut profiles = BTreeMap::new();
624        profiles.insert(
625            "workflows".to_owned(),
626            LoadProfile {
627                filter: "type in [workflow]".to_owned(),
628                estimated_tokens: None,
629            },
630        );
631        let mut file = make_file(vec![wf, facts]);
632        file.header.load_profiles = Some(profiles);
633
634        let result = resolve_and_apply(&file, Some("workflows")).unwrap();
635        assert_eq!(result.nodes.len(), 1);
636        assert_eq!(result.nodes[0].id, "wf");
637    }
638
639    #[test]
640    fn test_resolve_and_apply_debug_selects_failed_and_blocked() {
641        let mut failed = make_node("failed_node");
642        failed.execution_status = Some(ExecutionStatus::Failed);
643        let mut blocked = make_node("blocked_node");
644        blocked.execution_status = Some(ExecutionStatus::Blocked);
645        let ok = make_node("ok_node");
646
647        let file = make_file(vec![failed, blocked, ok]);
648        let result = resolve_and_apply(&file, Some("debug")).unwrap();
649        let ids: Vec<&str> = result.nodes.iter().map(|n| n.id.as_str()).collect();
650        assert!(ids.contains(&"failed_node"));
651        assert!(ids.contains(&"blocked_node"));
652        assert!(!ids.contains(&"ok_node"));
653    }
654
655    #[test]
656    fn test_resolve_and_apply_debug_includes_transitive_deps() {
657        let mut failed = make_node("task.failed");
658        failed.execution_status = Some(ExecutionStatus::Failed);
659        failed.depends = Some(vec!["task.dep".to_owned()]);
660        let dep = make_node("task.dep");
661        let unrelated = make_node("task.unrelated");
662
663        let file = make_file(vec![failed, dep, unrelated]);
664        let result = resolve_and_apply(&file, Some("debug")).unwrap();
665        let ids: Vec<&str> = result.nodes.iter().map(|n| n.id.as_str()).collect();
666        assert!(ids.contains(&"task.failed"));
667        assert!(ids.contains(&"task.dep"));
668        assert!(!ids.contains(&"task.unrelated"));
669    }
670
671    #[test]
672    fn test_resolve_and_apply_debug_excludes_unrelated_nodes() {
673        let mut failed = make_node("a");
674        failed.execution_status = Some(ExecutionStatus::Failed);
675        let unrelated = make_node("b"); // no execution status, not a dep
676
677        let file = make_file(vec![failed, unrelated]);
678        let result = resolve_and_apply(&file, Some("debug")).unwrap();
679        assert_eq!(result.nodes.len(), 1);
680        assert_eq!(result.nodes[0].id, "a");
681    }
682
683    #[test]
684    fn test_resolve_and_apply_debug_uses_executable_mode() {
685        let mut failed = make_node("task.a");
686        failed.execution_status = Some(ExecutionStatus::Failed);
687        failed.execution_log = Some("some log".to_owned());
688        // `detail` is Full-only and should be absent.
689        failed.detail = Some("detail text".to_owned());
690
691        let file = make_file(vec![failed]);
692        let result = resolve_and_apply(&file, Some("debug")).unwrap();
693        assert_eq!(result.nodes.len(), 1);
694        // Executable field included
695        assert!(result.nodes[0].execution_log.is_some());
696        // Full-only field excluded
697        assert!(result.nodes[0].detail.is_none());
698    }
699}