systemprompt_cli/commands/admin/config/
validate.rs1use anyhow::{Result, anyhow};
23use clap::Args;
24
25use super::types::{ConfigFileInfo, ConfigSection, ConfigValidateOutput, read_yaml_file};
26use crate::CliConfig;
27use crate::shared::CommandOutput;
28use systemprompt_loader::ConfigLoader;
29use systemprompt_manifest::profile::Profile;
30use systemprompt_scheduler::SchedulerConfig;
31
32#[derive(Debug, Clone, Args)]
33pub struct ValidateArgs {
34 #[arg(value_name = "PATH_OR_SECTION")]
35 pub target: Option<String>,
36
37 #[arg(long)]
38 pub strict: bool,
39
40 #[arg(
41 long,
42 help = "Print the generated JSON schema for the Profile config type instead of validating \
43 any file"
44 )]
45 pub schema: bool,
46}
47
48pub fn execute(args: &ValidateArgs, _config: &CliConfig) -> Result<(CommandOutput, bool)> {
49 if args.schema {
50 return print_profile_schema();
51 }
52
53 if let Some(target) = &args.target {
54 let path = std::path::PathBuf::from(target);
55 if path.exists() && is_yaml_file(&path) && target.parse::<ConfigSection>().is_err() {
56 return validate_profile_file(&path);
57 }
58 }
59
60 let (mut results, mut all_valid) =
61 validate_files(files_for_target(args.target.as_deref())?, args.strict);
62
63 all_valid &= super::plane_validate::append_full_validation(args.target.as_ref(), &mut results);
64
65 if let Some(error) = scheduler_catalog_error(&results, args.strict) {
66 all_valid = false;
67 for file in &mut results {
68 if file.section == "scheduler" && file.error.is_none() {
69 file.valid = false;
70 file.error = Some(error.clone());
71 }
72 }
73 }
74
75 Ok(render(&ConfigValidateOutput {
76 files: results,
77 all_valid,
78 }))
79}
80
81fn files_for_target(target: Option<&str>) -> Result<Vec<std::path::PathBuf>> {
82 let Some(target) = target else {
83 let mut all_files = Vec::new();
84 for section in ConfigSection::all() {
85 all_files.extend(section.all_files()?);
86 }
87 return Ok(all_files);
88 };
89 match target.parse::<ConfigSection>() {
90 Ok(section) => Ok(section.all_files()?),
91 Err(_) => Ok(vec![std::path::PathBuf::from(target)]),
92 }
93}
94
95fn validate_files(files: Vec<std::path::PathBuf>, strict: bool) -> (Vec<ConfigFileInfo>, bool) {
96 let mut results = Vec::new();
97 let mut all_valid = true;
98 for file_path in files {
99 let section = detect_section(&file_path);
100 let exists = file_path.exists();
101 let error = if exists {
102 validate_file(&file_path, strict)
103 .err()
104 .map(|e| e.to_string())
105 } else {
106 Some("File not found".to_owned())
107 };
108 let valid = error.is_none();
109 all_valid &= valid;
110 results.push(ConfigFileInfo {
111 path: file_path.display().to_string(),
112 section,
113 exists,
114 valid,
115 error,
116 });
117 }
118 (results, all_valid)
119}
120
121fn render(output: &ConfigValidateOutput) -> (CommandOutput, bool) {
122 let all_valid = output.all_valid;
123 let title = if all_valid {
124 "Validation Passed"
125 } else {
126 "Validation Failed"
127 };
128 (
129 CommandOutput::table_of(
130 vec!["path", "section", "exists", "valid", "error"],
131 &output.files,
132 )
133 .with_title(title),
134 all_valid,
135 )
136}
137
138fn print_profile_schema() -> Result<(CommandOutput, bool)> {
139 let schema = schemars::schema_for!(Profile);
140 let json = serde_json::to_string_pretty(&schema)
141 .map_err(|e| anyhow!("failed to serialize Profile JSON schema: {e}"))?;
142 Ok((CommandOutput::text(json), true))
143}
144
145fn validate_profile_file(path: &std::path::Path) -> Result<(CommandOutput, bool)> {
146 let content = std::fs::read_to_string(path)
147 .map_err(|e| anyhow!("failed to read profile {}: {e}", path.display()))?;
148
149 match Profile::from_yaml(&content, path) {
150 Ok(profile) => {
151 let output = ConfigValidateOutput {
152 files: vec![ConfigFileInfo {
153 path: path.display().to_string(),
154 section: "profile".to_owned(),
155 exists: true,
156 valid: true,
157 error: None,
158 }],
159 all_valid: true,
160 };
161 let title = format!("Profile '{}' is valid", profile.name);
162 Ok((
163 CommandOutput::table_of(
164 vec!["path", "section", "exists", "valid", "error"],
165 &output.files,
166 )
167 .with_title(title),
168 true,
169 ))
170 },
171 Err(e) => Err(anyhow!(
172 "invalid profile {}: {e}\nThe error above names the offending field or value — fix it \
173 and re-run.",
174 path.display()
175 )),
176 }
177}
178
179fn scheduler_catalog_error(files: &[ConfigFileInfo], strict: bool) -> Option<String> {
180 if !files
181 .iter()
182 .any(|file| file.section == "scheduler" && file.error.is_none())
183 {
184 return None;
185 }
186
187 match ConfigLoader::load() {
188 Ok(services) => {
189 let scheduler = services
190 .scheduler
191 .unwrap_or_else(SchedulerConfig::with_system_admin);
192 unknown_jobs_message(&scheduler)
193 .or_else(|| strict.then(|| unlisted_jobs_message(&scheduler)).flatten())
194 },
195 Err(e) => Some(format!("failed to load merged services config: {e}")),
196 }
197}
198
199pub fn unlisted_jobs_message(config: &SchedulerConfig) -> Option<String> {
200 let unlisted = systemprompt_scheduler::unlisted_job_names(config);
201 if unlisted.is_empty() {
202 return None;
203 }
204 Some(format!(
205 "registered job(s) with no scheduler.jobs entry: {}. Add an entry for each (enabled: \
206 false keeps one off the schedule).",
207 unlisted
208 .iter()
209 .map(systemprompt_identifiers::JobName::as_str)
210 .collect::<Vec<_>>()
211 .join(", ")
212 ))
213}
214
215pub fn unknown_jobs_message(config: &SchedulerConfig) -> Option<String> {
216 let unknown = systemprompt_scheduler::unknown_job_names(config);
217 if unknown.is_empty() {
218 return None;
219 }
220 Some(format!(
221 "job(s) not registered via submit_job!: {}. Run `systemprompt infra jobs list` to see \
222 every registered job.",
223 unknown.join(", ")
224 ))
225}
226
227fn is_yaml_file(path: &std::path::Path) -> bool {
228 matches!(
229 path.extension().and_then(|e| e.to_str()),
230 Some("yaml" | "yml")
231 )
232}
233
234fn validate_file(path: &std::path::Path, _strict: bool) -> Result<()> {
235 let _content = read_yaml_file(path)?;
236 Ok(())
237}
238
239fn detect_section(path: &std::path::Path) -> String {
240 let path_str = path.display().to_string();
241
242 if path_str.contains("/ai/") {
243 "ai".to_owned()
244 } else if path_str.contains("/content/") {
245 "content".to_owned()
246 } else if path_str.contains("/web/") {
247 "web".to_owned()
248 } else if path_str.contains("/scheduler/") {
249 "scheduler".to_owned()
250 } else if path_str.contains("/agents/") {
251 "agents".to_owned()
252 } else if path_str.contains("/mcp/") {
253 "mcp".to_owned()
254 } else if path_str.contains("/skills/") {
255 "skills".to_owned()
256 } else if path_str.contains("profile.yaml") {
257 "profile".to_owned()
258 } else if path_str.contains("/config/config.yaml") {
259 "services".to_owned()
260 } else {
261 "unknown".to_owned()
262 }
263}