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_models::profile::Profile`] schema. Also prints the
7//! generated `Profile` JSON schema on demand.
8//!
9//! Whenever the scheduler section is in scope, the merged services config is
10//! additionally cross-checked against the registered job catalog, so a job name
11//! no `submit_job!` registers is caught here instead of aborting scheduler
12//! startup on a deploy. That catalog is the set of jobs linked into the *CLI*
13//! binary; a downstream that links job-registering crates only into its API
14//! binary would see false positives.
15//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19use 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_models::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}