systemprompt_cli/commands/admin/config/
validate.rs1use 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 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_owned()))
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_owned(),
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_owned()
159 } else if path_str.contains("/content/") {
160 "content".to_owned()
161 } else if path_str.contains("/web/") {
162 "web".to_owned()
163 } else if path_str.contains("/scheduler/") {
164 "scheduler".to_owned()
165 } else if path_str.contains("/agents/") {
166 "agents".to_owned()
167 } else if path_str.contains("/mcp/") {
168 "mcp".to_owned()
169 } else if path_str.contains("/skills/") {
170 "skills".to_owned()
171 } else if path_str.contains("profile.yaml") {
172 "profile".to_owned()
173 } else if path_str.contains("/config/config.yaml") {
174 "services".to_owned()
175 } else {
176 "unknown".to_owned()
177 }
178}