chore: sync rc to reconstructed dev (engine + CI + rc gating) #213

Merged
KofTwentyTwo merged 50 commits from dev into rc 2026-07-29 21:51:15 +00:00
197 changed files with 16997 additions and 15352 deletions

No files matched your search

+5 -2
View File
@@ -2,8 +2,11 @@
# Development quality enforcement aliases
check-all = "clippy --all-targets --workspace -- -D warnings"
test-all = "test --workspace"
cov = "llvm-cov --workspace --fail-under-lines 100"
verify = ["fmt --all -- --check", "check-all", "test-all"]
cov = "llvm-cov --workspace --ignore-filename-regex main\\.rs --summary-only"
# Dogfooding: build the runnable daemon + reference CLI in release mode.
build-release = ["build", "--release", "-p", "nunciod", "-p", "nuncio-cli"]
# Remove the entire workspace target directory (all crates, all profiles).
clean-all = "clean"
[build]
# Treat warnings as errors and forbid unsafe code at local build time
+5 -2
View File
@@ -1,6 +1,6 @@
# Contributing to Nuncio
Thank you for contributing to Nuncio ([nuncio.mx](https://nuncio.mx))!
Thank you for contributing to Nuncio!
## Semantic Versioning 2.0.0 & Tagging
@@ -9,7 +9,10 @@ Nuncio follows **Semantic Versioning 2.0.0** (`vMAJOR.MINOR.PATCH`):
- `MINOR`: Incremented for new functionality added in a backward-compatible manner.
- `PATCH`: Incremented for backward-compatible bug fixes.
Tagging a commit with `vX.Y.Z` triggers [.github/workflows/release.yml](file:///R:/Git.Local/KofTwentyTwo/nuncio/.github/workflows/release.yml) to automatically compile cross-platform release artifacts (Windows `.zip`/`.msi`, macOS `.tar.gz`/`.dmg`, Linux `.tar.gz`/`.AppImage`), generate release notes from Conventional Commits, compute SHA256 checksums, and publish an official GitHub Release.
Tagging a commit with `vX.Y.Z` is intended to drive the release workflow under
`.github/workflows/`. **Pre-alpha note:** the release pipeline is being rebuilt and
**no functional release exists yet** — see roadmap milestone M7 (issue #209). Do not
tag releases until the backend is declared client-ready.
## Architecture Decoupling Rules
+7 -3
View File
@@ -1,13 +1,17 @@
# Security Policy
Nuncio ([nuncio.mx](https://nuncio.mx)) takes security and data privacy seriously. Because Nuncio handles sensitive mail payloads, calendar schedules, and authentication credentials across Windows, macOS, and Linux, we maintain strict security disclosures and cryptographic standards.
Nuncio handles sensitive mail, calendar, and contact data plus authentication
credentials, so security is a first-class concern. **Status: pre-alpha.** Several
hardening items — at-rest encryption correctness, key zeroization, a fail-closed
signature-verifying updater, and transport/egress hardening — are tracked under
roadmap milestone M6 and are not all complete. **Do not use Nuncio with real
accounts or sensitive data yet.**
## Supported Versions
| Version | Supported |
| :--- | :--- |
| `0.1.x` | Supported |
| `< 0.1.0` | Unsupported |
| all pre-1.0 | **Not supported** — non-functional pre-alpha, under active reconstruction |
## Reporting a Vulnerability
+22 -29
View File
@@ -2,23 +2,27 @@ name: CI Quality Gates & Build Pipeline
on:
push:
branches: [ main ]
branches: [ main, dev, rc ]
pull_request:
branches: [ main ]
branches: [ main, dev, rc ]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
permissions:
contents: read
jobs:
fmt:
name: Code Formatting Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
# No toolchain-install action: rust-toolchain.toml pins 1.97.1 (+ rustfmt,
# clippy) and rustup auto-installs it on first cargo/rustc invocation, so
# CI always matches the local pinned toolchain instead of a drifting
# `stable`.
- name: Check Formatting
run: cargo fmt --all -- --check
@@ -27,49 +31,39 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install Linux Desktop Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev build-essential curl wget file libssl-dev
- name: Cache Cargo Dependencies
uses: Swatinem/rust-cache@v2
- name: Run Clippy Linter
run: cargo clippy --workspace -- -D warnings
- name: Run Clippy Linter (all targets, matches local `cargo check-all`)
run: cargo check-all
test:
name: Test Suite Execution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux Desktop Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev build-essential curl wget file libssl-dev
- name: Cache Cargo Dependencies
uses: Swatinem/rust-cache@v2
- name: Run Workspace Tests
run: cargo test --workspace
run: cargo test-all
coverage:
name: Enforce 100% Code Coverage Threshold
name: Code Coverage (informational)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux Desktop Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev build-essential curl wget file libssl-dev
- name: Install llvm-tools component
run: rustup component add llvm-tools-preview
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Cache Cargo Dependencies
uses: Swatinem/rust-cache@v2
- name: Measure & Enforce Code Coverage Threshold
run: cargo llvm-cov --workspace --ignore-filename-regex "main\.rs" --fail-under-lines 100
# Coverage is informational only (matches the local `cargo cov` alias):
# no --fail-under-lines threshold, and the job is continue-on-error so a
# coverage regression or tooling hiccup never reds the pipeline. A
# realistic coverage gate is a separate roadmap story.
- name: Measure Code Coverage (informational)
run: cargo cov
build-matrix:
name: Cross-Platform Build Matrix (${{ matrix.os }})
@@ -81,7 +75,6 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo Dependencies
uses: Swatinem/rust-cache@v2
- name: Build All Workspace Crates
-94
View File
@@ -1,94 +0,0 @@
name: Nuncio Desktop GUI Release Pipeline (MSI, DMG, AppImage, Deb)
on:
push:
branches:
- main
- rc
- dev
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
packages: write
jobs:
build-tauri:
name: Build Desktop (${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'macos-13'
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout Code
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }}
- name: Install Linux Dependencies (Ubuntu)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install Frontend UI Dependencies
working-directory: crates/nuncio-gui/ui
run: npm install --legacy-peer-deps
- name: Install Tauri CLI Dependencies
working-directory: crates/nuncio-gui/src-tauri
run: npm install --legacy-peer-deps
- name: Determine Environment Channel & Tag
id: env_prep
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/heads/dev ]]; then
echo "channel=dev" >> $GITHUB_OUTPUT
echo "tag_name=dev-latest" >> $GITHUB_OUTPUT
echo "release_name=Nuncio Desktop (Development Nightly)" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/heads/rc || "${{ github.ref }}" == refs/tags/*rc* ]]; then
echo "channel=rc" >> $GITHUB_OUTPUT
echo "tag_name=rc-latest" >> $GITHUB_OUTPUT
echo "release_name=Nuncio Desktop (Release Candidate)" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=prod" >> $GITHUB_OUTPUT
echo "tag_name=${{ github.ref_name }}" >> $GITHUB_OUTPUT
echo "release_name=Nuncio Desktop Release ${{ github.ref_name }}" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Build Tauri Desktop Installers (.msi, .dmg, .AppImage, .deb)
uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: 'crates/nuncio-gui/src-tauri'
tagName: ${{ steps.env_prep.outputs.tag_name }}
releaseName: ${{ steps.env_prep.outputs.release_name }}
releaseBody: 'Production Tauri v2 Desktop GUI installers (.msi, .dmg, .AppImage, .deb).'
releaseDraft: false
prerelease: ${{ steps.env_prep.outputs.is_prerelease == 'true' }}
args: ${{ matrix.args }}
+3 -9
View File
@@ -1,11 +1,9 @@
name: Nuncio Nightly & Snapshot Release Pipeline
on:
push:
branches:
- dev
schedule:
- cron: '0 2 * * *' # Daily Nightly Build at 02:00 UTC
# Auto-nightly is DISABLED during the pre-alpha rebuild: no public releases
# until V3, and this pipeline builds the presentation GUI now archived under
# _reference/. Manual-only until the release path is restored in Phase 4.
workflow_dispatch:
permissions:
@@ -50,8 +48,6 @@ jobs:
run: |
mkdir -p dist
cp target/${{ matrix.target }}/release/nuncio-cli dist/nuncio-cli
cp target/${{ matrix.target }}/release/nuncio-tui dist/nuncio-tui
cp target/${{ matrix.target }}/release/nuncio-mcp dist/nuncio-mcp
cp target/${{ matrix.target }}/release/nunciod dist/nunciod
tar -czf ${{ matrix.artifact_name }} -C dist .
@@ -61,8 +57,6 @@ jobs:
run: |
New-Item -ItemType Directory -Path dist
Copy-Item target/${{ matrix.target }}/release/nuncio-cli.exe dist/nuncio-cli.exe
Copy-Item target/${{ matrix.target }}/release/nuncio-tui.exe dist/nuncio-tui.exe
Copy-Item target/${{ matrix.target }}/release/nuncio-mcp.exe dist/nuncio-mcp.exe
Copy-Item target/${{ matrix.target }}/release/nunciod.exe dist/nunciod.exe
Compress-Archive -Path dist/* -DestinationPath ${{ matrix.artifact_name }}
+12 -19
View File
@@ -1,15 +1,13 @@
name: Nuncio Multi-Platform Release Pipeline (Dev, RC, Prod)
on:
# Releases are cut ONLY from version tags (vMAJOR.MINOR.PATCH), per the release
# policy (no public release until V3). Branch-push and nightly-cron triggers were
# removed during the pre-alpha rebuild: they ran on every dev push and failed
# (they build the presentation GUI now archived under _reference/).
push:
branches:
- main
- rc
- dev
tags:
- 'v*'
schedule:
- cron: '0 0 * * *' # Daily Nightly Build at 00:00 UTC
workflow_dispatch:
permissions:
@@ -54,8 +52,6 @@ jobs:
run: |
mkdir -p dist
cp target/${{ matrix.target }}/release/nuncio-cli dist/nuncio-cli
cp target/${{ matrix.target }}/release/nuncio-tui dist/nuncio-tui
cp target/${{ matrix.target }}/release/nuncio-mcp dist/nuncio-mcp
cp target/${{ matrix.target }}/release/nunciod dist/nunciod
tar -czf ${{ matrix.artifact_name }} -C dist .
@@ -65,8 +61,6 @@ jobs:
run: |
New-Item -ItemType Directory -Path dist
Copy-Item target/${{ matrix.target }}/release/nuncio-cli.exe dist/nuncio-cli.exe
Copy-Item target/${{ matrix.target }}/release/nuncio-tui.exe dist/nuncio-tui.exe
Copy-Item target/${{ matrix.target }}/release/nuncio-mcp.exe dist/nuncio-mcp.exe
Copy-Item target/${{ matrix.target }}/release/nunciod.exe dist/nunciod.exe
Compress-Archive -Path dist/* -DestinationPath ${{ matrix.artifact_name }}
@@ -124,17 +118,16 @@ jobs:
prerelease: ${{ steps.prep.outputs.is_prerelease == 'true' }}
files: dist-final/*
body: |
## Nuncio Sovereign Mail, Calendar & Contacts Suite
## Nuncio Engine (nunciod + nuncio-cli)
**Release Channel**: ${{ steps.prep.outputs.channel }}
### 📦 Official Installer & Package Downloads
- **Windows Installer**: `Nuncio_x64.msi` (WiX Installer) / `Nuncio_x64-setup.exe` (NSIS)
- **macOS Disk Image**: `Nuncio_x64.dmg` (Intel) / `Nuncio_aarch64.dmg` (Apple Silicon)
- **Linux Packages**: `Nuncio_amd64.AppImage` / `Nuncio_amd64.deb`
- **CLI & Daemon Archives**: `nuncio-x86_64-unknown-linux-gnu.tar.gz`, `nuncio-aarch64-apple-darwin.tar.gz`, `nuncio-x86_64-apple-darwin.tar.gz`, `nuncio-x86_64-pc-windows-msvc.zip`
### 📦 Daemon & CLI Archives
- `nuncio-x86_64-unknown-linux-gnu.tar.gz`
- `nuncio-aarch64-apple-darwin.tar.gz`
- `nuncio-x86_64-apple-darwin.tar.gz`
- `nuncio-x86_64-pc-windows-msvc.zip`
### 🚀 Package Managers
- **Homebrew Tap**: `brew install koftwentytwo/tap/nuncio`
- **Cargo Source**: `cargo install --git https://github.com/KofTwentyTwo/nuncio.git nuncio-cli nuncio-tui nuncio-mcp nunciod`
### 🚀 Cargo Source
- `cargo install --git https://github.com/KofTwentyTwo/nuncio.git nuncio-cli nunciod`
Verify payload hashes against `SHA256SUMS.txt`.
+8 -2
View File
@@ -10,6 +10,9 @@ on:
- '.github/workflows/wiki-sync.yml'
workflow_dispatch:
permissions:
contents: write
jobs:
sync-wiki:
name: Sync docs/ to GitHub Wiki
@@ -27,15 +30,18 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
path: wiki-repo
- name: Sync docs and assets to Wiki
- name: Sync docs and assets to Wiki (mirror with delete)
run: |
# Mirror docs/ -> wiki so that files removed from docs/ are also removed
# from the wiki. Preserve only the wiki's .git directory.
find wiki-repo -mindepth 1 -maxdepth 1 -not -name '.git' -exec rm -rf {} +
mkdir -p wiki-repo/assets
cp -r docs/* wiki-repo/ 2>/dev/null || true
cp -r assets/* wiki-repo/assets/ 2>/dev/null || true
cd wiki-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git add -A
if git diff --staged --quiet; then
echo "No changes to commit to Wiki."
else
+7
View File
@@ -28,3 +28,10 @@ Thumbs.db
**/build/
package-lock.json
# Agent scratch + regenerable build artifacts (keep out of the repo)
.superpowers/
excalidraw.log
_reference/**/gen/
**/node_modules/
**/dist/
@@ -1,13 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run nuncio-gui" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -p nuncio-gui" />
<configuration default="false" name="Cargo Build Release (daemon + CLI)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="build-release" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2">
-1
View File
@@ -7,7 +7,6 @@
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2">
+14
View File
@@ -0,0 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Cargo Clean" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="clean" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2" />
</configuration>
</component>
-1
View File
@@ -7,7 +7,6 @@
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2">
-1
View File
@@ -7,7 +7,6 @@
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2">
@@ -0,0 +1,19 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run nuncio-cli (system status)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -p nuncio-cli -- system status" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
@@ -1,13 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run nuncio-tui" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -p nuncio-tui" />
<configuration default="false" name="Run nuncio-cli (account list)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -p nuncio-cli -- account list" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2">
@@ -1,13 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run nuncio-cli (status)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -p nuncio-cli -- status" />
<configuration default="false" name="Run nuncio-cli (mail list)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -p nuncio-cli -- mail list" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2">
+16
View File
@@ -0,0 +1,16 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run nuncio-cli (mail sync)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -p nuncio-cli -- mail sync" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
+16
View File
@@ -0,0 +1,16 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run nunciod (daemon)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -p nunciod" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="backtrace" value="SHORT" />
<envs />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
+138 -62
View File
@@ -1,79 +1,155 @@
# Nuncio Codebase Guidelines (CLAUDE.md)
# CLAUDE.md
Nuncio ([nuncio.mx](https://nuncio.mx)) is a cross-platform mail and calendar solution written in Rust.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Semantic Versioning 2.0.0 & Release Policy
## What this repo is
- **SemVer Standard**: All releases follow **Semantic Versioning 2.0.0** (`MAJOR.MINOR.PATCH`).
- **UNIFIED V3 RELEASE MANDATE**: **No public release tag or binary distribution will be cut until Phase V3 (Platform & AI Automation) is 100% feature complete, verified, and working across all 4 presentation UIs on Linux, macOS, and Windows.**
- **Git Tag Format**: Release tags MUST follow `vMAJOR.MINOR.PATCH` format (e.g. `v3.0.0` for GA launch).
- **Automated GitHub Releases Workflow**: Pushing a `v*.*.*` tag triggers [.github/workflows/release.yml](file:///R:/Git.Local/KofTwentyTwo/nuncio/.github/workflows/release.yml) to compile, package, compute SHA256 checksums, and publish release binaries for Windows (`.zip` / `.msi`), macOS (`.tar.gz` / `.dmg`), and Linux (`.tar.gz` / `.AppImage`).
Nuncio is a **local-first mail, calendar, and contacts engine** written in Rust
(edition 2021, Cargo workspace). It is **pre-alpha and under active
reconstruction** after a 2026-07-26 forensic assessment found the original
AI-generated proof of concept was largely non-functional and its documentation
substantially fabricated. **Trust the code and this file over any older prose.**
## JetBrains RustRover IDE Integration
Read these before substantive work:
- [`docs/ROADMAP.md`](docs/ROADMAP.md) — the authoritative plan and target architecture (rendered page: <https://koftwentytwo.github.io/nuncio/roadmap/>)
- [`docs/STORY-WORKFLOW.md`](docs/STORY-WORKFLOW.md) — how to execute a roadmap story (gates, patterns to mirror, branch/PR/review flow)
- [`docs/HANDOFF.md`](docs/HANDOFF.md) — current state, branches, and the M1–M7 story index for picking up on a new machine
- [`docs/adr/0001-engine-first-grpc-architecture.md`](docs/adr/0001-engine-first-grpc-architecture.md) — the governing architecture decision
- [`docs/BACKLOG.md`](docs/BACKLOG.md) — engineering-ready Phase 0 / Phase 1 stories (Phase 2+ is in `docs/ROADMAP.md`)
Nuncio is pre-configured for **JetBrains RustRover** out of the box:
- **Workspace Cargo Resolution**: RustRover automatically recognizes all 9 workspace member crates (`crates/*`).
- **Pre-Configured Shared Run Configurations**: Located in `.idea/runConfigurations/`:
- `Cargo Check All`: Executes `cargo check-all` with warnings treated as errors.
- `Cargo Test All`: Executes `cargo test-all` for the full workspace.
- `Cargo Coverage Gate`: Executes `cargo cov` measuring 100% unit test line coverage.
- `Run nuncio-cli (status)`: Executes `cargo run -p nuncio-cli -- status`.
- `Run nuncio-tui`: Executes `cargo run -p nuncio-tui`.
- `Run nuncio-gui`: Executes `cargo run -p nuncio-gui`.
- **Code Style & Formatting**: `.idea/codeStyles/Project.xml` configures RustRover to format files automatically via `rustfmt` on save.
## Architecture (target)
## Multi-Agent Execution Framework
The daemon is the product; UIs are thin clients over a published contract.
- **Master Orchestrator Agent (`agy` / Antigravity)**: Manages high-level task decomposition, roadmap tracking ([docs/PLAN-nuncio-roadmap.md](file:///R:/Git.Local/KofTwentyTwo/nuncio/docs/PLAN-nuncio-roadmap.md), [docs/TODO.md](file:///R:/Git.Local/KofTwentyTwo/nuncio/docs/TODO.md)), subagent dispatching, quality gate validation (`cargo verify`, `cargo cov`), git branch workflows, and wiki documentation.
- **Claude Code Subagent (`claude`)**: Assigned to headless engine crates (`crates/nuncio-core`, `crates/nuncio-mail`, `crates/nuncio-cal`, `crates/nuncio-store`), SQLite FTS5 migrations, protocol parsers, `rrule` recurrence math, `age` encryption, and 100% unit test coverage.
- **OpenAI Codex Subagent (`codex`)**: Assigned to CLI subcommands and shell pipeline automation (`crates/nuncio-cli`).
- **Antigravity Worker Subagent (`agy-worker`)**: Assigned to TUI (`crates/nuncio-tui`), GUI (`crates/nuncio-gui`), webview sandboxing, and CI/CD cross-platform release matrix.
- **`nunciod`** owns all state (SQLite/WAL), credentials (OS keyring), protocol
sync logic, the filter engine, and all business logic. The `nuncio-*` library
crates are composed by the daemon.
- The daemon **publishes a versioned gRPC API** (`proto/nuncio/v1/*.proto`, being
introduced in roadmap Phase 1) over **loopback TCP with a bearer-token
handshake** (token in the OS keyring). **Server-streaming RPCs carry push**
(new mail, sync progress, events).
- The `.proto` files are the single source of truth for the contract. **Feature
parity across clients is guaranteed by the contract, not by discipline.**
- **`nuncio-cli` is the in-repo reference client** and E2E driver. All other UIs
(native macOS/Windows GUIs, TUI, MCP bridge) become separate repositories.
## Architecture Guidelines
### Current vs target state
1. **Library-First ("Ghost" Decoupled Model)**:
- Business logic, protocol clients, offline state synchronization, SQLite FTS5 search indexing, and cryptographic key management MUST reside strictly inside headless Rust crates: `crates/nuncio-core`, `crates/nuncio-mail`, `crates/nuncio-cal`, and `crates/nuncio-store`.
- Presentation shells (`crates/nuncio-cli`, `crates/nuncio-tui`, `crates/nuncio-gui`, `crates/nuncio-mcp`) MUST remain thin UI layers interacting with `nuncio-core` via `IpcClient` and async Tokio channels.
The presentation shells (`nuncio-tui`, `nuncio-gui`, `nuncio-mcp`) have been moved
to `_reference/` (out of the workspace) and will be rebuilt as separate native
client repos. The daemon serves a single transport — the **gRPC API on loopback
`127.0.0.1:9420`**; the old hand-rolled JSON-RPC IPC has been removed.
2. **Standing Rule: 100% Multi-Shell Feature Parity Equivalence**:
- **Mandatory 1:1 Parity**: Any feature, action, search query, configuration setting, or workflow exposed in one presentation shell MUST be fully implemented and accessible across ALL FOUR presentation shells (`nuncio-cli`, `nuncio-tui`, `nuncio-gui`, `nuncio-mcp`).
- Adding a new feature to V1, V2, or V3 automatically requires implementing its CLI subcommand (`--json`), TUI keyboard shortcut/modal, GUI React component, and MCP LLM tool handler.
### Library-first boundary
3. **Language & Code Quality Standards**:
- **Rust Edition**: 2021 edition across all workspace crates.
- **Compiler & Linter Gates**: `rustflags = ["-D", "warnings"]` configured in `.cargo/config.toml`. All compiler and clippy warnings MUST be treated as hard build errors during normal development.
- **Formatting**: `cargo fmt` enforced. No unformatted code allowed.
- **Error Handling**: Use `thiserror` for library crates (`nuncio-core`, `nuncio-mail`, `nuncio-cal`, `nuncio-store`). `unwrap()` and `expect()` are **forbidden** in production library code (permitted only in tests and prototypes).
- **Security**: Never store plain-text passwords, tokens, or encryption keys in source files or SQLite tables. Route all credentials to OS native vaults via `keyring`. Payload attachments encrypted at rest via `age`. Untrusted HTML email sandboxed inside `<iframe sandbox>` with JS disabled.
Business logic, protocol clients, storage, search, and key management live in the
headless crates (`nuncio-core`, `nuncio-store`, `nuncio-mail`, `nuncio-cal`,
`nuncio-contacts`, `nuncio-filter`). Presentation code must remain a thin client
of the daemon's API and hold no business logic or private data store.
3. **Testing, E2E & Mocking Standards**:
- **Test-First Commit Policy**: 100% of workspace tests (`cargo test --workspace`) MUST pass locally before committing or triggering CI pipelines.
- **100% Line Coverage Requirement**: **100% line coverage** is required for unit tests across workspace engine crates (`cargo llvm-cov --workspace --fail-under-lines 100`).
- **Integration Testing Standards**: `tests/` directories MUST test multi-crate interaction, SQLite WAL migrations, FTS5 trigram queries, and channel event loops using isolated ephemeral databases (`tempfile` or `:memory:`). State leakage between tests is forbidden.
- **End-to-End (E2E) Testing Standards**: Headless E2E integration tests MUST validate complete user workflows from `nuncio-cli` subcommands and `nuncio-core` state streams down through storage and protocol layers.
- **Full Mocks for External Systems**:
- All external network protocols (JMAP, IMAP, CalDAV, CardDAV, SMTP) MUST be 100% mocked for offline test execution using `wiremock` or mock trait implementations (`MockMailBackend`, `MockCalDavClient`).
- OS native vaults MUST be mocked via an in-memory `MockKeyring` provider during tests.
- Live network calls during test runs are strictly **forbidden**.
## Build, test, and quality gates
4. **Commit & Branch Conventions**:
- Commit messages MUST follow Conventional Commits format (`feat(scope): description`, `fix(scope): description`). Subject lines under 72 characters, imperative mood, zero AI attribution.
- All work MUST be performed on feature branches (`feature/GH-123-description`), never committed directly to `main`.
Warnings are hard errors and `unsafe_code` is forbidden (see
[`.cargo/config.toml`](.cargo/config.toml) and workspace lints in
[`Cargo.toml`](Cargo.toml): `unwrap_used`, `expect_used`, `panic`, `todo` are all
`deny`).
## Local Build & Quality Verification Commands
Normal development automatically enforces compiler warnings as errors via `.cargo/config.toml`. Before pushing or creating a Pull Request, run the full verification suite locally:
Cargo aliases (defined in `.cargo/config.toml`):
```bash
# 1. Format Check
cargo fmt --all -- --check
# 2. Clippy Linter Check (warnings as errors)
cargo check-all
# 3. Complete Test Suite Execution (Unit, Integration & E2E - 100% passing required)
cargo test-all
# 4. Enforce 100% Code Coverage Threshold
cargo llvm-cov --workspace --fail-under-lines 100
cargo check-all # = clippy --all-targets --workspace -- -D warnings (the lint gate)
cargo test-all # = test --workspace
cargo cov # = llvm-cov --workspace --ignore-filename-regex main.rs --summary-only (informational; needs cargo-llvm-cov)
```
There is no `cargo verify` alias — Cargo aliases are a single command's argv and
cannot chain subcommands. Run the full gate as three separate commands (this is
exactly what the pre-commit hook runs):
```bash
cargo fmt --all -- --check
cargo check-all
cargo test-all
```
Common commands:
```bash
cargo build --workspace
cargo test -p nuncio-store # one crate
cargo test -p nuncio-filter parser:: # one module's tests
cargo test -p nunciod --test daemon_e2e_test # one integration suite
cargo run -p nunciod # start the daemon (gRPC on 127.0.0.1:9420)
cargo run -p nuncio-cli -- system status # drive it via the CLI (daemon must be running)
```
> **Gate status:** the workspace is green under the pinned toolchain
> (`rust-toolchain.toml` = 1.97.1), so the local pre-commit hook runs the exact
> `fmt`/`clippy --all-targets`/tests that CI does — "green locally" is CI-equivalent.
> CI itself is paused until the GitHub Actions minutes reset (releases are tag-only).
> Coverage is informational, not a 100% gate.
## RustRover / IDE
A shared RustRover setup lives in `.idea/` (the useful parts are tracked;
`workspace.xml`/`*.iml` and other local churn are gitignored):
- **Cargo project** auto-resolves from the root `Cargo.toml`; after pulling changes
that add or move crates, run **Reload Cargo Project** (RustRover usually prompts).
- **Toolchain** comes from `rust-toolchain.toml` (1.97.1) automatically.
- **rustfmt on save** is enabled (`.idea/codeStyles/Project.xml`).
- **Shared run configurations** (`.idea/runConfigurations/`):
- *Run nunciod (daemon)* — start the daemon.
- *Run nuncio-cli (system status)* — drive it (start the daemon first).
- *Cargo Build Release (daemon + CLI)* — `cargo build-release`.
- *Cargo Check All* · *Cargo Test All* — the gates (run `cargo fmt --all -- --check`
separately; there is no combined run configuration, since Cargo aliases and
RustRover run configurations can't chain subcommands).
- *Cargo Coverage* — informational `llvm-cov` (needs `cargo-llvm-cov` installed).
- Tip: set Clippy as the external linter (Settings → Rust → External Linters) so the
editor mirrors the `-D warnings` gate.
- **Full walkthrough:** [`docs/DEVELOPING-IN-RUSTROVER.md`](docs/DEVELOPING-IN-RUSTROVER.md)
— setup, running the daemon + CLI, running/debugging tests, gates, and the dev loop.
## Conventions
- **Rust edition 2021**; `cargo fmt` enforced (no unformatted code).
- **Error handling:** `thiserror` in library crates; `unwrap()`/`expect()` are
forbidden in production code (tests only).
- **Security:** never store plaintext credentials/keys in source or SQLite; route
secrets through the OS keyring; encrypt attachments at rest; sandbox untrusted
HTML email in an `<iframe sandbox>` with JS disabled. (Several of these are
aspirational in the current tree — see the assessment; Phase 0.E hardens them.)
- **Commits:** Conventional Commits (`feat(scope): …`, `fix(scope): …`), imperative,
<72-char subjects, no AI attribution.
- **Comments:** explain the code's *intent, constraints, and rationale*. Do NOT
put backlog-story numbers (`1.C.2`), GH issue references (`#156`, `GH-156`,
`Refs #`), or `Phase N` breadcrumbs in source comments (`.rs`/`.proto`) — that
traceability belongs in git history and `docs/`, and it rots in the code.
Keep genuine technical rationale (RFC citations, algorithm notes, why-not-X).
(Commit messages may still cite `Refs #NNN`.)
- **Branches:** feature branches; never commit directly to `main`. Branch flow is
`dev` → `rc` → `main`.
- **A capability is "done" only when engine + proto + CLI command + offline E2E
test all exist and CI is green.** No fabricated success output — an unimplemented
path returns an honest error, never fake data.
- **Roadmap is dual-format — keep it in sync.** `docs/ROADMAP.md` is the source of
truth; `docs/roadmap/index.html` is its rendered GitHub Pages page
(<https://koftwentytwo.github.io/nuncio/roadmap/>, linked from `README.md`).
Any change to milestones, status, or scope MUST update **both** in the same
commit — never let the rendered page drift from the markdown, and keep the
`README.md` link valid.
## Testing standards
- 100% of `cargo test-all` must pass before committing.
- Integration tests use ephemeral SQLite (`tempfile` / `:memory:`); no state
leakage between tests.
- **All external protocols (IMAP/JMAP/SMTP/CalDAV/CardDAV) must be mocked**
(`wiremock` or the `Mock*Backend` traits); the OS keyring is mocked via
`MockKeyring`. **Live network calls in tests are forbidden.**
## Release policy
SemVer 2.0.0; release tags are `vMAJOR.MINOR.PATCH`. **No release should be
presented as functional until the engine and its API are solid (roadmap Phase 4).**
Existing public pre-1.0 releases are non-functional and are being marked pre-alpha.
Generated
+344 -3411
View File
File diff suppressed because it is too large. Load diff
+8 -5
View File
@@ -5,14 +5,11 @@ members = [
"crates/nuncio-mail",
"crates/nuncio-cal",
"crates/nuncio-store",
"crates/nuncio-tui",
"crates/nuncio-gui",
"crates/nuncio-gui/src-tauri",
"crates/nuncio-cli",
"crates/nuncio-mcp",
"crates/nunciod",
"crates/nuncio-filter",
"crates/nuncio-contacts",
"crates/nuncio-proto",
]
[workspace.package]
@@ -64,7 +61,7 @@ rustls-pki-types = "1.7"
tokio-stream = "0.1"
futures = "0.3"
wiremock = "0.6"
icalendar = "0.15"
icalendar = "0.17"
chrono-tz = "0.10"
rrule = "0.10"
chrono = "0.4"
@@ -81,6 +78,12 @@ hex = "0.4"
flate2 = "1.0"
tar = "0.4"
zip = { version = "8.6", default-features = false, features = ["deflate"] }
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"
tonic-prost-build = "0.14"
protoc-bin-vendored = "3.2"
subtle = "2.6"
+55 -155
View File
@@ -1,176 +1,76 @@
# Nuncio
<p align="center">
<img src="assets/nuncio_app_icon.jpg" alt="Nuncio Application Icon" width="128" style="border-radius: 24px;" />
</p>
**A local-first mail, calendar, and contacts engine written in Rust.**
<h3 align="center">Nuncio Mail & Calendar Suite</h3>
> **Status: pre-alpha, under active reconstruction (2026-07-26).**
> Nuncio began as an AI-generated proof of concept. A forensic assessment found
> that while several library-layer components are genuinely well-built, the
> end-to-end product did not work and much of the prior documentation was
> inaccurate. The project is being rebuilt engine-first. **It is not usable yet,
> and published pre-1.0 releases are non-functional — do not install them.**
> See the **[live roadmap](https://koftwentytwo.github.io/nuncio/roadmap/)**
> (source: [`docs/ROADMAP.md`](docs/ROADMAP.md)) for the plan and
> [`docs/adr/0001-engine-first-grpc-architecture.md`](docs/adr/0001-engine-first-grpc-architecture.md)
> for the architecture decision.
<p align="center">
<b>High-Performance Library-First Messenger and Calendar Courier for Developers, Power Users, and AI Agents</b>
</p>
## What Nuncio is
<p align="center">
<a href="https://nuncio.mx">Official Site: nuncio.mx</a> •
<a href="https://github.com/KofTwentyTwo/nuncio">GitHub Repository</a>
</p>
A background daemon (`nunciod`) owns all state, credentials, and protocol logic
(IMAP/SMTP, then JMAP, CalDAV, CardDAV), and **publishes a versioned gRPC API over
loopback**. User interfaces are separate native client projects that consume that
API — they hold no business logic and no data of their own. This makes feature
parity across clients a property of the published contract rather than a matter of
discipline.
> **Etymology**: Derived from the Latin verb ***nūntiō*** ("I announce", "I declare", "I deliver a message") and noun ***nūntius*** ("messenger", "courier", "bearer of tidings"). Nuncio is built as the ultimate cross-platform messenger and calendar courier across Linux, macOS, and Windows.
The only client in this repository is `nuncio-cli`, which serves as the reference
API consumer and the driver for end-to-end tests. Native GUIs (macOS/Windows), a
TUI, and an MCP bridge will live in separate repositories once the engine and its
API are solid.
---
## Workspace layout
## Visual Application Preview
Engine libraries composed by the daemon:
![Nuncio Desktop Application UI Preview](assets/nuncio_gui_hero.jpg)
---
## 4 Great Interfaces + Central Daemon Topology
Nuncio operates on a **Hybrid Daemon-First Architecture**. Centralized state management, SQLite WAL persistence, credential security enclaves, and protocol synchronizers reside inside a standalone background daemon (`nunciod`). Four decoupled interfaces communicate with `nunciod` over native IPC socket streams:
```mermaid
graph TD
subgraph Four Great Interfaces
CLI["nuncio-cli (POSIX CLI)"]
TUI["nuncio-tui (Ratatui TUI)"]
GUI["nuncio-gui (Tauri v2 Desktop GUI)"]
MCP["nuncio-mcp (Native MCP LLM Agent Interface)"]
end
subgraph IPC Client Engine
Client["IpcClient (Auto-Spawn + Retry Loop + JSON-RPC 2.0 Framing)"]
end
subgraph Native OS Sockets
UnixSocket["POSIX: UNIX Domain Socket (~/.nuncio/nuncio.sock)"]
WinPipe["Windows: Named Pipe (\\\\.\\pipe\\nuncio-ipc)"]
end
subgraph Central Background Daemon
DaemonServer["IpcDaemonServer (Security Enclave)"]
DaemonProcess["nunciod Daemon Process"]
EventBus["nuncio-core EventBus"]
end
subgraph Core Engine & Protocol Libraries
MailEngine["nuncio-mail (IMAP / SMTP / JMAP)"]
CalEngine["nuncio-cal (CalDAV / iCal / rrule)"]
StoreEngine["nuncio-store (SQLite WAL / FTS5 Trigram / Age Cipher)"]
end
CLI --> Client
TUI --> Client
GUI --> Client
MCP --> Client
Client --> UnixSocket
Client --> WinPipe
UnixSocket --> DaemonServer
WinPipe --> DaemonServer
DaemonServer --> DaemonProcess
DaemonProcess --> EventBus
EventBus --> MailEngine
EventBus --> CalEngine
EventBus --> StoreEngine
```
1. **`nuncio-cli` (POSIX CLI)**: Scriptable `<Noun> <Verb>` commands with deterministic `--json` output and offline-resilient local SQLite reads.
2. **`nuncio-tui` (Terminal TUI)**: Keyboard-first 3-pane split view built in Ratatui with Vim navigation (`j`/`k`/`h`/`l`) and live `CoreEvent` push updates.
3. **`nuncio-gui` (Tauri v2 Desktop GUI)**: Cross-platform native window host with React 18 + Vite + TypeScript frontend, glassmorphic styling, and strict HTML `<iframe sandbox>` CSP email rendering.
4. **`nuncio-mcp` (Native LLM Agent UI)**: Model Context Protocol (MCP) JSON-RPC 2.0 stdio server exposing tools (`nuncio_mail_send`, `nuncio_cal_create_event`), resources (`nuncio://mail/inbox`), and prompts for local AI agents (Claude, Cursor, Antigravity).
5. **`nunciod` (Central Daemon Binary)**: Standalone background process owning SQLite WAL storage, security enclaves, background protocol sync loops, and multi-client IPC socket streams.
---
## Workspace Crate Architecture
The Nuncio Cargo workspace is modularized into 11 crates with strict domain boundaries:
| Crate Path | Architectural Role & Description |
| Crate | Role |
| :--- | :--- |
| `crates/nuncio-core` | Core domain models (`Email`, `CalendarEvent`, `Contact`), `EventBus`, OpenPGP/S/MIME E2EE, WASM Plugins, Local LLM Summarization, and `IpcClient`/`IpcDaemonServer` framing. |
| `crates/nuncio-mail` | IMAP4rev1, JMAP (RFC 8620/8621), SMTP transport engines, and MIME stream parser. |
| `crates/nuncio-cal` | CalDAV (RFC 4791) client, iCalendar (RFC 5545) parser, `rrule` recurrence engine, and Natural Language NLP scheduler. |
| `crates/nuncio-contacts` | Sovereign SQLite contacts engine, CardDAV client sync (RFC 6352), email contact harvester, and vCard 4.0 generator. |
| `crates/nuncio-store` | SQLite WAL persistence (`DatabaseEngine`), FTS5 trigram search index (<10ms across 100k+ emails), AES-256-GCM cipher, `age` stream cipher, OS Keyring vault, and WORM audit ledger. |
| `crates/nuncio-filter` | Declarative NSQL filter engine (`sqlparser 0.54`), AST actions, 6-pass validator, dry-run tester, and HMAC webhooks. |
| `crates/nuncio-cli` | Pure Noun + Verb CLI runner and Unix pipe scripting engine. |
| `crates/nuncio-tui` | Terminal user interface powered by Ratatui with Vim motion velocity (`j`/`k`/`h`/`l`/`g i`/`e`/`s`). |
| `crates/nuncio-gui` | Native desktop GUI application shell powered by Tauri v2 and React. |
| `crates/nuncio-mcp` | MCP stdio server providing LLM agents direct read/write access with `McpAgentPolicy` RBAC. |
| `crates/nunciod` | Standalone background daemon binary server. |
| `crates/nuncio-core` | Domain models, event bus, IPC/API layer, cross-cutting services |
| `crates/nuncio-store` | SQLite (WAL) persistence, FTS search, ciphers, corruption recovery, keyring vault |
| `crates/nuncio-mail` | IMAP, JMAP, SMTP protocol engines and MIME handling |
| `crates/nuncio-cal` | iCalendar, CalDAV, CardDAV, recurrence, scheduling |
| `crates/nuncio-contacts` | Contacts store, CardDAV sync, vCard |
| `crates/nuncio-filter` | NSQL declarative filter language (parser, validator, engine) |
| `crates/nunciod` | The daemon binary — owns state and serves the API |
| `crates/nuncio-cli` | Reference API client + E2E driver |
---
The former `nuncio-tui`, `nuncio-gui`, and `nuncio-mcp` shells have been moved to
`_reference/` (out of the workspace) and will be rebuilt as separate client
repositories (roadmap Phase 5).
## CLI Command Usage (Pure Noun + Verb Syntax)
Nuncio CLI enforces standardized `<Noun> <Verb> [Flags]` syntax with optional `--json` output envelopes:
## Build & test
```bash
# Account Management
nuncio account add --email james.maes@kof22.com --imap-host mail.kof22.com --imap-port 993
nuncio account list --json
# Mail Operations
nuncio mail sync [--account <id>]
nuncio mail list --folder INBOX --limit 50
nuncio mail read --id msg_123
nuncio mail search --query "roadmap"
nuncio mail send --to alice@nuncio.mx --subject "Release Update" --body "Message body"
# Calendar Operations
nuncio cal list --calendar work
nuncio cal create --summary "Team Standup" --start 1774348800 --end 1774352400
# System Status
nuncio system status --json
nuncio daemon
cargo build --workspace # build everything
cargo check-all # clippy, all targets, warnings-as-errors (the gate)
cargo test-all # run the whole test suite
cargo test -p nuncio-store # test a single crate
```
---
There is no `cargo verify` alias (Cargo aliases can't chain subcommands). Run the
full local gate as three separate commands — `cargo fmt --all -- --check`,
`cargo check-all`, `cargo test-all` — which is exactly what the pre-commit hook runs.
## Quality Gates & Verification Commands
Cargo aliases are defined in [`.cargo/config.toml`](.cargo/config.toml).
Build configurations enforce strict zero-warning policy (`-D warnings -F unsafe_code -D unused_must_use`):
## Documentation
```bash
# 1. Compiler & Linter Quality Gate (Zero warnings allowed)
cargo check --workspace
cargo clippy --workspace -- -D warnings
- **[Roadmap — live rendered page](https://koftwentytwo.github.io/nuncio/roadmap/)** — the visual plan to a client-ready backend (GitHub Pages)
- [`docs/ROADMAP.md`](docs/ROADMAP.md) — authoritative roadmap and target architecture (source for the page above)
- [`docs/STORY-WORKFLOW.md`](docs/STORY-WORKFLOW.md) — how contributors execute a roadmap story (gates, patterns, review flow)
- [`docs/HANDOFF.md`](docs/HANDOFF.md) — development handoff: current state, branches, and the M1–M7 story index
- [`docs/BACKLOG.md`](docs/BACKLOG.md) — engineering-ready Phase 0 / Phase 1 stories
- [`docs/adr/`](docs/adr/) — architecture decision records
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — technical architecture
# 2. Run Full Integration Test Suite (100 tests across 9 crates)
cargo test --workspace
## License
# 3. Formatter Gate
cargo fmt --all --check
```
---
## Master Architectural Documentation & Roadmaps
- **[Enhanced Production Roadmap (V1, V2, V3)](docs/PLAN-enhanced-roadmap-v1-v2-v3.md)**: Deep commercial feature specification benchmarked against Superhuman, Mimestream, Apple Mail, Outlook, Hey, Cron, and Fantastical.
- **[Hybrid Daemon Architecture Blueprint](docs/PLAN-hybrid-daemon-architecture.md)**: Sockets, framing, auto-spawning, and security enclave design.
- **[Executive Audit Review Report](docs/EXECUTIVE-AUDIT-REVIEW.md)**: Authoritative technical audit scorecard across architecture, security, code quality, UI/UX, and performance.
- **[Architecture Specification](docs/ARCHITECTURE.md)**: Domain encapsulation, Hexagonal Ports & Adapters model, and IPC streaming contracts.
---
## Single Source of Truth for Execution
Project planning, issue tracking, atomic micro-milestones, and subagent assignments are authoritatively managed directly on GitHub:
- **GitHub Project Board**: [Nuncio Roadmap Project #5](https://github.com/users/KofTwentyTwo/projects/5)
- **GitHub Milestones**: [KofTwentyTwo/nuncio/milestones](https://github.com/KofTwentyTwo/nuncio/milestones) (`v0.1.0` through `v1.0.0`)
- **GitHub Issues**: [KofTwentyTwo/nuncio/issues](https://github.com/KofTwentyTwo/nuncio/issues)
---
## License & Open Source Acknowledgments
- **Nuncio License**: MIT OR Apache-2.0
- **Third-Party Open Source Libraries**: See **[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md)** for full credits and license terms (Tokio, Ratatui, Tauri v2, SQLx, Lettre, async-imap, RustCrypto AES-GCM, age, Zeroize, Keyring, React, Lucide, Vite).
MIT OR Apache-2.0.
+40
View File
@@ -0,0 +1,40 @@
# `_reference/` — archived pre-gRPC presentation shells
This directory holds three presentation shells that were removed from the
Cargo workspace (backlog **0.D.2**, GH-145) as part of shrinking the
workspace to just the engine, the daemon, and the reference CLI:
- `nuncio-tui` — the Ratatui terminal UI shell
- `nuncio-gui` — the Rust GUI core plus its `src-tauri` Tauri desktop shell
and its `ui/` React/Vite frontend
- `nuncio-mcp` — the MCP (Model Context Protocol) server shell for LLM tool
access
They were built directly against the in-process `nuncio-core`/`nuncio-mail`/
`nuncio-cal`/`nuncio-store`/`nuncio-filter`/`nuncio-contacts` library APIs,
from the pre-gRPC phase of the project. Per the target architecture in
[`docs/ROADMAP.md`](../docs/ROADMAP.md), `nunciod` is moving to publish a
versioned gRPC API over loopback, and every presentation shell other than
the in-repo `nuncio-cli` reference client is planned to live in its own
separate native client repository (**Phase 5 — Client projects**):
`nuncio-tui`, `nuncio-gui-macos`, `nuncio-gui-windows`, `nuncio-mcp`, etc.
**This code is retained intact, not deleted**, so it can be mined for UI/UX
ideas, keybindings, view layout, and MCP tool-surface design when those
Phase 5 client repos are built against the new gRPC API. It is **not**
built, checked, or tested by the root Cargo workspace — none of these
directories are members of the workspace `Cargo.toml`, so
`cargo build --workspace`, `cargo check-all`, and `cargo test --workspace`
never touch them.
Each crate's internal `path = "../..."` dependencies on the engine crates
have been updated to point at `../../crates/<name>` (e.g.
`_reference/nuncio-tui/Cargo.toml` now points at `../../crates/nuncio-core`)
so the crate sources still resolve correctly relative to their new location.
They are **not guaranteed to build standalone** as-is: they still declare
`*.workspace = true` fields (`version`, `edition`, `lints`, and workspace
`[dependencies]` aliases) that only resolve inside a Cargo workspace, and
they are not their own workspace root. Restoring a standalone build (for
mining or resurrection) will require either vendoring those workspace
values directly into each `Cargo.toml`, or giving `_reference/` its own
`[workspace]` root.
@@ -10,10 +10,10 @@ repository.workspace = true
workspace = true
[dependencies]
nuncio-core = { path = "../nuncio-core" }
nuncio-mail = { path = "../nuncio-mail" }
nuncio-cal = { path = "../nuncio-cal" }
nuncio-store = { path = "../nuncio-store" }
nuncio-core = { path = "../../crates/nuncio-core" }
nuncio-mail = { path = "../../crates/nuncio-mail" }
nuncio-cal = { path = "../../crates/nuncio-cal" }
nuncio-store = { path = "../../crates/nuncio-store" }
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -10,7 +10,7 @@ repository.workspace = true
workspace = true
[dependencies]
nuncio-core = { path = "../../nuncio-core" }
nuncio-core = { path = "../../../crates/nuncio-core" }
nuncio-gui = { path = ".." }
tauri = { version = "2.0", features = [] }
tauri-plugin-updater = "2.0"
File renamed without changes.
@@ -1,5 +1,7 @@
//! Nuncio GUI Desktop Library.
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod ipc;
pub mod sandbox;
pub mod view;
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,7 @@
//! E2E System Test Suite for nuncio-gui.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_core::{EngineStatus, EventBus};
use nuncio_gui::{GuiViewState, HtmlSanitizer, IpcBridge, IpcCommandPayload};
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -10,12 +10,12 @@ repository.workspace = true
workspace = true
[dependencies]
nuncio-core = { path = "../nuncio-core" }
nuncio-mail = { path = "../nuncio-mail" }
nuncio-cal = { path = "../nuncio-cal" }
nuncio-store = { path = "../nuncio-store" }
nuncio-filter = { path = "../nuncio-filter" }
nuncio-contacts = { path = "../nuncio-contacts" }
nuncio-core = { path = "../../crates/nuncio-core" }
nuncio-mail = { path = "../../crates/nuncio-mail" }
nuncio-cal = { path = "../../crates/nuncio-cal" }
nuncio-store = { path = "../../crates/nuncio-store" }
nuncio-filter = { path = "../../crates/nuncio-filter" }
nuncio-contacts = { path = "../../crates/nuncio-contacts" }
tokio = { workspace = true, features = ["full"] }
@@ -2,6 +2,8 @@
//! Exposes a 4th presentation shell ("Native LLM Agent UI") allowing AI models
//! full access to email, calendar, search, and storage features.
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod resources;
pub mod server;
pub mod tools;
File renamed without changes.
@@ -48,13 +48,15 @@ impl McpResourceHandler {
McpResourceDefinition {
uri: "nuncio://filters".to_string(),
name: "NSQL Filter Rules".to_string(),
description: "Active server-side NSQL filter rules stored in Nuncio database.".to_string(),
description: "Active server-side NSQL filter rules stored in Nuncio database."
.to_string(),
mime_type: "application/json".to_string(),
},
McpResourceDefinition {
uri: "nuncio://system/status".to_string(),
name: "System Health & Recovery Diagnostic Status".to_string(),
description: "Database health probe and self-healing recovery diagnostic status.".to_string(),
description: "Database health probe and self-healing recovery diagnostic status."
.to_string(),
mime_type: "application/json".to_string(),
},
]
@@ -76,7 +78,11 @@ impl McpResourceHandler {
Ok(json!({ "uri": uri, "content": accounts }))
}
"nuncio://filters" => {
let rules = self.db.list_filter_rules().await.map_err(|e| e.to_string())?;
let rules = self
.db
.list_filter_rules()
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "uri": uri, "content": rules }))
}
"nuncio://system/status" => {
@@ -21,7 +21,10 @@ impl McpServer {
let (db_engine, dir) = DatabaseEngine::connect_ephemeral().await?;
let db = Arc::new(db_engine);
Ok(Self {
tools: McpToolHandler::with_policy(db.clone(), nuncio_core::McpAgentPolicy::unrestricted("stdio-agent")),
tools: McpToolHandler::with_policy(
db.clone(),
nuncio_core::McpAgentPolicy::unrestricted("stdio-agent"),
),
resources: McpResourceHandler::new(db),
_tempdir: Some(Arc::new(dir)),
})
@@ -30,7 +33,10 @@ impl McpServer {
/// Create a new `McpServer` wrapping shared `DatabaseEngine`.
pub fn new(db: Arc<DatabaseEngine>) -> Self {
Self {
tools: McpToolHandler::with_policy(db.clone(), nuncio_core::McpAgentPolicy::unrestricted("stdio-agent")),
tools: McpToolHandler::with_policy(
db.clone(),
nuncio_core::McpAgentPolicy::unrestricted("stdio-agent"),
),
resources: McpResourceHandler::new(db),
_tempdir: None,
}
@@ -299,13 +299,24 @@ impl McpToolHandler {
pub async fn call_tool(&self, name: &str, args: Value) -> Result<Value, String> {
match name {
"nuncio_mail_list" => {
if !self.policy.is_data_type_allowed(DataType::Mail) || !self.policy.permissions.read_mail {
return Err(format!("403 Forbidden: Agent '{}' lacks 'read_mail' permission", self.policy.agent_id));
if !self.policy.is_data_type_allowed(DataType::Mail)
|| !self.policy.permissions.read_mail
{
return Err(format!(
"403 Forbidden: Agent '{}' lacks 'read_mail' permission",
self.policy.agent_id
));
}
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let folder_id = args.get("folder_id").and_then(|v| v.as_str()).unwrap_or("INBOX");
let folder_id = args
.get("folder_id")
.and_then(|v| v.as_str())
.unwrap_or("INBOX");
if !self.policy.is_folder_allowed(folder_id) {
return Err(format!("403 Forbidden: Agent '{}' is restricted from folder '{}'", self.policy.agent_id, folder_id));
return Err(format!(
"403 Forbidden: Agent '{}' is restricted from folder '{}'",
self.policy.agent_id, folder_id
));
}
let messages = self
.db
@@ -330,16 +341,36 @@ impl McpToolHandler {
Ok(json!({ "messages": filtered }))
}
"nuncio_mail_send" => {
if !self.policy.is_data_type_allowed(DataType::Mail) || !self.policy.permissions.send_mail {
return Err(format!("403 Forbidden: Agent '{}' lacks 'send_mail' permission", self.policy.agent_id));
if !self.policy.is_data_type_allowed(DataType::Mail)
|| !self.policy.permissions.send_mail
{
return Err(format!(
"403 Forbidden: Agent '{}' lacks 'send_mail' permission",
self.policy.agent_id
));
}
let account_id = args.get("account_id").and_then(|v| v.as_str()).ok_or("missing account_id")?;
let account_id = args
.get("account_id")
.and_then(|v| v.as_str())
.ok_or("missing account_id")?;
if !self.policy.is_account_allowed(account_id) {
return Err(format!("403 Forbidden: Agent '{}' is restricted from account '{}'", self.policy.agent_id, account_id));
return Err(format!(
"403 Forbidden: Agent '{}' is restricted from account '{}'",
self.policy.agent_id, account_id
));
}
let recipient = args.get("recipient").and_then(|v| v.as_str()).ok_or("missing recipient")?;
let subject = args.get("subject").and_then(|v| v.as_str()).ok_or("missing subject")?;
let body = args.get("body").and_then(|v| v.as_str()).ok_or("missing body")?;
let recipient = args
.get("recipient")
.and_then(|v| v.as_str())
.ok_or("missing recipient")?;
let subject = args
.get("subject")
.and_then(|v| v.as_str())
.ok_or("missing subject")?;
let body = args
.get("body")
.and_then(|v| v.as_str())
.ok_or("missing body")?;
let email = Email {
id: format!("mcp-outbound-{}", chrono::Utc::now().timestamp_millis()),
@@ -365,13 +396,24 @@ impl McpToolHandler {
Ok(json!({ "status": "queued_and_saved", "email_id": email.id }))
}
"nuncio_mail_search" => {
if !self.policy.is_data_type_allowed(DataType::Mail) || !self.policy.permissions.read_mail {
return Err(format!("403 Forbidden: Agent '{}' lacks 'read_mail' permission", self.policy.agent_id));
if !self.policy.is_data_type_allowed(DataType::Mail)
|| !self.policy.permissions.read_mail
{
return Err(format!(
"403 Forbidden: Agent '{}' lacks 'read_mail' permission",
self.policy.agent_id
));
}
let query = args.get("query").and_then(|v| v.as_str()).ok_or("missing query")?;
let query = args
.get("query")
.and_then(|v| v.as_str())
.ok_or("missing query")?;
let search_engine = SearchEngine::new(&self.db);
let _ = search_engine.setup_fts_tables().await;
let results = search_engine.search_messages(query).await.map_err(|e| e.to_string())?;
let results = search_engine
.search_messages(query)
.await
.map_err(|e| e.to_string())?;
let sanitized: Vec<_> = results
.into_iter()
@@ -384,10 +426,18 @@ impl McpToolHandler {
Ok(json!({ "results": sanitized }))
}
"nuncio_cal_list_events" => {
if !self.policy.is_data_type_allowed(DataType::Calendar) || !self.policy.permissions.read_calendar {
return Err(format!("403 Forbidden: Agent '{}' lacks 'read_calendar' permission", self.policy.agent_id));
if !self.policy.is_data_type_allowed(DataType::Calendar)
|| !self.policy.permissions.read_calendar
{
return Err(format!(
"403 Forbidden: Agent '{}' lacks 'read_calendar' permission",
self.policy.agent_id
));
}
let calendar_id = args.get("calendar_id").and_then(|v| v.as_str()).unwrap_or("work");
let calendar_id = args
.get("calendar_id")
.and_then(|v| v.as_str())
.unwrap_or("work");
#[allow(clippy::type_complexity)]
let rows: Vec<(String, String, String, String, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT id, account_id, calendar_id, summary, start_time, end_time, location FROM calendar_events WHERE calendar_id = ?"
@@ -405,17 +455,40 @@ impl McpToolHandler {
Ok(json!({ "events": filtered }))
}
"nuncio_cal_create_event" => {
if !self.policy.is_data_type_allowed(DataType::Calendar) || !self.policy.permissions.write_calendar {
return Err(format!("403 Forbidden: Agent '{}' lacks 'write_calendar' permission", self.policy.agent_id));
if !self.policy.is_data_type_allowed(DataType::Calendar)
|| !self.policy.permissions.write_calendar
{
return Err(format!(
"403 Forbidden: Agent '{}' lacks 'write_calendar' permission",
self.policy.agent_id
));
}
let account_id = args.get("account_id").and_then(|v| v.as_str()).unwrap_or("acct-1");
let account_id = args
.get("account_id")
.and_then(|v| v.as_str())
.unwrap_or("acct-1");
if !self.policy.is_account_allowed(account_id) {
return Err(format!("403 Forbidden: Agent '{}' is restricted from account '{}'", self.policy.agent_id, account_id));
return Err(format!(
"403 Forbidden: Agent '{}' is restricted from account '{}'",
self.policy.agent_id, account_id
));
}
let calendar_id = args.get("calendar_id").and_then(|v| v.as_str()).ok_or("missing calendar_id")?;
let summary = args.get("summary").and_then(|v| v.as_str()).ok_or("missing summary")?;
let start_time = args.get("start_time").and_then(|v| v.as_i64()).ok_or("missing start_time")?;
let end_time = args.get("end_time").and_then(|v| v.as_i64()).ok_or("missing end_time")?;
let calendar_id = args
.get("calendar_id")
.and_then(|v| v.as_str())
.ok_or("missing calendar_id")?;
let summary = args
.get("summary")
.and_then(|v| v.as_str())
.ok_or("missing summary")?;
let start_time = args
.get("start_time")
.and_then(|v| v.as_i64())
.ok_or("missing start_time")?;
let end_time = args
.get("end_time")
.and_then(|v| v.as_i64())
.ok_or("missing end_time")?;
let event = CalendarEvent {
id: format!("mcp-evt-{}", chrono::Utc::now().timestamp_millis()),
@@ -448,7 +521,10 @@ impl McpToolHandler {
}
"nuncio_account_list" => {
if !self.policy.is_data_type_allowed(DataType::Mail) {
return Err(format!("403 Forbidden: Agent '{}' lacks access to account list", self.policy.agent_id));
return Err(format!(
"403 Forbidden: Agent '{}' lacks access to account list",
self.policy.agent_id
));
}
let accounts = self.db.list_accounts().await.map_err(|e| e.to_string())?;
let filtered: Vec<_> = accounts
@@ -458,9 +534,18 @@ impl McpToolHandler {
Ok(json!({ "accounts": filtered }))
}
"nuncio_account_add" => {
let email = args.get("email").and_then(|v| v.as_str()).ok_or("missing email")?;
let imap_host = args.get("imap_host").and_then(|v| v.as_str()).ok_or("missing imap_host")?;
let imap_port = args.get("imap_port").and_then(|v| v.as_u64()).unwrap_or(993) as u16;
let email = args
.get("email")
.and_then(|v| v.as_str())
.ok_or("missing email")?;
let imap_host = args
.get("imap_host")
.and_then(|v| v.as_str())
.ok_or("missing imap_host")?;
let imap_port = args
.get("imap_port")
.and_then(|v| v.as_u64())
.unwrap_or(993) as u16;
let acct = nuncio_core::AccountConfig {
id: format!("acct-{}", chrono::Utc::now().timestamp_millis()),
@@ -480,80 +565,147 @@ impl McpToolHandler {
Ok(json!({ "status": "created", "account": acct }))
}
"nuncio_account_edit" => {
let account_id = args.get("account_id").and_then(|v| v.as_str()).ok_or("missing account_id")?;
let account_id = args
.get("account_id")
.and_then(|v| v.as_str())
.ok_or("missing account_id")?;
let email = args.get("email").and_then(|v| v.as_str());
Ok(json!({ "status": "updated", "account_id": account_id, "updated_email": email }))
}
"nuncio_account_delete" => {
let account_id = args.get("account_id").and_then(|v| v.as_str()).ok_or("missing account_id")?;
let account_id = args
.get("account_id")
.and_then(|v| v.as_str())
.ok_or("missing account_id")?;
Ok(json!({ "status": "deleted", "account_id": account_id }))
}
"nuncio_account_test" => {
let account_id = args.get("account_id").and_then(|v| v.as_str()).ok_or("missing account_id")?;
let account_id = args
.get("account_id")
.and_then(|v| v.as_str())
.ok_or("missing account_id")?;
Ok(json!({ "status": "ok", "account_id": account_id, "latency_ms": 24 }))
}
"nuncio_licenses" => {
Ok(json!({
"licenses": [
{ "name": "tokio", "license": "MIT", "description": "Asynchronous runtime engine" },
{ "name": "ratatui", "license": "MIT", "description": "Terminal UI rendering engine" },
{ "name": "tauri", "license": "MIT/Apache-2.0", "description": "Desktop WebView shell" },
{ "name": "sqlx", "license": "MIT/Apache-2.0", "description": "Async SQLite database driver" },
{ "name": "lettre", "license": "MIT", "description": "SMTP transport client" },
{ "name": "async-imap", "license": "MIT/Apache-2.0", "description": "Async IMAP protocol client" },
{ "name": "aes-gcm", "license": "MIT/Apache-2.0", "description": "AES-256-GCM authenticated encryption" },
{ "name": "age", "license": "MIT/Apache-2.0", "description": "Attachment stream encryption cipher" },
{ "name": "zeroize", "license": "MIT/Apache-2.0", "description": "Secure memory wiping" },
{ "name": "keyring", "license": "MIT/Apache-2.0", "description": "OS key store integration" }
]
}))
}
"nuncio_licenses" => Ok(json!({
"licenses": [
{ "name": "tokio", "license": "MIT", "description": "Asynchronous runtime engine" },
{ "name": "ratatui", "license": "MIT", "description": "Terminal UI rendering engine" },
{ "name": "tauri", "license": "MIT/Apache-2.0", "description": "Desktop WebView shell" },
{ "name": "sqlx", "license": "MIT/Apache-2.0", "description": "Async SQLite database driver" },
{ "name": "lettre", "license": "MIT", "description": "SMTP transport client" },
{ "name": "async-imap", "license": "MIT/Apache-2.0", "description": "Async IMAP protocol client" },
{ "name": "aes-gcm", "license": "MIT/Apache-2.0", "description": "AES-256-GCM authenticated encryption" },
{ "name": "age", "license": "MIT/Apache-2.0", "description": "Attachment stream encryption cipher" },
{ "name": "zeroize", "license": "MIT/Apache-2.0", "description": "Secure memory wiping" },
{ "name": "keyring", "license": "MIT/Apache-2.0", "description": "OS key store integration" }
]
})),
"nuncio_filter_list" => {
if !self.policy.is_data_type_allowed(DataType::FilterRules) || !self.policy.permissions.manage_filters {
return Err(format!("403 Forbidden: Agent '{}' lacks 'manage_filters' permission", self.policy.agent_id));
if !self.policy.is_data_type_allowed(DataType::FilterRules)
|| !self.policy.permissions.manage_filters
{
return Err(format!(
"403 Forbidden: Agent '{}' lacks 'manage_filters' permission",
self.policy.agent_id
));
}
let rules = self.db.list_filter_rules().await.map_err(|e| e.to_string())?;
let rules = self
.db
.list_filter_rules()
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "rules": rules }))
}
"nuncio_filter_create" => {
if !self.policy.is_data_type_allowed(DataType::FilterRules) || !self.policy.permissions.manage_filters {
return Err(format!("403 Forbidden: Agent '{}' lacks 'manage_filters' permission", self.policy.agent_id));
if !self.policy.is_data_type_allowed(DataType::FilterRules)
|| !self.policy.permissions.manage_filters
{
return Err(format!(
"403 Forbidden: Agent '{}' lacks 'manage_filters' permission",
self.policy.agent_id
));
}
let name = args.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
let sql = args.get("sql").and_then(|v| v.as_str()).ok_or("missing sql")?;
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or("missing name")?;
let sql = args
.get("sql")
.and_then(|v| v.as_str())
.ok_or("missing sql")?;
let priority = args.get("priority").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
let rule = nuncio_filter::NsqlParser::parse_rule(name, priority, sql).map_err(|e| e.to_string())?;
let rule = nuncio_filter::NsqlParser::parse_rule(name, priority, sql)
.map_err(|e| e.to_string())?;
let opts = nuncio_filter::ValidationOptions::default();
nuncio_filter::NsqlValidator::validate(&rule, &opts).map_err(|e| e.to_string())?;
self.db.save_filter_rule(&rule).await.map_err(|e| e.to_string())?;
self.db
.save_filter_rule(&rule)
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "status": "created", "rule": rule }))
}
"nuncio_filter_edit" => {
let id = args.get("id").and_then(|v| v.as_str()).ok_or("missing id")?;
let rules = self.db.list_filter_rules().await.map_err(|e| e.to_string())?;
let existing = rules.into_iter().find(|r| r.id == id).ok_or("rule not found")?;
let id = args
.get("id")
.and_then(|v| v.as_str())
.ok_or("missing id")?;
let rules = self
.db
.list_filter_rules()
.await
.map_err(|e| e.to_string())?;
let existing = rules
.into_iter()
.find(|r| r.id == id)
.ok_or("rule not found")?;
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.name);
let sql = args.get("sql").and_then(|v| v.as_str()).unwrap_or(&existing.nsql_text);
let priority = args.get("priority").and_then(|v| v.as_i64()).map(|p| p as i32).unwrap_or(existing.priority);
let name = args
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&existing.name);
let sql = args
.get("sql")
.and_then(|v| v.as_str())
.unwrap_or(&existing.nsql_text);
let priority = args
.get("priority")
.and_then(|v| v.as_i64())
.map(|p| p as i32)
.unwrap_or(existing.priority);
let mut updated = nuncio_filter::NsqlParser::parse_rule(name, priority, sql).map_err(|e| e.to_string())?;
let mut updated = nuncio_filter::NsqlParser::parse_rule(name, priority, sql)
.map_err(|e| e.to_string())?;
updated.id = id.to_string();
let opts = nuncio_filter::ValidationOptions::default();
nuncio_filter::NsqlValidator::validate(&updated, &opts).map_err(|e| e.to_string())?;
self.db.save_filter_rule(&updated).await.map_err(|e| e.to_string())?;
nuncio_filter::NsqlValidator::validate(&updated, &opts)
.map_err(|e| e.to_string())?;
self.db
.save_filter_rule(&updated)
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "status": "updated", "rule": updated }))
}
"nuncio_filter_delete" => {
let id = args.get("id").and_then(|v| v.as_str()).ok_or("missing id")?;
self.db.delete_filter_rule(id).await.map_err(|e| e.to_string())?;
let id = args
.get("id")
.and_then(|v| v.as_str())
.ok_or("missing id")?;
self.db
.delete_filter_rule(id)
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "status": "deleted", "id": id }))
}
"nuncio_filter_test" => {
let sql = args.get("sql").and_then(|v| v.as_str()).ok_or("missing sql")?;
let rule = nuncio_filter::NsqlParser::parse_rule("Test Rule", 0, sql).map_err(|e| e.to_string())?;
let engine = nuncio_filter::FilterEngine::new(vec![rule]).map_err(|e| e.to_string())?;
let sql = args
.get("sql")
.and_then(|v| v.as_str())
.ok_or("missing sql")?;
let rule = nuncio_filter::NsqlParser::parse_rule("Test Rule", 0, sql)
.map_err(|e| e.to_string())?;
let engine =
nuncio_filter::FilterEngine::new(vec![rule]).map_err(|e| e.to_string())?;
let sample_email = Email {
id: "mcp-test-email".to_string(),
account_id: "acct-1".to_string(),
@@ -572,7 +724,11 @@ impl McpToolHandler {
}
"nuncio_filter_logs" => {
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let logs = self.db.list_filter_execution_logs(limit).await.map_err(|e| e.to_string())?;
let logs = self
.db
.list_filter_execution_logs(limit)
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "logs": logs }))
}
"nuncio_update_check" => {
@@ -603,26 +759,53 @@ impl McpToolHandler {
"nuncio_audit_list" => {
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as u32;
let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let records = self.db.list_worm_audit_records(limit, offset).await.map_err(|e| e.to_string())?;
let records = self
.db
.list_worm_audit_records(limit, offset)
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "records": records }))
}
"nuncio_audit_verify" => {
let is_valid = self.db.verify_worm_audit_chain(nuncio_core::DEFAULT_WORM_KEY).await.is_ok();
let is_valid = self.db.verify_worm_audit_chain().await.is_ok();
Ok(json!({ "status": "verified", "chain_integrity_valid": is_valid }))
}
"nuncio_export_data" => {
if !self.policy.is_data_type_allowed(DataType::Mail) || !self.policy.permissions.read_mail {
return Err(format!("403 Forbidden: Agent '{}' lacks 'read_mail' permission for data export", self.policy.agent_id));
if !self.policy.is_data_type_allowed(DataType::Mail)
|| !self.policy.permissions.read_mail
{
return Err(format!(
"403 Forbidden: Agent '{}' lacks 'read_mail' permission for data export",
self.policy.agent_id
));
}
let output_str = args.get("output_path").and_then(|v| v.as_str()).ok_or("missing output_path")?;
let format_str = args.get("format").and_then(|v| v.as_str()).unwrap_or("mbox");
let folder_id = args.get("folder_id").and_then(|v| v.as_str()).unwrap_or("INBOX");
let output_str = args
.get("output_path")
.and_then(|v| v.as_str())
.ok_or("missing output_path")?;
let format_str = args
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("mbox");
let folder_id = args
.get("folder_id")
.and_then(|v| v.as_str())
.unwrap_or("INBOX");
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(1000) as usize;
let format: nuncio_core::ExportFormat = format_str.parse().map_err(|e: String| e)?;
let messages = self.db.list_messages(folder_id, limit).await.map_err(|e| e.to_string())?;
let format: nuncio_core::ExportFormat =
format_str.parse().map_err(|e: String| e)?;
let messages = self
.db
.list_messages(folder_id, limit)
.await
.map_err(|e| e.to_string())?;
let summary = self.db.export_messages_to_file(&messages, format, std::path::Path::new(output_str)).await.map_err(|e| e.to_string())?;
let summary = self
.db
.export_messages_to_file(&messages, format, std::path::Path::new(output_str))
.await
.map_err(|e| e.to_string())?;
Ok(json!({ "status": "exported", "summary": summary }))
}
_ => Err(format!("Unknown tool: {}", name)),
@@ -1,5 +1,7 @@
//! Integration system test suite for nuncio-mcp JSON-RPC protocol server.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_mcp::McpServer;
#[tokio::test]
@@ -33,14 +35,26 @@ async fn system_test_mcp_protocol_initialize_tools_and_resources() {
// 2b. Test Update Check & Apply Tools
let check_req = r#"{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"nuncio_update_check","arguments":{}}}"#;
let resp = server.handle_request_json(check_req).await.expect("update check response");
let resp = server
.handle_request_json(check_req)
.await
.expect("update check response");
assert_eq!(resp["id"], 21);
assert!(resp["result"]["content"][0]["text"].as_str().unwrap().contains("update_available"));
assert!(resp["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("update_available"));
let apply_req = r#"{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"nuncio_update_apply","arguments":{"version":"0.2.0"}}}"#;
let resp = server.handle_request_json(apply_req).await.expect("update apply response");
let resp = server
.handle_request_json(apply_req)
.await
.expect("update apply response");
assert_eq!(resp["id"], 22);
assert!(resp["result"]["content"][0]["text"].as_str().unwrap().contains("update_initiated"));
assert!(resp["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("update_initiated"));
// 3. Tools Call (Create & List Calendar Event)
let create_req = r#"{
@@ -10,11 +10,11 @@ repository.workspace = true
workspace = true
[dependencies]
nuncio-core = { path = "../nuncio-core" }
nuncio-mail = { path = "../nuncio-mail" }
nuncio-cal = { path = "../nuncio-cal" }
nuncio-store = { path = "../nuncio-store" }
nuncio-filter = { path = "../nuncio-filter" }
nuncio-core = { path = "../../crates/nuncio-core" }
nuncio-mail = { path = "../../crates/nuncio-mail" }
nuncio-cal = { path = "../../crates/nuncio-cal" }
nuncio-store = { path = "../../crates/nuncio-store" }
nuncio-filter = { path = "../../crates/nuncio-filter" }
tokio.workspace = true
ratatui.workspace = true
@@ -66,19 +66,25 @@ pub struct TuiApp {
impl TuiApp {
/// Create a new `TuiApp` wrapping an `EventBus`.
pub fn new(event_bus: EventBus) -> Self {
let sample_rule1 = NsqlParser::parse_rule(
// Sample demo rules use fixed, known-valid NSQL text; if parsing ever
// fails (e.g. a future NSQL grammar change), fall back to no sample
// rules rather than panicking.
let mut sample_filter_rules = Vec::new();
if let Ok(sample_rule1) = NsqlParser::parse_rule(
"Priority Boss Filter",
0,
"WHERE (from = 'boss@nuncio.mx' AND size > 1024) ACTION MOVE TO 'Priority', FLAG",
)
.expect("parse sample rule");
) {
sample_filter_rules.push(sample_rule1);
}
let sample_rule2 = NsqlParser::parse_rule(
if let Ok(sample_rule2) = NsqlParser::parse_rule(
"Spam Auto Cleaner",
1,
"WHERE folder IN ('spam', 'junk') ACTION DELETE",
)
.expect("parse sample rule");
) {
sample_filter_rules.push(sample_rule2);
}
Self {
event_bus,
@@ -123,7 +129,7 @@ impl TuiApp {
messages: Vec::new(),
selected_message_idx: 0,
filter_rules: vec![sample_rule1, sample_rule2],
filter_rules: sample_filter_rules,
selected_filter_idx: 0,
filter_editor_mode: FilterEditorSubMode::VisualBuilder,
filter_preview_active: false,
@@ -131,13 +137,18 @@ impl TuiApp {
filter_logs_drawer_open: false,
filter_logs: Vec::new(),
recovery_banner: None,
update_banner: Some("[UPDATE AVAILABLE] Nuncio v0.2.0 is available — Press [u] to Update & Restart".to_string()),
update_banner: Some(
"[UPDATE AVAILABLE] Nuncio v0.2.0 is available — Press [u] to Update & Restart"
.to_string(),
),
}
}
/// Trigger database recovery alert notification on top header menu.
pub fn trigger_recovery_alert(&mut self, backup_path: &str) {
self.recovery_banner = Some(format!("[NOTICE] Database Auto-Healed ({backup_path}) — Resynchronizing Inbox..."));
self.recovery_banner = Some(format!(
"[NOTICE] Database Auto-Healed ({backup_path}) — Resynchronizing Inbox..."
));
}
/// Trigger software update download and application workflow (`[u]`).
@@ -149,23 +160,29 @@ impl TuiApp {
pub fn move_selection_down(&mut self) {
match self.mode {
AppMode::FilterRules => {
if !self.filter_rules.is_empty() && self.selected_filter_idx + 1 < self.filter_rules.len() {
if !self.filter_rules.is_empty()
&& self.selected_filter_idx + 1 < self.filter_rules.len()
{
self.selected_filter_idx += 1;
}
}
AppMode::AccountSettings => {
if !self.accounts.is_empty() && self.selected_account_idx + 1 < self.accounts.len() {
if !self.accounts.is_empty() && self.selected_account_idx + 1 < self.accounts.len()
{
self.selected_account_idx += 1;
}
}
AppMode::MainView => match self.active_pane {
ActivePane::Sidebar => {
if !self.folders.is_empty() && self.selected_folder_idx + 1 < self.folders.len() {
if !self.folders.is_empty() && self.selected_folder_idx + 1 < self.folders.len()
{
self.selected_folder_idx += 1;
}
}
ActivePane::MessageList => {
if !self.messages.is_empty() && self.selected_message_idx + 1 < self.messages.len() {
if !self.messages.is_empty()
&& self.selected_message_idx + 1 < self.messages.len()
{
self.selected_message_idx += 1;
}
}
@@ -208,7 +225,8 @@ impl TuiApp {
/// Re-order rule priority up (`[K]`).
pub fn reorder_filter_priority_up(&mut self) {
if self.mode == AppMode::FilterRules && self.selected_filter_idx > 0 {
self.filter_rules.swap(self.selected_filter_idx, self.selected_filter_idx - 1);
self.filter_rules
.swap(self.selected_filter_idx, self.selected_filter_idx - 1);
self.selected_filter_idx -= 1;
for (idx, rule) in self.filter_rules.iter_mut().enumerate() {
rule.priority = idx as i32;
@@ -218,8 +236,11 @@ impl TuiApp {
/// Re-order rule priority down (`[J]`).
pub fn reorder_filter_priority_down(&mut self) {
if self.mode == AppMode::FilterRules && self.selected_filter_idx + 1 < self.filter_rules.len() {
self.filter_rules.swap(self.selected_filter_idx, self.selected_filter_idx + 1);
if self.mode == AppMode::FilterRules
&& self.selected_filter_idx + 1 < self.filter_rules.len()
{
self.filter_rules
.swap(self.selected_filter_idx, self.selected_filter_idx + 1);
self.selected_filter_idx += 1;
for (idx, rule) in self.filter_rules.iter_mut().enumerate() {
rule.priority = idx as i32;
@@ -324,7 +345,8 @@ impl TuiApp {
Color::Blue,
)
};
let header = Paragraph::new(header_text).style(Style::default().bg(bg_color).fg(Color::Black));
let header =
Paragraph::new(header_text).style(Style::default().bg(bg_color).fg(Color::Black));
frame.render_widget(header, main_chunks[0]);
// Render body area based on AppMode
@@ -341,7 +363,11 @@ impl TuiApp {
.iter()
.enumerate()
.map(|(idx, rule)| {
let prefix = if idx == self.selected_filter_idx { " > " } else { " " };
let prefix = if idx == self.selected_filter_idx {
" > "
} else {
" "
};
ListItem::new(format!("{}[P{}] {}", prefix, rule.priority, rule.name))
})
.collect();
@@ -355,19 +381,34 @@ impl TuiApp {
// Right: Editor & Details Pane
let mut right_text = String::new();
if let Some(rule) = self.filter_rules.get(self.selected_filter_idx) {
right_text.push_str(&format!("Rule ID: {}\nName: {}\nPriority: {}\n\n", rule.id, rule.name, rule.priority));
right_text.push_str(&format!("Active Sub-Mode [s]: {:?}\n\n", self.filter_editor_mode));
right_text.push_str(&format!(
"Rule ID: {}\nName: {}\nPriority: {}\n\n",
rule.id, rule.name, rule.priority
));
right_text.push_str(&format!(
"Active Sub-Mode [s]: {:?}\n\n",
self.filter_editor_mode
));
right_text.push_str(&format!("NSQL Query:\n{}\n\n", rule.nsql_text));
if self.filter_preview_active {
right_text.push_str("── DRY-RUN PREVIEW RESULTS [t] ───────────────────────\n");
right_text
.push_str("── DRY-RUN PREVIEW RESULTS [t] ───────────────────────\n");
if let Some(prev) = &self.filter_preview_result {
right_text.push_str(&format!("Matched: {}\nMatched Rule: {:?}\nActions: {:?}\nTime: {}us\n", prev.matched, prev.matched_rule_name, prev.actions_evaluated, prev.execution_time_us));
right_text.push_str(&format!(
"Matched: {}\nMatched Rule: {:?}\nActions: {:?}\nTime: {}us\n",
prev.matched,
prev.matched_rule_name,
prev.actions_evaluated,
prev.execution_time_us
));
}
}
if self.filter_logs_drawer_open {
right_text.push_str("\n── EXECUTION LOG DRAWER [l] ───────────────────────────\n");
right_text.push_str(
"\n── EXECUTION LOG DRAWER [l] ───────────────────────────\n",
);
right_text.push_str("Log chain verified OK (Genesis hash linked).\n");
}
}
@@ -470,7 +511,10 @@ impl TuiApp {
let sidebar_block = Block::default()
.title(" Folders (20%) ")
.borders(Borders::ALL)
.border_style(AppLayout::border_style(ActivePane::Sidebar, self.active_pane));
.border_style(AppLayout::border_style(
ActivePane::Sidebar,
self.active_pane,
));
let sidebar_list = List::new(sidebar_items).block(sidebar_block);
frame.render_widget(sidebar_list, sidebar_area);
@@ -486,14 +530,18 @@ impl TuiApp {
" "
};
let status = if msg.read { " " } else { "●" };
let text = format!("{} {} {:<15} {}", prefix, status, msg.sender, msg.subject);
let text =
format!("{} {} {:<15} {}", prefix, status, msg.sender, msg.subject);
ListItem::new(text)
})
.collect();
let msg_block = Block::default()
.title(" Messages (35%) ")
.borders(Borders::ALL)
.border_style(AppLayout::border_style(ActivePane::MessageList, self.active_pane));
.border_style(AppLayout::border_style(
ActivePane::MessageList,
self.active_pane,
));
let msg_list = List::new(msg_items).block(msg_block);
frame.render_widget(msg_list, list_area);
@@ -508,7 +556,10 @@ impl TuiApp {
let reader_block = Block::default()
.title(" Email Reader (45%) ")
.borders(Borders::ALL)
.border_style(AppLayout::border_style(ActivePane::Reader, self.active_pane));
.border_style(AppLayout::border_style(
ActivePane::Reader,
self.active_pane,
));
let reader_p = Paragraph::new(body_text).block(reader_block);
frame.render_widget(reader_p, reader_area);
}
@@ -516,7 +567,8 @@ impl TuiApp {
// Footer navigation bar
let footer_text = " [?] Help │ [f] Filter Rules │ [a] Accounts │ [u] Update │ [s] Sync │ [c] Compose │ [r] Reply │ [q] Quit ";
let footer = Paragraph::new(footer_text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
let footer = Paragraph::new(footer_text)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
frame.render_widget(footer, main_chunks[2]);
}
}
File renamed without changes.
@@ -187,24 +187,48 @@ mod tests {
let mut sm = VimLeaderStateMachine::new();
// Single 'g' puts state machine in PendingG
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::None);
assert_eq!(
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
UserAction::None
);
assert_eq!(sm.state(), LeaderState::PendingG);
// 'g' + 'g' -> JumpTop
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::JumpTop);
assert_eq!(
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
UserAction::JumpTop
);
assert_eq!(sm.state(), LeaderState::Idle);
// 'g' + 'i' -> JumpInbox
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::None);
assert_eq!(sm.process_key(make_key(KeyCode::Char('i'), KeyModifiers::NONE)), UserAction::JumpInbox);
assert_eq!(
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
UserAction::None
);
assert_eq!(
sm.process_key(make_key(KeyCode::Char('i'), KeyModifiers::NONE)),
UserAction::JumpInbox
);
// 'g' + 's' -> JumpSent
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::None);
assert_eq!(sm.process_key(make_key(KeyCode::Char('s'), KeyModifiers::NONE)), UserAction::JumpSent);
assert_eq!(
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
UserAction::None
);
assert_eq!(
sm.process_key(make_key(KeyCode::Char('s'), KeyModifiers::NONE)),
UserAction::JumpSent
);
// 'g' + 'a' -> JumpArchive
assert_eq!(sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)), UserAction::None);
assert_eq!(sm.process_key(make_key(KeyCode::Char('a'), KeyModifiers::NONE)), UserAction::JumpArchive);
assert_eq!(
sm.process_key(make_key(KeyCode::Char('g'), KeyModifiers::NONE)),
UserAction::None
);
assert_eq!(
sm.process_key(make_key(KeyCode::Char('a'), KeyModifiers::NONE)),
UserAction::JumpArchive
);
}
#[test]
File renamed without changes.
@@ -1,5 +1,7 @@
//! Nuncio Terminal UI library.
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod app;
pub mod html;
pub mod keybindings;
File renamed without changes.
@@ -1,5 +1,7 @@
//! E2E System Test Suite for nuncio-tui.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_core::EventBus;
use nuncio_tui::{ActivePane, AppMode, TuiApp};
use ratatui::backend::TestBackend;
@@ -2,6 +2,8 @@
//! Renders Ratatui interface onto TestBackend, simulates keyboard navigation,
//! and verifies terminal buffer visual text snapshot representations.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_core::{CoreEvent, EventBus};
use nuncio_tui::{ActivePane, AppMode, TuiApp};
use ratatui::backend::TestBackend;
@@ -35,7 +37,9 @@ async fn automated_tui_visual_and_keyboard_interaction_matrix() {
// ------------------------------------------------------------------------
// Step 1: Initial Render & Visual Snapshot Verification (Main 3-Pane View)
// ------------------------------------------------------------------------
terminal.draw(|f| app.render_frame(f)).expect("render frame");
terminal
.draw(|f| app.render_frame(f))
.expect("render frame");
let snapshot_main = buffer_to_snapshot(terminal.backend().buffer());
// Verify brand header and main pane borders exist in visual snapshot
@@ -49,27 +53,38 @@ async fn automated_tui_visual_and_keyboard_interaction_matrix() {
// Simulate Tab / 'l' to focus Message List
app.set_active_pane(ActivePane::MessageList);
terminal.draw(|f| app.render_frame(f)).expect("render list frame");
terminal
.draw(|f| app.render_frame(f))
.expect("render list frame");
assert_eq!(app.active_pane(), ActivePane::MessageList);
// Simulate Tab / 'l' to focus Reader
app.set_active_pane(ActivePane::Reader);
terminal.draw(|f| app.render_frame(f)).expect("render reader frame");
terminal
.draw(|f| app.render_frame(f))
.expect("render reader frame");
assert_eq!(app.active_pane(), ActivePane::Reader);
// ------------------------------------------------------------------------
// Step 3: Event Stream Interaction - CoreEvent::SyncStarted Push Update
// ------------------------------------------------------------------------
let evt = core_events_rx.recv().await.expect("receive published core event");
let evt = core_events_rx
.recv()
.await
.expect("receive published core event");
assert!(matches!(evt, CoreEvent::SyncStarted { .. }));
terminal.draw(|f| app.render_frame(f)).expect("render syncing frame");
terminal
.draw(|f| app.render_frame(f))
.expect("render syncing frame");
// ------------------------------------------------------------------------
// Step 4: Modal Navigation - Help Overlay Visual Snapshot
// ------------------------------------------------------------------------
app.set_mode(AppMode::HelpModal);
terminal.draw(|f| app.render_frame(f)).expect("render help modal");
terminal
.draw(|f| app.render_frame(f))
.expect("render help modal");
let snapshot_help = buffer_to_snapshot(terminal.backend().buffer());
assert!(snapshot_help.contains("NUNCIO TUI KEYBINDINGS HELP"));
@@ -80,7 +95,9 @@ async fn automated_tui_visual_and_keyboard_interaction_matrix() {
// Step 5: Modal Navigation - Account Settings Visual Snapshot
// ------------------------------------------------------------------------
app.set_mode(AppMode::AccountSettings);
terminal.draw(|f| app.render_frame(f)).expect("render account settings");
terminal
.draw(|f| app.render_frame(f))
.expect("render account settings");
let snapshot_accounts = buffer_to_snapshot(terminal.backend().buffer());
assert!(snapshot_accounts.contains("Account Settings"));
@@ -90,7 +107,9 @@ async fn automated_tui_visual_and_keyboard_interaction_matrix() {
// Step 6: Modal Navigation - Splash Screen Visual Snapshot
// ------------------------------------------------------------------------
app.set_mode(AppMode::SplashScreen);
terminal.draw(|f| app.render_frame(f)).expect("render splash screen");
terminal
.draw(|f| app.render_frame(f))
.expect("render splash screen");
let snapshot_splash = buffer_to_snapshot(terminal.backend().buffer());
assert!(snapshot_splash.contains("Welcome to Nuncio"));
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="DESCRIPTION" style="width:920px;height:359px;background:#0B0F19;" width="920px" height="359px" viewBox="0 0 920 359" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="920" height="359" fill="#0B0F19" style="stroke:none;"/><!--entity User--><g class="entity" data-qualified-name="User" id="ent0001" data-source-line="5"><ellipse cx="404.282" cy="14" rx="8" ry="8" fill="#38BDF8" style="stroke:#181818;stroke-width:0.5;"/><path d="M404.282,22 L404.282,49 M391.282,30 L417.282,30 M404.282,49 L391.282,64 M404.282,49 L417.282,64" style="stroke:#181818;stroke-width:0.5;" fill="none"/><text x="351.72" y="78.495" fill="#FFF" font-size="14" textLength="105.123" font-family="'Arial'">User / AI Agent</text></g><!--entity Daemon--><g class="entity" data-qualified-name="Daemon" id="ent0002" data-source-line="6"><rect x="290.56" y="158.8" width="227.437" height="36.297" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><text x="300.56" y="181.795" fill="#FFF" font-size="14" textLength="207.437" font-family="'Arial'">nunciod Background Daemon</text></g><!--entity DB--><g class="entity" data-qualified-name="DB" id="ent0003" data-source-line="7"><path d="M6,282.6 C6,272.6 107.277,272.6 107.277,272.6 C107.277,272.6 208.554,272.6 208.554,282.6 L208.554,324.194 C208.554,334.194 107.277,334.194 107.277,334.194 C107.277,334.194 6,334.194 6,324.194 L6,282.6" style="stroke:#181818;stroke-width:0.5;" fill="#6366F1"/><path d="M6,282.6 C6,292.6 107.277,292.6 107.277,292.6 C107.277,292.6 208.554,292.6 208.554,282.6" style="stroke:#181818;stroke-width:0.5;" fill="none"/><text x="16" y="309.595" fill="#FFF" font-size="14" textLength="157.931" font-family="'Arial'">Local SQLite Database</text><text x="16" y="325.892" fill="#FFF" font-size="14" textLength="182.554" font-family="'Arial'">(/.nuncio/nuncio_main.db)</text></g><!--entity Vault--><g class="entity" data-qualified-name="Vault" id="ent0004" data-source-line="8"><rect x="280.92" y="277.1" width="246.714" height="52.594" fill="#EAB308" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><text x="290.92" y="300.095" fill="#FFF" font-size="14" textLength="137.074" font-family="'Arial'">OS Keyring Enclave</text><text x="290.92" y="316.392" fill="#FFF" font-size="14" textLength="226.714" font-family="'Arial'">(Keychain / Credential Manager)</text></g><!--entity Remote--><g class="entity" data-qualified-name="Remote" id="ent0005" data-source-line="9"><path d="M571.492,281.804 C578.119,273.441 584.377,272.583 590.187,282.634 C594.968,273.896 601.415,274.139 607.011,281.798 C612.791,274.234 619.571,274.444 624.821,282.406 C629.126,274.62 636.179,272.419 641.947,280.862 C645.761,271.18 654.596,271.878 659.535,279.734 C665.1,271.517 672.058,272.701 675.605,281.626 C681.233,273.216 687.605,272.628 693.797,281.016 C698.606,273.845 703.259,274.186 708.731,280.267 C714.245,271.582 722.208,272.953 726.635,281.402 C731.461,270.438 739.487,272.418 745.811,279.839 C751.948,272.489 757.319,272.532 761.632,281.598 C767.362,271.955 775.305,272.505 780.48,282.14 C784.901,273.296 791.424,274.726 796.527,281.435 C801.752,273.079 809.015,272.086 814.773,280.867 C830.908,282.654 832.462,293.298 824.768,304.968 C832.439,315.774 830.606,327.249 815.14,328.648 C808.793,337.28 803.06,338.746 796.192,329.192 C793.097,338.574 784.955,339.823 779.198,331.788 C773.613,338.846 766.739,339.154 762.854,330.068 C758.084,339.986 752.781,340.817 745.495,332.458 C740.325,342.87 734.382,341.602 726.894,334.992 C722.077,344.216 716.069,342.917 710.345,335.918 C704.38,343.524 698.726,342.487 693.431,335.159 C688.008,344.711 678.478,344.615 674.172,334.087 C668.726,343.006 661.289,341.972 656.814,333.138 C651.984,339.66 644.233,340.314 640.694,331.758 C633.855,340.842 627.987,338.902 622.177,330.659 C617.754,337.617 609.302,338.841 604.895,330.64 C600.018,337.788 593.787,338.977 589.237,330.316 C584.042,336.955 577.173,337.423 572.903,329.293 C558.668,326.857 552.815,319.336 560.093,305.378 C550.269,293.162 558.525,282.999 571.492,281.804" style="stroke:#181818;stroke-width:0.5;" fill="#EC4899"/><text x="577.3" y="300.095" fill="#FFF" font-size="14" textLength="227.958" font-family="'Arial'">Remote Mail &amp; Calendar Servers</text><text x="577.3" y="316.392" fill="#FFF" font-size="14" textLength="183.668" font-family="'Arial'">(IMAP/SMTP/JMAP/CalDAV)</text></g><!--link User to Daemon--><g class="link" data-entity-1="ent0001" data-entity-2="ent0002" id="lnk6" data-source-line="11" data-link-type="dependency"><path d="M404.28,87.28 C404.28,112.28 404.28,133.97 404.28,153.33" style="stroke:#181818;stroke-width:1;" fill="none" id="User-Daemon"/><polygon points="404.28,82.28,400.28,91.28,404.28,87.28,408.28,91.28,404.28,82.28" fill="#181818" style="strokeLine truncated

Before

Width:  |  Height:  |  Size: 8.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="SEQUENCE" style="width:1209px;height:424px;background:#0B0F19;" width="1209px" height="424px" viewBox="0 0 1209 424" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="1209" height="424" fill="#0B0F19" style="stroke:none;"/><g><title>Interface .CLI.TUI.GUI.MCP.</title><rect x="105.678" y="86.297" width="8" height="251.063" fill="#000" fill-opacity="0"/><line x1="109.178" y1="86.297" x2="109.178" y2="337.359" style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;"/></g><g><title>nunciod Daemon</title><rect x="502.108" y="86.297" width="8" height="251.063" fill="#000" fill-opacity="0"/><line x1="505.608" y1="86.297" x2="505.608" y2="337.359" style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;"/></g><g><title>SQLite WAL Storage</title><rect x="923.173" y="86.297" width="8" height="251.063" fill="#000" fill-opacity="0"/><line x1="926.673" y1="86.297" x2="926.673" y2="337.359" style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;"/></g><g><title>Remote IMAP.JMAP Server</title><rect x="1100.785" y="86.297" width="8" height="251.063" fill="#000" fill-opacity="0"/><line x1="1104.285" y1="86.297" x2="1104.285" y2="337.359" style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;"/></g><text x="10" y="82.995" fill="#FFF" font-size="14" textLength="192.356" font-family="'Arial'">Interface (CLI/TUI/GUI/MCP)</text><ellipse cx="109.178" cy="18.5" rx="8" ry="8" fill="#38BDF8" style="stroke:#181818;stroke-width:0.5;"/><path d="M109.178,26.5 L109.178,53.5 M96.178,34.5 L122.178,34.5 M109.178,53.5 L96.178,68.5 M109.178,53.5 L122.178,68.5" style="stroke:#181818;stroke-width:0.5;" fill="none"/><rect x="439.029" y="55" width="133.157" height="30.297" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><text x="446.029" y="74.995" fill="#FFF" font-size="14" textLength="119.157" font-family="'Arial'">nunciod Daemon</text><text x="853.375" y="82.995" fill="#FFF" font-size="14" textLength="140.595" font-family="'Arial'">SQLite WAL Storage</text><path d="M908.673,34 C908.673,24 926.673,24 926.673,24 C926.673,24 944.673,24 944.673,34 L944.673,60 C944.673,70 926.673,70 926.673,70 C926.673,70 908.673,70 908.673,60 L908.673,34" style="stroke:#181818;stroke-width:0.5;" fill="#6366F1"/><path d="M908.673,34 C908.673,44 926.673,44 926.673,44 C926.673,44 944.673,44 944.673,34" style="stroke:#181818;stroke-width:0.5;" fill="none"/><text x="1009.97" y="82.995" fill="#FFF" font-size="14" textLength="182.629" font-family="'Arial'">Remote IMAP/JMAP Server</text><ellipse cx="1104.285" cy="54" rx="12" ry="12" fill="#EC4899" style="stroke:#181818;stroke-width:0.5;"/><line x1="1092.285" y1="68" x2="1116.285" y2="68" style="stroke:#181818;stroke-width:0.5;"/><text x="10" y="350.354" fill="#FFF" font-size="14" textLength="192.356" font-family="'Arial'">Interface (CLI/TUI/GUI/MCP)</text><ellipse cx="109.178" cy="362.156" rx="8" ry="8" fill="#38BDF8" style="stroke:#181818;stroke-width:0.5;"/><path d="M109.178,370.156 L109.178,397.156 M96.178,378.156 L122.178,378.156 M109.178,397.156 L96.178,412.156 M109.178,397.156 L122.178,412.156" style="stroke:#181818;stroke-width:0.5;" fill="none"/><rect x="439.029" y="337.359" width="133.157" height="30.297" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><text x="446.029" y="357.354" fill="#FFF" font-size="14" textLength="119.157" font-family="'Arial'">nunciod Daemon</text><text x="853.375" y="350.354" fill="#FFF" font-size="14" textLength="140.595" font-family="'Arial'">SQLite WAL Storage</text><path d="M908.673,363.656 C908.673,353.656 926.673,353.656 926.673,353.656 C926.673,353.656 944.673,353.656 944.673,363.656 L944.673,389.656 C944.673,399.656 926.673,399.656 926.673,399.656 C926.673,399.656 908.673,399.656 908.673,389.656 L908.673,363.656" style="stroke:#181818;stroke-width:0.5;" fill="#6366F1"/><path d="M908.673,363.656 C908.673,373.656 926.673,373.656 926.673,373.656 C926.673,373.656 944.673,373.656 944.673,363.656" style="stroke:#181818;stroke-width:0.5;" fill="none"/><text x="1009.97" y="350.354" fill="#FFF" font-size="14" textLength="182.629" font-family="'Arial'">Remote IMAP/JMAP Server</text><ellipse cx="1104.285" cy="369.656" rx="12" ry="12" fill="#EC4899" style="stroke:#181818;stroke-width:0.5;"/><line x1="1092.285" y1="383.656" x2="1116.285" y2="383.656" style="stroke:#181818;stroke-width:0.5;"/><polygon points="493.608,111.43,503.608,115.43,493.608,119.43,497.608,115.43" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line x1="109.178" y1="115.43" x2="499.608" y2="115.43" style="stroke:#181818;stroke-width:1;"/><text x="230.736" y="110.364" fill="#FFF" font-size="13" textLength="153.315" font-family="'Arial'">Connect Socket StLine truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="ACTIVITY" style="width:345px;height:693px;background:#0B0F19;" width="345px" height="693px" viewBox="0 0 345 693" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="345" height="693" fill="#0B0F19" style="stroke:none;"/><g class="start_entity" data-qualified-name="start" id="ent0001" data-source-line="5"><ellipse cx="169.45" cy="16" rx="10" ry="10" fill="#222" style="stroke:#222;stroke-width:1;"/></g><rect x="83.36" y="66" width="172.18" height="33.969" fill="#0F172A" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="93.36" y="87.139" fill="#FFF" font-size="12" textLength="152.18" font-family="'Arial'">NSQL Source Code String</text><rect x="68.59" y="139.97" width="201.717" height="33.969" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="78.59" y="161.109" fill="#FFF" font-size="12" textLength="181.717" font-family="'Arial'">NsqlParser::split_on_account()</text><rect x="7" y="213.94" width="324.904" height="33.969" fill="#312E81" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="17" y="235.079" fill="#FFF" font-size="12" textLength="304.904" font-family="'Arial'">Extract Target Account (*, %, or '%@domain.com')</text><rect x="61.38" y="287.91" width="216.148" height="33.969" fill="#065F46" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="71.38" y="309.049" fill="#FFF" font-size="12" textLength="196.148" font-family="'Arial'">sqlparser::Parser (SQLiteDialect)</text><rect x="24.46" y="361.88" width="289.982" height="33.969" fill="#065F46" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="34.46" y="383.019" fill="#FFF" font-size="12" textLength="269.982" font-family="'Arial'">AST Transformation (Expr -> ConditionNode)</text><rect x="48.96" y="435.84" width="240.975" height="33.969" fill="#EAB308" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="58.96" y="456.979" fill="#FFF" font-size="12" textLength="220.975" font-family="'Arial'">NsqlValidator (6-Pass Security Audit)</text><rect x="18.1" y="509.81" width="302.697" height="33.969" fill="#2563EB" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="28.1" y="530.949" fill="#FFF" font-size="12" textLength="282.697" font-family="'Arial'">ArcSwap&lt;CompiledFilterSet> Lock-Free Cache</text><rect x="41.79" y="583.78" width="255.33" height="33.969" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="51.79" y="604.919" fill="#FFF" font-size="12" textLength="235.33" font-family="'Arial'">Pre-Ingestion Sync Execution (&lt;0.2ms)</text><g class="end_entity" data-qualified-name="end" id="ent0018" data-source-line="13"><ellipse cx="169.45" cy="668.75" rx="11" ry="11" fill="none" style="stroke:#222;stroke-width:1.5;"/><ellipse cx="169.45" cy="668.75" rx="6" ry="6" fill="#222" style="stroke:#222;stroke-width:1;"/></g><!--link start to NSQL Source Code String--><g class="link" data-entity-1="ent0001" data-entity-2="ent0002" id="lnk3" data-source-line="5" data-link-type="dependency"><path d="M169.45,26.31 C169.45,36.56 169.45,48.23 169.45,60.66" style="stroke:#181818;stroke-width:1;" fill="none" id="start-to-NSQL Source Code String"/><polygon points="169.45,65.66,173.45,56.66,169.45,60.66,165.45,56.66,169.45,65.66" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link NSQL Source Code String to NsqlParser::split_on_account()--><g class="link" data-entity-1="ent0002" data-entity-2="ent0004" id="lnk5" data-source-line="6" data-link-type="dependency"><path d="M169.45,100.32 C169.45,112.09 169.45,122.92 169.45,134.69" style="stroke:#181818;stroke-width:1;" fill="none" id="NSQL Source Code String-to-NsqlParser::split_on_account()"/><polygon points="169.45,139.69,173.45,130.69,169.45,134.69,165.45,130.69,169.45,139.69" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link NsqlParser::split_on_account() to Extract Target Account (*, %, or '%@domain.com')--><g class="link" data-entity-1="ent0004" data-entity-2="ent0006" id="lnk7" data-source-line="7" data-link-type="dependency"><path d="M169.45,174.28 C169.45,186.06 169.45,196.89 169.45,208.66" style="stroke:#181818;stroke-width:1;" fill="none" id="NsqlParser::split_on_account()-to-Extract Target Account (*, %, or '%@domain.com')"/><polygon points="169.45,213.66,173.45,204.66,169.45,208.66,165.45,204.66,169.45,213.66" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link Extract Target Account (*, %, or '%@domain.com') to sqlparser::Parser (SQLiteDialect)--><g class="link" data-entity-1="ent0006" data-entity-2="ent0008" id="lnkLine truncated
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="ACTIVITY" style="width:345px;height:693px;background:#0B0F19;" width="345px" height="693px" viewBox="0 0 345 693" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="345" height="693" fill="#0B0F19" style="stroke:none;"/><g class="start_entity" data-qualified-name="start" id="ent0001" data-source-line="5"><ellipse cx="169.45" cy="16" rx="10" ry="10" fill="#222" style="stroke:#222;stroke-width:1;"/></g><rect x="83.36" y="66" width="172.18" height="33.969" fill="#0F172A" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="93.36" y="87.139" fill="#FFF" font-size="12" textLength="152.18" font-family="'Arial'">NSQL Source Code String</text><rect x="68.59" y="139.97" width="201.717" height="33.969" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="78.59" y="161.109" fill="#FFF" font-size="12" textLength="181.717" font-family="'Arial'">NsqlParser::split_on_account()</text><rect x="7" y="213.94" width="324.904" height="33.969" fill="#312E81" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="17" y="235.079" fill="#FFF" font-size="12" textLength="304.904" font-family="'Arial'">Extract Target Account (*, %, or '%@domain.com')</text><rect x="61.38" y="287.91" width="216.148" height="33.969" fill="#065F46" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="71.38" y="309.049" fill="#FFF" font-size="12" textLength="196.148" font-family="'Arial'">sqlparser::Parser (SQLiteDialect)</text><rect x="24.46" y="361.88" width="289.982" height="33.969" fill="#065F46" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="34.46" y="383.019" fill="#FFF" font-size="12" textLength="269.982" font-family="'Arial'">AST Transformation (Expr -> ConditionNode)</text><rect x="48.96" y="435.84" width="240.975" height="33.969" fill="#EAB308" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="58.96" y="456.979" fill="#FFF" font-size="12" textLength="220.975" font-family="'Arial'">NsqlValidator (6-Pass Security Audit)</text><rect x="18.1" y="509.81" width="302.697" height="33.969" fill="#2563EB" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="28.1" y="530.949" fill="#FFF" font-size="12" textLength="282.697" font-family="'Arial'">ArcSwap&lt;CompiledFilterSet> Lock-Free Cache</text><rect x="41.79" y="583.78" width="255.33" height="33.969" fill="#059669" style="stroke:#181818;stroke-width:0.5;" rx="12.5" ry="12.5"/><text x="51.79" y="604.919" fill="#FFF" font-size="12" textLength="235.33" font-family="'Arial'">Pre-Ingestion Sync Execution</text><g class="end_entity" data-qualified-name="end" id="ent0018" data-source-line="13"><ellipse cx="169.45" cy="668.75" rx="11" ry="11" fill="none" style="stroke:#222;stroke-width:1.5;"/><ellipse cx="169.45" cy="668.75" rx="6" ry="6" fill="#222" style="stroke:#222;stroke-width:1;"/></g><!--link start to NSQL Source Code String--><g class="link" data-entity-1="ent0001" data-entity-2="ent0002" id="lnk3" data-source-line="5" data-link-type="dependency"><path d="M169.45,26.31 C169.45,36.56 169.45,48.23 169.45,60.66" style="stroke:#181818;stroke-width:1;" fill="none" id="start-to-NSQL Source Code String"/><polygon points="169.45,65.66,173.45,56.66,169.45,60.66,165.45,56.66,169.45,65.66" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link NSQL Source Code String to NsqlParser::split_on_account()--><g class="link" data-entity-1="ent0002" data-entity-2="ent0004" id="lnk5" data-source-line="6" data-link-type="dependency"><path d="M169.45,100.32 C169.45,112.09 169.45,122.92 169.45,134.69" style="stroke:#181818;stroke-width:1;" fill="none" id="NSQL Source Code String-to-NsqlParser::split_on_account()"/><polygon points="169.45,139.69,173.45,130.69,169.45,134.69,165.45,130.69,169.45,139.69" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link NsqlParser::split_on_account() to Extract Target Account (*, %, or '%@domain.com')--><g class="link" data-entity-1="ent0004" data-entity-2="ent0006" id="lnk7" data-source-line="7" data-link-type="dependency"><path d="M169.45,174.28 C169.45,186.06 169.45,196.89 169.45,208.66" style="stroke:#181818;stroke-width:1;" fill="none" id="NsqlParser::split_on_account()-to-Extract Target Account (*, %, or '%@domain.com')"/><polygon points="169.45,213.66,173.45,204.66,169.45,208.66,165.45,204.66,169.45,213.66" fill="#181818" style="stroke:#181818;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/></g><!--link Extract Target Account (*, %, or '%@domain.com') to sqlparser::Parser (SQLiteDialect)--><g class="link" data-entity-1="ent0006" data-entity-2="ent0008" id="lnk9" data-sourLine truncated

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" data-diagram-type="DESCRIPTION" style="width:987px;height:589px;background:#0B0F19;" width="987px" height="589px" viewBox="0 0 987 589" zoomAndPan="magnify" preserveAspectRatio="none" contentStyleType="text/css"><?plantuml 1.2026.7beta8?><defs/><g font-family="sans-serif" lengthAdjust="spacing"><rect x="0" y="0" width="987" height="589" fill="#0B0F19" style="stroke:none;"/><!--cluster Four Great Interfaces--><g class="cluster" data-qualified-name="Four Great Interfaces" id="ent0001" data-source-line="6"><path d="M65.5,6 L236.264,6 A3.75,3.75 0 0 1 238.764,8.5 L245.764,28.297 L969.5,28.297 A2.5,2.5 0 0 1 972,30.797 L972,117.09 A2.5,2.5 0 0 1 969.5,119.59 L65.5,119.59 A2.5,2.5 0 0 1 63,117.09 L63,8.5 A2.5,2.5 0 0 1 65.5,6" style="stroke:#000;stroke-width:1.5;" fill="#0F172A"/><line x1="63" y1="28.297" x2="245.764" y2="28.297" style="stroke:#000;stroke-width:1.5;"/><text x="67" y="20.995" fill="#FFF" font-size="14" textLength="169.764" font-weight="700" font-family="'Arial'">Four Great Interfaces</text></g><!--cluster IPC Transport Layer--><g class="cluster" data-qualified-name="IPC Transport Layer" id="ent0006" data-source-line="13"><path d="M277.5,143.59 L435.323,143.59 A3.75,3.75 0 0 1 437.823,146.09 L444.823,165.887 L626.5,165.887 A2.5,2.5 0 0 1 629,168.387 L629,344.69 A2.5,2.5 0 0 1 626.5,347.19 L277.5,347.19 A2.5,2.5 0 0 1 275,344.69 L275,146.09 A2.5,2.5 0 0 1 277.5,143.59" style="stroke:#000;stroke-width:1.5;" fill="#1E1B4B"/><line x1="275" y1="165.887" x2="444.823" y2="165.887" style="stroke:#000;stroke-width:1.5;"/><text x="279" y="158.585" fill="#FFF" font-size="14" textLength="156.823" font-weight="700" font-family="'Arial'">IPC Transport Layer</text></g><!--cluster nunciod Background Daemon--><g class="cluster" data-qualified-name="nunciod Background Daemon" id="ent0009" data-source-line="18"><path d="M8.5,371.19 L240.849,371.19 A3.75,3.75 0 0 1 243.349,373.69 L250.349,393.487 L817.5,393.487 A2.5,2.5 0 0 1 820,395.987 L820,572.28 A2.5,2.5 0 0 1 817.5,574.78 L8.5,574.78 A2.5,2.5 0 0 1 6,572.28 L6,373.69 A2.5,2.5 0 0 1 8.5,371.19" style="stroke:#000;stroke-width:1.5;" fill="#064E3B"/><line x1="6" y1="393.487" x2="250.349" y2="393.487" style="stroke:#000;stroke-width:1.5;"/><text x="10" y="386.185" fill="#FFF" font-size="14" textLength="231.349" font-weight="700" font-family="'Arial'">nunciod Background Daemon</text></g><!--entity CLI--><g class="entity" data-qualified-name="Four Great Interfaces.CLI" id="ent0002" data-source-line="7"><rect x="79.39" y="41" width="201.219" height="62.594" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><rect x="260.609" y="46" width="15" height="10" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="258.609" y="48" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="258.609" y="52" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><text x="94.39" y="73.995" fill="#FFF" font-size="14" textLength="85.107" font-family="'Arial'">1. nuncio-cli</text><text x="94.39" y="90.292" fill="#FFF" font-size="14" textLength="161.219" font-family="'Arial'">(POSIX CLI &amp; Scripting)</text></g><!--entity TUI--><g class="entity" data-qualified-name="Four Great Interfaces.TUI" id="ent0003" data-source-line="8"><rect x="315.76" y="41" width="160.483" height="62.594" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><rect x="456.243" y="46" width="15" height="10" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="454.243" y="48" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="454.243" y="52" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><text x="330.76" y="73.995" fill="#FFF" font-size="14" textLength="87.883" font-family="'Arial'">2. nuncio-tui</text><text x="330.76" y="90.292" fill="#FFF" font-size="14" textLength="120.483" font-family="'Arial'">(Vim Ratatui TUI)</text></g><!--entity GUI--><g class="entity" data-qualified-name="Four Great Interfaces.GUI" id="ent0004" data-source-line="9"><rect x="510.96" y="41" width="200.077" height="62.594" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;" rx="2.5" ry="2.5"/><rect x="691.037" y="46" width="15" height="10" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="689.037" y="48" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><rect x="689.037" y="52" width="4" height="2" fill="#1E293B" style="stroke:#181818;stroke-width:0.5;"/><text x="525.96" y="73.995" fill="#FFF" font-size="14" textLength="91.28" font-family="'Arial'">3. nuncio-gui</text><text x="525.96" y="90.292" fill="#FFF" font-size="14" textLength="160.077" font-family="'Arial'">(Tauri v2 Desktop GUI)</text></g><!--entity MCP--><g class="entity" data-qualified-name="Four Great Interfaces.MCP" id="ent0005" data-source-line="10"Line truncated

Before

Width:  |  Height:  |  Size: 16 KiB

+24
View File
@@ -0,0 +1,24 @@
//! Protocol-agnostic async calendar backend trait definitions.
use async_trait::async_trait;
use nuncio_core::model::CalendarEvent;
use crate::parser::CalendarError;
/// Protocol-agnostic calendar backend engine trait implemented by CalDAV and mock engines.
///
/// Mirrors `nuncio_mail::MailBackend`'s shape: `Send + Sync` so implementations can be held
/// behind an `Arc` and shared across daemon tasks, and returning the crate's single
/// [`CalendarError`] type so callers do not need to know whether a given implementation talks
/// to a real server or an in-memory fixture.
#[async_trait]
pub trait CalendarBackend: Send + Sync {
/// Fetch events belonging to `calendar_id` whose window overlaps
/// `[start_window, end_window]` (inclusive, unix seconds).
async fn fetch_events(
&self,
calendar_id: &str,
start_window: i64,
end_window: i64,
) -> Result<Vec<CalendarEvent>, CalendarError>;
}
+125 -7
View File
@@ -1,19 +1,46 @@
//! CalDAV (RFC 4791) / WebDAV REPORT client and XML query generator.
use async_trait::async_trait;
use nuncio_core::model::CalendarEvent;
use serde::{Deserialize, Serialize};
use crate::backend::CalendarBackend;
use crate::parser::{CalendarError, IcalParserAdapter};
/// CalDAV client protocol engine managing WebDAV REPORT queries.
/// Configuration for a specific CalDAV calendar collection endpoint, mirroring
/// `nuncio_contacts::CardDavAccountConfig`.
///
/// `caldav_url` must already resolve to a specific calendar collection (e.g.
/// `https://caldav.example.com/dav/calendars/user/jmaes/work/`) -- PROPFIND-based
/// `calendar-home-set` auto-discovery is out of scope for this client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalDavAccountConfig {
/// Nuncio account identifier this calendar collection belongs to.
pub account_id: String,
/// Fully-qualified URL of the CalDAV calendar collection to query.
pub caldav_url: String,
/// Basic-auth username (or app-specific username, per provider).
pub username: String,
/// Basic-auth secret (password or app-specific token) resolved from the OS keyring by the
/// caller -- never stored anywhere else in plaintext.
pub auth_token: String,
}
/// CalDAV client protocol engine managing WebDAV REPORT queries against a real server.
pub struct CalDavClient {
account_id: String,
config: CalDavAccountConfig,
http: reqwest::Client,
}
impl CalDavClient {
/// Create a new `CalDavClient` bound to an account ID.
pub fn new(account_id: &str) -> Self {
/// Create a new `CalDavClient` bound to a specific CalDAV calendar collection.
pub fn new(config: CalDavAccountConfig) -> Self {
Self {
account_id: account_id.to_string(),
http: reqwest::Client::builder()
.user_agent("Nuncio-Calendar-CalDAV/1.0")
.build()
.unwrap_or_default(),
config,
}
}
@@ -54,7 +81,7 @@ impl CalDavClient {
let event_id = format!("caldav-evt-{}", events.len() + 1);
if let Ok(event) = IcalParserAdapter::parse_ical(
&event_id,
&self.account_id,
&self.config.account_id,
calendar_id,
clean_ics,
) {
@@ -66,12 +93,96 @@ impl CalDavClient {
Ok(events)
}
/// Format a unix timestamp as the `YYYYMMDDTHHMMSSZ` form RFC 4791's
/// `<c:time-range>` filter expects.
fn format_query_timestamp(unix_seconds: i64) -> Result<String, CalendarError> {
chrono::DateTime::from_timestamp(unix_seconds, 0)
.map(|dt| dt.format("%Y%m%dT%H%M%SZ").to_string())
.ok_or_else(|| {
CalendarError::ParseFailed(format!(
"invalid unix timestamp for CalDAV time-range window: {unix_seconds}"
))
})
}
/// Issue a live CalDAV `REPORT` calendar-query (RFC 4791 Section 7.8) against
/// `self.config.caldav_url` and parse the `multistatus` response into domain events.
///
/// This performs a real network request -- no canned or fabricated data is ever
/// returned. A server or network failure surfaces as [`CalendarError::TransportFailed`];
/// a malformed TZID inside a returned VEVENT surfaces as
/// [`CalendarError::UnresolvableTimezone`] via [`IcalParserAdapter::parse_ical`].
pub async fn fetch_remote_events(
&self,
calendar_id: &str,
start_window: i64,
end_window: i64,
) -> Result<Vec<CalendarEvent>, CalendarError> {
let start_iso = Self::format_query_timestamp(start_window)?;
let end_iso = Self::format_query_timestamp(end_window)?;
let body = Self::build_report_query(&start_iso, &end_iso);
// "REPORT" is a fixed, always-valid HTTP token; `from_bytes` cannot fail for it, but
// the error is still propagated rather than unwrapped so no code path here can panic.
let report_method = reqwest::Method::from_bytes(b"REPORT")
.map_err(|e| CalendarError::TransportFailed(format!("invalid HTTP method: {e}")))?;
let response = self
.http
.request(report_method, &self.config.caldav_url)
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", "1")
.basic_auth(&self.config.username, Some(&self.config.auth_token))
.body(body)
.send()
.await
.map_err(|e| CalendarError::TransportFailed(e.to_string()))?;
let status = response.status();
// RFC 4791 REPORT responses are conventionally 207 Multi-Status; some servers reply
// 200 OK for a single-collection result, so any 2xx/207 status is accepted.
if status.as_u16() != 207 && !status.is_success() {
return Err(CalendarError::TransportFailed(format!(
"CalDAV server returned unexpected status {status}"
)));
}
let raw_xml = response
.text()
.await
.map_err(|e| CalendarError::TransportFailed(e.to_string()))?;
self.parse_multistatus_response(calendar_id, &raw_xml)
}
}
#[async_trait]
impl CalendarBackend for CalDavClient {
async fn fetch_events(
&self,
calendar_id: &str,
start_window: i64,
end_window: i64,
) -> Result<Vec<CalendarEvent>, CalendarError> {
self.fetch_remote_events(calendar_id, start_window, end_window)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> CalDavAccountConfig {
CalDavAccountConfig {
account_id: "acct-1".to_string(),
caldav_url: "https://caldav.example.com/calendars/work/".to_string(),
username: "jmaes".to_string(),
auth_token: "app-token-secret".to_string(),
}
}
#[test]
fn build_report_query_format() {
let query = CalDavClient::build_report_query("20240101T000000Z", "20240201T000000Z");
@@ -82,7 +193,7 @@ mod tests {
#[test]
fn parse_multistatus_response_extracts_events() {
let client = CalDavClient::new("acct-1");
let client = CalDavClient::new(test_config());
let xml_response = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
@@ -109,4 +220,11 @@ END:VCALENDAR</c:calendar-data>
assert_eq!(events[0].summary, "Product Planning");
assert_eq!(events[0].location, Some("Boardroom".to_string()));
}
#[test]
fn format_query_timestamp_produces_ical_utc_form() {
let formatted =
CalDavClient::format_query_timestamp(1_704_067_200).expect("valid timestamp");
assert_eq!(formatted, "20240101T000000Z");
}
}
+9 -4
View File
@@ -1,7 +1,7 @@
//! CardDAV (vCard RFC 6350) contact sync and XML query parser.
use serde::{Deserialize, Serialize};
use crate::parser::CalendarError;
use serde::{Deserialize, Serialize};
/// Contact entity representation synced over CardDAV.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -34,7 +34,8 @@ impl CardDavClient {
<d:getetag />
<card:address-data />
</d:prop>
</card:addressbook-query>"#.to_string()
</card:addressbook-query>"#
.to_string()
}
/// Parse raw vCard string into domain [`Contact`] entity.
@@ -66,7 +67,9 @@ impl CardDavClient {
}
if name.is_empty() && email.is_empty() {
return Err(CalendarError::ParseFailed("invalid or empty vCard data".to_string()));
return Err(CalendarError::ParseFailed(
"invalid or empty vCard data".to_string(),
));
}
if name.is_empty() {
@@ -136,7 +139,9 @@ END:VCARD</card:address-data>
</d:response>
</d:multistatus>"#;
let contacts = client.parse_multistatus_response(xml).expect("parse multistatus");
let contacts = client
.parse_multistatus_response(xml)
.expect("parse multistatus");
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].name, "Alice Dev");
}
+4 -1
View File
@@ -1,7 +1,9 @@
//! iCalendar (RFC 5545), CalDAV (RFC 4791), and recurrence engine for Nuncio.
#![forbid(unsafe_code)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod backend;
pub mod caldav;
pub mod carddav;
pub mod mock;
@@ -10,7 +12,8 @@ pub mod parser;
pub mod rrule;
pub mod scheduling;
pub use caldav::CalDavClient;
pub use backend::CalendarBackend;
pub use caldav::{CalDavAccountConfig, CalDavClient};
pub use carddav::{CardDavClient, Contact};
pub use mock::MockCalendarBackend;
pub use nlp::{NaturalLanguageScheduler, NlpError, ParsedSchedulingIntent};
+46 -2
View File
@@ -1,8 +1,10 @@
//! Deterministic mock calendar backend for offline testing and integration verification.
use async_trait::async_trait;
use nuncio_core::model::CalendarEvent;
use std::sync::{Arc, Mutex};
use crate::backend::CalendarBackend;
use crate::parser::CalendarError;
/// Thread-safe mock calendar backend for offline testing.
@@ -56,9 +58,14 @@ impl MockCalendarBackend {
let matches = guard
.iter()
.filter(|e| {
// Interval-overlap (inclusive): an event is in the window if it
// starts before the window ends and ends after the window
// begins. Matches the CalendarBackend contract and the store's
// list_calendar_events query, so mock and persisted results
// agree for the same window.
e.calendar_id == calendar_id
&& e.start_time >= start_window
&& e.end_time <= end_window
&& e.start_time <= end_window
&& e.end_time >= start_window
})
.cloned()
.collect();
@@ -67,6 +74,18 @@ impl MockCalendarBackend {
}
}
#[async_trait]
impl CalendarBackend for MockCalendarBackend {
async fn fetch_events(
&self,
calendar_id: &str,
start_window: i64,
end_window: i64,
) -> Result<Vec<CalendarEvent>, CalendarError> {
self.list_events(calendar_id, start_window, end_window)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -100,6 +119,31 @@ mod tests {
.is_err());
}
/// Proves `MockCalendarBackend` is usable behind the `CalendarBackend` trait object, the
/// same way the daemon will inject either it or the real CalDAV client.
#[tokio::test]
async fn mock_calendar_backend_is_usable_as_a_trait_object() {
let mock = MockCalendarBackend::new();
mock.add_event(CalendarEvent {
id: "evt-trait-1".to_string(),
account_id: "acct-1".to_string(),
calendar_id: "cal-work".to_string(),
summary: "Trait Object Planning".to_string(),
start_time: 1700000000,
end_time: 1700003600,
rrule: None,
location: None,
});
let backend: Arc<dyn CalendarBackend> = Arc::new(mock);
let events = backend
.fetch_events("cal-work", 1699999000, 1700004000)
.await
.expect("fetch events succeeds");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, "evt-trait-1");
}
#[tokio::test]
async fn wiremock_caldav_report_simulation() {
let mock_server = MockServer::start().await;
+182 -26
View File
@@ -1,7 +1,10 @@
//! iCalendar (RFC 5545) and VCard (RFC 6350) parser adapter wrapping `icalendar`.
use chrono::{TimeZone, Utc};
use chrono_tz::Tz;
use icalendar::{Component, EventLike};
use nuncio_core::model::CalendarEvent;
use std::str::FromStr;
use thiserror::Error;
/// Errors returned by the iCalendar parsing engine.
@@ -10,6 +13,62 @@ pub enum CalendarError {
/// Failed to parse RFC 5545 iCalendar payload.
#[error("failed to parse iCalendar payload: {0}")]
ParseFailed(String),
/// A `DTSTART`/`DTEND` carried a `TZID` parameter (RFC 5545 3.3.5 Form #3) that could not
/// be resolved to a single unambiguous UTC instant: either the TZID string is not a
/// recognized IANA zone name, or the local wall-clock value falls inside a DST transition
/// gap/overlap with no (or two) valid UTC equivalents. Returned instead of ever silently
/// substituting a zeroed or fixed-offset timestamp.
#[error("unresolvable TZID timezone reference: {0}")]
UnresolvableTimezone(String),
/// CalDAV network/transport-layer failure (connection, TLS, or an unexpected HTTP status)
/// distinct from a payload that connected fine but failed to parse.
#[error("CalDAV transport failure: {0}")]
TransportFailed(String),
}
/// Resolve an iCalendar `DATE`-or-`DATE-TIME` value to a UTC unix timestamp.
///
/// `date_only_time` supplies the wall-clock time assumed for a bare `DATE` value (no time
/// component at all): start-of-day for `DTSTART`, end-of-day for `DTEND`, matching this
/// parser's pre-existing whole-day-event convention.
fn resolve_timestamp(
value: icalendar::DatePerhapsTime,
date_only_time: (u32, u32, u32),
) -> Result<i64, CalendarError> {
match value {
icalendar::DatePerhapsTime::Date(date) => {
let (hour, min, sec) = date_only_time;
date.and_hms_opt(hour, min, sec)
.map(|naive| naive.and_utc().timestamp())
.ok_or_else(|| {
CalendarError::ParseFailed(
"invalid DATE value in iCalendar payload".to_string(),
)
})
}
icalendar::DatePerhapsTime::DateTime(dt) => match dt {
icalendar::CalendarDateTime::Utc(dt) => Ok(dt.timestamp()),
// Floating times (RFC 5545 3.3.5 Form #1) carry no zone information at all -- there
// is no correct universal interpretation, only a documented convention. Treating
// the naive wall-clock value as if it were UTC is the pre-existing behavior here
// and is left unchanged: fixing it would require guessing a zone, which is exactly
// the kind of fabrication this parser must not do.
icalendar::CalendarDateTime::Floating(dt) => Ok(dt.and_utc().timestamp()),
icalendar::CalendarDateTime::WithTimezone { date_time, tzid } => {
let tz = Tz::from_str(&tzid)
.map_err(|_| CalendarError::UnresolvableTimezone(tzid.clone()))?;
// `.single()` rejects ambiguous local times -- those that fall inside a DST
// "spring forward" gap (no valid instant) or "fall back" overlap (two valid
// instants) -- rather than guessing which offset the source server intended.
tz.from_local_datetime(&date_time)
.single()
.map(|resolved| resolved.with_timezone(&Utc).timestamp())
.ok_or(CalendarError::UnresolvableTimezone(tzid))
}
},
}
}
/// iCalendar parser adapter converting raw `.ics` payloads into Nuncio [`CalendarEvent`] domain entities.
@@ -45,33 +104,15 @@ impl IcalParserAdapter {
.get("RRULE")
.map(|p| p.value().to_string());
let start_time = event
.get_start()
.map(|d| match d {
icalendar::DatePerhapsTime::Date(date) => date
.and_hms_opt(0, 0, 0)
.map_or(0, |dt| dt.and_utc().timestamp()),
icalendar::DatePerhapsTime::DateTime(dt) => match dt {
icalendar::CalendarDateTime::Utc(dt) => dt.timestamp(),
icalendar::CalendarDateTime::Floating(dt) => dt.and_utc().timestamp(),
_ => 0,
},
})
.unwrap_or(0);
let start_time = match event.get_start() {
Some(value) => resolve_timestamp(value, (0, 0, 0))?,
None => 0,
};
let end_time = event
.get_end()
.map(|d| match d {
icalendar::DatePerhapsTime::Date(date) => date
.and_hms_opt(23, 59, 59)
.map_or(0, |dt| dt.and_utc().timestamp()),
icalendar::DatePerhapsTime::DateTime(dt) => match dt {
icalendar::CalendarDateTime::Utc(dt) => dt.timestamp(),
icalendar::CalendarDateTime::Floating(dt) => dt.and_utc().timestamp(),
_ => 0,
},
})
.unwrap_or(start_time + 3600);
let end_time = match event.get_end() {
Some(value) => resolve_timestamp(value, (23, 59, 59))?,
None => start_time + 3600,
};
return Ok(CalendarEvent {
id: id.to_string(),
@@ -142,4 +183,119 @@ mod tests {
.to_string()
.contains("exceeds maximum allowed limit of 25MB"));
}
/// Regression for the TZID bug: `DTSTART;TZID=America/New_York:...` during Eastern
/// Daylight Time (UTC-4) must resolve to the correct UTC instant, not fall through the old
/// catch-all that zeroed the timestamp.
#[test]
fn parse_tzid_dtstart_during_edt_resolves_correct_utc_epoch() {
let ics = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Standup EDT\r\n\
DTSTART;TZID=America/New_York:20240715T090000\r\n\
DTEND;TZID=America/New_York:20240715T100000\r\n\
END:VEVENT\r\n\
END:VCALENDAR";
let event = IcalParserAdapter::parse_ical("evt-tz-edt", "acct-1", "cal-1", ics)
.expect("parse succeeds");
assert_ne!(
event.start_time, 0,
"TZID timestamp must never be silently zeroed"
);
// 2024-07-15 09:00 America/New_York is EDT (UTC-4) -> 2024-07-15T13:00:00Z.
assert_eq!(event.start_time, 1_721_048_400);
// 2024-07-15 10:00 America/New_York (EDT) -> 2024-07-15T14:00:00Z.
assert_eq!(event.end_time, 1_721_052_000);
}
/// Same wall-clock hour as the EDT fixture above but on the winter side of the DST
/// boundary: America/New_York is EST (UTC-5) there, so the resolved UTC instant must
/// differ by an hour from the summer fixture -- proving the fix performs a genuine
/// chrono_tz calendar-aware lookup rather than assuming a fixed UTC offset.
#[test]
fn parse_tzid_dtstart_during_est_resolves_correct_utc_epoch_across_dst_boundary() {
let ics = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Standup EST\r\n\
DTSTART;TZID=America/New_York:20240115T090000\r\n\
DTEND;TZID=America/New_York:20240115T100000\r\n\
END:VEVENT\r\n\
END:VCALENDAR";
let event = IcalParserAdapter::parse_ical("evt-tz-est", "acct-1", "cal-1", ics)
.expect("parse succeeds");
assert_ne!(
event.start_time, 0,
"TZID timestamp must never be silently zeroed"
);
// 2024-01-15 09:00 America/New_York is EST (UTC-5) -> 2024-01-15T14:00:00Z.
assert_eq!(event.start_time, 1_705_327_200);
// 2024-01-15 10:00 America/New_York (EST) -> 2024-01-15T15:00:00Z.
assert_eq!(event.end_time, 1_705_330_800);
}
/// Plain `Z`-suffixed UTC datetimes must keep working exactly as before the TZID fix.
#[test]
fn parse_plain_utc_z_dtstart_has_no_regression() {
let ics = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:UTC Event\r\n\
DTSTART:20240301T120000Z\r\n\
DTEND:20240301T130000Z\r\n\
END:VEVENT\r\n\
END:VCALENDAR";
let event = IcalParserAdapter::parse_ical("evt-utc", "acct-1", "cal-1", ics)
.expect("parse succeeds");
assert_eq!(event.start_time, 1_709_294_400);
assert_eq!(event.end_time, 1_709_298_000);
}
/// An unresolvable TZID (not a real IANA zone) must surface as a genuine error, never a
/// fabricated zero timestamp.
#[test]
fn parse_unknown_tzid_returns_error_not_a_fabricated_zero() {
let ics = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Bogus TZID Event\r\n\
DTSTART;TZID=Not/A_Real_Zone:20240115T090000\r\n\
END:VEVENT\r\n\
END:VCALENDAR";
let err = IcalParserAdapter::parse_ical("evt-bad-tz", "acct-1", "cal-1", ics)
.expect_err("unresolvable TZID must be a real error, not a zeroed timestamp");
assert!(
matches!(err, CalendarError::UnresolvableTimezone(ref tz) if tz == "Not/A_Real_Zone")
);
}
/// A local time inside a DST "spring forward" gap has no valid instant, so
/// `from_local_datetime(..).single()` yields `None`. That must surface as an
/// error rather than a silently substituted timestamp. In America/New_York,
/// 2024-03-10 02:30 never occurs (clocks jump 02:00 -> 03:00).
#[test]
fn parse_tzid_in_dst_gap_returns_error_not_a_fabricated_timestamp() {
let ics = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Nonexistent Local Time\r\n\
DTSTART;TZID=America/New_York:20240310T023000\r\n\
END:VEVENT\r\n\
END:VCALENDAR";
let err = IcalParserAdapter::parse_ical("evt-gap", "acct-1", "cal-1", ics).expect_err(
"a nonexistent DST-gap local time must be an error, not a fabricated instant",
);
assert!(
matches!(err, CalendarError::UnresolvableTimezone(ref tz) if tz == "America/New_York")
);
}
}
+17 -4
View File
@@ -67,17 +67,30 @@ mod tests {
#[test]
fn generate_1on1_link_format() {
let link = SchedulingLinkGenerator::generate_1on1_link("james.maes@kof22.com", 30, "Architecture Review");
let link = SchedulingLinkGenerator::generate_1on1_link(
"james.maes@kof22.com",
30,
"Architecture Review",
);
assert_eq!(link.host_email, "james.maes@kof22.com");
assert_eq!(link.duration_mins, 30);
assert_eq!(link.booking_url, "https://nuncio.mx/meet/james.maes@kof22.com/architecture-review?dur=30");
assert_eq!(
link.booking_url,
"https://nuncio.mx/meet/james.maes@kof22.com/architecture-review?dur=30"
);
}
#[test]
fn parse_duration_minutes_variations() {
assert_eq!(SchedulingLinkGenerator::parse_duration_minutes("30m"), 30);
assert_eq!(SchedulingLinkGenerator::parse_duration_minutes("1h"), 60);
assert_eq!(SchedulingLinkGenerator::parse_duration_minutes("45 mins"), 45);
assert_eq!(SchedulingLinkGenerator::parse_duration_minutes("2 hours"), 120);
assert_eq!(
SchedulingLinkGenerator::parse_duration_minutes("45 mins"),
45
);
assert_eq!(
SchedulingLinkGenerator::parse_duration_minutes("2 hours"),
120
);
}
}
@@ -0,0 +1,119 @@
//! WireMock integration test suite for the real CalDAV `REPORT` transport.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_cal::{CalDavAccountConfig, CalDavClient, CalendarBackend, CalendarError};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A canned CalDAV `multistatus` response containing one TZID-qualified VEVENT and one plain
/// UTC VEVENT, exercising both the real HTTP transport and the TZID resolution fix together.
fn multistatus_body() -> String {
r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/calendars/work/standup.ics</d:href>
<d:propstat>
<d:prop>
<c:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Wiremock CalDAV Standup
LOCATION:Conference Room B
DTSTART;TZID=America/New_York:20240715T090000
DTEND;TZID=America/New_York:20240715T100000
END:VEVENT
END:VCALENDAR</c:calendar-data>
</d:prop>
</d:propstat>
</d:response>
<d:response>
<d:href>/calendars/work/utc-review.ics</d:href>
<d:propstat>
<d:prop>
<c:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Wiremock UTC Review
DTSTART:20240301T120000Z
DTEND:20240301T130000Z
END:VEVENT
END:VCALENDAR</c:calendar-data>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"#
.to_string()
}
#[tokio::test]
async fn wiremock_caldav_report_fetches_and_resolves_tzid_events() {
let mock_server = MockServer::start().await;
Mock::given(method("REPORT"))
.and(path("/calendars/work/"))
.respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body()))
.mount(&mock_server)
.await;
let config = CalDavAccountConfig {
account_id: "acct-wm-1".to_string(),
caldav_url: format!("{}/calendars/work/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string(),
};
let client = CalDavClient::new(config);
// Drive the real client through the `CalendarBackend` trait, exactly how the daemon will
// invoke either this real client or `MockCalendarBackend` behind the same seam.
let events: Vec<_> =
CalendarBackend::fetch_events(&client, "cal-work", 1_700_000_000, 1_800_000_000)
.await
.expect("fetch_events succeeds against the wiremock server");
assert_eq!(events.len(), 2);
let tzid_event = events
.iter()
.find(|e| e.summary == "Wiremock CalDAV Standup")
.expect("TZID event present");
assert_eq!(tzid_event.account_id, "acct-wm-1");
assert_eq!(tzid_event.calendar_id, "cal-work");
assert_eq!(tzid_event.location, Some("Conference Room B".to_string()));
// 2024-07-15 09:00 America/New_York is EDT (UTC-4) -> 2024-07-15T13:00:00Z.
assert_eq!(tzid_event.start_time, 1_721_048_400);
assert_eq!(tzid_event.end_time, 1_721_052_000);
let utc_event = events
.iter()
.find(|e| e.summary == "Wiremock UTC Review")
.expect("UTC event present");
assert_eq!(utc_event.start_time, 1_709_294_400);
assert_eq!(utc_event.end_time, 1_709_298_000);
}
#[tokio::test]
async fn wiremock_caldav_report_server_error_surfaces_as_transport_failure() {
let mock_server = MockServer::start().await;
Mock::given(method("REPORT"))
.and(path("/calendars/broken/"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock_server)
.await;
let config = CalDavAccountConfig {
account_id: "acct-wm-2".to_string(),
caldav_url: format!("{}/calendars/broken/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string(),
};
let client = CalDavClient::new(config);
let err = client
.fetch_remote_events("cal-broken", 1_700_000_000, 1_800_000_000)
.await
.expect_err("a 500 response must be a real error, not fabricated empty success");
assert!(matches!(err, CalendarError::TransportFailed(_)));
}
+7
View File
@@ -16,9 +16,11 @@ nuncio-cal = { path = "../nuncio-cal" }
nuncio-store = { path = "../nuncio-store" }
nuncio-filter = { path = "../nuncio-filter" }
nuncio-contacts = { path = "../nuncio-contacts" }
nuncio-proto = { path = "../nuncio-proto" }
tokio.workspace = true
sqlx.workspace = true
tonic.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -26,5 +28,10 @@ thiserror.workspace = true
tracing.workspace = true
rpassword.workspace = true
chrono.workspace = true
hex.workspace = true
tempfile.workspace = true
[dev-dependencies]
tokio-stream = { workspace = true, features = ["net"] }
+309 -9
View File
@@ -1,7 +1,52 @@
//! Command-line argument hierarchy and Clap subcommand parsing.
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Carries an account password credential from an interactive `rpassword`
/// prompt (see `nuncio-cli`'s `main`) through to `Accounts/AddAccount`
/// without ever being reachable as a Clap CLI flag, printed by `Debug`, or
/// carried by value through `Serialize`/`Deserialize`.
///
/// `AccountSubcommand::Add`'s `password` field is `#[arg(skip)]`, so Clap
/// never parses it from argv; `main` populates it exactly once, right after
/// `Cli::parse()`, by prompting without echo. `Debug`, `Serialize` are
/// hand-implemented below to redact the value on principle -- defense in
/// depth in case a future caller logs or serializes a `Commands` value --
/// and `Deserialize` always yields an empty password, since a password
/// must never round-trip through a serialized command payload.
#[derive(Clone, Default)]
pub struct PasswordArg(pub String);
impl std::fmt::Debug for PasswordArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PasswordArg(\"[REDACTED]\")")
}
}
impl PartialEq for PasswordArg {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl Eq for PasswordArg {}
impl Serialize for PasswordArg {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str("[REDACTED]")
}
}
impl<'de> Deserialize<'de> for PasswordArg {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// Discard whatever was serialized (see the redacting `Serialize`
// impl above) and always yield an empty password rather than ever
// reconstructing a real credential from a deserialized payload.
let _ = String::deserialize(deserializer);
Ok(PasswordArg(String::new()))
}
}
/// Nuncio CLI — Pure Noun + Verb Developer-First Mail & Calendar Automation.
#[derive(Parser, Debug, Clone, PartialEq, Eq)]
@@ -87,12 +132,6 @@ pub enum Commands {
#[command(subcommand)]
action: ContactSubcommand,
},
/// Launch centralized local background server daemon (`nuncio daemon`).
Daemon {
/// TCP port to bind IPC daemon server to (default: 9422).
#[arg(long, default_value = "9422")]
port: u16,
},
}
/// Contact subcommands (`nuncio contact <verb>`).
@@ -131,7 +170,6 @@ pub enum UpdateSubcommand {
Apply,
}
/// Account subcommands (`nuncio account <verb>`).
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
@@ -161,6 +199,12 @@ pub enum AccountSubcommand {
/// SMTP connection transport mode (implicit_tls, start_tls, plain).
#[arg(long, default_value = "implicit_tls", help = "SMTP transport mode")]
smtp_mode: String,
/// Account password credential. Never a CLI flag -- always read
/// interactively without echo (via `rpassword`) in `main` and
/// injected here immediately before dispatch to the daemon's
/// `Accounts/AddAccount` RPC. See [`PasswordArg`].
#[arg(skip)]
password: PasswordArg,
},
/// Display details for a specific configured account.
Show {
@@ -239,6 +283,47 @@ pub enum MailSubcommand {
#[arg(short, long, help = "Search query string")]
query: String,
},
/// Mark a message read or unread.
Mark {
/// Unique message identifier.
#[arg(short, long, help = "Unique message identifier")]
id: String,
/// Mark the message as read.
#[arg(long, help = "Mark the message as read", conflicts_with = "unread")]
read: bool,
/// Mark the message as unread.
#[arg(long, help = "Mark the message as unread", conflicts_with = "read")]
unread: bool,
},
/// Export mailbox messages to a portable file format: MBOX, an EML
/// zip archive, JSON, or JSON Lines.
Export {
/// Export format (mbox, eml, json, or jsonl).
#[arg(
short = 'f',
long,
default_value = "mbox",
help = "Export format (mbox, eml, json, jsonl)"
)]
format: String,
/// Destination output file path, on the daemon's host filesystem.
#[arg(short = 'o', long, help = "Destination output file path")]
out: String,
/// Restrict the export to a single account's messages.
#[arg(
long,
help = "Restrict export to a single account ID",
conflicts_with = "folder"
)]
account: Option<String>,
/// Restrict the export to a single folder's messages.
#[arg(
long,
help = "Restrict export to a single folder ID",
conflicts_with = "account"
)]
folder: Option<String>,
},
}
/// Folder subcommands (`nuncio folder <verb>`).
@@ -265,6 +350,30 @@ pub enum CalSubcommand {
pub enum SystemSubcommand {
/// Display system, daemon, and event bus status.
Status,
/// WORM (Write Once, Read Many) tamper-evident audit ledger operations
/// (`nuncio system audit <verb>`).
Audit {
#[command(subcommand)]
action: AuditSubcommand,
},
}
/// Audit subcommands (`nuncio system audit <verb>`).
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuditSubcommand {
/// List persisted WORM audit ledger records, sequence ascending.
List {
/// Max records to fetch (0 = server default).
#[arg(short, long, default_value_t = 50, help = "Max records to fetch")]
limit: u32,
/// Pagination offset.
#[arg(short, long, default_value_t = 0, help = "Pagination offset")]
offset: u32,
},
/// Verify the WORM audit ledger's cryptographic HMAC hash-chain
/// integrity.
Verify,
}
/// Filter subcommands (`nuncio filter <verb>`).
@@ -306,6 +415,12 @@ pub enum FilterSubcommand {
#[arg(short, long, help = "Rule identifier")]
id: String,
},
/// Validate an NSQL query's syntax and semantics without persisting it.
Validate {
/// NSQL query string to validate.
#[arg(short, long, help = "NSQL query string")]
sql: String,
},
/// Test / dry-run NSQL query against an email.
Test {
/// NSQL query string to test.
@@ -369,6 +484,7 @@ mod tests {
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "implicit_tls".to_string(),
password: PasswordArg::default(),
}
}
);
@@ -395,6 +511,142 @@ mod tests {
}
}
);
let cli_mark_read =
Cli::parse_from(["nuncio", "mail", "mark", "--id", "msg-123", "--read"]);
assert_eq!(
cli_mark_read.command,
Commands::Mail {
action: MailSubcommand::Mark {
id: "msg-123".to_string(),
read: true,
unread: false,
}
}
);
let cli_mark_unread =
Cli::parse_from(["nuncio", "mail", "mark", "--id", "msg-123", "--unread"]);
assert_eq!(
cli_mark_unread.command,
Commands::Mail {
action: MailSubcommand::Mark {
id: "msg-123".to_string(),
read: false,
unread: true,
}
}
);
// `--read` and `--unread` are mutually exclusive at the Clap level.
let conflict = Cli::try_parse_from([
"nuncio", "mail", "mark", "--id", "msg-123", "--read", "--unread",
]);
assert!(conflict.is_err());
}
#[test]
fn parse_pure_noun_verb_mail_export_command() {
let cli_export = Cli::parse_from([
"nuncio",
"mail",
"export",
"--format",
"json",
"--out",
"/tmp/out.json",
]);
assert_eq!(
cli_export.command,
Commands::Mail {
action: MailSubcommand::Export {
format: "json".to_string(),
out: "/tmp/out.json".to_string(),
account: None,
folder: None,
}
}
);
let cli_export_scoped = Cli::parse_from([
"nuncio",
"mail",
"export",
"--format",
"jsonl",
"--out",
"/tmp/scoped.jsonl",
"--account",
"acct-1",
]);
assert_eq!(
cli_export_scoped.command,
Commands::Mail {
action: MailSubcommand::Export {
format: "jsonl".to_string(),
out: "/tmp/scoped.jsonl".to_string(),
account: Some("acct-1".to_string()),
folder: None,
}
}
);
// `--account` and `--folder` are mutually exclusive at the Clap
// level -- an export scope is either "one account", "one folder",
// or (neither flag) "everything".
let conflict = Cli::try_parse_from([
"nuncio",
"mail",
"export",
"--out",
"/tmp/out.json",
"--account",
"acct-1",
"--folder",
"inbox",
]);
assert!(conflict.is_err());
}
#[test]
fn parse_pure_noun_verb_system_audit_commands() {
let cli_list = Cli::parse_from(["nuncio", "system", "audit", "list"]);
assert_eq!(
cli_list.command,
Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::List {
limit: 50,
offset: 0,
}
}
}
);
let cli_list_paged = Cli::parse_from([
"nuncio", "system", "audit", "list", "--limit", "10", "--offset", "5",
]);
assert_eq!(
cli_list_paged.command,
Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::List {
limit: 10,
offset: 5,
}
}
}
);
let cli_verify = Cli::parse_from(["nuncio", "system", "audit", "verify"]);
assert_eq!(
cli_verify.command,
Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::Verify
}
}
);
}
#[test]
@@ -415,5 +667,53 @@ mod tests {
}
);
}
}
#[test]
fn account_add_password_is_never_parsed_from_argv() {
// `--password` is deliberately not a recognized flag: the password
// MUST always come from an interactive, non-echoing prompt in
// `main`, never from argv (which would land it in shell history and
// process listings).
let cli_add = Cli::parse_from([
"nuncio",
"account",
"add",
"--email",
"a@b.com",
"--imap-host",
"imap.b.com",
]);
match cli_add.command {
Commands::Account {
action: AccountSubcommand::Add { password, .. },
} => assert_eq!(password, PasswordArg::default()),
other => panic!("expected Account::Add, got {other:?}"),
}
}
#[test]
fn password_arg_debug_and_serialize_always_redact_the_value() {
let secret = PasswordArg("super-secret-value".to_string());
assert_eq!(format!("{secret:?}"), "PasswordArg(\"[REDACTED]\")");
let json = serde_json::to_string(&secret).expect("serializes");
assert_eq!(json, "\"[REDACTED]\"");
assert!(!json.contains("super-secret-value"));
}
#[test]
fn password_arg_deserialize_never_reconstructs_a_real_credential() {
let deserialized: PasswordArg =
serde_json::from_str("\"super-secret-value\"").expect("deserializes");
assert_eq!(deserialized, PasswordArg::default());
assert_eq!(deserialized.0, "");
}
#[test]
fn password_arg_equality_and_default() {
assert_eq!(PasswordArg::default(), PasswordArg(String::new()));
assert_ne!(PasswordArg("a".to_string()), PasswordArg("b".to_string()));
let cloned = PasswordArg("clone-me".to_string()).clone();
assert_eq!(cloned.0, "clone-me");
}
}
+4 -1
View File
@@ -1,11 +1,14 @@
//! Nuncio CLI library.
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod args;
pub mod output;
pub mod runner;
pub use args::{
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, MailSubcommand, SystemSubcommand,
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, MailSubcommand, PasswordArg,
SystemSubcommand,
};
pub use output::JsonResponse;
pub use runner::HeadlessRunner;
+18 -2
View File
@@ -1,9 +1,25 @@
use clap::Parser;
use nuncio_cli::{args::Cli, HeadlessRunner};
use nuncio_cli::{args::Cli, AccountSubcommand, Commands, HeadlessRunner, PasswordArg};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let mut cli = Cli::parse();
// `account add`'s password is deliberately never a Clap-parsed CLI
// flag (see `PasswordArg`'s doc comment): read it here, interactively
// and without echo, immediately after parsing argv and before any
// other work, then inject it into the already-parsed command. This is
// the ONLY place in the whole `nuncio-cli` binary that ever reads a raw
// password from the terminal.
if let Commands::Account {
action: AccountSubcommand::Add { password, .. },
} = &mut cli.command
{
let entered = rpassword::prompt_password("Account password: ")
.map_err(|e| format!("failed to read password: {e}"))?;
*password = PasswordArg(entered);
}
let runner = HeadlessRunner::ephemeral().await?;
let output_str = runner.execute_command(&cli.command, cli.json).await;
File diff suppressed because it is too large. Load diff
+199 -80
View File
@@ -1,26 +1,24 @@
//! E2E System Test Suite for nuncio-cli.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_cli::{
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, HeadlessRunner, MailSubcommand,
SystemSubcommand,
PasswordArg, SystemSubcommand,
};
use nuncio_store::vault::SecretManager;
use serde_json::Value;
use std::sync::Arc;
#[tokio::test]
async fn system_test_cli_noun_verb_execution_matrix() {
let runner = HeadlessRunner::ephemeral().await.expect("runner init");
// 1. System status
let out: String = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 1. System status is a real gRPC client of the `nunciod` daemon, so it
// is exercised separately below via `ephemeral_with` + `SecretManager::mock()`
// rather than through this `ephemeral()`-constructed runner (which holds
// the production `SecretManager` and must never touch the real OS
// keyring in a test).
// 1b. Banner output
let out: String = runner.execute_command(&Commands::Banner, true).await;
@@ -33,75 +31,16 @@ async fn system_test_cli_noun_verb_execution_matrix() {
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 2. Account list & add
let out: String = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::List,
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 2. Account list & add are real gRPC clients of the `nunciod` daemon's
// `Accounts` API, so -- exactly like `system status` above -- they are
// exercised separately below via `ephemeral_with` + `SecretManager::mock()`
// rather than through this `ephemeral()`-constructed runner.
let out: String = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Add {
email: "test@nuncio.mx".to_string(),
imap_host: "mail.nuncio.mx".to_string(),
imap_port: 993,
smtp_host: "mail.nuncio.mx".to_string(),
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "implicit_tls".to_string(),
},
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 3. Folder list
let out: String = runner
.execute_command(
&Commands::Folder {
action: FolderSubcommand::List,
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 4. Mail list & search & read
let out: String = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::List {
folder: "INBOX".to_string(),
},
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
let out: String = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Search {
query: "Architecture".to_string(),
},
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 3. Folder list & 4. Mail list/search/read/mark are real gRPC clients
// of the `nunciod` daemon's `Mail` API, so -- exactly like `system
// status` and `account add`/`list` above -- they are exercised
// separately below via `ephemeral_with` + `SecretManager::mock()`
// rather than through this `ephemeral()`-constructed runner.
// 5. Calendar list & sync
let out: String = runner
@@ -115,3 +54,183 @@ async fn system_test_cli_noun_verb_execution_matrix() {
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
}
/// `system status` is a real gRPC client of the `nunciod` daemon. With no
/// daemon reachable at an address nothing is listening on, it must report
/// a clear, honest error rather than fabricating a status.
#[tokio::test]
async fn system_status_reports_honest_error_when_daemon_unreachable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
drop(listener); // free the port; nothing is listening on it now
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("runner init");
let out: String = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "error");
assert!(json["error"]
.as_str()
.expect("error message present")
.contains("unreachable"));
}
/// `account add` / `account list` are real gRPC clients of the `nunciod`
/// daemon's `Accounts` API. With no daemon reachable, both must report a
/// clear, honest error rather than fabricating success or silently
/// falling back to any local state.
#[tokio::test]
async fn account_add_and_list_report_honest_errors_when_daemon_unreachable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
drop(listener); // free the port; nothing is listening on it now
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("runner init");
let list_out: String = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::List,
},
true,
)
.await;
let list_json: Value = serde_json::from_str(&list_out).expect("valid json");
assert_eq!(list_json["status"], "error");
assert!(list_json["error"]
.as_str()
.expect("error message present")
.contains("unreachable"));
let add_out: String = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Add {
email: "test@nuncio.mx".to_string(),
imap_host: "mail.nuncio.mx".to_string(),
imap_port: 993,
smtp_host: "mail.nuncio.mx".to_string(),
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "implicit_tls".to_string(),
password: PasswordArg("irrelevant-unreachable-daemon".to_string()),
},
},
true,
)
.await;
let add_json: Value = serde_json::from_str(&add_out).expect("valid json");
assert_eq!(add_json["status"], "error");
assert!(add_json["error"]
.as_str()
.expect("error message present")
.contains("unreachable"));
// The password must never leak into an error message either.
assert!(!add_out.contains("irrelevant-unreachable-daemon"));
}
/// `folder list`, `mail list`, `mail read`, `mail search`, and `mail mark`
/// are real gRPC clients of the `nunciod` daemon's `Mail` API. With no
/// daemon reachable, all of them must report a clear, honest error rather
/// than fabricating an empty result.
#[tokio::test]
async fn mail_and_folder_report_honest_errors_when_daemon_unreachable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
drop(listener); // free the port; nothing is listening on it now
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("runner init");
let assert_honest_error = |out: String| {
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "error");
assert!(json["error"]
.as_str()
.expect("error message present")
.contains("unreachable"));
};
assert_honest_error(
runner
.execute_command(
&Commands::Folder {
action: FolderSubcommand::List,
},
true,
)
.await,
);
assert_honest_error(
runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::List {
folder: "INBOX".to_string(),
},
},
true,
)
.await,
);
assert_honest_error(
runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Read {
id: "msg-1".to_string(),
},
},
true,
)
.await,
);
assert_honest_error(
runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Search {
query: "Architecture".to_string(),
},
},
true,
)
.await,
);
assert_honest_error(
runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Mark {
id: "msg-1".to_string(),
read: true,
unread: false,
},
},
true,
)
.await,
);
}
+4 -1
View File
@@ -38,7 +38,10 @@ impl CardDavClient {
}
pub async fn fetch_remote_vcards(&self) -> Result<Vec<Contact>, CardDavError> {
info!("Initiating CardDAV PROPFIND fetch from {}", self.config.carddav_url);
info!(
"Initiating CardDAV PROPFIND fetch from {}",
self.config.carddav_url
);
// Returns mock or parsed remote vcards for CardDAV endpoint
let sample_contact = Contact::new("Google CardDAV Sync Contact", "carddav.sync@nuncio.mx");
Ok(vec![sample_contact])
+17 -7
View File
@@ -63,7 +63,7 @@ impl ContactsDatabase {
emails_json,
tokenize='trigram'
);
"#
"#,
)
.execute(&self.pool)
.await?;
@@ -73,8 +73,10 @@ impl ContactsDatabase {
}
pub async fn save_contact(&self, contact: &Contact) -> Result<(), ContactsStoreError> {
let emails_json = serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string());
let phones_json = serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string());
let emails_json =
serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string());
let phones_json =
serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string());
let created_str = contact.created_at.to_rfc3339();
let updated_str = contact.updated_at.to_rfc3339();
let last_interacted_str = contact.last_interacted_at.map(|t| t.to_rfc3339());
@@ -95,7 +97,7 @@ impl ContactsDatabase {
interaction_count = excluded.interaction_count,
last_interacted_at = excluded.last_interacted_at,
updated_at = excluded.updated_at
"#
"#,
)
.bind(&contact.id)
.bind(&contact.account_id)
@@ -144,7 +146,7 @@ impl ContactsDatabase {
WHERE display_name LIKE ? OR organization LIKE ? OR emails_json LIKE ?
ORDER BY interaction_count DESC, display_name ASC
LIMIT 50
"#
"#,
)
.bind(&pattern)
.bind(&pattern)
@@ -188,14 +190,22 @@ impl ContactsDatabase {
self.search_contacts("").await
}
pub async fn harvest_email_address(&self, display_name: &str, email: &str) -> Result<(), ContactsStoreError> {
pub async fn harvest_email_address(
&self,
display_name: &str,
email: &str,
) -> Result<(), ContactsStoreError> {
if email.trim().is_empty() {
return Ok(());
}
let existing = self.search_contacts(email).await?;
if existing.is_empty() {
let name = if display_name.trim().is_empty() { email } else { display_name };
let name = if display_name.trim().is_empty() {
email
} else {
display_name
};
let mut contact = Contact::new(name, email);
contact.interaction_count = 1;
contact.last_interacted_at = Some(chrono::Utc::now());
+19 -5
View File
@@ -1,3 +1,5 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod carddav;
pub mod db;
pub mod models;
@@ -12,21 +14,33 @@ mod tests {
#[tokio::test]
async fn test_contacts_database_crud_and_fts() {
let db = ContactsDatabase::in_memory().await.expect("Failed to create in-memory db");
let db = ContactsDatabase::in_memory()
.await
.expect("Failed to create in-memory db");
let mut contact = Contact::new("James Maes", "james.maes@kof22.com");
contact.organization = Some("KofTwentyTwo".to_string());
db.save_contact(&contact).await.expect("Failed to save contact");
db.save_contact(&contact)
.await
.expect("Failed to save contact");
let results = db.search_contacts("James").await.expect("Failed to search contacts");
let results = db
.search_contacts("James")
.await
.expect("Failed to search contacts");
assert_eq!(results.len(), 1);
assert_eq!(results[0].display_name, "James Maes");
assert_eq!(results[0].emails[0].email, "james.maes@kof22.com");
let harvested = db.harvest_email_address("Alice Smith", "alice@kof22.com").await;
let harvested = db
.harvest_email_address("Alice Smith", "alice@kof22.com")
.await;
assert!(harvested.is_ok());
let results2 = db.search_contacts("alice").await.expect("Failed to search harvested");
let results2 = db
.search_contacts("alice")
.await
.expect("Failed to search harvested");
assert_eq!(results2.len(), 1);
assert_eq!(results2[0].display_name, "Alice Smith");
}
+11 -3
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Contact {
@@ -86,10 +86,18 @@ impl Contact {
vcard.push_str(&format!("TITLE:{}\r\n", title));
}
for email in &self.emails {
vcard.push_str(&format!("EMAIL;TYPE={}:{}\r\n", email.label.to_uppercase(), email.email));
vcard.push_str(&format!(
"EMAIL;TYPE={}:{}\r\n",
email.label.to_uppercase(),
email.email
));
}
for phone in &self.phones {
vcard.push_str(&format!("TEL;TYPE={}:{}\r\n", phone.label.to_uppercase(), phone.phone));
vcard.push_str(&format!(
"TEL;TYPE={}:{}\r\n",
phone.label.to_uppercase(),
phone.phone
));
}
vcard.push_str("END:VCARD\r\n");
vcard
+6 -2
View File
@@ -119,7 +119,9 @@ mod tests {
#[test]
fn summarize_thread_returns_valid_structure() {
let engine = LocalAiEngine::default_local();
let summary = engine.summarize_thread("Meeting notes for Q3 project...").unwrap();
let summary = engine
.summarize_thread("Meeting notes for Q3 project...")
.unwrap();
assert_eq!(summary.bullets.len(), 3);
assert!(!summary.sentiment.is_empty());
}
@@ -127,7 +129,9 @@ mod tests {
#[test]
fn extract_action_items_returns_items() {
let engine = LocalAiEngine::default_local();
let items = engine.extract_action_items("Please review the audit by Friday.").unwrap();
let items = engine
.extract_action_items("Please review the audit by Friday.")
.unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].task_description, "Review security audit report");
}
+33 -2
View File
@@ -56,10 +56,16 @@ pub struct AccountConfig {
pub email_address: String,
/// Protocol engine type.
pub protocol: AccountProtocol,
/// Server hostname or IP address.
/// Server hostname or IP address (IMAP/JMAP).
pub server_host: String,
/// Server port number.
/// Server port number (IMAP/JMAP).
pub server_port: u16,
/// SMTP server hostname or IP address, used for outbound mail. Distinct
/// from `server_host` because a mail provider's inbound (IMAP/JMAP) and
/// outbound (SMTP) endpoints commonly differ.
pub smtp_host: String,
/// SMTP server port number, used for outbound mail.
pub smtp_port: u16,
/// Whether TLS connection encryption is enabled.
pub use_tls: bool,
/// IMAP connection security transport mode.
@@ -105,6 +111,12 @@ impl AccountConfig {
if self.server_port == 0 {
return Err(ConfigError::InvalidPort);
}
if self.smtp_host.trim().is_empty() {
return Err(ConfigError::EmptyField { field: "smtp_host" });
}
if self.smtp_port == 0 {
return Err(ConfigError::InvalidPort);
}
if self.keyring_secret_key.trim().is_empty() {
return Err(ConfigError::EmptyField {
field: "keyring_secret_key",
@@ -130,6 +142,8 @@ mod tests {
protocol: AccountProtocol::Jmap,
server_host: "jmap.nuncio.mx".to_string(),
server_port: 443,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: TlsMode::ImplicitTls,
smtp_tls_mode: TlsMode::ImplicitTls,
@@ -211,6 +225,23 @@ mod tests {
assert_eq!(config.validate().unwrap_err(), ConfigError::InvalidPort);
}
#[test]
fn empty_smtp_host_fails_validation() {
let mut config = valid_account();
config.smtp_host = " ".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::EmptyField { field: "smtp_host" }
);
}
#[test]
fn zero_smtp_port_fails_validation() {
let mut config = valid_account();
config.smtp_port = 0;
assert_eq!(config.validate().unwrap_err(), ConfigError::InvalidPort);
}
#[test]
fn empty_keyring_key_fails_validation() {
let mut config = valid_account();
+5 -3
View File
@@ -74,8 +74,7 @@ impl E2eeEngine {
}
// Simulating Sequoia / OpenPGP verification verification logic
let valid = signature.contains("BEGIN PGP SIGNATURE")
&& expected_fingerprint.len() >= 8;
let valid = signature.contains("BEGIN PGP SIGNATURE") && expected_fingerprint.len() >= 8;
if valid {
Ok(SecurityBadge {
@@ -139,7 +138,10 @@ mod tests {
let badge = E2eeEngine::verify_openpgp_signature(payload, sig, fp).unwrap();
assert_eq!(badge.encryption_type, EncryptionType::OpenPGP);
assert!(badge.is_encrypted);
assert!(matches!(badge.signature_status, SignatureStatus::Valid { .. }));
assert!(matches!(
badge.signature_status,
SignatureStatus::Valid { .. }
));
}
#[test]
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more