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 totui::loop_::run_loopfor 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 thepour initflow — generates a starterconfig.tomlwith interactive vault path selection and example modules. Also hosts the canonicalmodule_order(&config)helper used by bothapp.rsand the dashboard.src/tui/: Presentation layer. Routes events to screen handlers (dashboard.rs,form/,summary.rs,configure/) and dispatchesActionenums.tui/loop_.rs(post-Slice-13) owns the event-poll/draw/dispatch loop and 21 action handlers;tui/mod.rs::renderis 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.rsownsFormActionand theNavCtxshared with key handlers.render/{mod,fields,composite}.rssplit rendering by concern.key/{mod,text,select,composite,navigation,submit}.rssplit the 930-linehandle_keyby mode.overlays/{mod,preset_picker,sub_form,small}.rscolocate the four overlays with their key-handlers (small.rsmerges 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.rsownsConfigureActionand re-exports the public surface.render.rsholds all 6 render functions.key/{mod,fields,sub_fields,vault}.rssplit the 1000-linehandle_keyby mode.autosave.rsinvokescrate::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 onApp.src/app.rs: State management. OwnsFormState,ConfigureState,BrowserState, active field indices, and input validation.FormState.active_fieldis a visible-set index;active_config_idxmirrors it as the config-level index.FormState.cursor_positionis a char-index (post-v1-hardening), never a raw byte offset — emoji and CJK in textareas no longer panic on backspace.FormState.callout_overridesholds 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 givenshow_whenrules. Called on every form key event. Integration points:src/app.rsuses it to computeFormState.active_fieldbounds;src/output/skips hidden fields during generation;src/config.rsvalidatesshow_whenconstraints at load time;src/tui/configure/serializesshow_whenwhen writing fields back viaadd_field_on_disk/update_field_on_disk.src/output/: Write execution. Orchestratesfrontmatter.rsgeneration andtemplate.rspath/template rendering (including{{callout}}resolution and field-level callout wrapping).template.rsshares asubstitute_keyskernel betweenrender_pathandrender_append_template; the 8 intentional behavioral divergences between the two are explicit configuration of the kernel and pinned by snapshots intests/output/template_snapshot.rs. Literal%in user templates is preserved byescape_nonspecifier_percent(post-v1-hardening). Related: ADR-002-Custom-YAML-Serialization.src/data/: Fetch, cache, and history tier. All persistence routes throughdata/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.rsbacks dynamic-select dropdowns.history.rstracks capture events at~/.pour/cache/history.jsonl(append-only JSONL); statistical computation lives inhistory_summary.rs; legacy-format reader lives inhistory_legacy.rs.presets.rsstores per-module saved field-value sets at~/.pour/presets.json;field_presets.rsstores per-field row-set presets forcomposite_arrayfields.preset_tree.rsbuilds a hierarchicalPresetTreefromVec<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 onestd::fs::renamecall that resolves toMoveFileExW(MOVEFILE_REPLACE_EXISTING)on Windows.fs::resolve_path_validated(post-v1-hardening) rejects.., absolute paths, and~-prefixes on every public method. Exposesexecute_command()for Obsidian plugin commands andread_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.rsdefines the value layer (Config,ModuleConfig,FieldConfig,ConfigError, etc.) plus the 15*_on_diskmutator methods (post-Slice-3 they all share oneConfig::write_atomic; post-Slice-8 they're thin facades overConfig::edit).config_edit.rsdefinesConfig::edit(path, |draft| { ... })— the transactional load → mutate → validate → write entry point withConfigDraft<'_>exposing both&mut DocumentMutand&Config.config_updates.rs(post-Slice-5) is the canonical home forbuild_module_updates,build_vault_updates,build_field_updates,build_sub_field_updates— previously duplicated betweenmain.rsandtui/configure.rs. See ADR-006-V1-Lock-In-Patterns.src/server/: axum HTTP server powering the mobile PWA companion. Post-decomposition layout:mod.rsis lifecycle (auth middleware, AppState,run/run_with_shutdown);routing.rsowns the router builder;static_assets.rsowns the embedded PWA shell.dto/{mod,response,requests,mapping}.rssplit wire types by concern —mapping.rsisolatesConfig → DTOtranslation soresponse.rsis Config-free.handlers/submit/{mod,validate,idempotency_lookup,autocreate_step,write_step,history_step}.rssplit the 607-line submit handler into a thin orchestrator + 5 phase modules using aSubmitContextstruct.idempotency.rsis an in-memory LRU (1024 cap, 60s in-flight TTL, 5min done TTL).startup.rscontainsresolve_tokenandprint_banner. See ADR-005-PWA-Companion for the architectural decision and pour-api-contract for the wire shape.- TUI ↔ Serve handoff (
src/tui/loop_.rsrun_loop,Action::Serve): pressingsfrom the dashboard suspends the TUI (ratatui::restore()), probes port 8421 (TOCTOU-free: the bound listener is passed directly intorun_with_shutdown), prints the banner viastartup::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 toapp.deferred_stderrand printed after TUI restoration. Thepour serveCLI path is unchanged. web/: Embedded PWA shell. Vanilla HTML/CSS/JS, compiled into the binary at build time viarust-embed. Served at/; static assets require no auth. Phase 1: module list, per-module form rendering fromGET /api/v1/config, submit. Phase 1.5/1.5++: preset chip row,show_whencascade, 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 adynamic_selectfield withcreate_templateset. The overlay renders template fields (text/number/static_select with inline cycling, no<select>), collects values into_pendingAutoCreateInputs, and includes them asauto_create_inputsin 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 withCache-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/*. InterceptsPOST /api/v1/submit/*when offline or server 5xx: writes to IDB viaweb/queue.jsand 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.onlinefallback 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 originalIdempotency-Key. Version bump rule: any change toweb/requires aCACHE_VERSIONbump insw.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 guardnavigator.onLineand show a toast if offline — no mutation queueing (offline queue is scoped to submits only)._activePresetis cleared byrefreshPresetChipRowFromListwhenever 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'snext_cursor(never timestamp-derived — same-millisecond entries from offline-queue replay are immune). Tap-to-read panel:GET /api/v1/captures/{id}→<pre>+textContentonly (neverinnerHTML); 404/502 error states. 90-day heatmap: client-side rollup from/api/v1/historyentries (Open Q2 resolution — path (a), no new endpoint); columns-of-weeks CSS grid; count tiers 0/1/2-3/4+ (cyan accent family);aria-labelper cell; tap-popover;prefers-reduced-motionrespected. Heatmap fetch loop terminates whenhas_more===falseOR oldest entry in last page is outside the 90-day window.history.pushState/popstatewired 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 viapour_home()and friends.POUR_HOMEenv var overrides the base directory. No other module constructs these paths manually.src/util.rs: Backwards-compatibility re-export. The canonicalatomic_replacelives atsrc/transport/atomic.rs(post-Slice-1);util.rskeepspub use crate::transport::atomic::atomic_replace;so all existing callsites compile unchanged.src/autocreate.rs: Inline note creation. On form submit, scansdynamic_selectfields withallow_create = truefor 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 (minimaldate-only frontmatter) for fields withoutcreate_template, and template-driven (full frontmatter from[templates.<name>]fields via sub-form overlay) for fields withcreate_template. Template path resolution expands strftime tokens before{{name}}substitution to prevent injection. Also handlespost_create_commanddispatch 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.
This behavior is documented in ADR-001-Hybrid-Transport-Layer and ADR-004-API-Append-Read-Modify-Write.
For the integrated event loop and subsystem wiring, see sprint-6-integration-report.