use anyhow::anyhow;
use anyhow::Result;
use bytesize::ByteSize;
use content_inspector::{inspect, ContentType};
use rayon::prelude::*;
use std::{
collections::HashMap,
fs::File,
io::{self, Read},
path::Path,
str::FromStr,
sync::OnceLock,
};
use tiktoken_rs::CoreBPE;
pub mod category;
pub mod config;
pub mod defaults;
pub mod error;
pub mod models;
pub mod parallel;
pub mod pipeline;
pub mod priority;
pub mod repository;
pub mod tree;
use config::YekConfig;
use models::ProcessedFile;
use parallel::process_files_parallel;
use priority::compute_recentness_boost;
use tree::generate_tree;
static TOKENIZER: OnceLock<CoreBPE> = OnceLock::new();
fn get_tokenizer() -> &'static CoreBPE {
TOKENIZER.get_or_init(|| {
tiktoken_rs::get_bpe_from_model("gpt-3.5-turbo").expect("Failed to load tokenizer")
})
}
pub fn is_text_file(path: &Path, user_binary_extensions: &[String]) -> io::Result<bool> {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if user_binary_extensions.iter().any(|bin_ext| bin_ext == ext) {
return Ok(false);
}
}
const INSPECTION_BYTES: usize = 8192;
let mut file = File::open(path)?;
let mut buf = vec![0u8; INSPECTION_BYTES];
let n = file.read(&mut buf)?;
buf.truncate(n);
Ok(inspect(&buf) != ContentType::BINARY)
}
pub fn serialize_repo(config: &YekConfig) -> Result<(String, Vec<ProcessedFile>)> {
let mut non_existent_paths = Vec::new();
for path_str in &config.input_paths {
let path = Path::new(path_str);
if !path.exists() && !path_str.contains('*') && !path_str.contains('?') {
non_existent_paths.push(path_str.clone());
}
}
if !non_existent_paths.is_empty() {
for path in &non_existent_paths {
eprintln!("Warning: Path '{}' does not exist", path);
}
}
let combined_commit_times = config
.input_paths
.par_iter()
.filter_map(|path_str| {
let repo_path = Path::new(path_str);
if repo_path.is_dir() {
priority::get_recent_commit_times_git2(
repo_path,
config.max_git_depth.try_into().unwrap_or(0),
)
} else {
None
}
})
.flatten()
.collect::<HashMap<String, u64>>();
let recentness_boost =
compute_recentness_boost(&combined_commit_times, config.git_boost_max.unwrap_or(100));
let merged_files = config
.input_paths
.par_iter()
.map(|path_str| {
let path = Path::new(path_str);
process_files_parallel(path, config, &recentness_boost)
})
.collect::<Result<Vec<Vec<ProcessedFile>>>>()?
.into_iter()
.flatten()
.collect::<Vec<ProcessedFile>>();
let mut files = merged_files;
files.par_sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.rel_path.cmp(&b.rel_path))
});
if files.is_empty() && !non_existent_paths.is_empty() {
eprintln!("Warning: No files were processed. All specified paths were non-existent or contained no valid files.");
}
let output_string = concat_files(&files, config)?;
if tracing::Level::DEBUG <= tracing::level_filters::STATIC_MAX_LEVEL {
tracing::debug!("{} tokens generated", count_tokens(&output_string));
}
Ok((output_string, files))
}
pub fn concat_files(files: &[ProcessedFile], config: &YekConfig) -> anyhow::Result<String> {
let tree_header = if config.tree_header || config.tree_only {
let file_paths: Vec<std::path::PathBuf> = files
.iter()
.map(|f| std::path::PathBuf::from(&f.rel_path))
.collect();
generate_tree(&file_paths)
} else {
String::new()
};
if config.tree_only {
return Ok(tree_header);
}
let mut accumulated = 0_usize;
let cap = if config.token_mode {
parse_token_limit(&config.tokens)?
} else {
ByteSize::from_str(&config.max_size)
.map_err(|e| anyhow!("max_size: Invalid size format: {}", e))?
.as_u64() as usize
};
let tree_header_size = if config.tree_header {
if config.token_mode {
count_tokens(&tree_header)
} else {
tree_header.len()
}
} else {
0
};
accumulated += tree_header_size;
let mut sorted_files: Vec<_> = files.iter().collect();
sorted_files.sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.rel_path.cmp(&b.rel_path))
});
let mut files_to_include = Vec::new();
for file in sorted_files {
let content_size = if config.token_mode {
let content = format_content_with_line_numbers(&file.content, config.line_numbers);
let formatted = if config.json {
serde_json::to_string(&serde_json::json!({
"filename": &file.rel_path,
"content": content,
}))
.map_err(|e| anyhow!("Failed to serialize JSON: {}", e))?
} else {
config
.output_template
.as_ref()
.expect("output_template should be set")
.replace("FILE_PATH", &file.rel_path)
.replace("FILE_CONTENT", &content)
.replace("\\\\\n", "\n") .replace("\\\\n", "\n") };
count_tokens(&formatted)
} else {
let content = format_content_with_line_numbers(&file.content, config.line_numbers);
content.len()
};
if accumulated + content_size <= cap {
accumulated += content_size;
files_to_include.push(file);
} else {
break;
}
}
let main_content = if config.json {
serde_json::to_string_pretty(
&files_to_include
.iter()
.map(|f| {
let content = format_content_with_line_numbers(&f.content, config.line_numbers);
serde_json::json!({
"filename": &f.rel_path,
"content": content,
})
})
.collect::<Vec<_>>(),
)?
} else {
files_to_include
.iter()
.map(|f| {
let content = format_content_with_line_numbers(&f.content, config.line_numbers);
config
.output_template
.as_ref()
.expect("output_template should be set")
.replace("FILE_PATH", &f.rel_path)
.replace("FILE_CONTENT", &content)
.replace("\\\\\n", "\n") .replace("\\\\n", "\n") })
.collect::<Vec<_>>()
.join("\n")
};
if config.tree_header {
Ok(format!("{}{}", tree_header, main_content))
} else {
Ok(main_content)
}
}
fn format_content_with_line_numbers(content: &str, include_line_numbers: bool) -> String {
if !include_line_numbers {
return content.to_string();
}
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let width = if total_lines == 0 {
3
} else {
std::cmp::max(3, total_lines.to_string().len())
};
lines
.iter()
.enumerate()
.map(|(i, line)| format!("{:width$} | {}", i + 1, line, width = width))
.collect::<Vec<_>>()
.join("\n")
}
pub fn parse_token_limit(limit: &str) -> anyhow::Result<usize> {
if limit.to_lowercase().ends_with('k') {
let chars: Vec<char> = limit.chars().collect();
if chars.len() > 1 {
chars[..chars.len() - 1]
.iter()
.collect::<String>()
.trim()
.parse::<usize>()
.map(|n| n * 1000)
.map_err(|e| anyhow!("tokens: Invalid token size: {}", e))
} else {
Err(anyhow!("tokens: Invalid token format: {}", limit))
}
} else {
limit
.parse::<usize>()
.map_err(|e| anyhow!("tokens: Invalid token size: {}", e))
}
}
pub fn count_tokens(text: &str) -> usize {
get_tokenizer().encode_with_special_tokens(text).len()
}