use std::collections::{HashSet, VecDeque}; use std::env; use std::fs::{self, File}; use std::io::{self, BufRead, BufReader, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Instant; const DEFAULT_SAMPLES: usize = 5; const DEFAULT_LIMIT: usize = 200; const DEFAULT_SCAN_DEPTH: usize = 3; const DEFAULT_DISCOVER_DEPTH: usize = 4; const MAX_DISCOVER_DIRS: usize = 20_000; #[derive(Clone, Debug)] struct Config { roots: Vec, iccid: String, date: Option, interface: Option, contains: Vec, lines_mode: bool, count_only: bool, list_roots: bool, choose_roots: bool, interactive: bool, include_gz: bool, samples: usize, limit: usize, threads: usize, max_depth: usize, max_files: Option, } impl Default for Config { fn default() -> Self { let threads = thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4) .clamp(1, 16); Self { roots: Vec::new(), iccid: String::new(), date: None, interface: None, contains: Vec::new(), lines_mode: false, count_only: false, list_roots: false, choose_roots: false, interactive: false, include_gz: true, samples: DEFAULT_SAMPLES, limit: DEFAULT_LIMIT, threads, max_depth: DEFAULT_SCAN_DEPTH, max_files: None, } } } #[derive(Clone, Debug)] struct RootCandidate { path: PathBuf, log_files: usize, gz_files: usize, } #[derive(Clone, Debug)] struct SampleLine { file: PathBuf, line_no: usize, line: String, } #[derive(Debug)] struct FileResult { path: PathBuf, matched: usize, scanned_lines: usize, samples: Vec, error: Option, } #[derive(Debug)] struct MatchFilter { iccid: String, date: Option, interface_terms: Vec, contains: Vec, } fn main() { let start = Instant::now(); let mut cfg = match parse_args() { Ok(cfg) => cfg, Err(msg) => { eprintln!("{msg}"); print_usage(io::stderr()); std::process::exit(2); } }; if cfg.list_roots { let roots = if cfg.roots.is_empty() { discover_roots(DEFAULT_DISCOVER_DEPTH) } else { discover_log_dirs(&cfg.roots, cfg.max_depth, cfg.include_gz) }; print_root_candidates(&roots); return; } if cfg.interactive { cfg = run_wizard(cfg).unwrap_or_else(|err| { eprintln!("交互配置失败: {err}"); std::process::exit(2); }); } if cfg.iccid.is_empty() { eprintln!("必须传入 --iccid,避免误扫大日志"); print_usage(io::stderr()); std::process::exit(2); } let roots_were_provided = !cfg.roots.is_empty() || !roots_from_args().is_empty(); if cfg.roots.is_empty() { cfg.roots = resolve_default_roots(); } if cfg.choose_roots || (!roots_were_provided && !cfg.interactive && io::stdin().is_terminal()) { cfg.roots = choose_roots(&cfg.roots, cfg.max_depth, cfg.include_gz).unwrap_or_else(|err| { eprintln!("选择目录失败: {err}"); std::process::exit(2); }); } if cfg.roots.is_empty() { eprintln!("没有可用日志目录。先运行 --list-roots 查看自动发现结果,或确认日志目录已挂载。"); std::process::exit(2); } let filter = MatchFilter { iccid: cfg.iccid.clone(), date: cfg.date.clone(), interface_terms: interface_terms(cfg.interface.as_deref()).unwrap_or_else(|err| { eprintln!("{err}"); std::process::exit(2); }), contains: cfg.contains.clone(), }; let mut files = collect_log_files( &cfg.roots, cfg.max_depth, cfg.include_gz, cfg.date.as_deref(), ); files.sort_by(|a, b| b.modified_score.cmp(&a.modified_score)); if let Some(max_files) = cfg.max_files { files.truncate(max_files); } if files.is_empty() { eprintln!("没有找到可扫描日志文件。"); std::process::exit(1); } let scan_total = files.len(); let queue = Arc::new(Mutex::new(VecDeque::from(files))); let results = Arc::new(Mutex::new(Vec::::new())); let filter = Arc::new(filter); let cfg_arc = Arc::new(cfg.clone()); let mut handles = Vec::new(); for _ in 0..cfg.threads { let queue = Arc::clone(&queue); let results = Arc::clone(&results); let filter = Arc::clone(&filter); let cfg_arc = Arc::clone(&cfg_arc); handles.push(thread::spawn(move || loop { let file = { let mut guard = queue.lock().expect("文件队列锁异常"); guard.pop_front() }; let Some(file) = file else { break; }; let result = scan_file(&file.path, &filter, &cfg_arc); results.lock().expect("结果锁异常").push(result); })); } for handle in handles { if handle.join().is_err() { eprintln!("有扫描线程异常退出"); } } let mut results = Arc::try_unwrap(results) .expect("结果仍被引用") .into_inner() .expect("结果锁异常"); results.sort_by(|a, b| b.matched.cmp(&a.matched).then_with(|| a.path.cmp(&b.path))); print_results(&cfg, &results, scan_total, start.elapsed().as_millis()); } #[derive(Clone, Debug)] struct LogFile { path: PathBuf, modified_score: u64, } fn parse_args() -> Result { let mut cfg = Config::default(); let raw_args: Vec = env::args().skip(1).collect(); if raw_args.is_empty() && io::stdin().is_terminal() { cfg.interactive = true; } let mut args = raw_args.into_iter().peekable(); while let Some(arg) = args.next() { match arg.as_str() { "--root" => cfg .roots .push(PathBuf::from(next_value(&mut args, "--root")?)), "--roots" => add_roots(&mut cfg.roots, &next_value(&mut args, "--roots")?), "--iccid" => cfg.iccid = next_value(&mut args, "--iccid")?, "--date" => cfg.date = Some(next_value(&mut args, "--date")?), "--interface" | "--api" => cfg.interface = Some(next_value(&mut args, "--interface")?), "--contains" => cfg.contains.push(next_value(&mut args, "--contains")?), "--lines" => cfg.lines_mode = true, "--count-only" => cfg.count_only = true, "--list-roots" => cfg.list_roots = true, "--choose" => cfg.choose_roots = true, "--interactive" | "-i" => cfg.interactive = true, "--no-gz" => cfg.include_gz = false, "--samples" => { cfg.samples = parse_usize(&next_value(&mut args, "--samples")?, "--samples")? } "--limit" => cfg.limit = parse_usize(&next_value(&mut args, "--limit")?, "--limit")?, "--threads" => { cfg.threads = parse_usize(&next_value(&mut args, "--threads")?, "--threads")?.clamp(1, 64) } "--max-depth" => { cfg.max_depth = parse_usize(&next_value(&mut args, "--max-depth")?, "--max-depth")? } "--max-files" => { cfg.max_files = Some(parse_usize( &next_value(&mut args, "--max-files")?, "--max-files", )?) } "-h" | "--help" => { print_usage(io::stdout()); std::process::exit(0); } _ => return Err(format!("未知参数: {arg}")), } } if cfg.roots.is_empty() { for root in roots_from_args() { cfg.roots.push(root); } } Ok(cfg) } fn next_value( args: &mut std::iter::Peekable>, name: &str, ) -> Result { args.next().ok_or_else(|| format!("{name} 缺少参数")) } fn parse_usize(raw: &str, name: &str) -> Result { raw.parse::() .map_err(|_| format!("{name} 需要整数,当前值: {raw}")) } fn roots_from_args() -> Vec { let mut roots = Vec::new(); if let Ok(raw) = env::var("JUNHONG_WORKER_LOG_ROOTS") { add_roots(&mut roots, &raw); } roots } fn add_roots(roots: &mut Vec, raw: &str) { for part in raw.split(':') { let trimmed = part.trim(); if !trimmed.is_empty() { roots.push(PathBuf::from(trimmed)); } } } fn run_wizard(mut cfg: Config) -> io::Result { clear_screen(); print_banner(); let root_candidates = if cfg.roots.is_empty() { discover_roots(DEFAULT_DISCOVER_DEPTH) } else { cfg.roots .iter() .filter(|p| p.is_dir()) .flat_map(|p| discover_log_dirs(&[p.clone()], cfg.max_depth, cfg.include_gz)) .collect() }; if root_candidates.is_empty() { println!("{}", color("没有自动发现日志目录。", "31;1")); println!("可先确认日志挂载目录,或设置 JUNHONG_LOG_DISCOVERY_BASES 后重新运行。"); return Ok(cfg); } cfg.roots = choose_root_candidates_pretty(&root_candidates)?; println!(); cfg.iccid = prompt_required("ICCID", "输入要查询的卡 ICCID")?; println!(); cfg.date = choose_date_pretty()?; println!(); cfg.interface = choose_interface_pretty()?; println!(); let extra = prompt_optional("追加关键词过滤(可直接回车跳过)", None)?; if !extra.is_empty() { cfg.contains.push(extra); } println!(); cfg.lines_mode = prompt_yes_no("是否输出原始命中行?默认只看摘要,避免刷屏", false)?; if cfg.lines_mode { let raw_limit = prompt_optional("最多输出多少行", Some(&cfg.limit.to_string()))?; if !raw_limit.is_empty() { if let Ok(limit) = raw_limit.parse::() { cfg.limit = limit; } } } cfg.include_gz = prompt_yes_no("是否扫描 .gz 压缩轮转日志", true)?; println!(); println!("{}", color("即将扫描", "36;1")); println!(" 目录数: {}", cfg.roots.len()); for root in &cfg.roots { println!(" - {}", root.display()); } println!(" ICCID: {}", cfg.iccid); println!(" 日期: {}", cfg.date.as_deref().unwrap_or("不限")); println!(" 接口: {}", cfg.interface.as_deref().unwrap_or("不限")); println!( " 输出: {}", if cfg.lines_mode { "原始行" } else { "摘要" } ); pause("按回车开始扫描")?; clear_screen(); Ok(cfg) } fn clear_screen() { if io::stdout().is_terminal() { print!("\x1b[2J\x1b[H"); let _ = io::stdout().flush(); } } fn supports_color() -> bool { io::stdout().is_terminal() && env::var_os("NO_COLOR").is_none() } fn color(text: &str, code: &str) -> String { if supports_color() { format!("\x1b[{code}m{text}\x1b[0m") } else { text.to_string() } } fn print_banner() { println!( "{}", color("╭────────────────────────────────────────────╮", "36;1") ); println!( "{}", color("│ 君鸿 Worker 日志查询向导 │", "36;1") ); println!( "{}", color("╰────────────────────────────────────────────╯", "36;1") ); println!("{}", color("默认只输出摘要;需要原始行时再开启。", "2")); println!(); } fn choose_root_candidates_pretty(candidates: &[RootCandidate]) -> io::Result> { println!("{}", color("1. 日志目录", "36;1")); println!("已自动发现以下候选目录,默认选择全部:"); println!(); for (index, item) in candidates.iter().enumerate() { let marker = if item.gz_files > 0 { "含压缩" } else { "普通" }; println!( " {} {:<48} {:>3} 个日志 {:>3} 个 gz {}", color(&format!("{:>2}.", index + 1), "32;1"), truncate_path(&item.path, 48), item.log_files, item.gz_files, color(marker, "2") ); } println!(); println!( "{}", color( "回车=全部;输入 1,3-4=只选部分;all,!2,!5-7=排除部分;q=退出", "2" ) ); let input = prompt_optional("选择目录", Some("all"))?; if input.eq_ignore_ascii_case("q") { std::process::exit(0); } if input.is_empty() || input.eq_ignore_ascii_case("all") { return Ok(candidates.iter().map(|c| c.path.clone()).collect()); } let selected = select_candidate_paths(candidates, &input); if selected.is_empty() { println!("{}", color("没有选中有效目录,使用全部候选目录。", "33")); return Ok(candidates.iter().map(|c| c.path.clone()).collect()); } Ok(selected) } fn truncate_path(path: &Path, width: usize) -> String { let text = path.display().to_string(); let count = text.chars().count(); if count <= width { return text; } let keep = width.saturating_sub(1); let suffix: String = text .chars() .rev() .take(keep) .collect::() .chars() .rev() .collect(); format!("…{suffix}") } fn prompt_required(label: &str, help: &str) -> io::Result { loop { println!("{}", color(help, "2")); let value = prompt_optional(label, None)?; if !value.trim().is_empty() { return Ok(value.trim().to_string()); } println!("{}", color("不能为空。", "31")); } } fn prompt_optional(label: &str, default: Option<&str>) -> io::Result { match default { Some(default) => print!("{} [{}]: ", color(label, "33;1"), default), None => print!("{}: ", color(label, "33;1")), } io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let value = input.trim().to_string(); if value.is_empty() { Ok(default.unwrap_or("").to_string()) } else { Ok(value) } } fn prompt_yes_no(label: &str, default: bool) -> io::Result { let default_text = if default { "Y/n" } else { "y/N" }; loop { let value = prompt_optional(label, Some(default_text))?; if value == default_text || value.is_empty() { return Ok(default); } match value.to_ascii_lowercase().as_str() { "y" | "yes" | "是" => return Ok(true), "n" | "no" | "否" => return Ok(false), _ => println!("{}", color("请输入 y 或 n。", "31")), } } } fn pause(label: &str) -> io::Result<()> { print!("{}...", color(label, "33;1")); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; Ok(()) } fn choose_date_pretty() -> io::Result> { println!("{}", color("2. 查询日期", "36;1")); let today = command_output("date", &["+%F"]).unwrap_or_default(); let yesterday = command_output("date", &["-d", "yesterday", "+%F"]) .or_else(|| command_output("date", &["-v-1d", "+%F"])) .unwrap_or_default(); let today_label = if today.is_empty() { "今天".to_string() } else { format!("今天 {today}") }; let yesterday_label = if yesterday.is_empty() { "昨天".to_string() } else { format!("昨天 {yesterday}") }; println!(" {} {}", color("1.", "32;1"), today_label); println!(" {} {}", color("2.", "32;1"), yesterday_label); println!(" {} 自定义日期", color("3.", "32;1")); println!(" {} 不限制日期", color("4.", "32;1")); let choice = prompt_optional("选择", Some("2"))?; match choice.as_str() { "" | "2" => Ok(if yesterday.is_empty() { None } else { Some(yesterday) }), "1" => Ok(if today.is_empty() { None } else { Some(today) }), "3" => { let date = prompt_required("日期", "格式:YYYY-MM-DD,例如 2026-05-24")?; Ok(Some(date)) } "4" => Ok(None), other if other.len() == 10 && other.chars().nth(4) == Some('-') => { Ok(Some(other.to_string())) } _ => Ok(if yesterday.is_empty() { None } else { Some(yesterday) }), } } fn choose_interface_pretty() -> io::Result> { println!("{}", color("3. 接口类型", "36;1")); println!(" {} 实名 realname", color("1.", "32;1")); println!(" {} 流量 flow/carddata", color("2.", "32;1")); println!(" {} 卡状态 card_status", color("3.", "32;1")); println!(" {} 停机 stop", color("4.", "32;1")); println!(" {} 复机 start", color("5.", "32;1")); println!(" {} 不限制接口", color("6.", "32;1")); println!(" {} 自定义 Gateway 路径", color("7.", "32;1")); let choice = prompt_optional("选择", Some("1"))?; let value = match choice.as_str() { "" | "1" => Some("realname".to_string()), "2" => Some("flow".to_string()), "3" => Some("card_status".to_string()), "4" => Some("stop".to_string()), "5" => Some("start".to_string()), "6" => None, "7" => Some(prompt_required("Gateway 路径", "例如 /flow-card/realName")?), other => Some(other.to_string()), }; Ok(value) } fn command_output(program: &str, args: &[&str]) -> Option { let output = Command::new(program).args(args).output().ok()?; if !output.status.success() { return None; } let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); if text.is_empty() { None } else { Some(text) } } fn print_usage(mut writer: W) { let _ = writeln!( writer, r#"用法: rustc scripts/ops/search-worker-logs.rs -O -o /tmp/jh-logq /tmp/jh-logq /tmp/jh-logq --iccid [选项] 核心选项: --root 增加一个日志目录,可重复 --roots 一次传多个日志目录 --list-roots 自动发现候选日志目录并退出;配合 --root/--roots 时只列指定范围 --choose 从扫描范围内的日志目录交互选择;无需手动输入目录路径 --iccid 必填,按 ICCID 过滤 --date 按日志行日期过滤,并按文件名日期剪枝 --interface realname, flow, carddata, card_status, stop, start,或直接传 /flow-card/realName --contains 追加文本过滤,可重复 输出控制: 默认只输出汇总、命中文件和少量样例,不刷屏。 --lines 输出原始命中行 --limit --lines 最大输出行数,默认 200 --samples 汇总模式样例行数,默认 5 --count-only 只输出命中文件和行数 性能选项: --threads 并发扫描线程数,默认自动 --max-depth 每个 root 下递归深度,默认 3 --max-files 最多扫描 N 个文件 --no-gz 跳过 .gz 文件 环境变量: JUNHONG_WORKER_LOG_ROOTS 默认日志目录,冒号分隔 "# ); } fn resolve_default_roots() -> Vec { let mut roots = roots_from_args(); if !roots.is_empty() { return dedup_paths(roots); } for path in ["/opt/junhong_cmp/logs", "/app/logs", "./logs"] { let p = PathBuf::from(path); if p.is_dir() { roots.push(p); } } if roots.is_empty() { roots = discover_roots(DEFAULT_DISCOVER_DEPTH) .into_iter() .map(|c| c.path) .collect(); } dedup_paths(roots) } fn dedup_paths(paths: Vec) -> Vec { let mut seen = HashSet::new(); let mut result = Vec::new(); for path in paths { let key = path.to_string_lossy().to_string(); if seen.insert(key) { result.push(path); } } result } fn discover_roots(max_depth: usize) -> Vec { let mut bases = Vec::new(); if let Ok(raw) = env::var("JUNHONG_LOG_DISCOVERY_BASES") { add_roots(&mut bases, &raw); } else { for path in [".", "/opt/junhong_cmp", "/app", "/data", "/srv"] { let p = PathBuf::from(path); if p.is_dir() { bases.push(p); } } } let mut candidates = Vec::new(); let mut visited = HashSet::new(); let mut visited_dirs = 0usize; for base in bases { let mut queue = VecDeque::new(); queue.push_back((base, 0usize)); while let Some((dir, depth)) = queue.pop_front() { if visited_dirs >= MAX_DISCOVER_DIRS { break; } visited_dirs += 1; let key = dir.to_string_lossy().to_string(); if !visited.insert(key) { continue; } let Ok(entries) = fs::read_dir(&dir) else { continue; }; let mut log_files = 0usize; let mut gz_files = 0usize; let mut subdirs = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { if depth < max_depth && !should_skip_dir(&path) { subdirs.push(path); } continue; } if is_log_file(&path, true) { log_files += 1; if is_gz(&path) { gz_files += 1; } } } if log_files > 0 { candidates.push(RootCandidate { path: dir.clone(), log_files, gz_files, }); } for subdir in subdirs { queue.push_back((subdir, depth + 1)); } } } candidates.sort_by(|a, b| a.path.cmp(&b.path)); candidates } fn discover_log_dirs(roots: &[PathBuf], max_depth: usize, include_gz: bool) -> Vec { let mut candidates = Vec::new(); let mut visited = HashSet::new(); let mut visited_dirs = 0usize; for root in roots { if !root.is_dir() { continue; } let mut queue = VecDeque::new(); queue.push_back((root.clone(), 0usize)); while let Some((dir, depth)) = queue.pop_front() { if visited_dirs >= MAX_DISCOVER_DIRS { break; } visited_dirs += 1; let key = dir.to_string_lossy().to_string(); if !visited.insert(key) { continue; } let (log_files, gz_files) = count_immediate_logs(&dir, include_gz); if log_files > 0 { candidates.push(RootCandidate { path: dir.clone(), log_files, gz_files, }); } if depth >= max_depth { continue; } let Ok(entries) = fs::read_dir(&dir) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() && !should_skip_dir(&path) { queue.push_back((path, depth + 1)); } } } } candidates.sort_by(|a, b| a.path.cmp(&b.path)); candidates } fn should_skip_dir(path: &Path) -> bool { let Some(name) = path.file_name().and_then(|s| s.to_str()) else { return false; }; matches!( name, ".git" | ".omx" | "node_modules" | "vendor" | "tmp" | "cache" ) } fn print_root_candidates(candidates: &[RootCandidate]) { if candidates.is_empty() { println!("没有发现候选日志目录。可设置 JUNHONG_LOG_DISCOVERY_BASES 后重试。"); return; } println!("发现 {} 个候选日志目录:", candidates.len()); for (index, item) in candidates.iter().enumerate() { println!( "{:>2}. {} (日志文件: {}, gz: {})", index + 1, item.path.display(), item.log_files, item.gz_files ); } } fn choose_roots(roots: &[PathBuf], max_depth: usize, include_gz: bool) -> io::Result> { let candidates = discover_log_dirs(roots, max_depth, include_gz); print_root_candidates(&candidates); if candidates.is_empty() { return Ok(Vec::new()); } print!("选择目录编号,例如 1,3-4,或 all,!2,!5-7: "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim(); if input.eq_ignore_ascii_case("all") || input.is_empty() { return Ok(candidates.into_iter().map(|c| c.path).collect()); } let selected = select_candidate_paths(&candidates, input); Ok(selected) } fn select_candidate_paths(candidates: &[RootCandidate], input: &str) -> Vec { let indexes = parse_selection_indexes(input, candidates.len()); indexes .into_iter() .filter_map(|idx| candidates.get(idx - 1).map(|c| c.path.clone())) .collect() } fn parse_selection_indexes(input: &str, len: usize) -> Vec { if len == 0 { return Vec::new(); } let mut selected = vec![false; len]; let mut touched = false; for raw_part in input.split(',') { let part = raw_part.trim(); if part.is_empty() { continue; } if part.eq_ignore_ascii_case("all") { selected.fill(true); touched = true; continue; } let (exclude, range_part) = match part.strip_prefix('!') { Some(rest) => (true, rest.trim()), None => (false, part), }; for idx in parse_index_range(range_part, len) { selected[idx - 1] = !exclude; touched = true; } } if !touched { return Vec::new(); } selected .into_iter() .enumerate() .filter_map(|(idx, is_selected)| if is_selected { Some(idx + 1) } else { None }) .collect() } fn parse_index_range(raw: &str, len: usize) -> Vec { if let Some((start, end)) = raw.split_once('-') { let start = start.trim().parse::().unwrap_or(0); let end = end.trim().parse::().unwrap_or(0); if start == 0 || end == 0 { return Vec::new(); } let start = start.min(len); let end = end.min(len); let (start, end) = if start <= end { (start, end) } else { (end, start) }; return (start..=end).collect(); } let idx = raw.trim().parse::().unwrap_or(0); if idx == 0 || idx > len { Vec::new() } else { vec![idx] } } fn count_immediate_logs(dir: &Path, include_gz: bool) -> (usize, usize) { let Ok(entries) = fs::read_dir(dir) else { return (0, 0); }; let mut log_files = 0usize; let mut gz_files = 0usize; for entry in entries.flatten() { let path = entry.path(); if path.is_file() && is_log_file(&path, include_gz) { log_files += 1; if is_gz(&path) { gz_files += 1; } } } (log_files, gz_files) } fn collect_log_files( roots: &[PathBuf], max_depth: usize, include_gz: bool, date: Option<&str>, ) -> Vec { let mut result = Vec::new(); let mut seen = HashSet::new(); for root in roots { if root.is_file() { maybe_add_file(root, include_gz, date, &mut result, &mut seen); continue; } if !root.is_dir() { eprintln!("跳过不存在的目录: {}", root.display()); continue; } let mut queue = VecDeque::new(); queue.push_back((root.clone(), 0usize)); while let Some((dir, depth)) = queue.pop_front() { let Ok(entries) = fs::read_dir(&dir) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { if depth < max_depth && !should_skip_dir(&path) { queue.push_back((path, depth + 1)); } continue; } maybe_add_file(&path, include_gz, date, &mut result, &mut seen); } } } result } fn maybe_add_file( path: &Path, include_gz: bool, date: Option<&str>, result: &mut Vec, seen: &mut HashSet, ) { if !is_log_file(path, include_gz) { return; } if let Some(date) = date { if !filename_can_contain_date(path, date) { return; } } let key = path.to_string_lossy().to_string(); if !seen.insert(key) { return; } let modified_score = path .metadata() .and_then(|m| m.modified()) .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); result.push(LogFile { path: path.to_path_buf(), modified_score, }); } fn is_log_file(path: &Path, include_gz: bool) -> bool { let Some(name) = path.file_name().and_then(|s| s.to_str()) else { return false; }; let lower = name.to_ascii_lowercase(); if lower.ends_with(".gz") { return include_gz && lower.contains("log"); } lower.ends_with(".log") || lower.contains(".log.") || lower.ends_with("-json.log") || lower == "stdout" || lower == "stderr" } fn is_gz(path: &Path) -> bool { path.extension() .and_then(|s| s.to_str()) .map(|s| s.eq_ignore_ascii_case("gz")) .unwrap_or(false) } fn filename_can_contain_date(path: &Path, date: &str) -> bool { let Some(name) = path.file_name().and_then(|s| s.to_str()) else { return true; }; let compact = date.replace('-', ""); let dates = extract_filename_dates(name); dates.is_empty() || dates.iter().any(|d| d == date || d == &compact) } fn extract_filename_dates(name: &str) -> Vec { let bytes = name.as_bytes(); let mut dates = Vec::new(); for i in 0..bytes.len() { if i + 10 <= bytes.len() && bytes[i..].get(4) == Some(&b'-') && bytes[i..].get(7) == Some(&b'-') && bytes[i..i + 4].iter().all(u8::is_ascii_digit) && bytes[i + 5..i + 7].iter().all(u8::is_ascii_digit) && bytes[i + 8..i + 10].iter().all(u8::is_ascii_digit) { dates.push(name[i..i + 10].to_string()); } if i + 8 <= bytes.len() && bytes[i..i + 8].iter().all(u8::is_ascii_digit) { let year = &name[i..i + 4]; if year.starts_with("20") { dates.push(name[i..i + 8].to_string()); } } } dates } fn interface_terms(interface: Option<&str>) -> Result, String> { let Some(interface) = interface else { return Ok(Vec::new()); }; let terms = match interface { "realname" => vec!["/flow-card/realName", "realStatus", "实名"], "flow" | "carddata" => vec!["/flow-card/flow", "gateway_flow", "流量", "\"used\""], "card_status" | "status" => vec!["/flow-card/status", "cardStatus", "卡状态"], "stop" => vec!["/flow-card/cardStop", "cardStop", "停机"], "start" | "resume" => vec!["/flow-card/cardStart", "cardStart", "复机"], other if other.starts_with('/') => vec![other], other => return Err(format!("未知接口别名: {other}")), }; Ok(terms.into_iter().map(String::from).collect()) } fn scan_file(path: &Path, filter: &MatchFilter, cfg: &Config) -> FileResult { let mut result = FileResult { path: path.to_path_buf(), matched: 0, scanned_lines: 0, samples: Vec::new(), error: None, }; let scan_result = if is_gz(path) { scan_gzip_file(path, filter, cfg, &mut result) } else { scan_plain_file(path, filter, cfg, &mut result) }; if let Err(err) = scan_result { result.error = Some(err); } result } fn scan_plain_file( path: &Path, filter: &MatchFilter, cfg: &Config, result: &mut FileResult, ) -> Result<(), String> { let file = File::open(path).map_err(|err| format!("打开失败: {err}"))?; let reader = BufReader::new(file); scan_reader(path, reader, filter, cfg, result) } fn scan_gzip_file( path: &Path, filter: &MatchFilter, cfg: &Config, result: &mut FileResult, ) -> Result<(), String> { let mut child = Command::new("gzip") .arg("-cd") .arg("--") .arg(path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|err| format!("启动 gzip 失败: {err}"))?; let stdout = child .stdout .take() .ok_or_else(|| String::from("读取 gzip stdout 失败"))?; let reader = BufReader::new(stdout); let scan_result = scan_reader(path, reader, filter, cfg, result); let output = child .wait_with_output() .map_err(|err| format!("等待 gzip 失败: {err}"))?; if !output.status.success() { let mut msg = String::from_utf8_lossy(&output.stderr).trim().to_string(); if msg.is_empty() { msg = format!("gzip 退出码 {:?}", output.status.code()); } return Err(msg); } scan_result } fn scan_reader( path: &Path, reader: BufReader, filter: &MatchFilter, cfg: &Config, result: &mut FileResult, ) -> Result<(), String> { for (idx, line) in reader.lines().enumerate() { let line = line.map_err(|err| format!("读取失败: {err}"))?; result.scanned_lines += 1; if matches_line(&line, filter) { result.matched += 1; let sample_cap = if cfg.lines_mode { cfg.limit } else { cfg.samples }; if sample_cap == 0 || result.samples.len() < sample_cap.min(1_000) { result.samples.push(SampleLine { file: path.to_path_buf(), line_no: idx + 1, line, }); } } } Ok(()) } fn matches_line(line: &str, filter: &MatchFilter) -> bool { if !line.contains(&filter.iccid) { return false; } if let Some(date) = &filter.date { if !line.contains(date) { return false; } } if !filter.interface_terms.is_empty() && !filter .interface_terms .iter() .any(|term| line.contains(term)) { return false; } for term in &filter.contains { if !line.contains(term) { return false; } } true } fn print_results(cfg: &Config, results: &[FileResult], scan_total: usize, elapsed_ms: u128) { let total_matches: usize = results.iter().map(|r| r.matched).sum(); let total_lines: usize = results.iter().map(|r| r.scanned_lines).sum(); let matched_files: Vec<&FileResult> = results.iter().filter(|r| r.matched > 0).collect(); let errors: Vec<&FileResult> = results.iter().filter(|r| r.error.is_some()).collect(); if cfg.count_only { for item in matched_files { println!("{}\t{}", item.matched, item.path.display()); } if !errors.is_empty() { eprintln!( "有 {} 个文件读取失败,去掉 --count-only 可看详情", errors.len() ); } return; } println!("扫描完成: {} ms", elapsed_ms); println!( "文件: 扫描 {} 个,命中 {} 个;行: 扫描 {} 行,命中 {} 行", scan_total, matched_files.len(), total_lines, total_matches ); println!("ICCID: {}", cfg.iccid); if let Some(date) = &cfg.date { println!("日期: {}", date); } if let Some(interface) = &cfg.interface { println!("接口过滤: {}", interface); } if !errors.is_empty() { println!(); println!("读取失败文件(最多 10 个):"); for item in errors.iter().take(10) { println!( "- {}: {}", item.path.display(), item.error.as_deref().unwrap_or("") ); } } if total_matches == 0 { println!(); println!("没有命中。建议先去掉 --interface 只按 ICCID+日期查,确认这张卡当天是否有任何轮询日志。"); return; } println!(); println!("命中文件(最多 30 个):"); for item in matched_files.iter().take(30) { println!("{:>8} {}", item.matched, item.path.display()); } if matched_files.len() > 30 { println!( "... 还有 {} 个命中文件,使用 --count-only 查看完整列表", matched_files.len() - 30 ); } let mut samples = Vec::new(); for item in results { for sample in &item.samples { samples.push(sample.clone()); } } samples.sort_by(|a, b| a.file.cmp(&b.file).then(a.line_no.cmp(&b.line_no))); if cfg.lines_mode { println!(); println!("原始命中行(最多 {} 行):", cfg.limit); for sample in samples.into_iter().take(cfg.limit) { println!( "{}:{}:{}", sample.file.display(), sample.line_no, sample.line ); } if total_matches > cfg.limit { println!( "... 已按 --limit={} 截断,实际命中 {} 行", cfg.limit, total_matches ); } } else if cfg.samples > 0 { println!(); println!("样例(最多 {} 条;完整原始行用 --lines):", cfg.samples); for sample in samples.into_iter().take(cfg.samples) { println!( "{}:{}:{}", sample.file.display(), sample.line_no, truncate_line(&sample.line, 260) ); } } } fn truncate_line(line: &str, max_chars: usize) -> String { let mut out = String::new(); for (idx, ch) in line.chars().enumerate() { if idx >= max_chars { out.push_str("..."); return out; } out.push(ch); } out }