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