context_creator/
cli.rs

1//! Command-line interface configuration and parsing
2
3use clap::{Parser, ValueEnum};
4use std::path::PathBuf;
5
6/// Help message explaining custom priority rules and usage
7const AFTER_HELP_MSG: &str = "\
8CUSTOM PRIORITY RULES:
9  Custom priority rules are processed in a 'first-match-wins' basis. Rules are 
10  evaluated in the order they are defined in your .context-creator.toml configuration 
11  file. The first rule that matches a given file will be used, and all subsequent 
12  rules will be ignored for that file.
13
14  Example configuration:
15    [[priorities]]
16    pattern = \"src/**/*.rs\"
17    weight = 10.0
18    
19    [[priorities]]  
20    pattern = \"tests/*\"
21    weight = -2.0
22
23USAGE EXAMPLES:
24  # Process current directory with a prompt
25  context-creator --prompt \"Analyze this code\"
26  
27  # Process specific directories (positional arguments)
28  context-creator src/ tests/ docs/
29  
30  # Process specific directories (explicit include flags)
31  context-creator --include src/ --include tests/ --include docs/
32  
33  # Process files matching glob patterns (QUOTE patterns to prevent shell expansion)
34  context-creator --include \"**/*.py\" --include \"src/**/*.{rs,toml}\"
35  
36  # Process specific file types across all directories
37  context-creator --include \"**/*repository*.py\" --include \"**/test[0-9].py\"
38  
39  # Combine prompt with include patterns for targeted analysis
40  context-creator --prompt \"Review security\" --include \"src/auth/**\" --include \"src/security/**\"
41  
42  # Use ignore patterns to exclude unwanted files
43  context-creator --include \"**/*.rs\" --ignore \"target/**\" --ignore \"**/*_test.rs\"
44  
45  # Combine prompt with ignore patterns
46  context-creator --prompt \"Analyze core logic\" --ignore \"tests/**\" --ignore \"docs/**\"
47  
48  # Process a GitHub repository
49  context-creator --repo https://github.com/owner/repo
50  
51  # Read prompt from stdin
52  echo \"Review this code\" | context-creator --stdin .
53  
54  # FLEXIBLE COMBINATIONS (NEW):
55  # Combine prompt with specific directories
56  context-creator --prompt \"Security audit\" src/auth/ src/security/
57  
58  # Combine prompt with GitHub repository
59  context-creator --prompt \"Find bugs\" --repo https://github.com/owner/repo
60  
61  # Combine stdin with specific directories
62  echo \"Analyze patterns\" | context-creator --stdin src/ tests/
63  
64  # Combine include patterns with GitHub repository
65  context-creator --include \"**/*.rs\" --repo https://github.com/owner/repo
66  
67  # Combine stdin with include patterns
68  echo \"Review code\" | context-creator --stdin --include \"**/*.py\"
69";
70
71/// Supported LLM CLI tools
72#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
73pub enum LlmTool {
74    /// Use gemini (default)
75    #[value(name = "gemini")]
76    #[default]
77    Gemini,
78    /// Use codex CLI
79    #[value(name = "codex")]
80    Codex,
81}
82
83impl LlmTool {
84    /// Get the command name for the tool
85    pub fn command(&self) -> &'static str {
86        match self {
87            LlmTool::Gemini => "gemini",
88            LlmTool::Codex => "codex",
89        }
90    }
91
92    /// Get the installation instructions for the tool
93    pub fn install_instructions(&self) -> &'static str {
94        match self {
95            LlmTool::Gemini => "Please install gemini with: pip install gemini",
96            LlmTool::Codex => {
97                "Please install codex CLI from: https://github.com/microsoft/codex-cli"
98            }
99        }
100    }
101
102    /// Get the default maximum tokens for the tool
103    pub fn default_max_tokens(&self) -> usize {
104        match self {
105            LlmTool::Gemini => 1_000_000,
106            LlmTool::Codex => 1_000_000,
107        }
108    }
109
110    /// Get the default maximum tokens for the tool with optional config override
111    pub fn default_max_tokens_with_config(
112        &self,
113        config_token_limits: Option<&crate::config::TokenLimits>,
114    ) -> usize {
115        if let Some(token_limits) = config_token_limits {
116            match self {
117                LlmTool::Gemini => token_limits.gemini.unwrap_or(1_000_000),
118                LlmTool::Codex => token_limits.codex.unwrap_or(1_000_000),
119            }
120        } else {
121            self.default_max_tokens()
122        }
123    }
124}
125
126/// High-performance CLI tool to convert codebases to Markdown for LLM context
127#[derive(Parser, Debug, Clone)]
128#[command(author, version, about, long_about = None, after_help = AFTER_HELP_MSG)]
129pub struct Config {
130    /// The prompt to send to the LLM for processing
131    #[arg(short = 'p', long = "prompt", help = "Process a text prompt directly")]
132    pub prompt: Option<String>,
133
134    /// One or more directory paths to process
135    /// IMPORTANT: Use `get_directories()` to access the correct input paths.
136    #[arg(value_name = "PATHS", help = "Process directories")]
137    pub paths: Option<Vec<PathBuf>>,
138
139    /// Include files and directories matching glob patterns
140    /// IMPORTANT: Use `get_directories()` to access the correct input paths.
141    #[arg(
142        long,
143        help = "Include files and directories matching the given glob pattern.\nPatterns use gitignore-style syntax. To prevent shell expansion,\nquote patterns: --include \"*.py\" --include \"src/**/*.{rs,toml}\""
144    )]
145    pub include: Option<Vec<String>>,
146
147    /// Ignore files and directories matching glob patterns
148    #[arg(
149        long,
150        help = "Ignore files and directories matching the given glob pattern.\nPatterns use gitignore-style syntax. To prevent shell expansion,\nquote patterns: --ignore \"node_modules/**\" --ignore \"target/**\""
151    )]
152    pub ignore: Option<Vec<String>>,
153
154    /// GitHub repository URL to analyze (e.g., <https://github.com/owner/repo>)
155    #[arg(long, help = "Process a GitHub repository")]
156    pub repo: Option<String>,
157
158    /// Read prompt from stdin
159    #[arg(long = "stdin", help = "Read prompt from standard input")]
160    pub read_stdin: bool,
161
162    /// The path to the output Markdown file. If used, won't call the LLM CLI
163    #[arg(short = 'o', long)]
164    pub output_file: Option<PathBuf>,
165
166    /// Maximum number of tokens for the generated codebase context
167    #[arg(long)]
168    pub max_tokens: Option<usize>,
169
170    /// LLM CLI tool to use for processing
171    #[arg(short = 't', long = "tool", default_value = "gemini")]
172    pub llm_tool: LlmTool,
173
174    /// Suppress all output except for errors and the final LLM response
175    #[arg(short = 'q', long)]
176    pub quiet: bool,
177
178    /// Enable verbose logging
179    #[arg(short = 'v', long)]
180    pub verbose: bool,
181
182    /// Path to configuration file
183    #[arg(short = 'c', long)]
184    pub config: Option<PathBuf>,
185
186    /// Show progress indicators during processing
187    #[arg(long)]
188    pub progress: bool,
189
190    /// Copy output to system clipboard instead of stdout
191    #[arg(short = 'C', long)]
192    pub copy: bool,
193
194    /// Enable enhanced context with file metadata
195    #[arg(long = "enhanced-context")]
196    pub enhanced_context: bool,
197
198    /// Enable import tracing for included files
199    #[arg(long, help = "Include files that import the specified modules")]
200    pub trace_imports: bool,
201
202    /// Include files that call functions from specified modules
203    #[arg(long, help = "Include files containing callers of specified functions")]
204    pub include_callers: bool,
205
206    /// Include type definitions used by specified files
207    #[arg(long, help = "Include type definitions and interfaces")]
208    pub include_types: bool,
209
210    /// Maximum depth for semantic dependency traversal
211    #[arg(
212        long,
213        default_value = "2",
214        help = "Depth limit for dependency traversal"
215    )]
216    pub semantic_depth: usize,
217
218    /// Custom priority rules loaded from config file (not a CLI argument)
219    #[clap(skip)]
220    pub custom_priorities: Vec<crate::config::Priority>,
221
222    /// Token limits loaded from config file (not a CLI argument)
223    #[clap(skip)]
224    pub config_token_limits: Option<crate::config::TokenLimits>,
225
226    /// Maximum tokens from config defaults (not a CLI argument)
227    #[clap(skip)]
228    pub config_defaults_max_tokens: Option<usize>,
229}
230
231impl Default for Config {
232    fn default() -> Self {
233        Self {
234            prompt: None,
235            paths: None,
236            include: None,
237            ignore: None,
238            repo: None,
239            read_stdin: false,
240            output_file: None,
241            max_tokens: None,
242            llm_tool: LlmTool::default(),
243            quiet: false,
244            verbose: false,
245            config: None,
246            progress: false,
247            copy: false,
248            enhanced_context: false,
249            trace_imports: false,
250            include_callers: false,
251            include_types: false,
252            semantic_depth: 2,
253            custom_priorities: vec![],
254            config_token_limits: None,
255            config_defaults_max_tokens: None,
256        }
257    }
258}
259
260impl Config {
261    /// Validate the configuration
262    pub fn validate(&self) -> Result<(), crate::utils::error::ContextCreatorError> {
263        use crate::utils::error::ContextCreatorError;
264
265        // Validate that at least one input source is provided
266        let has_input_source = self.get_prompt().is_some()
267            || self.paths.is_some()
268            || self.include.is_some()
269            || self.repo.is_some()
270            || self.read_stdin;
271
272        if !has_input_source {
273            return Err(ContextCreatorError::InvalidConfiguration(
274                "At least one input source must be provided: --prompt, paths, --include, --repo, or --stdin".to_string(),
275            ));
276        }
277
278        // Note: Removed overly restrictive validation rules per issue #34
279        // Now allowing flexible combinations like:
280        // - --prompt with paths (--prompt "text" src/)
281        // - --prompt with --repo (--prompt "text" --repo url)
282        // - --stdin with paths (echo "prompt" | context-creator --stdin src/)
283        // - --include with --repo (--include "**/*.rs" --repo url)
284        // - --include with --stdin (--stdin --include "**/*.rs")
285        //
286        // The only remaining restrictions are for legitimate conflicts:
287        // - --prompt with --output-file (can't send to LLM and write to file)
288        // - --copy with --output-file (can't copy to clipboard and write to file)
289
290        // Validate repo URL if provided
291        if let Some(repo_url) = &self.repo {
292            if !repo_url.starts_with("https://github.com/")
293                && !repo_url.starts_with("http://github.com/")
294            {
295                return Err(ContextCreatorError::InvalidConfiguration(
296                    "Repository URL must be a GitHub URL (https://github.com/owner/repo)"
297                        .to_string(),
298                ));
299            }
300        } else {
301            // Only validate directories if repo is not provided
302            let directories = self.get_directories();
303            for directory in &directories {
304                if !directory.exists() {
305                    return Err(ContextCreatorError::InvalidPath(format!(
306                        "Directory does not exist: {}",
307                        directory.display()
308                    )));
309                }
310
311                if !directory.is_dir() {
312                    return Err(ContextCreatorError::InvalidPath(format!(
313                        "Path is not a directory: {}",
314                        directory.display()
315                    )));
316                }
317            }
318        }
319
320        // Note: Pattern validation is handled by OverrideBuilder in walker.rs
321        // which provides better security and ReDoS protection
322
323        // Validate output file parent directory exists if specified
324        if let Some(output) = &self.output_file {
325            if let Some(parent) = output.parent() {
326                // Handle empty parent (current directory) and check if parent exists
327                if !parent.as_os_str().is_empty() && !parent.exists() {
328                    return Err(ContextCreatorError::InvalidPath(format!(
329                        "Output directory does not exist: {}",
330                        parent.display()
331                    )));
332                }
333            }
334        }
335
336        // Validate mutually exclusive options
337        if self.output_file.is_some() && self.get_prompt().is_some() {
338            return Err(ContextCreatorError::InvalidConfiguration(
339                "Cannot specify both --output and a prompt".to_string(),
340            ));
341        }
342
343        // Validate copy and output mutual exclusivity
344        if self.copy && self.output_file.is_some() {
345            return Err(ContextCreatorError::InvalidConfiguration(
346                "Cannot specify both --copy and --output".to_string(),
347            ));
348        }
349
350        // Validate repo and paths mutual exclusivity
351        // When --repo is specified, any positional paths are silently ignored in run()
352        // This prevents user confusion by failing early with a clear error message
353        if self.repo.is_some() && self.paths.is_some() {
354            return Err(ContextCreatorError::InvalidConfiguration(
355                "Cannot specify both --repo and local paths. Use --repo to analyze a remote repository, or provide local paths to analyze local directories.".to_string(),
356            ));
357        }
358
359        Ok(())
360    }
361
362    /// Load configuration from file if specified
363    pub fn load_from_file(&mut self) -> Result<(), crate::utils::error::ContextCreatorError> {
364        use crate::config::ConfigFile;
365
366        let config_file = if let Some(ref config_path) = self.config {
367            // Load from specified config file
368            Some(ConfigFile::load_from_file(config_path)?)
369        } else {
370            // Try to load from default locations
371            ConfigFile::load_default()?
372        };
373
374        if let Some(config_file) = config_file {
375            // Store custom priorities for the walker
376            self.custom_priorities = config_file.priorities.clone();
377
378            // Store token limits for token resolution
379            self.config_token_limits = Some(config_file.tokens.clone());
380
381            config_file.apply_to_cli_config(self);
382
383            if self.verbose {
384                if let Some(ref config_path) = self.config {
385                    eprintln!("📄 Loaded configuration from: {}", config_path.display());
386                } else {
387                    eprintln!("📄 Loaded configuration from default location");
388                }
389            }
390        }
391
392        Ok(())
393    }
394
395    /// Get the prompt from the explicit prompt flag
396    pub fn get_prompt(&self) -> Option<String> {
397        self.prompt
398            .as_ref()
399            .filter(|s| !s.trim().is_empty())
400            .cloned()
401    }
402
403    /// Get all directories from paths argument
404    /// When using --include patterns, this returns the default directory (current dir)
405    /// unless explicit paths are also provided (flexible combinations)
406    pub fn get_directories(&self) -> Vec<PathBuf> {
407        // If explicit paths are provided, use them
408        if let Some(paths) = &self.paths {
409            paths.clone()
410        } else if self.include.is_some() {
411            // When using include patterns without explicit paths, use current directory as base
412            vec![PathBuf::from(".")]
413        } else {
414            // Default to current directory
415            vec![PathBuf::from(".")]
416        }
417    }
418
419    /// Get include patterns if specified
420    pub fn get_include_patterns(&self) -> Vec<String> {
421        self.include.as_ref().cloned().unwrap_or_default()
422    }
423
424    /// Get ignore patterns if specified
425    pub fn get_ignore_patterns(&self) -> Vec<String> {
426        self.ignore.as_ref().cloned().unwrap_or_default()
427    }
428
429    /// Get effective max tokens with precedence: explicit CLI > token limits (if prompt) > config defaults > hard-coded defaults (if prompt) > None
430    pub fn get_effective_max_tokens(&self) -> Option<usize> {
431        // 1. Explicit CLI value always takes precedence
432        if let Some(explicit_tokens) = self.max_tokens {
433            return Some(explicit_tokens);
434        }
435
436        // 2. If using prompt, check token limits from config first
437        if let Some(_prompt) = self.get_prompt() {
438            // Check if we have config token limits for this tool
439            if let Some(token_limits) = &self.config_token_limits {
440                let config_limit = match self.llm_tool {
441                    LlmTool::Gemini => token_limits.gemini,
442                    LlmTool::Codex => token_limits.codex,
443                };
444
445                if let Some(limit) = config_limit {
446                    return Some(limit);
447                }
448            }
449
450            // 3. Fall back to config defaults if available
451            if let Some(defaults_tokens) = self.config_defaults_max_tokens {
452                return Some(defaults_tokens);
453            }
454
455            // 4. Fall back to hard-coded defaults for prompts
456            return Some(self.llm_tool.default_max_tokens());
457        }
458
459        // 5. For non-prompt usage, check config defaults
460        if let Some(defaults_tokens) = self.config_defaults_max_tokens {
461            return Some(defaults_tokens);
462        }
463
464        // 6. No automatic token limits for non-prompt usage
465        None
466    }
467
468    /// Get effective context tokens with prompt reservation
469    /// This accounts for prompt tokens when calculating available space for codebase context
470    pub fn get_effective_context_tokens(&self) -> Option<usize> {
471        if let Some(max_tokens) = self.get_effective_max_tokens() {
472            if let Some(prompt) = self.get_prompt() {
473                // Create token counter to measure prompt
474                if let Ok(counter) = crate::core::token::TokenCounter::new() {
475                    if let Ok(prompt_tokens) = counter.count_tokens(&prompt) {
476                        // Reserve space for prompt + safety buffer for response
477                        let safety_buffer = 1000; // Reserve for LLM response
478                        let reserved = prompt_tokens + safety_buffer;
479                        let available = max_tokens.saturating_sub(reserved);
480                        return Some(available);
481                    }
482                }
483                // Fallback: rough estimation if tiktoken fails
484                let estimated_prompt_tokens = prompt.len().div_ceil(4); // ~4 chars per token
485                let safety_buffer = 1000;
486                let reserved = estimated_prompt_tokens + safety_buffer;
487                let available = max_tokens.saturating_sub(reserved);
488                Some(available)
489            } else {
490                // No prompt, use full token budget
491                Some(max_tokens)
492            }
493        } else {
494            None
495        }
496    }
497
498    /// Check if we should read from stdin
499    pub fn should_read_stdin(&self) -> bool {
500        use std::io::IsTerminal;
501
502        // Explicitly requested stdin
503        if self.read_stdin {
504            return true;
505        }
506
507        // If stdin is not a terminal (i.e., it's piped) and no prompt is provided
508        if !std::io::stdin().is_terminal() && self.get_prompt().is_none() {
509            return true;
510        }
511
512        false
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use std::fs;
520    use tempfile::TempDir;
521
522    impl Config {
523        /// Helper function for creating Config instances in tests
524        #[allow(dead_code)]
525        fn new_for_test(paths: Option<Vec<PathBuf>>) -> Self {
526            Self {
527                paths,
528                quiet: true, // Good default for tests
529                ..Self::default()
530            }
531        }
532
533        /// Helper function for creating Config instances with include patterns in tests
534        #[allow(dead_code)]
535        fn new_for_test_with_include(include: Option<Vec<String>>) -> Self {
536            Self {
537                include,
538                quiet: true, // Good default for tests
539                ..Self::default()
540            }
541        }
542    }
543
544    #[test]
545    fn test_config_validation_valid_directory() {
546        let temp_dir = TempDir::new().unwrap();
547        let config = Config {
548            paths: Some(vec![temp_dir.path().to_path_buf()]),
549            ..Default::default()
550        };
551
552        assert!(config.validate().is_ok());
553    }
554
555    #[test]
556    fn test_config_validation_invalid_directory() {
557        let config = Config {
558            prompt: None,
559            paths: Some(vec![PathBuf::from("/nonexistent/directory")]),
560            include: None,
561            ignore: None,
562            repo: None,
563            read_stdin: false,
564            output_file: None,
565            max_tokens: None,
566            llm_tool: LlmTool::default(),
567            quiet: false,
568            verbose: false,
569            config: None,
570            progress: false,
571            copy: false,
572            enhanced_context: false,
573            trace_imports: false,
574            include_callers: false,
575            include_types: false,
576            semantic_depth: 2,
577            custom_priorities: vec![],
578            config_token_limits: None,
579            config_defaults_max_tokens: None,
580        };
581
582        assert!(config.validate().is_err());
583    }
584
585    #[test]
586    fn test_config_validation_file_as_directory() {
587        let temp_dir = TempDir::new().unwrap();
588        let file_path = temp_dir.path().join("file.txt");
589        fs::write(&file_path, "test").unwrap();
590
591        let config = Config {
592            prompt: None,
593            paths: Some(vec![file_path]),
594            include: None,
595            ignore: None,
596            repo: None,
597            read_stdin: false,
598            output_file: None,
599            max_tokens: None,
600            llm_tool: LlmTool::default(),
601            quiet: false,
602            verbose: false,
603            config: None,
604            progress: false,
605            copy: false,
606            enhanced_context: false,
607            trace_imports: false,
608            include_callers: false,
609            include_types: false,
610            semantic_depth: 2,
611            custom_priorities: vec![],
612            config_token_limits: None,
613            config_defaults_max_tokens: None,
614        };
615
616        assert!(config.validate().is_err());
617    }
618
619    #[test]
620    fn test_config_validation_invalid_output_directory() {
621        let temp_dir = TempDir::new().unwrap();
622        let config = Config {
623            prompt: None,
624            paths: Some(vec![temp_dir.path().to_path_buf()]),
625            include: None,
626            ignore: None,
627            repo: None,
628            read_stdin: false,
629            output_file: Some(PathBuf::from("/nonexistent/directory/output.md")),
630            max_tokens: None,
631            llm_tool: LlmTool::default(),
632            quiet: false,
633            verbose: false,
634            config: None,
635            progress: false,
636            copy: false,
637            enhanced_context: false,
638            trace_imports: false,
639            include_callers: false,
640            include_types: false,
641            semantic_depth: 2,
642            custom_priorities: vec![],
643            config_token_limits: None,
644            config_defaults_max_tokens: None,
645        };
646
647        assert!(config.validate().is_err());
648    }
649
650    #[test]
651    fn test_config_validation_mutually_exclusive_options() {
652        let temp_dir = TempDir::new().unwrap();
653        let config = Config {
654            prompt: Some("test prompt".to_string()),
655            paths: Some(vec![temp_dir.path().to_path_buf()]),
656            include: None,
657            ignore: None,
658            repo: None,
659            read_stdin: false,
660            output_file: Some(temp_dir.path().join("output.md")),
661            max_tokens: None,
662            llm_tool: LlmTool::default(),
663            quiet: false,
664            verbose: false,
665            config: None,
666            progress: false,
667            copy: false,
668            enhanced_context: false,
669            trace_imports: false,
670            include_callers: false,
671            include_types: false,
672            semantic_depth: 2,
673            custom_priorities: vec![],
674            config_token_limits: None,
675            config_defaults_max_tokens: None,
676        };
677
678        assert!(config.validate().is_err());
679    }
680
681    #[test]
682    fn test_llm_tool_enum_values() {
683        assert_eq!(LlmTool::Gemini.command(), "gemini");
684        assert_eq!(LlmTool::Codex.command(), "codex");
685
686        assert!(LlmTool::Gemini
687            .install_instructions()
688            .contains("pip install"));
689        assert!(LlmTool::Codex.install_instructions().contains("github.com"));
690
691        assert_eq!(LlmTool::default(), LlmTool::Gemini);
692    }
693
694    #[test]
695    fn test_llm_tool_default_max_tokens() {
696        assert_eq!(LlmTool::Gemini.default_max_tokens(), 1_000_000);
697        assert_eq!(LlmTool::Codex.default_max_tokens(), 1_000_000);
698    }
699
700    #[test]
701    fn test_config_get_effective_max_tokens_with_explicit() {
702        let config = Config {
703            prompt: Some("test prompt".to_string()),
704            max_tokens: Some(500_000),
705            llm_tool: LlmTool::Gemini,
706            ..Config::new_for_test(None)
707        };
708        assert_eq!(config.get_effective_max_tokens(), Some(500_000));
709    }
710
711    #[test]
712    fn test_config_get_effective_max_tokens_with_prompt_default() {
713        let config = Config {
714            prompt: Some("test prompt".to_string()),
715            max_tokens: None,
716            llm_tool: LlmTool::Gemini,
717            ..Config::new_for_test(None)
718        };
719        assert_eq!(config.get_effective_max_tokens(), Some(1_000_000));
720    }
721
722    #[test]
723    fn test_config_get_effective_max_tokens_no_prompt() {
724        let config = Config {
725            prompt: None,
726            max_tokens: None,
727            llm_tool: LlmTool::Gemini,
728            ..Config::new_for_test(None)
729        };
730        assert_eq!(config.get_effective_max_tokens(), None);
731    }
732
733    #[test]
734    fn test_config_get_effective_max_tokens_with_config_gemini() {
735        use crate::config::TokenLimits;
736
737        let config = Config {
738            prompt: Some("test prompt".to_string()),
739            max_tokens: None,
740            llm_tool: LlmTool::Gemini,
741            config_token_limits: Some(TokenLimits {
742                gemini: Some(2_500_000),
743                codex: Some(1_800_000),
744            }),
745            ..Config::new_for_test(None)
746        };
747        assert_eq!(config.get_effective_max_tokens(), Some(2_500_000));
748    }
749
750    #[test]
751    fn test_config_get_effective_max_tokens_with_config_codex() {
752        use crate::config::TokenLimits;
753
754        let config = Config {
755            prompt: Some("test prompt".to_string()),
756            max_tokens: None,
757            llm_tool: LlmTool::Codex,
758            config_token_limits: Some(TokenLimits {
759                gemini: Some(2_500_000),
760                codex: Some(1_800_000),
761            }),
762            ..Config::new_for_test(None)
763        };
764        assert_eq!(config.get_effective_max_tokens(), Some(1_800_000));
765    }
766
767    #[test]
768    fn test_config_get_effective_max_tokens_explicit_overrides_config() {
769        use crate::config::TokenLimits;
770
771        let config = Config {
772            prompt: Some("test prompt".to_string()),
773            max_tokens: Some(500_000), // Explicit value should override config
774            llm_tool: LlmTool::Gemini,
775            config_token_limits: Some(TokenLimits {
776                gemini: Some(2_500_000),
777                codex: Some(1_800_000),
778            }),
779            ..Config::new_for_test(None)
780        };
781        assert_eq!(config.get_effective_max_tokens(), Some(500_000));
782    }
783
784    #[test]
785    fn test_config_get_effective_max_tokens_config_partial_gemini() {
786        use crate::config::TokenLimits;
787
788        let config = Config {
789            prompt: Some("test prompt".to_string()),
790            max_tokens: None,
791            llm_tool: LlmTool::Gemini,
792            config_token_limits: Some(TokenLimits {
793                gemini: Some(3_000_000),
794                codex: None, // Codex not configured
795            }),
796            ..Config::new_for_test(None)
797        };
798        assert_eq!(config.get_effective_max_tokens(), Some(3_000_000));
799    }
800
801    #[test]
802    fn test_config_get_effective_max_tokens_config_partial_codex() {
803        use crate::config::TokenLimits;
804
805        let config = Config {
806            prompt: Some("test prompt".to_string()),
807            max_tokens: None,
808            llm_tool: LlmTool::Codex,
809            config_token_limits: Some(TokenLimits {
810                gemini: None, // Gemini not configured
811                codex: Some(1_200_000),
812            }),
813            ..Config::new_for_test(None)
814        };
815        assert_eq!(config.get_effective_max_tokens(), Some(1_200_000));
816    }
817
818    #[test]
819    fn test_config_get_effective_max_tokens_config_fallback_to_default() {
820        use crate::config::TokenLimits;
821
822        let config = Config {
823            prompt: Some("test prompt".to_string()),
824            max_tokens: None,
825            llm_tool: LlmTool::Gemini,
826            config_token_limits: Some(TokenLimits {
827                gemini: None, // No limit configured for Gemini
828                codex: Some(1_800_000),
829            }),
830            ..Config::new_for_test(None)
831        };
832        // Should fall back to hard-coded default
833        assert_eq!(config.get_effective_max_tokens(), Some(1_000_000));
834    }
835
836    #[test]
837    fn test_llm_tool_default_max_tokens_with_config() {
838        use crate::config::TokenLimits;
839
840        let token_limits = TokenLimits {
841            gemini: Some(2_500_000),
842            codex: Some(1_800_000),
843        };
844
845        assert_eq!(
846            LlmTool::Gemini.default_max_tokens_with_config(Some(&token_limits)),
847            2_500_000
848        );
849        assert_eq!(
850            LlmTool::Codex.default_max_tokens_with_config(Some(&token_limits)),
851            1_800_000
852        );
853    }
854
855    #[test]
856    fn test_llm_tool_default_max_tokens_with_config_partial() {
857        use crate::config::TokenLimits;
858
859        let token_limits = TokenLimits {
860            gemini: Some(3_000_000),
861            codex: None, // Codex not configured
862        };
863
864        assert_eq!(
865            LlmTool::Gemini.default_max_tokens_with_config(Some(&token_limits)),
866            3_000_000
867        );
868        // Should fall back to hard-coded default
869        assert_eq!(
870            LlmTool::Codex.default_max_tokens_with_config(Some(&token_limits)),
871            1_000_000
872        );
873    }
874
875    #[test]
876    fn test_llm_tool_default_max_tokens_with_no_config() {
877        assert_eq!(
878            LlmTool::Gemini.default_max_tokens_with_config(None),
879            1_000_000
880        );
881        assert_eq!(
882            LlmTool::Codex.default_max_tokens_with_config(None),
883            1_000_000
884        );
885    }
886
887    #[test]
888    fn test_get_effective_context_tokens_with_prompt() {
889        let config = Config {
890            prompt: Some("This is a test prompt".to_string()),
891            max_tokens: Some(10000),
892            llm_tool: LlmTool::Gemini,
893            ..Config::new_for_test(None)
894        };
895
896        let context_tokens = config.get_effective_context_tokens().unwrap();
897        // Should be less than max_tokens due to prompt + safety buffer reservation
898        assert!(context_tokens < 10000);
899        // Should be at least max_tokens - 1000 (safety buffer) - prompt tokens
900        assert!(context_tokens > 8000); // Conservative estimate
901    }
902
903    #[test]
904    fn test_get_effective_context_tokens_no_prompt() {
905        let config = Config {
906            prompt: None,
907            max_tokens: Some(10000),
908            llm_tool: LlmTool::Gemini,
909            ..Config::new_for_test(None)
910        };
911
912        // Without prompt, should use full token budget
913        assert_eq!(config.get_effective_context_tokens(), Some(10000));
914    }
915
916    #[test]
917    fn test_get_effective_context_tokens_no_limit() {
918        let config = Config {
919            prompt: None, // No prompt means no auto-limits
920            max_tokens: None,
921            llm_tool: LlmTool::Gemini,
922            ..Config::new_for_test(None)
923        };
924
925        // No max tokens configured and no prompt, should return None
926        assert_eq!(config.get_effective_context_tokens(), None);
927    }
928
929    #[test]
930    fn test_get_effective_context_tokens_with_config_limits() {
931        use crate::config::TokenLimits;
932
933        let config = Config {
934            prompt: Some("This is a longer test prompt for token counting".to_string()),
935            max_tokens: None, // Use config limits instead
936            llm_tool: LlmTool::Gemini,
937            config_token_limits: Some(TokenLimits {
938                gemini: Some(50000),
939                codex: Some(40000),
940            }),
941            ..Config::new_for_test(None)
942        };
943
944        let context_tokens = config.get_effective_context_tokens().unwrap();
945        // Should be less than config limit due to prompt reservation
946        assert!(context_tokens < 50000);
947        assert!(context_tokens > 45000); // Should be most of the budget
948    }
949
950    #[test]
951    fn test_config_validation_output_file_in_current_dir() {
952        let temp_dir = TempDir::new().unwrap();
953        let config = Config {
954            prompt: None,
955            paths: Some(vec![temp_dir.path().to_path_buf()]),
956            include: None,
957            ignore: None,
958            repo: None,
959            read_stdin: false,
960            output_file: Some(PathBuf::from("output.md")),
961            max_tokens: None,
962            llm_tool: LlmTool::default(),
963            quiet: false,
964            verbose: false,
965            config: None,
966            progress: false,
967            copy: false,
968            enhanced_context: false,
969            trace_imports: false,
970            include_callers: false,
971            include_types: false,
972            semantic_depth: 2,
973            custom_priorities: vec![],
974            config_token_limits: None,
975            config_defaults_max_tokens: None,
976        };
977
978        // Should not error for files in current directory
979        assert!(config.validate().is_ok());
980    }
981
982    #[test]
983    fn test_config_load_from_file_no_config() {
984        let temp_dir = TempDir::new().unwrap();
985        let mut config = Config {
986            prompt: None,
987            paths: Some(vec![temp_dir.path().to_path_buf()]),
988            include: None,
989            ignore: None,
990            repo: None,
991            read_stdin: false,
992            output_file: None,
993            max_tokens: None,
994            llm_tool: LlmTool::default(),
995            quiet: false,
996            verbose: false,
997            config: None,
998            progress: false,
999            copy: false,
1000            enhanced_context: false,
1001            trace_imports: false,
1002            include_callers: false,
1003            include_types: false,
1004            semantic_depth: 2,
1005            custom_priorities: vec![],
1006            config_token_limits: None,
1007            config_defaults_max_tokens: None,
1008        };
1009
1010        // Should not error when no config file is found
1011        assert!(config.load_from_file().is_ok());
1012    }
1013
1014    #[test]
1015    fn test_parse_directories() {
1016        use clap::Parser;
1017
1018        // Test single directory
1019        let args = vec!["context-creator", "/path/one"];
1020        let config = Config::parse_from(args);
1021        assert_eq!(config.paths.as_ref().unwrap().len(), 1);
1022        assert_eq!(
1023            config.paths.as_ref().unwrap()[0],
1024            PathBuf::from("/path/one")
1025        );
1026    }
1027
1028    #[test]
1029    fn test_parse_multiple_directories() {
1030        use clap::Parser;
1031
1032        // Test multiple directories
1033        let args = vec!["context-creator", "/path/one", "/path/two", "/path/three"];
1034        let config = Config::parse_from(args);
1035        assert_eq!(config.paths.as_ref().unwrap().len(), 3);
1036        assert_eq!(
1037            config.paths.as_ref().unwrap()[0],
1038            PathBuf::from("/path/one")
1039        );
1040        assert_eq!(
1041            config.paths.as_ref().unwrap()[1],
1042            PathBuf::from("/path/two")
1043        );
1044        assert_eq!(
1045            config.paths.as_ref().unwrap()[2],
1046            PathBuf::from("/path/three")
1047        );
1048
1049        // Test with explicit prompt
1050        let args = vec!["context-creator", "--prompt", "Find duplicated patterns"];
1051        let config = Config::parse_from(args);
1052        assert_eq!(config.prompt, Some("Find duplicated patterns".to_string()));
1053    }
1054
1055    #[test]
1056    fn test_validate_multiple_directories() {
1057        let temp_dir = TempDir::new().unwrap();
1058        let dir1 = temp_dir.path().join("dir1");
1059        let dir2 = temp_dir.path().join("dir2");
1060        fs::create_dir(&dir1).unwrap();
1061        fs::create_dir(&dir2).unwrap();
1062
1063        // All directories exist - should succeed
1064        let config = Config {
1065            prompt: None,
1066            paths: Some(vec![dir1.clone(), dir2.clone()]),
1067            include: None,
1068            ignore: None,
1069            repo: None,
1070            read_stdin: false,
1071            output_file: None,
1072            max_tokens: None,
1073            llm_tool: LlmTool::default(),
1074            quiet: false,
1075            verbose: false,
1076            config: None,
1077            progress: false,
1078            copy: false,
1079            enhanced_context: false,
1080            trace_imports: false,
1081            include_callers: false,
1082            include_types: false,
1083            semantic_depth: 2,
1084            custom_priorities: vec![],
1085            config_token_limits: None,
1086            config_defaults_max_tokens: None,
1087        };
1088        assert!(config.validate().is_ok());
1089
1090        // One directory doesn't exist - should fail
1091        let config = Config {
1092            prompt: None,
1093            paths: Some(vec![dir1, PathBuf::from("/nonexistent/dir")]),
1094            include: None,
1095            ignore: None,
1096            repo: None,
1097            read_stdin: false,
1098            output_file: None,
1099            max_tokens: None,
1100            llm_tool: LlmTool::default(),
1101            quiet: false,
1102            verbose: false,
1103            config: None,
1104            progress: false,
1105            copy: false,
1106            enhanced_context: false,
1107            trace_imports: false,
1108            include_callers: false,
1109            include_types: false,
1110            semantic_depth: 2,
1111            custom_priorities: vec![],
1112            config_token_limits: None,
1113            config_defaults_max_tokens: None,
1114        };
1115        assert!(config.validate().is_err());
1116    }
1117
1118    #[test]
1119    fn test_validate_files_as_directories() {
1120        let temp_dir = TempDir::new().unwrap();
1121        let dir1 = temp_dir.path().join("dir1");
1122        let file1 = temp_dir.path().join("file.txt");
1123        fs::create_dir(&dir1).unwrap();
1124        fs::write(&file1, "test content").unwrap();
1125
1126        // Mix of directory and file - should fail
1127        let config = Config {
1128            prompt: None,
1129            paths: Some(vec![dir1, file1]),
1130            include: None,
1131            ignore: None,
1132            repo: None,
1133            read_stdin: false,
1134            output_file: None,
1135            max_tokens: None,
1136            llm_tool: LlmTool::default(),
1137            quiet: false,
1138            verbose: false,
1139            config: None,
1140            progress: false,
1141            copy: false,
1142            enhanced_context: false,
1143            trace_imports: false,
1144            include_callers: false,
1145            include_types: false,
1146            semantic_depth: 2,
1147            custom_priorities: vec![],
1148            config_token_limits: None,
1149            config_defaults_max_tokens: None,
1150        };
1151        assert!(config.validate().is_err());
1152    }
1153}