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| {
59 AppPaths::from_profile(
60 &p.paths,
61 p.path_resolution(),
62 systemprompt_loader::ServicesRootBootstrap::get().map(|r| r.path.as_path()),
63 )
64 .map_err(|e| e.to_string())
65 });
66
67 match paths_result {
68 Ok(paths) => validate_extension_assets(&extensions, &paths, report, verbose),
69 Err(_) if verbose => {
70 CliService::output(&format!(
71 " {} Asset validation skipped (profile not loaded)",
72 BrandColors::dim("○")
73 ));
74 },
75 Err(_) => {},
76 }
77}
78
79fn validate_extension_assets(
80 registry: &ExtensionRegistry,
81 paths: &AppPaths,
82 report: &mut StartupValidationReport,
83 verbose: bool,
84) {
85 for ext in registry.asset_extensions() {
86 let ext_id = ext.id();
87 let mut has_errors = false;
88
89 for asset in ext.required_assets(paths) {
90 if asset.is_required() && !asset.source().exists() {
91 has_errors = true;
92 let mut ext_report = ValidationReport::new(format!("ext:{}", ext_id));
93 ext_report.add_error(
94 ValidationError::new(
95 "required_asset",
96 format!("Missing required asset: {}", asset.source().display()),
97 )
98 .with_suggestion("Ensure the asset file exists at the specified path"),
99 );
100 report.add_extension(ext_report);
101
102 CliService::output(&format!(
103 " {} [ext:{}] Missing asset: {}",
104 BrandColors::stopped("✗"),
105 ext_id,
106 asset.source().display()
107 ));
108 }
109 }
110
111 if !has_errors && verbose {
112 render_phase_success(&format!("[ext:{}]", ext_id), Some("assets valid"));
113 }
114 }
115}
116
117#[derive(Debug)]
123pub struct ExtensionConfigOutcome {
124 pub extension_id: String,
125 pub config_key: String,
126 pub error: Option<String>,
127}
128
129enum ExtConfigError {
130 Load(String),
131 Validate(String),
132}
133
134impl ExtConfigError {
135 fn message(&self) -> &str {
136 match self {
137 Self::Load(m) | Self::Validate(m) => m,
138 }
139 }
140}
141
142fn evaluate_extension_config(
143 ext: &dyn systemprompt_extension::Extension,
144 services_path: &Path,
145) -> Result<(), ExtConfigError> {
146 let Some(prefix) = ext.config_prefix() else {
147 return Ok(());
148 };
149
150 let config_path = services_path
151 .join("config")
152 .join(format!("{}.yaml", prefix));
153
154 let config_json = if config_path.exists() {
155 load_extension_config(&config_path).map_err(ExtConfigError::Load)?
156 } else {
157 serde_json::json!({})
158 };
159
160 ext.validate_config(&config_json)
161 .map_err(|e| ExtConfigError::Validate(e.to_string()))
162}
163
164pub fn validate_extension_configs(
165 services_path: &Path,
166) -> Result<Vec<ExtensionConfigOutcome>, String> {
167 let extensions = ExtensionRegistry::discover().map_err(|e| e.to_string())?;
168
169 Ok(extensions
170 .config_extensions()
171 .iter()
172 .filter_map(|ext| {
173 let prefix = ext.config_prefix()?;
174 let error = evaluate_extension_config(ext.as_ref(), services_path)
175 .err()
176 .map(|e| e.message().to_owned());
177 Some(ExtensionConfigOutcome {
178 extension_id: ext.id().to_owned(),
179 config_key: format!("{}.config", prefix),
180 error,
181 })
182 })
183 .collect())
184}
185
186fn validate_single_extension(
187 config: &Config,
188 ext: &dyn systemprompt_extension::Extension,
189 report: &mut StartupValidationReport,
190 verbose: bool,
191) {
192 let ext_id = ext.id();
193 let Some(prefix) = ext.config_prefix() else {
194 return;
195 };
196
197 match evaluate_extension_config(ext, Path::new(&config.services_path)) {
198 Ok(()) => {
199 if verbose {
200 render_phase_success(&format!("[ext:{}]", ext_id), Some("valid"));
201 }
202 },
203 Err(e) => {
204 let report_message = match &e {
205 ExtConfigError::Load(m) => format!("Failed to load config: {}", m),
206 ExtConfigError::Validate(m) => m.clone(),
207 };
208 let mut ext_report = ValidationReport::new(format!("ext:{}", ext_id));
209 ext_report.add_error(ValidationError::new(
210 format!("{}.config", prefix),
211 report_message,
212 ));
213 report.add_extension(ext_report);
214 CliService::output(&format!(
215 " {} [ext:{}] {}",
216 BrandColors::stopped("✗"),
217 ext_id,
218 e.message()
219 ));
220 },
221 }
222}