systemprompt_cli/commands/admin/config/
validate.rs1use 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 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}