1use crate::canon::{read_canon_auto, Canon};
4use anyhow::Result;
5use tracing::{error, info, warn};
6
7#[derive(Debug)]
9pub struct ValidationError {
10 pub server: Option<String>,
11 pub field: String,
12 pub message: String,
13}
14
15impl std::fmt::Display for ValidationError {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 if let Some(server) = &self.server {
18 write!(f, "servers.{}.{}: {}", server, self.field, self.message)
19 } else {
20 write!(f, "{}: {}", self.field, self.message)
21 }
22 }
23}
24
25pub fn validate_canon(canon: &Canon) -> Vec<ValidationError> {
27 let mut errors = Vec::new();
28
29 for (name, server) in &canon.servers {
30 if let Some(kind) = &server.kind {
32 let k = kind.to_lowercase();
33 if k != "http" && k != "stdio" {
34 errors.push(ValidationError {
35 server: Some(name.clone()),
36 field: "kind".to_string(),
37 message: format!("invalid kind '{}', expected 'http' or 'stdio'", kind),
38 });
39 }
40 }
41
42 let is_http = server.kind() == "http";
44
45 if is_http {
46 if server.url.is_none() {
47 errors.push(ValidationError {
48 server: Some(name.clone()),
49 field: "url".to_string(),
50 message: "http server requires 'url' field".to_string(),
51 });
52 }
53 if server.command.is_some() {
54 errors.push(ValidationError {
55 server: Some(name.clone()),
56 field: "command".to_string(),
57 message: "http server should not have 'command' field".to_string(),
58 });
59 }
60 } else if server.command.is_none() {
61 errors.push(ValidationError {
62 server: Some(name.clone()),
63 field: "command".to_string(),
64 message: "stdio server requires 'command' field".to_string(),
65 });
66 } else if server.url.is_some() {
67 errors.push(ValidationError {
68 server: Some(name.clone()),
69 field: "url".to_string(),
70 message: "stdio server should not have 'url' field".to_string(),
71 });
72 }
73
74 if let Some(url) = &server.url
76 && !url.starts_with("http://") && !url.starts_with("https://") {
77 errors.push(ValidationError {
78 server: Some(name.clone()),
79 field: "url".to_string(),
80 message: "url must start with http:// or https://".to_string(),
81 });
82 }
83 }
84
85 errors
86}
87
88pub fn run(canon_path: &str) -> Result<bool> {
90 info!("Validating {}", canon_path);
91
92 let canon = match read_canon_auto(canon_path) {
94 Ok(c) => c,
95 Err(e) => {
96 error!("Failed to parse: {:#}", e);
97 return Ok(false);
98 }
99 };
100
101 info!("Parsed {} servers, {} plugins",
102 canon.servers.len(),
103 canon.plugins.len()
104 );
105
106 let errors = validate_canon(&canon);
108
109 if errors.is_empty() {
110 info!("✅ Validation passed");
111 Ok(true)
112 } else {
113 for err in &errors {
114 error!("❌ {}", err);
115 }
116 warn!("{} validation error(s) found", errors.len());
117 Ok(false)
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::canon::{Canon, CanonServer};
125 use std::collections::BTreeMap;
126
127 #[test]
128 fn test_validate_valid_stdio_server() {
129 let mut servers = BTreeMap::new();
130 servers.insert("test".to_string(), CanonServer {
131 kind: None,
132 command: Some("echo".to_string()),
133 args: None,
134 env: None,
135 cwd: None,
136 url: None,
137 headers: None,
138 bearer_token_env_var: None,
139 enabled: None,
140 });
141 let canon = Canon { version: Some(1), servers, plugins: vec![] };
142
143 let errors = validate_canon(&canon);
144 assert!(errors.is_empty());
145 }
146
147 #[test]
148 fn test_validate_valid_http_server() {
149 let mut servers = BTreeMap::new();
150 servers.insert("api".to_string(), CanonServer {
151 kind: Some("http".to_string()),
152 command: None,
153 args: None,
154 env: None,
155 cwd: None,
156 url: Some("https://example.com".to_string()),
157 headers: None,
158 bearer_token_env_var: None,
159 enabled: None,
160 });
161 let canon = Canon { version: Some(1), servers, plugins: vec![] };
162
163 let errors = validate_canon(&canon);
164 assert!(errors.is_empty());
165 }
166
167 #[test]
168 fn test_validate_stdio_missing_command() {
169 let mut servers = BTreeMap::new();
170 servers.insert("test".to_string(), CanonServer {
171 kind: None,
172 command: None,
173 args: None,
174 env: None,
175 cwd: None,
176 url: None,
177 headers: None,
178 bearer_token_env_var: None,
179 enabled: None,
180 });
181 let canon = Canon { version: Some(1), servers, plugins: vec![] };
182
183 let errors = validate_canon(&canon);
184 assert_eq!(errors.len(), 1);
185 assert!(errors[0].message.contains("command"));
186 }
187
188 #[test]
189 fn test_validate_http_missing_url() {
190 let mut servers = BTreeMap::new();
191 servers.insert("api".to_string(), CanonServer {
192 kind: Some("http".to_string()),
193 command: None,
194 args: None,
195 env: None,
196 cwd: None,
197 url: None,
198 headers: None,
199 bearer_token_env_var: None,
200 enabled: None,
201 });
202 let canon = Canon { version: Some(1), servers, plugins: vec![] };
203
204 let errors = validate_canon(&canon);
205 assert_eq!(errors.len(), 1);
206 assert!(errors[0].message.contains("url"));
207 }
208
209 #[test]
210 fn test_validate_invalid_url_scheme() {
211 let mut servers = BTreeMap::new();
212 servers.insert("api".to_string(), CanonServer {
213 kind: Some("http".to_string()),
214 command: None,
215 args: None,
216 env: None,
217 cwd: None,
218 url: Some("ftp://example.com".to_string()),
219 headers: None,
220 bearer_token_env_var: None,
221 enabled: None,
222 });
223 let canon = Canon { version: Some(1), servers, plugins: vec![] };
224
225 let errors = validate_canon(&canon);
226 assert!(!errors.is_empty());
227 }
228}