use anyhow::{bail, Result};
use clap::Parser;
use flat::parse::{parse_binary_number, parse_decimal_number};
use flat::tokens::TokenizerKind;
use flat::{walk_and_flatten, Config};
use globset::Glob;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "flat")]
#[command(version)]
#[command(about = "Flatten a codebase into AI-friendly format")]
#[command(long_about = "\
Flatten a codebase into AI-friendly XML format. Outputs <file> tags with source \
content, respecting .gitignore and skipping binaries and secrets automatically.
Examples:
flat Flatten current directory to stdout
flat src/ | pbcopy Copy to clipboard (macOS)
flat --include rs,toml Only Rust and TOML files
flat --compress Signatures only — strip function bodies
flat --compress --tokens 8k Fit into a token budget (8k = 8,000 tokens)
flat --compress --full-match 'main.rs' Keep main.rs full, compress the rest
flat --stats Preview file count and size
flat --dry-run List files without content")]
#[command(after_help = "\
Compression (--compress) extracts signatures and strips function/method bodies, \
reducing token usage by 30-60%. Supported languages: Rust, TypeScript, JavaScript, \
Python, Go. Unsupported files pass through in full.
Combine --compress with --tokens to fit a codebase into a context window. \
High-priority files (README, entry points, configs) are included first; \
low-priority files (tests, fixtures) are excluded first.
Exit codes: 0 = success, 3 = no files matched")]
struct Cli {
#[arg(default_value = ".", value_name = "DIR")]
path: PathBuf,
#[arg(long, value_delimiter = ',', value_name = "EXT")]
include: Option<Vec<String>>,
#[arg(long, value_delimiter = ',', value_name = "EXT")]
exclude: Option<Vec<String>>,
#[arg(long, alias = "regex", value_name = "GLOB")]
r#match: Option<Vec<String>>,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
stats: bool,
#[arg(long, value_name = "FILE")]
gitignore: Option<PathBuf>,
#[arg(long, default_value = "1048576", value_parser = parse_binary_number, value_name = "BYTES")]
max_size: u64,
#[arg(long)]
compress: bool,
#[arg(long, value_delimiter = ',', value_name = "GLOB")]
full_match: Option<Vec<String>>,
#[arg(long, value_parser = parse_decimal_number, value_name = "N")]
tokens: Option<usize>,
#[arg(long, default_value = "heuristic", value_name = "NAME")]
tokenizer: String,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let match_patterns = match cli.r#match {
Some(patterns) => {
let mut compiled = Vec::new();
for pattern in &patterns {
match Glob::new(pattern) {
Ok(glob) => compiled.push(glob.compile_matcher()),
Err(e) => bail!("Invalid match pattern '{}': {}", pattern, e),
}
}
Some(compiled)
}
None => None,
};
let full_match_patterns = match cli.full_match {
Some(patterns) => {
if !cli.compress {
eprintln!("Warning: --full-match has no effect without --compress");
}
let mut compiled = Vec::new();
for pattern in &patterns {
match Glob::new(pattern) {
Ok(glob) => compiled.push(glob.compile_matcher()),
Err(e) => bail!("Invalid full-match pattern '{}': {}", pattern, e),
}
}
Some(compiled)
}
None => None,
};
let tokenizer = match TokenizerKind::parse_name(&cli.tokenizer) {
Some(kind) => kind,
None => bail!(
"Unknown tokenizer '{}'. Valid options: {}",
cli.tokenizer,
TokenizerKind::valid_names()
),
};
let config = Config {
path: cli.path,
include_extensions: cli.include,
exclude_extensions: cli.exclude,
match_patterns,
output_file: cli.output,
dry_run: cli.dry_run,
stats_only: cli.stats,
gitignore_path: cli.gitignore,
max_file_size: cli.max_size,
compress: cli.compress,
full_match_patterns,
token_budget: cli.tokens,
tokenizer,
};
let stats = walk_and_flatten(&config)?;
let output_files = if stats.token_budget.is_some() {
stats
.included_files
.saturating_sub(stats.excluded_by_budget.len())
} else {
stats.included_files
};
if output_files == 0 {
eprintln!("Error: No files matched the criteria");
std::process::exit(3);
}
Ok(())
}