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, bool)> {
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((
105        CommandOutput::table_of(
106            vec!["path", "section", "exists", "valid", "error"],
107            &output.files,
108        )
109        .with_title(title),
110        all_valid,
111    ))
112}
113
114fn print_profile_schema() -> Result<(CommandOutput, bool)> {
115    let schema = schemars::schema_for!(Profile);
116    let json = serde_json::to_string_pretty(&schema)
117        .map_err(|e| anyhow!("failed to serialize Profile JSON schema: {e}"))?;
118    CliService::output(&json);
119
120    let output = ConfigValidateOutput {
121        files: Vec::new(),
122        all_valid: true,
123    };
124    Ok((
125        CommandOutput::table_of(
126            vec!["path", "section", "exists", "valid", "error"],
127            &output.files,
128        )
129        .with_skip_render(),
130        true,
131    ))
132}
133
134fn validate_profile_file(path: &std::path::Path) -> Result<(CommandOutput, bool)> {
135    let content = std::fs::read_to_string(path)
136        .map_err(|e| anyhow!("failed to read profile {}: {e}", path.display()))?;
137
138    match Profile::from_yaml(&content, path) {
139        Ok(profile) => {
140            let output = ConfigValidateOutput {
141                files: vec![ConfigFileInfo {
142                    path: path.display().to_string(),
143                    section: "profile".to_owned(),
144                    exists: true,
145                    valid: true,
146                    error: None,
147                }],
148                all_valid: true,
149            };
150            let title = format!("Profile '{}' is valid", profile.name);
151            Ok((
152                CommandOutput::table_of(
153                    vec!["path", "section", "exists", "valid", "error"],
154                    &output.files,
155                )
156                .with_title(title),
157                true,
158            ))
159        },
160        Err(e) => Err(anyhow!(
161            "invalid profile {}: {e}\nThe error above names the offending field or value — fix it \
162             and re-run.",
163            path.display()
164        )),
165    }
166}
167
168fn is_yaml_file(path: &std::path::Path) -> bool {
169    matches!(
170        path.extension().and_then(|e| e.to_str()),
171        Some("yaml" | "yml")
172    )
173}
174
175fn validate_file(path: &std::path::Path, _strict: bool) -> Result<()> {
176    let _content = read_yaml_file(path)?;
177    Ok(())
178}
179
180fn detect_section(path: &std::path::Path) -> String {
181    let path_str = path.display().to_string();
182
183    if path_str.contains("/ai/") {
184        "ai".to_owned()
185    } else if path_str.contains("/content/") {
186        "content".to_owned()
187    } else if path_str.contains("/web/") {
188        "web".to_owned()
189    } else if path_str.contains("/scheduler/") {
190        "scheduler".to_owned()
191    } else if path_str.contains("/agents/") {
192        "agents".to_owned()
193    } else if path_str.contains("/mcp/") {
194        "mcp".to_owned()
195    } else if path_str.contains("/skills/") {
196        "skills".to_owned()
197    } else if path_str.contains("profile.yaml") {
198        "profile".to_owned()
199    } else if path_str.contains("/config/config.yaml") {
200        "services".to_owned()
201    } else {
202        "unknown".to_owned()
203    }
204}