Skip to main content

systemprompt_runtime/startup_validation/
extension_validator.rs

1//! Per-extension config and asset validation at startup.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::path::Path;
7use systemprompt_config::ProfileBootstrap;
8use systemprompt_config::paths::AppPaths;
9use systemprompt_extension::ExtensionRegistry;
10use systemprompt_logging::CliService;
11use systemprompt_logging::services::cli::{BrandColors, render_phase_success};
12use systemprompt_models::Config;
13use systemprompt_traits::validation_report::ValidationIssue;
14use systemprompt_traits::{StartupValidationReport, ValidationReport};
15
16use super::config_loaders::load_extension_config;
17
18pub(super) fn validate_extensions(
19    config: &Config,
20    report: &mut StartupValidationReport,
21    verbose: bool,
22) {
23    let extensions = match ExtensionRegistry::discover() {
24        Ok(extensions) => extensions,
25        Err(e) => {
26            let mut ext_report = ValidationReport::new("ext:registry".to_owned());
27            ext_report.add_error(ValidationIssue::new(
28                "extension_discovery",
29                format!("Failed to discover extensions: {}", e),
30            ));
31            report.add_extension(ext_report);
32            return;
33        },
34    };
35    let config_extensions = extensions.config_extensions();
36    let asset_extensions = extensions.asset_extensions();
37
38    let has_extensions = !config_extensions.is_empty() || !asset_extensions.is_empty();
39
40    if !has_extensions {
41        return;
42    }
43
44    if verbose {
45        CliService::output("");
46        CliService::output(&format!(
47            "{} {}",
48            BrandColors::primary("▸"),
49            BrandColors::white_bold("Validating extensions")
50        ));
51    }
52
53    for ext in config_extensions {
54        validate_single_extension(config, ext.as_ref(), report, verbose);
55    }
56
57    let paths_result = ProfileBootstrap::get()
58        .map_err(|e| e.to_string())
59        .and_then(|p| {
60            AppPaths::from_profile(
61                &p.paths,
62                p.path_resolution(),
63                systemprompt_loader::ServicesRootBootstrap::get().map(|r| r.path.as_path()),
64            )
65            .map_err(|e| e.to_string())
66        });
67
68    match paths_result {
69        Ok(paths) => validate_extension_assets(&extensions, &paths, report, verbose),
70        Err(_) if verbose => {
71            CliService::output(&format!(
72                "  {} Asset validation skipped (profile not loaded)",
73                BrandColors::dim("○")
74            ));
75        },
76        Err(_) => {},
77    }
78}
79
80fn validate_extension_assets(
81    registry: &ExtensionRegistry,
82    paths: &AppPaths,
83    report: &mut StartupValidationReport,
84    verbose: bool,
85) {
86    for ext in registry.asset_extensions() {
87        let ext_id = ext.id();
88        let mut has_errors = false;
89
90        for asset in ext.required_assets(paths) {
91            if asset.is_required() && !asset.source().exists() {
92                has_errors = true;
93                let mut ext_report = ValidationReport::new(format!("ext:{}", ext_id));
94                ext_report.add_error(
95                    ValidationIssue::new(
96                        "required_asset",
97                        format!("Missing required asset: {}", asset.source().display()),
98                    )
99                    .with_suggestion("Ensure the asset file exists at the specified path"),
100                );
101                report.add_extension(ext_report);
102
103                CliService::output(&format!(
104                    "  {} [ext:{}] Missing asset: {}",
105                    BrandColors::stopped("✗"),
106                    ext_id,
107                    asset.source().display()
108                ));
109            }
110        }
111
112        if !has_errors && verbose {
113            render_phase_success(&format!("[ext:{}]", ext_id), Some("assets valid"));
114        }
115    }
116}
117
118/// A single extension's resolved config-validation result.
119///
120/// Produced by [`validate_extension_configs`] so callers outside the serve
121/// boot path (e.g. the `cloud doctor` preflight) can run the exact same
122/// `validate_config` pass without a `Config` or a [`StartupValidationReport`].
123#[derive(Debug)]
124pub struct ExtensionConfigOutcome {
125    pub extension_id: String,
126    pub config_key: String,
127    pub error: Option<String>,
128}
129
130enum ExtConfigError {
131    Load(String),
132    Validate(String),
133}
134
135impl ExtConfigError {
136    fn message(&self) -> &str {
137        match self {
138            Self::Load(m) | Self::Validate(m) => m,
139        }
140    }
141}
142
143fn evaluate_extension_config(
144    ext: &dyn systemprompt_extension::Extension,
145    services_path: &Path,
146) -> Result<(), ExtConfigError> {
147    let Some(prefix) = ext.config_prefix() else {
148        return Ok(());
149    };
150
151    let config_path = services_path
152        .join("config")
153        .join(format!("{}.yaml", prefix));
154
155    let config_json = if config_path.exists() {
156        load_extension_config(&config_path).map_err(ExtConfigError::Load)?
157    } else {
158        serde_json::json!({})
159    };
160
161    ext.validate_config(&config_json)
162        .map_err(|e| ExtConfigError::Validate(e.to_string()))
163}
164
165pub fn validate_extension_configs(
166    services_path: &Path,
167) -> Result<Vec<ExtensionConfigOutcome>, String> {
168    let extensions = ExtensionRegistry::discover().map_err(|e| e.to_string())?;
169
170    Ok(extensions
171        .config_extensions()
172        .iter()
173        .filter_map(|ext| {
174            let prefix = ext.config_prefix()?;
175            let error = evaluate_extension_config(ext.as_ref(), services_path)
176                .err()
177                .map(|e| e.message().to_owned());
178            Some(ExtensionConfigOutcome {
179                extension_id: ext.id().to_owned(),
180                config_key: format!("{}.config", prefix),
181                error,
182            })
183        })
184        .collect())
185}
186
187fn validate_single_extension(
188    config: &Config,
189    ext: &dyn systemprompt_extension::Extension,
190    report: &mut StartupValidationReport,
191    verbose: bool,
192) {
193    let ext_id = ext.id();
194    let Some(prefix) = ext.config_prefix() else {
195        return;
196    };
197
198    match evaluate_extension_config(ext, Path::new(&config.services_path)) {
199        Ok(()) => {
200            if verbose {
201                render_phase_success(&format!("[ext:{}]", ext_id), Some("valid"));
202            }
203        },
204        Err(e) => {
205            let report_message = match &e {
206                ExtConfigError::Load(m) => format!("Failed to load config: {}", m),
207                ExtConfigError::Validate(m) => m.clone(),
208            };
209            let mut ext_report = ValidationReport::new(format!("ext:{}", ext_id));
210            ext_report.add_error(ValidationIssue::new(
211                format!("{}.config", prefix),
212                report_message,
213            ));
214            report.add_extension(ext_report);
215            CliService::output(&format!(
216                "  {} [ext:{}] {}",
217                BrandColors::stopped("✗"),
218                ext_id,
219                e.message()
220            ));
221        },
222    }
223}