use anyhow::{anyhow, Result};
use bytesize::ByteSize;
use clap_config_file::ClapConfigFile;
use sha2::{Digest, Sha256};
use std::io::{self, BufRead, BufReader, 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_paths: 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(long = "line-numbers")]
pub line_numbers: bool,
#[config_arg()]
pub output_dir: Option<String>,
#[config_arg(long = "output-name")]
pub output_name: Option<String>,
#[config_arg()]
pub output_template: Option<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>,
#[config_arg(long = "tree-header", short = 't')]
pub tree_header: bool,
#[config_arg(long = "tree-only")]
pub tree_only: bool,
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_paths: Vec::new(),
version: false,
max_size: "10MB".to_string(),
tokens: String::new(),
json: false,
debug: false,
line_numbers: false,
output_dir: None,
output_name: None,
output_template: Some(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),
tree_header: false,
tree_only: false,
stream: false,
token_mode: false,
output_file_full_path: None,
max_git_depth: 100,
}
}
}
impl YekConfig {
pub fn extend_config_with_defaults(input_paths: Vec<String>, output_dir: String) -> Self {
YekConfig {
input_paths,
output_dir: Some(output_dir),
..Default::default()
}
}
fn read_input_paths_from_stdin(&self) -> Result<Vec<String>> {
let stdin = io::stdin();
let reader = BufReader::new(stdin.lock());
let mut paths = Vec::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if !trimmed.is_empty() {
paths.push(trimmed.to_string());
}
}
Ok(paths)
}
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.output_template.is_none() {
cfg.output_template = Some(DEFAULT_OUTPUT_TEMPLATE.to_string());
}
if cfg.input_paths.is_empty() {
if !std::io::stdin().is_terminal() {
match cfg.read_input_paths_from_stdin() {
Ok(stdin_paths) => {
if !stdin_paths.is_empty() {
cfg.input_paths = stdin_paths;
} else {
cfg.input_paths.push(".".to_string());
}
}
Err(e) => {
eprintln!("Warning: Failed to read from stdin: {}", e);
cfg.input_paths.push(".".to_string());
}
}
} else {
cfg.input_paths.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_paths: &[String]) -> String {
let mut hasher = Sha256::new();
for path_str in input_paths {
let base_path = Path::new(path_str);
if !base_path.exists() {
continue;
}
if base_path.is_file() {
if let Ok(meta) = fs::metadata(base_path) {
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());
}
}
}
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<()> {
let template = self
.output_template
.as_ref()
.ok_or_else(|| anyhow!("output_template: must be provided"))?;
if !template.contains("FILE_PATH") || !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 chars: Vec<char> = self.tokens.chars().collect();
if chars.len() > 1 {
let val = chars[..chars.len() - 1]
.iter()
.collect::<String>()
.trim()
.parse::<usize>()
.map_err(|e| anyhow!("tokens: Invalid token size: {}", e))?;
if val == 0 {
return Err(anyhow!("tokens: cannot be 0"));
}
} else {
return Err(anyhow!("tokens: Invalid token format: {}", self.tokens));
}
} 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)
})?;
}
if self.tree_header && self.tree_only {
return Err(anyhow!("tree_header and tree_only cannot both be enabled"));
}
if self.json && self.tree_header {
return Err(anyhow!("JSON output not supported with tree header mode"));
}
if self.json && self.tree_only {
return Err(anyhow!("JSON output not supported in tree-only mode"));
}
Ok(())
}
}