Files
bbs-md/.planning/phases/03-navigation-and-links/03-01-PLAN.md
T
2026-02-28 22:54:12 +01:00

11 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-navigation-and-links 01 execute 1
src/renderer.rs
src/vault.rs
true
NAV-01
NAV-02
NAV-10
truths artifacts key_links
render_markdown returns both styled lines and a parallel Vec<LinkRecord> for every link in the document
Wiki-links (Page Name) are parsed via pulldown-cmark ENABLE_WIKILINKS and rendered as bracket-wrapped LightCyan text
Standard markdown links ([text](path.md)) are rendered as bracket-wrapped LightCyan text
Broken wiki-links (dest does not resolve in vault) are rendered as red text with CROSSED_OUT modifier
resolve_wiki_link() maps raw wiki targets to vault-relative file paths with case-insensitive matching
Path traversal attacks via ../.. are blocked by canonicalize + starts_with guard
path provides contains
src/renderer.rs LinkRecord struct, updated render_markdown returning (Vec<Line>, Vec<LinkRecord>) pub struct LinkRecord
path provides contains
src/vault.rs Wiki-link resolution and path traversal guard pub fn resolve_wiki_link
from to via pattern
src/renderer.rs pulldown-cmark ENABLE_WIKILINKS Options::ENABLE_WIKILINKS inserted in render_markdown ENABLE_WIKILINKS
from to via pattern
src/renderer.rs src/vault.rs LinkRecord.is_wiki flag consumed by app.rs to decide whether to call resolve_wiki_link is_wiki
from to via pattern
src/vault.rs std::fs::read_dir resolve_wiki_link scans vault directory for case-insensitive matches read_dir
Extend the renderer to extract link metadata and render links with BBS-style bracket-wrapped styling, and add wiki-link resolution to the vault module.

Purpose: The renderer currently ignores links (Phase 2 pass-through). This plan makes links visible and extractable so Plan 02 can wire navigation. Wiki-link resolution is the vault-side counterpart that maps raw targets to actual files.

Output:

  • render_markdown() returns (Vec<Line<'static>>, Vec<LinkRecord>) instead of Vec<Line<'static>>
  • LinkRecord struct with line_index, col_offset, span_len, dest, is_wiki
  • All links rendered as [Link Text] in LightCyan; broken wiki-links in Red+CROSSED_OUT
  • resolve_wiki_link() in vault.rs with path traversal guard

<execution_context> @/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md @/Users/ruohki/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-navigation-and-links/03-RESEARCH.md @src/renderer.rs @src/vault.rs Task 1: Extend renderer with LinkRecord extraction and link styling src/renderer.rs Modify `src/renderer.rs` to produce link metadata alongside styled lines:
  1. Add LinkRecord struct (public, at top of file):
pub struct LinkRecord {
    pub line_index: usize,   // Index into Vec<Line> where this link appears
    pub col_offset: usize,   // Character column offset within that line
    pub span_len: usize,     // Display length of the link text (including brackets)
    pub dest: String,         // Raw destination (path for inline, wiki target for wiki-links)
    pub is_wiki: bool,        // True if WikiLink type (needs resolution at nav time)
}
  1. Add PendingLink to RenderState:
  • Add fields: pending_link: Option<PendingLink> and pending_link_records: Vec<PendingLinkRecord> and link_records: Vec<LinkRecord>
  • PendingLink holds dest: String, is_wiki: bool, col_offset: usize (computed from sum of current_spans character lengths at Tag::Link Start)
  • PendingLinkRecord holds dest, is_wiki, col_offset (finalized at TagEnd::Link, awaiting line_index from flush)
  1. Enable ENABLE_WIKILINKS in render_markdown():
  • Add opts.insert(Options::ENABLE_WIKILINKS); to the options block
  • IMPORTANT: Test TextMergeStream interaction — if wiki-link text is garbled, wrap only the parser without TextMergeStream for wiki-link detection and apply TextMergeStream separately for code blocks. Research indicates this is LOW risk but must be verified.
  1. Handle Tag::Link Start event (replace the current pass-through):
Event::Start(Tag::Link { link_type, dest_url, .. }) => {
    let is_wiki = matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. });
    let col_offset: usize = state.current_spans.iter()
        .map(|s| s.content.chars().count())
        .sum();
    state.pending_link = Some(PendingLink {
        dest: dest_url.to_string(),
        is_wiki,
        col_offset,
    });
    // Determine link color: broken wiki-links get Red+CROSSED_OUT
    // For wiki-links, check at render time whether the link resolves.
    // Pass vault_path into render_markdown for this check.
    // Push opening bracket "[" in appropriate color
}

For broken wiki-link detection at render time: render_markdown needs access to vault_path to call resolve_wiki_link for wiki-links. Add vault_path: Option<&Path> parameter. When is_wiki and vault_path is Some, call crate::vault::resolve_wiki_link(vault_path, &dest_url) — if None, use red/strikethrough style; if Some, use LightCyan style. For non-wiki links, always use LightCyan (broken standard links are revealed only at navigation time per research recommendation).

The link style determines the color of brackets and text:

  • Normal link: Style::default().fg(Color::LightCyan)
  • Broken wiki-link: Style::default().fg(Color::Red).add_modifier(Modifier::CROSSED_OUT)

Push opening bracket span: state.current_spans.push(Span::styled("[", link_style)) Set a flag so Text events inside the link use the link style instead of current_style().

  1. Handle TagEnd::Link:
  • Push closing bracket span "]" with same link style
  • Compute span_len = chars count of all spans pushed between Start and End (including brackets)
  • Create a PendingLinkRecord { dest, is_wiki, col_offset, span_len } and push to state.pending_link_records
  • Clear state.pending_link
  1. Update flush_line() to finalize link records: After pushing the line to self.lines, iterate self.pending_link_records, drain them, and for each create a LinkRecord with line_index = self.lines.len() - 1 (the just-pushed line). Push to self.link_records.

  2. Update finish() to return the pair: Change return type to (Vec<Line<'static>>, Vec<LinkRecord>). Return (self.lines, self.link_records).

  3. Update render_markdown() signature:

pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>)

Note: Using chars().count() for col_offset instead of len() — handles multi-byte Unicode correctly per research Pitfall 4. cargo build compiles with zero new errors. The function signature change will cause errors in app.rs and main.rs which are expected — Plan 02 will fix those callers. To verify in isolation, temporarily adjust the call sites in app.rs handle_resize and main.rs to destructure the tuple (e.g., let (lines, _links) = renderer::render_markdown(...)) so cargo build passes. If this is done, add a // TODO(03-02): use link_records comment. render_markdown returns (Vec<Line>, Vec<LinkRecord>). Wiki-links parsed via ENABLE_WIKILINKS. Links display as bracket-wrapped colored text. Broken wiki-links show red/strikethrough. LinkRecord contains line_index, col_offset, span_len, dest, is_wiki for every link in the document.

Task 2: Add wiki-link resolution and path traversal guard to vault.rs src/vault.rs Add two public functions to `src/vault.rs`:
  1. is_within_vault(vault_path: &Path, candidate: &Path) -> bool:
pub 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,
    }
}
  1. resolve_wiki_link(vault_path: &Path, raw_target: &str) -> Option<PathBuf>:

Algorithm (per locked decisions — case-insensitive, hyphens first, then underscores, then literal):

  • Split raw_target on last / to get (subdir, name). If no /, subdir is empty.
  • Generate candidates: name.replace(' ', "-"), name.replace(' ', "_"), name.to_string() (literal spaces)
  • Compute search_dir: vault_path.join(subdir) if subdir is non-empty, otherwise vault_path
  • Call std::fs::read_dir(&search_dir) — return None on error
  • For each entry, get filename, strip .md suffix, lowercase compare against each candidate (lowercased)
  • On first match: construct full path, call is_within_vault() to guard against path traversal
  • If within vault: return Some(canonical.strip_prefix(vault_canonical).to_path_buf()) — vault-relative path
  • If no match found: return None

Per locked decision: first match wins (filesystem order). This is documented as intentional in the research.

  1. resolve_standard_link(vault_path: &Path, current_doc: &str, dest: &str) -> Option<PathBuf>:

For standard markdown links [text](path.md):

  • Compute base_dir from current_doc: vault_path.join(current_doc).parent() (directory of current document)
  • Join: base_dir.join(dest)
  • Apply is_within_vault() guard
  • If file exists and within vault: return vault-relative path
  • Otherwise: return None

This function is needed for Plan 02 when following standard links. Adding it here keeps all vault path resolution in one place. cargo build compiles. Add a small test at the bottom of vault.rs (or verify mentally): resolve_wiki_link with a known vault directory returns the expected path; is_within_vault rejects ../../etc/passwd. resolve_wiki_link() resolves raw wiki targets to vault-relative paths with case-insensitive multi-strategy matching. resolve_standard_link() resolves relative markdown link paths. Both are guarded by is_within_vault() which uses canonicalize + starts_with to prevent path traversal.

1. `cargo build` succeeds (caller sites temporarily updated to handle new return type) 2. render_markdown with a document containing `Test Link` and `[Click](page.md)` produces LinkRecords with correct is_wiki, dest, line_index, col_offset, span_len 3. Links render as bracket-wrapped colored text: `[Test Link]` in LightCyan, broken `Missing` in Red+CROSSED_OUT 4. resolve_wiki_link with "Getting Started" matches "getting-started.md" (case-insensitive, hyphen strategy) 5. resolve_wiki_link with "guides/Getting Started" resolves subpath correctly 6. is_within_vault rejects candidates outside the vault root

<success_criteria> The renderer produces a parallel link metadata structure alongside styled lines. Wiki-links and standard links are visually distinct from plain text. The vault module can resolve wiki-link targets to file paths safely. Plan 02 can consume these outputs to wire navigation. </success_criteria>

After completion, create `.planning/phases/03-navigation-and-links/03-01-SUMMARY.md`