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:
Generated
+2430
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -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"] }
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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 ®.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(®.gitea);
|
||||
check_and_report(®, &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(®(), &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(®(), &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(®(), &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(®(), &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(®_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(®(), &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(®(), &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(®_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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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(®.gitea));
|
||||
let report = check_all_boot(®, 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)
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
#[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(®(), "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 `<`/`>`).
|
||||
#[test]
|
||||
fn html_escape_neutralises_markup_metacharacters() {
|
||||
assert_eq!(html_escape("a & b <tag> c"), "a & b <tag> c");
|
||||
}
|
||||
}
|
||||
+193
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user