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