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 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}