Skip to main content

systemprompt_runtime/startup_validation/
extension_validator.rs

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