abi2human 1.0.3

Zero-dependency CLI tool to convert Ethereum ABI JSON to human-readable format
use crate::converter::Converter;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};

pub struct ConvertOptions {
    pub suffix: String,
    pub pretty: bool,
    pub pattern: Option<String>,
}

impl Default for ConvertOptions {
    fn default() -> Self {
        Self {
            suffix: ".readable".to_string(),
            pretty: true,
            pattern: None,
        }
    }
}

pub struct ConvertResult {
    pub input_path: PathBuf,
    pub output_path: Option<PathBuf>,
    pub success: bool,
    pub error: Option<String>,
    pub item_count: Option<usize>,
}

pub fn convert_file(
    input_path: &Path,
    output_path: Option<&Path>,
    options: &ConvertOptions,
) -> ConvertResult {
    let content = match fs::read_to_string(input_path) {
        Ok(c) => c,
        Err(e) => {
            return ConvertResult {
                input_path: input_path.to_path_buf(),
                output_path: output_path.map(|p| p.to_path_buf()),
                success: false,
                error: Some(format!("Failed to read file: {e}")),
                item_count: None,
            };
        }
    };

    let abi_items = match Converter::parse_abi_content(&content) {
        Ok(items) => items,
        Err(e) => {
            return ConvertResult {
                input_path: input_path.to_path_buf(),
                output_path: output_path.map(|p| p.to_path_buf()),
                success: false,
                error: Some(format!("Failed to parse ABI: {e}")),
                item_count: None,
            };
        }
    };

    if abi_items.is_empty() {
        return ConvertResult {
            input_path: input_path.to_path_buf(),
            output_path: output_path.map(|p| p.to_path_buf()),
            success: false,
            error: Some("No valid ABI items found".to_string()),
            item_count: Some(0),
        };
    }

    let human_readable = Converter::convert_to_human_readable(&abi_items);
    let formatted = Converter::format_as_json_array(&human_readable, options.pretty);

    let final_output_path = if let Some(path) = output_path {
        path.to_path_buf()
    } else {
        let mut path = input_path.to_path_buf();
        let stem = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("output");
        let new_name = format!("{}{}.json", stem, options.suffix);
        path.set_file_name(new_name);
        path
    };

    if let Some(parent) = final_output_path.parent() {
        if !parent.exists() {
            if let Err(e) = fs::create_dir_all(parent) {
                return ConvertResult {
                    input_path: input_path.to_path_buf(),
                    output_path: Some(final_output_path),
                    success: false,
                    error: Some(format!("Failed to create directory: {e}")),
                    item_count: Some(human_readable.len()),
                };
            }
        }
    }

    match fs::write(&final_output_path, formatted) {
        Ok(_) => ConvertResult {
            input_path: input_path.to_path_buf(),
            output_path: Some(final_output_path),
            success: true,
            error: None,
            item_count: Some(human_readable.len()),
        },
        Err(e) => ConvertResult {
            input_path: input_path.to_path_buf(),
            output_path: Some(final_output_path),
            success: false,
            error: Some(format!("Failed to write file: {e}")),
            item_count: Some(human_readable.len()),
        },
    }
}

pub fn convert_directory(
    input_dir: &Path,
    output_dir: &Path,
    options: &ConvertOptions,
) -> Vec<ConvertResult> {
    let mut results = Vec::new();

    let entries = match fs::read_dir(input_dir) {
        Ok(e) => e,
        Err(e) => {
            results.push(ConvertResult {
                input_path: input_dir.to_path_buf(),
                output_path: None,
                success: false,
                error: Some(format!("Failed to read directory: {e}")),
                item_count: None,
            });
            return results;
        }
    };

    for entry in entries.filter_map(Result::ok) {
        let path = entry.path();

        if path.is_file() {
            if let Some(ext) = path.extension() {
                if ext == "json" {
                    if let Some(pattern) = &options.pattern {
                        if let Some(file_name) = path.file_name() {
                            if let Some(name_str) = file_name.to_str() {
                                if !matches_pattern(name_str, pattern) {
                                    continue;
                                }
                            }
                        }
                    }

                    let relative = path.strip_prefix(input_dir).unwrap_or(&path);
                    let output_path = output_dir.join(relative);

                    results.push(convert_file(&path, Some(&output_path), options));
                }
            }
        }
    }

    results
}

pub fn convert_stdin_to_stdout(options: &ConvertOptions) -> io::Result<()> {
    let mut content = String::new();
    io::stdin().read_to_string(&mut content)?;

    let abi_items = Converter::parse_abi_content(&content)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

    if abi_items.is_empty() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "No valid ABI items found",
        ));
    }

    let human_readable = Converter::convert_to_human_readable(&abi_items);
    let formatted = Converter::format_as_json_array(&human_readable, options.pretty);

    io::stdout().write_all(formatted.as_bytes())?;
    io::stdout().write_all(b"\n")?;

    Ok(())
}

fn matches_pattern(filename: &str, pattern: &str) -> bool {
    let pattern_parts: Vec<&str> = pattern.split('*').collect();

    if pattern_parts.is_empty() {
        return true;
    }

    let mut filename_pos = 0;

    for (i, part) in pattern_parts.iter().enumerate() {
        if part.is_empty() && (i == 0 || i == pattern_parts.len() - 1) {
            continue;
        }

        if let Some(pos) = filename[filename_pos..].find(part) {
            filename_pos += pos + part.len();
        } else {
            return false;
        }
    }

    if !pattern.ends_with('*') && filename_pos != filename.len() {
        return false;
    }

    true
}