.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/patch.diff 13.2 KiB 362 lines Raw
1 From 37d631341ec843514cc6637eb43479bd03c8b56b Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 08:38:01 +01004 Subject: [PATCH] m6/hide-empty: conditional nav tabs, pages still build5 6 ---7  src/build.rs        |  61 +++++++++++++-----8  src/wiki.rs         |  90 ++++++++++++++++++++++++++9  tests/hide_empty.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++10  3 files changed, 288 insertions(+), 15 deletions(-)11  create mode 100644 tests/hide_empty.rs12 13 diff --git a/src/build.rs b/src/build.rs14 index d70dc3f..70aa5d9 10064415 --- a/src/build.rs16 +++ b/src/build.rs17 @@ -757,18 +757,37 @@ fn render_text_pre(display: &str) -> String {18  }19  20  /// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:21 -/// open means `state == "open"` exactly; drafts count as open).22 -fn nav_bar(issue_count: usize, pr_count: usize) -> String {23 -    format!(24 -        "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \25 -         <a href=\"/issues/index.html\">issues ({issue_count})</a> · \26 -         <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \27 -         <a href=\"/tasks/index.html\">tasks</a> · \28 -         <a href=\"/ci/index.html\">ci</a> · \29 -         <a href=\"/releases/index.html\">releases</a> · \30 -         <a href=\"/wiki/index.html\">wiki</a> · \31 -         <a href=\"/agents.html\">agents</a>"32 -    )33 +/// open means `state == "open"` exactly; drafts count as open) and34 +/// conditional section tabs (m6 hide-empty, f0rge #38): `tasks` /35 +/// `releases` / `wiki` render ONLY when non-empty; `issues` / `pulls`36 +/// always stay (they carry the submit path), as do `code` / `commits` /37 +/// `ci` / `agents`. Hidden sections still build (deep links keep38 +/// working); only the nav omits the tab.39 +fn nav_bar(40 +    issue_count: usize,41 +    pr_count: usize,42 +    has_tasks: bool,43 +    has_releases: bool,44 +    has_wiki: bool,45 +) -> String {46 +    let mut tabs = vec![47 +        "<a href=\"/\">code</a>".to_string(),48 +        "<a href=\"/commits.html\">commits</a>".to_string(),49 +        format!("<a href=\"/issues/index.html\">issues ({issue_count})</a>"),50 +        format!("<a href=\"/pulls/index.html\">pulls ({pr_count})</a>"),51 +    ];52 +    if has_tasks {53 +        tabs.push("<a href=\"/tasks/index.html\">tasks</a>".to_string());54 +    }55 +    tabs.push("<a href=\"/ci/index.html\">ci</a>".to_string());56 +    if has_releases {57 +        tabs.push("<a href=\"/releases/index.html\">releases</a>".to_string());58 +    }59 +    if has_wiki {60 +        tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());61 +    }62 +    tabs.push("<a href=\"/agents.html\">agents</a>".to_string());63 +    tabs.join(" · ")64  }65  66  /// Write `out/style.css` from the embedded static file.67 @@ -843,9 +862,19 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu68      // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front69      // so every page (including file pages below) gets a nav with live70      // open counts (m6 open-counts, f0rge #29). Loading never fails:71 -    // bad files are skipped + warned.72 +    // bad files are skipped + warned. Releases load here too (m673 +    // hide-empty, f0rge #38) so the nav knows whether the releases tab74 +    // has content; wiki presence comes from `wiki::page_count` (same75 +    // filters as `wiki::render`, read-only, no files emitted).76      let forge_data = forge::load(repo);77 -    let nav = nav_bar(forge_data.open_issue_count(), forge_data.open_pr_count());78 +    let release_data = releases::load(repo);79 +    let nav = nav_bar(80 +        forge_data.open_issue_count(),81 +        forge_data.open_pr_count(),82 +        !forge_data.tasks.is_empty(),83 +        !release_data.is_empty(),84 +        wiki::page_count(repo) > 0,85 +    );86  87      // Raw files first: byte-identical copies.88      let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();89 @@ -975,8 +1004,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu90      feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;91      let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;92      // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.93 +    // Tasks/releases/wiki indexes always render even when empty (m694 +    // hide-empty, f0rge #38): only the nav hides the tab, deep links95 +    // keep working with their "No X yet" empty states.96      forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;97 -    let release_data = releases::load(repo);98      releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;99      api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;100      agents::render(101 diff --git a/src/wiki.rs b/src/wiki.rs102 index 4f20af8..02edd5c 100644103 --- a/src/wiki.rs104 +++ b/src/wiki.rs105 @@ -66,6 +66,96 @@ pub fn page_title(body: &str, filename: &str) -> String {106      filename.to_string()107  }108  109 +/// Number of renderable wiki pages (m6 hide-empty, f0rge issue #38).110 +///111 +/// Same walk + filters as [`render`] (symlinked dirs/files skipped,112 +/// oversize files skipped, non-UTF-8 files skipped), so the header nav113 +/// hides the wiki tab exactly when the index would say "No wiki pages114 +/// yet". Read-only: emits no files, only the `eprintln!` warnings that115 +/// [`render`] would emit for skipped entries.116 +#[must_use]117 +pub fn page_count(repo: &Path) -> usize {118 +    let root = repo.join(".forge").join("wiki");119 +    if !root.exists() {120 +        return 0;121 +    }122 +    // Same walk as `render`: `/`-separated relative dirs, symlinked123 +    // dirs never descended, symlinked files skipped later.124 +    let mut rel_files: Vec<String> = Vec::new();125 +    let mut stack: Vec<String> = vec![String::new()];126 +    while let Some(dir) = stack.pop() {127 +        let abs = if dir.is_empty() {128 +            root.clone()129 +        } else {130 +            root.join(&dir)131 +        };132 +        let rd = match std::fs::read_dir(&abs) {133 +            Ok(rd) => rd,134 +            Err(e) => {135 +                eprintln!(136 +                    "hu13: wiki: cannot list {}: {e}",137 +                    if dir.is_empty() { "." } else { &dir }138 +                );139 +                continue;140 +            }141 +        };142 +        for ent in rd {143 +            let ent = match ent {144 +                Ok(ent) => ent,145 +                Err(e) => {146 +                    eprintln!("hu13: wiki: bad dir entry in {dir}: {e}");147 +                    continue;148 +                }149 +            };150 +            let meta = match std::fs::symlink_metadata(ent.path()) {151 +                Ok(m) => m,152 +                Err(e) => {153 +                    eprintln!(154 +                        "hu13: wiki: cannot stat {}: {e}",155 +                        ent.file_name().to_string_lossy()156 +                    );157 +                    continue;158 +                }159 +            };160 +            if meta.file_type().is_symlink() {161 +                continue;162 +            }163 +            let name = ent.file_name().to_string_lossy().into_owned();164 +            let rel = if dir.is_empty() {165 +                name166 +            } else {167 +                format!("{dir}/{name}")168 +            };169 +            if meta.is_dir() {170 +                stack.push(rel);171 +            } else if meta.is_file() {172 +                rel_files.push(rel);173 +            }174 +        }175 +    }176 +    // Same per-file filters as `render`: oversize + unreadable +177 +    // non-UTF-8 files never become pages, so they never keep the tab.178 +    let mut count = 0;179 +    for rel in rel_files {180 +        let abs = root.join(&rel);181 +        let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);182 +        if len > WIKI_MAX_BYTES {183 +            continue;184 +        }185 +        let bytes = match std::fs::read(&abs) {186 +            Ok(b) => b,187 +            Err(_) => continue,188 +        };189 +        if bytes.len() as u64 > WIKI_MAX_BYTES {190 +            continue;191 +        }192 +        if std::str::from_utf8(&bytes).is_err() {193 +            continue;194 +        }195 +        count += 1;196 +    }197 +    count198 +}199  /// Render `wiki/index.html` plus one `wiki/<path>.html` per page.200  ///201  /// A missing `<repo>/.forge/wiki/` dir renders an empty index ("No wiki202 diff --git a/tests/hide_empty.rs b/tests/hide_empty.rs203 new file mode 100644204 index 0000000..5b19f01205 --- /dev/null206 +++ b/tests/hide_empty.rs207 @@ -0,0 +1,152 @@208 +//! Conditional nav tabs (m6 hide-empty, f0rge issue #38):209 +//! `tasks` / `releases` / `wiki` render in the header ONLY when non-empty;210 +//! `issues` / `pulls` / `ci` / `code` / `commits` / `agents` always stay.211 +//! Hidden sections still build (deep links keep working with their212 +//! "No X yet" empty states); only the nav omits the tab.213 +214 +use std::path::{Path, PathBuf};215 +use std::process::Command;216 +217 +fn bin() -> &'static str {218 +    env!("CARGO_BIN_EXE_hu13")219 +}220 +221 +fn fresh(prefix: &str) -> PathBuf {222 +    let dir = std::env::temp_dir().join(format!(223 +        "hu13-hide-empty-{prefix}-{}-{}",224 +        std::process::id(),225 +        std::time::SystemTime::now()226 +            .duration_since(std::time::UNIX_EPOCH)227 +            .map(|d| d.as_nanos())228 +            .unwrap_or(0)229 +    ));230 +    std::fs::create_dir_all(&dir).unwrap();231 +    dir232 +}233 +234 +fn git(repo: &Path, args: &[&str]) {235 +    let st = Command::new("git")236 +        .arg("-C")237 +        .arg(repo)238 +        .args(args)239 +        .status()240 +        .expect("spawn git");241 +    assert!(st.success(), "git {args:?} failed");242 +}243 +244 +fn forge_write(repo: &Path, rel: &str, content: &str) {245 +    let dest = repo.join(".forge").join(rel);246 +    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();247 +    std::fs::write(&dest, content).unwrap();248 +}249 +250 +fn bare_repo() -> PathBuf {251 +    let dir = fresh("repo");252 +    git(&dir, &["init", "-b", "main"]);253 +    git(&dir, &["config", "user.name", "hu13-test"]);254 +    git(&dir, &["config", "user.email", "[email protected]"]);255 +    git(&dir, &["config", "commit.gpgsign", "false"]);256 +    std::fs::write(dir.join("README.md"), "# hide-empty fixture\n").unwrap();257 +    git(&dir, &["add", "-A"]);258 +    git(&dir, &["commit", "-m", "init"]);259 +    dir260 +}261 +262 +fn build(repo: &Path) -> PathBuf {263 +    let out = fresh("out");264 +    let st = Command::new(bin())265 +        .arg("build")266 +        .arg(repo)267 +        .arg("-o")268 +        .arg(&out)269 +        .status()270 +        .expect("spawn hu13 build");271 +    assert!(st.success(), "build exits 0");272 +    out273 +}274 +275 +fn header_of(out: &Path, rel: &str) -> String {276 +    let html = std::fs::read_to_string(out.join(rel)).expect(rel);277 +    html.split("</header>")278 +        .next()279 +        .expect("header close")280 +        .to_string()281 +}282 +283 +const ALWAYS_TABS: [&str; 6] = [284 +    "<a href=\"/\"",285 +    "<a href=\"/commits.html\"",286 +    "<a href=\"/issues/index.html\"",287 +    "<a href=\"/pulls/index.html\"",288 +    "<a href=\"/ci/index.html\"",289 +    "<a href=\"/agents.html\"",290 +];291 +292 +const CONDITIONAL_TABS: [(&str, &str); 3] = [293 +    ("tasks", "<a href=\"/tasks/index.html\""),294 +    ("releases", "<a href=\"/releases/index.html\""),295 +    ("wiki", "<a href=\"/wiki/index.html\""),296 +];297 +298 +#[test]299 +fn empty_sections_hide_tabs_but_pages_still_build() {300 +    let out = build(&bare_repo());301 +    // Empty-state pages still build (deep links keep working).302 +    for (page, marker) in [303 +        ("tasks/index.html", "No tasks yet"),304 +        ("releases/index.html", "No releases yet"),305 +        ("wiki/index.html", "No wiki pages yet"),306 +    ] {307 +        let html = std::fs::read_to_string(out.join(page)).expect(page);308 +        assert!(html.contains(marker), "{page} keeps its empty state");309 +    }310 +    // Every sampled header hides the three tabs but keeps the rest.311 +    for page in ["index.html", "commits.html", "tasks/index.html"] {312 +        let header = header_of(&out, page);313 +        for (name, needle) in CONDITIONAL_TABS {314 +            assert!(!header.contains(needle), "{page} header hides empty {name}");315 +        }316 +        for needle in ALWAYS_TABS {317 +            assert!(318 +                header.contains(needle),319 +                "{page} header keeps always-shown {needle}"320 +            );321 +        }322 +    }323 +}324 +325 +#[test]326 +fn full_forge_shows_all_tabs() {327 +    let repo = bare_repo();328 +    forge_write(329 +        &repo,330 +        "tasks/77777777-7777-4777-8777-777777777777.md",331 +        "---\nid: 77777777-7777-4777-8777-777777777777\ntitle: A task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n",332 +    );333 +    forge_write(334 +        &repo,335 +        "releases/v1.0.md",336 +        "---\nversion: v1.0\ntitle: First\ncreated: 2026-09-10T02:00:00Z\n---\n# v1.0 notes\n",337 +    );338 +    forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");339 +    git(&repo, &["add", "-A"]);340 +    git(&repo, &["commit", "-m", "full forge"]);341 +    let out = build(&repo);342 +    let header = header_of(&out, "index.html");343 +    for (name, needle) in CONDITIONAL_TABS {344 +        assert!(345 +            header.contains(needle),346 +            "populated {name} tab shown in header"347 +        );348 +    }349 +    for needle in ALWAYS_TABS {350 +        assert!(header.contains(needle), "header keeps {needle}");351 +    }352 +    // Populated pages list their content.353 +    let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");354 +    assert!(tasks.contains("A task"), "task listed");355 +    let releases = std::fs::read_to_string(out.join("releases/index.html")).expect("releases");356 +    assert!(releases.contains("v1.0"), "release listed");357 +    let wiki = std::fs::read_to_string(out.join("wiki/index.html")).expect("wiki");358 +    assert!(wiki.contains("Notes"), "wiki page listed");359 +}360 -- 361 2.55.0362