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