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