Abbaye

at f73fb3b

use std::path::Path;

use gix::bstr::ByteSlice;
use miette::{IntoDiagnostic, Result};
use serde::Serialize;
use tera::{Context, Tera};

use crate::config::OutputFormat;
use crate::git_ui::{
    TEMPLATE_GIT_BLOB_GEMTEXT, TEMPLATE_GIT_BLOB_HTML, TEMPLATE_GIT_TREE_GEMTEXT,
    TEMPLATE_GIT_TREE_HTML,
};

// ── Types ──────────────────────────────────────────────────────────────────────

#[derive(Serialize)]
struct Crumb {
    name: String,
    url: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum TreeEntryKind {
    Tree,
    Blob,
}

#[derive(Serialize)]
struct TreeEntry {
    name: String,
    kind: TreeEntryKind,
    url: String,
}

// ── Entry point ────────────────────────────────────────────────────────────────

/// Build the full static tree browser for every revision in `revisions`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_browse_pages(
    revisions: &[(String, gix::ObjectId)],
    browse_dir: &Path,
    repo_path: &Path,
    theme_path: &Path,
    project_name: &str,
    lang: &Option<String>,
    clone_url: &Option<String>,
    formats: &[OutputFormat],
) -> Result<()> {
    use syntect::highlighting::ThemeSet;
    use syntect::parsing::SyntaxSet;

    let ss = SyntaxSet::load_defaults_newlines();
    let ts = ThemeSet::load_defaults();
    let theme = &ts.themes["InspiredGitHub"];

    let mut tera = Tera::default();
    crate::site::register_format_templates(
        &mut tera,
        theme_path,
        formats,
        &[
            (
                "git_tree",
                TEMPLATE_GIT_TREE_HTML,
                TEMPLATE_GIT_TREE_GEMTEXT,
            ),
            (
                "git_blob",
                TEMPLATE_GIT_BLOB_HTML,
                TEMPLATE_GIT_BLOB_GEMTEXT,
            ),
        ],
    )?;

    for (hash, oid) in revisions {
        let rev_dir = browse_dir.join(hash);
        std::fs::create_dir_all(&rev_dir).into_diagnostic()?;

        let repo = gix::open(repo_path).into_diagnostic()?;
        let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
        let decoded = commit_obj.decode().into_diagnostic()?;
        let tree_id = decoded.tree();

        walk_tree_dir(
            repo_path,
            &repo,
            tree_id,
            "",
            hash,
            &rev_dir,
            &tera,
            project_name,
            lang,
            clone_url,
            &ss,
            theme,
            formats,
        )?;
    }

    Ok(())
}

// ── Tree walker ────────────────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
fn walk_tree_dir(
    repo_path: &Path,
    repo: &gix::Repository,
    tree_id: gix::ObjectId,
    dir_path: &str,
    commit_hash: &str,
    rev_dir: &Path,
    tera: &Tera,
    project_name: &str,
    lang: &Option<String>,
    clone_url: &Option<String>,
    ss: &syntect::parsing::SyntaxSet,
    theme: &syntect::highlighting::Theme,
    formats: &[OutputFormat],
) -> Result<()> {
    let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
    let decoded = tree_obj.decode().into_diagnostic()?;

    let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();

    let page_dir = if dir_path.is_empty() {
        rev_dir.to_path_buf()
    } else {
        dir_path
            .split('/')
            .filter(|s| !s.is_empty())
            .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
    };
    std::fs::create_dir_all(&page_dir).into_diagnostic()?;

    let mut entries: Vec<TreeEntry> = Vec::new();
    let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
    let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();

    for entry in decoded.entries.iter() {
        let name = entry.filename.to_str_lossy().into_owned();
        let oid: gix::ObjectId = entry.oid.to_owned();

        if entry.mode.is_tree() {
            entries.push(TreeEntry {
                url: format!("{name}/index.html"),
                name: name.clone(),
                kind: TreeEntryKind::Tree,
            });
            subdirs.push((name, oid));
        } else {
            entries.push(TreeEntry {
                url: format!("{name}.html"),
                name: name.clone(),
                kind: TreeEntryKind::Blob,
            });
            if !entry.mode.is_commit() {
                blobs.push((name, oid));
            }
        }
    }

    entries.sort_by(|a, b| {
        let a_tree = matches!(a.kind, TreeEntryKind::Tree);
        let b_tree = matches!(b.kind, TreeEntryKind::Tree);
        b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
    });

    let root_path = "../".repeat(3 + depth);
    let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
    let breadcrumbs = make_crumbs(dir_path, false, None);

    for format in formats {
        let suffix = format.extension();
        let tmpl_name = format!("git_tree.{suffix}");
        let ext = format.extension();

        let mut ctx = Context::new();
        ctx.insert("project_name", project_name);
        ctx.insert("lang", lang);
        ctx.insert("clone_url", clone_url);
        ctx.insert("commit_hash", commit_hash);
        ctx.insert("commit_hash_short", &commit_hash[..7]);
        ctx.insert("commit_url", &commit_url);
        ctx.insert("dir_path", dir_path);
        ctx.insert("entries", &entries);
        ctx.insert("breadcrumbs", &breadcrumbs);
        ctx.insert("root_path", &root_path);

        let content = tera
            .render(&tmpl_name, &ctx)
            .map_err(|e| miette::miette!("{e}"))?;
        std::fs::write(page_dir.join(format!("index.{ext}")), content).into_diagnostic()?;
    }

    for (name, oid) in subdirs {
        let child_path = if dir_path.is_empty() {
            name
        } else {
            format!("{dir_path}/{name}")
        };
        walk_tree_dir(
            repo_path,
            repo,
            oid,
            &child_path,
            commit_hash,
            rev_dir,
            tera,
            project_name,
            lang,
            clone_url,
            ss,
            theme,
            formats,
        )?;
    }

    for (name, oid) in blobs {
        let file_path = if dir_path.is_empty() {
            name.clone()
        } else {
            format!("{dir_path}/{name}")
        };
        render_blob_page(
            repo_path,
            &name,
            &file_path,
            oid,
            commit_hash,
            depth,
            &page_dir,
            tera,
            project_name,
            lang,
            clone_url,
            ss,
            theme,
            formats,
        )?;
    }

    Ok(())
}

// ── Blob page ──────────────────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
fn render_blob_page(
    repo_path: &Path,
    filename: &str,
    file_path: &str,
    oid: gix::ObjectId,
    commit_hash: &str,
    depth: usize,
    page_dir: &Path,
    tera: &Tera,
    project_name: &str,
    lang: &Option<String>,
    clone_url: &Option<String>,
    ss: &syntect::parsing::SyntaxSet,
    theme: &syntect::highlighting::Theme,
    formats: &[OutputFormat],
) -> Result<()> {
    const MAX_BLOB_BYTES: usize = 1024 * 1024;

    let data: Vec<u8> = std::process::Command::new("git")
        .current_dir(repo_path)
        .args(["cat-file", "blob", &oid.to_string()])
        .output()
        .map(|o| o.stdout)
        .unwrap_or_default();

    let is_binary = data[..data.len().min(8192)].contains(&0u8);
    let too_large = data.len() > MAX_BLOB_BYTES;

    let text = String::from_utf8_lossy(&data);
    let content_plain: Option<String> = if is_binary || too_large || data.is_empty() {
        None
    } else {
        Some(text.to_string())
    };
    let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
        None
    } else {
        let ext = std::path::Path::new(filename)
            .extension()
            .and_then(|s| s.to_str())
            .unwrap_or("");
        let syntax = ss
            .find_syntax_by_extension(ext)
            .or_else(|| {
                text.lines()
                    .next()
                    .and_then(|l| ss.find_syntax_by_first_line(l))
            })
            .unwrap_or_else(|| ss.find_syntax_plain_text());
        Some(
            syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
                .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
        )
    };

    let root_path = "../".repeat(3 + depth);
    let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
    let breadcrumbs = make_crumbs(
        std::path::Path::new(file_path)
            .parent()
            .and_then(|p| p.to_str())
            .unwrap_or(""),
        true,
        Some(filename),
    );

    for format in formats {
        let suffix = format.extension();
        let tmpl_name = format!("git_blob.{suffix}");
        let ext = format.extension();

        let mut ctx = Context::new();
        ctx.insert("project_name", project_name);
        ctx.insert("lang", lang);
        ctx.insert("clone_url", clone_url);
        ctx.insert("commit_hash", commit_hash);
        ctx.insert("commit_hash_short", &commit_hash[..7]);
        ctx.insert("commit_url", &commit_url);
        ctx.insert("file_path", file_path);
        ctx.insert("filename", filename);
        ctx.insert("breadcrumbs", &breadcrumbs);
        ctx.insert("content_html", &content_html);
        ctx.insert("content_plain", &content_plain);
        ctx.insert("is_binary", &is_binary);
        ctx.insert("too_large", &too_large);
        ctx.insert("size", &data.len());
        ctx.insert("root_path", &root_path);

        let content = tera
            .render(&tmpl_name, &ctx)
            .map_err(|e| miette::miette!("{e}"))?;
        std::fs::write(page_dir.join(format!("{filename}.{ext}")), content).into_diagnostic()?;
    }

    Ok(())
}

// ── Helpers ────────────────────────────────────────────────────────────────────

fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
    let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
    let depth = parts.len();
    let mut crumbs = Vec::new();

    let root_url = if depth == 0 && !is_blob {
        None
    } else {
        Some(format!("{}index.html", "../".repeat(depth)))
    };
    crumbs.push(Crumb {
        name: "~".to_string(),
        url: root_url,
    });

    for (i, &part) in parts.iter().enumerate() {
        let is_last_and_tree = i == depth - 1 && !is_blob;
        let url = if is_last_and_tree {
            None
        } else {
            let levels_up = depth - i - 1;
            Some(format!("{}index.html", "../".repeat(levels_up)))
        };
        crumbs.push(Crumb {
            name: part.to_string(),
            url,
        });
    }

    if is_blob {
        if let Some(name) = filename {
            crumbs.push(Crumb {
                name: name.to_string(),
                url: None,
            });
        }
    }

    crumbs
}

fn escape_html(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}