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            span: Span::new(1, 1),
349            ..Default::default()
350        }
351    }
352
353    fn make_file(nodes: Vec<Node>) -> AgmFile {
354        AgmFile {
355            header: minimal_header(),
356            nodes,
357        }
358    }
359
360    // -----------------------------------------------------------------------
361    // parse_filter tests
362    // -----------------------------------------------------------------------
363
364    #[test]
365    fn test_parse_filter_wildcard_returns_wildcard_expr() {
366        let fs = parse_filter("*");
367        assert_eq!(fs.exprs, vec![FilterExpr::Wildcard]);
368        assert!(fs.warnings.is_empty());
369    }
370
371    #[test]
372    fn test_parse_filter_priority_in_returns_priority_expr() {
373        let fs = parse_filter("priority in [critical, high]");
374        assert_eq!(
375            fs.exprs,
376            vec![FilterExpr::PriorityIn(vec![
377                "critical".to_owned(),
378                "high".to_owned()
379            ])]
380        );
381        assert!(fs.warnings.is_empty());
382    }
383
384    #[test]
385    fn test_parse_filter_type_in_returns_type_expr() {
386        let fs = parse_filter("type in [workflow, rules]");
387        assert_eq!(
388            fs.exprs,
389            vec![FilterExpr::TypeIn(vec![
390                "workflow".to_owned(),
391                "rules".to_owned()
392            ])]
393        );
394    }
395
396    #[test]
397    fn test_parse_filter_execution_status_in_returns_status_expr() {
398        let fs = parse_filter("execution_status in [failed, blocked]");
399        assert_eq!(
400            fs.exprs,
401            vec![FilterExpr::ExecutionStatusIn(vec![
402                "failed".to_owned(),
403                "blocked".to_owned()
404            ])]
405        );
406    }
407
408    #[test]
409    fn test_parse_filter_and_conjunction_parses_multiple_exprs() {
410        let fs = parse_filter("priority in [critical] AND type in [workflow]");
411        assert_eq!(fs.exprs.len(), 2);
412        assert!(matches!(&fs.exprs[0], FilterExpr::PriorityIn(_)));
413        assert!(matches!(&fs.exprs[1], FilterExpr::TypeIn(_)));
414    }
415
416    #[test]
417    fn test_parse_filter_and_is_case_insensitive() {
418        let fs = parse_filter("priority in [critical] and type in [workflow]");
419        assert_eq!(fs.exprs.len(), 2);
420    }
421
422    #[test]
423    fn test_parse_filter_unrecognized_clause_produces_warning() {
424        let fs = parse_filter("is_experimental");
425        assert_eq!(fs.exprs.len(), 1);
426        assert!(matches!(&fs.exprs[0], FilterExpr::Unrecognized(_)));
427        assert!(!fs.warnings.is_empty());
428    }
429
430    // -----------------------------------------------------------------------
431    // matches_filter tests
432    // -----------------------------------------------------------------------
433
434    #[test]
435    fn test_matches_filter_wildcard_always_matches() {
436        let node = make_node("n");
437        let fs = parse_filter("*");
438        assert!(matches_filter(&node, &fs));
439    }
440
441    #[test]
442    fn test_matches_filter_priority_in_matches_node_with_matching_priority() {
443        let mut node = make_node("n");
444        node.priority = Some(Priority::Critical);
445        let fs = parse_filter("priority in [critical]");
446        assert!(matches_filter(&node, &fs));
447    }
448
449    #[test]
450    fn test_matches_filter_priority_in_rejects_node_with_wrong_priority() {
451        let mut node = make_node("n");
452        node.priority = Some(Priority::Low);
453        let fs = parse_filter("priority in [critical]");
454        assert!(!matches_filter(&node, &fs));
455    }
456
457    #[test]
458    fn test_matches_filter_type_in_matches_correct_type() {
459        let mut node = make_node("n");
460        node.node_type = NodeType::Workflow;
461        let fs = parse_filter("type in [workflow]");
462        assert!(matches_filter(&node, &fs));
463    }
464
465    #[test]
466    fn test_matches_filter_code_is_present_matches_node_with_code() {
467        let mut node = make_node("n");
468        node.code = Some(CodeBlock {
469            lang: None,
470            target: None,
471            action: CodeAction::Full,
472            body: "echo hi".to_owned(),
473            anchor: None,
474            old: None,
475        });
476        let fs = parse_filter("code is present");
477        assert!(matches_filter(&node, &fs));
478    }
479
480    #[test]
481    fn test_matches_filter_tags_in_matches_if_any_tag_present() {
482        let mut node = make_node("n");
483        node.tags = Some(vec!["auth".to_owned(), "api".to_owned()]);
484        let fs = parse_filter("tags in [auth]");
485        assert!(matches_filter(&node, &fs));
486    }
487
488    #[test]
489    fn test_matches_filter_conjunction_requires_all_exprs() {
490        let mut node = make_node("n");
491        node.priority = Some(Priority::Critical);
492        node.node_type = NodeType::Rules; // doesn't match "workflow"
493        let fs = parse_filter("priority in [critical] AND type in [workflow]");
494        assert!(!matches_filter(&node, &fs));
495    }
496
497    #[test]
498    fn test_matches_filter_unrecognized_passes_through() {
499        let node = make_node("n");
500        let fs = FilterSet {
501            exprs: vec![FilterExpr::Unrecognized("whatever".to_owned())],
502            warnings: vec![],
503        };
504        assert!(matches_filter(&node, &fs));
505    }
506
507    // -----------------------------------------------------------------------
508    // resolve_and_apply tests
509    // -----------------------------------------------------------------------
510
511    #[test]
512    fn test_resolve_and_apply_unknown_profile_returns_error() {
513        let file = make_file(vec![]);
514        let result = resolve_and_apply(&file, Some("nonexistent"));
515        assert!(matches!(result, Err(LoadError::NoProfilesDefined)));
516    }
517
518    #[test]
519    fn test_resolve_and_apply_no_profiles_defined_returns_error() {
520        let mut file = make_file(vec![]);
521        // Has a default_load but no load_profiles map.
522        file.header.default_load = Some("ops".to_owned());
523        let result = resolve_and_apply(&file, None);
524        assert!(matches!(result, Err(LoadError::NoProfilesDefined)));
525    }
526
527    #[test]
528    fn test_resolve_and_apply_default_load_not_found_returns_error() {
529        let mut file = make_file(vec![]);
530        file.header.default_load = Some("missing_profile".to_owned());
531        let mut profiles = BTreeMap::new();
532        profiles.insert(
533            "other".to_owned(),
534            LoadProfile {
535                filter: "*".to_owned(),
536                estimated_tokens: None,
537            },
538        );
539        file.header.load_profiles = Some(profiles);
540        let result = resolve_and_apply(&file, None);
541        assert!(matches!(
542            result,
543            Err(LoadError::DefaultProfileNotFound { .. })
544        ));
545    }
546
547    #[test]
548    fn test_resolve_and_apply_no_default_returns_full_file() {
549        let node = make_node("a");
550        let file = make_file(vec![node]);
551        let result = resolve_and_apply(&file, None).unwrap();
552        assert_eq!(result.nodes.len(), 1);
553    }
554
555    #[test]
556    fn test_resolve_and_apply_filter_by_priority_keeps_matching_nodes() {
557        let mut crit = make_node("crit");
558        crit.priority = Some(Priority::Critical);
559        let low = make_node("low");
560        // low.priority stays None
561
562        let mut profiles = BTreeMap::new();
563        profiles.insert(
564            "critical_only".to_owned(),
565            LoadProfile {
566                filter: "priority in [critical]".to_owned(),
567                estimated_tokens: None,
568            },
569        );
570        let mut file = make_file(vec![crit, low]);
571        file.header.load_profiles = Some(profiles);
572
573        let result = resolve_and_apply(&file, Some("critical_only")).unwrap();
574        assert_eq!(result.nodes.len(), 1);
575        assert_eq!(result.nodes[0].id, "crit");
576    }
577
578    #[test]
579    fn test_resolve_and_apply_filter_by_type_keeps_matching_nodes() {
580        let mut wf = make_node("wf");
581        wf.node_type = NodeType::Workflow;
582        let facts = make_node("facts");
583
584        let mut profiles = BTreeMap::new();
585        profiles.insert(
586            "workflows".to_owned(),
587            LoadProfile {
588                filter: "type in [workflow]".to_owned(),
589                estimated_tokens: None,
590            },
591        );
592        let mut file = make_file(vec![wf, facts]);
593        file.header.load_profiles = Some(profiles);
594
595        let result = resolve_and_apply(&file, Some("workflows")).unwrap();
596        assert_eq!(result.nodes.len(), 1);
597        assert_eq!(result.nodes[0].id, "wf");
598    }
599
600    #[test]
601    fn test_resolve_and_apply_debug_selects_failed_and_blocked() {
602        let mut failed = make_node("failed_node");
603        failed.execution_status = Some(ExecutionStatus::Failed);
604        let mut blocked = make_node("blocked_node");
605        blocked.execution_status = Some(ExecutionStatus::Blocked);
606        let ok = make_node("ok_node");
607
608        let file = make_file(vec![failed, blocked, ok]);
609        let result = resolve_and_apply(&file, Some("debug")).unwrap();
610        let ids: Vec<&str> = result.nodes.iter().map(|n| n.id.as_str()).collect();
611        assert!(ids.contains(&"failed_node"));
612        assert!(ids.contains(&"blocked_node"));
613        assert!(!ids.contains(&"ok_node"));
614    }
615
616    #[test]
617    fn test_resolve_and_apply_debug_includes_transitive_deps() {
618        let mut failed = make_node("task.failed");
619        failed.execution_status = Some(ExecutionStatus::Failed);
620        failed.depends = Some(vec!["task.dep".to_owned()]);
621        let dep = make_node("task.dep");
622        let unrelated = make_node("task.unrelated");
623
624        let file = make_file(vec![failed, dep, unrelated]);
625        let result = resolve_and_apply(&file, Some("debug")).unwrap();
626        let ids: Vec<&str> = result.nodes.iter().map(|n| n.id.as_str()).collect();
627        assert!(ids.contains(&"task.failed"));
628        assert!(ids.contains(&"task.dep"));
629        assert!(!ids.contains(&"task.unrelated"));
630    }
631
632    #[test]
633    fn test_resolve_and_apply_debug_excludes_unrelated_nodes() {
634        let mut failed = make_node("a");
635        failed.execution_status = Some(ExecutionStatus::Failed);
636        let unrelated = make_node("b"); // no execution status, not a dep
637
638        let file = make_file(vec![failed, unrelated]);
639        let result = resolve_and_apply(&file, Some("debug")).unwrap();
640        assert_eq!(result.nodes.len(), 1);
641        assert_eq!(result.nodes[0].id, "a");
642    }
643
644    #[test]
645    fn test_resolve_and_apply_debug_uses_executable_mode() {
646        let mut failed = make_node("task.a");
647        failed.execution_status = Some(ExecutionStatus::Failed);
648        failed.execution_log = Some("some log".to_owned());
649        // `detail` is Full-only and should be absent.
650        failed.detail = Some("detail text".to_owned());
651
652        let file = make_file(vec![failed]);
653        let result = resolve_and_apply(&file, Some("debug")).unwrap();
654        assert_eq!(result.nodes.len(), 1);
655        // Executable field included
656        assert!(result.nodes[0].execution_log.is_some());
657        // Full-only field excluded
658        assert!(result.nodes[0].detail.is_none());
659    }
660}