luff 0.2.1

Print files with formatting
Documentation
//! Tree-style directory printer

use crate::{
    error::{Error, Result},
    printer::colors::Colors,
    walker::WalkerEntry,
};
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::io::Write;
use std::path::{Path, PathBuf};

/// Printer for tree-style output
///
/// This printer accumulates entries and prints them in a tree structure
/// showing the directory hierarchy with colored output based on file types.
///
/// # Security
///
/// Enforces a maximum number of entries to prevent memory exhaustion attacks.
/// The limit defaults to 1,000,000 entries and can be configured via `Config::max_files`.
pub struct TreePrinter {
    /// Accumulated file and directory paths to include in the tree output
    ///
    /// These paths are collected before printing to enable proper tree formatting
    /// and to enforce memory limits. All paths are absolute for consistent processing.
    entries: Vec<PathBuf>,

    /// Maximum number of entries allowed to prevent memory exhaustion
    ///
    /// This limit protects against `DoS` attacks from directories with millions of files.
    /// When exceeded, `add_entry()` returns an error to prevent unbounded memory growth.
    max_entries: usize,
}

impl TreePrinter {
    /// Create a new tree printer with default limits
    #[must_use]
    pub fn new() -> Self {
        Self {
            entries: Vec::with_capacity(1024), // Reasonable default
            max_entries: 1_000_000,            // Match Config::max_files default
        }
    }

    /// Create a new tree printer with custom entry limit
    #[must_use]
    pub fn with_max_entries(max_entries: usize) -> Self {
        Self {
            entries: Vec::with_capacity(max_entries.min(1024)),
            max_entries,
        }
    }

    /// Check if the printer has any entries
    #[must_use]
    pub fn has_entries(&self) -> bool {
        !self.entries.is_empty()
    }

    /// Add an entry to the tree
    ///
    /// # Errors
    ///
    /// Returns `Err` if adding this entry would exceed the configured maximum.
    /// This prevents memory exhaustion when processing very large directory trees.
    pub fn add_entry(&mut self, path: PathBuf) -> Result<()> {
        if self.entries.len() >= self.max_entries {
            return Err(Error::Printer {
                message: format!(
                    "Tree printer entry limit exceeded (max: {}). \
                    Consider using --max-depth or --max-files to limit scope.",
                    self.max_entries
                ),
            });
        }

        self.entries.push(path);
        Ok(())
    }

    /// Print the accumulated tree structure to stdout
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Terminal output cannot be written to stdout
    /// - Color formatting fails due to invalid terminal state
    /// - The tree structure contains invalid UTF-8 paths
    pub fn print_tree(&self, root: &Path) -> Result<()> {
        let stdout = std::io::stdout();
        let mut handle = std::io::BufWriter::new(stdout.lock());
        self.write_tree(&mut handle, root)
    }

    /// Write the accumulated tree structure to a writer
    ///
    /// # Errors
    ///
    /// Returns an error if writing to the writer fails.
    pub fn write_tree<W: Write>(&self, writer: &mut W, root: &Path) -> Result<()> {
        if self.entries.is_empty() {
            return Ok(());
        }

        // Build tree structure
        let mut tree = TreeNode::new_directory(root);
        for entry in &self.entries {
            let relative = entry.strip_prefix(root).unwrap_or(entry);
            insert_path(&mut tree, relative, entry);
        }

        // Use cached Colors instance for better performance
        let colors = Colors::get();

        // Print root as "." with directory coloring
        let colored_root = colors.colorize(".", root);
        writeln!(writer, "{colored_root}").map_err(Error::Io)?;

        // Print children with connectors
        // BTreeMap iterates in sorted order, so no explicit sort needed
        write_node_children(writer, &tree, "", colors)?;

        Ok(())
    }

    /// Print a single entry in tree format (streaming mode)
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The entry path cannot be colorized due to terminal issues
    /// - Stdout is not available for writing
    pub fn print(entry: &WalkerEntry, _root: &Path) -> Result<()> {
        let colors = Colors::get();
        let display_string = entry.relative_path.display().to_string();
        let colored_name = colors.colorize(&display_string, &entry.path);
        println!("{colored_name}");
        Ok(())
    }
}

impl Default for TreePrinter {
    fn default() -> Self {
        Self::new()
    }
}

/// Format a collection of entries as a tree structure
///
/// # Errors
///
/// Returns an error if:
/// - The tree structure cannot be built due to invalid paths
/// - Color formatting fails for any entry
/// - The output string cannot be constructed due to memory constraints
pub fn format_tree(entries: &[WalkerEntry], root: &Path) -> Result<String> {
    if entries.is_empty() {
        return Ok(String::new());
    }

    let colors = Colors::get();
    let mut tree = TreeNode::new_directory(root);

    // Build tree structure from all entries (files and directories)
    for entry in entries {
        let relative = entry
            .path
            .strip_prefix(root)
            .unwrap_or(&entry.relative_path);
        insert_path(&mut tree, relative, &entry.path);
    }

    // Format tree to a Vec<u8> so we can reuse write_node (io::Write)
    let mut buf = Vec::new();

    let colored_root = colors.colorize(".", root);
    writeln!(buf, "{colored_root}").map_err(Error::Io)?;

    write_node_children(&mut buf, &tree, "", colors)?;

    // Safe: write_node only writes Display output from OsStr::to_string_lossy
    // and ANSI escape sequences, both of which are valid UTF-8.
    Ok(String::from_utf8(buf).unwrap_or_else(|e| e.to_string()))
}

/// Insert a relative path into the tree, building intermediate nodes as needed
fn insert_path(node: &mut TreeNode, path: &Path, full_path: &Path) {
    let components: Vec<_> = path.components().collect();

    if components.is_empty() {
        return;
    }

    // Calculate the root by going up from full_path for each component in path
    let num_components = components.len();
    let mut root = full_path;
    for _ in 0..num_components {
        root = root.parent().unwrap_or(root);
    }

    let mut current = node;
    let mut current_path = root.to_path_buf();

    for component in components {
        let name_os = component.as_os_str().to_os_string();
        current_path = current_path.join(&name_os);

        current = current
            .children
            .entry(name_os.clone())
            .or_insert_with(|| TreeNode {
                name: name_os,
                full_path: current_path.clone(),
                children: BTreeMap::new(),
            });
    }
}

/// Write the children of a tree node with proper connectors
fn write_node_children<W: Write>(
    writer: &mut W,
    node: &TreeNode,
    prefix: &str,
    colors: &Colors,
) -> Result<()> {
    let children_count = node.children.len();
    for (i, child) in node.children.values().enumerate() {
        let is_last = i == children_count - 1;
        write_node(writer, child, prefix, is_last, colors)?;
    }
    Ok(())
}

/// Write a tree node with proper formatting and colors
fn write_node<W: Write>(
    writer: &mut W,
    node: &TreeNode,
    prefix: &str,
    is_last: bool,
    colors: &Colors,
) -> Result<()> {
    let connector = if is_last { "└── " } else { "├── " };
    let name_str = node.name.to_string_lossy();
    let colored_name = colors.colorize(&name_str, &node.full_path);
    writeln!(writer, "{prefix}{connector}{colored_name}").map_err(Error::Io)?;

    let child_prefix = if is_last {
        format!("{prefix}    ")
    } else {
        format!("{prefix}")
    };

    write_node_children(writer, node, &child_prefix, colors)?;

    Ok(())
}

/// Node in the file tree representing a file or directory
///
/// This private struct is used internally to build the hierarchical
/// tree structure before formatting. It maintains both the display name
/// and full path for colorization and reference.
#[derive(Debug)]
struct TreeNode {
    /// The name of this node (filename or directory name)
    name: OsString,

    /// The absolute path to this node for metadata lookup
    full_path: PathBuf,

    /// Child nodes keyed by name for hierarchical traversal
    /// Using `BTreeMap` for automatic sorting and `OsString` keys to avoid UTF-8 overhead
    children: BTreeMap<OsString, Self>,
}

impl TreeNode {
    /// Create a new directory node from a path
    fn new_directory(path: &Path) -> Self {
        Self {
            name: path
                .file_name()
                .unwrap_or_else(|| OsStr::new(""))
                .to_os_string(),
            full_path: path.to_path_buf(),
            children: BTreeMap::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;
    use std::path::PathBuf;

    #[test]
    fn test_tree_printer_empty() {
        let printer = TreePrinter::new();
        assert_eq!(printer.entries.len(), 0);
    }

    #[test]
    fn test_tree_printer_add_entry() {
        let mut printer = TreePrinter::new();
        printer.add_entry(PathBuf::from("test.txt")).unwrap();
        assert_eq!(printer.entries.len(), 1);
    }

    #[test]
    fn test_tree_printer_respects_max_entries() {
        let mut printer = TreePrinter::with_max_entries(2);

        assert!(printer.add_entry(PathBuf::from("test1.txt")).is_ok());
        assert!(printer.add_entry(PathBuf::from("test2.txt")).is_ok());

        // Third entry should fail
        let result = printer.add_entry(PathBuf::from("test3.txt"));
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("limit exceeded"));
    }

    #[test]
    fn test_simple_print() {
        let entry = WalkerEntry {
            path: PathBuf::from("/tmp/test.txt"),
            relative_path: PathBuf::from("test.txt"),
            is_dir: false,
        };

        let result = TreePrinter::print(&entry, Path::new("/tmp"));
        assert!(result.is_ok());
    }

    #[test]
    fn test_format_tree_empty() {
        let entries: Vec<WalkerEntry> = vec![];
        let result = format_tree(&entries, Path::new("/tmp"));
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), "");
    }

    #[test]
    fn test_format_tree_single_file() {
        let entries = vec![WalkerEntry {
            path: PathBuf::from("/tmp/test.txt"),
            relative_path: PathBuf::from("test.txt"),
            is_dir: false,
        }];

        let result = format_tree(&entries, Path::new("/tmp"));
        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.contains("test.txt"));
        assert!(output.contains("└── "));
    }

    #[test]
    fn test_format_tree_with_directory() {
        let entries = vec![
            WalkerEntry {
                path: PathBuf::from("/tmp/mydir"),
                relative_path: PathBuf::from("mydir"),
                is_dir: true,
            },
            WalkerEntry {
                path: PathBuf::from("/tmp/mydir/test.txt"),
                relative_path: PathBuf::from("mydir/test.txt"),
                is_dir: false,
            },
        ];

        let result = format_tree(&entries, Path::new("/tmp"));
        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.contains("mydir"));
        assert!(output.contains("test.txt"));
    }

    proptest! {
        #[test]
        fn test_tree_printer_handles_any_path(
            s in "[a-zA-Z0-9_-]{1,20}"
        ) {
            let mut printer = TreePrinter::new();
            let path = PathBuf::from(format!("{s}.txt"));
            printer.add_entry(path).unwrap();
            assert_eq!(printer.entries.len(), 1);
        }
    }
}