Phase: 0 — Outline & Research
Feature: 003-path-review-webapp
Status: Complete — all unknowns resolved
Decision: axum ^0.8 with tokio ^1 (multi-threaded runtime)
Rationale: axum is the canonical Rust web framework for async HTTP. It composes directly with Tower middleware, has first-class integration with the axum TestClient for testing, and works seamlessly with tokio's multi-threaded runtime. It is MIT-licensed and already adopted widely in the Rust ecosystem for exactly this use-case (short-lived local utility servers). The tokio::main macro in tp-cli's main.rs enables the entire CLI to remain async without restructuring existing synchronous code — non-async work just calls spawn_blocking.
Alternatives considered:
actix-web: LGPL-licensed risk for some versions; more boilerplate for a single-session local serverwarp: Older wrapper model; less ergonomic for typed state sharing; largely superseded by axumtiny-http(synchronous): No async, would complicate the integrated-mode blocking pattern; poorer test story
Decision: rust-embed ^8 — embed the entire tp-webapp/static/ directory at compile time
Rationale: rust-embed generates asset-serving code at compile time, producing a single self-contained binary with no runtime file lookups. This is essential for the distribution model: a user runs tp-cli webapp without needing to know where the library's static files are installed. rust-embed supports both debug mode (read from disk for fast frontend iteration) and release mode (embed into binary), which is ideal for development. MIT/Apache-2.0 licensed — compatible.
Alternatives considered:
- Bundle assets with
include_str!/include_bytes!manually: verbose, no directory support, no auto-reloading in dev - Ship static files alongside the binary: requires installation step, breaks portable distribution
- WASM frontend (e.g., Yew, Leptos): Massive complexity increase — requires npm/wasm-pack, build pipeline, much larger binary. Rejected per spec constraint (no build step, no npm) and per constitution III (avoid unnecessary complexity)
Decision: Leaflet.js 1.9 + vanilla HTML/CSS/JS (no framework, no build step)
Rationale: The feature spec explicitly requires no npm/build step. Leaflet is the standard open-source interactive map library for the browser; it is BSD-2-Clause licensed (compatible), well-documented, and trivially bundled as static files. Vanilla JS is sufficient for the interaction model: three layer groups (network segments, path segments, GNSS markers), a sidebar list with remove buttons, and click handlers. No reactive state management is needed for a single-session local tool.
Frontend file layout:
static/
├── index.html # App shell: map div + sidebar div + script/style tags
├── app.js # Map init, layer management, edit dispatch, sidebar sync
├── style.css # Map container height, sidebar layout, confidence colour scale
└── leaflet/ # Leaflet.js 1.9 dist files (leaflet.js, leaflet.css, images/)
Alternatives considered:
- React/Vue/Svelte: Require npm and a build step — rejected per spec constraints
- OpenLayers: Heavier than Leaflet for this use-case; more complex API for basic vector overlays
- MapLibre GL: WebGL-based; dependency on a larger JS bundle; overkill for non-tile rendering
Decision: Arc<RwLock<WebAppState>> shared across axum handlers via extension
pub struct WebAppState {
pub network: RailwayNetwork,
pub path: TrainPath,
pub gnss: Option<Vec<GnssPosition>>,
pub mode: AppMode,
pub output_path: Option<PathBuf>,
pub confirm_tx: Option<oneshot::Sender<ConfirmResult>>,
}
pub enum AppMode {
Standalone,
Integrated,
}
pub enum ConfirmResult {
Confirmed,
Aborted,
}Rationale: Arc<RwLock<…>> is the idiomatic axum pattern for shared mutable state. RwLock allows concurrent reads (GET /api/network, GET /api/path) while exclusive writes are rare (PUT /api/path, POST /api/save). The confirm_tx is a one-shot tokio channel whose Sender lives in state; the run_webapp_integrated function awaits the corresponding Receiver on the blocking side, enabling the CLI to pause the projection pipeline cleanly without busy-waiting or thread parking.
Alternatives considered:
Arc<Mutex<…>>: Simpler but blocks readers unnecessarily; GET /api/network and GET /api/path would serialize against each otherAtomicBoolsignal: Works for a binary confirmed/not flag but cannot carry theAbortedresult or be properlyawait-ed; a tokiooneshotis cleaner and more expressiveDashMapor other concurrent collections: Over-engineered for a single-state model
Decision: tokio oneshot channel — run_webapp_integrated awaits the receiver after spawning the server task
CLI main thread (tokio runtime):
1. calculate_train_path(…) → TrainPath
2. build WebAppState with oneshot::channel()
3. spawn axum server task (with Sender in state)
4. open browser
5. AWAIT oneshot Receiver
- POST /confirm → state.confirm_tx.take().send(Confirmed) → server shutdowns
- POST /abort → state.confirm_tx.take().send(Aborted) → server shutdowns
6. match result { Confirmed → continue projection, Aborted → exit non-zero }
Rationale: A tokio oneshot channel is the exact tool for "wait for exactly one signal". It is cancellation-safe, places no spin-wait pressure on the CPU, and integrates naturally with tokio's async executor. The server task can be given a CancellationToken (from tokio-util) to cleanly shut down after the channel fires.
Alternatives considered:
std::sync::Condvar: Would require blocking a thread rather than yielding the async executor; incompatible with tokio- HTTP polling from CLI to its own server: Architecturally circular and wasteful
- Signal files on disk: Fragile, non-portable, adds I/O dependency
Decision: Try port 8765 first; if in use, try successively incrementing ports up to 8774; print the actual bound URL to the terminal regardless
Rationale: A fixed default port (8765) is easy to remember and unlikely to conflict with common development tools (3000, 5173, 8080, 8443, etc.). If it is occupied the server should not fail silently — it tries 10 ports and then returns an error. The actual URL is always printed to the terminal (FR-019) regardless of browser auto-open success.
Alternatives considered:
- Port 0 (OS assigns): Guaranteed not to conflict but produces an unpredictable URL that the user cannot bookmark; worse UX
- Only port 8080: Too commonly occupied by other services on developer machines
Decision: Add PathOrigin enum to tp-core/src/models/path_origin.rs and add origin: PathOrigin field to AssociatedNetElement with backward-compatible serde defaults
// tp-core/src/models/path_origin.rs
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum PathOrigin {
#[default]
Algorithm,
Manual,
}AssociatedNetElement gains:
#[serde(default)]
pub origin: PathOrigin,For manually-added segments, gnss_start_index and gnss_end_index are both set to 0 (they are meaningless for segments with no GNSS association; downstream consumers that check origin == Manual should ignore them).
Rationale: Adding origin to the core model satisfies Constitution principle IX (Data Provenance). #[serde(default)] ensures all existing CSV files that lack this column deserialize correctly as Algorithm — fully backward-compatible. The field is also serialized to the output CSV, making provenance machine-readable.
Alternatives considered:
- Wrapper type
WebAssociatedNetElementintp-webapp: Keeps tp-core unchanged but breaks SC-004 (output must be accepted by--train-path) because the wrapper's CSV format would differ from the core type. Rejected. - Separate sidecar metadata file: Over-complex; requires two output files where one suffices
Decision: Use the existing petgraph DiGraph of netrelations (already constructed in tp-core's path module) to determine where a manually-added netelement fits in the current path order
Algorithm sketch:
- Load netrelations from the network GeoJSON (already done during server startup)
- When the user adds netelement
Nto the path: a. For each positioniin the current path, check if there is a navigable edge frompath[i]→N(N follows path[i]) or fromN→path[i+1](N precedes path[i+1]) b. If exactly one insertion gap satisfies both constraints, insert N there c. If multiple positions satisfy, pick the one where the combined edge weights (haversine distances) are smallest d. If none satisfy, append at the nearest end and markorigin = Manualwith a disconnected flag in the response body (the client renders the disconnected-marker style per FR-009) - Insertion is O(|path| × degree_of_N) — negligible at ≤200 path segments
Rationale: Reuses the network graph already constructed by the path calculation feature; no new graph-building code required. Respects FR-009 (no geometry guessing; netrelations required).
Alternatives considered:
- Spatial proximity snap (nearest geometry endpoint): Explicitly forbidden by spec (FR-009, Clarification 5)
- Full BFS/DFS through the graph to find shortest route: Overkill for ≤200 segments; the simpler scan is O(|path|) and sufficient
Decision: webapp Cargo feature in tp-cli/Cargo.toml, default-enabled; tp-webapp crate is only a dependency when that feature is active
# tp-cli/Cargo.toml
[features]
default = ["webapp"]
webapp = ["dep:tp-webapp"]
[dependencies]
tp-webapp = { path = "../tp-webapp", optional = true }Rationale: Users who want a minimal CLI binary without the web server can compile with --no-default-features. This also keeps the dependency tree clean for tp-py (Python bindings) which does not need axum at all.
Alternatives considered:
- Always-on dependency: Would force axum/tokio into every consumer of tp-cli — unnecessary for automated pipelines
- Separate binary crate
tp-webapp-cli: Would fragment the CLI; users would need to know about two binaries
Decision: Three-tier test approach
| Tier | Location | Tool | Covers |
|---|---|---|---|
| Unit — endpoint handlers | tp-webapp/tests/unit/routes_test.rs |
axum::extract::testing::TestClient (no network) |
Each handler in isolation with a pre-built WebAppState |
| Unit — edit logic | tp-webapp/tests/unit/edit_test.rs |
#[test] (sync) |
add_segment snap insertion + remove_segment with various netrelation graphs |
| Integration | tp-webapp/tests/integration/webapp_integration_test.rs |
tokio::test + reqwest |
Full server on a random port; tests all 6 endpoints end-to-end; verifies confirm/abort lifecycle |
CLI integration tests (tp-cli/tests/) cover argument parsing and verify that --review invokes the library correctly (mock the run_webapp_integrated fn behind a feature).
Property-based testing: not required for this feature — the edit logic is deterministic graph traversal, not a probability formula. If snap insertion logic grows complex, quickcheck is already available in the workspace.
All unknowns from Technical Context are resolved:
| Unknown | Resolution |
|---|---|
| Web framework | axum ^0.8 + tokio ^1 |
| Static asset strategy | rust-embed ^8 (compile-time embedding) |
| Frontend stack | Leaflet.js 1.9 + vanilla HTML/CSS/JS |
| AppState concurrency | Arc<RwLock<WebAppState>> |
| Integrated-mode blocking | tokio oneshot channel |
| Port selection | Start at 8765, try 10 consecutively |
| PathOrigin model | New enum in tp-core, serde default backward-compatible |
| Snap insertion | netrelations graph scan, O( |
| Feature flag | webapp feature, default-enabled in tp-cli |
| Testing approach | Three-tier: unit handlers, unit edit, integration |
| Dark mode / theming | CSS custom properties + body.dark class + prefers-color-scheme detection |
Decision: CSS custom properties (var()) with a body.dark class toggle; window.matchMedia('(prefers-color-scheme: dark)') checked at startup to auto-apply.
Rationale: CSS custom properties allow a complete colour-scheme swap by changing a single class on <body>. This avoids duplicating any style rules — only the variable values differ between light and dark. The approach has zero runtime cost (no JS colour manipulation, no DOM traversal) and works with the existing vanilla-JS, no-build-step frontend without introducing a framework dependency. Detecting prefers-color-scheme at startup means users with a dark OS theme get the correct appearance immediately without manual action.
Implementation:
:rootdefines light-theme variables (--bg,--surface,--text,--text-sub,--segment-bg, etc.)body.darkoverrides each variable with dark-theme values- Leaflet's
.leaflet-tooltip,.leaflet-popup-content-wrapper,.leaflet-popup-tip, and.leaflet-bar aelements receive explicit dark overrides (Leaflet styles are not CSS-variable-aware) app.jsstartup:if (window.matchMedia('(prefers-color-scheme: dark)').matches) { document.body.classList.add('dark'); darkToggle.checked = true; }- Manual toggle:
darkToggle.addEventListener('change', e => document.body.classList.toggle('dark', e.target.checked))
Alternatives considered:
- Separate light/dark CSS stylesheets: Requires a full duplicate of style rules; hard to keep in sync
- CSS
prefers-color-schememedia query only (no manual toggle): Does not allow the user to override the OS preference within the webapp - CSS-in-JS or a theming library: Incompatible with the no-npm, no-build-step constraint (Decision 3)