use clap::{Parser, ValueEnum};
use std::path::PathBuf;
const AFTER_HELP_MSG: &str = "\
CUSTOM PRIORITY RULES:
Custom priority rules are processed in a 'first-match-wins' basis. Rules are
evaluated in the order they are defined in your .code-digest.toml configuration
file. The first rule that matches a given file will be used, and all subsequent
rules will be ignored for that file.
Example configuration:
[[priorities]]
pattern = \"src/**/*.rs\"
weight = 10.0
[[priorities]]
pattern = \"tests/*\"
weight = -2.0
USAGE EXAMPLES:
# Process current directory with a prompt
code-digest --prompt \"Analyze this code\"
# Process specific directories
code-digest src/ tests/ docs/
# Process a GitHub repository
code-digest --repo https://github.com/owner/repo
# Read prompt from stdin
echo \"Review this code\" | code-digest --stdin .
";
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub enum LlmTool {
#[value(name = "gemini")]
#[default]
Gemini,
#[value(name = "codex")]
Codex,
}
impl LlmTool {
pub fn command(&self) -> &'static str {
match self {
LlmTool::Gemini => "gemini",
LlmTool::Codex => "codex",
}
}
pub fn install_instructions(&self) -> &'static str {
match self {
LlmTool::Gemini => "Please install gemini with: pip install gemini",
LlmTool::Codex => {
"Please install codex CLI from: https://github.com/microsoft/codex-cli"
}
}
}
}
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None, after_help = AFTER_HELP_MSG)]
#[command(group(
clap::ArgGroup::new("input_source")
.required(true)
.args(&["prompt", "paths", "repo", "read_stdin"]),
))]
pub struct Config {
#[arg(short = 'p', long = "prompt", help = "Process a text prompt directly")]
pub prompt: Option<String>,
#[arg(value_name = "PATHS", help = "Process files and directories")]
pub paths: Option<Vec<PathBuf>>,
#[arg(long, help = "Process a GitHub repository")]
pub repo: Option<String>,
#[arg(long = "stdin", help = "Read prompt from standard input")]
pub read_stdin: bool,
#[arg(short = 'o', long)]
pub output_file: Option<PathBuf>,
#[arg(long)]
pub max_tokens: Option<usize>,
#[arg(short = 't', long = "tool", default_value = "gemini")]
pub llm_tool: LlmTool,
#[arg(short = 'q', long)]
pub quiet: bool,
#[arg(short = 'v', long)]
pub verbose: bool,
#[arg(short = 'c', long)]
pub config: Option<PathBuf>,
#[arg(long)]
pub progress: bool,
#[arg(short = 'C', long)]
pub copy: bool,
#[arg(long = "enhanced-context")]
pub enhanced_context: bool,
#[clap(skip)]
pub custom_priorities: Vec<crate::config::Priority>,
}
impl Config {
pub fn validate(&self) -> Result<(), crate::utils::error::CodeDigestError> {
use crate::utils::error::CodeDigestError;
if let Some(repo_url) = &self.repo {
if !repo_url.starts_with("https://github.com/")
&& !repo_url.starts_with("http://github.com/")
{
return Err(CodeDigestError::InvalidConfiguration(
"Repository URL must be a GitHub URL (https://github.com/owner/repo)"
.to_string(),
));
}
} else {
let directories = self.get_directories();
for directory in &directories {
if !directory.exists() {
return Err(CodeDigestError::InvalidPath(format!(
"Directory does not exist: {}",
directory.display()
)));
}
if !directory.is_dir() {
return Err(CodeDigestError::InvalidPath(format!(
"Path is not a directory: {}",
directory.display()
)));
}
}
}
if let Some(output) = &self.output_file {
if let Some(parent) = output.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(CodeDigestError::InvalidPath(format!(
"Output directory does not exist: {}",
parent.display()
)));
}
}
}
if self.output_file.is_some() && self.get_prompt().is_some() {
return Err(CodeDigestError::InvalidConfiguration(
"Cannot specify both --output and a prompt".to_string(),
));
}
if self.copy && self.output_file.is_some() {
return Err(CodeDigestError::InvalidConfiguration(
"Cannot specify both --copy and --output".to_string(),
));
}
Ok(())
}
pub fn load_from_file(&mut self) -> Result<(), crate::utils::error::CodeDigestError> {
use crate::config::ConfigFile;
let config_file = if let Some(ref config_path) = self.config {
Some(ConfigFile::load_from_file(config_path)?)
} else {
ConfigFile::load_default()?
};
if let Some(config_file) = config_file {
self.custom_priorities = config_file.priorities.clone();
config_file.apply_to_cli_config(self);
if self.verbose {
if let Some(ref config_path) = self.config {
eprintln!("📄 Loaded configuration from: {}", config_path.display());
} else {
eprintln!("📄 Loaded configuration from default location");
}
}
}
Ok(())
}
pub fn get_prompt(&self) -> Option<String> {
self.prompt.as_ref().filter(|s| !s.trim().is_empty()).cloned()
}
pub fn get_directories(&self) -> Vec<PathBuf> {
self.paths.as_ref().cloned().unwrap_or_else(|| vec![PathBuf::from(".")])
}
pub fn should_read_stdin(&self) -> bool {
use std::io::IsTerminal;
if self.read_stdin {
return true;
}
if !std::io::stdin().is_terminal() && self.get_prompt().is_none() {
return true;
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_config_validation_valid_directory() {
let temp_dir = TempDir::new().unwrap();
let config = Config {
prompt: None,
paths: Some(vec![temp_dir.path().to_path_buf()]),
repo: None,
read_stdin: false,
output_file: None,
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_ok());
}
#[test]
fn test_config_validation_invalid_directory() {
let config = Config {
prompt: None,
paths: Some(vec![PathBuf::from("/nonexistent/directory")]),
repo: None,
read_stdin: false,
output_file: None,
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_err());
}
#[test]
fn test_config_validation_file_as_directory() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("file.txt");
fs::write(&file_path, "test").unwrap();
let config = Config {
prompt: None,
paths: Some(vec![file_path]),
repo: None,
read_stdin: false,
output_file: None,
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_err());
}
#[test]
fn test_config_validation_invalid_output_directory() {
let temp_dir = TempDir::new().unwrap();
let config = Config {
prompt: None,
paths: Some(vec![temp_dir.path().to_path_buf()]),
repo: None,
read_stdin: false,
output_file: Some(PathBuf::from("/nonexistent/directory/output.md")),
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_err());
}
#[test]
fn test_config_validation_mutually_exclusive_options() {
let temp_dir = TempDir::new().unwrap();
let config = Config {
prompt: Some("test prompt".to_string()),
paths: Some(vec![temp_dir.path().to_path_buf()]),
repo: None,
read_stdin: false,
output_file: Some(temp_dir.path().join("output.md")),
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_err());
}
#[test]
fn test_llm_tool_enum_values() {
assert_eq!(LlmTool::Gemini.command(), "gemini");
assert_eq!(LlmTool::Codex.command(), "codex");
assert!(LlmTool::Gemini.install_instructions().contains("pip install"));
assert!(LlmTool::Codex.install_instructions().contains("github.com"));
assert_eq!(LlmTool::default(), LlmTool::Gemini);
}
#[test]
fn test_config_validation_output_file_in_current_dir() {
let temp_dir = TempDir::new().unwrap();
let config = Config {
prompt: None,
paths: Some(vec![temp_dir.path().to_path_buf()]),
repo: None,
read_stdin: false,
output_file: Some(PathBuf::from("output.md")),
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_ok());
}
#[test]
fn test_config_load_from_file_no_config() {
let temp_dir = TempDir::new().unwrap();
let mut config = Config {
prompt: None,
paths: Some(vec![temp_dir.path().to_path_buf()]),
repo: None,
read_stdin: false,
output_file: None,
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.load_from_file().is_ok());
}
#[test]
fn test_parse_directories() {
use clap::Parser;
let args = vec!["code-digest", "/path/one"];
let config = Config::parse_from(args);
assert_eq!(config.paths.as_ref().unwrap().len(), 1);
assert_eq!(config.paths.as_ref().unwrap()[0], PathBuf::from("/path/one"));
}
#[test]
fn test_parse_multiple_directories() {
use clap::Parser;
let args = vec!["code-digest", "/path/one", "/path/two", "/path/three"];
let config = Config::parse_from(args);
assert_eq!(config.paths.as_ref().unwrap().len(), 3);
assert_eq!(config.paths.as_ref().unwrap()[0], PathBuf::from("/path/one"));
assert_eq!(config.paths.as_ref().unwrap()[1], PathBuf::from("/path/two"));
assert_eq!(config.paths.as_ref().unwrap()[2], PathBuf::from("/path/three"));
let args = vec!["code-digest", "--prompt", "Find duplicated patterns"];
let config = Config::parse_from(args);
assert_eq!(config.prompt, Some("Find duplicated patterns".to_string()));
}
#[test]
fn test_validate_multiple_directories() {
let temp_dir = TempDir::new().unwrap();
let dir1 = temp_dir.path().join("dir1");
let dir2 = temp_dir.path().join("dir2");
fs::create_dir(&dir1).unwrap();
fs::create_dir(&dir2).unwrap();
let config = Config {
prompt: None,
paths: Some(vec![dir1.clone(), dir2.clone()]),
repo: None,
read_stdin: false,
output_file: None,
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_ok());
let config = Config {
prompt: None,
paths: Some(vec![dir1, PathBuf::from("/nonexistent/dir")]),
repo: None,
read_stdin: false,
output_file: None,
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_files_as_directories() {
let temp_dir = TempDir::new().unwrap();
let dir1 = temp_dir.path().join("dir1");
let file1 = temp_dir.path().join("file.txt");
fs::create_dir(&dir1).unwrap();
fs::write(&file1, "test content").unwrap();
let config = Config {
prompt: None,
paths: Some(vec![dir1, file1]),
repo: None,
read_stdin: false,
output_file: None,
max_tokens: None,
llm_tool: LlmTool::default(),
quiet: false,
verbose: false,
config: None,
progress: false,
copy: false,
enhanced_context: false,
custom_priorities: vec![],
};
assert!(config.validate().is_err());
}
}