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 files_to_validate = if let Some(target) = &args.target {
61 if let Ok(section) = target.parse::<ConfigSection>() {
62 section.all_files()?
63 } else {
64 vec![std::path::PathBuf::from(target)]
65 }
66 } else {
67 let mut all_files = Vec::new();
68 for section in ConfigSection::all() {
69 all_files.extend(section.all_files()?);
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 all_valid &= super::plane_validate::append_full_validation(args.target.as_ref(), &mut results);
104
105 if let Some(error) = scheduler_catalog_error(&results, args.strict) {
106 all_valid = false;
107 for file in &mut results {
108 if file.section == "scheduler" && file.error.is_none() {
109 file.valid = false;
110 file.error = Some(error.clone());
111 }
112 }
113 }
114
115 let output = ConfigValidateOutput {
116 files: results,
117 all_valid,
118 };
119
120 let title = if all_valid {
121 "Validation Passed"
122 } else {
123 "Validation Failed"
124 };
125
126 Ok((
127 CommandOutput::table_of(
128 vec!["path", "section", "exists", "valid", "error"],
129 &output.files,
130 )
131 .with_title(title),
132 all_valid,
133 ))
134}
135
136fn print_profile_schema() -> Result<(CommandOutput, bool)> {
137 let schema = schemars::schema_for!(Profile);
138 let json = serde_json::to_string_pretty(&schema)
139 .map_err(|e| anyhow!("failed to serialize Profile JSON schema: {e}"))?;
140 Ok((CommandOutput::text(json), true))
141}
142
143fn validate_profile_file(path: &std::path::Path) -> Result<(CommandOutput, bool)> {
144 let content = std::fs::read_to_string(path)
145 .map_err(|e| anyhow!("failed to read profile {}: {e}", path.display()))?;
146
147 match Profile::from_yaml(&content, path) {
148 Ok(profile) => {
149 let output = ConfigValidateOutput {
150 files: vec![ConfigFileInfo {
151 path: path.display().to_string(),
152 section: "profile".to_owned(),
153 exists: true,
154 valid: true,
155 error: None,
156 }],
157 all_valid: true,
158 };
159 let title = format!("Profile '{}' is valid", profile.name);
160 Ok((
161 CommandOutput::table_of(
162 vec!["path", "section", "exists", "valid", "error"],
163 &output.files,
164 )
165 .with_title(title),
166 true,
167 ))
168 },
169 Err(e) => Err(anyhow!(
170 "invalid profile {}: {e}\nThe error above names the offending field or value — fix it \
171 and re-run.",
172 path.display()
173 )),
174 }
175}
176
177fn scheduler_catalog_error(files: &[ConfigFileInfo], strict: bool) -> Option<String> {
178 if !files
179 .iter()
180 .any(|file| file.section == "scheduler" && file.error.is_none())
181 {
182 return None;
183 }
184
185 match ConfigLoader::load() {
186 Ok(services) => {
187 let scheduler = services
188 .scheduler
189 .unwrap_or_else(SchedulerConfig::with_system_admin);
190 unknown_jobs_message(&scheduler)
191 .or_else(|| strict.then(|| unlisted_jobs_message(&scheduler)).flatten())
192 },
193 Err(e) => Some(format!("failed to load merged services config: {e}")),
194 }
195}
196
197pub fn unlisted_jobs_message(config: &SchedulerConfig) -> Option<String> {
198 let unlisted = systemprompt_scheduler::unlisted_job_names(config);
199 if unlisted.is_empty() {
200 return None;
201 }
202 Some(format!(
203 "registered job(s) with no scheduler.jobs entry: {}. Add an entry for each (enabled: \
204 false keeps one off the schedule).",
205 unlisted
206 .iter()
207 .map(systemprompt_identifiers::JobName::as_str)
208 .collect::<Vec<_>>()
209 .join(", ")
210 ))
211}
212
213pub fn unknown_jobs_message(config: &SchedulerConfig) -> Option<String> {
214 let unknown = systemprompt_scheduler::unknown_job_names(config);
215 if unknown.is_empty() {
216 return None;
217 }
218 Some(format!(
219 "job(s) not registered via submit_job!: {}. Run `systemprompt infra jobs list` to see \
220 every registered job.",
221 unknown.join(", ")
222 ))
223}
224
225fn is_yaml_file(path: &std::path::Path) -> bool {
226 matches!(
227 path.extension().and_then(|e| e.to_str()),
228 Some("yaml" | "yml")
229 )
230}
231
232fn validate_file(path: &std::path::Path, _strict: bool) -> Result<()> {
233 let _content = read_yaml_file(path)?;
234 Ok(())
235}
236
237fn detect_section(path: &std::path::Path) -> String {
238 let path_str = path.display().to_string();
239
240 if path_str.contains("/ai/") {
241 "ai".to_owned()
242 } else if path_str.contains("/content/") {
243 "content".to_owned()
244 } else if path_str.contains("/web/") {
245 "web".to_owned()
246 } else if path_str.contains("/scheduler/") {
247 "scheduler".to_owned()
248 } else if path_str.contains("/agents/") {
249 "agents".to_owned()
250 } else if path_str.contains("/mcp/") {
251 "mcp".to_owned()
252 } else if path_str.contains("/skills/") {
253 "skills".to_owned()
254 } else if path_str.contains("profile.yaml") {
255 "profile".to_owned()
256 } else if path_str.contains("/config/config.yaml") {
257 "services".to_owned()
258 } else {
259 "unknown".to_owned()
260 }
261}