Skip to main content

systemprompt_cli/commands/admin/config/
validate.rs

1//! `admin config validate` command: validate config files against their
2//! schemas.
3//!
4//! Resolves a target to a single file, a [`ConfigSection`], or the full set of
5//! sections, parses each as YAML, and validates a full profile document against
6//! the [`systemprompt_manifest::profile::Profile`] schema. Also prints the
7//! generated `Profile` JSON schema on demand.
8//!
9//! A full validation (no target) also parses every registered config plane's
10//! declaration offline ([`super::plane_validate`]).
11//!
12//! Whenever the scheduler section is in scope, the merged services config is
13//! additionally cross-checked against the registered job catalog, so a job name
14//! no `submit_job!` registers is caught here instead of aborting scheduler
15//! startup on a deploy. That catalog is the set of jobs linked into the *CLI*
16//! binary; a downstream that links job-registering crates only into its API
17//! binary would see false positives.
18//!
19//! Copyright (c) systemprompt.io — Business Source License 1.1.
20//! See <https://systemprompt.io> for licensing details.
21
22use 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}