Skip to main content

systemprompt_runtime/startup_validation/
mod.rs

1//! Startup configuration and extension validation.
2//!
3//! [`StartupValidator`] composes per-domain validators via
4//! [`DomainConfigRegistry`], aggregates results into a
5//! [`StartupValidationReport`], and is consumed by `systemprompt`'s
6//! boot sequence to gate startup on configuration health.
7
8mod config_loaders;
9mod display;
10mod extension_validator;
11mod files_validator;
12mod mcp_validator;
13
14use systemprompt_config::{ProfileBootstrap, SkillConfigValidator};
15use systemprompt_logging::services::cli::{
16    BrandColors, render_phase_success, render_phase_warning,
17};
18use systemprompt_logging::{CliService, is_startup_mode};
19use systemprompt_models::Config;
20use systemprompt_models::validators::{
21    AgentConfigValidator, AiConfigValidator, ContentConfigValidator, McpConfigValidator,
22    RateLimitsConfigValidator, ValidationConfigProvider, WebConfigValidator,
23};
24use systemprompt_traits::validation_report::ValidationError;
25use systemprompt_traits::{DomainConfigRegistry, StartupValidationReport, ValidationReport};
26
27use config_loaders::{create_spinner, load_content_config, load_web_config, load_web_metadata};
28use extension_validator::validate_extensions;
29use mcp_validator::validate_mcp_manifests;
30
31pub use display::{display_validation_report, display_validation_warnings};
32pub use extension_validator::{ExtensionConfigOutcome, validate_extension_configs};
33pub use files_validator::FilesConfigValidator;
34
35#[derive(Debug)]
36pub struct StartupValidator {
37    registry: DomainConfigRegistry,
38}
39
40impl StartupValidator {
41    pub fn new() -> Self {
42        let mut registry = DomainConfigRegistry::new();
43
44        registry.register(Box::new(FilesConfigValidator::new()));
45        registry.register(Box::new(RateLimitsConfigValidator::new()));
46        registry.register(Box::new(WebConfigValidator::new()));
47        registry.register(Box::new(ContentConfigValidator::new()));
48        registry.register(Box::new(SkillConfigValidator::new()));
49        registry.register(Box::new(AgentConfigValidator::new()));
50        registry.register(Box::new(McpConfigValidator::new()));
51        registry.register(Box::new(AiConfigValidator::new()));
52
53        Self { registry }
54    }
55
56    pub fn validate(&mut self, config: &Config) -> StartupValidationReport {
57        let mut report = StartupValidationReport::new();
58        let verbose = is_startup_mode();
59
60        if let Ok(path) = ProfileBootstrap::get_path() {
61            report = report.with_profile_path(path);
62        }
63
64        if verbose {
65            CliService::section("Validating configuration");
66        }
67
68        let Some(validation_provider) = Self::load_configs(config, &mut report, verbose) else {
69            return report;
70        };
71
72        if self.load_domain_validators(&validation_provider, &mut report, verbose) {
73            return report;
74        }
75
76        self.run_domain_validations(&mut report, verbose);
77
78        validate_mcp_manifests(config, validation_provider.services_config(), &mut report);
79
80        if report.has_errors() {
81            return report;
82        }
83
84        validate_extensions(config, &mut report, verbose);
85
86        if verbose {
87            CliService::output("");
88        }
89
90        report
91    }
92
93    fn load_configs(
94        config: &Config,
95        report: &mut StartupValidationReport,
96        verbose: bool,
97    ) -> Option<ValidationConfigProvider> {
98        let spinner = if verbose {
99            Some(create_spinner("Loading services config"))
100        } else {
101            None
102        };
103        let services_config = match systemprompt_loader::ConfigLoader::load() {
104            Ok(cfg) => {
105                if let Some(s) = spinner {
106                    s.finish_and_clear();
107                }
108                if verbose {
109                    CliService::phase_success("Services config", Some("includes merged"));
110                }
111                cfg
112            },
113            Err(e) => {
114                if let Some(s) = spinner {
115                    s.finish_and_clear();
116                }
117                CliService::error(&format!("Services config: {}", e));
118                let mut domain_report = ValidationReport::new("services");
119                domain_report.add_error(ValidationError::new(
120                    "services_config",
121                    format!("Failed to load: {}", e),
122                ));
123                report.add_domain(domain_report);
124                return None;
125            },
126        };
127
128        let mut provider = ValidationConfigProvider::new(config.clone(), services_config);
129
130        provider = load_content_config(config, provider, verbose);
131        provider = load_web_config(config, provider, verbose);
132        provider = load_web_metadata(config, provider, verbose);
133
134        Some(provider)
135    }
136
137    fn load_domain_validators(
138        &mut self,
139        provider: &ValidationConfigProvider,
140        report: &mut StartupValidationReport,
141        verbose: bool,
142    ) -> bool {
143        if verbose {
144            CliService::output("");
145            CliService::output(&format!(
146                "{} {}",
147                BrandColors::primary("▸"),
148                BrandColors::white_bold("Validating domains")
149            ));
150        }
151
152        for validator in self.registry.validators_mut() {
153            let domain_id = validator.domain_id();
154            let spinner = if verbose {
155                Some(create_spinner(&format!("Loading {}", domain_id)))
156            } else {
157                None
158            };
159
160            match validator.load(provider) {
161                Ok(()) => {
162                    if let Some(s) = spinner {
163                        s.finish_and_clear();
164                    }
165                },
166                Err(e) => {
167                    if let Some(s) = spinner {
168                        s.finish_and_clear();
169                    }
170                    CliService::output(&format!(
171                        "  {} [{}] {}",
172                        BrandColors::stopped("✗"),
173                        domain_id,
174                        e
175                    ));
176
177                    let mut domain_report = ValidationReport::new(domain_id);
178                    domain_report.add_error(ValidationError::new(
179                        format!("{}_config", domain_id),
180                        format!("Failed to load: {}", e),
181                    ));
182                    report.add_domain(domain_report);
183                },
184            }
185        }
186
187        report.has_errors()
188    }
189
190    fn run_domain_validations(&self, report: &mut StartupValidationReport, verbose: bool) {
191        for validator in self.registry.validators_sorted() {
192            let domain_id = validator.domain_id();
193            let spinner = if verbose {
194                Some(create_spinner(&format!("Validating {}", domain_id)))
195            } else {
196                None
197            };
198
199            match validator.validate() {
200                Ok(domain_report) => {
201                    if let Some(s) = spinner {
202                        s.finish_and_clear();
203                    }
204                    if verbose {
205                        Self::print_domain_result(&domain_report, domain_id);
206                    }
207                    report.add_domain(domain_report);
208                },
209                Err(e) => {
210                    if let Some(s) = spinner {
211                        s.finish_and_clear();
212                    }
213                    CliService::output(&format!(
214                        "  {} [{}] {}",
215                        BrandColors::stopped("✗"),
216                        domain_id,
217                        e
218                    ));
219
220                    let mut domain_report = ValidationReport::new(domain_id);
221                    domain_report.add_error(ValidationError::new(
222                        format!("{}_validation", domain_id),
223                        format!("Validation error: {}", e),
224                    ));
225                    report.add_domain(domain_report);
226                },
227            }
228        }
229    }
230
231    fn print_domain_result(domain_report: &ValidationReport, domain_id: &str) {
232        if domain_report.has_errors() {
233            CliService::output(&format!(
234                "  {} [{}] {} error(s)",
235                BrandColors::stopped("✗"),
236                domain_id,
237                domain_report.errors.len()
238            ));
239        } else if domain_report.has_warnings() {
240            render_phase_warning(
241                &format!("[{}]", domain_id),
242                Some(&format!("{} warning(s)", domain_report.warnings.len())),
243            );
244        } else {
245            render_phase_success(&format!("[{}]", domain_id), Some("valid"));
246        }
247    }
248}
249
250impl Default for StartupValidator {
251    fn default() -> Self {
252        Self::new()
253    }
254}