System-Architecture-Overview

System Architecture Overview

The codebase strictly separates concerns to isolate terminal drawing from data logic. This note is the short-form companion to pour-design-spec.

  • src/main.rs: Entry point only. CLI parsing (pour, pour init, pour serve, pour <module>), config load, transport connect, terminal lifecycle. Hands off to tui::loop_::run_loop for the event loop. Slimmed from 1945 LOC pre-decomposition to ~205 LOC post-Slice-13. See also ADR-003-Synchronous-TUI-Async-Operations.
  • src/init.rs: First-run setup. Implements the pour init flow — generates a starter config.toml with interactive vault path selection and example modules. Also hosts the canonical module_order(&config) helper used by both app.rs and the dashboard.
  • src/tui/: Presentation layer. Routes events to screen handlers (dashboard.rs, form/, summary.rs, configure/) and dispatches Action enums. tui/loop_.rs (post-Slice-13) owns the event-poll/draw/dispatch loop and 21 action handlers; tui/mod.rs::render is the central render dispatcher and paints the ephemeral status-bar toast overlay. The dashboard acts as an ambient capture surface — showing recent activity, capture rhythm stats, and module gaps rather than a simple launcher. Built with ratatui and crossterm.
  • src/tui/form/: Form screen module (post-Slice-11 decomposition). mod.rs owns FormAction and the NavCtx shared with key handlers. render/{mod,fields,composite}.rs split rendering by concern. key/{mod,text,select,composite,navigation,submit}.rs split the 930-line handle_key by mode. overlays/{mod,preset_picker,sub_form,small}.rs colocate the four overlays with their key-handlers (small.rs merges callout-title and preset-save per the "<100 LOC = bad seam" heuristic). No editor/ subdirectory — text-input logic was already cohesive within key/ files.
  • src/tui/configure/: In-app configurator (post-Slice-12 decomposition). mod.rs owns ConfigureAction and re-exports the public surface. render.rs holds all 6 render functions. key/{mod,fields,sub_fields,vault}.rs split the 1000-line handle_key by mode. autosave.rs invokes crate::config_updates::* builders to persist setting changes on navigate. init.rs (post-Slice-14) hosts the four configure-init helpers (build_field_settings, build_sub_field_settings, init_vault_configure, init_new_module_configure) that previously lived as methods on App.
  • src/app.rs: State management. Owns FormState, ConfigureState, BrowserState, active field indices, and input validation. FormState.active_field is a visible-set index; active_config_idx mirrors it as the config-level index. FormState.cursor_position is a char-index (post-v1-hardening), never a raw byte offset — emoji and CJK in textareas no longer panic on backspace. FormState.callout_overrides holds per-entry callout type selections. FormState.selected_preset_name: Option<String> is the source of truth for the active preset (name-keyed, survives reorder). FormState.preset_picker: Option<PresetPickerState> is the drilldown picker overlay state. App::status_message: Option<StatusMessage> is the ephemeral toast slot — set_status_warning(msg) populates it with a 5-second expiry; tick_status() clears expired messages each frame.
  • src/visibility.rs: Conditional field visibility. visible_field_indices(fields, values) returns the subset of config field indices that are currently visible given show_when rules. Called on every form key event. Integration points: src/app.rs uses it to compute FormState.active_field bounds; src/output/ skips hidden fields during generation; src/config.rs validates show_when constraints at load time; src/tui/configure/ serializes show_when when writing fields back via add_field_on_disk / update_field_on_disk.
  • src/output/: Write execution. Orchestrates frontmatter.rs generation and template.rs path/template rendering (including {{callout}} resolution and field-level callout wrapping). template.rs shares a substitute_keys kernel between render_path and render_append_template; the 8 intentional behavioral divergences between the two are explicit configuration of the kernel and pinned by snapshots in tests/output/template_snapshot.rs. Literal % in user templates is preserved by escape_nonspecifier_percent (post-v1-hardening). Related: ADR-002-Custom-YAML-Serialization.
  • src/data/: Fetch, cache, and history tier. All persistence routes through data/json_store.rs::JsonStore<T> — generic load/save with optional migration hook, the single sanctioned JSON write path (see ADR-006-V1-Lock-In-Patterns). cache.rs backs dynamic-select dropdowns. history.rs tracks capture events at ~/.pour/cache/history.jsonl (append-only JSONL); statistical computation lives in history_summary.rs; legacy-format reader lives in history_legacy.rs. presets.rs stores per-module saved field-value sets at ~/.pour/presets.json; field_presets.rs stores per-field row-set presets for composite_array fields. preset_tree.rs builds a hierarchical PresetTree from Vec<PresetEntry> + preset_axes. See pour-preset-hierarchy and The-3-Tier-Data-Fallback.
  • src/transport/: Network/disk boundary. Hides API vs filesystem from the rest of the application. transport/atomic.rs (post-Slice-1) is the single sanctioned atomic-write primitive: atomic_replace(src, dst) is one std::fs::rename call that resolves to MoveFileExW(MOVEFILE_REPLACE_EXISTING) on Windows. fs::resolve_path_validated (post-v1-hardening) rejects .., absolute paths, and ~-prefixes on every public method. Exposes execute_command() for Obsidian plugin commands and read_file() for vault reads. Related: ADR-001-Hybrid-Transport-Layer.
  • src/config.rs / src/config_edit.rs / src/config_updates.rs: Schema, mutation, and update-builder layer. config.rs defines the value layer (Config, ModuleConfig, FieldConfig, ConfigError, etc.) plus the 15 *_on_disk mutator methods (post-Slice-3 they all share one Config::write_atomic; post-Slice-8 they're thin facades over Config::edit). config_edit.rs defines Config::edit(path, |draft| { ... }) — the transactional load → mutate → validate → write entry point with ConfigDraft<'_> exposing both &mut DocumentMut and &Config. config_updates.rs (post-Slice-5) is the canonical home for build_module_updates, build_vault_updates, build_field_updates, build_sub_field_updates — previously duplicated between main.rs and tui/configure.rs. See ADR-006-V1-Lock-In-Patterns.
  • src/server/: axum HTTP server powering the mobile PWA companion. Post-decomposition layout: mod.rs is lifecycle (auth middleware, AppState, run/run_with_shutdown); routing.rs owns the router builder; static_assets.rs owns the embedded PWA shell. dto/{mod,response,requests,mapping}.rs split wire types by concern — mapping.rs isolates Config → DTO translation so response.rs is Config-free. handlers/submit/{mod,validate,idempotency_lookup,autocreate_step,write_step,history_step}.rs split the 607-line submit handler into a thin orchestrator + 5 phase modules using a SubmitContext struct. idempotency.rs is an in-memory LRU (1024 cap, 60s in-flight TTL, 5min done TTL). startup.rs contains resolve_token and print_banner. See ADR-005-PWA-Companion for the architectural decision and pour-api-contract for the wire shape.
  • TUI ↔ Serve handoff (src/tui/loop_.rs run_loop, Action::Serve): pressing s from the dashboard suspends the TUI (ratatui::restore()), probes port 8421 (TOCTOU-free: the bound listener is passed directly into run_with_shutdown), prints the banner via startup::print_banner, runs the server until Ctrl+C fires (tokio::signal::ctrl_c()), applies a 5s drain timeout, then re-enters the TUI (ratatui::init() + terminal.clear()). Port conflicts surface as a one-line error; server errors are pushed to app.deferred_stderr and printed after TUI restoration. The pour serve CLI path is unchanged.
  • web/: Embedded PWA shell. Vanilla HTML/CSS/JS, compiled into the binary at build time via rust-embed. Served at /; static assets require no auth. Phase 1: module list, per-module form rendering from GET /api/v1/config, submit. Phase 1.5/1.5++: preset chip row, show_when cascade, visual identity, idempotency-key persistence. Phase 2 Stream B: <section id="subform-overlay" role="dialog" aria-modal> — a modal sub-form that opens when the user types a novel value into a dynamic_select field with create_template set. The overlay renders template fields (text/number/static_select with inline cycling, no <select>), collects values into _pendingAutoCreateInputs, and includes them as auto_create_inputs in the parent submit body (contract §6.4). Cancel reverts the parent input to its open-time value. Server-side errors relay back into the overlay or summary view. Phase 2 Stream A (offline queue + service worker): web/sw.js — registered at root scope (/sw.js, served with Cache-Control: no-cache). Shell cache: pre-caches /, /app.js, /queue.js, /styles.css, /manifest.json, /static/icon.svg; cache-first for shell assets; network-only for /api/v1/*. Intercepts POST /api/v1/submit/* when offline or server 5xx: writes to IDB via web/queue.js and returns a synthetic 202 (contract §6.4 note; server never returns 202). Background Sync (pour-queue-drain) drains the IDB queue when network returns; window.online fallback for iOS Safari. Queue badge ("Queued (n)") in header updates via SW postMessages without polling. Conflict UX: Discard/Retry/Edit per record; edit pre-fills form with queued body, retains original Idempotency-Key. Version bump rule: any change to web/ requires a CACHE_VERSION bump in sw.js. Phase 2 Stream C (preset mutation client surface): inline "Save as preset" zone below the chip row; long-press/right-click on any named chip opens an inline edit panel (name + description editable, Save + Delete + Cancel); delete uses a single-tap-confirm (2s window); drag-to-reorder via pointer events with a ghost clone + placeholder; reorder PUT is serialized (latest-wins queue); all mutations guard navigator.onLine and show a toast if offline — no mutation queueing (offline queue is scoped to submits only). _activePreset is cleared by refreshPresetChipRowFromList whenever the active preset name is absent from the re-fetched list (covers delete). PUT-then-DELETE rename has a known minor page-close-mid-rename leak (a duplicate may survive); the user can remove it on next mutation. Phase 2 Stream D (history view + heatmap): <section id="history"> — lazy-loaded on first history-tab tap. Bottom-tab nav (role="tablist", 2 tabs: capture/history, cyan active underline, hidden on form/summary). History list renders module key + relative time + first_field per entry; cursor-paginated via server's next_cursor (never timestamp-derived — same-millisecond entries from offline-queue replay are immune). Tap-to-read panel: GET /api/v1/captures/{id}<pre> + textContent only (never innerHTML); 404/502 error states. 90-day heatmap: client-side rollup from /api/v1/history entries (Open Q2 resolution — path (a), no new endpoint); columns-of-weeks CSS grid; count tiers 0/1/2-3/4+ (cyan accent family); aria-label per cell; tap-popover; prefers-reduced-motion respected. Heatmap fetch loop terminates when has_more===false OR oldest entry in last page is outside the 90-day window. history.pushState/popstate wired so browser back from history returns to dashboard. See ADR-005-PWA-Companion.
  • src/paths.rs: Centralized path resolution. All runtime file locations (~/.pour/, config.toml, secrets.toml, presets.json, field_presets.json, cache/) are computed here via pour_home() and friends. POUR_HOME env var overrides the base directory. No other module constructs these paths manually.
  • src/util.rs: Backwards-compatibility re-export. The canonical atomic_replace lives at src/transport/atomic.rs (post-Slice-1); util.rs keeps pub use crate::transport::atomic::atomic_replace; so all existing callsites compile unchanged.
  • src/autocreate.rs: Inline note creation. On form submit, scans dynamic_select fields with allow_create = true for novel values (not in the existing options list), sanitizes the value into a safe cross-platform filename, and creates a note via the transport layer. Updates the in-memory cache on success. Supports two creation modes: bare stub (minimal date-only frontmatter) for fields without create_template, and template-driven (full frontmatter from [templates.<name>] fields via sub-form overlay) for fields with create_template. Template path resolution expands strftime tokens before {{name}} substitution to prevent injection. Also handles post_create_command dispatch after successful template-driven creation.

Append-Mode Filesystem Fallback

When a module uses `mode = "append"` and the API is unavailable, the filesystem transport cannot perform a true in-place append because it has no read-modify-write mechanism for arbitrary heading targets without risking data loss. Instead, Pour falls back to creating a standalone __atomic note__ with a timestamped filename (e.g. 20260421-143022-me.md) at the module's configured path directory. The note contains the rendered append_template output as its body. This ensures zero data loss — the entry is always persisted — even when the target daily note is inaccessible. The user can manually merge the standalone note into their daily note if desired. See also pour-append-target-recovery.

For the integrated event loop and subsystem wiring, see sprint-6-integration-report.