diff --git a/.planning/phases/03-navigation-and-links/03-RESEARCH.md b/.planning/phases/03-navigation-and-links/03-RESEARCH.md new file mode 100644 index 0000000..faef5b7 --- /dev/null +++ b/.planning/phases/03-navigation-and-links/03-RESEARCH.md @@ -0,0 +1,600 @@ +# Phase 3: Navigation and Links - Research + +**Researched:** 2026-02-28 +**Domain:** pulldown-cmark link events, wiki-link resolution, ratatui link rendering, navigation history, breadcrumb status bar +**Confidence:** HIGH (core stack verified via official docs; patterns validated from codebase) + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +#### Link appearance & cycling +- Links displayed as bracket-wrapped text with color: `[Link Text]` — fits the retro BBS aesthetic +- Selected link (via Tab cycling) shown with inverted colors (foreground/background swap) +- Tab cycling wraps around — after the last link, Tab returns to the first link on the page +- When Tab-cycling to an off-screen link, the view auto-scrolls to center the selected link on screen + +#### Breadcrumb & status bar +- Current location shown as breadcrumb trail with separator: `docs > guides > getting-started` +- File extensions (.md) stripped from breadcrumb display +- Back/forward indicators shown when history exists (e.g. `< Back | Forward >`) — hidden when no history in that direction +- Link cycling index displayed when a link is selected: `Link 3/7` + +#### Wiki-link resolution +- Case-insensitive matching: `[[Getting Started]]` matches `getting-started.md`, `Getting-Started.md`, etc. +- Space-to-filename mapping tries multiple strategies: hyphens first, then underscores, then literal spaces +- Broken wiki-links shown inline as red/strikethrough text — user sees it's broken without navigating +- Subpaths supported: `[[guides/Getting Started]]` resolves to `guides/getting-started.md` + +#### History behavior +- Scroll position restored when navigating back — puts you exactly where you were +- Selected link restored when navigating back — full state restoration +- Unlimited history depth — full session history preserved +- Forward stack cleared when following a new link after going back (browser-style fork behavior) + +### Claude's Discretion +- Exact keybindings for back/forward (Backspace, Alt+Left/Right, etc.) +- Link resolution search order for ambiguous matches +- Error page design for broken link navigation attempts +- Breadcrumb separator character and styling + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| NAV-01 | User can follow `[[wiki-links]]` to other vault documents | pulldown-cmark `Options::ENABLE_WIKILINKS` emits `Tag::Link { link_type: LinkType::WikiLink { has_pothole }, dest_url, .. }` — dest_url holds raw wiki target (e.g. `"Getting Started"`); resolver maps to vault file path | +| NAV-02 | User can follow standard `[text](path.md)` links to other documents | pulldown-cmark `Tag::Link { link_type: LinkType::Inline, dest_url, .. }` — dest_url holds the path literally; path traversal guard required before loading | +| NAV-03 | User can navigate back through history stack | `Vec` with an index pointer; back = decrement index and load stored path + scroll + link_index | +| NAV-04 | User can navigate forward after going back | Same `Vec` stack; forward = increment index; forward stack is truncated when new link is followed after going back | +| NAV-10 | User sees links highlighted inline and can Tab-cycle between them | Renderer outputs a parallel `Vec` (line_index, col_offset, dest) alongside `Vec`; Tab increments `selected_link_index`; redraw applies `Modifier::REVERSED` to the selected span; auto-scroll ensures link is on screen | +| NAV-11 | User sees breadcrumb / current location in status bar | `draw_status_bar` updated to build breadcrumb from current path relative to vault root; path components joined with ` > ` separator; extensions stripped; back/forward availability drives `< Back` / `Forward >` display | + + +--- + +## Summary + +Phase 3 builds navigation on top of the existing render pipeline from Phase 2. The key insight is that the current renderer produces `Vec>` with all styling embedded in `Span` objects but no link metadata whatsoever. To support link navigation, the renderer must be extended to simultaneously produce a parallel `Vec` that maps each link's position (line index, column offset, character length) to its destination path. This parallel structure is the central architectural challenge. + +The rendering approach works as follows: during the pulldown-cmark event pass, when a `Tag::Link` or `Tag::Link { WikiLink }` event is encountered, the renderer records the upcoming span's position before pushing the styled span. After rendering, the two outputs — styled lines and link records — are stored together in `App` state. The selected link index is stored separately in `App` and drives style overrides at draw time. + +Wiki-link resolution is a pure Rust filesystem operation with no additional crates needed. The resolver takes a raw wiki-link target (e.g. `"Getting Started"`), generates candidate filenames (hyphenated, underscored, literal), and performs case-insensitive matching against `std::fs::read_dir` output. Path traversal security requires `canonicalize()` + prefix check before any file read. The history stack is a plain `Vec` with a current-index pointer — no external crate needed. + +**Primary recommendation:** Extend the renderer to return `(Vec>, Vec)` as a pair; store both in `App`. Use `ENABLE_WIKILINKS` flag on the pulldown-cmark parser. Implement the resolver as a standalone `resolve_wiki_link()` function in `vault.rs`. Keep history as `Vec` in `App` state. + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| pulldown-cmark | 0.13.1 (locked) | Parse wiki-links and markdown links from events | Already in use; 0.13.1 includes `Options::ENABLE_WIKILINKS` and `LinkType::WikiLink` | +| ratatui | 0.30.0 (locked) | Render link spans with `Modifier::REVERSED` for selection | Already in use; `Span::styled` with `add_modifier(Modifier::REVERSED)` is the selection mechanism | +| crossterm | (transitive via ratatui) | Keyboard events: `KeyCode::Tab`, `KeyCode::BackTab`, `KeyCode::Enter`, `KeyCode::Backspace`, `KeyModifiers::ALT + KeyCode::Left/Right` | Already the event backend | +| std (no new crates) | — | `std::fs::read_dir`, `PathBuf::canonicalize`, `Vec` for history | Sufficient for all navigation and resolution needs | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| (none required) | — | — | No additional crates needed for Phase 3 | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| std::fs::read_dir for case-insensitive resolution | `walkdir` crate | walkdir is powerful but overkill; Phase 3 only needs single-level + subpath lookup | +| Manual history Vec | External navigation crate | No suitable crate exists for this use case; Vec with index pointer is the standard pattern | + +**Installation:** +No new dependencies needed. All required functionality is in the already-locked crate versions. + +--- + +## Architecture Patterns + +### Recommended Project Structure +The phase extends existing modules rather than adding new ones: + +``` +src/ +├── app.rs # Add: navigation history, selected_link_index, link_records, navigate_to() +├── renderer.rs # Modify: render_markdown returns (Vec, Vec) +├── vault.rs # Add: resolve_wiki_link(), path traversal guard +└── (existing modules unchanged) +``` + +A new type `LinkRecord` should be defined — the question is where. Best location is `renderer.rs` since it is produced there and consumed by `app.rs`. + +### Pattern 1: Parallel Link Record Production During Render + +**What:** The renderer, when it encounters `Tag::Link`, records the current output line index and character column offset into a `Vec`. The link's display span is pushed to `current_spans` as normal, but its position is logged before it is flushed. + +**When to use:** Whenever a `Tag::Link` Start event is encountered during the event loop. + +**Key data:** +```rust +// Source: renderer.rs design — no external reference needed +pub struct LinkRecord { + /// Index into the rendered Vec where this link appears. + pub line_index: usize, + /// Character column offset within that line where the link span starts. + pub col_offset: usize, + /// Display length of the link span (for scroll-to-center calculation). + pub span_len: usize, + /// Resolved or raw destination — the path to navigate to. + pub dest: String, + /// True if this is a wiki-link (needs vault resolution), false for inline paths. + pub is_wiki: bool, +} +``` + +The renderer flushes current_spans into a Line at paragraph/item/heading end. The challenge: column offset of the link span within the line must be computed *before* the span is pushed, by summing the character lengths of all current_spans already accumulated. After flushing to a line, the line_index is `lines.len() - 1` (or the next line that will be pushed). + +**The precise tracking window:** The link record is created on `Tag::Link` Start; the `dest` comes from `dest_url.to_string()`. However, because the span isn't pushed to a Line until flush, the line_index is only known at flush time. Two options: + +- **Option A (simpler):** Record a `pending_link` in RenderState on `Tag::Link` Start; at `TagEnd::Link` End, the span for the link text has been accumulated; at the next flush, finalize the record with the line_index. +- **Option B (cleaner):** Record line_index at flush time by tracking a link-in-progress flag and resolving at the end of the flush. + +Option A is simpler and sufficient. The key constraint: a link's text spans are always flushed at the same paragraph/item end as the surrounding text — links don't cross block boundaries. + +**Example render flow:** +```rust +// Inside handle_event, on Tag::Link Start: +Event::Start(Tag::Link { link_type, dest_url, .. }) => { + let is_wiki = matches!(link_type, LinkType::WikiLink { .. }); + state.pending_link = Some(PendingLink { + dest: dest_url.to_string(), + is_wiki, + col_offset: state.current_spans.iter() + .map(|s| s.content.len()) + .sum(), + }); + // Push bracket prefix span: "[" + state.current_spans.push(Span::styled( + "[".to_string(), + Style::default().fg(Color::LightCyan), // or another link color + )); +} + +// On TagEnd::Link: +Event::End(TagEnd::Link) => { + // Push closing bracket span: "]" + state.current_spans.push(Span::styled( + "]".to_string(), + Style::default().fg(Color::LightCyan), + )); + // Finalize link record — line_index resolved at flush + if let Some(pending) = state.pending_link.take() { + state.pending_link_records.push(PendingLinkRecord { + dest: pending.dest, + is_wiki: pending.is_wiki, + col_offset: pending.col_offset, + }); + } +} +``` + +Then at flush_line, assign the current `lines.len()` as the `line_index` for any `pending_link_records`. + +### Pattern 2: Wiki-Link Resolution + +**What:** Convert a raw wiki-link target string (e.g. `"Getting Started"` or `"guides/Getting Started"`) to a vault-relative file path. + +**When to use:** When `LinkRecord.is_wiki == true` and the user presses Enter to follow the link. + +**Algorithm:** +```rust +// Source: vault.rs — canonical resolution algorithm +pub fn resolve_wiki_link(vault_path: &Path, raw_target: &str) -> Option { + // Split off subpath prefix if present: "guides/Getting Started" -> ("guides", "Getting Started") + let (subdir, name) = match raw_target.rfind('/') { + Some(i) => (&raw_target[..i], &raw_target[i+1..]), + None => ("", raw_target), + }; + + // Generate candidate stems (space replacement strategies, per locked decision) + let candidates: Vec = { + let hyphen = name.replace(' ', "-"); + let under = name.replace(' ', "_"); + let literal = name.to_string(); + // Order: hyphens first, then underscores, then literal spaces + vec![hyphen, under, literal] + }; + + // Case-insensitive match against vault directory + let search_dir = if subdir.is_empty() { + vault_path.to_path_buf() + } else { + vault_path.join(subdir) + }; + + let Ok(entries) = std::fs::read_dir(&search_dir) else { return None }; + + for entry in entries.flatten() { + let fname = entry.file_name(); + let fname_str = fname.to_string_lossy(); + // Strip .md extension for comparison + let stem = fname_str.strip_suffix(".md").unwrap_or(&fname_str); + let stem_lower = stem.to_lowercase(); + + for candidate in &candidates { + if stem_lower == candidate.to_lowercase() { + // Found — return vault-relative path + let full_path = entry.path(); + // Path traversal guard: ensure resolved path is within vault + if let Ok(canonical) = full_path.canonicalize() { + if let Ok(vault_canonical) = vault_path.canonicalize() { + if canonical.starts_with(&vault_canonical) { + // Return relative path from vault root + return canonical.strip_prefix(&vault_canonical) + .ok() + .map(|p| p.to_path_buf()); + } + } + } + } + } + } + None +} +``` + +### Pattern 3: Navigation History + +**What:** A browser-style back/forward stack stored in `App` state as a `Vec` with a `history_index: usize` pointer. + +**When to use:** On every document navigation, back press, forward press. + +**Data structure:** +```rust +// Source: app.rs design +struct HistoryEntry { + /// Vault-relative path (e.g. "guides/getting-started.md") + path: String, + /// Scroll offset at time of navigation + scroll_offset: u16, + /// Selected link index (None if no link was selected) + selected_link: Option, +} +``` + +**Navigation operations:** +- **Follow link:** Push current state to `history[..=history_index]`, increment index. If `history_index < history.len() - 1`, truncate forward stack (browser fork behavior). +- **Back:** If `history_index > 0`, save current state to `history[history_index]`, decrement index, load `history[history_index]`. +- **Forward:** If `history_index < history.len() - 1`, save current state, increment index, load. + +```rust +// Example: App fields for navigation +struct App { + // ... existing fields ... + history: Vec, + history_index: usize, + link_records: Vec, + selected_link: Option, +} +``` + +### Pattern 4: Selected Link Rendering (Draw-Time Style Override) + +**What:** At draw time, when `selected_link` is `Some(i)`, the span at `link_records[i].line_index` must have `Modifier::REVERSED` applied. + +**Problem:** `Vec>` is stored pre-rendered. Modifying a span at draw time without mutating the stored lines requires rebuilding the affected line each frame, or storing lines in a way that allows per-draw-call modification. + +**Recommended approach:** Rebuild the selected line at draw time. Since the link records know which line and column offset the link occupies, the draw function can: +1. Clone the affected line +2. Find the link span(s) by column offset +3. Apply `Modifier::REVERSED` to those spans +4. Substitute the modified line into a local `Vec` passed to `Paragraph` + +This is an immediate-mode operation — no persistent mutation of stored lines. The cost is O(spans_in_line) per frame, negligible. + +**Alternative:** Store lines and link records together as `Vec<(Line, Option)>` where the usize is the link_records index. At draw time, if a line has an associated link record and it is the selected link, apply REVERSED. Same cost, slightly cleaner. + +### Pattern 5: Breadcrumb Generation + +**What:** Convert a vault-relative path like `"guides/getting-started.md"` to `"guides > getting-started"`. + +**Algorithm:** +```rust +// Source: app.rs design +fn build_breadcrumb(vault_relative_path: &str) -> String { + Path::new(vault_relative_path) + .components() + .map(|c| { + let s = c.as_os_str().to_string_lossy(); + // Strip .md extension from the last component + s.strip_suffix(".md") + .unwrap_or(&s) + .to_string() + }) + .collect::>() + .join(" > ") +} +``` + +### Pattern 6: Auto-Scroll to Selected Link + +**What:** When Tab-cycling to a link whose `line_index` is outside the current viewport, adjust `scroll_offset` to center the link on screen. + +**Algorithm:** +```rust +fn scroll_to_link(link_line: usize, scroll_offset: &mut u16, content_height: u16) { + let half = content_height / 2; + let target = (link_line as u16).saturating_sub(half); + *scroll_offset = target; +} +``` + +Call this whenever `selected_link` changes and the link's `line_index` falls outside `[scroll_offset, scroll_offset + content_height)`. + +### Anti-Patterns to Avoid + +- **Storing mutable link style in Vec:** Do NOT mutate stored `Vec` to apply selection style. Lines are immutable once rendered. Apply selection style at draw time only. +- **Re-reading the file on back-navigation:** History restores from memory. Only follow-link navigations trigger `vault::load_document()`. +- **Resolving wiki-links at render time:** Resolution requires filesystem access. Resolve at navigation time (user presses Enter), not during rendering. Renderer only stores the raw `dest` string. +- **Regex pre-pass for wiki-links:** The user confirmed using pulldown-cmark's built-in `ENABLE_WIKILINKS` option. Do not implement a regex pre-pass. +- **Path traversal via `../`:** Always guard with `canonicalize()` + `starts_with(vault_canonical)` before loading any linked file. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Wiki-link parsing from raw markdown | Custom regex for `[[...]]` | `Options::ENABLE_WIKILINKS` in pulldown-cmark | Already in the locked version; handles edge cases like nested brackets, piped aliases (`[[foo\|bar]]`), and `has_pothole` | +| Keyboard modifier detection | Manual bit manipulation | `key.modifiers.contains(KeyModifiers::ALT)` | crossterm's bitflags type handles this correctly | +| Case-insensitive filename comparison | ICU/locale-aware comparison | `.to_lowercase()` on both sides | ASCII filenames; `.to_lowercase()` is sufficient and no-dep | + +**Key insight:** All the heavy lifting (markdown parsing, wiki-link tokenizing, keyboard events, styled span rendering) is already handled by the locked dependency set. Phase 3 is pure orchestration: connecting existing pieces with new state and new logic. + +--- + +## Common Pitfalls + +### Pitfall 1: Line Index Drift When Links Span Block Boundaries + +**What goes wrong:** If a link appears in a list item, the flush happens at `TagEnd::Item`, not `TagEnd::Paragraph`. The line index recorded for the link record must be the line pushed by *that specific flush*, not a global assumption that all flushes come from paragraphs. + +**Why it happens:** `flush_line()` is called from multiple code paths (heading end, paragraph end, item end, hard break). Each call increments `lines.len()`. + +**How to avoid:** Record the line_index in the link record only after the containing flush is called — i.e., set `link_record.line_index = lines.len()` at the moment of `flush_line()` for any pending link, not when `TagEnd::Link` is processed. + +**Warning signs:** Links in list items appear at wrong line indices (off by the number of blank lines from other elements). + +### Pitfall 2: ENABLE_WIKILINKS + TextMergeStream Interaction + +**What goes wrong:** The current renderer uses `TextMergeStream::new(Parser::new_ext(...))`. TextMergeStream merges consecutive `Event::Text` events. Wiki-link text events are also text events; merging may affect how text inside `[[foo]]` is presented. + +**Why it happens:** TextMergeStream was added in Phase 2 specifically for syntect grammar correctness (decision 02-01). It may or may not affect wiki-link text events inside a link tag. + +**How to avoid:** Test with a markdown file containing `[[simple]]`, `[[foo|bar]]`, and verify that the text events inside the Link tag are correct after TextMergeStream. If wiki-link display text appears doubled or missing, remove TextMergeStream from the outer wrapper and wrap only the code block sub-pass, or apply TextMergeStream conditionally. + +**Warning signs:** Wiki-link display text is garbled, doubled, or empty in rendered output. + +**Confidence:** LOW — specific interaction with TextMergeStream has not been confirmed by official docs or tests. + +### Pitfall 3: Path Traversal via Markdown Links + +**What goes wrong:** A markdown link `[evil](../../etc/passwd)` resolves to a file outside the vault if not guarded. + +**Why it happens:** `vault_path.join("../../etc/passwd")` traverses the filesystem. `Path::join` with an absolute path replaces the base entirely. + +**How to avoid:** After constructing the candidate path: `let full = vault_path.join(dest_url)`. Then: `canonicalize(full)` must return a path that `starts_with(canonicalize(vault_path))`. If not, treat as broken link. + +**Warning signs:** This is a silent security failure — no panic, just wrong file loaded. Test with `[bad](../../../etc/hosts)` in a test vault document. + +### Pitfall 4: Column Offset Counts Characters, Not Display Width + +**What goes wrong:** Emoji or multi-byte Unicode in text before a link cause column offset tracking to fail because `String::len()` returns byte count, not character count. + +**Why it happens:** The current renderer sums `s.content.len()` (byte count) for widths. For ASCII-only vault content this is fine. For Unicode content, the column offset used for scroll-to-center would be incorrect. + +**How to avoid:** Use `s.content.chars().count()` for character-level column offsets, or use `unicode_width::UnicodeWidthStr` for display-width calculations. Given BBS aesthetic and typical vault content being ASCII, LOW priority but worth noting. + +**Confidence:** MEDIUM — depends on vault content. For pure ASCII vaults (most BBS content), byte count equals char count. + +### Pitfall 5: History Restoration Requires Re-Render + +**What goes wrong:** Navigating back restores a `HistoryEntry` with a stored path, scroll, and link selection, but the rendered `Vec` and `Vec` are not stored in history. They must be re-rendered from the stored path. + +**Why it happens:** `Vec>` can be large and history depth is unlimited. Storing rendered output for each history entry would consume significant memory. + +**How to avoid:** On back/forward navigation, call `vault::load_document()` + `renderer::render_markdown()` again. This is the same flow as following a new link. The cost is acceptable because navigation is a user action, not a loop iteration. + +**Alternative considered:** Cache rendered output in a `HashMap, Vec)>`. This improves back-navigation responsiveness at the cost of memory. Given BBS single-file session nature, probably not needed. + +--- + +## Code Examples + +Verified patterns from official sources and codebase analysis: + +### Wiki-Link Parser Setup +```rust +// Source: docs.rs/pulldown-cmark/latest +// Add ENABLE_WIKILINKS to existing options in renderer.rs +let mut opts = Options::empty(); +opts.insert(Options::ENABLE_TABLES); +opts.insert(Options::ENABLE_STRIKETHROUGH); +opts.insert(Options::ENABLE_TASKLISTS); +opts.insert(Options::ENABLE_GFM); +opts.insert(Options::ENABLE_WIKILINKS); // ADD THIS + +let parser = TextMergeStream::new(Parser::new_ext(input, opts)); +``` + +### Wiki-Link Event Matching +```rust +// Source: docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.Tag.html +// Source: docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.LinkType.html +Event::Start(Tag::Link { link_type, dest_url, .. }) => { + match link_type { + LinkType::WikiLink { has_pothole } => { + // dest_url = raw wiki target, e.g. "Getting Started" or "guides/foo" + // has_pothole = true means [[foo|bar]] piped form; dest_url is still "foo" + // display text comes from the Text events inside this tag + } + LinkType::Inline => { + // dest_url = literal path from [text](path.md) + } + _ => { + // Reference links, shortcuts, autolinks — treat as non-navigable for Phase 3 + } + } +} +``` + +### Keyboard Events for Navigation +```rust +// Source: docs.rs/crossterm/latest/crossterm/event/enum.KeyCode.html +// Source: docs.rs/crossterm/latest/crossterm/event/struct.KeyEvent.html +KeyCode::Tab => { /* Next link */ } +KeyCode::BackTab => { /* Previous link (Shift+Tab) */ } +KeyCode::Enter => { /* Follow selected link */ } +KeyCode::Backspace => { /* Navigate back */ } +// For Alt+Left / Alt+Right back/forward: +key if key.code == KeyCode::Left && key.modifiers.contains(KeyModifiers::ALT) => { /* back */ } +key if key.code == KeyCode::Right && key.modifiers.contains(KeyModifiers::ALT) => { /* forward */ } +``` + +### Link Span Rendering with Selection Style +```rust +// Source: ratatui Span::styled + Modifier::REVERSED (existing pattern in app.rs) +// At render time, for the selected link's line: +let link_style = if Some(link_index) == selected_link { + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::REVERSED) +} else { + Style::default().fg(Color::LightCyan) +}; +// The bracket "[" prefix and "]" suffix get the same style +spans.push(Span::styled("[".to_string(), link_style)); +// ... text spans inside get link_style too ... +spans.push(Span::styled("]".to_string(), link_style)); +``` + +### Breadcrumb Status Bar +```rust +// Source: existing draw_status_bar pattern in app.rs +fn build_breadcrumb(vault_relative: &str) -> String { + use std::path::Path; + Path::new(vault_relative) + .components() + .map(|c| { + let s = c.as_os_str().to_string_lossy(); + s.strip_suffix(".md").unwrap_or(&s).to_string() + }) + .collect::>() + .join(" > ") +} + +// Status bar left side becomes breadcrumb: +let left = format!(" {} ", build_breadcrumb(¤t_path)); + +// Back/forward indicators (per locked decision): +let back_indicator = if history_index > 0 { "< Back " } else { " " }; +let fwd_indicator = if history_index < history.len() - 1 { " Forward >" } else { " " }; + +// Link index display (when link selected): +let link_info = selected_link + .map(|i| format!(" Link {}/{}", i + 1, link_records.len())) + .unwrap_or_default(); +``` + +### Path Traversal Guard +```rust +// Source: standard Rust security pattern (confirmed via stackhawk.com article) +fn is_within_vault(vault_path: &Path, candidate: &Path) -> bool { + match (candidate.canonicalize(), vault_path.canonicalize()) { + (Ok(canon_candidate), Ok(canon_vault)) => canon_candidate.starts_with(&canon_vault), + _ => false, + } +} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Regex pre-pass for wiki-links | `Options::ENABLE_WIKILINKS` in pulldown-cmark | Added in pulldown-cmark 0.13.0 (Feb 2026) | No custom tokenizer needed; parser handles all edge cases | +| Manual link extraction from HTML output | Event-based link extraction during parse | N/A — always event-based here | Link positions available during render, not post-process | +| Separate crate for navigation history | Plain `Vec` with index | N/A — standard pattern | No deps, full control | + +**Deprecated/outdated:** +- `pulldown-cmark-wikilink` fork crate: Existed as a third-party fork before `ENABLE_WIKILINKS` landed in upstream 0.13.0. No longer needed — use upstream. + +--- + +## Open Questions + +1. **TextMergeStream + ENABLE_WIKILINKS interaction** + - What we know: TextMergeStream merges consecutive Text events; ENABLE_WIKILINKS emits Link events wrapping Text events + - What's unclear: Whether TextMergeStream causes any unexpected merging of wiki-link text with surrounding text + - Recommendation: Verify with a test document in Task 1 of the plan before committing to the architecture. If problematic, apply TextMergeStream only to non-link passes (unlikely to be needed). + +2. **Broken wiki-link inline rendering (red/strikethrough)** + - What we know: Broken wiki-links should show inline as red/strikethrough text (per locked decision) + - What's unclear: Whether to check link validity at render time (filesystem check) or at navigation time only + - Recommendation: Check validity at render time for wiki-links (resolve_wiki_link returns None → render as broken). For standard markdown links, do NOT check at render time (avoid stat() on every link on every render). Broken standard links are revealed only when the user tries to follow them. + +3. **Keybindings for back/forward — recommend:** + - Back: `Backspace` (primary) + `Alt+Left` (secondary) + - Forward: `Alt+Right` (no primary equivalent — Backspace has no natural forward) + - Rationale: Backspace as back matches vim/w3m/lynx conventions for TUI browsers; Alt+Left/Right matches browser conventions. Both are ergonomic in terminal environments. + +4. **Link color — recommend:** + - Unselected links: `Color::LightCyan` with brackets in same color + - Selected links: same color + `Modifier::REVERSED` + - Broken wiki-links: `Color::Red` + `Modifier::CROSSED_OUT` (matches the CROSSED_OUT modifier already present in renderer.rs for strikethrough) + - This fits the existing CGA palette established in Phase 2. + +5. **Ambiguous wiki-link match priority** + - What we know: Multiple files could match a wiki-link if the vault has duplicates + - Recommendation: First match wins (take the first result from `read_dir` iteration). `read_dir` returns entries in filesystem order, which is stable within a session but not guaranteed. Document this limitation — do not over-engineer. + +--- + +## Sources + +### Primary (HIGH confidence) +- `docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.Tag.html` — `Tag::Link` field definitions +- `docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.LinkType.html` — `LinkType::WikiLink { has_pothole }` confirmed +- `docs.rs/pulldown-cmark/latest/pulldown_cmark/struct.Options.html` — `ENABLE_WIKILINKS` flag confirmed +- `pulldown-cmark.github.io/pulldown-cmark/specs/wikilinks.html` — dest_url semantics for `[[foo]]` vs `[[foo|bar]]` +- `docs.rs/crossterm/latest/crossterm/event/enum.KeyCode.html` — Tab, BackTab, Backspace, Enter, Left, Right variants confirmed +- `docs.rs/crossterm/latest/crossterm/event/struct.KeyEvent.html` — `modifiers.contains(KeyModifiers::ALT)` pattern +- `docs.rs/ratatui/latest/ratatui/widgets/struct.Paragraph.html` — scroll((y, x)) API +- Codebase: `/Users/ruohki/shared/bbs-md/src/renderer.rs` — existing render pipeline (RenderState, flush_line, handle_event) +- Codebase: `/Users/ruohki/shared/bbs-md/src/app.rs` — existing App struct, draw_status_bar, Modifier::REVERSED pattern +- Codebase: `/Users/ruohki/shared/bbs-md/Cargo.toml` — locked versions (pulldown-cmark 0.13.1, ratatui 0.30.0) + +### Secondary (MEDIUM confidence) +- `github.com/zoni/obsidian-export/discussions/58` — vault wikilink resolution strategy (four-strategy matching: exact, lowercase, with-.md, lowercase-with-.md) — verified against codebase patterns +- `ratatui.rs/highlights/v030/` — ratatui 0.30.0 changes (modularization, WidgetRef blanket impl) +- `stackhawk.com/blog/rust-path-traversal-guide-example-and-prevention/` — canonicalize + starts_with pattern for path traversal guard + +### Tertiary (LOW confidence) +- TextMergeStream + ENABLE_WIKILINKS compatibility: No official documentation found confirming behavior. Flag as Open Question. + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all crates locked, APIs verified via official docs +- Architecture: HIGH — patterns follow directly from existing codebase + verified APIs +- Wiki-link resolution: HIGH — algorithm confirmed by obsidian-export precedent + std::fs docs +- Pitfalls: MEDIUM — most pitfalls are code-review-level reasoning; TextMergeStream interaction is LOW +- Keybinding recommendations: MEDIUM — conventional choices, no terminal-specific verification done + +**Research date:** 2026-02-28 +**Valid until:** 2026-03-28 (stable libraries — pulldown-cmark, ratatui, crossterm APIs are stable)