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.
+
+
+

+
+
-[](https://ratatui.rs/)
+ [](https://github.com/blackopsrepl/solverforge-calendar/actions/workflows/ci.yml)
+ [](https://github.com/blackopsrepl/solverforge-calendar)
+ [](https://www.rust-lang.org)
+ [](https://ratatui.rs/)
+
+
+
+A spiffy ratatui TUI calendar — local SQLite with Google Calendar sync and DAG-linked events.
-
+
## 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