treestat 1.1.0

A CLI that displays source file counts in a tree view by directory and language
Documentation
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

use crate::cli::{Cli, CountMode};
use crate::lang::display_langs;
use crate::model::ScanResult;

fn display_count(
    path: &Path,
    scan: &ScanResult,
    tree_counts: &HashMap<PathBuf, usize>,
    mode: CountMode,
) -> usize {
    match mode {
        CountMode::Direct => scan.dirs.get(path).map_or(0, |d| d.direct_files),
        CountMode::Tree => *tree_counts.get(path).unwrap_or(&0),
    }
}

fn should_show_at_depth(
    path: &Path,
    depth: usize,
    scan: &ScanResult,
    tree_counts: &HashMap<PathBuf, usize>,
    cli: &Cli,
) -> bool {
    if path == scan.root {
        return true;
    }
    if cli.show_empty {
        return true;
    }
    let subtree = *tree_counts.get(path).unwrap_or(&0);
    if subtree == 0 {
        return false;
    }

    let current = display_count(path, scan, tree_counts, cli.count_mode);
    if current >= cli.min_count {
        return true;
    }

    if cli.max_depth.is_some_and(|max| depth >= max) {
        // We can't show deeper levels, so keep this node if it has any matching descendants.
        return subtree > 0;
    }

    scan.dirs.get(path).is_some_and(|d| {
        d.children
            .iter()
            .any(|c| should_show_at_depth(c, depth + 1, scan, tree_counts, cli))
    })
}

pub fn render_text(
    scan: &ScanResult,
    tree_counts: &HashMap<PathBuf, usize>,
    langs: &[String],
    cli: &Cli,
    duration_secs: f64,
) -> String {
    let mut out = String::new();
    let title = format!("{} file statistics (Tree View):", display_langs(langs));

    out.push_str(&title);
    out.push('\n');
    out.push_str("============================================================\n");
    let root_name = scan
        .root
        .file_name()
        .unwrap_or_else(|| OsStr::new("."))
        .to_string_lossy();
    out.push_str(&format!(
        "{root_name}/ ({})\n",
        display_count(&scan.root, scan, tree_counts, cli.count_mode)
    ));

    let children = scan
        .dirs
        .get(&scan.root)
        .map(|d| d.children.iter().cloned().collect::<Vec<_>>())
        .unwrap_or_default();
    let visible = children
        .into_iter()
        .filter(|c| should_show_at_depth(c, 1, scan, tree_counts, cli))
        .collect::<Vec<_>>();
    for (idx, child) in visible.iter().enumerate() {
        render_text_node(
            &mut out,
            child,
            scan,
            tree_counts,
            cli,
            "",
            idx + 1 == visible.len(),
            1,
        );
    }

    out.push_str("============================================================\n");
    out.push_str(&format!("Total matching files: {}\n", scan.total_files));
    out.push_str(&format!(
        "Directories containing files: {}\n",
        scan.dirs_with_files
    ));
    let mut lang_entries: Vec<_> = scan.language_counts.0.iter().collect();
    // Sort by count descending, then by language name ascending for stable output.
    lang_entries.sort_by(|a, b| {
        let ac = *a.1;
        let bc = *b.1;
        bc.cmp(&ac).then_with(|| a.0.cmp(b.0))
    });
    let top = lang_entries.into_iter().take(5).collect::<Vec<_>>();
    let lang_summary = top
        .iter()
        .map(|(k, v)| format!("{}={}", k, v))
        .collect::<Vec<_>>()
        .join(",");
    out.push_str(&format!("Languages (Top 5): {}\n", lang_summary));

    let files_per_sec = if duration_secs > 0.0 {
        scan.total_files as f64 / duration_secs
    } else {
        0.0
    };
    out.push_str(&format!(
        "Scan time: {:.2} s, {:.2} files/s\n",
        duration_secs, files_per_sec
    ));
    out
}

fn render_text_node(
    out: &mut String,
    path: &Path,
    scan: &ScanResult,
    tree_counts: &HashMap<PathBuf, usize>,
    cli: &Cli,
    prefix: &str,
    is_last: bool,
    depth: usize,
) {
    let Some(dir) = scan.dirs.get(path) else {
        return;
    };
    let connector = if is_last { "└── " } else { "├── " };
    out.push_str(&format!(
        "{prefix}{connector}{}/ ({})\n",
        dir.name,
        display_count(path, scan, tree_counts, cli.count_mode)
    ));

    if cli.max_depth.is_some_and(|max| depth >= max) {
        return;
    }

    let next_prefix = if is_last {
        format!("{prefix}    ")
    } else {
        format!("{prefix}│   ")
    };
    let children = dir
        .children
        .iter()
        .filter(|c| should_show_at_depth(c, depth + 1, scan, tree_counts, cli))
        .cloned()
        .collect::<Vec<_>>();
    for (idx, child) in children.iter().enumerate() {
        render_text_node(
            out,
            child,
            scan,
            tree_counts,
            cli,
            &next_prefix,
            idx + 1 == children.len(),
            depth + 1,
        );
    }
}

pub fn render_json(
    scan: &ScanResult,
    tree_counts: &HashMap<PathBuf, usize>,
    langs: &[String],
    cli: &Cli,
    _duration_secs: f64,
    pretty: bool,
) -> String {
    fn node(
        path: &Path,
        scan: &ScanResult,
        tree_counts: &HashMap<PathBuf, usize>,
        cli: &Cli,
        pretty: bool,
        indent: usize,
        depth: usize,
    ) -> String {
        let d = scan.dirs.get(path).expect("node exists");
        let children = if cli.max_depth.is_some_and(|max| depth >= max) {
            vec![]
        } else {
            d.children
                .iter()
                .filter(|c| should_show_at_depth(c, depth + 1, scan, tree_counts, cli))
                .cloned()
                .collect::<Vec<_>>()
        };

        let mut child_json = vec![];
        for child in &children {
            child_json.push(node(
                child,
                scan,
                tree_counts,
                cli,
                pretty,
                indent + 2,
                depth + 1,
            ));
        }
        let pad = if pretty {
            " ".repeat(indent)
        } else {
            String::new()
        };
        let sep = if pretty { "\n" } else { "" };
        let inner = if pretty {
            " ".repeat(indent + 2)
        } else {
            String::new()
        };
        let children_str = if child_json.is_empty() {
            "[]".to_string()
        } else if pretty {
            format!("[\n{}\n{}]", child_json.join(",\n"), inner)
        } else {
            format!("[{}]", child_json.join(","))
        };

        format!(
            "{pad}{{{sep}{inner}\"name\":\"{}\",{sep}{inner}\"path\":\"{}\",{sep}{inner}\"files\":{},\n{inner}\"children\":{}{sep}{pad}}}",
            escape_json(&d.name),
            escape_json(&path.to_string_lossy()),
            display_count(path, scan, tree_counts, cli.count_mode),
            children_str
        )
    }

    let mut lang_entries: Vec<_> = scan.language_counts.0.iter().collect();
    lang_entries.sort_by(|a, b| a.0.cmp(b.0));
    let language_counts_json = if pretty {
        lang_entries
            .iter()
            .map(|(k, v)| format!("\"{}\": {}", escape_json(k), v))
            .collect::<Vec<_>>()
            .join(", ")
    } else {
        lang_entries
            .iter()
            .map(|(k, v)| format!("\"{}\": {}", escape_json(k), v))
            .collect::<Vec<_>>()
            .join(",")
    };
    let lang_str = display_langs(langs);

    if pretty {
        format!(
            "{{\n  \"root\": \"{}\",\n  \"path\": \"{}\",\n  \"count_mode\": \"{}\",\n  \"lang\": \"{}\",\n  \"language_counts\": {{{}}},\n  \"max_depth\": {},\n  \"total_files\": {},\n  \"dirs_with_files\": {},\n  \"tree\": {}\n}}",
            escape_json(
                &scan
                    .root
                    .file_name()
                    .unwrap_or_else(|| OsStr::new("."))
                    .to_string_lossy()
            ),
            escape_json(&scan.root.to_string_lossy()),
            match cli.count_mode {
                CountMode::Direct => "direct",
                CountMode::Tree => "tree",
            },
            lang_str,
            language_counts_json,
            cli.max_depth
                .map(|v| v.to_string())
                .unwrap_or_else(|| "null".to_string()),
            scan.total_files,
            scan.dirs_with_files,
            node(&scan.root, scan, tree_counts, cli, true, 2, 0)
        )
    } else {
        format!(
            "{{\"root\":\"{}\",\"path\":\"{}\",\"count_mode\":\"{}\",\"lang\":\"{}\",\"language_counts\":{{{}}},\"max_depth\":{},\"total_files\":{},\"dirs_with_files\":{},\"tree\":{}}}",
            escape_json(
                &scan
                    .root
                    .file_name()
                    .unwrap_or_else(|| OsStr::new("."))
                    .to_string_lossy()
            ),
            escape_json(&scan.root.to_string_lossy()),
            match cli.count_mode {
                CountMode::Direct => "direct",
                CountMode::Tree => "tree",
            },
            lang_str,
            language_counts_json,
            cli.max_depth
                .map(|v| v.to_string())
                .unwrap_or_else(|| "null".to_string()),
            scan.total_files,
            scan.dirs_with_files,
            node(&scan.root, scan, tree_counts, cli, false, 0, 0)
        )
    }
}

fn escape_json(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}