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