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