Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 265 additions & 57 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,219 @@ use anyhow::{Context, Result};
use clap::{Arg, Command};
use glob::glob;
use semver::Version;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::Path;
use std::path::{Path, PathBuf};

/// Maximum recursion depth to prevent stack overflow
const MAX_RECURSION_DEPTH: usize = 100;

/// Expand input paths to a list of BBL files.
/// If a path is a file, add it directly (will be filtered later for BBL/BFL/TXT extension).
/// If a path is a directory, recursively find all BBL files within it.
/// If a path contains glob patterns, expand them first.
fn expand_input_paths(
input_paths: &[String],
visited: &mut HashSet<PathBuf>,
) -> Result<Vec<String>> {
expand_input_paths_with_depth(input_paths, visited, 0)
}

/// Internal function with depth tracking for recursion protection
fn expand_input_paths_with_depth(
input_paths: &[String],
visited: &mut HashSet<PathBuf>,
depth: usize,
) -> Result<Vec<String>> {
if depth > MAX_RECURSION_DEPTH {
return Err(anyhow::anyhow!(
"Maximum recursion depth exceeded ({})",
MAX_RECURSION_DEPTH
));
}
let mut bbl_files = Vec::new();

for input_path_str in input_paths {
// Check if this is a glob pattern
if input_path_str.contains('*') || input_path_str.contains('?') {
Comment thread
nerdCopter marked this conversation as resolved.
match glob(input_path_str) {
Ok(glob_iter) => {
let collected = glob_iter.collect::<Result<Vec<_>, _>>();
match collected {
Ok(mut paths) => {
paths.sort(); // deterministic ordering
for path in paths {
if let Some(path_str) = path.to_str() {
let sub_result = expand_input_paths_with_depth(
&[path_str.to_string()],
visited,
depth + 1,
)?;
bbl_files.extend(sub_result);
}
}
}
Err(e) => {
return Err(anyhow::Error::new(e).context(format!(
"Error expanding glob pattern '{}'",
input_path_str
)));
}
}
}
Err(e) => {
return Err(anyhow::Error::new(e)
.context(format!("Invalid glob pattern '{}'", input_path_str)));
}
}
continue;
}

let input_path = Path::new(input_path_str);

match input_path.canonicalize() {
Ok(canonical_path) => {
if canonical_path.is_file() {
// It's a file; dedupe using visited
if visited.insert(canonical_path.clone()) {
if let Some(path_str) = canonical_path.to_str() {
bbl_files.push(path_str.to_string());
}
}
} else if canonical_path.is_dir() {
// It's a directory, find all BBL files recursively
// Don't add to visited here since find_bbl_files_in_dir_with_depth will handle it
let mut dir_bbl_files =
find_bbl_files_in_dir_with_depth(&canonical_path, visited, depth + 1)?;
bbl_files.append(&mut dir_bbl_files);
} else {
// Path doesn't exist or isn't accessible
eprintln!(
"Warning: Path not found or not accessible: {}",
input_path_str
);
}
}
Err(e) => {
eprintln!(
"Warning: Failed to canonicalize path '{}': {}",
input_path_str, e
);
// Skip this path
continue;
}
}
}

Ok(bbl_files)
}

/// Recursively find all BBL files in a directory, protecting against symlink cycles and depth overflow
fn find_bbl_files_in_dir_with_depth(
dir_path: &Path,
visited: &mut HashSet<PathBuf>,
depth: usize,
) -> Result<Vec<String>> {
if depth > MAX_RECURSION_DEPTH {
return Err(anyhow::anyhow!(
"Maximum recursion depth exceeded in directory traversal ({})",
MAX_RECURSION_DEPTH
));
}

let mut bbl_files = Vec::new();

match dir_path.canonicalize() {
Ok(canonical_dir) => {
if visited.contains(&canonical_dir) {
// Already visited, skip to avoid cycles
return Ok(bbl_files);
}
visited.insert(canonical_dir.clone());

if !canonical_dir.is_dir() {
return Ok(bbl_files);
}

let entries = match fs::read_dir(&canonical_dir) {
Ok(entries) => entries,
Err(e) => {
eprintln!(
"Warning: Cannot read directory '{}': {}",
canonical_dir.display(),
e
);
return Ok(bbl_files);
}
};

for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
eprintln!(
"Warning: Cannot read entry in directory '{}': {}",
canonical_dir.display(),
e
);
continue;
}
};
let path = entry.path();

match path.canonicalize() {
Ok(canonical_path) => {
if visited.contains(&canonical_path) {
continue;
}
visited.insert(canonical_path.clone());

if canonical_path.is_dir() {
// Recursively search subdirectories
let mut sub_bbl_files = find_bbl_files_in_dir_with_depth(
&canonical_path,
visited,
depth + 1,
)?;
bbl_files.append(&mut sub_bbl_files);
} else if canonical_path.is_file() {
// Check if it's a BBL file (only BBL for directories, not TXT)
if let Some(extension) = canonical_path.extension() {
let ext_lower = extension.to_string_lossy().to_ascii_lowercase();
if ext_lower == "bbl" || ext_lower == "bfl" {
if let Some(path_str) = canonical_path.to_str() {
bbl_files.push(path_str.to_string());
}
}
}
}
}
Err(e) => {
eprintln!(
"Warning: Failed to canonicalize path in dir '{}': {}",
path.display(),
e
);
continue;
}
}
}
}
Err(e) => {
eprintln!(
"Warning: Failed to canonicalize directory '{}': {}",
dir_path.display(),
e
);
return Ok(bbl_files);
}
}

// Sort the files for consistent ordering
bbl_files.sort();
Ok(bbl_files)
}

#[derive(Debug, Clone)]
struct FieldDefinition {
Expand Down Expand Up @@ -264,7 +474,7 @@ fn build_command() -> Command {
.about("Read and parse BBL blackbox log files. Exports to CSV by default (optionally GPX/JSON).")
.arg(
Arg::new("files")
.help("BBL files to parse (.BBL, .BFL, .TXT extensions supported, case-insensitive, supports globbing)")
.help("BBL files or directories to parse. Direct file paths: .BBL, .BFL, .TXT extensions supported. Directories: recursively finds .BBL/.BFL files only (TXT files must be specified directly). Case-insensitive, supports globbing.")
.required(false)
.num_args(1..)
.index(1),
Expand Down Expand Up @@ -344,69 +554,67 @@ fn main() -> Result<()> {
println!("Input patterns: {file_patterns:?}");
}

// Expand input paths (files and directories) to a list of BBL files
let mut visited = HashSet::new();
let mut input_files = match expand_input_paths(
&file_patterns
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
&mut visited,
) {
Ok(files) => files,
Err(e) => {
eprintln!("Error expanding input paths: {e}");
std::process::exit(1);
}
};

// Dedupe while preserving original order
{
let mut seen = std::collections::HashSet::new();
input_files.retain(|p| seen.insert(p.clone()));
}

if input_files.is_empty() {
eprintln!("Error: No valid BBL/BFL/TXT files found in the specified input paths.");
std::process::exit(1);
}

// Collect all valid file paths
let mut valid_paths = Vec::new();
for pattern in &file_patterns {
for file_path_str in &input_files {
let path = PathBuf::from(file_path_str);

if debug {
println!("Processing pattern: {pattern}");
println!("Checking file: {path:?}");
}

let paths: Vec<_> = if pattern.contains('*') || pattern.contains('?') {
match glob(pattern) {
Ok(glob_iter) => {
let collected = glob_iter.collect::<Result<Vec<_>, _>>();
match collected {
Ok(paths) => {
if debug {
println!("Glob pattern '{pattern}' matched {} files", paths.len());
}
paths
}
Err(e) => {
eprintln!("Error expanding glob pattern '{pattern}': {e}");
continue;
}
}
}
Err(e) => {
eprintln!("Invalid glob pattern '{pattern}': {e}");
continue;
}
}
} else {
vec![Path::new(pattern).to_path_buf()]
};

for path in paths {
if debug {
println!("Checking file: {path:?}");
}

if !path.exists() {
eprintln!("Warning: File does not exist: {path:?}");
continue;
}
if !path.exists() {
eprintln!("Warning: File does not exist: {path:?}");
continue;
}

let valid_extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| {
let ext_lower = ext.to_ascii_lowercase();
ext_lower == "bbl" || ext_lower == "bfl" || ext_lower == "txt"
})
.unwrap_or(false);
// Check file extension
let valid_extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| {
let ext_lower = ext.to_ascii_lowercase();
ext_lower == "bbl" || ext_lower == "bfl" || ext_lower == "txt"
})
.unwrap_or(false);

if !valid_extension {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("none");
eprintln!("Warning: Skipping file with unsupported extension '{ext}': {path:?}");
continue;
}
if !valid_extension {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("none");
eprintln!("Warning: Skipping file with unsupported extension '{ext}': {path:?}");
continue;
}

if debug {
println!("Added valid file: {path:?}");
}
valid_paths.push(path);
if debug {
println!("Added valid file: {path:?}");
}
valid_paths.push(path);
}

if debug {
Expand Down