use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use coding_tools::block::{self, NearestMiss};
use coding_tools::explain::Format;
use coding_tools::pulse::{self, HeartbeatOpts, PulseState};
use coding_tools::verdict::Expect;
use coding_tools::walk::{self, EntryType};
use coding_tools::{pattern, payload, template};
use regex::Regex;
use serde_json::json;
const EXPLAIN_MD: &str = include_str!("../../docs/explain/ct-search.md");
const EXPLAIN_JSON: &str = include_str!("../../docs/explain/ct-search.json");
#[derive(Parser, Debug)]
#[command(
name = "ct-search",
version,
about = "Recursively find files by name, type, size, and content from a chosen root.",
long_about = "ct-search combines the predicates you would otherwise assemble from find, xargs, \
and grep into one declarative command (also reachable as `ct search`). An entry \
matches only when every supplied predicate holds. See `ct-search --explain` for \
agent-oriented documentation."
)]
#[command(group = clap::ArgGroup::new("output_mode")
.args(["list", "summary", "detail", "quiet"])
.multiple(false))]
struct Cli {
#[arg(long, default_value = ".")]
base: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(long, value_enum, value_delimiter = ',')]
r#type: Vec<EntryType>,
#[arg(long)]
grep: Option<String>,
#[arg(long, value_enum)]
mode: Option<pattern::Mode>,
#[arg(long)]
size: Option<String>,
#[arg(long)]
hidden: bool,
#[arg(long)]
follow: bool,
#[arg(long)]
limit: Option<usize>,
#[arg(long, value_name = "SECS")]
timeout: Option<f64>,
#[command(flatten)]
heartbeat: HeartbeatOpts,
#[arg(long)]
question: Option<String>,
#[arg(long)]
expect: Option<String>,
#[arg(long, alias = "emit-stdout")]
emit: Option<String>,
#[arg(long)]
emit_stderr: Option<String>,
#[arg(long)]
list: bool,
#[arg(long)]
summary: bool,
#[arg(long)]
detail: bool,
#[arg(long)]
quiet: bool,
#[arg(long)]
json: bool,
#[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
explain: Option<Format>,
}
enum Mode {
List,
Summary,
Detail,
Quiet,
}
impl Mode {
fn from(cli: &Cli) -> Mode {
if cli.summary {
Mode::Summary
} else if cli.detail {
Mode::Detail
} else if cli.quiet {
Mode::Quiet
} else {
Mode::List
}
}
}
enum Grep {
Line(Regex),
Block(Vec<String>),
}
fn compile_grep(resolved: &payload::Resolved, mode: Option<pattern::Mode>) -> Result<Grep, String> {
let lines = payload::to_lines(&resolved.text);
if lines.len() > 1 {
if matches!(mode, Some(pattern::Mode::Glob) | Some(pattern::Mode::Regex)) {
return Err(
"a multi-line pattern matches as a literal block; --mode glob/regex is reserved"
.to_string(),
);
}
return Ok(Grep::Block(lines));
}
let effective = mode.or(resolved.from_file.then_some(pattern::Mode::Literal));
let single = lines.into_iter().next().unwrap_or_default();
pattern::compile_with(&single, effective)
.map(Grep::Line)
.map_err(|e| format!("invalid --grep pattern: {e}"))
}
fn run(cli: Cli) -> Result<ExitCode, String> {
let _watchdog = pulse::watchdog("ct-search", cli.timeout)?;
let _pulse = cli.heartbeat.start("ct-search", PulseState::new())?;
let names = match &cli.name {
Some(spec) => Some(
pattern::compile_name_set_with(spec, cli.mode)
.map_err(|e| format!("invalid --name pattern: {e}"))?,
),
None => None,
};
let grep_re = match &cli.grep {
Some(p) => Some(compile_grep(&payload::resolve(p)?, cli.mode)?),
None => None,
};
let size = match &cli.size {
Some(s) => Some(walk::parse_size(s)?),
None => None,
};
let expect = match &cli.expect {
Some(s) => Expect::parse(s).map_err(|e| format!("invalid --expect: {e}"))?,
None => Expect::default(),
};
let selector = walk::Selector {
base: cli.base.clone(),
names,
types: cli.r#type.clone(),
size,
hidden: cli.hidden,
follow: cli.follow,
};
let mode = Mode::from(&cli);
let emit_present = cli.emit.is_some() || cli.emit_stderr.is_some();
let need_lines =
(matches!(mode, Mode::Detail | Mode::Summary) || emit_present) && grep_re.is_some();
let collect_matches = emit_present || cli.json;
if !cli.json
&& !cli.quiet
&& let Some(q) = &cli.question
{
println!("== {q} ==");
}
let mut matched = 0usize;
let mut total_lines = 0usize;
let mut match_paths: Vec<String> = Vec::new();
let mut nearest: Option<(String, NearestMiss)> = None;
for entry in selector.walk() {
let entry = entry?;
let mut lines: Vec<(usize, String)> = Vec::new();
if let Some(grep) = &grep_re {
if !entry.file_type().is_file() {
continue;
}
let bytes = match std::fs::read(entry.path()) {
Ok(b) => b,
Err(_) => continue,
};
let content = String::from_utf8_lossy(&bytes);
match grep {
Grep::Line(re) => {
if !re.is_match(&content) {
continue;
}
if need_lines {
for (i, line) in content.lines().enumerate() {
if re.is_match(line) {
lines.push((i + 1, line.to_string()));
}
}
}
}
Grep::Block(b) => {
let file_lines: Vec<&str> = content.lines().collect();
let starts = block::find_starts(&file_lines, b);
if starts.is_empty() {
if let Some(miss) = block::nearest_miss(&file_lines, b)
&& nearest
.as_ref()
.is_none_or(|(_, n)| miss.first_diverging_line > n.first_diverging_line)
{
nearest = Some((entry.path().display().to_string(), miss));
}
continue;
}
for s in starts {
lines.push((s + 1, file_lines[s].to_string()));
}
}
}
}
matched += 1;
total_lines += lines.len();
if collect_matches {
match_paths.push(entry.path().display().to_string());
}
if !cli.json {
match mode {
Mode::List => println!("{}", entry.path().display()),
Mode::Detail => {
if grep_re.is_some() && !lines.is_empty() {
for (ln, text) in &lines {
println!("{}:{}:{}", entry.path().display(), ln, text);
}
} else {
println!("{}", entry.path().display());
}
}
Mode::Summary | Mode::Quiet => {}
}
}
if let Some(limit) = cli.limit
&& matched >= limit
{
break;
}
}
if !cli.json
&& let Mode::Summary = mode
{
if grep_re.is_some() {
println!("{matched} file(s) matched, {total_lines} matching line(s)");
} else {
println!("{matched} match(es)");
}
}
if matched == 0
&& matches!(mode, Mode::Detail)
&& let Some((path, m)) = &nearest
{
eprintln!(
"ct-search: nearest miss: {path}:{}: block diverges at its line {}",
m.line, m.first_diverging_line
);
eprintln!("ct-search: expected: {}", m.expected);
eprintln!("ct-search: found: {}", m.found);
}
let verdict = expect.eval(matched as u64);
if cli.json {
let obj = json!({
"tool": "ct-search",
"verdict": verdict.label(),
"base": cli.base.display().to_string(),
"count": matched,
"lines": total_lines,
"matches": match_paths,
});
println!("{obj}");
} else if emit_present {
let count = matched.to_string();
let lines = total_lines.to_string();
let base = cli.base.display().to_string();
let matches_joined = match_paths.join("\n");
let tokens = [
("RESULT", verdict.label()),
("QUESTION", cli.question.as_deref().unwrap_or("")),
("BASE", base.as_str()),
("COUNT", count.as_str()),
("LINES", lines.as_str()),
("MATCHES", matches_joined.as_str()),
];
if let Some(t) = &cli.emit {
println!("{}", template::render(t, &tokens));
}
if let Some(t) = &cli.emit_stderr {
eprintln!("{}", template::render(t, &tokens));
}
}
Ok(verdict.exit_code())
}
fn main() -> ExitCode {
let cli = Cli::parse();
if let Some(fmt) = cli.explain {
let body = match fmt {
Format::Md => EXPLAIN_MD,
Format::Json => EXPLAIN_JSON,
};
print!("{body}");
return ExitCode::SUCCESS;
}
match run(cli) {
Ok(code) => code,
Err(msg) => {
eprintln!("ct-search: {msg}");
ExitCode::from(2)
}
}
}