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//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use anyhow::{Result, anyhow};
13use clap::Args;
14
15use super::types::{ConfigFileInfo, ConfigSection, ConfigValidateOutput, read_yaml_file};
16use crate::CliConfig;
17use crate::shared::CommandOutput;
18use systemprompt_logging::CliService;
19use systemprompt_models::profile::Profile;
20
21#[derive(Debug, Clone, Args)]
22pub struct ValidateArgs {
23    #[arg(value_name = "PATH_OR_SECTION")]
24    pub target: Option<String>,
25
26    #[arg(long)]
27    pub strict: bool,
28
29    #[arg(
30        long,
31        help = "Print the generated JSON schema for the Profile config type instead of validating \
32                any file"
33    )]
34    pub schema: bool,
35}
36
37pub fn execute(args: &ValidateArgs, _config: &CliConfig) -> Result<(CommandOutput, bool)> {
38    if args.schema {
39        return print_profile_schema();
40    }
41
42    if let Some(target) = &args.target {
43        let path = std::path::PathBuf::from(target);
44        if path.exists() && is_yaml_file(&path) && target.parse::<ConfigSection>().is_err() {
45            return validate_profile_file(&path);
46        }
47    }
48
49    let files_to_validate = if let Some(target) = &args.target {
50        if let Ok(section) = target.parse::<ConfigSection>() {
51            section.all_files()?
52        } else {
53            vec![std::path::PathBuf::from(target)]
54        }
55    } else {
56        let mut all_files = Vec::new();
57        for section in ConfigSection::all() {
58            if let Ok(files) = section.all_files() {
59                all_files.extend(files);
60            }
61        }
62        all_files
63    };
64
65    let mut results = Vec::new();
66    let mut all_valid = true;
67
68    for file_path in files_to_validate {
69        let section = detect_section(&file_path);
70        let exists = file_path.exists();
71
72        let (valid, error) = if exists {
73            match validate_file(&file_path, args.strict) {
74                Ok(()) => (true, None),
75                Err(e) => {
76                    all_valid = false;
77                    (false, Some(e.to_string()))
78                },
79            }
80        } else {
81            all_valid = false;
82            (false, Some("File not found".to_owned()))
83        };
84
85        results.push(ConfigFileInfo {
86            path: file_path.display().to_string(),
87            section,
88            exists,
89            valid,
90            error,
91        });
92    }
93
94    let output = ConfigValidateOutput {
95        files: results,
96        all_valid,
97    };
98
99    let title = if all_valid {
100        "Validation Passed"
101    } else {
102        "Validation Failed"
103    };
104
105    Ok((
106        CommandOutput::table_of(
107            vec!["path", "section", "exists", "valid", "error"],
108            &output.files,
109        )
110        .with_title(title),
111        all_valid,
112    ))
113}
114
115fn print_profile_schema() -> Result<(CommandOutput, bool)> {
116    let schema = schemars::schema_for!(Profile);
117    let json = serde_json::to_string_pretty(&schema)
118        .map_err(|e| anyhow!("failed to serialize Profile JSON schema: {e}"))?;
119    CliService::output(&json);
120
121    let output = ConfigValidateOutput {
122        files: Vec::new(),
123        all_valid: true,
124    };
125    Ok((
126        CommandOutput::table_of(
127            vec!["path", "section", "exists", "valid", "error"],
128            &output.files,
129        )
130        .with_skip_render(),
131        true,
132    ))
133}
134
135fn validate_profile_file(path: &std::path::Path) -> Result<(CommandOutput, bool)> {
136    let content = std::fs::read_to_string(path)
137        .map_err(|e| anyhow!("failed to read profile {}: {e}", path.display()))?;
138
139    match Profile::from_yaml(&content, path) {
140        Ok(profile) => {
141            let output = ConfigValidateOutput {
142                files: vec![ConfigFileInfo {
143                    path: path.display().to_string(),
144                    section: "profile".to_owned(),
145                    exists: true,
146                    valid: true,
147                    error: None,
148                }],
149                all_valid: true,
150            };
151            let title = format!("Profile '{}' is valid", profile.name);
152            Ok((
153                CommandOutput::table_of(
154                    vec!["path", "section", "exists", "valid", "error"],
155                    &output.files,
156                )
157                .with_title(title),
158                true,
159            ))
160        },
161        Err(e) => Err(anyhow!(
162            "invalid profile {}: {e}\nThe error above names the offending field or value — fix it \
163             and re-run.",
164            path.display()
165        )),
166    }
167}
168
169fn is_yaml_file(path: &std::path::Path) -> bool {
170    matches!(
171        path.extension().and_then(|e| e.to_str()),
172        Some("yaml" | "yml")
173    )
174}
175
176fn validate_file(path: &std::path::Path, _strict: bool) -> Result<()> {
177    let _content = read_yaml_file(path)?;
178    Ok(())
179}
180
181fn detect_section(path: &std::path::Path) -> String {
182    let path_str = path.display().to_string();
183
184    if path_str.contains("/ai/") {
185        "ai".to_owned()
186    } else if path_str.contains("/content/") {
187        "content".to_owned()
188    } else if path_str.contains("/web/") {
189        "web".to_owned()
190    } else if path_str.contains("/scheduler/") {
191        "scheduler".to_owned()
192    } else if path_str.contains("/agents/") {
193        "agents".to_owned()
194    } else if path_str.contains("/mcp/") {
195        "mcp".to_owned()
196    } else if path_str.contains("/skills/") {
197        "skills".to_owned()
198    } else if path_str.contains("profile.yaml") {
199        "profile".to_owned()
200    } else if path_str.contains("/config/config.yaml") {
201        "services".to_owned()
202    } else {
203        "unknown".to_owned()
204    }
205}