From 27e09a395b2c9a4bbdc2d856e8c0d3ab11371b32 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 28 Jun 2026 14:48:38 +0200 Subject: [PATCH] plan: 0001 walking-skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bite-sized implementation plan for the walking skeleton (parent spec docs/specs/0001-walking-skeleton.md): nine TDD-structured tasks — scaffold, config, gitea client, render, theme, cache, check, server, and main wiring — building the live-render server for aura + data-server docs from Gitea main. src/lib.rs registers modules incrementally so the lib compiles after every task; main stays a stub until the final wiring task. refs #1 --- docs/plans/0001-walking-skeleton.md | 1117 +++++++++++++++++++++++++++ 1 file changed, 1117 insertions(+) create mode 100644 docs/plans/0001-walking-skeleton.md diff --git a/docs/plans/0001-walking-skeleton.md b/docs/plans/0001-walking-skeleton.md new file mode 100644 index 0000000..73e468f --- /dev/null +++ b/docs/plans/0001-walking-skeleton.md @@ -0,0 +1,1117 @@ +# Walking Skeleton — Implementation Plan + +> **Parent spec:** `docs/specs/0001-walking-skeleton.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to +> run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** A `docsite serve` HTTP server plus a `docsite check` subcommand +that render aura + data-server docs live from Gitea `main`, dark-themed, +navigated from a DocSite-owned whitelist registry, with `code` + `table` +directives. + +**Architecture:** Single crate with a lib (`src/lib.rs`, modules below) and +a thin bin (`src/main.rs`, subcommand dispatch). Source is fetched read-only +through a `Fetcher` trait — `GiteaClient` (blocking reqwest) in production, +`MockFetcher` in tests — so tests run offline. Render is comrak (GFM) + +syntect; the themed shell wraps it; an in-memory `(repo,path,sha)` cache +keys on the source revision. `docsite check` and a boot-time pass validate +every nav reference against the live sources. + +**Tech Stack:** Rust 2021, axum + tokio (server), reqwest blocking (Gitea), +comrak + syntect (render), serde + toml (config). lib+bin split; theme.css +embedded via `include_str!`. + +**Derived layout decisions (plan-recon open questions):** (1) lib+bin split +— required for `tests/` integration tests; (2) theme.css embedded via +`include_str!` — self-contained binary; (3) dependency versions via +`cargo add` — current compatible versions, not pinned by guess. + +**Module registration (ordering contract):** `src/lib.rs` starts with NO +module declarations. Each component task below FIRST appends its own +`pub mod ;` line to `src/lib.rs`, so the library compiles after every +task — a per-task `cargo test --lib ::` gate would otherwise fail on a +lib that declares a not-yet-created module. `src/main.rs` stays a standalone +dispatch stub until Task 9 wires it to `server::run` / `check::run_cli`. Task +order respects the dependency graph: config, gitea, render, theme, cache +(independent or config-only), then check (config+gitea), then server (all). + +--- + +### Task 1: Scaffold — dependencies + lib/bin split + +**Files:** +- Modify: `Cargo.toml:7-9` (populate `[dependencies]`) +- Create: `src/lib.rs` +- Modify: `src/main.rs:9-11` (subcommand dispatch stub) + +- [ ] **Step 1: Add dependencies** + +Run: +``` +cargo add axum tokio --features tokio/rt-multi-thread,tokio/macros,tokio/net +cargo add reqwest --no-default-features --features blocking,rustls-tls +cargo add comrak syntect serde --features serde/derive +cargo add toml +``` +Expected: each prints `Adding v to dependencies`; `cargo` resolves +without error. (LAN Gitea is plain HTTP, but reqwest still needs a TLS +backend to build; rustls avoids a system-openssl dependency.) + +- [ ] **Step 2: Create the lib crate root** + +Create `src/lib.rs` (no module declarations yet — each component task appends +its own `pub mod` line, per the ordering contract above): +```rust +//! DocSite library crate — the render/serve/check machinery behind the +//! `docsite` binary. Split out as a lib so the integration tests under +//! `tests/` can drive it directly with an injected `Fetcher`. Module +//! declarations are appended by the tasks that create each module, so the +//! lib compiles after every task. +``` + +- [ ] **Step 3: Replace the main stub with subcommand dispatch** + +Replace `src/main.rs` entirely: +```rust +//! `docsite` — a single-source-of-truth LAN documentation server. +//! Subcommands: `serve` (run the HTTP server) and `check` (validate every +//! configured nav reference against the live Gitea sources, exit-coded). + +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + match args.get(1).map(String::as_str) { + // server::run / check::run_cli are wired in Task 9, once those modules + // exist; until then the dispatch shape compiles as a standalone stub. + Some("serve") => { eprintln!("docsite: serve not yet wired"); ExitCode::from(70) } + Some("check") => { eprintln!("docsite: check not yet wired"); ExitCode::from(70) } + _ => { + eprintln!("usage: docsite "); + ExitCode::from(2) + } + } +} +``` + +- [ ] **Step 4: Build gate** + +Run: `cargo build` +Expected: PASS — the scaffold compiles: the lib has no modules yet and main's +dispatch is a standalone stub referencing nothing unbuilt. The real +serve/check wiring lands in Task 9. + +--- + +### Task 2: config — registry model, parse, lookup, whitelist + +**Files:** +- Create: `src/config/mod.rs` +- Create: `src/config/registry.rs` +- Test: in-module `#[cfg(test)]` in `src/config/mod.rs` + +- [ ] **Step 1: Write the serde model** + +Create `src/config/registry.rs`: +```rust +//! The DocSite-owned `registry.toml` model: the publish whitelist + nav. +//! NO `deny_unknown_fields` — unknown keys are tolerated so the schema can +//! grow (forward-compatible). + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Registry { + pub bind: String, + pub gitea: String, + #[serde(default)] + pub project: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Project { + pub slug: String, + pub repo: String, + pub branch: String, + pub docs: String, + #[serde(default)] + pub section: Vec
, +} + +#[derive(Debug, Deserialize)] +pub struct Section { + pub title: String, + pub pages: Vec, +} + +/// A nav page resolved to its source location: the project plus the page +/// path relative to the project's `docs` root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageRef { + pub slug: String, + pub rel: String, +} +``` + +- [ ] **Step 2: Write the failing test for load + lookup + whitelist** + +Create `src/config/mod.rs`: +```rust +pub mod registry; +pub use registry::{PageRef, Project, Registry, Section}; + +use std::path::Path; + +/// Parse a `registry.toml` from disk. +pub fn load(path: impl AsRef) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("registry: {e}"))?; + toml::from_str(&text).map_err(|e| format!("registry parse: {e}")) +} + +impl Registry { + pub fn project(&self, slug: &str) -> Option<&Project> { + self.project.iter().find(|p| p.slug == slug) + } + + /// Resolve a served url path (`/`) to a `PageRef`, + /// IFF that page is listed in some section of the project (whitelist). + /// `url_page` is the path after the slug, without a trailing `.md` + /// (e.g. `design/INDEX`). A `..` component is refused (traversal). + pub fn resolve(&self, slug: &str, url_page: &str) -> Option { + if url_page.split('/').any(|c| c == ".." || c.is_empty()) { + return None; + } + let proj = self.project(slug)?; + let want = format!("{url_page}.md"); + let listed = proj + .section + .iter() + .flat_map(|s| &s.pages) + .any(|p| p == &want); + listed.then(|| PageRef { slug: slug.to_string(), rel: want }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Registry { + toml::from_str( + r#" +bind = "0.0.0.0:8080" +gitea = "http://gitea.test" +[[project]] +slug = "aura" +repo = "Brummel/aura" +branch = "main" +docs = "docs" +[[project.section]] +title = "Design" +pages = ["design/INDEX.md"] +"#, + ) + .expect("sample registry parses") + } + + #[test] + fn resolves_a_listed_page() { + let r = sample(); + assert_eq!( + r.resolve("aura", "design/INDEX"), + Some(PageRef { slug: "aura".into(), rel: "design/INDEX.md".into() }) + ); + } + + #[test] + fn refuses_an_unlisted_page() { + // glossary is not in any section -> not reachable (whitelist) + assert_eq!(sample().resolve("aura", "glossary"), None); + } + + #[test] + fn refuses_traversal() { + assert_eq!(sample().resolve("aura", "../secret"), None); + } + + #[test] + fn refuses_unknown_slug() { + assert_eq!(sample().resolve("nope", "design/INDEX"), None); + } + + #[test] + fn tolerates_unknown_keys() { + // a future key must not break parsing (forward-compatible) + let r: Result = + toml::from_str("bind=\"x\"\ngitea=\"y\"\nfuture_key=\"z\"\n"); + assert!(r.is_ok(), "unknown top-level key must be tolerated"); + } +} +``` + +- [ ] **Step 3: Run the tests to verify they pass** + +Run: `cargo test --lib config::` +Expected: PASS — 5 tests in `config::tests`. + +--- + +### Task 3: gitea — Fetcher trait, GiteaError, client + mock + +**Files:** +- Create: `src/gitea/mod.rs` +- Create: `src/gitea/client.rs` +- Test: in-module `#[cfg(test)]` in `src/gitea/mod.rs` + +- [ ] **Step 1: Write the trait, error, and mock** + +Create `src/gitea/mod.rs`: +```rust +pub mod client; +pub use client::GiteaClient; + +use std::collections::HashMap; + +/// A read-only source fetcher. Injected so tests run offline. SYNC by design +/// — the production impl uses blocking reqwest; axum handlers call it inside +/// `spawn_blocking`. +pub trait Fetcher: Send + Sync { + /// The current commit SHA of `branch` in `repo` (the cache key source). + fn branch_sha(&self, repo: &str, branch: &str) -> Result; + /// The raw bytes of `path` at `branch` in `repo`. + fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result; +} + +/// `NotFound` is the broken-reference signal — distinct from a transport +/// failure, so a renamed source and an unreachable Gitea render differently. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GiteaError { + NotFound { repo: String, path: String }, + Transport(String), +} + +/// In-memory fetcher for tests: `(repo, path)` -> body, plus a branch SHA. +pub struct MockFetcher { + pub sha: String, + pub files: HashMap<(String, String), String>, +} + +impl MockFetcher { + pub fn new(sha: &str) -> Self { + Self { sha: sha.to_string(), files: HashMap::new() } + } + pub fn with(mut self, repo: &str, path: &str, body: &str) -> Self { + self.files.insert((repo.to_string(), path.to_string()), body.to_string()); + self + } +} + +impl Fetcher for MockFetcher { + fn branch_sha(&self, _repo: &str, _branch: &str) -> Result { + Ok(self.sha.clone()) + } + fn fetch_raw(&self, repo: &str, _branch: &str, path: &str) -> Result { + self.files + .get(&(repo.to_string(), path.to_string())) + .cloned() + .ok_or_else(|| GiteaError::NotFound { repo: repo.to_string(), path: path.to_string() }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock() -> MockFetcher { + MockFetcher::new("a517899").with("Brummel/aura", "docs/glossary.md", "# Glossary\n") + } + + #[test] + fn fetch_hit_returns_body() { + assert_eq!(mock().fetch_raw("Brummel/aura", "main", "docs/glossary.md").unwrap(), "# Glossary\n"); + } + + #[test] + fn fetch_miss_is_notfound() { + let err = mock().fetch_raw("Brummel/aura", "main", "docs/gone.md").unwrap_err(); + assert!(matches!(err, GiteaError::NotFound { .. }), "miss must be NotFound, got {err:?}"); + } + + #[test] + fn branch_sha_returns_pinned() { + assert_eq!(mock().branch_sha("Brummel/aura", "main").unwrap(), "a517899"); + } +} +``` + +- [ ] **Step 2: Run mock tests** + +Run: `cargo test --lib gitea::` +Expected: PASS — 3 tests. + +- [ ] **Step 3: Write the production GiteaClient** + +Create `src/gitea/client.rs`: +```rust +//! Read-only Gitea source client (blocking reqwest). Resolves a branch SHA +//! via the API and fetches raw file bytes via the raw endpoint. Token from +//! `$DOCSITE_GITEA_TOKEN` for private repos. + +use super::{Fetcher, GiteaError}; + +pub struct GiteaClient { + base: String, + token: Option, + http: reqwest::blocking::Client, +} + +impl GiteaClient { + pub fn new(base: &str) -> Self { + Self { + base: base.trim_end_matches('/').to_string(), + token: std::env::var("DOCSITE_GITEA_TOKEN").ok(), + http: reqwest::blocking::Client::new(), + } + } + + fn get(&self, url: &str) -> Result { + let mut req = self.http.get(url); + if let Some(t) = &self.token { + req = req.header("Authorization", format!("token {t}")); + } + req.send().map_err(|e| GiteaError::Transport(e.to_string())) + } +} + +impl Fetcher for GiteaClient { + fn branch_sha(&self, repo: &str, branch: &str) -> Result { + // GET {base}/api/v1/repos/{repo}/branches/{branch} -> { "commit": { "id": "" } } + let url = format!("{}/api/v1/repos/{repo}/branches/{branch}", self.base); + let resp = self.get(&url)?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(GiteaError::NotFound { repo: repo.into(), path: format!("branch:{branch}") }); + } + let body = resp.text().map_err(|e| GiteaError::Transport(e.to_string()))?; + // minimal extraction without a json dep on the model: the API returns + // {"name":...,"commit":{"id":"",...}} + let key = "\"id\":\""; + let start = body.find(key).ok_or_else(|| GiteaError::Transport("no commit id".into()))? + key.len(); + let end = body[start..].find('"').ok_or_else(|| GiteaError::Transport("unterminated id".into()))?; + Ok(body[start..start + end].to_string()) + } + + fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result { + // GET {base}/{repo}/raw/branch/{branch}/{path} + let url = format!("{}/{repo}/raw/branch/{branch}/{path}", self.base); + let resp = self.get(&url)?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(GiteaError::NotFound { repo: repo.into(), path: path.into() }); + } + if !resp.status().is_success() { + return Err(GiteaError::Transport(format!("status {}", resp.status()))); + } + resp.text().map_err(|e| GiteaError::Transport(e.to_string())) + } +} +``` + +- [ ] **Step 4: Build gate** + +Run: `cargo build` +Expected: PASS — the client compiles (no test asserts the live endpoints; +those are exercised by the gated live smoke in Task 9). + +--- + +### Task 4: render — comrak (GFM) + syntect + +**Files:** +- Create: `src/render/mod.rs` +- Test: in-module `#[cfg(test)]` + +- [ ] **Step 1: Write the failing render test** + +Create `src/render/mod.rs`: +```rust +//! Markdown -> HTML render: comrak (GitHub-flavored, tables on) with syntect +//! highlighting of code fences (dark theme). `render_doc` is pure. A no-op +//! directive-expansion seam is reserved here (invariant 4) for `code-include` +//! in a later iteration; `code` and `table` are native Markdown. + +use comrak::plugins::syntect::SyntectAdapter; +use comrak::{markdown_to_html_with_plugins, Options, Plugins}; + +/// Render GFM markdown to an HTML fragment (no / wrapper). +pub fn render_doc(markdown: &str) -> String { + let mut options = Options::default(); + options.extension.table = true; + options.extension.strikethrough = true; + options.extension.autolink = true; + + let adapter = SyntectAdapter::new(Some("base16-ocean.dark")); + let mut plugins = Plugins::default(); + plugins.render.codefence_syntax_highlighter = Some(&adapter); + + markdown_to_html_with_plugins(markdown, &options, &plugins) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_a_table() { + let html = render_doc("| a | b |\n|---|---|\n| 1 | 2 |\n"); + assert!(html.contains(""), "GFM table not rendered: {html}"); + assert!(html.contains(""), "table cell missing: {html}"); + } + + #[test] + fn highlights_a_code_fence() { + let html = render_doc("```rust\nfn main() {}\n```\n"); + // syntect emits inline-styled spans inside a
+        assert!(html.contains(" for the fence: {html}");
+        assert!(html.contains("style=\"color"), "syntect did not style the fence: {html}");
+    }
+
+    #[test]
+    fn unknown_language_falls_back_without_panic() {
+        let html = render_doc("```nosuchlang\nplain text\n```\n");
+        assert!(html.contains(": {html}");
+    }
+}
+```
+
+- [ ] **Step 2: Run render tests**
+
+Run: `cargo test --lib render::`
+Expected: PASS — 3 tests. (If the syntect adapter constructor signature
+differs in the resolved comrak version, adjust the `SyntectAdapter::new`
+call to the resolved API; the assertion contract — `
1
`, styled `
`,
+no panic on unknown lang — is fixed.)
+
+---
+
+### Task 5: theme — shell + embedded stylesheet + page_html
+
+**Files:**
+- Create: `src/theme/mod.rs`
+- Create: `assets/theme.css`
+- Test: in-module `#[cfg(test)]`
+
+- [ ] **Step 1: Write the dark stylesheet**
+
+Create `assets/theme.css`:
+```css
+:root {
+  --bg: #16161a; --fg: #cdd6f4; --dim: #6c7086; --link: #89b4fa;
+  --panel: #1e1e2e; --border: #313244; --accent: #f5e0dc;
+}
+html, body { margin: 0; background: var(--bg); color: var(--fg);
+  font-family: system-ui, sans-serif; line-height: 1.6; }
+header.crumb { padding: 10px 16px; border-bottom: 1px solid var(--border);
+  font-size: 13px; color: var(--dim); }
+header.crumb a { color: var(--link); text-decoration: none; }
+nav { padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 14px; }
+nav .sec { color: var(--dim); margin: 8px 0 2px; text-transform: uppercase; font-size: 12px; }
+nav a { display: block; color: var(--link); text-decoration: none; padding: 1px 0; }
+main.doc { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
+main.doc code, main.doc pre { font-family: ui-monospace, monospace; }
+main.doc pre { background: var(--panel); border: 1px solid var(--border);
+  border-radius: 6px; padding: 12px; overflow-x: auto; }
+main.doc table { border-collapse: collapse; margin: 12px 0; }
+main.doc th, main.doc td { border: 1px solid var(--border); padding: 6px 10px; }
+main.doc a { color: var(--link); }
+.docsite-error { background: #3a1e26; border: 1px solid #f38ba8; color: #f38ba8;
+  border-radius: 6px; padding: 12px; margin: 12px 0; font-family: ui-monospace, monospace; }
+```
+
+- [ ] **Step 2: Write the shell + nav builder with a failing test**
+
+Create `src/theme/mod.rs`:
+```rust
+//! The themed HTML shell: header breadcrumb, nav built from the registry,
+//! and the rendered doc body. The stylesheet is embedded (served at
+//! /assets/theme.css by the server module) so the binary is self-contained.
+
+use crate::config::{Project, Registry};
+
+pub const THEME_CSS: &str = include_str!("../../assets/theme.css");
+
+/// Build the nav `
"), "table not rendered"); + assert!(body.contains(" visible error block + let mut s = state(); + let r: Registry = toml::from_str( + "bind=\"x\"\ngitea=\"y\"\n[[project]]\nslug=\"aura\"\nrepo=\"Brummel/aura\"\nbranch=\"main\"\ndocs=\"docs\"\n[[project.section]]\ntitle=\"D\"\npages=[\"gone.md\"]\n", + ).unwrap(); + s.reg = Arc::new(r); + let (status, body) = get(router(s), "/aura/gone").await; + assert_eq!(status, StatusCode::OK, "broken ref is content, not a server fault"); + assert!(body.contains("docsite-error"), "no fail-loud error block: {body}"); + assert!(body.contains("broken reference"), "error block text missing"); +} +``` + +- [ ] **Step 3: Add the test-only dev-dependency** + +Run: `cargo add --dev tower --features util` +Expected: `tower` dev-dep added (for `tower::ServiceExt::oneshot`). The +`#[tokio::test]` macro is already covered by the regular `tokio` dependency +(it carries `macros` + `rt-multi-thread`). + +- [ ] **Step 4: Run the e2e suite** + +Run: `cargo test --test e2e` +Expected: PASS — 4 tests (render, 404, traversal, fail-loud block). + +--- + +### Task 9: main wiring + gated live smoke + +**Files:** +- Modify: `src/main.rs` (replace the stub arms with the real entries) +- Test: `tests/live_smoke.rs` + +- [ ] **Step 1: Wire main.rs to the real entries** + +Replace the two stub arms in `src/main.rs`'s match with: +```rust + Some("serve") => docsite::server::run("registry.toml"), + Some("check") => docsite::check::run_cli("registry.toml"), +``` +Both modules now exist (Task 8 created `server`, Task 7 created `check`), and +both return `std::process::ExitCode`, matching `main`'s return type. + +- [ ] **Step 2: Full build + whole suite gate** + +Run: `cargo build && cargo test` +Expected: PASS — `docsite serve` / `docsite check` dispatch compiles; all lib ++ e2e tests green. + +- [ ] **Step 3: Write the gated live smoke test** + +Create `tests/live_smoke.rs`: +```rust +//! Gated smoke test against the real Gitea. Skips cleanly when Gitea is +//! unreachable (the project's skip-on-no-dependency convention) so it never +//! fails on an offline machine. + +use docsite::gitea::{Fetcher, GiteaClient}; + +#[test] +fn aura_glossary_is_fetchable_from_live_gitea() { + let client = GiteaClient::new("http://192.168.178.103:3000"); + match client.fetch_raw("Brummel/aura", "main", "docs/glossary.md") { + Ok(body) => assert!(!body.is_empty(), "live glossary should be non-empty"), + Err(e) => { + eprintln!("skip: live Gitea unreachable or auth-gated: {e:?}"); + } + } +} +``` + +- [ ] **Step 4: Run the live smoke (non-fatal)** + +Run: `cargo test --test live_smoke -- --nocapture` +Expected: PASS — either the assertion holds (Gitea reachable) or it prints +`skip:` and passes (unreachable/auth-gated). Never fails the suite. + +- [ ] **Step 5: Manual serve sanity (optional, recorded not gated)** + +Run: `DOCSITE_GITEA_TOKEN= cargo run -- serve` then +`curl -s localhost:8080/aura/design/INDEX | head -3` +Expected: the themed HTML shell for the aura design ledger. (A manual check +the orchestrator records; not part of `cargo test`.)