Skip to main content

layover_core/validate/
mod.rs

1//! Load-time validation of a factory definition.
2//!
3//! Everything here runs before the first flight, because an unattended factory that discovers a
4//! typo three agents deep has already spent money to find out. Errors block startup; warnings
5//! describe shapes that are legal but known to be hazardous.
6//!
7//! The checks are grouped by what they are about — agents, routes, reach, pipelines and prompts —
8//! and each group lives in its own file so that any one of them fits comfortably in view.
9
10mod agents;
11mod pipelines;
12mod prompts;
13mod reach;
14mod routes;
15mod wiring;
16
17use crate::config::Config;
18use crate::prompt::PromptSource;
19
20/// How serious a finding is.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum Severity {
23    /// The factory must not start.
24    Error,
25    /// Legal, but likely to misbehave.
26    Warning,
27}
28
29/// A single validation finding.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Diagnostic {
32    /// How serious it is.
33    pub severity: Severity,
34    /// Human-readable explanation.
35    pub message: String,
36}
37
38impl Diagnostic {
39    /// Builds an error.
40    pub(crate) fn error(message: impl Into<String>) -> Self {
41        Self {
42            severity: Severity::Error,
43            message: message.into(),
44        }
45    }
46
47    /// Builds a warning.
48    pub(crate) fn warning(message: impl Into<String>) -> Self {
49        Self {
50            severity: Severity::Warning,
51            message: message.into(),
52        }
53    }
54
55    /// Returns `true` if this finding should block startup.
56    #[must_use]
57    pub fn is_error(&self) -> bool {
58        self.severity == Severity::Error
59    }
60}
61
62/// Returns `true` if any finding blocks startup.
63#[must_use]
64pub fn has_errors(diagnostics: &[Diagnostic]) -> bool {
65    diagnostics.iter().any(Diagnostic::is_error)
66}
67
68/// Checks everything about a factory definition that does not need prompt files.
69///
70/// Prompt composition is checked separately by [`validate_prompts`], because reading prompt files
71/// needs a [`PromptSource`] and callers that only have the TOML text should still get the rest of
72/// the findings.
73#[must_use]
74pub fn validate(config: &Config) -> Vec<Diagnostic> {
75    let mut found = Vec::new();
76
77    routes::check_routes_name_known_agents(config, &mut found);
78    agents::check_runners_exist(config, &mut found);
79    agents::check_prompts_are_unambiguous(config, &mut found);
80    agents::check_agents_are_described(config, &mut found);
81    agents::check_fuel_is_usable(config, &mut found);
82    agents::check_reserve_window_is_usable(config, &mut found);
83    agents::check_reserve_cap_is_deliberate(config, &mut found);
84    agents::check_model_reaches_its_runner(config, &mut found);
85    reach::check_entry_points(config, &mut found);
86    routes::check_joins_are_unambiguous(config, &mut found);
87    routes::check_read_write_fan_out(config, &mut found);
88    routes::check_spawns_do_not_join(config, &mut found);
89    reach::check_every_agent_is_within_reach(config, &mut found);
90    pipelines::check_pipelines(config, &mut found);
91    wiring::check_mcp_and_workspaces(config, &mut found);
92
93    found
94}
95
96/// Checks that every agent's prompt composes, and that it only names declared flags.
97///
98/// Kept separate from [`validate`] so that the pure-TOML checks stay usable without a filesystem.
99/// Run both before starting a factory.
100#[must_use]
101pub fn validate_prompts(config: &Config, source: &dyn PromptSource) -> Vec<Diagnostic> {
102    let mut found = Vec::new();
103    prompts::check_prompt_files(config, source, &mut found);
104    found
105}
106
107#[cfg(test)]
108pub(crate) mod testing {
109    use super::{Severity, validate};
110    use crate::config::Config;
111
112    pub(crate) const RUNNER: &str = r#"
113        [runners.claude]
114        command = ["claude", "-p", "{prompt}"]
115    "#;
116
117    pub(crate) fn parse(body: &str) -> Config {
118        Config::from_toml(&format!("{RUNNER}{body}"), "test.toml").expect("config parses")
119    }
120
121    pub(crate) fn messages(config: &Config, severity: Severity) -> Vec<String> {
122        validate(config)
123            .into_iter()
124            .filter(|d| d.severity == severity)
125            .map(|d| d.message)
126            .collect()
127    }
128
129    pub(crate) fn errors(config: &Config) -> Vec<String> {
130        messages(config, Severity::Error)
131    }
132
133    pub(crate) fn warnings(config: &Config) -> Vec<String> {
134        messages(config, Severity::Warning)
135    }
136
137    pub(crate) fn assert_mentions(found: &[String], needle: &str) {
138        assert!(
139            found.iter().any(|message| message.contains(needle)),
140            "expected a finding mentioning `{needle}`, got {found:?}"
141        );
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::testing::{assert_mentions, errors, parse};
148    use super::{Severity, has_errors, validate};
149
150    #[test]
151    fn a_minimal_factory_is_clean() {
152        let config = parse(
153            r#"
154            [agents.planner]
155            runner = "claude"
156            description = "Breaks a goal into tasks"
157            prompt = "plan"
158            entry = true
159
160            [agents.coder]
161            runner = "claude"
162            description = "Implements a task"
163            prompt = "code"
164
165            [[routes]]
166            from = "planner"
167            to = "coder"
168            "#,
169        );
170
171        assert_eq!(validate(&config), Vec::new());
172    }
173
174    #[test]
175    fn a_factory_with_no_agents_is_an_error() {
176        let config = parse("");
177
178        assert!(has_errors(&validate(&config)));
179        assert_mentions(&errors(&config), "defines no agents");
180    }
181
182    #[test]
183    fn severity_orders_errors_before_warnings() {
184        assert!(Severity::Error < Severity::Warning);
185    }
186}