code_digest/
cli.rs

1//! Command-line interface configuration and parsing
2
3use clap::{Parser, ValueEnum};
4use std::path::PathBuf;
5
6/// Supported LLM CLI tools
7#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
8pub enum LlmTool {
9    /// Use gemini (default)
10    #[value(name = "gemini")]
11    #[default]
12    Gemini,
13    /// Use codex CLI
14    #[value(name = "codex")]
15    Codex,
16}
17
18impl LlmTool {
19    /// Get the command name for the tool
20    pub fn command(&self) -> &'static str {
21        match self {
22            LlmTool::Gemini => "gemini",
23            LlmTool::Codex => "codex",
24        }
25    }
26
27    /// Get the installation instructions for the tool
28    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/// High-performance CLI tool to convert codebases to Markdown for LLM context
39#[derive(Parser, Debug, Clone)]
40#[command(author, version, about, long_about = None)]
41pub struct Config {
42    /// Directories to process (positional)
43    #[arg(value_name = "DIRECTORIES", num_args = 0..)]
44    pub directories_positional: Vec<PathBuf>,
45
46    /// The prompt to send to the LLM. If omitted, only generates the Markdown context
47    #[arg(value_name = "PROMPT", last = true, conflicts_with = "prompt_flag")]
48    pub prompt: Option<String>,
49
50    /// The prompt to send to the LLM (use when prompt contains spaces)
51    #[arg(short = 'p', long = "prompt", conflicts_with = "prompt")]
52    pub prompt_flag: Option<String>,
53
54    /// The paths to the directories to process
55    #[arg(short = 'd', long, num_args = 1.., conflicts_with = "repo")]
56    pub directories: Vec<PathBuf>,
57
58    /// GitHub repository URL to analyze (e.g., <https://github.com/owner/repo>)
59    #[arg(long, conflicts_with = "directories")]
60    pub repo: Option<String>,
61
62    /// Read prompt from stdin
63    #[arg(long = "stdin")]
64    pub read_stdin: bool,
65
66    /// The path to the output Markdown file. If used, won't call the LLM CLI
67    #[arg(short = 'o', long)]
68    pub output_file: Option<PathBuf>,
69
70    /// Maximum number of tokens for the generated codebase context
71    #[arg(long)]
72    pub max_tokens: Option<usize>,
73
74    /// LLM CLI tool to use for processing
75    #[arg(short = 't', long = "tool", default_value = "gemini")]
76    pub llm_tool: LlmTool,
77
78    /// Suppress all output except for errors and the final LLM response
79    #[arg(short = 'q', long)]
80    pub quiet: bool,
81
82    /// Enable verbose logging
83    #[arg(short = 'v', long)]
84    pub verbose: bool,
85
86    /// Path to configuration file
87    #[arg(short = 'c', long)]
88    pub config: Option<PathBuf>,
89
90    /// Show progress indicators during processing
91    #[arg(long)]
92    pub progress: bool,
93}
94
95impl Config {
96    /// Validate the configuration
97    pub fn validate(&self) -> Result<(), crate::utils::error::CodeDigestError> {
98        use crate::utils::error::CodeDigestError;
99
100        // Validate repo URL if provided
101        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            // Only validate directories if repo is not provided
112            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        // Validate output file parent directory exists if specified
130        if let Some(output) = &self.output_file {
131            if let Some(parent) = output.parent() {
132                // Handle empty parent (current directory) and check if parent exists
133                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        // Validate mutually exclusive options
143        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    /// Load configuration from file if specified
153    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            // Load from specified config file
158            Some(ConfigFile::load_from_file(config_path)?)
159        } else {
160            // Try to load from default locations
161            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    /// Get the actual prompt from various sources
180    pub fn get_prompt(&self) -> Option<String> {
181        // First check explicit prompt flag
182        if let Some(prompt) = &self.prompt_flag {
183            return Some(prompt.clone());
184        }
185
186        // Then check the prompt positional argument
187        if let Some(prompt) = &self.prompt {
188            // Don't return empty strings as prompts
189            if !prompt.trim().is_empty() {
190                return Some(prompt.clone());
191            }
192        }
193
194        // For backward compatibility: check if the last directory argument is actually a prompt
195        // This should only apply when using old syntax: -d dir1 "prompt text"
196        // Not when using new multi-directory syntax: -d dir1 dir2
197        if self.directories.len() > 1 && self.prompt.is_none() && self.prompt_flag.is_none() {
198            let last = self.directories.last().unwrap();
199            // Only treat as prompt if it doesn't exist AND doesn't look like a path
200            if !last.exists() {
201                let path_str = last.to_string_lossy();
202                // Check if it looks like a path (contains separators or common path patterns)
203                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    /// Get all directories from both sources
219    pub fn get_directories(&self) -> Vec<PathBuf> {
220        let mut dirs = self.directories.clone();
221
222        // For backward compatibility: remove last directory if it's being used as a prompt
223        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                // Check if it looks like a path (contains separators or common path patterns)
228                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        // Add positional directories
241        dirs.extend(self.directories_positional.clone());
242
243        // If no directories specified, use current directory
244        if dirs.is_empty() {
245            vec![PathBuf::from(".")]
246        } else {
247            dirs
248        }
249    }
250
251    /// Check if we should read from stdin
252    pub fn should_read_stdin(&self) -> bool {
253        use std::io::IsTerminal;
254
255        // Explicitly requested stdin
256        if self.read_stdin {
257            return true;
258        }
259
260        // If stdin is not a terminal (i.e., it's piped) and no prompt is provided
261        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        // Should not error for files in current directory
418        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        // Should not error when no config file is found
441        assert!(config.load_from_file().is_ok());
442    }
443
444    #[test]
445    fn test_parse_multiple_directories() {
446        use clap::Parser;
447
448        // Test single directory (backward compatibility)
449        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        // Test single directory (backward compatibility)
460        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        // Test multiple directories
466        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        // Test with prompt after directories using -- separator
474        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        // All directories exist - should succeed
496        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        // One directory doesn't exist - should fail
514        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        // Mix of directory and file - should fail
541        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}