diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e1d00d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt, clippy + + # The GUI crate links tauri, which needs webkit at compile time. + - name: Install Linux deps + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Cache cargo + uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src-tauri + + - name: Install JS deps + run: bun install + + - name: Typecheck frontend + run: bunx tsc --noEmit + + - name: Frontend tests + run: bun run test + + - name: Frontend build + run: bun run build + + - name: Rust format + working-directory: src-tauri + run: cargo fmt --check + + - name: Rust lints + working-directory: src-tauri + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Rust tests (unit + MCP stdio integration) + working-directory: src-tauri + run: cargo test --workspace + + - name: MCP binary stays tauri-free + working-directory: src-tauri + run: | + COUNT=$(cargo tree -p docsreader-mcp | grep -c " tauri v" || true) + test "$COUNT" -eq 0 || { echo "::error::tauri leaked into docsreader-mcp"; exit 1; } diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index e69b4c4..5b4bb6a 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -46,8 +46,13 @@ jobs: # Cargo.toml: only the [package] version, not deps sed -i '0,/^version = "[^"]*"/s//version = "'"$VERSION"'"/' src-tauri/Cargo.toml - # Cargo.lock: docsreader package entry + # docsreader-mcp reports its version in the MCP handshake; keep it + # in lockstep with the app. + sed -i '0,/^version = "[^"]*"/s//version = "'"$VERSION"'"/' src-tauri/mcp/Cargo.toml + + # Cargo.lock: docsreader + docsreader-mcp package entries sed -i '/^name = "docsreader"$/{n;s/^version = "[^"]*"/version = "'"$VERSION"'"/}' src-tauri/Cargo.lock + sed -i '/^name = "docsreader-mcp"$/{n;s/^version = "[^"]*"/version = "'"$VERSION"'"/}' src-tauri/Cargo.lock # README.md: download URLs embed the version in asset filenames. # Scoped to /releases/latest/download/ paths so we don't accidentally @@ -57,7 +62,9 @@ jobs: echo "After:" grep '"version"' src-tauri/tauri.conf.json | head -1 grep '^version' src-tauri/Cargo.toml | head -1 + grep '^version' src-tauri/mcp/Cargo.toml | head -1 grep -A1 '^name = "docsreader"$' src-tauri/Cargo.lock | head -2 + grep -A1 '^name = "docsreader-mcp"$' src-tauri/Cargo.lock | head -2 grep -E 'DocsReader_[0-9]' README.md | head -2 || true - name: Commit, tag, push @@ -67,7 +74,7 @@ jobs: set -e git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src-tauri/tauri.conf.json src-tauri/Cargo.toml src-tauri/Cargo.lock README.md + git add src-tauri/tauri.conf.json src-tauri/Cargo.toml src-tauri/mcp/Cargo.toml src-tauri/Cargo.lock README.md git commit -m "chore(release): v$VERSION" git tag "v$VERSION" git push origin HEAD:main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fe82b4..0a56041 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,9 +31,13 @@ jobs: steps: - id: pick run: | - MAC='{"platform":"macos-latest","args":"--target universal-apple-darwin --skip-stapling"}' - LIN='{"platform":"ubuntu-22.04","args":""}' - WIN='{"platform":"windows-latest","args":""}' + # The sidecar overlay adds bundle.externalBin (docsreader-mcp) and a + # beforeBuildCommand that builds it; the base config stays sidecar-free + # so plain `cargo build`/`tauri dev` never require the staged binary. + SIDECAR='--config src-tauri/tauri.sidecar.conf.json' + MAC="{\"platform\":\"macos-latest\",\"args\":\"--target universal-apple-darwin --skip-stapling $SIDECAR\"}" + LIN="{\"platform\":\"ubuntu-22.04\",\"args\":\"$SIDECAR\"}" + WIN="{\"platform\":\"windows-latest\",\"args\":\"$SIDECAR\"}" if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then TAG="${{ inputs.tag }}" case "${{ inputs.platforms }}" in @@ -136,6 +140,28 @@ jobs: prerelease: false args: ${{ matrix.args }} + # The bundler copies externalBin into the app bundle and signs it with + # the app (tauri-bundler sign_paths), so DMG notarization covers the + # sidecar too. These asserts fail the release if the sidecar is absent. + - name: Verify sidecar in bundle (macOS) + if: matrix.platform == 'macos-latest' + run: | + set -euo pipefail + APP=$(find src-tauri/target/universal-apple-darwin/release/bundle/macos -name '*.app' -maxdepth 1 | head -1) + SIDECAR="$APP/Contents/MacOS/docsreader-mcp" + test -f "$SIDECAR" || { echo "::error::sidecar missing from $APP"; exit 1; } + lipo -archs "$SIDECAR" | grep -q "x86_64 arm64" || { echo "::error::sidecar is not universal"; exit 1; } + codesign --verify --strict "$SIDECAR" + echo "sidecar present, universal, and signed" + + - name: Verify sidecar in bundle (Linux) + if: matrix.platform == 'ubuntu-22.04' + run: | + set -euo pipefail + DEB=$(find src-tauri/target/release/bundle/deb -name '*.deb' | head -1) + dpkg-deb -c "$DEB" | grep -q 'usr/bin/docsreader-mcp' || { echo "::error::sidecar missing from $DEB"; exit 1; } + echo "sidecar present in deb" + # macOS-only: submit the DMG to Apple async (no --wait), then stash # the submission id in the release body alongside a user-facing # warning. notarize-staple.yml polls Apple, staples the DMG when diff --git a/.github/workflows/update-homebrew-tap.yml b/.github/workflows/update-homebrew-tap.yml index 62d5155..5ebd037 100644 --- a/.github/workflows/update-homebrew-tap.yml +++ b/.github/workflows/update-homebrew-tap.yml @@ -79,6 +79,7 @@ jobs: homepage "https://github.com/anbturki/docsreader" app "DocsReader.app" + binary "#{appdir}/DocsReader.app/Contents/MacOS/docsreader-mcp" zap trash: [ "~/Library/Application Support/com.docsreader.app", diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..0a9668e --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,12 @@ +{ + "config": { + // Prose lines are not wrapped in this repo. + "MD013": false, + // The README uses inline HTML for the logo and download badges. + "MD033": false, + "MD041": false, + // Bold group labels inside Features are a deliberate style choice. + "MD036": false + }, + "globs": ["README.md", "docs/*.md", "ROADMAP.md", "SECURITY.md"] +} diff --git a/README.md b/README.md index 10f19a4..4d8d674 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # DocsReader -Point it at any folder. DocsReader scans the directory for Markdown files, organizes them into a navigable tree, and renders each one in a clean reader. macOS, Linux, Windows. +The human window into an agent-managed markdown corpus. Your AI agents write docs, memory, and tasks through the bundled MCP server; you read the same plain markdown files in a clean native app. And it still works as a fast reader for any folder of markdown - macOS, Linux, Windows. [![Latest Release](https://img.shields.io/github/v/release/anbturki/docsreader?label=latest&color=7c3aed)](https://github.com/anbturki/docsreader/releases/latest) [![Downloads](https://img.shields.io/github/downloads/anbturki/docsreader/total?color=7c3aed)](https://github.com/anbturki/docsreader/releases) @@ -19,9 +19,63 @@ Or install via [Homebrew or one-liner](#install). ![DocsReader](docs/screenshots/main.png) +## How it works + +Everything is plain markdown on disk - no database, no lock-in, greppable, and versionable with git. Agents are the primary writers; DocsReader is where you watch the corpus grow, and open docs reload live as agents write. + +A **workspace** is a folder of markdown. Your default one lives at `~/notes` (created on first write); any project can opt in to its own `/notes`. Three namespaces live inside: + +- **Docs** hold the substance: research, plans, decisions. Each doc sits in the folder matching its lifecycle status - `research/`, `in-progress/`, `done/`, `archived/` - so the folder IS the status, and moving the file IS the status change. Optional phase subfolders (`research/v2-launch/`) group work inside a status. +- **Memory** (`memory/`) holds short topic-addressed facts ("user prefers tabs", "we deploy to staging"). One entry per topic; writing a topic again replaces it wholesale. +- **Tasks** (`tasks/`) are [Backlog.md](https://github.com/MrLesk/Backlog.md)-shaped files: `task-N` ids, status in frontmatter, an acceptance-criteria checklist agents tick as they go. + +The MCP server exposes tools for all of it (`write_doc`, `search_docs`, `set_status`, `write_memory`, `write_task`, ...), a `docsreader://onboarding` resource that teaches agents the model, and every doc as a readable MCP resource. Tool errors carry recovery hints, so agents self-correct instead of stalling. + +## Connect your AI agents + +In DocsReader: **Settings → AI agents → Connect**. The app detects installed clients (Claude Code, Cursor, Windsurf, VS Code, Codex) and registers the bundled `docsreader-mcp` server with each - one click, user-wide. + +To register manually, point any stdio-capable MCP client at the binary: + +| Install | Binary location | +| --- | --- | +| Homebrew (macOS) | `docsreader-mcp` (on PATH) | +| DMG (macOS) | `/Applications/DocsReader.app/Contents/MacOS/docsreader-mcp` | +| deb (Linux) | `/usr/bin/docsreader-mcp` (on PATH) | +| Windows | `docsreader-mcp.exe` next to `DocsReader.exe` in the install folder | + +`docsreader-mcp` is a local stdio server - there is no URL to add; each client spawns the binary itself. With Claude Code: + +```sh +claude mcp add --scope user docsreader -- docsreader-mcp +``` + +Codex CLI: + +```sh +codex mcp add docsreader -- docsreader-mcp +``` + +VS Code: + +```sh +code --add-mcp '{"name":"docsreader","command":"docsreader-mcp"}' +``` + +Cursor (`~/.cursor/mcp.json`) and Windsurf (`~/.codeium/windsurf/mcp_config.json`): + +```json +{ "mcpServers": { "docsreader": { "command": "docsreader-mcp" } } } +``` + +If `docsreader-mcp` is not on your PATH (macOS DMG install without Homebrew), use the full binary path from the table above instead. + +Then tell your agents how to use it: copy the [AGENTS template](docs/AGENTS-TEMPLATE.md) into your repo's `AGENTS.md` or `CLAUDE.md`. + ## Features **Reading** + - **Rendering:** GitHub-flavored Markdown via remark-gfm (tables, task lists, footnotes, autolinks, strikethrough) - **Math expressions:** LaTeX rendered inline and in blocks via KaTeX - **Diagrams:** Mermaid renderer (lazy-loaded, follows theme) @@ -29,34 +83,38 @@ Or install via [Homebrew or one-liner](#install). - **Code blocks:** 20 bundled language grammars via Shiki, twelve highlighter palettes (5 light, 7 dark) - **Appearance:** light, dark, or follow-system, with six accent hues - **Type controls:** font family, body size, and reading column width +- **Quick edit:** a pencil on any open doc flips to the raw markdown for fast human fixes; agents stay the primary writers **Browsing** + - **Workspaces:** keep multiple unrelated folders open and pivot between them - **Lenses:** four browsing modes over the same library (Tree, Recent, Tags, Pinned) - **Jump-to-file:** fuzzy finder across every workspace, opens with Cmd+P (binding configurable) - **Document outline:** auto-built TOC that follows the active heading as you scroll +- **Backlinks:** the sidebar lists every doc that links to the one you are reading, grouped by folder - **Tabs:** many docs open at once; scroll position remembered per tab - **Split view:** read two docs side-by-side or stacked; each pane keeps its own tabs, scroll, and external-change banner. Toggle from the header, drag the splitter to resize, or use Cmd+\ (horizontal), Cmd+Shift+\ (vertical), Cmd+1 / Cmd+2 to focus a pane. "Open in other pane" lives in the file context menu. - **Search:** filename, path, frontmatter title, or tag - **Sticky favorites:** pin individual files to the top of any workspace - **Clutter rules:** glob patterns silently exclude files and folders from the explorer -- **External changes surfaced:** when a file you have open changes on disk (other editor, sync service, AI agent), a banner shows what changed with reload / keep / show-diff actions; configurable to silent reload - -**Project-aware** -DocsReader stays "point it at any folder" by default. When a workspace happens to ship a `.docs.yaml` or live inside a git repo, extra signals light up automatically. +**Agent-aware** -- **`.docs.yaml` manifests:** projects shipping a v0.1 manifest get curated navigation (hand-curated `items` and auto-listed `folder` sections with sort, title-from, badges, and nesting), project metadata in the workspace switcher, automatic homepage open on first add, cross-project links between open workspaces, ignore patterns, a visibility toggle for previewing public-only views, and a sidebar pane that surfaces manifest issues +- **Managed workspaces:** a folder with a `.docsreader.yaml` marker gets its display name in the switcher, its homepage opened on first add, and agent writes reloading open docs silently +- **Convert prompt:** opening a plain folder offers to make it a managed workspace; declining keeps it read-only forever +- **External changes surfaced:** in unmanaged folders, when a file you have open changes on disk (other editor, sync service, AI agent), a banner shows what changed with reload / keep / show-diff actions - **Git status decorations:** in a git repo, the file tree shows per-file status badges (M / A / D / R / ? / U) that refresh as files change - **Git diff vs HEAD:** right-click any tracked file to see the diff between HEAD and your working tree, with unified or side-by-side views and word-level highlighting **Quiet by default** + - **Minimal chrome:** flat active states, no badges, only user-initiated motion (ADHD-friendly, low visual load) - **One thing at a time:** lenses replace the tree instead of stacking on top of it - **Same place, every launch:** controls do not migrate around the window - **Resumes where you left off:** tabs, scroll, sidebar, and active lens persist across sessions **Trust** + - **Stays local:** no telemetry, no sync; the only outbound request is the updater check - **Signed updates:** every release artifact ships with a minisign signature; the updater verifies before applying - **Gatekeeper-friendly:** code-signed and Apple-notarized on macOS @@ -68,18 +126,18 @@ Found a bug, want a feature, or have feedback? [Open an issue](https://github.co See [ROADMAP.md](./ROADMAP.md) for the full picture. Short version: -- **Next:** find-in-page, full-text search, focus mode, recognition for markdown task formats (Backlog.md, taskmd, generic frontmatter). -- **Later:** PDF export, kanban view over recognized task files, drag-a-folder-to-add-root, file management. +- **Next:** find-in-page, full-text search, focus mode. +- **Later:** PDF export, kanban view over task files, drag-a-folder-to-add-root, file management. - **Considering:** plugin API, annotations, drag tabs between panes / N-pane nesting, local "smart" features (related-docs, TL;DR). ## Screenshots | | | -|---|---| -| ![Light theme](docs/screenshots/light-theme.png) | ![Split view, side-by-side](docs/screenshots/split.png) | -| ![Split view, stacked (dark)](docs/screenshots/horizontal-split.png) | ![Split view, dark](docs/screenshots/split-dark.png) | -| ![Settings](docs/screenshots/settings.png) | ![Search](docs/screenshots/search.png) | -| ![Context menu](docs/screenshots/context-menu.png) | ![Quick Open (Cmd+P)](docs/screenshots/quick-open.png) | +| --- | --- | +| ![Light theme](docs/screenshots/light-theme.png) | ![Split view, dark](docs/screenshots/split-dark.png) | +| ![Split view, stacked (dark)](docs/screenshots/horizontal-split.png) | ![Settings](docs/screenshots/settings.png) | +| ![Search](docs/screenshots/search.png) | ![Context menu](docs/screenshots/context-menu.png) | +| ![Quick Open (Cmd+P)](docs/screenshots/quick-open.png) | ![Images](docs/screenshots/images.png) | ## Install @@ -100,9 +158,9 @@ curl -fsSL https://raw.githubusercontent.com/anbturki/docsreader/main/install.sh From [Releases](https://github.com/anbturki/docsreader/releases/latest): | OS | File | -|---|---| +| --- | --- | | macOS (Intel + Apple Silicon) | `DocsReader_*_universal.dmg` | -| Linux | `docsreader_*_amd64.AppImage` or `.deb` | +| Linux | `DocsReader_*_amd64.AppImage` or `.deb` | | Windows | `DocsReader_*_x64-setup.exe` | ## Security @@ -120,6 +178,12 @@ bun install bun run tauri dev ``` +The MCP server is a separate tauri-free binary in the same cargo workspace: + +```sh +cargo build --manifest-path src-tauri/Cargo.toml -p docsreader-mcp +``` + ### Releasing GitHub → Actions → **Cut Release** → enter the version (e.g. `0.1.2`). The workflow bumps `tauri.conf.json` + `Cargo.toml` + `Cargo.lock`, commits, tags, and triggers the release pipeline (signs, notarizes the macOS bundle, drafts a GitHub Release, updates the Homebrew tap). diff --git a/ROADMAP.md b/ROADMAP.md index cc13ce6..d11c79b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,21 +8,24 @@ Layout uses **Now / Next / Later / Considering** - no fixed dates, no quarter co In active development. -_(picking from "Next" - v0.4 candidate features merged to main; see "Unreleased" below)_ +_(v0.6 - the MCP foundation - is merged to main and queued for release; see "Unreleased" below)_ ## Next Committed and scoped, not yet started. ### Reading experience + - **Find in page** (Cmd+F) - search within the open document, jump between matches with Enter / Shift+Enter. - **Full-text search across docs** - go beyond filename matching. Index document bodies on scan; rank with BM25. - **Focus / reading mode** - hide sidebar, max width, distraction-free. ### Markdown task formats -A universal reader for the emerging "markdown-as-PM" conventions used by AI coding agents. Parser registry under the hood; ships with built-in parsers for the most common conventions, with room to add more later. + +A universal reader for the emerging "markdown-as-PM" conventions used by AI coding agents. Parser registry under the hood; ships with built-in parsers for the most common conventions, with room to add more later. (v0.6 already ships Backlog.md-shaped tasks in managed workspaces via the MCP server; this item is the generic read-only recognition for arbitrary folders.) Built-in parsers in v1: + - **Generic frontmatter** - any file with `status:` + checkbox progress. - **[Backlog.md](https://github.com/MrLesk/Backlog.md)** - markdown-native task manager for Claude Code, Codex, Gemini CLI, etc. - **[taskmd](https://medium.com/@driangle/taskmd-task-management-for-the-ai-era-92d8b476e24e)** - local-first markdown tasks for AI coding agents. @@ -35,18 +38,21 @@ Built-in parsers in v1: Committed direction, not scoped yet. ### Reading + - Print / Export to PDF - Image lightbox with zoom - Recently-viewed list (per session and persistent) - Open-in-external-editor button (VS Code, Cursor, Vim) ### Tasks view + - Kanban-style sidebar mode grouping recognized task files by normalized status. - Tag / owner / priority filters. - Click a card → open the file as a tab. - Live updates as files change (file watcher already in place). ### File management + - Drag a folder into the window to add it as a root. - Rename / delete / create files from the sidebar context menu. @@ -58,14 +64,26 @@ Direction unclear, community input wanted before committing. - **Annotations** - highlights and notes that travel with the document. Would shift DocsReader from a reader to a research tool, meaningful identity change. - **Drag tabs between panes / N-pane nesting** - extend the v0.5 split view to support dragging a tab from one pane to the other, plus arbitrary nesting beyond two panes. Deferred until the 2-pane MVP has real usage feedback. - **Local "smart" features** - related-docs ("you may also want…") via TF-IDF, extractive TL;DR via TextRank. AI-feeling, no AI service. -- **Drag-to-update task status** - requires DocsReader to write user files (currently read-only). Trust shift. +- **Drag-to-update task status** - builds on the write posture v0.6 introduced with quick-edit; needs the kanban view first. ## Recently shipped -### Unreleased (in main since v0.4.0) +### Unreleased (v0.6 candidate, in main) + +- **MCP server (`docsreader-mcp`)** - a bundled stdio MCP server AI agents write through: docs with a status-as-folder lifecycle (research / in-progress / done / archived, optional phase subfolders), topic-addressed memory, and Backlog.md-shaped tasks. Tools for the full lifecycle, every doc exposed as a resource, a self-describing onboarding resource, `start-task` / `record-decision` prompts, and recovery-bearing tool errors. +- **Connect to AI agents** - Settings pane that detects installed MCP clients (Claude Code, Cursor, Windsurf, VS Code, Codex) and registers the server with each in one click; non-destructive config merges. +- **Managed workspaces** - `.docsreader.yaml` marker, convert-to-workspace prompt for plain folders, display names in the switcher, homepage auto-open, and silent live reload while agents write. This replaces the v0.4 `.docs.yaml` curated-navigation manifest system: existing manifests auto-migrate to the new marker on first scan, and curated nav sections, the internal-visibility toggle, and the manifest-issues pane are removed. +- **Quick edit** - pencil toggle on the open doc for fast human fixes; raw markdown round-trips frontmatter untouched. +- **Backlinks pane** - incoming links to the open doc, grouped by source folder, collected during the scan. +- **Agent workspace pickers** - project auto-detection via `CLAUDE_PROJECT_DIR` + walk-up, and an elicitation-based workspace picker for interactive clients when a slug is unknown. +- **Polish + fixes** - mermaid/svgbob diagrams render again in production builds (regression-tested), a denser sidebar, a refreshed agent-first welcome tour, and per-client MCP setup commands in the README. + +### v0.5.0 + - **Split view** - side-by-side or stacked panes for reading two docs at once. Toggle from the header (single / horizontal / vertical), drag the splitter to resize, "Open in other pane" context-menu entry. Each pane keeps its own tabs, scroll, and external-change banner; the outline tracks whichever pane is focused. Keyboard shortcuts: `Cmd+\` toggles horizontal, `Cmd+Shift+\` toggles vertical, `Cmd+1` / `Cmd+2` focus pane 0 / pane 1. Pane 1's tabs persist across single/split toggles so re-splitting brings them back exactly as they were. ### v0.4.0 + - **`.docs.yaml` v0.1 manifest support** - projects shipping a manifest get curated navigation (hand-curated `items` and auto-listed `folder` sections with sort, title-from, badges, nesting), project metadata in the workspace switcher, automatic homepage open on first add, cross-project links between open workspaces, ignore patterns, a visibility toggle for previewing public-only views, and a sidebar pane that surfaces manifest issues. - **Git integration (T1+T2)** - per-file status badges in the file tree (M / A / D / R / ? / U) for workspaces inside a git repo; "Show git diff" context menu opens a diff vs HEAD with unified or side-by-side view and word-level highlighting. Git binary auto-discovered across PATH plus common Homebrew locations. - **External-change banner** - when a file open in a tab changes on disk, a banner shows what changed with reload / show-diff / dismiss / always-auto-reload actions; same diff dialog as the git diff feature. @@ -73,12 +91,14 @@ Direction unclear, community input wanted before committing. - **Manifest in-place edit detection** - editing `.docs.yaml` triggers a rescan even though the file set is unchanged. ### v0.3.0 + - Workspace-level filesystem watcher: edit / add / remove / rename anywhere in a workspace and the file tree updates without manual refresh. - Rate-limited watcher: events filtered against a skip list (mirrors the scanner's), 600ms debounce, 2-second minimum interval between rescans regardless of churn. - Async-then-staple release pipeline: ship the build immediately, swap in the stapled DMG once Apple's notary returns. CI no longer blocks for hours. - README rewrite around four feature groups (Reading / Browsing / Quiet by default / Trust); right-aligned header logo. ### v0.2.0 + - Quick Open (Cmd+P, configurable shortcut) jumps to any file across all roots. - Outline / TOC sidebar with active-heading scroll-spy. - LaTeX math via KaTeX. Mermaid diagrams (lazy-loaded; theme-aware). @@ -88,6 +108,7 @@ Direction unclear, community input wanted before committing. - README badge tracking pending notarization. ### v0.1.x + - Multi-root file scanning, tabs, file watcher, light/dark themes, accent colors, frontmatter parsing, code-signed + notarized macOS bundles, Homebrew tap, signed auto-updates. --- diff --git a/SECURITY.md b/SECURITY.md index 079c2e4..e6a4cb6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ If you have discovered a security vulnerability in DocsReader, please report it through GitHub's private vulnerability reporting: -https://github.com/anbturki/docsreader/security/advisories/new + Please do **not** open a public issue or pull request that describes the vulnerability before a fix is released. diff --git a/bun.lock b/bun.lock index c15feb3..2e1af0e 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "diff": "^9.0.0", + "dompurify": "^3.4.11", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "js-yaml": "^4.1.1", @@ -49,6 +50,10 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/diff": "^8.0.0", "@types/js-yaml": "^4.0.9", "@types/node": "^25.6.0", @@ -56,18 +61,30 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^29.1.1", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "typescript": "~5.8.3", "unified": "^11.0.5", "vite": "^7.0.4", + "vitest": "^4.1.9", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], @@ -122,6 +139,8 @@ "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -130,6 +149,8 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], @@ -140,6 +161,18 @@ "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.9", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.6", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.64.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-6+xRpZaWuHXEqnhBjae+VmQI9Uaqw5Uzu/ScpO+W7ww9Zp3lHSNBoNjFcUxhrCyc7pRGQzyDjhKzloqrPHERiQ=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], @@ -196,6 +229,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -452,6 +487,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], @@ -522,8 +559,18 @@ "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -532,6 +579,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -596,6 +645,8 @@ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -638,6 +689,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -648,14 +713,18 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -664,6 +733,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bob-wasm": ["bob-wasm@1.0.1", "", { "dependencies": { "decode-base64": "^3.0.1", "function-once": "^3.0.0", "uint8-encoding": "^2.0.0" } }, "sha512-MmuVQCBQJgyRO8qg6Qm+yVq4dv6CtrM3rQNDYPJoco9vTFGDwkQGrmPSACLPRnhCDD3h/ng9ndxfRo4wlXPe0g=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -688,6 +759,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -746,6 +819,10 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -824,10 +901,14 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decode-base64": ["decode-base64@3.0.2", "", { "dependencies": { "node-buffer-encoding": "^1.0.3" } }, "sha512-hgXhqIon8A1gznsTnvsyHFH+n2Jh20VVHnbiSDCBtpoaeCxpzbFJDtkWFVEaGF179d0y67Akdnxz4ipi/7Dcjg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -856,7 +937,9 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -874,7 +957,7 @@ "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -884,6 +967,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], @@ -898,6 +983,8 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -906,6 +993,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="], @@ -1024,6 +1113,8 @@ "hono": ["hono@4.12.16", "", {}, "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], @@ -1040,6 +1131,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1084,6 +1177,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], @@ -1104,6 +1199,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], @@ -1160,10 +1257,12 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -1204,6 +1303,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -1282,6 +1383,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1314,6 +1417,8 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1340,7 +1445,7 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -1372,6 +1477,8 @@ "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -1380,6 +1487,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1394,6 +1503,8 @@ "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], @@ -1408,6 +1519,8 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -1462,6 +1575,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], @@ -1492,6 +1607,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1504,8 +1621,12 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], @@ -1524,12 +1645,16 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], @@ -1540,10 +1665,14 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], "tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="], @@ -1554,6 +1683,8 @@ "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1578,6 +1709,8 @@ "uint8-encoding": ["uint8-encoding@2.0.1", "", {}, "sha512-xZAjZ+3OvrDtjFLLgojrLmG6T0YwZEo0OTyqCBxFjlFimIKnLtFqyYk6z/jDOUlJFJE52Srtrv4W4x7t4Cn/dA=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1628,6 +1761,8 @@ "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], + "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], @@ -1640,18 +1775,32 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1670,6 +1819,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1688,6 +1839,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1708,8 +1861,14 @@ "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "mermaid/dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -1730,6 +1889,10 @@ "shadcn/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1750,8 +1913,6 @@ "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -1760,14 +1921,14 @@ "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/docs/AGENTS-TEMPLATE.md b/docs/AGENTS-TEMPLATE.md new file mode 100644 index 0000000..df238b0 --- /dev/null +++ b/docs/AGENTS-TEMPLATE.md @@ -0,0 +1,36 @@ +# AGENTS template for DocsReader + +Copy the section below into your repo's `AGENTS.md` or `CLAUDE.md` so agents +keep docs, memory, and tasks in the project's DocsReader workspace. Replace +`my-project` with a slug for your repo (the folder name works). It assumes +the `docsreader` MCP server is registered (DocsReader: Settings → AI agents +→ Connect). + +--- + +## Docs, memory, and tasks (DocsReader) + +This project keeps its knowledge in a DocsReader workspace at `./notes` +(workspace slug: `my-project`), served by the `docsreader` MCP server. Use +its tools instead of writing markdown files by hand: they handle slugs, +frontmatter, status folders, and git staging. + +- Pass `workspace: "my-project"` on every docsreader call so nothing lands + in the wrong workspace. If a call fails with `workspace_not_found`, create + the workspace once with `init_workspace {path: "", + slug: "my-project"}` and retry. +- Read the `docsreader://onboarding` resource once per session for the full + model. +- Before writing anything, search: `search_memory {query}` for prior facts, + `search_docs {query}` for prior docs. Supersede instead of duplicating. +- Record durable findings, designs, and decisions as docs: + `write_doc {title, body, status}` with status `research`, `in-progress`, + `done`, or `archived`. Move them later with `set_status` / `archive`. +- Save short facts worth keeping across sessions with + `write_memory {topic, content}`. One entry per topic; a rewrite replaces + the entry wholesale, so include everything still true. +- Track multi-step work with `write_task {title, description, + acceptance_criteria}`; move it with `set_task_status {id, status}` and tick + criteria via `update_task` by replacing `- [ ] #1 ...` with `- [x] #1 ...`. +- When a tool call fails, follow the `recovery` field in the error - it names + valid values, available workspaces, or where a doc moved. diff --git a/docs/screenshots/horizontal-split.png b/docs/screenshots/horizontal-split.png index 1580a84..40f49cf 100644 Binary files a/docs/screenshots/horizontal-split.png and b/docs/screenshots/horizontal-split.png differ diff --git a/docs/screenshots/light-theme.png b/docs/screenshots/light-theme.png index dd32616..6fa3e38 100644 Binary files a/docs/screenshots/light-theme.png and b/docs/screenshots/light-theme.png differ diff --git a/docs/screenshots/main.png b/docs/screenshots/main.png index 0c47c69..2770666 100644 Binary files a/docs/screenshots/main.png and b/docs/screenshots/main.png differ diff --git a/docs/screenshots/quick-open.png b/docs/screenshots/quick-open.png index 704eb3f..8718e9c 100644 Binary files a/docs/screenshots/quick-open.png and b/docs/screenshots/quick-open.png differ diff --git a/docs/screenshots/split-dark.png b/docs/screenshots/split-dark.png index 76efd04..52a02b3 100644 Binary files a/docs/screenshots/split-dark.png and b/docs/screenshots/split-dark.png differ diff --git a/package.json b/package.json index 67a3b75..b1c08cb 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "build:sidecar": "bun scripts/build-sidecar.ts", "tauri": "tauri" }, "dependencies": { @@ -27,6 +30,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "diff": "^9.0.0", + "dompurify": "^3.4.11", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "js-yaml": "^4.1.1", @@ -54,6 +58,10 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/diff": "^8.0.0", "@types/js-yaml": "^4.0.9", "@types/node": "^25.6.0", @@ -61,11 +69,13 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^29.1.1", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "typescript": "~5.8.3", "unified": "^11.0.5", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^4.1.9" } } diff --git a/scripts/build-sidecar.ts b/scripts/build-sidecar.ts new file mode 100644 index 0000000..18a661e --- /dev/null +++ b/scripts/build-sidecar.ts @@ -0,0 +1,57 @@ +// Builds the docsreader-mcp sidecar and places it where the Tauri bundler +// expects external binaries: src-tauri/binaries/docsreader-mcp-[.exe]. +// The triple comes from TAURI_ENV_TARGET_TRIPLE when run as a Tauri hook; +// "universal-apple-darwin" builds both Apple arches and merges them via lipo. +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dir, ".."); +const srcTauri = path.join(root, "src-tauri"); +const outDir = path.join(srcTauri, "binaries"); + +function run(cmd: string, args: string[]): string { + const res = spawnSync(cmd, args, { cwd: root, stdio: ["ignore", "pipe", "inherit"] }); + if (res.status !== 0) { + throw new Error(`${cmd} ${args.join(" ")} exited with ${res.status}`); + } + return res.stdout.toString(); +} + +function hostTriple(): string { + const line = run("rustc", ["-vV"]) + .split("\n") + .find((l) => l.startsWith("host: ")); + if (!line) throw new Error("could not determine host triple from rustc -vV"); + return line.slice("host: ".length).trim(); +} + +function buildFor(target: string): string { + run("cargo", [ + "build", + "--release", + "--manifest-path", + path.join(srcTauri, "Cargo.toml"), + "-p", + "docsreader-mcp", + "--target", + target, + ]); + const ext = target.includes("windows") ? ".exe" : ""; + return path.join(srcTauri, "target", target, "release", `docsreader-mcp${ext}`); +} + +const triple = process.env.TAURI_ENV_TARGET_TRIPLE ?? hostTriple(); +mkdirSync(outDir, { recursive: true }); + +if (triple === "universal-apple-darwin") { + const slices = ["aarch64-apple-darwin", "x86_64-apple-darwin"].map(buildFor); + const dest = path.join(outDir, `docsreader-mcp-${triple}`); + run("lipo", ["-create", ...slices, "-output", dest]); + console.log(`sidecar: ${dest} (universal)`); +} else { + const ext = triple.includes("windows") ? ".exe" : ""; + const dest = path.join(outDir, `docsreader-mcp-${triple}${ext}`); + copyFileSync(buildFor(triple), dest); + console.log(`sidecar: ${dest}`); +} diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..e8eed13 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -5,3 +5,6 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas + +# Sidecar staging for bundle.externalBin (built by scripts/build-sidecar.ts) +/binaries/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d384ab4..1ddbc3d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -455,9 +455,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -750,7 +750,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -803,10 +803,9 @@ dependencies = [ name = "docsreader" version = "0.5.1" dependencies = [ - "rayon", + "docsreader-core", "serde", "serde_json", - "serde_yaml", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -815,10 +814,40 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", + "tempfile", + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "docsreader-core" +version = "0.6.0" +dependencies = [ + "chrono", + "rayon", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_yaml", "tokio", "walkdir", ] +[[package]] +name = "docsreader-mcp" +version = "0.1.0" +dependencies = [ + "chrono", + "docsreader-core", + "rmcp", + "schemars 1.2.1", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -962,7 +991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1110,6 +1139,21 @@ dependencies = [ "libc", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1117,6 +1161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1184,6 +1229,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2049,6 +2095,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2154,6 +2206,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2221,7 +2282,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2295,6 +2356,15 @@ dependencies = [ "serde", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2648,6 +2718,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2852,7 +2928,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -3099,6 +3175,42 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52d21e5b342699bc4de690e6104fc4e43255e4e8420ff0f2cbb963aac09da6f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "rmcp-macros" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c68cec74c5b3ac73ff46375ae49e161637bda80bba70f0d5db641583bb308ee" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3124,7 +3236,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3180,7 +3292,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3238,7 +3350,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -3263,8 +3375,10 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -3281,6 +3395,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3394,10 +3520,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -3520,6 +3647,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3583,7 +3719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4174,7 +4310,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4227,6 +4363,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -4270,9 +4415,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4413,13 +4558,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.2", ] @@ -4514,6 +4660,22 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + [[package]] name = "tray-icon" version = "0.23.1" @@ -4533,7 +4695,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4562,7 +4724,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4995,7 +5157,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b880bf1..6f22dae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,14 +20,19 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } tauri-plugin-dialog = "2" tauri-plugin-fs = { version = "2", features = ["watch"] } tauri-plugin-store = "2" -walkdir = "2" -serde_yaml = "0.9" -rayon = "1.12.0" tauri-plugin-updater = "2" -tokio = { version = "1.52.2", features = ["process", "time"] } tauri-plugin-process = "2.3.1" +docsreader-core = { path = "core" } +toml_edit = "0.25.12" + +[workspace] +members = ["core", "mcp"] +resolver = "2" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1e9a104..48f25b2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,14 +9,64 @@ "dialog:default", "dialog:allow-open", "fs:default", - "fs:allow-read-text-file", - "fs:allow-read-dir", - "fs:allow-stat", - "fs:allow-exists", - "fs:allow-watch", - "fs:allow-unwatch", { - "identifier": "fs:scope", + "identifier": "fs:allow-read-text-file", + "allow": [ + { "path": "$HOME" }, + { "path": "$HOME/**" }, + { "path": "$DESKTOP" }, + { "path": "$DESKTOP/**" }, + { "path": "$DOCUMENT" }, + { "path": "$DOCUMENT/**" }, + { "path": "$DOWNLOAD" }, + { "path": "$DOWNLOAD/**" } + ] + }, + { + "identifier": "fs:allow-write-text-file", + "allow": [ + { "path": "$HOME/**/*.md" }, + { "path": "$HOME/**/*.markdown" }, + { "path": "$HOME/**/*.mdx" }, + { "path": "$DESKTOP/**/*.md" }, + { "path": "$DESKTOP/**/*.markdown" }, + { "path": "$DESKTOP/**/*.mdx" }, + { "path": "$DOCUMENT/**/*.md" }, + { "path": "$DOCUMENT/**/*.markdown" }, + { "path": "$DOCUMENT/**/*.mdx" }, + { "path": "$DOWNLOAD/**/*.md" }, + { "path": "$DOWNLOAD/**/*.markdown" }, + { "path": "$DOWNLOAD/**/*.mdx" } + ] + }, + { + "identifier": "fs:allow-read-dir", + "allow": [ + { "path": "$HOME" }, + { "path": "$HOME/**" }, + { "path": "$DESKTOP" }, + { "path": "$DESKTOP/**" }, + { "path": "$DOCUMENT" }, + { "path": "$DOCUMENT/**" }, + { "path": "$DOWNLOAD" }, + { "path": "$DOWNLOAD/**" } + ] + }, + { + "identifier": "fs:allow-stat", + "allow": [ + { "path": "$HOME" }, + { "path": "$HOME/**" }, + { "path": "$DESKTOP" }, + { "path": "$DESKTOP/**" }, + { "path": "$DOCUMENT" }, + { "path": "$DOCUMENT/**" }, + { "path": "$DOWNLOAD" }, + { "path": "$DOWNLOAD/**" } + ] + }, + { + "identifier": "fs:allow-exists", "allow": [ { "path": "$HOME" }, { "path": "$HOME/**" }, @@ -28,6 +78,20 @@ { "path": "$DOWNLOAD/**" } ] }, + { + "identifier": "fs:allow-watch", + "allow": [ + { "path": "$HOME" }, + { "path": "$HOME/**" }, + { "path": "$DESKTOP" }, + { "path": "$DESKTOP/**" }, + { "path": "$DOCUMENT" }, + { "path": "$DOCUMENT/**" }, + { "path": "$DOWNLOAD" }, + { "path": "$DOWNLOAD/**" } + ] + }, + "fs:allow-unwatch", "store:default", "updater:default", "process:default" diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml new file mode 100644 index 0000000..08eac84 --- /dev/null +++ b/src-tauri/core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "docsreader-core" +version = "0.6.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +walkdir = "2" +rayon = "1.12.0" +tokio = { version = "1.52.2", features = ["process", "time", "io-util", "macros"] } +serde_json = "1.0.150" +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +schemars = { version = "1.2.1", optional = true } + +[dev-dependencies] +tokio = { version = "1.52.2", features = ["rt", "rt-multi-thread", "macros"] } + +[features] +schemars = ["dep:schemars"] diff --git a/src-tauri/core/src/delete.rs b/src-tauri/core/src/delete.rs new file mode 100644 index 0000000..e376086 --- /dev/null +++ b/src-tauri/core/src/delete.rs @@ -0,0 +1,43 @@ +use std::path::Path; + +use crate::error::CoreError; +use crate::write::{locate_doc, stage_in_git, WrittenDoc}; + +/// Permanent removal; archive_doc_core is the soft alternative. +pub async fn delete_doc_core(root: &Path, doc_ref: &str) -> Result { + let doc = locate_doc(root, doc_ref)?; + std::fs::remove_file(&doc.path)?; + stage_in_git(root, &[&doc.rel_path]).await; + Ok(doc.to_written()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ErrorCode; + use crate::write::{write_doc_core, DocStatus, NewDoc}; + + fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_del_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn deletes_doc_and_second_delete_is_not_found() { + let root = test_dir("gone"); + let doc = write_doc_core(&root, &NewDoc::new("Doomed", "x", DocStatus::Research)) + .await + .unwrap(); + assert!(doc.path.is_file()); + + let deleted = delete_doc_core(&root, &doc.slug).await.unwrap(); + assert_eq!(deleted.rel_path, "research/doomed.md"); + assert!(!doc.path.exists()); + + let err = delete_doc_core(&root, &doc.slug).await.unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/error.rs b/src-tauri/core/src/error.rs new file mode 100644 index 0000000..2eb9c10 --- /dev/null +++ b/src-tauri/core/src/error.rs @@ -0,0 +1,82 @@ +use std::fmt; + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + WorkspaceNotFound, + DocNotFound, + InvalidPath, + InvalidInput, + Conflict, + Io, + Git, +} + +#[derive(Debug, Serialize)] +pub struct CoreError { + pub code: ErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery: Option, +} + +impl CoreError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + recovery: None, + } + } + + pub fn with_recovery(mut self, recovery: impl Into) -> Self { + self.recovery = Some(recovery.into()); + self + } +} + +impl fmt::Display for CoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.recovery { + Some(r) => write!(f, "{:?}: {} ({})", self.code, self.message, r), + None => write!(f, "{:?}: {}", self.code, self.message), + } + } +} + +impl std::error::Error for CoreError {} + +impl From for CoreError { + fn from(e: std::io::Error) -> Self { + Self::new(ErrorCode::Io, e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_to_the_wire_shape_agents_parse() { + let err = CoreError::new(ErrorCode::WorkspaceNotFound, "no workspace") + .with_recovery("call list_workspaces"); + let json = serde_json::to_value(&err).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "code": "workspace_not_found", + "message": "no workspace", + "recovery": "call list_workspaces", + }) + ); + } + + #[test] + fn recovery_is_omitted_when_absent() { + let json = serde_json::to_value(CoreError::new(ErrorCode::InvalidPath, "bad")).unwrap(); + assert_eq!(json.as_object().unwrap().len(), 2); + assert_eq!(json["code"], "invalid_path"); + } +} diff --git a/src-tauri/core/src/frontmatter.rs b/src-tauri/core/src/frontmatter.rs new file mode 100644 index 0000000..a5cf19a --- /dev/null +++ b/src-tauri/core/src/frontmatter.rs @@ -0,0 +1,125 @@ +#[derive(Debug, Default)] +pub(crate) struct DocMeta { + pub title: Option, + pub tags: Vec, +} + +pub(crate) fn split_frontmatter(content: &str) -> (Option<&str>, &str) { + let trimmed = content.trim_start_matches('\u{feff}').trim_start(); + let Some(after) = trimmed.strip_prefix("---") else { + return (None, content); + }; + let Some(nl) = after.find('\n') else { + return (None, content); + }; + let rest = &after[nl + 1..]; + let Some(end) = rest.find("\n---") else { + return (None, content); + }; + let fm = &rest[..end]; + let after_fence = &rest[end + 4..]; + let body = match after_fence.find('\n') { + Some(i) => &after_fence[i + 1..], + None => "", + }; + (Some(fm), body) +} + +fn yaml_str(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.into())) + .and_then(|v| v.as_str()) + .map(str::to_string) +} + +pub(crate) fn parse_doc_meta(fm: &str) -> DocMeta { + let Ok(value) = serde_yaml::from_str::(fm) else { + return DocMeta::default(); + }; + let Some(map) = value.as_mapping() else { + return DocMeta::default(); + }; + DocMeta { + title: yaml_str(map, "title"), + tags: map + .get(serde_yaml::Value::String("tags".into())) + .map(parse_tags) + .unwrap_or_default(), + } +} + +/// Replaces the top-level `key:` line inside the frontmatter, or appends it +/// after the existing keys (creating the frontmatter block if the content has +/// none). `line` is a full "key: value" line without a trailing newline; +/// everything else is preserved byte-for-byte. +pub(crate) fn upsert_fm_line(content: &str, key: &str, line: &str) -> String { + let prefix = format!("{key}:"); + let Some(fm) = split_frontmatter(content).0 else { + return format!("---\n{line}\n---\n\n{content}"); + }; + let new_fm = match fm.lines().position(|l| l.starts_with(&prefix)) { + Some(i) => { + let mut lines: Vec<&str> = fm.lines().collect(); + lines[i] = line; + lines.join("\n") + } + None => format!("{}\n{line}", fm.trim_end()), + }; + // fm borrows from content, so its offsets splice the exact byte range. + let start = fm.as_ptr() as usize - content.as_ptr() as usize; + let end = start + fm.len(); + format!("{}{}{}", &content[..start], new_fm, &content[end..]) +} + +/// One "key: value" YAML line with proper escaping, no trailing newline. +pub(crate) fn yaml_line(key: &str, value: &str) -> Result { + let mut map = serde_yaml::Mapping::new(); + map.insert(key.into(), value.into()); + serde_yaml::to_string(&map) + .map(|s| s.trim_end().to_string()) + .map_err(|e| { + crate::error::CoreError::new( + crate::error::ErrorCode::Io, + format!("serialize {key}: {e}"), + ) + }) +} + +pub(crate) fn parse_tags(value: &serde_yaml::Value) -> Vec { + if let Some(seq) = value.as_sequence() { + return seq + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + if let Some(s) = value.as_str() { + return s + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + } + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_frontmatter_and_body() { + let (fm, body) = split_frontmatter("---\ntitle: X\ntags: [a]\n---\n\nBody here."); + assert_eq!(fm, Some("title: X\ntags: [a]")); + assert_eq!(body, "\nBody here."); + + let meta = parse_doc_meta(fm.unwrap()); + assert_eq!(meta.title.as_deref(), Some("X")); + assert_eq!(meta.tags, vec!["a".to_string()]); + } + + #[test] + fn no_frontmatter_returns_full_body() { + let (fm, body) = split_frontmatter("# Just a heading\n"); + assert_eq!(fm, None); + assert_eq!(body, "# Just a heading\n"); + } +} diff --git a/src-tauri/core/src/git.rs b/src-tauri/core/src/git.rs new file mode 100644 index 0000000..2c0882e --- /dev/null +++ b/src-tauri/core/src/git.rs @@ -0,0 +1,325 @@ +// Shells out to `git` via tokio with a per-call timeout, rather than linking +// libgit2: zero bundle weight and the user's own git always understands their repo. + +use std::process::Stdio; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; + +const GIT_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_GIT_STDOUT_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +pub struct GitFileStatus { + pub path: String, + pub status: String, + #[serde( + default, + rename = "originalPath", + skip_serializing_if = "Option::is_none" + )] + pub original_path: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GitStatus { + pub root: String, + pub files: Vec, +} + +pub struct GitOutput { + pub success: bool, + pub stdout: Vec, + pub stderr: String, +} + +// PATH on a GUI-launched macOS app lacks Homebrew dirs, so probe common +// install locations as a fallback. Cached after the first lookup. +pub fn git_binary() -> Option<&'static str> { + static CACHED: std::sync::OnceLock> = std::sync::OnceLock::new(); + *CACHED.get_or_init(|| { + const CANDIDATES: &[&str] = &[ + "git", + "/usr/bin/git", + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + ]; + for c in CANDIDATES { + let ok = std::process::Command::new(c) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if ok { + return Some(*c); + } + } + None + }) +} + +// stdout is capped at MAX_GIT_STDOUT_BYTES: a huge committed blob (git show) or +// a repo with enormous status output must not be buffered unbounded into memory. +// Overflow is drained to a sink so the child can't block on a full pipe. +pub async fn run_git(args: &[&str]) -> Result { + let bin = match git_binary() { + Some(b) => b, + None => return Err("git not found".to_string()), + }; + let label = args.first().copied().unwrap_or(""); + let collect = async { + let mut child = tokio::process::Command::new(bin) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("git {label}: {e}"))?; + let mut stdout_pipe = child.stdout.take().ok_or("git stdout unavailable")?; + let mut stderr_pipe = child.stderr.take().ok_or("git stderr unavailable")?; + + let read_stdout = async { + let mut buf = Vec::new(); + let cap = MAX_GIT_STDOUT_BYTES as u64 + 1; + (&mut stdout_pipe).take(cap).read_to_end(&mut buf).await?; + tokio::io::copy(&mut stdout_pipe, &mut tokio::io::sink()).await?; + buf.truncate(MAX_GIT_STDOUT_BYTES); + Ok::, std::io::Error>(buf) + }; + let read_stderr = async { + let mut s = String::new(); + let _ = stderr_pipe.read_to_string(&mut s).await; + s + }; + let (stdout_res, stderr) = tokio::join!(read_stdout, read_stderr); + let stdout = stdout_res.map_err(|e| format!("git {label}: {e}"))?; + let status = child + .wait() + .await + .map_err(|e| format!("git {label}: {e}"))?; + Ok::(GitOutput { + success: status.success(), + stdout, + stderr, + }) + }; + match tokio::time::timeout(GIT_TIMEOUT, collect).await { + Ok(r) => r, + Err(_) => Err(format!("git {label} timed out")), + } +} + +pub async fn is_git_repo(dir: &str) -> bool { + match run_git(&["-C", dir, "rev-parse", "--is-inside-work-tree"]).await { + Ok(o) => o.success && String::from_utf8_lossy(&o.stdout).trim() == "true", + Err(_) => false, + } +} + +pub async fn git_add(dir: &str, paths: &[&str]) -> bool { + let mut args = vec!["-C", dir, "add", "-A", "--"]; + args.extend_from_slice(paths); + matches!(run_git(&args).await, Ok(o) if o.success) +} + +pub async fn git_mv(dir: &str, from: &str, to: &str) -> bool { + matches!( + run_git(&["-C", dir, "mv", "--", from, to]).await, + Ok(o) if o.success + ) +} + +fn classify_xy(xy: &str) -> &'static str { + let bytes = xy.as_bytes(); + if bytes.len() < 2 { + return "modified"; + } + let x = bytes[0] as char; + let y = bytes[1] as char; + if x == '?' || y == '?' { + return "untracked"; + } + if x == 'U' || y == 'U' || (x == 'D' && y == 'D') || (x == 'A' && y == 'A') { + return "unmerged"; + } + if x == 'A' || y == 'A' { + return "added"; + } + if x == 'D' || y == 'D' { + return "deleted"; + } + if x == 'R' || y == 'R' || x == 'C' || y == 'C' { + return "renamed"; + } + "modified" +} + +pub async fn git_status_core(workspace: String) -> Result, String> { + if git_binary().is_none() { + return Ok(None); + } + let toplevel_out = match run_git(&["-C", &workspace, "rev-parse", "--show-toplevel"]).await { + Ok(o) => o, + Err(_) => return Ok(None), + }; + if !toplevel_out.success { + return Ok(None); + } + let toplevel = String::from_utf8_lossy(&toplevel_out.stdout) + .trim() + .to_string(); + + // Workspace must live inside the repo. Compute the prefix so we can + // translate repo-relative paths (what git emits) into + // workspace-relative paths (what the scan uses). + let ws_canonical = std::path::Path::new(&workspace) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(&workspace).to_path_buf()); + let tl_canonical = std::path::Path::new(&toplevel) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(&toplevel).to_path_buf()); + let prefix = ws_canonical + .strip_prefix(&tl_canonical) + .ok() + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + + let status_out = run_git(&["-C", &workspace, "status", "--porcelain=v1", "-z"]).await?; + if !status_out.success { + return Err(format!("git status: {}", status_out.stderr)); + } + + let mut files = Vec::new(); + let mut iter = status_out + .stdout + .split(|b| *b == 0) + .filter(|t| !t.is_empty()) + .peekable(); + while let Some(tok) = iter.next() { + let s = match std::str::from_utf8(tok) { + Ok(s) => s, + Err(_) => continue, + }; + if s.len() < 4 { + continue; + } + let xy = &s[..2]; + let path = s[3..].to_string(); + let status = classify_xy(xy); + let is_rename = xy.contains('R') || xy.contains('C'); + + let original_path = if is_rename { + iter.next() + .and_then(|t| std::str::from_utf8(t).ok().map(|s| s.to_string())) + } else { + None + }; + + let final_path = if prefix.is_empty() { + path.clone() + } else if path == prefix { + String::new() + } else if let Some(rest) = path.strip_prefix(&format!("{}/", prefix)) { + rest.to_string() + } else { + continue; + }; + + files.push(GitFileStatus { + path: final_path, + status: status.to_string(), + original_path, + }); + } + + Ok(Some(GitStatus { + root: toplevel, + files, + })) +} + +pub async fn git_show_head_core(workspace: String, path: String) -> Result, String> { + if git_binary().is_none() { + return Ok(None); + } + let out = run_git(&["-C", &workspace, "show", &format!("HEAD:./{}", path), "--"]).await?; + if !out.success { + // Untracked / new file has no HEAD revision: not an error - the caller + // renders an "all added" diff. + if out.stderr.contains("exists on disk, but not in") + || out.stderr.contains("does not exist") + || out.stderr.contains("path does not exist") + || out.stderr.contains("bad revision") + { + return Ok(None); + } + return Err(format!("git show: {}", out.stderr)); + } + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use std::process::Command; + + fn git(dir: &Path, args: &[&str]) { + let ok = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .map(|s| s.success()) + .unwrap_or(false); + assert!(ok, "git {args:?} failed"); + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_git_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn show_head_returns_content_and_none_for_untracked() { + if git_binary().is_none() { + return; + } + let dir = temp_dir("show"); + git(&dir, &["init", "-q"]); + git(&dir, &["config", "user.email", "a@b.c"]); + git(&dir, &["config", "user.name", "x"]); + std::fs::write(dir.join("f.md"), "hello\n").unwrap(); + git(&dir, &["add", "f.md"]); + git(&dir, &["commit", "-qm", "init"]); + + let ws = dir.to_string_lossy().to_string(); + let committed = git_show_head_core(ws.clone(), "f.md".into()).await.unwrap(); + assert_eq!(committed.as_deref(), Some("hello\n")); + + std::fs::write(dir.join("new.md"), "x\n").unwrap(); + let untracked = git_show_head_core(ws.clone(), "new.md".into()) + .await + .unwrap(); + assert_eq!(untracked, None); + + let status = git_status_core(ws).await.unwrap(); + assert!(status.is_some(), "files() in a repo returns Some"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn status_on_non_repo_is_none() { + if git_binary().is_none() { + return; + } + let dir = temp_dir("nonrepo"); + let status = git_status_core(dir.to_string_lossy().to_string()) + .await + .unwrap(); + assert!(status.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs new file mode 100644 index 0000000..6952e29 --- /dev/null +++ b/src-tauri/core/src/lib.rs @@ -0,0 +1,15 @@ +pub mod delete; +pub mod error; +mod frontmatter; +pub mod git; +pub mod links; +pub mod memory; +pub mod path_guard; +pub mod read; +pub mod rename; +pub mod scan; +pub mod slug; +pub mod tasks; +pub mod update; +pub mod workspace; +pub mod write; diff --git a/src-tauri/core/src/links.rs b/src-tauri/core/src/links.rs new file mode 100644 index 0000000..8d7dab5 --- /dev/null +++ b/src-tauri/core/src/links.rs @@ -0,0 +1,127 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::scan::is_markdown; + +/// Workspace-relative markdown targets of the inline `[text](target)` links +/// in `content`, resolved against the linking file's own directory. +/// External URLs, absolute paths, and links escaping the workspace drop out. +pub fn links_from(content: &str, source_rel: &str) -> Vec { + let mut out: Vec = Vec::new(); + for target in inline_targets(content) { + if let Some(resolved) = resolve_link(source_rel, target) { + if !out.contains(&resolved) { + out.push(resolved); + } + } + } + out +} + +fn inline_targets(content: &str) -> impl Iterator { + content.split("](").skip(1).filter_map(|rest| { + if let Some(stripped) = rest.strip_prefix('<') { + stripped.split('>').next() + } else { + rest.split(')') + .next() + .and_then(|t| t.split_whitespace().next()) + } + }) +} + +fn resolve_link(source_rel: &str, target: &str) -> Option { + let decoded = percent_decode(target.split('#').next().unwrap_or("")); + if decoded.is_empty() || decoded.starts_with('/') { + return None; + } + // A colon in the first segment means a scheme (https:, mailto:) or a + // Windows drive - either way not a workspace-relative link. + if decoded.split('/').next().is_some_and(|s| s.contains(':')) { + return None; + } + if !is_markdown(&decoded) { + return None; + } + + let source_dir = Path::new(source_rel).parent().unwrap_or(Path::new("")); + let mut stack: Vec<&std::ffi::OsStr> = Vec::new(); + for comp in source_dir + .components() + .chain(Path::new(&decoded).components()) + { + match comp { + Component::Normal(c) => stack.push(c), + Component::ParentDir => { + stack.pop()?; + } + Component::CurDir => {} + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some( + stack + .iter() + .collect::() + .to_string_lossy() + .into_owned(), + ) +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let decoded = (bytes[i] == b'%' && i + 2 < bytes.len()) + .then(|| u8::from_str_radix(&s[i + 1..i + 3], 16).ok()) + .flatten(); + match decoded { + Some(b) => { + out.push(b); + i += 3; + } + None => { + out.push(bytes[i]); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_and_resolves_relative_links() { + let content = "See [a](./sibling.md), [b](../up/other.md), and [c](nested/deep.md)."; + assert_eq!( + links_from(content, "docs/source.md"), + ["docs/sibling.md", "up/other.md", "docs/nested/deep.md"] + ); + } + + #[test] + fn skips_external_absolute_anchor_and_non_markdown_targets() { + let content = "[u](https://x.com/a.md) [m](mailto:a@b.md) [abs](/etc/a.md) \ + [anchor](#section) [img](./pic.png) [code](./main.rs)"; + assert_eq!(links_from(content, "doc.md"), Vec::::new()); + } + + #[test] + fn strips_fragments_decodes_percent_and_handles_angle_form() { + let content = "[a](./target.md#heading) [b](./my%20doc.md) [c](<./my doc.md>) \ + [titled](./cited.md \"Title\")"; + assert_eq!( + links_from(content, "doc.md"), + ["target.md", "my doc.md", "cited.md"] + ); + } + + #[test] + fn drops_links_escaping_the_workspace_and_dedupes() { + let content = "[esc](../../outside.md) [a](./a.md) [again](a.md)"; + assert_eq!(links_from(content, "sub/doc.md"), ["sub/a.md"]); + } +} diff --git a/src-tauri/core/src/memory.rs b/src-tauri/core/src/memory.rs new file mode 100644 index 0000000..4964b5b --- /dev/null +++ b/src-tauri/core/src/memory.rs @@ -0,0 +1,326 @@ +use std::path::{Path, PathBuf}; + +use chrono::{SecondsFormat, Utc}; +use serde::Serialize; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::{parse_doc_meta, split_frontmatter}; +use crate::read::score_match; +use crate::slug::slugify; +use crate::write::{enforce_size_limit, stage_in_git}; + +pub const MEMORY_DIR: &str = "memory"; + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct MemoryEntry { + pub slug: String, + pub rel_path: String, + pub path: PathBuf, + /// false when an existing entry for the topic was overwritten. + pub created: bool, +} + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct MemoryHit { + pub slug: String, + pub rel_path: String, + pub title: Option, + pub tags: Vec, + /// Full body of the entry; memory entries are short by design. + pub content: String, + pub score: u32, + pub modified: Option, +} + +pub fn memory_rel_path(slug: &str) -> String { + format!("{MEMORY_DIR}/{slug}.md") +} + +fn memory_slug(mem_ref: &str) -> Result { + let slug = mem_ref + .strip_prefix("memory/") + .unwrap_or(mem_ref) + .trim_end_matches(".md"); + if slug.is_empty() || slugify(slug) != slug { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("invalid memory reference {mem_ref:?}"), + ) + .with_recovery("pass a topic slug or a path like \"memory/user-preferences.md\"")); + } + Ok(slug.to_string()) +} + +#[derive(Serialize)] +struct MemoryFrontmatter<'a> { + title: &'a str, + #[serde(skip_serializing_if = "<[String]>::is_empty")] + tags: &'a [String], + created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_by: Option<&'a str>, +} + +fn existing_creation_stamp(path: &Path) -> Option<(String, Option)> { + let content = std::fs::read_to_string(path).ok()?; + let fm = split_frontmatter(&content).0?; + let map: serde_yaml::Mapping = serde_yaml::from_str(fm).ok()?; + let field = |key: &str| { + map.get(serde_yaml::Value::String(key.into())) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + Some((field("created_at")?, field("created_by"))) +} + +/// Upsert by topic: one entry per topic slug, overwritten wholesale on each +/// write (the Anthropic memory-tool "create or overwrite" contract). +/// created_at/created_by survive updates; updated_at/updated_by track them. +pub async fn write_memory_core( + root: &Path, + topic: &str, + content: &str, + tags: &[String], + agent: Option<&str>, +) -> Result { + if topic.trim().is_empty() { + return Err(CoreError::new(ErrorCode::InvalidInput, "topic is required") + .with_recovery("pass a short topic, e.g. \"user-preferences\"")); + } + let slug = slugify(topic); + let rel_path = memory_rel_path(&slug); + let path = root.join(&rel_path); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let existing = existing_creation_stamp(&path); + let created = existing.is_none(); + let (created_at, created_by) = + existing.unwrap_or_else(|| (now.clone(), agent.map(str::to_string))); + let fm = serde_yaml::to_string(&MemoryFrontmatter { + title: topic, + tags, + created_at, + created_by, + updated_at: (!created).then(|| now.clone()), + updated_by: (!created).then_some(agent).flatten(), + }) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize frontmatter: {e}")))?; + let rendered = format!("---\n{fm}---\n\n{}\n", content.trim_end()); + enforce_size_limit(rendered.len())?; + std::fs::create_dir_all(path.parent().unwrap_or(root))?; + std::fs::write(&path, rendered)?; + stage_in_git(root, &[&rel_path]).await; + Ok(MemoryEntry { + slug, + rel_path, + path, + created, + }) +} + +/// Empty/absent query lists everything, newest first; otherwise hits are +/// ranked like search_docs. Full content rides along so recall is one call. +pub fn search_memory_core( + root: &Path, + query: Option<&str>, + tag: Option<&str>, +) -> Result, CoreError> { + if !root.is_dir() { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("workspace directory {} is missing", root.display()), + ) + .with_recovery("call list_workspaces to see valid slugs")); + } + let q = query + .map(|q| q.trim().to_lowercase()) + .filter(|q| !q.is_empty()); + let mut hits = Vec::new(); + for entry in std::fs::read_dir(root.join(MEMORY_DIR)) + .into_iter() + .flatten() + .flatten() + { + let Some(hit) = memory_hit(&entry.path()) else { + continue; + }; + if tag.is_some_and(|t| !hit.tags.iter().any(|x| x == t)) { + continue; + } + match &q { + None => hits.push(hit), + Some(q) => { + let score = score_match( + hit.title.as_deref(), + &hit.tags, + &hit.slug, + hit.content.to_lowercase().contains(q), + q, + ); + if score > 0 { + hits.push(MemoryHit { score, ..hit }); + } + } + } + } + match q { + None => hits.sort_by(|a, b| { + b.modified + .cmp(&a.modified) + .then_with(|| a.slug.cmp(&b.slug)) + }), + Some(_) => hits.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.slug.cmp(&b.slug))), + } + Ok(hits) +} + +fn memory_hit(path: &Path) -> Option { + if path.extension().is_none_or(|e| e != "md") || !path.is_file() { + return None; + } + let slug = path.file_stem()?.to_string_lossy().to_string(); + let content = std::fs::read_to_string(path).ok()?; + let (fm, body) = split_frontmatter(&content); + let meta = fm.map(parse_doc_meta).unwrap_or_default(); + let modified = std::fs::metadata(path) + .ok()? + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + Some(MemoryHit { + rel_path: memory_rel_path(&slug), + slug, + title: meta.title, + tags: meta.tags, + content: body.trim().to_string(), + score: 0, + modified, + }) +} + +pub async fn delete_memory_core(root: &Path, mem_ref: &str) -> Result { + let slug = memory_slug(mem_ref)?; + let rel_path = memory_rel_path(&slug); + let path = root.join(&rel_path); + if !path.is_file() { + return Err(CoreError::new( + ErrorCode::DocNotFound, + format!("no memory entry for {slug:?}"), + ) + .with_recovery("call search_memory to see existing entries")); + } + std::fs::remove_file(&path)?; + stage_in_git(root, &[&rel_path]).await; + Ok(MemoryEntry { + slug, + rel_path, + path, + created: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dr_mem_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn upsert_creates_then_overwrites_preserving_creation_stamp() { + let root = test_dir("upsert"); + let first = write_memory_core(&root, "User Preferences", "Likes tabs.", &[], Some("codex")) + .await + .unwrap(); + assert!(first.created); + assert_eq!(first.rel_path, "memory/user-preferences.md"); + let raw = std::fs::read_to_string(&first.path).unwrap(); + assert!(raw.contains("created_by: codex")); + assert!(!raw.contains("updated_at:")); + + let second = write_memory_core( + &root, + "User Preferences", + "Likes spaces now.", + &[], + Some("claude-code"), + ) + .await + .unwrap(); + assert!(!second.created, "same topic upserts"); + let raw = std::fs::read_to_string(&second.path).unwrap(); + assert!(raw.contains("created_by: codex"), "creation stamp survives"); + assert!(raw.contains("updated_by: claude-code")); + assert!(raw.contains("updated_at:")); + assert!(raw.ends_with("Likes spaces now.\n")); + assert!(!raw.contains("tabs")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn search_ranks_and_empty_query_lists_all() { + let root = test_dir("search"); + write_memory_core( + &root, + "Auth Stack", + "Uses Better Auth.", + &["stack".into()], + None, + ) + .await + .unwrap(); + write_memory_core(&root, "Editor", "Neovim, auth for git via ssh.", &[], None) + .await + .unwrap(); + + let all = search_memory_core(&root, None, None).unwrap(); + assert_eq!(all.len(), 2); + assert!(all.iter().all(|h| !h.content.is_empty())); + + let hits = search_memory_core(&root, Some("auth"), None).unwrap(); + assert_eq!(hits.len(), 2); + assert_eq!( + hits[0].slug, "auth-stack", + "title+slug match outranks content" + ); + assert!(hits[0].score > hits[1].score); + + let tagged = search_memory_core(&root, None, Some("stack")).unwrap(); + assert_eq!(tagged.len(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn delete_removes_entry_and_rejects_bad_refs() { + let root = test_dir("del"); + write_memory_core(&root, "Stale Fact", "old", &[], None) + .await + .unwrap(); + + let gone = delete_memory_core(&root, "memory/stale-fact.md") + .await + .unwrap(); + assert!(!gone.path.exists()); + + let err = delete_memory_core(&root, "stale-fact").await.unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + + let err = delete_memory_core(&root, "memory/../escape") + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/path_guard.rs b/src-tauri/core/src/path_guard.rs new file mode 100644 index 0000000..44ca4d7 --- /dev/null +++ b/src-tauri/core/src/path_guard.rs @@ -0,0 +1,81 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::error::{CoreError, ErrorCode}; + +const ENCODED_TRAVERSAL_MARKERS: &[&str] = &["%2e", "%2f", "%5c"]; + +fn invalid(relative: &str, why: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidPath, + format!("invalid path {relative:?}: {why}"), + ) + .with_recovery("use a relative path inside the workspace, e.g. \"guides/setup.md\"") +} + +pub fn safe_join(root: &Path, relative: &str) -> Result { + if relative.is_empty() { + return Err(invalid(relative, "empty path")); + } + let lower = relative.to_ascii_lowercase(); + if ENCODED_TRAVERSAL_MARKERS.iter().any(|m| lower.contains(m)) { + return Err(invalid(relative, "percent-encoded separator or dot")); + } + if relative.contains(':') { + return Err(invalid(relative, "drive or stream separator")); + } + if relative.contains('\0') { + return Err(invalid(relative, "NUL byte")); + } + let normalized = relative.replace('\\', "/"); + let rel_path = Path::new(&normalized); + for component in rel_path.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir => return Err(invalid(relative, "parent traversal")), + Component::RootDir | Component::Prefix(_) => { + return Err(invalid(relative, "absolute path")) + } + } + } + Ok(root.join(rel_path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn join(rel: &str) -> Result { + safe_join(Path::new("/ws"), rel) + } + + #[test] + fn accepts_normal_relative_paths() { + assert_eq!( + join("guides/setup.md").unwrap(), + Path::new("/ws/guides/setup.md") + ); + assert_eq!(join("./a.md").unwrap(), Path::new("/ws/a.md")); + assert_eq!(join("目录/文件.md").unwrap(), Path::new("/ws/目录/文件.md")); + } + + #[test] + fn rejects_traversal_vectors() { + for vector in [ + "", + "../evil.md", + "a/../../evil.md", + "/etc/passwd", + "C:\\evil.md", + "c:/evil.md", + "a\\..\\evil.md", + "%2e%2e%2fevil.md", + "%2E%2E/evil.md", + "a%2fevil.md", + "a%5cevil.md", + "a\0.md", + ] { + let err = join(vector).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidPath, "vector: {vector:?}"); + } + } +} diff --git a/src-tauri/core/src/read.rs b/src-tauri/core/src/read.rs new file mode 100644 index 0000000..3357f3c --- /dev/null +++ b/src-tauri/core/src/read.rs @@ -0,0 +1,400 @@ +use std::path::Path; + +use serde::Serialize; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::{parse_doc_meta, split_frontmatter}; +use crate::write::DocStatus; + +/// ~25k tokens at ~4 chars/token; MCP responses stay under this. +pub const RESPONSE_BUDGET_CHARS: usize = 100_000; +pub const SNIPPET_CHARS: usize = 500; + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct DocSummary { + pub slug: String, + pub rel_path: String, + pub status: DocStatus, + pub title: Option, + pub tags: Vec, + pub phase: Option, + pub size: u64, + pub modified: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct SearchHit { + #[serde(flatten)] + pub doc: DocSummary, + pub score: u32, + pub snippet: Option, +} + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct DocContent { + pub slug: String, + pub rel_path: String, + pub status: DocStatus, + pub phase: Option, + pub frontmatter: Option, + pub title: Option, + /// Full markdown body (detailed mode only). + pub body: Option, + /// Leading excerpt of the body (concise mode only). + pub snippet: Option, + pub size: u64, + pub truncated: bool, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct DocFilters<'a> { + pub status: Option, + pub phase: Option<&'a str>, + pub tag: Option<&'a str>, +} + +fn truncate_at_char_boundary(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +fn doc_summary( + root: &Path, + status: DocStatus, + phase: Option<&str>, + path: &Path, +) -> Option<(DocSummary, String)> { + let slug = path.file_stem()?.to_string_lossy().to_string(); + let metadata = std::fs::metadata(path).ok()?; + let content = std::fs::read_to_string(path).ok()?; + let (fm, _) = split_frontmatter(&content); + let meta = fm.map(parse_doc_meta).unwrap_or_default(); + let rel_path = path + .strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + Some(( + DocSummary { + slug, + rel_path, + status, + title: meta.title, + tags: meta.tags, + phase: phase.map(str::to_string), + size: metadata.len(), + modified, + }, + content, + )) +} + +fn matches_filters(doc: &DocSummary, filters: &DocFilters<'_>) -> bool { + if let Some(status) = filters.status { + if doc.status != status { + return false; + } + } + if let Some(phase) = filters.phase { + if doc.phase.as_deref() != Some(phase) { + return false; + } + } + if let Some(tag) = filters.tag { + if !doc.tags.iter().any(|t| t == tag) { + return false; + } + } + true +} + +fn collect_docs( + root: &Path, + filters: &DocFilters<'_>, +) -> Result, CoreError> { + if !root.is_dir() { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("workspace directory {} is missing", root.display()), + ) + .with_recovery("call list_workspaces to see valid slugs")); + } + let mut docs = Vec::new(); + let mut push_doc = |status: DocStatus, phase: Option<&str>, path: &Path| { + let is_md = path.extension().is_some_and(|e| e == "md"); + if !is_md || !path.is_file() { + return; + } + if let Some((doc, content)) = doc_summary(root, status, phase, path) { + if matches_filters(&doc, filters) { + docs.push((doc, content)); + } + } + }; + for status in DocStatus::ALL { + let entries = match std::fs::read_dir(root.join(status.folder())) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let phase = path.file_name().map(|n| n.to_string_lossy().to_string()); + for nested in std::fs::read_dir(&path).into_iter().flatten().flatten() { + push_doc(status, phase.as_deref(), &nested.path()); + } + } else { + push_doc(status, None, &path); + } + } + } + docs.sort_by(|a, b| { + b.0.modified + .cmp(&a.0.modified) + .then_with(|| a.0.rel_path.cmp(&b.0.rel_path)) + }); + Ok(docs) +} + +pub fn list_docs_core(root: &Path, filters: &DocFilters<'_>) -> Result, CoreError> { + Ok(collect_docs(root, filters)? + .into_iter() + .map(|(doc, _)| doc) + .collect()) +} + +const SCORE_TITLE: u32 = 3; +const SCORE_TAG: u32 = 2; +const SCORE_SLUG: u32 = 2; +const SCORE_CONTENT: u32 = 1; + +pub(crate) fn score_match( + title: Option<&str>, + tags: &[String], + slug: &str, + body_matches: bool, + query_lower: &str, +) -> u32 { + let mut score = 0u32; + if title.is_some_and(|t| t.to_lowercase().contains(query_lower)) { + score += SCORE_TITLE; + } + if tags.iter().any(|t| t.to_lowercase() == query_lower) { + score += SCORE_TAG; + } + if slug.to_lowercase().contains(query_lower) { + score += SCORE_SLUG; + } + if body_matches { + score += SCORE_CONTENT; + } + score +} + +fn content_snippet(content: &str, query_lower: &str) -> Option { + let body = split_frontmatter(content).1; + let lower = body.to_lowercase(); + let hit = lower.find(query_lower)?; + let start = body[..hit] + .char_indices() + .rev() + .take(80) + .last() + .map(|(i, _)| i) + .unwrap_or(hit); + let excerpt = truncate_at_char_boundary(&body[start..], 160); + Some(format!("...{}...", excerpt.trim())) +} + +pub fn search_docs_core( + root: &Path, + query: &str, + filters: &DocFilters<'_>, +) -> Result, CoreError> { + if query.trim().is_empty() { + return Err( + CoreError::new(ErrorCode::InvalidInput, "query must not be empty") + .with_recovery("pass a search term, or use list_docs to browse"), + ); + } + let q = query.to_lowercase(); + let mut hits = Vec::new(); + for (doc, content) in collect_docs(root, filters)? { + let snippet = content_snippet(&content, &q); + let score = score_match( + doc.title.as_deref(), + &doc.tags, + &doc.slug, + snippet.is_some(), + &q, + ); + if score > 0 { + hits.push(SearchHit { + doc, + score, + snippet, + }); + } + } + hits.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.doc.rel_path.cmp(&b.doc.rel_path)) + }); + Ok(hits) +} + +pub fn read_doc_core(root: &Path, doc_ref: &str, detailed: bool) -> Result { + let doc = crate::write::locate_doc(root, doc_ref)?; + let content = std::fs::read_to_string(&doc.path)?; + let size = content.len() as u64; + let (fm, body) = split_frontmatter(&content); + let meta = fm.map(parse_doc_meta).unwrap_or_default(); + let frontmatter = fm + .and_then(|raw| serde_yaml::from_str::(raw).ok()) + .and_then(|v| serde_json::to_value(v).ok()); + + let body = body.trim_start_matches('\n'); + let (body_out, snippet, truncated) = if detailed { + let capped = truncate_at_char_boundary(body, RESPONSE_BUDGET_CHARS); + (Some(capped.to_string()), None, capped.len() < body.len()) + } else { + let snip = truncate_at_char_boundary(body, SNIPPET_CHARS); + (None, Some(snip.to_string()), snip.len() < body.len()) + }; + + Ok(DocContent { + slug: doc.slug, + rel_path: doc.rel_path, + status: doc.status, + phase: doc.phase, + frontmatter, + title: meta.title, + body: body_out, + snippet, + size, + truncated, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::write::{write_doc_core, NewDoc}; + + fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_read_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + async fn seed(root: &Path) { + let mut alpha = NewDoc::new("Alpha Guide", "How to use alpha features.", DocStatus::Done); + alpha.tags = vec!["guide".into()]; + alpha.phase = Some("v1"); + write_doc_core(root, &alpha).await.unwrap(); + + let mut beta = NewDoc::new( + "Beta Notes", + "Rough notes mentioning alpha once.", + DocStatus::Research, + ); + beta.tags = vec!["notes".into()]; + write_doc_core(root, &beta).await.unwrap(); + } + + #[tokio::test] + async fn list_filters_and_together() { + let root = test_dir("list"); + seed(&root).await; + + let all = list_docs_core(&root, &DocFilters::default()).unwrap(); + assert_eq!(all.len(), 2); + + let filtered = list_docs_core( + &root, + &DocFilters { + status: Some(DocStatus::Done), + tag: Some("guide"), + phase: Some("v1"), + }, + ) + .unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].slug, "alpha-guide"); + + let none = list_docs_core( + &root, + &DocFilters { + status: Some(DocStatus::Done), + tag: Some("notes"), + phase: None, + }, + ) + .unwrap(); + assert!(none.is_empty(), "filters AND together"); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn search_ranks_title_match_above_content_match() { + let root = test_dir("search"); + seed(&root).await; + + let hits = search_docs_core(&root, "alpha", &DocFilters::default()).unwrap(); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].doc.slug, "alpha-guide", "title match first"); + assert!(hits[0].score > hits[1].score); + assert!(hits[1].snippet.as_deref().unwrap().contains("alpha")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn read_concise_vs_detailed() { + let root = test_dir("read"); + seed(&root).await; + + let concise = read_doc_core(&root, "alpha-guide", false).unwrap(); + assert!(concise.body.is_none()); + assert!(concise.snippet.as_deref().unwrap().contains("alpha")); + assert_eq!(concise.status, DocStatus::Done); + assert_eq!(concise.phase.as_deref(), Some("v1"), "phase from subfolder"); + assert!(concise.frontmatter.is_some()); + + let detailed = read_doc_core(&root, "done/v1/alpha-guide.md", true).unwrap(); + assert!(detailed.body.as_deref().unwrap().contains("alpha features")); + assert!(detailed.snippet.is_none()); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn read_missing_doc_is_not_found_and_traversal_rejected() { + let root = test_dir("read_missing"); + seed(&root).await; + + let err = read_doc_core(&root, "ghost", false).unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + + let err = read_doc_core(&root, "../outside.md", false).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidPath); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/rename.rs b/src-tauri/core/src/rename.rs new file mode 100644 index 0000000..2efbcfe --- /dev/null +++ b/src-tauri/core/src/rename.rs @@ -0,0 +1,137 @@ +use std::path::Path; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::{upsert_fm_line, yaml_line}; +use crate::slug::slugify; +use crate::write::{ + find_doc_location, locate_doc, location_at, relocate, stage_in_git, WrittenDoc, +}; + +/// The new title becomes both the frontmatter title and the slug/filename; +/// status and phase stay put. A slug already taken by another doc is a +/// Conflict, never a silent counter suffix. +pub async fn rename_doc_core( + root: &Path, + doc_ref: &str, + new_title: &str, +) -> Result { + if new_title.trim().is_empty() { + return Err( + CoreError::new(ErrorCode::InvalidInput, "new_title is required") + .with_recovery("pass a short human-readable title; it becomes the doc's new slug"), + ); + } + let from = locate_doc(root, doc_ref)?; + let new_slug = slugify(new_title); + let target = if new_slug == from.slug { + from + } else { + if let Some(existing) = find_doc_location(root, &new_slug) { + return Err(CoreError::new( + ErrorCode::Conflict, + format!( + "a doc with slug {new_slug:?} already exists at {:?}", + existing.rel_path + ), + ) + .with_recovery("choose a different title, or archive/delete the existing doc first")); + } + let to = location_at(root, from.status, from.phase.as_deref(), &new_slug); + relocate(root, &from, &to).await?; + to + }; + let content = std::fs::read_to_string(&target.path)?; + std::fs::write(&target.path, with_title(&content, new_title)?)?; + stage_in_git(root, &[&target.rel_path]).await; + Ok(target.to_written()) +} + +fn with_title(content: &str, title: &str) -> Result { + let line = yaml_line("title", title)?; + Ok(upsert_fm_line(content, "title", &line)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::write::{set_status_core, write_doc_core, DocStatus, NewDoc}; + + fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_ren_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn rename_moves_file_and_updates_title_within_status_and_phase() { + let root = test_dir("move"); + let doc = write_doc_core( + &root, + &NewDoc { + phase: Some("v1"), + tags: vec!["keep".into()], + ..NewDoc::new("Old Name", "Body.", DocStatus::Research) + }, + ) + .await + .unwrap(); + + let renamed = rename_doc_core(&root, &doc.slug, "New Name").await.unwrap(); + assert_eq!(renamed.rel_path, "research/v1/new-name.md"); + assert!(!doc.path.exists()); + + let raw = std::fs::read_to_string(&renamed.path).unwrap(); + assert!(raw.contains("title: New Name"), "got: {raw}"); + assert!(!raw.contains("Old Name")); + assert!(raw.contains("- keep"), "other frontmatter preserved: {raw}"); + assert!(raw.ends_with("Body.\n")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn same_slug_rename_only_updates_title() { + let root = test_dir("retitle"); + let doc = write_doc_core(&root, &NewDoc::new("my doc", "x", DocStatus::Done)) + .await + .unwrap(); + + let renamed = rename_doc_core(&root, &doc.slug, "My Doc").await.unwrap(); + assert_eq!(renamed.rel_path, doc.rel_path); + let raw = std::fs::read_to_string(&renamed.path).unwrap(); + assert!(raw.contains("title: My Doc")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn rename_onto_existing_slug_is_conflict() { + let root = test_dir("clash"); + write_doc_core(&root, &NewDoc::new("Taken", "a", DocStatus::Research)) + .await + .unwrap(); + let doc = write_doc_core(&root, &NewDoc::new("Mine", "b", DocStatus::Done)) + .await + .unwrap(); + set_status_core(&root, "taken", DocStatus::Archived) + .await + .unwrap(); + + let err = rename_doc_core(&root, &doc.slug, "Taken") + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + assert!(err.message.contains("archived/taken.md"), "{}", err.message); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn with_title_escapes_yaml_and_handles_missing_frontmatter() { + let updated = with_title("just a body\n", "Plain").unwrap(); + assert!(updated.starts_with("---\ntitle: Plain\n---\n\n")); + assert!(updated.ends_with("just a body\n")); + + let updated = with_title("---\ntags: [x]\n---\n\nbody\n", "Has: colon").unwrap(); + assert!(updated.contains("title: 'Has: colon'"), "got: {updated}"); + assert!(updated.contains("tags: [x]")); + } +} diff --git a/src-tauri/core/src/scan.rs b/src-tauri/core/src/scan.rs new file mode 100644 index 0000000..3a42b9b --- /dev/null +++ b/src-tauri/core/src/scan.rs @@ -0,0 +1,391 @@ +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use walkdir::{DirEntry, WalkDir}; + +use crate::frontmatter::{parse_doc_meta, split_frontmatter}; +use crate::workspace::migrate::marker_with_migration; +use crate::workspace::WorkspaceMarker; + +const PROGRESS_INTERVAL_MS: u64 = 100; +const MAX_FILES: usize = 50_000; +pub const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; +const PARTIAL_READ_BYTES: usize = 16 * 1024; +const MAX_HEADING_SCAN_LINES: usize = 120; + +pub trait ScanProgressSink: Send + Sync { + fn emit(&self, progress: &ScanProgress); +} + +pub struct NoopProgressSink; + +impl ScanProgressSink for NoopProgressSink { + fn emit(&self, _progress: &ScanProgress) {} +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MarkdownFile { + pub path: String, + pub name: String, + #[serde(rename = "relPath")] + pub rel_path: String, + pub title: Option, + pub tags: Vec, + pub modified: Option, + pub size: u64, + // Workspace-relative markdown files this doc links to. Extracted from + // the same partial read as title/tags, so links beyond the first 16 KiB + // are not seen. Backs the backlinks pane in the GUI. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub links: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ScanResult { + pub root: String, + pub files: Vec, + pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub marker: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ScanProgress { + pub root: String, + #[serde(rename = "currentDir")] + pub current_dir: String, + #[serde(rename = "filesFound")] + pub files_found: u64, + #[serde(rename = "dirsVisited")] + pub dirs_visited: u64, + #[serde(rename = "lastFile")] + pub last_file: Option, +} + +fn extract_first_heading(content: &str) -> Option { + for line in content.lines().take(MAX_HEADING_SCAN_LINES) { + let line = line.trim_start(); + if let Some(rest) = line.strip_prefix("# ") { + return Some(rest.trim().to_string()); + } + } + None +} + +fn parse_meta(content: &str) -> (Option, Vec) { + let (fm, _) = split_frontmatter(content); + let meta = fm.map(parse_doc_meta).unwrap_or_default(); + let title = meta.title.or_else(|| extract_first_heading(content)); + (title, meta.tags) +} + +const SKIP_DIRS: &[&str] = &[ + "node_modules", + "target", + ".git", + ".next", + "dist", + "build", + ".venv", + "venv", + ".cache", + ".turbo", + ".vercel", + ".idea", + ".vscode", + "Library", + "Applications", + "System", + "Pictures", + "Movies", + "Music", + ".Trash", + ".npm", + ".yarn", + ".pnpm-store", + ".cargo", + ".rustup", + ".bun", + ".local", + "Pods", + ".gradle", + "DerivedData", +]; + +fn is_skipped_dir(name: &str) -> bool { + if name.starts_with('.') { + return true; + } + SKIP_DIRS.contains(&name) +} + +pub(crate) fn is_markdown(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".md") || lower.ends_with(".markdown") || lower.ends_with(".mdx") +} + +fn read_partial(path: &Path) -> std::io::Result { + let mut file = File::open(path)?; + let mut buf = vec![0u8; PARTIAL_READ_BYTES]; + let n = file.read(&mut buf)?; + buf.truncate(n); + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result { + let root_path = Path::new(&path); + if !root_path.exists() { + return Err(format!("Path does not exist: {}", path)); + } + + let dirs_visited = Arc::new(AtomicU64::new(0)); + let last_emit = Arc::new(Mutex::new(Instant::now())); + + let mut entries: Vec = Vec::new(); + let mut truncated = false; + + let walker = WalkDir::new(root_path) + .follow_links(false) + .into_iter() + .filter_entry(|e| { + if e.depth() == 0 { + return true; + } + let name = e.file_name().to_string_lossy(); + if e.file_type().is_dir() { + !is_skipped_dir(&name) + } else { + !name.starts_with('.') + } + }); + + for entry in walker.filter_map(|e| e.ok()) { + if entry.file_type().is_dir() { + dirs_visited.fetch_add(1, Ordering::Relaxed); + maybe_emit_walk_progress( + progress, + &path, + entry.path(), + root_path, + &dirs_visited, + 0, + None, + &last_emit, + ); + continue; + } + + if !entry.file_type().is_file() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + if !is_markdown(&name) { + continue; + } + + if let Ok(meta) = entry.metadata() { + if meta.len() > MAX_FILE_BYTES { + continue; + } + } + + if entries.len() >= MAX_FILES { + truncated = true; + break; + } + entries.push(entry); + } + + let total_to_read = entries.len() as u64; + let files_processed = Arc::new(AtomicU64::new(0)); + + let mut files: Vec = entries + .par_iter() + .filter_map(|entry| { + let metadata = entry.metadata().ok()?; + let size = metadata.len(); + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + + let content = read_partial(entry.path()).ok()?; + let (title, tags) = parse_meta(&content); + + let rel_path = entry + .path() + .strip_prefix(root_path) + .unwrap_or(entry.path()) + .to_string_lossy() + .to_string(); + + let links = crate::links::links_from(&content, &rel_path); + + let count = files_processed.fetch_add(1, Ordering::Relaxed) + 1; + maybe_emit_read_progress( + progress, + &path, + count, + total_to_read, + Some(rel_path.clone()), + &last_emit, + ); + + Some(MarkdownFile { + path: entry.path().to_string_lossy().to_string(), + name: entry + .path() + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + rel_path, + title, + tags, + modified, + size, + links, + }) + }) + .collect(); + + files.sort_by_key(|a| a.rel_path.to_lowercase()); + + progress.emit(&ScanProgress { + root: path.clone(), + current_dir: ".".to_string(), + files_found: files.len() as u64, + dirs_visited: dirs_visited.load(Ordering::Relaxed), + last_file: None, + }); + + // Best-effort: a broken marker must not stop the GUI from browsing the + // folder; the MCP path fails loud through resolve_workspace instead. + let marker = marker_with_migration(root_path).ok().flatten(); + + Ok(ScanResult { + root: root_path.to_string_lossy().to_string(), + files, + truncated, + marker, + }) +} + +fn should_emit(last_emit: &Mutex) -> bool { + let mut guard = match last_emit.lock() { + Ok(g) => g, + Err(_) => return false, + }; + if guard.elapsed() < Duration::from_millis(PROGRESS_INTERVAL_MS) { + return false; + } + *guard = Instant::now(); + true +} + +#[allow(clippy::too_many_arguments)] +fn maybe_emit_walk_progress( + progress: &dyn ScanProgressSink, + root: &str, + current: &Path, + root_path: &Path, + dirs_visited: &AtomicU64, + files_found: u64, + last_file: Option, + last_emit: &Mutex, +) { + if !should_emit(last_emit) { + return; + } + let rel = current + .strip_prefix(root_path) + .unwrap_or(current) + .to_string_lossy() + .to_string(); + progress.emit(&ScanProgress { + root: root.to_string(), + current_dir: if rel.is_empty() { ".".into() } else { rel }, + files_found, + dirs_visited: dirs_visited.load(Ordering::Relaxed), + last_file, + }); +} + +fn maybe_emit_read_progress( + progress: &dyn ScanProgressSink, + root: &str, + files_found: u64, + total: u64, + last_file: Option, + last_emit: &Mutex, +) { + if !should_emit(last_emit) { + return; + } + progress.emit(&ScanProgress { + root: root.to_string(), + current_dir: format!("reading {} of {}", files_found, total), + files_found, + dirs_visited: 0, + last_file, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::test_dir; + + #[test] + fn scan_migrates_legacy_manifest_and_reports_marker() { + let dir = test_dir("scan_migrates"); + std::fs::write( + dir.join(".docs.yaml"), + "project:\n slug: voice\n name: Vinfra Voice\n tagline: dropped\n", + ) + .unwrap(); + std::fs::write(dir.join("readme.md"), "# Hi\n").unwrap(); + + let result = run_scan(&NoopProgressSink, dir.to_string_lossy().to_string()).unwrap(); + let marker = result.marker.expect("marker migrated during scan"); + assert_eq!(marker.slug, "voice"); + assert_eq!(marker.name.as_deref(), Some("Vinfra Voice")); + assert!(dir.join(".docsreader.yaml").is_file()); + assert!(dir.join(".docs.yaml").is_file()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scan_collects_resolved_relative_links() { + let dir = test_dir("scan_links"); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::write( + dir.join("sub/source.md"), + "# Src\nSee [target](../target.md) and [web](https://x.com/a.md).\n", + ) + .unwrap(); + std::fs::write(dir.join("target.md"), "# Target\n").unwrap(); + + let result = run_scan(&NoopProgressSink, dir.to_string_lossy().to_string()).unwrap(); + let source = result + .files + .iter() + .find(|f| f.rel_path.ends_with("source.md")) + .unwrap(); + assert_eq!(source.links, ["target.md"]); + let target = result + .files + .iter() + .find(|f| f.rel_path == "target.md") + .unwrap(); + assert!(target.links.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/slug.rs b/src-tauri/core/src/slug.rs new file mode 100644 index 0000000..ac9001c --- /dev/null +++ b/src-tauri/core/src/slug.rs @@ -0,0 +1,67 @@ +const FALLBACK_SLUG: &str = "untitled"; +const MAX_SLUG_CHARS: usize = 80; + +pub fn slugify(input: &str) -> String { + let mut slug = String::new(); + let mut pending_dash = false; + for c in input.chars() { + if c.is_alphanumeric() { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + slug.extend(c.to_lowercase()); + pending_dash = false; + } else { + pending_dash = true; + } + if slug.chars().count() >= MAX_SLUG_CHARS { + break; + } + } + if slug.is_empty() { + FALLBACK_SLUG.to_string() + } else { + slug + } +} + +pub fn unique_slug(base: &str, exists: impl Fn(&str) -> bool) -> String { + if !exists(base) { + return base.to_string(); + } + let mut n = 2u32; + loop { + let candidate = format!("{base}-{n}"); + if !exists(&candidate) { + return candidate; + } + n += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugifies_titles() { + assert_eq!(slugify("Hello, World!"), "hello-world"); + assert_eq!(slugify(" --Weird__ spacing "), "weird-spacing"); + assert_eq!(slugify("API v2.0 Design"), "api-v2-0-design"); + assert_eq!(slugify("مرحبا بالعالم"), "مرحبا-بالعالم"); + assert_eq!(slugify("!!!"), "untitled"); + } + + #[test] + fn caps_slug_length() { + let long = "a".repeat(500); + assert_eq!(slugify(&long).chars().count(), MAX_SLUG_CHARS); + } + + #[test] + fn resolves_collisions_with_counter() { + let taken = ["doc", "doc-2"]; + assert_eq!(unique_slug("doc", |s| taken.contains(&s)), "doc-3"); + assert_eq!(unique_slug("fresh", |s| taken.contains(&s)), "fresh"); + } +} diff --git a/src-tauri/core/src/tasks.rs b/src-tauri/core/src/tasks.rs new file mode 100644 index 0000000..2d44963 --- /dev/null +++ b/src-tauri/core/src/tasks.rs @@ -0,0 +1,465 @@ +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use serde::Serialize; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::{split_frontmatter, upsert_fm_line, yaml_line}; +use crate::update::str_replace_at; +use crate::write::{enforce_size_limit, stage_in_git}; + +pub const TASKS_DIR: &str = "tasks"; + +/// Backlog.md's DEFAULT_STATUSES, verbatim; the file format is theirs. +pub const TASK_STATUSES: [&str; 3] = ["To Do", "In Progress", "Done"]; +pub const TASK_PRIORITIES: [&str; 3] = ["high", "medium", "low"]; + +fn normalize_status(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .collect::() + .to_lowercase() +} + +/// Accepts any casing/spacing variant ("to-do", "In Progress", "done"). +pub fn parse_task_status(value: &str) -> Result<&'static str, CoreError> { + let wanted = normalize_status(value); + TASK_STATUSES + .into_iter() + .find(|s| normalize_status(s) == wanted) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidInput, + format!("unknown task status {value:?}"), + ) + .with_recovery(format!("valid statuses: [{}]", TASK_STATUSES.join(", "))) + }) +} + +fn parse_task_priority(value: &str) -> Result<&'static str, CoreError> { + TASK_PRIORITIES + .into_iter() + .find(|p| p.eq_ignore_ascii_case(value)) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidInput, + format!("unknown priority {value:?}"), + ) + .with_recovery(format!( + "valid priorities: [{}]", + TASK_PRIORITIES.join(", ") + )) + }) +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct TaskSummary { + pub id: String, + pub title: Option, + pub status: String, + pub assignee: Vec, + pub labels: Vec, + pub dependencies: Vec, + pub priority: Option, + pub created_date: Option, + pub updated_date: Option, + pub rel_path: String, + pub path: PathBuf, +} + +#[derive(Debug, Default)] +pub struct NewTask<'a> { + pub title: &'a str, + pub description: &'a str, + pub acceptance_criteria: Vec, + pub status: Option<&'a str>, + pub priority: Option<&'a str>, + pub assignee: Vec, + pub labels: Vec, + pub dependencies: Vec, + pub reporter: Option<&'a str>, +} + +const MAX_TITLE_FILE_CHARS: usize = 60; + +/// Backlog.md filename shape: "task-3 - Title-with-dashes.md". +fn task_file_name(id: &str, title: &str) -> String { + let mut sanitized = String::new(); + for c in title.chars() { + if c.is_alphanumeric() { + sanitized.push(c); + } else if !sanitized.ends_with('-') && !sanitized.is_empty() { + sanitized.push('-'); + } + } + let sanitized: String = sanitized + .trim_matches('-') + .chars() + .take(MAX_TITLE_FILE_CHARS) + .collect(); + let sanitized = sanitized.trim_matches('-'); + if sanitized.is_empty() { + format!("{id}.md") + } else { + format!("{id} - {sanitized}.md") + } +} + +fn yaml_list(key: &str, items: &[String]) -> Result { + if items.is_empty() { + return Ok(format!("{key}: []")); + } + let mut map = serde_yaml::Mapping::new(); + map.insert(key.into(), items.into()); + serde_yaml::to_string(&map) + .map(|s| s.trim_end().to_string()) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize {key}: {e}"))) +} + +fn render_task( + id: &str, + task: &NewTask<'_>, + status: &str, + today: &str, +) -> Result { + let mut fm = vec![ + format!("id: {id}"), + yaml_line("title", task.title)?, + yaml_line("status", status)?, + yaml_list("assignee", &task.assignee)?, + ]; + if let Some(reporter) = task.reporter { + fm.push(yaml_line("reporter", reporter)?); + } + // Dates are quoted the way Backlog.md's own serializer emits them, so + // YAML 1.1 parsers read strings, not timestamps. + fm.push(format!("created_date: '{today}'")); + fm.push(yaml_list("labels", &task.labels)?); + fm.push(yaml_list("dependencies", &task.dependencies)?); + if let Some(priority) = task.priority { + fm.push(yaml_line("priority", priority)?); + } + + let mut body = format!("## Description\n\n{}\n", task.description.trim_end()); + if !task.acceptance_criteria.is_empty() { + body.push_str("\n## Acceptance Criteria\n\n"); + for (i, criterion) in task.acceptance_criteria.iter().enumerate() { + body.push_str(&format!("- [ ] #{} {}\n", i + 1, criterion.trim())); + } + body.push_str("\n"); + } + Ok(format!("---\n{}\n---\n\n{body}", fm.join("\n"))) +} + +fn parse_task(path: &Path) -> Option { + if path.extension().is_none_or(|e| e != "md") || !path.is_file() { + return None; + } + let content = std::fs::read_to_string(path).ok()?; + let fm = split_frontmatter(&content).0?; + let map: serde_yaml::Mapping = serde_yaml::from_str(fm).ok()?; + let text = |key: &str| { + map.get(serde_yaml::Value::String(key.into())) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + let list = |key: &str| -> Vec { + map.get(serde_yaml::Value::String(key.into())) + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + }; + Some(TaskSummary { + id: text("id")?, + title: text("title"), + status: text("status").unwrap_or_else(|| TASK_STATUSES[0].to_string()), + assignee: list("assignee"), + labels: list("labels"), + dependencies: list("dependencies"), + priority: text("priority"), + created_date: text("created_date"), + updated_date: text("updated_date"), + rel_path: format!("{TASKS_DIR}/{}", path.file_name()?.to_string_lossy()), + path: path.to_path_buf(), + }) +} + +fn all_tasks(root: &Path) -> Vec { + let mut tasks: Vec = std::fs::read_dir(root.join(TASKS_DIR)) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| parse_task(&entry.path())) + .collect(); + tasks.sort_by_key(|a| task_ordinal(&a.id)); + tasks +} + +fn task_ordinal(id: &str) -> u64 { + id.rsplit('-') + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0) +} + +fn next_task_id(root: &Path) -> String { + let max = all_tasks(root) + .iter() + .map(|t| task_ordinal(&t.id)) + .max() + .unwrap_or(0); + format!("task-{}", max + 1) +} + +pub async fn write_task_core(root: &Path, task: &NewTask<'_>) -> Result { + if task.title.trim().is_empty() { + return Err(CoreError::new(ErrorCode::InvalidInput, "title is required") + .with_recovery("pass a short task title")); + } + let status = match task.status { + Some(s) => parse_task_status(s)?, + None => TASK_STATUSES[0], + }; + if let Some(p) = task.priority { + parse_task_priority(p)?; + } + let id = next_task_id(root); + let today = Utc::now().format("%Y-%m-%d").to_string(); + let rendered = render_task(&id, task, status, &today)?; + enforce_size_limit(rendered.len())?; + let rel_path = format!("{TASKS_DIR}/{}", task_file_name(&id, task.title)); + let path = root.join(&rel_path); + std::fs::create_dir_all(path.parent().unwrap_or(root))?; + std::fs::write(&path, rendered)?; + stage_in_git(root, &[&rel_path]).await; + parse_task(&path) + .ok_or_else(|| CoreError::new(ErrorCode::Io, format!("task {id} written but unreadable"))) +} + +pub fn list_tasks_core( + root: &Path, + status: Option<&str>, + label: Option<&str>, +) -> Result, CoreError> { + let status = status.map(parse_task_status).transpose()?; + Ok(all_tasks(root) + .into_iter() + .filter(|t| status.is_none_or(|s| t.status == s)) + .filter(|t| label.is_none_or(|l| t.labels.iter().any(|x| x == l))) + .collect()) +} + +/// Accepts a task id ("task-3") or a tasks/ relative path. +fn locate_task(root: &Path, task_ref: &str) -> Result { + let by_id = |id: &str| all_tasks(root).into_iter().find(|t| t.id == id); + let found = if let Some(name) = task_ref.strip_prefix("tasks/") { + all_tasks(root) + .into_iter() + .find(|t| t.rel_path == task_ref || t.path.file_name().is_some_and(|f| f == name)) + } else { + by_id(task_ref) + }; + found.ok_or_else(|| { + CoreError::new( + ErrorCode::DocNotFound, + format!("no task found for {task_ref:?}"), + ) + .with_recovery("pass a task id like \"task-3\"; see list_tasks") + }) +} + +fn touch_updated_date(content: &str) -> String { + let stamp = Utc::now().format("%Y-%m-%d %H:%M").to_string(); + upsert_fm_line(content, "updated_date", &format!("updated_date: '{stamp}'")) +} + +async fn rewrite_task(root: &Path, task: &TaskSummary, content: String) -> Result<(), CoreError> { + enforce_size_limit(content.len())?; + std::fs::write(&task.path, content)?; + stage_in_git(root, &[&task.rel_path]).await; + Ok(()) +} + +pub async fn set_task_status_core( + root: &Path, + task_ref: &str, + status: &str, +) -> Result { + let status = parse_task_status(status)?; + let task = locate_task(root, task_ref)?; + let content = std::fs::read_to_string(&task.path)?; + let line = yaml_line("status", status)?; + let updated = touch_updated_date(&upsert_fm_line(&content, "status", &line)); + rewrite_task(root, &task, updated).await?; + parse_task(&task.path) + .ok_or_else(|| CoreError::new(ErrorCode::Io, "task updated but unreadable")) +} + +/// str_replace on a task file (check acceptance criteria, extend sections). +pub async fn update_task_core( + root: &Path, + task_ref: &str, + old_str: &str, + new_str: &str, +) -> Result { + let task = locate_task(root, task_ref)?; + str_replace_at(root, &task.path, &task.rel_path, old_str, new_str).await?; + let content = std::fs::read_to_string(&task.path)?; + rewrite_task(root, &task, touch_updated_date(&content)).await?; + parse_task(&task.path) + .ok_or_else(|| CoreError::new(ErrorCode::Io, "task updated but unreadable")) +} + +pub async fn delete_task_core(root: &Path, task_ref: &str) -> Result { + let task = locate_task(root, task_ref)?; + std::fs::remove_file(&task.path)?; + stage_in_git(root, &[&task.rel_path]).await; + Ok(task) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dr_task_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn write_renders_backlog_md_shape_with_incrementing_ids() { + let root = test_dir("shape"); + let task = write_task_core( + &root, + &NewTask { + title: "Add core search functionality", + description: "Search across docs.", + acceptance_criteria: vec!["Results ranked".into(), "Budget enforced".into()], + labels: vec!["enhancement".into()], + priority: Some("medium"), + reporter: Some("claude-code"), + ..NewTask::default() + }, + ) + .await + .unwrap(); + assert_eq!(task.id, "task-1"); + assert_eq!( + task.rel_path, + "tasks/task-1 - Add-core-search-functionality.md" + ); + assert_eq!(task.status, "To Do"); + + let raw = std::fs::read_to_string(&task.path).unwrap(); + assert!(raw.starts_with("---\nid: task-1\ntitle: Add core search functionality\nstatus: To Do\nassignee: []\nreporter: claude-code\ncreated_date: '"), "exact backlog frontmatter order: {raw}"); + assert!(raw.contains("labels:\n- enhancement")); + assert!(raw.contains("dependencies: []")); + assert!(raw.contains("priority: medium")); + assert!(raw.contains("## Description\n\nSearch across docs.")); + assert!(raw.contains("## Acceptance Criteria\n\n- [ ] #1 Results ranked\n- [ ] #2 Budget enforced\n")); + + let next = write_task_core( + &root, + &NewTask { + title: "Second", + description: "x", + ..NewTask::default() + }, + ) + .await + .unwrap(); + assert_eq!(next.id, "task-2"); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn status_updates_in_frontmatter_and_lenient_parse() { + let root = test_dir("status"); + write_task_core( + &root, + &NewTask { + title: "Move me", + description: "x", + ..NewTask::default() + }, + ) + .await + .unwrap(); + + let moved = set_task_status_core(&root, "task-1", "in-progress") + .await + .unwrap(); + assert_eq!(moved.status, "In Progress"); + assert!(moved.updated_date.is_some()); + let raw = std::fs::read_to_string(&moved.path).unwrap(); + assert!(raw.contains("status: In Progress")); + assert!(raw.contains("updated_date: '")); + + let err = set_task_status_core(&root, "task-1", "blocked") + .await + .unwrap_err(); + assert!(err.recovery.unwrap().contains("To Do, In Progress, Done")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn update_checks_acceptance_criterion_and_list_filters() { + let root = test_dir("update"); + write_task_core( + &root, + &NewTask { + title: "With AC", + description: "x", + acceptance_criteria: vec!["Ship it".into()], + labels: vec!["mcp".into()], + ..NewTask::default() + }, + ) + .await + .unwrap(); + + update_task_core(&root, "task-1", "- [ ] #1 Ship it", "- [x] #1 Ship it") + .await + .unwrap(); + let raw = std::fs::read_to_string(root.join("tasks/task-1 - With-AC.md")).unwrap(); + assert!(raw.contains("- [x] #1 Ship it")); + + let done = list_tasks_core(&root, Some("done"), None).unwrap(); + assert!(done.is_empty()); + let tagged = list_tasks_core(&root, None, Some("mcp")).unwrap(); + assert_eq!(tagged.len(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn delete_removes_task_file() { + let root = test_dir("del"); + let task = write_task_core( + &root, + &NewTask { + title: "Doomed", + description: "x", + ..NewTask::default() + }, + ) + .await + .unwrap(); + delete_task_core(&root, "tasks/task-1 - Doomed.md") + .await + .unwrap(); + assert!(!task.path.exists()); + + let err = delete_task_core(&root, "task-1").await.unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/update.rs b/src-tauri/core/src/update.rs new file mode 100644 index 0000000..ad5214e --- /dev/null +++ b/src-tauri/core/src/update.rs @@ -0,0 +1,146 @@ +use std::path::Path; + +use crate::error::{CoreError, ErrorCode}; +use crate::git; +use crate::write::{enforce_size_limit, locate_doc, WrittenDoc}; + +fn match_line_numbers(content: &str, needle: &str) -> Vec { + content + .match_indices(needle) + .map(|(offset, _)| content[..offset].matches('\n').count() + 1) + .collect() +} + +/// str_replace with the Anthropic memory-tool contract: old_str must appear +/// exactly once, and the error strings match that tool's reference wording +/// verbatim so agents trained on it recover the same way. +pub async fn str_replace_core( + root: &Path, + doc_ref: &str, + old_str: &str, + new_str: &str, +) -> Result { + let doc = locate_doc(root, doc_ref)?; + str_replace_at(root, &doc.path, &doc.rel_path, old_str, new_str).await?; + Ok(doc.to_written()) +} + +/// The memory-tool str_replace contract applied to an already-located file. +pub(crate) async fn str_replace_at( + root: &Path, + path: &std::path::Path, + rel_path: &str, + old_str: &str, + new_str: &str, +) -> Result<(), CoreError> { + if old_str.is_empty() { + return Err( + CoreError::new(ErrorCode::InvalidInput, "old_str must not be empty") + .with_recovery("pass the exact text to replace"), + ); + } + let content = std::fs::read_to_string(path)?; + let lines = match_line_numbers(&content, old_str); + match lines.len() { + 0 => Err(CoreError::new( + ErrorCode::InvalidInput, + format!( + "No replacement was performed, old_str `{old_str}` did not appear verbatim in {rel_path}." + ), + )), + 1 => { + let updated = content.replacen(old_str, new_str, 1); + enforce_size_limit(updated.len())?; + std::fs::write(path, updated)?; + let root_str = root.to_string_lossy(); + if git::is_git_repo(&root_str).await { + git::git_add(&root_str, &[rel_path]).await; + } + Ok(()) + } + _ => { + let mut unique_lines = lines; + unique_lines.dedup(); + let listed: Vec = unique_lines.iter().map(usize::to_string).collect(); + Err(CoreError::new( + ErrorCode::InvalidInput, + format!( + "No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines: {}. Please ensure it is unique", + listed.join(", ") + ), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::write::{write_doc_core, DocStatus, NewDoc}; + + fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_upd_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn replaces_a_unique_match() { + let root = test_dir("ok"); + let doc = write_doc_core( + &root, + &NewDoc::new("Prefs", "Favorite color: blue", DocStatus::Research), + ) + .await + .unwrap(); + + str_replace_core(&root, &doc.slug, "blue", "green") + .await + .unwrap(); + let raw = std::fs::read_to_string(&doc.path).unwrap(); + assert!(raw.contains("Favorite color: green")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn no_match_uses_verbatim_memory_tool_error() { + let root = test_dir("nomatch"); + let doc = write_doc_core(&root, &NewDoc::new("Prefs", "text", DocStatus::Research)) + .await + .unwrap(); + + let err = str_replace_core(&root, &doc.slug, "absent", "x") + .await + .unwrap_err(); + assert_eq!( + err.message, + "No replacement was performed, old_str `absent` did not appear verbatim in research/prefs.md." + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn duplicate_match_lists_lines_and_asks_for_unique() { + let root = test_dir("dupe"); + let doc = write_doc_core( + &root, + &NewDoc::new("Dupes", "alpha\nother\nalpha", DocStatus::Research), + ) + .await + .unwrap(); + + let err = str_replace_core(&root, &doc.slug, "alpha", "x") + .await + .unwrap_err(); + assert!( + err.message.starts_with( + "No replacement was performed. Multiple occurrences of old_str `alpha` in lines:" + ), + "got: {}", + err.message + ); + assert!(err.message.ends_with("Please ensure it is unique")); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/workspace/init.rs b/src-tauri/core/src/workspace/init.rs new file mode 100644 index 0000000..8c7ebb0 --- /dev/null +++ b/src-tauri/core/src/workspace/init.rs @@ -0,0 +1,244 @@ +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use super::registry::{load_registry, upsert_workspace, WorkspaceEntry}; +use super::{load_marker, save_marker, WorkspaceMarker, WorkspaceScope, MARKER_FILE}; +use crate::error::{CoreError, ErrorCode}; +use crate::slug::slugify; +use crate::write::DocStatus; + +#[derive(Debug, Serialize)] +pub struct InitializedWorkspace { + pub root: PathBuf, + pub slug: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub scope: WorkspaceScope, +} + +fn default_slug(root: &Path, scope: WorkspaceScope) -> String { + if scope == WorkspaceScope::User { + return super::resolve::DEFAULT_USER_SLUG.to_string(); + } + let parent_name = root + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + slugify(&parent_name) +} + +fn ensure_target_is_fresh(root: &Path) -> Result<(), CoreError> { + if !root.exists() { + return Ok(()); + } + if load_marker(root)?.is_some() { + return Err(CoreError::new( + ErrorCode::Conflict, + format!("{} is already a DocsReader workspace", root.display()), + ) + .with_recovery("it is ready to use; call write_doc or list_workspaces")); + } + let has_content = std::fs::read_dir(root)?.next().is_some(); + if has_content { + return Err(CoreError::new( + ErrorCode::Conflict, + format!( + "{} already has content but no {MARKER_FILE}", + root.display() + ), + ) + .with_recovery("pick an empty or new directory, or convert it in the DocsReader app")); + } + Ok(()) +} + +fn materialize_workspace( + root: &Path, + slug: String, + name: Option<&str>, + scope: WorkspaceScope, + registry_file: &Path, +) -> Result { + let marker = WorkspaceMarker { + slug: slug.clone(), + name: name.map(str::to_string), + homepage: None, + }; + save_marker(root, &marker)?; + for status in DocStatus::ALL { + std::fs::create_dir_all(root.join(status.folder()))?; + } + upsert_workspace( + registry_file, + WorkspaceEntry { + slug: slug.clone(), + path: root.to_path_buf(), + scope, + }, + )?; + Ok(InitializedWorkspace { + root: root.to_path_buf(), + slug, + name: name.map(str::to_string), + scope, + }) +} + +pub fn init_workspace_core( + root: &Path, + slug: Option<&str>, + name: Option<&str>, + scope: WorkspaceScope, + registry_file: &Path, +) -> Result { + ensure_target_is_fresh(root)?; + let slug = match slug { + Some(s) => s.to_string(), + None => default_slug(root, scope), + }; + materialize_workspace(root, slug, name, scope, registry_file) +} + +/// Converts an existing folder of markdown into a managed workspace in place: +/// marker, status folders, registry entry. This is the GUI's answer to init's +/// "already has content" conflict; the slug derives from the folder name and +/// is suffixed if another registered workspace already uses it. +pub fn convert_workspace_core( + root: &Path, + registry_file: &Path, +) -> Result { + if load_marker(root)?.is_some() { + return Err(CoreError::new( + ErrorCode::Conflict, + format!("{} is already a DocsReader workspace", root.display()), + ) + .with_recovery("it is ready to use as-is")); + } + let base = root + .file_name() + .and_then(|n| n.to_str()) + .map(slugify) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidInput, + format!("cannot derive a workspace slug from {}", root.display()), + ) + })?; + let slug = free_slug(base, root, registry_file)?; + materialize_workspace(root, slug, None, WorkspaceScope::Project, registry_file) +} + +fn free_slug(base: String, root: &Path, registry_file: &Path) -> Result { + let entries = load_registry(registry_file)?; + let taken = |slug: &str| entries.iter().any(|e| e.slug == slug && e.path != root); + if !taken(&base) { + return Ok(base); + } + Ok((2..) + .map(|n| format!("{base}-{n}")) + .find(|candidate| !taken(candidate)) + .expect("unbounded suffix search always finds a free slug")) +} + +#[cfg(test)] +mod tests { + use super::super::test_dir; + use super::*; + + #[test] + fn init_creates_marker_status_folders_and_registers() { + let dir = test_dir("init_ok"); + let root = dir.join("myrepo/notes"); + let registry = dir.join("registry.json"); + + let ws = + init_workspace_core(&root, None, None, WorkspaceScope::Project, ®istry).unwrap(); + assert_eq!(ws.slug, "myrepo", "slug derives from parent folder"); + for folder in ["research", "in-progress", "done", "archived"] { + assert!(root.join(folder).is_dir(), "missing {folder}"); + } + assert!(load_marker(&root).unwrap().is_some()); + + let entries = super::super::registry::load_registry(®istry).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "myrepo"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn init_on_existing_workspace_is_conflict() { + let dir = test_dir("init_twice"); + let root = dir.join("notes"); + let registry = dir.join("registry.json"); + init_workspace_core(&root, None, None, WorkspaceScope::User, ®istry).unwrap(); + + let err = + init_workspace_core(&root, None, None, WorkspaceScope::User, ®istry).unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn convert_turns_populated_folder_into_workspace() { + let dir = test_dir("convert_ok"); + let root = dir.join("my-notes"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("existing.md"), "# Hi\n").unwrap(); + let registry = dir.join("registry.json"); + + let ws = convert_workspace_core(&root, ®istry).unwrap(); + assert_eq!(ws.slug, "my-notes"); + assert_eq!(ws.scope, WorkspaceScope::Project); + assert!(load_marker(&root).unwrap().is_some()); + for folder in ["research", "in-progress", "done", "archived"] { + assert!(root.join(folder).is_dir(), "missing {folder}"); + } + assert!(root.join("existing.md").is_file(), "content untouched"); + + let err = convert_workspace_core(&root, ®istry).unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict, "second convert conflicts"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn convert_suffixes_slug_taken_by_another_path() { + let dir = test_dir("convert_dupe"); + let registry = dir.join("registry.json"); + let first = dir.join("a/notes"); + let second = dir.join("b/notes"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + + assert_eq!( + convert_workspace_core(&first, ®istry).unwrap().slug, + "notes" + ); + assert_eq!( + convert_workspace_core(&second, ®istry).unwrap().slug, + "notes-2" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn init_on_populated_non_workspace_dir_is_conflict() { + let dir = test_dir("init_populated"); + let root = dir.join("stuff"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("existing.txt"), "x").unwrap(); + + let err = init_workspace_core( + &root, + None, + None, + WorkspaceScope::Project, + &dir.join("r.json"), + ) + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/migrate.rs b/src-tauri/core/src/workspace/migrate.rs new file mode 100644 index 0000000..79fdd0b --- /dev/null +++ b/src-tauri/core/src/workspace/migrate.rs @@ -0,0 +1,148 @@ +use std::path::Path; + +use serde::Deserialize; + +use super::{load_marker, save_marker, WorkspaceMarker}; +use crate::error::CoreError; +use crate::slug::slugify; + +const LEGACY_FILES: [&str; 2] = [".docs.yaml", "docs.yaml"]; + +#[derive(Debug, Default, Deserialize)] +struct LegacyProject { + slug: Option, + name: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct LegacyManifest { + project: Option, +} + +/// Loads the workspace marker, migrating a legacy `.docs.yaml`/`docs.yaml` +/// manifest on first sight: only the project slug and name carry over, and +/// the legacy file stays in place for one release so older builds keep +/// working. Folders whose legacy manifest names no project are left alone - +/// a bare `docs.yaml` is not proof the folder was a DocsReader workspace. +pub fn marker_with_migration(root: &Path) -> Result, CoreError> { + if let Some(marker) = load_marker(root)? { + return Ok(Some(marker)); + } + let Some(project) = legacy_project(root) else { + return Ok(None); + }; + let Some(slug) = [project.slug.as_deref(), project.name.as_deref()] + .into_iter() + .flatten() + .map(slugify) + .find(|s| !s.is_empty()) + else { + return Ok(None); + }; + let name = project + .name + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()); + let marker = WorkspaceMarker { + slug, + name, + homepage: None, + }; + save_marker(root, &marker)?; + Ok(Some(marker)) +} + +fn legacy_project(root: &Path) -> Option { + LEGACY_FILES + .iter() + .find_map(|name| std::fs::read_to_string(root.join(name)).ok()) + .and_then(|raw| serde_yaml::from_str::(&raw).ok()) + .and_then(|manifest| manifest.project) +} + +#[cfg(test)] +mod tests { + use super::super::{test_dir, MARKER_FILE}; + use super::*; + + const RICH_LEGACY: &str = r##" +spec_version: "0.1" +project: + slug: voice + name: Vinfra Voice + tagline: Carrier-grade VoIP + icon: phone + homepage: docs/spec/architecture.md +navigation: + - title: Start here + items: + - title: Overview + path: docs/overview.md +ignore: + - docs/archived/** +visibility: internal +"##; + + #[test] + fn migrates_legacy_manifest_keeping_slug_and_name_only() { + let dir = test_dir("mig_rich"); + std::fs::write(dir.join(".docs.yaml"), RICH_LEGACY).unwrap(); + + let marker = marker_with_migration(&dir).unwrap().unwrap(); + assert_eq!(marker.slug, "voice"); + assert_eq!(marker.name.as_deref(), Some("Vinfra Voice")); + assert_eq!(marker.homepage, None); + + let written = std::fs::read_to_string(dir.join(MARKER_FILE)).unwrap(); + for dropped in ["tagline", "icon", "navigation", "ignore", "visibility"] { + assert!(!written.contains(dropped), "{dropped} should be dropped"); + } + assert!( + dir.join(".docs.yaml").exists(), + "legacy file stays for one release" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn existing_marker_wins_over_legacy_manifest() { + let dir = test_dir("mig_marker_wins"); + std::fs::write(dir.join(MARKER_FILE), "slug: existing\n").unwrap(); + std::fs::write(dir.join(".docs.yaml"), RICH_LEGACY).unwrap(); + + let marker = marker_with_migration(&dir).unwrap().unwrap(); + assert_eq!(marker.slug, "existing"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn foreign_docs_yaml_without_project_is_ignored() { + let dir = test_dir("mig_foreign"); + std::fs::write(dir.join("docs.yaml"), "site_name: Some Other Tool\n").unwrap(); + + assert_eq!(marker_with_migration(&dir).unwrap(), None); + assert!(!dir.join(MARKER_FILE).exists(), "no marker written"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn non_slug_legacy_values_are_slugified() { + let dir = test_dir("mig_badslug"); + std::fs::write( + dir.join(".docs.yaml"), + "project:\n slug: \"Bad Slug!\"\n name: Bad Slug\n", + ) + .unwrap(); + + let marker = marker_with_migration(&dir).unwrap().unwrap(); + assert_eq!(marker.slug, "bad-slug"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn plain_folder_yields_none() { + let dir = test_dir("mig_plain"); + assert_eq!(marker_with_migration(&dir).unwrap(), None); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/mod.rs b/src-tauri/core/src/workspace/mod.rs new file mode 100644 index 0000000..7657dd8 --- /dev/null +++ b/src-tauri/core/src/workspace/mod.rs @@ -0,0 +1,129 @@ +pub mod init; +pub mod migrate; +pub mod registry; +pub mod resolve; + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::{CoreError, ErrorCode}; +use crate::slug::slugify; + +pub const MARKER_FILE: &str = ".docsreader.yaml"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceScope { + User, + Project, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceMarker { + pub slug: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub homepage: Option, +} + +fn validate_slug(slug: &str) -> Result<(), CoreError> { + if slug.is_empty() || slugify(slug) != slug { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("invalid workspace slug {slug:?}"), + ) + .with_recovery( + "slugs are lowercase alphanumerics separated by dashes, e.g. \"my-project\"", + )); + } + Ok(()) +} + +pub fn load_marker(dir: &Path) -> Result, CoreError> { + let path = dir.join(MARKER_FILE); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + let marker: WorkspaceMarker = serde_yaml::from_str(&raw).map_err(|e| { + CoreError::new( + ErrorCode::InvalidInput, + format!("malformed {MARKER_FILE}: {e}"), + ) + .with_recovery("the marker needs at least a `slug:` line") + })?; + validate_slug(&marker.slug)?; + Ok(Some(marker)) +} + +pub fn save_marker(dir: &Path, marker: &WorkspaceMarker) -> Result<(), CoreError> { + validate_slug(&marker.slug)?; + let raw = serde_yaml::to_string(marker) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize {MARKER_FILE}: {e}")))?; + std::fs::create_dir_all(dir)?; + std::fs::write(dir.join(MARKER_FILE), raw)?; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_ws_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marker_round_trips() { + let dir = test_dir("marker_rt"); + let marker = WorkspaceMarker { + slug: "my-project".into(), + name: Some("My Project".into()), + homepage: Some("index.md".into()), + }; + save_marker(&dir, &marker).unwrap(); + assert_eq!(load_marker(&dir).unwrap(), Some(marker)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_marker_is_none() { + let dir = test_dir("marker_none"); + assert_eq!(load_marker(&dir).unwrap(), None); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_slug_is_typed_error() { + let dir = test_dir("marker_noslug"); + std::fs::write(dir.join(MARKER_FILE), "name: No Slug Here\n").unwrap(); + let err = load_marker(&dir).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn non_slug_value_rejected_on_save_and_load() { + let dir = test_dir("marker_badslug"); + let bad = WorkspaceMarker { + slug: "Bad Slug!".into(), + name: None, + homepage: None, + }; + assert_eq!( + save_marker(&dir, &bad).unwrap_err().code, + ErrorCode::InvalidInput + ); + std::fs::write(dir.join(MARKER_FILE), "slug: \"Bad Slug!\"\n").unwrap(); + assert_eq!(load_marker(&dir).unwrap_err().code, ErrorCode::InvalidInput); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/registry.rs b/src-tauri/core/src/workspace/registry.rs new file mode 100644 index 0000000..0ca1f25 --- /dev/null +++ b/src-tauri/core/src/workspace/registry.rs @@ -0,0 +1,113 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::WorkspaceScope; +use crate::error::{CoreError, ErrorCode}; + +pub const REGISTRY_DIR: &str = ".docsreader"; +pub const REGISTRY_FILE: &str = "workspaces.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceEntry { + pub slug: String, + pub path: PathBuf, + pub scope: WorkspaceScope, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct RegistryFile { + workspaces: Vec, +} + +pub fn default_registry_path(home: &Path) -> PathBuf { + home.join(REGISTRY_DIR).join(REGISTRY_FILE) +} + +pub fn load_registry(file: &Path) -> Result, CoreError> { + let raw = match std::fs::read_to_string(file) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + let parsed: RegistryFile = serde_json::from_str(&raw).map_err(|e| { + CoreError::new( + ErrorCode::InvalidInput, + format!("malformed workspace registry: {e}"), + ) + .with_recovery(format!("delete or fix {}", file.display())) + })?; + Ok(parsed.workspaces) +} + +pub fn save_registry(file: &Path, workspaces: &[WorkspaceEntry]) -> Result<(), CoreError> { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + let raw = serde_json::to_string_pretty(&RegistryFile { + workspaces: workspaces.to_vec(), + }) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize registry: {e}")))?; + std::fs::write(file, raw)?; + Ok(()) +} + +/// Replaces any entry with the same path, so re-registering updates slug/scope. +pub fn upsert_workspace(file: &Path, entry: WorkspaceEntry) -> Result<(), CoreError> { + let mut workspaces = load_registry(file)?; + workspaces.retain(|w| w.path != entry.path); + workspaces.push(entry); + save_registry(file, &workspaces) +} + +#[cfg(test)] +mod tests { + use super::super::test_dir; + use super::*; + + fn entry(slug: &str, path: &str, scope: WorkspaceScope) -> WorkspaceEntry { + WorkspaceEntry { + slug: slug.into(), + path: PathBuf::from(path), + scope, + } + } + + #[test] + fn missing_registry_is_empty() { + let dir = test_dir("reg_missing"); + assert_eq!(load_registry(&dir.join("nope.json")).unwrap(), Vec::new()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_round_trips_and_dedupes_by_path() { + let dir = test_dir("reg_upsert"); + let file = dir.join(REGISTRY_FILE); + upsert_workspace(&file, entry("notes", "/home/u/notes", WorkspaceScope::User)).unwrap(); + upsert_workspace(&file, entry("proj", "/repo/notes", WorkspaceScope::Project)).unwrap(); + upsert_workspace( + &file, + entry("renamed", "/repo/notes", WorkspaceScope::Project), + ) + .unwrap(); + + let entries = load_registry(&file).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].slug, "notes"); + assert_eq!(entries[1].slug, "renamed"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn malformed_registry_is_typed_error() { + let dir = test_dir("reg_bad"); + let file = dir.join(REGISTRY_FILE); + std::fs::write(&file, "not json").unwrap(); + assert_eq!( + load_registry(&file).unwrap_err().code, + ErrorCode::InvalidInput + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/resolve.rs b/src-tauri/core/src/workspace/resolve.rs new file mode 100644 index 0000000..9c20170 --- /dev/null +++ b/src-tauri/core/src/workspace/resolve.rs @@ -0,0 +1,257 @@ +use std::path::{Path, PathBuf}; + +use super::registry::WorkspaceEntry; +use super::{load_marker, WorkspaceScope}; +use crate::error::{CoreError, ErrorCode}; + +pub const DEFAULT_WORKSPACE_DIR: &str = "notes"; +pub const DEFAULT_USER_SLUG: &str = "notes"; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ResolvedWorkspace { + pub root: PathBuf, + pub slug: String, + pub scope: WorkspaceScope, +} + +/// Resolution order mirrors Claude Code's user+project hierarchy: +/// explicit slug > client roots hint > walk-up from cwd > default user +/// workspace. An explicit slug matches the registry or the ambient +/// (roots/walk-up/default) workspace, so every slug the server reports is +/// resolvable even before registration. Only an explicit slug matching +/// neither fails. +pub fn resolve_workspace( + explicit_slug: Option<&str>, + roots_hint: &[PathBuf], + cwd: &Path, + home: &Path, + registry: &[WorkspaceEntry], +) -> Result { + let ambient = ambient_workspace(roots_hint, cwd, home)?; + match explicit_slug { + Some(slug) => resolve_explicit(slug, registry, ambient), + None => Ok(ambient), + } +} + +fn ambient_workspace( + roots_hint: &[PathBuf], + cwd: &Path, + home: &Path, +) -> Result { + for base in roots_hint.iter().map(PathBuf::as_path).chain(walk_up(cwd)) { + if let Some(found) = project_workspace_at(base)? { + return Ok(found); + } + } + user_default(home) +} + +fn resolve_explicit( + slug: &str, + registry: &[WorkspaceEntry], + ambient: ResolvedWorkspace, +) -> Result { + if let Some(entry) = registry.iter().find(|w| w.slug == slug && w.path.is_dir()) { + return Ok(ResolvedWorkspace { + root: entry.path.clone(), + slug: entry.slug.clone(), + scope: entry.scope, + }); + } + if ambient.slug == slug && ambient.root.is_dir() { + return Ok(ambient); + } + let available = available_slugs(registry, Some(&ambient)); + Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("no workspace with slug {slug:?}"), + ) + .with_recovery(format!( + "available workspaces: [{}]; call list_workspaces or init_workspace", + available.join(", ") + ))) +} + +/// Slugs that would resolve right now: registered workspaces whose directory +/// still exists, plus the ambient workspace when it exists unregistered. +pub fn available_slugs( + registry: &[WorkspaceEntry], + ambient: Option<&ResolvedWorkspace>, +) -> Vec { + let mut available: Vec = registry + .iter() + .filter(|w| w.path.is_dir()) + .map(|w| w.slug.clone()) + .collect(); + if let Some(ambient) = ambient { + if ambient.root.is_dir() && !available.iter().any(|s| s == &ambient.slug) { + available.push(ambient.slug.clone()); + } + } + available +} + +fn walk_up(cwd: &Path) -> impl Iterator { + cwd.ancestors() +} + +fn project_workspace_at(base: &Path) -> Result, CoreError> { + let candidate = base.join(DEFAULT_WORKSPACE_DIR); + match load_marker(&candidate)? { + Some(marker) => Ok(Some(ResolvedWorkspace { + root: candidate, + slug: marker.slug, + scope: WorkspaceScope::Project, + })), + None => Ok(None), + } +} + +fn user_default(home: &Path) -> Result { + let root = home.join(DEFAULT_WORKSPACE_DIR); + let slug = match load_marker(&root)? { + Some(marker) => marker.slug, + None => DEFAULT_USER_SLUG.to_string(), + }; + Ok(ResolvedWorkspace { + root, + slug, + scope: WorkspaceScope::User, + }) +} + +#[cfg(test)] +mod tests { + use super::super::{save_marker, test_dir, WorkspaceMarker}; + use super::*; + + fn marker(slug: &str) -> WorkspaceMarker { + WorkspaceMarker { + slug: slug.into(), + name: None, + homepage: None, + } + } + + #[test] + fn walk_up_finds_project_workspace_from_nested_cwd() { + let dir = test_dir("res_walkup"); + let project = dir.join("repo"); + save_marker(&project.join("notes"), &marker("repo-notes")).unwrap(); + let cwd = project.join("src/deeply/nested"); + std::fs::create_dir_all(&cwd).unwrap(); + + let resolved = resolve_workspace(None, &[], &cwd, &dir.join("home"), &[]).unwrap(); + assert_eq!(resolved.slug, "repo-notes"); + assert_eq!(resolved.scope, WorkspaceScope::Project); + assert_eq!(resolved.root, project.join("notes")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn roots_hint_wins_over_cwd_walk_up() { + let dir = test_dir("res_roots"); + let hinted = dir.join("hinted"); + save_marker(&hinted.join("notes"), &marker("hinted-notes")).unwrap(); + let other = dir.join("other"); + save_marker(&other.join("notes"), &marker("other-notes")).unwrap(); + + let resolved = resolve_workspace( + None, + std::slice::from_ref(&hinted), + &other, + &dir.join("home"), + &[], + ) + .unwrap(); + assert_eq!(resolved.slug, "hinted-notes"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn no_marker_defaults_to_user_workspace() { + let dir = test_dir("res_default"); + let home = dir.join("home"); + let cwd = dir.join("elsewhere"); + std::fs::create_dir_all(&cwd).unwrap(); + + let resolved = resolve_workspace(None, &[], &cwd, &home, &[]).unwrap(); + assert_eq!(resolved.root, home.join("notes")); + assert_eq!(resolved.slug, DEFAULT_USER_SLUG); + assert_eq!(resolved.scope, WorkspaceScope::User); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn user_workspace_marker_slug_wins_over_default() { + let dir = test_dir("res_userslug"); + let home = dir.join("home"); + save_marker(&home.join("notes"), &marker("ali-notes")).unwrap(); + + let resolved = resolve_workspace(None, &[], &dir, &home, &[]).unwrap(); + assert_eq!(resolved.slug, "ali-notes"); + assert_eq!(resolved.scope, WorkspaceScope::User); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explicit_slug_resolves_from_registry() { + let dir = test_dir("res_explicit"); + let ws = dir.join("proj/notes"); + save_marker(&ws, &marker("proj")).unwrap(); + let registry = vec![WorkspaceEntry { + slug: "proj".into(), + path: ws.clone(), + scope: WorkspaceScope::Project, + }]; + + let resolved = resolve_workspace(Some("proj"), &[], &dir, &dir, ®istry).unwrap(); + assert_eq!(resolved.root, ws); + assert_eq!(resolved.scope, WorkspaceScope::Project); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explicit_slug_matches_unregistered_walk_up_workspace() { + let dir = test_dir("res_ambient"); + let project = dir.join("repo"); + save_marker(&project.join("notes"), &marker("repo-notes")).unwrap(); + + let resolved = + resolve_workspace(Some("repo-notes"), &[], &project, &dir.join("home"), &[]).unwrap(); + assert_eq!(resolved.root, project.join("notes")); + assert_eq!(resolved.scope, WorkspaceScope::Project); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unknown_slug_recovery_lists_ambient_workspace() { + let dir = test_dir("res_ambient_list"); + let project = dir.join("repo"); + save_marker(&project.join("notes"), &marker("repo-notes")).unwrap(); + + let err = + resolve_workspace(Some("ghost"), &[], &project, &dir.join("home"), &[]).unwrap_err(); + assert!(err.recovery.as_deref().unwrap().contains("repo-notes")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explicit_unknown_slug_fails_loud_with_available_list() { + let dir = test_dir("res_unknown"); + let ws = dir.join("known/notes"); + save_marker(&ws, &marker("known")).unwrap(); + let registry = vec![WorkspaceEntry { + slug: "known".into(), + path: ws, + scope: WorkspaceScope::Project, + }]; + + let err = resolve_workspace(Some("ghost"), &[], &dir, &dir, ®istry).unwrap_err(); + assert_eq!(err.code, ErrorCode::WorkspaceNotFound); + assert!(err.recovery.as_deref().unwrap().contains("known")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/write.rs b/src-tauri/core/src/write.rs new file mode 100644 index 0000000..945c644 --- /dev/null +++ b/src-tauri/core/src/write.rs @@ -0,0 +1,586 @@ +use std::path::{Path, PathBuf}; + +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::{CoreError, ErrorCode}; +use crate::git; +use crate::scan::MAX_FILE_BYTES; +use crate::slug::{slugify, unique_slug}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum DocStatus { + Research, + InProgress, + Done, + Archived, +} + +impl DocStatus { + pub const ALL: [Self; 4] = [Self::Research, Self::InProgress, Self::Done, Self::Archived]; + + pub fn folder(self) -> &'static str { + match self { + Self::Research => "research", + Self::InProgress => "in-progress", + Self::Done => "done", + Self::Archived => "archived", + } + } + + pub fn parse(value: &str) -> Result { + Self::ALL + .into_iter() + .find(|s| s.folder() == value) + .ok_or_else(|| { + let known: Vec<&str> = Self::ALL.into_iter().map(Self::folder).collect(); + CoreError::new(ErrorCode::InvalidInput, format!("unknown status {value:?}")) + .with_recovery(format!("valid statuses: [{}]", known.join(", "))) + }) + } +} + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct WrittenDoc { + pub slug: String, + pub rel_path: String, + pub path: PathBuf, + pub status: DocStatus, + pub phase: Option, +} + +#[derive(Debug, Clone)] +pub struct DocLocation { + pub slug: String, + pub status: DocStatus, + pub phase: Option, + pub rel_path: String, + pub path: PathBuf, +} + +impl DocLocation { + pub(crate) fn to_written(&self) -> WrittenDoc { + WrittenDoc { + slug: self.slug.clone(), + rel_path: self.rel_path.clone(), + path: self.path.clone(), + status: self.status, + phase: self.phase.clone(), + } + } +} + +#[derive(Debug)] +pub struct NewDoc<'a> { + pub title: &'a str, + pub body: &'a str, + pub status: DocStatus, + pub created_by: Option<&'a str>, + pub phase: Option<&'a str>, + pub owner: Option<&'a str>, + pub tags: Vec, + pub priority: Option<&'a str>, + pub due: Option<&'a str>, +} + +impl<'a> NewDoc<'a> { + pub fn new(title: &'a str, body: &'a str, status: DocStatus) -> Self { + Self { + title, + body, + status, + created_by: None, + phase: None, + owner: None, + tags: Vec::new(), + priority: None, + due: None, + } + } +} + +#[derive(Serialize)] +struct Frontmatter<'a> { + title: &'a str, + created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + created_by: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + owner: Option<&'a str>, + #[serde(skip_serializing_if = "<[String]>::is_empty")] + tags: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + priority: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + due: Option<&'a str>, +} + +fn doc_rel_path(status: DocStatus, phase: Option<&str>, slug: &str) -> String { + match phase { + Some(p) => format!("{}/{p}/{slug}.md", status.folder()), + None => format!("{}/{slug}.md", status.folder()), + } +} + +pub(crate) fn location_at( + root: &Path, + status: DocStatus, + phase: Option<&str>, + slug: &str, +) -> DocLocation { + let rel_path = doc_rel_path(status, phase, slug); + DocLocation { + slug: slug.to_string(), + status, + phase: phase.map(str::to_string), + rel_path: rel_path.clone(), + path: root.join(rel_path), + } +} + +fn phases_in(root: &Path, status: DocStatus) -> Vec { + let Ok(entries) = std::fs::read_dir(root.join(status.folder())) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect() +} + +fn validate_phase(phase: &str) -> Result<(), CoreError> { + if phase.is_empty() || slugify(phase) != phase { + return Err( + CoreError::new(ErrorCode::InvalidInput, format!("invalid phase {phase:?}")) + .with_recovery( + "phases are lowercase alphanumerics separated by dashes, e.g. \"v2-launch\"", + ), + ); + } + Ok(()) +} + +pub(crate) fn find_doc_location(root: &Path, slug: &str) -> Option { + for status in DocStatus::ALL { + let direct = location_at(root, status, None, slug); + if direct.path.is_file() { + return Some(direct); + } + for phase in phases_in(root, status) { + let nested = location_at(root, status, Some(&phase), slug); + if nested.path.is_file() { + return Some(nested); + } + } + } + None +} + +fn doc_not_found(doc_ref: &str) -> CoreError { + CoreError::new( + ErrorCode::DocNotFound, + format!("no doc found for {doc_ref:?}"), + ) + .with_recovery( + "pass a slug or a status-relative path like \"research/my-doc.md\"; see list_docs", + ) +} + +/// Resolves a slug or status-relative path to an existing doc. A stale path +/// whose doc now lives elsewhere is a Conflict ("changed under you"), not a +/// plain not-found, so agents learn the new location. +pub fn locate_doc(root: &Path, doc_ref: &str) -> Result { + if doc_ref.starts_with("memory/") { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("{doc_ref:?} is a memory entry, not a doc; memory has no lifecycle"), + ) + .with_recovery( + "read it with search_memory, overwrite it with write_memory, or remove it with delete_doc", + )); + } + if doc_ref.starts_with("tasks/") { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("{doc_ref:?} is a task, not a doc; tasks keep status in frontmatter"), + ) + .with_recovery( + "use set_task_status to move it, update_task to edit it, or delete_doc to remove it", + )); + } + if !doc_ref.contains('/') { + return find_doc_location(root, doc_ref).ok_or_else(|| doc_not_found(doc_ref)); + } + let path = crate::path_guard::safe_join(root, doc_ref)?; + let segments: Vec<&str> = doc_ref.split('/').collect(); + let status = DocStatus::parse(segments[0])?; + let (phase, file_name) = match segments.as_slice() { + [_, file] => (None, *file), + [_, phase, file] => (Some(*phase), *file), + _ => return Err(doc_not_found(doc_ref)), + }; + let Some(slug) = file_name.strip_suffix(".md") else { + return Err(doc_not_found(doc_ref)); + }; + if path.is_file() { + return Ok(location_at(root, status, phase, slug)); + } + match find_doc_location(root, slug) { + Some(current) => Err(CoreError::new( + ErrorCode::Conflict, + format!( + "doc {slug:?} is no longer at {doc_ref:?}; it moved to {:?}", + current.rel_path + ), + ) + .with_recovery(format!("retry with path {:?}", current.rel_path))), + None => Err(doc_not_found(doc_ref)), + } +} + +fn render_doc(doc: &NewDoc<'_>) -> Result { + // Agent-supplied frontmatter wins; otherwise stamp the typed metadata. + if doc.body.trim_start().starts_with("---") { + return Ok(format!("{}\n", doc.body.trim_end())); + } + let fm = serde_yaml::to_string(&Frontmatter { + title: doc.title, + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + created_by: doc.created_by, + owner: doc.owner, + tags: &doc.tags, + priority: doc.priority, + due: doc.due, + }) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize frontmatter: {e}")))?; + Ok(format!("---\n{fm}---\n\n{}\n", doc.body.trim_end())) +} + +pub(crate) fn enforce_size_limit(rendered_len: usize) -> Result<(), CoreError> { + if rendered_len as u64 > MAX_FILE_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!( + "content exceeds the {} MiB limit", + MAX_FILE_BYTES / (1024 * 1024) + ), + ) + .with_recovery("split the document into smaller docs")); + } + Ok(()) +} + +pub(crate) async fn stage_in_git(root: &Path, rel_paths: &[&str]) { + let root_str = root.to_string_lossy(); + if git::is_git_repo(&root_str).await { + git::git_add(&root_str, rel_paths).await; + } +} + +pub async fn write_doc_core(root: &Path, doc: &NewDoc<'_>) -> Result { + if doc.title.trim().is_empty() { + return Err(CoreError::new(ErrorCode::InvalidInput, "title is required") + .with_recovery("pass a short human-readable title; it becomes the doc's slug")); + } + if let Some(phase) = doc.phase { + validate_phase(phase)?; + } + let rendered = render_doc(doc)?; + enforce_size_limit(rendered.len())?; + let slug = unique_slug(&slugify(doc.title), |s| { + find_doc_location(root, s).is_some() + }); + let target = location_at(root, doc.status, doc.phase, &slug); + std::fs::create_dir_all(target.path.parent().unwrap_or(root))?; + std::fs::write(&target.path, rendered)?; + stage_in_git(root, &[&target.rel_path]).await; + Ok(target.to_written()) +} + +pub(crate) async fn relocate( + root: &Path, + from: &DocLocation, + to: &DocLocation, +) -> Result<(), CoreError> { + if to.path.is_file() { + return Err(CoreError::new( + ErrorCode::Conflict, + format!("a doc already exists at {:?}", to.rel_path), + ) + .with_recovery("archive or rename the existing doc first")); + } + std::fs::create_dir_all(to.path.parent().unwrap_or(root))?; + let root_str = root.to_string_lossy(); + let moved_by_git = git::is_git_repo(&root_str).await + && git::git_mv(&root_str, &from.rel_path, &to.rel_path).await; + if !moved_by_git { + // Untracked file or non-repo: plain rename, then best-effort stage. + std::fs::rename(&from.path, &to.path)?; + stage_in_git(root, &[&from.rel_path, &to.rel_path]).await; + } + Ok(()) +} + +/// The move IS the status change; the phase subfolder is preserved. +pub async fn set_status_core( + root: &Path, + doc_ref: &str, + new_status: DocStatus, +) -> Result { + let from = locate_doc(root, doc_ref)?; + let to = location_at(root, new_status, from.phase.as_deref(), &from.slug); + if from.status == new_status { + return Ok(from.to_written()); + } + relocate(root, &from, &to).await?; + Ok(to.to_written()) +} + +/// Moves a doc into (or out of, with None) a phase subfolder within its status. +pub async fn set_phase_core( + root: &Path, + doc_ref: &str, + phase: Option<&str>, +) -> Result { + if let Some(p) = phase { + validate_phase(p)?; + } + let from = locate_doc(root, doc_ref)?; + let to = location_at(root, from.status, phase, &from.slug); + if from.phase.as_deref() == phase { + return Ok(from.to_written()); + } + relocate(root, &from, &to).await?; + Ok(to.to_written()) +} + +pub async fn archive_doc_core(root: &Path, doc_ref: &str) -> Result { + set_status_core(root, doc_ref, DocStatus::Archived).await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn test_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dr_write_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn git(dir: &Path, args: &[&str]) { + let ok = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .map(|s| s.success()) + .unwrap_or(false); + assert!(ok, "git {args:?} failed"); + } + + #[tokio::test] + async fn write_places_doc_in_status_folder_with_frontmatter() { + let root = test_dir("place"); + let new_doc = NewDoc { + created_by: Some("claude-code"), + phase: Some("discovery"), + tags: vec!["mcp".into()], + ..NewDoc::new("My First Doc", "Body text.", DocStatus::Research) + }; + let doc = write_doc_core(&root, &new_doc).await.unwrap(); + assert_eq!( + doc.rel_path, "research/discovery/my-first-doc.md", + "phase is a subfolder" + ); + + let raw = std::fs::read_to_string(&doc.path).unwrap(); + assert!(raw.starts_with("---\n"), "has frontmatter: {raw}"); + assert!(raw.contains("title: My First Doc")); + assert!(raw.contains("created_at:")); + assert!(raw.contains("created_by: claude-code")); + assert!(raw.contains("- mcp")); + assert!(!raw.contains("status:"), "status must stay folder-only"); + assert!(!raw.contains("phase:"), "phase must stay folder-only"); + assert!(raw.ends_with("Body text.\n")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn set_status_preserves_phase_and_set_phase_moves_within_status() { + let root = test_dir("phase_moves"); + let doc = write_doc_core( + &root, + &NewDoc { + phase: Some("v1"), + ..NewDoc::new("Phased", "x", DocStatus::Research) + }, + ) + .await + .unwrap(); + assert_eq!(doc.rel_path, "research/v1/phased.md"); + + let moved = set_status_core(&root, "phased", DocStatus::Done) + .await + .unwrap(); + assert_eq!(moved.rel_path, "done/v1/phased.md", "phase preserved"); + + let rephased = set_phase_core(&root, "phased", Some("v2")).await.unwrap(); + assert_eq!(rephased.rel_path, "done/v2/phased.md"); + + let cleared = set_phase_core(&root, "phased", None).await.unwrap(); + assert_eq!(cleared.rel_path, "done/phased.md"); + assert!(cleared.path.is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn stale_path_reports_moved_doc_conflict() { + let root = test_dir("stale"); + let doc = write_doc_core(&root, &NewDoc::new("Wander", "x", DocStatus::Research)) + .await + .unwrap(); + set_status_core(&root, &doc.slug, DocStatus::Done) + .await + .unwrap(); + + let err = set_status_core(&root, "research/wander.md", DocStatus::Archived) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + assert!( + err.message.contains("done/wander.md"), + "got: {}", + err.message + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn slug_collisions_get_counter_across_statuses() { + let root = test_dir("collide"); + let a = write_doc_core(&root, &NewDoc::new("Same Title", "a", DocStatus::Research)) + .await + .unwrap(); + let b = write_doc_core(&root, &NewDoc::new("Same Title", "b", DocStatus::Done)) + .await + .unwrap(); + assert_eq!(a.slug, "same-title"); + assert_eq!(b.slug, "same-title-2"); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn move_relocates_between_status_folders() { + let root = test_dir("move"); + let doc = write_doc_core(&root, &NewDoc::new("Movable", "x", DocStatus::Research)) + .await + .unwrap(); + let moved = set_status_core(&root, &doc.slug, DocStatus::Done) + .await + .unwrap(); + assert_eq!(moved.rel_path, "done/movable.md"); + assert!(!doc.path.exists()); + assert!(moved.path.is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn git_repo_write_stages_and_move_shows_rename() { + if crate::git::git_binary().is_none() { + return; + } + let root = test_dir("gitmv"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "a@b.c"]); + git(&root, &["config", "user.name", "x"]); + + let doc = write_doc_core(&root, &NewDoc::new("Tracked Doc", "x", DocStatus::Research)) + .await + .unwrap(); + let staged = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&root) + .output() + .unwrap(); + let staged = String::from_utf8_lossy(&staged.stdout).to_string(); + assert!( + staged.contains("A research/tracked-doc.md"), + "write staged the file: {staged}" + ); + + git(&root, &["commit", "-qm", "add doc"]); + set_status_core(&root, &doc.slug, DocStatus::InProgress) + .await + .unwrap(); + let after = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&root) + .output() + .unwrap(); + let after = String::from_utf8_lossy(&after.stdout).to_string(); + assert!( + after.contains("R research/tracked-doc.md -> in-progress/tracked-doc.md"), + "history reflects git mv: {after}" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn agent_frontmatter_passes_through_unchanged() { + let root = test_dir("ownfm"); + let content = "---\ntitle: Custom\ntags: [x]\n---\n\nBody."; + let doc = write_doc_core( + &root, + &NewDoc::new("Ignored Title", content, DocStatus::Research), + ) + .await + .unwrap(); + let raw = std::fs::read_to_string(&doc.path).unwrap(); + assert_eq!(raw, format!("{content}\n")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn rejects_empty_title_and_oversize_content() { + let root = test_dir("reject"); + let err = write_doc_core(&root, &NewDoc::new(" ", "x", DocStatus::Research)) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + + let huge = "x".repeat(MAX_FILE_BYTES as usize + 1); + let err = write_doc_core(&root, &NewDoc::new("Huge", &huge, DocStatus::Research)) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn move_unknown_slug_is_doc_not_found() { + let root = test_dir("nomove"); + let err = set_status_core(&root, "ghost", DocStatus::Done) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn status_parses_folder_names_only() { + assert_eq!( + DocStatus::parse("in-progress").unwrap(), + DocStatus::InProgress + ); + let err = DocStatus::parse("wip").unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + assert!(err.recovery.unwrap().contains("research")); + } +} diff --git a/src-tauri/core/tests/welcome_bundle.rs b/src-tauri/core/tests/welcome_bundle.rs new file mode 100644 index 0000000..fa9976e --- /dev/null +++ b/src-tauri/core/tests/welcome_bundle.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; + +use docsreader_core::workspace::load_marker; + +fn welcome_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("core crate lives inside src-tauri") + .join("resources/welcome") +} + +#[test] +fn bundled_welcome_marker_is_a_valid_managed_workspace() { + let dir = welcome_dir(); + let marker = load_marker(&dir) + .expect("marker parses with the production loader") + .expect("marker file present"); + assert_eq!(marker.slug, "docsreader-welcome"); + assert_eq!(marker.name.as_deref(), Some("DocsReader welcome")); + let homepage = marker + .homepage + .expect("homepage set for first-run auto-open"); + assert!( + dir.join(&homepage).is_file(), + "homepage {homepage} missing from the bundle" + ); +} + +#[test] +fn welcome_tour_teaches_agent_setup() { + let page = welcome_dir().join("Getting started/Connect your AI agents.md"); + let body = std::fs::read_to_string(&page).expect("agents getting-started page exists"); + for needle in [ + "docsreader-mcp", + "claude mcp add", + "codex mcp add", + "mcpServers", + ] { + assert!( + body.contains(needle), + "agents page lost setup step: {needle}" + ); + } +} diff --git a/src-tauri/mcp/Cargo.toml b/src-tauri/mcp/Cargo.toml new file mode 100644 index 0000000..1391f4c --- /dev/null +++ b/src-tauri/mcp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "docsreader-mcp" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +docsreader-core = { version = "0.6.0", path = "../core", features = ["schemars"] } +rmcp = { version = "2.0.0", features = ["server", "macros", "transport-io", "elicitation"] } +schemars = "1.2.1" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", default-features = false, features = ["env-filter", "fmt", "std", "ansi"] } + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/src-tauri/mcp/src/main.rs b/src-tauri/mcp/src/main.rs new file mode 100644 index 0000000..c925b85 --- /dev/null +++ b/src-tauri/mcp/src/main.rs @@ -0,0 +1,23 @@ +mod server; + +use rmcp::ServiceExt; +use server::DocsServer; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // stdout carries JSON-RPC only; logs go to stderr per the MCP stdio + // transport spec (clients may capture or ignore them). Level via RUST_LOG. + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + "docsreader-mcp starting" + ); + let running = DocsServer.serve(rmcp::transport::stdio()).await?; + running.waiting().await?; + Ok(()) +} diff --git a/src-tauri/mcp/src/server/elicit.rs b/src-tauri/mcp/src/server/elicit.rs new file mode 100644 index 0000000..4565e0a --- /dev/null +++ b/src-tauri/mcp/src/server/elicit.rs @@ -0,0 +1,71 @@ +use docsreader_core::error::{CoreError, ErrorCode}; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use rmcp::model::{ElicitRequestParams, ElicitationAction, ElicitationSchema, EnumSchema}; +use rmcp::service::ElicitationMode; +use rmcp::{Peer, RoleServer}; + +use super::{known_slugs, resolve}; + +/// Unknown-slug recovery for interactive clients: offer a form-elicitation +/// enum picker over the workspaces that would resolve (2025-11-25 enum + +/// default support). Headless clients and declines keep the recovery-bearing +/// error, so the non-interactive `workspace` argument path always works. +/// Only workspace slugs cross the wire - never sensitive data. +pub(crate) async fn resolve_or_pick( + peer: &Peer, + explicit: Option<&str>, +) -> Result { + let err = match resolve(explicit) { + Ok(ws) => return Ok(ws), + Err(err) => err, + }; + if err.code != ErrorCode::WorkspaceNotFound { + return Err(err); + } + match pick_workspace(peer, explicit.unwrap_or_default()).await { + Some(slug) => resolve(Some(&slug)), + None => Err(err), + } +} + +async fn pick_workspace(peer: &Peer, requested: &str) -> Option { + if !peer + .supported_elicitation_modes() + .contains(&ElicitationMode::Form) + { + return None; + } + let choices = known_slugs().ok()?; + if choices.is_empty() { + return None; + } + let params = ElicitRequestParams::FormElicitationParams { + meta: None, + message: format!("Workspace {requested:?} was not found. Pick the workspace to use."), + requested_schema: workspace_schema(choices)?, + }; + let result = peer.create_elicitation(params).await.ok()?; + if result.action != ElicitationAction::Accept { + return None; + } + result + .content? + .get("workspace")? + .as_str() + .map(str::to_owned) +} + +fn workspace_schema(choices: Vec) -> Option { + let default = resolve(None) + .ok() + .map(|ws| ws.slug) + .filter(|slug| choices.contains(slug)); + let mut choices = EnumSchema::builder(choices); + if let Some(default) = default { + choices = choices.with_default(default).ok()?; + } + ElicitationSchema::builder() + .required_enum_schema("workspace", choices.build()) + .build() + .ok() +} diff --git a/src-tauri/mcp/src/server/memory_tools.rs b/src-tauri/mcp/src/server/memory_tools.rs new file mode 100644 index 0000000..28a8f48 --- /dev/null +++ b/src-tauri/mcp/src/server/memory_tools.rs @@ -0,0 +1,142 @@ +use docsreader_core::error::CoreError; +use docsreader_core::memory::{MemoryEntry, MemoryHit, search_memory_core, write_memory_core}; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::CallToolResult; +use rmcp::{Peer, RoleServer, tool, tool_router}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ + DocsServer, TRUNCATION_GUIDANCE, client_name, doc_uri, ensure_workspace_exists, error_result, + resolve_or_pick, take_within_budget, +}; + +#[derive(Deserialize, JsonSchema)] +pub struct WriteMemoryParams { + /// Short topic the memory is about, e.g. "user-preferences" or + /// "auth stack". One entry per topic; writing the same topic overwrites. + pub topic: String, + /// The fact(s) to remember, as markdown. Replaces any previous content + /// for this topic wholesale. + pub content: String, + /// Topic tags. + pub tags: Option>, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SearchMemoryParams { + /// Search term matched against topic, tags, and content. Omit to list + /// every entry, newest first. + pub query: Option, + /// Filter by tag. + pub tag: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MemoryWriteResult { + pub ok: bool, + pub workspace: ResolvedWorkspace, + #[serde(flatten)] + pub entry: MemoryEntry, + /// Resource URI for this entry (docsreader:///memory/.md). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MemoryHitEntry { + #[serde(flatten)] + pub hit: MemoryHit, + /// Resource URI for this entry (docsreader:///memory/.md). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SearchMemoryResult { + pub workspace: ResolvedWorkspace, + pub memories: Vec, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance: Option, +} + +#[tool_router(router = memory_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Save a short topic-addressed memory (a fact, preference, or decision) for future sessions. One entry per topic: writing an existing topic overwrites its content wholesale, so include everything still worth remembering. Memories have no lifecycle status.", + annotations(idempotent_hint = true) + )] + async fn write_memory( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let agent = client_name(&peer); + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + ensure_workspace_exists(&ws)?; + let entry = write_memory_core( + &ws.root, + &p.topic, + &p.content, + &p.tags.clone().unwrap_or_default(), + agent.as_deref(), + ) + .await?; + Ok::<_, CoreError>((ws, entry)) + } + .await; + result + .map(|(ws, entry)| { + Json(MemoryWriteResult { + ok: true, + uri: doc_uri(&ws.slug, &entry.rel_path), + workspace: ws, + entry, + }) + }) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Recall memories: ranked matches against topic, tags, and content, each with its full content. Omit the query to list every entry, newest first. Check here for prior context before re-deriving facts.", + annotations(read_only_hint = true) + )] + async fn search_memory( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let hits = if ws.root.is_dir() { + search_memory_core(&ws.root, p.query.as_deref(), p.tag.as_deref())? + } else { + Vec::new() + }; + let entries: Vec = hits + .into_iter() + .map(|hit| MemoryHitEntry { + uri: doc_uri(&ws.slug, &hit.rel_path), + hit, + }) + .collect(); + let (memories, truncated) = take_within_budget(entries); + Ok::<_, CoreError>(SearchMemoryResult { + workspace: ws, + memories, + truncated, + guidance: truncated.then(|| TRUNCATION_GUIDANCE.to_string()), + }) + } + .await; + result.map(Json).map_err(|e| error_result(&e)) + } +} diff --git a/src-tauri/mcp/src/server/mod.rs b/src-tauri/mcp/src/server/mod.rs new file mode 100644 index 0000000..dcc8585 --- /dev/null +++ b/src-tauri/mcp/src/server/mod.rs @@ -0,0 +1,188 @@ +mod elicit; +mod memory_tools; +mod prompts; +mod read_tools; +mod resources; +mod task_tools; +mod workspace_tools; +mod write_tools; + +use std::path::PathBuf; + +use docsreader_core::error::{CoreError, ErrorCode}; +use docsreader_core::workspace::WorkspaceScope; +use docsreader_core::workspace::init::init_workspace_core; +use docsreader_core::workspace::registry::{default_registry_path, load_registry}; +use docsreader_core::workspace::resolve::{ResolvedWorkspace, available_slugs, resolve_workspace}; +use docsreader_core::write::DocStatus; +use rmcp::model::{ + CallToolResult, ContentBlock, Implementation, ListResourceTemplatesResult, ListResourcesResult, + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Resource, + ServerCapabilities, ServerInfo, +}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData, Peer, RoleServer, ServerHandler, prompt_handler, tool_handler}; + +pub(crate) use elicit::resolve_or_pick; + +pub struct DocsServer; + +impl DocsServer { + fn combined_router() -> rmcp::handler::server::router::tool::ToolRouter { + Self::workspace_tool_router() + + Self::write_tool_router() + + Self::read_tool_router() + + Self::memory_tool_router() + + Self::task_tool_router() + } +} + +const INSTRUCTIONS: &str = "DocsReader serves markdown docs, memory, and tasks from local workspaces. Docs live in status folders (research/in-progress/done/archived), optionally grouped by phase subfolders. Read the docsreader://onboarding resource first for the full model. Start with list_workspaces; create docs with write_doc; find them with list_docs/search_docs; read with read_doc; edit with update_doc; move through the lifecycle with set_status/set_phase/archive. Save short topic-addressed facts with write_memory; recall them with search_memory. Track work with write_task/list_tasks/set_task_status/update_task (Backlog.md-shaped files in tasks/)."; + +#[tool_handler(router = Self::combined_router())] +#[prompt_handler(router = Self::prompt_router())] +impl ServerHandler for DocsServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .enable_prompts() + .build(), + ) + .with_server_info(Implementation::new("docsreader", env!("CARGO_PKG_VERSION"))) + .with_instructions(INSTRUCTIONS) + } + + async fn list_resources( + &self, + request: Option, + _context: RequestContext, + ) -> Result { + resources::list_resources_page(request.and_then(|r| r.cursor)) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(resources::list_templates()) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + resources::read_resource_at(&request.uri) + } +} + +pub(crate) fn success_json(value: serde_json::Value) -> CallToolResult { + CallToolResult::success(vec![ContentBlock::text(value.to_string())]) +} + +pub(crate) fn error_result(err: &CoreError) -> CallToolResult { + tracing::debug!(code = ?err.code, message = %err.message, "tool error"); + let payload = serde_json::json!({ "error": err }); + CallToolResult::error(vec![ContentBlock::text(payload.to_string())]) +} + +pub(crate) fn home_dir() -> Result { + std::env::home_dir().ok_or_else(|| { + CoreError::new(ErrorCode::Io, "cannot determine home directory") + .with_recovery("set the HOME environment variable for the MCP server process") + }) +} + +pub(crate) fn client_name(peer: &Peer) -> Option { + peer.peer_info().map(|info| info.client_info.name.clone()) +} + +pub(crate) fn resolve(explicit_slug: Option<&str>) -> Result { + let home = home_dir()?; + let registry = load_registry(&default_registry_path(&home))?; + let cwd = std::env::current_dir()?; + resolve_workspace(explicit_slug, &project_dir_hint(), &cwd, &home, ®istry) +} + +/// Claude Code sets CLAUDE_PROJECT_DIR to the project root in the spawned +/// server's environment (code.claude.com/docs/en/mcp). MCP Roots would carry +/// the same signal but is deprecated by SEP-2577, so the env var plus cwd +/// walk-up is the whole auto-detect story. +fn project_dir_hint() -> Vec { + std::env::var_os("CLAUDE_PROJECT_DIR") + .map(PathBuf::from) + .into_iter() + .collect() +} + +pub(crate) fn known_slugs() -> Result, CoreError> { + let home = home_dir()?; + let registry = load_registry(&default_registry_path(&home))?; + let ambient = resolve(None).ok(); + Ok(available_slugs(®istry, ambient.as_ref())) +} + +/// The default user workspace is create-on-first-use: writing to it before +/// init_workspace must succeed, so agents never hit a setup wall. +pub(crate) fn ensure_workspace_exists(ws: &ResolvedWorkspace) -> Result<(), CoreError> { + if ws.root.is_dir() { + return Ok(()); + } + if ws.scope != WorkspaceScope::User { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("workspace directory {} is missing", ws.root.display()), + ) + .with_recovery( + "call list_workspaces to see valid slugs, or init_workspace to create one", + )); + } + let home = home_dir()?; + init_workspace_core( + &ws.root, + Some(&ws.slug), + None, + WorkspaceScope::User, + &default_registry_path(&home), + )?; + Ok(()) +} + +pub(crate) fn doc_uri(ws_slug: &str, rel_path: &str) -> String { + format!("docsreader://{ws_slug}/{rel_path}") +} + +pub(crate) fn doc_resource_link(ws_slug: &str, rel_path: &str, title: &str) -> ContentBlock { + let resource = Resource::new(doc_uri(ws_slug, rel_path), rel_path) + .with_title(title) + .with_mime_type("text/markdown"); + ContentBlock::resource_link(resource) +} + +pub(crate) fn parse_status(value: Option<&str>) -> Result, CoreError> { + value.map(DocStatus::parse).transpose() +} + +pub(crate) const TRUNCATION_GUIDANCE: &str = + "response hit the size budget; narrow with status/phase/tag filters or a more specific query"; + +/// Keeps the serialized entry list under the response budget; the guidance +/// string tells the agent how to narrow instead of silently dropping items. +pub(crate) fn take_within_budget(items: Vec) -> (Vec, bool) { + let mut used = 0usize; + let mut kept = Vec::new(); + let mut truncated = false; + for item in items { + let cost = serde_json::to_string(&item).map(|s| s.len()).unwrap_or(0); + if used + cost > docsreader_core::read::RESPONSE_BUDGET_CHARS { + truncated = true; + break; + } + used += cost; + kept.push(item); + } + (kept, truncated) +} diff --git a/src-tauri/mcp/src/server/onboarding.md b/src-tauri/mcp/src/server/onboarding.md new file mode 100644 index 0000000..e4d03ad --- /dev/null +++ b/src-tauri/mcp/src/server/onboarding.md @@ -0,0 +1,64 @@ +# DocsReader agent onboarding + +DocsReader is a local markdown store you write to over MCP while humans read the +same files in the DocsReader app. Prefer these tools over raw file writes: they +handle slugs, frontmatter, collisions, lifecycle moves, and git staging. + +## Model + +- A workspace is a folder of markdown docs. Default user workspace: `~/notes` + (created on first write). A project can opt in with its own `/notes` + via `init_workspace`; when one exists it takes precedence. +- Docs live in the folder matching their lifecycle status: + `research/`, `in-progress/`, `done/`, `archived/`. The folder IS the status. +- Optional phase subfolders group work inside a status, e.g. + `research/v2-launch/plan.md`. The folder IS the phase. +- Filenames are slugs generated from titles. Frontmatter carries title, tags, + owner, created_at, created_by - never status or phase. + +## Workflow + +1. `list_workspaces` shows what exists; `init_workspace` creates one. +2. `write_doc {title, body, status}` creates a doc and returns its URI. +3. `list_docs` / `search_docs` find docs; results are ranked and budgeted. +4. `read_doc {path}` reads one: concise (snippet) by default, `detailed` for + the full body. +5. `update_doc {path, old_str, new_str}` edits in place by exact string + replacement; `old_str` must appear exactly once. +6. `set_status` / `set_phase` / `archive` move docs through the lifecycle. + The move is the status change; nothing else to update. + +## Memory + +- Short, topic-addressed facts live in `memory/` ("user prefers tabs", + "project uses Better Auth"), outside the doc lifecycle: no status, no phase. +- `write_memory {topic, content}` creates or overwrites the entry for that + topic wholesale, so include everything still worth remembering. +- `search_memory {query}` returns matching entries with their full content; + omit the query to list all. Check memory before re-deriving facts. +- Remove stale entries with `delete_doc {path: "memory/.md"}`. +- Long-form knowledge belongs in docs; promote a grown memory with + `write_doc`, then delete the entry. + +## Tasks + +- Work items live in `tasks/` as Backlog.md-shaped files: `task-N` ids, + status in frontmatter ("To Do" | "In Progress" | "Done"), a Description + section, and an Acceptance Criteria checklist. +- `write_task {title, description, acceptance_criteria?}` creates one; + `list_tasks {status?}` shows the board; `set_task_status {id, status}` + moves it. +- `update_task {id, old_str, new_str}` edits in place: check a criterion by + replacing `- [ ] #1 ...` with `- [x] #1 ...`, or append notes. +- Unlike docs, tasks never move between folders; status is frontmatter-only. + +## Conventions + +- Address docs by slug (`api-notes`) or status-relative path + (`research/api-notes.md`). +- Tool failures return `{error: {code, message, recovery}}`; follow the + recovery hint - it names valid values, available slugs, or the doc's new + location when it moved. +- Every doc is also an MCP resource: `docsreader://{workspace}/{path}`. +- Write docs for future readers (humans and agents): a clear title, a short + opening summary, and tags make `search_docs` work well. diff --git a/src-tauri/mcp/src/server/prompts.rs b/src-tauri/mcp/src/server/prompts.rs new file mode 100644 index 0000000..209f213 --- /dev/null +++ b/src-tauri/mcp/src/server/prompts.rs @@ -0,0 +1,84 @@ +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{PromptMessage, Role}; +use rmcp::{prompt, prompt_router}; +use schemars::JsonSchema; +use serde::Deserialize; + +use super::DocsServer; + +#[derive(Deserialize, JsonSchema)] +pub struct StartTaskArgs { + /// Short title for the task. + pub title: String, + /// Extra context: constraints, links, or hints for acceptance criteria. + pub context: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct RecordDecisionArgs { + /// The decision taken, in one sentence. + pub decision: String, + /// Why: the problem, alternatives weighed, constraints. + pub rationale: Option, +} + +fn with_context(base: String, label: &str, extra: Option<&str>) -> String { + match extra { + Some(extra) if !extra.trim().is_empty() => { + format!("{base}\n\n{label}: {}", extra.trim()) + } + _ => base, + } +} + +#[prompt_router(vis = "pub(crate)")] +impl DocsServer { + #[prompt( + name = "start-task", + description = "Scaffold a DocsReader task for a piece of work and start on it: creates the Backlog.md-shaped task with acceptance criteria, moves it to In Progress, and tracks progress by checking criteria off." + )] + async fn start_task(&self, Parameters(args): Parameters) -> Vec { + let text = with_context( + format!( + "Start work on this task: {}\n\n\ + 1. If you have not read it this session, read the docsreader://onboarding resource.\n\ + 2. Create the task with write_task: a one-paragraph description of what and why, \ + plus 2-5 verifiable acceptance criteria.\n\ + 3. Move it to \"In Progress\" with set_task_status before you begin.\n\ + 4. As you complete each criterion, check it off with update_task \ + (replace \"- [ ] #N ...\" with \"- [x] #N ...\"); append implementation notes the same way.\n\ + 5. When every criterion is checked, set the status to \"Done\".", + args.title + ), + "Context", + args.context.as_deref(), + ); + vec![PromptMessage::new_text(Role::User, text)] + } + + #[prompt( + name = "record-decision", + description = "Record a decision as a doc in the workspace: searches for prior related decisions, writes a structured decision doc, and saves a memory pointer when it changes day-to-day behavior." + )] + async fn record_decision( + &self, + Parameters(args): Parameters, + ) -> Vec { + let text = with_context( + format!( + "Record this decision in the workspace: {}\n\n\ + 1. Search first: run search_docs and search_memory for prior related decisions; \ + link or supersede them instead of duplicating.\n\ + 2. Create the doc with write_doc: status \"done\", tags [\"decision\"], a title naming \ + the decision, and a body with sections for Context, Decision, Alternatives considered, \ + and Consequences.\n\ + 3. If the decision changes how agents should work in this workspace day-to-day, also \ + save a short write_memory entry that states the rule and links the doc.", + args.decision + ), + "Rationale", + args.rationale.as_deref(), + ); + vec![PromptMessage::new_text(Role::User, text)] + } +} diff --git a/src-tauri/mcp/src/server/read_tools.rs b/src-tauri/mcp/src/server/read_tools.rs new file mode 100644 index 0000000..8a1efc6 --- /dev/null +++ b/src-tauri/mcp/src/server/read_tools.rs @@ -0,0 +1,207 @@ +use docsreader_core::error::{CoreError, ErrorCode}; +use docsreader_core::read::{ + DocContent, DocFilters, DocSummary, SearchHit, list_docs_core, read_doc_core, search_docs_core, +}; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::CallToolResult; +use rmcp::{Peer, RoleServer, tool, tool_router}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ + DocsServer, TRUNCATION_GUIDANCE, doc_uri, error_result, parse_status, resolve_or_pick, + take_within_budget, +}; + +#[derive(Deserialize, JsonSchema)] +pub struct ReadDocParams { + /// Doc slug (e.g. "api-design-notes") or status-relative path + /// (e.g. "research/api-design-notes.md"). + pub path: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, + /// "concise" (default: frontmatter + snippet) or "detailed" (full body). + pub response_format: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ListDocsParams { + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, + /// Filter by status: "research" | "in-progress" | "done" | "archived". + pub status: Option, + /// Filter by phase subfolder. + pub phase: Option, + /// Filter by tag. Filters AND together. + pub tag: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SearchDocsParams { + /// Search term, matched against title, tags, slug, and content. + pub query: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, + /// Filter by status: "research" | "in-progress" | "done" | "archived". + pub status: Option, + /// Filter by phase subfolder. + pub phase: Option, + /// Filter by tag. Filters AND together. + pub tag: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocEntry { + #[serde(flatten)] + pub doc: DocSummary, + /// Resource URI for this doc (docsreader:///). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct HitEntry { + #[serde(flatten)] + pub hit: SearchHit, + /// Resource URI for this doc (docsreader:///). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListDocsResult { + pub workspace: ResolvedWorkspace, + pub docs: Vec, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SearchDocsResult { + pub workspace: ResolvedWorkspace, + pub hits: Vec, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadDocResult { + pub workspace: ResolvedWorkspace, + pub doc: DocContent, +} + +fn parse_filters<'a>( + status: Option<&'a str>, + phase: Option<&'a str>, + tag: Option<&'a str>, +) -> Result, CoreError> { + Ok(DocFilters { + status: parse_status(status)?, + phase, + tag, + }) +} + +fn parse_response_format(value: Option<&str>) -> Result { + match value { + None | Some("concise") => Ok(false), + Some("detailed") => Ok(true), + Some(other) => Err(CoreError::new( + ErrorCode::InvalidInput, + format!("unknown response_format {other:?}"), + ) + .with_recovery("valid formats: [concise, detailed]")), + } +} + +#[tool_router(router = read_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Read one doc from a DocsReader workspace by slug or status-relative path. Default concise mode returns frontmatter + a snippet; response_format=\"detailed\" returns the full body.", + annotations(read_only_hint = true) + )] + async fn read_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let detailed = parse_response_format(p.response_format.as_deref())?; + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let doc = read_doc_core(&ws.root, &p.path, detailed)?; + Ok(ReadDocResult { workspace: ws, doc }) + } + .await; + result.map(Json).map_err(|e: CoreError| error_result(&e)) + } + + #[tool( + description = "List docs in a DocsReader workspace, newest first. Filter by status, phase, or tag (filters AND together). Results carry docsreader:// resource URIs.", + annotations(read_only_hint = true) + )] + async fn list_docs( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let filters = parse_filters(p.status.as_deref(), p.phase.as_deref(), p.tag.as_deref())?; + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let docs = list_docs_core(&ws.root, &filters)?; + let entries: Vec = docs + .into_iter() + .map(|doc| DocEntry { + uri: doc_uri(&ws.slug, &doc.rel_path), + doc, + }) + .collect(); + let (docs, truncated) = take_within_budget(entries); + Ok(ListDocsResult { + workspace: ws, + docs, + truncated, + guidance: truncated.then(|| TRUNCATION_GUIDANCE.to_string()), + }) + } + .await; + result.map(Json).map_err(|e: CoreError| error_result(&e)) + } + + #[tool( + description = "Search docs in a DocsReader workspace. Ranks matches across title, tags, slug, and content; returns snippets and docsreader:// resource URIs. Combine with status/phase/tag filters to narrow.", + annotations(read_only_hint = true) + )] + async fn search_docs( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let filters = parse_filters(p.status.as_deref(), p.phase.as_deref(), p.tag.as_deref())?; + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let hits = search_docs_core(&ws.root, &p.query, &filters)?; + let entries: Vec = hits + .into_iter() + .map(|hit| HitEntry { + uri: doc_uri(&ws.slug, &hit.doc.rel_path), + hit, + }) + .collect(); + let (hits, truncated) = take_within_budget(entries); + Ok(SearchDocsResult { + workspace: ws, + hits, + truncated, + guidance: truncated.then(|| TRUNCATION_GUIDANCE.to_string()), + }) + } + .await; + result.map(Json).map_err(|e: CoreError| error_result(&e)) + } +} diff --git a/src-tauri/mcp/src/server/resources.rs b/src-tauri/mcp/src/server/resources.rs new file mode 100644 index 0000000..ba07dc4 --- /dev/null +++ b/src-tauri/mcp/src/server/resources.rs @@ -0,0 +1,192 @@ +use docsreader_core::error::CoreError; +use docsreader_core::memory::{MemoryHit, search_memory_core}; +use docsreader_core::path_guard::safe_join; +use docsreader_core::read::{DocFilters, DocSummary, list_docs_core}; +use docsreader_core::tasks::{TaskSummary, list_tasks_core}; +use docsreader_core::write::{DocStatus, locate_doc}; +use rmcp::ErrorData; +use rmcp::model::{ + Annotations, ListResourceTemplatesResult, ListResourcesResult, ReadResourceResult, Resource, + ResourceContents, ResourceTemplate, +}; + +use super::{doc_uri, resolve}; + +pub(crate) const ONBOARDING_URI: &str = "docsreader://onboarding"; +const ONBOARDING_TEXT: &str = include_str!("onboarding.md"); +const MARKDOWN_MIME: &str = "text/markdown"; +const PAGE_SIZE: usize = 100; + +/// Attention hint for clients: active work outranks finished work. +fn status_priority(status: DocStatus) -> f32 { + match status { + DocStatus::InProgress => 0.9, + DocStatus::Research => 0.7, + DocStatus::Done => 0.5, + DocStatus::Archived => 0.2, + } +} + +fn doc_annotations(doc: &DocSummary) -> Annotations { + let annotations = Annotations::default().with_priority(status_priority(doc.status)); + match doc + .modified + .and_then(|s| chrono::DateTime::from_timestamp(i64::try_from(s).ok()?, 0)) + { + Some(ts) => annotations.with_timestamp(ts), + None => annotations, + } +} + +fn onboarding_resource() -> Resource { + Resource::new(ONBOARDING_URI, "onboarding") + .with_title("DocsReader agent onboarding") + .with_description( + "Read this first: how DocsReader workspaces, the status/phase lifecycle, \ + and the doc tools fit together.", + ) + .with_mime_type(MARKDOWN_MIME) + .with_annotations(Annotations::default().with_priority(1.0)) +} + +fn doc_resource(ws_slug: &str, doc: &DocSummary) -> Resource { + let mut resource = Resource::new(doc_uri(ws_slug, &doc.rel_path), doc.rel_path.clone()) + .with_mime_type(MARKDOWN_MIME) + .with_size(doc.size) + .with_annotations(doc_annotations(doc)); + if let Some(title) = &doc.title { + resource = resource.with_title(title.clone()); + } + resource +} + +const MEMORY_PRIORITY: f32 = 0.8; +const TASK_PRIORITY: f32 = 0.6; + +fn memory_resource(ws_slug: &str, hit: &MemoryHit) -> Resource { + let mut resource = Resource::new(doc_uri(ws_slug, &hit.rel_path), hit.rel_path.clone()) + .with_mime_type(MARKDOWN_MIME) + .with_annotations(Annotations::default().with_priority(MEMORY_PRIORITY)); + if let Some(title) = &hit.title { + resource = resource.with_title(title.clone()); + } + resource +} + +fn task_resource(ws_slug: &str, task: &TaskSummary) -> Resource { + let mut resource = Resource::new(doc_uri(ws_slug, &task.rel_path), task.id.clone()) + .with_mime_type(MARKDOWN_MIME) + .with_annotations(Annotations::default().with_priority(TASK_PRIORITY)); + if let Some(title) = &task.title { + resource = resource.with_title(title.clone()); + } + resource +} + +fn protocol_error(err: &CoreError) -> ErrorData { + ErrorData::internal_error( + err.message.clone(), + Some(serde_json::json!({ "error": err })), + ) +} + +fn parse_cursor(cursor: Option) -> Result { + match cursor { + None => Ok(0), + Some(c) => c + .parse() + .map_err(|_| ErrorData::invalid_params(format!("invalid cursor `{c}`"), None)), + } +} + +pub(crate) fn list_resources_page( + cursor: Option, +) -> Result { + let offset = parse_cursor(cursor)?; + let ws = resolve(None).map_err(|e| protocol_error(&e))?; + let mut all = Vec::new(); + if ws.root.is_dir() { + let memories = search_memory_core(&ws.root, None, None).map_err(|e| protocol_error(&e))?; + all.extend(memories.iter().map(|hit| memory_resource(&ws.slug, hit))); + let tasks = list_tasks_core(&ws.root, None, None).map_err(|e| protocol_error(&e))?; + all.extend(tasks.iter().map(|task| task_resource(&ws.slug, task))); + let docs = + list_docs_core(&ws.root, &DocFilters::default()).map_err(|e| protocol_error(&e))?; + all.extend(docs.iter().map(|doc| doc_resource(&ws.slug, doc))); + } + + let mut resources = Vec::new(); + if offset == 0 { + resources.push(onboarding_resource()); + } + resources.extend(all.iter().skip(offset).take(PAGE_SIZE).cloned()); + + let next = offset + PAGE_SIZE; + let mut result = ListResourcesResult::with_all_items(resources); + if next < all.len() { + result.next_cursor = Some(next.to_string()); + } + Ok(result) +} + +pub(crate) fn list_templates() -> ListResourceTemplatesResult { + let template = ResourceTemplate::new("docsreader://{workspace}/{+path}", "doc") + .with_title("DocsReader doc") + .with_description( + "A markdown doc in a DocsReader workspace. `workspace` is a workspace slug \ + (see list_workspaces); `path` is the doc's status-relative path, e.g. \ + `research/api-notes.md`.", + ) + .with_mime_type(MARKDOWN_MIME); + ListResourceTemplatesResult::with_all_items(vec![template]) +} + +pub(crate) fn read_resource_at(uri: &str) -> Result { + if uri == ONBOARDING_URI { + return Ok(ReadResourceResult::new(vec![ + ResourceContents::text(ONBOARDING_TEXT, uri).with_mime_type(MARKDOWN_MIME), + ])); + } + let (ws_slug, rel_path) = split_doc_uri(uri)?; + let path = resolve(Some(ws_slug)) + .and_then(|ws| { + if rel_path.starts_with("memory/") || rel_path.starts_with("tasks/") { + let path = safe_join(&ws.root, rel_path)?; + if !path.is_file() { + return Err(CoreError::new( + docsreader_core::error::ErrorCode::DocNotFound, + format!("nothing at {rel_path:?}"), + ) + .with_recovery("call search_memory or list_tasks to see existing entries")); + } + Ok(path) + } else { + locate_doc(&ws.root, rel_path).map(|doc| doc.path) + } + }) + .map_err(|err| { + ErrorData::resource_not_found( + err.message.clone(), + Some(serde_json::json!({ "uri": uri, "error": err })), + ) + })?; + let text = std::fs::read_to_string(&path) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(ReadResourceResult::new(vec![ + ResourceContents::text(text, uri).with_mime_type(MARKDOWN_MIME), + ])) +} + +fn split_doc_uri(uri: &str) -> Result<(&str, &str), ErrorData> { + uri.strip_prefix("docsreader://") + .and_then(|rest| rest.split_once('/')) + .filter(|(ws, path)| !ws.is_empty() && !path.is_empty()) + .ok_or_else(|| { + ErrorData::resource_not_found( + format!("unknown resource URI `{uri}`"), + Some(serde_json::json!({ + "expected": "docsreader://{workspace}/{path} or docsreader://onboarding", + })), + ) + }) +} diff --git a/src-tauri/mcp/src/server/task_tools.rs b/src-tauri/mcp/src/server/task_tools.rs new file mode 100644 index 0000000..34bbfb7 --- /dev/null +++ b/src-tauri/mcp/src/server/task_tools.rs @@ -0,0 +1,226 @@ +use docsreader_core::error::CoreError; +use docsreader_core::tasks::{ + NewTask, TaskSummary, list_tasks_core, set_task_status_core, update_task_core, write_task_core, +}; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::CallToolResult; +use rmcp::{Peer, RoleServer, tool, tool_router}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ + DocsServer, TRUNCATION_GUIDANCE, client_name, doc_uri, ensure_workspace_exists, error_result, + resolve_or_pick, take_within_budget, +}; + +#[derive(Deserialize, JsonSchema)] +pub struct WriteTaskParams { + /// Short task title. + pub title: String, + /// What the task is and why (markdown; becomes the Description section). + pub description: String, + /// Acceptance criteria; rendered as a checklist the assignee ticks off. + pub acceptance_criteria: Option>, + /// "To Do" (default) | "In Progress" | "Done". + pub status: Option, + /// "high" | "medium" | "low". + pub priority: Option, + /// Who the task is assigned to. + pub assignee: Option>, + /// Topic labels. + pub labels: Option>, + /// Ids of tasks this one depends on, e.g. ["task-2"]. + pub dependencies: Option>, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ListTasksParams { + /// Filter by status: "To Do" | "In Progress" | "Done". + pub status: Option, + /// Filter by label. + pub label: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SetTaskStatusParams { + /// Task id (e.g. "task-3") or tasks/ relative path. + pub id: String, + /// Target status: "To Do" | "In Progress" | "Done". + pub status: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdateTaskParams { + /// Task id (e.g. "task-3") or tasks/ relative path. + pub id: String, + /// Exact text to replace; must appear exactly once in the task file. + /// To check an acceptance criterion, replace "- [ ] #1 ..." with + /// "- [x] #1 ...". + pub old_str: String, + /// Replacement text. Omit to delete old_str. + pub new_str: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskChangeResult { + pub ok: bool, + pub workspace: ResolvedWorkspace, + #[serde(flatten)] + pub task: TaskSummary, + /// Resource URI for this task (docsreader:///). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskEntry { + #[serde(flatten)] + pub task: TaskSummary, + /// Resource URI for this task (docsreader:///). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListTasksResult { + pub workspace: ResolvedWorkspace, + pub tasks: Vec, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance: Option, +} + +fn task_change(ws: ResolvedWorkspace, task: TaskSummary) -> Json { + Json(TaskChangeResult { + ok: true, + uri: doc_uri(&ws.slug, &task.rel_path), + workspace: ws, + task, + }) +} + +#[tool_router(router = task_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Create a task in the workspace's tasks/ folder using the Backlog.md file shape (task-N id, frontmatter status, Description + Acceptance Criteria checklist). Unlike docs, task status lives in frontmatter, not folders.", + annotations(destructive_hint = false) + )] + async fn write_task( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let reporter = client_name(&peer); + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + ensure_workspace_exists(&ws)?; + let task = NewTask { + title: &p.title, + description: &p.description, + acceptance_criteria: p.acceptance_criteria.clone().unwrap_or_default(), + status: p.status.as_deref(), + priority: p.priority.as_deref(), + assignee: p.assignee.clone().unwrap_or_default(), + labels: p.labels.clone().unwrap_or_default(), + dependencies: p.dependencies.clone().unwrap_or_default(), + reporter: reporter.as_deref(), + }; + let written = write_task_core(&ws.root, &task).await?; + Ok::<_, CoreError>((ws, written)) + } + .await; + result + .map(|(ws, task)| task_change(ws, task)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "List tasks in the workspace, ordered by id. Filter by status (\"To Do\" | \"In Progress\" | \"Done\") or label.", + annotations(read_only_hint = true) + )] + async fn list_tasks( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let tasks = if ws.root.is_dir() { + list_tasks_core(&ws.root, p.status.as_deref(), p.label.as_deref())? + } else { + Vec::new() + }; + let entries: Vec = tasks + .into_iter() + .map(|task| TaskEntry { + uri: doc_uri(&ws.slug, &task.rel_path), + task, + }) + .collect(); + let (tasks, truncated) = take_within_budget(entries); + Ok::<_, CoreError>(ListTasksResult { + workspace: ws, + tasks, + truncated, + guidance: truncated.then(|| TRUNCATION_GUIDANCE.to_string()), + }) + } + .await; + result.map(Json).map_err(|e| error_result(&e)) + } + + #[tool( + description = "Move a task to a different status (\"To Do\" | \"In Progress\" | \"Done\") by rewriting its frontmatter; updated_date is stamped.", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn set_task_status( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let task = set_task_status_core(&ws.root, &p.id, &p.status).await?; + Ok::<_, CoreError>((ws, task)) + } + .await; + result + .map(|(ws, task)| task_change(ws, task)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Edit a task file by exact string replacement (same contract as update_doc). Typical use: check an acceptance criterion by replacing \"- [ ] #1 ...\" with \"- [x] #1 ...\", or append implementation notes." + )] + async fn update_task( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let task = update_task_core( + &ws.root, + &p.id, + &p.old_str, + p.new_str.as_deref().unwrap_or(""), + ) + .await?; + Ok::<_, CoreError>((ws, task)) + } + .await; + result + .map(|(ws, task)| task_change(ws, task)) + .map_err(|e| error_result(&e)) + } +} diff --git a/src-tauri/mcp/src/server/workspace_tools.rs b/src-tauri/mcp/src/server/workspace_tools.rs new file mode 100644 index 0000000..8222d25 --- /dev/null +++ b/src-tauri/mcp/src/server/workspace_tools.rs @@ -0,0 +1,121 @@ +use std::path::PathBuf; + +use docsreader_core::error::{CoreError, ErrorCode}; +use docsreader_core::workspace::WorkspaceScope; +use docsreader_core::workspace::init::{InitializedWorkspace, init_workspace_core}; +use docsreader_core::workspace::registry::{default_registry_path, load_registry}; +use docsreader_core::workspace::resolve::DEFAULT_WORKSPACE_DIR; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::CallToolResult; +use rmcp::{tool, tool_router}; +use schemars::JsonSchema; +use serde::Deserialize; + +use super::{DocsServer, error_result, home_dir, success_json}; + +#[derive(Deserialize, JsonSchema)] +pub struct InitWorkspaceParams { + /// Project directory; the workspace is created at /notes. Omit to + /// create the user workspace at ~/notes. + pub path: Option, + /// Workspace scope: "user" (~/notes) or "project" (/notes). + /// Defaults to project when path is given, user otherwise. + pub scope: Option, + /// Workspace slug; defaults to the project folder name, or "notes" for user scope. + pub slug: Option, + /// Human-readable display name. + pub name: Option, +} + +#[tool_router(router = workspace_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Health check. Returns \"pong\" if the server is alive.", + annotations(read_only_hint = true) + )] + async fn ping(&self) -> String { + "pong".to_string() + } + + #[tool( + description = "List all known DocsReader workspaces: registered project workspaces plus the default user workspace (~/notes). Call this when a workspace slug is unknown or before choosing where to write.", + annotations(read_only_hint = true) + )] + async fn list_workspaces(&self) -> CallToolResult { + match list_workspaces_impl() { + Ok(value) => success_json(value), + Err(err) => error_result(&err), + } + } + + #[tool( + description = "Create a new DocsReader workspace and register it. No args: creates the user workspace at ~/notes. With path: creates a project workspace at /notes. Fails if the target already has content.", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn init_workspace( + &self, + Parameters(p): Parameters, + ) -> CallToolResult { + match init_workspace_impl(&p) { + Ok(ws) => success_json(serde_json::json!({ + "ok": true, + "workspacePath": ws.root, + "slug": ws.slug, + "name": ws.name, + "scope": ws.scope, + })), + Err(err) => error_result(&err), + } + } +} + +fn list_workspaces_impl() -> Result { + let home = home_dir()?; + let entries = load_registry(&default_registry_path(&home))?; + let default_root = home.join(DEFAULT_WORKSPACE_DIR); + Ok(serde_json::json!({ + "workspaces": entries, + "defaultUserWorkspace": { + "path": default_root, + "exists": default_root.is_dir(), + "scope": "user", + }, + })) +} + +fn parse_scope(value: &str) -> Result { + match value { + "user" => Ok(WorkspaceScope::User), + "project" => Ok(WorkspaceScope::Project), + other => Err( + CoreError::new(ErrorCode::InvalidInput, format!("unknown scope {other:?}")) + .with_recovery("valid scopes: [user, project]"), + ), + } +} + +fn init_workspace_impl(p: &InitWorkspaceParams) -> Result { + let scope = match (p.scope.as_deref(), p.path.as_deref()) { + (Some(s), _) => parse_scope(s)?, + (None, Some(_)) => WorkspaceScope::Project, + (None, None) => WorkspaceScope::User, + }; + let home = home_dir()?; + let root = match scope { + WorkspaceScope::User => home.join(DEFAULT_WORKSPACE_DIR), + WorkspaceScope::Project => { + let base = match p.path.as_deref() { + Some(path) => PathBuf::from(path), + None => std::env::current_dir()?, + }; + base.join(DEFAULT_WORKSPACE_DIR) + } + }; + init_workspace_core( + &root, + p.slug.as_deref(), + p.name.as_deref(), + scope, + &default_registry_path(&home), + ) +} diff --git a/src-tauri/mcp/src/server/write_tools.rs b/src-tauri/mcp/src/server/write_tools.rs new file mode 100644 index 0000000..f90cd6d --- /dev/null +++ b/src-tauri/mcp/src/server/write_tools.rs @@ -0,0 +1,338 @@ +use std::path::Path; + +use docsreader_core::delete::delete_doc_core; +use docsreader_core::error::CoreError; +use docsreader_core::memory::delete_memory_core; +use docsreader_core::rename::rename_doc_core; +use docsreader_core::tasks::delete_task_core; +use docsreader_core::update::str_replace_core; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use docsreader_core::write::{ + DocStatus, NewDoc, WrittenDoc, archive_doc_core, set_phase_core, set_status_core, + write_doc_core, +}; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::{CallToolResult, ContentBlock}; +use rmcp::{Peer, RoleServer, tool, tool_router}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ + DocsServer, client_name, doc_resource_link, doc_uri, ensure_workspace_exists, error_result, + resolve_or_pick, +}; + +#[derive(Deserialize, JsonSchema)] +pub struct WriteDocParams { + /// Short human-readable title; becomes the doc's slug and filename. + pub title: String, + /// Markdown body. Frontmatter is generated automatically; do not include it. + pub body: String, + /// Lifecycle status: one of "research", "in-progress", "done", "archived". + pub status: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default + /// workspace (project ./notes if present, else user ~/notes). + pub workspace: Option, + /// Phase subfolder within the status folder, e.g. "discovery" or "v2-launch". + pub phase: Option, + /// Owner of the doc (person or agent name). + pub owner: Option, + /// Topic tags. + pub tags: Option>, + /// Priority label, e.g. "high" | "medium" | "low". + pub priority: Option, + /// Due date in ISO format (YYYY-MM-DD). + pub due: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdateDocParams { + /// Doc slug or status-relative path (e.g. "research/api-notes.md"). + pub path: String, + /// Exact text to replace; must appear exactly once in the doc. + pub old_str: String, + /// Replacement text. Omit to delete old_str. + pub new_str: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SetStatusParams { + /// Doc slug or status-relative path. + pub path: String, + /// Target status: "research" | "in-progress" | "done" | "archived". + pub status: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SetPhaseParams { + /// Doc slug or status-relative path. + pub path: String, + /// Phase subfolder name (e.g. "v2-launch"). Omit to move the doc out of + /// its phase subfolder. + pub phase: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ArchiveParams { + /// Doc slug or status-relative path. + pub path: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct DeleteDocParams { + /// Doc slug or status-relative path. + pub path: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct RenameDocParams { + /// Doc slug or status-relative path. + pub path: String, + /// New human-readable title; becomes the frontmatter title and the new + /// slug/filename. + pub new_title: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocDeleteResult { + pub ok: bool, + pub workspace: ResolvedWorkspace, + pub slug: String, + /// Status-relative path the doc was deleted from. + pub rel_path: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocChangeResult { + pub ok: bool, + pub workspace: ResolvedWorkspace, + #[serde(flatten)] + pub doc: WrittenDoc, + /// Resource URI for this doc (docsreader:///). + pub uri: String, +} + +fn change_result(ws: ResolvedWorkspace, doc: WrittenDoc) -> Json { + Json(DocChangeResult { + ok: true, + uri: doc_uri(&ws.slug, &doc.rel_path), + workspace: ws, + doc, + }) +} + +async fn located( + peer: &Peer, + workspace: Option<&str>, +) -> Result { + let ws = resolve_or_pick(peer, workspace).await?; + ensure_workspace_exists(&ws)?; + Ok(ws) +} + +#[tool_router(router = write_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Create a markdown doc in a DocsReader workspace. The doc lands in the folder matching its status (research | in-progress | done | archived), optionally inside a phase subfolder, with generated frontmatter. Prefer this over writing files directly: it handles slugs, collisions, metadata, and git staging.", + annotations(destructive_hint = false) + )] + async fn write_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> CallToolResult { + let created_by = client_name(&peer); + match write_doc_impl(&peer, &p, created_by.as_deref()).await { + Ok((ws, doc)) => { + let link = doc_resource_link(&ws.slug, &doc.rel_path, &p.title); + let body = serde_json::json!({ + "ok": true, + "path": doc.path, + "relPath": doc.rel_path, + "slug": doc.slug, + "status": doc.status, + "phase": doc.phase, + "workspace": ws, + }); + CallToolResult::success(vec![ContentBlock::text(body.to_string()), link]) + } + Err(err) => error_result(&err), + } + } + + #[tool( + description = "Edit a doc in place by exact string replacement (str_replace). old_str must appear exactly once; on failure the error explains whether it was missing or ambiguous." + )] + async fn update_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = str_replace_core( + &ws.root, + &p.path, + &p.old_str, + p.new_str.as_deref().unwrap_or(""), + ) + .await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Move a doc to a different lifecycle status (research | in-progress | done | archived). The move IS the status change; any phase subfolder is preserved.", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn set_status( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let status = DocStatus::parse(&p.status)?; + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = set_status_core(&ws.root, &p.path, status).await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Move a doc into a phase subfolder within its status (or out of it when phase is omitted).", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn set_phase( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = set_phase_core(&ws.root, &p.path, p.phase.as_deref()).await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Archive a doc: shorthand for set_status(path, \"archived\").", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn archive( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = archive_doc_core(&ws.root, &p.path).await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Rename a doc: the new title becomes the frontmatter title and a new slug/filename. The doc stays in its status and phase; renaming onto a slug another doc uses is a conflict.", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn rename_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = rename_doc_core(&ws.root, &p.path, &p.new_title).await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Permanently delete a doc (or a memory entry via \"memory/.md\", or a task via its \"tasks/...\" path) from the workspace. Outside git history this cannot be undone; prefer archive to keep a doc browsable.", + annotations(destructive_hint = true, idempotent_hint = true) + )] + async fn delete_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let (slug, rel_path) = if p.path.starts_with("memory/") { + let entry = delete_memory_core(&ws.root, &p.path).await?; + (entry.slug, entry.rel_path) + } else if p.path.starts_with("tasks/") { + let task = delete_task_core(&ws.root, &p.path).await?; + (task.id, task.rel_path) + } else { + let doc = delete_doc_core(&ws.root, &p.path).await?; + (doc.slug, doc.rel_path) + }; + Ok::<_, CoreError>((ws, slug, rel_path)) + } + .await; + result + .map(|(ws, slug, rel_path)| { + Json(DocDeleteResult { + ok: true, + workspace: ws, + slug, + rel_path, + }) + }) + .map_err(|e| error_result(&e)) + } +} + +async fn write_doc_impl( + peer: &Peer, + p: &WriteDocParams, + created_by: Option<&str>, +) -> Result<(ResolvedWorkspace, WrittenDoc), CoreError> { + let status = DocStatus::parse(&p.status)?; + let ws = located(peer, p.workspace.as_deref()).await?; + let doc = NewDoc { + created_by, + phase: p.phase.as_deref(), + owner: p.owner.as_deref(), + tags: p.tags.clone().unwrap_or_default(), + priority: p.priority.as_deref(), + due: p.due.as_deref(), + ..NewDoc::new(&p.title, &p.body, status) + }; + let written = write_doc_core(Path::new(&ws.root), &doc).await?; + Ok((ws, written)) +} diff --git a/src-tauri/mcp/tests/stdio.rs b/src-tauri/mcp/tests/stdio.rs new file mode 100644 index 0000000..ef625df --- /dev/null +++ b/src-tauri/mcp/tests/stdio.rs @@ -0,0 +1,443 @@ +//! End-to-end tests driving the real docsreader-mcp binary over stdio. +//! Each test gets an isolated HOME, so nothing touches the developer's +//! workspaces; any stray non-JSON byte on stdout fails the harness. + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +struct McpClient { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl McpClient { + fn spawn(home: &Path, envs: &[(&str, &str)], capabilities: Value) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_docsreader-mcp")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .env("HOME", home) + .envs(envs.iter().copied()) + .spawn() + .expect("spawn docsreader-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut client = Self { + child, + stdin, + stdout, + next_id: 0, + }; + client.request( + "initialize", + json!({ + "protocolVersion": "2025-11-25", + "capabilities": capabilities, + "clientInfo": {"name": "stdio-test", "version": "0"}, + }), + no_server_requests, + ); + client.notify("notifications/initialized"); + client + } + + fn send(&mut self, value: Value) { + let mut line = value.to_string(); + line.push('\n'); + self.stdin.write_all(line.as_bytes()).expect("write stdin"); + self.stdin.flush().expect("flush stdin"); + } + + fn notify(&mut self, method: &str) { + self.send(json!({"jsonrpc": "2.0", "method": method})); + } + + fn read_message(&mut self) -> Value { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read stdout"); + assert!(n > 0, "server closed stdout unexpectedly"); + serde_json::from_str(&line).expect("stdout carried a non-JSON line") + } + + /// Send a request and pump messages until its response arrives. Server- + /// initiated requests (e.g. elicitation/create) are answered by + /// `on_request`, which returns the result to reply with. + fn request( + &mut self, + method: &str, + params: Value, + mut on_request: impl FnMut(&Value) -> Value, + ) -> Value { + self.next_id += 1; + let id = self.next_id; + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + loop { + let msg = self.read_message(); + if msg.get("id") == Some(&json!(id)) && msg.get("method").is_none() { + assert!( + msg.get("error").is_none(), + "protocol error for {method}: {msg}" + ); + return msg["result"].clone(); + } + if let (Some(req_id), Some(_)) = (msg.get("id"), msg.get("method")) { + let reply = on_request(&msg); + let req_id = req_id.clone(); + self.send(json!({"jsonrpc": "2.0", "id": req_id, "result": reply})); + } + } + } + + /// Call a tool; returns (payload parsed from the first text block, isError). + fn call(&mut self, tool: &str, args: Value) -> (Value, bool) { + self.call_with(tool, args, no_server_requests) + } + + fn call_with( + &mut self, + tool: &str, + args: Value, + on_request: impl FnMut(&Value) -> Value, + ) -> (Value, bool) { + let result = self.request( + "tools/call", + json!({"name": tool, "arguments": args}), + on_request, + ); + let text = result["content"][0]["text"] + .as_str() + .expect("tool result carries a text block"); + let payload = serde_json::from_str(text).expect("tool text block is JSON"); + (payload, result["isError"].as_bool().unwrap_or(false)) + } +} + +impl Drop for McpClient { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn no_server_requests(msg: &Value) -> Value { + panic!("unexpected server-initiated request: {msg}"); +} + +fn temp_home() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") +} + +fn init_project(client: &mut McpClient, home: &Path, slug: &str) -> String { + let project = home.join(format!("{slug}-proj")); + std::fs::create_dir_all(&project).unwrap(); + let (payload, is_err) = client.call( + "init_workspace", + json!({"path": project.to_str().unwrap(), "slug": slug}), + ); + assert!(!is_err, "init_workspace failed: {payload}"); + project.to_str().unwrap().to_string() +} + +#[test] +fn doc_lifecycle_round_trip() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "life"); + + let (doc, is_err) = c.call( + "write_doc", + json!({ + "title": "API Design", + "body": "# API Design\n\nfirst draft", + "status": "research", + "workspace": "life", + "tags": ["api"], + }), + ); + assert!(!is_err, "{doc}"); + assert_eq!(doc["ok"], true); + assert_eq!(doc["slug"], "api-design"); + assert_eq!(doc["status"], "research"); + + let (list, _) = c.call("list_docs", json!({"workspace": "life"})); + assert_eq!(list["docs"].as_array().unwrap().len(), 1); + let uri = list["docs"][0]["uri"].as_str().unwrap().to_string(); + assert!(uri.starts_with("docsreader://life/"), "{uri}"); + + let (read, _) = c.call( + "read_doc", + json!({"path": "api-design", "workspace": "life", "response_format": "detailed"}), + ); + assert!(read.to_string().contains("first draft")); + + let (updated, is_err) = c.call( + "update_doc", + json!({ + "path": "api-design", + "old_str": "first draft", + "new_str": "revised draft", + "workspace": "life", + }), + ); + assert!(!is_err, "{updated}"); + let (read, _) = c.call( + "read_doc", + json!({"path": "api-design", "workspace": "life", "response_format": "detailed"}), + ); + assert!(read.to_string().contains("revised draft")); + + let (moved, _) = c.call( + "set_status", + json!({"path": "api-design", "status": "in-progress", "workspace": "life"}), + ); + assert!( + moved["relPath"] + .as_str() + .unwrap() + .starts_with("in-progress/") + ); + + let (archived, _) = c.call( + "archive", + json!({"path": "api-design", "workspace": "life"}), + ); + assert!( + archived["relPath"] + .as_str() + .unwrap() + .starts_with("archived/") + ); + + let (list, _) = c.call( + "list_docs", + json!({"workspace": "life", "status": "archived"}), + ); + assert_eq!(list["docs"].as_array().unwrap().len(), 1); +} + +#[test] +fn resources_and_prompts_are_served() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let project = init_project(&mut c, home.path(), "res"); + drop(c); + // resources/list serves the ambient workspace; point it at the project. + let mut c = McpClient::spawn( + home.path(), + &[("CLAUDE_PROJECT_DIR", project.as_str())], + json!({}), + ); + c.call( + "write_doc", + json!({"title": "Findings", "body": "insight", "status": "done", "workspace": "res"}), + ); + + let listed = c.request("resources/list", json!({}), no_server_requests); + let uris: Vec<&str> = listed["resources"] + .as_array() + .unwrap() + .iter() + .filter_map(|r| r["uri"].as_str()) + .collect(); + assert!(uris.contains(&"docsreader://onboarding"), "{uris:?}"); + + let onboarding = c.request( + "resources/read", + json!({"uri": "docsreader://onboarding"}), + no_server_requests, + ); + let text = onboarding["contents"][0]["text"].as_str().unwrap(); + assert!( + text.contains("workspace"), + "onboarding should explain the model" + ); + + let doc_uri = uris + .iter() + .find(|u| u.starts_with("docsreader://res/")) + .expect("written doc listed as a resource"); + let doc = c.request( + "resources/read", + json!({"uri": doc_uri}), + no_server_requests, + ); + assert!( + doc["contents"][0]["text"] + .as_str() + .unwrap() + .contains("insight") + ); + + let prompts = c.request("prompts/list", json!({}), no_server_requests); + let names: Vec<&str> = prompts["prompts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["name"].as_str()) + .collect(); + assert!(names.contains(&"start-task"), "{names:?}"); + assert!(names.contains(&"record-decision"), "{names:?}"); + + let prompt = c.request( + "prompts/get", + json!({"name": "start-task", "arguments": {"title": "Ship v1"}}), + no_server_requests, + ); + assert!(!prompt["messages"].as_array().unwrap().is_empty()); +} + +#[test] +fn memory_last_write_wins_round_trip() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "mem"); + + let (first, is_err) = c.call( + "write_memory", + json!({"topic": "deploy target", "content": "we deploy to staging", "workspace": "mem"}), + ); + assert!(!is_err, "{first}"); + let (second, _) = c.call( + "write_memory", + json!({"topic": "deploy target", "content": "we deploy to prod now", "workspace": "mem"}), + ); + assert_eq!(second["created"], false, "overwrite, not new entry"); + + let (found, _) = c.call( + "search_memory", + json!({"query": "deploy", "workspace": "mem"}), + ); + let serialized = found.to_string(); + assert!(serialized.contains("prod now")); + assert!(!serialized.contains("staging"), "old content must be gone"); + assert_eq!(found["memories"].as_array().unwrap().len(), 1); +} + +#[test] +fn tasks_round_trip() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "tsk"); + + let (task, is_err) = c.call( + "write_task", + json!({ + "title": "Wire CI", + "description": "Add the pipeline", + "acceptance_criteria": ["pipeline runs on PR"], + "workspace": "tsk", + }), + ); + assert!(!is_err, "{task}"); + let id = task["id"].as_str().unwrap().to_string(); + assert_eq!(task["status"], "To Do"); + + let (moved, _) = c.call( + "set_task_status", + json!({"id": id, "status": "In Progress", "workspace": "tsk"}), + ); + assert_eq!(moved["status"], "In Progress"); + + let (checked, is_err) = c.call( + "update_task", + json!({ + "id": id, + "old_str": "- [ ] #1 pipeline runs on PR", + "new_str": "- [x] #1 pipeline runs on PR", + "workspace": "tsk", + }), + ); + assert!(!is_err, "{checked}"); + + let (list, _) = c.call( + "list_tasks", + json!({"workspace": "tsk", "status": "In Progress"}), + ); + assert_eq!(list["tasks"].as_array().unwrap().len(), 1); +} + +#[test] +fn traversal_and_unknown_slug_are_tool_errors_not_protocol_errors() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "sec"); + + let (payload, is_err) = c.call( + "read_doc", + json!({"path": "../../../etc/passwd", "workspace": "sec"}), + ); + assert!(is_err, "traversal must be rejected: {payload}"); + assert!(payload["error"]["code"].is_string()); + + let (payload, is_err) = c.call("list_docs", json!({"workspace": "ghost"})); + assert!(is_err); + assert_eq!(payload["error"]["code"], "workspace_not_found"); + assert!( + payload["error"]["recovery"] + .as_str() + .unwrap() + .contains("sec"), + "recovery lists known workspaces: {payload}" + ); +} + +#[test] +fn claude_project_dir_auto_selects_workspace() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let project = init_project(&mut c, home.path(), "auto"); + drop(c); + + let mut c = McpClient::spawn( + home.path(), + &[("CLAUDE_PROJECT_DIR", project.as_str())], + json!({}), + ); + let (list, is_err) = c.call("list_docs", json!({})); + assert!(!is_err, "{list}"); + assert_eq!(list["workspace"]["slug"], "auto"); +} + +#[test] +fn unknown_slug_offers_elicitation_picker_and_accept_resolves() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({"elicitation": {}})); + init_project(&mut c, home.path(), "pick"); + + let mut picker = None; + let (list, is_err) = c.call_with("list_docs", json!({"workspace": "ghost"}), |msg| { + assert_eq!(msg["method"], "elicitation/create"); + picker = Some(msg["params"].clone()); + json!({"action": "accept", "content": {"workspace": "pick"}}) + }); + assert!(!is_err, "{list}"); + assert_eq!(list["workspace"]["slug"], "pick"); + + let picker = picker.expect("server sent an elicitation request"); + assert_eq!(picker["mode"], "form"); + let choices = &picker["requestedSchema"]["properties"]["workspace"]["enum"]; + assert!( + choices.as_array().unwrap().contains(&json!("pick")), + "{picker}" + ); +} + +#[test] +fn elicitation_decline_falls_back_to_recovery_error() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({"elicitation": {}})); + init_project(&mut c, home.path(), "dec"); + + let (payload, is_err) = c.call_with( + "list_docs", + json!({"workspace": "ghost"}), + |_| json!({"action": "decline"}), + ); + assert!(is_err); + assert_eq!(payload["error"]["code"], "workspace_not_found"); +} diff --git a/src-tauri/resources/welcome/.docs.yaml b/src-tauri/resources/welcome/.docs.yaml deleted file mode 100644 index 1d6b5d4..0000000 --- a/src-tauri/resources/welcome/.docs.yaml +++ /dev/null @@ -1,24 +0,0 @@ -spec_version: "0.1" - -project: - slug: docsreader-welcome - name: DocsReader welcome - tagline: Get started in 5 minutes - homepage: Welcome.md - -navigation: - - title: Start here - items: - - title: Welcome - path: Welcome.md - - title: Add a folder - path: Getting started/Add a folder.md - - title: Search and Quick Open - path: Getting started/Search and Quick Open.md - - - title: Features - items: - - title: Lenses and tabs - path: Features/Lenses and tabs.md - - title: Curated nav with .docs.yaml - path: Features/Manifests.md diff --git a/src-tauri/resources/welcome/.docsreader.yaml b/src-tauri/resources/welcome/.docsreader.yaml new file mode 100644 index 0000000..38c61fa --- /dev/null +++ b/src-tauri/resources/welcome/.docsreader.yaml @@ -0,0 +1,3 @@ +slug: docsreader-welcome +name: DocsReader welcome +homepage: Welcome.md diff --git a/src-tauri/resources/welcome/Features/Manifests.md b/src-tauri/resources/welcome/Features/Manifests.md deleted file mode 100644 index bb85a11..0000000 --- a/src-tauri/resources/welcome/Features/Manifests.md +++ /dev/null @@ -1,44 +0,0 @@ -# Curated nav with `.docs.yaml` - -If your project's docs folder ships a `.docs.yaml` at the root, DocsReader uses it to drive the file tree, the workspace label, and which files are hidden. The result is a curated reading order instead of an alphabetical scan. - -This welcome workspace itself uses one - look at the sidebar. Sections like **Start here** and **Features** are declared in `.docs.yaml`, not derived from folder names. - -## A minimal manifest - -```yaml -spec_version: "0.1" - -project: - slug: my-project - name: My Project - tagline: One-line description - -navigation: - - title: Start here - items: - - title: Architecture - path: docs/architecture.md - - title: Operating contract - path: docs/CONTRACT.md - - - title: Decisions - folder: docs/adr/ - sort: filename - title_from: heading - -ignore: - - docs/archived/** - - "**/*.draft.md" -``` - -Drop that in your repo root and DocsReader picks it up on the next scan. - -## What it controls - -- `project.name` becomes the workspace tab label. -- `project.homepage` opens automatically when you first add the workspace. -- `navigation` becomes the sidebar tree (`items` for hand-curated lists, `folder` for auto-listed sections). -- `ignore` adds glob patterns to your clutter rules for that workspace only. - -DocsReader implements the `.docs.yaml` v0.1 spec. Any project that ships a compliant manifest gets a curated experience for free. diff --git a/src-tauri/resources/welcome/Getting started/Connect your AI agents.md b/src-tauri/resources/welcome/Getting started/Connect your AI agents.md new file mode 100644 index 0000000..eccaa3a --- /dev/null +++ b/src-tauri/resources/welcome/Getting started/Connect your AI agents.md @@ -0,0 +1,40 @@ +# Connect your AI agents + +DocsReader ships with `docsreader-mcp`, a local MCP server your AI agents write through: docs, memory, and tasks, all as plain markdown files you read here. Connect it once and agents like Claude Code, Cursor, Windsurf, VS Code, and Codex can record research, decisions, and progress while you watch the corpus grow live. + +## One click + +Open **Settings -> AI agents**. DocsReader detects the agent tools installed on this machine and shows a **Connect** button for each - one click registers the server, user-wide. Already-connected tools show a check mark. + +## Manual setup + +`docsreader-mcp` is a local stdio server - there is no URL to add; each agent spawns the binary itself. With Homebrew (macOS) or the Linux deb it is already on your PATH: + +```sh +# Claude Code +claude mcp add --scope user docsreader -- docsreader-mcp + +# Codex CLI +codex mcp add docsreader -- docsreader-mcp + +# VS Code +code --add-mcp '{"name":"docsreader","command":"docsreader-mcp"}' +``` + +Cursor (`~/.cursor/mcp.json`) and Windsurf (`~/.codeium/windsurf/mcp_config.json`) take the same JSON shape: + +```json +{ "mcpServers": { "docsreader": { "command": "docsreader-mcp" } } } +``` + +Installed from the macOS DMG without Homebrew? Use the full path instead: `/Applications/DocsReader.app/Contents/MacOS/docsreader-mcp`. + +## What agents do with it + +- **Docs** land in folders that mirror their lifecycle - `research/`, `in-progress/`, `done/`, `archived/`. The folder is the status; moving the file is the status change. +- **Memory** (`memory/`) holds short topic-addressed facts. Writing a topic again replaces it. +- **Tasks** (`tasks/`) are checklist files agents tick off as they work. + +By default everything goes to `~/notes` (created on the first write); any project can opt in to its own workspace. The server teaches agents this model itself - after connecting, just ask your agent to "record what you learned in DocsReader". + +To make agents use it consistently in a repo, copy the [AGENTS template](https://github.com/anbturki/docsreader/blob/main/docs/AGENTS-TEMPLATE.md) into your `AGENTS.md` or `CLAUDE.md`. diff --git a/src-tauri/resources/welcome/Welcome.md b/src-tauri/resources/welcome/Welcome.md index 6f6bafd..e64d1fe 100644 --- a/src-tauri/resources/welcome/Welcome.md +++ b/src-tauri/resources/welcome/Welcome.md @@ -1,14 +1,14 @@ # Welcome to DocsReader -DocsReader is a fast, focused markdown reader for any folder on your computer. Point it at a documentation folder, a notes vault, or a repository, and it renders everything as a clean, ADHD-friendly reading surface. +DocsReader is a fast, focused markdown reader - and the human window into docs your AI agents write. Point it at any folder of markdown, or connect your agents and watch research, decisions, memory, and tasks appear as plain files you can read, grep, and version. This welcome workspace is a real folder on your disk - your own copy. Edit it, delete files, or remove the workspace entirely. DocsReader treats it like any other workspace. ## What's next - **Add a folder** - bring your own docs in. +- **Connect your AI agents** - one click in Settings, and agents write docs you read here. - **Search and Quick Open** - navigate fast. - **Lenses and tabs** - how the sidebar reorganizes itself. -- **Curated nav with `.docs.yaml`** - give a project repo a curated reading order. When you're done with this workspace, just remove it from the switcher (right-click the tab). It won't come back unless you ask via Settings. diff --git a/src-tauri/src/agents/config.rs b/src-tauri/src/agents/config.rs new file mode 100644 index 0000000..54706c5 --- /dev/null +++ b/src-tauri/src/agents/config.rs @@ -0,0 +1,183 @@ +use std::fs; +use std::path::Path; + +use serde_json::{json, Map, Value}; + +use super::SERVER_NAME; + +pub fn read_json_command(path: &Path, top_key: &str) -> Option { + let text = fs::read_to_string(path).ok()?; + let root: Value = serde_json::from_str(&text).ok()?; + root.get(top_key)? + .get(SERVER_NAME)? + .get("command")? + .as_str() + .map(str::to_owned) +} + +pub fn upsert_json_server( + path: &Path, + top_key: &str, + entry_type: bool, + command: &str, +) -> Result<(), String> { + let mut root = match fs::read_to_string(path) { + Ok(text) => serde_json::from_str::(&text).map_err(|e| { + format!( + "{} is not valid JSON ({e}); fix the file or add this entry under \"{top_key}\" manually: {}", + path.display(), + json_entry(entry_type, command) + ) + })?, + Err(_) => Value::Object(Map::new()), + }; + let obj = root + .as_object_mut() + .ok_or_else(|| format!("{} does not contain a JSON object", path.display()))?; + let servers = obj + .entry(top_key) + .or_insert_with(|| Value::Object(Map::new())); + let servers = servers + .as_object_mut() + .ok_or_else(|| format!("\"{top_key}\" in {} is not a JSON object", path.display()))?; + servers.insert(SERVER_NAME.into(), json_entry(entry_type, command)); + write_atomic(path, &format!("{:#}\n", root)) +} + +fn json_entry(entry_type: bool, command: &str) -> Value { + if entry_type { + json!({ "type": "stdio", "command": command }) + } else { + json!({ "command": command }) + } +} + +pub fn read_toml_command(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + let doc: toml_edit::DocumentMut = text.parse().ok()?; + doc.get("mcp_servers")? + .get(SERVER_NAME)? + .get("command")? + .as_str() + .map(str::to_owned) +} + +pub fn upsert_toml_server(path: &Path, command: &str) -> Result<(), String> { + let text = fs::read_to_string(path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e| { + format!( + "{} is not valid TOML ({e}); fix the file or add this entry manually: [mcp_servers.{SERVER_NAME}] command = \"{command}\"", + path.display() + ) + })?; + let servers = doc + .entry("mcp_servers") + .or_insert(toml_edit::table()) + .as_table_mut() + .ok_or_else(|| format!("mcp_servers in {} is not a table", path.display()))?; + servers.set_implicit(true); + let mut entry = toml_edit::Table::new(); + entry["command"] = toml_edit::value(command); + servers.insert(SERVER_NAME, toml_edit::Item::Table(entry)); + write_atomic(path, &doc.to_string()) +} + +fn write_atomic(path: &Path, content: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{} has no parent directory", path.display()))?; + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + let tmp = path.with_extension("docsreader-tmp"); + fs::write(&tmp, content).map_err(|e| format!("write {}: {e}", tmp.display()))?; + fs::rename(&tmp, path).map_err(|e| format!("replace {}: {e}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + #[test] + fn json_upsert_creates_file_and_reads_back() { + let dir = tmp(); + let path = dir.path().join("nested/mcp.json"); + upsert_json_server(&path, "mcpServers", false, "/bin/x").unwrap(); + assert_eq!( + read_json_command(&path, "mcpServers").as_deref(), + Some("/bin/x") + ); + let text = fs::read_to_string(&path).unwrap(); + assert!(!text.contains("\"type\"")); + } + + #[test] + fn json_upsert_preserves_other_servers_and_keys() { + let dir = tmp(); + let path = dir.path().join("claude.json"); + fs::write( + &path, + r#"{"zTrailing": 1, "mcpServers": {"other": {"command": "keep"}}, "aLeading": {"x": true}}"#, + ) + .unwrap(); + upsert_json_server(&path, "mcpServers", true, "/bin/x").unwrap(); + let text = fs::read_to_string(&path).unwrap(); + let root: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(root["mcpServers"]["other"]["command"], "keep"); + assert_eq!(root["mcpServers"][SERVER_NAME]["type"], "stdio"); + assert_eq!(root["zTrailing"], 1); + assert_eq!(root["aLeading"]["x"], true); + let z = text.find("zTrailing").unwrap(); + let a = text.find("aLeading").unwrap(); + assert!(z < a, "original key order must be preserved"); + } + + #[test] + fn json_upsert_replaces_existing_docsreader_entry() { + let dir = tmp(); + let path = dir.path().join("mcp.json"); + upsert_json_server(&path, "servers", true, "/old").unwrap(); + upsert_json_server(&path, "servers", true, "/new").unwrap(); + assert_eq!(read_json_command(&path, "servers").as_deref(), Some("/new")); + } + + #[test] + fn json_upsert_rejects_invalid_json_without_touching_file() { + let dir = tmp(); + let path = dir.path().join("mcp.json"); + fs::write(&path, "{ not json").unwrap(); + let err = upsert_json_server(&path, "mcpServers", false, "/bin/x").unwrap_err(); + assert!(err.contains("not valid JSON")); + assert!(err.contains("manually")); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); + } + + #[test] + fn toml_upsert_preserves_comments_and_existing_servers() { + let dir = tmp(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + "# my codex config\nmodel = \"o4\"\n\n[mcp_servers.other]\ncommand = \"keep\"\n", + ) + .unwrap(); + upsert_toml_server(&path, "/bin/x").unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("# my codex config")); + assert!(text.contains("model = \"o4\"")); + assert!(text.contains("[mcp_servers.other]")); + assert!(text.contains(&format!("[mcp_servers.{SERVER_NAME}]"))); + assert!(!text.contains("[mcp_servers]\n"), "no empty parent header"); + assert_eq!(read_toml_command(&path).as_deref(), Some("/bin/x")); + } + + #[test] + fn toml_upsert_creates_missing_file() { + let dir = tmp(); + let path = dir.path().join("config.toml"); + upsert_toml_server(&path, "/bin/x").unwrap(); + assert_eq!(read_toml_command(&path).as_deref(), Some("/bin/x")); + } +} diff --git a/src-tauri/src/agents/mod.rs b/src-tauri/src/agents/mod.rs new file mode 100644 index 0000000..31bf2b1 --- /dev/null +++ b/src-tauri/src/agents/mod.rs @@ -0,0 +1,296 @@ +mod config; + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const SERVER_NAME: &str = "docsreader"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ClientId { + ClaudeCode, + Cursor, + Windsurf, + #[serde(rename = "vscode")] + VsCode, + Codex, +} + +pub const CLIENT_IDS: [ClientId; 5] = [ + ClientId::ClaudeCode, + ClientId::Cursor, + ClientId::Windsurf, + ClientId::VsCode, + ClientId::Codex, +]; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ConnectionStatus { + Connected, + Stale, + Disconnected, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentClient { + pub id: ClientId, + pub name: &'static str, + pub detected: bool, + pub status: ConnectionStatus, + pub config_path: String, +} + +enum ConfigFormat { + Json { + top_key: &'static str, + entry_type: bool, + }, + Toml, +} + +struct ClientSpec { + name: &'static str, + detect: Vec, + config: PathBuf, + format: ConfigFormat, +} + +// Config locations and shapes verified against each client's docs +// (Claude Code: code.claude.com/docs/en/mcp; Cursor: cursor.com/docs/context/mcp; +// VS Code: code.visualstudio.com/docs/copilot/customization/mcp-servers; +// Windsurf: docs.windsurf.com/windsurf/cascade/mcp; Codex: developers.openai.com/codex/mcp). +fn spec(id: ClientId, home: &Path) -> ClientSpec { + match id { + ClientId::ClaudeCode => ClientSpec { + name: "Claude Code", + detect: vec![home.join(".claude.json"), home.join(".claude")], + config: home.join(".claude.json"), + format: ConfigFormat::Json { + top_key: "mcpServers", + entry_type: true, + }, + }, + ClientId::Cursor => ClientSpec { + name: "Cursor", + detect: vec![home.join(".cursor")], + config: home.join(".cursor").join("mcp.json"), + format: ConfigFormat::Json { + top_key: "mcpServers", + entry_type: false, + }, + }, + ClientId::Windsurf => ClientSpec { + name: "Windsurf", + detect: vec![home.join(".codeium").join("windsurf")], + config: home + .join(".codeium") + .join("windsurf") + .join("mcp_config.json"), + format: ConfigFormat::Json { + top_key: "mcpServers", + entry_type: false, + }, + }, + ClientId::VsCode => ClientSpec { + name: "VS Code", + detect: vec![vscode_user_dir(home)], + config: vscode_user_dir(home).join("mcp.json"), + format: ConfigFormat::Json { + top_key: "servers", + entry_type: true, + }, + }, + ClientId::Codex => ClientSpec { + name: "Codex", + detect: vec![home.join(".codex")], + config: home.join(".codex").join("config.toml"), + format: ConfigFormat::Toml, + }, + } +} + +// Same folder as settings.json (code.visualstudio.com/docs/configure/settings). +fn vscode_user_dir(home: &Path) -> PathBuf { + if cfg!(target_os = "macos") { + home.join("Library") + .join("Application Support") + .join("Code") + .join("User") + } else if cfg!(windows) { + home.join("AppData") + .join("Roaming") + .join("Code") + .join("User") + } else { + home.join(".config").join("Code").join("User") + } +} + +pub fn sidecar_path() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("resolve current executable: {e}"))?; + let dir = exe + .parent() + .ok_or_else(|| format!("{} has no parent directory", exe.display()))?; + Ok(dir.join(format!("docsreader-mcp{}", std::env::consts::EXE_SUFFIX))) +} + +pub fn detect_clients(home: &Path, sidecar: &Path) -> Vec { + CLIENT_IDS + .iter() + .map(|&id| client_status(id, home, sidecar)) + .collect() +} + +pub fn connect_client(home: &Path, sidecar: &Path, id: ClientId) -> Result { + if !sidecar.exists() { + return Err(format!( + "MCP server binary not found at {}; reinstall DocsReader (dev: cargo build -p docsreader-mcp)", + sidecar.display() + )); + } + let s = spec(id, home); + let command = sidecar.to_string_lossy(); + match s.format { + ConfigFormat::Json { + top_key, + entry_type, + } => config::upsert_json_server(&s.config, top_key, entry_type, &command)?, + ConfigFormat::Toml => config::upsert_toml_server(&s.config, &command)?, + } + Ok(client_status(id, home, sidecar)) +} + +fn client_status(id: ClientId, home: &Path, sidecar: &Path) -> AgentClient { + let s = spec(id, home); + let registered = match s.format { + ConfigFormat::Json { top_key, .. } => config::read_json_command(&s.config, top_key), + ConfigFormat::Toml => config::read_toml_command(&s.config), + }; + let status = match registered { + Some(cmd) if Path::new(&cmd) == sidecar => ConnectionStatus::Connected, + Some(_) => ConnectionStatus::Stale, + None => ConnectionStatus::Disconnected, + }; + AgentClient { + id, + name: s.name, + detected: s.detect.iter().any(|p| p.exists()), + status, + config_path: s.config.to_string_lossy().into_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn home() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + fn find(clients: &[AgentClient], id: ClientId) -> &AgentClient { + clients.iter().find(|c| c.id == id).expect("client present") + } + + #[test] + fn detects_installed_clients_only() { + let dir = home(); + fs::create_dir_all(dir.path().join(".cursor")).unwrap(); + fs::create_dir_all(dir.path().join(".codex")).unwrap(); + let clients = detect_clients(dir.path(), Path::new("/bin/mcp")); + assert_eq!(clients.len(), CLIENT_IDS.len()); + assert!(find(&clients, ClientId::Cursor).detected); + assert!(find(&clients, ClientId::Codex).detected); + assert!(!find(&clients, ClientId::ClaudeCode).detected); + assert!(!find(&clients, ClientId::Windsurf).detected); + } + + #[test] + fn connect_then_status_is_connected_for_each_client() { + let dir = home(); + let sidecar = dir.path().join("docsreader-mcp"); + fs::write(&sidecar, "").unwrap(); + for id in CLIENT_IDS { + let client = connect_client(dir.path(), &sidecar, id).unwrap(); + assert_eq!(client.status, ConnectionStatus::Connected, "{:?}", id); + } + } + + #[test] + fn stale_when_registered_command_points_elsewhere() { + let dir = home(); + let old = dir.path().join("docsreader-mcp"); + fs::write(&old, "").unwrap(); + connect_client(dir.path(), &old, ClientId::Cursor).unwrap(); + let clients = detect_clients(dir.path(), Path::new("/new/docsreader-mcp")); + assert_eq!( + find(&clients, ClientId::Cursor).status, + ConnectionStatus::Stale + ); + } + + #[test] + fn connect_fails_loud_when_sidecar_missing() { + let dir = home(); + let missing = dir.path().join("docsreader-mcp"); + let err = connect_client(dir.path(), &missing, ClientId::Cursor).unwrap_err(); + assert!(err.contains("not found")); + assert!(err.contains("reinstall")); + } + + #[test] + fn claude_code_entry_lands_under_top_level_mcp_servers_with_stdio_type() { + let dir = home(); + let sidecar = dir.path().join("docsreader-mcp"); + fs::write(&sidecar, "").unwrap(); + fs::write( + dir.path().join(".claude.json"), + r#"{"numStartups": 5, "mcpServers": {"other": {"type": "http", "url": "https://x"}}}"#, + ) + .unwrap(); + connect_client(dir.path(), &sidecar, ClientId::ClaudeCode).unwrap(); + let text = fs::read_to_string(dir.path().join(".claude.json")).unwrap(); + let root: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(root["numStartups"], 5); + assert_eq!(root["mcpServers"]["other"]["url"], "https://x"); + assert_eq!(root["mcpServers"][SERVER_NAME]["type"], "stdio"); + assert_eq!( + root["mcpServers"][SERVER_NAME]["command"], + sidecar.to_string_lossy().as_ref() + ); + } + + #[test] + fn vscode_entry_lands_under_servers_key() { + let dir = home(); + let sidecar = dir.path().join("docsreader-mcp"); + fs::write(&sidecar, "").unwrap(); + connect_client(dir.path(), &sidecar, ClientId::VsCode).unwrap(); + let config = spec(ClientId::VsCode, dir.path()).config; + let root: serde_json::Value = + serde_json::from_str(&fs::read_to_string(config).unwrap()).unwrap(); + assert_eq!(root["servers"][SERVER_NAME]["type"], "stdio"); + } + + #[test] + fn client_ids_serialize_kebab_case() { + let ids: Vec = CLIENT_IDS + .iter() + .map(|id| serde_json::to_string(id).unwrap()) + .collect(); + assert_eq!( + ids, + [ + "\"claude-code\"", + "\"cursor\"", + "\"windsurf\"", + "\"vscode\"", + "\"codex\"" + ] + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a09973..1a8f8b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,822 +1,5 @@ -use std::fs::File; -use std::io::Read; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant}; - -use rayon::prelude::*; -use serde::{Deserialize, Serialize}; -use tauri::path::BaseDirectory; -use tauri::{AppHandle, Emitter, Manager}; -use walkdir::{DirEntry, WalkDir}; - -const PROGRESS_EVENT: &str = "scan-progress"; -const PROGRESS_INTERVAL_MS: u64 = 100; -const MAX_FILES: usize = 50_000; -const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; -const PARTIAL_READ_BYTES: usize = 16 * 1024; - -#[derive(Debug, Serialize, Deserialize)] -pub struct MarkdownFile { - pub path: String, - pub name: String, - #[serde(rename = "relPath")] - pub rel_path: String, - pub title: Option, - pub tags: Vec, - pub modified: Option, - pub size: u64, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct DocsYamlProject { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tagline: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub icon: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub homepage: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum DocsYamlNavItem { - Markdown { - title: String, - path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - badge: Option, - }, - OpenApi { - title: String, - openapi: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - badge: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum DocsYamlNavSection { - Items { - title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - collapsed: Option, - items: Vec, - }, - Folder { - title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - collapsed: Option, - folder: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - sort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - direction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - title_from: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pattern: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - nested: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct DocsYamlCrossLink { - pub project: String, - pub label: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub contexts: Vec, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct DocsYaml { - #[serde(default, rename = "spec_version", skip_serializing_if = "Option::is_none")] - pub spec_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub project: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub navigation: Vec, - #[serde(default, rename = "cross_links", skip_serializing_if = "Vec::is_empty")] - pub cross_links: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ignore: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub visibility: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ScanResult { - pub root: String, - pub files: Vec, - pub truncated: bool, - #[serde(default, rename = "docsYaml", skip_serializing_if = "Option::is_none")] - pub docs_yaml: Option, - #[serde(default, rename = "docsYamlError", skip_serializing_if = "Option::is_none")] - pub docs_yaml_error: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ScanProgress { - pub root: String, - #[serde(rename = "currentDir")] - pub current_dir: String, - #[serde(rename = "filesFound")] - pub files_found: u64, - #[serde(rename = "dirsVisited")] - pub dirs_visited: u64, - #[serde(rename = "lastFile")] - pub last_file: Option, -} - -fn extract_frontmatter(content: &str) -> Option<&str> { - let trimmed = content.trim_start_matches('\u{feff}').trim_start(); - if !trimmed.starts_with("---") { - return None; - } - let after = &trimmed[3..]; - let nl_idx = after.find('\n')?; - let body = &after[nl_idx + 1..]; - let end = body.find("\n---")?; - Some(&body[..end]) -} - -fn extract_first_heading(content: &str) -> Option { - for line in content.lines().take(120) { - let line = line.trim_start(); - if let Some(rest) = line.strip_prefix("# ") { - return Some(rest.trim().to_string()); - } - } - None -} - -fn parse_meta(content: &str) -> (Option, Vec) { - let mut title: Option = None; - let mut tags: Vec = Vec::new(); - - if let Some(fm) = extract_frontmatter(content) { - if let Ok(value) = serde_yaml::from_str::(fm) { - if let Some(map) = value.as_mapping() { - if let Some(t) = map.get(serde_yaml::Value::String("title".into())) { - if let Some(s) = t.as_str() { - title = Some(s.to_string()); - } - } - if let Some(tg) = map.get(serde_yaml::Value::String("tags".into())) { - if let Some(seq) = tg.as_sequence() { - for v in seq { - if let Some(s) = v.as_str() { - tags.push(s.to_string()); - } - } - } else if let Some(s) = tg.as_str() { - tags.extend( - s.split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()), - ); - } - } - } - } - } - - if title.is_none() { - title = extract_first_heading(content); - } - - (title, tags) -} - -const SKIP_DIRS: &[&str] = &[ - "node_modules", - "target", - ".git", - ".next", - "dist", - "build", - ".venv", - "venv", - ".cache", - ".turbo", - ".vercel", - ".idea", - ".vscode", - "Library", - "Applications", - "System", - "Pictures", - "Movies", - "Music", - ".Trash", - ".npm", - ".yarn", - ".pnpm-store", - ".cargo", - ".rustup", - ".bun", - ".local", - "Pods", - "build", - ".gradle", - "DerivedData", -]; - -fn is_skipped_dir(name: &str) -> bool { - if name.starts_with('.') { - return true; - } - SKIP_DIRS.contains(&name) -} - -fn is_markdown(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower.ends_with(".md") || lower.ends_with(".markdown") || lower.ends_with(".mdx") -} - -fn read_partial(path: &Path) -> std::io::Result { - let mut file = File::open(path)?; - let mut buf = vec![0u8; PARTIAL_READ_BYTES]; - let n = file.read(&mut buf)?; - buf.truncate(n); - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let ty = entry.file_type()?; - let from = entry.path(); - let to = dst.join(entry.file_name()); - if ty.is_dir() { - copy_dir_recursive(&from, &to)?; - } else if ty.is_file() { - std::fs::copy(&from, &to)?; - } - } - Ok(()) -} - -#[tauri::command] -async fn install_welcome_workspace(app: AppHandle) -> Result { - let src = app - .path() - .resolve("resources/welcome", BaseDirectory::Resource) - .map_err(|e| format!("could not resolve welcome resource: {e}"))?; - if !src.exists() { - return Err(format!( - "welcome resource not found at {} - in dev mode, ensure src-tauri/resources/welcome exists; in a packaged build, ensure tauri.conf.json bundle.resources includes resources/welcome/**/*", - src.display() - )); - } - let dst_root = app - .path() - .app_data_dir() - .map_err(|e| format!("could not resolve app data dir: {e}"))?; - let dst = dst_root.join("welcome"); - - if !dst.exists() { - copy_dir_recursive(&src, &dst).map_err(|e| { - format!("copy welcome from {} to {}: {e}", src.display(), dst.display()) - })?; - } - - Ok(dst.to_string_lossy().to_string()) -} - -// ─── Git integration ──────────────────────────────────────────────────────── -// -// We shell out to git for status and HEAD-content lookups. Three reasons -// over a Rust git library: zero binary weight, no runtime dependency on -// libgit2 in the bundle, and the user's installed git always understands -// the user's repo. Workspaces without a `.git` (or without git installed) -// silently skip git decorations. Commands run via tokio::process so the -// async runtime isn't blocked, with a per-call timeout so a hung git -// can't stall other Tauri commands indefinitely. - -#[derive(Debug, Serialize, Deserialize)] -pub struct GitFileStatus { - pub path: String, - pub status: String, - #[serde(default, rename = "originalPath", skip_serializing_if = "Option::is_none")] - pub original_path: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct GitStatus { - pub root: String, - pub files: Vec, -} - -const GIT_TIMEOUT: Duration = Duration::from_secs(30); - -// Find the git executable. PATH on a GUI-launched macOS app is typically -// minimal (no Homebrew dirs), so we probe a small set of common -// install locations as a fallback. Cached after the first lookup. -fn git_binary() -> Option<&'static str> { - static CACHED: OnceLock> = OnceLock::new(); - *CACHED.get_or_init(|| { - const CANDIDATES: &[&str] = &[ - "git", - "/usr/bin/git", - "/opt/homebrew/bin/git", - "/usr/local/bin/git", - ]; - for c in CANDIDATES { - let ok = std::process::Command::new(c) - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if ok { - return Some(*c); - } - } - None - }) -} - -async fn run_git(args: &[&str]) -> Result { - let bin = match git_binary() { - Some(b) => b, - None => return Err("git not found".to_string()), - }; - let fut = tokio::process::Command::new(bin).args(args).output(); - match tokio::time::timeout(GIT_TIMEOUT, fut).await { - Ok(Ok(out)) => Ok(out), - Ok(Err(e)) => Err(format!("git {}: {e}", args.first().copied().unwrap_or(""))), - Err(_) => Err(format!("git {} timed out", args.first().copied().unwrap_or(""))), - } -} - -fn classify_xy(xy: &str) -> &'static str { - let bytes = xy.as_bytes(); - if bytes.len() < 2 { - return "modified"; - } - let x = bytes[0] as char; - let y = bytes[1] as char; - if x == '?' || y == '?' { - return "untracked"; - } - if x == 'U' || y == 'U' || (x == 'D' && y == 'D') || (x == 'A' && y == 'A') { - return "unmerged"; - } - if x == 'A' || y == 'A' { - return "added"; - } - if x == 'D' || y == 'D' { - return "deleted"; - } - if x == 'R' || y == 'R' || x == 'C' || y == 'C' { - return "renamed"; - } - "modified" -} - -#[tauri::command] -async fn git_status(workspace: String) -> Result, String> { - if git_binary().is_none() { - return Ok(None); - } - let toplevel_out = match run_git(&["-C", &workspace, "rev-parse", "--show-toplevel"]).await { - Ok(o) => o, - Err(_) => return Ok(None), - }; - if !toplevel_out.status.success() { - return Ok(None); - } - let toplevel = String::from_utf8_lossy(&toplevel_out.stdout) - .trim() - .to_string(); - - // Workspace must live inside the repo. Compute the prefix so we can - // translate repo-relative paths (what git emits) into - // workspace-relative paths (what the scan uses). - let ws_canonical = std::path::Path::new(&workspace) - .canonicalize() - .unwrap_or_else(|_| std::path::Path::new(&workspace).to_path_buf()); - let tl_canonical = std::path::Path::new(&toplevel) - .canonicalize() - .unwrap_or_else(|_| std::path::Path::new(&toplevel).to_path_buf()); - let prefix = ws_canonical - .strip_prefix(&tl_canonical) - .ok() - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_default(); - - let status_out = run_git(&["-C", &workspace, "status", "--porcelain=v1", "-z"]).await?; - if !status_out.status.success() { - return Err(format!( - "git status: {}", - String::from_utf8_lossy(&status_out.stderr) - )); - } - - let mut files = Vec::new(); - let mut iter = status_out - .stdout - .split(|b| *b == 0) - .filter(|t| !t.is_empty()) - .peekable(); - while let Some(tok) = iter.next() { - let s = match std::str::from_utf8(tok) { - Ok(s) => s, - Err(_) => continue, - }; - if s.len() < 4 { - continue; - } - let xy = &s[..2]; - let path = s[3..].to_string(); - let status = classify_xy(xy); - let is_rename = xy.contains('R') || xy.contains('C'); - - let original_path = if is_rename { - iter.next() - .and_then(|t| std::str::from_utf8(t).ok().map(|s| s.to_string())) - } else { - None - }; - - let final_path = if prefix.is_empty() { - path.clone() - } else if path == prefix { - String::new() - } else if let Some(rest) = path.strip_prefix(&format!("{}/", prefix)) { - rest.to_string() - } else { - continue; - }; - - files.push(GitFileStatus { - path: final_path, - status: status.to_string(), - original_path, - }); - } - - Ok(Some(GitStatus { - root: toplevel, - files, - })) -} - -#[tauri::command] -async fn git_show_head(workspace: String, path: String) -> Result, String> { - if git_binary().is_none() { - return Ok(None); - } - let out = run_git(&["-C", &workspace, "show", &format!("HEAD:./{}", path)]).await?; - if !out.status.success() { - // Common case: file is untracked / new (no HEAD revision). Don't - // treat as an error - the caller renders an "all added" diff. - let stderr = String::from_utf8_lossy(&out.stderr); - if stderr.contains("exists on disk, but not in") - || stderr.contains("does not exist") - || stderr.contains("path does not exist") - || stderr.contains("bad revision") - { - return Ok(None); - } - return Err(format!("git show: {}", stderr)); - } - Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) -} - -fn load_docs_yaml(root: &Path) -> (Option, Option) { - for name in [".docs.yaml", "docs.yaml"] { - let p = root.join(name); - if !p.exists() { - continue; - } - return match std::fs::read_to_string(&p) { - Ok(s) => match serde_yaml::from_str::(&s) { - Ok(parsed) => (Some(parsed), None), - Err(e) => (None, Some(format!("{}: {}", name, e))), - }, - Err(e) => (None, Some(format!("{}: {}", name, e))), - }; - } - (None, None) -} - -#[tauri::command] -async fn scan_markdown(app: AppHandle, path: String) -> Result { - let app_clone = app.clone(); - let path_for_task = path.clone(); - tauri::async_runtime::spawn_blocking(move || run_scan(app_clone, path_for_task)) - .await - .map_err(|e| format!("scan task panicked: {}", e))? -} - -fn run_scan(app: AppHandle, path: String) -> Result { - let root_path = Path::new(&path); - if !root_path.exists() { - return Err(format!("Path does not exist: {}", path)); - } - - let dirs_visited = Arc::new(AtomicU64::new(0)); - let last_emit = Arc::new(Mutex::new(Instant::now())); - - let mut entries: Vec = Vec::new(); - let mut truncated = false; - - let walker = WalkDir::new(root_path) - .follow_links(false) - .into_iter() - .filter_entry(|e| { - if e.depth() == 0 { - return true; - } - let name = e.file_name().to_string_lossy(); - if e.file_type().is_dir() { - !is_skipped_dir(&name) - } else { - !name.starts_with('.') - } - }); - - for entry in walker.filter_map(|e| e.ok()) { - if entry.file_type().is_dir() { - dirs_visited.fetch_add(1, Ordering::Relaxed); - maybe_emit_walk_progress( - &app, - &path, - entry.path(), - root_path, - &dirs_visited, - 0, - None, - &last_emit, - ); - continue; - } - - if !entry.file_type().is_file() { - continue; - } - - let name = entry.file_name().to_string_lossy().to_string(); - if !is_markdown(&name) { - continue; - } - - if let Ok(meta) = entry.metadata() { - if meta.len() > MAX_FILE_BYTES { - continue; - } - } - - if entries.len() >= MAX_FILES { - truncated = true; - break; - } - entries.push(entry); - } - - let total_to_read = entries.len() as u64; - let files_processed = Arc::new(AtomicU64::new(0)); - - let mut files: Vec = entries - .par_iter() - .filter_map(|entry| { - let metadata = entry.metadata().ok()?; - let size = metadata.len(); - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - let content = read_partial(entry.path()).ok()?; - let (title, tags) = parse_meta(&content); - - let rel_path = entry - .path() - .strip_prefix(root_path) - .unwrap_or(entry.path()) - .to_string_lossy() - .to_string(); - - let count = files_processed.fetch_add(1, Ordering::Relaxed) + 1; - maybe_emit_read_progress( - &app, - &path, - count, - total_to_read, - Some(rel_path.clone()), - &last_emit, - ); - - Some(MarkdownFile { - path: entry.path().to_string_lossy().to_string(), - name: entry - .path() - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(), - rel_path, - title, - tags, - modified, - size, - }) - }) - .collect(); - - files.sort_by(|a, b| a.rel_path.to_lowercase().cmp(&b.rel_path.to_lowercase())); - - let _ = app.emit( - PROGRESS_EVENT, - ScanProgress { - root: path.clone(), - current_dir: ".".to_string(), - files_found: files.len() as u64, - dirs_visited: dirs_visited.load(Ordering::Relaxed), - last_file: None, - }, - ); - - let (docs_yaml, docs_yaml_error) = load_docs_yaml(root_path); - - Ok(ScanResult { - root: root_path.to_string_lossy().to_string(), - files, - truncated, - docs_yaml, - docs_yaml_error, - }) -} - -fn maybe_emit_walk_progress( - app: &AppHandle, - root: &str, - current: &Path, - root_path: &Path, - dirs_visited: &AtomicU64, - files_found: u64, - last_file: Option, - last_emit: &Mutex, -) { - { - let mut guard = match last_emit.lock() { - Ok(g) => g, - Err(_) => return, - }; - if guard.elapsed() < Duration::from_millis(PROGRESS_INTERVAL_MS) { - return; - } - *guard = Instant::now(); - } - let rel = current - .strip_prefix(root_path) - .unwrap_or(current) - .to_string_lossy() - .to_string(); - let _ = app.emit( - PROGRESS_EVENT, - ScanProgress { - root: root.to_string(), - current_dir: if rel.is_empty() { ".".into() } else { rel }, - files_found, - dirs_visited: dirs_visited.load(Ordering::Relaxed), - last_file, - }, - ); -} - -fn maybe_emit_read_progress( - app: &AppHandle, - root: &str, - files_found: u64, - total: u64, - last_file: Option, - last_emit: &Mutex, -) { - { - let mut guard = match last_emit.lock() { - Ok(g) => g, - Err(_) => return, - }; - if guard.elapsed() < Duration::from_millis(PROGRESS_INTERVAL_MS) { - return; - } - *guard = Instant::now(); - } - let _ = app.emit( - PROGRESS_EVENT, - ScanProgress { - root: root.to_string(), - current_dir: format!("reading {} of {}", files_found, total), - files_found, - dirs_visited: 0, - last_file, - }, - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_v01_manifest_with_mixed_sections() { - let yaml = r##" -spec_version: "0.1" -project: - slug: voice - name: Vinfra Voice - tagline: Carrier-grade VoIP for MENA - scope: L1 - icon: phone - homepage: docs/spec/architecture.md -navigation: - - title: Start here - items: - - title: Architecture overview - path: docs/spec/architecture.md - - title: Operating contract - path: docs/CONTRACT.md - badge: live - - title: Architecture decisions - folder: docs/adr/ - sort: filename - title_from: heading - - title: Build curriculum - folder: docs/phases/ - nested: true -cross_links: - - project: agent - label: "Need voice AI?" - description: Vinfra Agent runs alongside. -ignore: - - docs/archived/** - - "**/*.draft.md" -visibility: internal -"##; - let parsed: DocsYaml = serde_yaml::from_str(yaml).expect("should parse"); - let project = parsed.project.expect("project required"); - assert_eq!(project.slug.as_deref(), Some("voice")); - assert_eq!(project.name.as_deref(), Some("Vinfra Voice")); - assert_eq!(parsed.navigation.len(), 3); - assert_eq!(parsed.ignore.len(), 2); - assert_eq!(parsed.cross_links.len(), 1); - assert_eq!(parsed.cross_links[0].project, "agent"); - assert_eq!(parsed.cross_links[0].label, "Need voice AI?"); - - let (mut items, mut folder) = (false, false); - for s in &parsed.navigation { - match s { - DocsYamlNavSection::Items { .. } => items = true, - DocsYamlNavSection::Folder { .. } => folder = true, - } - } - assert!(items && folder, "both section kinds match"); - } - - #[test] - fn ignores_unknown_top_level_fields() { - let yaml = r##" -spec_version: "0.1" -project: - slug: x - name: X -navigation: - - title: T - folder: docs/ -api_reference: - openapi: api.yaml -versions: - current: a - supported: [a] -theme: - primary_color: "#000" -metadata: - team: t -"##; - let parsed: DocsYaml = serde_yaml::from_str(yaml).expect("should parse with extras"); - assert_eq!(parsed.navigation.len(), 1); - } -} +mod agents; +mod tauri_api; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -828,10 +11,13 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ - scan_markdown, - install_welcome_workspace, - git_status, - git_show_head + tauri_api::scan_markdown, + tauri_api::convert_workspace, + tauri_api::detect_agent_clients, + tauri_api::connect_agent_client, + tauri_api::install_welcome_workspace, + tauri_api::git_status, + tauri_api::git_show_head ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/tauri_api/mod.rs b/src-tauri/src/tauri_api/mod.rs new file mode 100644 index 0000000..49eb81e --- /dev/null +++ b/src-tauri/src/tauri_api/mod.rs @@ -0,0 +1,114 @@ +use std::path::Path; + +use tauri::path::BaseDirectory; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::agents::{self, AgentClient, ClientId}; +use docsreader_core::git::{git_show_head_core, git_status_core, GitStatus}; +use docsreader_core::scan::{run_scan, ScanProgress, ScanProgressSink, ScanResult}; +use docsreader_core::workspace::init::{convert_workspace_core, InitializedWorkspace}; +use docsreader_core::workspace::registry::default_registry_path; + +const PROGRESS_EVENT: &str = "scan-progress"; + +struct TauriProgressSink { + app: AppHandle, +} + +impl ScanProgressSink for TauriProgressSink { + fn emit(&self, progress: &ScanProgress) { + let _ = self.app.emit(PROGRESS_EVENT, progress.clone()); + } +} + +#[tauri::command] +pub async fn scan_markdown(app: AppHandle, path: String) -> Result { + let sink = TauriProgressSink { app }; + let path_for_task = path.clone(); + tauri::async_runtime::spawn_blocking(move || run_scan(&sink, path_for_task)) + .await + .map_err(|e| format!("scan task panicked: {}", e))? +} + +#[tauri::command] +pub async fn convert_workspace( + app: AppHandle, + path: String, +) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + let registry = default_registry_path(&home); + tauri::async_runtime::spawn_blocking(move || { + convert_workspace_core(Path::new(&path), ®istry).map_err(|e| e.message) + }) + .await + .map_err(|e| format!("convert task panicked: {e}"))? +} + +#[tauri::command] +pub fn detect_agent_clients(app: AppHandle) -> Result, String> { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + Ok(agents::detect_clients(&home, &agents::sidecar_path()?)) +} + +#[tauri::command] +pub fn connect_agent_client(app: AppHandle, id: ClientId) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + agents::connect_client(&home, &agents::sidecar_path()?, id) +} + +#[tauri::command] +pub async fn git_status(workspace: String) -> Result, String> { + git_status_core(workspace).await +} + +#[tauri::command] +pub async fn git_show_head(workspace: String, path: String) -> Result, String> { + git_show_head_core(workspace, path).await +} + +#[tauri::command] +pub async fn install_welcome_workspace(app: AppHandle) -> Result { + let src = app + .path() + .resolve("resources/welcome", BaseDirectory::Resource) + .map_err(|e| format!("could not resolve welcome resource: {e}"))?; + if !src.exists() { + return Err(format!( + "welcome resource not found at {} - in dev mode, ensure src-tauri/resources/welcome exists; in a packaged build, ensure tauri.conf.json bundle.resources includes resources/welcome/**/*", + src.display() + )); + } + let dst_root = app + .path() + .app_data_dir() + .map_err(|e| format!("could not resolve app data dir: {e}"))?; + let dst = dst_root.join("welcome"); + + if !dst.exists() { + copy_dir_recursive(&src, &dst).map_err(|e| { + format!( + "copy welcome from {} to {}: {e}", + src.display(), + dst.display() + ) + })?; + } + + Ok(dst.to_string_lossy().to_string()) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_recursive(&from, &to)?; + } else if ty.is_file() { + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f442318..3c1d3e0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -50,8 +50,7 @@ "entitlements": null, "exceptionDomain": null, "frameworks": [] - }, - "createUpdaterArtifacts": true + } }, "plugins": { "updater": { diff --git a/src-tauri/tauri.sidecar.conf.json b/src-tauri/tauri.sidecar.conf.json new file mode 100644 index 0000000..267e792 --- /dev/null +++ b/src-tauri/tauri.sidecar.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "build": { + "beforeBuildCommand": "bun run build && bun run build:sidecar" + }, + "bundle": { + "externalBin": ["binaries/docsreader-mcp"], + "createUpdaterArtifacts": true + } +} diff --git a/src/App.tsx b/src/App.tsx index e2d59a9..178d4cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { message } from "@tauri-apps/plugin-dialog"; import { Columns2, ListTree, Rows2, Square, Settings as SettingsIcon } from "lucide-react"; import type { QuickOpenFile } from "@/components/quickopen/QuickOpenDialog"; import type { SettingsSection } from "@/components/settings/SettingsDialog"; +import { BacklinksPanel } from "@/components/document/BacklinksPanel"; import { OutlinePanel } from "@/components/document/OutlinePanel"; import { matchShortcut, parseShortcut } from "@/lib/shortcuts"; @@ -18,16 +19,15 @@ import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; -import { - ExplorerSidebar, - type ResolvedCrossLink, -} from "@/components/explorer/ExplorerSidebar"; +import { ExplorerSidebar } from "@/components/explorer/ExplorerSidebar"; +import { ConvertWorkspacePrompt } from "@/components/explorer/ConvertWorkspacePrompt"; import { PathBreadcrumb } from "@/components/document/PathBreadcrumb"; import { PaneView } from "@/components/document/PaneView"; import { UpdateBanner } from "@/components/document/UpdateBanner"; const SettingsDialog = lazy(() => import("@/components/settings/SettingsDialog")); import { useLibrary } from "@/hooks/useLibrary"; +import { useConvertPrompt } from "@/hooks/useConvertPrompt"; import { usePanes } from "@/hooks/usePanes"; import type { SplitMode } from "@/lib/storage"; import { useTheme } from "@/hooks/useTheme"; @@ -36,17 +36,9 @@ import { useSidebarState } from "@/hooks/useSidebarState"; import { usePinned } from "@/hooks/usePinned"; import { useUpdater } from "@/hooks/useUpdater"; import { buildTree } from "@/lib/tree"; -import { buildCuratedTree } from "@/lib/curatedTree"; import { collectDirKeys } from "@/components/explorer/FileTree"; import { buildHideMatcher } from "@/lib/match"; import { basename } from "@/lib/path"; -import { - getIgnorePatterns, - getProjectMeta, - hasNavigation, - type ProjectMeta, -} from "@/lib/docsYaml"; -import { computeManifestIssues } from "@/lib/manifestIssues"; import { fetchGitHead, type GitFileStatusKind } from "@/lib/git"; import { parseFrontmatter } from "@/lib/scan"; import { DiffViewerDialog } from "@/components/document/DiffViewerDialog"; @@ -56,8 +48,18 @@ import "@/styles/code-theme.css"; function App() { const library = useLibrary(); const viewSettings = useViewSettings(); + const managedRoots = useMemo( + () => library.roots.filter((root) => library.scans[root]?.result.marker), + [library.roots, library.scans] + ); + const isManagedPath = useCallback( + (path: string) => + managedRoots.some((root) => path === root || path.startsWith(root + "/")), + [managedRoots] + ); const panes = usePanes({ autoReloadOnExternalChange: viewSettings.settings.autoReloadOnExternalChange, + isManagedPath, }); const tabs = panes.activePane; const sidebar = useSidebarState(viewSettings.settings.defaultFolderState); @@ -91,97 +93,27 @@ function App() { }); }, [viewSettings]); - const activeDocsIgnore = useMemo( - () => getIgnorePatterns(library.activeScan?.result.docsYaml), - [library.activeScan?.result.docsYaml] + const hideMatcher = useMemo( + () => buildHideMatcher(viewSettings.settings.hidePatterns), + [viewSettings.settings.hidePatterns] ); - const hideMatcher = useMemo( - () => buildHideMatcher([...viewSettings.settings.hidePatterns, ...activeDocsIgnore]), - [viewSettings.settings.hidePatterns, activeDocsIgnore] + const convertPrompt = useConvertPrompt( + library.activeRoot, + library.activeScan, + library.rescan ); - const projectMetaByRoot = useMemo>(() => { - const out: Record = {}; + const workspaceNamesByRoot = useMemo>(() => { + const out: Record = {}; for (const root of library.roots) { - const meta = getProjectMeta(library.scans[root]?.result.docsYaml); - if (meta) out[root] = meta; + const name = library.scans[root]?.result.marker?.name?.trim(); + if (name) out[root] = name; } return out; }, [library.roots, library.scans]); - // Visibility filter: internal-only workspaces are hidden from the switcher - // and QuickOpen when the user is in "preview public" mode (showInternal=off). - const showInternal = viewSettings.settings.showInternal; - const visibleRoots = useMemo(() => { - if (showInternal) return library.roots; - return library.roots.filter( - (root) => library.scans[root]?.result.docsYaml?.visibility !== "internal" - ); - }, [library.roots, library.scans, showInternal]); - - // If the currently active workspace is now hidden by the visibility filter, - // switch to the first visible one. If no visible workspace exists, clear - // activeRoot - otherwise the content area would render files from a tab - // the user can no longer see in the switcher. - useEffect(() => { - if (showInternal) return; - if (!library.activeRoot) return; - if (visibleRoots.includes(library.activeRoot)) return; - void library.selectRoot(visibleRoots[0]); - }, [showInternal, library, library.activeRoot, visibleRoots]); - - const rootBySlug = useMemo>(() => { - const map = new Map(); - for (const root of library.roots) { - const slug = library.scans[root]?.result.docsYaml?.project?.slug?.trim(); - if (slug) map.set(slug, root); - } - return map; - }, [library.roots, library.scans]); - - const manifestIssues = useMemo(() => { - const scan = library.activeScan; - if (!scan) return []; - return computeManifestIssues({ - docsYaml: scan.result.docsYaml, - docsYamlError: scan.result.docsYamlError, - files: scan.result.files, - knownSlugs: new Set(rootBySlug.keys()), - ownSlug: scan.result.docsYaml?.project?.slug, - }); - }, [library.activeScan, rootBySlug]); - - const crossLinks = useMemo(() => { - const list = library.activeScan?.result.docsYaml?.cross_links; - if (!Array.isArray(list) || list.length === 0) return []; - const out: ResolvedCrossLink[] = []; - for (const link of list) { - const targetRoot = rootBySlug.get(link.project); - if (!targetRoot) continue; - if (targetRoot === library.activeRoot) continue; // don't link to self - const targetName = - library.scans[targetRoot]?.result.docsYaml?.project?.name?.trim() || - targetRoot.split("/").pop() || - link.project; - out.push({ - label: link.label, - description: link.description, - targetRoot, - targetName, - }); - } - return out; - }, [library.activeScan, library.activeRoot, library.scans, rootBySlug]); - - const activeDocsYamlError = library.activeScan?.result.docsYamlError; - useEffect(() => { - if (activeDocsYamlError) { - console.warn("[docsreader] .docs.yaml parse error:", activeDocsYamlError); - } - }, [activeDocsYamlError]); - - // Auto-open project.homepage once per workspace per session: only fires on + // Auto-open the marker homepage once per workspace per session: only fires on // the first time the active scan finishes with no tabs open in that root, // so closing the homepage tab doesn't keep reopening it on workspace switch. // Homepage auto-open targets pane 0 specifically, regardless of which @@ -200,7 +132,7 @@ function App() { const root = library.activeRoot; if (autoOpenedHomepageRef.current.has(root)) return; - const homepage = scan.result.docsYaml?.project?.homepage?.trim(); + const homepage = scan.result.marker?.homepage?.trim(); if (!homepage) return; const homepageRel = homepage.replace(/\\/g, "/").replace(/^\.\//, ""); @@ -229,21 +161,16 @@ function App() { const quickOpenFiles = useMemo(() => { const out: QuickOpenFile[] = []; - for (const root of visibleRoots) { + for (const root of library.roots) { const scan = library.scans[root]; if (!scan) continue; - const docsIgnore = getIgnorePatterns(scan.result.docsYaml); - const matcher = - docsIgnore.length === 0 - ? buildHideMatcher(viewSettings.settings.hidePatterns) - : buildHideMatcher([...viewSettings.settings.hidePatterns, ...docsIgnore]); for (const f of scan.result.files) { - if (!matcher.empty && matcher.matchesPath(f.relPath)) continue; + if (!hideMatcher.empty && hideMatcher.matchesPath(f.relPath)) continue; out.push({ ...f, rootPath: root }); } } return out; - }, [visibleRoots, library.scans, viewSettings.settings.hidePatterns]); + }, [library.roots, library.scans, hideMatcher]); const quickOpenShortcut = useMemo( () => parseShortcut(viewSettings.settings.quickOpenShortcut), @@ -324,14 +251,10 @@ function App() { }; }, [quickOpenMounted]); const filteredFiles = useFilteredFiles(allFiles, search); - const activeDocsYaml = library.activeScan?.result.docsYaml; const tree = useMemo(() => { if (!library.activeRoot) return undefined; - if (hasNavigation(activeDocsYaml)) { - return buildCuratedTree(library.activeRoot, filteredFiles, activeDocsYaml); - } return buildTree(library.activeRoot, filteredFiles); - }, [library.activeRoot, filteredFiles, activeDocsYaml]); + }, [library.activeRoot, filteredFiles]); const rootKey = library.activeRoot ?? ""; const handleCollapseAll = useCallback(() => { if (!tree) return; @@ -431,12 +354,15 @@ function App() { const handleOpenWelcome = useCallback(async () => { try { const path = await invoke("install_welcome_workspace"); + // The welcome workspace is a guided tour, not an agent target; never + // greet a first-time user with the convert dialog. + convertPrompt.declineRoot(path); await library.addRoot(path); viewSettings.update({ ...viewSettings.settings, welcomeOpened: true }); } catch (err) { console.error("install_welcome_workspace failed", err); } - }, [library, viewSettings]); + }, [library, viewSettings, convertPrompt]); // Auto-open the welcome workspace exactly once for true first-time users: // both stores hydrated, no roots persisted from previous sessions, and the @@ -462,10 +388,10 @@ function App() { void library.pickDirectory()} onSelectRoot={(path) => void library.selectRoot(path)} onRemoveRoot={(path) => void library.removeRoot(path)} @@ -475,8 +401,6 @@ function App() { ? undefined : () => void handleOpenWelcome() } - crossLinks={crossLinks} - manifestIssues={manifestIssues} gitStatusByPath={gitStatusByPath} onShowGitDiff={ activeGitStatus ? (path) => void handleShowGitDiff(path) : undefined @@ -690,6 +614,11 @@ function App() { content={tabs.activeTab.content} scrollContainer={activeScrollEl} /> + )} @@ -704,6 +633,16 @@ function App() { /> )} + {convertPrompt.candidateRoot && ( + void convertPrompt.convert()} + onDecline={() => + convertPrompt.candidateRoot && + convertPrompt.declineRoot(convertPrompt.candidateRoot) + } + /> + )} {gitDiffState && ( = {}): MarkdownFile { + return { + path: `/ws/${relPath}`, + name: relPath.split("/").pop() ?? relPath, + relPath, + tags: [], + size: 1, + ...overrides, + }; +} + +const FILES: MarkdownFile[] = [ + file("target.md", { title: "Target" }), + file("intro.md", { title: "Intro", links: ["target.md"] }), + file("guides/setup.md", { title: "Setup guide", links: ["target.md", "intro.md"] }), + file("guides/unrelated.md", { links: ["intro.md"] }), +]; + +describe("BacklinksPanel", () => { + it("lists linking docs grouped by source folder", () => { + render( + + ); + expect(screen.getByText("Backlinks")).toBeInTheDocument(); + expect(screen.getByText("/")).toBeInTheDocument(); + expect(screen.getByText("guides")).toBeInTheDocument(); + expect(screen.getByText("Intro")).toBeInTheDocument(); + expect(screen.getByText("Setup guide")).toBeInTheDocument(); + expect(screen.queryByText("unrelated.md")).not.toBeInTheDocument(); + }); + + it("navigates to the linking doc on click", async () => { + const onNavigate = vi.fn(); + render( + + ); + await userEvent.click(screen.getByText("Setup guide")); + expect(onNavigate).toHaveBeenCalledWith("/ws/guides/setup.md"); + }); + + it("renders nothing when no doc links here", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/document/BacklinksPanel.tsx b/src/components/document/BacklinksPanel.tsx new file mode 100644 index 0000000..0eafa6a --- /dev/null +++ b/src/components/document/BacklinksPanel.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import type { MarkdownFile } from "@/lib/scan"; +import { backlinksFor, groupByFolder } from "@/lib/backlinks"; + +interface Props { + files: MarkdownFile[]; + activePath: string; + onNavigate: (path: string) => void; +} + +export function BacklinksPanel({ files, activePath, onNavigate }: Props) { + const groups = useMemo( + () => groupByFolder(backlinksFor(files, activePath)), + [files, activePath] + ); + + if (groups.length === 0) return null; + + return ( +
+

+ Backlinks +

+ {groups.map((group) => ( +
+
+ {group.folder} +
+ {group.sources.map((file) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/src/components/document/DocumentView.tsx b/src/components/document/DocumentView.tsx index a0c8c6a..3d40da8 100644 --- a/src/components/document/DocumentView.tsx +++ b/src/components/document/DocumentView.tsx @@ -1,10 +1,13 @@ +import { Pencil } from "lucide-react"; import { cn } from "@/lib/utils"; import type { MarkdownFile } from "@/lib/scan"; import type { ViewSettings } from "@/lib/storage"; import type { Tab } from "@/hooks/useTabs"; import { MarkdownViewer } from "@/components/viewer/MarkdownViewer"; +import { Button } from "@/components/ui/button"; import { DocumentHeader } from "./DocumentHeader"; import { Frontmatter } from "./Frontmatter"; +import { QuickEditor } from "./QuickEditor"; interface Props { tab: Tab; @@ -12,12 +15,28 @@ interface Props { rootPath: string | undefined; viewSettings: ViewSettings; onNavigate: (path: string) => void; + onBeginEdit: () => void; + onDraftChange: (value: string) => void; + onCancelEdit: () => void; + onSaveEdit: () => Promise; } -export function DocumentView({ tab, file, rootPath, viewSettings, onNavigate }: Props) { +export function DocumentView({ + tab, + file, + rootPath, + viewSettings, + onNavigate, + onBeginEdit, + onDraftChange, + onCancelEdit, + onSaveEdit, +}: Props) { const title = file?.title || file?.name || tab.title; const tags = file?.tags ?? []; const modified = file?.modified; + const editing = tab.draft !== undefined; + const editable = !tab.loading && !tab.error && !editing; return (
- - +
+
+ +
+ {editable && ( + + )} +
+ {!editing && tab.draftError && ( +

{tab.draftError}

+ )} + {!editing && }
{tab.loading ? (

Loading…

) : tab.error ? (

{tab.error}

+ ) : editing ? ( + ) : ( ); }) diff --git a/src/components/document/QuickEditor.test.tsx b/src/components/document/QuickEditor.test.tsx new file mode 100644 index 0000000..ed78364 --- /dev/null +++ b/src/components/document/QuickEditor.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect } from "vitest"; +import { QuickEditor } from "./QuickEditor"; + +function setup(overrides: Partial[0]> = {}) { + const props = { + value: "# hello", + error: undefined, + onChange: vi.fn(), + onSave: vi.fn(), + onCancel: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe("QuickEditor", () => { + it("shows the raw source and reports edits", async () => { + const props = setup(); + const textarea = screen.getByRole("textbox", { name: "Edit markdown source" }); + expect(textarea).toHaveValue("# hello"); + await userEvent.type(textarea, "!"); + expect(props.onChange).toHaveBeenCalledWith("# hello!"); + }); + + it("saves via button and via ctrl/cmd+s", () => { + const props = setup(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(props.onSave).toHaveBeenCalledTimes(1); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "s", metaKey: true }); + expect(props.onSave).toHaveBeenCalledTimes(2); + }); + + it("cancels via button and via escape", () => { + const props = setup(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(props.onCancel).toHaveBeenCalledTimes(2); + expect(props.onSave).not.toHaveBeenCalled(); + }); + + it("renders a save error inline", () => { + setup({ error: "permission denied" }); + expect(screen.getByText("permission denied")).toBeInTheDocument(); + }); +}); diff --git a/src/components/document/QuickEditor.tsx b/src/components/document/QuickEditor.tsx new file mode 100644 index 0000000..63f83da --- /dev/null +++ b/src/components/document/QuickEditor.tsx @@ -0,0 +1,67 @@ +import { useState, type KeyboardEvent } from "react"; +import { Button } from "@/components/ui/button"; + +interface Props { + value: string; + error: string | undefined; + onChange: (value: string) => void; + onSave: () => Promise | void; + onCancel: () => void; +} + +const isMac = navigator.platform.toUpperCase().includes("MAC"); + +export function QuickEditor({ value, error, onChange, onSave, onCancel }: Props) { + const [saving, setSaving] = useState(false); + + const save = async () => { + setSaving(true); + try { + await onSave(); + } finally { + setSaving(false); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + void save(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + + return ( +
+