# 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`.)