splashboard 1.1.0

A customizable terminal splash screen with plugin-based data sources
Documentation
//! `code_files` — counts tracked files in the discovered git repo's index and ranks the
//! top-level directories that hold them. `Text` is the headline file count;
//! `TextBlock` / `Entries` / `Bars` / `MarkdownTextBlock` all carry the per-top-level-dir
//! breakdown. Vendored / generated trees are skipped via the family-wide exclusion list.

use std::collections::HashMap;

use async_trait::async_trait;

use crate::payload::{
    Bar, BarsData, Body, EntriesData, Entry, MarkdownTextBlockData, Payload, TextBlockData,
    TextData,
};
use crate::render::Shape;
use crate::samples;

use super::super::git::{open_repo, payload, repo_cache_key};
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::scan::for_each_tracked_path;

const SHAPES: &[Shape] = &[
    Shape::Text,
    Shape::TextBlock,
    Shape::Entries,
    Shape::Bars,
    Shape::MarkdownTextBlock,
];
const TOP_LEVEL_LIMIT: usize = 10;
const ROOT_LABEL: &str = "(root)";

pub struct CodeFiles;

#[async_trait]
impl Fetcher for CodeFiles {
    fn name(&self) -> &str {
        "code_files"
    }
    fn safety(&self) -> Safety {
        Safety::Safe
    }
    fn description(&self) -> &'static str {
        "Counts tracked files in the discovered git repo's index and ranks top-level directories by file count. `Text` is the headline file count (`\"342 files\"`); `TextBlock` lists `\"<dir> (<count>)\"` rows; `Entries` exposes the same as key/value rows; `Bars` ranks them as bar values; `MarkdownTextBlock` formats them as a Markdown bullet list with emphasis. Files at the repo root are grouped under `\"(root)\"`. Vendored / generated dirs (`node_modules` / `vendor` / `target` / `dist` / `build`) are filtered out even when committed."
    }
    fn shapes(&self) -> &[Shape] {
        SHAPES
    }
    fn default_shape(&self) -> Shape {
        Shape::Text
    }
    fn cache_key(&self, ctx: &FetchContext) -> String {
        repo_cache_key(self.name(), ctx)
    }
    fn sample_body(&self, shape: Shape) -> Option<Body> {
        Some(match shape {
            Shape::Text => samples::text("342 files"),
            Shape::TextBlock => {
                samples::text_block(&["src (180)", "tests (42)", "docs (18)", "(root) (5)"])
            }
            Shape::Entries => samples::entries(&[
                ("src", "180"),
                ("tests", "42"),
                ("docs", "18"),
                ("(root)", "5"),
            ]),
            Shape::Bars => samples::bars(&[("src", 180), ("tests", 42), ("docs", 18)]),
            Shape::MarkdownTextBlock => samples::markdown(
                "- **src** — 180 files\n- **tests** — 42 files\n- **docs** — 18 files",
            ),
            _ => return None,
        })
    }
    async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
        let scan = scan_cwd()?;
        Ok(payload(render_body(scan, ctx.shape.unwrap_or(Shape::Text))))
    }
}

#[derive(Debug, Default)]
struct Scan {
    file_count: usize,
    top_level: Vec<(String, u64)>,
}

fn scan_cwd() -> Result<Scan, FetchError> {
    let repo = open_repo()?;
    scan_repo(&repo)
}

fn scan_repo(repo: &gix::Repository) -> Result<Scan, FetchError> {
    let mut file_count = 0usize;
    let mut top_counts: HashMap<String, u64> = HashMap::new();
    for_each_tracked_path(repo, |path| {
        file_count += 1;
        *top_counts.entry(top_level_key(path)).or_insert(0) += 1;
    })?;
    let mut top_level: Vec<_> = top_counts.into_iter().collect();
    top_level.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    Ok(Scan {
        file_count,
        top_level,
    })
}

fn top_level_key(path: &str) -> String {
    match path.split_once('/') {
        Some((head, _)) => head.to_string(),
        None => ROOT_LABEL.to_string(),
    }
}

fn render_body(scan: Scan, shape: Shape) -> Body {
    if scan.file_count == 0 {
        return empty_body(shape);
    }
    match shape {
        Shape::TextBlock => text_block_body(&scan),
        Shape::Entries => entries_body(&scan),
        Shape::Bars => bars_body(scan),
        Shape::MarkdownTextBlock => markdown_body(&scan),
        _ => text_body(&scan),
    }
}

fn empty_body(shape: Shape) -> Body {
    match shape {
        Shape::TextBlock => Body::TextBlock(TextBlockData { lines: vec![] }),
        Shape::Entries => Body::Entries(EntriesData { items: vec![] }),
        Shape::Bars => Body::Bars(BarsData { bars: vec![] }),
        Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
            value: String::new(),
        }),
        _ => Body::Text(TextData {
            value: String::new(),
        }),
    }
}

fn text_body(scan: &Scan) -> Body {
    Body::Text(TextData {
        value: format!("{} {}", scan.file_count, files_word(scan.file_count)),
    })
}

fn text_block_body(scan: &Scan) -> Body {
    Body::TextBlock(TextBlockData {
        lines: top_level_iter(&scan.top_level)
            .map(|(label, count)| format!("{label} ({count})"))
            .collect(),
    })
}

fn entries_body(scan: &Scan) -> Body {
    Body::Entries(EntriesData {
        items: top_level_iter(&scan.top_level)
            .map(|(label, count)| Entry {
                key: label.to_string(),
                value: Some(count.to_string()),
                status: None,
            })
            .collect(),
    })
}

fn bars_body(scan: Scan) -> Body {
    Body::Bars(BarsData {
        bars: scan
            .top_level
            .into_iter()
            .take(TOP_LEVEL_LIMIT)
            .map(|(label, value)| Bar { label, value })
            .collect(),
    })
}

fn markdown_body(scan: &Scan) -> Body {
    Body::MarkdownTextBlock(MarkdownTextBlockData {
        value: top_level_iter(&scan.top_level)
            .map(|(label, count)| format!("- **{label}** — {count} {}", files_word(count as usize)))
            .collect::<Vec<_>>()
            .join("\n"),
    })
}

fn top_level_iter(top_level: &[(String, u64)]) -> impl Iterator<Item = (&str, u64)> {
    top_level
        .iter()
        .take(TOP_LEVEL_LIMIT)
        .map(|(k, v)| (k.as_str(), *v))
}

fn files_word(n: usize) -> &'static str {
    if n == 1 { "file" } else { "files" }
}

#[cfg(test)]
mod tests {
    use super::super::super::git::test_support::{commit_touching, make_repo};
    use super::*;

    fn fixed_scan() -> Scan {
        Scan {
            file_count: 220,
            top_level: vec![
                ("src".into(), 180),
                ("tests".into(), 30),
                ("docs".into(), 9),
                (ROOT_LABEL.into(), 1),
            ],
        }
    }

    #[test]
    fn empty_repo_returns_empty_scan() {
        let (_tmp, repo) = make_repo();
        let scan = scan_repo(&repo).unwrap();
        assert_eq!(scan.file_count, 0);
        assert!(scan.top_level.is_empty());
    }

    #[test]
    fn root_file_groups_under_root_label() {
        let (_tmp, repo) = make_repo();
        commit_touching(&repo, "README.md", "x");
        let scan = scan_repo(&repo).unwrap();
        assert_eq!(scan.file_count, 1);
        assert_eq!(scan.top_level, vec![(ROOT_LABEL.into(), 1)]);
    }

    #[test]
    fn nested_path_attributes_to_top_segment() {
        let (_tmp, repo) = make_repo();
        commit_touching(&repo, "a/b/c/x.rs", "x");
        let scan = scan_repo(&repo).unwrap();
        assert_eq!(scan.file_count, 1);
        assert_eq!(scan.top_level, vec![("a".into(), 1)]);
    }

    #[test]
    fn vendored_dirs_excluded_even_when_committed() {
        let (_tmp, repo) = make_repo();
        commit_touching(&repo, "src/main.rs", "x");
        commit_touching(&repo, "node_modules/foo/index.js", "y");
        commit_touching(&repo, "vendor/lib/x.go", "z");
        let scan = scan_repo(&repo).unwrap();
        assert_eq!(scan.file_count, 1);
        assert_eq!(scan.top_level[0].0, "src");
    }

    #[test]
    fn top_level_breakdown_ranks_by_count() {
        let (_tmp, repo) = make_repo();
        commit_touching(&repo, "src/a.rs", "1");
        commit_touching(&repo, "src/b.rs", "2");
        commit_touching(&repo, "src/c.rs", "3");
        commit_touching(&repo, "tests/x.rs", "4");
        commit_touching(&repo, "tests/y.rs", "5");
        commit_touching(&repo, "README.md", "6");
        let scan = scan_repo(&repo).unwrap();
        let labels: Vec<_> = scan.top_level.iter().map(|(k, _)| k.clone()).collect();
        assert_eq!(
            labels,
            vec!["src".to_string(), "tests".into(), ROOT_LABEL.into()]
        );
        assert_eq!(scan.top_level[0].1, 3);
        assert_eq!(scan.top_level[1].1, 2);
    }

    #[test]
    fn text_renders_headline_file_count_with_grammar() {
        let one = Scan {
            file_count: 1,
            top_level: vec![],
        };
        match render_body(one, Shape::Text) {
            Body::Text(d) => assert_eq!(d.value, "1 file"),
            _ => panic!(),
        }
        match render_body(fixed_scan(), Shape::Text) {
            Body::Text(d) => assert_eq!(d.value, "220 files"),
            _ => panic!(),
        }
    }

    #[test]
    fn text_is_empty_when_no_files() {
        match render_body(Scan::default(), Shape::Text) {
            Body::Text(d) => assert!(d.value.is_empty()),
            _ => panic!(),
        }
    }

    #[test]
    fn text_block_lists_top_level_dirs() {
        match render_body(fixed_scan(), Shape::TextBlock) {
            Body::TextBlock(d) => assert_eq!(
                d.lines,
                vec!["src (180)", "tests (30)", "docs (9)", "(root) (1)",]
            ),
            _ => panic!(),
        }
    }

    #[test]
    fn entries_uses_dir_names_as_keys() {
        match render_body(fixed_scan(), Shape::Entries) {
            Body::Entries(d) => {
                let pairs: Vec<_> = d
                    .items
                    .iter()
                    .map(|e| (e.key.as_str(), e.value.as_deref().unwrap_or("")))
                    .collect();
                assert_eq!(
                    pairs,
                    vec![
                        ("src", "180"),
                        ("tests", "30"),
                        ("docs", "9"),
                        (ROOT_LABEL, "1"),
                    ]
                );
            }
            _ => panic!(),
        }
    }

    #[test]
    fn bars_caps_top_level_entries() {
        let top: Vec<_> = (0..(TOP_LEVEL_LIMIT + 5))
            .map(|i| (format!("dir{i}"), 1))
            .collect();
        let scan = Scan {
            file_count: top.len(),
            top_level: top,
        };
        match render_body(scan, Shape::Bars) {
            Body::Bars(d) => assert_eq!(d.bars.len(), TOP_LEVEL_LIMIT),
            _ => panic!(),
        }
    }

    #[test]
    fn markdown_emphasises_dir_names_with_count_suffix() {
        match render_body(fixed_scan(), Shape::MarkdownTextBlock) {
            Body::MarkdownTextBlock(d) => assert_eq!(
                d.value,
                "- **src** — 180 files\n- **tests** — 30 files\n- **docs** — 9 files\n- **(root)** — 1 file"
            ),
            _ => panic!(),
        }
    }
}