remove scrap
This commit is contained in:
parent
44e32da9d2
commit
bbd4673005
278 changed files with 35 additions and 14188 deletions
55
.gitignore
vendored
55
.gitignore
vendored
|
|
@ -1,15 +1,21 @@
|
|||
# Cargo build artifacts
|
||||
/target
|
||||
/reference/crates/target
|
||||
/.dev/reference/crates/target
|
||||
|
||||
# C++ prototype build output
|
||||
/prototypes/*/build
|
||||
|
||||
# C runtime prototype (prototypes/wo-rt-c): compiled binary + WAL/snapshot data.
|
||||
# OCaml/dune build artifacts (compiler/, the woc compiler front)
|
||||
/compiler/_build
|
||||
|
||||
# C runtime (runtime/): compiled binary + WAL/snapshot data.
|
||||
# `just rt-c-demo` and the default WO_DATA write ./wo-data at the repo root.
|
||||
/prototypes/wo-rt-c/wo-rt
|
||||
/runtime/wo-rt
|
||||
/wo-data
|
||||
/prototypes/wo-rt-c/wo-data
|
||||
/runtime/wo-data
|
||||
# wovm VM core: binary + test build dir (ASan test binaries, smoke fixtures)
|
||||
/runtime/wovm
|
||||
/runtime/build/
|
||||
|
||||
# Runtime data directories for the sample projects.
|
||||
# `wo.toml` points at `./data` which holds the per-project engine state.
|
||||
|
|
@ -19,23 +25,33 @@
|
|||
# Legacy blog content and data (v1 writeonce storage)
|
||||
/content
|
||||
|
||||
# Symlinks to research source trees — user-specific absolute paths.
|
||||
# Each contributor sets their own via:
|
||||
# ln -s <path-to-linux-src> reference/linux
|
||||
# ln -s <path-to-go-src> reference/go
|
||||
# ln -s <path-to-postgresql-src> reference/postgresql
|
||||
# ln -s <path-to-mcp-python-sdk-src> reference/mcp-python-sdk
|
||||
# (clone: https://github.com/modelcontextprotocol/python-sdk.git)
|
||||
/reference/linux
|
||||
/reference/go
|
||||
/reference/postgresql
|
||||
/reference/mcp-python-sdk
|
||||
|
||||
# Developer-local links into agent-tooling state (Claude Code project data,
|
||||
# opencode config/data, llama.cpp cache) — user-specific absolute paths.
|
||||
# Only .dev/README.md is committed; recreate the links per machine as it describes.
|
||||
# Committed inside .dev: README.md and reference/ (v1 archive + rest smoke files);
|
||||
# recreate the per-machine links as .dev/README.md describes.
|
||||
/.dev/*
|
||||
!/.dev/README.md
|
||||
!/.dev/reference/
|
||||
|
||||
# Symlinks to research source trees inside .dev/reference — user-specific
|
||||
# absolute paths. Each contributor sets their own via:
|
||||
# ln -s <path-to-linux-src> .dev/reference/linux
|
||||
# ln -s <path-to-go-src> .dev/reference/go
|
||||
# ln -s <path-to-postgresql-src> .dev/reference/postgresql
|
||||
# ln -s <path-to-mcp-python-sdk-src> .dev/reference/mcp-python-sdk
|
||||
# (clone: https://github.com/modelcontextprotocol/python-sdk.git)
|
||||
# ln -s <path-to-colibri> .dev/reference/colibri
|
||||
# ln -s <path-to-llama.cpp> .dev/reference/llama-cpp
|
||||
/.dev/reference/linux
|
||||
/.dev/reference/go
|
||||
/.dev/reference/postgresql
|
||||
/.dev/reference/mcp-python-sdk
|
||||
/.dev/reference/colibri
|
||||
/.dev/reference/llama-cpp
|
||||
/.dev/reference/*
|
||||
|
||||
# Agent-orchestration scratch (SDD ledgers, briefs, review packages)
|
||||
/.superpowers/
|
||||
|
||||
# Editor / OS noise — left broad on purpose so a contributor doesn't
|
||||
# accidentally commit their IDE scratch or macOS metadata.
|
||||
|
|
@ -47,7 +63,6 @@
|
|||
!/.vscode/settings.json.example
|
||||
!/.vscode/extensions.json
|
||||
|
||||
/prototypes/wo-db
|
||||
# Phase-F bench binaries (sources are committed; builds are not)
|
||||
/prototypes/wo-rt-c/bench/bench
|
||||
/prototypes/wo-rt-c/bench/goref/goref
|
||||
/runtime/bench/bench
|
||||
/runtime/bench/goref/goref
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce app manifest — `##app` routes, startup hooks, theme, i18n"
|
||||
|
||||
[lib]
|
||||
name = "app"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
//! `app` — `##app` manifest: routes, theme, i18n, startup hooks.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 6 (see
|
||||
//! [06-lowcode-fullstack.md § `##app`](
|
||||
//! ../../../docs/runtime/database/06-lowcode-fullstack.md)).
|
||||
//!
|
||||
//! One `##app` block per project. Declares:
|
||||
//! * the static route table (URL path → `ui.<screen>` binding, possibly
|
||||
//! parameterised by dynamic segments like `/article/:slug`)
|
||||
//! * cross-entity policies ("Admin bypasses row-level filters on every type")
|
||||
//! * `on startup do …` hooks — idempotent seed code that runs once before
|
||||
//! [`http`](../http/index.html) binds a listening socket
|
||||
//! * theme tokens, i18n locale set, project metadata
|
||||
//!
|
||||
//! Consumed by [`ui`](../ui/index.html) for route rendering and by
|
||||
//! [`policy`](../policy/index.html) for the cross-entity rules block.
|
||||
//!
|
||||
//! The [`docs/examples/blog/app.wo`](../../../docs/examples/blog/app.wo) and
|
||||
//! [`docs/examples/ecommerce/app.wo`](../../../docs/examples/ecommerce/app.wo)
|
||||
//! files are the reference shapes.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "db"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce top-level database facade — `open()`, `Tx`, `Query`, `Subscribe` over the engine"
|
||||
|
||||
[lib]
|
||||
name = "db"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
//! `wo-db` — the Rust-facing database SDK.
|
||||
//!
|
||||
//! **Status: placeholder.** Phases 2–4 integrator.
|
||||
//!
|
||||
//! The top-level crate that the rest of the workspace (`wo-rt`, `wo-http`,
|
||||
//! future `wo-serve` integration glue) depends on. Composes:
|
||||
//!
|
||||
//! | | |
|
||||
//! | --- | --- |
|
||||
//! | [`ql`](../ql/index.html) | parse |
|
||||
//! | [`value`](../value/index.html) | row representation |
|
||||
//! | [`engine`](../engine/index.html) | storage + execution |
|
||||
//! | [`txn`](../txn/index.html) | transaction coordinator |
|
||||
//! | [`wal`](../wal/index.html) | durability (Phase 3) |
|
||||
//! | [`sub`](../sub/index.html) | live subscriptions (Phase 4) |
|
||||
//!
|
||||
//! Public surface the rest of the workspace will call:
|
||||
//!
|
||||
//! ```text
|
||||
//! let db = db::open(&catalog, &options)?;
|
||||
//! db.wo(ctx, ".wo source", params).await?; // raw DML
|
||||
//! db.tx(ctx, |tx| async move { ... }).await?;
|
||||
//! let sub = db.subscribe(ctx, "LIVE SELECT ...", params).await?;
|
||||
//! ```
|
||||
//!
|
||||
//! See [05-go-sdk.md](../../../docs/runtime/database/05-go-sdk.md) — the Go
|
||||
//! SDK's shape mirrors this one; both speak the same `.wo` wire protocol.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "engine"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce in-memory executor — relational, document, and graph storage over a shared catalog"
|
||||
|
||||
[lib]
|
||||
name = "engine"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
//! `wo-engine` — the in-memory executor.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 2 (see
|
||||
//! [02-wo-language.md](../../../docs/runtime/database/02-wo-language.md))
|
||||
//! designs the three storage paradigms; Phase 3
|
||||
//! ([03-inmemory-engine.md](../../../docs/runtime/database/03-inmemory-engine.md))
|
||||
//! details the RAM-primary layout, arenas, MVCC version chains.
|
||||
//!
|
||||
//! The engine owns:
|
||||
//! * relational tables — per-type `BTreeMap<id, Row>` today, B+ tree when
|
||||
//! sized appropriately
|
||||
//! * document collections — shape-free `Value::Object` rows; LSM when Phase 3
|
||||
//! activates
|
||||
//! * graph nodes + edges — adjacency over the type catalog
|
||||
//! * a schema catalog compiled from `wo-ql` output
|
||||
//!
|
||||
//! Stage 2 ships this as a module inside [`rt`](../rt/index.html).
|
||||
//! It extracts here when `wo-wal` and `wo-txn` need to reach into the same
|
||||
//! structures.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "gen"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce codegen — `.wo` schema → typed Go / TypeScript / Rust / Python clients"
|
||||
|
||||
[lib]
|
||||
name = "gen"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
//! `wo-gen` — client-SDK codegen.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 5 (see
|
||||
//! [05-go-sdk.md § Typed SDK via `.wo` Schema Codegen](
|
||||
//! ../../../docs/runtime/database/05-go-sdk.md)).
|
||||
//!
|
||||
//! Reads a compiled catalog (the same output [`ql`](../ql/index.html) +
|
||||
//! [`engine`](../engine/index.html) produce from a `.wo` source tree)
|
||||
//! and emits typed client code:
|
||||
//!
|
||||
//! * **Go** — structs with `wo:"column"` tags, `*Client`,
|
||||
//! `TypedSubscription[T]` generics over channels
|
||||
//! * **TypeScript** — types + `fetch` + WebSocket subscriptions
|
||||
//! * **Rust** — `#[derive(Deserialize)]` structs + `impl Stream<Item = Delta>`
|
||||
//! * **Python** — dataclasses + `async for delta in sub`
|
||||
//! * **OpenAPI / GraphQL SDL** — machine-generated contracts served by
|
||||
//! [`http`](../http/index.html)
|
||||
//!
|
||||
//! Invoked as `wo gen sdk --lang go --out ./client` via the toolchain; a bin
|
||||
//! target will be added when the crate has real behaviour. Until then this is
|
||||
//! an empty library scaffold.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "http"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce wire protocol — native binary + GraphQL over WebSocket + REST dispatch"
|
||||
|
||||
[lib]
|
||||
name = "http"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
//! `wo-http` — HTTP + WebSocket + native wire protocol for the `.wo` runtime.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 4 (see
|
||||
//! [04-client-api.md](../../../docs/runtime/database/04-client-api.md)).
|
||||
//!
|
||||
//! Three protocol surfaces, one dispatch:
|
||||
//! * **REST** — JSON in/out, one route per `service rest` operation.
|
||||
//! * **GraphQL over WebSocket** — SDL auto-generated from the schema; query,
|
||||
//! mutation, `subscription` all routed through the same planner.
|
||||
//! * **Native binary** — typed wire codec for `wo-db` and the Go SDK.
|
||||
//!
|
||||
//! Authentication middleware (`WithAPIKey`, `WithJWT`, `WithMTLS`) lives in
|
||||
//! this crate — cross-cutting over every protocol surface.
|
||||
//!
|
||||
//! Stage 2 ships a minimal axum-based REST server inside
|
||||
//! [`rt::server`](../rt/server/index.html). It migrates here when
|
||||
//! Phase 4 activates and GraphQL / native surfaces join REST.
|
||||
//!
|
||||
//! Not the v1 `reference/crates/wo-http/` — that was a from-scratch HTTP
|
||||
//! parser for the old single-threaded event loop.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "logic"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce trigger + stored-fn compiler — type-attached `on <event>` and cross-entity `##logic`"
|
||||
|
||||
[lib]
|
||||
name = "logic"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
//! `wo-logic` — triggers and stored-function interpreter.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 6 (see
|
||||
//! [06-lowcode-fullstack.md § `##logic`](
|
||||
//! ../../../docs/runtime/database/06-lowcode-fullstack.md)).
|
||||
//!
|
||||
//! Two sources of runtime code:
|
||||
//! * **Type-attached triggers** — `on insert | update | delete [when <pred>]
|
||||
//! do <action>` declared inside a `type` body. Fires inside the committing
|
||||
//! transaction so timestamp stamps (`paid_at`, `shipped_at`) are atomic
|
||||
//! with the state change, never observable half-way.
|
||||
//! * **Standalone `##logic`** — cross-entity workflows like the ecommerce
|
||||
//! on-order-placed hook that decrements inventory on every line item.
|
||||
//! * **`fn name(args) in txn ... { ... }`** — transactional stored
|
||||
//! functions (the `checkout` in `docs/examples/ecommerce/logic/`).
|
||||
//!
|
||||
//! All three compile to the same intermediate form that [`txn`](../txn/index.html)
|
||||
//! invokes during commit (for triggers) or directly on the main loop (for
|
||||
//! `fn` calls from REST/native wire).
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "policy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce policy engine — RBAC + row-level rules compiled into planner rewrites"
|
||||
|
||||
[lib]
|
||||
name = "policy"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
//! `wo-policy` — RBAC + row-level policies.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 6 (see
|
||||
//! [06-lowcode-fullstack.md](../../../docs/runtime/database/06-lowcode-fullstack.md)).
|
||||
//!
|
||||
//! Compiles type-attached `policy` blocks (and standalone `##policy` blocks
|
||||
//! for cross-entity rules) into predicates that [`engine`](../engine/index.html)
|
||||
//! AND-s into every query at plan time. The rule is enforced once, at the
|
||||
//! planner, so no caller can bypass it — not the REST handler, not the
|
||||
//! `wo-db` SDK, not an admin script calling `fn` directly.
|
||||
//!
|
||||
//! Shape of a compiled policy:
|
||||
//!
|
||||
//! ```text
|
||||
//! for type Article:
|
||||
//! read: published == true OR $session.role == Admin
|
||||
//! OR author == $session.user
|
||||
//! write: $session.role == Admin OR ($session.role == Author && author == $session.user)
|
||||
//! delete: $session.role == Admin
|
||||
//! ```
|
||||
//!
|
||||
//! The planner merges the read predicate as a `WHERE` conjunct, the write
|
||||
//! predicate as an assertion inside UPDATE/INSERT codegen, and delete as a
|
||||
//! DELETE guard. Same mechanism as Postgres RLS — just authored declaratively
|
||||
//! in the `type` block instead of via SQL migrations.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "ql"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce query language — lexer, parser, AST for the `.wo` grammar"
|
||||
|
||||
[lib]
|
||||
name = "ql"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
//! `wo-ql` — the `.wo` grammar: lexer, parser, AST.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 2 of the [`.wo` runtime design](
|
||||
//! ../../../docs/runtime/database/02-wo-language.md).
|
||||
//!
|
||||
//! Stage 2 of the runtime (shipped) carries the lexer/parser/AST inside
|
||||
//! [`rt`](../rt/index.html) as part of its monolithic cut. This crate
|
||||
//! is where those modules move to when Stage 3+ needs the grammar from
|
||||
//! multiple places — the HTTP raw-query handler, `wo-gen`, the server-side
|
||||
//! `fn` interpreter.
|
||||
//!
|
||||
//! Reference implementation: the C++ prototype at
|
||||
//! [`prototypes/wo-db/`](../../../prototypes/wo-db/).
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "service"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce service dispatcher — type-attached `service rest/graphql/native` and standalone `##service` bundles"
|
||||
|
||||
[lib]
|
||||
name = "service"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
//! `service` — endpoint registration and dispatch.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 6 (see
|
||||
//! [06-lowcode-fullstack.md § `##service`](
|
||||
//! ../../../docs/runtime/database/06-lowcode-fullstack.md)).
|
||||
//!
|
||||
//! Walks every `service rest "/api/..." expose ...` block declared on a type
|
||||
//! (and every standalone `##service` bundle for multi-entity APIs) and binds
|
||||
//! handlers on [`http`](../http/index.html)'s router. Each exposed operation
|
||||
//! (`list` / `get` / `create` / `update` / `delete` / `subscribe` / `me`)
|
||||
//! maps to a predetermined shape — declarative CRUD is the whole point.
|
||||
//!
|
||||
//! `fn` endpoints (`POST /api/fn/checkout`) are also dispatched from here —
|
||||
//! the service declares the function-as-endpoint wiring, [`logic`](../logic/index.html)
|
||||
//! executes.
|
||||
//!
|
||||
//! Stage 2 does this work inline inside [`rt::server`](../rt/server/index.html).
|
||||
//! It extracts here when Phase 6 adds GraphQL + native-protocol surfaces that
|
||||
//! share the same service-block source of truth.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "sub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce live subscriptions — LIVE query registry + delta frames on commit"
|
||||
|
||||
[lib]
|
||||
name = "sub"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
//! `wo-sub` — the live-subscription engine.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 4 (see
|
||||
//! [04-client-api.md](../../../docs/runtime/database/04-client-api.md)).
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! * per-connection subscription registry — predicate + bound parameters
|
||||
//! * commit-path dispatch: each [`txn`](../txn/index.html) commit
|
||||
//! walks the registry, matches predicates against the delta set, and
|
||||
//! pushes `Insert` / `Update` / `Delete` frames into per-subscription
|
||||
//! ring buffers
|
||||
//! * back-pressure policy (drop-and-resync / coalesce / disconnect) —
|
||||
//! mirrors the client-side policy in [`wo-db`](../db/index.html)
|
||||
//! / the Go SDK
|
||||
//!
|
||||
//! Stage 2 stubs the `/api/<type>/live` endpoint to 501 in
|
||||
//! [`rt::server`](../rt/server/index.html). Stage 3 wires this crate
|
||||
//! behind that route and upgrades the connection to WebSocket.
|
||||
//!
|
||||
//! Not related to the v1 `reference/crates/wo-sub/` crate — that one did
|
||||
//! polling-based diff delivery over SSE for the old flat-file blog.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "txn"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce transaction coordinator — MVCC, snapshot isolation, `RETURNING` alias table"
|
||||
|
||||
[lib]
|
||||
name = "txn"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
//! `wo-txn` — the cross-paradigm transaction coordinator.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 2 milestone 3 (see
|
||||
//! [02-wo-language.md § Cross-Paradigm Transaction Coordinator](
|
||||
//! ../../../docs/runtime/database/02-wo-language.md)).
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! * `txn_id` allocation + snapshot timestamps
|
||||
//! * ordering commits across `wo-engine`'s three storage paradigms
|
||||
//! * the transaction-scoped **`RETURNING` alias table** — the state that
|
||||
//! lets `INSERT … RETURNING id AS oid` thread `$oid` into a later
|
||||
//! `CREATE (u)-[:PURCHASED {order_id: $oid}]->(p)` in the same
|
||||
//! `BEGIN … COMMIT` block
|
||||
//! * driving the in-process 2PC between the three engines on commit
|
||||
//!
|
||||
//! Under the single-threaded event-loop design (see
|
||||
//! [§ Concurrency Model](../../../docs/runtime/database/02-wo-language.md#concurrency-model))
|
||||
//! this reduces to a sequential counter + a per-txn name table. When the
|
||||
//! runtime grows past one core via sharding, this crate owns cross-shard 2PC.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce UI renderer — `##ui` screens compile to SSR HTML + thin client runtime"
|
||||
|
||||
[lib]
|
||||
name = "ui"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
//! `ui` — `##ui` screens → SSR HTML + client-side delta runtime.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 6 (see
|
||||
//! [06-lowcode-fullstack.md § `##ui`](
|
||||
//! ../../../docs/runtime/database/06-lowcode-fullstack.md)).
|
||||
//!
|
||||
//! Declarative screens compile to:
|
||||
//! * a **server-rendered HTML** tree — a compact template + data-binding
|
||||
//! form computed from the screen's `source:` projection and `columns:` /
|
||||
//! `sections:` declarations
|
||||
//! * a **client-side runtime** (~50 KB vanilla JS) — opens a WebSocket to
|
||||
//! the engine's [`sub`](../sub/index.html) endpoint, binds incoming delta
|
||||
//! frames to DOM fragments by row key, handles sort / filter / paginate
|
||||
//! without refetching
|
||||
//! * an **auto-admin fallback** — any declared `type` without an explicit
|
||||
//! `##ui` gets a generic CRUD screen
|
||||
//!
|
||||
//! `live: true` on a screen auto-generates a matching `LIVE SELECT` that the
|
||||
//! client runtime subscribes to — no hand-written subscription code on the UI
|
||||
//! side.
|
||||
//!
|
||||
//! The [`docs/examples/blog/ui/`](../../../docs/examples/blog/ui/) and
|
||||
//! [`docs/examples/ecommerce/ui/`](../../../docs/examples/ecommerce/ui/)
|
||||
//! directories are the reference shapes this crate has to handle.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "value"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce tagged `Value` + dotted-path utilities"
|
||||
|
||||
[lib]
|
||||
name = "value"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
//! `wo-value` — the runtime's tagged `Value` type plus dotted-path helpers.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 2 foundation.
|
||||
//!
|
||||
//! Carries the union of all scalar + document + array shapes the engines pass
|
||||
//! around: `Null`, `Bool`, `Int`, `Str`, `Array`, `Object`. Plus `fetch_path`
|
||||
//! / `assign_path` utilities that drive the dotted-access semantics shared by
|
||||
//! SQL expressions, document `UPDATE SET meta.x.y = z`, and Cypher projections
|
||||
//! (see [the two-layer language spec](
|
||||
//! ../../../docs/runtime/database/02-wo-language.md)).
|
||||
//!
|
||||
//! Stage 2 keeps these inside [`rt`](../rt/index.html)'s `engine` module;
|
||||
//! they extract here once a second consumer (the WAL serializer, the wire codec)
|
||||
//! needs them.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "wal"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "writeonce write-ahead log — io_uring + fsync + crash recovery"
|
||||
|
||||
[lib]
|
||||
name = "wal"
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
//! `wo-wal` — the write-ahead log.
|
||||
//!
|
||||
//! **Status: placeholder.** Phase 3 (see
|
||||
//! [03-inmemory-engine.md](../../../docs/runtime/database/03-inmemory-engine.md)).
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! * append committed txn records to an on-disk ring buffer via `io_uring`
|
||||
//! * issue `IORING_OP_FSYNC` with a linked SQE for durability
|
||||
//! * batch concurrent commits into one fsync per tick (group commit)
|
||||
//! * drive crash recovery by replaying the log from the last checkpoint
|
||||
//!
|
||||
//! Under the single-threaded event-loop design, the WAL is owned directly by
|
||||
//! the main loop — no separate WAL-writer thread. `io_uring`'s kernel-owned
|
||||
//! SQPOLL handler does the draining; userland just submits and parks on CQEs.
|
||||
//!
|
||||
//! This is the crate that turns the RAM-primary engine from "cache" into
|
||||
//! "database": commits aren't acked until their WAL record is on the SSD.
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
CC ?= cc
|
||||
CFLAGS ?= -O2 -Wall -Wextra -std=c11
|
||||
LDFLAGS ?= -pthread
|
||||
|
||||
wo-rt: wo-rt.c
|
||||
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
|
||||
|
||||
bench/bench: bench/bench.c
|
||||
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
|
||||
|
||||
bench: bench/bench
|
||||
|
||||
run: wo-rt
|
||||
./wo-rt
|
||||
|
||||
clean:
|
||||
rm -f wo-rt bench/bench
|
||||
|
||||
.PHONY: bench run clean
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
# `wo-rt-c` — the writeonce runtime environment, in C
|
||||
|
||||
A single-file C implementation of the **runtime layer** the writeonce language runs on — now at **phase E: a durable RAM database**. Writes follow the dual-write order — RAM apply, framed WAL record (`len|crc32|payload|COMMIT`) to a per-shard `fallocate`'d log, one group-commit `fdatasync` per loop tick, **HTTP ack only after the fsync completion**. Boot performs the **first load, hard drive → RAM**: each shard replays its snapshot + WAL tail into its arena slice in parallel before any accept arms; clean shutdown snapshots each slice and truncates the WAL; a `meta` file pins the shard count so a mismatched `WO_THREADS` refuses to boot. `./wo-rt wal-check <file>` validates a log offline. `WO_THREADS` pinned threads (default = online cores), each owning its own raw io_uring ring (`io_uring_setup` + mmap'd SQ/CQ rings + `io_uring_enter` — **no liburing**), its own `SO_REUSEPORT` listener (multishot accept), its own keep-alive connections, and its own slice of the one mlock'd mmap arena — shared-nothing, no locks. Steady state is one `io_uring_enter` syscall per loop tick. Thread 0 owns the `signalfd`; shutdown broadcasts through per-thread `eventfd`s, both watched via `POLL_ADD` SQEs. **Zero dependencies beyond libc + kernel uapi headers.** The kernel is the runtime.
|
||||
|
||||
This is the runtime-layer sibling of [`prototypes/wo-db/`](../wo-db/) (the C++ query-layer prototype): a reference card showing, with no abstraction in the way, exactly which kernel primitives the production Rust runtime (`crates/rt/`) drives through `libc`. Same role, different layer.
|
||||
|
||||
```
|
||||
prototypes/wo-db/ C++ what the LANGUAGE executes (parser, engine, transactions)
|
||||
prototypes/wo-rt-c/ C what the RUNTIME stands on (epoll, signalfd, sockets)
|
||||
crates/rt/ Rust the product — both layers, libc only
|
||||
```
|
||||
|
||||
## Build, run, poke
|
||||
|
||||
```bash
|
||||
make # cc -O2 -Wall -Wextra -std=c11 -pthread — no libraries
|
||||
./wo-rt # 127.0.0.1:8085 (WO_PORT=9000 WO_THREADS=4 ./wo-rt to override)
|
||||
|
||||
curl localhost:8085/ # {"runtime":"wo-rt-c","loop":"epoll-et","threads":4,
|
||||
# "shard":2,"shard_requests":[68,36,44,53]}
|
||||
curl -X POST localhost:8085/api/notes -d '{"title":"hello"}'
|
||||
# {"id":2,"title":"hello","shard":1} ← ids interleave per shard
|
||||
curl localhost:8085/api/notes # the connection's shard only — shared-nothing
|
||||
# ctrl-C → signalfd on shard 0 → eventfd broadcast → all shards join
|
||||
```
|
||||
|
||||
Each connection hashes to one shard for life (`SO_REUSEPORT` 4-tuple): a list may land on a different shard than the create that preceded it. That is the architecture, not a bug — cross-shard reads are a later phase / design decision (see the [architecture doc's improvements](../../docs/plan/exploration/c-runtime/01-architecture.md)).
|
||||
|
||||
Or from the repo root: `just rt-c-demo`.
|
||||
|
||||
## Module map
|
||||
|
||||
Every block in `wo-rt.c` corresponds one-to-one to a module of the Rust runtime, which in turn mirrors Go's netpoller — the same lineage the docs trace:
|
||||
|
||||
| `wo-rt.c` block | Rust (`crates/rt/src/`) | Go (`reference/go/src/runtime/`) | Kernel reference card |
|
||||
| --- | --- | --- | --- |
|
||||
| `main` event loop (`epoll_create1` / `epoll_wait`, `EPOLLET`) | `runtime/netpoll_epoll.rs` | `netpoll_epoll.go` | [`linux/01-epoll.md`](../../docs/plan/exploration/linux/01-epoll.md) |
|
||||
| `sig_setup` (`sigprocmask` + `signalfd`) | `runtime/signalfd.rs` | signal mask handling | [`linux/04-signalfd.md`](../../docs/plan/exploration/linux/04-signalfd.md) |
|
||||
| `listener_bind` (`SOCK_NONBLOCK`, `accept4`-to-EAGAIN) | `http/listener.rs` | `net.Listen` + accept loop | `socket(7)` |
|
||||
| `conn_drive` (read-to-EAGAIN, one buffer per fd) | `http/connection.rs` | `conn.Read` loop | the edge-triggered contract |
|
||||
| `notes[]` store | `engine.rs` (BTreeMaps) | — | [`03-inmemory-engine.md`](../../docs/runtime/database/03-inmemory-engine.md) |
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- **One thread owns each shard outright.** Accept, parse, store, respond — no locks, no worker pool, no connection migration. Scaling past one core is more shards ([`09-concurrency-scaleout.md`](../../docs/plan/09-concurrency-scaleout.md)), never shared mutable state. The single cross-thread touch is the relaxed-atomic stats counters on `/` — monotonic, never on the data path.
|
||||
- **Edge-triggered discipline.** Every registration sets `EPOLLET`; every readiness event is drained to `EAGAIN` (the accept loop and the read loop both). Get this wrong and connections silently hang — the reason the Rust module documents the same contract at the top of `netpoll_epoll.rs`.
|
||||
- **Signals as fd events.** `SIGINT`/`SIGTERM` are blocked, then read from a `signalfd` on the same epoll — no async-signal-unsafe handler, no self-pipe trick.
|
||||
- **RAM is the read path.** `GET /api/notes` touches a C array. The production engine is the same idea with MVCC and a WAL behind it.
|
||||
|
||||
## Architecture and roadmap
|
||||
|
||||
Documentation lives under `docs/` (repo convention) — this README stays here as the directory's orientation page only:
|
||||
|
||||
- [`docs/plan/exploration/c-runtime/01-architecture.md`](../../docs/plan/exploration/c-runtime/01-architecture.md) — the runtime defined by tracing **one memory address** through user space, kernel space, and hardware under a million concurrent connections, plus seven improvement proposals (seqlock reads, registered buffers, zero-copy send, SQPOLL, …).
|
||||
- [`docs/plan/exploration/c-runtime/00-plan.md`](../../docs/plan/exploration/c-runtime/00-plan.md) — the phase sequence: **A → B → C → D → E → F, all ✅ shipped.**
|
||||
|
||||
## Measured (phase F, 20-core Linux 6.14, tmpfs data dir, `just rt-c-bench`)
|
||||
|
||||
Same C bench client (`bench/bench.c`, keep-alive, only 2xx counted) against both servers:
|
||||
|
||||
| Benchmark | **wo-rt-c** (8 shards, durable WAL, io_uring + group commit) | **Go `net/http`** (go1.25.1, 20 cores, no durability) | **Rust `wo`** (release, 8 shards, durable WAL, io_uring group commit)¹ |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /healthz` | **859,033 req/s** · p50 71 µs · p99 159 µs | 336,444 req/s · p50 70 µs · p99 1,277 µs | 746,340 req/s · p50 73 µs · p99 186 µs |
|
||||
| `GET /` (JSON) | **671,312 req/s** · p99 180 µs | — | 692,671 req/s · p99 167 µs |
|
||||
| `POST` write (tmpfs) | **618,343 commits/s** — fsync-acked · p99 194 µs | 320,516 req/s — RAM only, no WAL · p99 1,581 µs | 330,285 commits/s — fsync-acked · p99 360 µs |
|
||||
| `POST` write (real ext4/NVMe) | — | — | **27,014 commits/s group commit vs 5,765 per-commit (4.7×)** · p50 2.2 ms |
|
||||
| 10,000 idle conns | 0 errors | 0 errors | 0 errors |
|
||||
|
||||
¹ All numbers measured on a clean box with the same client (earlier parasite-contaminated runs superseded). The Rust column's history is the architecture roadmap, measured: 09a global mutex (74.9k writes/s) → 09b sharded engine (+51%) → 09c per-shard WAL (~1% durability cost) → **keep-alive: reads ×3.4 to 770k/s, durable writes ×1.9 to 331k/s, p99 under 350 µs everywhere**. Rust now beats Go on both columns *while fsyncing every write*, and sits within ~10% of the C prototype on reads — converging exactly as the same-architecture argument predicted. The one remaining C advantage is **group commit on io_uring** (one batched fsync + one syscall per tick vs per-commit fsync over epoll), which is the next port. Bonus finding: with `/tmp` accidentally full, the C runtime **refused to ack non-durable writes under ENOSPC** — the durability guarantee holding in an unplanned failure mode.
|
||||
|
||||
wo-rt-c on 8 cores outpaces Go on 20 with ~8× tighter p99 (Go's GC shows there) — while fsyncing every write Go doesn't. Honest caveats: `net/http` does full general-purpose HTTP; our parser is minimal; .NET was not installed on the box. **ACID under load:** three crash rounds (`kill -9` mid-bench at ~2M commits) all showed WAL records ≥ acked; isolation probe: 300 concurrent commits → 300 distinct ids; torn-tail records drop whole by CRC.
|
||||
|
||||
The crash-under-load test **found and fixed two real bugs** the lighter phase-D test missed: an ack-before-fsync race (`conn_continue` armed the send in the same tick the commit was staged) and an fd-reuse ABA hazard in ack parking (fixed with per-connection generation stamps). That is what phase F is for.
|
||||
- [`docs/plan/exploration/c-runtime/02-single-binary.md`](../../docs/plan/exploration/c-runtime/02-single-binary.md) — the end goal: how the `wo build` **single binary** runs on this runtime environment — the runtime kernel is statically linked into every writeonce app (Go model, nothing to install), with the catalog/routes/bytecode payload consumed at boot.
|
||||
|
||||
## Deliberate simplifications
|
||||
|
||||
Single-shot RECV re-armed per request (multishot recv + buffer rings are a phase-F improvement), one outstanding SQE per connection, fixed-size buffers, naive `"title"` extraction instead of a JSON parser, no `timerfd`. Requires kernel ≥ 5.19 (multishot accept). This file is for reading; `crates/rt` is for running writeonce.
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
/*
|
||||
* bench.c — load client for wo-rt-c (phase F). Zero deps beyond libc.
|
||||
*
|
||||
* T threads, each driving ONE keep-alive connection in a tight request/
|
||||
* response loop (TCP_NODELAY, full-response framing via Content-Length).
|
||||
* Every request is latency-stamped; the run prints req/s, p50, p99, errors.
|
||||
*
|
||||
* usage: ./bench <host> <port> <threads> <seconds> <path> [post-json]
|
||||
* read: ./bench 127.0.0.1 8085 64 5 /api/notes
|
||||
* write: ./bench 127.0.0.1 8085 64 5 /api/notes '{"title":"bench"}'
|
||||
* idle: ./bench 127.0.0.1 8085 10000 0 /healthz # seconds=0: open conns,
|
||||
* # one request each, hold, exit
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MAX_SAMPLES 400000 /* per thread; counting continues past it */
|
||||
|
||||
static char g_req[2048];
|
||||
static int g_reqlen;
|
||||
static struct sockaddr_in g_addr;
|
||||
static long g_deadline_us; /* 0 = idle-connection mode */
|
||||
static int g_hold_secs;
|
||||
|
||||
struct worker {
|
||||
pthread_t tid;
|
||||
long reqs, errs;
|
||||
long ok2xx, non2xx; /* honest accounting: only 2xx is success */
|
||||
long *lat; /* µs samples */
|
||||
int nlat;
|
||||
};
|
||||
|
||||
static long now_us(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return ts.tv_sec * 1000000L + ts.tv_nsec / 1000;
|
||||
}
|
||||
|
||||
static int conn_open(void) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) return -1;
|
||||
int one = 1;
|
||||
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
|
||||
if (connect(fd, (struct sockaddr *)&g_addr, sizeof g_addr) < 0) { close(fd); return -1; }
|
||||
return fd;
|
||||
}
|
||||
|
||||
/* One request/response round-trip. Returns HTTP status, or -1 conn-dead. */
|
||||
static int round_trip(int fd) {
|
||||
size_t off = 0;
|
||||
while (off < (size_t)g_reqlen) {
|
||||
ssize_t n = write(fd, g_req + off, (size_t)g_reqlen - off);
|
||||
if (n <= 0) { if (n < 0 && errno == EINTR) continue; return -1; }
|
||||
off += (size_t)n;
|
||||
}
|
||||
static _Thread_local char buf[131072];
|
||||
size_t got = 0, need = 0;
|
||||
for (;;) {
|
||||
ssize_t n = read(fd, buf + got, sizeof buf - 1 - got);
|
||||
if (n <= 0) { if (n < 0 && errno == EINTR) continue; return -1; }
|
||||
got += (size_t)n;
|
||||
buf[got] = 0;
|
||||
if (!need) {
|
||||
char *he = strstr(buf, "\r\n\r\n");
|
||||
if (!he) { if (got >= sizeof buf - 1) return -1; continue; }
|
||||
size_t hdr = (size_t)(he + 4 - buf);
|
||||
long cl = 0;
|
||||
char *p = strcasestr(buf, "Content-Length:");
|
||||
if (p) cl = strtol(p + 15, NULL, 10);
|
||||
need = hdr + (size_t)cl;
|
||||
}
|
||||
if (got >= need) {
|
||||
int code = 0;
|
||||
sscanf(buf, "HTTP/1.1 %d", &code);
|
||||
return code;
|
||||
}
|
||||
if (got >= sizeof buf - 1) return -1;
|
||||
}
|
||||
}
|
||||
|
||||
static void *worker_main(void *arg) {
|
||||
struct worker *w = arg;
|
||||
int fd = conn_open();
|
||||
if (fd < 0) { w->errs++; return NULL; }
|
||||
|
||||
if (g_deadline_us == 0) { /* idle-connection mode */
|
||||
if (round_trip(fd) > 0) w->reqs++; else w->errs++;
|
||||
sleep((unsigned)g_hold_secs);
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
while (now_us() < g_deadline_us) {
|
||||
long t0 = now_us();
|
||||
int code = round_trip(fd);
|
||||
if (code < 0) { /* reconnect once, then count errs */
|
||||
close(fd);
|
||||
fd = conn_open();
|
||||
if (fd < 0) { w->errs++; break; }
|
||||
w->errs++;
|
||||
continue;
|
||||
}
|
||||
long dt = now_us() - t0;
|
||||
if (w->nlat < MAX_SAMPLES) w->lat[w->nlat++] = dt;
|
||||
w->reqs++;
|
||||
if (code >= 200 && code < 300) w->ok2xx++; else w->non2xx++;
|
||||
}
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int cmp_long(const void *a, const void *b) {
|
||||
long x = *(const long *)a, y = *(const long *)b;
|
||||
return (x > y) - (x < y);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 6) {
|
||||
fprintf(stderr, "usage: %s <host> <port> <threads> <seconds> <path> [post-json]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const char *host = argv[1];
|
||||
int port = atoi(argv[2]);
|
||||
int threads = atoi(argv[3]);
|
||||
int secs = atoi(argv[4]);
|
||||
const char *path = argv[5];
|
||||
const char *body = argc > 6 ? argv[6] : NULL;
|
||||
|
||||
struct rlimit rl;
|
||||
if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < rl.rlim_max) {
|
||||
rl.rlim_cur = rl.rlim_max;
|
||||
setrlimit(RLIMIT_NOFILE, &rl);
|
||||
}
|
||||
|
||||
memset(&g_addr, 0, sizeof g_addr);
|
||||
g_addr.sin_family = AF_INET;
|
||||
g_addr.sin_port = htons((uint16_t)port);
|
||||
inet_pton(AF_INET, host, &g_addr.sin_addr);
|
||||
|
||||
if (body)
|
||||
g_reqlen = snprintf(g_req, sizeof g_req,
|
||||
"POST %s HTTP/1.1\r\nHost: %s\r\nContent-Type: application/json\r\n"
|
||||
"Content-Length: %zu\r\nConnection: keep-alive\r\n\r\n%s",
|
||||
path, host, strlen(body), body);
|
||||
else
|
||||
g_reqlen = snprintf(g_req, sizeof g_req,
|
||||
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: keep-alive\r\n\r\n", path, host);
|
||||
|
||||
g_hold_secs = 3;
|
||||
g_deadline_us = secs > 0 ? now_us() + (long)secs * 1000000L : 0;
|
||||
|
||||
struct worker *ws = calloc((size_t)threads, sizeof *ws);
|
||||
for (int i = 0; i < threads; i++) {
|
||||
ws[i].lat = secs > 0 ? malloc(MAX_SAMPLES * sizeof(long)) : NULL;
|
||||
pthread_create(&ws[i].tid, NULL, worker_main, &ws[i]);
|
||||
}
|
||||
|
||||
long t0 = now_us();
|
||||
long total = 0, errs = 0, nlat = 0, ok = 0, bad = 0;
|
||||
for (int i = 0; i < threads; i++) {
|
||||
pthread_join(ws[i].tid, NULL);
|
||||
total += ws[i].reqs;
|
||||
errs += ws[i].errs;
|
||||
nlat += ws[i].nlat;
|
||||
ok += ws[i].ok2xx;
|
||||
bad += ws[i].non2xx;
|
||||
}
|
||||
long wall_us = now_us() - t0;
|
||||
|
||||
if (secs == 0) {
|
||||
printf("idle-conns: opened %ld / %d connections (errs %ld), held %ds, server survived\n",
|
||||
total, threads, errs, g_hold_secs);
|
||||
return errs ? 1 : 0;
|
||||
}
|
||||
|
||||
long *all = malloc((size_t)nlat * sizeof(long));
|
||||
long k = 0;
|
||||
for (int i = 0; i < threads; i++) {
|
||||
memcpy(all + k, ws[i].lat, (size_t)ws[i].nlat * sizeof(long));
|
||||
k += ws[i].nlat;
|
||||
}
|
||||
qsort(all, (size_t)nlat, sizeof(long), cmp_long);
|
||||
|
||||
double rps = (double)ok / ((double)wall_us / 1e6);
|
||||
printf("%-22s %d conns %ds %ld ok (2xx) %.0f ok/s p50 %ld µs p99 %ld µs non-2xx %ld errs %ld\n",
|
||||
body ? "WRITE (POST)" : path, threads, secs, ok, rps,
|
||||
nlat ? all[nlat / 2] : 0, nlat ? all[(long)((double)nlat * 0.99)] : 0, bad, errs);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
module goref
|
||||
|
||||
go 1.25.1
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
// goref — the Go net/http comparison server for the phase-F benchmark.
|
||||
// Same endpoints and semantics as wo-rt-c's RAM read path: /healthz, /,
|
||||
// GET/POST /api/notes against an in-memory store. Durability is NOT
|
||||
// implemented here (Go side has no WAL), so only READ benchmarks are
|
||||
// apples-to-apples; the POST comparison measures Go's non-durable path
|
||||
// against wo-rt-c's fsync-backed path and is labeled as such.
|
||||
//
|
||||
// go build -o goref . && ./goref # :8095, GOMAXPROCS = all cores
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type note struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
notes []note
|
||||
nextID = 1
|
||||
)
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "ok")
|
||||
})
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, `{"runtime":"go-net-http"}`)
|
||||
})
|
||||
http.HandleFunc("/api/notes", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
mu.RLock()
|
||||
b, _ := json.Marshal(notes)
|
||||
mu.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(b)
|
||||
case http.MethodPost:
|
||||
var in struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&in) != nil || in.Title == "" {
|
||||
http.Error(w, `{"error":"expected {\"title\":\"...\"}"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
n := note{ID: nextID, Title: in.Title}
|
||||
nextID++
|
||||
if len(notes) < 100000 {
|
||||
notes = append(notes, n)
|
||||
}
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(n)
|
||||
default:
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
fmt.Println("[goref] listening on :8095")
|
||||
http.ListenAndServe("127.0.0.1:8095", nil)
|
||||
}
|
||||
|
|
@ -1,979 +0,0 @@
|
|||
/*
|
||||
* wo-rt.c — the writeonce runtime environment, in C. Phase E: first load.
|
||||
*
|
||||
* Phases A–D: N pinned threads with raw io_uring loops, SO_REUSEPORT
|
||||
* listeners, keep-alive connections, one mlock'd mmap arena, and durable
|
||||
* commits (RAM apply → framed WAL record → per-tick group fdatasync → ack
|
||||
* on the fsync CQE). Phase E closes the loop: at boot — BEFORE any accept
|
||||
* is armed — each shard thread loads its snapshot and replays its WAL into
|
||||
* its arena slice, in parallel, validating every frame's CRC + COMMIT
|
||||
* trailer and truncating at the first torn record. Appends resume at the
|
||||
* validated tail. A clean shutdown writes a per-shard snapshot
|
||||
* (`shard-<t>.data`) and truncates the WAL; boot prefers snapshot + WAL
|
||||
* tail. The data directory carries a `meta` file pinning the shard count —
|
||||
* a restart with a different WO_THREADS refuses to start (resharding is
|
||||
* plan 09f, not silent data loss). Zero deps beyond libc + kernel uapi.
|
||||
*
|
||||
* build: make run: ./wo-rt [WO_PORT=8085 WO_THREADS=4 WO_DATA=./wo-data ./wo-rt]
|
||||
* poke: curl -X POST localhost:8085/api/notes -d '{"title":"hello"}' # acked after fsync
|
||||
* wal: ./wo-rt wal-check wo-data/shard-0.wal # offline frame/CRC validation
|
||||
*
|
||||
* Phase map: docs/plan/exploration/c-runtime/00-plan.md (A ✅ threads,
|
||||
* B ✅ arena, C ✅ io_uring, D ✅ WAL, E this file, F bench). One-address
|
||||
* trace: 01-architecture.md. Single-binary end goal: 02-single-binary.md.
|
||||
*
|
||||
* Module map (C ↔ Rust ↔ kernel reference card):
|
||||
* ring_init/ring_enter ↔ (plan 09 decision 4: per-thread ring) ↔ linux/07-io_uring.md
|
||||
* arena_init ↔ (plan 10 storage foundations) ↔ linux/08-mmap.md
|
||||
* wal_flush / OP_FSYNC ↔ (plan 11 WAL + 09c per-shard WAL) ↔ linux/12-pwrite-fsync.md, 09-fallocate.md
|
||||
* sig/evfd via POLL_ADD↔ runtime/{signalfd,eventfd}.rs ↔ linux/04-signalfd.md, 02-eventfd.md
|
||||
*
|
||||
* Requires IORING_FEAT_SINGLE_MMAP (≥5.4) and multishot accept (≥5.19).
|
||||
* Phase D opens WALs with O_TRUNC (fresh log each boot) — replay-on-boot and
|
||||
* snapshots are phase E; the crash test inspects the WAL offline via
|
||||
* `wal-check` BEFORE any restart. Simplifications: single-shot RECV re-armed
|
||||
* per request, naive JSON extraction, fixed-size WAL payloads.
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/io_uring.h>
|
||||
#include <netinet/in.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/signalfd.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifndef IORING_ACCEPT_MULTISHOT
|
||||
#define IORING_ACCEPT_MULTISHOT (1U << 0)
|
||||
#endif
|
||||
|
||||
#define MAX_THREADS 64
|
||||
#ifndef MAX_FDS
|
||||
#define MAX_FDS 16384 /* conn slots per shard, fd-indexed */
|
||||
#endif
|
||||
#define IN_CAP 8192
|
||||
#define OUT_CAP 65536
|
||||
#define PAGE 4096
|
||||
#define HUGE_2M (2u * 1024 * 1024)
|
||||
#ifndef SLOT_SIZE
|
||||
#define SLOT_SIZE 256 /* -D overridable for scale runs (phase F) */
|
||||
#endif
|
||||
#ifndef SLOTS_PER_SHARD
|
||||
#define SLOTS_PER_SHARD 256
|
||||
#endif
|
||||
#define RING_ENTRIES 1024
|
||||
#define WAL_PREALLOC (4u * 1024 * 1024) /* fallocate per shard */
|
||||
#define WAL_COMMIT 0xC0FFEE42u /* frame trailer magic */
|
||||
#define WAL_BATCH_CAP 65536 /* staged bytes per group commit */
|
||||
#define WAL_BATCH_CONNS 256 /* acks parked per batch */
|
||||
|
||||
/* ---------------------------------------------------------------- crc32 --
|
||||
* Hand-rolled (poly 0xEDB88320), table built once at boot. Zero deps. */
|
||||
|
||||
static uint32_t crc_table[256];
|
||||
|
||||
static void crc32_init(void) {
|
||||
for (uint32_t i = 0; i < 256; i++) {
|
||||
uint32_t c = i;
|
||||
for (int k = 0; k < 8; k++) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1;
|
||||
crc_table[i] = c;
|
||||
}
|
||||
}
|
||||
|
||||
static uint32_t crc32(const void *buf, size_t len) {
|
||||
const uint8_t *p = buf;
|
||||
uint32_t c = 0xFFFFFFFFu;
|
||||
while (len--) c = crc_table[(c ^ *p++) & 0xFF] ^ (c >> 8);
|
||||
return c ^ 0xFFFFFFFFu;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ WAL frame --
|
||||
* [u32 len][u32 crc(payload)][payload][u32 WAL_COMMIT]. A record replays
|
||||
* whole or not at all: bad len, bad crc, or missing trailer = torn tail.
|
||||
* The payload carries the (shard,slot) coordinates phase B made stable. */
|
||||
|
||||
struct wal_payload {
|
||||
uint32_t op; /* 1 = insert note */
|
||||
uint32_t slot;
|
||||
int32_t id;
|
||||
char title[128];
|
||||
};
|
||||
|
||||
#define WAL_FRAME_BYTES (4 + 4 + sizeof(struct wal_payload) + 4)
|
||||
|
||||
/* ---------------------------------------------------------------- arena --
|
||||
* Unchanged from phase B: [header page][shard 0: bitmap page + slots]... */
|
||||
|
||||
struct arena_hdr {
|
||||
char magic[8];
|
||||
uint32_t version;
|
||||
uint32_t n_shards;
|
||||
uint32_t slots_per_shard;
|
||||
uint32_t slot_size;
|
||||
};
|
||||
|
||||
struct slot_note { int32_t id; char title[128]; };
|
||||
|
||||
static uint8_t *arena;
|
||||
static size_t arena_bytes, arena_map_bytes, slice_bytes, bitmap_bytes;
|
||||
static int arena_huge = 0, arena_locked = 0;
|
||||
|
||||
static int arena_init(int n_shards) {
|
||||
bitmap_bytes = ((size_t)SLOTS_PER_SHARD / 8 + PAGE - 1) & ~((size_t)PAGE - 1);
|
||||
slice_bytes = bitmap_bytes + (size_t)SLOTS_PER_SHARD * SLOT_SIZE;
|
||||
arena_bytes = PAGE + (size_t)n_shards * slice_bytes;
|
||||
|
||||
arena_map_bytes = (arena_bytes + HUGE_2M - 1) & ~((size_t)HUGE_2M - 1);
|
||||
arena = mmap(NULL, arena_map_bytes, PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB | MAP_POPULATE, -1, 0);
|
||||
if (arena != MAP_FAILED) {
|
||||
arena_huge = 1;
|
||||
} else {
|
||||
arena_map_bytes = (arena_bytes + PAGE - 1) & ~((size_t)PAGE - 1);
|
||||
arena = mmap(NULL, arena_map_bytes, PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
|
||||
if (arena == MAP_FAILED) { perror("mmap arena"); return -1; }
|
||||
}
|
||||
|
||||
arena_locked = (mlock(arena, arena_map_bytes) == 0);
|
||||
if (!arena_locked)
|
||||
fprintf(stderr, "[wo-rt-c] warn: mlock refused (%s) — arena not pinned\n", strerror(errno));
|
||||
|
||||
struct arena_hdr *hdr = (struct arena_hdr *)arena;
|
||||
memcpy(hdr->magic, "WORTC\0\0", 8);
|
||||
hdr->version = 3; /* phase C */
|
||||
hdr->n_shards = (uint32_t)n_shards;
|
||||
hdr->slots_per_shard = SLOTS_PER_SHARD;
|
||||
hdr->slot_size = SLOT_SIZE;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static uint64_t *shard_bitmap(int t) { return (uint64_t *)(arena + PAGE + (size_t)t * slice_bytes); }
|
||||
static uint8_t *shard_slots (int t) { return arena + PAGE + (size_t)t * slice_bytes + bitmap_bytes; }
|
||||
static struct slot_note *slot_at(int t, uint32_t i) {
|
||||
return (struct slot_note *)(shard_slots(t) + (size_t)i * SLOT_SIZE);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- io_uring --
|
||||
* The raw ring: three pieces of memory shared with the kernel — the SQ/CQ
|
||||
* ring headers+arrays (one mmap, IORING_FEAT_SINGLE_MMAP) and the SQE array.
|
||||
* Submission: fill sqes[tail&mask], publish tail with a release store, tell
|
||||
* the kernel with ONE io_uring_enter that also waits for completions. */
|
||||
|
||||
struct ring {
|
||||
int fd;
|
||||
unsigned *sq_head, *sq_tail, *sq_mask, *sq_array;
|
||||
unsigned *cq_head, *cq_tail, *cq_mask;
|
||||
struct io_uring_sqe *sqes;
|
||||
struct io_uring_cqe *cqes;
|
||||
unsigned local_tail; /* SQEs filled, not yet published */
|
||||
unsigned to_submit;
|
||||
};
|
||||
|
||||
static int ring_init(struct ring *r) {
|
||||
struct io_uring_params p;
|
||||
memset(&p, 0, sizeof p);
|
||||
r->fd = (int)syscall(__NR_io_uring_setup, RING_ENTRIES, &p);
|
||||
if (r->fd < 0) { perror("io_uring_setup"); return -1; }
|
||||
if (!(p.features & IORING_FEAT_SINGLE_MMAP)) {
|
||||
fprintf(stderr, "[wo-rt-c] kernel lacks IORING_FEAT_SINGLE_MMAP (need >= 5.4)\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t sq_sz = p.sq_off.array + p.sq_entries * sizeof(unsigned);
|
||||
size_t cq_sz = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);
|
||||
size_t sz = sq_sz > cq_sz ? sq_sz : cq_sz;
|
||||
uint8_t *sqcq = mmap(NULL, sz, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
|
||||
r->fd, IORING_OFF_SQ_RING);
|
||||
if (sqcq == MAP_FAILED) { perror("mmap sq/cq ring"); return -1; }
|
||||
|
||||
r->sq_head = (unsigned *)(sqcq + p.sq_off.head);
|
||||
r->sq_tail = (unsigned *)(sqcq + p.sq_off.tail);
|
||||
r->sq_mask = (unsigned *)(sqcq + p.sq_off.ring_mask);
|
||||
r->sq_array = (unsigned *)(sqcq + p.sq_off.array);
|
||||
r->cq_head = (unsigned *)(sqcq + p.cq_off.head);
|
||||
r->cq_tail = (unsigned *)(sqcq + p.cq_off.tail);
|
||||
r->cq_mask = (unsigned *)(sqcq + p.cq_off.ring_mask);
|
||||
r->cqes = (struct io_uring_cqe *)(sqcq + p.cq_off.cqes);
|
||||
|
||||
r->sqes = mmap(NULL, p.sq_entries * sizeof(struct io_uring_sqe),
|
||||
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
|
||||
r->fd, IORING_OFF_SQES);
|
||||
if (r->sqes == MAP_FAILED) { perror("mmap sqes"); return -1; }
|
||||
|
||||
r->local_tail = *r->sq_tail;
|
||||
r->to_submit = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static struct io_uring_sqe *sqe_get(struct ring *r) {
|
||||
unsigned idx = r->local_tail & *r->sq_mask;
|
||||
struct io_uring_sqe *s = &r->sqes[idx];
|
||||
memset(s, 0, sizeof *s);
|
||||
r->sq_array[idx] = idx;
|
||||
r->local_tail++;
|
||||
r->to_submit++;
|
||||
return s;
|
||||
}
|
||||
|
||||
/* user_data = (op << 32) | fd-or-batch-index */
|
||||
enum { OP_ACCEPT = 1, OP_RECV, OP_SEND, OP_EVFD, OP_SIGFD, OP_WALWR, OP_FSYNC };
|
||||
static uint64_t ud(int op, int fd) { return ((uint64_t)op << 32) | (uint32_t)fd; }
|
||||
|
||||
static int ring_enter(struct ring *r, unsigned wait) {
|
||||
__atomic_store_n(r->sq_tail, r->local_tail, __ATOMIC_RELEASE);
|
||||
unsigned n = r->to_submit;
|
||||
r->to_submit = 0;
|
||||
for (;;) {
|
||||
int rc = (int)syscall(__NR_io_uring_enter, r->fd, n, wait,
|
||||
IORING_ENTER_GETEVENTS, NULL, 0);
|
||||
if (rc >= 0) return rc;
|
||||
if (errno == EINTR) { n = 0; continue; } /* already submitted */
|
||||
perror("io_uring_enter");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- connection --
|
||||
* Keep-alive state machine. Exactly one outstanding SQE per connection:
|
||||
* RECV while a request is being assembled, SEND while a response drains.
|
||||
* Leftover bytes after a request (pipelining) are carried over and parsed
|
||||
* before the next RECV is armed. */
|
||||
|
||||
struct conn {
|
||||
char in[IN_CAP]; size_t in_len;
|
||||
char out[OUT_CAP]; size_t out_len, out_off;
|
||||
int in_use;
|
||||
int closing; /* close once the out buffer drains */
|
||||
int await_durable;/* response parked until this tick's fsync CQE */
|
||||
uint64_t gen; /* incarnation stamp — kernel fds get reused */
|
||||
};
|
||||
|
||||
/* One group-commit batch: staged WAL bytes + the connections whose acks ride
|
||||
* its fsync. Double-buffered: while batch[k] is in flight (write→fsync
|
||||
* linked SQEs), new commits stage into batch[k^1].
|
||||
* Acks are parked as (fd, gen) pairs: an fd alone is ABA-unsafe — a parked
|
||||
* connection can die, the kernel reuses its fd for a NEW connection whose
|
||||
* commit sits in the OTHER batch, and a bare-fd release would ack that new
|
||||
* connection before ITS record is durable. Found by the phase-F crash test
|
||||
* (7 acked-but-unwritten records out of ~990k under reconnect churn). */
|
||||
struct wal_batch {
|
||||
char buf[WAL_BATCH_CAP];
|
||||
size_t len;
|
||||
int conns[WAL_BATCH_CONNS];
|
||||
uint64_t gens[WAL_BATCH_CONNS];
|
||||
int n_conns;
|
||||
};
|
||||
|
||||
struct shard {
|
||||
int id;
|
||||
int lfd, evfd;
|
||||
struct ring ring;
|
||||
pthread_t tid;
|
||||
int next_id;
|
||||
_Atomic int used;
|
||||
int wal_fd;
|
||||
_Atomic size_t wal_off; /* owner-written; stats-readable cross-shard */
|
||||
struct wal_batch batch[2];
|
||||
int active; /* batch being staged */
|
||||
int in_flight; /* a write→fsync pair is on the ring */
|
||||
char snap_path[320];
|
||||
struct conn conns[MAX_FDS];
|
||||
};
|
||||
|
||||
/* Snapshot file: [snap_hdr][bitmap page][slot bytes]. Written on clean
|
||||
* shutdown, loaded at boot before WAL replay. */
|
||||
struct snap_hdr {
|
||||
char magic[8]; /* "WOSNAP\0\0" */
|
||||
uint32_t version;
|
||||
int32_t next_id;
|
||||
int32_t used;
|
||||
};
|
||||
|
||||
static struct shard *shards;
|
||||
static int n_threads = 1;
|
||||
static int sigfd = -1;
|
||||
static _Atomic unsigned long reqs[MAX_THREADS];
|
||||
|
||||
static int slot_alloc(struct shard *sh) {
|
||||
uint64_t *bm = shard_bitmap(sh->id);
|
||||
for (uint32_t w = 0; w < SLOTS_PER_SHARD / 64; w++) {
|
||||
if (bm[w] == UINT64_MAX) continue;
|
||||
uint32_t b = (uint32_t)__builtin_ctzll(~bm[w]);
|
||||
uint32_t i = w * 64 + b;
|
||||
if (i >= SLOTS_PER_SHARD) break;
|
||||
bm[w] |= (1ULL << b);
|
||||
atomic_fetch_add_explicit(&sh->used, 1, memory_order_relaxed);
|
||||
return (int)i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- SQE builders -- */
|
||||
|
||||
static void arm_accept(struct shard *sh) { /* multishot: arm once */
|
||||
struct io_uring_sqe *s = sqe_get(&sh->ring);
|
||||
s->opcode = IORING_OP_ACCEPT;
|
||||
s->fd = sh->lfd;
|
||||
s->ioprio = IORING_ACCEPT_MULTISHOT;
|
||||
s->user_data = ud(OP_ACCEPT, sh->lfd);
|
||||
}
|
||||
|
||||
static void arm_poll(struct shard *sh, int fd, int op) {
|
||||
struct io_uring_sqe *s = sqe_get(&sh->ring);
|
||||
s->opcode = IORING_OP_POLL_ADD;
|
||||
s->fd = fd;
|
||||
s->poll_events = POLLIN;
|
||||
s->user_data = ud(op, fd);
|
||||
}
|
||||
|
||||
static void arm_recv(struct shard *sh, int fd) {
|
||||
struct conn *c = &sh->conns[fd];
|
||||
struct io_uring_sqe *s = sqe_get(&sh->ring);
|
||||
s->opcode = IORING_OP_RECV;
|
||||
s->fd = fd;
|
||||
s->addr = (uint64_t)(uintptr_t)(c->in + c->in_len);
|
||||
s->len = (uint32_t)(IN_CAP - 1 - c->in_len);
|
||||
s->user_data = ud(OP_RECV, fd);
|
||||
}
|
||||
|
||||
static void arm_send(struct shard *sh, int fd) {
|
||||
struct conn *c = &sh->conns[fd];
|
||||
struct io_uring_sqe *s = sqe_get(&sh->ring);
|
||||
s->opcode = IORING_OP_SEND;
|
||||
s->fd = fd;
|
||||
s->addr = (uint64_t)(uintptr_t)(c->out + c->out_off);
|
||||
s->len = (uint32_t)(c->out_len - c->out_off);
|
||||
s->msg_flags = MSG_NOSIGNAL;
|
||||
s->user_data = ud(OP_SEND, fd);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ WAL flush --
|
||||
* Called once per loop tick. If commits were staged and no batch is in
|
||||
* flight, submit ONE write SQE for the whole batch at the shard's tail
|
||||
* offset, hard-linked to ONE fdatasync SQE. Every parked ack in the batch
|
||||
* is released when the fsync CQE arrives — group commit. */
|
||||
|
||||
static void wal_flush(struct shard *sh) {
|
||||
if (sh->in_flight) return;
|
||||
struct wal_batch *b = &sh->batch[sh->active];
|
||||
if (b->len == 0) return;
|
||||
|
||||
size_t off = atomic_load_explicit(&sh->wal_off, memory_order_relaxed);
|
||||
|
||||
struct io_uring_sqe *w = sqe_get(&sh->ring);
|
||||
w->opcode = IORING_OP_WRITE;
|
||||
w->fd = sh->wal_fd;
|
||||
w->addr = (uint64_t)(uintptr_t)b->buf;
|
||||
w->len = (uint32_t)b->len;
|
||||
w->off = off;
|
||||
w->flags = IOSQE_IO_LINK; /* fsync follows the write */
|
||||
w->user_data = ud(OP_WALWR, sh->active);
|
||||
|
||||
struct io_uring_sqe *f = sqe_get(&sh->ring);
|
||||
f->opcode = IORING_OP_FSYNC;
|
||||
f->fd = sh->wal_fd;
|
||||
f->fsync_flags = IORING_FSYNC_DATASYNC;
|
||||
f->user_data = ud(OP_FSYNC, sh->active);
|
||||
|
||||
sh->in_flight = 1;
|
||||
sh->active ^= 1; /* new commits stage in the twin */
|
||||
}
|
||||
|
||||
/* Stage one commit's frame + park the connection's ack on the active batch.
|
||||
* Returns 0 if the batch has no room (caller responds 503, no RAM apply). */
|
||||
static int wal_append(struct shard *sh, int connfd, uint32_t slot, int32_t id, const char *title) {
|
||||
struct wal_batch *b = &sh->batch[sh->active];
|
||||
if (b->len + WAL_FRAME_BYTES > WAL_BATCH_CAP || b->n_conns >= WAL_BATCH_CONNS)
|
||||
return 0;
|
||||
|
||||
struct wal_payload p;
|
||||
memset(&p, 0, sizeof p);
|
||||
p.op = 1;
|
||||
p.slot = slot;
|
||||
p.id = id;
|
||||
snprintf(p.title, sizeof p.title, "%s", title);
|
||||
b->gens[b->n_conns] = sh->conns[connfd].gen;
|
||||
|
||||
uint32_t len = (uint32_t)sizeof p;
|
||||
uint32_t crc = crc32(&p, sizeof p);
|
||||
uint32_t end = WAL_COMMIT;
|
||||
char *dst = b->buf + b->len;
|
||||
memcpy(dst, &len, 4);
|
||||
memcpy(dst + 4, &crc, 4);
|
||||
memcpy(dst + 8, &p, sizeof p);
|
||||
memcpy(dst + 8 + sizeof p, &end, 4);
|
||||
b->len += WAL_FRAME_BYTES;
|
||||
b->conns[b->n_conns++] = connfd;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- http -- */
|
||||
|
||||
static void respond(struct conn *c, const char *status, const char *ctype, const char *body) {
|
||||
size_t blen = strlen(body);
|
||||
int n = snprintf(c->out + c->out_len, OUT_CAP - c->out_len,
|
||||
"HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\nConnection: %s\r\n\r\n%s",
|
||||
status, ctype, blen, c->closing ? "close" : "keep-alive", body);
|
||||
if (n > 0 && (size_t)n < OUT_CAP - c->out_len) c->out_len += (size_t)n;
|
||||
else c->closing = 1; /* response too big — drop conn */
|
||||
}
|
||||
|
||||
static int json_title(const char *body, char *out, size_t cap) {
|
||||
const char *p = strstr(body, "\"title\"");
|
||||
if (!p) return 0;
|
||||
p = strchr(p + 7, ':'); if (!p) return 0;
|
||||
p = strchr(p, '"'); if (!p) return 0;
|
||||
p++;
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && i + 1 < cap) out[i++] = *p++;
|
||||
out[i] = 0;
|
||||
return i > 0;
|
||||
}
|
||||
|
||||
static void route(struct shard *sh, struct conn *c, const char *method, const char *path, const char *body) {
|
||||
atomic_fetch_add_explicit(&reqs[sh->id], 1, memory_order_relaxed);
|
||||
|
||||
if (!strcmp(method, "GET") && !strcmp(path, "/")) {
|
||||
char out[1024];
|
||||
size_t off = (size_t)snprintf(out, sizeof out,
|
||||
"{\"runtime\":\"wo-rt-c\",\"loop\":\"io_uring\",\"threads\":%d,\"shard\":%d,"
|
||||
"\"arena\":{\"bytes\":%zu,\"mapped\":%zu,\"hugepages\":%s,\"mlocked\":%s,"
|
||||
"\"slot_size\":%d,\"slots_per_shard\":%d},\"shard_used\":[",
|
||||
n_threads, sh->id, arena_bytes, arena_map_bytes,
|
||||
arena_huge ? "true" : "false", arena_locked ? "true" : "false",
|
||||
SLOT_SIZE, SLOTS_PER_SHARD);
|
||||
for (int t = 0; t < n_threads; t++)
|
||||
off += (size_t)snprintf(out + off, sizeof out - off, "%s%d", t ? "," : "",
|
||||
atomic_load_explicit(&shards[t].used, memory_order_relaxed));
|
||||
off += (size_t)snprintf(out + off, sizeof out - off, "],\"shard_requests\":[");
|
||||
for (int t = 0; t < n_threads; t++)
|
||||
off += (size_t)snprintf(out + off, sizeof out - off, "%s%lu", t ? "," : "",
|
||||
atomic_load_explicit(&reqs[t], memory_order_relaxed));
|
||||
off += (size_t)snprintf(out + off, sizeof out - off, "],\"wal_bytes\":[");
|
||||
for (int t = 0; t < n_threads; t++)
|
||||
off += (size_t)snprintf(out + off, sizeof out - off, "%s%zu", t ? "," : "",
|
||||
atomic_load_explicit(&shards[t].wal_off, memory_order_relaxed));
|
||||
snprintf(out + off, sizeof out - off, "]}");
|
||||
respond(c, "200 OK", "application/json", out);
|
||||
} else if (!strcmp(method, "GET") && !strcmp(path, "/healthz")) {
|
||||
respond(c, "200 OK", "text/plain", "ok");
|
||||
} else if (!strcmp(method, "GET") && !strcmp(path, "/api/notes")) {
|
||||
static _Thread_local char out[SLOTS_PER_SHARD * 160 + 64];
|
||||
uint64_t *bm = shard_bitmap(sh->id);
|
||||
size_t off = (size_t)snprintf(out, sizeof out, "{\"shard\":%d,\"notes\":[", sh->id);
|
||||
int first = 1;
|
||||
for (uint32_t i = 0; i < SLOTS_PER_SHARD; i++) {
|
||||
if (!(bm[i / 64] & (1ULL << (i % 64)))) continue;
|
||||
struct slot_note *n = slot_at(sh->id, i);
|
||||
off += (size_t)snprintf(out + off, sizeof out - off,
|
||||
"%s{\"id\":%d,\"title\":\"%s\"}", first ? "" : ",", n->id, n->title);
|
||||
first = 0;
|
||||
}
|
||||
snprintf(out + off, sizeof out - off, "]}");
|
||||
respond(c, "200 OK", "application/json", out);
|
||||
} else if (!strcmp(method, "POST") && !strcmp(path, "/api/notes")) {
|
||||
char title[128];
|
||||
int i;
|
||||
if (!json_title(body, title, sizeof title)) {
|
||||
respond(c, "400 Bad Request", "application/json", "{\"error\":\"expected {\\\"title\\\":\\\"...\\\"}\"}");
|
||||
return;
|
||||
}
|
||||
/* Capacity gates BEFORE the RAM apply — an aborted op writes nothing. */
|
||||
struct wal_batch *b = &sh->batch[sh->active];
|
||||
if (b->len + WAL_FRAME_BYTES > WAL_BATCH_CAP || b->n_conns >= WAL_BATCH_CONNS) {
|
||||
respond(c, "503 Service Unavailable", "application/json", "{\"error\":\"commit batch full, retry\"}");
|
||||
return;
|
||||
}
|
||||
if ((i = slot_alloc(sh)) < 0) {
|
||||
respond(c, "507 Insufficient Storage", "application/json", "{\"error\":\"shard full\"}");
|
||||
return;
|
||||
}
|
||||
struct slot_note *n = slot_at(sh->id, (uint32_t)i); /* 1. the RAM apply */
|
||||
n->id = sh->next_id;
|
||||
sh->next_id += n_threads;
|
||||
snprintf(n->title, sizeof n->title, "%s", title);
|
||||
char out[224];
|
||||
snprintf(out, sizeof out, "{\"id\":%d,\"title\":\"%s\",\"shard\":%d,\"slot\":%d}",
|
||||
n->id, n->title, sh->id, i);
|
||||
respond(c, "201 Created", "application/json", out); /* built, NOT sent */
|
||||
int connfd = (int)(c - sh->conns); /* conns is fd-indexed */
|
||||
wal_append(sh, connfd, (uint32_t)i, n->id, n->title);/* 2. stage WAL frame */
|
||||
c->await_durable = 1; /* 4. ack rides fsync */
|
||||
} else {
|
||||
respond(c, "404 Not Found", "application/json", "{\"error\":\"no such route\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- state machine ----- */
|
||||
|
||||
static void conn_open(struct shard *sh, int fd) {
|
||||
struct conn *c = &sh->conns[fd];
|
||||
c->in_len = c->out_len = c->out_off = 0;
|
||||
c->closing = 0;
|
||||
c->await_durable = 0;
|
||||
c->gen++; /* new incarnation — stale parked acks won't match */
|
||||
c->in_use = 1;
|
||||
}
|
||||
|
||||
static void conn_close(struct shard *sh, int fd) {
|
||||
if (fd >= 0 && fd < MAX_FDS) sh->conns[fd].in_use = 0;
|
||||
close(fd);
|
||||
}
|
||||
|
||||
/* Try to consume ONE complete request from the in buffer. Returns 1 if a
|
||||
* response was produced (out has bytes), 0 if the request is incomplete. */
|
||||
static int try_process(struct shard *sh, struct conn *c) {
|
||||
c->in[c->in_len] = 0;
|
||||
char *hdr_end = strstr(c->in, "\r\n\r\n");
|
||||
if (!hdr_end) return 0;
|
||||
char *body = hdr_end + 4;
|
||||
size_t total = (size_t)(body - c->in);
|
||||
|
||||
const char *cl = strcasestr(c->in, "Content-Length:");
|
||||
if (cl) {
|
||||
long want = strtol(cl + 15, NULL, 10);
|
||||
if (want < 0) want = 0;
|
||||
if (c->in_len < total + (size_t)want) return 0;
|
||||
total += (size_t)want;
|
||||
}
|
||||
|
||||
/* HTTP/1.1 defaults to keep-alive; honor an explicit close. */
|
||||
if (strcasestr(c->in, "connection: close") ||
|
||||
(strstr(c->in, "HTTP/1.0") && !strcasestr(c->in, "connection: keep-alive")))
|
||||
c->closing = 1;
|
||||
|
||||
char method[8] = {0}, path[256] = {0};
|
||||
if (sscanf(c->in, "%7s %255s", method, path) == 2)
|
||||
route(sh, c, method, path, body);
|
||||
else
|
||||
c->closing = 1;
|
||||
|
||||
memmove(c->in, c->in + total, c->in_len - total); /* carry pipelined tail */
|
||||
c->in_len -= total;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Advance a connection: drain out via SEND, else parse, else arm RECV.
|
||||
* A parked commit ack arms nothing — the fsync CQE handler resumes it. */
|
||||
static void conn_continue(struct shard *sh, int fd) {
|
||||
struct conn *c = &sh->conns[fd];
|
||||
if (c->await_durable) { return; }
|
||||
if (c->out_off < c->out_len) { arm_send(sh, fd); return; }
|
||||
c->out_off = c->out_len = 0;
|
||||
if (c->closing) { conn_close(sh, fd); return; }
|
||||
if (try_process(sh, c)) {
|
||||
/* route() may have JUST parked this response (await set inside
|
||||
* try_process) — sending now would race the fsync. The fsync CQE
|
||||
* re-enters here with await cleared and arms the send.
|
||||
* (Found by the phase-F crash-under-load test: ~6 acked-but-
|
||||
* unwritten records per ~750k at the kill instant.) */
|
||||
if (!c->await_durable) arm_send(sh, fd);
|
||||
return;
|
||||
}
|
||||
if (c->in_len >= IN_CAP - 1) { conn_close(sh, fd); return; } /* oversize head */
|
||||
arm_recv(sh, fd);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- recovery --
|
||||
* First load: hard drive → RAM, per shard, in parallel, BEFORE accept arms.
|
||||
* Snapshot (if any) restores the slice wholesale; the WAL tail replays
|
||||
* commits since that snapshot. Frame validation is wal-check's logic with
|
||||
* the printf swapped for the arena apply. */
|
||||
|
||||
static long now_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
static int snap_load(struct shard *sh) {
|
||||
int fd = open(sh->snap_path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return 0;
|
||||
struct snap_hdr h;
|
||||
size_t slot_bytes = (size_t)SLOTS_PER_SHARD * SLOT_SIZE;
|
||||
if (read(fd, &h, sizeof h) != (ssize_t)sizeof h ||
|
||||
memcmp(h.magic, "WOSNAP\0", 8) != 0 ||
|
||||
pread(fd, shard_bitmap(sh->id), bitmap_bytes, (off_t)sizeof h) != (ssize_t)bitmap_bytes ||
|
||||
pread(fd, shard_slots(sh->id), slot_bytes, (off_t)(sizeof h + bitmap_bytes)) != (ssize_t)slot_bytes) {
|
||||
fprintf(stderr, "[wo-rt-c] shard %d: snapshot unreadable — starting from WAL only\n", sh->id);
|
||||
memset(shard_bitmap(sh->id), 0, bitmap_bytes + slot_bytes);
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
sh->next_id = h.next_id;
|
||||
atomic_store_explicit(&sh->used, h.used, memory_order_relaxed);
|
||||
close(fd);
|
||||
return h.used;
|
||||
}
|
||||
|
||||
static int wal_replay(struct shard *sh) {
|
||||
size_t off = 0;
|
||||
int recs = 0;
|
||||
int32_t maxid = 0;
|
||||
for (;;) {
|
||||
uint32_t len, crc, end;
|
||||
struct wal_payload p;
|
||||
if (pread(sh->wal_fd, &len, 4, (off_t)off) != 4 || len == 0) break;
|
||||
if (len != sizeof p ||
|
||||
pread(sh->wal_fd, &crc, 4, (off_t)(off + 4)) != 4 ||
|
||||
pread(sh->wal_fd, &p, sizeof p, (off_t)(off + 8)) != (ssize_t)sizeof p ||
|
||||
pread(sh->wal_fd, &end, 4, (off_t)(off + 8 + sizeof p)) != 4 ||
|
||||
crc32(&p, sizeof p) != crc || end != WAL_COMMIT) {
|
||||
fprintf(stderr, "[wo-rt-c] shard %d: torn WAL record at byte %zu — truncating\n",
|
||||
sh->id, off);
|
||||
break;
|
||||
}
|
||||
if (p.op == 1 && p.slot < SLOTS_PER_SHARD) { /* idempotent apply */
|
||||
uint64_t *bm = shard_bitmap(sh->id);
|
||||
if (!(bm[p.slot / 64] & (1ULL << (p.slot % 64)))) {
|
||||
bm[p.slot / 64] |= (1ULL << (p.slot % 64));
|
||||
atomic_fetch_add_explicit(&sh->used, 1, memory_order_relaxed);
|
||||
}
|
||||
struct slot_note *n = slot_at(sh->id, p.slot);
|
||||
n->id = p.id;
|
||||
snprintf(n->title, sizeof n->title, "%s", p.title);
|
||||
if (p.id > maxid) maxid = p.id;
|
||||
}
|
||||
recs++;
|
||||
off += WAL_FRAME_BYTES;
|
||||
}
|
||||
/* Resume appends at the validated tail; drop torn bytes, re-preallocate. */
|
||||
if (ftruncate(sh->wal_fd, (off_t)off) == 0)
|
||||
(void)!fallocate(sh->wal_fd, 0, 0, off > WAL_PREALLOC ? off : WAL_PREALLOC);
|
||||
atomic_store_explicit(&sh->wal_off, off, memory_order_relaxed);
|
||||
if (maxid > 0 && maxid + n_threads > sh->next_id)
|
||||
sh->next_id = maxid + n_threads; /* interleaved high-water */
|
||||
return recs;
|
||||
}
|
||||
|
||||
/* Clean-shutdown snapshot: write slice → fsync → atomic rename → truncate WAL. */
|
||||
static void snap_write(struct shard *sh) {
|
||||
char tmp[336];
|
||||
snprintf(tmp, sizeof tmp, "%s.tmp", sh->snap_path);
|
||||
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
if (fd < 0) { perror("snapshot open"); return; }
|
||||
struct snap_hdr h;
|
||||
memset(&h, 0, sizeof h);
|
||||
memcpy(h.magic, "WOSNAP\0", 8);
|
||||
h.version = 1;
|
||||
h.next_id = sh->next_id;
|
||||
h.used = atomic_load_explicit(&sh->used, memory_order_relaxed);
|
||||
size_t slot_bytes = (size_t)SLOTS_PER_SHARD * SLOT_SIZE;
|
||||
int ok = write(fd, &h, sizeof h) == (ssize_t)sizeof h
|
||||
&& write(fd, shard_bitmap(sh->id), bitmap_bytes) == (ssize_t)bitmap_bytes
|
||||
&& write(fd, shard_slots(sh->id), slot_bytes) == (ssize_t)slot_bytes
|
||||
&& fsync(fd) == 0;
|
||||
close(fd);
|
||||
if (!ok || rename(tmp, sh->snap_path) < 0) { fprintf(stderr, "[wo-rt-c] shard %d: snapshot failed\n", sh->id); unlink(tmp); return; }
|
||||
if (ftruncate(sh->wal_fd, 0) == 0) { /* WAL now redundant */
|
||||
(void)!fallocate(sh->wal_fd, 0, 0, WAL_PREALLOC);
|
||||
fsync(sh->wal_fd);
|
||||
}
|
||||
printf("[wo-rt-c] shard %d: snapshot %d rows → %s, wal truncated\n", sh->id, h.used, sh->snap_path);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ shard loop -- */
|
||||
|
||||
static void *shard_main(void *arg) {
|
||||
struct shard *sh = arg;
|
||||
struct ring *r = &sh->ring;
|
||||
|
||||
cpu_set_t set;
|
||||
CPU_ZERO(&set);
|
||||
CPU_SET((unsigned)sh->id % (unsigned)sysconf(_SC_NPROCESSORS_ONLN), &set);
|
||||
pthread_setaffinity_np(pthread_self(), sizeof set, &set);
|
||||
|
||||
/* First load: disk → RAM, before any accept is armed. */
|
||||
long t0 = now_ms();
|
||||
int srows = snap_load(sh);
|
||||
int wrecs = wal_replay(sh);
|
||||
if (srows || wrecs)
|
||||
printf("[wo-rt-c] shard %d: recovered %d snapshot rows + %d wal records in %ld ms\n",
|
||||
sh->id, srows, wrecs, now_ms() - t0);
|
||||
|
||||
arm_accept(sh);
|
||||
arm_poll(sh, sh->evfd, OP_EVFD);
|
||||
if (sh->id == 0) arm_poll(sh, sigfd, OP_SIGFD);
|
||||
|
||||
for (;;) {
|
||||
if (ring_enter(r, 1) < 0) break; /* ONE syscall per tick */
|
||||
|
||||
unsigned head = *r->cq_head;
|
||||
unsigned tail = __atomic_load_n(r->cq_tail, __ATOMIC_ACQUIRE);
|
||||
for (; head != tail; head++) {
|
||||
struct io_uring_cqe *cqe = &r->cqes[head & *r->cq_mask];
|
||||
int op = (int)(cqe->user_data >> 32);
|
||||
int fd = (int)(uint32_t)cqe->user_data;
|
||||
int res = cqe->res;
|
||||
|
||||
switch (op) {
|
||||
case OP_EVFD: {
|
||||
uint64_t v;
|
||||
(void)!read(sh->evfd, &v, sizeof v);
|
||||
__atomic_store_n(r->cq_head, head + 1, __ATOMIC_RELEASE);
|
||||
return NULL;
|
||||
}
|
||||
case OP_SIGFD: { /* shard 0 only */
|
||||
struct signalfd_siginfo si;
|
||||
if (read(sigfd, &si, sizeof si) == sizeof si)
|
||||
printf("\n[wo-rt-c] signal %u — broadcasting shutdown to %d shards\n",
|
||||
si.ssi_signo, n_threads);
|
||||
uint64_t one = 1;
|
||||
for (int t = 0; t < n_threads; t++)
|
||||
(void)!write(shards[t].evfd, &one, sizeof one);
|
||||
break;
|
||||
}
|
||||
case OP_ACCEPT: {
|
||||
if (res >= 0) {
|
||||
int cfd = res;
|
||||
if (cfd >= MAX_FDS) close(cfd);
|
||||
else { conn_open(sh, cfd); arm_recv(sh, cfd); }
|
||||
}
|
||||
if (!(cqe->flags & IORING_CQE_F_MORE)) arm_accept(sh); /* re-arm */
|
||||
break;
|
||||
}
|
||||
case OP_RECV: {
|
||||
struct conn *c = &sh->conns[fd];
|
||||
if (!c->in_use) break;
|
||||
if (res <= 0) { conn_close(sh, fd); break; }
|
||||
c->in_len += (size_t)res;
|
||||
conn_continue(sh, fd);
|
||||
break;
|
||||
}
|
||||
case OP_SEND: {
|
||||
struct conn *c = &sh->conns[fd];
|
||||
if (!c->in_use) break;
|
||||
if (res <= 0) { conn_close(sh, fd); break; }
|
||||
c->out_off += (size_t)res;
|
||||
conn_continue(sh, fd);
|
||||
break;
|
||||
}
|
||||
case OP_WALWR: { /* fd field carries the batch idx */
|
||||
struct wal_batch *b = &sh->batch[fd];
|
||||
if (res != (int)b->len)
|
||||
fprintf(stderr, "[wo-rt-c] shard %d: WAL write %d != %zu\n", sh->id, res, b->len);
|
||||
else
|
||||
atomic_fetch_add_explicit(&sh->wal_off, b->len, memory_order_relaxed);
|
||||
break;
|
||||
}
|
||||
case OP_FSYNC: { /* group commit lands: release acks */
|
||||
struct wal_batch *b = &sh->batch[fd];
|
||||
int failed = (res < 0); /* incl. -ECANCELED from a failed link */
|
||||
if (failed)
|
||||
fprintf(stderr, "[wo-rt-c] shard %d: fsync failed (%d) — dropping %d acks\n",
|
||||
sh->id, res, b->n_conns);
|
||||
for (int k = 0; k < b->n_conns; k++) {
|
||||
int cfd = b->conns[k];
|
||||
if (cfd < 0 || cfd >= MAX_FDS) continue;
|
||||
struct conn *c = &sh->conns[cfd];
|
||||
if (!c->in_use || !c->await_durable) continue;
|
||||
if (c->gen != b->gens[k]) continue; /* fd reused — not ours */
|
||||
c->await_durable = 0;
|
||||
if (failed) conn_close(sh, cfd); /* never ack non-durable */
|
||||
else conn_continue(sh, cfd);
|
||||
}
|
||||
b->len = 0;
|
||||
b->n_conns = 0;
|
||||
sh->in_flight = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
__atomic_store_n(r->cq_head, head, __ATOMIC_RELEASE);
|
||||
wal_flush(sh); /* one write→fsync pair per tick */
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- listener -- */
|
||||
|
||||
static int listener_bind(uint16_t port) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) { perror("socket"); return -1; }
|
||||
int one = 1;
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
|
||||
struct sockaddr_in addr = {0};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) < 0) { perror("bind"); close(fd); return -1; }
|
||||
if (listen(fd, SOMAXCONN) < 0) { perror("listen"); close(fd); return -1; }
|
||||
return fd;
|
||||
}
|
||||
|
||||
static int sig_setup(void) {
|
||||
sigset_t mask;
|
||||
sigemptyset(&mask);
|
||||
sigaddset(&mask, SIGINT);
|
||||
sigaddset(&mask, SIGTERM);
|
||||
if (sigprocmask(SIG_BLOCK, &mask, NULL) < 0) { perror("sigprocmask"); return -1; }
|
||||
int fd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
|
||||
if (fd < 0) perror("signalfd");
|
||||
return fd;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ wal-check --
|
||||
* Offline frame walker: validates every record's len/CRC/COMMIT trailer,
|
||||
* reports the count and where (if anywhere) the log tears. This is the
|
||||
* crash test's witness, and the skeleton of phase E's replay loop. */
|
||||
|
||||
static int wal_check(const char *path) {
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0) { fprintf(stderr, "wal-check: %s: %s\n", path, strerror(errno)); return 1; }
|
||||
size_t off = 0;
|
||||
int recs = 0;
|
||||
for (;;) {
|
||||
uint32_t len, crc, end;
|
||||
struct wal_payload p;
|
||||
if (pread(fd, &len, 4, (off_t)off) != 4) break;
|
||||
if (len == 0) break; /* fallocate'd tail */
|
||||
if (len != sizeof p) {
|
||||
printf("%s: TORN at byte %zu (bad len %u) — %d whole records before it\n",
|
||||
path, off, len, recs);
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
if (pread(fd, &crc, 4, (off_t)(off + 4)) != 4 ||
|
||||
pread(fd, &p, sizeof p, (off_t)(off + 8)) != (ssize_t)sizeof p ||
|
||||
pread(fd, &end, 4, (off_t)(off + 8 + sizeof p)) != 4 ||
|
||||
crc32(&p, sizeof p) != crc || end != WAL_COMMIT) {
|
||||
printf("%s: TORN at byte %zu (bad crc/trailer) — %d whole records before it\n",
|
||||
path, off, recs);
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
printf("%s: rec %d op=%u slot=%u id=%d title=\"%s\"\n", path, recs, p.op, p.slot, p.id, p.title);
|
||||
recs++;
|
||||
off += WAL_FRAME_BYTES;
|
||||
}
|
||||
printf("%s: %d records, all frames valid, clean tail at byte %zu\n", path, recs, off);
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- main -- */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
crc32_init();
|
||||
|
||||
if (argc >= 3 && !strcmp(argv[1], "wal-check")) {
|
||||
int rc = 0;
|
||||
for (int a = 2; a < argc; a++) rc |= wal_check(argv[a]);
|
||||
return rc;
|
||||
}
|
||||
|
||||
uint16_t port = 8085;
|
||||
const char *env = getenv("WO_PORT");
|
||||
if (env && atoi(env) > 0) port = (uint16_t)atoi(env);
|
||||
|
||||
long cores = sysconf(_SC_NPROCESSORS_ONLN);
|
||||
n_threads = (int)cores;
|
||||
env = getenv("WO_THREADS");
|
||||
if (env && atoi(env) > 0) n_threads = atoi(env);
|
||||
if (n_threads < 1) n_threads = 1;
|
||||
if (n_threads > MAX_THREADS) n_threads = MAX_THREADS;
|
||||
|
||||
/* Million-connection posture: lift the fd ceiling to the hard max. */
|
||||
struct rlimit rl;
|
||||
if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < rl.rlim_max) {
|
||||
rl.rlim_cur = rl.rlim_max;
|
||||
setrlimit(RLIMIT_NOFILE, &rl);
|
||||
}
|
||||
|
||||
sigfd = sig_setup();
|
||||
if (sigfd < 0) return 1;
|
||||
if (arena_init(n_threads) < 0) return 1;
|
||||
|
||||
const char *data_dir = getenv("WO_DATA");
|
||||
if (!data_dir || !*data_dir) data_dir = "./wo-data";
|
||||
if (mkdir(data_dir, 0755) < 0 && errno != EEXIST) { perror("mkdir data dir"); return 1; }
|
||||
|
||||
/* The data dir is sharded for exactly n_threads. A different WO_THREADS
|
||||
* would strand WAL/snapshot files silently — refuse (resharding = 09f). */
|
||||
char mpath[512];
|
||||
snprintf(mpath, sizeof mpath, "%s/meta", data_dir);
|
||||
FILE *mf = fopen(mpath, "r");
|
||||
if (mf) {
|
||||
int prev = 0;
|
||||
if (fscanf(mf, "%d", &prev) == 1 && prev != n_threads) {
|
||||
fprintf(stderr, "[wo-rt-c] %s was written with WO_THREADS=%d — restart with that, or wipe the dir\n",
|
||||
data_dir, prev);
|
||||
fclose(mf);
|
||||
return 1;
|
||||
}
|
||||
fclose(mf);
|
||||
} else if ((mf = fopen(mpath, "w"))) {
|
||||
fprintf(mf, "%d\n", n_threads);
|
||||
fclose(mf);
|
||||
}
|
||||
|
||||
shards = calloc((size_t)n_threads, sizeof(struct shard));
|
||||
if (!shards) { perror("calloc"); return 1; }
|
||||
|
||||
for (int t = 0; t < n_threads; t++) {
|
||||
struct shard *sh = &shards[t];
|
||||
sh->id = t;
|
||||
sh->next_id = t + 1;
|
||||
sh->lfd = listener_bind(port);
|
||||
sh->evfd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
|
||||
if (sh->lfd < 0 || sh->evfd < 0) return 1;
|
||||
if (ring_init(&sh->ring) < 0) return 1;
|
||||
|
||||
/* Per-shard WAL, preallocated, NOT truncated — boot replays it. */
|
||||
char path[512];
|
||||
snprintf(path, sizeof path, "%s/shard-%d.wal", data_dir, t);
|
||||
sh->wal_fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0644);
|
||||
if (sh->wal_fd < 0) { perror("open wal"); return 1; }
|
||||
if (fallocate(sh->wal_fd, 0, 0, WAL_PREALLOC) < 0)
|
||||
fprintf(stderr, "[wo-rt-c] warn: fallocate %s refused (%s)\n", path, strerror(errno));
|
||||
snprintf(sh->snap_path, sizeof sh->snap_path, "%s/shard-%d.data", data_dir, t);
|
||||
}
|
||||
|
||||
printf("[wo-rt-c] %d shard%s on http://127.0.0.1:%u — io_uring loops, keep-alive — arena %zu KB (%s pages, %s) — ctrl-C to stop\n",
|
||||
n_threads, n_threads == 1 ? "" : "s", port,
|
||||
arena_map_bytes / 1024,
|
||||
arena_huge ? "2M huge" : "4K",
|
||||
arena_locked ? "mlocked" : "NOT locked");
|
||||
printf(" GET / runtime + arena info, per-shard stats\n GET /healthz liveness\n");
|
||||
printf(" GET /api/notes list (connection's shard)\n POST /api/notes create {\"title\":\"...\"}\n");
|
||||
fflush(stdout);
|
||||
|
||||
for (int t = 0; t < n_threads; t++)
|
||||
pthread_create(&shards[t].tid, NULL, shard_main, &shards[t]);
|
||||
for (int t = 0; t < n_threads; t++)
|
||||
pthread_join(shards[t].tid, NULL);
|
||||
|
||||
/* Clean shutdown: persist each slice as a snapshot, truncate the WALs.
|
||||
* A kill -9 skips this — that's what boot-time WAL replay is for. */
|
||||
for (int t = 0; t < n_threads; t++)
|
||||
snap_write(&shards[t]);
|
||||
|
||||
printf("[wo-rt-c] all %d shards joined — bye\n", n_threads);
|
||||
for (int t = 0; t < n_threads; t++) {
|
||||
close(shards[t].ring.fd); close(shards[t].lfd); close(shards[t].evfd);
|
||||
close(shards[t].wal_fd);
|
||||
}
|
||||
close(sigfd);
|
||||
munmap(arena, arena_map_bytes);
|
||||
free(shards);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# `reference/` — archived for reference
|
||||
|
||||
Source material preserved alongside the active codebase. Nothing here is on the build path of the root workspace.
|
||||
|
||||
## `reference/crates/` — v1 writeonce blog
|
||||
|
||||
The original writeonce blog engine: 13 Rust crates implementing a flat-file content store, three sidecar indexes, a minimal HTTP server, and an `.htmlx` template layer.
|
||||
|
||||
| Crate | Responsibility |
|
||||
| --- | --- |
|
||||
| `wo-model` | `Article` domain type + content loader |
|
||||
| `wo-seg` | Append-only `.seg` binary file format (~475 LOC) |
|
||||
| `wo-index` | `title.idx`, `date.idx`, `tags.idx` sidecar indexes |
|
||||
| `wo-store` | Composes seg + indexes, exposes the query API |
|
||||
| `wo-watch` | inotify-driven content ingest |
|
||||
| `wo-event` | Domain event type |
|
||||
| `wo-sub` | Subscription layer (pre-engine-native `LIVE`) |
|
||||
| `wo-rt` | v1 runtime glue |
|
||||
| `wo-http`, `wo-route`, `wo-serve` | HTTP server |
|
||||
| `wo-htmlx` | `.htmlx` template engine |
|
||||
| `wo-md` | Markdown rendering |
|
||||
|
||||
It is a nested workspace. Build or test it standalone:
|
||||
|
||||
```bash
|
||||
cd reference/crates
|
||||
cargo build
|
||||
cargo test
|
||||
```
|
||||
|
||||
See [docs/runtime/database/07-wo-seg-migration.md](../docs/runtime/database/07-wo-seg-migration.md) for the plan to replace these crates with the new `.wo` runtime at `crates/wo-rt/` via phased coexistence. The v1 codebase is the source that migration reads from.
|
||||
|
||||
## `reference/writeonce-api/` and `reference/writeonce-app/`
|
||||
|
||||
Earlier exploration snapshots that predate the v1 crates. Self-contained; no active build wiring.
|
||||
|
||||
## Research source trees (symlinks)
|
||||
|
||||
Gitignored, user-specific absolute paths — each contributor sets their own:
|
||||
|
||||
```bash
|
||||
ln -s <path-to-linux-src> reference/linux
|
||||
ln -s <path-to-go-src> reference/go
|
||||
ln -s <path-to-postgresql-src> reference/postgresql
|
||||
ln -s <path-to-mcp-python-sdk-src> reference/mcp-python-sdk # https://github.com/modelcontextprotocol/python-sdk.git
|
||||
```
|
||||
|
||||
- **`reference/linux/`** — the Linux kernel source. Read `io_uring/`, `fs/notify/inotify/`, `kernel/eventfd.c`, `include/uapi/linux/*.h` when designing kernel-primitive modules. See per-primitive reference cards under [`docs/plan/linux/`](../docs/plan/linux/).
|
||||
- **`reference/go/`** — the Go source tree. Read `src/runtime/netpoll_epoll.go`, `src/runtime/netpoll.go`, `src/runtime/asm_*.s`, `src/runtime/sys_linux_*.s` when designing the runtime layer — writeonce's `crates/rt/src/runtime/` mirrors Go's `src/runtime/` file-per-flavour naming. See [`docs/plan/assembly/`](../docs/plan/assembly/) for the asm policy doc that cites this tree.
|
||||
- **`reference/postgresql/`** — the PostgreSQL source tree, for database-engine research.
|
||||
- **`reference/mcp-python-sdk/`** — the official MCP Python SDK, the reference implementation for [plan 15](../docs/plan/15-mcp-streamable-http.md) (MCP over Streamable HTTP). Read `src/mcp/server/streamable_http.py` (transport server: POST/GET handling, SSE framing, session validation, `Last-Event-ID` replay) and `src/mcp/server/streamable_http_manager.py` (session lifecycle) when implementing the server side; `src/mcp/client/streamable_http.py` shows what a conforming client expects. writeonce ports the *behaviour*, not the code — the Rust implementation stays hand-rolled per the zero-deps doctrine.
|
||||
3
reference/crates/.gitignore
vendored
3
reference/crates/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
# Nested v1 workspace — has its own Cargo.lock and target dir.
|
||||
/target
|
||||
Cargo.lock
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# Nested workspace for the v1 writeonce blog crates.
|
||||
#
|
||||
# The root workspace at /Cargo.toml drives the new `.wo` runtime at crates/wo-rt.
|
||||
# These thirteen crates are the previous generation of writeonce — the flat-file
|
||||
# blog engine described in docs/runtime/database/07-wo-seg-migration.md. They
|
||||
# are preserved here for reference: source is readable, tests still run, and
|
||||
# the Phase 7 migration plan assumes a gradual trait-abstracted coexistence
|
||||
# rather than deletion.
|
||||
#
|
||||
# To build the v1 system standalone:
|
||||
# cd reference/crates && cargo build
|
||||
# cd reference/crates && cargo test
|
||||
#
|
||||
# The v1 crates are deliberately NOT members of the root workspace. Excluding
|
||||
# them avoids the `wo-rt` name collision (both v1 and v2 claim that name) and
|
||||
# keeps the root build fast while the new runtime is under active development.
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"wo-model",
|
||||
"wo-seg",
|
||||
"wo-index",
|
||||
"wo-store",
|
||||
"wo-watch",
|
||||
"wo-event",
|
||||
"wo-sub",
|
||||
"wo-rt",
|
||||
"wo-http",
|
||||
"wo-route",
|
||||
"wo-htmlx",
|
||||
"wo-md",
|
||||
"wo-serve",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bincode = "1"
|
||||
libc = "0.2"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
[package]
|
||||
name = "wo-event"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
libc = { workspace = true }
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Caller-assigned identifier for a registered file descriptor.
|
||||
pub type Token = u64;
|
||||
|
||||
/// Interest flags for epoll registration.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Interest {
|
||||
Readable,
|
||||
Writable,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
impl Interest {
|
||||
fn to_epoll_flags(self) -> u32 {
|
||||
match self {
|
||||
Interest::Readable => libc::EPOLLIN as u32,
|
||||
Interest::Writable => libc::EPOLLOUT as u32,
|
||||
Interest::ReadWrite => (libc::EPOLLIN | libc::EPOLLOUT) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An event delivered by the event loop.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Event {
|
||||
pub token: Token,
|
||||
pub readable: bool,
|
||||
pub writable: bool,
|
||||
pub error: bool,
|
||||
pub hangup: bool,
|
||||
}
|
||||
|
||||
/// Single-threaded event loop built on epoll.
|
||||
pub struct EventLoop {
|
||||
epoll_fd: RawFd,
|
||||
}
|
||||
|
||||
impl EventLoop {
|
||||
/// Create a new event loop.
|
||||
pub fn new() -> io::Result<Self> {
|
||||
let fd = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(Self { epoll_fd: fd })
|
||||
}
|
||||
|
||||
/// Register a file descriptor for the given interest.
|
||||
pub fn register(&self, fd: RawFd, interest: Interest, token: Token) -> io::Result<()> {
|
||||
let mut event = libc::epoll_event {
|
||||
events: interest.to_epoll_flags() | libc::EPOLLRDHUP as u32,
|
||||
u64: token,
|
||||
};
|
||||
let ret = unsafe { libc::epoll_ctl(self.epoll_fd, libc::EPOLL_CTL_ADD, fd, &mut event) };
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify interest for an already-registered fd.
|
||||
pub fn modify(&self, fd: RawFd, interest: Interest, token: Token) -> io::Result<()> {
|
||||
let mut event = libc::epoll_event {
|
||||
events: interest.to_epoll_flags() | libc::EPOLLRDHUP as u32,
|
||||
u64: token,
|
||||
};
|
||||
let ret = unsafe { libc::epoll_ctl(self.epoll_fd, libc::EPOLL_CTL_MOD, fd, &mut event) };
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a file descriptor from the event loop.
|
||||
pub fn deregister(&self, fd: RawFd) -> io::Result<()> {
|
||||
let ret = unsafe {
|
||||
libc::epoll_ctl(self.epoll_fd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut())
|
||||
};
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for events. Returns when at least one event is ready or timeout expires.
|
||||
///
|
||||
/// `timeout`: `None` blocks indefinitely, `Some(duration)` sets a timeout.
|
||||
/// Returns up to 64 events per call.
|
||||
pub fn poll(&self, timeout: Option<Duration>) -> io::Result<Vec<Event>> {
|
||||
let timeout_ms = match timeout {
|
||||
None => -1i32,
|
||||
Some(d) => d.as_millis() as i32,
|
||||
};
|
||||
|
||||
let mut events = [libc::epoll_event { events: 0, u64: 0 }; 64];
|
||||
let n = unsafe {
|
||||
libc::epoll_wait(self.epoll_fd, events.as_mut_ptr(), events.len() as i32, timeout_ms)
|
||||
};
|
||||
|
||||
if n < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
// EINTR is not an error — just return empty.
|
||||
if err.raw_os_error() == Some(libc::EINTR) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let result = (0..n as usize)
|
||||
.map(|i| {
|
||||
let e = events[i].events;
|
||||
Event {
|
||||
token: events[i].u64,
|
||||
readable: (e & libc::EPOLLIN as u32) != 0,
|
||||
writable: (e & libc::EPOLLOUT as u32) != 0,
|
||||
error: (e & libc::EPOLLERR as u32) != 0,
|
||||
hangup: (e & (libc::EPOLLHUP | libc::EPOLLRDHUP) as u32) != 0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get the raw epoll file descriptor.
|
||||
pub fn fd(&self) -> RawFd {
|
||||
self.epoll_fd
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EventLoop {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.epoll_fd) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::EventFd;
|
||||
|
||||
#[test]
|
||||
fn register_and_poll_eventfd() {
|
||||
let eloop = EventLoop::new().unwrap();
|
||||
let efd = EventFd::new().unwrap();
|
||||
|
||||
eloop.register(efd.fd(), Interest::Readable, 42).unwrap();
|
||||
|
||||
// Write to eventfd from the same thread.
|
||||
efd.write(1).unwrap();
|
||||
|
||||
let events = eloop.poll(Some(Duration::from_millis(100))).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].token, 42);
|
||||
assert!(events[0].readable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_timeout_no_events() {
|
||||
let eloop = EventLoop::new().unwrap();
|
||||
let events = eloop.poll(Some(Duration::from_millis(10))).unwrap();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deregister() {
|
||||
let eloop = EventLoop::new().unwrap();
|
||||
let efd = EventFd::new().unwrap();
|
||||
|
||||
eloop.register(efd.fd(), Interest::Readable, 1).unwrap();
|
||||
eloop.deregister(efd.fd()).unwrap();
|
||||
|
||||
efd.write(1).unwrap();
|
||||
let events = eloop.poll(Some(Duration::from_millis(10))).unwrap();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
|
||||
/// Wrapper around Linux eventfd(2) for lightweight signaling.
|
||||
pub struct EventFd {
|
||||
fd: RawFd,
|
||||
}
|
||||
|
||||
impl EventFd {
|
||||
/// Create a new eventfd with initial value 0.
|
||||
pub fn new() -> io::Result<Self> {
|
||||
let fd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(Self { fd })
|
||||
}
|
||||
|
||||
/// Write a value to the eventfd (signals waiters).
|
||||
pub fn write(&self, val: u64) -> io::Result<()> {
|
||||
let buf = val.to_ne_bytes();
|
||||
let ret = unsafe { libc::write(self.fd, buf.as_ptr() as *const libc::c_void, 8) };
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current value (resets to 0).
|
||||
pub fn read(&self) -> io::Result<u64> {
|
||||
let mut buf = [0u8; 8];
|
||||
let ret = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, 8) };
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(u64::from_ne_bytes(buf))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the raw file descriptor for epoll registration.
|
||||
pub fn fd(&self) -> RawFd {
|
||||
self.fd
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EventFd {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn write_and_read() {
|
||||
let efd = EventFd::new().unwrap();
|
||||
efd.write(5).unwrap();
|
||||
efd.write(3).unwrap();
|
||||
// eventfd accumulates writes.
|
||||
let val = efd.read().unwrap();
|
||||
assert_eq!(val, 8);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
mod epoll;
|
||||
mod eventfd;
|
||||
mod timerfd;
|
||||
mod signalfd;
|
||||
|
||||
pub use epoll::{EventLoop, Event, Interest, Token};
|
||||
pub use eventfd::EventFd;
|
||||
pub use timerfd::TimerFd;
|
||||
pub use signalfd::SignalFd;
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
|
||||
/// Wrapper around Linux signalfd(2) for handling signals via fd.
|
||||
///
|
||||
/// Blocks SIGINT and SIGTERM in the process signal mask and delivers
|
||||
/// them as readable events on the fd instead.
|
||||
pub struct SignalFd {
|
||||
fd: RawFd,
|
||||
}
|
||||
|
||||
impl SignalFd {
|
||||
/// Create a signalfd that catches SIGINT and SIGTERM.
|
||||
///
|
||||
/// Also blocks these signals in the process signal mask so they
|
||||
/// don't trigger default handlers.
|
||||
pub fn new() -> io::Result<Self> {
|
||||
let mut mask: libc::sigset_t = unsafe { std::mem::zeroed() };
|
||||
unsafe {
|
||||
libc::sigemptyset(&mut mask);
|
||||
libc::sigaddset(&mut mask, libc::SIGINT);
|
||||
libc::sigaddset(&mut mask, libc::SIGTERM);
|
||||
|
||||
// Block these signals so they go to signalfd instead.
|
||||
let ret = libc::pthread_sigmask(libc::SIG_BLOCK, &mask, std::ptr::null_mut());
|
||||
if ret != 0 {
|
||||
return Err(io::Error::from_raw_os_error(ret));
|
||||
}
|
||||
}
|
||||
|
||||
let fd = unsafe { libc::signalfd(-1, &mask, libc::SFD_NONBLOCK | libc::SFD_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(Self { fd })
|
||||
}
|
||||
|
||||
/// Read a pending signal. Returns the signal number (e.g., SIGINT = 2).
|
||||
pub fn read(&self) -> io::Result<i32> {
|
||||
let mut info: libc::signalfd_siginfo = unsafe { std::mem::zeroed() };
|
||||
let size = std::mem::size_of::<libc::signalfd_siginfo>();
|
||||
let ret = unsafe {
|
||||
libc::read(self.fd, &mut info as *mut _ as *mut libc::c_void, size)
|
||||
};
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(info.ssi_signo as i32)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fd(&self) -> RawFd {
|
||||
self.fd
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SignalFd {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Wrapper around Linux timerfd_create(2) for timer-as-fd.
|
||||
pub struct TimerFd {
|
||||
fd: RawFd,
|
||||
}
|
||||
|
||||
impl TimerFd {
|
||||
/// Create a new monotonic timer fd.
|
||||
pub fn new() -> io::Result<Self> {
|
||||
let fd = unsafe {
|
||||
libc::timerfd_create(
|
||||
libc::CLOCK_MONOTONIC,
|
||||
libc::TFD_NONBLOCK | libc::TFD_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(Self { fd })
|
||||
}
|
||||
|
||||
/// Arm the timer to fire once after `initial` and then repeat every `interval`.
|
||||
///
|
||||
/// Pass `Duration::ZERO` for `interval` for a one-shot timer.
|
||||
pub fn set(&self, initial: Duration, interval: Duration) -> io::Result<()> {
|
||||
let spec = libc::itimerspec {
|
||||
it_interval: duration_to_timespec(interval),
|
||||
it_value: duration_to_timespec(initial),
|
||||
};
|
||||
let ret =
|
||||
unsafe { libc::timerfd_settime(self.fd, 0, &spec, std::ptr::null_mut()) };
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the number of expirations since last read.
|
||||
pub fn read(&self) -> io::Result<u64> {
|
||||
let mut buf = [0u8; 8];
|
||||
let ret = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, 8) };
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(u64::from_ne_bytes(buf))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fd(&self) -> RawFd {
|
||||
self.fd
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TimerFd {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_to_timespec(d: Duration) -> libc::timespec {
|
||||
libc::timespec {
|
||||
tv_sec: d.as_secs() as libc::time_t,
|
||||
tv_nsec: d.subsec_nanos() as libc::c_long,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EventLoop, Interest};
|
||||
|
||||
#[test]
|
||||
fn timer_fires() {
|
||||
let eloop = EventLoop::new().unwrap();
|
||||
let timer = TimerFd::new().unwrap();
|
||||
|
||||
timer.set(Duration::from_millis(20), Duration::ZERO).unwrap();
|
||||
eloop.register(timer.fd(), Interest::Readable, 99).unwrap();
|
||||
|
||||
let events = eloop.poll(Some(Duration::from_millis(100))).unwrap();
|
||||
assert!(!events.is_empty());
|
||||
assert_eq!(events[0].token, 99);
|
||||
|
||||
let expirations = timer.read().unwrap();
|
||||
assert!(expirations >= 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "wo-htmlx"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-model = { path = "../wo-model" }
|
||||
wo-md = { path = "../wo-md" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
/// A parsed template node.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Node {
|
||||
/// Raw HTML passthrough.
|
||||
Literal(String),
|
||||
/// Variable binding: `{{path.to.value}}`
|
||||
Binding(Vec<String>),
|
||||
/// Iteration: `{{#each path}} ... {{/each}}`
|
||||
Each {
|
||||
path: Vec<String>,
|
||||
body: Vec<Node>,
|
||||
},
|
||||
/// Partial inclusion: `{{> name arg=value}}`
|
||||
Partial {
|
||||
name: String,
|
||||
args: Vec<(String, Vec<String>)>,
|
||||
},
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
mod ast;
|
||||
mod parser;
|
||||
mod render;
|
||||
mod registry;
|
||||
mod value;
|
||||
|
||||
pub use ast::Node;
|
||||
pub use parser::parse;
|
||||
pub use render::render;
|
||||
pub use registry::TemplateRegistry;
|
||||
pub use value::Value;
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
use crate::ast::Node;
|
||||
|
||||
/// Parse a template string into a list of AST nodes.
|
||||
pub fn parse(template: &str) -> Vec<Node> {
|
||||
let mut nodes = Vec::new();
|
||||
let mut rest = template;
|
||||
|
||||
while !rest.is_empty() {
|
||||
if let Some(pos) = rest.find("{{") {
|
||||
// Literal before the opening `{{`.
|
||||
if pos > 0 {
|
||||
nodes.push(Node::Literal(rest[..pos].to_string()));
|
||||
}
|
||||
|
||||
let after_open = &rest[pos + 2..];
|
||||
|
||||
if let Some(close) = after_open.find("}}") {
|
||||
let expr = after_open[..close].trim();
|
||||
rest = &after_open[close + 2..];
|
||||
|
||||
if let Some(each_path) = expr.strip_prefix("#each ") {
|
||||
// Block: {{#each path}} ... {{/each}}
|
||||
let path = parse_path(each_path.trim());
|
||||
let (body, remaining) = parse_until_end_each(rest);
|
||||
nodes.push(Node::Each { path, body });
|
||||
rest = remaining;
|
||||
} else if let Some(partial_expr) = expr.strip_prefix("> ") {
|
||||
// Partial: {{> name arg=value}}
|
||||
let (name, args) = parse_partial_expr(partial_expr.trim());
|
||||
nodes.push(Node::Partial { name, args });
|
||||
} else if !expr.starts_with('/') {
|
||||
// Binding: {{path.to.value}}
|
||||
let path = parse_path(expr);
|
||||
nodes.push(Node::Binding(path));
|
||||
}
|
||||
// {{/each}} handled by parse_until_end_each
|
||||
} else {
|
||||
// No closing `}}` — treat rest as literal.
|
||||
nodes.push(Node::Literal(rest.to_string()));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No more `{{` — rest is literal.
|
||||
nodes.push(Node::Literal(rest.to_string()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
nodes
|
||||
}
|
||||
|
||||
fn parse_path(s: &str) -> Vec<String> {
|
||||
s.split('.').map(|p| p.trim().to_string()).collect()
|
||||
}
|
||||
|
||||
fn parse_until_end_each(input: &str) -> (Vec<Node>, &str) {
|
||||
// Find the matching {{/each}}.
|
||||
let mut depth = 1;
|
||||
let mut pos = 0;
|
||||
let bytes = input.as_bytes();
|
||||
|
||||
while pos < bytes.len() {
|
||||
if let Some(open) = input[pos..].find("{{") {
|
||||
let abs = pos + open;
|
||||
let after = &input[abs + 2..];
|
||||
if let Some(close) = after.find("}}") {
|
||||
let expr = after[..close].trim();
|
||||
if expr.starts_with("#each ") {
|
||||
depth += 1;
|
||||
} else if expr == "/each" {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
let body_str = &input[..abs];
|
||||
let rest = &after[close + 2..];
|
||||
return (parse(body_str), rest);
|
||||
}
|
||||
}
|
||||
pos = abs + 2 + close + 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Unmatched {{#each}} — return rest as literal body.
|
||||
(vec![Node::Literal(input.to_string())], "")
|
||||
}
|
||||
|
||||
fn parse_partial_expr(expr: &str) -> (String, Vec<(String, Vec<String>)>) {
|
||||
let parts: Vec<&str> = expr.splitn(2, ' ').collect();
|
||||
let name = parts[0].to_string();
|
||||
let mut args = Vec::new();
|
||||
|
||||
if parts.len() > 1 {
|
||||
for arg in parts[1].split_whitespace() {
|
||||
if let Some((key, val)) = arg.split_once('=') {
|
||||
args.push((key.to_string(), parse_path(val)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(name, args)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ast::Node;
|
||||
|
||||
#[test]
|
||||
fn parse_literal() {
|
||||
let nodes = parse("<h1>Hello</h1>");
|
||||
assert_eq!(nodes, vec![Node::Literal("<h1>Hello</h1>".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_binding() {
|
||||
let nodes = parse("{{article.title}}");
|
||||
assert_eq!(nodes, vec![Node::Binding(vec!["article".into(), "title".into()])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mixed() {
|
||||
let nodes = parse("<h1>{{title}}</h1>");
|
||||
assert_eq!(nodes.len(), 3);
|
||||
assert_eq!(nodes[0], Node::Literal("<h1>".into()));
|
||||
assert_eq!(nodes[1], Node::Binding(vec!["title".into()]));
|
||||
assert_eq!(nodes[2], Node::Literal("</h1>".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_each() {
|
||||
let nodes = parse("{{#each items}}<li>{{this}}</li>{{/each}}");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
match &nodes[0] {
|
||||
Node::Each { path, body } => {
|
||||
assert_eq!(path, &vec!["items".to_string()]);
|
||||
assert_eq!(body.len(), 3);
|
||||
}
|
||||
_ => panic!("expected Each"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_partial() {
|
||||
let nodes = parse("{{> article-card article=this}}");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
match &nodes[0] {
|
||||
Node::Partial { name, args } => {
|
||||
assert_eq!(name, "article-card");
|
||||
assert_eq!(args.len(), 1);
|
||||
assert_eq!(args[0].0, "article");
|
||||
assert_eq!(args[0].1, vec!["this".to_string()]);
|
||||
}
|
||||
_ => panic!("expected Partial"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nested_each() {
|
||||
let nodes = parse("{{#each sections}}{{#each paragraphs}}{{this}}{{/each}}{{/each}}");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
match &nodes[0] {
|
||||
Node::Each { body, .. } => {
|
||||
assert_eq!(body.len(), 1);
|
||||
assert!(matches!(&body[0], Node::Each { .. }));
|
||||
}
|
||||
_ => panic!("expected nested Each"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::ast::Node;
|
||||
use crate::parser;
|
||||
|
||||
/// Holds parsed templates and partials, loaded from a templates directory.
|
||||
pub struct TemplateRegistry {
|
||||
/// Page templates (e.g., "home" -> parsed nodes from home.htmlx).
|
||||
templates: HashMap<String, Vec<Node>>,
|
||||
/// Component partials (e.g., "article-card" -> parsed nodes).
|
||||
partials: HashMap<String, Vec<Node>>,
|
||||
}
|
||||
|
||||
impl TemplateRegistry {
|
||||
/// Load all .htmlx files from the given templates directory.
|
||||
///
|
||||
/// Templates in the root are page templates.
|
||||
/// Templates in `components/` are partials.
|
||||
pub fn load(templates_dir: &Path) -> io::Result<Self> {
|
||||
let mut templates = HashMap::new();
|
||||
let mut partials = HashMap::new();
|
||||
|
||||
// Load page templates from root.
|
||||
if templates_dir.exists() {
|
||||
for entry in fs::read_dir(templates_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_file() && has_htmlx_ext(&path) {
|
||||
let name = stem(&path);
|
||||
let content = fs::read_to_string(&path)?;
|
||||
templates.insert(name, parser::parse(&content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load partials from components/.
|
||||
let components_dir = templates_dir.join("components");
|
||||
if components_dir.exists() {
|
||||
for entry in fs::read_dir(&components_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_file() && has_htmlx_ext(&path) {
|
||||
let name = stem(&path);
|
||||
let content = fs::read_to_string(&path)?;
|
||||
partials.insert(name, parser::parse(&content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { templates, partials })
|
||||
}
|
||||
|
||||
/// Get a page template by name.
|
||||
pub fn get(&self, name: &str) -> Option<&Vec<Node>> {
|
||||
self.templates.get(name)
|
||||
}
|
||||
|
||||
/// Get the partials map (for passing to render()).
|
||||
pub fn partials(&self) -> &HashMap<String, Vec<Node>> {
|
||||
&self.partials
|
||||
}
|
||||
|
||||
/// Number of loaded templates.
|
||||
pub fn template_count(&self) -> usize {
|
||||
self.templates.len()
|
||||
}
|
||||
|
||||
/// Number of loaded partials.
|
||||
pub fn partial_count(&self) -> usize {
|
||||
self.partials.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn has_htmlx_ext(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()) == Some("htmlx")
|
||||
}
|
||||
|
||||
fn stem(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn load_templates() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
fs::write(dir.join("home.htmlx"), "<h1>{{title}}</h1>").unwrap();
|
||||
fs::write(dir.join("article.htmlx"), "<article>{{body}}</article>").unwrap();
|
||||
|
||||
fs::create_dir_all(dir.join("components")).unwrap();
|
||||
fs::write(
|
||||
dir.join("components/card.htmlx"),
|
||||
"<div>{{name}}</div>",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let reg = TemplateRegistry::load(dir).unwrap();
|
||||
assert_eq!(reg.template_count(), 2);
|
||||
assert_eq!(reg.partial_count(), 1);
|
||||
assert!(reg.get("home").is_some());
|
||||
assert!(reg.get("article").is_some());
|
||||
assert!(reg.partials().contains_key("card"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let reg = TemplateRegistry::load(tmp.path()).unwrap();
|
||||
assert_eq!(reg.template_count(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::ast::Node;
|
||||
use crate::value::Value;
|
||||
|
||||
/// Render a list of AST nodes against a context value.
|
||||
///
|
||||
/// `partials` maps partial names to their parsed node lists.
|
||||
pub fn render(
|
||||
nodes: &[Node],
|
||||
context: &Value,
|
||||
partials: &std::collections::HashMap<String, Vec<Node>>,
|
||||
) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
for node in nodes {
|
||||
match node {
|
||||
Node::Literal(text) => {
|
||||
output.push_str(text);
|
||||
}
|
||||
Node::Binding(path) => {
|
||||
if path.len() == 1 && path[0] == "this" {
|
||||
output.push_str(&context.to_display());
|
||||
} else {
|
||||
let val = context.resolve(path);
|
||||
output.push_str(&val.to_display());
|
||||
}
|
||||
}
|
||||
Node::Each { path, body } => {
|
||||
let list_val = if path.len() == 1 && path[0] == "this" {
|
||||
context
|
||||
} else {
|
||||
context.resolve(path)
|
||||
};
|
||||
|
||||
for item in list_val.as_list() {
|
||||
output.push_str(&render(body, item, partials));
|
||||
}
|
||||
}
|
||||
Node::Partial { name, args } => {
|
||||
if let Some(partial_nodes) = partials.get(name.as_str()) {
|
||||
// Build the partial context from args.
|
||||
let partial_ctx = if args.is_empty() {
|
||||
context.clone()
|
||||
} else {
|
||||
let mut map = BTreeMap::new();
|
||||
for (key, path) in args {
|
||||
let val = if path.len() == 1 && path[0] == "this" {
|
||||
context.clone()
|
||||
} else {
|
||||
context.resolve(path).clone()
|
||||
};
|
||||
map.insert(key.clone(), val);
|
||||
}
|
||||
Value::Object(map)
|
||||
};
|
||||
output.push_str(&render(partial_nodes, &partial_ctx, partials));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::parser;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn ctx(json: serde_json::Value) -> Value {
|
||||
Value::from_json(&json)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_literal() {
|
||||
let nodes = parser::parse("<h1>Hello</h1>");
|
||||
let result = render(&nodes, &Value::Null, &HashMap::new());
|
||||
assert_eq!(result, "<h1>Hello</h1>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_binding() {
|
||||
let nodes = parser::parse("<h1>{{title}}</h1>");
|
||||
let context = ctx(serde_json::json!({"title": "My Article"}));
|
||||
let result = render(&nodes, &context, &HashMap::new());
|
||||
assert_eq!(result, "<h1>My Article</h1>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_nested_binding() {
|
||||
let nodes = parser::parse("by {{article.author}}");
|
||||
let context = ctx(serde_json::json!({"article": {"author": "Shoney"}}));
|
||||
let result = render(&nodes, &context, &HashMap::new());
|
||||
assert_eq!(result, "by Shoney");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_each() {
|
||||
let nodes = parser::parse("<ul>{{#each tags}}<li>{{this}}</li>{{/each}}</ul>");
|
||||
let context = ctx(serde_json::json!({"tags": ["rust", "linux"]}));
|
||||
let result = render(&nodes, &context, &HashMap::new());
|
||||
assert_eq!(result, "<ul><li>rust</li><li>linux</li></ul>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_each_objects() {
|
||||
let nodes = parser::parse("{{#each sections}}<h2>{{heading}}</h2>{{/each}}");
|
||||
let context = ctx(serde_json::json!({
|
||||
"sections": [
|
||||
{"heading": "Intro"},
|
||||
{"heading": "Details"}
|
||||
]
|
||||
}));
|
||||
let result = render(&nodes, &context, &HashMap::new());
|
||||
assert_eq!(result, "<h2>Intro</h2><h2>Details</h2>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_partial() {
|
||||
let nodes = parser::parse("{{> card item=this}}");
|
||||
let mut partials = HashMap::new();
|
||||
partials.insert(
|
||||
"card".to_string(),
|
||||
parser::parse("<div>{{item.name}}</div>"),
|
||||
);
|
||||
let context = ctx(serde_json::json!({"name": "Test"}));
|
||||
let result = render(&nodes, &context, &partials);
|
||||
assert_eq!(result, "<div>Test</div>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_missing_binding() {
|
||||
let nodes = parser::parse("{{nonexistent}}");
|
||||
let context = ctx(serde_json::json!({}));
|
||||
let result = render(&nodes, &context, &HashMap::new());
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
/// Template context value — the data that template bindings resolve against.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
String(String),
|
||||
Number(f64),
|
||||
Bool(bool),
|
||||
List(Vec<Value>),
|
||||
Object(BTreeMap<String, Value>),
|
||||
Null,
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// Resolve a dotted path like ["article", "title"] against this value.
|
||||
pub fn resolve(&self, path: &[String]) -> &Value {
|
||||
let mut current = self;
|
||||
for key in path {
|
||||
match current {
|
||||
Value::Object(map) => {
|
||||
current = map.get(key.as_str()).unwrap_or(&Value::Null);
|
||||
}
|
||||
_ => return &Value::Null,
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
/// Convert to a display string for template output.
|
||||
pub fn to_display(&self) -> String {
|
||||
match self {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => {
|
||||
if *n == (*n as i64) as f64 {
|
||||
format!("{}", *n as i64)
|
||||
} else {
|
||||
format!("{}", n)
|
||||
}
|
||||
}
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => String::new(),
|
||||
Value::List(items) => {
|
||||
let parts: Vec<String> = items.iter().map(|v| v.to_display()).collect();
|
||||
parts.join(", ")
|
||||
}
|
||||
Value::Object(_) => "[object]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to a list for iteration (returns empty vec if not a list).
|
||||
pub fn as_list(&self) -> &[Value] {
|
||||
match self {
|
||||
Value::List(items) => items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a serde_json::Value to a template Value.
|
||||
pub fn from_json(json: &serde_json::Value) -> Self {
|
||||
match json {
|
||||
serde_json::Value::Null => Value::Null,
|
||||
serde_json::Value::Bool(b) => Value::Bool(*b),
|
||||
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
|
||||
serde_json::Value::String(s) => Value::String(s.clone()),
|
||||
serde_json::Value::Array(arr) => {
|
||||
Value::List(arr.iter().map(Value::from_json).collect())
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
let btree = map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::from_json(v)))
|
||||
.collect();
|
||||
Value::Object(btree)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an Article to a template Value using serde_json as intermediary.
|
||||
pub fn article_to_value(article: &wo_model::Article) -> Value {
|
||||
let json = serde_json::to_value(article).unwrap_or(serde_json::Value::Null);
|
||||
Value::from_json(&json)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_path() {
|
||||
let mut inner = BTreeMap::new();
|
||||
inner.insert("title".into(), Value::String("Hello".into()));
|
||||
let mut root = BTreeMap::new();
|
||||
root.insert("article".into(), Value::Object(inner));
|
||||
let val = Value::Object(root);
|
||||
|
||||
let result = val.resolve(&["article".into(), "title".into()]);
|
||||
assert_eq!(result, &Value::String("Hello".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing() {
|
||||
let val = Value::Object(BTreeMap::new());
|
||||
assert_eq!(val.resolve(&["nope".into()]), &Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"name": "test",
|
||||
"count": 42,
|
||||
"active": true,
|
||||
"tags": ["a", "b"]
|
||||
});
|
||||
let val = Value::from_json(&json);
|
||||
assert_eq!(
|
||||
val.resolve(&["name".into()]),
|
||||
&Value::String("test".into())
|
||||
);
|
||||
assert_eq!(val.resolve(&["count".into()]), &Value::Number(42.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[package]
|
||||
name = "wo-http"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
|
||||
use crate::request::{self, ParseResult, Request};
|
||||
use crate::response::Response;
|
||||
|
||||
/// Connection state machine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum State {
|
||||
/// Accumulating request bytes.
|
||||
ReadingRequest,
|
||||
/// Writing response bytes.
|
||||
WritingResponse,
|
||||
/// Initial response sent, fd handed to subscription manager.
|
||||
/// Connection stays open for diff payloads.
|
||||
Subscribed,
|
||||
/// Ready to close.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// A single HTTP connection.
|
||||
pub struct Connection {
|
||||
pub fd: RawFd,
|
||||
pub state: State,
|
||||
read_buf: Vec<u8>,
|
||||
write_buf: Vec<u8>,
|
||||
write_offset: usize,
|
||||
parsed_request: Option<Request>,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub fn new(fd: RawFd) -> Self {
|
||||
Self {
|
||||
fd,
|
||||
state: State::ReadingRequest,
|
||||
read_buf: Vec::with_capacity(4096),
|
||||
write_buf: Vec::new(),
|
||||
write_offset: 0,
|
||||
parsed_request: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read available data from the socket into the read buffer.
|
||||
/// Returns Ok(true) if data was read, Ok(false) if connection closed.
|
||||
pub fn read(&mut self) -> io::Result<bool> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = unsafe {
|
||||
libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
|
||||
};
|
||||
if n < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EAGAIN) {
|
||||
return Ok(true); // no data yet, but connection still open
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
if n == 0 {
|
||||
return Ok(false); // connection closed
|
||||
}
|
||||
self.read_buf.extend_from_slice(&buf[..n as usize]);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Try to parse the request from the read buffer.
|
||||
/// Returns Some(Request) if a full request was parsed.
|
||||
pub fn try_parse(&mut self) -> Option<Request> {
|
||||
if self.parsed_request.is_some() {
|
||||
return self.parsed_request.clone();
|
||||
}
|
||||
match request::parse(&self.read_buf) {
|
||||
ParseResult::Complete(req, _consumed) => {
|
||||
self.parsed_request = Some(req.clone());
|
||||
Some(req)
|
||||
}
|
||||
ParseResult::Incomplete => None,
|
||||
ParseResult::Error(_) => {
|
||||
self.state = State::Done;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the response to write back to the client.
|
||||
pub fn set_response(&mut self, response: &Response) {
|
||||
self.write_buf = response.to_bytes();
|
||||
self.write_offset = 0;
|
||||
self.state = State::WritingResponse;
|
||||
}
|
||||
|
||||
/// Write pending response bytes to the socket.
|
||||
/// Returns Ok(true) if all bytes written, Ok(false) if partial (need EPOLLOUT).
|
||||
pub fn write(&mut self) -> io::Result<bool> {
|
||||
let remaining = &self.write_buf[self.write_offset..];
|
||||
if remaining.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n = unsafe {
|
||||
libc::write(
|
||||
self.fd,
|
||||
remaining.as_ptr() as *const libc::c_void,
|
||||
remaining.len(),
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EAGAIN) {
|
||||
return Ok(false);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
self.write_offset += n as usize;
|
||||
|
||||
if self.write_offset >= self.write_buf.len() {
|
||||
Ok(true) // all written
|
||||
} else {
|
||||
Ok(false) // partial write
|
||||
}
|
||||
}
|
||||
|
||||
/// Transition to subscribed state (connection stays open).
|
||||
pub fn set_subscribed(&mut self) {
|
||||
self.state = State::Subscribed;
|
||||
}
|
||||
|
||||
/// Mark connection as done (will be closed).
|
||||
pub fn set_done(&mut self) {
|
||||
self.state = State::Done;
|
||||
}
|
||||
|
||||
/// Take the parsed request out of the connection.
|
||||
pub fn take_request(&mut self) -> Option<Request> {
|
||||
self.parsed_request.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Connection {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connection_lifecycle() {
|
||||
// Use a socketpair to simulate a connection.
|
||||
let mut fds = [0i32; 2];
|
||||
assert_eq!(
|
||||
unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_NONBLOCK, 0, fds.as_mut_ptr()) },
|
||||
0
|
||||
);
|
||||
let (server_fd, client_fd) = (fds[0], fds[1]);
|
||||
|
||||
// Write a request from the "client" side.
|
||||
let request_bytes = b"GET /blog/test HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
unsafe {
|
||||
libc::write(
|
||||
client_fd,
|
||||
request_bytes.as_ptr() as *const libc::c_void,
|
||||
request_bytes.len(),
|
||||
);
|
||||
}
|
||||
|
||||
// Create connection on the "server" side.
|
||||
// We need to manage the fd manually since Connection will close it on drop.
|
||||
let dup_fd = unsafe { libc::dup(server_fd) };
|
||||
let mut conn = Connection::new(dup_fd);
|
||||
assert_eq!(conn.state, State::ReadingRequest);
|
||||
|
||||
// Read and parse.
|
||||
assert!(conn.read().unwrap());
|
||||
let req = conn.try_parse().unwrap();
|
||||
assert_eq!(req.path, "/blog/test");
|
||||
|
||||
// Set response.
|
||||
let response = Response::html("<h1>Test</h1>".into());
|
||||
conn.set_response(&response);
|
||||
assert_eq!(conn.state, State::WritingResponse);
|
||||
|
||||
// Write response.
|
||||
let complete = conn.write().unwrap();
|
||||
assert!(complete);
|
||||
|
||||
// Read from client side to verify.
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = unsafe {
|
||||
libc::read(client_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
|
||||
};
|
||||
assert!(n > 0);
|
||||
let response_str = std::str::from_utf8(&buf[..n as usize]).unwrap();
|
||||
assert!(response_str.contains("HTTP/1.1 200 OK"));
|
||||
assert!(response_str.contains("<h1>Test</h1>"));
|
||||
|
||||
unsafe {
|
||||
libc::close(server_fd);
|
||||
libc::close(client_fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
pub mod listener;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod connection;
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
|
||||
/// A non-blocking TCP listener wrapping raw socket syscalls.
|
||||
pub struct TcpListener {
|
||||
fd: RawFd,
|
||||
}
|
||||
|
||||
impl TcpListener {
|
||||
/// Bind to the given address (e.g., "0.0.0.0:3000") and start listening.
|
||||
pub fn bind(addr: &str) -> io::Result<Self> {
|
||||
let (ip, port) = parse_addr(addr)?;
|
||||
|
||||
let fd = unsafe {
|
||||
libc::socket(
|
||||
libc::AF_INET,
|
||||
libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// SO_REUSEADDR to allow quick restart.
|
||||
let optval: libc::c_int = 1;
|
||||
let ret = unsafe {
|
||||
libc::setsockopt(
|
||||
fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_REUSEADDR,
|
||||
&optval as *const _ as *const libc::c_void,
|
||||
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
unsafe { libc::close(fd) };
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let sockaddr = libc::sockaddr_in {
|
||||
sin_family: libc::AF_INET as libc::sa_family_t,
|
||||
sin_port: port.to_be(),
|
||||
sin_addr: libc::in_addr { s_addr: ip },
|
||||
sin_zero: [0; 8],
|
||||
};
|
||||
|
||||
let ret = unsafe {
|
||||
libc::bind(
|
||||
fd,
|
||||
&sockaddr as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
unsafe { libc::close(fd) };
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let ret = unsafe { libc::listen(fd, 128) };
|
||||
if ret < 0 {
|
||||
unsafe { libc::close(fd) };
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(Self { fd })
|
||||
}
|
||||
|
||||
/// Accept a new connection. Returns the client fd or EAGAIN if none pending.
|
||||
pub fn accept(&self) -> io::Result<Option<RawFd>> {
|
||||
let client_fd = unsafe {
|
||||
libc::accept4(
|
||||
self.fd,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if client_fd < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EAGAIN)
|
||||
|| err.raw_os_error() == Some(libc::EWOULDBLOCK)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
Ok(Some(client_fd))
|
||||
}
|
||||
|
||||
/// The listener file descriptor for epoll registration.
|
||||
pub fn fd(&self) -> RawFd {
|
||||
self.fd
|
||||
}
|
||||
|
||||
/// Get the actual bound address (useful when binding to port 0).
|
||||
pub fn local_addr(&self) -> io::Result<(String, u16)> {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
let mut len = std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t;
|
||||
let ret = unsafe {
|
||||
libc::getsockname(
|
||||
self.fd,
|
||||
&mut addr as *mut _ as *mut libc::sockaddr,
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let ip = u32::from_be(addr.sin_addr.s_addr);
|
||||
let port = u16::from_be(addr.sin_port);
|
||||
let ip_str = format!(
|
||||
"{}.{}.{}.{}",
|
||||
(ip >> 24) & 0xFF,
|
||||
(ip >> 16) & 0xFF,
|
||||
(ip >> 8) & 0xFF,
|
||||
ip & 0xFF,
|
||||
);
|
||||
Ok((ip_str, port))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TcpListener {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_addr(addr: &str) -> io::Result<(u32, u16)> {
|
||||
let parts: Vec<&str> = addr.rsplitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "expected host:port"));
|
||||
}
|
||||
let port: u16 = parts[0]
|
||||
.parse()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid port"))?;
|
||||
let ip_str = parts[1];
|
||||
|
||||
let ip = if ip_str == "0.0.0.0" {
|
||||
0u32
|
||||
} else if ip_str == "127.0.0.1" {
|
||||
0x7F000001u32.to_be()
|
||||
} else {
|
||||
let octets: Vec<u8> = ip_str
|
||||
.split('.')
|
||||
.map(|s| s.parse().unwrap_or(0))
|
||||
.collect();
|
||||
if octets.len() != 4 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid IP"));
|
||||
}
|
||||
u32::from_ne_bytes([octets[0], octets[1], octets[2], octets[3]])
|
||||
};
|
||||
|
||||
Ok((ip, port))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bind_and_accept() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let (_, port) = listener.local_addr().unwrap();
|
||||
assert!(port > 0);
|
||||
|
||||
// No pending connections → None.
|
||||
assert!(listener.accept().unwrap().is_none());
|
||||
|
||||
// Connect a client.
|
||||
let client_fd = unsafe {
|
||||
libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0)
|
||||
};
|
||||
assert!(client_fd >= 0);
|
||||
|
||||
let addr = libc::sockaddr_in {
|
||||
sin_family: libc::AF_INET as libc::sa_family_t,
|
||||
sin_port: port.to_be(),
|
||||
sin_addr: libc::in_addr {
|
||||
s_addr: 0x7F000001u32.to_be(),
|
||||
},
|
||||
sin_zero: [0; 8],
|
||||
};
|
||||
let ret = unsafe {
|
||||
libc::connect(
|
||||
client_fd,
|
||||
&addr as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
assert_eq!(ret, 0);
|
||||
|
||||
// Now accept should return a fd.
|
||||
let accepted = listener.accept().unwrap();
|
||||
assert!(accepted.is_some());
|
||||
|
||||
unsafe {
|
||||
libc::close(client_fd);
|
||||
libc::close(accepted.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
/// HTTP method.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Method {
|
||||
Get,
|
||||
Post,
|
||||
Head,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// A parsed HTTP/1.1 request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub method: Method,
|
||||
pub path: String,
|
||||
pub query: Option<String>,
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Result of attempting to parse a request from a byte buffer.
|
||||
pub enum ParseResult {
|
||||
/// Request fully parsed; returns the request and number of bytes consumed.
|
||||
Complete(Request, usize),
|
||||
/// Need more data.
|
||||
Incomplete,
|
||||
/// Malformed request.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Parse an HTTP/1.1 request from a byte buffer.
|
||||
///
|
||||
/// Returns `ParseResult::Complete` when the full header has been received
|
||||
/// (delimited by `\r\n\r\n`).
|
||||
pub fn parse(buf: &[u8]) -> ParseResult {
|
||||
// Find the end of headers.
|
||||
let header_end = match find_header_end(buf) {
|
||||
Some(pos) => pos,
|
||||
None => {
|
||||
if buf.len() > 8192 {
|
||||
return ParseResult::Error("request too large".into());
|
||||
}
|
||||
return ParseResult::Incomplete;
|
||||
}
|
||||
};
|
||||
|
||||
let header_str = match std::str::from_utf8(&buf[..header_end]) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return ParseResult::Error("invalid UTF-8 in headers".into()),
|
||||
};
|
||||
|
||||
let mut lines = header_str.lines();
|
||||
|
||||
// Request line: "GET /path HTTP/1.1"
|
||||
let request_line = match lines.next() {
|
||||
Some(l) => l,
|
||||
None => return ParseResult::Error("empty request".into()),
|
||||
};
|
||||
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = match parts.next() {
|
||||
Some("GET") => Method::Get,
|
||||
Some("POST") => Method::Post,
|
||||
Some("HEAD") => Method::Head,
|
||||
Some(_) => Method::Unknown,
|
||||
None => return ParseResult::Error("missing method".into()),
|
||||
};
|
||||
|
||||
let raw_path = match parts.next() {
|
||||
Some(p) => p,
|
||||
None => return ParseResult::Error("missing path".into()),
|
||||
};
|
||||
|
||||
// Split path and query string.
|
||||
let (path, query) = match raw_path.split_once('?') {
|
||||
Some((p, q)) => (p.to_string(), Some(q.to_string())),
|
||||
None => (raw_path.to_string(), None),
|
||||
};
|
||||
|
||||
// Parse headers.
|
||||
let mut headers = HashMap::new();
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
headers.insert(
|
||||
key.trim().to_lowercase(),
|
||||
value.trim().to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Bytes consumed: header + \r\n\r\n delimiter.
|
||||
let consumed = header_end + 4;
|
||||
|
||||
ParseResult::Complete(Request { method, path, query, headers }, consumed)
|
||||
}
|
||||
|
||||
fn find_header_end(buf: &[u8]) -> Option<usize> {
|
||||
for i in 0..buf.len().saturating_sub(3) {
|
||||
if &buf[i..i + 4] == b"\r\n\r\n" {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_get() {
|
||||
let raw = b"GET /blog/linux-misc HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
match parse(raw) {
|
||||
ParseResult::Complete(req, consumed) => {
|
||||
assert_eq!(req.method, Method::Get);
|
||||
assert_eq!(req.path, "/blog/linux-misc");
|
||||
assert!(req.query.is_none());
|
||||
assert_eq!(req.headers.get("host").unwrap(), "localhost");
|
||||
assert_eq!(consumed, raw.len());
|
||||
}
|
||||
_ => panic!("expected Complete"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_query_string() {
|
||||
let raw = b"GET /tag/rust?page=2 HTTP/1.1\r\n\r\n";
|
||||
match parse(raw) {
|
||||
ParseResult::Complete(req, _) => {
|
||||
assert_eq!(req.path, "/tag/rust");
|
||||
assert_eq!(req.query.as_deref(), Some("page=2"));
|
||||
}
|
||||
_ => panic!("expected Complete"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_incomplete() {
|
||||
let raw = b"GET / HTTP/1.1\r\nHost: local";
|
||||
assert!(matches!(parse(raw), ParseResult::Incomplete));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_headers() {
|
||||
let raw = b"GET / HTTP/1.1\r\nHost: localhost\r\nAccept: text/html\r\nConnection: keep-alive\r\n\r\n";
|
||||
match parse(raw) {
|
||||
ParseResult::Complete(req, _) => {
|
||||
assert_eq!(req.headers.len(), 3);
|
||||
assert_eq!(req.headers.get("accept").unwrap(), "text/html");
|
||||
assert_eq!(req.headers.get("connection").unwrap(), "keep-alive");
|
||||
}
|
||||
_ => panic!("expected Complete"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
/// HTTP response builder.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Response {
|
||||
pub status: u16,
|
||||
pub status_text: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn new(status: u16, status_text: &str) -> Self {
|
||||
Self {
|
||||
status,
|
||||
status_text: status_text.to_string(),
|
||||
headers: vec![],
|
||||
body: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// 200 OK with HTML body.
|
||||
pub fn html(body: String) -> Self {
|
||||
let mut r = Self::new(200, "OK");
|
||||
r.header("Content-Type", "text/html; charset=utf-8");
|
||||
r.header("Content-Length", &body.len().to_string());
|
||||
r.body = body.into_bytes();
|
||||
r
|
||||
}
|
||||
|
||||
/// 200 OK with a raw body and content type.
|
||||
pub fn ok(body: Vec<u8>, content_type: &str) -> Self {
|
||||
let mut r = Self::new(200, "OK");
|
||||
r.header("Content-Type", content_type);
|
||||
r.header("Content-Length", &body.len().to_string());
|
||||
r.body = body;
|
||||
r
|
||||
}
|
||||
|
||||
/// 404 Not Found.
|
||||
pub fn not_found() -> Self {
|
||||
let body = "<h1>404 Not Found</h1>";
|
||||
let mut r = Self::new(404, "Not Found");
|
||||
r.header("Content-Type", "text/html; charset=utf-8");
|
||||
r.header("Content-Length", &body.len().to_string());
|
||||
r.body = body.as_bytes().to_vec();
|
||||
r
|
||||
}
|
||||
|
||||
/// 500 Internal Server Error.
|
||||
pub fn internal_error(msg: &str) -> Self {
|
||||
let body = format!("<h1>500 Internal Server Error</h1><p>{}</p>", msg);
|
||||
let mut r = Self::new(500, "Internal Server Error");
|
||||
r.header("Content-Type", "text/html; charset=utf-8");
|
||||
r.header("Content-Length", &body.len().to_string());
|
||||
r.body = body.into_bytes();
|
||||
r
|
||||
}
|
||||
|
||||
/// Add a header.
|
||||
pub fn header(&mut self, key: &str, value: &str) -> &mut Self {
|
||||
self.headers.push((key.to_string(), value.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Serialize the response to bytes for writing to a socket.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(256 + self.body.len());
|
||||
|
||||
// Status line.
|
||||
buf.extend_from_slice(
|
||||
format!("HTTP/1.1 {} {}\r\n", self.status, self.status_text).as_bytes(),
|
||||
);
|
||||
|
||||
// Headers.
|
||||
for (key, value) in &self.headers {
|
||||
buf.extend_from_slice(format!("{}: {}\r\n", key, value).as_bytes());
|
||||
}
|
||||
|
||||
// End of headers.
|
||||
buf.extend_from_slice(b"\r\n");
|
||||
|
||||
// Body.
|
||||
buf.extend_from_slice(&self.body);
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn html_response() {
|
||||
let r = Response::html("<h1>Hello</h1>".into());
|
||||
let bytes = r.to_bytes();
|
||||
let s = String::from_utf8(bytes).unwrap();
|
||||
assert!(s.starts_with("HTTP/1.1 200 OK\r\n"));
|
||||
assert!(s.contains("Content-Type: text/html"));
|
||||
assert!(s.contains("<h1>Hello</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_response() {
|
||||
let r = Response::not_found();
|
||||
assert_eq!(r.status, 404);
|
||||
let bytes = r.to_bytes();
|
||||
let s = String::from_utf8(bytes).unwrap();
|
||||
assert!(s.contains("404 Not Found"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "wo-index"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-model = { path = "../wo-model" }
|
||||
libc = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// Sorted index mapping timestamps to byte offsets in a .seg file.
|
||||
///
|
||||
/// On-disk format:
|
||||
/// ```text
|
||||
/// [8 bytes] entry_count: u64 LE
|
||||
/// For each entry (sorted by timestamp ascending):
|
||||
/// [8 bytes] timestamp: i64 LE
|
||||
/// [8 bytes] offset: u64 LE
|
||||
/// ```
|
||||
const ENTRY_SIZE: usize = 16; // i64 + u64
|
||||
const HEADER_SIZE: usize = 8;
|
||||
|
||||
pub struct DateIndex {
|
||||
data: Vec<u8>,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl DateIndex {
|
||||
/// Build a date index. Entries are sorted by timestamp before writing.
|
||||
pub fn build(path: &Path, entries: &mut [(i64, u64)]) -> io::Result<()> {
|
||||
entries.sort_by_key(|e| e.0);
|
||||
|
||||
let file_size = HEADER_SIZE + entries.len() * ENTRY_SIZE;
|
||||
let mut data = vec![0u8; file_size];
|
||||
|
||||
data[0..8].copy_from_slice(&(entries.len() as u64).to_le_bytes());
|
||||
|
||||
for (i, &(ts, offset)) in entries.iter().enumerate() {
|
||||
let base = HEADER_SIZE + i * ENTRY_SIZE;
|
||||
data[base..base + 8].copy_from_slice(&ts.to_le_bytes());
|
||||
data[base + 8..base + 16].copy_from_slice(&offset.to_le_bytes());
|
||||
}
|
||||
|
||||
std::fs::write(path, &data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a date index from disk.
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
let data = std::fs::read(path)?;
|
||||
if data.len() < HEADER_SIZE {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "date.idx too small"));
|
||||
}
|
||||
let count = u64::from_le_bytes(data[0..8].try_into().unwrap()) as usize;
|
||||
Ok(Self { data, count })
|
||||
}
|
||||
|
||||
/// Get offsets for all entries with timestamps in [start, end].
|
||||
pub fn range(&self, start: i64, end: i64) -> Vec<u64> {
|
||||
let lo = self.lower_bound(start);
|
||||
let hi = self.upper_bound(end);
|
||||
|
||||
(lo..hi).map(|i| self.offset_at(i)).collect()
|
||||
}
|
||||
|
||||
/// Get offsets for the N most recent entries (highest timestamps).
|
||||
pub fn latest(&self, n: usize) -> Vec<u64> {
|
||||
let start = self.count.saturating_sub(n);
|
||||
(start..self.count)
|
||||
.rev()
|
||||
.map(|i| self.offset_at(i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.count == 0
|
||||
}
|
||||
|
||||
fn timestamp_at(&self, i: usize) -> i64 {
|
||||
let base = HEADER_SIZE + i * ENTRY_SIZE;
|
||||
i64::from_le_bytes(self.data[base..base + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn offset_at(&self, i: usize) -> u64 {
|
||||
let base = HEADER_SIZE + i * ENTRY_SIZE;
|
||||
u64::from_le_bytes(self.data[base + 8..base + 16].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// Binary search: first index where timestamp >= target.
|
||||
fn lower_bound(&self, target: i64) -> usize {
|
||||
let (mut lo, mut hi) = (0usize, self.count);
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if self.timestamp_at(mid) < target {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// Binary search: first index where timestamp > target.
|
||||
fn upper_bound(&self, target: i64) -> usize {
|
||||
let (mut lo, mut hi) = (0usize, self.count);
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if self.timestamp_at(mid) <= target {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_and_range_query() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("date.idx");
|
||||
|
||||
let mut entries = vec![
|
||||
(1000i64, 100u64),
|
||||
(2000, 200),
|
||||
(3000, 300),
|
||||
(4000, 400),
|
||||
(5000, 500),
|
||||
];
|
||||
|
||||
DateIndex::build(&path, &mut entries).unwrap();
|
||||
let idx = DateIndex::open(&path).unwrap();
|
||||
|
||||
assert_eq!(idx.len(), 5);
|
||||
|
||||
// Full range.
|
||||
let all = idx.range(0, 9999);
|
||||
assert_eq!(all, vec![100, 200, 300, 400, 500]);
|
||||
|
||||
// Partial range.
|
||||
let mid = idx.range(2000, 4000);
|
||||
assert_eq!(mid, vec![200, 300, 400]);
|
||||
|
||||
// Single.
|
||||
let one = idx.range(3000, 3000);
|
||||
assert_eq!(one, vec![300]);
|
||||
|
||||
// Empty range.
|
||||
let none = idx.range(6000, 9000);
|
||||
assert!(none.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_entries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("date.idx");
|
||||
|
||||
let mut entries = vec![
|
||||
(1000i64, 100u64),
|
||||
(2000, 200),
|
||||
(3000, 300),
|
||||
];
|
||||
|
||||
DateIndex::build(&path, &mut entries).unwrap();
|
||||
let idx = DateIndex::open(&path).unwrap();
|
||||
|
||||
let top2 = idx.latest(2);
|
||||
assert_eq!(top2, vec![300, 200]);
|
||||
|
||||
let top10 = idx.latest(10);
|
||||
assert_eq!(top10, vec![300, 200, 100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_input() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("date.idx");
|
||||
|
||||
let mut entries = vec![
|
||||
(5000i64, 500u64),
|
||||
(1000, 100),
|
||||
(3000, 300),
|
||||
];
|
||||
|
||||
DateIndex::build(&path, &mut entries).unwrap();
|
||||
let idx = DateIndex::open(&path).unwrap();
|
||||
|
||||
// Should be sorted on disk.
|
||||
let all = idx.range(0, 9999);
|
||||
assert_eq!(all, vec![100, 300, 500]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
mod title;
|
||||
mod date;
|
||||
mod tags;
|
||||
|
||||
pub use title::TitleIndex;
|
||||
pub use date::DateIndex;
|
||||
pub use tags::TagIndex;
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// Rebuild all three index files from an iterator of article metadata.
|
||||
///
|
||||
/// Each entry is `(sys_title, published_on_timestamp, tags, seg_offset)`.
|
||||
pub fn rebuild_all(
|
||||
index_dir: &Path,
|
||||
entries: &[(String, i64, Vec<String>, u64)],
|
||||
) -> io::Result<()> {
|
||||
std::fs::create_dir_all(index_dir)?;
|
||||
|
||||
// Build title index.
|
||||
let title_entries: Vec<(&str, u64)> = entries.iter().map(|e| (e.0.as_str(), e.3)).collect();
|
||||
TitleIndex::build(&index_dir.join("title.idx"), &title_entries)?;
|
||||
|
||||
// Build date index.
|
||||
let mut date_entries: Vec<(i64, u64)> = entries.iter().map(|e| (e.1, e.3)).collect();
|
||||
DateIndex::build(&index_dir.join("date.idx"), &mut date_entries)?;
|
||||
|
||||
// Build tags index.
|
||||
let mut tag_map: std::collections::HashMap<String, Vec<u64>> = std::collections::HashMap::new();
|
||||
for entry in entries {
|
||||
for tag in &entry.2 {
|
||||
tag_map.entry(tag.clone()).or_default().push(entry.3);
|
||||
}
|
||||
}
|
||||
let tag_entries: Vec<(String, Vec<u64>)> = tag_map.into_iter().collect();
|
||||
TagIndex::build(&index_dir.join("tags.idx"), &tag_entries)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Inverted index mapping tag strings to lists of .seg byte offsets.
|
||||
///
|
||||
/// Serialized to disk using bincode for simplicity (article count is
|
||||
/// small enough that loading the full index into memory is fine).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TagIndexData {
|
||||
tags: HashMap<String, Vec<u64>>,
|
||||
}
|
||||
|
||||
pub struct TagIndex {
|
||||
tags: HashMap<String, Vec<u64>>,
|
||||
}
|
||||
|
||||
impl TagIndex {
|
||||
/// Build a tags index from a list of (tag, offsets) pairs.
|
||||
pub fn build(path: &Path, entries: &[(String, Vec<u64>)]) -> io::Result<()> {
|
||||
let tags: HashMap<String, Vec<u64>> = entries.iter().cloned().collect();
|
||||
let data = TagIndexData { tags };
|
||||
let bytes = bincode::serialize(&data).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("bincode: {}", e))
|
||||
})?;
|
||||
std::fs::write(path, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a tags index from disk.
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let data: TagIndexData = bincode::deserialize(&bytes).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("bincode: {}", e))
|
||||
})?;
|
||||
Ok(Self { tags: data.tags })
|
||||
}
|
||||
|
||||
/// Get all offsets for articles tagged with the given tag.
|
||||
pub fn get(&self, tag: &str) -> Option<&[u64]> {
|
||||
self.tags.get(tag).map(|v| v.as_slice())
|
||||
}
|
||||
|
||||
/// All tag names in the index.
|
||||
pub fn tags(&self) -> Vec<&str> {
|
||||
self.tags.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Number of distinct tags.
|
||||
pub fn len(&self) -> usize {
|
||||
self.tags.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tags.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_and_lookup() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("tags.idx");
|
||||
|
||||
let entries = vec![
|
||||
("rust".to_string(), vec![100u64, 200]),
|
||||
("linux".to_string(), vec![300]),
|
||||
("tutorial".to_string(), vec![100, 300, 400]),
|
||||
];
|
||||
|
||||
TagIndex::build(&path, &entries).unwrap();
|
||||
let idx = TagIndex::open(&path).unwrap();
|
||||
|
||||
assert_eq!(idx.len(), 3);
|
||||
assert_eq!(idx.get("rust"), Some(vec![100u64, 200].as_slice()));
|
||||
assert_eq!(idx.get("linux"), Some(vec![300u64].as_slice()));
|
||||
assert_eq!(idx.get("tutorial"), Some(vec![100u64, 300, 400].as_slice()));
|
||||
assert_eq!(idx.get("nonexistent"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_index() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("tags.idx");
|
||||
|
||||
TagIndex::build(&path, &[]).unwrap();
|
||||
let idx = TagIndex::open(&path).unwrap();
|
||||
|
||||
assert!(idx.is_empty());
|
||||
assert_eq!(idx.get("anything"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// On-disk hash index mapping `sys_title` (string) to byte offset in a .seg file.
|
||||
///
|
||||
/// Uses open addressing with linear probing. The table is sized at 2x the entry
|
||||
/// count (load factor 0.5) to keep collision chains short.
|
||||
///
|
||||
/// On-disk format:
|
||||
/// ```text
|
||||
/// [8 bytes] table_size: u64 LE (number of slots)
|
||||
/// [8 bytes] entry_count: u64 LE
|
||||
/// For each slot (table_size slots):
|
||||
/// [8 bytes] offset: u64 LE (0 = empty, otherwise seg offset + 1)
|
||||
/// [2 bytes] key_len: u16 LE
|
||||
/// [128 bytes] key_data: zero-padded sys_title
|
||||
/// Total slot size: 138 bytes
|
||||
/// ```
|
||||
const SLOT_SIZE: usize = 8 + 2 + 128; // offset + key_len + key_data
|
||||
const HEADER_SIZE: usize = 16; // table_size + entry_count
|
||||
const MAX_KEY_LEN: usize = 128;
|
||||
|
||||
pub struct TitleIndex {
|
||||
data: Vec<u8>,
|
||||
table_size: u64,
|
||||
}
|
||||
|
||||
impl TitleIndex {
|
||||
/// Build a title index file from a list of (sys_title, seg_offset) pairs.
|
||||
pub fn build(path: &Path, entries: &[(&str, u64)]) -> io::Result<()> {
|
||||
let table_size = (entries.len() * 2).max(16) as u64;
|
||||
let file_size = HEADER_SIZE + (table_size as usize) * SLOT_SIZE;
|
||||
|
||||
let mut data = vec![0u8; file_size];
|
||||
|
||||
// Write header.
|
||||
data[0..8].copy_from_slice(&table_size.to_le_bytes());
|
||||
data[8..16].copy_from_slice(&(entries.len() as u64).to_le_bytes());
|
||||
|
||||
// Insert entries.
|
||||
for &(key, offset) in entries {
|
||||
let slot = Self::find_empty_slot(&data, table_size, key);
|
||||
Self::write_slot(&mut data, slot, key, offset);
|
||||
}
|
||||
|
||||
std::fs::write(path, &data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a title index from disk (loads into memory).
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
let data = std::fs::read(path)?;
|
||||
if data.len() < HEADER_SIZE {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "title.idx too small"));
|
||||
}
|
||||
let table_size = u64::from_le_bytes(data[0..8].try_into().unwrap());
|
||||
Ok(Self { data, table_size })
|
||||
}
|
||||
|
||||
/// Look up a sys_title, returning the seg file byte offset if found.
|
||||
pub fn get(&self, sys_title: &str) -> Option<u64> {
|
||||
let mut slot = Self::hash_key(sys_title) % self.table_size;
|
||||
|
||||
for _ in 0..self.table_size {
|
||||
let (stored_offset, stored_key) = self.read_slot(slot as usize);
|
||||
|
||||
if stored_offset == 0 {
|
||||
return None; // empty slot, key not found
|
||||
}
|
||||
|
||||
if stored_key == sys_title {
|
||||
return Some(stored_offset - 1); // stored as offset + 1
|
||||
}
|
||||
|
||||
slot = (slot + 1) % self.table_size;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Number of entries in the index.
|
||||
pub fn len(&self) -> usize {
|
||||
u64::from_le_bytes(self.data[8..16].try_into().unwrap()) as usize
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
fn hash_key(key: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
key.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn find_empty_slot(data: &[u8], table_size: u64, key: &str) -> usize {
|
||||
let mut slot = (Self::hash_key(key) % table_size) as usize;
|
||||
loop {
|
||||
let base = HEADER_SIZE + slot * SLOT_SIZE;
|
||||
let stored_offset = u64::from_le_bytes(data[base..base + 8].try_into().unwrap());
|
||||
if stored_offset == 0 {
|
||||
return slot;
|
||||
}
|
||||
slot = (slot + 1) % table_size as usize;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_slot(data: &mut [u8], slot: usize, key: &str, offset: u64) {
|
||||
let base = HEADER_SIZE + slot * SLOT_SIZE;
|
||||
let stored_offset = offset + 1; // +1 so that 0 means empty
|
||||
data[base..base + 8].copy_from_slice(&stored_offset.to_le_bytes());
|
||||
|
||||
let key_bytes = key.as_bytes();
|
||||
let key_len = key_bytes.len().min(MAX_KEY_LEN) as u16;
|
||||
data[base + 8..base + 10].copy_from_slice(&key_len.to_le_bytes());
|
||||
data[base + 10..base + 10 + key_len as usize].copy_from_slice(&key_bytes[..key_len as usize]);
|
||||
}
|
||||
|
||||
fn read_slot(&self, slot: usize) -> (u64, String) {
|
||||
let base = HEADER_SIZE + slot * SLOT_SIZE;
|
||||
let stored_offset = u64::from_le_bytes(self.data[base..base + 8].try_into().unwrap());
|
||||
let key_len = u16::from_le_bytes(self.data[base + 8..base + 10].try_into().unwrap()) as usize;
|
||||
let key = String::from_utf8_lossy(&self.data[base + 10..base + 10 + key_len]).to_string();
|
||||
(stored_offset, key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_and_lookup() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("title.idx");
|
||||
|
||||
let entries = vec![
|
||||
("linux-misc", 32u64),
|
||||
("aws-lambda-pulumi", 500),
|
||||
("rust-patterns", 1200),
|
||||
];
|
||||
|
||||
TitleIndex::build(&path, &entries).unwrap();
|
||||
let idx = TitleIndex::open(&path).unwrap();
|
||||
|
||||
assert_eq!(idx.len(), 3);
|
||||
assert_eq!(idx.get("linux-misc"), Some(32));
|
||||
assert_eq!(idx.get("aws-lambda-pulumi"), Some(500));
|
||||
assert_eq!(idx.get("rust-patterns"), Some(1200));
|
||||
assert_eq!(idx.get("nonexistent"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_index() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("title.idx");
|
||||
|
||||
TitleIndex::build(&path, &[]).unwrap();
|
||||
let idx = TitleIndex::open(&path).unwrap();
|
||||
|
||||
assert!(idx.is_empty());
|
||||
assert_eq!(idx.get("anything"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_entries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("title.idx");
|
||||
|
||||
let keys: Vec<String> = (0..100).map(|i| format!("article-{}", i)).collect();
|
||||
let entries: Vec<(&str, u64)> = keys.iter().enumerate().map(|(i, k)| (k.as_str(), i as u64 * 100)).collect();
|
||||
|
||||
TitleIndex::build(&path, &entries).unwrap();
|
||||
let idx = TitleIndex::open(&path).unwrap();
|
||||
|
||||
assert_eq!(idx.len(), 100);
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
assert_eq!(idx.get(key), Some(i as u64 * 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
[package]
|
||||
name = "wo-md"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
use crate::highlight::highlight;
|
||||
use crate::inline::markdown_to_html;
|
||||
|
||||
/// Parse a full markdown document into HTML.
|
||||
///
|
||||
/// Handles block-level elements (headings, paragraphs, code fences, lists,
|
||||
/// blockquotes, images) and delegates inline formatting to `markdown_to_html`.
|
||||
pub fn markdown_to_html_block(md: &str) -> String {
|
||||
let lines: Vec<&str> = md.lines().collect();
|
||||
let len = lines.len();
|
||||
let mut html = String::with_capacity(md.len() * 2);
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
let line = lines[i];
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip blank lines.
|
||||
if trimmed.is_empty() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fenced code block: ```lang ... ```
|
||||
if trimmed.starts_with("```") {
|
||||
let lang = trimmed[3..].trim();
|
||||
i += 1;
|
||||
let mut code = String::new();
|
||||
while i < len && !lines[i].trim().starts_with("```") {
|
||||
if !code.is_empty() {
|
||||
code.push('\n');
|
||||
}
|
||||
code.push_str(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if i < len {
|
||||
i += 1; // skip closing ```
|
||||
}
|
||||
|
||||
if lang.is_empty() {
|
||||
html.push_str("<pre><code>");
|
||||
html.push_str(&escape_html(&code));
|
||||
html.push_str("</code></pre>\n");
|
||||
} else {
|
||||
html.push_str(&format!("<pre><code class=\"language-{}\">", escape_html(lang)));
|
||||
html.push_str(&highlight(&code, lang));
|
||||
html.push_str("</code></pre>\n");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading: # ... ######
|
||||
if trimmed.starts_with('#') {
|
||||
let level = trimmed.chars().take_while(|&c| c == '#').count().min(6);
|
||||
let text = trimmed[level..].trim();
|
||||
html.push_str(&format!(
|
||||
"<h{0}>{1}</h{0}>\n",
|
||||
level,
|
||||
markdown_to_html(text)
|
||||
));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote: > text
|
||||
if trimmed.starts_with('>') {
|
||||
let mut quote_lines = Vec::new();
|
||||
while i < len && lines[i].trim().starts_with('>') {
|
||||
let content = lines[i].trim().strip_prefix('>').unwrap_or("").trim();
|
||||
quote_lines.push(content);
|
||||
i += 1;
|
||||
}
|
||||
html.push_str("<blockquote>");
|
||||
html.push_str(&markdown_to_html("e_lines.join(" ")));
|
||||
html.push_str("</blockquote>\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list: - item or * item
|
||||
if (trimmed.starts_with("- ") || trimmed.starts_with("* "))
|
||||
&& !trimmed.starts_with("---")
|
||||
{
|
||||
html.push_str("<ul>\n");
|
||||
while i < len {
|
||||
let lt = lines[i].trim();
|
||||
if lt.starts_with("- ") || lt.starts_with("* ") {
|
||||
let text = <[2..];
|
||||
html.push_str(&format!("<li>{}</li>\n", markdown_to_html(text)));
|
||||
i += 1;
|
||||
} else if lt.is_empty() {
|
||||
i += 1;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
html.push_str("</ul>\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list: 1. item
|
||||
if trimmed.len() > 2 && trimmed.as_bytes()[0].is_ascii_digit() {
|
||||
if let Some(rest) = strip_ordered_prefix(trimmed) {
|
||||
html.push_str("<ol>\n");
|
||||
html.push_str(&format!("<li>{}</li>\n", markdown_to_html(rest)));
|
||||
i += 1;
|
||||
while i < len {
|
||||
let lt = lines[i].trim();
|
||||
if let Some(rest) = strip_ordered_prefix(lt) {
|
||||
html.push_str(&format!("<li>{}</li>\n", markdown_to_html(rest)));
|
||||
i += 1;
|
||||
} else if lt.is_empty() {
|
||||
i += 1;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
html.push_str("</ol>\n");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Image: 
|
||||
if trimmed.starts_with("![") {
|
||||
if let Some((alt, url)) = parse_image(trimmed) {
|
||||
html.push_str(&format!(
|
||||
"<img src=\"{}\" alt=\"{}\">\n",
|
||||
escape_html(url),
|
||||
escape_html(alt)
|
||||
));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal rule: --- or ***
|
||||
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
||||
html.push_str("<hr>\n");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: collect consecutive non-blank, non-block lines.
|
||||
let mut para_lines = Vec::new();
|
||||
while i < len {
|
||||
let lt = lines[i].trim();
|
||||
if lt.is_empty()
|
||||
|| lt.starts_with('#')
|
||||
|| lt.starts_with("```")
|
||||
|| lt.starts_with('>')
|
||||
|| lt == "---"
|
||||
|| lt == "***"
|
||||
|| lt == "___"
|
||||
{
|
||||
break;
|
||||
}
|
||||
// Check if next line starts a list
|
||||
if (lt.starts_with("- ") || lt.starts_with("* ")) && !lt.starts_with("---") {
|
||||
break;
|
||||
}
|
||||
if strip_ordered_prefix(lt).is_some() && para_lines.is_empty() {
|
||||
break;
|
||||
}
|
||||
para_lines.push(lt);
|
||||
i += 1;
|
||||
}
|
||||
if !para_lines.is_empty() {
|
||||
html.push_str("<p>");
|
||||
html.push_str(&markdown_to_html(¶_lines.join(" ")));
|
||||
html.push_str("</p>\n");
|
||||
}
|
||||
}
|
||||
|
||||
html
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn strip_ordered_prefix(s: &str) -> Option<&str> {
|
||||
let dot_pos = s.find(". ")?;
|
||||
if dot_pos > 0 && s[..dot_pos].chars().all(|c| c.is_ascii_digit()) {
|
||||
Some(&s[dot_pos + 2..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_image(s: &str) -> Option<(&str, &str)> {
|
||||
// 
|
||||
let alt_start = s.find("![")? + 2;
|
||||
let alt_end = s[alt_start..].find(']')? + alt_start;
|
||||
let url_start = s[alt_end..].find('(')? + alt_end + 1;
|
||||
let url_end = s[url_start..].find(')')? + url_start;
|
||||
Some((&s[alt_start..alt_end], &s[url_start..url_end]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn headings() {
|
||||
let md = "# Title\n\n## Subtitle\n\n### Third";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<h1>Title</h1>"));
|
||||
assert!(html.contains("<h2>Subtitle</h2>"));
|
||||
assert!(html.contains("<h3>Third</h3>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs() {
|
||||
let md = "First paragraph.\n\nSecond paragraph.";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<p>First paragraph.</p>"));
|
||||
assert!(html.contains("<p>Second paragraph.</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_fence_with_language() {
|
||||
let md = "```rust\nfn main() {\n let x = 42;\n}\n```";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<pre><code class=\"language-rust\">"));
|
||||
assert!(html.contains("<span class=\"kw\">fn</span>"));
|
||||
assert!(html.contains("<span class=\"num\">42</span>"));
|
||||
assert!(html.contains("</code></pre>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_fence_no_language() {
|
||||
let md = "```\nplain code\n```";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<pre><code>plain code</code></pre>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unordered_list() {
|
||||
let md = "- Item one\n- Item two\n- Item three";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<ul>"));
|
||||
assert!(html.contains("<li>Item one</li>"));
|
||||
assert!(html.contains("<li>Item two</li>"));
|
||||
assert!(html.contains("</ul>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_list() {
|
||||
let md = "1. First\n2. Second\n3. Third";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<ol>"));
|
||||
assert!(html.contains("<li>First</li>"));
|
||||
assert!(html.contains("<li>Third</li>"));
|
||||
assert!(html.contains("</ol>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockquote() {
|
||||
let md = "> This is a quote\n> spanning two lines";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<blockquote>This is a quote spanning two lines</blockquote>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image() {
|
||||
let md = "";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<img src=\"https://example.com/img.png\" alt=\"Alt text\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_rule() {
|
||||
let md = "Before\n\n---\n\nAfter";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<hr>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_formatting_in_paragraphs() {
|
||||
let md = "Use **bold** and `code` in a paragraph.";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<strong>bold</strong>"));
|
||||
assert!(html.contains("<code>code</code>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_article() {
|
||||
let md = "\
|
||||
# Getting Started
|
||||
|
||||
Welcome to the guide.
|
||||
|
||||
## Installation
|
||||
|
||||
Install with cargo:
|
||||
|
||||
```bash
|
||||
cargo install writeonce
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Fast rendering
|
||||
- Zero dependencies
|
||||
- **Bold** feature
|
||||
|
||||
> Note: this is a quote.
|
||||
|
||||

|
||||
";
|
||||
let html = markdown_to_html_block(md);
|
||||
assert!(html.contains("<h1>Getting Started</h1>"));
|
||||
assert!(html.contains("<h2>Installation</h2>"));
|
||||
assert!(html.contains("<p>Install with cargo:</p>"));
|
||||
assert!(html.contains("<pre><code class=\"language-bash\">"));
|
||||
assert!(html.contains("<li>Fast rendering</li>"));
|
||||
assert!(html.contains("<blockquote>"));
|
||||
assert!(html.contains("<img src=\"logo.png\""));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
/// Server-side syntax highlighting for code blocks.
|
||||
///
|
||||
/// Returns HTML with `<span class="...">` tokens. Pair with code-theme.css.
|
||||
pub fn highlight(code: &str, language: &str) -> String {
|
||||
let keywords = keywords_for(language);
|
||||
let mut result = String::with_capacity(code.len() * 2);
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
// String literals (double or single quotes).
|
||||
if chars[i] == '"' || chars[i] == '\'' {
|
||||
let quote = chars[i];
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < len && chars[i] != quote {
|
||||
if chars[i] == '\\' { i += 1; } // skip escaped char
|
||||
i += 1;
|
||||
}
|
||||
if i < len { i += 1; } // closing quote
|
||||
let s: String = chars[start..i].iter().collect();
|
||||
result.push_str(&format!("<span class=\"str\">{}</span>", escape(&s)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line comments.
|
||||
if i + 1 < len && chars[i] == '/' && chars[i + 1] == '/' {
|
||||
let start = i;
|
||||
while i < len && chars[i] != '\n' { i += 1; }
|
||||
let s: String = chars[start..i].iter().collect();
|
||||
result.push_str(&format!("<span class=\"cm\">{}</span>", escape(&s)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hash comments (bash, yaml, etc.).
|
||||
if chars[i] == '#' && (language == "bash" || language == "yaml" || language == "python") {
|
||||
let start = i;
|
||||
while i < len && chars[i] != '\n' { i += 1; }
|
||||
let s: String = chars[start..i].iter().collect();
|
||||
result.push_str(&format!("<span class=\"cm\">{}</span>", escape(&s)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbers.
|
||||
if chars[i].is_ascii_digit() {
|
||||
let start = i;
|
||||
while i < len && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') { i += 1; }
|
||||
let s: String = chars[start..i].iter().collect();
|
||||
result.push_str(&format!("<span class=\"num\">{}</span>", escape(&s)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifiers / keywords.
|
||||
if chars[i].is_ascii_alphabetic() || chars[i] == '_' {
|
||||
let start = i;
|
||||
while i < len && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { i += 1; }
|
||||
let word: String = chars[start..i].iter().collect();
|
||||
if keywords.contains(&word.as_str()) {
|
||||
result.push_str(&format!("<span class=\"kw\">{}</span>", escape(&word)));
|
||||
} else {
|
||||
result.push_str(&escape(&word));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything else.
|
||||
result.push_str(&escape(&chars[i].to_string()));
|
||||
i += 1;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn keywords_for(language: &str) -> &'static [&'static str] {
|
||||
match language {
|
||||
"rust" => &[
|
||||
"fn", "let", "mut", "const", "struct", "enum", "impl", "trait", "pub", "use",
|
||||
"mod", "crate", "self", "super", "return", "if", "else", "match", "for", "while",
|
||||
"loop", "break", "continue", "where", "type", "as", "in", "ref", "move",
|
||||
"async", "await", "unsafe", "extern", "dyn", "true", "false",
|
||||
],
|
||||
"go" => &[
|
||||
"func", "var", "const", "type", "struct", "interface", "map", "chan",
|
||||
"package", "import", "return", "if", "else", "for", "range", "switch",
|
||||
"case", "default", "go", "defer", "select", "true", "false", "nil",
|
||||
],
|
||||
"bash" | "sh" => &[
|
||||
"if", "then", "else", "elif", "fi", "for", "while", "do", "done",
|
||||
"case", "esac", "function", "return", "export", "local", "echo",
|
||||
"in", "true", "false",
|
||||
],
|
||||
"yaml" => &["true", "false", "null", "yes", "no"],
|
||||
"json" => &["true", "false", "null"],
|
||||
"python" => &[
|
||||
"def", "class", "if", "elif", "else", "for", "while", "return",
|
||||
"import", "from", "as", "with", "try", "except", "finally",
|
||||
"raise", "pass", "lambda", "yield", "True", "False", "None", "in",
|
||||
"not", "and", "or", "is", "async", "await",
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn highlight_rust() {
|
||||
let code = "fn main() {\n let x = 42;\n}";
|
||||
let html = highlight(code, "rust");
|
||||
assert!(html.contains("<span class=\"kw\">fn</span>"));
|
||||
assert!(html.contains("<span class=\"kw\">let</span>"));
|
||||
assert!(html.contains("<span class=\"num\">42</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_string() {
|
||||
let code = r#"let s = "hello";"#;
|
||||
let html = highlight(code, "rust");
|
||||
assert!(html.contains("<span class=\"str\">\"hello\"</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_comment() {
|
||||
let code = "// this is a comment\nlet x = 1;";
|
||||
let html = highlight(code, "rust");
|
||||
assert!(html.contains("<span class=\"cm\">// this is a comment</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_bash() {
|
||||
let code = "# comment\nexport PATH=/usr/bin";
|
||||
let html = highlight(code, "bash");
|
||||
assert!(html.contains("<span class=\"cm\"># comment</span>"));
|
||||
assert!(html.contains("<span class=\"kw\">export</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_html() {
|
||||
let code = "fn compare<T>(a: T, b: T) {}";
|
||||
let html = highlight(code, "rust");
|
||||
assert!(html.contains("<"));
|
||||
assert!(html.contains(">"));
|
||||
assert!(!html.contains("<T>"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
/// Convert inline markdown to HTML.
|
||||
///
|
||||
/// Handles: **bold**, *italic*, `code`, [text](url), and HTML entity escaping.
|
||||
/// Does not handle block-level elements (headers, lists, etc.) — those are
|
||||
/// already structured in the article JSON.
|
||||
pub fn markdown_to_html(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len() * 2);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
match chars[i] {
|
||||
// HTML entity escaping.
|
||||
'&' => { result.push_str("&"); i += 1; }
|
||||
'<' => { result.push_str("<"); i += 1; }
|
||||
'>' => { result.push_str(">"); i += 1; }
|
||||
|
||||
// **bold** or *italic*
|
||||
'*' => {
|
||||
if i + 1 < len && chars[i + 1] == '*' {
|
||||
// **bold**
|
||||
if let Some(end) = find_closing(&chars, i + 2, "**") {
|
||||
result.push_str("<strong>");
|
||||
let inner: String = chars[i + 2..end].iter().collect();
|
||||
result.push_str(&escape_html(&inner));
|
||||
result.push_str("</strong>");
|
||||
i = end + 2;
|
||||
} else {
|
||||
result.push('*');
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
// *italic*
|
||||
if let Some(end) = find_closing_char(&chars, i + 1, '*') {
|
||||
result.push_str("<em>");
|
||||
let inner: String = chars[i + 1..end].iter().collect();
|
||||
result.push_str(&escape_html(&inner));
|
||||
result.push_str("</em>");
|
||||
i = end + 1;
|
||||
} else {
|
||||
result.push('*');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `inline code`
|
||||
'`' => {
|
||||
if let Some(end) = find_closing_char(&chars, i + 1, '`') {
|
||||
result.push_str("<code>");
|
||||
let inner: String = chars[i + 1..end].iter().collect();
|
||||
result.push_str(&escape_html(&inner));
|
||||
result.push_str("</code>");
|
||||
i = end + 1;
|
||||
} else {
|
||||
result.push('`');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// [text](url)
|
||||
'[' => {
|
||||
if let Some((text_end, url_start, url_end)) = parse_link(&chars, i) {
|
||||
let link_text: String = chars[i + 1..text_end].iter().collect();
|
||||
let url: String = chars[url_start..url_end].iter().collect();
|
||||
result.push_str(&format!(
|
||||
"<a href=\"{}\">{}</a>",
|
||||
escape_html(&url),
|
||||
escape_html(&link_text)
|
||||
));
|
||||
i = url_end + 1;
|
||||
} else {
|
||||
result.push('[');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
c => {
|
||||
result.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn find_closing(chars: &[char], start: usize, marker: &str) -> Option<usize> {
|
||||
let marker_chars: Vec<char> = marker.chars().collect();
|
||||
let mlen = marker_chars.len();
|
||||
for i in start..chars.len().saturating_sub(mlen - 1) {
|
||||
if chars[i..i + mlen] == marker_chars[..] {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_closing_char(chars: &[char], start: usize, marker: char) -> Option<usize> {
|
||||
for i in start..chars.len() {
|
||||
if chars[i] == marker {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_link(chars: &[char], start: usize) -> Option<(usize, usize, usize)> {
|
||||
// [text](url)
|
||||
let text_end = find_closing_char(chars, start + 1, ']')?;
|
||||
if text_end + 1 >= chars.len() || chars[text_end + 1] != '(' {
|
||||
return None;
|
||||
}
|
||||
let url_start = text_end + 2;
|
||||
let url_end = find_closing_char(chars, url_start, ')')?;
|
||||
Some((text_end, url_start, url_end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plain_text() {
|
||||
assert_eq!(markdown_to_html("hello world"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold() {
|
||||
assert_eq!(markdown_to_html("**bold**"), "<strong>bold</strong>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italic() {
|
||||
assert_eq!(markdown_to_html("*italic*"), "<em>italic</em>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_code() {
|
||||
assert_eq!(markdown_to_html("`code`"), "<code>code</code>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link() {
|
||||
assert_eq!(
|
||||
markdown_to_html("[click](https://example.com)"),
|
||||
"<a href=\"https://example.com\">click</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed() {
|
||||
assert_eq!(
|
||||
markdown_to_html("Use **Rust** with `cargo` for [docs](https://doc.rust-lang.org)"),
|
||||
"Use <strong>Rust</strong> with <code>cargo</code> for <a href=\"https://doc.rust-lang.org\">docs</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escaping() {
|
||||
assert_eq!(markdown_to_html("<script>alert('xss')</script>"), "<script>alert('xss')</script>");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
mod block;
|
||||
mod inline;
|
||||
mod highlight;
|
||||
|
||||
pub use block::markdown_to_html_block;
|
||||
pub use inline::markdown_to_html;
|
||||
pub use highlight::highlight;
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "wo-model"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
wo-md = { path = "../wo-md" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Markdown-first article: minimal metadata + pre-rendered HTML content.
|
||||
///
|
||||
/// The `sys_title` field is the primary key — a URL-safe slug
|
||||
/// used for lookups and routing (e.g., "linux-misc").
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Article {
|
||||
pub sys_title: String,
|
||||
pub title: String,
|
||||
pub published: bool,
|
||||
pub author: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub published_on: Option<i64>,
|
||||
/// Pre-rendered HTML from the .md file. Populated by ContentLoader.
|
||||
#[serde(default)]
|
||||
pub content_html: String,
|
||||
}
|
||||
|
||||
// --- Legacy types for backwards compatibility with old JSON format ---
|
||||
|
||||
/// Legacy article format (nested JSON with sections/codes/etc.).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegacyArticle {
|
||||
pub title: String,
|
||||
pub sys_title: String,
|
||||
pub published: bool,
|
||||
pub content: LegacyArticleContent,
|
||||
#[serde(default)]
|
||||
pub do_aws_sync: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub published_on: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegacyArticleContent {
|
||||
pub author: String,
|
||||
pub content: LegacyArticleBody,
|
||||
#[serde(default, rename = "publishedOn")]
|
||||
pub published_on: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub references: Vec<LegacyReference>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub systitle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegacyArticleBody {
|
||||
#[serde(default)]
|
||||
pub sections: Vec<LegacySection>,
|
||||
#[serde(default)]
|
||||
pub codes: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub images: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub img: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegacySection {
|
||||
pub heading: String,
|
||||
#[serde(default)]
|
||||
pub paragraphs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegacyReference {
|
||||
#[serde(default, rename = "dateAccessed")]
|
||||
pub date_accessed: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
impl LegacyArticle {
|
||||
/// Convert a legacy article to the new format by building HTML from sections.
|
||||
pub fn to_article(&self) -> Article {
|
||||
let mut html = String::new();
|
||||
for section in &self.content.content.sections {
|
||||
html.push_str(&format!("<h2>{}</h2>\n", section.heading));
|
||||
for para in §ion.paragraphs {
|
||||
if !para.is_empty() {
|
||||
html.push_str(&format!("<p>{}</p>\n", wo_md::markdown_to_html(para)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Article {
|
||||
sys_title: self.sys_title.clone(),
|
||||
title: self.title.clone(),
|
||||
published: self.published,
|
||||
author: self.content.author.clone(),
|
||||
tags: self.content.tags.clone(),
|
||||
published_on: self.published_on.or(self.content.published_on),
|
||||
content_html: html,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_new_format() {
|
||||
let json = r#"{
|
||||
"sys_title": "test-article",
|
||||
"title": "Test Article",
|
||||
"published": true,
|
||||
"author": "Author",
|
||||
"tags": ["test"],
|
||||
"published_on": 1000
|
||||
}"#;
|
||||
|
||||
let article: Article = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(article.sys_title, "test-article");
|
||||
assert_eq!(article.author, "Author");
|
||||
assert_eq!(article.tags, vec!["test"]);
|
||||
assert!(article.published);
|
||||
assert_eq!(article.content_html, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_bincode() {
|
||||
let article = Article {
|
||||
sys_title: "test".into(),
|
||||
title: "Test".into(),
|
||||
published: true,
|
||||
author: "Author".into(),
|
||||
tags: vec!["rust".into()],
|
||||
published_on: Some(1000),
|
||||
content_html: "<h1>Hello</h1>".into(),
|
||||
};
|
||||
|
||||
let bytes = bincode::serialize(&article).unwrap();
|
||||
let deserialized: Article = bincode::deserialize(&bytes).unwrap();
|
||||
assert_eq!(article, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_to_article() {
|
||||
let json = r#"{
|
||||
"title": "Legacy Article",
|
||||
"sys_title": "legacy",
|
||||
"published": true,
|
||||
"content": {
|
||||
"author": "Author",
|
||||
"content": {
|
||||
"sections": [
|
||||
{"heading": "Intro", "paragraphs": ["Hello **world**."]}
|
||||
],
|
||||
"codes": [],
|
||||
"images": []
|
||||
},
|
||||
"tags": ["test"],
|
||||
"publishedOn": 1000
|
||||
},
|
||||
"published_on": 2000
|
||||
}"#;
|
||||
|
||||
let legacy: LegacyArticle = serde_json::from_str(json).unwrap();
|
||||
let article = legacy.to_article();
|
||||
assert_eq!(article.sys_title, "legacy");
|
||||
assert_eq!(article.author, "Author");
|
||||
assert_eq!(article.tags, vec!["test"]);
|
||||
assert_eq!(article.published_on, Some(2000));
|
||||
assert!(article.content_html.contains("<h2>Intro</h2>"));
|
||||
assert!(article.content_html.contains("<strong>world</strong>"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
mod article;
|
||||
mod loader;
|
||||
|
||||
pub use article::*;
|
||||
pub use loader::ContentLoader;
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{Article, LegacyArticle};
|
||||
|
||||
/// Walks a content directory and loads all articles.
|
||||
///
|
||||
/// Supports two formats:
|
||||
/// - **New**: minimal `.json` metadata + `.md` file for content
|
||||
/// - **Legacy**: full JSON with nested sections/codes (falls back if no `.md`)
|
||||
///
|
||||
/// ```text
|
||||
/// content_dir/
|
||||
/// article-slug/
|
||||
/// article-slug.json # metadata
|
||||
/// article-slug.md # content (new format)
|
||||
/// ```
|
||||
pub struct ContentLoader;
|
||||
|
||||
impl ContentLoader {
|
||||
/// Load all articles from the given content directory.
|
||||
pub fn load_all(content_dir: &Path) -> io::Result<Vec<Article>> {
|
||||
let mut articles = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(content_dir)?;
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match Self::load_from_dir(&path) {
|
||||
Ok(article) => articles.push(article),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"wo-model: skipping {:?}: {}",
|
||||
path.file_name().unwrap_or_default(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
articles.sort_by(|a, b| {
|
||||
b.published_on
|
||||
.unwrap_or(0)
|
||||
.cmp(&a.published_on.unwrap_or(0))
|
||||
});
|
||||
|
||||
Ok(articles)
|
||||
}
|
||||
|
||||
/// Load a single article from a directory.
|
||||
///
|
||||
/// Tries new format first (minimal JSON + .md), falls back to legacy JSON.
|
||||
pub fn load_from_dir(dir: &Path) -> io::Result<Article> {
|
||||
let json_file = Self::find_json_file(dir)?;
|
||||
let json_contents = fs::read_to_string(&json_file)?;
|
||||
|
||||
// Check if a .md file exists alongside the JSON.
|
||||
let md_file = Self::find_md_file(dir);
|
||||
|
||||
if let Some(md_path) = md_file {
|
||||
// New format: minimal JSON + markdown file.
|
||||
// Try parsing as new format first, then fall back to legacy.
|
||||
let mut article: Article =
|
||||
if let Ok(a) = serde_json::from_str::<Article>(&json_contents) {
|
||||
a
|
||||
} else {
|
||||
let legacy: LegacyArticle =
|
||||
serde_json::from_str(&json_contents).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{}: {}", json_file.display(), e),
|
||||
)
|
||||
})?;
|
||||
legacy.to_article()
|
||||
};
|
||||
|
||||
let md_content = fs::read_to_string(&md_path)?;
|
||||
article.content_html = wo_md::markdown_to_html_block(&md_content);
|
||||
Ok(article)
|
||||
} else {
|
||||
// Legacy format: full JSON with sections, no .md file.
|
||||
let legacy: LegacyArticle = serde_json::from_str(&json_contents).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{}: {}", json_file.display(), e),
|
||||
)
|
||||
})?;
|
||||
Ok(legacy.to_article())
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a single article from a specific JSON file path.
|
||||
pub fn load_from_file(path: &Path) -> io::Result<Article> {
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
Self::load_from_dir(dir)
|
||||
}
|
||||
|
||||
fn find_json_file(dir: &Path) -> io::Result<std::path::PathBuf> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("json") {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("no .json file in {}", dir.display()),
|
||||
))
|
||||
}
|
||||
|
||||
fn find_md_file(dir: &Path) -> Option<std::path::PathBuf> {
|
||||
let entries = fs::read_dir(dir).ok()?;
|
||||
for entry in entries {
|
||||
let entry = entry.ok()?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Create a new-format article: minimal JSON + .md file.
|
||||
fn create_new_article(base: &Path, sys_title: &str, published: bool, tags: &[&str]) {
|
||||
let dir = base.join(sys_title);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let tags_json: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t)).collect();
|
||||
let json = format!(
|
||||
r#"{{
|
||||
"sys_title": "{}",
|
||||
"title": "{}",
|
||||
"published": {},
|
||||
"author": "Test Author",
|
||||
"tags": [{}],
|
||||
"published_on": 2000
|
||||
}}"#,
|
||||
sys_title, sys_title, published, tags_json.join(", ")
|
||||
);
|
||||
fs::write(dir.join(format!("{}.json", sys_title)), json).unwrap();
|
||||
|
||||
let md = format!("# {}\n\nThis is the **content** of the article.\n\n```rust\nfn main() {{}}\n```\n", sys_title);
|
||||
fs::write(dir.join(format!("{}.md", sys_title)), md).unwrap();
|
||||
}
|
||||
|
||||
/// Create a legacy-format article: full JSON, no .md file.
|
||||
fn create_legacy_article(base: &Path, sys_title: &str, published: bool, tags: &[&str]) {
|
||||
let dir = base.join(sys_title);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let tags_json: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t)).collect();
|
||||
let json = format!(
|
||||
r#"{{
|
||||
"title": "{}",
|
||||
"sys_title": "{}",
|
||||
"published": {},
|
||||
"content": {{
|
||||
"author": "Test Author",
|
||||
"content": {{
|
||||
"sections": [{{ "heading": "Intro", "paragraphs": ["Hello **world**."] }}],
|
||||
"codes": [],
|
||||
"images": []
|
||||
}},
|
||||
"tags": [{}],
|
||||
"publishedOn": 1000
|
||||
}},
|
||||
"published_on": 2000
|
||||
}}"#,
|
||||
sys_title, sys_title, published, tags_json.join(", ")
|
||||
);
|
||||
fs::write(dir.join(format!("{}.json", sys_title)), json).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_new_format() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
create_new_article(tmp.path(), "my-article", true, &["rust"]);
|
||||
|
||||
let article = ContentLoader::load_from_dir(&tmp.path().join("my-article")).unwrap();
|
||||
assert_eq!(article.sys_title, "my-article");
|
||||
assert_eq!(article.author, "Test Author");
|
||||
assert_eq!(article.tags, vec!["rust"]);
|
||||
assert!(article.content_html.contains("<h1>my-article</h1>"));
|
||||
assert!(article.content_html.contains("<strong>content</strong>"));
|
||||
assert!(article.content_html.contains("<pre><code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_legacy_format() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
create_legacy_article(tmp.path(), "legacy-art", true, &["test"]);
|
||||
|
||||
let article = ContentLoader::load_from_dir(&tmp.path().join("legacy-art")).unwrap();
|
||||
assert_eq!(article.sys_title, "legacy-art");
|
||||
assert_eq!(article.author, "Test Author");
|
||||
assert!(article.content_html.contains("<h2>Intro</h2>"));
|
||||
assert!(article.content_html.contains("<strong>world</strong>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_all_mixed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
create_new_article(tmp.path(), "new-art", true, &["rust"]);
|
||||
create_legacy_article(tmp.path(), "old-art", true, &["go"]);
|
||||
|
||||
let articles = ContentLoader::load_all(tmp.path()).unwrap();
|
||||
assert_eq!(articles.len(), 2);
|
||||
|
||||
let new = articles.iter().find(|a| a.sys_title == "new-art").unwrap();
|
||||
assert!(new.content_html.contains("<h1>"));
|
||||
|
||||
let old = articles.iter().find(|a| a.sys_title == "old-art").unwrap();
|
||||
assert!(old.content_html.contains("<h2>Intro</h2>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_non_directory_entries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
create_new_article(tmp.path(), "valid-article", true, &[]);
|
||||
fs::write(tmp.path().join("README.md"), "not an article").unwrap();
|
||||
|
||||
let articles = ContentLoader::load_all(tmp.path()).unwrap();
|
||||
assert_eq!(articles.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_directory_without_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
create_new_article(tmp.path(), "valid-article", true, &[]);
|
||||
fs::create_dir_all(tmp.path().join("images")).unwrap();
|
||||
fs::write(tmp.path().join("images/photo.png"), b"fake image").unwrap();
|
||||
|
||||
let articles = ContentLoader::load_all(tmp.path()).unwrap();
|
||||
assert_eq!(articles.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
[package]
|
||||
name = "wo-route"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-http = { path = "../wo-http" }
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
mod pattern;
|
||||
mod router;
|
||||
|
||||
pub use pattern::{Pattern, Segment};
|
||||
pub use router::{RouteParams, Router};
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
/// A compiled URL pattern segment.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Segment {
|
||||
/// Exact literal match (e.g., "blog").
|
||||
Literal(String),
|
||||
/// Named parameter (e.g., ":sys_title").
|
||||
Param(String),
|
||||
/// Wildcard matching the rest of the path (e.g., "*path").
|
||||
Wildcard(String),
|
||||
}
|
||||
|
||||
/// A compiled URL pattern like "/blog/:sys_title" or "/static/*path".
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pattern {
|
||||
pub segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
/// Compile a pattern string into segments.
|
||||
///
|
||||
/// - `/blog/:sys_title` → `[Literal("blog"), Param("sys_title")]`
|
||||
/// - `/static/*path` → `[Literal("static"), Wildcard("path")]`
|
||||
/// - `/` → `[]`
|
||||
pub fn compile(pattern: &str) -> Self {
|
||||
let segments = pattern
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| {
|
||||
if let Some(name) = s.strip_prefix(':') {
|
||||
Segment::Param(name.to_string())
|
||||
} else if let Some(name) = s.strip_prefix('*') {
|
||||
Segment::Wildcard(name.to_string())
|
||||
} else {
|
||||
Segment::Literal(s.to_string())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self { segments }
|
||||
}
|
||||
|
||||
/// Try to match a URL path against this pattern.
|
||||
///
|
||||
/// Returns `Some(params)` if the path matches, where params is a list
|
||||
/// of `(name, value)` pairs for any `:param` or `*wildcard` segments.
|
||||
pub fn matches(&self, path: &str) -> Option<Vec<(String, String)>> {
|
||||
let path_segments: Vec<&str> = path
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let mut params = Vec::new();
|
||||
let mut pi = 0; // path segment index
|
||||
|
||||
for seg in &self.segments {
|
||||
match seg {
|
||||
Segment::Literal(lit) => {
|
||||
if pi >= path_segments.len() || path_segments[pi] != lit.as_str() {
|
||||
return None;
|
||||
}
|
||||
pi += 1;
|
||||
}
|
||||
Segment::Param(name) => {
|
||||
if pi >= path_segments.len() {
|
||||
return None;
|
||||
}
|
||||
params.push((name.clone(), path_segments[pi].to_string()));
|
||||
pi += 1;
|
||||
}
|
||||
Segment::Wildcard(name) => {
|
||||
if pi >= path_segments.len() {
|
||||
return None;
|
||||
}
|
||||
let rest = path_segments[pi..].join("/");
|
||||
params.push((name.clone(), rest));
|
||||
return Some(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All pattern segments consumed; path must also be fully consumed.
|
||||
if pi == path_segments.len() {
|
||||
Some(params)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn root_path() {
|
||||
let p = Pattern::compile("/");
|
||||
assert!(p.matches("/").is_some());
|
||||
assert!(p.matches("/blog").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_path() {
|
||||
let p = Pattern::compile("/about");
|
||||
assert!(p.matches("/about").is_some());
|
||||
assert!(p.matches("/contact").is_none());
|
||||
assert!(p.matches("/about/extra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_extraction() {
|
||||
let p = Pattern::compile("/blog/:sys_title");
|
||||
let params = p.matches("/blog/linux-misc").unwrap();
|
||||
assert_eq!(params, vec![("sys_title".into(), "linux-misc".into())]);
|
||||
|
||||
assert!(p.matches("/blog").is_none());
|
||||
assert!(p.matches("/blog/linux-misc/extra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard() {
|
||||
let p = Pattern::compile("/static/*path");
|
||||
let params = p.matches("/static/styles/main.css").unwrap();
|
||||
assert_eq!(params, vec![("path".into(), "styles/main.css".into())]);
|
||||
|
||||
let params = p.matches("/static/logo.png").unwrap();
|
||||
assert_eq!(params, vec![("path".into(), "logo.png".into())]);
|
||||
|
||||
assert!(p.matches("/static").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_segment() {
|
||||
let p = Pattern::compile("/tag/:tag");
|
||||
let params = p.matches("/tag/rust").unwrap();
|
||||
assert_eq!(params, vec![("tag".into(), "rust".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match() {
|
||||
let p = Pattern::compile("/blog/:sys_title");
|
||||
assert!(p.matches("/about").is_none());
|
||||
assert!(p.matches("/").is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use wo_http::request::{Method, Request};
|
||||
|
||||
use crate::pattern::Pattern;
|
||||
|
||||
/// Extracted route parameters.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RouteParams {
|
||||
params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl RouteParams {
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.params.get(key).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn from_pairs(pairs: Vec<(String, String)>) -> Self {
|
||||
Self {
|
||||
params: pairs.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A route entry: method + pattern + handler name.
|
||||
struct Route {
|
||||
method: Method,
|
||||
pattern: Pattern,
|
||||
handler: String,
|
||||
}
|
||||
|
||||
/// URL router that matches requests to named handlers.
|
||||
pub struct Router {
|
||||
routes: Vec<Route>,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
pub fn new() -> Self {
|
||||
Self { routes: Vec::new() }
|
||||
}
|
||||
|
||||
/// Add a route. Handler is a string name that the caller maps to a function.
|
||||
pub fn add(&mut self, method: Method, pattern: &str, handler: &str) {
|
||||
self.routes.push(Route {
|
||||
method,
|
||||
pattern: Pattern::compile(pattern),
|
||||
handler: handler.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Match a request to a route. Returns the handler name and extracted params.
|
||||
pub fn dispatch(&self, request: &Request) -> Option<(String, RouteParams)> {
|
||||
for route in &self.routes {
|
||||
if route.method != request.method {
|
||||
continue;
|
||||
}
|
||||
if let Some(pairs) = route.pattern.matches(&request.path) {
|
||||
return Some((
|
||||
route.handler.clone(),
|
||||
RouteParams::from_pairs(pairs),
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_request(method: Method, path: &str) -> Request {
|
||||
Request {
|
||||
method,
|
||||
path: path.to_string(),
|
||||
query: None,
|
||||
headers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_routes() {
|
||||
let mut router = Router::new();
|
||||
router.add(Method::Get, "/", "home");
|
||||
router.add(Method::Get, "/blog/:sys_title", "article");
|
||||
router.add(Method::Get, "/about", "about");
|
||||
router.add(Method::Get, "/tag/:tag", "tag_listing");
|
||||
router.add(Method::Get, "/static/*path", "static_file");
|
||||
|
||||
// Home.
|
||||
let (handler, _) = router.dispatch(&make_request(Method::Get, "/")).unwrap();
|
||||
assert_eq!(handler, "home");
|
||||
|
||||
// Article.
|
||||
let (handler, params) = router
|
||||
.dispatch(&make_request(Method::Get, "/blog/linux-misc"))
|
||||
.unwrap();
|
||||
assert_eq!(handler, "article");
|
||||
assert_eq!(params.get("sys_title"), Some("linux-misc"));
|
||||
|
||||
// About.
|
||||
let (handler, _) = router.dispatch(&make_request(Method::Get, "/about")).unwrap();
|
||||
assert_eq!(handler, "about");
|
||||
|
||||
// Tag.
|
||||
let (handler, params) = router
|
||||
.dispatch(&make_request(Method::Get, "/tag/rust"))
|
||||
.unwrap();
|
||||
assert_eq!(handler, "tag_listing");
|
||||
assert_eq!(params.get("tag"), Some("rust"));
|
||||
|
||||
// Static.
|
||||
let (handler, params) = router
|
||||
.dispatch(&make_request(Method::Get, "/static/styles/main.css"))
|
||||
.unwrap();
|
||||
assert_eq!(handler, "static_file");
|
||||
assert_eq!(params.get("path"), Some("styles/main.css"));
|
||||
|
||||
// No match.
|
||||
assert!(router
|
||||
.dispatch(&make_request(Method::Get, "/nonexistent"))
|
||||
.is_none());
|
||||
|
||||
// Wrong method.
|
||||
assert!(router.dispatch(&make_request(Method::Post, "/")).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
[package]
|
||||
name = "wo-rt"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-model = { path = "../wo-model" }
|
||||
wo-store = { path = "../wo-store" }
|
||||
wo-watch = { path = "../wo-watch" }
|
||||
wo-event = { path = "../wo-event" }
|
||||
wo-sub = { path = "../wo-sub" }
|
||||
wo-http = { path = "../wo-http" }
|
||||
wo-route = { path = "../wo-route" }
|
||||
wo-htmlx = { path = "../wo-htmlx" }
|
||||
wo-md = { path = "../wo-md" }
|
||||
wo-serve = { path = "../wo-serve" }
|
||||
libc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "writeonce"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
bincode = { workspace = true }
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
/// Runtime configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Path to the content directory (JSON + MD source files).
|
||||
pub content_dir: PathBuf,
|
||||
/// Path to the data directory (.seg + .idx derived files).
|
||||
pub data_dir: PathBuf,
|
||||
/// Path to the templates directory (.htmlx files).
|
||||
pub templates_dir: PathBuf,
|
||||
/// Path to the static assets directory (CSS, images).
|
||||
pub static_dir: PathBuf,
|
||||
/// HTTP bind address (e.g., "0.0.0.0:3000").
|
||||
pub bind_addr: String,
|
||||
/// Force a full rebuild on startup (ignore existing data/).
|
||||
pub rebuild_on_start: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(content_dir: impl Into<PathBuf>, data_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
content_dir: content_dir.into(),
|
||||
data_dir: data_dir.into(),
|
||||
templates_dir: PathBuf::from("templates"),
|
||||
static_dir: PathBuf::from("static"),
|
||||
bind_addr: "0.0.0.0:3000".into(),
|
||||
rebuild_on_start: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
use wo_htmlx::{self, Value};
|
||||
use wo_http::response::Response;
|
||||
use wo_route::RouteParams;
|
||||
use wo_store::Store;
|
||||
use wo_htmlx::TemplateRegistry;
|
||||
|
||||
/// Render the homepage: list of published articles.
|
||||
pub fn handle_home(store: &Store, templates: &TemplateRegistry) -> Response {
|
||||
let articles = match store.list_published(0, 20) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return Response::internal_error(&e.to_string()),
|
||||
};
|
||||
|
||||
let articles_val: Vec<Value> = articles
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let json = serde_json::to_value(a).unwrap_or_default();
|
||||
Value::from_json(&json)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut ctx = std::collections::BTreeMap::new();
|
||||
ctx.insert("articles".into(), Value::List(articles_val));
|
||||
ctx.insert("page_title".into(), Value::String("writeonce".into()));
|
||||
let context = Value::Object(ctx);
|
||||
|
||||
render_page("home", &context, templates)
|
||||
}
|
||||
|
||||
/// Render a single article page.
|
||||
pub fn handle_article(params: &RouteParams, store: &Store, templates: &TemplateRegistry) -> Response {
|
||||
let sys_title = match params.get("sys_title") {
|
||||
Some(t) => t,
|
||||
None => return Response::not_found(),
|
||||
};
|
||||
|
||||
let article = match store.get_by_title(sys_title) {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => return Response::not_found(),
|
||||
Err(e) => return Response::internal_error(&e.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&article).unwrap_or_default();
|
||||
let mut ctx = std::collections::BTreeMap::new();
|
||||
ctx.insert("article".into(), Value::from_json(&json));
|
||||
ctx.insert("page_title".into(), Value::String(article.title.clone()));
|
||||
let context = Value::Object(ctx);
|
||||
|
||||
render_page("article", &context, templates)
|
||||
}
|
||||
|
||||
/// Render a tag listing page.
|
||||
pub fn handle_tag(params: &RouteParams, store: &Store, templates: &TemplateRegistry) -> Response {
|
||||
let tag = match params.get("tag") {
|
||||
Some(t) => t,
|
||||
None => return Response::not_found(),
|
||||
};
|
||||
|
||||
let articles = match store.list_by_tag(tag) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return Response::internal_error(&e.to_string()),
|
||||
};
|
||||
|
||||
let articles_val: Vec<Value> = articles
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let json = serde_json::to_value(a).unwrap_or_default();
|
||||
Value::from_json(&json)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut ctx = std::collections::BTreeMap::new();
|
||||
ctx.insert("articles".into(), Value::List(articles_val));
|
||||
ctx.insert("page_title".into(), Value::String(format!("tag: {}", tag)));
|
||||
ctx.insert("tag".into(), Value::String(tag.to_string()));
|
||||
let context = Value::Object(ctx);
|
||||
|
||||
render_page("home", &context, templates)
|
||||
}
|
||||
|
||||
/// Render a static page (about, contact).
|
||||
pub fn handle_static_page(name: &str, templates: &TemplateRegistry) -> Response {
|
||||
let mut ctx = std::collections::BTreeMap::new();
|
||||
ctx.insert("page_title".into(), Value::String(name.to_string()));
|
||||
let context = Value::Object(ctx);
|
||||
|
||||
render_page(name, &context, templates)
|
||||
}
|
||||
|
||||
/// Render a page template composed with layout, header, and footer.
|
||||
fn render_page(template_name: &str, context: &Value, templates: &TemplateRegistry) -> Response {
|
||||
let page_nodes = match templates.get(template_name) {
|
||||
Some(nodes) => nodes,
|
||||
None => return Response::not_found(),
|
||||
};
|
||||
|
||||
let partials = templates.partials();
|
||||
|
||||
// Render the page content.
|
||||
let page_html = wo_htmlx::render(page_nodes, context, partials);
|
||||
|
||||
// Compose with layout if it exists.
|
||||
let html = if let Some(layout_nodes) = templates.get("layout") {
|
||||
// Inject page content and header/footer.
|
||||
let header_html = templates
|
||||
.get("header")
|
||||
.map(|n| wo_htmlx::render(n, context, partials))
|
||||
.unwrap_or_default();
|
||||
let footer_html = templates
|
||||
.get("footer")
|
||||
.map(|n| wo_htmlx::render(n, context, partials))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut layout_ctx = std::collections::BTreeMap::new();
|
||||
layout_ctx.insert("content".into(), Value::String(page_html));
|
||||
layout_ctx.insert("header".into(), Value::String(header_html));
|
||||
layout_ctx.insert("footer".into(), Value::String(footer_html));
|
||||
if let Value::Object(map) = context {
|
||||
for (k, v) in map {
|
||||
layout_ctx.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
let layout_context = Value::Object(layout_ctx);
|
||||
wo_htmlx::render(layout_nodes, &layout_context, partials)
|
||||
} else {
|
||||
page_html
|
||||
};
|
||||
|
||||
Response::html(html)
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
mod config;
|
||||
mod handlers;
|
||||
mod runtime;
|
||||
|
||||
pub use config::Config;
|
||||
pub use runtime::{Runtime, RuntimeHandle};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
use wo_rt::{Config, Runtime};
|
||||
|
||||
fn main() {
|
||||
let content_dir = PathBuf::from("content");
|
||||
let data_dir = PathBuf::from("data");
|
||||
|
||||
let config = Config {
|
||||
content_dir,
|
||||
data_dir,
|
||||
templates_dir: PathBuf::from("templates"),
|
||||
static_dir: PathBuf::from("static"),
|
||||
bind_addr: "0.0.0.0:3000".into(),
|
||||
rebuild_on_start: true,
|
||||
};
|
||||
|
||||
let mut rt = match Runtime::new(&config) {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("failed to start: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("writeonce started — content={} data={}",
|
||||
config.content_dir.display(), config.data_dir.display());
|
||||
|
||||
if let Err(e) = rt.run() {
|
||||
eprintln!("runtime error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
eprintln!("writeonce shut down");
|
||||
}
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use wo_event::{EventLoop, Interest, SignalFd, TimerFd, Token};
|
||||
use wo_htmlx::TemplateRegistry;
|
||||
use wo_http::connection::{Connection, State};
|
||||
use wo_http::listener::TcpListener;
|
||||
use wo_http::request::Method;
|
||||
use wo_http::response::Response;
|
||||
use wo_route::{RouteParams, Router};
|
||||
use wo_store::Store;
|
||||
use wo_sub::{ChangeType, Subscription, SubscriptionManager};
|
||||
use wo_watch::{ContentChange, ContentWatcher};
|
||||
|
||||
use crate::handlers;
|
||||
use crate::Config;
|
||||
|
||||
// Well-known tokens.
|
||||
const TOKEN_WATCHER: Token = 1;
|
||||
const TOKEN_SIGNAL: Token = 2;
|
||||
const TOKEN_TIMER: Token = 3;
|
||||
const TOKEN_NOTIFY: Token = 4;
|
||||
const TOKEN_HTTP_LISTENER: Token = 5;
|
||||
// Token ranges.
|
||||
const TOKEN_HTTP_BASE: Token = 10000;
|
||||
const TOKEN_HTTP_MAX: Token = 19999;
|
||||
const TOKEN_SUB_BASE: Token = 20000;
|
||||
|
||||
/// The writeonce runtime: single process, single event loop, all fds on one epoll.
|
||||
pub struct Runtime {
|
||||
event_loop: EventLoop,
|
||||
store: Store,
|
||||
watcher: ContentWatcher,
|
||||
subscriptions: SubscriptionManager,
|
||||
signal_fd: SignalFd,
|
||||
timer_fd: TimerFd,
|
||||
running: Arc<AtomicBool>,
|
||||
// HTTP
|
||||
listener: Option<TcpListener>,
|
||||
connections: HashMap<Token, Connection>,
|
||||
next_http_token: Token,
|
||||
router: Router,
|
||||
templates: TemplateRegistry,
|
||||
static_dir: std::path::PathBuf,
|
||||
// Subscribed browser connections.
|
||||
next_sub_token: Token,
|
||||
sub_token_to_fd: HashMap<Token, RawFd>,
|
||||
}
|
||||
|
||||
/// Handle returned by `Runtime::start()` for external interaction.
|
||||
pub struct RuntimeHandle {
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl RuntimeHandle {
|
||||
pub fn shutdown(&self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn new(config: &Config) -> io::Result<Self> {
|
||||
let event_loop = EventLoop::new()?;
|
||||
|
||||
let store = if config.rebuild_on_start {
|
||||
let mut s = Store::open(&config.content_dir, &config.data_dir)?;
|
||||
s.rebuild()?;
|
||||
s
|
||||
} else {
|
||||
Store::open(&config.content_dir, &config.data_dir)?
|
||||
};
|
||||
|
||||
let watcher = ContentWatcher::new(&config.content_dir)?;
|
||||
let subscriptions = SubscriptionManager::new()?;
|
||||
let signal_fd = SignalFd::new()?;
|
||||
let timer_fd = TimerFd::new()?;
|
||||
timer_fd.set(Duration::from_secs(60), Duration::from_secs(60))?;
|
||||
|
||||
// Register system fds.
|
||||
event_loop.register(watcher.fd(), Interest::Readable, TOKEN_WATCHER)?;
|
||||
event_loop.register(signal_fd.fd(), Interest::Readable, TOKEN_SIGNAL)?;
|
||||
event_loop.register(timer_fd.fd(), Interest::Readable, TOKEN_TIMER)?;
|
||||
event_loop.register(subscriptions.notify_fd(), Interest::Readable, TOKEN_NOTIFY)?;
|
||||
|
||||
// HTTP listener (optional — may not bind in test mode).
|
||||
let listener = if !config.bind_addr.is_empty() {
|
||||
match TcpListener::bind(&config.bind_addr) {
|
||||
Ok(l) => {
|
||||
event_loop.register(l.fd(), Interest::Readable, TOKEN_HTTP_LISTENER)?;
|
||||
Some(l)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("wo-rt: failed to bind {}: {}", config.bind_addr, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Load templates.
|
||||
let templates = TemplateRegistry::load(&config.templates_dir)?;
|
||||
|
||||
// Build router.
|
||||
let mut router = Router::new();
|
||||
router.add(Method::Get, "/", "home");
|
||||
router.add(Method::Get, "/blog/:sys_title", "article");
|
||||
router.add(Method::Get, "/about", "about");
|
||||
router.add(Method::Get, "/contact", "contact");
|
||||
router.add(Method::Get, "/tag/:tag", "tag_listing");
|
||||
router.add(Method::Get, "/static/*path", "static_file");
|
||||
|
||||
Ok(Self {
|
||||
event_loop,
|
||||
store,
|
||||
watcher,
|
||||
subscriptions,
|
||||
signal_fd,
|
||||
timer_fd,
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
listener,
|
||||
connections: HashMap::new(),
|
||||
next_http_token: TOKEN_HTTP_BASE,
|
||||
router,
|
||||
templates,
|
||||
static_dir: config.static_dir.clone(),
|
||||
next_sub_token: TOKEN_SUB_BASE,
|
||||
sub_token_to_fd: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run(&mut self) -> io::Result<()> {
|
||||
self.running.store(true, Ordering::SeqCst);
|
||||
|
||||
if let Some(ref listener) = self.listener {
|
||||
let (_, port) = listener.local_addr()?;
|
||||
eprintln!("writeonce listening on port {}", port);
|
||||
}
|
||||
|
||||
while self.running.load(Ordering::SeqCst) {
|
||||
let events = self.event_loop.poll(Some(Duration::from_millis(500)))?;
|
||||
|
||||
for event in events {
|
||||
match event.token {
|
||||
TOKEN_WATCHER if event.readable => {
|
||||
self.handle_watcher_event()?;
|
||||
}
|
||||
TOKEN_SIGNAL if event.readable => {
|
||||
let _ = self.signal_fd.read();
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
TOKEN_TIMER if event.readable => {
|
||||
let _ = self.timer_fd.read();
|
||||
}
|
||||
TOKEN_NOTIFY if event.readable => {
|
||||
// Notifications already written by SubscriptionManager.
|
||||
}
|
||||
TOKEN_HTTP_LISTENER if event.readable => {
|
||||
self.handle_accept()?;
|
||||
}
|
||||
token if token >= TOKEN_SUB_BASE => {
|
||||
// Subscribed connection.
|
||||
if event.hangup || event.error {
|
||||
if let Some(&fd) = self.sub_token_to_fd.get(&token) {
|
||||
self.subscriptions.unsubscribe(fd);
|
||||
let _ = self.event_loop.deregister(fd);
|
||||
self.sub_token_to_fd.remove(&token);
|
||||
// Don't close fd here — Connection owns it.
|
||||
}
|
||||
}
|
||||
}
|
||||
token if token >= TOKEN_HTTP_BASE => {
|
||||
self.handle_http_event(token, &event)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up completed connections.
|
||||
let done_tokens: Vec<Token> = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|(_, c)| c.state == State::Done)
|
||||
.map(|(&t, _)| t)
|
||||
.collect();
|
||||
for token in done_tokens {
|
||||
if let Some(conn) = self.connections.remove(&token) {
|
||||
let _ = self.event_loop.deregister(conn.fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> RuntimeHandle {
|
||||
RuntimeHandle {
|
||||
running: self.running.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store(&self) -> &Store {
|
||||
&self.store
|
||||
}
|
||||
|
||||
pub fn store_mut(&mut self) -> &mut Store {
|
||||
&mut self.store
|
||||
}
|
||||
|
||||
/// Subscribe a fd (binary format, for internal use).
|
||||
pub fn subscribe(&mut self, fd: RawFd, query: Subscription) -> io::Result<Token> {
|
||||
let token = self.next_sub_token;
|
||||
self.next_sub_token += 1;
|
||||
self.event_loop.register(fd, Interest::Readable, token)?;
|
||||
self.subscriptions.subscribe(fd, query);
|
||||
self.sub_token_to_fd.insert(token, fd);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn handle_accept(&mut self) -> io::Result<()> {
|
||||
let listener = match &self.listener {
|
||||
Some(l) => l,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Accept all pending connections.
|
||||
while let Some(client_fd) = listener.accept()? {
|
||||
if self.next_http_token > TOKEN_HTTP_MAX {
|
||||
self.next_http_token = TOKEN_HTTP_BASE; // recycle tokens
|
||||
}
|
||||
let token = self.next_http_token;
|
||||
self.next_http_token += 1;
|
||||
|
||||
self.event_loop
|
||||
.register(client_fd, Interest::Readable, token)?;
|
||||
self.connections.insert(token, Connection::new(client_fd));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_http_event(&mut self, token: Token, event: &wo_event::Event) -> io::Result<()> {
|
||||
if event.hangup || event.error {
|
||||
if let Some(conn) = self.connections.remove(&token) {
|
||||
let _ = self.event_loop.deregister(conn.fd);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read phase: parse the request without holding mutable borrow during dispatch.
|
||||
let conn = match self.connections.get_mut(&token) {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
match conn.state {
|
||||
State::ReadingRequest if event.readable => {
|
||||
let open = conn.read()?;
|
||||
if !open {
|
||||
conn.set_done();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(request) = conn.try_parse() {
|
||||
// Dispatch the handler (borrows self immutably via store/templates).
|
||||
let dispatch_result = self.router.dispatch(&request);
|
||||
let response = match dispatch_result {
|
||||
Some((ref handler, ref params)) => {
|
||||
dispatch_handler(handler, params, &self.store, &self.templates, &self.static_dir)
|
||||
}
|
||||
None => Response::not_found(),
|
||||
};
|
||||
|
||||
// Now get the connection again to set the response.
|
||||
let conn = self.connections.get_mut(&token).unwrap();
|
||||
conn.set_response(&response);
|
||||
if conn.write()? {
|
||||
conn.set_done();
|
||||
} else {
|
||||
let fd = conn.fd;
|
||||
self.event_loop.modify(fd, Interest::Writable, token)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
State::WritingResponse if event.writable => {
|
||||
if conn.write()? {
|
||||
conn.set_done();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_watcher_event(&mut self) -> io::Result<()> {
|
||||
let changes = self.watcher.process_events()?;
|
||||
|
||||
for change in &changes {
|
||||
match change {
|
||||
ContentChange::Created(sys_title) | ContentChange::Modified(sys_title) => {
|
||||
self.store.rebuild()?;
|
||||
let version = self.store.article_version(sys_title).unwrap_or(0);
|
||||
let tags = self
|
||||
.store
|
||||
.get_by_title(sys_title)?
|
||||
.map(|a| a.tags.clone())
|
||||
.unwrap_or_default();
|
||||
let change_type = match change {
|
||||
ContentChange::Created(_) => ChangeType::Created,
|
||||
_ => ChangeType::Updated,
|
||||
};
|
||||
self.subscriptions.notify(sys_title, &tags, change_type, version)?;
|
||||
}
|
||||
ContentChange::Deleted(sys_title) => {
|
||||
let version = self.store.article_version(sys_title).unwrap_or(0);
|
||||
self.subscriptions.notify(sys_title, &[], ChangeType::Deleted, version)?;
|
||||
self.store.rebuild()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_handler(
|
||||
handler: &str,
|
||||
params: &RouteParams,
|
||||
store: &Store,
|
||||
templates: &TemplateRegistry,
|
||||
static_dir: &std::path::Path,
|
||||
) -> Response {
|
||||
match handler {
|
||||
"home" => handlers::handle_home(store, templates),
|
||||
"article" => handlers::handle_article(params, store, templates),
|
||||
"tag_listing" => handlers::handle_tag(params, store, templates),
|
||||
"about" => handlers::handle_static_page("about", templates),
|
||||
"contact" => handlers::handle_static_page("contact", templates),
|
||||
"static_file" => {
|
||||
let path = params.get("path").unwrap_or("");
|
||||
wo_serve::send_file(static_dir, path)
|
||||
}
|
||||
_ => Response::not_found(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn create_test_article(base: &std::path::Path, sys_title: &str, published: bool) {
|
||||
let dir = base.join(sys_title);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let json = format!(
|
||||
r#"{{
|
||||
"title": "{}",
|
||||
"sys_title": "{}",
|
||||
"published": {},
|
||||
"content": {{
|
||||
"author": "Author",
|
||||
"content": {{
|
||||
"sections": [{{ "heading": "Intro", "paragraphs": ["Hello."] }}],
|
||||
"codes": [],
|
||||
"images": []
|
||||
}},
|
||||
"tags": ["test"],
|
||||
"publishedOn": 1000
|
||||
}},
|
||||
"published_on": 1000
|
||||
}}"#,
|
||||
sys_title, sys_title, published
|
||||
);
|
||||
fs::write(dir.join(format!("{}.json", sys_title)), json).unwrap();
|
||||
}
|
||||
|
||||
fn test_config(content: &std::path::Path, data: &std::path::Path) -> Config {
|
||||
Config {
|
||||
bind_addr: String::new(), // no HTTP in tests
|
||||
..Config::new(content, data)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_creates_and_queries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let data = tmp.path().join("data");
|
||||
|
||||
create_test_article(&content, "art-one", true);
|
||||
create_test_article(&content, "art-two", true);
|
||||
|
||||
let config = test_config(&content, &data);
|
||||
let rt = Runtime::new(&config).unwrap();
|
||||
|
||||
let art = rt.store().get_by_title("art-one").unwrap().unwrap();
|
||||
assert_eq!(art.sys_title, "art-one");
|
||||
assert_eq!(rt.store().count_published().unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_subscribe_and_notify() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let data = tmp.path().join("data");
|
||||
|
||||
create_test_article(&content, "art-one", true);
|
||||
|
||||
let config = test_config(&content, &data);
|
||||
let mut rt = Runtime::new(&config).unwrap();
|
||||
|
||||
let mut fds = [0i32; 2];
|
||||
assert_eq!(unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_NONBLOCK) }, 0);
|
||||
let (read_fd, write_fd) = (fds[0], fds[1]);
|
||||
|
||||
rt.subscribe(write_fd, Subscription::ByTitle("art-one".into())).unwrap();
|
||||
|
||||
rt.subscriptions
|
||||
.notify("art-one", &["test".into()], ChangeType::Updated, 1)
|
||||
.unwrap();
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
let ret = unsafe { libc::read(read_fd, len_buf.as_mut_ptr() as *mut libc::c_void, 4) };
|
||||
assert_eq!(ret, 4);
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
let ret = unsafe { libc::read(read_fd, payload.as_mut_ptr() as *mut libc::c_void, len) };
|
||||
assert_eq!(ret as usize, len);
|
||||
|
||||
let notification: wo_sub::Notification = bincode::deserialize(&payload).unwrap();
|
||||
assert_eq!(notification.sys_title, "art-one");
|
||||
|
||||
unsafe { libc::close(read_fd); libc::close(write_fd); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_shutdown_via_handle() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let data = tmp.path().join("data");
|
||||
fs::create_dir_all(&content).unwrap();
|
||||
|
||||
let config = test_config(&content, &data);
|
||||
let mut rt = Runtime::new(&config).unwrap();
|
||||
let handle = rt.handle();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
handle.shutdown();
|
||||
});
|
||||
|
||||
rt.run().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_rebuild_on_start() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let data = tmp.path().join("data");
|
||||
|
||||
create_test_article(&content, "art-one", true);
|
||||
|
||||
let config = test_config(&content, &data);
|
||||
let rt = Runtime::new(&config).unwrap();
|
||||
assert_eq!(rt.store().count_published().unwrap(), 1);
|
||||
drop(rt);
|
||||
|
||||
let config = Config {
|
||||
rebuild_on_start: true,
|
||||
..test_config(&content, &data)
|
||||
};
|
||||
let rt = Runtime::new(&config).unwrap();
|
||||
assert_eq!(rt.store().count_published().unwrap(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "wo-seg"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-model = { path = "../wo-model" }
|
||||
serde = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
use std::io;
|
||||
|
||||
/// Magic bytes: "WOSF" (WriteOnce Segment File).
|
||||
pub const MAGIC: [u8; 4] = *b"WOSF";
|
||||
|
||||
/// Segment file version.
|
||||
pub const VERSION: u16 = 1;
|
||||
|
||||
/// Fixed header size in bytes.
|
||||
pub const HEADER_SIZE: u64 = 32;
|
||||
|
||||
/// The segment file header, stored at byte 0 of every .seg file.
|
||||
///
|
||||
/// Layout (32 bytes):
|
||||
/// ```text
|
||||
/// [0..4] magic: b"WOSF"
|
||||
/// [4..6] version: u16 LE
|
||||
/// [6..8] flags: u16 LE (reserved)
|
||||
/// [8..16] record_count: u64 LE
|
||||
/// [16..24] data_start: u64 LE
|
||||
/// [24..32] reserved: 8 bytes
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SegHeader {
|
||||
pub version: u16,
|
||||
pub flags: u16,
|
||||
pub record_count: u64,
|
||||
pub data_start: u64,
|
||||
}
|
||||
|
||||
impl SegHeader {
|
||||
/// Create a new header with default values.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
version: VERSION,
|
||||
flags: 0,
|
||||
record_count: 0,
|
||||
data_start: HEADER_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the header into a 32-byte buffer.
|
||||
pub fn to_bytes(&self) -> [u8; 32] {
|
||||
let mut buf = [0u8; 32];
|
||||
buf[0..4].copy_from_slice(&MAGIC);
|
||||
buf[4..6].copy_from_slice(&self.version.to_le_bytes());
|
||||
buf[6..8].copy_from_slice(&self.flags.to_le_bytes());
|
||||
buf[8..16].copy_from_slice(&self.record_count.to_le_bytes());
|
||||
buf[16..24].copy_from_slice(&self.data_start.to_le_bytes());
|
||||
// [24..32] reserved, stays zero
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse a header from a 32-byte buffer.
|
||||
pub fn from_bytes(buf: &[u8; 32]) -> io::Result<Self> {
|
||||
if &buf[0..4] != &MAGIC {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"invalid magic bytes: expected {:?}, got {:?}",
|
||||
MAGIC,
|
||||
&buf[0..4]
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let version = u16::from_le_bytes([buf[4], buf[5]]);
|
||||
if version != VERSION {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unsupported version: {}", version),
|
||||
));
|
||||
}
|
||||
|
||||
let flags = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let record_count = u64::from_le_bytes(buf[8..16].try_into().unwrap());
|
||||
let data_start = u64::from_le_bytes(buf[16..24].try_into().unwrap());
|
||||
|
||||
Ok(Self {
|
||||
version,
|
||||
flags,
|
||||
record_count,
|
||||
data_start,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let header = SegHeader {
|
||||
version: VERSION,
|
||||
flags: 0,
|
||||
record_count: 42,
|
||||
data_start: HEADER_SIZE,
|
||||
};
|
||||
let bytes = header.to_bytes();
|
||||
let parsed = SegHeader::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(header, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_magic() {
|
||||
let mut bytes = SegHeader::new().to_bytes();
|
||||
bytes[0] = b'X';
|
||||
assert!(SegHeader::from_bytes(&bytes).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
mod header;
|
||||
mod reader;
|
||||
mod writer;
|
||||
|
||||
pub use header::{SegHeader, HEADER_SIZE, MAGIC};
|
||||
pub use reader::SegReader;
|
||||
pub use writer::SegWriter;
|
||||
|
||||
/// Record flags.
|
||||
pub const FLAG_ACTIVE: u8 = 0x00;
|
||||
pub const FLAG_TOMBSTONED: u8 = 0x01;
|
||||
|
||||
/// Size of the per-record header: 4 bytes length + 1 byte flags.
|
||||
pub const RECORD_HEADER_SIZE: usize = 5;
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::Path;
|
||||
|
||||
use wo_model::Article;
|
||||
|
||||
use crate::header::SegHeader;
|
||||
use crate::{FLAG_TOMBSTONED, RECORD_HEADER_SIZE};
|
||||
|
||||
/// Reads articles from a .seg file.
|
||||
pub struct SegReader {
|
||||
file: File,
|
||||
header: SegHeader,
|
||||
}
|
||||
|
||||
impl SegReader {
|
||||
/// Open an existing .seg file and validate its header.
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
let file = File::open(path)?;
|
||||
let mut header_buf = [0u8; 32];
|
||||
pread_exact(&file, &mut header_buf, 0)?;
|
||||
let header = SegHeader::from_bytes(&header_buf)?;
|
||||
|
||||
Ok(Self { file, header })
|
||||
}
|
||||
|
||||
/// Read a single article record at the given byte offset.
|
||||
///
|
||||
/// Returns `None` if the record is tombstoned.
|
||||
pub fn read_at(&self, offset: u64) -> io::Result<Option<Article>> {
|
||||
// Read the record header: [u32 length][u8 flags]
|
||||
let mut rec_header = [0u8; RECORD_HEADER_SIZE];
|
||||
pread_exact(&self.file, &mut rec_header, offset)?;
|
||||
|
||||
let payload_len = u32::from_le_bytes(rec_header[0..4].try_into().unwrap()) as usize;
|
||||
let flags = rec_header[4];
|
||||
|
||||
if flags == FLAG_TOMBSTONED {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Read the payload.
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
pread_exact(&self.file, &mut payload, offset + RECORD_HEADER_SIZE as u64)?;
|
||||
|
||||
let article: Article = bincode::deserialize(&payload).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("bincode: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Some(article))
|
||||
}
|
||||
|
||||
/// Iterate all active (non-tombstoned) records.
|
||||
///
|
||||
/// Returns `(byte_offset, Article)` pairs.
|
||||
pub fn iter(&self) -> io::Result<Vec<(u64, Article)>> {
|
||||
let mut results = Vec::new();
|
||||
let mut pos = self.header.data_start;
|
||||
|
||||
for _ in 0..self.header.record_count {
|
||||
// Read record header.
|
||||
let mut rec_header = [0u8; RECORD_HEADER_SIZE];
|
||||
pread_exact(&self.file, &mut rec_header, pos)?;
|
||||
|
||||
let payload_len = u32::from_le_bytes(rec_header[0..4].try_into().unwrap()) as usize;
|
||||
let flags = rec_header[4];
|
||||
let record_offset = pos;
|
||||
|
||||
pos += RECORD_HEADER_SIZE as u64 + payload_len as u64;
|
||||
|
||||
if flags == FLAG_TOMBSTONED {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
pread_exact(
|
||||
&self.file,
|
||||
&mut payload,
|
||||
record_offset + RECORD_HEADER_SIZE as u64,
|
||||
)?;
|
||||
|
||||
let article: Article = bincode::deserialize(&payload).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("bincode: {}", e))
|
||||
})?;
|
||||
|
||||
results.push((record_offset, article));
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Return the parsed file header.
|
||||
pub fn header(&self) -> &SegHeader {
|
||||
&self.header
|
||||
}
|
||||
}
|
||||
|
||||
/// Positional read using pread(2). Does not modify the file offset.
|
||||
fn pread_exact(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
|
||||
let fd = file.as_raw_fd();
|
||||
let mut read = 0usize;
|
||||
while read < buf.len() {
|
||||
let ret = unsafe {
|
||||
libc::pread(
|
||||
fd,
|
||||
buf[read..].as_mut_ptr() as *mut libc::c_void,
|
||||
buf.len() - read,
|
||||
(offset + read as u64) as libc::off_t,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if ret == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"pread returned 0",
|
||||
));
|
||||
}
|
||||
read += ret as usize;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::SegWriter;
|
||||
use wo_model::*;
|
||||
|
||||
fn sample_article(sys_title: &str) -> Article {
|
||||
Article {
|
||||
sys_title: sys_title.to_string(),
|
||||
title: format!("Title: {}", sys_title),
|
||||
published: true,
|
||||
author: "Author".into(),
|
||||
tags: vec!["test".into()],
|
||||
published_on: Some(2000),
|
||||
content_html: "<h1>Hello world.</h1>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_single() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("test.seg");
|
||||
|
||||
let article = sample_article("test-one");
|
||||
|
||||
let offset = {
|
||||
let mut writer = SegWriter::create(&path).unwrap();
|
||||
writer.append(&article).unwrap()
|
||||
};
|
||||
|
||||
let reader = SegReader::open(&path).unwrap();
|
||||
assert_eq!(reader.header().record_count, 1);
|
||||
|
||||
let loaded = reader.read_at(offset).unwrap().unwrap();
|
||||
assert_eq!(loaded.sys_title, "test-one");
|
||||
assert_eq!(loaded, article);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_multiple() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("multi.seg");
|
||||
|
||||
let articles: Vec<Article> = (0..5).map(|i| sample_article(&format!("art-{}", i))).collect();
|
||||
|
||||
let offsets: Vec<u64> = {
|
||||
let mut writer = SegWriter::create(&path).unwrap();
|
||||
articles.iter().map(|a| writer.append(a).unwrap()).collect()
|
||||
};
|
||||
|
||||
let reader = SegReader::open(&path).unwrap();
|
||||
assert_eq!(reader.header().record_count, 5);
|
||||
|
||||
// Read each by offset.
|
||||
for (i, offset) in offsets.iter().enumerate() {
|
||||
let loaded = reader.read_at(*offset).unwrap().unwrap();
|
||||
assert_eq!(loaded.sys_title, format!("art-{}", i));
|
||||
}
|
||||
|
||||
// Iterate all.
|
||||
let all = reader.iter().unwrap();
|
||||
assert_eq!(all.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_record() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("tomb.seg");
|
||||
|
||||
let offset = {
|
||||
let mut writer = SegWriter::create(&path).unwrap();
|
||||
let o = writer.append(&sample_article("to-delete")).unwrap();
|
||||
writer.append(&sample_article("to-keep")).unwrap();
|
||||
writer.tombstone(o).unwrap();
|
||||
o
|
||||
};
|
||||
|
||||
let reader = SegReader::open(&path).unwrap();
|
||||
|
||||
// Direct read returns None for tombstoned.
|
||||
assert!(reader.read_at(offset).unwrap().is_none());
|
||||
|
||||
// Iter skips tombstoned.
|
||||
let all = reader.iter().unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].1.sys_title, "to-keep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_header() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("bad.seg");
|
||||
std::fs::write(&path, b"not a segment file at all!!!!!xx").unwrap();
|
||||
assert!(SegReader::open(&path).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
use std::fs::{File, OpenOptions};
|
||||
use std::io;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::Path;
|
||||
|
||||
use wo_model::Article;
|
||||
|
||||
use crate::header::{SegHeader, HEADER_SIZE};
|
||||
use crate::{FLAG_ACTIVE, RECORD_HEADER_SIZE};
|
||||
|
||||
/// Writes articles to a .seg file.
|
||||
///
|
||||
/// Records are appended sequentially. Each append returns the byte offset
|
||||
/// of the record, which can be stored in an index for direct access.
|
||||
pub struct SegWriter {
|
||||
file: File,
|
||||
header: SegHeader,
|
||||
/// Current write position (end of file).
|
||||
pos: u64,
|
||||
}
|
||||
|
||||
impl SegWriter {
|
||||
/// Create a new .seg file at the given path.
|
||||
///
|
||||
/// Writes the initial header and optionally pre-allocates disk space
|
||||
/// using `fallocate` to reduce fragmentation.
|
||||
pub fn create(path: &Path) -> io::Result<Self> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
|
||||
let header = SegHeader::new();
|
||||
// Write the header at position 0.
|
||||
pwrite_all(&file, &header.to_bytes(), 0)?;
|
||||
|
||||
// Pre-allocate 1 MB to reduce fragmentation.
|
||||
let _ = fallocate_safe(&file, HEADER_SIZE as i64, 1024 * 1024);
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
header,
|
||||
pos: HEADER_SIZE,
|
||||
})
|
||||
}
|
||||
|
||||
/// Append an article as an active record.
|
||||
///
|
||||
/// Returns the byte offset of the record start (the position of the
|
||||
/// length prefix), which can be used for direct reads via `SegReader::read_at`.
|
||||
pub fn append(&mut self, article: &Article) -> io::Result<u64> {
|
||||
let payload = bincode::serialize(article).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("bincode: {}", e))
|
||||
})?;
|
||||
|
||||
let record_offset = self.pos;
|
||||
let payload_len = payload.len() as u32;
|
||||
|
||||
// Build record: [u32 length][u8 flags][payload]
|
||||
let mut record = Vec::with_capacity(RECORD_HEADER_SIZE + payload.len());
|
||||
record.extend_from_slice(&payload_len.to_le_bytes());
|
||||
record.push(FLAG_ACTIVE);
|
||||
record.extend_from_slice(&payload);
|
||||
|
||||
pwrite_all(&self.file, &record, record_offset)?;
|
||||
|
||||
self.pos += record.len() as u64;
|
||||
self.header.record_count += 1;
|
||||
|
||||
// Update the header with the new record count.
|
||||
pwrite_all(&self.file, &self.header.to_bytes(), 0)?;
|
||||
|
||||
Ok(record_offset)
|
||||
}
|
||||
|
||||
/// Tombstone a record at the given offset.
|
||||
///
|
||||
/// Sets the flags byte to `FLAG_TOMBSTONED` without modifying the payload.
|
||||
pub fn tombstone(&self, record_offset: u64) -> io::Result<()> {
|
||||
let flags_offset = record_offset + 4; // skip the u32 length
|
||||
pwrite_all(&self.file, &[crate::FLAG_TOMBSTONED], flags_offset)
|
||||
}
|
||||
|
||||
/// Return the current header (record count, etc.).
|
||||
pub fn header(&self) -> &SegHeader {
|
||||
&self.header
|
||||
}
|
||||
|
||||
/// Sync file data and metadata to disk.
|
||||
pub fn sync(&self) -> io::Result<()> {
|
||||
self.file.sync_all()
|
||||
}
|
||||
}
|
||||
|
||||
/// Positional write using pwrite(2). Does not modify the file offset.
|
||||
fn pwrite_all(file: &File, buf: &[u8], offset: u64) -> io::Result<()> {
|
||||
let fd = file.as_raw_fd();
|
||||
let mut written = 0usize;
|
||||
while written < buf.len() {
|
||||
let ret = unsafe {
|
||||
libc::pwrite(
|
||||
fd,
|
||||
buf[written..].as_ptr() as *const libc::c_void,
|
||||
buf.len() - written,
|
||||
(offset + written as u64) as libc::off_t,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if ret == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::WriteZero, "pwrite returned 0"));
|
||||
}
|
||||
written += ret as usize;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort fallocate. Non-fatal if unsupported.
|
||||
fn fallocate_safe(file: &File, offset: i64, len: i64) -> io::Result<()> {
|
||||
let ret = unsafe { libc::fallocate(file.as_raw_fd(), 0, offset, len) };
|
||||
if ret < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[package]
|
||||
name = "wo-serve"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-http = { path = "../wo-http" }
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
mod mime;
|
||||
mod resolve;
|
||||
mod sendfile;
|
||||
|
||||
pub use mime::content_type_for;
|
||||
pub use resolve::resolve_path;
|
||||
pub use sendfile::send_file;
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/// Map a file extension to its HTTP Content-Type.
|
||||
pub fn content_type_for(path: &str) -> &'static str {
|
||||
let ext = path.rsplit('.').next().unwrap_or("");
|
||||
match ext {
|
||||
"html" | "htmlx" => "text/html; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"js" => "application/javascript; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"svg" => "image/svg+xml",
|
||||
"ico" => "image/x-icon",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
"ttf" => "font/ttf",
|
||||
"txt" => "text/plain; charset=utf-8",
|
||||
"xml" => "application/xml; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_types() {
|
||||
assert_eq!(content_type_for("main.css"), "text/css; charset=utf-8");
|
||||
assert_eq!(content_type_for("logo.png"), "image/png");
|
||||
assert_eq!(content_type_for("app.js"), "application/javascript; charset=utf-8");
|
||||
assert_eq!(content_type_for("favicon.ico"), "image/x-icon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type() {
|
||||
assert_eq!(content_type_for("file.xyz"), "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_path() {
|
||||
assert_eq!(content_type_for("styles/code-theme.css"), "text/css; charset=utf-8");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolve a request path relative to a static directory.
|
||||
///
|
||||
/// Returns the absolute path to the file, or an error if:
|
||||
/// - The path contains `..` (directory traversal)
|
||||
/// - The resolved path escapes the base directory
|
||||
/// - The file doesn't exist
|
||||
pub fn resolve_path(base: &Path, request_path: &str) -> io::Result<PathBuf> {
|
||||
// Reject any path with `..`
|
||||
if request_path.contains("..") {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"path traversal rejected",
|
||||
));
|
||||
}
|
||||
|
||||
let clean = request_path.trim_start_matches('/');
|
||||
let resolved = base.join(clean);
|
||||
|
||||
// Canonicalize and verify it's still under base.
|
||||
let canonical = resolved.canonicalize().map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "file not found")
|
||||
})?;
|
||||
|
||||
let canonical_base = base.canonicalize().map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "base directory not found")
|
||||
})?;
|
||||
|
||||
if !canonical.starts_with(&canonical_base) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"path traversal rejected",
|
||||
));
|
||||
}
|
||||
|
||||
if !canonical.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "not a file"));
|
||||
}
|
||||
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn valid_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("test.css"), "body {}").unwrap();
|
||||
|
||||
let path = resolve_path(tmp.path(), "test.css").unwrap();
|
||||
assert!(path.ends_with("test.css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(tmp.path().join("styles")).unwrap();
|
||||
fs::write(tmp.path().join("styles/main.css"), "body {}").unwrap();
|
||||
|
||||
let path = resolve_path(tmp.path(), "styles/main.css").unwrap();
|
||||
assert!(path.ends_with("styles/main.css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_traversal() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(resolve_path(tmp.path(), "../etc/passwd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_nonexistent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(resolve_path(tmp.path(), "nonexistent.css").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::path::Path;
|
||||
|
||||
use wo_http::response::Response;
|
||||
|
||||
use crate::mime::content_type_for;
|
||||
use crate::resolve::resolve_path;
|
||||
|
||||
/// Serve a static file. Returns a Response with the file contents.
|
||||
///
|
||||
/// For small files, reads into memory and returns a normal Response.
|
||||
/// For zero-copy serving via sendfile, use `send_file_zero_copy` instead.
|
||||
pub fn send_file(static_dir: &Path, request_path: &str) -> Response {
|
||||
let file_path = match resolve_path(static_dir, request_path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return match e.kind() {
|
||||
io::ErrorKind::NotFound => Response::not_found(),
|
||||
io::ErrorKind::PermissionDenied => {
|
||||
Response::new(403, "Forbidden")
|
||||
}
|
||||
_ => Response::internal_error(&e.to_string()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let body = match fs::read(&file_path) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Response::internal_error("failed to read file"),
|
||||
};
|
||||
|
||||
let ct = content_type_for(file_path.to_str().unwrap_or(""));
|
||||
let mut response = Response::ok(body, ct);
|
||||
response.header("Cache-Control", "public, max-age=3600");
|
||||
response
|
||||
}
|
||||
|
||||
/// Zero-copy file serving using sendfile(2).
|
||||
///
|
||||
/// Sends the file directly from the page cache to the socket fd.
|
||||
/// Returns the number of bytes sent, or an error.
|
||||
pub fn sendfile_to_fd(socket_fd: RawFd, file_path: &Path) -> io::Result<usize> {
|
||||
let file = fs::File::open(file_path)?;
|
||||
let file_fd = {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
file.as_raw_fd()
|
||||
};
|
||||
|
||||
let metadata = file.metadata()?;
|
||||
let file_size = metadata.len() as usize;
|
||||
|
||||
let mut offset: libc::off_t = 0;
|
||||
let mut sent = 0usize;
|
||||
|
||||
while sent < file_size {
|
||||
let n = unsafe {
|
||||
libc::sendfile(
|
||||
socket_fd,
|
||||
file_fd,
|
||||
&mut offset,
|
||||
file_size - sent,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EAGAIN) {
|
||||
break; // partial send, caller should retry on EPOLLOUT
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
sent += n as usize;
|
||||
}
|
||||
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serve_existing_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("test.css"), "body { color: red; }").unwrap();
|
||||
|
||||
let response = send_file(tmp.path(), "test.css");
|
||||
assert_eq!(response.status, 200);
|
||||
assert!(String::from_utf8_lossy(&response.body).contains("color: red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_nonexistent_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let response = send_file(tmp.path(), "nope.css");
|
||||
assert_eq!(response.status, 404);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_traversal_attempt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let response = send_file(tmp.path(), "../etc/passwd");
|
||||
assert_eq!(response.status, 403);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "wo-store"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-model = { path = "../wo-model" }
|
||||
wo-seg = { path = "../wo-seg" }
|
||||
wo-index = { path = "../wo-index" }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use wo_index::{DateIndex, TagIndex, TitleIndex};
|
||||
use wo_model::{Article, ContentLoader};
|
||||
use wo_seg::{SegReader, SegWriter};
|
||||
|
||||
/// Unified storage engine composing .seg files and indexes.
|
||||
///
|
||||
/// Provides the query API that the rest of the system uses.
|
||||
/// Handles cold-start (rebuild from content/) and incremental ingestion.
|
||||
pub struct Store {
|
||||
seg_path: PathBuf,
|
||||
index_dir: PathBuf,
|
||||
content_dir: PathBuf,
|
||||
title_idx: TitleIndex,
|
||||
date_idx: DateIndex,
|
||||
tag_idx: TagIndex,
|
||||
/// Monotonic version counter per sys_title (for subscription diffs).
|
||||
versions: HashMap<String, u64>,
|
||||
version_counter: u64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Open an existing data directory, or cold-start from content/.
|
||||
///
|
||||
/// If `data_dir` contains valid .seg and index files, opens them.
|
||||
/// Otherwise, rebuilds everything from `content_dir`.
|
||||
pub fn open(content_dir: &Path, data_dir: &Path) -> io::Result<Self> {
|
||||
let seg_path = data_dir.join("articles.seg");
|
||||
let index_dir = data_dir.join("index");
|
||||
|
||||
if seg_path.exists() && index_dir.join("title.idx").exists() {
|
||||
Self::open_existing(content_dir, &seg_path, &index_dir)
|
||||
} else {
|
||||
let mut store = Self::init_empty(content_dir, &seg_path, &index_dir)?;
|
||||
store.rebuild()?;
|
||||
Ok(store)
|
||||
}
|
||||
}
|
||||
|
||||
fn open_existing(
|
||||
content_dir: &Path,
|
||||
seg_path: &Path,
|
||||
index_dir: &Path,
|
||||
) -> io::Result<Self> {
|
||||
let title_idx = TitleIndex::open(&index_dir.join("title.idx"))?;
|
||||
let date_idx = DateIndex::open(&index_dir.join("date.idx"))?;
|
||||
let tag_idx = TagIndex::open(&index_dir.join("tags.idx"))?;
|
||||
|
||||
// Build version map from existing seg records.
|
||||
let reader = SegReader::open(seg_path)?;
|
||||
let mut versions = HashMap::new();
|
||||
let mut version_counter = 0u64;
|
||||
for (_, article) in reader.iter()? {
|
||||
version_counter += 1;
|
||||
versions.insert(article.sys_title.clone(), version_counter);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
seg_path: seg_path.to_path_buf(),
|
||||
index_dir: index_dir.to_path_buf(),
|
||||
content_dir: content_dir.to_path_buf(),
|
||||
title_idx,
|
||||
date_idx,
|
||||
tag_idx,
|
||||
versions,
|
||||
version_counter,
|
||||
})
|
||||
}
|
||||
|
||||
fn init_empty(
|
||||
content_dir: &Path,
|
||||
seg_path: &Path,
|
||||
index_dir: &Path,
|
||||
) -> io::Result<Self> {
|
||||
std::fs::create_dir_all(seg_path.parent().unwrap())?;
|
||||
std::fs::create_dir_all(index_dir)?;
|
||||
|
||||
// Create empty seg file.
|
||||
let _writer = SegWriter::create(seg_path)?;
|
||||
|
||||
// Create empty indexes.
|
||||
TitleIndex::build(&index_dir.join("title.idx"), &[])?;
|
||||
DateIndex::build(&index_dir.join("date.idx"), &mut [])?;
|
||||
TagIndex::build(&index_dir.join("tags.idx"), &[])?;
|
||||
|
||||
let title_idx = TitleIndex::open(&index_dir.join("title.idx"))?;
|
||||
let date_idx = DateIndex::open(&index_dir.join("date.idx"))?;
|
||||
let tag_idx = TagIndex::open(&index_dir.join("tags.idx"))?;
|
||||
|
||||
Ok(Self {
|
||||
seg_path: seg_path.to_path_buf(),
|
||||
index_dir: index_dir.to_path_buf(),
|
||||
content_dir: content_dir.to_path_buf(),
|
||||
title_idx,
|
||||
date_idx,
|
||||
tag_idx,
|
||||
versions: HashMap::new(),
|
||||
version_counter: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full rebuild: load all articles from content/, rewrite .seg and indexes.
|
||||
pub fn rebuild(&mut self) -> io::Result<()> {
|
||||
let articles = ContentLoader::load_all(&self.content_dir)?;
|
||||
|
||||
// Write all articles to a new .seg file.
|
||||
let mut writer = SegWriter::create(&self.seg_path)?;
|
||||
let mut index_entries: Vec<(String, i64, Vec<String>, u64)> = Vec::new();
|
||||
|
||||
self.versions.clear();
|
||||
self.version_counter = 0;
|
||||
|
||||
for article in &articles {
|
||||
let offset = writer.append(article)?;
|
||||
let timestamp = article.published_on.unwrap_or(0);
|
||||
|
||||
index_entries.push((
|
||||
article.sys_title.clone(),
|
||||
timestamp,
|
||||
article.tags.clone(),
|
||||
offset,
|
||||
));
|
||||
|
||||
self.version_counter += 1;
|
||||
self.versions
|
||||
.insert(article.sys_title.clone(), self.version_counter);
|
||||
}
|
||||
|
||||
writer.sync()?;
|
||||
|
||||
// Rebuild all indexes.
|
||||
wo_index::rebuild_all(&self.index_dir, &index_entries)?;
|
||||
|
||||
// Reload indexes.
|
||||
self.title_idx = TitleIndex::open(&self.index_dir.join("title.idx"))?;
|
||||
self.date_idx = DateIndex::open(&self.index_dir.join("date.idx"))?;
|
||||
self.tag_idx = TagIndex::open(&self.index_dir.join("tags.idx"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a single article by sys_title.
|
||||
pub fn get_by_title(&self, sys_title: &str) -> io::Result<Option<Article>> {
|
||||
match self.title_idx.get(sys_title) {
|
||||
Some(offset) => {
|
||||
let reader = SegReader::open(&self.seg_path)?;
|
||||
reader.read_at(offset)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List published articles, most recent first, with pagination.
|
||||
pub fn list_published(&self, skip: usize, limit: usize) -> io::Result<Vec<Article>> {
|
||||
let offsets = self.date_idx.latest(skip + limit);
|
||||
let reader = SegReader::open(&self.seg_path)?;
|
||||
|
||||
let mut articles = Vec::new();
|
||||
for &offset in offsets.iter().skip(skip).take(limit) {
|
||||
if let Some(article) = reader.read_at(offset)? {
|
||||
if article.published {
|
||||
articles.push(article);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(articles)
|
||||
}
|
||||
|
||||
/// List all articles with a given tag.
|
||||
pub fn list_by_tag(&self, tag: &str) -> io::Result<Vec<Article>> {
|
||||
let offsets = match self.tag_idx.get(tag) {
|
||||
Some(offsets) => offsets,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let reader = SegReader::open(&self.seg_path)?;
|
||||
let mut articles = Vec::new();
|
||||
for &offset in offsets {
|
||||
if let Some(article) = reader.read_at(offset)? {
|
||||
articles.push(article);
|
||||
}
|
||||
}
|
||||
Ok(articles)
|
||||
}
|
||||
|
||||
/// List articles published within a timestamp range.
|
||||
pub fn list_by_date_range(&self, start: i64, end: i64) -> io::Result<Vec<Article>> {
|
||||
let offsets = self.date_idx.range(start, end);
|
||||
let reader = SegReader::open(&self.seg_path)?;
|
||||
|
||||
let mut articles = Vec::new();
|
||||
for offset in offsets {
|
||||
if let Some(article) = reader.read_at(offset)? {
|
||||
articles.push(article);
|
||||
}
|
||||
}
|
||||
Ok(articles)
|
||||
}
|
||||
|
||||
/// Count of published articles.
|
||||
pub fn count_published(&self) -> io::Result<usize> {
|
||||
// For now, iterate and count. With small article counts this is fine.
|
||||
let reader = SegReader::open(&self.seg_path)?;
|
||||
let count = reader
|
||||
.iter()?
|
||||
.into_iter()
|
||||
.filter(|(_, a)| a.published)
|
||||
.count();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Ingest a single article from a JSON file path.
|
||||
///
|
||||
/// Appends to .seg and triggers a full index rebuild.
|
||||
/// Returns the sys_title of the ingested article.
|
||||
pub fn ingest_article(&mut self, json_path: &Path) -> io::Result<String> {
|
||||
let article = ContentLoader::load_from_file(json_path)?;
|
||||
let sys_title = article.sys_title.clone();
|
||||
|
||||
// For simplicity, rebuild the entire store.
|
||||
// A future optimization can do incremental append + index update.
|
||||
self.rebuild()?;
|
||||
|
||||
Ok(sys_title)
|
||||
}
|
||||
|
||||
/// Get the current version number for a sys_title.
|
||||
pub fn article_version(&self, sys_title: &str) -> Option<u64> {
|
||||
self.versions.get(sys_title).copied()
|
||||
}
|
||||
|
||||
/// Get a reference to the content directory path.
|
||||
pub fn content_dir(&self) -> &Path {
|
||||
&self.content_dir
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn create_test_article(base: &Path, sys_title: &str, published: bool, tags: &[&str], ts: i64) {
|
||||
let dir = base.join(sys_title);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let tags_json: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t)).collect();
|
||||
let json = format!(
|
||||
r#"{{
|
||||
"title": "{}",
|
||||
"sys_title": "{}",
|
||||
"published": {},
|
||||
"content": {{
|
||||
"author": "Author",
|
||||
"content": {{
|
||||
"sections": [{{ "heading": "Intro", "paragraphs": ["Hello."] }}],
|
||||
"codes": [],
|
||||
"images": []
|
||||
}},
|
||||
"tags": [{}],
|
||||
"publishedOn": {}
|
||||
}},
|
||||
"published_on": {}
|
||||
}}"#,
|
||||
sys_title,
|
||||
sys_title,
|
||||
published,
|
||||
tags_json.join(", "),
|
||||
ts,
|
||||
ts
|
||||
);
|
||||
fs::write(dir.join(format!("{}.json", sys_title)), json).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_start_and_queries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let data = tmp.path().join("data");
|
||||
|
||||
create_test_article(&content, "art-one", true, &["rust"], 1000);
|
||||
create_test_article(&content, "art-two", true, &["rust", "linux"], 2000);
|
||||
create_test_article(&content, "art-three", false, &["draft"], 3000);
|
||||
create_test_article(&content, "art-four", true, &["linux"], 4000);
|
||||
|
||||
let store = Store::open(&content, &data).unwrap();
|
||||
|
||||
// Title lookup.
|
||||
let art = store.get_by_title("art-one").unwrap().unwrap();
|
||||
assert_eq!(art.sys_title, "art-one");
|
||||
|
||||
assert!(store.get_by_title("nonexistent").unwrap().is_none());
|
||||
|
||||
// Tag query.
|
||||
let rust_articles = store.list_by_tag("rust").unwrap();
|
||||
assert_eq!(rust_articles.len(), 2);
|
||||
|
||||
let linux_articles = store.list_by_tag("linux").unwrap();
|
||||
assert_eq!(linux_articles.len(), 2);
|
||||
|
||||
// Date range.
|
||||
let range = store.list_by_date_range(1500, 3500).unwrap();
|
||||
assert_eq!(range.len(), 2); // art-two(2000) and art-three(3000)
|
||||
|
||||
// Published count.
|
||||
let count = store.count_published().unwrap();
|
||||
assert_eq!(count, 3);
|
||||
|
||||
// Version tracking.
|
||||
assert!(store.article_version("art-one").is_some());
|
||||
assert!(store.article_version("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_after_delete() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let data = tmp.path().join("data");
|
||||
|
||||
create_test_article(&content, "art-one", true, &["test"], 1000);
|
||||
|
||||
// First open: cold start.
|
||||
let store = Store::open(&content, &data).unwrap();
|
||||
assert_eq!(store.count_published().unwrap(), 1);
|
||||
drop(store);
|
||||
|
||||
// Delete data/ and reopen: should rebuild.
|
||||
fs::remove_dir_all(&data).unwrap();
|
||||
let store = Store::open(&content, &data).unwrap();
|
||||
assert_eq!(store.count_published().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let data = tmp.path().join("data");
|
||||
|
||||
create_test_article(&content, "art-one", true, &["test"], 1000);
|
||||
|
||||
// First open: cold start builds .seg + indexes.
|
||||
let store = Store::open(&content, &data).unwrap();
|
||||
assert_eq!(store.count_published().unwrap(), 1);
|
||||
drop(store);
|
||||
|
||||
// Second open: loads existing files, no rebuild.
|
||||
let store = Store::open(&content, &data).unwrap();
|
||||
assert_eq!(store.count_published().unwrap(), 1);
|
||||
let art = store.get_by_title("art-one").unwrap().unwrap();
|
||||
assert_eq!(art.sys_title, "art-one");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
[package]
|
||||
name = "wo-sub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-model = { path = "../wo-model" }
|
||||
wo-store = { path = "../wo-store" }
|
||||
wo-event = { path = "../wo-event" }
|
||||
libc = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,470 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wo_event::EventFd;
|
||||
|
||||
/// A subscription query pattern.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Subscription {
|
||||
/// Subscribe to changes for a single article by sys_title.
|
||||
ByTitle(String),
|
||||
/// Subscribe to changes for all articles with a given tag.
|
||||
ByTag(String),
|
||||
/// Subscribe to all content changes.
|
||||
All,
|
||||
}
|
||||
|
||||
/// The type of content change.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ChangeType {
|
||||
Created,
|
||||
Updated,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
/// Notification payload written to subscriber fds.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Notification {
|
||||
pub change_type: ChangeType,
|
||||
pub sys_title: String,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
/// Wire format for notification payloads.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum FdFormat {
|
||||
/// Length-prefixed bincode (for internal subscribers).
|
||||
Binary,
|
||||
/// Length-prefixed JSON (for browser socket fds).
|
||||
Json,
|
||||
}
|
||||
|
||||
/// Manages subscriptions and delivers notifications to subscriber fds.
|
||||
///
|
||||
/// Subscribers register interest via `subscribe()` with a query pattern.
|
||||
/// When content changes, `notify()` evaluates which subscriptions match
|
||||
/// and writes a length-prefixed notification to each matching fd.
|
||||
pub struct SubscriptionManager {
|
||||
/// sys_title → list of subscriber fds.
|
||||
by_title: HashMap<String, Vec<RawFd>>,
|
||||
/// tag → list of subscriber fds.
|
||||
by_tag: HashMap<String, Vec<RawFd>>,
|
||||
/// Fds subscribed to all changes.
|
||||
global: Vec<RawFd>,
|
||||
/// fd → list of subscriptions (for cleanup on disconnect).
|
||||
fd_registry: HashMap<RawFd, Vec<Subscription>>,
|
||||
/// fd → wire format.
|
||||
fd_format: HashMap<RawFd, FdFormat>,
|
||||
/// EventFd for signaling the event loop that notifications are pending.
|
||||
notify_efd: EventFd,
|
||||
}
|
||||
|
||||
impl SubscriptionManager {
|
||||
/// Create a new subscription manager.
|
||||
pub fn new() -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
by_title: HashMap::new(),
|
||||
by_tag: HashMap::new(),
|
||||
global: Vec::new(),
|
||||
fd_registry: HashMap::new(),
|
||||
fd_format: HashMap::new(),
|
||||
notify_efd: EventFd::new()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The eventfd for signaling the event loop.
|
||||
pub fn notify_fd(&self) -> RawFd {
|
||||
self.notify_efd.fd()
|
||||
}
|
||||
|
||||
/// Register a subscription for the given fd (binary format).
|
||||
pub fn subscribe(&mut self, fd: RawFd, query: Subscription) {
|
||||
self.subscribe_with_format(fd, query, FdFormat::Binary);
|
||||
}
|
||||
|
||||
/// Register a subscription for the given fd (JSON format for browsers).
|
||||
pub fn subscribe_json(&mut self, fd: RawFd, query: Subscription) {
|
||||
self.subscribe_with_format(fd, query, FdFormat::Json);
|
||||
}
|
||||
|
||||
fn subscribe_with_format(&mut self, fd: RawFd, query: Subscription, format: FdFormat) {
|
||||
match &query {
|
||||
Subscription::ByTitle(title) => {
|
||||
self.by_title.entry(title.clone()).or_default().push(fd);
|
||||
}
|
||||
Subscription::ByTag(tag) => {
|
||||
self.by_tag.entry(tag.clone()).or_default().push(fd);
|
||||
}
|
||||
Subscription::All => {
|
||||
self.global.push(fd);
|
||||
}
|
||||
}
|
||||
self.fd_registry.entry(fd).or_default().push(query);
|
||||
self.fd_format.insert(fd, format);
|
||||
}
|
||||
|
||||
/// Remove all subscriptions for a fd (e.g., on EPOLLHUP).
|
||||
pub fn unsubscribe(&mut self, fd: RawFd) {
|
||||
self.fd_format.remove(&fd);
|
||||
if let Some(subs) = self.fd_registry.remove(&fd) {
|
||||
for sub in subs {
|
||||
match sub {
|
||||
Subscription::ByTitle(title) => {
|
||||
if let Some(fds) = self.by_title.get_mut(&title) {
|
||||
fds.retain(|&f| f != fd);
|
||||
}
|
||||
}
|
||||
Subscription::ByTag(tag) => {
|
||||
if let Some(fds) = self.by_tag.get_mut(&tag) {
|
||||
fds.retain(|&f| f != fd);
|
||||
}
|
||||
}
|
||||
Subscription::All => {
|
||||
self.global.retain(|&f| f != fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify subscribers about a content change.
|
||||
///
|
||||
/// Evaluates which subscriptions match the changed sys_title and tags,
|
||||
/// then writes a length-prefixed notification to each matching fd.
|
||||
pub fn notify(
|
||||
&self,
|
||||
sys_title: &str,
|
||||
tags: &[String],
|
||||
change_type: ChangeType,
|
||||
version: u64,
|
||||
) -> io::Result<usize> {
|
||||
let notification = Notification {
|
||||
change_type,
|
||||
sys_title: sys_title.to_string(),
|
||||
version,
|
||||
};
|
||||
|
||||
// Pre-serialize both formats (only actually used if there are subscribers).
|
||||
let bincode_payload = bincode::serialize(¬ification).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("bincode: {}", e))
|
||||
})?;
|
||||
let json_payload = serde_json::to_vec(¬ification).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("json: {}", e))
|
||||
})?;
|
||||
|
||||
// Build wire formats: [u32 length][payload]
|
||||
let bincode_wire = build_wire(&bincode_payload);
|
||||
let json_wire = build_wire(&json_payload);
|
||||
|
||||
// Collect all fds that should receive this notification.
|
||||
let mut target_fds: Vec<RawFd> = Vec::new();
|
||||
|
||||
// Title subscribers.
|
||||
if let Some(fds) = self.by_title.get(sys_title) {
|
||||
target_fds.extend(fds);
|
||||
}
|
||||
|
||||
// Tag subscribers.
|
||||
for tag in tags {
|
||||
if let Some(fds) = self.by_tag.get(tag) {
|
||||
target_fds.extend(fds);
|
||||
}
|
||||
}
|
||||
|
||||
// Global subscribers.
|
||||
target_fds.extend(&self.global);
|
||||
|
||||
// Deduplicate.
|
||||
target_fds.sort_unstable();
|
||||
target_fds.dedup();
|
||||
|
||||
let mut sent = 0;
|
||||
for &fd in &target_fds {
|
||||
let format = self.fd_format.get(&fd).copied().unwrap_or(FdFormat::Binary);
|
||||
let wire = match format {
|
||||
FdFormat::Binary => &bincode_wire,
|
||||
FdFormat::Json => &json_wire,
|
||||
};
|
||||
if write_all_fd(fd, wire).is_ok() {
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Signal the event loop.
|
||||
if sent > 0 {
|
||||
let _ = self.notify_efd.write(1);
|
||||
}
|
||||
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
/// Number of active subscriber fds.
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.fd_registry.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wire(payload: &[u8]) -> Vec<u8> {
|
||||
let len = payload.len() as u32;
|
||||
let mut wire = Vec::with_capacity(4 + payload.len());
|
||||
wire.extend_from_slice(&len.to_le_bytes());
|
||||
wire.extend_from_slice(payload);
|
||||
wire
|
||||
}
|
||||
|
||||
/// Write all bytes to a raw fd.
|
||||
fn write_all_fd(fd: RawFd, buf: &[u8]) -> io::Result<()> {
|
||||
let mut written = 0;
|
||||
while written < buf.len() {
|
||||
let ret = unsafe {
|
||||
libc::write(
|
||||
fd,
|
||||
buf[written..].as_ptr() as *const libc::c_void,
|
||||
buf.len() - written,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if ret == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0"));
|
||||
}
|
||||
written += ret as usize;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convenience macro for compile-time subscription wiring.
|
||||
///
|
||||
/// ```ignore
|
||||
/// register!(sub_manager, fd, ByTitle("linux-misc"));
|
||||
/// register!(sub_manager, fd, ByTag("rust"));
|
||||
/// register!(sub_manager, fd, All);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! register {
|
||||
($mgr:expr, $fd:expr, ByTitle($title:expr)) => {
|
||||
$mgr.subscribe($fd, $crate::Subscription::ByTitle($title.into()))
|
||||
};
|
||||
($mgr:expr, $fd:expr, ByTag($tag:expr)) => {
|
||||
$mgr.subscribe($fd, $crate::Subscription::ByTag($tag.into()))
|
||||
};
|
||||
($mgr:expr, $fd:expr, All) => {
|
||||
$mgr.subscribe($fd, $crate::Subscription::All)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Create a pipe and return (read_fd, write_fd).
|
||||
fn make_pipe() -> (RawFd, RawFd) {
|
||||
let mut fds = [0i32; 2];
|
||||
assert_eq!(unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_NONBLOCK) }, 0);
|
||||
(fds[0], fds[1])
|
||||
}
|
||||
|
||||
fn read_notification(fd: RawFd) -> Notification {
|
||||
let mut len_buf = [0u8; 4];
|
||||
let ret = unsafe { libc::read(fd, len_buf.as_mut_ptr() as *mut libc::c_void, 4) };
|
||||
assert!(ret == 4, "failed to read length prefix");
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
let ret = unsafe { libc::read(fd, payload.as_mut_ptr() as *mut libc::c_void, len) };
|
||||
assert_eq!(ret as usize, len);
|
||||
|
||||
bincode::deserialize(&payload).unwrap()
|
||||
}
|
||||
|
||||
fn close_fd(fd: RawFd) {
|
||||
unsafe { libc::close(fd) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_subscription() {
|
||||
let mut mgr = SubscriptionManager::new().unwrap();
|
||||
|
||||
let (r1, w1) = make_pipe();
|
||||
let (r2, w2) = make_pipe();
|
||||
|
||||
mgr.subscribe(w1, Subscription::ByTitle("art-one".into()));
|
||||
mgr.subscribe(w2, Subscription::ByTitle("art-one".into()));
|
||||
|
||||
let sent = mgr
|
||||
.notify("art-one", &[], ChangeType::Updated, 1)
|
||||
.unwrap();
|
||||
assert_eq!(sent, 2);
|
||||
|
||||
let n1 = read_notification(r1);
|
||||
assert_eq!(n1.sys_title, "art-one");
|
||||
assert_eq!(n1.change_type, ChangeType::Updated);
|
||||
assert_eq!(n1.version, 1);
|
||||
|
||||
let n2 = read_notification(r2);
|
||||
assert_eq!(n2.sys_title, "art-one");
|
||||
|
||||
// Unrelated title: no notifications.
|
||||
let sent = mgr
|
||||
.notify("art-two", &[], ChangeType::Created, 2)
|
||||
.unwrap();
|
||||
assert_eq!(sent, 0);
|
||||
|
||||
close_fd(r1); close_fd(w1);
|
||||
close_fd(r2); close_fd(w2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_subscription() {
|
||||
let mut mgr = SubscriptionManager::new().unwrap();
|
||||
|
||||
let (r, w) = make_pipe();
|
||||
mgr.subscribe(w, Subscription::ByTag("rust".into()));
|
||||
|
||||
let sent = mgr
|
||||
.notify("art-one", &["rust".into(), "linux".into()], ChangeType::Updated, 1)
|
||||
.unwrap();
|
||||
assert_eq!(sent, 1);
|
||||
|
||||
let n = read_notification(r);
|
||||
assert_eq!(n.sys_title, "art-one");
|
||||
|
||||
// No "rust" tag: no notification.
|
||||
let sent = mgr
|
||||
.notify("art-two", &["python".into()], ChangeType::Created, 2)
|
||||
.unwrap();
|
||||
assert_eq!(sent, 0);
|
||||
|
||||
close_fd(r); close_fd(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_subscription() {
|
||||
let mut mgr = SubscriptionManager::new().unwrap();
|
||||
|
||||
let (r, w) = make_pipe();
|
||||
mgr.subscribe(w, Subscription::All);
|
||||
|
||||
let sent = mgr.notify("anything", &[], ChangeType::Deleted, 5).unwrap();
|
||||
assert_eq!(sent, 1);
|
||||
|
||||
let n = read_notification(r);
|
||||
assert_eq!(n.change_type, ChangeType::Deleted);
|
||||
|
||||
close_fd(r); close_fd(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsubscribe_removes_fd() {
|
||||
let mut mgr = SubscriptionManager::new().unwrap();
|
||||
|
||||
let (r, w) = make_pipe();
|
||||
mgr.subscribe(w, Subscription::ByTitle("art-one".into()));
|
||||
mgr.subscribe(w, Subscription::All);
|
||||
assert_eq!(mgr.subscriber_count(), 1);
|
||||
|
||||
mgr.unsubscribe(w);
|
||||
assert_eq!(mgr.subscriber_count(), 0);
|
||||
|
||||
let sent = mgr.notify("art-one", &[], ChangeType::Updated, 1).unwrap();
|
||||
assert_eq!(sent, 0);
|
||||
|
||||
close_fd(r); close_fd(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_notifications() {
|
||||
let mut mgr = SubscriptionManager::new().unwrap();
|
||||
|
||||
let (r, w) = make_pipe();
|
||||
// Same fd subscribed to both title AND tag — should only get one notification.
|
||||
mgr.subscribe(w, Subscription::ByTitle("art-one".into()));
|
||||
mgr.subscribe(w, Subscription::ByTag("rust".into()));
|
||||
|
||||
let sent = mgr
|
||||
.notify("art-one", &["rust".into()], ChangeType::Updated, 1)
|
||||
.unwrap();
|
||||
assert_eq!(sent, 1); // deduped
|
||||
|
||||
let n = read_notification(r);
|
||||
assert_eq!(n.sys_title, "art-one");
|
||||
|
||||
close_fd(r); close_fd(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_format_subscription() {
|
||||
let mut mgr = SubscriptionManager::new().unwrap();
|
||||
|
||||
let (r, w) = make_pipe();
|
||||
mgr.subscribe_json(w, Subscription::ByTitle("art-one".into()));
|
||||
|
||||
mgr.notify("art-one", &[], ChangeType::Updated, 1).unwrap();
|
||||
|
||||
// Read length-prefixed JSON.
|
||||
let mut len_buf = [0u8; 4];
|
||||
let ret = unsafe { libc::read(r, len_buf.as_mut_ptr() as *mut libc::c_void, 4) };
|
||||
assert_eq!(ret, 4);
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
let ret = unsafe { libc::read(r, payload.as_mut_ptr() as *mut libc::c_void, len) };
|
||||
assert_eq!(ret as usize, len);
|
||||
|
||||
// Should be valid JSON.
|
||||
let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(json["sys_title"], "art-one");
|
||||
assert_eq!(json["change_type"], "Updated");
|
||||
assert_eq!(json["version"], 1);
|
||||
|
||||
close_fd(r); close_fd(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_format_subscribers() {
|
||||
let mut mgr = SubscriptionManager::new().unwrap();
|
||||
|
||||
let (r_bin, w_bin) = make_pipe();
|
||||
let (r_json, w_json) = make_pipe();
|
||||
|
||||
mgr.subscribe(w_bin, Subscription::ByTitle("art-one".into()));
|
||||
mgr.subscribe_json(w_json, Subscription::ByTitle("art-one".into()));
|
||||
|
||||
let sent = mgr.notify("art-one", &[], ChangeType::Created, 5).unwrap();
|
||||
assert_eq!(sent, 2);
|
||||
|
||||
// Binary subscriber gets bincode.
|
||||
let n = read_notification(r_bin);
|
||||
assert_eq!(n.sys_title, "art-one");
|
||||
assert_eq!(n.version, 5);
|
||||
|
||||
// JSON subscriber gets JSON.
|
||||
let mut len_buf = [0u8; 4];
|
||||
unsafe { libc::read(r_json, len_buf.as_mut_ptr() as *mut libc::c_void, 4) };
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
let mut payload = vec![0u8; len];
|
||||
unsafe { libc::read(r_json, payload.as_mut_ptr() as *mut libc::c_void, len) };
|
||||
let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(json["sys_title"], "art-one");
|
||||
|
||||
close_fd(r_bin); close_fd(w_bin);
|
||||
close_fd(r_json); close_fd(w_json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_macro() {
|
||||
let mut mgr = SubscriptionManager::new().unwrap();
|
||||
let (r, w) = make_pipe();
|
||||
|
||||
register!(mgr, w, ByTitle("test-article"));
|
||||
register!(mgr, w, ByTag("rust"));
|
||||
register!(mgr, w, All);
|
||||
|
||||
assert_eq!(mgr.subscriber_count(), 1);
|
||||
|
||||
close_fd(r); close_fd(w);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[package]
|
||||
name = "wo-watch"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wo-store = { path = "../wo-store" }
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A content change detected by the watcher.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ContentChange {
|
||||
Created(String), // sys_title
|
||||
Modified(String), // sys_title
|
||||
Deleted(String), // sys_title
|
||||
}
|
||||
|
||||
/// Watches a content directory for article file changes using inotify.
|
||||
///
|
||||
/// Expects the directory structure:
|
||||
/// ```text
|
||||
/// content_dir/
|
||||
/// {sys_title}/
|
||||
/// {sys_title}.json
|
||||
/// ```
|
||||
pub struct ContentWatcher {
|
||||
inotify_fd: RawFd,
|
||||
/// Maps inotify watch descriptor → directory path.
|
||||
wd_to_path: HashMap<i32, PathBuf>,
|
||||
/// Maps directory path → watch descriptor.
|
||||
path_to_wd: HashMap<PathBuf, i32>,
|
||||
content_dir: PathBuf,
|
||||
}
|
||||
|
||||
const EVENT_MASK: u32 =
|
||||
(libc::IN_CREATE | libc::IN_MODIFY | libc::IN_DELETE | libc::IN_MOVED_TO | libc::IN_MOVED_FROM)
|
||||
as u32;
|
||||
|
||||
impl ContentWatcher {
|
||||
/// Create a new watcher on the given content directory.
|
||||
///
|
||||
/// Adds inotify watches on the content dir itself and all subdirectories.
|
||||
pub fn new(content_dir: &Path) -> io::Result<Self> {
|
||||
let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let mut watcher = Self {
|
||||
inotify_fd: fd,
|
||||
wd_to_path: HashMap::new(),
|
||||
path_to_wd: HashMap::new(),
|
||||
content_dir: content_dir.to_path_buf(),
|
||||
};
|
||||
|
||||
// Watch the root content directory (for new article dirs).
|
||||
watcher.add_watch(content_dir)?;
|
||||
|
||||
// Watch each existing subdirectory.
|
||||
if content_dir.exists() {
|
||||
for entry in std::fs::read_dir(content_dir)? {
|
||||
let entry = entry?;
|
||||
if entry.path().is_dir() {
|
||||
watcher.add_watch(&entry.path())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
/// The inotify file descriptor, for registration on an event loop.
|
||||
pub fn fd(&self) -> RawFd {
|
||||
self.inotify_fd
|
||||
}
|
||||
|
||||
/// Read and process pending inotify events.
|
||||
///
|
||||
/// Returns a list of content changes. Call this when the event loop
|
||||
/// signals that the inotify fd is readable.
|
||||
pub fn process_events(&mut self) -> io::Result<Vec<ContentChange>> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = unsafe {
|
||||
libc::read(
|
||||
self.inotify_fd,
|
||||
buf.as_mut_ptr() as *mut libc::c_void,
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
|
||||
if n < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EAGAIN) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let mut changes = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
|
||||
while offset < n as usize {
|
||||
let event = unsafe { &*(buf.as_ptr().add(offset) as *const libc::inotify_event) };
|
||||
let name_len = event.len as usize;
|
||||
let name = if name_len > 0 {
|
||||
let name_ptr = unsafe { buf.as_ptr().add(offset + std::mem::size_of::<libc::inotify_event>()) };
|
||||
let name_bytes = unsafe { std::slice::from_raw_parts(name_ptr, name_len) };
|
||||
let end = name_bytes.iter().position(|&b| b == 0).unwrap_or(name_len);
|
||||
Some(String::from_utf8_lossy(&name_bytes[..end]).to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
offset += std::mem::size_of::<libc::inotify_event>() + name_len;
|
||||
|
||||
let Some(name) = name else { continue };
|
||||
let mask = event.mask;
|
||||
|
||||
// Event on the root content directory: subdirectory created/deleted.
|
||||
if let Some(dir_path) = self.wd_to_path.get(&event.wd) {
|
||||
let dir_path = dir_path.clone();
|
||||
|
||||
if dir_path == self.content_dir {
|
||||
let sub_path = self.content_dir.join(&name);
|
||||
if (mask & libc::IN_CREATE as u32) != 0 || (mask & libc::IN_MOVED_TO as u32) != 0 {
|
||||
if sub_path.is_dir() {
|
||||
let _ = self.add_watch(&sub_path);
|
||||
changes.push(ContentChange::Created(name.clone()));
|
||||
}
|
||||
}
|
||||
if (mask & libc::IN_DELETE as u32) != 0 || (mask & libc::IN_MOVED_FROM as u32) != 0 {
|
||||
self.remove_watch(&sub_path);
|
||||
changes.push(ContentChange::Deleted(name));
|
||||
}
|
||||
} else {
|
||||
// Event inside an article subdirectory.
|
||||
if name.ends_with(".json") || name.ends_with(".md") {
|
||||
let sys_title = dir_path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
if (mask & libc::IN_MODIFY as u32) != 0 {
|
||||
changes.push(ContentChange::Modified(sys_title));
|
||||
} else if (mask & libc::IN_CREATE as u32) != 0
|
||||
|| (mask & libc::IN_MOVED_TO as u32) != 0
|
||||
{
|
||||
changes.push(ContentChange::Created(sys_title));
|
||||
} else if (mask & libc::IN_DELETE as u32) != 0
|
||||
|| (mask & libc::IN_MOVED_FROM as u32) != 0
|
||||
{
|
||||
changes.push(ContentChange::Deleted(sys_title));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: keep only the last change per sys_title.
|
||||
let mut seen = HashMap::new();
|
||||
for change in changes {
|
||||
let key = match &change {
|
||||
ContentChange::Created(t) | ContentChange::Modified(t) | ContentChange::Deleted(t) => {
|
||||
t.clone()
|
||||
}
|
||||
};
|
||||
seen.insert(key, change);
|
||||
}
|
||||
|
||||
Ok(seen.into_values().collect())
|
||||
}
|
||||
|
||||
fn add_watch(&mut self, path: &Path) -> io::Result<()> {
|
||||
let c_path = std::ffi::CString::new(path.to_str().unwrap_or("")).map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "invalid path for inotify")
|
||||
})?;
|
||||
|
||||
let wd = unsafe { libc::inotify_add_watch(self.inotify_fd, c_path.as_ptr(), EVENT_MASK) };
|
||||
if wd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
self.wd_to_path.insert(wd, path.to_path_buf());
|
||||
self.path_to_wd.insert(path.to_path_buf(), wd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_watch(&mut self, path: &Path) {
|
||||
if let Some(wd) = self.path_to_wd.remove(path) {
|
||||
unsafe { libc::inotify_rm_watch(self.inotify_fd, wd) };
|
||||
self.wd_to_path.remove(&wd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ContentWatcher {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.inotify_fd) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn detect_json_modification() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let art_dir = content.join("my-article");
|
||||
fs::create_dir_all(&art_dir).unwrap();
|
||||
fs::write(art_dir.join("my-article.json"), r#"{"test": true}"#).unwrap();
|
||||
|
||||
let mut watcher = ContentWatcher::new(&content).unwrap();
|
||||
|
||||
// Modify the file.
|
||||
fs::write(art_dir.join("my-article.json"), r#"{"test": false}"#).unwrap();
|
||||
|
||||
// Give inotify a moment.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let changes = watcher.process_events().unwrap();
|
||||
assert!(!changes.is_empty());
|
||||
assert!(changes.iter().any(|c| matches!(c, ContentChange::Modified(t) if t == "my-article")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_new_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
fs::create_dir_all(&content).unwrap();
|
||||
|
||||
let mut watcher = ContentWatcher::new(&content).unwrap();
|
||||
|
||||
// Create a new article directory.
|
||||
let art_dir = content.join("new-article");
|
||||
fs::create_dir_all(&art_dir).unwrap();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let changes = watcher.process_events().unwrap();
|
||||
assert!(changes.iter().any(|c| matches!(c, ContentChange::Created(t) if t == "new-article")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_json_delete() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let art_dir = content.join("delete-me");
|
||||
fs::create_dir_all(&art_dir).unwrap();
|
||||
fs::write(art_dir.join("delete-me.json"), "{}").unwrap();
|
||||
|
||||
let mut watcher = ContentWatcher::new(&content).unwrap();
|
||||
|
||||
// Delete the JSON file.
|
||||
fs::remove_file(art_dir.join("delete-me.json")).unwrap();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let changes = watcher.process_events().unwrap();
|
||||
assert!(changes.iter().any(|c| matches!(c, ContentChange::Deleted(t) if t == "delete-me")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_non_json_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let content = tmp.path().join("content");
|
||||
let art_dir = content.join("my-article");
|
||||
fs::create_dir_all(&art_dir).unwrap();
|
||||
|
||||
let mut watcher = ContentWatcher::new(&content).unwrap();
|
||||
|
||||
// Create a non-JSON file.
|
||||
fs::write(art_dir.join("notes.txt"), "not json").unwrap();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let changes = watcher.process_events().unwrap();
|
||||
// Non-JSON files should not trigger Modified events.
|
||||
assert!(changes.iter().all(|c| !matches!(c, ContentChange::Modified(_))));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
# `reference/rest/` — HTTP test files for the `.wo` runtime
|
||||
|
||||
`.rest` (or `.http`) is the plain-text HTTP-request format supported by the two main editor HTTP clients:
|
||||
|
||||
- **VS Code** — install [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) (`humao.rest-client`) and click "Send Request" above any block.
|
||||
- **JetBrains IDEs** (IntelliJ, WebStorm, RustRover, Goland) — built-in HTTP Client recognises `.rest` and `.http` natively.
|
||||
|
||||
Both clients understand:
|
||||
|
||||
- `### ...` block separators
|
||||
- `@var = value` document-level variables referenced as `{{var}}`
|
||||
- `# @name foo` on a request, whose response fields are later reachable as `{{foo.response.body.id}}` — useful for threading auto-generated ids from `create` responses into later `get`/`patch`/`delete` calls
|
||||
|
||||
## Files
|
||||
|
||||
| File | Against | What it exercises |
|
||||
| --- | --- | --- |
|
||||
| [`blog.rest`](./blog.rest) | `docs/examples/blog/` | Full CRUD on Article/Comment, read-only on Author/Tag (matches the sample's `expose` lists). End-to-end flow: create article → list → get by id → PATCH title → PATCH embedded doc → DELETE draft → verify final state. |
|
||||
| [`ecommerce.rest`](./ecommerce.rest) | `docs/examples/ecommerce/` | What Stage 2 currently serves for the ecommerce sample: read-only Product/Order/Customer lists, 405s for non-exposed create endpoints, 501s for Stage 3 stubs. Documents the shape of Stage 3/4 endpoints (`fn checkout`, LIVE subscribe, `/me`) even though they're not wired yet. |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# one terminal — start the runtime
|
||||
cargo run --bin wo -- run docs/examples/blog
|
||||
# [wo] listening on http://127.0.0.1:8080
|
||||
|
||||
# another terminal — or just open the .rest file in VS Code/JetBrains and click
|
||||
```
|
||||
|
||||
Override the port via `WO_LISTEN`:
|
||||
|
||||
```bash
|
||||
WO_LISTEN=127.0.0.1:9000 cargo run --bin wo -- run docs/examples/blog
|
||||
```
|
||||
|
||||
…and update the `@host` line at the top of the `.rest` file to match.
|
||||
|
||||
## Without an editor (just `curl`)
|
||||
|
||||
Each `.rest` block maps directly to `curl`. Some examples:
|
||||
|
||||
```bash
|
||||
# Runtime info
|
||||
curl http://127.0.0.1:8080/
|
||||
|
||||
# List
|
||||
curl http://127.0.0.1:8080/api/articles
|
||||
|
||||
# Create — server assigns `id` automatically
|
||||
curl -X POST http://127.0.0.1:8080/api/articles \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"slug": "hello-writeonce",
|
||||
"title": "Hello, writeonce",
|
||||
"author": 1,
|
||||
"published": true,
|
||||
"meta": { "excerpt": "first post", "body_md": "# hi" }
|
||||
}'
|
||||
|
||||
# Get by id
|
||||
curl http://127.0.0.1:8080/api/articles/1
|
||||
|
||||
# Partial update
|
||||
curl -X PATCH http://127.0.0.1:8080/api/articles/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "title": "Updated" }'
|
||||
|
||||
# Delete
|
||||
curl -X DELETE http://127.0.0.1:8080/api/articles/1 # 204 on success
|
||||
|
||||
# Stage 3 stub
|
||||
curl -i http://127.0.0.1:8080/api/articles/live # 501 Not Implemented
|
||||
```
|
||||
|
||||
For a scripted smoke run against the blog sample, the top-to-bottom `curl` sequence that exactly mirrors `blog.rest` is in [`docs/examples/blog/README.md`](../../docs/examples/blog/README.md).
|
||||
|
||||
## Expected-status cheat sheet
|
||||
|
||||
Every block in the `.rest` files ends its description with the expected HTTP status. Quick legend:
|
||||
|
||||
| Status | Meaning in this prototype |
|
||||
| --- | --- |
|
||||
| `200` | OK — list / get / update succeeded |
|
||||
| `201` | Created — new row, `id` in the response body |
|
||||
| `204` | No Content — delete succeeded |
|
||||
| `400` | Bad JSON body |
|
||||
| `404` | No such row, OR no method at all is attached to the path (e.g. `/api/customers` when the `service rest` block doesn't `expose` any collection-root op) |
|
||||
| `405` | Method not allowed — the path is registered for a *different* method (e.g. POST against `/api/products` when only `list` is exposed, so GET is attached but POST isn't) |
|
||||
| `501` | Not Implemented — Stage 3+ feature (LIVE subscriptions, `/me`, transactional fns) |
|
||||
|
||||
A `405` is a *feature* of the sample — it confirms the `expose` list in the `.wo` file is being honoured. A `501` is a Stage marker — the runtime acknowledges the shape but hasn't wired the handler yet.
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
###############################################################################
|
||||
# blog.rest — exercise the `.wo` runtime against docs/examples/blog/
|
||||
#
|
||||
# Start the server first:
|
||||
# cargo run --bin wo -- run docs/examples/blog
|
||||
#
|
||||
# Then in VS Code (REST Client extension) or JetBrains (HTTP Client):
|
||||
# click "Send Request" on each block, top to bottom
|
||||
#
|
||||
# Response back-references (# @name foo → {{foo.response.body.id}}) are
|
||||
# supported by both clients — later blocks pick up ids minted by earlier
|
||||
# blocks automatically. For `curl` equivalents, see reference/rest/README.md.
|
||||
###############################################################################
|
||||
|
||||
@host = http://127.0.0.1:8080
|
||||
|
||||
|
||||
### Runtime info — expected 200
|
||||
GET {{host}}/
|
||||
|
||||
### Liveness probe — expected 200 "ok"
|
||||
GET {{host}}/healthz
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Author — exposes: list, get, me, subscribe
|
||||
###############################################################################
|
||||
|
||||
### List authors (empty on fresh boot) — expected 200 []
|
||||
GET {{host}}/api/authors
|
||||
|
||||
### Author create is NOT exposed in the blog sample (expose list, get, me, subscribe)
|
||||
# Expected 405 — method not allowed. The sample expects authors seeded by
|
||||
# `on startup do: seed_admin()` in app.wo. Stage 2 doesn't run startup hooks
|
||||
# yet, so the list above will be empty.
|
||||
POST {{host}}/api/authors
|
||||
Content-Type: application/json
|
||||
|
||||
{ "email": "alice@example.com", "handle": "alice", "display": "Alice" }
|
||||
|
||||
### /me — Stage 3 session layer; expected 501
|
||||
GET {{host}}/api/authors/me
|
||||
|
||||
### LIVE subscribe — Stage 3; expected 501
|
||||
GET {{host}}/api/authors/live
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Article — exposes: list, get, create, update, delete, subscribe
|
||||
###############################################################################
|
||||
|
||||
### Create an article — expected 201
|
||||
# @name createArticle
|
||||
POST {{host}}/api/articles
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"slug": "hello-writeonce",
|
||||
"title": "Hello, writeonce",
|
||||
"author": 1,
|
||||
"published": true,
|
||||
"meta": {
|
||||
"excerpt": "First post on the new runtime.",
|
||||
"body_md": "# Hi\n\nHello from the `.wo` runtime. The server, the database, and this HTTP API are all one binary.\n"
|
||||
}
|
||||
}
|
||||
|
||||
### Create a second article (draft) — expected 201
|
||||
# @name createDraft
|
||||
POST {{host}}/api/articles
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"slug": "second-draft",
|
||||
"title": "Second Post (draft)",
|
||||
"author": 1,
|
||||
"published": false,
|
||||
"meta": { "excerpt": "", "body_md": "WIP." }
|
||||
}
|
||||
|
||||
### List articles — expected 200 with 2 rows
|
||||
GET {{host}}/api/articles
|
||||
|
||||
### Get one article by id — expected 200
|
||||
GET {{host}}/api/articles/{{createArticle.response.body.id}}
|
||||
|
||||
### Partial update (PATCH) — change just the title — expected 200
|
||||
PATCH {{host}}/api/articles/{{createArticle.response.body.id}}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "title": "Hi, writeonce!" }
|
||||
|
||||
### Partial update of an embedded-document field — expected 200
|
||||
# Note: Stage 2's PATCH does a shallow merge at the top level.
|
||||
# To change `meta.excerpt` alone you re-send the whole `meta` object.
|
||||
PATCH {{host}}/api/articles/{{createArticle.response.body.id}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"meta": { "excerpt": "Updated excerpt.", "body_md": "# Hi\n\nUpdated body." }
|
||||
}
|
||||
|
||||
### Publish the draft — expected 200 (`on update` trigger that sets
|
||||
### `published_at` is Stage 3+; Stage 2 just records the bool change).
|
||||
PATCH {{host}}/api/articles/{{createDraft.response.body.id}}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "published": true }
|
||||
|
||||
### Delete the draft — expected 204 No Content
|
||||
DELETE {{host}}/api/articles/{{createDraft.response.body.id}}
|
||||
|
||||
### Get the deleted id — expected 404
|
||||
GET {{host}}/api/articles/{{createDraft.response.body.id}}
|
||||
|
||||
### LIVE subscribe (Article) — Stage 3; expected 501
|
||||
GET {{host}}/api/articles/live
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Tag — exposes: list, get, subscribe
|
||||
###############################################################################
|
||||
|
||||
### List tags — expected 200 [] (create is not exposed)
|
||||
GET {{host}}/api/tags
|
||||
|
||||
### Tag create NOT exposed (expose list, get, subscribe) — expected 405
|
||||
POST {{host}}/api/tags
|
||||
Content-Type: application/json
|
||||
|
||||
{ "slug": "rust", "label": "Rust" }
|
||||
|
||||
### LIVE subscribe (Tag) — Stage 3; expected 501
|
||||
GET {{host}}/api/tags/live
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Comment — exposes: list, get, create, update, delete, subscribe
|
||||
###############################################################################
|
||||
|
||||
### Create a comment — expected 201
|
||||
# @name createComment
|
||||
POST {{host}}/api/comments
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"article": {{createArticle.response.body.id}},
|
||||
"author": 1,
|
||||
"body": "Nice post. Runs on one binary which is still weird to me."
|
||||
}
|
||||
|
||||
### List comments — expected 200 with 1 row
|
||||
GET {{host}}/api/comments
|
||||
|
||||
### Get one comment — expected 200
|
||||
GET {{host}}/api/comments/{{createComment.response.body.id}}
|
||||
|
||||
### Update comment body — expected 200 (the `on update when old.body != new.body
|
||||
### do set self.edited_at = now()` trigger is Stage 3+; edited_at stays unset)
|
||||
PATCH {{host}}/api/comments/{{createComment.response.body.id}}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "body": "Edited: really, one binary? Neat." }
|
||||
|
||||
### Delete comment — expected 204
|
||||
DELETE {{host}}/api/comments/{{createComment.response.body.id}}
|
||||
|
||||
### LIVE subscribe (Comment) — Stage 3; expected 501
|
||||
GET {{host}}/api/comments/live
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Final state — should show the updated article still there, no drafts,
|
||||
# no comments (all deleted above).
|
||||
###############################################################################
|
||||
|
||||
### Final article list — expected 200 with 1 row
|
||||
GET {{host}}/api/articles
|
||||
|
||||
### Final comment list — expected 200 []
|
||||
GET {{host}}/api/comments
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
###############################################################################
|
||||
# ecommerce.rest — exercise the `.wo` runtime against docs/examples/ecommerce/
|
||||
#
|
||||
# Start the server first:
|
||||
# cargo run --bin wo -- run docs/examples/ecommerce
|
||||
#
|
||||
# The ecommerce sample is heavier on features that land in later stages:
|
||||
# * orders are minted by `fn checkout(customer, product, qty) in txn snapshot`
|
||||
# — transactional functions are a Stage 3/4 addition. Stage 2 does not
|
||||
# register `/api/fn/checkout` yet; the block below documents that.
|
||||
# * customer/product/order rows are seeded by `on startup do: seed()` in
|
||||
# app.wo — startup hooks are also Stage 3+. Lists start empty.
|
||||
# * Order-status lifecycle triggers (`on update when old.status != Paid ...`)
|
||||
# are Stage 3+.
|
||||
#
|
||||
# This file therefore focuses on what Stage 2 *does* serve — route wiring,
|
||||
# empty-list reads, method-not-allowed for non-exposed operations, and the
|
||||
# Stage-3 stubs that respond 501. It doubles as a living spec for what the
|
||||
# ecommerce sample should behave like once Stage 3+ lands.
|
||||
###############################################################################
|
||||
|
||||
@host = http://127.0.0.1:8080
|
||||
|
||||
|
||||
### Runtime info — expected 200
|
||||
GET {{host}}/
|
||||
|
||||
### Liveness probe — expected 200 "ok"
|
||||
GET {{host}}/healthz
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Product — exposes: list, get, subscribe
|
||||
# Stage 2 does NOT expose create — admin console is expected to seed inventory.
|
||||
###############################################################################
|
||||
|
||||
### List products — expected 200 [] (no startup seed yet)
|
||||
GET {{host}}/api/products
|
||||
|
||||
### Product create NOT exposed — expected 405
|
||||
POST {{host}}/api/products
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"sku": "SKU-WIDGET",
|
||||
"name": "Widget",
|
||||
"price": 1999,
|
||||
"meta": { "description": "A widget.", "images": [], "attributes": { "colour": "blue" } },
|
||||
"inventory": { "on_hand": 50, "reserved": 0, "reorder_at": 10 }
|
||||
}
|
||||
|
||||
### Get by id — expected 404 (nothing exists)
|
||||
GET {{host}}/api/products/1
|
||||
|
||||
### LIVE subscribe — Stage 3; expected 501
|
||||
GET {{host}}/api/products/live
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Customer — exposes: get, me, update, subscribe
|
||||
# Customer create is gated by admin/self-signup flows outside this sample's
|
||||
# scope. For now the list endpoint is not exposed either.
|
||||
###############################################################################
|
||||
|
||||
### List NOT exposed — expected 404
|
||||
# Customer's `expose get, me, update, subscribe` has nothing at the collection
|
||||
# root, so no route is registered at `/api/customers` at all. The server
|
||||
# returns 404 "no route" rather than 405 "method not allowed".
|
||||
GET {{host}}/api/customers
|
||||
|
||||
### Customer create NOT exposed — expected 404
|
||||
# Same reason: no method attached to /api/customers.
|
||||
POST {{host}}/api/customers
|
||||
Content-Type: application/json
|
||||
|
||||
{ "email": "carol@shop.test", "name": "Carol", "role": "Customer" }
|
||||
|
||||
### Get by id — expected 404 (nothing exists)
|
||||
GET {{host}}/api/customers/1
|
||||
|
||||
### Update by id — would work if the customer existed; expected 404
|
||||
PATCH {{host}}/api/customers/1
|
||||
Content-Type: application/json
|
||||
|
||||
{ "name": "Carol Updated" }
|
||||
|
||||
### /me — Stage 3 session layer; expected 501
|
||||
GET {{host}}/api/customers/me
|
||||
|
||||
### LIVE subscribe — Stage 3; expected 501
|
||||
GET {{host}}/api/customers/live
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Order — exposes: list, get, subscribe
|
||||
# Orders are created by `fn checkout(...)` (see logic/checkout.wo), not via
|
||||
# POST. Stage 2 does not register transactional-fn endpoints, so the list is
|
||||
# empty until Stage 3/4 brings them online.
|
||||
###############################################################################
|
||||
|
||||
### List orders — expected 200 []
|
||||
GET {{host}}/api/orders
|
||||
|
||||
### Order create NOT exposed (use fn checkout) — expected 405
|
||||
POST {{host}}/api/orders
|
||||
Content-Type: application/json
|
||||
|
||||
{ "customer": 1, "status": "Pending", "line_items": [] }
|
||||
|
||||
### LIVE subscribe — same WebSocket the `##ui #admin-orders` board opens in
|
||||
### Stage 6. For now the endpoint responds 501. This is the single most
|
||||
### requested Stage 3 endpoint for this sample.
|
||||
GET {{host}}/api/orders/live
|
||||
|
||||
|
||||
###############################################################################
|
||||
# fn checkout — deferred to Stage 3/4 (transactional functions)
|
||||
#
|
||||
# When transactional-fn endpoints land, this block becomes the canonical
|
||||
# cross-paradigm ACID test: one call updates the product's inventory doc,
|
||||
# inserts an Order row, creates a Purchase graph edge, and threads the new
|
||||
# order id through all three stores inside one BEGIN ... COMMIT.
|
||||
#
|
||||
# See docs/examples/ecommerce/logic/checkout.wo and
|
||||
# docs/runtime/database/05-go-sdk.md "Checkout from Go without codegen".
|
||||
###############################################################################
|
||||
|
||||
### Stage 2 returns 404 — the route isn't registered.
|
||||
POST {{host}}/api/fn/checkout
|
||||
Content-Type: application/json
|
||||
|
||||
{ "customer": 1, "product": 1, "qty": 2 }
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Purchase (link type) — no `service rest` block declared
|
||||
# Purchase edges are created by fn checkout and read via
|
||||
# `Customer.purchased` traversal (Stage 3+ query language).
|
||||
###############################################################################
|
||||
|
||||
### Purchase list NOT exposed — expected 404 (no route registered)
|
||||
GET {{host}}/api/purchases
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue