From 37d631341ec843514cc6637eb43479bd03c8b56b Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 08:38:01 +0100 Subject: [PATCH] m6/hide-empty: conditional nav tabs, pages still build --- src/build.rs | 61 +++++++++++++----- src/wiki.rs | 90 ++++++++++++++++++++++++++ tests/hide_empty.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 tests/hide_empty.rs diff --git a/src/build.rs b/src/build.rs index d70dc3f..70aa5d9 100644 --- a/src/build.rs +++ b/src/build.rs @@ -757,18 +757,37 @@ fn render_text_pre(display: &str) -> String { } /// Top nav with live open f0rge counts (m6 open-counts, f0rge #29: -/// open means `state == "open"` exactly; drafts count as open). -fn nav_bar(issue_count: usize, pr_count: usize) -> String { - format!( - "code · commits · \ - issues ({issue_count}) · \ - pulls ({pr_count}) · \ - tasks · \ - ci · \ - releases · \ - wiki · \ - agents" - ) +/// open means `state == "open"` exactly; drafts count as open) and +/// conditional section tabs (m6 hide-empty, f0rge #38): `tasks` / +/// `releases` / `wiki` render ONLY when non-empty; `issues` / `pulls` +/// always stay (they carry the submit path), as do `code` / `commits` / +/// `ci` / `agents`. Hidden sections still build (deep links keep +/// working); only the nav omits the tab. +fn nav_bar( + issue_count: usize, + pr_count: usize, + has_tasks: bool, + has_releases: bool, + has_wiki: bool, +) -> String { + let mut tabs = vec![ + "code".to_string(), + "commits".to_string(), + format!("issues ({issue_count})"), + format!("pulls ({pr_count})"), + ]; + if has_tasks { + tabs.push("tasks".to_string()); + } + tabs.push("ci".to_string()); + if has_releases { + tabs.push("releases".to_string()); + } + if has_wiki { + tabs.push("wiki".to_string()); + } + tabs.push("agents".to_string()); + tabs.join(" · ") } /// Write `out/style.css` from the embedded static file. @@ -843,9 +862,19 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result 0, + ); // Raw files first: byte-identical copies. let mut blobs: BTreeMap> = BTreeMap::new(); @@ -975,8 +1004,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result String { filename.to_string() } +/// Number of renderable wiki pages (m6 hide-empty, f0rge issue #38). +/// +/// Same walk + filters as [`render`] (symlinked dirs/files skipped, +/// oversize files skipped, non-UTF-8 files skipped), so the header nav +/// hides the wiki tab exactly when the index would say "No wiki pages +/// yet". Read-only: emits no files, only the `eprintln!` warnings that +/// [`render`] would emit for skipped entries. +#[must_use] +pub fn page_count(repo: &Path) -> usize { + let root = repo.join(".forge").join("wiki"); + if !root.exists() { + return 0; + } + // Same walk as `render`: `/`-separated relative dirs, symlinked + // dirs never descended, symlinked files skipped later. + let mut rel_files: Vec = Vec::new(); + let mut stack: Vec = vec![String::new()]; + while let Some(dir) = stack.pop() { + let abs = if dir.is_empty() { + root.clone() + } else { + root.join(&dir) + }; + let rd = match std::fs::read_dir(&abs) { + Ok(rd) => rd, + Err(e) => { + eprintln!( + "hu13: wiki: cannot list {}: {e}", + if dir.is_empty() { "." } else { &dir } + ); + continue; + } + }; + for ent in rd { + let ent = match ent { + Ok(ent) => ent, + Err(e) => { + eprintln!("hu13: wiki: bad dir entry in {dir}: {e}"); + continue; + } + }; + let meta = match std::fs::symlink_metadata(ent.path()) { + Ok(m) => m, + Err(e) => { + eprintln!( + "hu13: wiki: cannot stat {}: {e}", + ent.file_name().to_string_lossy() + ); + continue; + } + }; + if meta.file_type().is_symlink() { + continue; + } + let name = ent.file_name().to_string_lossy().into_owned(); + let rel = if dir.is_empty() { + name + } else { + format!("{dir}/{name}") + }; + if meta.is_dir() { + stack.push(rel); + } else if meta.is_file() { + rel_files.push(rel); + } + } + } + // Same per-file filters as `render`: oversize + unreadable + + // non-UTF-8 files never become pages, so they never keep the tab. + let mut count = 0; + for rel in rel_files { + let abs = root.join(&rel); + let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0); + if len > WIKI_MAX_BYTES { + continue; + } + let bytes = match std::fs::read(&abs) { + Ok(b) => b, + Err(_) => continue, + }; + if bytes.len() as u64 > WIKI_MAX_BYTES { + continue; + } + if std::str::from_utf8(&bytes).is_err() { + continue; + } + count += 1; + } + count +} /// Render `wiki/index.html` plus one `wiki/.html` per page. /// /// A missing `/.forge/wiki/` dir renders an empty index ("No wiki diff --git a/tests/hide_empty.rs b/tests/hide_empty.rs new file mode 100644 index 0000000..5b19f01 --- /dev/null +++ b/tests/hide_empty.rs @@ -0,0 +1,152 @@ +//! Conditional nav tabs (m6 hide-empty, f0rge issue #38): +//! `tasks` / `releases` / `wiki` render in the header ONLY when non-empty; +//! `issues` / `pulls` / `ci` / `code` / `commits` / `agents` always stay. +//! Hidden sections still build (deep links keep working with their +//! "No X yet" empty states); only the nav omits the tab. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_hu13") +} + +fn fresh(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-hide-empty-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); +} + +fn forge_write(repo: &Path, rel: &str, content: &str) { + let dest = repo.join(".forge").join(rel); + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap(); + std::fs::write(&dest, content).unwrap(); +} + +fn bare_repo() -> PathBuf { + let dir = fresh("repo"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + std::fs::write(dir.join("README.md"), "# hide-empty fixture\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "init"]); + dir +} + +fn build(repo: &Path) -> PathBuf { + let out = fresh("out"); + let st = Command::new(bin()) + .arg("build") + .arg(repo) + .arg("-o") + .arg(&out) + .status() + .expect("spawn hu13 build"); + assert!(st.success(), "build exits 0"); + out +} + +fn header_of(out: &Path, rel: &str) -> String { + let html = std::fs::read_to_string(out.join(rel)).expect(rel); + html.split("") + .next() + .expect("header close") + .to_string() +} + +const ALWAYS_TABS: [&str; 6] = [ + "