Skip to main content

systemprompt_cli/commands/admin/config/
validate.rs

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