Files
junhong_cmp_fiber/scripts/ops/search-worker-logs.rs
huang 20b5d70af9 1. 清理预充值SQL
2. 查日志的脚本
2026-05-25 14:30:09 +08:00

1309 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PathBuf>,
iccid: String,
date: Option<String>,
interface: Option<String>,
contains: Vec<String>,
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<usize>,
}
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<SampleLine>,
error: Option<String>,
}
#[derive(Debug)]
struct MatchFilter {
iccid: String,
date: Option<String>,
interface_terms: Vec<String>,
contains: Vec<String>,
}
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::<FileResult>::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<Config, String> {
let mut cfg = Config::default();
let raw_args: Vec<String> = 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<impl Iterator<Item = String>>,
name: &str,
) -> Result<String, String> {
args.next().ok_or_else(|| format!("{name} 缺少参数"))
}
fn parse_usize(raw: &str, name: &str) -> Result<usize, String> {
raw.parse::<usize>()
.map_err(|_| format!("{name} 需要整数,当前值: {raw}"))
}
fn roots_from_args() -> Vec<PathBuf> {
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<PathBuf>, 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<Config> {
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::<usize>() {
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<Vec<PathBuf>> {
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::<String>()
.chars()
.rev()
.collect();
format!("{suffix}")
}
fn prompt_required(label: &str, help: &str) -> io::Result<String> {
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<String> {
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<bool> {
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<Option<String>> {
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<Option<String>> {
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<String> {
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<W: Write>(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 <ICCID> [选项]
核心选项:
--root <dir> 增加一个日志目录,可重复
--roots <a:b:c> 一次传多个日志目录
--list-roots 自动发现候选日志目录并退出;配合 --root/--roots 时只列指定范围
--choose 从扫描范围内的日志目录交互选择;无需手动输入目录路径
--iccid <ICCID> 必填,按 ICCID 过滤
--date <YYYY-MM-DD> 按日志行日期过滤,并按文件名日期剪枝
--interface <name/path> realname, flow, carddata, card_status, stop, start或直接传 /flow-card/realName
--contains <text> 追加文本过滤,可重复
输出控制:
默认只输出汇总、命中文件和少量样例,不刷屏。
--lines 输出原始命中行
--limit <N> --lines 最大输出行数,默认 200
--samples <N> 汇总模式样例行数,默认 5
--count-only 只输出命中文件和行数
性能选项:
--threads <N> 并发扫描线程数,默认自动
--max-depth <N> 每个 root 下递归深度,默认 3
--max-files <N> 最多扫描 N 个文件
--no-gz 跳过 .gz 文件
环境变量:
JUNHONG_WORKER_LOG_ROOTS 默认日志目录,冒号分隔
"#
);
}
fn resolve_default_roots() -> Vec<PathBuf> {
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<PathBuf>) -> Vec<PathBuf> {
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<RootCandidate> {
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<RootCandidate> {
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<Vec<PathBuf>> {
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<PathBuf> {
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<usize> {
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<usize> {
if let Some((start, end)) = raw.split_once('-') {
let start = start.trim().parse::<usize>().unwrap_or(0);
let end = end.trim().parse::<usize>().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::<usize>().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<LogFile> {
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<LogFile>,
seen: &mut HashSet<String>,
) {
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<String> {
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<Vec<String>, 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<R: Read>(
path: &Path,
reader: BufReader<R>,
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
}