feat: walking skeleton — live-render aura + data-server docs from Gitea

The first runnable DocSite server (plan docs/plans/0001-walking-skeleton.md):
`docsite serve` renders the published docs of aura and data-server live from
their Gitea main revisions, dark-themed, navigated from the DocSite-owned
whitelist registry; `docsite check` validates every nav reference against the
live sources with an exit code. Built across nine TDD tasks — config (registry
whitelist + traversal guard), gitea read-only client (Fetcher trait, injected
for offline tests), render (comrak GFM + syntect), theme (embedded dark CSS +
shell), revision-keyed cache, reference validator, axum server, main wiring.

All five domain invariants land: read-only Gitea access via the Fetcher trait;
single source of truth via the (repo,path,sha) cache; reference integrity
detected (check + a fail-loud render-time error block) never enforced;
whitelist publishing with path-traversal refusal; the closed directive
vocabulary's no-op expansion seam reserved in render.

Verification (run by the orchestrator): cargo build green; cargo test green —
40 tests (32 lib unit + 7 e2e via an injected MockFetcher + 1 gated live smoke
that reached the real Gitea this run); cargo clippy --all-targets -D warnings
clean.

Held quality findings (plan-prescribed / plan-deferred, not defects; tracked
as follow-up issues): nav_html interpolates the registry slug/page into the
href unescaped (operator-trusted registry — latent, not a live vector);
GiteaClient's 404->NotFound status mapping (the invariant-3 broken-reference
signal) is covered only by the gated live smoke's happy path, not a unit test.

Resolved API deviations from the plan text (newer crate versions): reqwest
0.13 renamed feature rustls-tls -> rustls; comrak 0.52 deprecated `Plugins`
(used `comrak::options::Plugins`); axum 0.8 capture syntax `/{slug}/{*page}`.

refs #1
This commit is contained in:
2026-06-28 16:43:20 +02:00
parent 27e09a395b
commit cb82987c33
16 changed files with 3673 additions and 4 deletions
Generated
+2430
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -5,5 +5,13 @@ edition = "2021"
description = "Single-source-of-truth LAN documentation server"
[dependencies]
# Dependencies are added as the walking-skeleton implementation lands
# (HTTP stack, comrak, syntect, toml/serde) via the docs/specs/0001 cycle.
axum = "0.8.9"
comrak = "0.52.0"
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] }
serde = { version = "1.0.228", features = ["derive"] }
syntect = "5.3.0"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net"] }
toml = "1.1.2"
[dev-dependencies]
tower = { version = "0.5.3", features = ["util"] }
+21
View File
@@ -0,0 +1,21 @@
: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; }
+57
View File
@@ -0,0 +1,57 @@
//! Revision-keyed render cache: (repo, path, sha) -> rendered HTML. A new
//! sha is a new key, so a changed source re-renders; an unchanged sha serves
//! byte-identical output (invariant 2). Thread-safe for the axum handlers.
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
pub struct Cache {
map: Mutex<HashMap<(String, String, String), String>>,
}
impl Cache {
pub fn new() -> Self {
Self::default()
}
/// Return the cached render, or compute it with `f`, store, and return it.
pub fn get_or_render<F: FnOnce() -> String>(
&self, repo: &str, path: &str, sha: &str, f: F,
) -> String {
let key = (repo.to_string(), path.to_string(), sha.to_string());
if let Some(hit) = self.map.lock().unwrap().get(&key) {
return hit.clone();
}
let html = f();
self.map.lock().unwrap().insert(key, html.clone());
html
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
#[test]
fn same_sha_renders_once_and_is_byte_identical() {
let cache = Cache::new();
let calls = Cell::new(0);
let render = || { calls.set(calls.get() + 1); "<p>x</p>".to_string() };
let a = cache.get_or_render("r", "p", "sha1", render);
let b = cache.get_or_render("r", "p", "sha1", render);
assert_eq!(a, b, "same sha must be byte-identical");
assert_eq!(calls.get(), 1, "same sha must render exactly once");
}
#[test]
fn changed_sha_re_renders() {
let cache = Cache::new();
let calls = Cell::new(0);
let render = || { calls.set(calls.get() + 1); "x".to_string() };
cache.get_or_render("r", "p", "sha1", render);
cache.get_or_render("r", "p", "sha2", render);
assert_eq!(calls.get(), 2, "a new sha must re-render");
}
}
+252
View File
@@ -0,0 +1,252 @@
//! Reference-integrity validator (invariant 3): for every nav page in the
//! registry, confirm the source exists at the project's branch. Drives
//! `docsite check` (exit-coded) and the boot-time validation pass. Detects
//! and reports; never writes to a source.
use crate::config::Registry;
use crate::gitea::{Fetcher, GiteaError};
use std::process::ExitCode;
/// Width of the `slug/path` column in the `docsite check` report. The whole
/// `slug/path` entry is left-aligned (right-padded) to this width so the detail
/// column lines up even across slugs of differing length.
const PATH_COL: usize = 28;
pub struct RefResult {
pub slug: String,
pub path: String,
pub ok: bool,
pub detail: String,
}
pub struct CheckReport {
pub results: Vec<RefResult>,
}
impl CheckReport {
pub fn broken(&self) -> usize {
self.results.iter().filter(|r| !r.ok).count()
}
}
/// Validate every nav reference in `reg` against `fetcher`.
pub fn check_all(reg: &Registry, fetcher: &dyn Fetcher) -> CheckReport {
let mut results = Vec::new();
for proj in &reg.project {
let sha = fetcher.branch_sha(&proj.repo, &proj.branch);
for page in proj.section.iter().flat_map(|s| &s.pages) {
let (ok, detail) = match &sha {
Err(GiteaError::NotFound { .. }) => {
(false, format!("branch error: {} {} not found", proj.repo, proj.branch))
}
Err(GiteaError::Transport(t)) => {
(false, format!("branch error: {} {} unreachable ({t})", proj.repo, proj.branch))
}
Ok(sha) => {
// the source path is needed only once the branch resolves
let full = proj.source_path(page);
match fetcher.fetch_raw(&proj.repo, &proj.branch, &full) {
Ok(_) => (true, format!("ok ({} @ {})", proj.branch, &sha[..sha.len().min(7)])),
Err(GiteaError::NotFound { .. }) => {
(false, format!("BROKEN — not found at {} {}:{}", proj.repo, proj.branch, full))
}
Err(GiteaError::Transport(t)) => (false, format!("transport error: {t}")),
}
}
};
results.push(RefResult { slug: proj.slug.clone(), path: page.clone(), ok, detail });
}
}
CheckReport { results }
}
/// `docsite check`: load the registry, validate against live Gitea, print a
/// report, exit 0 iff nothing is broken.
pub fn run_cli(registry_path: &str) -> ExitCode {
ExitCode::from(run_cli_code(registry_path))
}
/// The exit-coded body of `docsite check` (invariant 3, exit-coded half),
/// returning the process exit code as a `u8` so the 2/1/0 mapping is testable
/// (`ExitCode` is opaque and not comparable): 2 = registry could not load,
/// 1 = at least one broken reference, 0 = every reference ok. The config-error
/// arm returns before a fetcher is built, keeping that path offline.
fn run_cli_code(registry_path: &str) -> u8 {
let reg = match crate::config::load(registry_path) {
Ok(r) => r,
Err(e) => { eprintln!("docsite: {e}"); return 2; }
};
let fetcher = crate::gitea::GiteaClient::new(&reg.gitea);
check_and_report(&reg, &fetcher)
}
/// Validate every reference in `reg` against `fetcher`, print the report, and
/// return the broken/ok exit code (1 if any reference is broken, else 0). The
/// injected `fetcher` is the seam that lets the exit mapping be driven offline.
fn check_and_report(reg: &Registry, fetcher: &dyn Fetcher) -> u8 {
let report = check_all(reg, fetcher);
for r in &report.results {
let entry = format!("{}/{}", r.slug, r.path);
println!("{entry:<width$} {}", r.detail, width = PATH_COL);
}
let broken = report.broken();
println!("{} references ok, {} broken", report.results.len() - broken, broken);
if broken == 0 { 0 } else { 1 }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gitea::MockFetcher;
fn reg_for(branch: &str, docs: &str) -> Registry {
toml::from_str(&format!(
r#"
bind="x"
gitea="y"
[[project]]
slug="aura"
repo="Brummel/aura"
branch="{branch}"
docs="{docs}"
[[project.section]]
title="Design"
pages=["design/INDEX.md","glossary.md"]
"#,
)).unwrap()
}
fn reg() -> Registry {
reg_for("main", "docs")
}
/// A `Fetcher` whose branch lookup and raw fetch each return a fixed
/// `Result`. Reaches the transport / branch-error arms `MockFetcher`
/// (which only yields Ok/NotFound) cannot exercise.
struct FailFetcher {
branch: Result<String, GiteaError>,
fetch: Result<String, GiteaError>,
}
impl Fetcher for FailFetcher {
fn branch_sha(&self, _repo: &str, _branch: &str) -> Result<String, GiteaError> {
self.branch.clone()
}
fn fetch_raw(&self, _repo: &str, _branch: &str, _path: &str) -> Result<String, GiteaError> {
self.fetch.clone()
}
}
#[test]
fn all_ok_reports_zero_broken() {
let f = MockFetcher::new("a517899")
.with("Brummel/aura", "docs/design/INDEX.md", "x")
.with("Brummel/aura", "docs/glossary.md", "y");
assert_eq!(check_all(&reg(), &f).broken(), 0);
}
#[test]
fn missing_source_is_broken() {
// only one of the two pages exists -> one broken
let f = MockFetcher::new("a517899").with("Brummel/aura", "docs/design/INDEX.md", "x");
let report = check_all(&reg(), &f);
assert_eq!(report.broken(), 1, "the missing glossary must be broken");
assert!(report.results.iter().any(|r| !r.ok && r.detail.contains("BROKEN")));
}
/// Invariant 3: an unreachable Gitea is reported as broken but kept
/// distinct from a missing/renamed source — a transport failure must NOT
/// masquerade as a "BROKEN — not found" reference.
#[test]
fn transport_failure_is_broken_but_not_reported_as_missing() {
let f = FailFetcher {
branch: Ok("a517899".into()),
fetch: Err(GiteaError::Transport("connection refused".into())),
};
let report = check_all(&reg(), &f);
assert_eq!(report.broken(), 2, "both pages broken when Gitea is unreachable");
assert!(
report.results.iter().all(|r| !r.ok && r.detail.contains("transport")),
"transport failures must be labelled as transport errors"
);
assert!(
report.results.iter().all(|r| !r.detail.contains("BROKEN")),
"an unreachable Gitea must not be reported as a missing/renamed source"
);
}
/// A branch lookup failure marks every page of that project broken and is
/// surfaced as a branch error, not a per-page not-found.
#[test]
fn branch_lookup_failure_marks_all_pages_broken() {
let f = FailFetcher {
branch: Err(GiteaError::Transport("connection refused".into())),
fetch: Ok("ignored".into()),
};
let report = check_all(&reg(), &f);
assert_eq!(report.broken(), 2, "all pages broken when the branch can't be resolved");
assert!(
report.results.iter().all(|r| !r.ok && r.detail.contains("branch error")),
"a failed branch lookup must be labelled as a branch error"
);
}
/// `docs = "."` (repo-root docs) builds the bare page path with no docs
/// prefix — the source must be looked up at exactly the page path.
#[test]
fn repo_root_docs_uses_bare_page_path() {
let f = MockFetcher::new("a517899")
.with("Brummel/aura", "design/INDEX.md", "x")
.with("Brummel/aura", "glossary.md", "y");
assert_eq!(check_all(&reg_for("main", "."), &f).broken(), 0);
}
/// Invariant 3 (exit-coded half): `docsite check` returns exit 0 when every
/// reference resolves — the success contract an operator / CI gate relies on.
#[test]
fn check_exit_zero_when_all_references_ok() {
let f = MockFetcher::new("a517899")
.with("Brummel/aura", "docs/design/INDEX.md", "x")
.with("Brummel/aura", "docs/glossary.md", "y");
assert_eq!(check_and_report(&reg(), &f), 0);
}
/// Invariant 3 (exit-coded half): a single broken reference makes
/// `docsite check` return exit 1 — the broken-reference gate, kept distinct
/// from the all-ok 0.
#[test]
fn check_exit_one_when_a_reference_is_broken() {
let f = MockFetcher::new("a517899").with("Brummel/aura", "docs/design/INDEX.md", "x");
assert_eq!(check_and_report(&reg(), &f), 1);
}
/// Invariant 3 (exit-coded half): an unloadable registry makes `docsite
/// check` return exit 2 (config error), distinct from a broken-reference 1
/// — so an operator can tell a bad config from a broken source. The arm
/// returns before any fetcher is built, so this stays offline.
#[test]
fn check_exit_two_when_config_cannot_load() {
let absent = std::env::temp_dir()
.join(format!("docsite-check-absent-{}.toml", std::process::id()));
let _ = std::fs::remove_file(&absent);
assert_eq!(run_cli_code(absent.to_str().expect("temp path is utf8")), 2);
}
/// The ok-detail names the project's actual branch, not a hardcoded
/// "main", so a project tracking another branch is reported correctly.
#[test]
fn ok_detail_names_the_projects_actual_branch() {
let f = MockFetcher::new("a517899")
.with("Brummel/aura", "docs/design/INDEX.md", "x")
.with("Brummel/aura", "docs/glossary.md", "y");
let report = check_all(&reg_for("dev", "docs"), &f);
assert!(
report.results.iter().all(|r| r.ok && r.detail.contains("dev")),
"ok detail must name the project's actual branch"
);
assert!(
report.results.iter().all(|r| !r.detail.contains("main")),
"ok detail must not hardcode 'main'"
);
}
}
+209
View File
@@ -0,0 +1,209 @@
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<Path>) -> Result<Registry, String> {
let path = path.as_ref();
let text = std::fs::read_to_string(path)
.map_err(|e| format!("registry {}: {e}", path.display()))?;
toml::from_str(&text).map_err(|e| format!("registry {} parse: {e}", path.display()))
}
impl Registry {
pub fn project(&self, slug: &str) -> Option<&Project> {
self.project.iter().find(|p| p.slug == slug)
}
/// Resolve a served url path (`<slug>/<page-no-ext>`) 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<PageRef> {
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 })
}
}
impl Project {
/// Join this project's `docs` root with a `page` path (relative to that
/// root) into the source path fetched from Gitea. `docs = "."` (repo-root
/// docs) yields the bare page path; otherwise the docs root, with a trailing
/// slash trimmed, is prefixed. This is the single source for the join so
/// `docsite check` validates exactly the path the server fetches
/// (invariant 3).
pub fn source_path(&self, page: &str) -> String {
if self.docs == "." {
page.to_string()
} else {
format!("{}/{page}", self.docs.trim_end_matches('/'))
}
}
}
#[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);
}
/// `source_path` is the single source for the docs-root↔source-path join
/// that decides exactly what gets fetched (invariant 3: `docsite check` and
/// the server must agree on the path). `docs = "docs"` prefixes the root;
/// `docs = "."` yields the bare page path; a trailing slash on the root is
/// trimmed so no double slash leaks into the fetched path.
#[test]
fn source_path_joins_docs_root_to_page() {
fn proj(docs: &str) -> Project {
Project {
slug: "aura".into(),
repo: "Brummel/aura".into(),
branch: "main".into(),
docs: docs.into(),
section: vec![],
}
}
assert_eq!(proj("docs").source_path("design/INDEX.md"), "docs/design/INDEX.md");
assert_eq!(proj(".").source_path("design/INDEX.md"), "design/INDEX.md");
assert_eq!(proj("docs/").source_path("design/INDEX.md"), "docs/design/INDEX.md");
}
#[test]
fn tolerates_unknown_keys() {
// a future key must not break parsing (forward-compatible)
let r: Result<Registry, _> =
toml::from_str("bind=\"x\"\ngitea=\"y\"\nfuture_key=\"z\"\n");
assert!(r.is_ok(), "unknown top-level key must be tolerated");
}
/// A unique temp file path for a single test, removed when dropped, so the
/// `load` disk tests don't collide or leave residue.
struct TempFile(std::path::PathBuf);
impl TempFile {
fn with(tag: &str, contents: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"docsite-registry-{}-{}.toml",
std::process::id(),
tag
));
std::fs::write(&path, contents).expect("write temp registry");
TempFile(path)
}
}
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
/// `load` reads the registry off disk and parses it, so a well-formed file
/// on disk round-trips to a usable `Registry` (not just an in-memory string).
#[test]
fn load_reads_a_file_from_disk() {
let tmp = TempFile::with(
"ok",
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"]
"#,
);
let r = load(&tmp.0).expect("well-formed registry loads from disk");
assert_eq!(r.bind, "0.0.0.0:8080");
assert_eq!(
r.resolve("aura", "design/INDEX"),
Some(PageRef { slug: "aura".into(), rel: "design/INDEX.md".into() })
);
}
/// `load` surfaces the read failure (not a parse failure) when the file is
/// absent, and names the offending path so the operator knows which file.
#[test]
fn load_reports_a_missing_file_with_its_path() {
let path = std::env::temp_dir().join(format!(
"docsite-registry-{}-absent.toml",
std::process::id()
));
let _ = std::fs::remove_file(&path);
let err = load(&path).expect_err("a missing file must be an error");
assert!(err.starts_with("registry "), "read error, got: {err}");
assert!(!err.contains("parse"), "must be the read arm, got: {err}");
assert!(
err.contains(&path.display().to_string()),
"error must name the path, got: {err}"
);
}
/// `load` surfaces a parse failure (distinct from the read failure) when the
/// file exists but is malformed, and names the offending path.
#[test]
fn load_reports_a_parse_error_with_its_path() {
let tmp = TempFile::with("bad", "bind = \"x\"\nthis is not toml\n");
let err = load(&tmp.0).expect_err("malformed toml must be an error");
assert!(err.contains("parse"), "parse error, got: {err}");
assert!(
err.contains(&tmp.0.display().to_string()),
"error must name the path, got: {err}"
);
}
}
+37
View File
@@ -0,0 +1,37 @@
//! 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<Project>,
}
#[derive(Debug, Deserialize)]
pub struct Project {
pub slug: String,
pub repo: String,
pub branch: String,
pub docs: String,
#[serde(default)]
pub section: Vec<Section>,
}
#[derive(Debug, Deserialize)]
pub struct Section {
pub title: String,
pub pages: Vec<String>,
}
/// 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,
}
+98
View File
@@ -0,0 +1,98 @@
//! 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};
/// Extract the commit SHA from a Gitea branch API body without a JSON dep on
/// the model. The API returns `{"name":...,"commit":{"id":"<sha>",...}}`; we
/// pull the first `"id":"..."` value. A missing or unterminated key is a
/// malformed response (`Transport`), never a silent empty SHA.
fn extract_commit_id(body: &str) -> Result<String, GiteaError> {
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())
}
pub struct GiteaClient {
base: String,
token: Option<String>,
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<reqwest::blocking::Response, GiteaError> {
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<String, GiteaError> {
// GET {base}/api/v1/repos/{repo}/branches/{branch} -> { "commit": { "id": "<sha>" } }
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}") });
}
if !resp.status().is_success() {
return Err(GiteaError::Transport(format!("status {}", resp.status())));
}
let body = resp.text().map_err(|e| GiteaError::Transport(e.to_string()))?;
extract_commit_id(&body)
}
fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result<String, GiteaError> {
// 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()))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// extract_commit_id pulls the branch HEAD SHA out of a well-formed Gitea
/// branch API body — the parse that branch_sha relies on, isolated from HTTP.
#[test]
fn extract_commit_id_pulls_sha_from_branch_body() {
let body = r#"{"name":"main","commit":{"id":"a517899","message":"x"}}"#;
assert_eq!(extract_commit_id(body).unwrap(), "a517899");
}
/// A body with no `"id":"` key is malformed transport, not a NotFound — so
/// branch_sha cannot return a bogus empty SHA when the API shape changes.
#[test]
fn extract_commit_id_missing_key_is_transport() {
let err = extract_commit_id(r#"{"name":"main","commit":{}}"#).unwrap_err();
assert!(matches!(err, GiteaError::Transport(_)), "missing id must be Transport, got {err:?}");
}
/// An unterminated id value (no closing quote) is malformed transport, never
/// a slice that runs off the end of the body.
#[test]
fn extract_commit_id_unterminated_is_transport() {
let err = extract_commit_id("{\"id\":\"a517899").unwrap_err();
assert!(matches!(err, GiteaError::Transport(_)), "unterminated id must be Transport, got {err:?}");
}
}
+75
View File
@@ -0,0 +1,75 @@
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<String, GiteaError>;
/// The raw bytes of `path` at `branch` in `repo`.
fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result<String, GiteaError>;
}
/// `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<String, GiteaError> {
Ok(self.sha.clone())
}
fn fetch_raw(&self, repo: &str, _branch: &str, path: &str) -> Result<String, GiteaError> {
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");
}
}
+13
View File
@@ -0,0 +1,13 @@
//! 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.
pub mod cache;
pub mod check;
pub mod config;
pub mod gitea;
pub mod render;
pub mod server;
pub mod theme;
+12 -2
View File
@@ -6,6 +6,16 @@
//! data-server docs, themed, navigated from the whitelist registry) lands
//! through the first cycle under `docs/specs/`.
fn main() {
eprintln!("docsite: not yet implemented");
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("serve") => docsite::server::run("registry.toml"),
Some("check") => docsite::check::run_cli("registry.toml"),
_ => {
eprintln!("usage: docsite <serve|check>");
ExitCode::from(2)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
//! 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::options::Plugins;
use comrak::plugins::syntect::SyntectAdapter;
use comrak::{markdown_to_html_with_plugins, Options};
/// Render GFM markdown to an HTML fragment (no <html>/<body> 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("<table>"), "GFM table not rendered: {html}");
assert!(html.contains("<td>1</td>"), "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 <pre>
assert!(html.contains("<pre"), "no <pre> 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("<pre"), "unknown-language fence still needs a <pre>: {html}");
}
}
+115
View File
@@ -0,0 +1,115 @@
//! axum router. Routes: `GET /` (project index), `GET /{slug}/{*page}`
//! (rendered doc), `GET /assets/theme.css`. The fetcher and registry live in
//! shared state; the page handler enforces the whitelist (config::resolve)
//! and renders a visible error block on a NotFound (fail-loud, invariant 3).
use crate::cache::Cache;
use crate::config::Registry;
use crate::gitea::{Fetcher, GiteaError};
use axum::{
extract::{Path, State},
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub reg: Arc<Registry>,
pub fetcher: Arc<dyn Fetcher>,
pub cache: Arc<Cache>,
}
/// Build the router for a given state — the test seam (no network, no bind).
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(index))
.route("/assets/theme.css", get(theme_css))
.route("/{slug}/{*page}", get(page))
.with_state(state)
}
async fn theme_css() -> impl IntoResponse {
([(axum::http::header::CONTENT_TYPE, "text/css")], crate::theme::THEME_CSS)
}
async fn index(State(st): State<AppState>) -> Html<String> {
let mut s = String::from("<!doctype html><link rel=\"stylesheet\" href=\"/assets/theme.css\">\
<header class=\"crumb\">docsite</header><main class=\"doc\"><ul>");
for p in &st.reg.project {
s.push_str(&format!("<li><a href=\"/{0}/\">{0}</a></li>", p.slug));
}
s.push_str("</ul></main>");
Html(s)
}
async fn page(State(st): State<AppState>, Path((slug, page)): Path<(String, String)>) -> Response {
let url_page = page.trim_end_matches('/');
let Some(pref) = st.reg.resolve(&slug, url_page) else {
return (StatusCode::NOT_FOUND, "404 — not a published page").into_response();
};
let proj = st.reg.project(&pref.slug).expect("resolved slug exists");
let full = proj.source_path(&pref.rel);
let title = url_page.rsplit('/').next().unwrap_or(url_page).to_string();
// fetch SHA + raw inside spawn_blocking (the Fetcher is sync)
let f = st.fetcher.clone();
let (repo, branch) = (proj.repo.clone(), proj.branch.clone());
let full2 = full.clone();
let fetched = tokio::task::spawn_blocking(move || {
let sha = f.branch_sha(&repo, &branch)?;
let body = f.fetch_raw(&repo, &branch, &full2)?;
Ok::<_, GiteaError>((sha, body))
})
.await
.expect("join");
match fetched {
Ok((sha, md)) => {
let html = st.cache.get_or_render(&proj.repo, &full, &sha, || {
let body = crate::render::render_doc(&md);
crate::theme::page_html(&st.reg, &pref.slug, &title, &body)
});
Html(html).into_response()
}
Err(GiteaError::NotFound { repo, path }) => {
// fail-loud: a visible error block, 200 (the broken ref is content)
let block = format!(
"<div class=\"docsite-error\">broken reference: {repo} {}:{path} not found</div>",
proj.branch
);
let html = crate::theme::page_html(&st.reg, &pref.slug, &title, &block);
Html(html).into_response()
}
Err(GiteaError::Transport(t)) => {
(StatusCode::BAD_GATEWAY, format!("502 — source unreachable: {t}")).into_response()
}
}
}
/// Bind and serve (the production entry from `main`).
pub fn run(registry_path: &str) -> std::process::ExitCode {
let reg = match crate::config::load(registry_path) {
Ok(r) => r,
Err(e) => { eprintln!("docsite: {e}"); return std::process::ExitCode::from(2); }
};
let fetcher = Arc::new(crate::gitea::GiteaClient::new(&reg.gitea));
let report = check_all_boot(&reg, fetcher.as_ref());
eprintln!("docsite: serving {} projects on {}", reg.project.len(), reg.bind);
eprintln!("docsite: {} nav references, {} broken at boot", report.results.len(), report.broken());
let bind = reg.bind.clone();
let state = AppState { reg: Arc::new(reg), fetcher, cache: Arc::new(Cache::new()) };
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
rt.block_on(async move {
let listener = tokio::net::TcpListener::bind(&bind).await.expect("bind");
axum::serve(listener, router(state)).await.expect("serve");
});
std::process::ExitCode::SUCCESS
}
fn check_all_boot(reg: &Registry, f: &dyn Fetcher) -> crate::check::CheckReport {
crate::check::check_all(reg, f)
}
+87
View File
@@ -0,0 +1,87 @@
//! 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 `<nav>` block for a project from its registry sections.
fn nav_html(proj: &Project) -> String {
let mut s = String::from("<nav>");
for sec in &proj.section {
s.push_str(&format!("<div class=\"sec\">{}</div>", html_escape(&sec.title)));
for page in &sec.pages {
let url = page.strip_suffix(".md").unwrap_or(page);
s.push_str(&format!(
"<a href=\"/{}/{}\">{}</a>",
proj.slug, url, html_escape(url)
));
}
}
s.push_str("</nav>");
s
}
/// Wrap a rendered doc body in the full themed page for `slug`/`title`.
pub fn page_html(reg: &Registry, slug: &str, title: &str, body: &str) -> String {
let nav = reg.project(slug).map(nav_html).unwrap_or_default();
format!(
"<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\">\
<link rel=\"stylesheet\" href=\"/assets/theme.css\"><title>{t}</title></head>\n<body>\n\
<header class=\"crumb\"><a href=\"/\">docsite</a> / <a href=\"/{s}/\">{s}</a> / {t}</header>\n\
{nav}\n<main class=\"doc\">{body}</main>\n</body>\n</html>\n",
t = html_escape(title), s = html_escape(slug),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::*;
fn reg() -> Registry {
toml::from_str(
r#"
bind="x"
gitea="y"
[[project]]
slug="aura"
repo="Brummel/aura"
branch="main"
docs="docs"
[[project.section]]
title="Design"
pages=["design/INDEX.md"]
"#,
).unwrap()
}
#[test]
fn page_has_shell_nav_and_body() {
let html = page_html(&reg(), "aura", "Design Ledger", "<p>hi</p>");
assert!(html.starts_with("<!doctype html>"), "no doctype");
assert!(html.contains("href=\"/assets/theme.css\""), "stylesheet link missing");
assert!(html.contains("class=\"crumb\""), "breadcrumb missing");
assert!(html.contains("<a href=\"/aura/design/INDEX\">"), "nav link from registry missing");
assert!(html.contains("<main class=\"doc\"><p>hi</p></main>"), "body not wrapped");
}
#[test]
fn theme_css_is_embedded() {
assert!(THEME_CSS.contains("--bg: #16161a"), "embedded css missing");
}
/// `html_escape` neutralises the three HTML metacharacters `&`, `<`, `>`
/// so registry-sourced text (titles, slugs, page paths) cannot inject
/// markup into the shell. `&` is escaped first, so the test input mixes
/// all three to also pin the ordering (escaping `&` last would
/// double-escape `&lt;`/`&gt;`).
#[test]
fn html_escape_neutralises_markup_metacharacters() {
assert_eq!(html_escape("a & b <tag> c"), "a &amp; b &lt;tag&gt; c");
}
}
+193
View File
@@ -0,0 +1,193 @@
//! End-to-end: drive the router with a MockFetcher (offline). Asserts the
//! themed render, the whitelist 404, the traversal refusal, and the
//! fail-loud broken-reference block.
use axum::body::to_bytes;
use axum::http::{Request, StatusCode};
use docsite::cache::Cache;
use docsite::config::Registry;
use docsite::gitea::{Fetcher, GiteaError, MockFetcher};
use docsite::server::{router, AppState};
use std::sync::Arc;
use tower::ServiceExt; // oneshot
fn reg() -> Registry {
toml::from_str(
r#"
bind="0.0.0.0:0"
gitea="http://gitea.test"
[[project]]
slug="aura"
repo="Brummel/aura"
branch="main"
docs="docs"
[[project.section]]
title="Design"
pages=["design/INDEX.md"]
"#,
).unwrap()
}
fn state() -> AppState {
let fetcher = MockFetcher::new("a517899").with(
"Brummel/aura", "docs/design/INDEX.md",
"# Ledger\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n```rust\nfn x() {}\n```\n",
);
AppState {
reg: Arc::new(reg()),
fetcher: Arc::new(fetcher),
cache: Arc::new(Cache::new()),
}
}
async fn get(app: axum::Router, uri: &str) -> (StatusCode, String) {
let resp = app.oneshot(Request::builder().uri(uri).body(axum::body::Body::empty()).unwrap())
.await.unwrap();
let status = resp.status();
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&bytes).to_string())
}
#[tokio::test]
async fn renders_listed_page_with_theme_and_nav() {
let (status, body) = get(router(state()), "/aura/design/INDEX").await;
assert_eq!(status, StatusCode::OK);
assert!(body.starts_with("<!doctype html>"), "no themed shell");
assert!(body.contains("href=\"/assets/theme.css\""), "no stylesheet");
assert!(body.contains("<a href=\"/aura/design/INDEX\">"), "no nav from registry");
assert!(body.contains("<table>"), "table not rendered");
assert!(body.contains("<pre"), "code fence not rendered");
}
#[tokio::test]
async fn unlisted_page_is_404() {
// glossary is not whitelisted in any section
let (status, _) = get(router(state()), "/aura/glossary").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn traversal_is_refused() {
let (status, _) = get(router(state()), "/aura/../secret").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn broken_reference_shows_failloud_block() {
// a registry listing a page the fetcher does not have -> 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");
}
/// A Fetcher that is always unreachable — every call is a transport error.
/// Lets the E2E layer reach the 502 arm that MockFetcher (which only yields
/// Ok / NotFound) cannot exercise.
struct UnreachableFetcher;
impl Fetcher for UnreachableFetcher {
fn branch_sha(&self, _repo: &str, _branch: &str) -> Result<String, GiteaError> {
Err(GiteaError::Transport("connection refused".into()))
}
fn fetch_raw(&self, _repo: &str, _branch: &str, _path: &str) -> Result<String, GiteaError> {
Err(GiteaError::Transport("connection refused".into()))
}
}
/// Invariant 3's broken-ref-vs-unreachable distinction, at the HTTP layer: an
/// *unreachable* Gitea is a server-side fault (502 Bad Gateway), NOT the 200
/// fail-loud content block that a renamed/missing source produces. Collapsing
/// the two — e.g. rendering a 200 error block on a transport failure — would let
/// a transient outage masquerade as a permanent broken reference. The page is
/// whitelisted so `resolve` passes and the transport arm is what's exercised.
/// This is the twin of `broken_reference_shows_failloud_block` (the 200 arm).
#[tokio::test]
async fn transport_failure_is_502_not_failloud_block() {
let st = AppState {
reg: Arc::new(reg()),
fetcher: Arc::new(UnreachableFetcher),
cache: Arc::new(Cache::new()),
};
let (status, body) = get(router(st), "/aura/design/INDEX").await;
assert_eq!(status, StatusCode::BAD_GATEWAY, "unreachable Gitea must be 502, not 200 content");
assert!(
!body.contains("docsite-error"),
"a transport failure must not render the fail-loud broken-ref block: {body}"
);
}
/// The themed page links its stylesheet at `/assets/theme.css`; this pins that
/// the route actually *serves* the embedded stylesheet — 200, a `text/css`
/// content type, and the real CSS body — so the self-contained binary's styling
/// cannot silently 404. `renders_listed_page_with_theme_and_nav` only asserts the
/// href string is present in the shell, never that the route resolves.
#[tokio::test]
async fn theme_css_route_serves_embedded_stylesheet() {
let resp = router(state())
.oneshot(
Request::builder()
.uri("/assets/theme.css")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(ctype, "text/css", "stylesheet must be served as text/css");
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains("--bg: #16161a"), "served body must be the embedded stylesheet");
}
/// Whitelist publishing (invariant 5) at the index level: the root index
/// enumerates exactly the registry's projects as navigable links — a project the
/// registry does not list never appears, and each listed project links to its own
/// slug root. Two projects pin that the list is built from the registry (not
/// hardcoded to one), and the `<li>` count pins *exactly* those, no more.
#[tokio::test]
async fn index_lists_exactly_the_registry_projects_as_links() {
let r: Registry = toml::from_str(
r#"
bind="x"
gitea="y"
[[project]]
slug="aura"
repo="Brummel/aura"
branch="main"
docs="docs"
[[project]]
slug="data-server"
repo="Brummel/data-server"
branch="main"
docs="docs"
"#,
).unwrap();
let st = AppState {
reg: Arc::new(r),
fetcher: Arc::new(MockFetcher::new("a517899")),
cache: Arc::new(Cache::new()),
};
let (status, body) = get(router(st), "/").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<a href=\"/aura/\">aura</a>"), "aura missing from index: {body}");
assert!(
body.contains("<a href=\"/data-server/\">data-server</a>"),
"data-server missing from index: {body}"
);
assert_eq!(
body.matches("<li>").count(),
2,
"index must list exactly the registry's projects, no more: {body}"
);
}
+16
View File
@@ -0,0 +1,16 @@
//! 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:?}");
}
}
}