1use clap::{Parser, ValueEnum};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
8pub enum LlmTool {
9 #[value(name = "gemini")]
11 #[default]
12 Gemini,
13 #[value(name = "codex")]
15 Codex,
16}
17
18impl LlmTool {
19 pub fn command(&self) -> &'static str {
21 match self {
22 LlmTool::Gemini => "gemini",
23 LlmTool::Codex => "codex",
24 }
25 }
26
27 pub fn install_instructions(&self) -> &'static str {
29 match self {
30 LlmTool::Gemini => "Please install gemini with: pip install gemini",
31 LlmTool::Codex => {
32 "Please install codex CLI from: https://github.com/microsoft/codex-cli"
33 }
34 }
35 }
36}
37
38#[derive(Parser, Debug, Clone)]
40#[command(author, version, about, long_about = None)]
41pub struct Config {
42 #[arg(value_name = "DIRECTORIES", num_args = 0..)]
44 pub directories_positional: Vec<PathBuf>,
45
46 #[arg(value_name = "PROMPT", last = true, conflicts_with = "prompt_flag")]
48 pub prompt: Option<String>,
49
50 #[arg(short = 'p', long = "prompt", conflicts_with = "prompt")]
52 pub prompt_flag: Option<String>,
53
54 #[arg(short = 'd', long, num_args = 1.., conflicts_with = "repo")]
56 pub directories: Vec<PathBuf>,
57
58 #[arg(long, conflicts_with = "directories")]
60 pub repo: Option<String>,
61
62 #[arg(long = "stdin")]
64 pub read_stdin: bool,
65
66 #[arg(short = 'o', long)]
68 pub output_file: Option<PathBuf>,
69
70 #[arg(long)]
72 pub max_tokens: Option<usize>,
73
74 #[arg(short = 't', long = "tool", default_value = "gemini")]
76 pub llm_tool: LlmTool,
77
78 #[arg(short = 'q', long)]
80 pub quiet: bool,
81
82 #[arg(short = 'v', long)]
84 pub verbose: bool,
85
86 #[arg(short = 'c', long)]
88 pub config: Option<PathBuf>,
89
90 #[arg(long)]
92 pub progress: bool,
93}
94
95impl Config {
96 pub fn validate(&self) -> Result<(), crate::utils::error::CodeDigestError> {
98 use crate::utils::error::CodeDigestError;
99
100 if let Some(repo_url) = &self.repo {
102 if !repo_url.starts_with("https://github.com/")
103 && !repo_url.starts_with("http://github.com/")
104 {
105 return Err(CodeDigestError::InvalidConfiguration(
106 "Repository URL must be a GitHub URL (https://github.com/owner/repo)"
107 .to_string(),
108 ));
109 }
110 } else {
111 for directory in &self.directories {
113 if !directory.exists() {
114 return Err(CodeDigestError::InvalidPath(format!(
115 "Directory does not exist: {}",
116 directory.display()
117 )));
118 }
119
120 if !directory.is_dir() {
121 return Err(CodeDigestError::InvalidPath(format!(
122 "Path is not a directory: {}",
123 directory.display()
124 )));
125 }
126 }
127 }
128
129 if let Some(output) = &self.output_file {
131 if let Some(parent) = output.parent() {
132 if !parent.as_os_str().is_empty() && !parent.exists() {
134 return Err(CodeDigestError::InvalidPath(format!(
135 "Output directory does not exist: {}",
136 parent.display()
137 )));
138 }
139 }
140 }
141
142 if self.output_file.is_some() && self.get_prompt().is_some() {
144 return Err(CodeDigestError::InvalidConfiguration(
145 "Cannot specify both --output and a prompt".to_string(),
146 ));
147 }
148
149 Ok(())
150 }
151
152 pub fn load_from_file(&mut self) -> Result<(), crate::utils::error::CodeDigestError> {
154 use crate::config::ConfigFile;
155
156 let config_file = if let Some(ref config_path) = self.config {
157 Some(ConfigFile::load_from_file(config_path)?)
159 } else {
160 ConfigFile::load_default()?
162 };
163
164 if let Some(config_file) = config_file {
165 config_file.apply_to_cli_config(self);
166
167 if self.verbose {
168 if let Some(ref config_path) = self.config {
169 eprintln!("📄 Loaded configuration from: {}", config_path.display());
170 } else {
171 eprintln!("📄 Loaded configuration from default location");
172 }
173 }
174 }
175
176 Ok(())
177 }
178
179 pub fn get_prompt(&self) -> Option<String> {
181 if let Some(prompt) = &self.prompt_flag {
183 return Some(prompt.clone());
184 }
185
186 if let Some(prompt) = &self.prompt {
188 if !prompt.trim().is_empty() {
190 return Some(prompt.clone());
191 }
192 }
193
194 if self.directories.len() > 1 && self.prompt.is_none() && self.prompt_flag.is_none() {
198 let last = self.directories.last().unwrap();
199 if !last.exists() {
201 let path_str = last.to_string_lossy();
202 if !path_str.contains('/')
204 && !path_str.contains('\\')
205 && !path_str.starts_with('.')
206 && !path_str.contains("project")
207 && !path_str.contains("_dir")
208 && !path_str.contains("tmp")
209 {
210 return Some(path_str.to_string());
211 }
212 }
213 }
214
215 None
216 }
217
218 pub fn get_directories(&self) -> Vec<PathBuf> {
220 let mut dirs = self.directories.clone();
221
222 if dirs.len() > 1 && self.prompt.is_none() && self.prompt_flag.is_none() {
224 let last = dirs.last().unwrap();
225 if !last.exists() {
226 let path_str = last.to_string_lossy();
227 if !path_str.contains('/')
229 && !path_str.contains('\\')
230 && !path_str.starts_with('.')
231 && !path_str.contains("project")
232 && !path_str.contains("_dir")
233 && !path_str.contains("tmp")
234 {
235 dirs.pop();
236 }
237 }
238 }
239
240 dirs.extend(self.directories_positional.clone());
242
243 if dirs.is_empty() {
245 vec![PathBuf::from(".")]
246 } else {
247 dirs
248 }
249 }
250
251 pub fn should_read_stdin(&self) -> bool {
253 use std::io::IsTerminal;
254
255 if self.read_stdin {
257 return true;
258 }
259
260 if !std::io::stdin().is_terminal() && self.get_prompt().is_none() {
262 return true;
263 }
264
265 false
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use std::fs;
273 use tempfile::TempDir;
274
275 #[test]
276 fn test_config_validation_valid_directory() {
277 let temp_dir = TempDir::new().unwrap();
278 let config = Config {
279 prompt: None,
280 prompt_flag: None,
281 directories: vec![temp_dir.path().to_path_buf()],
282 directories_positional: vec![],
283 repo: None,
284 read_stdin: false,
285 output_file: None,
286 max_tokens: None,
287 llm_tool: LlmTool::default(),
288 quiet: false,
289 verbose: false,
290 config: None,
291 progress: false,
292 };
293
294 assert!(config.validate().is_ok());
295 }
296
297 #[test]
298 fn test_config_validation_invalid_directory() {
299 let config = Config {
300 prompt: None,
301 prompt_flag: None,
302 directories: vec![PathBuf::from("/nonexistent/directory")],
303 directories_positional: vec![],
304 repo: None,
305 read_stdin: false,
306 output_file: None,
307 max_tokens: None,
308 llm_tool: LlmTool::default(),
309 quiet: false,
310 verbose: false,
311 config: None,
312 progress: false,
313 };
314
315 assert!(config.validate().is_err());
316 }
317
318 #[test]
319 fn test_config_validation_file_as_directory() {
320 let temp_dir = TempDir::new().unwrap();
321 let file_path = temp_dir.path().join("file.txt");
322 fs::write(&file_path, "test").unwrap();
323
324 let config = Config {
325 prompt: None,
326 prompt_flag: None,
327 directories: vec![file_path],
328 directories_positional: vec![],
329 repo: None,
330 read_stdin: false,
331 output_file: None,
332 max_tokens: None,
333 llm_tool: LlmTool::default(),
334 quiet: false,
335 verbose: false,
336 config: None,
337 progress: false,
338 };
339
340 assert!(config.validate().is_err());
341 }
342
343 #[test]
344 fn test_config_validation_invalid_output_directory() {
345 let temp_dir = TempDir::new().unwrap();
346 let config = Config {
347 prompt: None,
348 prompt_flag: None,
349 directories: vec![temp_dir.path().to_path_buf()],
350 directories_positional: vec![],
351 repo: None,
352 read_stdin: false,
353 output_file: Some(PathBuf::from("/nonexistent/directory/output.md")),
354 max_tokens: None,
355 llm_tool: LlmTool::default(),
356 quiet: false,
357 verbose: false,
358 config: None,
359 progress: false,
360 };
361
362 assert!(config.validate().is_err());
363 }
364
365 #[test]
366 fn test_config_validation_mutually_exclusive_options() {
367 let temp_dir = TempDir::new().unwrap();
368 let config = Config {
369 prompt: Some("test prompt".to_string()),
370 prompt_flag: None,
371 directories: vec![temp_dir.path().to_path_buf()],
372 directories_positional: vec![],
373 repo: None,
374 read_stdin: false,
375 output_file: Some(temp_dir.path().join("output.md")),
376 max_tokens: None,
377 llm_tool: LlmTool::default(),
378 quiet: false,
379 verbose: false,
380 config: None,
381 progress: false,
382 };
383
384 assert!(config.validate().is_err());
385 }
386
387 #[test]
388 fn test_llm_tool_enum_values() {
389 assert_eq!(LlmTool::Gemini.command(), "gemini");
390 assert_eq!(LlmTool::Codex.command(), "codex");
391
392 assert!(LlmTool::Gemini.install_instructions().contains("pip install"));
393 assert!(LlmTool::Codex.install_instructions().contains("github.com"));
394
395 assert_eq!(LlmTool::default(), LlmTool::Gemini);
396 }
397
398 #[test]
399 fn test_config_validation_output_file_in_current_dir() {
400 let temp_dir = TempDir::new().unwrap();
401 let config = Config {
402 prompt: None,
403 prompt_flag: None,
404 directories: vec![temp_dir.path().to_path_buf()],
405 directories_positional: vec![],
406 repo: None,
407 read_stdin: false,
408 output_file: Some(PathBuf::from("output.md")),
409 max_tokens: None,
410 llm_tool: LlmTool::default(),
411 quiet: false,
412 verbose: false,
413 config: None,
414 progress: false,
415 };
416
417 assert!(config.validate().is_ok());
419 }
420
421 #[test]
422 fn test_config_load_from_file_no_config() {
423 let temp_dir = TempDir::new().unwrap();
424 let mut config = Config {
425 prompt: None,
426 prompt_flag: None,
427 directories: vec![temp_dir.path().to_path_buf()],
428 directories_positional: vec![],
429 repo: None,
430 read_stdin: false,
431 output_file: None,
432 max_tokens: None,
433 llm_tool: LlmTool::default(),
434 quiet: false,
435 verbose: false,
436 config: None,
437 progress: false,
438 };
439
440 assert!(config.load_from_file().is_ok());
442 }
443
444 #[test]
445 fn test_parse_multiple_directories() {
446 use clap::Parser;
447
448 let args = vec!["code-digest", "-d", "/path/one"];
450 let config = Config::parse_from(args);
451 assert_eq!(config.directories.len(), 1);
452 assert_eq!(config.directories[0], PathBuf::from("/path/one"));
453 }
454
455 #[test]
456 fn test_parse_multiple_directories_new_api() {
457 use clap::Parser;
458
459 let args = vec!["code-digest", "-d", "/path/one"];
461 let config = Config::parse_from(args);
462 assert_eq!(config.directories.len(), 1);
463 assert_eq!(config.directories[0], PathBuf::from("/path/one"));
464
465 let args = vec!["code-digest", "-d", "/path/one", "/path/two", "/path/three"];
467 let config = Config::parse_from(args);
468 assert_eq!(config.directories.len(), 3);
469 assert_eq!(config.directories[0], PathBuf::from("/path/one"));
470 assert_eq!(config.directories[1], PathBuf::from("/path/two"));
471 assert_eq!(config.directories[2], PathBuf::from("/path/three"));
472
473 let args = vec![
475 "code-digest",
476 "-d",
477 "/src/module1",
478 "/src/module2",
479 "--",
480 "Find duplicated patterns",
481 ];
482 let config = Config::parse_from(args);
483 assert_eq!(config.directories.len(), 2);
484 assert_eq!(config.prompt, Some("Find duplicated patterns".to_string()));
485 }
486
487 #[test]
488 fn test_validate_multiple_directories() {
489 let temp_dir = TempDir::new().unwrap();
490 let dir1 = temp_dir.path().join("dir1");
491 let dir2 = temp_dir.path().join("dir2");
492 fs::create_dir(&dir1).unwrap();
493 fs::create_dir(&dir2).unwrap();
494
495 let config = Config {
497 prompt: None,
498 prompt_flag: None,
499 directories: vec![dir1.clone(), dir2.clone()],
500 directories_positional: vec![],
501 repo: None,
502 read_stdin: false,
503 output_file: None,
504 max_tokens: None,
505 llm_tool: LlmTool::default(),
506 quiet: false,
507 verbose: false,
508 config: None,
509 progress: false,
510 };
511 assert!(config.validate().is_ok());
512
513 let config = Config {
515 prompt: None,
516 prompt_flag: None,
517 directories: vec![dir1, PathBuf::from("/nonexistent/dir")],
518 directories_positional: vec![],
519 repo: None,
520 read_stdin: false,
521 output_file: None,
522 max_tokens: None,
523 llm_tool: LlmTool::default(),
524 quiet: false,
525 verbose: false,
526 config: None,
527 progress: false,
528 };
529 assert!(config.validate().is_err());
530 }
531
532 #[test]
533 fn test_validate_files_as_directories() {
534 let temp_dir = TempDir::new().unwrap();
535 let dir1 = temp_dir.path().join("dir1");
536 let file1 = temp_dir.path().join("file.txt");
537 fs::create_dir(&dir1).unwrap();
538 fs::write(&file1, "test content").unwrap();
539
540 let config = Config {
542 prompt: None,
543 prompt_flag: None,
544 directories: vec![dir1, file1],
545 directories_positional: vec![],
546 repo: None,
547 read_stdin: false,
548 output_file: None,
549 max_tokens: None,
550 llm_tool: LlmTool::default(),
551 quiet: false,
552 verbose: false,
553 config: None,
554 progress: false,
555 };
556 assert!(config.validate().is_err());
557 }
558}