systemprompt_runtime/startup_validation/
extension_validator.rs1use std::path::Path;
7use systemprompt_config::ProfileBootstrap;
8use systemprompt_config::paths::AppPaths;
9use systemprompt_extension::{ExtensionConfigError, ExtensionRegistry, LoaderError};
10use systemprompt_identifiers::ExtensionId;
11use systemprompt_logging::CliService;
12use systemprompt_logging::services::cli::{BrandColors, render_phase_success};
13use systemprompt_manifest::Config;
14use systemprompt_traits::validation_report::ValidationIssue;
15use systemprompt_traits::{StartupValidationReport, ValidationReport};
16
17use super::config_loaders::{ConfigFileError, load_extension_config};
18
19pub(super) fn validate_extensions(
20 config: &Config,
21 report: &mut StartupValidationReport,
22 verbose: bool,
23) {
24 let extensions = match ExtensionRegistry::discover() {
25 Ok(extensions) => extensions,
26 Err(e) => {
27 let mut ext_report = ValidationReport::new("ext:registry".to_owned());
28 ext_report.add_error(ValidationIssue::new(
29 "extension_discovery",
30 format!("Failed to discover extensions: {}", e),
31 ));
32 report.add_extension(ext_report);
33 return;
34 },
35 };
36 let config_extensions = extensions.config_extensions();
37 let asset_extensions = extensions.asset_extensions();
38
39 let has_extensions = !config_extensions.is_empty() || !asset_extensions.is_empty();
40
41 if !has_extensions {
42 return;
43 }
44
45 if verbose {
46 CliService::output("");
47 CliService::output(&format!(
48 "{} {}",
49 BrandColors::primary("▸"),
50 BrandColors::white_bold("Validating extensions")
51 ));
52 }
53
54 for ext in config_extensions {
55 validate_single_extension(config, ext.as_ref(), report, verbose);
56 }
57
58 let paths = ProfileBootstrap::get().ok().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 .ok()
65 });
66
67 match paths {
68 Some(paths) => validate_extension_assets(&extensions, &paths, report, verbose),
69 None if verbose => {
70 CliService::output(&format!(
71 " {} Asset validation skipped (profile not loaded)",
72 BrandColors::dim("○")
73 ));
74 },
75 None => {},
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 ValidationIssue::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: ExtensionId,
125 pub config_key: String,
126 pub error: Option<String>,
127}
128
129#[derive(Debug, thiserror::Error)]
130enum ExtConfigError {
131 #[error("{0}")]
132 Load(#[source] ConfigFileError),
133 #[error("{0}")]
134 Validate(#[source] ExtensionConfigError),
135}
136
137fn evaluate_extension_config(
138 ext: &dyn systemprompt_extension::Extension,
139 services_path: &Path,
140) -> Result<(), ExtConfigError> {
141 let Some(prefix) = ext.config_prefix() else {
142 return Ok(());
143 };
144
145 let config_path = services_path
146 .join("config")
147 .join(format!("{}.yaml", prefix));
148
149 let config_json = if config_path.exists() {
150 load_extension_config(&config_path).map_err(ExtConfigError::Load)?
151 } else {
152 serde_json::json!({})
153 };
154
155 ext.validate_config(&config_json)
156 .map_err(ExtConfigError::Validate)
157}
158
159pub fn validate_extension_configs(
160 services_path: &Path,
161) -> Result<Vec<ExtensionConfigOutcome>, LoaderError> {
162 let extensions = ExtensionRegistry::discover()?;
163
164 Ok(extensions
165 .config_extensions()
166 .iter()
167 .filter_map(|ext| {
168 let prefix = ext.config_prefix()?;
169 let error = evaluate_extension_config(ext.as_ref(), services_path)
170 .err()
171 .map(|e| e.to_string());
172 Some(ExtensionConfigOutcome {
173 extension_id: ExtensionId::new(ext.id()),
174 config_key: format!("{}.config", prefix),
175 error,
176 })
177 })
178 .collect())
179}
180
181fn validate_single_extension(
182 config: &Config,
183 ext: &dyn systemprompt_extension::Extension,
184 report: &mut StartupValidationReport,
185 verbose: bool,
186) {
187 let ext_id = ext.id();
188 let Some(prefix) = ext.config_prefix() else {
189 return;
190 };
191
192 match evaluate_extension_config(ext, Path::new(&config.services_path)) {
193 Ok(()) => {
194 if verbose {
195 render_phase_success(&format!("[ext:{}]", ext_id), Some("valid"));
196 }
197 },
198 Err(e) => {
199 let report_message = match &e {
200 ExtConfigError::Load(cause) => format!("Failed to load config: {cause}"),
201 ExtConfigError::Validate(cause) => cause.to_string(),
202 };
203 let mut ext_report = ValidationReport::new(format!("ext:{}", ext_id));
204 ext_report.add_error(ValidationIssue::new(
205 format!("{}.config", prefix),
206 report_message,
207 ));
208 report.add_extension(ext_report);
209 CliService::output(&format!(
210 " {} [ext:{}] {}",
211 BrandColors::stopped("✗"),
212 ext_id,
213 e
214 ));
215 },
216 }
217}