a73x

ab75a7c5

feat(server): readme lookup with precedence and markdown rendering

alex emery   2026-04-12 17:33


diff --git a/src/server/http/repo/readme.rs b/src/server/http/repo/readme.rs
index 7ac24c6..58c55a1 100644
--- a/src/server/http/repo/readme.rs
+++ b/src/server/http/repo/readme.rs
@@ -19,22 +19,31 @@ pub fn load_readme(repo: &Repository) -> Option<RenderedReadme> {
    let head = repo.head().ok()?;
    let commit = head.peel_to_commit().ok()?;
    let tree = commit.tree().ok()?;
    let entry = find_readme_entry(&tree)?;

    let _entry = find_readme_entry(&tree)?;
    None // Task 3 will replace this
    let blob = repo.find_blob(entry.oid).ok()?;
    if blob.is_binary() {
        return None;
    }
    let text = std::str::from_utf8(blob.content()).ok()?;

    let html = match entry.kind {
        ReadmeKind::Markdown => render_markdown(text),
        ReadmeKind::Plain => render_plain(text),
    };
    Some(RenderedReadme { html })
}

/// One root-tree entry. Carries the resolved category so the renderer can
/// branch on type without re-parsing the filename.
#[allow(dead_code)]
#[derive(Debug)]
struct ReadmeEntry {
    #[allow(dead_code)]
    name: String,
    oid: git2::Oid,
    kind: ReadmeKind,
}

#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReadmeKind {
    Markdown,
@@ -43,14 +52,79 @@ enum ReadmeKind {

/// Walk the root tree (non-recursive) and pick the highest-precedence README
/// blob entry. Symlinks and tree entries are ignored.
#[allow(dead_code)]
fn find_readme_entry(_tree: &git2::Tree) -> Option<ReadmeEntry> {
    None // Task 3 implements the real walk
fn find_readme_entry(tree: &git2::Tree) -> Option<ReadmeEntry> {
    // Score: lower is better. None means "not a README".
    fn classify(name: &str) -> Option<(u8, ReadmeKind)> {
        match name.to_ascii_lowercase().as_str() {
            "readme.md" => Some((0, ReadmeKind::Markdown)),
            "readme" => Some((1, ReadmeKind::Plain)),
            "readme.txt" => Some((2, ReadmeKind::Plain)),
            _ => None,
        }
    }

    let mut best: Option<(u8, ReadmeEntry)> = None;
    for entry in tree.iter() {
        // Skip subtrees, symlinks, submodules — only regular blobs.
        if entry.kind() != Some(git2::ObjectType::Blob) {
            continue;
        }
        if entry.filemode() != 0o100644 && entry.filemode() != 0o100755 {
            continue; // 0o120000 (symlink) and anything else
        }
        let name = match entry.name() {
            Some(n) => n,
            None => continue,
        };
        let (score, kind) = match classify(name) {
            Some(v) => v,
            None => continue,
        };
        let candidate = ReadmeEntry { name: name.to_string(), oid: entry.id(), kind };
        match &best {
            None => best = Some((score, candidate)),
            Some((cur_score, _)) if score < *cur_score => best = Some((score, candidate)),
            _ => {}
        }
    }
    best.map(|(_, e)| e)
}

fn render_markdown(src: &str) -> String {
    use pulldown_cmark::{Options, Parser, html};
    let mut opts = Options::empty();
    opts.insert(Options::ENABLE_TABLES);
    opts.insert(Options::ENABLE_STRIKETHROUGH);
    opts.insert(Options::ENABLE_TASKLISTS);
    let parser = Parser::new_ext(src, opts);
    let mut unsafe_html = String::new();
    html::push_html(&mut unsafe_html, parser);
    sanitize(&unsafe_html)
}

fn render_plain(src: &str) -> String {
    // Task 4 will tighten this. For now, escape and wrap.
    let escaped = html_escape(src);
    format!("<pre>{}</pre>", escaped)
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

fn sanitize(html: &str) -> String {
    // Task 5 will tighten the policy (img URL schemes). Default is fine for now.
    ammonia::clean(html)
}

#[cfg(test)]
mod tests {
    use super::*;
    use git2::Repository;
    use tempfile::TempDir;

    /// Build an empty git repo with a single commit containing the given
@@ -82,4 +156,88 @@ mod tests {
        let (repo, _tmp) = repo_with_files(&[("lib.rs", b"fn main() {}")]);
        assert!(load_readme(&repo).is_none());
    }

    #[test]
    fn finds_uppercase_readme_md() {
        let (repo, _tmp) = repo_with_files(&[("README.md", b"# Title\n\nbody\n")]);
        let r = load_readme(&repo).expect("README found");
        assert!(r.html.contains("<h1>Title</h1>"), "got: {}", r.html);
    }

    #[test]
    fn finds_mixed_case_readme_md() {
        let (repo, _tmp) = repo_with_files(&[("Readme.MD", b"# T\n")]);
        assert!(load_readme(&repo).is_some());
    }

    #[test]
    fn md_wins_over_txt() {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"# md\n"),
            ("README.txt", b"plain"),
        ]);
        let r = load_readme(&repo).unwrap();
        assert!(r.html.contains("<h1>md</h1>"));
        assert!(!r.html.contains("plain"));
    }

    #[test]
    fn readme_wins_over_txt() {
        let (repo, _tmp) = repo_with_files(&[
            ("README", b"plain readme"),
            ("README.txt", b"plain txt"),
        ]);
        let r = load_readme(&repo).unwrap();
        assert!(r.html.contains("plain readme"));
        assert!(!r.html.contains("plain txt"));
    }

    #[test]
    fn mixed_case_md_still_wins_over_lowercase_txt() {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"# md\n"),
            ("readme.txt", b"plain"),
        ]);
        assert!(load_readme(&repo).unwrap().html.contains("<h1>md</h1>"));
    }

    #[test]
    fn nested_readme_is_not_matched() {
        // Note: tree is built non-recursively here, so we put the nested README
        // in a subtree manually.
        let tmp = TempDir::new().unwrap();
        let repo = Repository::init(tmp.path()).unwrap();
        let sig = git2::Signature::now("T", "t@e").unwrap();

        let blob = repo.blob(b"# nope\n").unwrap();
        let mut sub = repo.treebuilder(None).unwrap();
        sub.insert("README.md", blob, 0o100644).unwrap();
        let sub_oid = sub.write().unwrap();

        let mut root = repo.treebuilder(None).unwrap();
        root.insert("docs", sub_oid, 0o040000).unwrap();
        let root_oid = root.write().unwrap();
        let tree = repo.find_tree(root_oid).unwrap();
        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();

        assert!(load_readme(&repo).is_none());
    }

    #[test]
    fn symlink_readme_is_ignored() {
        let tmp = TempDir::new().unwrap();
        let repo = Repository::init(tmp.path()).unwrap();
        let sig = git2::Signature::now("T", "t@e").unwrap();

        // Symlinks are stored as a blob whose contents are the target path,
        // with file mode 0o120000.
        let target_blob = repo.blob(b"docs/REAL.md").unwrap();
        let mut root = repo.treebuilder(None).unwrap();
        root.insert("README.md", target_blob, 0o120000).unwrap();
        let root_oid = root.write().unwrap();
        let tree = repo.find_tree(root_oid).unwrap();
        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();

        assert!(load_readme(&repo).is_none());
    }
}