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