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