1use 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#[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#[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#[derive(Debug, Clone, PartialEq)]
54pub(crate) struct FilterSet {
55 pub exprs: Vec<FilterExpr>,
56 pub warnings: Vec<String>,
57}
58
59pub(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
91pub(crate) fn split_on_and(s: &str) -> Vec<&str> {
97 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
122pub(crate) fn try_parse_field_in(clause: &str) -> Option<FilterExpr> {
131 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(); 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
155pub(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
190pub fn resolve_and_apply(file: &AgmFile, profile_name: Option<&str>) -> Result<AgmFile, LoadError> {
203 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 None => return Ok(file.clone()),
210 },
211 };
212
213 if effective_name.eq_ignore_ascii_case("debug") {
215 return Ok(apply_debug_profile(file));
216 }
217
218 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 if profile_name.is_none() {
228 LoadError::DefaultProfileNotFound {
230 name: effective_name.clone(),
231 }
232 } else {
233 LoadError::UnknownProfile {
234 name: effective_name.clone(),
235 }
236 }
237 })?;
238
239 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#[must_use]
270pub fn apply_debug_profile(file: &AgmFile) -> AgmFile {
271 let graph = build_graph(file);
272
273 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 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#[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 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 #[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 #[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; 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 #[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 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 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"); 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 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 assert!(result.nodes[0].execution_log.is_some());
696 assert!(result.nodes[0].detail.is_none());
698 }
699}