oxiarc-cli 0.3.2

Command-line interface for OxiArc archive operations
use crate::commands::SortBy;
use crate::style::Styler;

/// Parse a human-readable byte size string into a raw `u64` byte count.
///
/// Accepted formats: `"100"`, `"100k"` / `"100K"` (×1 000), `"100m"` / `"100M"` (×1 000 000),
/// `"100g"` / `"100G"` (×1 000 000 000).  The suffixes use decimal (SI) multipliers, not
/// binary (KiB/MiB/GiB).
///
/// # Errors
/// Returns `Err(String)` with a human-readable message when the input cannot be parsed.
pub fn parse_byte_size(s: &str) -> Result<u64, String> {
    let s = s.trim();
    let (num_str, mult) = if let Some(rest) = s.strip_suffix(['k', 'K']) {
        (rest, 1_000u64)
    } else if let Some(rest) = s.strip_suffix(['m', 'M']) {
        (rest, 1_000_000u64)
    } else if let Some(rest) = s.strip_suffix(['g', 'G']) {
        (rest, 1_000_000_000u64)
    } else {
        (s, 1u64)
    };
    num_str
        .parse::<u64>()
        .map(|n| n * mult)
        .map_err(|_| format!("invalid byte size: '{s}'"))
}
use glob::Pattern;
use indicatif::{ProgressBar, ProgressStyle};
use oxiarc_core::{Entry, EntryType};
use std::collections::BTreeMap;

pub type ExtractedEntry = (String, bool, Vec<u8>);

pub fn create_progress_bar(len: u64, enable: bool) -> ProgressBar {
    if !enable {
        return ProgressBar::hidden();
    }

    let pb = ProgressBar::new(len);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("[{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}")
            .expect("progress bar template is valid")
            .progress_chars("█▓▒░ "),
    );
    pb
}

pub fn matches_filters(name: &str, include: &[String], exclude: &[String]) -> bool {
    for pattern_str in exclude {
        if let Ok(pattern) = Pattern::new(pattern_str) {
            if pattern.matches(name) {
                return false;
            }
        }
    }

    if include.is_empty() {
        return true;
    }

    for pattern_str in include {
        if let Ok(pattern) = Pattern::new(pattern_str) {
            if pattern.matches(name) {
                return true;
            }
        }
    }

    false
}

pub fn filter_entries(entries: &[Entry], include: &[String], exclude: &[String]) -> Vec<Entry> {
    if include.is_empty() && exclude.is_empty() {
        return entries.to_vec();
    }

    entries
        .iter()
        .filter(|e| matches_filters(&e.name, include, exclude))
        .cloned()
        .collect()
}

pub fn print_entries(entries: &[Entry], verbose: bool, styler: &Styler) {
    if verbose {
        let header_text = format!(
            "{:>10} {:>10} {:>6} {:>8}  Name",
            "Size", "Compressed", "Ratio", "Method"
        );
        println!("{}", styler.header(&header_text));
        println!("{}", "-".repeat(60));

        let mut total_size = 0u64;
        let mut total_compressed = 0u64;

        for entry in entries {
            let ratio = if entry.size > 0 {
                format!("{:.1}%", entry.space_savings())
            } else {
                "-".to_string()
            };

            let (type_prefix, styled_name) = if entry.is_dir() {
                ("d ", format!("{}", styler.dir_entry(&entry.name)))
            } else if entry.entry_type == EntryType::Symlink {
                ("l ", format!("{}", styler.symlink_entry(&entry.name)))
            } else {
                ("  ", format!("{}", styler.file_entry(&entry.name)))
            };

            let size_str = format!("{:>10}", entry.size);
            let compressed_str = format!("{:>10}", entry.compressed_size);
            let ratio_str = format!("{:>6}", ratio);

            println!(
                "{} {} {} {:>8}  {}{}",
                styler.size(&size_str),
                styler.size(&compressed_str),
                ratio_str,
                entry.method.name(),
                type_prefix,
                styled_name
            );

            total_size += entry.size;
            total_compressed += entry.compressed_size;
        }

        println!("{}", "-".repeat(60));
        let total_ratio = if total_size > 0 {
            (1.0 - total_compressed as f64 / total_size as f64) * 100.0
        } else {
            0.0
        };
        let total_line = format!(
            "{:>10} {:>10} {:>5.1}%          {} files",
            total_size,
            total_compressed,
            total_ratio,
            entries.len()
        );
        println!("{}", styler.size(&total_line));
    } else {
        for entry in entries {
            if entry.is_dir() {
                println!("{}", styler.dir_entry(&entry.name));
            } else if entry.entry_type == EntryType::Symlink {
                println!("{}", styler.symlink_entry(&entry.name));
            } else {
                println!("{}", styler.file_entry(&entry.name));
            }
        }
    }
}

pub fn sort_entries(entries: &mut [Entry], sort_by: SortBy, reverse: bool) {
    match sort_by {
        SortBy::Name => {
            entries.sort_by(|a, b| a.name.cmp(&b.name));
        }
        SortBy::Size => {
            entries.sort_by_key(|a| a.size);
        }
        SortBy::Date => {
            entries.sort_by_key(|a| a.modified);
        }
        SortBy::Ratio => {
            entries.sort_by(|a, b| {
                let ratio_a = if a.size > 0 {
                    a.compressed_size as f64 / a.size as f64
                } else {
                    0.0
                };
                let ratio_b = if b.size > 0 {
                    b.compressed_size as f64 / b.size as f64
                } else {
                    0.0
                };
                ratio_a
                    .partial_cmp(&ratio_b)
                    .unwrap_or(std::cmp::Ordering::Equal)
            });
        }
    }

    if reverse {
        entries.reverse();
    }
}

#[derive(Debug, Default)]
struct TreeNode {
    children: BTreeMap<String, TreeNode>,
    entry: Option<Entry>,
    is_dir: bool,
}

impl TreeNode {
    fn insert(&mut self, path: &str, entry: Entry) {
        let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
        self.insert_parts(&parts, 0, entry);
    }

    fn insert_parts(&mut self, parts: &[&str], idx: usize, entry: Entry) {
        if idx >= parts.len() {
            self.entry = Some(entry);
            return;
        }

        let part = parts[idx];
        let child = self.children.entry(part.to_string()).or_default();

        if idx == parts.len() - 1 {
            child.entry = Some(entry.clone());
            child.is_dir = entry.is_dir();
        } else {
            child.is_dir = true;
            child.insert_parts(parts, idx + 1, entry);
        }
    }
}

pub fn print_tree(entries: &[Entry], verbose: bool, styler: &Styler) {
    let mut root = TreeNode::default();
    for entry in entries {
        root.insert(&entry.name, entry.clone());
    }

    print_tree_node(&root, "", verbose, true, styler);
}

fn print_tree_node(node: &TreeNode, prefix: &str, verbose: bool, is_root: bool, styler: &Styler) {
    let mut children: Vec<(&String, &TreeNode)> = node.children.iter().collect();
    children.sort_by(|a, b| match (a.1.is_dir, b.1.is_dir) {
        (true, false) => std::cmp::Ordering::Less,
        (false, true) => std::cmp::Ordering::Greater,
        _ => a.0.cmp(b.0),
    });

    for (i, (name, child)) in children.iter().enumerate() {
        let is_last_child = i == children.len() - 1;

        let (current_prefix, next_prefix) = if is_root {
            ("".to_string(), "".to_string())
        } else if is_last_child {
            (format!("{}{}── ", prefix, ""), format!("{}    ", prefix))
        } else {
            (format!("{}{}── ", prefix, ""), format!("{}", prefix))
        };

        let type_indicator = if child.is_dir { "/" } else { "" };

        let styled_name = if child.is_dir {
            format!("{}", styler.dir_entry(name))
        } else {
            format!("{}", styler.file_entry(name))
        };

        if verbose {
            if let Some(ref entry) = child.entry {
                let size_str = if child.is_dir {
                    "-".to_string()
                } else {
                    format_size(entry.size)
                };
                println!(
                    "{}{}{} [{}]",
                    current_prefix,
                    styled_name,
                    type_indicator,
                    styler.size(&size_str)
                );
            } else {
                println!("{}{}{}", current_prefix, styled_name, type_indicator);
            }
        } else {
            println!("{}{}{}", current_prefix, styled_name, type_indicator);
        }

        if child.is_dir {
            print_tree_node(child, &next_prefix, verbose, false, styler);
        }
    }
}

fn format_size(size: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = KB * 1024;
    const GB: u64 = MB * 1024;

    if size >= GB {
        format!("{:.1} GB", size as f64 / GB as f64)
    } else if size >= MB {
        format!("{:.1} MB", size as f64 / MB as f64)
    } else if size >= KB {
        format!("{:.1} KB", size as f64 / KB as f64)
    } else {
        format!("{size} B")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_byte_size_plain() {
        assert_eq!(parse_byte_size("100").unwrap(), 100);
        assert_eq!(parse_byte_size("0").unwrap(), 0);
        assert_eq!(parse_byte_size("1048576").unwrap(), 1_048_576);
    }

    #[test]
    fn test_parse_byte_size_kilo() {
        assert_eq!(parse_byte_size("100K").unwrap(), 100_000);
        assert_eq!(parse_byte_size("100k").unwrap(), 100_000);
        assert_eq!(parse_byte_size("1K").unwrap(), 1_000);
    }

    #[test]
    fn test_parse_byte_size_mega() {
        assert_eq!(parse_byte_size("100M").unwrap(), 100_000_000);
        assert_eq!(parse_byte_size("100m").unwrap(), 100_000_000);
        assert_eq!(parse_byte_size("1M").unwrap(), 1_000_000);
    }

    #[test]
    fn test_parse_byte_size_giga() {
        assert_eq!(parse_byte_size("1G").unwrap(), 1_000_000_000);
        assert_eq!(parse_byte_size("1g").unwrap(), 1_000_000_000);
    }

    #[test]
    fn test_parse_byte_size_error() {
        assert!(parse_byte_size("garbage").is_err());
        assert!(parse_byte_size("").is_err());
        assert!(parse_byte_size("-1").is_err());
        assert!(parse_byte_size("1.5M").is_err());
    }

    #[test]
    fn test_parse_byte_size_whitespace() {
        assert_eq!(parse_byte_size("  100M  ").unwrap(), 100_000_000);
    }
}