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('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } 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()); } }