1use std::path::PathBuf;
8
9use crate::spec::{validate_max_graphs, DEFAULT_MAX_GRAPHS};
10
11#[derive(Debug, Clone)]
13pub struct CliConfig {
14 pub base_url: String,
15 pub default_model: String,
16 pub data_dir: Option<PathBuf>,
17 pub integrity_key_path: Option<PathBuf>,
18 pub require_integrity_key: bool,
19 pub ephemeral: bool,
20 pub max_graphs: usize,
21}
22
23impl Default for CliConfig {
24 fn default() -> Self {
25 Self {
26 base_url: "http://127.0.0.1:11434".to_string(),
27 default_model: "glm-5.2:cloud".to_string(),
28 data_dir: None,
29 integrity_key_path: None,
30 require_integrity_key: false,
31 ephemeral: false,
32 max_graphs: DEFAULT_MAX_GRAPHS,
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
40pub struct CliError {
41 pub message: String,
42 pub exit_code: i32,
43}
44
45impl CliError {
46 pub fn new(message: impl Into<String>) -> Self {
47 Self {
48 message: message.into(),
49 exit_code: 2,
50 }
51 }
52}
53
54impl std::fmt::Display for CliError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "{}", self.message)
57 }
58}
59
60impl std::error::Error for CliError {}
61
62#[derive(Debug, Clone)]
63pub struct ProxyConfig {
64 pub socket: PathBuf,
65 pub timeout_ms: u64,
66}
67pub fn parse_proxy_args(args: &[String]) -> Result<ProxyConfig, CliError> {
68 let mut socket = std::env::var_os("XDG_RUNTIME_DIR")
69 .map(PathBuf::from)
70 .unwrap_or_else(|| PathBuf::from("/tmp"))
71 .join("agent-graph/mcp.sock");
72 let mut timeout_ms = 2000;
73 let mut i = 0;
74 while i < args.len() {
75 match args[i].as_str() {
76 "--help" => {
77 return Err(CliError {
78 message: "agent-graph-mcp [--socket PATH] [--connect-timeout-ms N]".into(),
79 exit_code: 0,
80 });
81 }
82 "--version" => {
83 return Err(CliError {
84 message: env!("CARGO_PKG_VERSION").into(),
85 exit_code: 0,
86 });
87 }
88 "--socket" => {
89 i += 1;
90 socket = PathBuf::from(
91 args.get(i)
92 .ok_or_else(|| CliError::new("--socket requires a value"))?,
93 );
94 }
95 "--connect-timeout-ms" => {
96 i += 1;
97 timeout_ms = args
98 .get(i)
99 .ok_or_else(|| CliError::new("--connect-timeout-ms requires a value"))?
100 .parse()
101 .map_err(|_| CliError::new("invalid timeout"))?;
102 }
103 "--data-dir" | "--integrity-key" | "--base-url" | "--model" | "--max-graphs" => {
104 return Err(CliError::new("LEGACY_DIRECT_DURABLE_UNSUPPORTED"));
105 }
106 other => return Err(CliError::new(format!("unknown argument: '{other}'"))),
107 }
108 i += 1;
109 }
110 Ok(ProxyConfig { socket, timeout_ms })
111}
112
113pub fn parse_args(args: &[String]) -> Result<CliConfig, CliError> {
123 let mut config = CliConfig::default();
124 let mut iter = args.iter().peekable();
125
126 while let Some(arg) = iter.next() {
127 match arg.as_str() {
128 "--base-url" => {
129 let value = iter
130 .next()
131 .ok_or_else(|| CliError::new("--base-url requires a value"))?;
132 validate_url(value)?;
133 config.base_url = value.clone();
134 }
135 "--model" => {
136 let value = iter
137 .next()
138 .ok_or_else(|| CliError::new("--model requires a value"))?;
139 if value.is_empty() {
140 return Err(CliError::new("--model value must not be empty"));
141 }
142 config.default_model = value.clone();
143 }
144 "--data-dir" => {
145 let value = iter
146 .next()
147 .ok_or_else(|| CliError::new("--data-dir requires a value"))?;
148 if value.is_empty() {
149 return Err(CliError::new("--data-dir value must not be empty"));
150 }
151 config.data_dir = Some(PathBuf::from(value));
152 }
153 "--integrity-key" => {
154 let value = iter
155 .next()
156 .ok_or_else(|| CliError::new("--integrity-key requires a value"))?;
157 if value.is_empty() {
158 return Err(CliError::new("--integrity-key value must not be empty"));
159 }
160 config.integrity_key_path = Some(PathBuf::from(value));
161 }
162 "--require-integrity-key" => {
163 config.require_integrity_key = true;
164 }
165 "--ephemeral" => {
166 config.ephemeral = true;
167 }
168 "--max-graphs" => {
169 let value = iter
170 .next()
171 .ok_or_else(|| CliError::new("--max-graphs requires a value"))?;
172 let max_graphs = value
173 .parse::<usize>()
174 .map_err(|_| CliError::new("--max-graphs must be an integer"))?;
175 config.max_graphs = validate_max_graphs(max_graphs).map_err(CliError::new)?;
176 }
177 "--help" => {
178 eprintln!("agent-graph-mcp [OPTIONS]");
179 eprintln!();
180 eprintln!("Options:");
181 eprintln!(
182 " --base-url <url> Provider URL (default: http://127.0.0.1:11434)"
183 );
184 eprintln!(
185 " --model <name> Default model for LLM nodes (default: glm-5.2:cloud)"
186 );
187 eprintln!(" --data-dir <path> Persistent storage directory");
188 eprintln!(" --integrity-key <path> Integrity key file for durable mode");
189 eprintln!(
190 " --require-integrity-key Fail startup if integrity key is missing/unreadable"
191 );
192 eprintln!(" --ephemeral Explicit in-memory mode (no persistence)");
193 eprintln!(
194 " --max-graphs <n> Registered graph capacity (1-1024; default: 64)"
195 );
196 eprintln!(" --help Show this help message");
197 return Err(CliError {
198 message: String::new(),
199 exit_code: 0,
200 });
201 }
202 _ => {
203 return Err(CliError::new(format!(
204 "unknown argument: '{arg}' — use --help for usage"
205 )));
206 }
207 }
208 }
209
210 if config.ephemeral && config.data_dir.is_some() {
212 return Err(CliError::new(
213 "--ephemeral and --data-dir are mutually exclusive",
214 ));
215 }
216
217 if config.require_integrity_key && config.data_dir.is_none() {
218 return Err(CliError::new(
219 "--require-integrity-key requires --data-dir (durable mode)",
220 ));
221 }
222
223 Ok(config)
224}
225
226fn validate_url(url: &str) -> Result<(), CliError> {
228 let (scheme, rest) = url
229 .split_once("://")
230 .ok_or_else(|| CliError::new(format!("invalid URL (no scheme): {url}")))?;
231
232 if scheme != "http" && scheme != "https" {
233 return Err(CliError::new(format!(
234 "unsupported URL scheme '{scheme}': only http and https are allowed"
235 )));
236 }
237
238 let authority = rest.split_once('/').map(|(auth, _)| auth).unwrap_or(rest);
239 let host = authority
240 .rsplit_once('@')
241 .map(|(_, host)| host)
242 .unwrap_or(authority);
243
244 if host.is_empty() {
245 return Err(CliError::new(format!("invalid URL (empty host): {url}")));
246 }
247
248 Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn args(flags: &[&str]) -> Vec<String> {
256 flags.iter().map(|s| s.to_string()).collect()
257 }
258
259 #[test]
260 fn test_unknown_flag_rejected() {
261 let result = parse_args(&args(&["--dat-dir", "/secure/path"]));
262 assert!(result.is_err());
263 assert!(result.unwrap_err().message.contains("unknown argument"));
264 }
265
266 #[test]
267 fn test_missing_value_for_base_url() {
268 let result = parse_args(&args(&["--base-url"]));
269 assert!(result.is_err());
270 assert!(result.unwrap_err().message.contains("requires a value"));
271 }
272
273 #[test]
274 fn test_missing_value_for_model() {
275 let result = parse_args(&args(&["--model"]));
276 assert!(result.is_err());
277 assert!(result.unwrap_err().message.contains("requires a value"));
278 }
279
280 #[test]
281 fn test_missing_value_for_data_dir() {
282 let result = parse_args(&args(&["--data-dir"]));
283 assert!(result.is_err());
284 assert!(result.unwrap_err().message.contains("requires a value"));
285 }
286
287 #[test]
288 fn test_empty_model_value_rejected() {
289 let result = parse_args(&args(&["--model", ""]));
290 assert!(result.is_err());
291 assert!(result.unwrap_err().message.contains("must not be empty"));
292 }
293
294 #[test]
295 fn test_empty_data_dir_value_rejected() {
296 let result = parse_args(&args(&["--data-dir", ""]));
297 assert!(result.is_err());
298 assert!(result.unwrap_err().message.contains("must not be empty"));
299 }
300
301 #[test]
302 fn test_non_http_url_rejected() {
303 let result = parse_args(&args(&["--base-url", "ftp://example.com"]));
304 assert!(result.is_err());
305 assert!(result
306 .unwrap_err()
307 .message
308 .contains("unsupported URL scheme"));
309 }
310
311 #[test]
312 fn test_no_scheme_url_rejected() {
313 let result = parse_args(&args(&["--base-url", "example.com"]));
314 assert!(result.is_err());
315 assert!(result.unwrap_err().message.contains("no scheme"));
316 }
317
318 #[test]
319 fn test_empty_host_url_rejected() {
320 let result = parse_args(&args(&["--base-url", "http://"]));
321 assert!(result.is_err());
322 assert!(result.unwrap_err().message.contains("empty host"));
323 }
324
325 #[test]
326 fn test_valid_http_url_accepted() {
327 let result = parse_args(&args(&["--base-url", "http://127.0.0.1:11434"]));
328 assert!(result.is_ok());
329 assert_eq!(result.unwrap().base_url, "http://127.0.0.1:11434");
330 }
331
332 #[test]
333 fn test_valid_https_url_accepted() {
334 let result = parse_args(&args(&["--base-url", "https://api.openai.com"]));
335 assert!(result.is_ok());
336 assert_eq!(result.unwrap().base_url, "https://api.openai.com");
337 }
338
339 #[test]
340 fn test_url_with_credentials_stripped_in_validation() {
341 let result = parse_args(&args(&["--base-url", "https://user:pass@host.com"]));
342 assert!(result.is_ok()); }
344
345 #[test]
346 fn test_ephemeral_and_data_dir_mutually_exclusive() {
347 let result = parse_args(&args(&["--ephemeral", "--data-dir", "/tmp/test"]));
348 assert!(result.is_err());
349 assert!(result.unwrap_err().message.contains("mutually exclusive"));
350 }
351
352 #[test]
353 fn test_require_integrity_key_without_data_dir_rejected() {
354 let result = parse_args(&args(&["--require-integrity-key"]));
355 assert!(result.is_err());
356 assert!(result.unwrap_err().message.contains("requires --data-dir"));
357 }
358
359 #[test]
360 fn test_require_integrity_key_with_data_dir_accepted() {
361 let result = parse_args(&args(&[
362 "--require-integrity-key",
363 "--data-dir",
364 "/tmp/test",
365 "--integrity-key",
366 "/tmp/key",
367 ]));
368 assert!(result.is_ok());
369 assert!(result.unwrap().require_integrity_key);
370 }
371
372 #[test]
373 fn test_help_returns_exit_zero() {
374 let result = parse_args(&args(&["--help"]));
375 assert!(result.is_err());
376 assert_eq!(result.unwrap_err().exit_code, 0);
377 }
378
379 #[test]
380 fn test_default_config_when_no_args() {
381 let result = parse_args(&[]);
382 assert!(result.is_ok());
383 let config = result.unwrap();
384 assert_eq!(config.base_url, "http://127.0.0.1:11434");
385 assert_eq!(config.default_model, "glm-5.2:cloud");
386 assert!(config.data_dir.is_none());
387 assert!(!config.ephemeral);
388 }
389
390 #[test]
391 fn test_ephemeral_alone_accepted() {
392 let result = parse_args(&args(&["--ephemeral"]));
393 assert!(result.is_ok());
394 assert!(result.unwrap().ephemeral);
395 }
396
397 #[test]
398 fn test_integrity_key_path_recorded() {
399 let result = parse_args(&args(&[
400 "--data-dir",
401 "/tmp/test",
402 "--integrity-key",
403 "/tmp/my-key",
404 ]));
405 assert!(result.is_ok());
406 let config = result.unwrap();
407 assert_eq!(
408 config.integrity_key_path,
409 Some(PathBuf::from("/tmp/my-key"))
410 );
411 }
412
413 #[test]
414 fn test_max_graphs_records_configured_value() {
415 let config = parse_args(&args(&["--max-graphs", "256"])).expect("valid limit");
416 assert_eq!(config.max_graphs, 256);
417 }
418
419 #[test]
420 fn test_zero_max_graphs_is_rejected() {
421 let error = parse_args(&args(&["--max-graphs", "0"])).expect_err("zero is invalid");
422 assert!(error.message.contains("between 1 and"));
423 }
424
425 #[test]
426 fn test_excessive_max_graphs_is_rejected() {
427 let error = parse_args(&args(&["--max-graphs", "1025"])).expect_err("hard cap");
428 assert!(error.message.contains("between 1 and"));
429 }
430}