use anyhow::{anyhow, Result};
use bytesize::ByteSize;
use clap_config_file::ClapConfigFile;
use sha2::{Digest, Sha256};
use std::io::IsTerminal;
use std::{fs, path::Path, str::FromStr, time::UNIX_EPOCH};
use crate::{
defaults::{BINARY_FILE_EXTENSIONS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTPUT_TEMPLATE},
priority::PriorityRule,
};
#[derive(Clone, Debug, Default, clap::ValueEnum, serde::Serialize, serde::Deserialize)]
pub enum ConfigFormat {
#[default]
Toml,
Yaml,
Json,
}
#[derive(ClapConfigFile, Clone)]
#[config_file_name = "yek"]
#[config_file_formats = "toml,yaml,json"]
pub struct YekConfig {
#[config_arg(positional)]
pub input_dirs: Vec<String>,
#[config_arg(long = "version", short = 'V')]
pub version: bool,
#[config_arg(default_value = "10MB")]
pub max_size: String,
#[config_arg()]
pub tokens: String,
#[config_arg()]
pub json: bool,
#[config_arg()]
pub debug: bool,
#[config_arg()]
pub output_dir: Option<String>,
#[config_arg(default_value = ">>>> FILE_PATH\nFILE_CONTENT")]
pub output_template: String,
#[config_arg(long = "ignore-patterns", multi_value_behavior = "extend")]
pub ignore_patterns: Vec<String>,
#[config_arg(long = "unignore-patterns", multi_value_behavior = "extend")]
pub unignore_patterns: Vec<String>,
#[config_arg(accept_from = "config_only")]
pub priority_rules: Vec<PriorityRule>,
#[config_arg(accept_from = "config_only", default_value = BINARY_FILE_EXTENSIONS)]
pub binary_extensions: Vec<String>,
#[config_arg(accept_from = "config_only")]
pub git_boost_max: Option<i32>,
pub stream: bool,
pub token_mode: bool,
pub output_file_full_path: Option<String>,
#[config_arg(accept_from = "config_only", default_value = "100")]
pub max_git_depth: i32,
}
impl Default for YekConfig {
fn default() -> Self {
Self {
input_dirs: Vec::new(),
version: false,
max_size: "10MB".to_string(),
tokens: String::new(),
json: false,
debug: false,
output_dir: None,
output_template: DEFAULT_OUTPUT_TEMPLATE.to_string(),
ignore_patterns: Vec::new(),
unignore_patterns: Vec::new(),
priority_rules: Vec::new(),
binary_extensions: BINARY_FILE_EXTENSIONS
.iter()
.map(|s| s.to_string())
.collect(),
git_boost_max: Some(100),
stream: false,
token_mode: false,
output_file_full_path: None,
max_git_depth: 100,
}
}
}
impl YekConfig {
pub fn extend_config_with_defaults(input_dirs: Vec<String>, output_dir: String) -> Self {
YekConfig {
input_dirs,
output_dir: Some(output_dir),
..Default::default()
}
}
}
impl YekConfig {
pub fn ensure_output_dir(&self) -> Result<String> {
if self.stream {
return Ok(String::new());
}
let output_dir = if let Some(dir) = &self.output_dir {
dir.clone()
} else {
let temp_dir = std::env::temp_dir().join("yek-output");
temp_dir.to_string_lossy().to_string()
};
let path = Path::new(&output_dir);
if path.exists() && !path.is_dir() {
return Err(anyhow!(
"output_dir: '{}' exists but is not a directory",
output_dir
));
}
std::fs::create_dir_all(path)
.map_err(|e| anyhow!("output_dir: cannot create '{}': {}", output_dir, e))?;
Ok(output_dir)
}
pub fn init_config() -> Self {
let mut cfg = YekConfig::parse();
if cfg.version {
println!("{}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
cfg.token_mode = !cfg.tokens.is_empty();
let force_tty = std::env::var("FORCE_TTY").is_ok();
cfg.stream = !std::io::stdout().is_terminal() && !force_tty;
if cfg.input_dirs.is_empty() {
cfg.input_dirs.push(".".to_string());
}
let mut merged_bins = BINARY_FILE_EXTENSIONS
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
merged_bins.append(&mut cfg.binary_extensions);
cfg.binary_extensions = merged_bins
.into_iter()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let mut ignore = DEFAULT_IGNORE_PATTERNS
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
ignore.extend(cfg.ignore_patterns);
cfg.ignore_patterns = ignore;
cfg.ignore_patterns
.extend(cfg.unignore_patterns.iter().map(|pat| format!("!{}", pat)));
if !cfg.stream {
match cfg.ensure_output_dir() {
Ok(dir) => cfg.output_dir = Some(dir),
Err(e) => {
eprintln!("Warning: Failed to create output directory: {}", e);
cfg.stream = true; }
}
}
cfg.output_file_full_path = None;
if let Err(e) = cfg.validate() {
eprintln!("Error: {}", e);
std::process::exit(1);
}
cfg
}
pub fn get_checksum(input_dirs: &[String]) -> String {
let mut hasher = Sha256::new();
for dir in input_dirs {
let base_path = Path::new(dir);
if !base_path.exists() {
continue;
}
let entries = match fs::read_dir(base_path) {
Ok(iter) => iter.filter_map(|e| e.ok()).collect::<Vec<_>>(),
Err(_) => continue,
};
let mut sorted = entries;
sorted.sort_by_key(|a| a.path());
for entry in sorted {
let p = entry.path();
if let Ok(meta) = fs::metadata(&p) {
let path_str = p.to_string_lossy();
hasher.update(path_str.as_bytes());
hasher.update(meta.len().to_le_bytes());
if let Ok(mod_time) = meta.modified() {
if let Ok(dur) = mod_time.duration_since(UNIX_EPOCH) {
hasher.update(dur.as_secs().to_le_bytes());
hasher.update(dur.subsec_nanos().to_le_bytes());
}
}
}
}
}
let result = hasher.finalize();
let hex = format!("{:x}", result);
hex[..8].to_owned()
}
pub fn validate(&self) -> Result<()> {
if !self.output_template.contains("FILE_PATH")
|| !self.output_template.contains("FILE_CONTENT")
{
return Err(anyhow!(
"output_template: must contain FILE_PATH and FILE_CONTENT"
));
}
if self.max_size == "0" {
return Err(anyhow!("max_size: cannot be 0"));
}
if !self.token_mode {
ByteSize::from_str(&self.max_size)
.map_err(|e| anyhow!("max_size: Invalid size format: {}", e))?;
} else if self.tokens.to_lowercase().ends_with('k') {
let val = self.tokens[..self.tokens.len() - 1]
.trim()
.parse::<usize>()
.map_err(|e| anyhow!("tokens: Invalid token size: {}", e))?;
if val == 0 {
return Err(anyhow!("tokens: cannot be 0"));
}
} else if !self.tokens.is_empty() {
let val = self
.tokens
.parse::<usize>()
.map_err(|e| anyhow!("tokens: Invalid token size: {}", e))?;
if val == 0 {
return Err(anyhow!("tokens: cannot be 0"));
}
}
if !self.stream {
self.ensure_output_dir()?;
}
for pattern in &self.ignore_patterns {
glob::Pattern::new(pattern)
.map_err(|e| anyhow!("ignore_patterns: Invalid pattern '{}': {}", pattern, e))?;
}
for rule in &self.priority_rules {
if rule.score < 0 || rule.score > 1000 {
return Err(anyhow!(
"priority_rules: Priority score {} must be between 0 and 1000",
rule.score
));
}
glob::Pattern::new(&rule.pattern).map_err(|e| {
anyhow!("priority_rules: Invalid pattern '{}': {}", rule.pattern, e)
})?;
}
Ok(())
}
}