Skip to main content

zig_core/workflow/
validate.rs

1use std::collections::{HashMap, HashSet};
2
3use regex::Regex;
4
5use crate::error::ZigError;
6use crate::workflow::model::{
7    FailurePolicy, StepCommand, StorageKind, VarType, Variable, Workflow,
8};
9
10/// Validate a parsed workflow for structural correctness.
11///
12/// Checks:
13/// - At least one step exists
14/// - Step names are unique
15/// - `depends_on` references exist
16/// - No dependency cycles
17/// - `next` references exist
18/// - Variable references in prompts refer to declared variables
19/// - `saves` variable names are declared
20/// - Condition variable references are declared
21pub fn validate(workflow: &Workflow) -> Result<(), Vec<ZigError>> {
22    let mut errors = Vec::new();
23
24    if workflow.steps.is_empty() {
25        errors.push(ZigError::Validation(
26            "workflow must have at least one step".into(),
27        ));
28        return Err(errors);
29    }
30
31    let step_names: HashSet<&str> = workflow.steps.iter().map(|s| s.name.as_str()).collect();
32    let var_names: HashSet<&str> = workflow.vars.keys().map(|k| k.as_str()).collect();
33    let role_names: HashSet<&str> = workflow.roles.keys().map(|k| k.as_str()).collect();
34    let storage_names: HashSet<&str> = workflow.storage.keys().map(|k| k.as_str()).collect();
35
36    // Storage-level checks — spec-internal consistency.
37    for (name, spec) in &workflow.storage {
38        if spec.path.trim().is_empty() {
39            errors.push(ZigError::Validation(format!(
40                "storage '{name}' has an empty path"
41            )));
42        }
43        if matches!(spec.kind, StorageKind::File) && !spec.files.is_empty() {
44            errors.push(ZigError::Validation(format!(
45                "storage '{name}' has type = \"file\" but also declares 'files' hints \
46                 (the 'files' subtable is only valid for type = \"folder\")"
47            )));
48        }
49        for file in &spec.files {
50            if file.name.contains('/') || file.name.contains('\\') {
51                errors.push(ZigError::Validation(format!(
52                    "storage '{name}' file hint '{}' must be a bare filename, not a path",
53                    file.name
54                )));
55            }
56        }
57    }
58
59    // Check unique step names
60    let mut seen_names = HashSet::new();
61    for step in &workflow.steps {
62        if !seen_names.insert(&step.name) {
63            errors.push(ZigError::Validation(format!(
64                "duplicate step name: '{}'",
65                step.name
66            )));
67        }
68    }
69
70    for step in &workflow.steps {
71        // Check depends_on references
72        for dep in &step.depends_on {
73            if !step_names.contains(dep.as_str()) {
74                errors.push(ZigError::Validation(format!(
75                    "step '{}' depends on unknown step '{dep}'",
76                    step.name
77                )));
78            }
79            if dep == &step.name {
80                errors.push(ZigError::Validation(format!(
81                    "step '{}' depends on itself",
82                    step.name
83                )));
84            }
85        }
86
87        // Check next references
88        if let Some(next) = &step.next {
89            if !step_names.contains(next.as_str()) {
90                errors.push(ZigError::Validation(format!(
91                    "step '{}' references unknown next step '{next}'",
92                    step.name
93                )));
94            }
95        }
96
97        // Check variable references in prompt
98        for var_ref in extract_var_refs(&step.prompt) {
99            if !var_names.contains(var_ref.as_str()) {
100                errors.push(ZigError::Validation(format!(
101                    "step '{}' prompt references unknown variable '${{{var_ref}}}'",
102                    step.name
103                )));
104            }
105        }
106
107        // Check variable references in system_prompt
108        if let Some(system_prompt) = &step.system_prompt {
109            for var_ref in extract_var_refs(system_prompt) {
110                if !var_names.contains(var_ref.as_str()) {
111                    errors.push(ZigError::Validation(format!(
112                        "step '{}' system_prompt references unknown variable '${{{var_ref}}}'",
113                        step.name
114                    )));
115                }
116            }
117        }
118
119        // Check storage scoping references
120        if let Some(scope) = &step.storage {
121            for name in scope {
122                if !storage_names.contains(name.as_str()) {
123                    errors.push(ZigError::Validation(format!(
124                        "step '{}' storage scope references unknown storage '{name}'",
125                        step.name
126                    )));
127                }
128            }
129        }
130
131        // Check role and system_prompt are mutually exclusive
132        if step.role.is_some() && step.system_prompt.is_some() {
133            errors.push(ZigError::Validation(format!(
134                "step '{}' sets both 'role' and 'system_prompt' (they are mutually exclusive)",
135                step.name
136            )));
137        }
138
139        // Check role references
140        if let Some(role_ref) = &step.role {
141            let var_refs = extract_var_refs(role_ref);
142            if var_refs.is_empty() {
143                // Static role reference — must exist in [roles]
144                if !role_names.contains(role_ref.as_str()) {
145                    errors.push(ZigError::Validation(format!(
146                        "step '{}' role references unknown role '{role_ref}'",
147                        step.name
148                    )));
149                }
150            } else {
151                // Dynamic role reference — validate variable refs
152                for var_ref in var_refs {
153                    if !var_names.contains(var_ref.as_str()) {
154                        errors.push(ZigError::Validation(format!(
155                            "step '{}' role references unknown variable '${{{var_ref}}}'",
156                            step.name
157                        )));
158                    }
159                }
160            }
161        }
162
163        // Check saves reference declared variables
164        for var_name in step.saves.keys() {
165            if !var_names.contains(var_name.as_str()) {
166                errors.push(ZigError::Validation(format!(
167                    "step '{}' saves to unknown variable '{var_name}'",
168                    step.name
169                )));
170            }
171        }
172
173        // Check condition variable references
174        if let Some(cond) = &step.condition {
175            for var_ref in extract_condition_vars(cond) {
176                if !var_names.contains(var_ref.as_str()) && !step_names.contains(var_ref.as_str()) {
177                    errors.push(ZigError::Validation(format!(
178                        "step '{}' condition references unknown variable '{var_ref}'",
179                        step.name
180                    )));
181                }
182            }
183        }
184
185        // Check retry_model requires on_failure = "retry"
186        if step.retry_model.is_some() && step.on_failure.as_ref() != Some(&FailurePolicy::Retry) {
187            errors.push(ZigError::Validation(format!(
188                "step '{}' sets retry_model but on_failure is not 'retry'",
189                step.name
190            )));
191        }
192
193        // Check mcp_config requires claude provider (or no provider specified).
194        // The effective provider considers both step-level and workflow-level defaults.
195        if step.mcp_config.is_some() {
196            let effective_provider = step
197                .provider
198                .as_ref()
199                .or(workflow.workflow.provider.as_ref());
200            if let Some(provider) = effective_provider {
201                if provider != "claude" {
202                    errors.push(ZigError::Validation(format!(
203                        "step '{}' sets mcp_config but provider is '{}' \
204                         (mcp_config is only supported with the claude provider)",
205                        step.name, provider
206                    )));
207                }
208            }
209        }
210
211        // Check output format is a valid value
212        if let Some(ref output) = step.output {
213            let valid_formats = ["text", "json", "json-pretty", "stream-json", "native-json"];
214            if !valid_formats.contains(&output.as_str()) {
215                errors.push(ZigError::Validation(format!(
216                    "step '{}' has invalid output format '{}' \
217                     (must be one of: text, json, json-pretty, stream-json, native-json)",
218                    step.name, output
219                )));
220            }
221        }
222
223        // Check review-only fields require command = "review"
224        let is_review = step.command.as_ref() == Some(&StepCommand::Review);
225        if !is_review {
226            for (field, set) in [("uncommitted", step.uncommitted)] {
227                if set {
228                    errors.push(ZigError::Validation(format!(
229                        "step '{}' sets '{}' but command is not 'review'",
230                        step.name, field
231                    )));
232                }
233            }
234            for (field, set) in [
235                ("base", step.base.is_some()),
236                ("commit", step.commit.is_some()),
237                ("title", step.title.is_some()),
238            ] {
239                if set {
240                    errors.push(ZigError::Validation(format!(
241                        "step '{}' sets '{}' but command is not 'review'",
242                        step.name, field
243                    )));
244                }
245            }
246        }
247
248        // Check plan-only fields require command = "plan"
249        let is_plan = step.command.as_ref() == Some(&StepCommand::Plan);
250        if !is_plan {
251            for (field, set) in [
252                ("plan_output", step.plan_output.is_some()),
253                ("instructions", step.instructions.is_some()),
254            ] {
255                if set {
256                    errors.push(ZigError::Validation(format!(
257                        "step '{}' sets '{}' but command is not 'plan'",
258                        step.name, field
259                    )));
260                }
261            }
262        }
263
264        // Check pipe/collect/summary require depends_on
265        if let Some(ref cmd) = step.command {
266            match cmd {
267                StepCommand::Pipe | StepCommand::Collect | StepCommand::Summary => {
268                    if step.depends_on.is_empty() {
269                        errors.push(ZigError::Validation(format!(
270                            "step '{}' uses command '{}' but has no depends_on \
271                             (pipe/collect/summary operate on prior session outputs)",
272                            step.name,
273                            match cmd {
274                                StepCommand::Pipe => "pipe",
275                                StepCommand::Collect => "collect",
276                                StepCommand::Summary => "summary",
277                                _ => unreachable!(),
278                            }
279                        )));
280                    }
281                }
282                _ => {}
283            }
284        }
285
286        // Interactive-step guards. An interactive step hands the TTY to the
287        // agent for the duration of the step; anything that requires captured
288        // output, replay, or sibling execution is incompatible.
289        if step.interactive {
290            if step.race_group.is_some() {
291                errors.push(ZigError::Validation(format!(
292                    "step '{}' is interactive and also sets race_group \
293                     (interactive steps cannot race — one human, one tty)",
294                    step.name
295                )));
296            }
297            if !step.saves.is_empty() {
298                errors.push(ZigError::Validation(format!(
299                    "step '{}' is interactive but also sets saves \
300                     (interactive steps stream directly to the terminal — \
301                     nothing is captured to extract from)",
302                    step.name
303                )));
304            }
305            if step.on_failure.as_ref() == Some(&FailurePolicy::Retry) {
306                errors.push(ZigError::Validation(format!(
307                    "step '{}' is interactive but also sets on_failure = \"retry\" \
308                     (interactive steps cannot be retried — human input can't be replayed)",
309                    step.name
310                )));
311            }
312            if step.max_retries.is_some() {
313                errors.push(ZigError::Validation(format!(
314                    "step '{}' is interactive but also sets max_retries \
315                     (interactive steps cannot be retried)",
316                    step.name
317                )));
318            }
319            if step.json {
320                errors.push(ZigError::Validation(format!(
321                    "step '{}' is interactive but also sets json = true \
322                     (json mode forces non-interactive execution in zag)",
323                    step.name
324                )));
325            }
326            if step.output.is_some() {
327                errors.push(ZigError::Validation(format!(
328                    "step '{}' is interactive but also sets output \
329                     (an output format forces non-interactive execution in zag)",
330                    step.name
331                )));
332            }
333            if step.json_schema.is_some() {
334                errors.push(ZigError::Validation(format!(
335                    "step '{}' is interactive but also sets json_schema \
336                     (json_schema implies non-interactive execution)",
337                    step.name
338                )));
339            }
340            if let Some(ref cmd) = step.command {
341                let cmd_name = match cmd {
342                    StepCommand::Review => "review",
343                    StepCommand::Plan => "plan",
344                    StepCommand::Pipe => "pipe",
345                    StepCommand::Collect => "collect",
346                    StepCommand::Summary => "summary",
347                };
348                errors.push(ZigError::Validation(format!(
349                    "step '{}' is interactive but also sets command = \"{cmd_name}\" \
350                     (only the default run command has an interactive TUI)",
351                    step.name
352                )));
353            }
354        }
355    }
356
357    // Check role definitions
358    for (role_name, role) in &workflow.roles {
359        // system_prompt and system_prompt_file are mutually exclusive
360        if role.system_prompt.is_some() && role.system_prompt_file.is_some() {
361            errors.push(ZigError::Validation(format!(
362                "role '{role_name}' sets both 'system_prompt' and 'system_prompt_file' \
363                 (they are mutually exclusive)"
364            )));
365        }
366
367        // Validate ${var} references in role system_prompt
368        if let Some(ref sp) = role.system_prompt {
369            for var_ref in extract_var_refs(sp) {
370                if !var_names.contains(var_ref.as_str()) {
371                    errors.push(ZigError::Validation(format!(
372                        "role '{role_name}' system_prompt references unknown variable \
373                         '${{{var_ref}}}'"
374                    )));
375                }
376            }
377        }
378    }
379
380    // Check race_group: steps in the same group must not depend on each other
381    let mut race_groups: HashMap<&str, Vec<&str>> = HashMap::new();
382    for step in &workflow.steps {
383        if let Some(ref group) = step.race_group {
384            race_groups
385                .entry(group.as_str())
386                .or_default()
387                .push(step.name.as_str());
388        }
389    }
390    for (group, members) in &race_groups {
391        let member_set: HashSet<&str> = members.iter().copied().collect();
392        for step in &workflow.steps {
393            if step.race_group.as_deref() == Some(*group) {
394                for dep in &step.depends_on {
395                    if member_set.contains(dep.as_str()) {
396                        errors.push(ZigError::Validation(format!(
397                            "step '{}' depends on '{}' but both are in race_group '{}' \
398                             (race members must not depend on each other)",
399                            step.name, dep, group
400                        )));
401                    }
402                }
403            }
404        }
405    }
406
407    // Check variable constraints
408    validate_var_constraints(&workflow.vars, &mut errors);
409
410    // Check for dependency cycles
411    let has_cycle = if let Some(cycle) = detect_cycle(&workflow.steps) {
412        errors.push(ZigError::Validation(format!(
413            "dependency cycle detected: {}",
414            cycle.join(" -> ")
415        )));
416        true
417    } else {
418        false
419    };
420
421    // Interactive tier-isolation: any tier containing an interactive step
422    // must contain exactly one step. Only run this once we know the DAG is
423    // acyclic, since topological_sort errors on cycles.
424    if !has_cycle && workflow.steps.iter().any(|s| s.interactive) {
425        if let Ok(tiers) = crate::run::topological_sort(&workflow.steps) {
426            for tier in &tiers {
427                let has_interactive = tier.iter().any(|s| s.interactive);
428                if has_interactive && tier.len() > 1 {
429                    let names: Vec<&str> = tier.iter().map(|s| s.name.as_str()).collect();
430                    let interactive_names: Vec<&str> = tier
431                        .iter()
432                        .filter(|s| s.interactive)
433                        .map(|s| s.name.as_str())
434                        .collect();
435                    errors.push(ZigError::Validation(format!(
436                        "interactive step(s) [{}] share a tier with sibling(s) [{}] \
437                         (an interactive step must be alone in its tier — add a \
438                         depends_on chain so siblings run before or after it)",
439                        interactive_names.join(", "),
440                        names.join(", ")
441                    )));
442                }
443            }
444        }
445    }
446
447    if errors.is_empty() {
448        Ok(())
449    } else {
450        Err(errors)
451    }
452}
453
454/// Validate variable constraint declarations for structural correctness.
455fn validate_var_constraints(vars: &HashMap<String, Variable>, errors: &mut Vec<ZigError>) {
456    let mut prompt_bound_count = 0;
457
458    for (name, var) in vars {
459        // default and default_file are mutually exclusive
460        if var.default.is_some() && var.default_file.is_some() {
461            errors.push(ZigError::Validation(format!(
462                "variable '{name}' sets both 'default' and 'default_file' \
463                 (they are mutually exclusive)"
464            )));
465        }
466
467        // Validate `from` field
468        if let Some(ref from) = var.from {
469            if from != "prompt" {
470                errors.push(ZigError::Validation(format!(
471                    "variable '{name}' has unsupported from value '{from}' (only 'prompt' is supported)"
472                )));
473            } else {
474                prompt_bound_count += 1;
475            }
476        }
477
478        // String-only constraints on non-string types
479        if var.var_type != VarType::String {
480            if var.min_length.is_some() {
481                errors.push(ZigError::Validation(format!(
482                    "variable '{name}' has min_length but type is '{}' (only valid for 'string')",
483                    var.var_type
484                )));
485            }
486            if var.max_length.is_some() {
487                errors.push(ZigError::Validation(format!(
488                    "variable '{name}' has max_length but type is '{}' (only valid for 'string')",
489                    var.var_type
490                )));
491            }
492            if var.pattern.is_some() {
493                errors.push(ZigError::Validation(format!(
494                    "variable '{name}' has pattern but type is '{}' (only valid for 'string')",
495                    var.var_type
496                )));
497            }
498        }
499
500        // Number-only constraints on non-number types
501        if var.var_type != VarType::Number {
502            if var.min.is_some() {
503                errors.push(ZigError::Validation(format!(
504                    "variable '{name}' has min but type is '{}' (only valid for 'number')",
505                    var.var_type
506                )));
507            }
508            if var.max.is_some() {
509                errors.push(ZigError::Validation(format!(
510                    "variable '{name}' has max but type is '{}' (only valid for 'number')",
511                    var.var_type
512                )));
513            }
514        }
515
516        // Range consistency
517        if let (Some(min_len), Some(max_len)) = (var.min_length, var.max_length) {
518            if min_len > max_len {
519                errors.push(ZigError::Validation(format!(
520                    "variable '{name}' has min_length ({min_len}) greater than max_length ({max_len})"
521                )));
522            }
523        }
524        if let (Some(min), Some(max)) = (var.min, var.max) {
525            if min > max {
526                errors.push(ZigError::Validation(format!(
527                    "variable '{name}' has min ({min}) greater than max ({max})"
528                )));
529            }
530        }
531
532        // Validate pattern compiles
533        if let Some(ref pattern) = var.pattern {
534            if Regex::new(pattern).is_err() {
535                errors.push(ZigError::Validation(format!(
536                    "variable '{name}' has invalid regex pattern: '{pattern}'"
537                )));
538            }
539        }
540
541        // Validate allowed_values type compatibility
542        if let Some(ref allowed) = var.allowed_values {
543            for val in allowed {
544                let ok = match var.var_type {
545                    VarType::String => val.is_str(),
546                    VarType::Number => val.is_integer() || val.is_float(),
547                    VarType::Bool => matches!(val, toml::Value::Boolean(_)),
548                    VarType::Json => true,
549                };
550                if !ok {
551                    errors.push(ZigError::Validation(format!(
552                        "variable '{name}' has allowed_values entry {val} incompatible with type '{}'",
553                        var.var_type
554                    )));
555                }
556            }
557        }
558
559        // Validate default satisfies constraints
560        if let Some(ref default) = var.default {
561            let default_str = toml_value_to_string(default);
562            let constraint_errors = check_value_constraints(name, &default_str, var);
563            for msg in constraint_errors {
564                errors.push(ZigError::Validation(format!(
565                    "variable '{name}' default value violates constraint: {msg}"
566                )));
567            }
568        }
569    }
570
571    if prompt_bound_count > 1 {
572        errors.push(ZigError::Validation(
573            "multiple variables have from = \"prompt\" (only one is allowed)".into(),
574        ));
575    }
576}
577
578/// Convert a TOML value to its string representation for constraint checking.
579fn toml_value_to_string(val: &toml::Value) -> String {
580    match val {
581        toml::Value::String(s) => s.clone(),
582        toml::Value::Integer(n) => n.to_string(),
583        toml::Value::Float(f) => f.to_string(),
584        toml::Value::Boolean(b) => b.to_string(),
585        other => other.to_string(),
586    }
587}
588
589/// Check a single value against a variable's constraints.
590/// Returns a list of human-readable violation messages (empty if valid).
591fn check_value_constraints(name: &str, value: &str, var: &Variable) -> Vec<String> {
592    let mut violations = Vec::new();
593
594    if var.required && value.is_empty() {
595        violations.push(format!(
596            "variable '{name}' is required but was not provided"
597        ));
598    }
599
600    // Skip further checks for empty non-required values
601    if value.is_empty() && !var.required {
602        return violations;
603    }
604
605    if let Some(min_len) = var.min_length {
606        let len = value.len() as u32;
607        if len < min_len {
608            violations.push(format!(
609                "variable '{name}' must be at least {min_len} characters (got {len})"
610            ));
611        }
612    }
613
614    if let Some(max_len) = var.max_length {
615        let len = value.len() as u32;
616        if len > max_len {
617            violations.push(format!(
618                "variable '{name}' must be at most {max_len} characters (got {len})"
619            ));
620        }
621    }
622
623    if let Some(min) = var.min {
624        if let Ok(num) = value.parse::<f64>() {
625            if num < min {
626                violations.push(format!(
627                    "variable '{name}' must be at least {min} (got {num})"
628                ));
629            }
630        }
631    }
632
633    if let Some(max) = var.max {
634        if let Ok(num) = value.parse::<f64>() {
635            if num > max {
636                violations.push(format!(
637                    "variable '{name}' must be at most {max} (got {num})"
638                ));
639            }
640        }
641    }
642
643    if let Some(ref pattern) = var.pattern {
644        if let Ok(re) = Regex::new(pattern) {
645            if !re.is_match(value) {
646                violations.push(format!("variable '{name}' must match pattern '{pattern}'"));
647            }
648        }
649    }
650
651    if let Some(ref allowed) = var.allowed_values {
652        let allowed_strs: Vec<String> = allowed.iter().map(toml_value_to_string).collect();
653        if !allowed_strs.iter().any(|a| a == value) {
654            violations.push(format!(
655                "variable '{name}' must be one of: {}",
656                allowed_strs.join(", ")
657            ));
658        }
659    }
660
661    violations
662}
663
664/// Validate variable values against their declared constraints at runtime.
665///
666/// Called after `init_vars` and prompt binding, before step execution begins.
667pub fn validate_var_values(
668    vars: &HashMap<String, String>,
669    declarations: &HashMap<String, Variable>,
670) -> Result<(), Vec<ZigError>> {
671    let mut errors = Vec::new();
672
673    for (name, decl) in declarations {
674        let value = vars.get(name).map(|s| s.as_str()).unwrap_or("");
675        let violations = check_value_constraints(name, value, decl);
676        for msg in violations {
677            errors.push(ZigError::Validation(msg));
678        }
679    }
680
681    if errors.is_empty() {
682        Ok(())
683    } else {
684        Err(errors)
685    }
686}
687
688/// Extract `${var_name}` references from a prompt template.
689pub(crate) fn extract_var_refs(template: &str) -> Vec<String> {
690    let mut refs = Vec::new();
691    let mut rest = template;
692    while let Some(start) = rest.find("${") {
693        let after_start = &rest[start + 2..];
694        if let Some(end) = after_start.find('}') {
695            let var_name = &after_start[..end];
696            // Support dotted paths like ${quality.score} — take the root variable
697            let root = var_name.split('.').next().unwrap_or(var_name);
698            refs.push(root.to_string());
699            rest = &after_start[end + 1..];
700        } else {
701            break;
702        }
703    }
704    refs
705}
706
707/// Extract variable names from a condition expression.
708///
709/// Simple heuristic: split on whitespace and operators, keep identifiers
710/// that are not numeric literals, string literals, or comparison operators.
711pub(crate) fn extract_condition_vars(condition: &str) -> Vec<String> {
712    let operators = ["==", "!=", "<", ">", "<=", ">=", "&&", "||", "!"];
713    let keywords = ["true", "false"];
714
715    condition
716        .split(|c: char| c.is_whitespace() || c == '(' || c == ')')
717        .filter(|token| {
718            !token.is_empty()
719                && !operators.contains(token)
720                && !keywords.contains(token)
721                && !token.starts_with('"')
722                && !token.starts_with('\'')
723                && token.parse::<f64>().is_err()
724        })
725        .map(|token| {
726            // Handle dotted paths: score.value → score
727            token.split('.').next().unwrap_or(token).to_string()
728        })
729        .collect()
730}
731
732/// Detect cycles in the step dependency graph using DFS.
733/// Returns the cycle path if found, or None.
734fn detect_cycle(steps: &[crate::workflow::model::Step]) -> Option<Vec<String>> {
735    let adjacency: HashMap<&str, Vec<&str>> = steps
736        .iter()
737        .map(|s| {
738            (
739                s.name.as_str(),
740                s.depends_on.iter().map(|d| d.as_str()).collect(),
741            )
742        })
743        .collect();
744
745    let mut visited = HashSet::new();
746    let mut in_stack = HashSet::new();
747    let mut path = Vec::new();
748
749    for step in steps {
750        if !visited.contains(step.name.as_str())
751            && dfs_cycle(
752                step.name.as_str(),
753                &adjacency,
754                &mut visited,
755                &mut in_stack,
756                &mut path,
757            )
758        {
759            return Some(path);
760        }
761    }
762    None
763}
764
765fn dfs_cycle<'a>(
766    node: &'a str,
767    adjacency: &HashMap<&'a str, Vec<&'a str>>,
768    visited: &mut HashSet<&'a str>,
769    in_stack: &mut HashSet<&'a str>,
770    path: &mut Vec<String>,
771) -> bool {
772    visited.insert(node);
773    in_stack.insert(node);
774    path.push(node.to_string());
775
776    if let Some(neighbors) = adjacency.get(node) {
777        for &neighbor in neighbors {
778            if !visited.contains(neighbor) {
779                if dfs_cycle(neighbor, adjacency, visited, in_stack, path) {
780                    return true;
781                }
782            } else if in_stack.contains(neighbor) {
783                path.push(neighbor.to_string());
784                return true;
785            }
786        }
787    }
788
789    in_stack.remove(node);
790    path.pop();
791    false
792}
793
794#[cfg(test)]
795#[path = "validate_tests.rs"]
796mod tests;