diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..96728c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main, dev, release/*, feature/*, codex/*] + pull_request: + branches: [main, dev, release/*] + +env: + CARGO_TERM_COLOR: always + +jobs: + rust: + name: Rust checks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libdbus-1-dev libssl-dev + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run release bar + run: make pre-release + + - name: Smoke test CLI entrypoint + run: cargo run --bin solverforge-calendar-cli -- calendars list diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..03e5f2b --- /dev/null +++ b/AGENT.md @@ -0,0 +1,58 @@ +# AGENT.md + +## Purpose + +This repository contains a Linux-first Rust desktop calendar with two supported entrypoints: + +- `solverforge-calendar`: ratatui TUI application +- `solverforge-calendar-cli`: non-interactive JSON CLI for agents and automation + +## Commands + +- `make build`: build both binaries +- `make run`: launch the TUI +- `make run-cli ARGS="calendars list"`: run the automation CLI +- `make test`: run all tests +- `make lint`: run formatting and clippy checks +- `make ci-local`: match the GitHub Actions CI workflow locally +- `make pre-release`: run release-oriented validation before cutting or tagging a version + +Direct cargo commands used in CI: + +- `cargo fmt --all -- --check` +- `cargo clippy --bins --tests -- -D warnings` +- `cargo build --bins` +- `cargo test` + +## Repo map + +- `src/main.rs`: TUI entrypoint +- `src/bin/solverforge-calendar-cli.rs`: CLI entrypoint +- `src/cli.rs`: typed CLI parsing, JSON responses, command dispatch, CLI tests +- `src/db.rs`: SQLite schema, migrations, CRUD helpers, default-calendar recovery +- `src/google/`: OAuth, sync fetch/apply logic, Google event translation +- `tests/cli.rs`: binary-level CLI integration tests +- `docs/wireframes/`: ASCII wireframes for the TUI and CLI surfaces + +## Constraints + +- Keep the CLI fully non-interactive. No prompts, no confirmation flows, no “choices”. +- Preserve `cargo run` as the TUI default path. +- Keep agent automation explicit through `solverforge-calendar-cli` and `scripts/solverforge-calendar-cli`. +- Prefer shared DB/business-rule fixes over CLI-only patches when behavior affects both the TUI and CLI. +- Tests must stay deterministic. Do not add live Google API or real keyring dependencies to automated tests. + +## Change checklist + +Before pushing changes: + +1. Run `make lint` +2. Run `make test` +3. If binaries changed materially, run `make build` +4. Before tagging or pushing a release version, run `make pre-release` + +If you touch the CLI contract, update: + +- `README.md` +- `tests/cli.rs` +- `docs/wireframes/cli.md` when the command surface or response model changes diff --git a/Cargo.lock b/Cargo.lock index abb2a00..4d7d344 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,12 +37,77 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -238,6 +303,17 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -331,6 +407,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.8.1" @@ -544,6 +666,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -1352,6 +1480,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "iso8601" version = "0.6.3" @@ -1722,6 +1856,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.3" @@ -1925,6 +2065,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -2572,11 +2739,13 @@ dependencies = [ [[package]] name = "solverforge-calendar" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", + "assert_cmd", "chrono", "chrono-tz", + "clap", "crossterm", "dirs", "google-calendar3", @@ -2591,6 +2760,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "tempfile", "tokio", "toml", "unicode-width 0.2.0", @@ -2732,6 +2902,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -3089,6 +3265,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.21.0" @@ -3113,6 +3295,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 0f9195f..b96dfb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,15 @@ [package] name = "solverforge-calendar" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "A spiffy ratatui TUI calendar — local SQLite + Google Calendar, DAG-linked events" +default-run = "solverforge-calendar" [dependencies] # TUI ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } crossterm = "0.28" +clap = { version = "4.5", features = ["derive"] } # Async runtime (required by google-calendar3) tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } @@ -53,3 +55,5 @@ reqwest = { version = "0.12", features = ["json"] } [dev-dependencies] pretty_assertions = "1" +assert_cmd = "2" +tempfile = "3" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5eaad9f --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +# SolverForge Calendar Makefile + +GREEN := \033[92m +CYAN := \033[96m +YELLOW := \033[93m +RED := \033[91m +BOLD := \033[1m +RESET := \033[0m + +CHECK := ✓ +CROSS := ✗ +ARROW := ▸ + +VERSION := $(shell grep -m1 '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1/') + +.PHONY: help build build-release run run-cli test lint fmt fmt-check clippy ci-local pre-release clean version +.DEFAULT_GOAL := help + +help: + @printf "$(CYAN)$(BOLD)SolverForge Calendar$(RESET) v$(VERSION)\n\n" + @printf "$(ARROW) build Build the TUI and CLI binaries\n" + @printf "$(ARROW) build-release Build optimized binaries\n" + @printf "$(ARROW) run Launch the TUI\n" + @printf "$(ARROW) run-cli Run the agent CLI, pass ARGS='...'\n" + @printf "$(ARROW) test Run the full test suite\n" + @printf "$(ARROW) lint Run formatting and clippy checks\n" + @printf "$(ARROW) fmt Format the repo\n" + @printf "$(ARROW) fmt-check Check formatting without rewriting\n" + @printf "$(ARROW) clippy Run clippy with CI flags\n" + @printf "$(ARROW) ci-local Simulate the GitHub Actions workflow\n" + @printf "$(ARROW) pre-release Run release-oriented validation\n" + @printf "$(ARROW) clean Remove build artifacts\n" + @printf "$(ARROW) version Print the current crate version\n\n" + +build: + @printf "$(ARROW) Building binaries...\n" + @cargo build --bins && printf "$(GREEN)$(CHECK) Build passed$(RESET)\n" || (printf "$(RED)$(CROSS) Build failed$(RESET)\n" && exit 1) + +build-release: + @printf "$(ARROW) Building release binaries...\n" + @cargo build --release --bins && printf "$(GREEN)$(CHECK) Release build passed$(RESET)\n" || (printf "$(RED)$(CROSS) Release build failed$(RESET)\n" && exit 1) + +run: + @cargo run + +run-cli: + @cargo run --bin solverforge-calendar-cli -- $(ARGS) + +test: + @printf "$(ARROW) Running tests...\n" + @cargo test && printf "$(GREEN)$(CHECK) Tests passed$(RESET)\n" || (printf "$(RED)$(CROSS) Tests failed$(RESET)\n" && exit 1) + +fmt: + @cargo fmt --all + +fmt-check: + @printf "$(ARROW) Checking formatting...\n" + @cargo fmt --all --check && printf "$(GREEN)$(CHECK) Formatting valid$(RESET)\n" || (printf "$(RED)$(CROSS) Formatting issues found$(RESET)\n" && exit 1) + +clippy: + @printf "$(ARROW) Running clippy...\n" + @cargo clippy --bins --tests -- -D warnings && printf "$(GREEN)$(CHECK) Clippy passed$(RESET)\n" || (printf "$(RED)$(CROSS) Clippy failed$(RESET)\n" && exit 1) + +lint: fmt-check clippy + @printf "$(GREEN)$(CHECK) Lint checks passed$(RESET)\n" + +ci-local: fmt-check clippy + @printf "$(ARROW) Building binaries...\n" + @cargo build --bins + @printf "$(ARROW) Running tests...\n" + @cargo test + @printf "$(GREEN)$(BOLD)$(CHECK) Local CI passed$(RESET)\n" + +pre-release: fmt-check clippy + @printf "$(ARROW) Building release binaries...\n" + @cargo build --release --bins + @printf "$(ARROW) Running release validation tests...\n" + @cargo test + @printf "$(GREEN)$(BOLD)$(CHECK) Pre-release checks passed for v$(VERSION)$(RESET)\n" + +clean: + @cargo clean + +version: + @printf "$(YELLOW)$(BOLD)$(VERSION)$(RESET)\n" diff --git a/README.md b/README.md index b800497..884c6ca 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,38 @@ # SolverForge Calendar -A spiffy ratatui TUI calendar — local SQLite with Google Calendar sync and DAG-linked events. +
+ + SolverForge Mascot + +
-[![Built With Ratatui](https://ratatui.rs/built-with-ratatui/badge.svg)](https://ratatui.rs/) + [![CI](https://github.com/blackopsrepl/solverforge-calendar/actions/workflows/ci.yml/badge.svg?style=for-the-badge)](https://github.com/blackopsrepl/solverforge-calendar/actions/workflows/ci.yml) + [![Version](https://img.shields.io/badge/version-v0.2.0-00E6A8?style=for-the-badge)](https://github.com/blackopsrepl/solverforge-calendar) + [![Rust](https://img.shields.io/badge/rust-stable-orange?style=for-the-badge)](https://www.rust-lang.org) + [![Built With Ratatui](https://img.shields.io/badge/built%20with-ratatui-5A54FF?style=for-the-badge)](https://ratatui.rs/) + +
+ +A spiffy ratatui TUI calendar — local SQLite with Google Calendar sync and DAG-linked events. -![SolverForge Calendar](https://raw.githubusercontent.com/blackopsrepl/solverforge-calendar/main/screenshot.png) +![SolverForge Calendar](assets/screenshot.png) ## Quick Start ```bash -# Build and run +# Build both binaries cargo build --release ./target/release/solverforge-calendar -# Or run directly +# Human-facing TUI entrypoint cargo run + +# Agent-facing CLI entrypoint +cargo run --bin solverforge-calendar-cli -- calendars list + +# Or use the repo Makefile +make build +make test ``` ## Features @@ -27,6 +45,7 @@ cargo run - **Local SQLite database** - Events, calendars, projects stored in `~/.local/share/solverforge/calendar.db` - **iCal import/export** - Standard `.ics` support - **Desktop notifications** - Reminder alerts via libnotify +- **Pure CLI companion** - JSON-first CRUD and Google sync for agents and automation - **SolverForge theme** - Reads hackerman palette from `colors.toml` ## Keybindings @@ -55,6 +74,60 @@ cargo run ### Quick Add - `a` - Quick add event (command-line style) +## CLI Automation + +`solverforge-calendar-cli` is a non-interactive companion binary for agents and scripts. Successful commands write JSON to stdout, failures write JSON to stderr, and destructive actions require explicit flags rather than prompts. + +```bash +# Stable wrapper for agents +./scripts/solverforge-calendar-cli calendars list + +# Calendars +cargo run --bin solverforge-calendar-cli -- calendars list +cargo run --bin solverforge-calendar-cli -- calendars create --name Work --color '#50f872' + +# Events +cargo run --bin solverforge-calendar-cli -- events create \ + --calendar-id \ + --title 'Planning Session' \ + --start-at '2026-03-30 15:00:00' \ + --end-at '2026-03-30 16:00:00' + +# Dependencies +cargo run --bin solverforge-calendar-cli -- dependencies create \ + --from-event-id \ + --to-event-id \ + --dependency-type blocks + +# Explicit destructive flags +cargo run --bin solverforge-calendar-cli -- calendars delete --cascade-events +cargo run --bin solverforge-calendar-cli -- projects delete --detach-events +``` + +Available groups: + +- `calendars`: `list`, `get`, `create`, `update`, `delete` +- `projects`: `list`, `get`, `create`, `update`, `delete` +- `events`: `list`, `get`, `create`, `update`, `delete` +- `dependencies`: `list`, `get`, `create`, `update`, `delete` +- `google`: `sync` + +## Developer Workflow + +This repo now ships a local Makefile and GitHub Actions CI tailored to the calendar app. + +```bash +make build +make run +make run-cli ARGS="events list" +make lint +make test +make ci-local +make pre-release +``` + +Contributor and automation guidance lives in [AGENT.md](AGENT.md). UI and CLI structure references live in [docs/wireframes/tui.md](docs/wireframes/tui.md) and [docs/wireframes/cli.md](docs/wireframes/cli.md). + ## Google Calendar Setup 1. Press `G` to open the Google Auth flow @@ -80,20 +153,35 @@ cargo run ```bash cargo build # debug cargo build --release # optimized +cargo build --bins # both binaries cargo check # fast type check cargo clippy # lint cargo test # run tests +make ci-local # local CI simulation +make pre-release # release-oriented validation ``` ## Files ``` solverforge-calendar/ +├── .github/ +│ └── workflows/ +│ └── ci.yml # Linux-first CI for fmt, clippy, build, test +├── docs/ +│ └── wireframes/ +│ ├── cli.md # ASCII command/JSON contract reference +│ └── tui.md # ASCII TUI layout reference +├── AGENT.md # Contributor + automation guidance +├── Makefile # Repo-local developer workflow commands +├── scripts/ +│ └── solverforge-calendar-cli # Stable wrapper for the automation CLI └── src/ ├── main.rs # Entry point, terminal setup, event loop ├── app.rs # TEA state machine, all application state ├── keys.rs # (View, KeyEvent) → Action dispatch ├── worker.rs # Background thread pool, WorkerResult enum + ├── cli.rs # Typed JSON CLI handlers and shared automation logic ├── event.rs # Crossterm event handling ├── models.rs # Calendar, Event, Project, EventDependency structs ├── db.rs # SQLite CRUD, schema migrations @@ -106,6 +194,8 @@ solverforge-calendar/ │ ├── auth.rs # OAuth via OS keyring │ ├── sync.rs # Incremental Google Calendar API sync │ └── types.rs # Google JSON → local Event conversion + ├── bin/ + │ └── solverforge-calendar-cli.rs # CLI entry point └── ui/ ├── month_view.rs # 5-week calendar grid ├── week_view.rs # Hourly time grid diff --git a/assets/mascot-20260403.png b/assets/mascot-20260403.png new file mode 100644 index 0000000..15faa80 Binary files /dev/null and b/assets/mascot-20260403.png differ diff --git a/screenshot.png b/assets/screenshot.png similarity index 100% rename from screenshot.png rename to assets/screenshot.png diff --git a/docs/wireframes/cli.md b/docs/wireframes/cli.md new file mode 100644 index 0000000..c2a108e --- /dev/null +++ b/docs/wireframes/cli.md @@ -0,0 +1,41 @@ +# CLI Wireframe + +## Command shape + +```text +solverforge-calendar-cli [flags] +``` + +Supported groups: + +- `calendars` +- `projects` +- `events` +- `dependencies` +- `google sync` + +## Success response + +```json +{ + "status": "ok", + "data": { "...": "..." } +} +``` + +## Error response + +```json +{ + "status": "error", + "code": "validation_error", + "message": "human-readable explanation" +} +``` + +## Behavioral notes + +- Parsing is strict: unknown flags and malformed values fail fast. +- Destructive commands require flags instead of prompts. +- `calendars delete` cannot remove the last active calendar. +- `google sync` is non-interactive and must never depend on TUI state. diff --git a/docs/wireframes/tui.md b/docs/wireframes/tui.md new file mode 100644 index 0000000..fafe645 --- /dev/null +++ b/docs/wireframes/tui.md @@ -0,0 +1,27 @@ +# TUI Wireframe + +```text +┌──────────────── SolverForge Calendar ────────────────┬──────────── Sidebar ────────────┐ +│ Month / Week / Day / Agenda │ Calendars │ +│ Date range + sync status │ [x] Personal │ +├──────────────────────────────────────────────────────┤ [x] Work │ +│ │ [ ] Travel │ +│ Main calendar surface ├──────────────────────────────────┤ +│ │ Projects │ +│ - Month: 5-week grid │ Launch │ +│ - Week: hourly time grid │ Planning │ +│ - Day: single-day schedule ├──────────────────────────────────┤ +│ - Agenda: sorted upcoming events │ Selected event summary │ +│ │ Title │ +│ │ Time │ +│ │ Project / dependency hints │ +├──────────────────────────────────────────────────────┴──────────────────────────────────┤ +│ Status bar: key hints, transient errors, Google auth/sync state │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +## Interaction notes + +- Event creation/editing depends on at least one active calendar. +- Sidebar visibility controls filter the rendered event set. +- Google auth and sync status should remain visible without leaving the main workflow. diff --git a/scripts/solverforge-calendar-cli b/scripts/solverforge-calendar-cli new file mode 100755 index 0000000..fc03e8d --- /dev/null +++ b/scripts/solverforge-calendar-cli @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd) + +exec cargo run --manifest-path "$repo_root/Cargo.toml" --bin solverforge-calendar-cli -- "$@" diff --git a/src/bin/solverforge-calendar-cli.rs b/src/bin/solverforge-calendar-cli.rs new file mode 100644 index 0000000..64c6a05 --- /dev/null +++ b/src/bin/solverforge-calendar-cli.rs @@ -0,0 +1,40 @@ +use clap::{error::ErrorKind, Parser}; + +fn main() { + let cli = match solverforge_calendar::cli::Cli::try_parse() { + Ok(cli) => cli, + Err(err) => match err.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { + print!("{}", err); + std::process::exit(0); + } + _ => { + eprintln!( + "{}", + serde_json::to_string_pretty(&solverforge_calendar::cli::error_value( + &solverforge_calendar::cli::CliError::invalid_arguments(err.to_string()), + )) + .expect("serializable clap error") + ); + std::process::exit(2); + } + }, + }; + + match solverforge_calendar::cli::execute(cli) { + Ok(value) => { + println!( + "{}", + serde_json::to_string_pretty(&value).expect("serializable success payload") + ); + } + Err(err) => { + eprintln!( + "{}", + serde_json::to_string_pretty(&solverforge_calendar::cli::error_value(&err)) + .expect("serializable error payload") + ); + std::process::exit(1); + } + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..c04d83e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,1426 @@ +use anyhow::Context; +use chrono::NaiveDateTime; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::{dag::EventDag, db, google, models}; + +#[derive(Debug, Parser)] +#[command(name = "solverforge-calendar-cli", about = "JSON-first automation CLI")] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + Calendars { + #[command(subcommand)] + action: CalendarCommand, + }, + Projects { + #[command(subcommand)] + action: ProjectCommand, + }, + Events { + #[command(subcommand)] + action: EventCommand, + }, + Dependencies { + #[command(subcommand)] + action: DependencyCommand, + }, + Google { + #[command(subcommand)] + action: GoogleCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum CalendarCommand { + List, + Get { id: String }, + Create(CalendarCreateArgs), + Update(CalendarUpdateArgs), + Delete(CalendarDeleteArgs), +} + +#[derive(Debug, Subcommand)] +pub enum ProjectCommand { + List, + Get { id: String }, + Create(ProjectCreateArgs), + Update(ProjectUpdateArgs), + Delete(ProjectDeleteArgs), +} + +#[derive(Debug, Subcommand)] +pub enum EventCommand { + List(EventListArgs), + Get { id: String }, + Create(EventCreateArgs), + Update(EventUpdateArgs), + Delete { id: String }, +} + +#[derive(Debug, Subcommand)] +pub enum DependencyCommand { + List, + Get { id: String }, + Create(DependencyCreateArgs), + Update(DependencyUpdateArgs), + Delete { id: String }, +} + +#[derive(Debug, Subcommand)] +pub enum GoogleCommand { + Sync(GoogleSyncArgs), +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum CalendarSourceArg { + Local, + Google, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum DependencyTypeArg { + Blocks, + Related, +} + +#[derive(Debug, Args)] +pub struct CalendarCreateArgs { + #[arg(long)] + name: String, + #[arg(long)] + color: String, + #[arg(long, value_enum, default_value_t = CalendarSourceArg::Local)] + source: CalendarSourceArg, + #[arg(long)] + google_id: Option, + #[arg(long, default_value_t = true, value_parser = clap::builder::BoolishValueParser::new())] + visible: bool, + #[arg(long, default_value_t = 0)] + position: i64, +} + +#[derive(Debug, Args)] +pub struct CalendarUpdateArgs { + id: String, + #[arg(long)] + name: Option, + #[arg(long)] + color: Option, + #[arg(long, value_enum)] + source: Option, + #[arg(long)] + google_id: Option, + #[arg(long, value_parser = clap::builder::BoolishValueParser::new())] + visible: Option, + #[arg(long)] + position: Option, +} + +#[derive(Debug, Args)] +pub struct CalendarDeleteArgs { + id: String, + #[arg(long)] + cascade_events: bool, +} + +#[derive(Debug, Args)] +pub struct ProjectCreateArgs { + #[arg(long)] + name: String, + #[arg(long)] + color: String, + #[arg(long)] + description: Option, +} + +#[derive(Debug, Args)] +pub struct ProjectUpdateArgs { + id: String, + #[arg(long)] + name: Option, + #[arg(long)] + color: Option, + #[arg(long)] + description: Option, +} + +#[derive(Debug, Args)] +pub struct ProjectDeleteArgs { + id: String, + #[arg(long)] + detach_events: bool, +} + +#[derive(Debug, Args)] +pub struct EventListArgs { + #[arg(long, requires = "to", value_parser = parse_timestamp_arg)] + from: Option, + #[arg(long, requires = "from", value_parser = parse_timestamp_arg)] + to: Option, +} + +#[derive(Debug, Args)] +pub struct EventCreateArgs { + #[arg(long)] + calendar_id: String, + #[arg(long)] + title: String, + #[arg(long)] + project_id: Option, + #[arg(long)] + description: Option, + #[arg(long)] + location: Option, + #[arg(long, value_parser = parse_timestamp_arg)] + start_at: String, + #[arg(long, value_parser = parse_timestamp_arg)] + end_at: String, + #[arg(long, default_value_t = false, value_parser = clap::builder::BoolishValueParser::new())] + all_day: bool, + #[arg(long)] + rrule: Option, + #[arg(long)] + reminder_minutes: Option, + #[arg(long, default_value = "UTC")] + timezone: String, +} + +#[derive(Debug, Args)] +pub struct EventUpdateArgs { + id: String, + #[arg(long)] + calendar_id: Option, + #[arg(long)] + title: Option, + #[arg(long)] + project_id: Option, + #[arg(long)] + clear_project_id: bool, + #[arg(long)] + description: Option, + #[arg(long)] + clear_description: bool, + #[arg(long)] + location: Option, + #[arg(long)] + clear_location: bool, + #[arg(long, value_parser = parse_timestamp_arg)] + start_at: Option, + #[arg(long, value_parser = parse_timestamp_arg)] + end_at: Option, + #[arg(long, value_parser = clap::builder::BoolishValueParser::new())] + all_day: Option, + #[arg(long)] + rrule: Option, + #[arg(long)] + clear_rrule: bool, + #[arg(long)] + reminder_minutes: Option, + #[arg(long)] + clear_reminder_minutes: bool, + #[arg(long)] + timezone: Option, +} + +#[derive(Debug, Args)] +pub struct DependencyCreateArgs { + #[arg(long)] + from_event_id: String, + #[arg(long)] + to_event_id: String, + #[arg(long, value_enum, default_value_t = DependencyTypeArg::Blocks)] + dependency_type: DependencyTypeArg, +} + +#[derive(Debug, Args)] +pub struct DependencyUpdateArgs { + id: String, + #[arg(long)] + from_event_id: Option, + #[arg(long)] + to_event_id: Option, + #[arg(long, value_enum)] + dependency_type: Option, +} + +#[derive(Debug, Args)] +pub struct GoogleSyncArgs { + #[arg(long)] + calendar_id: Option, +} + +#[derive(Debug, Clone)] +pub struct CliError { + pub code: &'static str, + pub message: String, +} + +impl CliError { + fn validation(message: impl Into) -> Self { + Self { + code: "validation_error", + message: message.into(), + } + } + + fn not_found(resource: &'static str, id: &str) -> Self { + Self { + code: "not_found", + message: format!("{} '{}' not found", resource, id), + } + } + + fn conflict(message: impl Into) -> Self { + Self { + code: "conflict", + message: message.into(), + } + } + + fn external(message: impl Into) -> Self { + Self { + code: "external_error", + message: message.into(), + } + } + + fn internal(message: impl Into) -> Self { + Self { + code: "internal_error", + message: message.into(), + } + } + + pub fn invalid_arguments(message: impl Into) -> Self { + Self { + code: "invalid_arguments", + message: message.into(), + } + } +} + +#[derive(Debug, Serialize)] +struct ErrorPayload<'a> { + status: &'static str, + code: &'a str, + message: &'a str, +} + +#[derive(Debug, Serialize)] +struct SuccessPayload { + status: &'static str, + data: T, +} + +#[derive(Debug, Serialize)] +struct DeleteData<'a> { + resource: &'a str, + id: String, +} + +#[derive(Debug, Serialize)] +struct SyncCalendarData { + calendar_id: String, + calendar_name: String, + google_id: Option, + events_added: usize, + events_updated: usize, +} + +pub fn error_value(err: &CliError) -> Value { + serde_json::to_value(ErrorPayload { + status: "error", + code: err.code, + message: &err.message, + }) + .expect("serializable error payload") +} + +pub fn execute(cli: Cli) -> Result { + let conn = db::open().map_err(|e| CliError::internal(e.to_string()))?; + execute_with_connection(&conn, cli) +} + +pub fn execute_with_connection(conn: &Connection, cli: Cli) -> Result { + execute_with_backend(conn, cli, &RealGoogleSyncBackend) +} + +fn execute_with_backend( + conn: &Connection, + cli: Cli, + google_sync_backend: &dyn GoogleSyncBackend, +) -> Result { + let data = match cli.command { + Command::Calendars { action } => handle_calendars(conn, action)?, + Command::Projects { action } => handle_projects(conn, action)?, + Command::Events { action } => handle_events(conn, action)?, + Command::Dependencies { action } => handle_dependencies(conn, action)?, + Command::Google { action } => { + handle_google_with_backend(conn, action, google_sync_backend)? + } + }; + Ok(success_value(data)) +} + +fn success_value(data: T) -> Value { + serde_json::to_value(SuccessPayload { status: "ok", data }).expect("serializable success") +} + +fn parse_timestamp_arg(value: &str) -> Result { + NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S") + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .map_err(|_| { + format!( + "invalid timestamp '{}'; expected YYYY-MM-DD HH:MM:SS", + value + ) + }) +} + +fn handle_calendars(conn: &Connection, action: CalendarCommand) -> Result { + match action { + CalendarCommand::List => Ok(json!(db::load_calendars(conn).map_err(internal_error)?)), + CalendarCommand::Get { id } => Ok(json!(require_resource( + db::get_calendar(conn, &id).map_err(internal_error)?, + "calendar", + &id + )?)), + CalendarCommand::Create(args) => { + let now = timestamp_now(); + let name = non_empty(args.name, "name")?; + let color = non_empty(args.color, "color")?; + let source = calendar_source_from_arg(args.source); + let google_id = normalize_optional(args.google_id); + validate_calendar_source(&source, google_id.as_deref())?; + + let calendar = models::Calendar { + id: Uuid::new_v4().to_string(), + name, + color, + source, + google_id, + visible: args.visible, + position: args.position, + created_at: now.clone(), + updated_at: now, + deleted_at: None, + }; + db::insert_calendar(conn, &calendar).map_err(internal_error)?; + Ok(json!(calendar)) + } + CalendarCommand::Update(args) => { + let mut calendar = require_resource( + db::get_calendar(conn, &args.id).map_err(internal_error)?, + "calendar", + &args.id, + )?; + if let Some(name) = args.name { + calendar.name = non_empty(name, "name")?; + } + if let Some(color) = args.color { + calendar.color = non_empty(color, "color")?; + } + if let Some(source) = args.source { + calendar.source = calendar_source_from_arg(source); + } + if let Some(visible) = args.visible { + calendar.visible = visible; + } + if let Some(position) = args.position { + calendar.position = position; + } + if args.google_id.is_some() { + calendar.google_id = normalize_optional(args.google_id); + } + if calendar.source == models::CalendarSource::Local { + calendar.google_id = None; + } + validate_calendar_source(&calendar.source, calendar.google_id.as_deref())?; + db::update_calendar(conn, &calendar).map_err(internal_error)?; + Ok(json!(require_resource( + db::get_calendar(conn, &args.id).map_err(internal_error)?, + "calendar", + &args.id, + )?)) + } + CalendarCommand::Delete(args) => { + let tx = conn.unchecked_transaction().map_err(internal_error)?; + require_resource( + db::get_calendar(&tx, &args.id).map_err(internal_error)?, + "calendar", + &args.id, + )?; + if db::count_active_calendars(&tx).map_err(internal_error)? <= 1 { + return Err(CliError::conflict("cannot delete the last active calendar")); + } + let active_events = + db::count_active_events_for_calendar(&tx, &args.id).map_err(internal_error)?; + if active_events > 0 && !args.cascade_events { + return Err(CliError::conflict( + "calendar has active events; rerun with --cascade-events to delete them too", + )); + } + if args.cascade_events { + for event_id in + db::load_active_event_ids_for_calendar(&tx, &args.id).map_err(internal_error)? + { + db::soft_delete_event(&tx, &event_id).map_err(internal_error)?; + } + } + db::soft_delete_calendar(&tx, &args.id).map_err(internal_error)?; + db::delete_sync_token(&tx, &args.id).map_err(internal_error)?; + tx.commit().map_err(internal_error)?; + Ok(json!(DeleteData { + resource: "calendar", + id: args.id, + })) + } + } +} + +fn handle_projects(conn: &Connection, action: ProjectCommand) -> Result { + match action { + ProjectCommand::List => Ok(json!(db::load_projects(conn).map_err(internal_error)?)), + ProjectCommand::Get { id } => Ok(json!(require_resource( + db::get_project(conn, &id).map_err(internal_error)?, + "project", + &id + )?)), + ProjectCommand::Create(args) => { + let now = timestamp_now(); + let project = models::Project { + id: Uuid::new_v4().to_string(), + name: non_empty(args.name, "name")?, + color: non_empty(args.color, "color")?, + description: normalize_optional(args.description), + created_at: now.clone(), + updated_at: now, + deleted_at: None, + }; + db::insert_project(conn, &project).map_err(internal_error)?; + Ok(json!(project)) + } + ProjectCommand::Update(args) => { + let mut project = require_resource( + db::get_project(conn, &args.id).map_err(internal_error)?, + "project", + &args.id, + )?; + if let Some(name) = args.name { + project.name = non_empty(name, "name")?; + } + if let Some(color) = args.color { + project.color = non_empty(color, "color")?; + } + if args.description.is_some() { + project.description = normalize_optional(args.description); + } + db::update_project(conn, &project).map_err(internal_error)?; + Ok(json!(require_resource( + db::get_project(conn, &args.id).map_err(internal_error)?, + "project", + &args.id, + )?)) + } + ProjectCommand::Delete(args) => { + require_resource( + db::get_project(conn, &args.id).map_err(internal_error)?, + "project", + &args.id, + )?; + let active_events = + db::count_active_events_for_project(conn, &args.id).map_err(internal_error)?; + if active_events > 0 && !args.detach_events { + return Err(CliError::conflict( + "project has active events; rerun with --detach-events to clear project_id first", + )); + } + + let tx = conn.unchecked_transaction().map_err(internal_error)?; + if args.detach_events { + db::clear_project_id_for_project(&tx, &args.id).map_err(internal_error)?; + } + db::soft_delete_project(&tx, &args.id).map_err(internal_error)?; + tx.commit().map_err(internal_error)?; + Ok(json!(DeleteData { + resource: "project", + id: args.id, + })) + } + } +} + +fn handle_events(conn: &Connection, action: EventCommand) -> Result { + match action { + EventCommand::List(args) => { + let events = match (args.from, args.to) { + (Some(from), Some(to)) => { + db::load_events_in_range(conn, &from, &to).map_err(internal_error)? + } + (None, None) => db::load_events(conn).map_err(internal_error)?, + _ => unreachable!("clap enforces paired args"), + }; + Ok(json!(events)) + } + EventCommand::Get { id } => Ok(json!(require_resource( + db::get_event(conn, &id).map_err(internal_error)?, + "event", + &id + )?)), + EventCommand::Create(args) => { + ensure_calendar_exists(conn, &args.calendar_id)?; + if let Some(project_id) = args.project_id.as_deref() { + ensure_project_exists(conn, project_id)?; + } + ensure_title(&args.title)?; + validate_event_datetime(&args.start_at, &args.end_at)?; + let now = timestamp_now(); + let event = models::Event { + id: Uuid::new_v4().to_string(), + calendar_id: args.calendar_id, + project_id: normalize_optional(args.project_id), + title: args.title.trim().to_string(), + description: normalize_optional(args.description), + location: normalize_optional(args.location), + start_at: args.start_at, + end_at: args.end_at, + all_day: args.all_day, + rrule: normalize_optional(args.rrule), + google_id: None, + google_etag: None, + reminder_minutes: args.reminder_minutes, + timezone: non_empty(args.timezone, "timezone")?, + created_at: now.clone(), + updated_at: now, + deleted_at: None, + }; + db::insert_event(conn, &event).map_err(internal_error)?; + Ok(json!(event)) + } + EventCommand::Update(args) => { + validate_event_update_args(&args)?; + let mut event = require_resource( + db::get_event(conn, &args.id).map_err(internal_error)?, + "event", + &args.id, + )?; + + if let Some(calendar_id) = args.calendar_id { + ensure_calendar_exists(conn, &calendar_id)?; + event.calendar_id = calendar_id; + } + if let Some(title) = args.title { + ensure_title(&title)?; + event.title = title.trim().to_string(); + } + if args.clear_project_id { + event.project_id = None; + } else if let Some(project_id) = args.project_id { + ensure_project_exists(conn, &project_id)?; + event.project_id = Some(project_id); + } + if args.clear_description { + event.description = None; + } else if args.description.is_some() { + event.description = normalize_optional(args.description); + } + if args.clear_location { + event.location = None; + } else if args.location.is_some() { + event.location = normalize_optional(args.location); + } + if let Some(start_at) = args.start_at { + event.start_at = start_at; + } + if let Some(end_at) = args.end_at { + event.end_at = end_at; + } + if let Some(all_day) = args.all_day { + event.all_day = all_day; + } + if args.clear_rrule { + event.rrule = None; + } else if args.rrule.is_some() { + event.rrule = normalize_optional(args.rrule); + } + if args.clear_reminder_minutes { + event.reminder_minutes = None; + } else if let Some(reminder_minutes) = args.reminder_minutes { + event.reminder_minutes = Some(reminder_minutes); + } + if let Some(timezone) = args.timezone { + event.timezone = non_empty(timezone, "timezone")?; + } + + validate_event_datetime(&event.start_at, &event.end_at)?; + db::update_event(conn, &event).map_err(internal_error)?; + Ok(json!(require_resource( + db::get_event(conn, &args.id).map_err(internal_error)?, + "event", + &args.id, + )?)) + } + EventCommand::Delete { id } => { + require_resource( + db::get_event(conn, &id).map_err(internal_error)?, + "event", + &id, + )?; + db::soft_delete_event(conn, &id).map_err(internal_error)?; + Ok(json!(DeleteData { + resource: "event", + id, + })) + } + } +} + +fn handle_dependencies(conn: &Connection, action: DependencyCommand) -> Result { + match action { + DependencyCommand::List => Ok(json!(db::load_dependencies(conn).map_err(internal_error)?)), + DependencyCommand::Get { id } => Ok(json!(require_resource( + db::get_dependency(conn, &id).map_err(internal_error)?, + "dependency", + &id, + )?)), + DependencyCommand::Create(args) => { + validate_dependency_endpoints(conn, &args.from_event_id, &args.to_event_id)?; + validate_dependency_edge( + conn, + &args.from_event_id, + &args.to_event_id, + dependency_type_from_arg(args.dependency_type), + None, + )?; + let now = timestamp_now(); + let dependency = models::EventDependency { + id: Uuid::new_v4().to_string(), + from_event_id: args.from_event_id, + to_event_id: args.to_event_id, + dependency_type: dependency_type_from_arg(args.dependency_type), + created_at: now.clone(), + updated_at: now, + }; + db::insert_dependency(conn, &dependency).map_err(internal_error)?; + Ok(json!(dependency)) + } + DependencyCommand::Update(args) => { + let mut dependency = require_resource( + db::get_dependency(conn, &args.id).map_err(internal_error)?, + "dependency", + &args.id, + )?; + if let Some(from_event_id) = args.from_event_id { + dependency.from_event_id = from_event_id; + } + if let Some(to_event_id) = args.to_event_id { + dependency.to_event_id = to_event_id; + } + if let Some(dependency_type) = args.dependency_type { + dependency.dependency_type = dependency_type_from_arg(dependency_type); + } + validate_dependency_endpoints( + conn, + &dependency.from_event_id, + &dependency.to_event_id, + )?; + validate_dependency_edge( + conn, + &dependency.from_event_id, + &dependency.to_event_id, + dependency.dependency_type.clone(), + Some(dependency.id.as_str()), + )?; + db::update_dependency(conn, &dependency).map_err(internal_error)?; + Ok(json!(require_resource( + db::get_dependency(conn, &args.id).map_err(internal_error)?, + "dependency", + &args.id, + )?)) + } + DependencyCommand::Delete { id } => { + require_resource( + db::get_dependency(conn, &id).map_err(internal_error)?, + "dependency", + &id, + )?; + db::delete_dependency(conn, &id).map_err(internal_error)?; + Ok(json!(DeleteData { + resource: "dependency", + id, + })) + } + } +} + +fn handle_google_with_backend( + conn: &Connection, + action: GoogleCommand, + google_sync_backend: &dyn GoogleSyncBackend, +) -> Result { + match action { + GoogleCommand::Sync(args) => { + let mut calendars: Vec<_> = db::load_calendars(conn) + .map_err(internal_error)? + .into_iter() + .filter(|calendar| calendar.source == models::CalendarSource::Google) + .collect(); + + if let Some(calendar_id) = args.calendar_id.as_deref() { + calendars.retain(|calendar| calendar.id == calendar_id); + if calendars.is_empty() { + return Err(CliError::not_found("google calendar", calendar_id)); + } + } + + if calendars.is_empty() { + return Err(CliError::conflict("no google calendars found to sync")); + } + + let rt = tokio::runtime::Runtime::new() + .context("failed to start tokio runtime") + .map_err(|e| CliError::internal(e.to_string()))?; + let mut results = Vec::with_capacity(calendars.len()); + let mut total_added = 0usize; + let mut total_updated = 0usize; + + for calendar in &calendars { + let (added, updated) = google_sync_backend.sync_calendar(&rt, conn, calendar)?; + total_added += added; + total_updated += updated; + results.push(SyncCalendarData { + calendar_id: calendar.id.clone(), + calendar_name: calendar.name.clone(), + google_id: calendar.google_id.clone(), + events_added: added, + events_updated: updated, + }); + } + + Ok(json!({ + "calendars_synced": results.len(), + "events_added": total_added, + "events_updated": total_updated, + "results": results, + })) + } + } +} + +trait GoogleSyncBackend { + fn sync_calendar( + &self, + runtime: &tokio::runtime::Runtime, + conn: &Connection, + calendar: &models::Calendar, + ) -> Result<(usize, usize), CliError>; +} + +struct RealGoogleSyncBackend; + +impl GoogleSyncBackend for RealGoogleSyncBackend { + fn sync_calendar( + &self, + runtime: &tokio::runtime::Runtime, + conn: &Connection, + calendar: &models::Calendar, + ) -> Result<(usize, usize), CliError> { + if let Some(result) = google_sync_override_result(calendar)? { + return result; + } + let client = google::auth::GoogleClient::from_keyring().ok_or_else(|| { + CliError::external("google credentials are not configured in keyring") + })?; + let sync_token = db::get_sync_token(conn, &calendar.id).map_err(internal_error)?; + let delta = runtime + .block_on(google::sync::fetch_calendar_delta( + &client, + calendar, + sync_token.as_deref(), + )) + .with_context(|| format!("sync failed for calendar '{}'", calendar.name)) + .map_err(|e| CliError::external(e.to_string()))?; + google::sync::apply_calendar_sync(conn, calendar, delta) + .with_context(|| { + format!( + "failed to persist sync results for calendar '{}'", + calendar.name + ) + }) + .map_err(|e| CliError::external(e.to_string())) + } +} + +#[derive(Debug, Default, Deserialize)] +struct GoogleSyncOverrideConfig { + #[serde(default)] + default: Option, + #[serde(default)] + by_calendar_id: std::collections::HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +struct GoogleSyncOverrideResult { + added: Option, + updated: Option, + error: Option, +} + +type GoogleSyncCounts = (usize, usize); +type MaybeGoogleSyncOverride = Option>; + +fn google_sync_override_result( + calendar: &models::Calendar, +) -> Result { + let Ok(raw) = std::env::var("SOLVERFORGE_CALENDAR_TEST_GOOGLE_SYNC") else { + return Ok(None); + }; + + let config: GoogleSyncOverrideConfig = serde_json::from_str(&raw) + .map_err(|e| CliError::internal(format!("invalid google sync override: {}", e)))?; + let result = config + .by_calendar_id + .get(&calendar.id) + .cloned() + .or(config.default); + + Ok(result.map(|result| { + if let Some(error) = result.error { + Err(CliError::external(error)) + } else { + Ok((result.added.unwrap_or(0), result.updated.unwrap_or(0))) + } + })) +} + +fn timestamp_now() -> String { + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() +} + +fn internal_error(err: impl std::fmt::Display) -> CliError { + CliError::internal(err.to_string()) +} + +fn require_resource(resource: Option, name: &'static str, id: &str) -> Result { + resource.ok_or_else(|| CliError::not_found(name, id)) +} + +fn non_empty(value: String, field: &'static str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(CliError::validation(format!("{} cannot be empty", field))); + } + Ok(trimmed) +} + +fn normalize_optional(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn validate_event_update_args(args: &EventUpdateArgs) -> Result<(), CliError> { + if args.project_id.is_some() && args.clear_project_id { + return Err(CliError::validation( + "cannot combine --project-id with --clear-project-id", + )); + } + if args.description.is_some() && args.clear_description { + return Err(CliError::validation( + "cannot combine --description with --clear-description", + )); + } + if args.location.is_some() && args.clear_location { + return Err(CliError::validation( + "cannot combine --location with --clear-location", + )); + } + if args.rrule.is_some() && args.clear_rrule { + return Err(CliError::validation( + "cannot combine --rrule with --clear-rrule", + )); + } + if args.reminder_minutes.is_some() && args.clear_reminder_minutes { + return Err(CliError::validation( + "cannot combine --reminder-minutes with --clear-reminder-minutes", + )); + } + Ok(()) +} + +fn ensure_title(title: &str) -> Result<(), CliError> { + if title.trim().is_empty() { + return Err(CliError::validation("title cannot be empty")); + } + Ok(()) +} + +fn validate_event_datetime(start_at: &str, end_at: &str) -> Result<(), CliError> { + let start = NaiveDateTime::parse_from_str(start_at, "%Y-%m-%d %H:%M:%S") + .map_err(|_| CliError::validation("invalid start_at timestamp"))?; + let end = NaiveDateTime::parse_from_str(end_at, "%Y-%m-%d %H:%M:%S") + .map_err(|_| CliError::validation("invalid end_at timestamp"))?; + if end < start { + return Err(CliError::validation( + "end_at must be greater than or equal to start_at", + )); + } + Ok(()) +} + +fn validate_calendar_source( + source: &models::CalendarSource, + google_id: Option<&str>, +) -> Result<(), CliError> { + match source { + models::CalendarSource::Google if google_id.is_none() => { + Err(CliError::validation("google calendars require --google-id")) + } + models::CalendarSource::Local if google_id.is_some() => Err(CliError::validation( + "local calendars cannot set --google-id", + )), + _ => Ok(()), + } +} + +fn ensure_calendar_exists(conn: &Connection, calendar_id: &str) -> Result<(), CliError> { + require_resource( + db::get_calendar(conn, calendar_id).map_err(internal_error)?, + "calendar", + calendar_id, + )?; + Ok(()) +} + +fn ensure_project_exists(conn: &Connection, project_id: &str) -> Result<(), CliError> { + require_resource( + db::get_project(conn, project_id).map_err(internal_error)?, + "project", + project_id, + )?; + Ok(()) +} + +fn ensure_event_exists(conn: &Connection, event_id: &str) -> Result<(), CliError> { + require_resource( + db::get_event(conn, event_id).map_err(internal_error)?, + "event", + event_id, + )?; + Ok(()) +} + +fn validate_dependency_endpoints( + conn: &Connection, + from_event_id: &str, + to_event_id: &str, +) -> Result<(), CliError> { + if from_event_id == to_event_id { + return Err(CliError::validation( + "dependency endpoints must reference two distinct events", + )); + } + ensure_event_exists(conn, from_event_id)?; + ensure_event_exists(conn, to_event_id)?; + Ok(()) +} + +fn validate_dependency_edge( + conn: &Connection, + from_event_id: &str, + to_event_id: &str, + dependency_type: models::DependencyType, + exclude_dependency_id: Option<&str>, +) -> Result<(), CliError> { + let dependencies = db::load_dependencies(conn).map_err(internal_error)?; + if dependencies.iter().any(|dependency| { + Some(dependency.id.as_str()) != exclude_dependency_id + && dependency.from_event_id == from_event_id + && dependency.to_event_id == to_event_id + }) { + return Err(CliError::conflict("dependency edge already exists")); + } + + if dependency_type != models::DependencyType::Blocks { + return Ok(()); + } + + let active_blocks: Vec<_> = dependencies + .into_iter() + .filter(|dependency| { + dependency.dependency_type == models::DependencyType::Blocks + && Some(dependency.id.as_str()) != exclude_dependency_id + }) + .collect(); + let mut dag = EventDag::from_dependencies(&active_blocks); + if dag.add_edge(from_event_id, to_event_id).is_err() { + return Err(CliError::conflict( + "dependency would create a cycle in blocks edges", + )); + } + Ok(()) +} + +fn calendar_source_from_arg(source: CalendarSourceArg) -> models::CalendarSource { + match source { + CalendarSourceArg::Local => models::CalendarSource::Local, + CalendarSourceArg::Google => models::CalendarSource::Google, + } +} + +fn dependency_type_from_arg(dependency_type: DependencyTypeArg) -> models::DependencyType { + match dependency_type { + DependencyTypeArg::Blocks => models::DependencyType::Blocks, + DependencyTypeArg::Related => models::DependencyType::Related, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use tempfile::TempDir; + + fn open_test_db() -> (TempDir, Connection) { + let temp = TempDir::new().unwrap(); + let db_path = temp.path().join("calendar.db"); + let conn = db::open_at(&db_path).unwrap(); + (temp, conn) + } + + fn seed_calendar(conn: &Connection, name: &str) -> models::Calendar { + let now = timestamp_now(); + let calendar = models::Calendar { + id: Uuid::new_v4().to_string(), + name: name.to_string(), + color: "#82FB9C".to_string(), + source: models::CalendarSource::Local, + google_id: None, + visible: true, + position: 0, + created_at: now.clone(), + updated_at: now, + deleted_at: None, + }; + db::insert_calendar(conn, &calendar).unwrap(); + calendar + } + + fn seed_project(conn: &Connection, name: &str) -> models::Project { + let now = timestamp_now(); + let project = models::Project { + id: Uuid::new_v4().to_string(), + name: name.to_string(), + color: "#ffaa00".to_string(), + description: None, + created_at: now.clone(), + updated_at: now, + deleted_at: None, + }; + db::insert_project(conn, &project).unwrap(); + project + } + + fn seed_event( + conn: &Connection, + calendar_id: &str, + project_id: Option, + title: &str, + ) -> models::Event { + let now = timestamp_now(); + let event = models::Event { + id: Uuid::new_v4().to_string(), + calendar_id: calendar_id.to_string(), + project_id, + title: title.to_string(), + description: None, + location: None, + start_at: "2026-03-30 09:00:00".to_string(), + end_at: "2026-03-30 10:00:00".to_string(), + all_day: false, + rrule: None, + google_id: None, + google_etag: None, + reminder_minutes: None, + timezone: "UTC".to_string(), + created_at: now.clone(), + updated_at: now, + deleted_at: None, + }; + db::insert_event(conn, &event).unwrap(); + event + } + + struct FakeGoogleSyncBackend { + default_result: Result<(usize, usize), CliError>, + results_by_calendar_id: HashMap>, + } + + impl FakeGoogleSyncBackend { + fn with_default(result: Result<(usize, usize), CliError>) -> Self { + Self { + default_result: result, + results_by_calendar_id: HashMap::new(), + } + } + + fn with_calendar_result( + mut self, + calendar_id: &str, + result: Result<(usize, usize), CliError>, + ) -> Self { + self.results_by_calendar_id + .insert(calendar_id.to_string(), result); + self + } + } + + impl GoogleSyncBackend for FakeGoogleSyncBackend { + fn sync_calendar( + &self, + _runtime: &tokio::runtime::Runtime, + _conn: &Connection, + calendar: &models::Calendar, + ) -> Result<(usize, usize), CliError> { + self.results_by_calendar_id + .get(&calendar.id) + .cloned() + .unwrap_or_else(|| self.default_result.clone()) + } + } + + #[test] + fn blocks_cycle_is_rejected() { + let (_temp, conn) = open_test_db(); + let calendar = seed_calendar(&conn, "Work"); + let event_a = seed_event(&conn, &calendar.id, None, "A"); + let event_b = seed_event(&conn, &calendar.id, None, "B"); + + let first = Cli { + command: Command::Dependencies { + action: DependencyCommand::Create(DependencyCreateArgs { + from_event_id: event_a.id.clone(), + to_event_id: event_b.id.clone(), + dependency_type: DependencyTypeArg::Blocks, + }), + }, + }; + execute_with_connection(&conn, first).unwrap(); + + let second = Cli { + command: Command::Dependencies { + action: DependencyCommand::Create(DependencyCreateArgs { + from_event_id: event_b.id.clone(), + to_event_id: event_a.id.clone(), + dependency_type: DependencyTypeArg::Blocks, + }), + }, + }; + let err = execute_with_connection(&conn, second).unwrap_err(); + assert_eq!(err.code, "conflict"); + } + + #[test] + fn project_delete_requires_detach_flag() { + let (_temp, conn) = open_test_db(); + let calendar = seed_calendar(&conn, "Work"); + let project = seed_project(&conn, "Launch"); + seed_event(&conn, &calendar.id, Some(project.id.clone()), "Milestone"); + + let cli = Cli { + command: Command::Projects { + action: ProjectCommand::Delete(ProjectDeleteArgs { + id: project.id.clone(), + detach_events: false, + }), + }, + }; + let err = execute_with_connection(&conn, cli).unwrap_err(); + assert_eq!(err.code, "conflict"); + } + + #[test] + fn calendar_delete_rejects_last_active_calendar() { + let (_temp, conn) = open_test_db(); + let only_calendar = db::load_calendars(&conn) + .unwrap() + .into_iter() + .next() + .unwrap(); + + let cli = Cli { + command: Command::Calendars { + action: CalendarCommand::Delete(CalendarDeleteArgs { + id: only_calendar.id, + cascade_events: false, + }), + }, + }; + let err = execute_with_connection(&conn, cli).unwrap_err(); + assert_eq!(err.code, "conflict"); + assert_eq!(err.message, "cannot delete the last active calendar"); + } + + #[test] + fn project_delete_with_detach_clears_project_id() { + let (_temp, conn) = open_test_db(); + let calendar = seed_calendar(&conn, "Work"); + let project = seed_project(&conn, "Launch"); + let event = seed_event(&conn, &calendar.id, Some(project.id.clone()), "Milestone"); + + let cli = Cli { + command: Command::Projects { + action: ProjectCommand::Delete(ProjectDeleteArgs { + id: project.id.clone(), + detach_events: true, + }), + }, + }; + execute_with_connection(&conn, cli).unwrap(); + + let updated = db::get_event(&conn, &event.id).unwrap().unwrap(); + assert_eq!(updated.project_id, None); + assert!(db::get_project(&conn, &project.id).unwrap().is_none()); + } + + #[test] + fn event_update_conflicting_flags_are_rejected() { + let (_temp, conn) = open_test_db(); + let calendar = seed_calendar(&conn, "Work"); + let event = seed_event(&conn, &calendar.id, None, "Milestone"); + + let cli = Cli { + command: Command::Events { + action: EventCommand::Update(EventUpdateArgs { + id: event.id, + calendar_id: None, + title: None, + project_id: None, + clear_project_id: false, + description: Some("new".to_string()), + clear_description: true, + location: None, + clear_location: false, + start_at: None, + end_at: None, + all_day: None, + rrule: None, + clear_rrule: false, + reminder_minutes: None, + clear_reminder_minutes: false, + timezone: None, + }), + }, + }; + + let err = execute_with_connection(&conn, cli).unwrap_err(); + assert_eq!(err.code, "validation_error"); + } + + #[test] + fn google_sync_without_google_calendars_returns_conflict() { + let (_temp, conn) = open_test_db(); + let cli = Cli { + command: Command::Google { + action: GoogleCommand::Sync(GoogleSyncArgs { calendar_id: None }), + }, + }; + + let backend = FakeGoogleSyncBackend::with_default(Ok((0, 0))); + let err = execute_with_backend(&conn, cli, &backend).unwrap_err(); + assert_eq!(err.code, "conflict"); + assert_eq!(err.message, "no google calendars found to sync"); + } + + #[test] + fn google_sync_reports_missing_credentials() { + let (_temp, conn) = open_test_db(); + let now = timestamp_now(); + let google_calendar = models::Calendar { + id: Uuid::new_v4().to_string(), + name: "Google Work".to_string(), + color: "#00aaff".to_string(), + source: models::CalendarSource::Google, + google_id: Some("google-work".to_string()), + visible: true, + position: 1, + created_at: now.clone(), + updated_at: now, + deleted_at: None, + }; + db::insert_calendar(&conn, &google_calendar).unwrap(); + + let cli = Cli { + command: Command::Google { + action: GoogleCommand::Sync(GoogleSyncArgs { calendar_id: None }), + }, + }; + let backend = FakeGoogleSyncBackend::with_default(Err(CliError::external( + "google credentials are not configured in keyring", + ))); + let err = execute_with_backend(&conn, cli, &backend).unwrap_err(); + assert_eq!(err.code, "external_error"); + } + + #[test] + fn google_sync_filters_to_selected_calendar() { + let (_temp, conn) = open_test_db(); + let now = timestamp_now(); + let google_calendar = models::Calendar { + id: Uuid::new_v4().to_string(), + name: "Google Work".to_string(), + color: "#00aaff".to_string(), + source: models::CalendarSource::Google, + google_id: Some("google-work".to_string()), + visible: true, + position: 1, + created_at: now.clone(), + updated_at: now.clone(), + deleted_at: None, + }; + let other_google_calendar = models::Calendar { + id: Uuid::new_v4().to_string(), + name: "Google Personal".to_string(), + color: "#ffaa00".to_string(), + source: models::CalendarSource::Google, + google_id: Some("google-personal".to_string()), + visible: true, + position: 2, + created_at: now.clone(), + updated_at: now, + deleted_at: None, + }; + db::insert_calendar(&conn, &google_calendar).unwrap(); + db::insert_calendar(&conn, &other_google_calendar).unwrap(); + + let cli = Cli { + command: Command::Google { + action: GoogleCommand::Sync(GoogleSyncArgs { + calendar_id: Some(google_calendar.id.clone()), + }), + }, + }; + let backend = FakeGoogleSyncBackend::with_default(Ok((7, 8))) + .with_calendar_result(&google_calendar.id, Ok((2, 3))); + let value = execute_with_backend(&conn, cli, &backend).unwrap(); + assert_eq!(value["status"], "ok"); + assert_eq!(value["data"]["calendars_synced"], 1); + assert_eq!(value["data"]["events_added"], 2); + assert_eq!(value["data"]["events_updated"], 3); + } +} diff --git a/src/db.rs b/src/db.rs index 7b8d45c..d154518 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use rusqlite::Connection; @@ -15,14 +15,18 @@ pub fn db_path() -> PathBuf { /* Open (or create) the database and run pending migrations. */ pub fn open() -> Result { - let path = db_path(); + open_at(db_path()) +} + +pub fn open_at(path: impl AsRef) -> Result { + let path = path.as_ref(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("cannot create data directory: {}", parent.display()))?; } - let conn = Connection::open(&path) + let conn = Connection::open(path) .with_context(|| format!("cannot open database: {}", path.display()))?; conn.execute_batch( @@ -33,6 +37,7 @@ pub fn open() -> Result { .context("cannot configure pragmas")?; migrate(&conn).context("schema migration failed")?; + ensure_default_calendar_if_none_active(&conn).context("default calendar recovery failed")?; Ok(conn) } @@ -163,18 +168,6 @@ fn migrate_v1(conn: &Connection) -> Result<()> { ", )?; - // Seed a default local calendar if the table is empty. - let count: i64 = conn.query_row("SELECT COUNT(*) FROM calendars", [], |row| row.get(0))?; - - if count == 0 { - let id = uuid::Uuid::new_v4().to_string(); - conn.execute( - "INSERT INTO calendars (id, name, color, source, position) - VALUES (?1, 'Personal', '#82FB9C', 'local', 0)", - [&id], - )?; - } - Ok(()) } @@ -214,6 +207,27 @@ pub fn load_calendars(conn: &Connection) -> Result> { .map_err(Into::into) } +fn now_timestamp() -> String { + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() +} + +fn insert_default_calendar(conn: &Connection) -> Result<()> { + let id = uuid::Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO calendars (id, name, color, source, position) + VALUES (?1, 'Personal', '#82FB9C', 'local', 0)", + [&id], + )?; + Ok(()) +} + +pub fn ensure_default_calendar_if_none_active(conn: &Connection) -> Result<()> { + if count_active_calendars(conn)? == 0 { + insert_default_calendar(conn)?; + } + Ok(()) +} + pub fn load_projects(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, name, color, description, created_at, updated_at, deleted_at @@ -332,7 +346,8 @@ pub fn update_event(conn: &Connection, ev: &Event) -> Result<()> { /* Soft-delete an event by setting deleted_at. */ pub fn soft_delete_event(conn: &Connection, event_id: &str) -> Result<()> { - let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let now = now_timestamp(); + delete_dependencies_for_event(conn, event_id)?; conn.execute( "UPDATE events SET deleted_at=?2, updated_at=?2 WHERE id=?1", [event_id, &now], @@ -343,7 +358,9 @@ pub fn soft_delete_event(conn: &Connection, event_id: &str) -> Result<()> { pub fn load_dependencies(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, from_event_id, to_event_id, dependency_type, created_at, updated_at - FROM event_dependencies", + FROM event_dependencies + WHERE from_event_id IN (SELECT id FROM events WHERE deleted_at IS NULL) + AND to_event_id IN (SELECT id FROM events WHERE deleted_at IS NULL)", )?; let rows = stmt.query_map([], |row| { let dep_type_str: String = row.get(3)?; @@ -365,6 +382,333 @@ pub fn load_dependencies(conn: &Connection) -> Result> { .map_err(Into::into) } +pub fn load_events(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, calendar_id, project_id, title, description, location, + start_at, end_at, all_day, rrule, google_id, google_etag, + reminder_minutes, timezone, created_at, updated_at, deleted_at + FROM events + WHERE deleted_at IS NULL + ORDER BY start_at, title", + )?; + query_events(&mut stmt, &[]) +} + +pub fn get_calendar(conn: &Connection, calendar_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, name, color, source, google_id, visible, position, + created_at, updated_at, deleted_at + FROM calendars + WHERE id = ?1 AND deleted_at IS NULL", + )?; + + let calendar = stmt + .query_row([calendar_id], |row| { + let source_str: String = row.get(3)?; + let source = if source_str == "google" { + CalendarSource::Google + } else { + CalendarSource::Local + }; + Ok(Calendar { + id: row.get(0)?, + name: row.get(1)?, + color: row.get(2)?, + source, + google_id: row.get(4)?, + visible: row.get::<_, i64>(5)? != 0, + position: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + deleted_at: row.get(9)?, + }) + }) + .optional()?; + + Ok(calendar) +} + +pub fn insert_calendar(conn: &Connection, calendar: &Calendar) -> Result<()> { + conn.execute( + "INSERT INTO calendars + (id, name, color, source, google_id, visible, position, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + calendar.id, + calendar.name, + calendar.color, + calendar.source.to_string(), + calendar.google_id, + calendar.visible as i64, + calendar.position, + calendar.created_at, + calendar.updated_at, + ], + )?; + Ok(()) +} + +pub fn update_calendar(conn: &Connection, calendar: &Calendar) -> Result<()> { + let now = now_timestamp(); + conn.execute( + "UPDATE calendars SET + name = ?2, + color = ?3, + source = ?4, + google_id = ?5, + visible = ?6, + position = ?7, + updated_at = ?8 + WHERE id = ?1", + rusqlite::params![ + calendar.id, + calendar.name, + calendar.color, + calendar.source.to_string(), + calendar.google_id, + calendar.visible as i64, + calendar.position, + now, + ], + )?; + Ok(()) +} + +pub fn soft_delete_calendar(conn: &Connection, calendar_id: &str) -> Result<()> { + let now = now_timestamp(); + conn.execute( + "UPDATE calendars SET deleted_at = ?2, updated_at = ?2 WHERE id = ?1", + [calendar_id, &now], + )?; + Ok(()) +} + +pub fn get_project(conn: &Connection, project_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, name, color, description, created_at, updated_at, deleted_at + FROM projects + WHERE id = ?1 AND deleted_at IS NULL", + )?; + + let project = stmt + .query_row([project_id], |row| { + Ok(Project { + id: row.get(0)?, + name: row.get(1)?, + color: row.get(2)?, + description: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + deleted_at: row.get(6)?, + }) + }) + .optional()?; + + Ok(project) +} + +pub fn insert_project(conn: &Connection, project: &Project) -> Result<()> { + conn.execute( + "INSERT INTO projects + (id, name, color, description, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + project.id, + project.name, + project.color, + project.description, + project.created_at, + project.updated_at, + ], + )?; + Ok(()) +} + +pub fn update_project(conn: &Connection, project: &Project) -> Result<()> { + let now = now_timestamp(); + conn.execute( + "UPDATE projects SET + name = ?2, + color = ?3, + description = ?4, + updated_at = ?5 + WHERE id = ?1", + rusqlite::params![ + project.id, + project.name, + project.color, + project.description, + now + ], + )?; + Ok(()) +} + +pub fn soft_delete_project(conn: &Connection, project_id: &str) -> Result<()> { + let now = now_timestamp(); + conn.execute( + "UPDATE projects SET deleted_at = ?2, updated_at = ?2 WHERE id = ?1", + [project_id, &now], + )?; + Ok(()) +} + +pub fn get_event(conn: &Connection, event_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, calendar_id, project_id, title, description, location, + start_at, end_at, all_day, rrule, google_id, google_etag, + reminder_minutes, timezone, created_at, updated_at, deleted_at + FROM events + WHERE id = ?1 AND deleted_at IS NULL", + )?; + + let rows = query_events(&mut stmt, &[event_id])?; + Ok(rows.into_iter().next()) +} + +pub fn get_dependency(conn: &Connection, dependency_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, from_event_id, to_event_id, dependency_type, created_at, updated_at + FROM event_dependencies + WHERE id = ?1 + AND from_event_id IN (SELECT id FROM events WHERE deleted_at IS NULL) + AND to_event_id IN (SELECT id FROM events WHERE deleted_at IS NULL)", + )?; + + let dependency = stmt + .query_row([dependency_id], |row| { + let dep_type_str: String = row.get(3)?; + let dependency_type = if dep_type_str == "blocks" { + DependencyType::Blocks + } else { + DependencyType::Related + }; + Ok(EventDependency { + id: row.get(0)?, + from_event_id: row.get(1)?, + to_event_id: row.get(2)?, + dependency_type, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + }) + .optional()?; + + Ok(dependency) +} + +pub fn insert_dependency(conn: &Connection, dependency: &EventDependency) -> Result<()> { + conn.execute( + "INSERT INTO event_dependencies + (id, from_event_id, to_event_id, dependency_type, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + dependency.id, + dependency.from_event_id, + dependency.to_event_id, + dependency.dependency_type.to_string(), + dependency.created_at, + dependency.updated_at, + ], + )?; + Ok(()) +} + +pub fn update_dependency(conn: &Connection, dependency: &EventDependency) -> Result<()> { + let now = now_timestamp(); + conn.execute( + "UPDATE event_dependencies SET + from_event_id = ?2, + to_event_id = ?3, + dependency_type = ?4, + updated_at = ?5 + WHERE id = ?1", + rusqlite::params![ + dependency.id, + dependency.from_event_id, + dependency.to_event_id, + dependency.dependency_type.to_string(), + now, + ], + )?; + Ok(()) +} + +pub fn delete_dependency(conn: &Connection, dependency_id: &str) -> Result<()> { + conn.execute( + "DELETE FROM event_dependencies WHERE id = ?1", + [dependency_id], + )?; + Ok(()) +} + +pub fn count_active_events_for_calendar(conn: &Connection, calendar_id: &str) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM events WHERE calendar_id = ?1 AND deleted_at IS NULL", + [calendar_id], + |row| row.get(0), + ) + .map_err(Into::into) +} + +pub fn count_active_calendars(conn: &Connection) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM calendars WHERE deleted_at IS NULL", + [], + |row| row.get(0), + ) + .map_err(Into::into) +} + +pub fn count_active_events_for_project(conn: &Connection, project_id: &str) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM events WHERE project_id = ?1 AND deleted_at IS NULL", + [project_id], + |row| row.get(0), + ) + .map_err(Into::into) +} + +pub fn load_active_event_ids_for_calendar( + conn: &Connection, + calendar_id: &str, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT id FROM events WHERE calendar_id = ?1 AND deleted_at IS NULL ORDER BY start_at, title", + )?; + let rows = stmt.query_map([calendar_id], |row| row.get(0))?; + rows.collect::>>() + .map_err(Into::into) +} + +pub fn clear_project_id_for_project(conn: &Connection, project_id: &str) -> Result<()> { + let now = now_timestamp(); + conn.execute( + "UPDATE events + SET project_id = NULL, updated_at = ?2 + WHERE project_id = ?1 AND deleted_at IS NULL", + [project_id, &now], + )?; + Ok(()) +} + +pub fn delete_dependencies_for_event(conn: &Connection, event_id: &str) -> Result<()> { + conn.execute( + "DELETE FROM event_dependencies + WHERE from_event_id = ?1 OR to_event_id = ?1", + [event_id], + )?; + Ok(()) +} + +pub fn delete_sync_token(conn: &Connection, calendar_id: &str) -> Result<()> { + conn.execute( + "DELETE FROM sync_tokens WHERE calendar_id = ?1", + [calendar_id], + )?; + Ok(()) +} + pub fn upsert_sync_token(conn: &Connection, calendar_id: &str, token: &str) -> Result<()> { let id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); @@ -400,3 +744,28 @@ impl OptionalExt for rusqlite::Result { } } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn open_at_reseeds_when_all_calendars_are_soft_deleted() { + let temp = TempDir::new().unwrap(); + let db_path = temp.path().join("calendar.db"); + + { + let conn = open_at(&db_path).unwrap(); + let calendars = load_calendars(&conn).unwrap(); + assert_eq!(calendars.len(), 1); + soft_delete_calendar(&conn, &calendars[0].id).unwrap(); + assert_eq!(count_active_calendars(&conn).unwrap(), 0); + } + + let reopened = open_at(&db_path).unwrap(); + let calendars = load_calendars(&reopened).unwrap(); + assert_eq!(calendars.len(), 1); + assert_eq!(calendars[0].name, "Personal"); + } +} diff --git a/src/google/auth.rs b/src/google/auth.rs index f3193be..ef97076 100644 --- a/src/google/auth.rs +++ b/src/google/auth.rs @@ -38,7 +38,6 @@ impl GoogleClient { write_keyring(KEYRING_CLIENT_SECRET_KEY, client_secret)?; Ok(()) } - } fn read_keyring(key: &str) -> Option { diff --git a/src/google/sync.rs b/src/google/sync.rs index d063df5..e56b51c 100644 --- a/src/google/sync.rs +++ b/src/google/sync.rs @@ -1,37 +1,58 @@ use anyhow::{Context, Result}; +use rusqlite::Connection; use crate::google::auth::GoogleClient; use crate::models::Calendar; +pub struct CalendarSyncDelta { + pub events_json: Vec, + pub new_sync_token: Option, +} + /* Sync a single Google Calendar into the local database. */ /* Returns (events_added, events_updated). */ pub async fn sync_calendar(client: &GoogleClient, calendar: &Calendar) -> Result<(usize, usize)> { + let conn = crate::db::open()?; + let sync_token = crate::db::get_sync_token(&conn, &calendar.id)?; + let delta = fetch_calendar_delta(client, calendar, sync_token.as_deref()).await?; + apply_calendar_sync(&conn, calendar, delta) +} + +pub async fn fetch_calendar_delta( + client: &GoogleClient, + calendar: &Calendar, + sync_token: Option<&str>, +) -> Result { let google_cal_id = calendar .google_id .as_deref() .ok_or_else(|| anyhow::anyhow!("calendar '{}' has no google_id", calendar.name))?; - // Get an access token let access_token = refresh_access_token(client).await?; - // Check for an existing sync token (incremental sync) - let conn = crate::db::open()?; - let sync_token = crate::db::get_sync_token(&conn, &calendar.id)?; - drop(conn); // release before async work - let (events_json, new_sync_token) = - fetch_events(&access_token, google_cal_id, sync_token.as_deref()).await?; + fetch_events(&access_token, google_cal_id, sync_token).await?; + + Ok(CalendarSyncDelta { + events_json, + new_sync_token, + }) +} +pub fn apply_calendar_sync( + conn: &Connection, + calendar: &Calendar, + delta: CalendarSyncDelta, +) -> Result<(usize, usize)> { let mut added = 0; let mut updated = 0; - let conn = crate::db::open()?; - for gev in &events_json { + for gev in &delta.events_json { // Skip cancelled events (soft-delete them locally) if gev["status"].as_str() == Some("cancelled") { if let Some(gid) = gev["id"].as_str() { // Find local event by google_id and soft-delete it - let _ = soft_delete_by_google_id(&conn, gid); + let _ = soft_delete_by_google_id(conn, gid); } continue; } @@ -40,12 +61,12 @@ pub async fn sync_calendar(client: &GoogleClient, calendar: &Calendar) -> Result Ok(event) => { // Check if we already have this event (by google_id) let exists = - event_exists_by_google_id(&conn, event.google_id.as_deref().unwrap_or(""))?; + event_exists_by_google_id(conn, event.google_id.as_deref().unwrap_or(""))?; if exists { - let _ = update_event_by_google_id(&conn, &event); + let _ = update_event_by_google_id(conn, &event); updated += 1; } else { - let _ = crate::db::insert_event(&conn, &event); + let _ = crate::db::insert_event(conn, &event); added += 1; } } @@ -57,8 +78,8 @@ pub async fn sync_calendar(client: &GoogleClient, calendar: &Calendar) -> Result } // Persist new sync token - if let Some(token) = new_sync_token { - crate::db::upsert_sync_token(&conn, &calendar.id, &token)?; + if let Some(token) = delta.new_sync_token { + crate::db::upsert_sync_token(conn, &calendar.id, &token)?; } Ok((added, updated)) @@ -205,3 +226,68 @@ fn urlenccode(s: &str) -> String { }) .collect() } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use tempfile::TempDir; + + use super::{apply_calendar_sync, CalendarSyncDelta}; + use crate::{ + db, + models::{Calendar, CalendarSource}, + }; + + #[test] + fn apply_calendar_sync_uses_supplied_connection() -> Result<()> { + let temp = TempDir::new()?; + let path = temp.path().join("calendar.db"); + let conn = db::open_at(&path)?; + + let calendar = Calendar { + id: "google-cal".to_string(), + name: "Google".to_string(), + color: "#50f872".to_string(), + source: CalendarSource::Google, + google_id: Some("google-cal-id".to_string()), + visible: true, + position: 0, + created_at: "2026-03-30 10:00:00".to_string(), + updated_at: "2026-03-30 10:00:00".to_string(), + deleted_at: None, + }; + db::insert_calendar(&conn, &calendar)?; + + let delta = CalendarSyncDelta { + events_json: vec![serde_json::json!({ + "id": "google-event-1", + "summary": "Imported event", + "etag": "\"etag-1\"", + "status": "confirmed", + "start": { + "dateTime": "2026-03-30T09:00:00Z", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2026-03-30T10:00:00Z" + } + })], + new_sync_token: Some("sync-token-1".to_string()), + }; + + let (added, updated) = apply_calendar_sync(&conn, &calendar, delta)?; + assert_eq!(added, 1); + assert_eq!(updated, 0); + + let events = db::load_events(&conn)?; + assert_eq!(events.len(), 1); + assert_eq!(events[0].calendar_id, calendar.id); + assert_eq!(events[0].google_id.as_deref(), Some("google-event-1")); + assert_eq!( + db::get_sync_token(&conn, &calendar.id)?.as_deref(), + Some("sync-token-1") + ); + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 0f40636..1ac403b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ /* Library re-exports for test access. */ pub mod app; +pub mod cli; pub mod dag; pub mod db; pub mod event; diff --git a/src/main.rs b/src/main.rs index e1c00db..dfa48ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,22 +11,9 @@ use crossterm::{ }; use ratatui::{backend::CrosstermBackend, Terminal}; -mod app; -mod dag; -mod db; -mod event; -mod google; -mod ical; -mod keys; -mod models; -mod notifications; -mod recurrence; -mod theme; -mod ui; -mod worker; - -use crate::app::App; -use crate::event::{Event, EventHandler}; +use solverforge_calendar::app::App; +use solverforge_calendar::event::{Event, EventHandler}; +use solverforge_calendar::{notifications, ui}; fn main() -> Result<()> { // Install panic hook that restores the terminal before printing the panic. diff --git a/src/ui/week_view.rs b/src/ui/week_view.rs index 5f5ee9d..ad79539 100644 --- a/src/ui/week_view.rs +++ b/src/ui/week_view.rs @@ -194,7 +194,7 @@ pub fn render_time_grid( .map(|e| { if let (Some(start), Some(end)) = (e.start_dt(), e.end_dt()) { let duration_mins = (end - start).num_minutes().max(0) as u32; - let duration_hours = ((duration_mins + 59) / 60).max(1); + let duration_hours = duration_mins.div_ceil(60).max(1); let max_rows = area.y + area.height - y; covered_until[d as usize] = covered_until[d as usize].max(hour + duration_hours); diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..49c2d8a --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,616 @@ +use assert_cmd::Command; +use serde_json::Value; +use std::path::Path; +use tempfile::TempDir; + +fn cli_command(temp: &TempDir) -> Command { + let mut cmd = Command::cargo_bin("solverforge-calendar-cli").unwrap(); + cmd.env("XDG_DATA_HOME", temp.path()); + cmd +} + +fn read_json(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).unwrap() +} + +fn first_calendar_id(temp: &TempDir) -> String { + let calendars = cli_command(temp) + .args(["calendars", "list"]) + .output() + .unwrap(); + assert!(calendars.status.success()); + let calendars_json = read_json(&calendars.stdout); + calendars_json["data"][0]["id"] + .as_str() + .unwrap() + .to_string() +} + +fn create_google_calendar(temp: &TempDir, name: &str) -> String { + let created = cli_command(temp) + .args([ + "calendars", + "create", + "--name", + name, + "--color", + "#50f872", + "--source", + "google", + "--google-id", + &format!("{}@example.com", name.to_lowercase()), + ]) + .output() + .unwrap(); + assert!(created.status.success()); + let created_json = read_json(&created.stdout); + created_json["data"]["id"].as_str().unwrap().to_string() +} + +#[test] +fn unknown_flag_returns_json_error() { + let temp = TempDir::new().unwrap(); + let output = cli_command(&temp) + .args(["events", "list", "--bogus"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + let err = read_json(&output.stderr); + assert_eq!(err["status"], "error"); + assert_eq!(err["code"], "invalid_arguments"); +} + +#[test] +fn invalid_timestamp_returns_json_error() { + let temp = TempDir::new().unwrap(); + let calendar_id = first_calendar_id(&temp); + let output = cli_command(&temp) + .args([ + "events", + "create", + "--calendar-id", + &calendar_id, + "--title", + "Planning", + "--start-at", + "bad-timestamp", + "--end-at", + "2026-03-30 10:00:00", + ]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + let err = read_json(&output.stderr); + assert_eq!(err["status"], "error"); + assert_eq!(err["code"], "invalid_arguments"); +} + +#[test] +fn missing_resource_returns_not_found_json_error() { + let temp = TempDir::new().unwrap(); + let output = cli_command(&temp) + .args(["events", "get", "missing-event"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(1)); + let err = read_json(&output.stderr); + assert_eq!(err["status"], "error"); + assert_eq!(err["code"], "not_found"); +} + +#[test] +fn invalid_enum_returns_json_error() { + let temp = TempDir::new().unwrap(); + let output = cli_command(&temp) + .args([ + "dependencies", + "create", + "--from-event-id", + "a", + "--to-event-id", + "b", + "--dependency-type", + "invalid", + ]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + let err = read_json(&output.stderr); + assert_eq!(err["status"], "error"); + assert_eq!(err["code"], "invalid_arguments"); +} + +#[test] +fn agent_wrapper_script_exists() { + assert!(Path::new("scripts/solverforge-calendar-cli").exists()); +} + +#[test] +fn event_crud_works_against_isolated_db() { + let temp = TempDir::new().unwrap(); + let calendar_id = first_calendar_id(&temp); + + let created = cli_command(&temp) + .args([ + "events", + "create", + "--calendar-id", + &calendar_id, + "--title", + "Planning", + "--start-at", + "2026-03-30 09:00:00", + "--end-at", + "2026-03-30 10:00:00", + ]) + .output() + .unwrap(); + assert!(created.status.success()); + let created_json = read_json(&created.stdout); + let event_id = created_json["data"]["id"].as_str().unwrap().to_string(); + + let updated = cli_command(&temp) + .args([ + "events", + "update", + &event_id, + "--location", + "HQ", + "--reminder-minutes", + "30", + ]) + .output() + .unwrap(); + assert!(updated.status.success()); + let updated_json = read_json(&updated.stdout); + assert_eq!(updated_json["data"]["location"], "HQ"); + assert_eq!(updated_json["data"]["reminder_minutes"], 30); + + let listed = cli_command(&temp) + .args(["events", "list"]) + .output() + .unwrap(); + assert!(listed.status.success()); + let listed_json = read_json(&listed.stdout); + assert_eq!(listed_json["data"].as_array().unwrap().len(), 1); +} + +#[test] +fn calendar_crud_and_source_validation_work() { + let temp = TempDir::new().unwrap(); + + let invalid_local = cli_command(&temp) + .args([ + "calendars", + "create", + "--name", + "Local", + "--color", + "#123456", + "--google-id", + "google-local", + ]) + .output() + .unwrap(); + assert_eq!(invalid_local.status.code(), Some(1)); + let invalid_local_json = read_json(&invalid_local.stderr); + assert_eq!(invalid_local_json["code"], "validation_error"); + + let created = cli_command(&temp) + .args([ + "calendars", + "create", + "--name", + "Work", + "--color", + "#50f872", + "--source", + "google", + "--google-id", + "work@example.com", + ]) + .output() + .unwrap(); + assert!(created.status.success()); + let created_json = read_json(&created.stdout); + let calendar_id = created_json["data"]["id"].as_str().unwrap().to_string(); + + let fetched = cli_command(&temp) + .args(["calendars", "get", &calendar_id]) + .output() + .unwrap(); + assert!(fetched.status.success()); + let fetched_json = read_json(&fetched.stdout); + assert_eq!(fetched_json["data"]["google_id"], "work@example.com"); + + let updated = cli_command(&temp) + .args([ + "calendars", + "update", + &calendar_id, + "--source", + "local", + "--name", + "Personal", + ]) + .output() + .unwrap(); + assert!(updated.status.success()); + let updated_json = read_json(&updated.stdout); + assert_eq!(updated_json["data"]["source"], "local"); + assert!(updated_json["data"]["google_id"].is_null()); + assert_eq!(updated_json["data"]["name"], "Personal"); +} + +#[test] +fn project_crud_works() { + let temp = TempDir::new().unwrap(); + + let created = cli_command(&temp) + .args([ + "projects", + "create", + "--name", + "Launch", + "--color", + "#ffaa00", + "--description", + "Initial launch", + ]) + .output() + .unwrap(); + assert!(created.status.success()); + let created_json = read_json(&created.stdout); + let project_id = created_json["data"]["id"].as_str().unwrap().to_string(); + + let updated = cli_command(&temp) + .args([ + "projects", + "update", + &project_id, + "--description", + "", + "--name", + "Launch v2", + ]) + .output() + .unwrap(); + assert!(updated.status.success()); + let updated_json = read_json(&updated.stdout); + assert_eq!(updated_json["data"]["name"], "Launch v2"); + assert!(updated_json["data"]["description"].is_null()); + + let listed = cli_command(&temp) + .args(["projects", "list"]) + .output() + .unwrap(); + assert!(listed.status.success()); + let listed_json = read_json(&listed.stdout); + assert_eq!(listed_json["data"].as_array().unwrap().len(), 1); +} + +#[test] +fn events_support_range_lists_and_clear_conflicts() { + let temp = TempDir::new().unwrap(); + let calendar_id = first_calendar_id(&temp); + + let project = cli_command(&temp) + .args([ + "projects", "create", "--name", "Launch", "--color", "#ffaa00", + ]) + .output() + .unwrap(); + assert!(project.status.success()); + let project_json = read_json(&project.stdout); + let project_id = project_json["data"]["id"].as_str().unwrap().to_string(); + + let first = cli_command(&temp) + .args([ + "events", + "create", + "--calendar-id", + &calendar_id, + "--project-id", + &project_id, + "--title", + "Planning", + "--description", + "Discuss plan", + "--start-at", + "2026-03-30 09:00:00", + "--end-at", + "2026-03-30 10:00:00", + ]) + .output() + .unwrap(); + assert!(first.status.success()); + let first_json = read_json(&first.stdout); + let event_id = first_json["data"]["id"].as_str().unwrap().to_string(); + + let second = cli_command(&temp) + .args([ + "events", + "create", + "--calendar-id", + &calendar_id, + "--title", + "Retro", + "--start-at", + "2026-03-31 09:00:00", + "--end-at", + "2026-03-31 10:00:00", + ]) + .output() + .unwrap(); + assert!(second.status.success()); + + let ranged = cli_command(&temp) + .args([ + "events", + "list", + "--from", + "2026-03-30 00:00:00", + "--to", + "2026-03-30 23:59:59", + ]) + .output() + .unwrap(); + assert!(ranged.status.success()); + let ranged_json = read_json(&ranged.stdout); + assert_eq!(ranged_json["data"].as_array().unwrap().len(), 1); + + let cleared = cli_command(&temp) + .args([ + "events", + "update", + &event_id, + "--clear-description", + "--clear-project-id", + ]) + .output() + .unwrap(); + assert!(cleared.status.success()); + let cleared_json = read_json(&cleared.stdout); + assert!(cleared_json["data"]["description"].is_null()); + assert!(cleared_json["data"]["project_id"].is_null()); + + let conflicting = cli_command(&temp) + .args([ + "events", + "update", + &event_id, + "--description", + "new", + "--clear-description", + ]) + .output() + .unwrap(); + assert_eq!(conflicting.status.code(), Some(1)); + let conflicting_json = read_json(&conflicting.stderr); + assert_eq!(conflicting_json["code"], "validation_error"); +} + +#[test] +fn dependency_crud_and_validation_work() { + let temp = TempDir::new().unwrap(); + let calendar_id = first_calendar_id(&temp); + + let first = cli_command(&temp) + .args([ + "events", + "create", + "--calendar-id", + &calendar_id, + "--title", + "A", + "--start-at", + "2026-03-30 09:00:00", + "--end-at", + "2026-03-30 10:00:00", + ]) + .output() + .unwrap(); + let first_json = read_json(&first.stdout); + let event_a = first_json["data"]["id"].as_str().unwrap().to_string(); + + let second = cli_command(&temp) + .args([ + "events", + "create", + "--calendar-id", + &calendar_id, + "--title", + "B", + "--start-at", + "2026-03-30 11:00:00", + "--end-at", + "2026-03-30 12:00:00", + ]) + .output() + .unwrap(); + let second_json = read_json(&second.stdout); + let event_b = second_json["data"]["id"].as_str().unwrap().to_string(); + + let created = cli_command(&temp) + .args([ + "dependencies", + "create", + "--from-event-id", + &event_a, + "--to-event-id", + &event_b, + "--dependency-type", + "related", + ]) + .output() + .unwrap(); + assert!(created.status.success()); + let created_json = read_json(&created.stdout); + let dependency_id = created_json["data"]["id"].as_str().unwrap().to_string(); + + let duplicate = cli_command(&temp) + .args([ + "dependencies", + "create", + "--from-event-id", + &event_a, + "--to-event-id", + &event_b, + "--dependency-type", + "related", + ]) + .output() + .unwrap(); + assert_eq!(duplicate.status.code(), Some(1)); + let duplicate_json = read_json(&duplicate.stderr); + assert_eq!(duplicate_json["code"], "conflict"); + + let updated = cli_command(&temp) + .args([ + "dependencies", + "update", + &dependency_id, + "--dependency-type", + "blocks", + ]) + .output() + .unwrap(); + assert!(updated.status.success()); + let updated_json = read_json(&updated.stdout); + assert_eq!(updated_json["data"]["dependency_type"], "blocks"); + + let listed = cli_command(&temp) + .args(["dependencies", "list"]) + .output() + .unwrap(); + assert!(listed.status.success()); + let listed_json = read_json(&listed.stdout); + assert_eq!(listed_json["data"].as_array().unwrap().len(), 1); + + let deleted = cli_command(&temp) + .args(["dependencies", "delete", &dependency_id]) + .output() + .unwrap(); + assert!(deleted.status.success()); + + let empty = cli_command(&temp) + .args(["dependencies", "list"]) + .output() + .unwrap(); + assert!(empty.status.success()); + let empty_json = read_json(&empty.stdout); + assert!(empty_json["data"].as_array().unwrap().is_empty()); +} + +#[test] +fn calendar_delete_requires_explicit_cascade_flag() { + let temp = TempDir::new().unwrap(); + + let created_calendar = cli_command(&temp) + .args([ + "calendars", + "create", + "--name", + "Work", + "--color", + "#50f872", + ]) + .output() + .unwrap(); + assert!(created_calendar.status.success()); + let calendar_json = read_json(&created_calendar.stdout); + let calendar_id = calendar_json["data"]["id"].as_str().unwrap().to_string(); + + let created_event = cli_command(&temp) + .args([ + "events", + "create", + "--calendar-id", + &calendar_id, + "--title", + "Standup", + "--start-at", + "2026-03-30 09:00:00", + "--end-at", + "2026-03-30 09:30:00", + ]) + .output() + .unwrap(); + assert!(created_event.status.success()); + + let blocked_delete = cli_command(&temp) + .args(["calendars", "delete", &calendar_id]) + .output() + .unwrap(); + assert_eq!(blocked_delete.status.code(), Some(1)); + let blocked_json = read_json(&blocked_delete.stderr); + assert_eq!(blocked_json["code"], "conflict"); + + let allowed_delete = cli_command(&temp) + .args(["calendars", "delete", &calendar_id, "--cascade-events"]) + .output() + .unwrap(); + assert!(allowed_delete.status.success()); + + let listed = cli_command(&temp) + .args(["events", "list"]) + .output() + .unwrap(); + assert!(listed.status.success()); + let listed_json = read_json(&listed.stdout); + assert!(listed_json["data"].as_array().unwrap().is_empty()); +} + +#[test] +fn calendar_delete_rejects_last_active_calendar_via_binary() { + let temp = TempDir::new().unwrap(); + let calendar_id = first_calendar_id(&temp); + + let blocked_delete = cli_command(&temp) + .args(["calendars", "delete", &calendar_id]) + .output() + .unwrap(); + assert_eq!(blocked_delete.status.code(), Some(1)); + let blocked_json = read_json(&blocked_delete.stderr); + assert_eq!(blocked_json["code"], "conflict"); +} + +#[test] +fn google_sync_reports_missing_calendar_filter() { + let temp = TempDir::new().unwrap(); + let output = cli_command(&temp) + .args(["google", "sync", "--calendar-id", "missing-calendar"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(1)); + let err = read_json(&output.stderr); + assert_eq!(err["code"], "not_found"); +} + +#[test] +fn google_sync_runs_through_binary_with_test_override() { + let temp = TempDir::new().unwrap(); + let calendar_id = create_google_calendar(&temp, "WorkGoogle"); + let override_json = format!( + "{{\"by_calendar_id\":{{\"{}\":{{\"added\":4,\"updated\":2}}}}}}", + calendar_id + ); + + let output = cli_command(&temp) + .env("SOLVERFORGE_CALENDAR_TEST_GOOGLE_SYNC", override_json) + .args(["google", "sync", "--calendar-id", &calendar_id]) + .output() + .unwrap(); + + assert!(output.status.success()); + let json = read_json(&output.stdout); + assert_eq!(json["status"], "ok"); + assert_eq!(json["data"]["calendars_synced"], 1); + assert_eq!(json["data"]["events_added"], 4); + assert_eq!(json["data"]["events_updated"], 2); +}