use std::io::{self, BufRead, IsTerminal, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use pctx::cli::{Cli, Commands, ConfigCommands, FilesCommands};
use pctx::config::Config;
use pctx::content::{ContentProcessor, FileEntry};
use pctx::error::PctxError;
use pctx::exit_codes::exit;
use pctx::output::json_types::{
error_codes, ContextOutput, ErrorResponse, FileError, FileInfo, JsonResponse, PartialResponse,
ResponseData, StatsJson, SuccessResponse, TreeOutput,
};
use pctx::output::{clipboard, file, formatter, tree};
use pctx::scanner::Scanner;
use pctx::stats::Stats;
fn main() -> ExitCode {
let cli = Cli::parse();
if cli.global.no_color
|| std::env::var_os("NO_COLOR").is_some()
|| (!io::stderr().is_terminal() && !io::stdout().is_terminal())
{
colored::control::set_override(false);
}
let result = run(&cli);
match result {
Ok(code) => ExitCode::from(code as u8),
Err(e) => {
let (response, code) = error_to_response(&e);
if cli.global.json {
if let Ok(json) = serde_json::to_string_pretty(&response) {
println!("{}", json);
}
} else {
eprintln!("error: {}", e);
if let Some(suggestion) = e.suggestion() {
eprintln!("suggestion: {}", suggestion);
}
}
ExitCode::from(code as u8)
}
}
}
fn run(cli: &Cli) -> Result<i32, PctxError> {
match &cli.command {
Some(Commands::Files(files_cmd)) => run_files_command(files_cmd, &cli.global),
Some(Commands::Config(config_cmd)) => run_config_command(config_cmd, &cli.global),
Some(Commands::Completions { shell }) => {
generate_completions(shell);
Ok(exit::SUCCESS)
}
None => run_generate_command(&cli.generate, &cli.global),
}
}
fn run_generate_command(
args: &pctx::cli::GenerateArgs,
global: &pctx::cli::GlobalArgs,
) -> Result<i32, PctxError> {
let config = Config::from_args(args, global)?;
if args.stdin && !args.paths.is_empty() && !global.quiet {
eprintln!("Warning: positional paths are ignored when --stdin is used");
}
let start_time = std::time::Instant::now();
let scanner = Scanner::new(&config);
let scan_result = if args.stdin {
let paths = read_paths_from_stdin()?;
if paths.is_empty() {
return handle_no_files_matched(args, global);
}
scanner.scan_paths(paths)?
} else {
scanner.scan()?
};
if scan_result.files.is_empty() {
if let Some((_, err)) = scan_result.errors.into_iter().next() {
return Err(err);
}
return handle_no_files_matched(args, global);
}
let files = scan_result.files;
for (path, err) in &scan_result.errors {
if global.verbose && !global.json {
eprintln!("Warning: {}: {}", path.display(), err);
}
}
let mut scan_file_errors: Vec<FileError> = scan_result
.errors
.iter()
.map(|(path, err)| FileError {
path: path.to_string_lossy().to_string(),
code: err.code().to_string(),
message: err.to_string(),
transient: err.is_transient(),
})
.collect();
let processor = ContentProcessor::new(&config);
let mut entries: Vec<FileEntry> = Vec::new();
let mut file_errors: Vec<FileError> = Vec::new();
file_errors.append(&mut scan_file_errors);
let mut stats = Stats::new();
for file_path in files {
match processor.process(&file_path) {
Ok(entry) => {
stats.add_file(&entry);
entries.push(entry);
}
Err(e) => {
if global.verbose && !global.json {
eprintln!("Skipped {}: {}", file_path.display(), e);
}
file_errors.push(FileError {
path: file_path.to_string_lossy().to_string(),
code: e.code().to_string(),
message: e.to_string(),
transient: e.is_transient(),
});
}
}
}
if args.dry_run {
let formatted = formatter::format_output(&entries, &config)?;
stats.estimate_tokens(&formatted, &args.token_model);
return handle_dry_run(
&entries,
&file_errors,
&stats,
global,
config.absolute_paths,
);
}
let formatted = formatter::format_output(&entries, &config)?;
stats.duration_ms = start_time.elapsed().as_millis() as u64;
if args.output.stats || global.json {
stats.estimate_tokens(&formatted, &args.token_model);
}
if global.json {
let file_infos: Vec<FileInfo> = entries
.iter()
.map(|e| FileInfo::from_entry(e, config.absolute_paths))
.collect();
let response = if file_errors.is_empty() {
JsonResponse::Success(SuccessResponse {
data: ResponseData::Context(ContextOutput {
content: formatted.clone(),
format: args.output.format.as_str().to_string(),
files: file_infos,
}),
stats: (&stats).into(),
})
} else {
JsonResponse::Partial(PartialResponse {
data: ResponseData::Context(ContextOutput {
content: formatted.clone(),
format: args.output.format.as_str().to_string(),
files: file_infos,
}),
stats: (&stats).into(),
errors: file_errors.clone(),
})
};
println!("{}", serde_json::to_string_pretty(&response)?);
if args.output.output.is_some() || args.output.clipboard {
write_output(&formatted, &args.output, global)?;
}
return Ok(if file_errors.is_empty() {
exit::SUCCESS
} else {
exit::PARTIAL
});
}
write_output(&formatted, &args.output, global)?;
if args.output.stats {
stats.print_summary();
}
Ok(if file_errors.is_empty() {
exit::SUCCESS
} else {
exit::PARTIAL
})
}
fn read_paths_from_stdin() -> Result<Vec<PathBuf>, PctxError> {
let stdin = io::stdin();
let mut paths = Vec::new();
for line in stdin.lock().lines() {
let line = line?;
let trimmed = line.trim();
if !trimmed.is_empty() {
paths.push(PathBuf::from(trimmed));
}
}
Ok(paths)
}
fn handle_no_files_matched(
args: &pctx::cli::GenerateArgs,
global: &pctx::cli::GlobalArgs,
) -> Result<i32, PctxError> {
if global.json {
let response = JsonResponse::Error(ErrorResponse {
code: error_codes::NO_FILES_MATCHED.to_string(),
message: "No files matched the specified filters".to_string(),
input: Some(serde_json::json!({
"paths": args.paths,
"exclude": args.filter.exclude,
"include": args.filter.include,
"stdin": args.stdin,
})),
suggestion: Some("Try broadening your filters or checking the paths exist".to_string()),
transient: false,
exit_code: exit::NO_MATCH,
});
println!("{}", serde_json::to_string_pretty(&response)?);
} else if !global.quiet {
eprintln!("No files matched the specified filters.");
eprintln!("Hint: use --no-default-excludes to include commonly excluded directories");
}
Ok(exit::NO_MATCH)
}
fn write_output(
content: &str,
args: &pctx::cli::OutputArgs,
global: &pctx::cli::GlobalArgs,
) -> Result<(), PctxError> {
let mut wrote_to_dest = false;
if let Some(ref path) = args.output {
file::write(path, content, args.force)?;
if !global.json && !global.quiet {
eprintln!("Written to: {}", path.display());
}
wrote_to_dest = true;
}
if args.clipboard {
clipboard::write(content)?;
if !global.json && !global.quiet {
eprintln!("✓ Copied to clipboard ({} bytes)", content.len());
}
wrote_to_dest = true;
}
if !wrote_to_dest {
print!("{}", content);
io::stdout().flush().map_err(PctxError::Io)?;
}
Ok(())
}
fn run_files_command(
cmd: &FilesCommands,
global: &pctx::cli::GlobalArgs,
) -> Result<i32, PctxError> {
match cmd {
FilesCommands::List { filter, quiet } => {
let config = Config::from_filter_args(filter, global)?;
let scanner = Scanner::new(&config);
let scan_result = scanner.scan()?;
let files = scan_result.files;
let relative_files = relativize_paths(&files);
let suppress_extra = *quiet || global.quiet;
if suppress_extra {
for file in &relative_files {
println!("{}", file.display());
}
} else if global.json {
let file_infos: Vec<FileInfo> = files
.iter()
.filter_map(|f| FileInfo::try_from_path(f).ok())
.collect();
let response = JsonResponse::Success(SuccessResponse {
data: ResponseData::FileList(file_infos),
stats: StatsJson::new(files.len()),
});
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
for file in &relative_files {
println!("{}", file.display());
}
eprintln!("\n{} files", files.len());
}
Ok(if files.is_empty() {
exit::NO_MATCH
} else {
exit::SUCCESS
})
}
FilesCommands::Tree { filter } => {
let config = Config::from_filter_args(filter, global)?;
let scanner = Scanner::new(&config);
let scan_result = scanner.scan()?;
let files = scan_result.files;
let relative_files = relativize_paths(&files);
let tree_struct = tree::build_tree(&relative_files);
if global.json {
let response = JsonResponse::Success(SuccessResponse {
data: ResponseData::Tree(TreeOutput {
tree: tree::tree_to_string(&tree_struct),
}),
stats: StatsJson::new(files.len()),
});
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
tree::print_tree(&tree_struct);
if !global.quiet {
eprintln!("\n{} files", files.len());
}
}
Ok(exit::SUCCESS)
}
}
}
fn run_config_command(
cmd: &ConfigCommands,
global: &pctx::cli::GlobalArgs,
) -> Result<i32, PctxError> {
match cmd {
ConfigCommands::Show => {
let config = pctx::config::file::find_and_load()?;
if global.json {
println!("{}", serde_json::to_string_pretty(&config)?);
} else {
println!("{}", toml::to_string_pretty(&config)?);
}
Ok(exit::SUCCESS)
}
ConfigCommands::Init { force } => {
let path = std::path::Path::new(".pctx.toml");
pctx::config::file::write_template(path, *force)?;
if global.json {
println!(r#"{{"status": "success", "path": ".pctx.toml"}}"#);
} else {
eprintln!("Created .pctx.toml");
}
Ok(exit::SUCCESS)
}
ConfigCommands::Defaults => {
let defaults = pctx::config::defaults::DEFAULT_EXCLUDES;
if global.json {
println!("{}", serde_json::to_string_pretty(&defaults)?);
} else {
for pattern in defaults {
println!("{}", pattern);
}
}
Ok(exit::SUCCESS)
}
}
}
fn error_to_response(e: &PctxError) -> (JsonResponse, i32) {
let exit_code = e.exit_code();
let response = JsonResponse::Error(ErrorResponse {
code: e.code().to_string(),
message: e.to_string(),
input: e.input_context(),
suggestion: e.suggestion().map(String::from),
transient: e.is_transient(),
exit_code,
});
(response, exit_code)
}
fn generate_completions(shell: &pctx::cli::Shell) {
use clap::CommandFactory;
use clap_complete::generate;
let mut cmd = Cli::command();
let shell_type = match shell {
pctx::cli::Shell::Bash => clap_complete::Shell::Bash,
pctx::cli::Shell::Zsh => clap_complete::Shell::Zsh,
pctx::cli::Shell::Fish => clap_complete::Shell::Fish,
pctx::cli::Shell::PowerShell => clap_complete::Shell::PowerShell,
pctx::cli::Shell::Elvish => clap_complete::Shell::Elvish,
};
generate(shell_type, &mut cmd, "pctx", &mut io::stdout());
}
fn relativize_paths(files: &[PathBuf]) -> Vec<PathBuf> {
let cwd = std::env::current_dir()
.ok()
.and_then(|p| dunce::canonicalize(&p).ok())
.unwrap_or_else(|| PathBuf::from("."));
files
.iter()
.map(|f| f.strip_prefix(&cwd).unwrap_or(f).to_path_buf())
.collect()
}
fn handle_dry_run(
entries: &[FileEntry],
errors: &[FileError],
stats: &Stats,
global: &pctx::cli::GlobalArgs,
absolute_paths: bool,
) -> Result<i32, PctxError> {
if global.json {
let file_infos: Vec<FileInfo> = entries
.iter()
.map(|e| FileInfo::from_entry(e, absolute_paths))
.collect();
let response = if errors.is_empty() {
JsonResponse::Success(SuccessResponse {
data: ResponseData::FileList(file_infos),
stats: stats.into(),
})
} else {
JsonResponse::Partial(PartialResponse {
data: ResponseData::FileList(file_infos),
stats: stats.into(),
errors: errors.to_vec(),
})
};
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
use colored::*;
eprintln!("{}", "Dry run - files that would be included:".yellow());
eprintln!();
for entry in entries {
let display_path = entry.display_path(absolute_paths);
let marker = if entry.truncated {
" [truncated]".dimmed().to_string()
} else {
String::new()
};
eprintln!(
" {} ({} lines){}",
display_path.green(),
entry.original_lines,
marker
);
}
if !errors.is_empty() {
eprintln!();
eprintln!("{}", "Skipped files:".yellow());
for err in errors {
eprintln!(" {} ({})", err.path.red(), err.code);
}
}
eprintln!();
eprintln!(
"Total: {} files, ~{} tokens",
entries.len(),
stats.token_estimate.unwrap_or(0)
);
}
Ok(exit::SUCCESS)
}