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