use std::io::{self, IsTerminal, Write};
use anyhow::Context;
use crate::cli::{build_filter, SearchArgs};
use crate::files::{
self, discover_log_files, filter_files_by_date, format_size, lazy_log_reader, ReadFailures,
Segment,
};
use crate::prescan;
use crate::related;
use crate::run::{pattern_flag, print_epilogue, print_hidden, run_output, RunCtx, RunPlan};
const MAX_UNCONFIRMED_FILES: usize = 20;
const MAX_UNCONFIRMED_BYTES: u64 = 1024 * 1024 * 1024;
fn max_unconfirmed_bytes() -> anyhow::Result<u64> {
let Some(raw) = crate::env::var("FSLOG_CONFIRM_SIZE")? else {
return Ok(MAX_UNCONFIRMED_BYTES);
};
raw.parse()
.with_context(|| format!("FSLOG_CONFIRM_SIZE={raw} is not a byte count"))
}
fn coverage_note(files: &[files::LogFile]) -> Option<String> {
let stamps: Vec<&str> = files.iter().filter_map(|f| f.date.as_deref()).collect();
let active = files.iter().any(|f| f.date.is_none());
match (stamps.first(), active) {
(None, true) => Some("log coverage here: active log only".to_string()),
(None, false) => None,
(Some(first), _) => {
let day = |s: &str| s[..s.len().min(10)].to_string();
let last = if active {
"now".to_string()
} else {
day(stamps.last()?)
};
Some(format!("log coverage here: {} – {last}", day(first)))
}
}
}
fn resolve_search_files(
args: &SearchArgs,
all_files: &[files::LogFile],
from: Option<&str>,
until: Option<&str>,
) -> anyhow::Result<Option<Vec<Segment>>> {
if !args.files.is_empty() {
let v = args.files.iter().cloned().map(Segment::new).collect();
return Ok(Some(v));
}
let selected = filter_files_by_date(all_files, from, until);
if selected.is_empty() {
eprintln!("no log files match the date range");
return Ok(None);
}
let total_size: u64 = selected.iter().map(|f| f.size).sum();
if !args.yes
&& (selected.len() > MAX_UNCONFIRMED_FILES || total_size > max_unconfirmed_bytes()?)
{
let scale = format!("{} files ({})", selected.len(), format_size(total_size));
if !io::stdin().is_terminal() {
anyhow::bail!("refusing to scan {scale} without confirmation; pass -y to override");
}
eprint!("about to scan {scale}, proceed? [y/N] ");
let mut answer = String::new();
io::stdin()
.read_line(&mut answer)
.context("reading the confirmation answer")?;
if !answer.trim().eq_ignore_ascii_case("y") {
return Ok(None);
}
}
let v = selected
.iter()
.map(|f| Segment::new(f.path.clone()))
.collect();
Ok(Some(v))
}
fn build_segments(
files: &[Segment],
max_line_bytes: usize,
failures: &ReadFailures,
) -> Vec<(String, Box<dyn Iterator<Item = String>>)> {
files
.iter()
.map(|s| {
(
s.name.clone(),
lazy_log_reader(s.path.clone(), max_line_bytes, failures.clone()),
)
})
.collect()
}
pub fn run(ctx: &RunCtx, args: &SearchArgs, out: &mut dyn Write) -> anyhow::Result<()> {
let dir = ctx.dir.as_path();
if args.pattern.is_some() && args.filter.fgrep.is_some() {
anyhow::bail!("provide either a positional PATTERN or --fgrep, not both");
}
let (from, until) = args.window();
let mut filter = build_filter(&args.filter, from.as_deref(), until.as_deref())?;
if let Some(p) = &args.pattern {
filter.set_fgrep(p)?;
}
let discovered = match args.files.is_empty() {
true => discover_log_files(dir)?,
false => Vec::new(),
};
let files = match resolve_search_files(args, &discovered, from.as_deref(), until.as_deref())? {
Some(f) => f,
None => return Ok(()),
};
let report_empty = || match coverage_note(&discovered) {
Some(n) => eprintln!("no matching entries; {n}"),
None => eprintln!("no matching entries"),
};
let seeded = if files.len() > 1 {
match args
.pattern
.as_deref()
.or(args.filter.fgrep.as_deref())
.or(match args.filter.uuid.as_slice() {
[only] => Some(only.as_str()),
_ => None,
})
.filter(|n| prescan::is_single_line_safe(n))
{
Some(needle) => prescan::narrow(&files, needle, ctx.color),
None => files.clone(),
}
} else {
files.clone()
};
if seeded.is_empty() {
report_empty();
return Ok(());
}
let printer = args.filter.printer(ctx.color);
let failures = ReadFailures::default();
let mut rendered = seeded;
if args.related {
let legs = related::discover(
build_segments(&rendered, ctx.max_line_bytes, &failures),
&filter.for_discovery(),
);
failures.check()?;
if legs.is_empty() {
report_empty();
return Ok(());
}
let seeds: Vec<String> = legs.into_iter().collect();
filter.set_uuids(&seeds)?;
filter.uuid_strict = true;
rendered = files;
}
let plan = RunPlan {
filter: &filter,
printer: &printer,
fargs: &args.filter,
before: args.before(),
after: args.after(),
};
let run = run_output(
out,
build_segments(&rendered, ctx.max_line_bytes, &failures),
&plan,
)?;
failures.check()?;
if run.matched == 0 {
report_empty();
}
print_hidden(
&filter,
pattern_flag(&args.filter, args.pattern.is_some()),
args.related,
&run.hidden,
);
Ok(print_epilogue(&plan, &run)?)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn log_file(date: Option<&str>) -> files::LogFile {
files::LogFile {
path: PathBuf::from("freeswitch.log"),
date: date.map(str::to_string),
size: 0,
}
}
#[test]
fn coverage_spans_first_stamp_to_last() {
let files = vec![
log_file(Some("2026-05-01-00-00-00")),
log_file(Some("2026-05-20-00-00-00")),
];
assert_eq!(
coverage_note(&files).unwrap(),
"log coverage here: 2026-05-01 – 2026-05-20"
);
}
#[test]
fn active_log_makes_the_upper_bound_now() {
let files = vec![log_file(Some("2026-05-01-00-00-00")), log_file(None)];
assert_eq!(
coverage_note(&files).unwrap(),
"log coverage here: 2026-05-01 – now"
);
}
#[test]
fn active_log_alone_has_no_span() {
assert_eq!(
coverage_note(&[log_file(None)]).unwrap(),
"log coverage here: active log only"
);
}
#[test]
fn no_files_no_note() {
assert!(coverage_note(&[]).is_none());
}
}