Skip to main content

context_creator/
cli.rs

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