1 From 5d700ecb2cc50b63f5d7208a55ba0b815679cda8 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:49:52 +01004 Subject: [PATCH] m6/open-counts: open-only nav, drop code-tab counts line5 6 ---7 src/build.rs | 97 +++++++++++++++++++++++++++++++-------8 src/forge.rs | 103 ++++++++++++++++++++++++++++++++++++++++-9 tests/forge_readers.rs | 20 ++++++--10 3 files changed, 197 insertions(+), 23 deletions(-)11 12 diff --git a/src/build.rs b/src/build.rs13 index a48bee1..d158b5c 10064414 --- a/src/build.rs15 +++ b/src/build.rs16 @@ -424,7 +424,8 @@ fn render_text_pre(display: &str) -> String {17 out18 }19 20 -/// Top nav with live f0rge counts (no stubs, no TODO markers).21 +/// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:22 +/// open means `state == "open"` exactly; drafts count as open).23 fn nav_bar(issue_count: usize, pr_count: usize) -> String {24 format!(25 "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \26 @@ -509,9 +510,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu27 28 // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front29 // so every page (including file pages below) gets a nav with live30 - // counts. Loading never fails: bad files are skipped + warned.31 + // open counts (m6 open-counts, f0rge #29). Loading never fails:32 + // bad files are skipped + warned.33 let forge_data = forge::load(repo);34 - let nav = nav_bar(forge_data.issues.len(), forge_data.prs.len());35 + let nav = nav_bar(forge_data.open_issue_count(), forge_data.open_pr_count());36 37 // Raw files first: byte-identical copies.38 let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();39 @@ -667,6 +669,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu40 41 // index.html header: stacked repo-head (name + clone + meta) so narrow42 // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.43 + // No counts line here (m6 open-counts, f0rge #36): nav carries the44 + // open issues/pulls counts; branch/sha/CI meta stay.45 let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));46 if let Some(d) = &description {47 body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));48 @@ -676,16 +680,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu49 esc(&branch),50 esc(&short_sha)51 ));52 - body.push_str(&format!(53 - "<p><a href=\"/issues/index.html\">issues ({})</a> · \54 - <a href=\"/pulls/index.html\">pulls ({})</a> · \55 - <a href=\"/tasks/index.html\">tasks ({})</a> · \56 - <a href=\"/releases/index.html\">releases ({})</a></p>\n",57 - forge_data.issues.len(),58 - forge_data.prs.len(),59 - forge_data.tasks.len(),60 - release_data.len(),61 - ));62 body.push_str(&format!(63 "<p>CI: {}</p>\n",64 forge::verdict_badge(forge_data.ci.latest_verdict()),65 @@ -890,14 +884,21 @@ mod tests {66 "raw HTML escaped"67 );68 assert!(index.contains("<script>"), "escaped HTML present");69 - // Header: branch + short sha, live f0rge counts, CI verdict badge.70 + // Header: branch + short sha, live open f0rge counts, CI verdict badge.71 assert!(index.contains("main"), "branch shown");72 assert!(!index.contains("TODO"), "no TODO markers left");73 - assert!(index.contains("issues (0)"), "f0rge counts as 0");74 - assert!(index.contains("pulls (0)"), "pull counts as 0");75 - assert!(index.contains("tasks (0)"), "task counts as 0");76 + assert!(index.contains("issues (0)"), "nav open issues 0");77 + assert!(index.contains("pulls (0)"), "nav open pulls 0");78 + assert!(index.contains("repo-meta"), "branch/sha meta kept");79 assert!(index.contains("CI:"), "CI verdict shown");80 assert!(index.contains("badge"), "CI verdict badge");81 + // f0rge #36: code tab carries no redundant counts line.82 + let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");83 + let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;84 + let head = &index[head_start..head_end];85 + for needle in ["issues (", "pulls (", "tasks (", "releases ("] {86 + assert!(!head.contains(needle), "code tab drops {needle}");87 + }88 // Clone block (bundle-first: no origin remote is configured).89 assert!(index.contains("clone-block"), "clone block present");90 assert!(index.contains("repo.bundle"), "bundle linked");91 @@ -914,6 +915,68 @@ mod tests {92 );93 }94 95 + #[test]96 + fn open_counts_nav_and_no_code_tab_counts() {97 + // m6/open-counts (f0rge #29 + #36): header nav is open-only,98 + // code tab keeps branch/sha/CI but drops the counts line,99 + // index pages carry the `N closed` totals.100 + let repo = fixture_repo();101 + let forge_write = |rel: &str, content: &str| {102 + let dest = repo.join(".forge").join(rel);103 + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();104 + std::fs::write(&dest, content).unwrap();105 + };106 + for (uuid, state, hour) in [107 + ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),108 + ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),109 + ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),110 + ] {111 + forge_write(112 + &format!("issues/{uuid}/issue.md"),113 + &format!(114 + "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"115 + ),116 + );117 + }118 + for (uuid, state, draft, hour) in [119 + ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),120 + ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),121 + (122 + "ffffffff-ffff-ffff-ffff-ffffffffffff",123 + "merged",124 + false,125 + "05",126 + ),127 + ] {128 + forge_write(129 + &format!("prs/{uuid}/pr.md"),130 + &format!(131 + "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"132 + ),133 + );134 + }135 + git(&repo, &["add", "-A"]);136 + git(&repo, &["commit", "-m", "forge counts"]);137 + let out = mktemp("out-open-counts");138 + run_build(&repo, &out, &opts()).expect("build");139 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");140 + assert!(index.contains("issues (1)"), "nav open issues");141 + assert!(index.contains("pulls (2)"), "nav open pulls (draft incl)");142 + assert!(index.contains("repo-meta"), "branch/sha meta kept");143 + assert!(index.contains("CI:"), "CI meta kept");144 + let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");145 + let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;146 + let head = &index[head_start..head_end];147 + for needle in ["issues (", "pulls (", "tasks (", "releases ("] {148 + assert!(!head.contains(needle), "code tab drops {needle}");149 + }150 + let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");151 + assert!(issues.contains("2 closed"), "issues closed total");152 + let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");153 + assert!(pulls.contains("1 closed"), "pulls closed total");154 + assert!(pulls.contains(" draft"), "draft stays marked");155 + }156 +157 #[test]158 fn clone_url_flag_wins() {159 let repo = fixture_repo();160 diff --git a/src/forge.rs b/src/forge.rs161 index 3574c8a..fe2c0e4 100644162 --- a/src/forge.rs163 +++ b/src/forge.rs164 @@ -418,6 +418,37 @@ pub struct ForgeData {165 pub ci: CiInfo,166 }167 168 +impl ForgeData {169 + /// Open issues: `state == "open"` exactly (m6 open-counts, f0rge #29).170 + /// `in-progress` (and `closed`/`archived`) counts as NOT open.171 + #[must_use]172 + pub fn open_issue_count(&self) -> usize {173 + self.issues.iter().filter(|i| i.state == "open").count()174 + }175 +176 + /// Non-open issues (`total - open`): shown as the `N closed` line on177 + /// the issues index so closed totals stay visible (f0rge #29).178 + #[must_use]179 + pub fn closed_issue_count(&self) -> usize {180 + self.issues.len() - self.open_issue_count()181 + }182 +183 + /// Open PRs: `state == "open"` exactly (m6 open-counts, f0rge #29).184 + /// Drafts (`draft: true`) are counted as open and stay marked185 + /// (` draft` suffix) in the pulls table.186 + #[must_use]187 + pub fn open_pr_count(&self) -> usize {188 + self.prs.iter().filter(|p| p.state == "open").count()189 + }190 +191 + /// Non-open PRs (`closed`/`merged`; `total - open`): shown as the192 + /// `N closed` line on the pulls index (f0rge #29).193 + #[must_use]194 + pub fn closed_pr_count(&self) -> usize {195 + self.prs.len() - self.open_pr_count()196 + }197 +}198 +199 // ---------------------------------------------------------------------------200 // Loading201 // ---------------------------------------------------------------------------202 @@ -966,6 +997,10 @@ fn render_issues_index(203 }204 body.push_str("</tbody>\n</table></div>\n");205 }206 + body.push_str(&format!(207 + "<p class=\"muted\">{n} closed</p>\n",208 + n = data.closed_issue_count(),209 + ));210 if !data.labels.is_empty() {211 body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");212 for l in &data.labels {213 @@ -993,7 +1028,13 @@ fn render_issues_index(214 ))215 }216 217 -fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Option<&str>) -> String {218 +fn render_pulls_index(219 + data: &ForgeData,220 + site_name: &str,221 + nav: &str,222 + description: Option<&str>,223 +) -> String {224 + let prs = &data.prs;225 let mut body =226 String::from("<div class=\"page-head\"><h1>Pulls</h1>\n<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></div>\n");227 if prs.is_empty() {228 @@ -1014,6 +1055,10 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Optio229 }230 body.push_str("</tbody>\n</table></div>\n");231 }232 + body.push_str(&format!(233 + "<p class=\"muted\">{n} closed</p>\n",234 + n = data.closed_pr_count(),235 + ));236 page_site(237 &format!("pulls — {site_name}"),238 nav,239 @@ -1166,7 +1211,7 @@ pub fn render_loaded(240 write_page(241 out,242 "pulls/index.html",243 - &render_pulls_index(&data.prs, site_name, nav, description),244 + &render_pulls_index(data, site_name, nav, description),245 )?;246 for pr in &data.prs {247 write_page(248 @@ -1651,4 +1696,58 @@ mod tests {249 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");250 assert!(pulls.contains(&long_sha), "long head sha kept");251 }252 +253 + /// m6/open-counts (f0rge #29): nav counts are open-only254 + /// (`state == "open"` exactly; in-progress is NOT open; drafts count255 + /// as open and stay marked) with `N closed` totals on index pages.256 + #[test]257 + fn open_only_counts_with_closed_lines() {258 + let repo = mktemp("open-counts");259 + for (uuid, state, hour) in [260 + ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),261 + ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),262 + ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),263 + ] {264 + write_forge(265 + &repo,266 + &format!("issues/{uuid}/issue.md"),267 + &format!(268 + "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"269 + ),270 + );271 + }272 + for (uuid, state, draft, hour) in [273 + ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),274 + ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),275 + (276 + "ffffffff-ffff-ffff-ffff-ffffffffffff",277 + "merged",278 + false,279 + "05",280 + ),281 + ] {282 + write_forge(283 + &repo,284 + &format!("prs/{uuid}/pr.md"),285 + &format!(286 + "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"287 + ),288 + );289 + }290 + let data = load(&repo);291 + assert_eq!(data.open_issue_count(), 1, "only state==open");292 + assert_eq!(data.closed_issue_count(), 2, "in-progress + closed");293 + assert_eq!(data.open_pr_count(), 2, "open + draft-open");294 + assert_eq!(data.closed_pr_count(), 1, "merged");295 +296 + let out = mktemp("open-counts-out");297 + render_loaded(&data, &out, "test", "<nav>", None).expect("render");298 + let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");299 + assert!(issues.contains("2 closed"), "issues closed total");300 + assert!(issues.contains("Issue open"), "open row kept");301 + assert!(issues.contains("Issue closed"), "closed row kept");302 + let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");303 + assert!(pulls.contains("1 closed"), "pulls closed total");304 + assert!(pulls.contains(" draft"), "draft stays marked");305 + }306 }307 diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs308 index fda2b8b..d695600 100644309 --- a/tests/forge_readers.rs310 +++ b/tests/forge_readers.rs311 @@ -309,15 +309,27 @@ fn forge_fixture_renders_counts_numbers_and_ci() {312 assert!(ci.contains(">fail<"), "verdict text on ci page");313 assert!(ci.contains("boom: the test failed"), "run log in <pre>");314 315 - // --- index header: real counts + verdict, no TODO markers ---316 + // --- index header: open counts + verdict, no TODO markers ---317 + // m6/open-counts (#29 + #36): nav is open-only (Older open, Newer318 + // closed -> issues (1)); code tab keeps branch/sha/CI, drops the319 + // redundant counts line; closed totals live on index pages.320 let index = read("index.html");321 - assert!(index.contains("issues (2)"), "real issue count");322 - assert!(index.contains("pulls (1)"), "real pull count");323 - assert!(index.contains("tasks (4)"), "real task count");324 + assert!(index.contains("issues (1)"), "nav open issue count");325 + assert!(index.contains("pulls (1)"), "nav open pull count");326 + assert!(index.contains("repo-meta"), "branch/sha meta kept");327 + let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");328 + let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;329 + let head = &index[head_start..head_end];330 + for needle in ["issues (", "pulls (", "tasks (", "releases ("] {331 + assert!(!head.contains(needle), "code tab drops {needle}");332 + }333 assert!(index.contains("badge fail"), "index verdict badge");334 assert!(!index.contains("TODO"), "TODO markers deleted");335 assert!(index.contains("/issues/index.html"), "nav links issues");336 assert!(index.contains("/pulls/index.html"), "nav links pulls");337 assert!(index.contains("/tasks/index.html"), "nav links tasks");338 assert!(index.contains("/ci/index.html"), "nav links ci");339 + // Closed totals stay visible on their index pages.340 + assert!(issues.contains("1 closed"), "issues closed total");341 + assert!(pulls.contains("0 closed"), "pulls closed total");342 }343 -- 344 2.55.0345