use std::{
cmp::Reverse,
collections::HashSet,
fs::{self, File},
io::{self, Read as _},
path::{Path, PathBuf},
};
use clap::{ArgAction, Parser};
use ignore::WalkBuilder;
use regex::RegexSet;
#[allow(clippy::struct_excessive_bools)]
#[derive(Parser, Debug)]
#[command(
name = "fencecat",
version,
about = "Recursively emit Markdown code fences labeled with relative file paths.\nUseful for sharing source trees in LLM chats or other issue trackers."
)]
struct Cli {
#[arg(value_name = "PATHS", default_value = ".", num_args = 1..)]
paths: Vec<PathBuf>,
#[arg(short = 'c', long = "copy", action = ArgAction::SetTrue)]
copy: bool,
#[arg(short = 'B', long = "biggest-first", action = ArgAction::SetTrue)]
biggest_first: bool,
#[arg(
short,
long = "ext",
value_name = "EXT[,EXT...]",
value_delimiter = ','
)]
ext: Option<Vec<String>>,
#[arg(long = "not-ext", value_name = "EXT[,EXT...]", value_delimiter = ',')]
not_ext: Option<Vec<String>>,
#[arg(short, long = "regex", action = ArgAction::Append)]
regex: Option<Vec<String>>,
#[arg(long = "not-regex", action = ArgAction::Append)]
not_regex: Option<Vec<String>>,
#[arg(short = 'H', long = "no-ignore")]
no_ignore: bool,
#[arg(short = 'D', long = "dir-list", action = ArgAction::SetTrue)]
dir_list: bool,
#[arg(long = "context-relative", action = ArgAction::SetTrue)]
context_relative: bool,
}
impl Cli {
pub fn build_walkdir(&self) -> WalkBuilder {
let mut paths_iter = self.paths.iter();
let first_path = paths_iter.next().expect("At least one path is required");
let mut wb = WalkBuilder::new(first_path);
for path in paths_iter {
wb.add(path);
}
if self.no_ignore {
wb.hidden(false)
.ignore(false)
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.parents(false);
}
wb
}
}
fn is_binary(path: &Path) -> io::Result<bool> {
let mut f = File::open(path)?;
let mut buf = [0u8; 8192];
let n = f.read(&mut buf)?;
Ok(buf[..n].contains(&0))
}
fn choose_fence(content: &str) -> String {
for n in 3..=10 {
let fence = "`".repeat(n);
if !content.contains(&fence) {
return fence;
}
}
"````````````".to_string()
}
#[derive(Debug)]
struct FileInfo {
path: PathBuf,
rel: String,
size: u64,
arg_index: usize,
}
fn normalize_ext_list(list: &[String]) -> HashSet<String> {
list.iter()
.map(|s| s.trim().trim_start_matches('.').to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect()
}
fn build_ext_filters(cli: &Cli) -> (Option<HashSet<String>>, Option<HashSet<String>>) {
let allow = cli.ext.as_ref().map(|v| normalize_ext_list(v));
let deny = cli.not_ext.as_ref().map(|v| normalize_ext_list(v));
(allow, deny)
}
fn compile_regex_sets(cli: &Cli) -> (Option<RegexSet>, Option<RegexSet>) {
let allow = cli
.regex
.as_ref()
.map(|v| RegexSet::new(v).expect("Invalid regex in --regex"));
let deny = cli
.not_regex
.as_ref()
.map(|v| RegexSet::new(v).expect("Invalid regex in --not-regex"));
(allow, deny)
}
fn make_fileinfo_if_included(
path: &Path,
root_for_rel: &Path,
arg_index: usize,
ext_allow: Option<&HashSet<String>>,
ext_deny: Option<&HashSet<String>>,
re_allow: Option<&RegexSet>,
re_deny: Option<&RegexSet>,
) -> Option<FileInfo> {
if ext_allow.is_some() || ext_deny.is_some() {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
if let Some(allow) = ext_allow
&& !ext.as_ref().is_some_and(|e| allow.contains(e))
{
return None;
}
if let Some(deny) = ext_deny
&& ext.as_ref().is_some_and(|e| deny.contains(e))
{
return None;
}
}
if re_allow.is_some() || re_deny.is_some() {
let cwd_rel = fencecat::rel_string(Path::new("."), path);
if let Some(allow) = re_allow
&& !allow.is_match(&cwd_rel)
{
return None;
}
if let Some(deny) = re_deny
&& deny.is_match(&cwd_rel)
{
return None;
}
}
let md = match path.metadata() {
Ok(m) => m,
Err(e) => {
eprintln!("skip {}: metadata error: {e}", path.display());
return None;
}
};
if md.len() == 0 {
return None;
}
match is_binary(path) {
Ok(true) => return None,
Ok(false) => {}
Err(e) => {
eprintln!("skip {}: read error: {e}", path.display());
return None;
}
}
let rel = fencecat::rel_string(root_for_rel, path);
Some(FileInfo {
path: path.to_path_buf(),
rel,
size: md.len(),
arg_index,
})
}
fn collect_any(cli: &Cli) -> Vec<FileInfo> {
let (ext_allow, ext_deny) = build_ext_filters(cli);
let (re_allow, re_deny) = compile_regex_sets(cli);
for path in &cli.paths {
if !path.exists() {
eprintln!("No such file or directory: {}", path.display());
std::process::exit(1);
}
}
let walker = cli.build_walkdir().build();
let mut files: Vec<FileInfo> = Vec::new();
for dent in walker {
let entry = match dent {
Ok(e) => e,
Err(err) => {
eprintln!("walk error: {err}");
continue;
}
};
if entry.file_type().is_some_and(|ft| ft.is_file()) {
let path = entry.path();
let matched_arg = cli
.paths
.iter()
.enumerate()
.find(|(_, p)| path.starts_with(p));
let arg_index = matched_arg.map_or(0, |(i, _)| i);
let root_for_rel = if cli.context_relative {
matched_arg.map_or_else(
|| PathBuf::from("."),
|(_, p)| {
if p.is_file() {
p.parent().unwrap_or_else(|| Path::new(".")).to_path_buf()
} else {
p.clone()
}
},
)
} else {
PathBuf::from(".")
};
if let Some(info) = make_fileinfo_if_included(
path,
&root_for_rel,
arg_index,
ext_allow.as_ref(),
ext_deny.as_ref(),
re_allow.as_ref(),
re_deny.as_ref(),
) {
files.push(info);
}
}
}
if cli.biggest_first {
files.sort_by(|a, b| {
Reverse(a.size)
.cmp(&Reverse(b.size))
.then_with(|| a.arg_index.cmp(&b.arg_index))
.then_with(|| a.rel.cmp(&b.rel))
});
} else {
files.sort_by(|a, b| {
a.arg_index
.cmp(&b.arg_index)
.then_with(|| a.rel.cmp(&b.rel))
});
}
files
}
fn emit_dir_listing(files: &[FileInfo]) -> String {
let mut s = String::new();
s.push_str("```\n");
for f in files {
s.push_str(&f.rel);
s.push('\n');
}
s.push_str("```\n\n");
s
}
fn main() {
let cli = Cli::parse();
let files = collect_any(&cli);
let mut out = String::new();
if cli.dir_list {
out.push_str(&emit_dir_listing(&files));
}
for f in &files {
let bytes = match fs::read(&f.path) {
Ok(b) => b,
Err(e) => {
eprintln!("skip {}: read error: {e}", f.path.display());
continue;
}
};
let content = String::from_utf8_lossy(&bytes);
let fence = choose_fence(&content);
out.push_str(&fence);
out.push_str(&f.rel);
out.push('\n');
out.push_str(&content);
if !content.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out.push_str(&fence);
out.push_str("\n\n");
}
print!("{out}");
if cli.copy {
match fencecat::clipboard::copy_to_clipboard_multi(&out) {
Ok(()) => eprintln!(">> copied to clipboard"),
Err(e) => eprintln!(">> failed to copy to clipboard: {e}"),
}
}
}