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 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 #[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 #[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; 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 #[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 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 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"); 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 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 assert!(result.nodes[0].execution_log.is_some());
657 assert!(result.nodes[0].detail.is_none());
659 }
660}