diff --git a/CHANGELOG.md b/CHANGELOG.md index cc4c87d..08b878d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # changelog +## Unreleased +- Redeploys update news in place via `cite.lock` (commit it/keep it safe). +- Friendlier `metadata.yml`: inline timeline events, typos reported by line, flexible dates and links. +- Login with hidden password prompt and auto-refreshed sessions. +- Fixed private Markdown uploaded to the public bucket, broken timeline events, and edits not triggering a rebuild. +- Fixed `cite login` artist creation being rejected by row-level security, and login using a different Supabase project than deploy. + ## 0.1.0-beta.0 - Package renamed from `cite-cli` to `cite` (binary, installers, and repo `laezyAI/cite`) - Fixed `upgrade`, archived-project restore, and staging dry-run writing to the local database diff --git a/Cargo.lock b/Cargo.lock index 705e439..744a756 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -651,7 +651,6 @@ dependencies = [ "libsql", "percent-encoding", "ratatui", - "regex", "reqwest", "serde", "serde_json", @@ -3311,6 +3310,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -3482,30 +3487,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" dependencies = [ "lazy_static", - "symphonia-bundle-flac", "symphonia-bundle-mp3", "symphonia-codec-aac", "symphonia-codec-alac", "symphonia-codec-pcm", - "symphonia-codec-vorbis", "symphonia-core", "symphonia-format-isomp4", "symphonia-format-riff", "symphonia-metadata", ] -[[package]] -name = "symphonia-bundle-flac" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6405d5c34ff6f8f7ca08a4101efe66d21e180f7322ef9359900a0e55698a7892" -dependencies = [ - "log", - "symphonia-common", - "symphonia-core", - "symphonia-metadata", -] - [[package]] name = "symphonia-bundle-mp3" version = "0.6.1" @@ -3550,17 +3541,6 @@ dependencies = [ "symphonia-core", ] -[[package]] -name = "symphonia-codec-vorbis" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986" -dependencies = [ - "log", - "symphonia-common", - "symphonia-core", -] - [[package]] name = "symphonia-common" version = "0.6.1" @@ -4302,6 +4282,7 @@ dependencies = [ "getrandom 0.4.3", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index e80865a..850fbce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ tokio = { version = "1.44", features = ["rt", "rt-multi-thread", "macros", "fs", reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } sha2 = "0.10" percent-encoding = "2.3" -uuid = { version = "1.23", features = ["v4"] } +uuid = { version = "1.23", features = ["v4", "v5"] } colored = "3.0" thiserror = "2.0" tracing = "0.1" @@ -28,13 +28,12 @@ ratatui = { version = "0.30.2", features = ["crossterm"] } crossterm = { version = "0.29", features = ["event-stream"] } futures-util = { version = "0.3", default-features = false, features = ["std"] } libsql = { version = "0.10.0-pre.4", features = ["libsql-sys"] } -symphonia = { version = "0.6", default-features = false, features = ["mp3", "wav", "flac", "vorbis", "isomp4", "aac", "alac", "pcm"] } +symphonia = { version = "0.6", default-features = false, features = ["mp3", "wav", "isomp4", "aac", "alac", "pcm"] } imagesize = "0.15" [dev-dependencies] tempfile = "3.19" httpmock = "0.7" -regex = "1.13.0" [profile.dist] inherits = "release" diff --git a/README.md b/README.md index a0d79fe..7047a7d 100644 --- a/README.md +++ b/README.md @@ -32,30 +32,32 @@ cargo build --release ```bash cite init my-project # edit metadata.yml and add content files -cite doctor --path my-project -cite build --path my-project -cite login -cite deploy --path my-project +cite doctor --path my-project # check everything, with hints +cite login # prints the artist ids you can deploy as +cite deploy --path my-project # validates, rebuilds if needed, publishes ``` +`deploy` rebuilds changed sources itself and refuses to send anything `doctor` reports +as an error; `cite build` is only needed to inspect `build/content.json`. + ## Commands | Command | Description | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `init ` | Create project structure | -| `doctor` | Validate project, metadata, files, assets, config, content quality, and media; show project health and local analytics | +| `doctor` | Check the project, metadata, content, and media; show errors, warnings, and the last build and deploy | | `build` | Compile project → `build/content.json` (incremental) | -| `deploy` | Upload bundle to Supabase with verification | -| `deploy --staging` | Deploy to local cite.db instead of Supabase | -| `clean` | Remove build artifacts and cache | -| `rollback ` | Roll back to a previous deployment | -| `login` | Authenticate with Supabase credentials | +| `deploy` | Validate, rebuild if needed, and publish news, podcasts, timelines and assets; re-deploying updates in place | +| `deploy --dry-run` | Preview which episodes would be created or updated, from `cite.lock`, without contacting Supabase | +| `clean` | Remove `build/` and the incremental build cache (keeps `cite.lock` and deploy records) | +| `rollback ` | Remove the news a deployment created (updated items keep their content) | +| `login` | Sign in to Supabase and list the artists you can deploy as | | `upgrade` | Self-update CLI | | `uninstall` | Remove CLI and local data | -> Global options (all commands): `--path `, `--json`, `--quiet`, `--verbose`, `--dry-run` -> -> Command-specific flags: `build --force`, `deploy --staging`, `login --email --password ` +> Global options (all commands): `--path `, `--json`, `--quiet`, `--verbose` +> +> Command-specific flags: `build --force`, `deploy --dry-run`, `login --email ` (the password is asked for without echo, or read from `CITE_PASSWORD`) ## Interactive Terminal UI @@ -69,7 +71,7 @@ Run `cite` with no arguments to enter the TUI: | `PgUp` / `PgDn` | Fast-scroll analytics | | `←` / `→` | Navigate commands | | `Enter` | Execute command (confirms Y/N for deploy/rollback) / select project / expand item | -| Type | Enter arguments for the selected command in Details panel | +| Type | Enter arguments for the selected command in the Commands panel | | `Ctrl+r` | Refresh project list | | `Ctrl+e` | Open file editor picker (Projects panel) | | `Ctrl+l` / `Ctrl+a` | Toggle local / archived projects (Projects panel) | @@ -82,49 +84,82 @@ Run `cite` with no arguments to enter the TUI: ``` my-project/ -├── cite.toml # Project manifest (name, language, artist_id) +├── cite.toml # Project manifest (name, artist_id, optional [backend]) ├── metadata.yml # Podcast metadata -├── .gitignore # Ignores build/ +├── .gitignore # Ignores build/ and .cite/ ├── content/ # Markdown & BibTeX files ├── assets/ │ ├── audio/ # Podcast audio (optional) │ └── image/ # Thumbnails (optional) +├── cite.lock # News id of each deployed episode (commit this) +├── .cite/ # Deploy records for rollback (gitignored) └── build/ # Generated output (gitignored) ``` ## Metadata Model -Each entry in `metadata.yml` becomes one episode: +Each entry in `metadata.yml` becomes one episode (one news item in Supabase): ```yaml podcasts: - title: "My Podcast" file: content/my-article.md - source_url: "https://example.com" - category: "artificial intelligence" + category: "artificial intelligence" # must match a Supabase category (case-insensitive) + summary: "Up to 50 words shown in the app" # optional; defaults to the first 50 words of `file` + source_url: www.example.com/story # optional; https:// is added when missing audio: assets/audio/episode.mp3 # optional thumbnail: assets/image/thumb.jpg # optional timeline: # optional; deployed in order as timeline_news rows - - content/my-article.bib # BibTeX citation file -> inline events - - 26 # existing news item id -> linked row + - title: "Bill passes the Senate" # an event written inline + date: May 22, 2025 # optional; see below for accepted forms + url: example.com/story # optional (`link` works too) + description: "What happened" # optional + - content/my-article.bib # BibTeX citation file -> one event per entry + - content/earlier-episode.md # another episode of this project -> linked row + - 26 # news item already published (any artist) -> linked row ``` +- `title` (up to 500 characters), `file`, and `category` are required to deploy. +- `timeline` accepts at most one `.bib` file; entries are deployed in the order listed. + BibTeX dates come from `date` (e.g. `2025-05-22`) or `year` and `month`; links from + `url`, `link` or `doi`. +- Dates may be written `2025`, `2025-05`, `2025-05-22`, `2025/05/22`, `May 2025`, + `22 May 2025` or `May 22, 2025`; a missing month or day means the first. +- Links without a scheme (`www.example.com/story`) get `https://`. Anything that is still + not a web link (spaces, `ftp://`) is an error. +- Surrounding spaces are trimmed and empty optional values are ignored. +- A misspelled key is an error naming the key and line (e.g. ``unknown field `catgory` ``), + never silently ignored. +- Audio: mp3, wav, m4a or aac, up to 100 MB. Thumbnails: jpg, png, webp or gif, up to 5 MB. +- `cite doctor` checks all of the above; `cite deploy` runs the same checks first. + +### Updating episodes + +An episode is identified by its `file`. Titles, content, category, and assets can +change freely: `cite deploy` updates the same news item each time. Renaming the +Markdown file publishes a new item. + +`cite deploy` records which news item each episode became in `cite.lock`. Commit it +so every machine updates the same items; delete a line to publish that episode as a +new item on the next deploy. + ## Authentication -Credentials are stored at `~/.cite/credentials.toml` (via `login`) or read from -the `CITE_SUPABASE_URL` and `CITE_SUPABASE_API_KEY` environment variables. +cite finds the Supabase project in this order: -## Local Analytics +1. `[backend]` in `cite.toml` (`url`, `api_key`) +2. `CITE_SUPABASE_URL` and `CITE_SUPABASE_API_KEY` environment variables +3. `~/.cite/credentials.toml`, written the first time `cite login` asks for them -cite maintains a local database at `~/.cite/cite.db` for: +`cite login` signs in with your email and password and lists the artists you own; +use one of their ids as `artist_id` in `cite.toml`. If you have none, it offers to +create one. The session is refreshed automatically when it expires. -- Compiler cache (file hashes, UUID mappings) -- Build and deployment history -- Project and podcast statistics (word count, reading time, audio duration) -- Asset metadata and usage tracking -- Offline analytics — no network required +## Local Data -Override the database location with `CITE_DB_PATH`. +cite keeps build cache, build and deploy history, and a snapshot of each project's +last build in `~/.cite/cite.db` (override with `CITE_DB_PATH`). It powers the TUI +analytics and restoring archived projects, and works offline. ## Tests diff --git a/docs/PRD.md b/docs/PRD.md index 491eee0..dc3a83b 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,625 +1,377 @@ -## 1. Product Overview +# cite — Product Requirements -**cite** is a production-grade CLI tool for creating, validating, building, and deploying podcast content to a Supabase backend consumed by the aouxAI application. +## 1. Overview -The CLI manages the complete content lifecycle: +**cite** is a CLI and terminal UI for creating, validating, building, and deploying +podcast episodes to the Supabase backend that serves the aoux app. -- project initialization -- content validation -- deterministic compilation -- media processing -- remote deployment to Supabase -- rollback -- local analytics -- interactive terminal workflows +Authors write episodes by hand: Markdown for the text, `metadata.yml` for everything +else, plus optional audio, a thumbnail, and BibTeX citations. cite checks that input +against the database's rules, compiles it, and publishes it as news, podcast, and +timeline rows with their media in Supabase Storage. -The system is designed around a simple project structure, reproducible builds, safe deployments, and offline-first local state. All deployments are remote to Supabase; local state maintains deployment history for rollback and analytics. +### Users ---- - -# 2. Objectives - -- Provide consistent project scaffolding. -- Separate content, metadata, and deployment concerns. -- Ensure content and media quality through automated validation. -- Produce deterministic and incremental builds. -- Safely deploy content with verification and rollback. -- Maintain local analytics, history, and compiler cache. -- Support interactive TUI workflows and CI automation. -- Treat podcast text, audio, and media assets as first-class content. - ---- +- **Authors** who write and publish episodes for an artist they own. They edit files + by hand, so input must be forgiving where intent is clear and every mistake must be + reported by name before anything is sent. +- **Automation (CI)** that builds and deploys the same projects non-interactively, + using `--json` output and environment-variable credentials. -# 3. Core Concepts +### Goals -## Project +- One simple, fixed project layout. +- Catch every problem locally, before the first remote write. +- Deploys that are repeatable: deploying again updates the same rows. +- A way back: every deployment can be rolled back by id. +- Works offline for everything except deploy, rollback, login, and upgrade. -A project is a directory containing: +### Non-goals -- `cite.toml` -- `metadata.yml` -- content files -- media assets +- Editing the database schema or managing categories and domains (server-managed). +- Slugs, audio renditions/tiers, or publishing Markdown files themselves. +- Hosting or rendering content; the aoux app does that. --- -## Artist - -An existing Supabase user identified by UUID. - -Configured in: - -```toml -[project] -artist_id = "..." -``` - -The CLI user is the content creator. - ---- - -## Podcast - -A podcast item consists of: - -- Markdown content -- Optional audio -- Optional thumbnail -- Optional source URL -- Optional BibTeX citation - -Each podcast receives a persistent UUID during compilation. +## 2. Core Concepts ---- - -## Compiler Protocol +### Project -A versioned compiler transforms project files into a deployable bundle: +A directory with a fixed layout: ``` -project files → build/content.json +my-project/ +├── cite.toml # name, artist_id, optional [backend] +├── metadata.yml # one entry per episode +├── .gitignore # ignores build/ and .cite/ +├── content/ # Markdown and BibTeX +├── assets/ +│ ├── audio/ +│ └── image/ +├── cite.lock # episode file -> news id, per Supabase project (committed) +├── .cite/ # deployment records for rollback (gitignored) +└── build/ # content.json (gitignored) ``` -The compiler guarantees: +`--path` may point at a project or at a folder of projects; project commands then run +for each one. -- deterministic output -- incremental processing -- persistent identity -- asset metadata extraction +### Artist ---- +A row in `artists`, owned by the author's account (`artists.user_id`) and set as +`artist_id` in `cite.toml`. `cite login` lists the account's artists or creates one. +Deploy refuses an artist the logged-in account does not own. + +### Episode -## Deployment +One `metadata.yml` entry: Markdown content plus optional summary, source link, audio, +thumbnail, and timeline. It becomes one `news` row. -A deployment: +An episode is identified by its Markdown `file` path — the one value that stays fixed +while titles and content change. `cite.lock` maps that path to the news id it was +deployed as, so later deploys update the same row. Renaming the file publishes a new +item. -- uploads the complete bundle to Supabase -- synchronizes database records -- creates searchable/queryable data -- records deployment history +### Deployment -Every deployment receives a unique deployment ID. +One run of `cite deploy` against one Supabase project. It gets a unique id, and its +record of created rows and uploads lets `cite rollback ` remove them. --- -# 4. Metadata Model (`metadata.yml`) +## 3. Metadata Model (`metadata.yml`) ```yaml podcasts: - title: "My Podcast" file: content/my-article.md - source_url: "https://example.com" category: "artificial intelligence" + summary: "Up to 50 words shown in the app" + source_url: www.example.com/story audio: assets/audio/episode.mp3 thumbnail: assets/image/thumb.jpg timeline: - - content/my-article.bib # BibTeX citation file -> inline events - - 26 # existing news item id -> linked row + - title: "Bill passes the Senate" # event written inline + date: May 22, 2025 + url: example.com/story + - content/my-article.bib # BibTeX file -> one event per entry + - content/earlier-episode.md # another episode of this project -> linked row + - 26 # news item already published -> linked row ``` -Rules: +Fields: -- `artist_id` remains in `cite.toml` -- podcasts are defined as an array -- markdown remains separate from metadata -- no slugs -- no audio tiers -- `timeline` is a single ordered list mixing one BibTeX citation file (string path) with existing news item ids (integers); deploy writes its rows to `timeline_news` in declaration order, sharing one sort_order sequence -- linking between own episodes uses their deployed news ids, not titles -- other relationships are resolved during deployment -- audio, thumbnail, source_url, and timeline are optional fields +- `title`, `file`, and `category` are required to deploy. `category` must match an + existing Supabase category, ignoring case. +- `summary` (at most 50 words) defaults to the first 50 words of the Markdown prose. +- `source_url`, `audio`, `thumbnail`, and `timeline` are optional. +- `artist_id` lives in `cite.toml`, not here. ---- +Timeline: -# 5. CLI Commands +- A single ordered list; each entry's kind follows from its value: + - a mapping with `title` (optional `date`, `url`, `description`) is an inline event; + - a `.bib` path is a citation file whose entries become events (at most one per episode); + - any other path is another episode's `file`; + - a number (or quoted number) is an existing news id. +- Rows are written in the order listed, sharing one `sort_order` sequence. +- Authors link their own episodes by file path, never by database id; links are + resolved at deploy time. -| Command | Description | -| -------------------------- | ----------------------------------------------- | -| `init ` | Create project structure | -| `doctor` | Validate project, metadata, content, and assets | -| `lint` | Analyze content and media quality | -| `build` | Compile project into `build/content.json` | -| `deploy` | Upload bundle and synchronize Supabase backend | -| `deploy --staging` | Deploy to local cite.db instead of Supabase | -| `status` | Show project health and analytics | -| `clean` | Remove build artifacts and cache | -| `rollback ` | Roll back to a previous deployment | -| `login` | Authenticate and link account | -| `login --email --password` | Authenticate with email and password | -| `upgrade` | Update CLI | -| `uninstall` | Remove CLI | +Hand-written input rules — forgiving where intent is clear, strict where it is not: -### Options +- Values are trimmed; empty optional values count as absent. +- Dates may be `2025`, `2025-05`, `2025-05-22`, `2025/05/22`, `May 2025`, + `22 May 2025`, or `May 22, 2025`; a missing month or day means the first. +- Links without a scheme get `https://`; anything still not an http(s) link is an error. +- On events, `link` means `url` and `summary` means `description`. +- An unknown (e.g. misspelled) key is an error naming the key and its line. +- BibTeX dates come from `date`, or `year` and `month`; links from `url`, `link`, or `doi`. -**Global options** (available on all commands): +--- -``` ---path Path to project (default: current directory) --v, --verbose Detailed output --q, --quiet Suppress output ---json Machine-readable JSON output ---dry-run Preview changes without executing -``` +## 4. Commands ---- +| Command | Description | +| -------------------------- | ------------------------------------------------------------------------------- | +| `init ` | Create a project with starter files | +| `doctor` | Validate and lint the project; show the last build and deploy | +| `build` | Compile the project into `build/content.json` (incremental; `--force` rebuilds) | +| `deploy` | Validate, rebuild if needed, and publish (`--dry-run` previews) | +| `clean` | Remove `build/` and the build cache (keeps `cite.lock` and deploy records) | +| `rollback ` | Remove the news items and uploads a deployment created | +| `login` | Sign in with email and password; list or create the account's artist | +| `upgrade` | Update cite to the latest release | +| `uninstall` | Remove cite and its local data | -# 6. Build Pipeline +Global options: ``` -Project - | -Load Context - | -Resolve Paths - | -Validate Metadata - | -Validate Assets - | -Extract Media Metadata - | -Parse Markdown - | -Parse BibTeX - | -Resolve URLs - | -Assign Persistent UUIDs - | -Generate content.json - | -Update Cache +--path Project, or folder of projects (default: current directory) +-v, --verbose Detailed logs +-q, --quiet Errors only +--json Machine-readable JSON on stdout (init, build, deploy, doctor, clean, rollback) ``` -Compiler responsibilities: +`login --email` is optional; the password is read without echo or from `CITE_PASSWORD`. -- generate deterministic output -- maintain persistent UUID mapping -- embed markdown content -- parse citations -- extract audio metadata (duration, format, codec, bitrate) -- extract image metadata (format, dimensions, file size) -- calculate asset hashes -- perform incremental builds -- invalidate cache on compiler version change +Exit status is non-zero when a command fails, including doctor finding errors. --- -## Media Metadata +## 5. Validation (`cite doctor`) + +Doctor reports three levels: -### Audio Assets +- **Error** — must be fixed; `cite deploy` runs the same checks first and refuses to + write anything while one remains, listing each. +- **Warning** — does not block, but should be reviewed. +- **Info** — context: files found, backend in use, last build and deployment id. -Extracted during build: +### Errors -- duration (in seconds) -- format (MP3, WAV, FLAC, etc.) -- codec -- bitrate (kbps) -- sample rate (Hz) -- channels -- file size (bytes) -- checksum (SHA-256) +Project: -Validation requirements: +- `cite.toml` or the metadata file is missing +- `artist_id` is set but not a UUID -- duration > 0 -- format is supported (MP3, WAV, FLAC, OGG, M4A) -- file is readable +Metadata: -### Image Assets +- empty title, or a title over 500 characters (episodes and timeline events) +- missing `category` +- `summary` over 50 words +- empty, duplicate, or missing `file`; missing `audio` or `thumbnail` file +- duplicate episode titles +- `source_url` or event `url` that is not an http(s) link +- timeline: more than one `.bib` file, a missing `.bib` file, a path that is not + another episode's file, an episode listing itself, a news id that is not positive -Extracted during build: +Content and media: -- format (JPEG, PNG, WebP, etc.) -- dimensions (width × height in pixels) -- file size (bytes) -- checksum (SHA-256) +- empty Markdown, or invalid YAML frontmatter +- audio not mp3/wav/m4a/aac, or over 100 MB (the `podcasts` bucket rules) +- thumbnail not jpg/png/webp/gif, over 5 MB, or smaller than 100×100 pixels -Validation requirements: +### Warnings -- format is supported (JPEG, PNG, WebP, GIF) -- file is readable -- dimensions >= 100×100 pixels (minimum) -- file size <= 5 MB +- `artist_id` empty; `content/` or `assets/` folders missing +- no episodes defined; duplicate `source_url` +- event date not understood (deployed without a date) +- unclosed or empty frontmatter +- BibTeX file with no entries, or duplicate entry titles -### Binary File Handling +### Lints (warnings, content quality) -Binary files are not stored in local analytics; only metadata and checksums are tracked. +- word count under 100 or over 50,000 +- long content (over 500 words) whose timeline cites no sources +- no H1/H2 headings; short paragraphs (under 20 words) +- the same paragraph in more than one episode +- audio under 1 minute or over 4 hours; bitrate under 128 or over 320 kbps; over 50 MB +- audio format or sample rate differing from the rest of the project +- thumbnail under 200×200 or over 8000×8000 pixels, or over 3 MB --- -# 7. Deployment Pipeline +## 6. Build ``` -content.json - | -Verify Supabase connectivity - | -Upload bundle - | -Verify upload - | -Process podcasts - | -For each podcast: - - Verify artist - - Resolve category - - Resolve URL/domain - - Create news record - - Create artist relationship - - Create metrics - - If audio present: upload audio - - If thumbnail present: upload thumbnail - - Create timeline records - | -Record deployment history - | -Verify deployment complete +cite.toml + metadata.yml + → hash every source file (stop if nothing changed) + → read Markdown, parse BibTeX, extract audio and image metadata + → derive stable ids from the project and file paths + → write build/content.json + → update the build cache and the local project snapshot ``` -Deployment guarantees: - -- deployment ID tagging -- dry-run support -- transactional writes where supported -- retry handling for failed uploads -- verification after completion -- rollback support by deployment ID - -Deployment error handling: - -- If deployment fails partway through, record partial state in local history -- `--dry-run` previews all actions without uploading -- Failed uploads retry up to 3 times before aborting +- Output is deterministic: the same sources and compiler version give the same JSON. +- A change to `cite.toml`, `metadata.yml`, or any referenced file triggers a rebuild; + a compiler version change invalidates the cache. +- Validation is not part of the build; doctor and deploy run it. +- Audio metadata: duration, format, codec, bitrate, sample rate, channels, size, SHA-256. + Image metadata: format, dimensions, size, SHA-256. --- -# 8. Project Structure +## 7. Deployment ``` -my-project/ -├── cite.toml -├── metadata.yml -├── .gitignore -├── content/ -├── assets/ -│ ├── audio/ -│ └── image/ -└── build/ +Validate: artist_id is a UUID and doctor reports no errors + → Build if any source changed + → (--dry-run stops here: previews creates/updates from cite.lock, no network) + → Connect, refreshing the login session if expired + → Pre-flight, before any write: + artist exists and belongs to the logged-in user + every episode's category exists (or can be created with the service key) + → Pass 1, per episode: + find its news row: the id in cite.lock, else the artist's latest news with the same title + create the news row, or update it in place (created_at and published_at unchanged) + upload thumbnail and audio under content-hashed names (unchanged files are skipped) + create, update, or remove the podcast row + record the news id in cite.lock + → Pass 2, per episode: rebuild its timeline_news rows + → Save cite.lock, the deployment record, and local history ``` -This structure is fixed. A local database at `~/.cite/cite.db` stores analytics, cache, and history. - ---- - -# 9. Local Analytics Database - -cite maintains a local database at `~/.cite/cite.db` for offline analytics, caching, and history. The database is created on first run and updated by `build` and `deploy` commands. - -Database schema is initialized on first run and migrated automatically on CLI upgrades. - ---- - -## Stored Data - -### Projects - -- project metadata -- local path -- artist ID -- language -- synchronization state with Supabase - ---- - -### Podcasts - -- metadata -- compiled content -- word count -- reading time -- audio metadata (if present) -- thumbnail metadata (if present) -- timestamps -- deployment status +Guarantees: + +- Idempotent: deploying again updates the same rows instead of duplicating them. +- Every created row and upload is recorded as soon as it exists; a failed deploy can be + resumed by deploying again, or undone with `cite rollback `. +- Uploads retry up to 3 times. +- Replaced media objects are removed from storage. +- Rollback deletes the news rows a deployment created (cascading to their podcast and + timeline rows) and its uploads. Rows it updated keep their content. It refuses a + deployment made to a different Supabase project. + +Database triggers create the `artists_news` link and follower notifications when a +news row is inserted; updates do not notify again. + +### Schema contract (`supabase.sql`) + +What cite writes, and the constraints validation enforces before any write: + +| metadata.yml | Supabase | +| ---------------------------- | ------------------------------------------------------------------------------------------- | +| episode | `news` row (`artist_id` from `cite.toml`; `published_at` set on first publish) | +| `category` | `news.category_id` (existing `categories` row) | +| `source_url` | `news.url_id` → `urls` row (+ `domains` when permitted) | +| `summary` / Markdown | `news.summary` | +| `thumbnail` | `news.thumbnail` = `assets//news_-.` | +| `audio` | `podcasts` row: `podcast_url` = `podcasts//podcast_-.`, `duration_minutes` | +| timeline event / BibTeX | `timeline_news` row: `title`, `description`, `url_id`, `event_date`, `sort_order` | +| timeline episode / news id | `timeline_news` row: `child_news_id`, `sort_order` | + +- Titles are at most 500 characters; `news.summary` at most 50 words + (`chk_news_summary_word_count`). +- `news.url_id` is required: episodes without `source_url` get an internal + `cite:////` url, which the app does not open. +- `timeline_news` rows are either a link (`child_news_id` only) or an event (`title`, + with `description` always sent because the app reads it as non-null), per + `chk_timeline_items_type`; a news item never links itself. +- Storage values are `bucket/object`, which the app's `resolveStorageUrl` turns into a + public URL. Objects live under the artist's folder, which the storage policy lets the + artist's owner write. +- Audio MIME types match the `podcasts` bucket allow-list. +- Categories and domains are writable only with the service role key. Authors use + existing categories; a url whose domain cannot be created is stored without one. +- `cite login` creates artists with `user_id` set to the signed-in user, as the + `artists_insert_own` policy requires. --- -### Assets - -Tracks: +## 8. Authentication -- asset type (audio, image) -- file path -- checksum (SHA-256) -- size (bytes) -- metadata (duration, dimensions, codec, etc.) -- usage (which podcast uses this asset) - ---- - -### Timeline Entries - -- BibTeX-derived references -- linked podcast -- timestamps - ---- +cite resolves the Supabase project the same way for `login`, `deploy`, and `rollback`: -### Build History +1. `[backend]` in `cite.toml` (`url`, `api_key`; `staging_url` and + `staging_service_key` are accepted as older names) +2. `CITE_SUPABASE_URL` and `CITE_SUPABASE_API_KEY` +3. `~/.cite/credentials.toml` (path overridable with `CITE_CREDS_PATH`) -- compiler version -- build duration (seconds) -- build statistics (podcast count, total word count, etc.) -- incremental build flag -- timestamp - ---- +```toml +[supabase] +url = "https://your-project.supabase.co" +api_key = "eyJhbG..." +``` -### Deployment History +When none is found, `cite login` asks for the URL and key (the key without echo) and +saves them with owner-only permissions. -- deployment ID -- counts (podcasts deployed, assets uploaded) -- status (success, partial, failed) -- timestamps (start, end) -- dry-run flag -- error messages (if failed) +`cite login` signs in with email and password and stores the session in +`~/.cite/session.json` (owner-only) with its expiry and Supabase URL. Deploy and +rollback refresh an expired session automatically and ignore a session issued by a +different project. Without a session, requests use the configured key — the service +role key for automation. --- -### Compiler Cache - -- file hashes (SHA-256) -- UUID mappings (file → persistent podcast UUID) -- compiler version (for cache invalidation) +## 9. Local Data ---- - -# 10. Analytics Capabilities +`~/.cite/cite.db` (override with `CITE_DB_PATH`) is created on first run and migrated +automatically. It holds: -Provides: +- **Projects** — name, path, artist id, metadata file, last sync. +- **Snapshot of the last build** — each episode's metadata entry, content, and word + count, and its timeline events; used for analytics and to restore archived projects. +- **Build history** — compiler version, duration, counts, incremental or full. +- **Deployment history** — id, time, status, and counts (dry runs are not recorded). +- **Build cache** — SHA-256 of every source file and the compiler version. -- project statistics (podcast count, total word count) -- podcast statistics (per-podcast metadata) -- word counts (total and per-podcast) -- reading time estimates (total and per-podcast) -- total audio duration -- average episode duration -- media storage usage (total audio size, total image size) -- asset statistics (count, types, sizes) -- citation statistics (count, references) -- build history (last build date, build count, average duration) -- deployment history (last deployment, deployment count, status summary) -- offline analytics (all analytics available without network) +Media files are never stored, only their paths. Rollback records (created and updated +news ids, uploads, target Supabase URL) live in the project's `.cite/deployments/`. --- -# 11. Interactive Terminal UI - -Running `cite` with no arguments (or `--tui` flag) opens a ratatui-based interface. - -3 column Layout: - -Column 1: +## 10. Terminal UI -- Projects — categorized list of local and archived projects +Running `cite` without a command opens the TUI. -Column 2: +Layout: -- Commands (top) — tab bar of available commands -- Details (bottom, 80% overlay on commands) — description and argument input for the selected command -- Logs (bottom, 60%) — async task output and status messages. - -Column 3: - -- Analytics — inline statistics and history +- **Projects** (left) — local and archived projects; archived ones can be restored + from the local snapshot. +- **Commands** (middle, top) — init, build, doctor, deploy, rollback, with the selected + command's description and argument input. +- **Logs** (middle, bottom) — output of the running command. +- **Analytics** (right) — collapsible sections: global summary, project statistics, + episodes, timelines, build history, deployment history. Controls: -| Key | Action | -| ------------------- | ------------------------------------- | -| `Ctrl+P` | Toggle command palette | -| `Ctrl+R` | Refresh project list | -| `Tab` / `Shift+Tab` | Cycle focus between panels | -| `↑` / `↓` | Navigate lists / scroll focused panel | -| `Enter` | Execute command / select file | -| `←` / `→` | Navigate commands | -| `Ctrl+Q` | Quit | - -Analytics section toggles (when Analytics panel is focused): - -| Key | Action | -| ------- | -------------------------- | -| `Alt+P` | Toggle podcasts section | -| `Alt+T` | Toggle timelines section | -| `Alt+B` | Toggle builds section | -| `Alt+D` | Toggle deployments section | - -Projects pane toggles (when Projects panel is focused): - -| Key | Action | -| ------- | ------------------------ | -| `Alt+L` | Toggle local projects | -| `Alt+A` | Toggle archived projects | - -## Analytics (Inline) - -Analytics are displayed inline in the Runner view (right panel, 40% width), not in a separate mode. Sections are collapsible and toggleable: - -- **Global Summary** — aggregate stats across all projects -- **Project Statistics** — podcast count, word count, audio duration and metadata, image metadata, timelines count, last build, last deployment with ID -- **Podcasts Metadata** — categorizedper-podcast metadata -- **Timelines** — timeline entries with dates and sources -- **Build History** — categorized by date, list of build durations and incremental/full flags with timestamps -- **Deployment History** — categorized by date, list of deployment IDs with timestamps - -Navigation within analytics uses `↑`/`↓` scroll when the analytics panel is focused. - ---- - -# 12. Validation - -## Doctor - -Validates project integrity and reports errors (must fix before deploy). Runs on `doctor` and before `build`/`deploy`. - -### Project - -- structure (cite.toml, metadata.yml, content/, assets/ exist) -- configuration (valid cite.toml syntax) -- required files present - -### Metadata - -- required fields (title, file for each podcast) -- duplicate podcast titles -- invalid values (empty strings, invalid UUIDs) -- unknown keys (warn on unexpected fields) -- file references exist - -### Markdown - -- empty content (file exists and is non-empty) -- syntax errors (invalid YAML frontmatter if present) -- broken internal references - -### Audio (if specified) - -- supported formats (MP3, WAV, FLAC, OGG, M4A) -- file readability (file exists and is readable) -- duration > 0 seconds -- file size <= 500 MB - -### Images (if specified) - -- supported formats (JPEG, PNG, WebP, GIF) -- file readability (file exists and is readable) -- dimensions >= 100×100 pixels -- file size <= 5 MB - -### BibTeX (if specified) - -- parser errors (valid BibTeX syntax) -- duplicate keys -- invalid fields - -### URLs (if specified) - -- basic validity (well-formed URLs) -- no duplicates within project - -Output levels: - -- **Error:** Blocks build/deploy (must fix) -- **Warning:** Does not block but should be reviewed -- **Information:** Informational only - ---- - -# 13. Linting - -Analyzes content and media quality. Lint warnings do not block builds but should be reviewed. - -## Content Quality - -- word count (warn if < 100 or > 50,000 words) -- reading time (estimated from word count) -- heading consistency (document should have H1/H2 structure) -- paragraph quality (warn on very short paragraphs < 20 words) -- citation density (warn if no citations in long content) -- duplicate sections (detect repeated paragraphs) - -## Media Quality - -- missing audio (info if none specified, since audio is optional) -- unusual duration (warn if < 1 min or > 4 hours) -- inconsistent encoding (warn if audio format differs from majority) -- oversized files (warn if audio > 200 MB, image > 3 MB) -- image quality (warn if dimensions < 200×200 or > 8000×8000) -- audio bitrate (warn if < 128 kbps or > 320 kbps) -- sample rate inconsistency (warn if project has mixed sample rates) - ---- - -# 14. Authentication & Credentials - -### Login Flow - -`cite login` prompts for: - -- Supabase project URL -- Supabase API key (public/anon key) - -Credentials are stored at `~/.cite/credentials.toml`: - -```toml -[supabase] -url = "https://your-project.supabase.co" -api_key = "eyJhbG..." -``` - -Alternatively, credentials can be provided via environment variables: - -```bash -CITE_SUPABASE_URL="https://your-project.supabase.co" -CITE_SUPABASE_API_KEY="eyJhbG..." -cite deploy -``` - -Credentials are never stored in `cite.toml` or project directories. - ---- - -# 15. Design Principles - -- deterministic builds (same input → same output) -- incremental compilation (unchanged files skip reprocessing) -- immutable deployment bundles (deployments cannot be modified, only rolled back) -- metadata/content separation (no frontmatter in markdown) -- UUID-based identity (persistent podcast IDs across rebuilds) -- automatic relationship resolution (at deployment time, not build time) -- local-first analytics (all analytics work offline) -- media-aware processing (audio/image metadata extracted automatically) -- reproducible compiler output (same files + same compiler version = same JSON) -- remote-only deployment (all uploads go to Supabase, no local staging) -- local staging deployment (`--staging` writes to cite.db for testing) -- database-independent storage (local database abstracted via standard SQL) - ---- - -# 16. Reliability Requirements - -cite provides: - -- deterministic compiler output (byte-for-byte identical builds) -- persistent UUID assignment (podcast UUIDs survive rebuild/redeploy) -- incremental builds (only reprocess changed files) -- deployment verification (verify all assets uploaded before marking success) -- rollback by deployment ID (revert to a previous deployment state) -- compiler protocol versioning (incompatible versions invalidate cache) -- structured machine-readable output (--json flag on all commands) -- safe failure handling (partial deployments logged; no silent failures) -- media integrity verification (checksums verify audio/image integrity) -- dry-run support (--dry-run previews all actions without uploading) -- retry logic (automatic retry on transient failures) -- offline capability (build/lint/doctor work without network; only deploy/login require network) +| Key | Action | +| --------------------- | ------------------------------------------------------------- | +| `Ctrl+K` | Toggle command palette | +| `Tab` / `Shift+Tab` | Cycle focus between panels | +| `↑` / `↓` | Navigate lists / scroll the focused panel | +| `PgUp` / `PgDn` | Fast-scroll analytics | +| `←` / `→` | Navigate commands | +| typing | Arguments for the selected command | +| `Enter` | Run command (Y/N confirm for deploy and rollback) / select | +| `Ctrl+R` | Refresh projects and analytics | +| `Ctrl+E` | Open a project file in `$VISUAL`/`$EDITOR` (Projects panel) | +| `Ctrl+L` / `Ctrl+A` | Collapse local / archived projects (Projects panel) | +| `Ctrl+P/T/B/D` | Collapse episodes, timelines, builds, deploys (Analytics) | +| `Ctrl+C` | Cancel a running command, or quit if idle | +| `Esc` | Close a dialog or clear typed arguments, else quit | +| `Ctrl+Q` | Quit | diff --git a/src/cli.rs b/src/cli.rs index 47e3beb..09df67b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,10 +1,11 @@ +//! Standalone CLI surface: argument shapes and per-command dispatch over the core library. use clap::{Parser, Subcommand}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tracing::{error, info, instrument, warn}; +use crate::core::CiteError; use crate::core::db::DbManager; -use crate::core::report::CiteError; -use crate::core::{compiler, deploy, doctor, project, scaffold, uninstall, upgrade}; +use crate::core::{auth, compiler, deploy, doctor, install, project, scaffold}; use colored::Colorize; fn print_json(value: &T) { @@ -44,64 +45,90 @@ fn report_result(cli: &Cli, result: Result, err_prefix: &str) #[command( name = "cite", version, - about = "Create, validate, build, and deploy podcast content to Supabase" + about = "Create, validate, build, and deploy podcast content to Supabase", + after_help = "Run without a command to open the interactive dashboard." )] pub struct Cli { #[command(subcommand)] pub command: Option, - #[arg(global = true, long, default_value = ".")] - pub path: String, + #[arg( + global = true, + long, + default_value = ".", + help = "Project directory, or a folder containing several projects" + )] + pub path: PathBuf, - #[arg(global = true, short, long)] + #[arg(global = true, short, long, help = "Show detailed logs")] pub verbose: bool, - #[arg(global = true, short, long)] + #[arg( + global = true, + short, + long, + conflicts_with = "verbose", + help = "Show errors only" + )] pub quiet: bool, - #[arg(global = true, long)] + #[arg(global = true, long, help = "Print machine-readable JSON on stdout")] pub json: bool, - - #[arg(global = true, long)] - pub dry_run: bool, } #[derive(Subcommand)] pub enum CliCommand { + #[command(about = "Create a new project with cite.toml, metadata.yml and folders")] Init { + #[arg(help = "Name of the project folder to create")] name: String, }, + #[command(about = "Compile the project into build/content.json")] Build { - #[arg(long)] + #[arg(long, help = "Rebuild even if nothing changed")] force: bool, }, + #[command(about = "Validate, build if needed, and publish episodes to Supabase")] Deploy { - #[arg(long)] - staging: bool, + #[arg( + long, + help = "Show what would be created or updated without contacting Supabase" + )] + dry_run: bool, }, + #[command(about = "Sign in to Supabase and list the artists you can deploy as")] Login { #[arg(long)] email: Option, - #[arg(long)] + #[arg( + long, + env = "CITE_PASSWORD", + hide_env_values = true, + help = "Prefer CITE_PASSWORD or the hidden prompt; flags end up in shell history" + )] password: Option, }, + #[command(about = "Check the project for problems before deploying")] Doctor, + #[command(about = "Remove build output and the incremental build cache")] Clean, + #[command(about = "Remove the news items and uploads a deployment created")] Rollback { + #[arg(help = "Deployment id printed by deploy (also shown by doctor)")] id: String, }, + #[command(about = "Update cite to the latest release")] Upgrade, + #[command(about = "Remove cite and its local data from this machine")] Uninstall, } #[instrument] fn load_projects( - path: &str, + root: &Path, empty_msg: &str, ) -> Result>, CiteError> { - let root = PathBuf::from(path); - let mut roots = project::discover_projects(&root); - roots.sort(); + let roots = project::discover_projects(root); if roots.is_empty() { warn!("{empty_msg}"); return Ok(None); @@ -115,212 +142,224 @@ fn load_projects( impl CliCommand { pub async fn execute(self, cli: &Cli) -> Result<(), CiteError> { - let path = &cli.path; match self { - CliCommand::Init { name } => { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let root = PathBuf::from(path).join(&name); - let root = if root.is_absolute() { - root - } else { - cwd.join(&root) - }; - scaffold::init_project(&name, &root)?; - if cli.json { - print_json( - &serde_json::json!({"status": "ok", "project": name, "root": root.to_string_lossy()}), - ); - } else { - println!( - "{}", - format!("Project '{name}' ready at {}", root.display()).green() - ); - } - Ok(()) - } - CliCommand::Build { force } => { - let db = DbManager::open().await?; - let Some(projects) = load_projects(path, "No projects found (no cite.toml found)")? - else { - return Ok(()); - }; - let multi = projects.len() > 1; - let mut has_errors = false; - for ctx in &projects { - print_group_header(multi, &ctx.manifest.project.name); - match compiler::compile(&db, ctx, force).await { - Ok(outcome) => { - if cli.json { - match &outcome { - compiler::CompileOutcome::UpToDate => { - print_json(&serde_json::json!({"status": "uptodate"})); - } - compiler::CompileOutcome::Complete { stats, artifact } => { - let mut v = serde_json::to_value(stats).unwrap_or_default(); - if let Some(obj) = v.as_object_mut() { - obj.insert("status".into(), "complete".into()); - obj.insert( - "artifact".into(), - artifact.to_string_lossy().into(), - ); - } - print_json(&v); - } - } - } else { - outcome.emit(); - } - } - Err(e) => { - error!("Build failed: {e}"); - has_errors = true; - } - } - } - if has_errors { - return Err(CiteError::Config( - "Build failed in one or more projects".to_string(), - )); - } else if !cli.json { - println!("{}", "Build complete".green()); - } - Ok(()) - } - CliCommand::Deploy { staging } => { - let db = DbManager::open().await?; - let Some(projects) = load_projects(path, "No projects found (no cite.toml found)")? - else { - return Ok(()); - }; - let multi = projects.len() > 1; - let mut has_errors = false; - for ctx in &projects { - print_group_header(multi, &ctx.manifest.project.name); - let result = if staging { - deploy::deploy_staging(&db, ctx, cli.dry_run).await - } else { - deploy::deploy(&db, ctx, cli.dry_run).await - }; - let err_prefix = if staging { - "Staging deploy failed" - } else { - "Deploy failed" - }; - if report_result(cli, result, err_prefix) { - has_errors = true; - } - } - if has_errors { - return Err(CiteError::Deploy( - "Deploy failed in one or more projects".to_string(), - )); - } else if !cli.json { - println!("{}", "Deploy complete".green()); - } - Ok(()) - } - CliCommand::Doctor => { - let db = DbManager::open().await?; - let Some(projects) = load_projects(path, "")? else { - if cli.json { - print_json( - &serde_json::json!({"status": "noproject", "errors": ["No cite.toml found"]}), - ); - } else { - info!("Running diagnostics"); - info!("cite.toml: missing (run 'cite init')"); - info!("metadata.yml: missing"); - } - return Ok(()); - }; - let multi = projects.len() > 1; - let mut all_outcomes: Vec = Vec::new(); - let mut has_errors = false; - let mut has_warnings = false; - for ctx in &projects { - print_group_header(multi, &ctx.manifest.project.name); - let outcome = doctor::run(&db, ctx).await?; - if cli.json { - if let Ok(v) = serde_json::to_value(&outcome) { - all_outcomes.push(v); - } - } else { - outcome.emit(); - } - if outcome.has_errors() { - has_errors = true; - } - if outcome.has_warnings() { - has_warnings = true; - } - if !cli.json { - project::print_status(&db, ctx).await; - } - } - if cli.json { - print_json(&all_outcomes); - } - if has_errors { - return Err(CiteError::Config( - "Doctor found validation errors".to_string(), - )); - } - if !cli.json && !has_warnings { - println!("{}", "Doctor check complete — no issues found".green()); - } - Ok(()) + CliCommand::Init { name } => run_init(cli, &name), + CliCommand::Build { force } => run_build(cli, force).await, + CliCommand::Deploy { dry_run } => run_deploy(cli, dry_run).await, + CliCommand::Doctor => run_doctor(cli).await, + CliCommand::Clean => run_clean(cli).await, + CliCommand::Rollback { id } => run_rollback(cli, &id).await, + CliCommand::Login { email, password } => run_login(cli, email, password).await, + CliCommand::Upgrade => run_upgrade().await, + CliCommand::Uninstall => install::uninstall(), + } + } +} + +fn run_init(cli: &Cli, name: &str) -> Result<(), CiteError> { + let root = std::path::absolute(cli.path.join(name))?; + scaffold::init_project(name, &root)?; + if cli.json { + print_json( + &serde_json::json!({"status": "ok", "project": name, "root": root.to_string_lossy()}), + ); + } else { + println!( + "{}", + format!("Project '{name}' ready at {}", root.display()).green() + ); + } + Ok(()) +} + +async fn run_build(cli: &Cli, force: bool) -> Result<(), CiteError> { + let db = DbManager::open().await?; + let Some(projects) = load_projects(&cli.path, "No projects found (no cite.toml found)")? else { + return Ok(()); + }; + let multi = projects.len() > 1; + let mut has_errors = false; + for ctx in &projects { + print_group_header(multi, &ctx.manifest.project.name); + match compiler::compile(&db, ctx, force).await { + Ok(outcome) => emit_build(cli, &outcome), + Err(e) => { + error!("Build failed: {e}"); + has_errors = true; } - CliCommand::Clean => { - let db = DbManager::open().await?; - let Some(projects) = load_projects(path, "No projects found")? else { - return Ok(()); - }; - let multi = projects.len() > 1; - for ctx in &projects { - print_group_header(multi, &ctx.manifest.project.name); - ctx.clean(&db).await?; - if cli.json { - print_json( - &serde_json::json!({"status": "ok", "project": ctx.manifest.project.name}), - ); - } else { - println!("{}", "Cleaned build artifacts".green()); - } - } - Ok(()) + } + } + if has_errors { + return Err(CiteError::Config( + "Build failed in one or more projects".to_string(), + )); + } else if !cli.json { + println!("{}", "Build complete".green()); + } + Ok(()) +} + +fn emit_build(cli: &Cli, outcome: &compiler::CompileOutcome) { + if cli.json { + match outcome { + compiler::CompileOutcome::UpToDate => { + print_json(&serde_json::json!({"status": "uptodate"})); } - CliCommand::Rollback { id } => { - let root = PathBuf::from(path); - let ctx = project::ProjectContext::load(&root)?; - let msg = deploy::rollback(&ctx, &id).await?; - if cli.json { - print_json(&serde_json::json!({"status": "ok", "message": msg})); - } else { - info!("{msg}"); + compiler::CompileOutcome::Complete { stats, artifact } => { + let mut v = serde_json::to_value(stats).unwrap_or_default(); + if let Some(obj) = v.as_object_mut() { + obj.insert("status".into(), "complete".into()); + obj.insert("artifact".into(), artifact.to_string_lossy().into()); } - Ok(()) - } - CliCommand::Login { email, password } => { - let backend = PathBuf::from(path).canonicalize().ok().and_then(|root| { - if root.join("cite.toml").exists() { - project::ProjectContext::load(&root) - .ok() - .and_then(|ctx| ctx.manifest.backend) - } else { - None - } - }); - deploy::login(None, backend, email, password).await?; - println!("{}", "Login complete".green()); - Ok(()) - } - CliCommand::Upgrade => { - let msg = upgrade::upgrade().await?; - info!("{msg}"); - println!("{}", "Upgrade complete".green()); - Ok(()) + print_json(&v); } - CliCommand::Uninstall => uninstall::uninstall(), } + } else { + outcome.emit(); + } +} + +async fn run_deploy(cli: &Cli, dry_run: bool) -> Result<(), CiteError> { + let db = DbManager::open().await?; + let Some(projects) = load_projects(&cli.path, "No projects found (no cite.toml found)")? else { + return Ok(()); + }; + let multi = projects.len() > 1; + let mut has_errors = false; + for ctx in &projects { + print_group_header(multi, &ctx.manifest.project.name); + let result = deploy::deploy(&db, ctx, dry_run).await; + if report_result(cli, result, "Deploy failed") { + has_errors = true; + } + } + if has_errors { + return Err(CiteError::Deploy( + "Deploy failed in one or more projects".to_string(), + )); + } else if !cli.json { + let done = if dry_run { + "Dry run complete" + } else { + "Deploy complete" + }; + println!("{}", done.green()); + } + Ok(()) +} + +async fn run_doctor(cli: &Cli) -> Result<(), CiteError> { + let db = DbManager::open().await?; + let Some(projects) = load_projects(&cli.path, "")? else { + if cli.json { + print_json( + &serde_json::json!({"status": "noproject", "errors": ["No cite.toml found"]}), + ); + } else { + info!("Running diagnostics"); + info!("cite.toml: missing (run 'cite init')"); + info!("metadata.yml: missing"); + } + return Ok(()); + }; + let multi = projects.len() > 1; + let mut all_outcomes: Vec = Vec::new(); + let mut has_errors = false; + let mut has_warnings = false; + for ctx in &projects { + print_group_header(multi, &ctx.manifest.project.name); + let outcome = doctor::run(&db, ctx).await; + if cli.json { + let mut value = serde_json::to_value(&outcome)?; + value["project"] = ctx.manifest.project.name.clone().into(); + all_outcomes.push(value); + } else { + outcome.emit(); + } + has_errors |= outcome.has_errors(); + has_warnings |= outcome.has_warnings(); + } + if cli.json { + print_json(&all_outcomes); + } + if has_errors { + return Err(CiteError::Config( + "Doctor found validation errors".to_string(), + )); + } + if !cli.json && !has_warnings { + println!("{}", "Doctor check complete — no issues found".green()); + } + Ok(()) +} + +async fn run_clean(cli: &Cli) -> Result<(), CiteError> { + let db = DbManager::open().await?; + let Some(projects) = load_projects(&cli.path, "No projects found")? else { + return Ok(()); + }; + let multi = projects.len() > 1; + for ctx in &projects { + print_group_header(multi, &ctx.manifest.project.name); + ctx.clean(&db).await?; + if cli.json { + print_json(&serde_json::json!({"status": "ok", "project": ctx.manifest.project.name})); + } else { + println!("{}", "Cleaned build artifacts".green()); + } + } + Ok(()) +} + +async fn run_rollback(cli: &Cli, id: &str) -> Result<(), CiteError> { + let ctx = project::ProjectContext::load(&cli.path)?; + let msg = deploy::rollback(&ctx, id).await?; + if cli.json { + print_json(&serde_json::json!({"status": "ok", "message": msg})); + } else { + info!("{msg}"); + } + Ok(()) +} + +async fn run_login( + cli: &Cli, + email: Option, + password: Option, +) -> Result<(), CiteError> { + let backend = project::ProjectContext::load(&cli.path) + .ok() + .and_then(|ctx| ctx.manifest.backend); + auth::login(backend, email, password).await?; + println!("{}", "Login complete".green()); + Ok(()) +} + +async fn run_upgrade() -> Result<(), CiteError> { + let msg = install::upgrade().await?; + info!("{msg}"); + println!("{}", "Upgrade complete".green()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn test_cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn test_dry_run_belongs_to_deploy() { + let cli = Cli::try_parse_from(["cite", "deploy", "--dry-run", "--path", "p"]).unwrap(); + assert!(matches!( + cli.command, + Some(CliCommand::Deploy { dry_run: true }) + )); + assert_eq!(cli.path, PathBuf::from("p")); + assert!(Cli::try_parse_from(["cite", "build", "--dry-run"]).is_err()); } } diff --git a/src/core/auth.rs b/src/core/auth.rs new file mode 100644 index 0000000..e208900 --- /dev/null +++ b/src/core/auth.rs @@ -0,0 +1,647 @@ +//! Supabase access: stored credentials, login session with auto-refresh, and authenticated connections. + +use std::io::{IsTerminal, Write}; +use std::path::PathBuf; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::terminal; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::{info, warn}; + +use crate::core::CiteError; +use crate::core::manifest::BackendConfig; +use crate::core::project::ProjectContext; +use crate::core::supabase::{Row, Supabase, row}; + +const TOKEN_EXPIRY_MARGIN_SECS: i64 = 60; + +pub struct Connection { + pub api: Supabase, + pub user_id: Option, +} + +pub async fn connect(ctx: &ProjectContext) -> Result { + let backend = resolve_backend(ctx.manifest.backend.as_ref())?; + let client = reqwest::Client::new(); + let base_url = base_url(&backend); + let api_key = backend.api_key.as_deref().unwrap_or_default(); + let session = active_session(&client, base_url, api_key).await?; + let bearer = resolve_bearer(&backend, session.as_ref())?; + Ok(Connection { + api: Supabase::new(client, base_url, api_key, &bearer), + user_id: session.map(|s| s.user_id), + }) +} + +pub fn backend_url(ctx: &ProjectContext) -> Result { + Ok(base_url(&resolve_backend(ctx.manifest.backend.as_ref())?).to_string()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SupabaseCredentials { + url: String, + api_key: String, +} + +#[derive(Serialize, Deserialize)] +struct CredentialsFile { + supabase: SupabaseCredentials, +} + +fn creds_path() -> PathBuf { + std::env::var_os("CITE_CREDS_PATH") + .map(PathBuf::from) + .unwrap_or_else(|| crate::core::cite_home().join("credentials.toml")) +} + +fn load_credentials() -> Result { + if let (Ok(url), Ok(key)) = ( + std::env::var("CITE_SUPABASE_URL"), + std::env::var("CITE_SUPABASE_API_KEY"), + ) { + info!("Using credentials from environment variables"); + return Ok(SupabaseCredentials { url, api_key: key }); + } + + let path = creds_path(); + if !path.exists() { + return Err(CiteError::Config( + "No credentials found. Run 'cite login' or set CITE_SUPABASE_URL and CITE_SUPABASE_API_KEY environment variables." + .to_string(), + )); + } + + let file: CredentialsFile = toml::from_str(&std::fs::read_to_string(&path)?).map_err(|e| { + CiteError::Config(format!( + "{} needs a [supabase] section with url and api_key: {e}", + path.display() + )) + })?; + info!("Loaded credentials from {}", path.display()); + Ok(file.supabase) +} + +fn save_credentials(creds: &SupabaseCredentials) -> Result<(), CiteError> { + let file = CredentialsFile { + supabase: creds.clone(), + }; + crate::core::write_private(&creds_path(), toml::to_string(&file)?.as_bytes())?; + Ok(()) +} + +fn resolve_backend(project_backend: Option<&BackendConfig>) -> Result { + if let Some(backend) = project_backend { + return Ok(backend.clone()); + } + let creds = load_credentials()?; + Ok(BackendConfig { + url: Some(creds.url), + api_key: Some(creds.api_key), + }) +} + +fn base_url(backend: &BackendConfig) -> &str { + backend + .url + .as_deref() + .unwrap_or_default() + .trim_end_matches('/') +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: String, + expires_in: i64, + user: TokenUser, +} + +#[derive(Deserialize)] +struct TokenUser { + id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Session { + access_token: String, + refresh_token: String, + email: String, + user_id: String, + expires_at: i64, + url: String, +} + +impl Session { + fn new(token: TokenResponse, email: String, url: &str) -> Self { + Self { + access_token: token.access_token, + refresh_token: token.refresh_token, + email, + user_id: token.user.id, + expires_at: chrono::Utc::now().timestamp() + token.expires_in, + url: url.to_string(), + } + } + + fn is_expired(&self) -> bool { + self.expires_at <= chrono::Utc::now().timestamp() + TOKEN_EXPIRY_MARGIN_SECS + } +} + +fn session_path() -> PathBuf { + crate::core::cite_home().join("session.json") +} + +fn load_session() -> Option { + let content = std::fs::read_to_string(session_path()).ok()?; + serde_json::from_str(&content).ok() +} + +fn save_session(session: &Session) -> Result<(), CiteError> { + crate::core::write_private(&session_path(), &serde_json::to_vec_pretty(session)?)?; + Ok(()) +} + +async fn request_token( + client: &reqwest::Client, + base_url: &str, + api_key: &str, + grant_type: &str, + body: Value, +) -> Result { + let url = format!("{base_url}/auth/v1/token?grant_type={grant_type}"); + let response = client + .post(&url) + .header("apikey", api_key) + .json(&body) + .send() + .await?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(CiteError::Auth(format!("HTTP {status} - {body}"))); + } + serde_json::from_str(&body).map_err(|e| CiteError::Auth(format!("Invalid token response: {e}"))) +} + +async fn active_session( + client: &reqwest::Client, + base_url: &str, + api_key: &str, +) -> Result, CiteError> { + let Some(session) = load_session().filter(|s| s.url == base_url) else { + return Ok(None); + }; + if !session.is_expired() { + return Ok(Some(session)); + } + + let body = serde_json::json!({ "refresh_token": session.refresh_token }); + let token = request_token(client, base_url, api_key, "refresh_token", body) + .await + .map_err(|e| CiteError::Auth(format!("Session expired; run 'cite login' again ({e})")))?; + let refreshed = Session::new(token, session.email, base_url); + save_session(&refreshed)?; + info!("Refreshed login session for {}", refreshed.email); + Ok(Some(refreshed)) +} + +fn resolve_bearer(backend: &BackendConfig, session: Option<&Session>) -> Result { + if let Some(s) = session { + return Ok(s.access_token.clone()); + } + match backend.api_key.as_deref() { + Some(key) if !key.is_empty() => Ok(key.to_string()), + _ => Err(CiteError::Auth( + "Not logged in and no backend.api_key configured. Run 'cite login' or set the key in cite.toml" + .to_string(), + )), + } +} + +pub async fn login( + project_backend: Option, + email: Option, + password: Option, +) -> Result<(), CiteError> { + let backend = match resolve_backend(project_backend.as_ref()) { + Ok(backend) => backend, + Err(_) => prompt_credentials()?, + }; + let url = base_url(&backend); + let api_key = backend.api_key.as_deref().unwrap_or_default(); + if url.is_empty() || api_key.is_empty() { + return Err(CiteError::Auth( + "A Supabase URL and API key are required for login. Set them in [backend] in cite.toml, ~/.cite/credentials.toml, or CITE_SUPABASE_URL/CITE_SUPABASE_API_KEY." + .to_string(), + )); + } + + let email = match email { + Some(e) => e, + None => prompt_line("Email: ")?, + }; + let password = match password { + Some(p) => p, + None => prompt_secret("Password: ")?, + }; + + let client = reqwest::Client::new(); + let body = serde_json::json!({ "email": email, "password": password }); + let token = request_token(&client, url, api_key, "password", body) + .await + .map_err(|e| CiteError::Auth(format!("Login failed: {e}")))?; + + let session = Session::new(token, email, url); + save_session(&session)?; + info!("Logged in as {}", session.email); + + let api = Supabase::new(client, url, api_key, &session.access_token); + match fetch_user_artists(&api, &session.user_id).await { + Ok(artists) if artists.is_empty() => { + warn!("No artist linked to this account"); + match prompt_create_artist(&api, &session.user_id).await? { + Some((id, name)) => info!("Created artist '{name}' ({id})"), + None => info!("Skipped artist creation"), + } + } + Ok(artists) => { + info!("Associated artist(s):"); + for (id, name) in &artists { + info!(" - {name} ({id})"); + } + } + Err(e) => warn!("Could not fetch artists: {e}"), + } + + Ok(()) +} + +async fn fetch_user_artists( + api: &Supabase, + user_id: &str, +) -> Result, CiteError> { + let rows = api + .select("artists", &format!("select=id,name&user_id=eq.{user_id}")) + .await?; + Ok(rows + .iter() + .filter_map(|r| { + Some(( + r["id"].as_str()?.to_string(), + r["name"].as_str()?.to_string(), + )) + }) + .collect()) +} + +async fn prompt_create_artist( + api: &Supabase, + user_id: &str, +) -> Result, CiteError> { + let name = prompt_line("Artist name: ")?; + if name.is_empty() { + return Ok(None); + } + let description = prompt_line("Description (optional): ")?; + let website = prompt_line("Website URL (optional): ")?; + + let payload = artist_row(&name, user_id, &description, &website); + let created = api.insert_returning("artists", &payload).await?; + let id = created["id"].as_str().unwrap_or_default().to_string(); + Ok(Some((id, name))) +} + +fn artist_row(name: &str, user_id: &str, description: &str, website: &str) -> Row { + let mut payload = row([ + ("name", Value::from(name)), + ("user_id", Value::from(user_id)), + ]); + if !description.is_empty() { + payload.insert("description".into(), Value::from(description)); + } + if !website.is_empty() { + let website = if website.contains("://") { + website.to_string() + } else { + format!("https://{website}") + }; + payload.insert("website_url".into(), Value::String(website)); + } + payload +} + +fn prompt_line(label: &str) -> Result { + print!("{label}"); + let _ = std::io::stdout().flush(); + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + Ok(s.trim().to_string()) +} + +fn prompt_secret(label: &str) -> Result { + if !std::io::stdin().is_terminal() { + return prompt_line(label); + } + print!("{label}"); + let _ = std::io::stdout().flush(); + terminal::enable_raw_mode()?; + let secret = read_hidden_line(); + let _ = terminal::disable_raw_mode(); + println!(); + secret +} + +fn read_hidden_line() -> Result { + let mut secret = String::new(); + loop { + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Enter => return Ok(secret), + KeyCode::Backspace => { + secret.pop(); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return Err(CiteError::Auth("Login cancelled".to_string())); + } + KeyCode::Char(c) => secret.push(c), + _ => {} + } + } +} + +fn prompt_credentials() -> Result { + println!("No credentials found. Let's set them up."); + let url = prompt_line("Supabase URL: ")?; + let api_key = prompt_secret("Supabase API key (anon/public): ")?; + save_credentials(&SupabaseCredentials { + url: url.clone(), + api_key: api_key.clone(), + })?; + Ok(BackendConfig { + url: Some(url), + api_key: Some(api_key), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn backend(url: &str, key: &str) -> BackendConfig { + BackendConfig { + url: Some(url.to_string()), + api_key: Some(key.to_string()), + } + } + + fn token(expires_in: i64) -> TokenResponse { + TokenResponse { + access_token: "user-jwt".into(), + refresh_token: "refresh".into(), + expires_in, + user: TokenUser { id: "uid".into() }, + } + } + + #[test] + fn test_resolve_bearer_uses_inline_when_no_session() { + assert_eq!( + resolve_bearer(&backend("https://x.supabase.co", "inline"), None).unwrap(), + "inline" + ); + } + + #[test] + fn test_resolve_bearer_prefers_session() { + let session = Session::new(token(3600), "a@b.c".into(), "https://x.supabase.co"); + assert_eq!( + resolve_bearer(&backend("https://x.supabase.co", "inline"), Some(&session)).unwrap(), + "user-jwt" + ); + } + + #[test] + fn test_resolve_bearer_errors_when_empty() { + assert!(resolve_bearer(&backend("https://x.supabase.co", ""), None).is_err()); + } + + #[test] + fn test_session_expiry() { + assert!(!Session::new(token(3600), "e".into(), "u").is_expired()); + assert!(Session::new(token(30), "e".into(), "u").is_expired()); + } + + #[test] + fn test_artist_row_is_owned_by_the_user() { + let payload = artist_row("Aoux", "uid", "", "aoux.ai"); + assert_eq!(payload["user_id"], "uid"); + assert_eq!(payload["website_url"], "https://aoux.ai"); + assert!(!payload.contains_key("description")); + } + + #[tokio::test] + async fn test_fetch_user_artists_filters_by_owner() { + let server = httpmock::MockServer::start(); + let owned = server.mock(|w, t| { + w.method(httpmock::Method::GET) + .path("/rest/v1/artists") + .query_param("user_id", "eq.uid"); + t.status(200) + .json_body(serde_json::json!([{ "id": "a1", "name": "Mine" }])); + }); + let api = Supabase::new(reqwest::Client::new(), &server.base_url(), "k", "t"); + let artists = fetch_user_artists(&api, "uid").await.unwrap(); + assert_eq!(artists, vec![("a1".to_string(), "Mine".to_string())]); + owned.assert(); + } +} + +#[cfg(test)] +mod credentials_tests { + use super::*; + use std::env; + use std::sync::{Mutex, MutexGuard}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn env_lock() -> MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + struct EnvGuard { + key: String, + previous: Option, + } + + impl EnvGuard { + fn set(key: &str, value: &str) -> Self { + let previous = env::var(key).ok(); + unsafe { + env::set_var(key, value); + } + Self { + key: key.to_string(), + previous, + } + } + + fn remove(key: &str) -> Self { + let previous = env::var(key).ok(); + unsafe { + env::remove_var(key); + } + Self { + key: key.to_string(), + previous, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(val) => unsafe { + env::set_var(&self.key, val); + }, + None => unsafe { + env::remove_var(&self.key); + }, + } + } + } + + #[test] + fn test_expired_session_is_refreshed_and_other_projects_ignored() { + let _lock = env_lock(); + let home = tempfile::tempdir().unwrap(); + let _home = EnvGuard::set("HOME", home.path().to_str().unwrap()); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(refresh_scenario()); + } + + async fn refresh_scenario() { + let server = httpmock::MockServer::start(); + let refresh = server.mock(|w, t| { + w.method(httpmock::Method::POST) + .path("/auth/v1/token") + .query_param("grant_type", "refresh_token") + .json_body(serde_json::json!({ "refresh_token": "old-refresh" })); + t.status(200).json_body(serde_json::json!({ + "access_token": "new-jwt", + "refresh_token": "new-refresh", + "expires_in": 3600, + "user": { "id": "uid" } + })); + }); + let url = server.base_url(); + let mut expired = Session::new( + TokenResponse { + access_token: "old-jwt".into(), + refresh_token: "old-refresh".into(), + expires_in: 0, + user: TokenUser { id: "uid".into() }, + }, + "a@b.c".into(), + &url, + ); + save_session(&expired).unwrap(); + let client = reqwest::Client::new(); + + let session = active_session(&client, &url, "k").await.unwrap().unwrap(); + assert_eq!(session.access_token, "new-jwt"); + assert_eq!( + load_session().unwrap().refresh_token, + "new-refresh", + "saved" + ); + refresh.assert(); + + expired.url = "https://other.supabase.co".into(); + save_session(&expired).unwrap(); + assert!(active_session(&client, &url, "k").await.unwrap().is_none()); + } + + #[test] + fn test_creds_path_default() { + let _lock = env_lock(); + let p = creds_path(); + assert!(p.to_string_lossy().contains(".cite/credentials.toml")); + } + + #[test] + fn test_credentials_from_env() { + let _lock = env_lock(); + let _guard_url = EnvGuard::set("CITE_SUPABASE_URL", "https://env-test.supabase.co"); + let _guard_key = EnvGuard::set("CITE_SUPABASE_API_KEY", "env-key-456"); + + let result = load_credentials(); + assert!(result.is_ok()); + let creds = result.unwrap(); + assert_eq!(creds.url, "https://env-test.supabase.co"); + assert_eq!(creds.api_key, "env-key-456"); + } + + #[test] + fn test_save_credentials_round_trip_escapes_values() { + let _lock = env_lock(); + let dir = tempfile::tempdir().unwrap(); + let creds_file = dir.path().join("nested").join("credentials.toml"); + let _guard = EnvGuard::set("CITE_CREDS_PATH", creds_file.to_str().unwrap()); + let _url_guard = EnvGuard::remove("CITE_SUPABASE_URL"); + let _key_guard = EnvGuard::remove("CITE_SUPABASE_API_KEY"); + + let creds = SupabaseCredentials { + url: "https://x.supabase.co".into(), + api_key: r#"key-with-"quote"-and\backslash"#.into(), + }; + save_credentials(&creds).unwrap(); + let loaded = load_credentials().unwrap(); + assert_eq!(loaded.url, creds.url); + assert_eq!(loaded.api_key, creds.api_key); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&creds_file).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[test] + fn test_credentials_file_invalid() { + let _lock = env_lock(); + let dir = tempfile::tempdir().unwrap(); + let creds_file = dir.path().join("credentials.toml"); + std::fs::write(&creds_file, "not-toml").unwrap(); + + let _guard = EnvGuard::set("CITE_CREDS_PATH", creds_file.to_str().unwrap()); + let result = load_credentials(); + assert!(result.is_err()); + } + + #[test] + fn test_credentials_missing_supabase_section() { + let _lock = env_lock(); + let dir = tempfile::tempdir().unwrap(); + let creds_file = dir.path().join("credentials.toml"); + std::fs::write(&creds_file, "[other]\nkey = \"val\"").unwrap(); + + let _guard = EnvGuard::set("CITE_CREDS_PATH", creds_file.to_str().unwrap()); + let _url_guard = EnvGuard::remove("CITE_SUPABASE_URL"); + let _key_guard = EnvGuard::remove("CITE_SUPABASE_API_KEY"); + let result = load_credentials(); + assert!(result.is_err()); + match result { + Err(CiteError::Config(msg)) => assert!(msg.contains("[supabase]"), "{msg}"), + _ => panic!("expected Config error"), + } + } +} diff --git a/src/core/bibtex.rs b/src/core/bibtex.rs new file mode 100644 index 0000000..6ed43f9 --- /dev/null +++ b/src/core/bibtex.rs @@ -0,0 +1,331 @@ +//! BibTeX citation files parsed into timeline events and rendered back (biblatex `date` beats `year`/`month`). + +use crate::core::metadata::TimelineEntry; + +const MONTHS: [&str; 12] = [ + "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", +]; + +pub fn parse(content: &str) -> Vec { + let mut entries = Vec::new(); + let mut pos = 0; + let bytes = content.as_bytes(); + + while pos < bytes.len() { + if bytes[pos] != b'@' { + pos += 1; + continue; + } + pos += 1; + + let Some(open) = content[pos..].find('{').map(|i| pos + i) else { + break; + }; + let entry_type = content[pos..open].trim().to_lowercase(); + let Some(close) = matching_brace(bytes, open) else { + break; + }; + pos = close + 1; + if matches!( + entry_type.as_str(), + "comment" | "string" | "preamble" | "xdata" + ) { + continue; + } + + let body = &content[open + 1..close]; + let field = |name| extract_field(body, name).filter(|v| !v.is_empty()); + let url = field("url") + .or_else(|| field("link")) + .or_else(|| field("doi").map(|doi| doi_url(&doi))); + let date = field("date") + .or_else(|| format_date(field("year").as_deref(), field("month").as_deref())); + entries.push(TimelineEntry { + id: String::new(), + date, + title: format_title( + &field("title").unwrap_or_default(), + &field("author").unwrap_or_default(), + ), + description: field("abstract").or_else(|| field("note")), + url, + }); + } + + entries +} + +pub fn render<'a>(entries: impl IntoIterator) -> String { + let mut out = String::new(); + for (i, entry) in entries.into_iter().enumerate() { + let date = entry.date.as_deref().unwrap_or_default(); + out.push_str(&format!("@misc{{restored{i},\n")); + out.push_str(&format!(" title = {{{}}},\n", sanitize(&entry.title))); + if date.len() > 7 { + out.push_str(&format!(" date = {{{date}}},\n")); + } else if let Some(year) = date.get(..4) { + out.push_str(&format!(" year = {{{year}}},\n")); + if let Some(month) = date + .get(5..7) + .and_then(|m| m.parse::().ok()) + .and_then(|m| MONTHS.get(m.checked_sub(1)?)) + { + out.push_str(&format!(" month = {{{month}}},\n")); + } + } + if let Some(description) = &entry.description { + out.push_str(&format!(" abstract = {{{}}},\n", sanitize(description))); + } + if let Some(url) = &entry.url { + out.push_str(&format!(" url = {{{url}}},\n")); + } + out.push_str("}\n\n"); + } + out +} + +fn matching_brace(bytes: &[u8], open: usize) -> Option { + let mut depth = 0usize; + for (i, &b) in bytes.iter().enumerate().skip(open) { + match b { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + +fn extract_field(body: &str, field: &str) -> Option { + let bytes = body.as_bytes(); + let mut pos = 0; + + loop { + let abs_pos = pos + body[pos..].find(field)?; + pos = abs_pos + 1; + + let at_word_start = + abs_pos == 0 || matches!(bytes[abs_pos - 1], b'\n' | b' ' | b'\t' | b','); + let Some(after_eq) = body[abs_pos + field.len()..] + .trim_start() + .strip_prefix('=') + .map(str::trim) + else { + continue; + }; + if !at_word_start { + continue; + } + + let value = if let Some(inner) = after_eq.strip_prefix('{') { + let close = matching_brace(after_eq.as_bytes(), 0)?; + &inner[..close - 1] + } else if let Some(quoted) = after_eq.strip_prefix('"') { + "ed[..quoted.find('"')?] + } else { + after_eq[..after_eq.find([',', '}', '\n'])?].trim() + }; + return Some(value.trim().trim_end_matches(',').to_string()); + } +} + +fn doi_url(doi: &str) -> String { + if doi.starts_with("http://") || doi.starts_with("https://") { + return doi.to_string(); + } + let doi = doi.trim_start_matches("doi:").trim(); + format!("https://doi.org/{doi}") +} + +fn format_date(year: Option<&str>, month: Option<&str>) -> Option { + let year = year?.trim(); + let month = month.and_then(|m| { + let m = m.trim().to_lowercase(); + MONTHS + .iter() + .position(|name| m.starts_with(name)) + .or_else(|| { + m.parse::() + .ok() + .filter(|n| (1..=12).contains(n)) + .map(|n| n - 1) + }) + }); + Some(match month { + Some(index) => format!("{year}-{:02}", index + 1), + None => year.to_string(), + }) +} + +fn format_title(title: &str, author: &str) -> String { + if title.is_empty() { + return author.to_string(); + } + let cleaned: String = title.chars().filter(|&c| c != '{' && c != '}').collect(); + if author.is_empty() { + cleaned + } else { + format!("{cleaned} — {author}") + } +} + +fn sanitize(value: &str) -> String { + value + .chars() + .filter(|c| !matches!(c, '{' | '}')) + .map(|c| if c == '\n' { ' ' } else { c }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_extracts_timeline_entries() { + let bib = r#" +@article{einstein1935, + title = {Can Quantum-Mechanical Description of Physical Reality Be Considered Complete?}, + author = {Einstein, A. and Podolsky, B. and Rosen, N.}, + year = {1935}, + month = may, + abstract = {A description of physical reality}, + doi = {10.1038/35057060}, +} +"#; + let entries = parse(bib); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].date.as_deref(), Some("1935-05")); + assert!(entries[0].title.contains("Quantum-Mechanical")); + assert_eq!( + entries[0].url.as_deref(), + Some("https://doi.org/10.1038/35057060") + ); + } + + #[test] + fn test_parse_missing_fields_are_none() { + let entries = parse("@misc{a, title = {Only a title}}"); + assert_eq!(entries[0].title, "Only a title"); + assert_eq!(entries[0].date, None); + assert_eq!(entries[0].description, None); + assert_eq!(entries[0].url, None); + } + + #[test] + fn test_parse_empty() { + assert!(parse("").is_empty()); + } + + #[test] + fn test_parse_multiple_entries() { + let bib = r#" +@article{first, + title = {First Paper}, + year = {2020}, +} +@article{second, + title = {Second Paper}, + year = {2021}, +} +"#; + assert_eq!(parse(bib).len(), 2); + } + + #[test] + fn test_parse_skips_non_entries() { + let bib = r#" +@comment{ this should be ignored } +@string{ key = "value" } +@preamble{ "x" } +@article{real, + title = {Real Entry}, + year = {2023}, +} +"#; + let entries = parse(bib); + assert_eq!(entries.len(), 1); + assert!(entries[0].title.contains("Real Entry")); + } + + #[test] + fn test_render_round_trips_through_parse() { + let original = parse( + "@misc{a, title = {A {B}rief Note}, year = {2023}, month = mar, abstract = {Text}, url = {https://x.y}}", + ); + let reparsed = parse(&render(&original)); + assert_eq!(reparsed[0].title, "A Brief Note"); + assert_eq!(reparsed[0].date.as_deref(), Some("2023-03")); + assert_eq!(reparsed[0].description.as_deref(), Some("Text")); + assert_eq!(reparsed[0].url.as_deref(), Some("https://x.y")); + } + + #[test] + fn test_parse_prefers_full_date_and_round_trips_it() { + let entries = parse("@online{a, title = {Launch}, date = {2025-05-22}, year = {2025}}"); + assert_eq!(entries[0].date.as_deref(), Some("2025-05-22")); + assert_eq!( + parse(&render(&entries))[0].date.as_deref(), + Some("2025-05-22") + ); + } + + #[test] + fn test_parse_quoted_bare_and_link_values() { + let entries = parse( + "@misc{a,\n title = \"Quoted Title\",\n year = 2021,\n month = 11,\n link = {https://x.y/news}\n}", + ); + assert_eq!(entries[0].title, "Quoted Title"); + assert_eq!(entries[0].date.as_deref(), Some("2021-11")); + assert_eq!( + entries[0].url.as_deref(), + Some("https://x.y/news"), + "link used as url" + ); + } + + #[test] + fn test_doi_url() { + assert_eq!(doi_url("10.1/x"), "https://doi.org/10.1/x"); + assert_eq!(doi_url("doi:10.1/x"), "https://doi.org/10.1/x"); + assert_eq!(doi_url("https://doi.org/10.1/x"), "https://doi.org/10.1/x"); + } + + #[test] + fn test_format_title() { + assert_eq!( + format_title("My Paper", "Smith, J."), + "My Paper — Smith, J." + ); + assert_eq!(format_title("My Paper", ""), "My Paper"); + assert_eq!(format_title("", "Smith, J."), "Smith, J."); + assert_eq!(format_title("{E}nsemble {M}ethods", ""), "Ensemble Methods"); + } + + #[test] + fn test_format_date() { + assert_eq!(format_date(Some("2023"), None).as_deref(), Some("2023")); + assert_eq!( + format_date(Some("2023"), Some("may")).as_deref(), + Some("2023-05") + ); + assert_eq!( + format_date(Some("2023"), Some("January")).as_deref(), + Some("2023-01") + ); + assert_eq!( + format_date(Some("2023"), Some("11")).as_deref(), + Some("2023-11") + ); + assert_eq!( + format_date(Some("2023"), Some("invalid")).as_deref(), + Some("2023") + ); + assert_eq!(format_date(None, Some("may")), None); + } +} diff --git a/src/core/cache.rs b/src/core/cache.rs index df468c6..5773f56 100644 --- a/src/core/cache.rs +++ b/src/core/cache.rs @@ -1,8 +1,9 @@ +//! Content hashing and the incremental build cache (SHA-256 of sources plus compiler version). + use std::collections::HashMap; use std::fmt::Write as _; use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::core::CiteError; @@ -38,42 +39,6 @@ impl BuildCache { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UuidCache { - pub mapping: HashMap, -} - -impl UuidCache { - pub fn load(root: &Path) -> Self { - let path = root.join(".cite").join("cache").join("uuid_map.json"); - std::fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| Self { - mapping: HashMap::new(), - }) - } - - pub fn save(&self, root: &Path) { - let dir = root.join(".cite").join("cache"); - let _ = std::fs::create_dir_all(&dir); - let path = dir.join("uuid_map.json"); - if let Ok(json) = serde_json::to_string_pretty(self) { - let _ = std::fs::write(path, json); - } - } - - pub fn get_or_create(&mut self, key: &str) -> String { - if let Some(id) = self.mapping.get(key) { - return id.clone(); - } - let id = uuid::Uuid::new_v4().to_string(); - self.mapping.insert(key.to_string(), id.clone()); - id - } -} - -/// Hash every existing file in `files`, off the async runtime. pub async fn hash_files(files: Vec) -> Result, CiteError> { tokio::task::spawn_blocking(move || { let mut hashes = HashMap::with_capacity(files.len()); @@ -89,7 +54,6 @@ pub async fn hash_files(files: Vec) -> Result, .map_err(|e| CiteError::Config(format!("Hashing task failed: {e}")))? } -/// Streaming SHA-256 of a file as lowercase hex; never loads the whole file into memory. pub fn sha256_file(path: &Path) -> std::io::Result { let mut file = std::fs::File::open(path)?; let mut hasher = Sha256::new(); @@ -97,6 +61,10 @@ pub fn sha256_file(path: &Path) -> std::io::Result { Ok(to_hex(&hasher.finalize())) } +pub fn sha256_bytes(bytes: &[u8]) -> String { + to_hex(&Sha256::digest(bytes)) +} + fn to_hex(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { @@ -140,6 +108,13 @@ mod tests { assert_eq!(changed, vec!["a.md"]); } + #[test] + fn test_changed_since_removed_file() { + let hashes = HashMap::from([("a.md".to_string(), "abc".to_string())]); + let cache = BuildCache::new(0.0, hashes); + assert_eq!(cache.changed_since(&HashMap::new()), vec!["a.md"]); + } + #[test] fn test_sha256_file_known_value() { let dir = tempfile::tempdir().unwrap(); @@ -162,15 +137,4 @@ mod tests { assert_eq!(hashes.len(), 1); assert!(hashes.contains_key(present.to_string_lossy().as_ref())); } - - #[test] - fn test_uuid_cache_persistence() { - let dir = tempfile::tempdir().unwrap(); - let mut cache = UuidCache::load(dir.path()); - let id = cache.get_or_create("test-key"); - assert!(!id.is_empty()); - cache.save(dir.path()); - let loaded = UuidCache::load(dir.path()); - assert_eq!(loaded.mapping.get("test-key"), Some(&id)); - } } diff --git a/src/core/compiler.rs b/src/core/compiler.rs index 16a11ed..ab65072 100644 --- a/src/core/compiler.rs +++ b/src/core/compiler.rs @@ -1,15 +1,19 @@ +//! Deterministic build of sources into `build/content.json` with an incremental hash cache (local analytics writes are best-effort). use std::path::{Path, PathBuf}; use std::time::Instant; use serde::{Deserialize, Serialize}; use tracing::info; +use uuid::Uuid; use crate::core::CiteError; -use crate::core::cache::{UuidCache, hash_files}; -use crate::core::db::DbManager; +use crate::core::bibtex; +use crate::core::cache::hash_files; +use crate::core::db::{BuildRecord, DbManager}; +use crate::core::markdown::word_count; use crate::core::media::{AudioMeta, ImageMeta, extract_audio, extract_image}; use crate::core::metadata::{Podcast, TimelineEntry, TimelineItem}; -use crate::core::project::{BuildRecord, ProjectContext}; +use crate::core::project::ProjectContext; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContentBundle { @@ -89,7 +93,7 @@ pub async fn compile( let project_id = ctx.project_id(); let build = &ctx.manifest.build; - let current_hashes = hash_files(ctx.content_files()).await?; + let current_hashes = hash_files(ctx.source_files()).await?; let cache = if force || !build.incremental { None @@ -107,13 +111,10 @@ pub async fn compile( } let was_incremental = cache.is_some(); - let mut uuid_cache = UuidCache::load(&ctx.root); - let bundle = build_bundle(ctx, &project_id, &mut uuid_cache).await?; - uuid_cache.save(&ctx.root); + let bundle = build_bundle(ctx, &project_id).await?; - let build_dir = ctx.build_dir(); - let artifact = build_dir.join("content.json"); - tokio::fs::create_dir_all(&build_dir).await?; + let artifact = ctx.bundle_path(); + tokio::fs::create_dir_all(ctx.build_dir()).await?; tokio::fs::write(&artifact, serde_json::to_vec_pretty(&bundle)?).await?; let duration_ms = start.elapsed().as_millis() as i64; @@ -129,7 +130,6 @@ pub async fn compile( .map(|t| t.entries.len() as i64) .sum(); - // Local analytics are best-effort: a DB hiccup must not fail an otherwise good build. let _ = db.save_cache(&project_id, ¤t_hashes).await; let _ = db.sync_project(ctx, &bundle).await; let _ = db @@ -156,20 +156,17 @@ pub async fn compile( }) } -pub fn word_count(content: &str) -> i64 { - content.split_whitespace().count() as i64 +fn stable_id(project_id: &str, kind: &str, file: &str) -> String { + let key = format!("cite:{project_id}:{kind}:{file}"); + Uuid::new_v5(&Uuid::NAMESPACE_URL, key.as_bytes()).to_string() } -async fn build_bundle( - ctx: &ProjectContext, - project_id: &str, - uuid_cache: &mut UuidCache, -) -> Result { +async fn build_bundle(ctx: &ProjectContext, project_id: &str) -> Result { let mut podcasts = Vec::with_capacity(ctx.metadata.podcasts.len()); let mut timelines = Vec::new(); for p in &ctx.metadata.podcasts { - let id = uuid_cache.get_or_create(&format!("podcast:{project_id}:{}", p.file)); + let id = stable_id(project_id, "podcast", &p.file); let content = read_optional(&ctx.root.join(&p.file), !p.file.is_empty()).await?; let audio_path = p.audio.as_ref().map(|a| ctx.root.join(a)); @@ -194,11 +191,11 @@ async fn build_bundle( let Some(bib) = read_optional(&ctx.root.join(citation), true).await? else { continue; }; - let mut entries = parse_bibtex(&bib); + let mut entries = bibtex::parse(&bib); if entries.is_empty() { continue; } - let tl_id = uuid_cache.get_or_create(&format!("timeline:{project_id}:{citation}")); + let tl_id = stable_id(project_id, "timeline", citation); for (idx, entry) in entries.iter_mut().enumerate() { entry.id = format!("{tl_id}-{idx}"); } @@ -228,7 +225,6 @@ async fn build_bundle( }) } -/// Read a UTF-8 file if `enabled` and it exists; a missing file is not an error. async fn read_optional(path: &Path, enabled: bool) -> Result, CiteError> { if !enabled || !path.is_file() { return Ok(None); @@ -236,311 +232,10 @@ async fn read_optional(path: &Path, enabled: bool) -> Result, Cit Ok(Some(tokio::fs::read_to_string(path).await?)) } -pub fn parse_bibtex(content: &str) -> Vec { - let mut entries = Vec::new(); - let mut pos = 0; - let bytes = content.as_bytes(); - - while pos < bytes.len() { - if bytes[pos] != b'@' { - pos += 1; - continue; - } - pos += 1; - - let open = match content[pos..].find('{') { - Some(i) => pos + i, - None => break, - }; - let entry_type = content[pos..open].trim().to_lowercase(); - if matches!( - entry_type.as_str(), - "comment" | "string" | "preamble" | "xdata" - ) { - let mut depth = 1; - for (offset, &b) in bytes[open + 1..].iter().enumerate() { - match b { - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - pos = open + 1 + offset + 1; - break; - } - } - _ => {} - } - } - continue; - } - pos = open + 1; - - let mut depth = 1; - let mut close = None; - for (offset, &b) in bytes[pos..].iter().enumerate() { - match b { - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - close = Some(pos + offset); - break; - } - } - _ => {} - } - } - - let end = match close { - Some(i) => i, - None => break, - }; - - let body = &content[pos..end]; - pos = end + 1; - - let title = extract_bib_field(body, "title").unwrap_or_default(); - let author = extract_bib_field(body, "author").unwrap_or_default(); - let year = extract_bib_field(body, "year"); - let month = extract_bib_field(body, "month"); - let summary = extract_bib_field(body, "abstract") - .or_else(|| extract_bib_field(body, "note")) - .unwrap_or_default(); - let url = extract_bib_field(body, "url") - .or_else(|| extract_bib_field(body, "doi")) - .unwrap_or_default(); - let link = extract_bib_field(body, "link").filter(|l| !l.trim().is_empty()); - entries.push(TimelineEntry { - // Stable ids are assigned by the compiler once the owning timeline is known. - id: String::new(), - date: Some(format_bib_date(&year, &month)), - title: format_title(&title, &author), - summary: Some(summary), - url: Some(url), - link, - }); - } - - entries -} - -fn extract_bib_field(body: &str, field: &str) -> Option { - let bytes = body.as_bytes(); - let mut pos = 0; - - loop { - let fpos = body[pos..].find(field)?; - let abs_pos = pos + fpos; - - if abs_pos > 0 { - let prev = bytes[abs_pos - 1]; - if prev != b'\n' && prev != b' ' && prev != b'\t' { - pos = abs_pos + 1; - continue; - } - } - - let after_field = &body[abs_pos + field.len()..]; - let trimmed = after_field.trim_start(); - if !trimmed.starts_with('=') { - pos = abs_pos + 1; - continue; - } - - let after_eq = trimmed[1..].trim(); - let val: &str = if let Some(inner) = after_eq.strip_prefix('{') { - let mut depth = 1usize; - let mut end = None; - for (i, c) in inner.char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - end = Some(i); - break; - } - } - _ => {} - } - } - end.map(|i| &inner[..i])? - } else if let Some(quoted) = after_eq.strip_prefix('"') { - let close = quoted.find('"')?; - "ed[..close] - } else { - let delim = after_eq.find([',', '}', '\n'])?; - after_eq[..delim].trim() - }; - - let cleaned = val.trim().trim_end_matches(','); - return Some(cleaned.to_string()); - } -} - -fn format_bib_date(year: &Option, month: &Option) -> String { - let y = year.as_deref().unwrap_or(""); - let m = month.as_deref().and_then(|m| { - let m = m.trim().to_lowercase(); - Some(match m.as_str() { - "jan" | "january" => "01", - "feb" | "february" => "02", - "mar" | "march" => "03", - "apr" | "april" => "04", - "may" => "05", - "jun" | "june" => "06", - "jul" | "july" => "07", - "aug" | "august" => "08", - "sep" | "september" => "09", - "oct" | "october" => "10", - "nov" | "november" => "11", - "dec" | "december" => "12", - _ => return None, - }) - }); - - match (y, m) { - (y, Some(m)) if !y.is_empty() => format!("{y}-{m}"), - (y, _) if !y.is_empty() => y.to_string(), - _ => String::new(), - } -} - -fn format_title(title: &str, author: &str) -> String { - if title.is_empty() { - return author.to_string(); - } - let cleaned: String = title.chars().filter(|&c| c != '{' && c != '}').collect(); - if author.is_empty() { - cleaned - } else { - format!("{} — {}", cleaned, author) - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_parse_bibtex_extracts_timeline_entries() { - let bib = r#" -@article{einstein1935, - title = {Can Quantum-Mechanical Description of Physical Reality Be Considered Complete?}, - author = {Einstein, A. and Podolsky, B. and Rosen, N.}, - year = {1935}, - month = may, - abstract = {A description of physical reality}, - doi = {10.1038/35057060}, -} -"#; - let entries = parse_bibtex(bib); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].date.as_deref(), Some("1935-05")); - assert!(entries[0].title.contains("Quantum-Mechanical")); - assert_eq!(entries[0].url.as_deref(), Some("10.1038/35057060")); - } - - #[test] - fn test_parse_bibtex_empty() { - assert!(parse_bibtex("").is_empty()); - } - - #[test] - fn test_parse_bibtex_multiple_entries() { - let bib = r#" -@article{first, - title = {First Paper}, - year = {2020}, -} -@article{second, - title = {Second Paper}, - year = {2021}, -} -"#; - assert_eq!(parse_bibtex(bib).len(), 2); - } - - #[test] - fn test_format_title_with_author() { - assert_eq!( - format_title("My Paper", "Smith, J."), - "My Paper — Smith, J." - ); - } - - #[test] - fn test_format_title_without_author() { - assert_eq!(format_title("My Paper", ""), "My Paper"); - } - - #[test] - fn test_format_title_without_title() { - assert_eq!(format_title("", "Smith, J."), "Smith, J."); - } - - #[test] - fn test_format_title_trims_surrounding_braces() { - assert_eq!(format_title("{E}nsemble {M}ethods", ""), "Ensemble Methods"); - } - - #[test] - fn test_format_bib_date_year_only() { - let year = Some("2023".to_string()); - let month = None; - assert_eq!(format_bib_date(&year, &month), "2023"); - } - - #[test] - fn test_format_bib_date_year_month() { - let year = Some("2023".to_string()); - let month = Some("may".to_string()); - assert_eq!(format_bib_date(&year, &month), "2023-05"); - } - - #[test] - fn test_format_bib_date_full_month() { - let year = Some("2023".to_string()); - let month = Some("January".to_string()); - assert_eq!(format_bib_date(&year, &month), "2023-01"); - } - - #[test] - fn test_format_bib_date_empty() { - let year = None; - let month = None; - assert_eq!(format_bib_date(&year, &month), ""); - } - - #[test] - fn test_format_bib_date_invalid_month() { - let year = Some("2023".to_string()); - let month = Some("invalid".to_string()); - assert_eq!(format_bib_date(&year, &month), "2023"); - } - - #[test] - fn test_parse_bibtex_skips_non_entries() { - let bib = r#" -@comment{ this should be ignored } -@string{ key = "value" } -@preamble{ "x" } -@article{real, - title = {Real Entry}, - year = {2023}, -} -"#; - let entries = parse_bibtex(bib); - assert_eq!(entries.len(), 1); - assert!(entries[0].title.contains("Real Entry")); - } - - #[test] - fn test_word_count() { - assert_eq!(word_count("one two three\n"), 3); - assert_eq!(word_count(""), 0); - } - fn write_project(dir: &Path) -> ProjectContext { std::fs::write( dir.join("cite.toml"), @@ -562,6 +257,18 @@ mod tests { ProjectContext::load(dir).unwrap() } + #[test] + fn test_stable_id_depends_only_on_inputs() { + assert_eq!( + stable_id("p", "podcast", "content/a.md"), + stable_id("p", "podcast", "content/a.md") + ); + assert_ne!( + stable_id("p", "podcast", "content/a.md"), + stable_id("p", "podcast", "content/b.md") + ); + } + #[tokio::test] async fn test_compile_syncs_timelines_to_owning_podcast() { let dir = tempfile::tempdir().unwrap(); @@ -579,7 +286,6 @@ mod tests { "no cache yet, so this is a full build" ); - // Duplicate BibTeX titles must not collide in the local snapshot. let snapshot = db.get_restore_snapshot(&ctx.project_id()).await.unwrap(); assert_eq!(snapshot.podcasts.len(), 1); assert_eq!(snapshot.timelines.len(), 2); @@ -603,4 +309,28 @@ mod tests { }; assert!(stats.was_incremental); } + + #[tokio::test] + async fn test_metadata_edit_triggers_rebuild() { + let dir = tempfile::tempdir().unwrap(); + let ctx = write_project(dir.path()); + let db = DbManager::open_path(&dir.path().join("test.db")) + .await + .unwrap(); + compile(&db, &ctx, false).await.unwrap(); + + std::fs::write( + dir.path().join("metadata.yml"), + "podcasts:\n - title: Renamed\n file: content/ep.md\n", + ) + .unwrap(); + let ctx = ProjectContext::load(dir.path()).unwrap(); + assert!( + matches!( + compile(&db, &ctx, false).await.unwrap(), + CompileOutcome::Complete { .. } + ), + "a title change in metadata.yml must be rebuilt and redeployed" + ); + } } diff --git a/src/core/credentials.rs b/src/core/credentials.rs deleted file mode 100644 index a216318..0000000 --- a/src/core/credentials.rs +++ /dev/null @@ -1,197 +0,0 @@ -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; -use tracing::info; - -use crate::core::CiteError; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SupabaseCredentials { - pub url: String, - pub api_key: String, -} - -fn creds_path() -> PathBuf { - std::env::var_os("CITE_CREDS_PATH") - .map(PathBuf::from) - .unwrap_or_else(|| crate::core::cite_home().join("credentials.toml")) -} - -pub fn load_credentials() -> Result { - if let (Ok(url), Ok(key)) = ( - std::env::var("CITE_SUPABASE_URL"), - std::env::var("CITE_SUPABASE_API_KEY"), - ) { - info!("Using credentials from environment variables"); - return Ok(SupabaseCredentials { url, api_key: key }); - } - - let path = creds_path(); - if !path.exists() { - return Err(CiteError::Config( - "No credentials found. Run 'cite login' or set CITE_SUPABASE_URL and CITE_SUPABASE_API_KEY environment variables." - .to_string(), - )); - } - - let s = std::fs::read_to_string(&path)?; - let table: toml::Value = toml::from_str(&s)?; - let supabase = table.get("supabase").ok_or_else(|| { - CiteError::Config("Missing [supabase] section in credentials.toml".into()) - })?; - let url = supabase - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| CiteError::Config("Missing url in [supabase]".into()))? - .to_string(); - let api_key = supabase - .get("api_key") - .and_then(|v| v.as_str()) - .ok_or_else(|| CiteError::Config("Missing api_key in [supabase]".into()))? - .to_string(); - - info!("Loaded credentials from {}", path.display()); - Ok(SupabaseCredentials { url, api_key }) -} - -pub fn save_credentials(creds: &SupabaseCredentials) -> Result<(), CiteError> { - #[derive(Serialize)] - struct CredentialsFile<'a> { - supabase: &'a SupabaseCredentials, - } - let content = toml::to_string(&CredentialsFile { supabase: creds })?; - crate::core::write_private(&creds_path(), content.as_bytes())?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::env; - use std::sync::{Mutex, MutexGuard}; - - /// Tests in this module mutate process-wide env vars; run them one at a time. - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - fn env_lock() -> MutexGuard<'static, ()> { - ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) - } - - struct EnvGuard { - key: String, - previous: Option, - } - - impl EnvGuard { - fn set(key: &str, value: &str) -> Self { - let previous = env::var(key).ok(); - unsafe { - env::set_var(key, value); - } - Self { - key: key.to_string(), - previous, - } - } - - fn remove(key: &str) -> Self { - let previous = env::var(key).ok(); - unsafe { - env::remove_var(key); - } - Self { - key: key.to_string(), - previous, - } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.previous { - Some(val) => unsafe { - env::set_var(&self.key, val); - }, - None => unsafe { - env::remove_var(&self.key); - }, - } - } - } - - #[test] - fn test_creds_path_default() { - let _lock = env_lock(); - let p = creds_path(); - assert!(p.to_string_lossy().contains(".cite/credentials.toml")); - } - - #[test] - fn test_credentials_from_env() { - let _lock = env_lock(); - let _guard_url = EnvGuard::set("CITE_SUPABASE_URL", "https://env-test.supabase.co"); - let _guard_key = EnvGuard::set("CITE_SUPABASE_API_KEY", "env-key-456"); - - let result = load_credentials(); - assert!(result.is_ok()); - let creds = result.unwrap(); - assert_eq!(creds.url, "https://env-test.supabase.co"); - assert_eq!(creds.api_key, "env-key-456"); - } - - #[test] - fn test_save_credentials_round_trip_escapes_values() { - let _lock = env_lock(); - let dir = tempfile::tempdir().unwrap(); - let creds_file = dir.path().join("nested").join("credentials.toml"); - let _guard = EnvGuard::set("CITE_CREDS_PATH", creds_file.to_str().unwrap()); - let _url_guard = EnvGuard::remove("CITE_SUPABASE_URL"); - let _key_guard = EnvGuard::remove("CITE_SUPABASE_API_KEY"); - - let creds = SupabaseCredentials { - url: "https://x.supabase.co".into(), - api_key: r#"key-with-"quote"-and\backslash"#.into(), - }; - save_credentials(&creds).unwrap(); - let loaded = load_credentials().unwrap(); - assert_eq!(loaded.url, creds.url); - assert_eq!(loaded.api_key, creds.api_key); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&creds_file).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - } - } - - #[test] - fn test_credentials_file_invalid() { - let _lock = env_lock(); - let dir = tempfile::tempdir().unwrap(); - let creds_file = dir.path().join("credentials.toml"); - std::fs::write(&creds_file, "not-toml").unwrap(); - - let _guard = EnvGuard::set("CITE_CREDS_PATH", creds_file.to_str().unwrap()); - let result = load_credentials(); - assert!(result.is_err()); - } - - #[test] - fn test_credentials_missing_supabase_section() { - let _lock = env_lock(); - let dir = tempfile::tempdir().unwrap(); - let creds_file = dir.path().join("credentials.toml"); - std::fs::write(&creds_file, "[other]\nkey = \"val\"").unwrap(); - - let _guard = EnvGuard::set("CITE_CREDS_PATH", creds_file.to_str().unwrap()); - let _url_guard = EnvGuard::remove("CITE_SUPABASE_URL"); - let _key_guard = EnvGuard::remove("CITE_SUPABASE_API_KEY"); - let result = load_credentials(); - assert!(result.is_err()); - match result { - Err(CiteError::Config(msg)) => assert!(msg.contains("[supabase]"), "{msg}"), - _ => panic!("expected Config error"), - } - } -} diff --git a/src/core/db.rs b/src/core/db.rs index 15d3cea..ef6f56d 100644 --- a/src/core/db.rs +++ b/src/core/db.rs @@ -1,3 +1,4 @@ +//! Local database at `~/.cite/cite.db`: last-build snapshots, build and deploy history, and caches (legacy `link` column kept as a fallback). use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -6,7 +7,9 @@ use tracing::info; use crate::core::CiteError; use crate::core::cache::BuildCache; -use crate::core::compiler::{ContentBundle, word_count}; +use crate::core::compiler::ContentBundle; +use crate::core::markdown::word_count; +use crate::core::metadata::{Podcast, TimelineEntry}; use crate::core::project::ProjectContext; pub fn global_db_path() -> PathBuf { @@ -19,6 +22,114 @@ pub struct DbManager { conn: Connection, } +#[derive(Debug, Clone)] +pub struct BuildRecord { + pub project_id: String, + pub compiler_version: f64, + pub podcast_count: i64, + pub timeline_count: i64, + pub total_words: i64, + pub duration_ms: i64, + pub was_incremental: bool, +} + +#[derive(Debug, Clone)] +pub struct DeployReport { + pub project_id: String, + pub deployment_id: String, + pub news_count: i64, + pub timeline_count: i64, + pub asset_count: i64, + pub success: bool, +} + +#[derive(Debug, Clone)] +pub struct StoredPodcast { + pub title: String, + pub word_count: i64, + pub category: String, + pub file: String, + pub has_audio: bool, + pub has_thumbnail: bool, +} + +#[derive(Debug, Clone)] +pub struct StoredTimeline { + pub date: Option, + pub title: String, + pub url: Option, + pub entry_type: Option, +} + +#[derive(Debug, Clone)] +pub struct StoredDeployment { + pub deployment_id: String, + pub deployed_at: String, + pub success: bool, + pub news_count: i64, + pub asset_count: i64, +} + +#[derive(Debug, Clone)] +pub struct StoredBuild { + pub podcast_count: i64, + pub total_words: i64, + pub duration_ms: i64, + pub was_incremental: bool, + pub built_at: String, +} + +#[derive(Debug, Clone)] +pub struct ProjectStats { + pub podcast_count: i64, + pub timeline_count: i64, + pub total_words: i64, + pub build_count: i64, + pub last_built: Option, + pub deployment_count: i64, + pub last_deployed: Option, +} + +#[derive(Debug, Clone)] +pub struct AllStats { + pub project_count: i64, + pub total_podcasts: i64, + pub total_timelines: i64, + pub total_words: i64, + pub total_builds: i64, +} + +#[derive(Debug, Clone)] +pub struct RestoredPodcast { + pub id: String, + pub title: String, + pub file: String, + pub source_url: Option, + pub category: Option, + pub thumbnail: Option, + pub audio: Option, + pub citation_file: Option, + pub metadata: Option, + pub content: Option, +} + +#[derive(Debug, Clone)] +pub struct RestoredTimeline { + pub podcast_id: String, + pub from_citation: bool, + pub entry: TimelineEntry, +} + +#[derive(Debug, Clone)] +pub struct RestoredProject { + pub name: String, + pub language: String, + pub artist_id: String, + pub metadata_file: String, + pub podcasts: Vec, + pub timelines: Vec, +} + fn get_opt_string(row: &libsql::Row, idx: i32) -> String { match row.get_value(idx) { Ok(Value::Text(s)) => s, @@ -31,6 +142,25 @@ fn opt_string(row: &libsql::Row, idx: i32) -> Option { if s.is_empty() { None } else { Some(s) } } +fn entry_params( + id: &str, + project_id: &str, + podcast_id: &str, + entry: &TimelineEntry, + entry_type: &str, +) -> [libsql::Result; 8] { + params![ + id, + project_id, + podcast_id, + entry.date.as_deref(), + entry.title.as_str(), + entry.description.as_deref(), + entry.url.as_deref(), + entry_type, + ] +} + impl DbManager { pub async fn open() -> Result { let path = global_db_path(); @@ -130,16 +260,9 @@ impl DbManager { } } - if self - .conn - .query("SELECT link FROM timeline_entries LIMIT 1", ()) - .await - .is_err() - { - self.conn - .execute("ALTER TABLE timeline_entries ADD COLUMN link TEXT", ()) - .await?; - } + self.add_column_if_missing("timeline_entries", "link") + .await?; + self.add_column_if_missing("podcasts", "metadata").await?; let mut rows = self .conn @@ -159,7 +282,15 @@ impl DbManager { Ok(()) } - /// Replace the local snapshot of a project with the freshly compiled bundle. + async fn add_column_if_missing(&self, table: &str, column: &str) -> Result<(), CiteError> { + let probe = format!("SELECT {column} FROM {table} LIMIT 1"); + if self.conn.query(&probe, ()).await.is_err() { + let alter = format!("ALTER TABLE {table} ADD COLUMN {column} TEXT"); + self.conn.execute(&alter, ()).await?; + } + Ok(()) + } + pub async fn sync_project( &self, ctx: &ProjectContext, @@ -199,12 +330,16 @@ impl DbManager { ) .await?; + let insert_entry = "INSERT INTO timeline_entries + (id, project_id, podcast_id, date, title, summary, url, entry_type) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"; for pod in &bundle.podcasts { let meta = &pod.podcast; + let metadata = serde_json::to_string(meta)?; tx.execute( "INSERT INTO podcasts (id, project_id, title, file, source_url, category, - thumbnail, audio, citation_file, content, word_count) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + thumbnail, audio, citation_file, content, word_count, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ pod.id.as_str(), project_id.as_str(), @@ -217,27 +352,32 @@ impl DbManager { meta.citation(), pod.content.as_deref(), pod.content.as_deref().map_or(0, word_count), + metadata, ], ) .await?; + + for (i, entry) in meta.inline_events().enumerate() { + let id = format!("{}-event-{i}", pod.id); + tx.execute( + insert_entry, + entry_params(&id, &project_id, &pod.id, entry, "event"), + ) + .await?; + } } for timeline in &bundle.timelines { for entry in &timeline.entries { tx.execute( - "INSERT INTO timeline_entries - (id, project_id, podcast_id, date, title, summary, url, link) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - entry.id.as_str(), - project_id.as_str(), - timeline.podcast_id.as_str(), - entry.date.as_deref(), - entry.title.as_str(), - entry.summary.as_deref(), - entry.url.as_deref(), - entry.link.as_deref(), - ], + insert_entry, + entry_params( + &entry.id, + &project_id, + &timeline.podcast_id, + entry, + "citation", + ), ) .await?; } @@ -323,10 +463,7 @@ impl DbManager { Ok(()) } - pub async fn record_build( - &self, - record: &super::project::BuildRecord, - ) -> Result<(), CiteError> { + pub async fn record_build(&self, record: &BuildRecord) -> Result<(), CiteError> { let now = chrono::Utc::now().to_rfc3339(); let id = uuid::Uuid::new_v4().to_string(); self.conn @@ -350,28 +487,23 @@ impl DbManager { Ok(()) } - pub async fn record_deployment( - &self, - report: &super::project::DeployReport, - ) -> Result<(), CiteError> { + pub async fn record_deployment(&self, report: &DeployReport) -> Result<(), CiteError> { let now = chrono::Utc::now().to_rfc3339(); let id = uuid::Uuid::new_v4().to_string(); self.conn .execute( "INSERT INTO deployment_history - (id, project_id, deployment_id, deployed_at, storage_path, news_count, timeline_count, asset_count, success, dry_run) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + (id, project_id, deployment_id, deployed_at, news_count, timeline_count, asset_count, success) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ id, report.project_id.clone(), report.deployment_id.clone(), now, - report.storage_path.clone(), report.news_count, report.timeline_count, report.asset_count, report.success as i64, - report.dry_run as i64, ], ) .await?; @@ -381,7 +513,7 @@ impl DbManager { pub async fn get_podcasts_with_content( &self, project_id: &str, - ) -> Result, CiteError> { + ) -> Result, CiteError> { let mut rows = self .conn .query( @@ -393,7 +525,7 @@ impl DbManager { let mut podcasts = Vec::new(); while let Some(row) = rows.next().await? { - podcasts.push(super::project::StoredPodcast { + podcasts.push(StoredPodcast { title: row.get(0)?, word_count: row.get(1)?, category: get_opt_string(&row, 2), @@ -405,14 +537,11 @@ impl DbManager { Ok(podcasts) } - pub async fn get_timelines( - &self, - project_id: &str, - ) -> Result, CiteError> { + pub async fn get_timelines(&self, project_id: &str) -> Result, CiteError> { let mut rows = self .conn .query( - "SELECT date, title, url, entry_type, link + "SELECT date, title, COALESCE(url, link), entry_type FROM timeline_entries WHERE project_id = ?1 ORDER BY date DESC NULLS LAST", params![project_id], @@ -421,12 +550,11 @@ impl DbManager { let mut entries = Vec::new(); while let Some(row) = rows.next().await? { - entries.push(super::project::StoredTimeline { + entries.push(StoredTimeline { date: opt_string(&row, 0), title: row.get(1)?, url: opt_string(&row, 2), entry_type: opt_string(&row, 3), - link: opt_string(&row, 4), }); } Ok(entries) @@ -435,9 +563,7 @@ impl DbManager { pub async fn get_restore_snapshot( &self, project_id: &str, - ) -> Result { - use super::project::{RestoredPodcast, RestoredProject, RestoredTimeline}; - + ) -> Result { let mut rows = self .conn .query( @@ -468,7 +594,7 @@ impl DbManager { let mut rows = self .conn .query( - "SELECT id, title, file, source_url, category, thumbnail, audio, citation_file, content + "SELECT id, title, file, source_url, category, thumbnail, audio, citation_file, content, metadata FROM podcasts WHERE project_id = ?1 ORDER BY file", params![project_id], ) @@ -484,13 +610,14 @@ impl DbManager { audio: opt_string(&row, 6), citation_file: opt_string(&row, 7), content: opt_string(&row, 8), + metadata: opt_string(&row, 9).and_then(|json| serde_json::from_str(&json).ok()), }); } let mut rows = self .conn .query( - "SELECT podcast_id, date, title, summary, url, link + "SELECT podcast_id, date, title, summary, COALESCE(url, link), entry_type FROM timeline_entries WHERE project_id = ?1 ORDER BY date ASC NULLS LAST", params![project_id], @@ -499,11 +626,14 @@ impl DbManager { while let Some(row) = rows.next().await? { snapshot.timelines.push(RestoredTimeline { podcast_id: row.get(0)?, - date: opt_string(&row, 1), - title: get_opt_string(&row, 2), - summary: opt_string(&row, 3), - url: opt_string(&row, 4), - link: opt_string(&row, 5), + from_citation: opt_string(&row, 5).as_deref() != Some("event"), + entry: TimelineEntry { + id: String::new(), + date: opt_string(&row, 1), + title: get_opt_string(&row, 2), + description: opt_string(&row, 3), + url: opt_string(&row, 4), + }, }); } @@ -513,12 +643,12 @@ impl DbManager { pub async fn get_deployment_history( &self, project_id: &str, - ) -> Result, CiteError> { + ) -> Result, CiteError> { let mut rows = self .conn .query( "SELECT deployment_id, deployed_at, success, news_count, asset_count - FROM deployment_history WHERE project_id = ?1 + FROM deployment_history WHERE project_id = ?1 AND dry_run = 0 ORDER BY deployed_at DESC", params![project_id], ) @@ -526,7 +656,7 @@ impl DbManager { let mut deployments = Vec::new(); while let Some(row) = rows.next().await? { - deployments.push(super::project::StoredDeployment { + deployments.push(StoredDeployment { deployment_id: row.get(0)?, deployed_at: row.get(1)?, success: row.get::(2)? != 0, @@ -537,14 +667,11 @@ impl DbManager { Ok(deployments) } - pub async fn get_build_history( - &self, - project_id: &str, - ) -> Result, CiteError> { + pub async fn get_build_history(&self, project_id: &str) -> Result, CiteError> { let mut rows = self .conn .query( - "SELECT podcast_count, timeline_count, total_words, duration_ms, was_incremental, built_at + "SELECT podcast_count, total_words, duration_ms, was_incremental, built_at FROM build_history WHERE project_id = ?1 ORDER BY built_at DESC LIMIT 50", @@ -554,22 +681,18 @@ impl DbManager { let mut builds = Vec::new(); while let Some(row) = rows.next().await? { - builds.push(super::project::StoredBuild { + builds.push(StoredBuild { podcast_count: row.get(0)?, - timeline_count: row.get(1)?, - total_words: row.get(2)?, - duration_ms: row.get(3)?, - was_incremental: row.get::(4)? != 0, - built_at: get_opt_string(&row, 5), + total_words: row.get(1)?, + duration_ms: row.get(2)?, + was_incremental: row.get::(3)? != 0, + built_at: get_opt_string(&row, 4), }); } Ok(builds) } - pub async fn get_project_stats( - &self, - project_id: &str, - ) -> Result { + pub async fn get_project_stats(&self, project_id: &str) -> Result { let mut rows = self .conn .query( @@ -579,8 +702,8 @@ impl DbManager { (SELECT COALESCE(SUM(word_count), 0) FROM podcasts WHERE project_id = ?1), (SELECT COUNT(*) FROM build_history WHERE project_id = ?1), (SELECT MAX(built_at) FROM build_history WHERE project_id = ?1), - (SELECT COUNT(*) FROM deployment_history WHERE project_id = ?1 AND success = 1), - (SELECT MAX(deployed_at) FROM deployment_history WHERE project_id = ?1 AND success = 1)", + (SELECT COUNT(*) FROM deployment_history WHERE project_id = ?1 AND success = 1 AND dry_run = 0), + (SELECT MAX(deployed_at) FROM deployment_history WHERE project_id = ?1 AND success = 1 AND dry_run = 0)", params![project_id], ) .await?; @@ -589,7 +712,7 @@ impl DbManager { .await? .ok_or_else(|| CiteError::Database("Project stats query returned no row".into()))?; - Ok(super::project::ProjectStats { + Ok(ProjectStats { podcast_count: row.get(0)?, timeline_count: row.get(1)?, total_words: row.get(2)?, @@ -614,7 +737,7 @@ impl DbManager { Ok(projects) } - pub async fn get_all_stats(&self) -> Result { + pub async fn get_all_stats(&self) -> Result { let mut rows = self .conn .query( @@ -632,7 +755,7 @@ impl DbManager { .await? .ok_or_else(|| CiteError::Database("Global stats query returned no row".into()))?; - Ok(super::project::AllStats { + Ok(AllStats { project_count: row.get(0)?, total_podcasts: row.get(1)?, total_timelines: row.get(2)?, @@ -743,8 +866,9 @@ mod tests { let timelines = db.get_timelines("proj1").await.unwrap(); assert_eq!( - timelines[0].link.as_deref(), - Some("https://example.com/news/b") + timelines[0].url.as_deref(), + Some("https://example.com/a"), + "url wins over the legacy link column" ); let snap = db.get_restore_snapshot("proj1").await.unwrap(); @@ -758,12 +882,14 @@ mod tests { Some("https://example.com") ); assert_eq!(snap.timelines.len(), 1); - assert_eq!(snap.timelines[0].title, "Event"); - assert_eq!(snap.timelines[0].summary.as_deref(), Some("Summary text")); - assert_eq!( - snap.timelines[0].link.as_deref(), - Some("https://example.com/news/b") + let entry = &snap.timelines[0].entry; + assert_eq!(entry.title, "Event"); + assert_eq!(entry.description.as_deref(), Some("Summary text")); + assert!( + snap.timelines[0].from_citation, + "untyped rows predate inline events" ); + assert_eq!(entry.url.as_deref(), Some("https://example.com/a")); assert!(db.get_restore_snapshot("missing").await.is_err()); } diff --git a/src/core/deploy.rs b/src/core/deploy.rs index c05ca6f..3038292 100644 --- a/src/core/deploy.rs +++ b/src/core/deploy.rs @@ -1,47 +1,72 @@ -use std::io::Write; -use std::path::PathBuf; +//! Deploying built projects to Supabase news, podcast, and timeline rows with storage objects (validates before writing; updates lock-recorded rows; descriptions always sent; internal urls fill missing sources). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; -use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; -use reqwest::RequestBuilder; use serde::{Deserialize, Serialize}; use serde_json::Value; use tracing::{info, warn}; use uuid::Uuid; use crate::core::CiteError; -use crate::core::compiler::{BundlePodcast, BundleTimeline, ContentBundle, word_count}; -use crate::core::credentials; -use crate::core::db::DbManager; -use crate::core::manifest::BackendConfig; -use crate::core::metadata::{TimelineEntry, TimelineItem}; -use crate::core::project::{DeployReport, ProjectContext}; +use crate::core::auth::{self, Connection}; +use crate::core::cache::sha256_bytes; +use crate::core::compiler::{self, BundlePodcast, BundleTimeline, CompileOutcome, ContentBundle}; +use crate::core::db::{DbManager, DeployReport}; +use crate::core::doctor; +use crate::core::lockfile::Lockfile; +use crate::core::markdown::{plain_text, word_count}; +use crate::core::media::mime_type; +use crate::core::metadata::{MAX_SUMMARY_WORDS, TimelineEntry, TimelineItem}; +use crate::core::project::ProjectContext; +use crate::core::supabase::{Supabase, encode, row}; const ASSETS_BUCKET: &str = "assets"; const PODCASTS_BUCKET: &str = "podcasts"; -const DEFAULT_CATEGORY: &str = "General"; +const EXISTING_NEWS_COLUMNS: &str = "select=id,thumbnail,podcasts!fk_podcasts_news(podcast_url)"; -#[derive(Debug, Clone)] struct DeployContext { - client: reqwest::Client, - base_url: String, - api_key: String, - bearer: String, + api: Supabase, root: PathBuf, + project: String, + artist_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize)] struct DeploymentRecord { deployment_id: String, + #[serde(default)] + backend_url: String, + #[serde(default, skip_serializing_if = "String::is_empty")] storage_path: String, news_ids: Vec, + #[serde(default)] + updated_news_ids: Vec, timeline_ids: Vec, #[serde(default)] asset_paths: Vec, } -#[derive(Debug)] -struct UploadedAsset { - storage_path: String, +struct ExistingNews { + id: i64, + thumbnail: Option, + podcast_url: Option, +} + +impl ExistingNews { + fn from_row(row: &Value) -> Option { + let podcast = match &row["podcasts"] { + Value::Array(rows) => rows.first(), + other => Some(other), + }; + Some(Self { + id: row["id"].as_i64()?, + thumbnail: row["thumbnail"].as_str().map(str::to_string), + podcast_url: podcast + .and_then(|p| p["podcast_url"].as_str()) + .map(str::to_string), + }) + } } #[derive(Debug, Clone)] @@ -50,68 +75,43 @@ struct Category { name: String, } -fn with_auth(builder: RequestBuilder, api_key: &str, bearer: &str) -> RequestBuilder { - builder - .header("apikey", api_key) - .header("Authorization", format!("Bearer {bearer}")) -} - -fn encode_url(s: &str) -> String { - utf8_percent_encode(s, NON_ALPHANUMERIC).to_string() +fn parse_artist_id(artist_id: &str) -> Result { + match artist_id.trim() { + "" => Err(CiteError::Config( + "Set artist_id in [project] in cite.toml to one of the artists 'cite login' lists" + .to_string(), + )), + id => Uuid::parse_str(id).map_err(|_| { + CiteError::Config(format!("artist_id '{id}' in cite.toml is not a valid UUID")) + }), + } } fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339() } -fn base_url(backend: &BackendConfig) -> &str { - backend - .staging_url - .as_deref() - .unwrap_or_default() - .trim_end_matches('/') -} - -fn build_context( - ctx: &ProjectContext, - backend: &BackendConfig, -) -> Result { - let session = load_session(); - Ok(DeployContext { - client: reqwest::Client::new(), - base_url: base_url(backend).to_string(), - api_key: backend.staging_service_key.clone().unwrap_or_default(), - bearer: resolve_bearer(backend, session.as_ref())?, - root: ctx.root.clone(), - }) -} - -async fn ensure_success( - response: reqwest::Response, - context: impl std::fmt::Display, -) -> Result { - if response.status().is_success() { - return Ok(response); +fn ensure_valid(ctx: &ProjectContext) -> Result<(), CiteError> { + let errors = doctor::validate(ctx).errors; + if errors.is_empty() { + return Ok(()); } - let status = response.status(); - let body = response.text().await.unwrap_or_default(); Err(CiteError::Deploy(format!( - "{context}: HTTP {status} - {body}" + "Fix {} problem(s) before deploying:\n - {}", + errors.len(), + errors.join("\n - ") ))) } -async fn load_bundle(ctx: &ProjectContext) -> Result { - let bundle_path = ctx.build_dir().join("content.json"); - let bundle_str = match tokio::fs::read_to_string(&bundle_path).await { - Ok(s) => s, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(CiteError::Config( - "No build artifact found. Run 'cite build' first.".to_string(), - )); - } - Err(e) => return Err(e.into()), - }; - Ok(serde_json::from_str(&bundle_str)?) +async fn current_bundle(db: &DbManager, ctx: &ProjectContext) -> Result { + let bundle_path = ctx.bundle_path(); + let force = !bundle_path.is_file(); + let outcome = compiler::compile(db, ctx, force).await?; + if matches!(outcome, CompileOutcome::Complete { .. }) { + outcome.emit(); + } + let json = tokio::fs::read_to_string(&bundle_path).await?; + Ok(serde_json::from_str(&json)?) } pub async fn deploy( @@ -119,93 +119,77 @@ pub async fn deploy( ctx: &ProjectContext, dry_run: bool, ) -> Result { - let backend = resolve_backend_config(ctx)?; - let bundle = load_bundle(ctx).await?; - let deployment_id = Uuid::new_v4().to_string(); - let artist_id = bundle.artist_id.trim(); - - info!("Deploying: {deployment_id}"); - + let artist_id = parse_artist_id(&ctx.manifest.project.artist_id)?; + ensure_valid(ctx)?; + let bundle = current_bundle(db, ctx).await?; if dry_run { - warn!("DRY RUN - no data will be sent"); - info!("Podcast items: {}", bundle.podcasts.len()); - info!("Timeline groups: {}", bundle.timelines.len()); - if !artist_id.is_empty() { - info!("Artist ID: {artist_id}"); - } - let _ = db - .record_deployment(&DeployReport { - project_id: ctx.project_id(), - deployment_id, - storage_path: String::new(), - news_count: bundle.podcasts.len() as i64, - timeline_count: bundle.timelines.len() as i64, - asset_count: 0, - success: true, - dry_run: true, - }) - .await; - return Ok("Dry run complete".to_string()); + return preview(ctx, &bundle); } - - let artist_id = Uuid::parse_str(artist_id).map_err(|_| { - CiteError::Config("artist_id in content.json must be a valid UUID".to_string()) - })?; - let dctx = build_context(ctx, &backend)?; - let object_path = format!("{artist_id}/{}/{deployment_id}.json", bundle.project); - let storage_path = format!("{ASSETS_BUCKET}/{object_path}"); + let conn = auth::connect(ctx).await?; + ensure_artist_owned(&conn, artist_id).await?; + let category_ids = resolve_category_ids(&conn.api, &bundle.podcasts).await?; + let mut lock = Lockfile::load(&ctx.root)?; + let dctx = DeployContext { + api: conn.api, + root: ctx.root.clone(), + project: bundle.project.clone(), + artist_id, + }; let mut record = DeploymentRecord { - deployment_id, - storage_path, + deployment_id: Uuid::new_v4().to_string(), + backend_url: dctx.api.url().to_string(), + storage_path: String::new(), news_ids: Vec::new(), + updated_news_ids: Vec::new(), timeline_ids: Vec::new(), asset_paths: Vec::new(), }; + info!("Deploying: {}", record.deployment_id); + + let result = deploy_bundle(&dctx, &bundle, &category_ids, &mut lock, &mut record).await; + lock.save(&ctx.root)?; + persist_deployment_record(ctx, &record).await?; + record_deployment(db, ctx, &record, result.is_ok()).await; + if let Err(e) = result { + warn!( + "Deploy failed partway; fix the error and deploy again, or run 'cite rollback {}' to remove what was created", + record.deployment_id + ); + return Err(e); + } - ensure_artist_exists(&dctx, artist_id).await?; - let categories = fetch_categories(&dctx).await?; + Ok(format!( + "Deployed {} podcast(s) ({} created, {} updated), {} timeline row(s)", + record.news_ids.len() + record.updated_news_ids.len(), + record.news_ids.len(), + record.updated_news_ids.len(), + record.timeline_ids.len() + )) +} +fn preview(ctx: &ProjectContext, bundle: &ContentBundle) -> Result { + warn!("DRY RUN - nothing will be sent"); + let lock = Lockfile::load(&ctx.root)?; + let backend = auth::backend_url(ctx).ok(); + let mut updates = 0; for pod in &bundle.podcasts { - let pod_groups: Vec<&BundleTimeline> = bundle - .timelines - .iter() - .filter(|tl| tl.podcast_id == pod.id) - .collect(); - match deploy_podcast(&dctx, pod, &pod_groups, artist_id, &categories).await { - Ok((news_id, timeline_ids, asset_paths)) => { - record.news_ids.push(news_id); - record.timeline_ids.extend(timeline_ids); - record.asset_paths.extend(asset_paths); - } - Err(e) => { - warn!("Deploy failed partway: {e}"); - record_deployment(db, ctx, &record, false).await; - persist_deployment_record(ctx, &record).await?; - return Err(e); + let title = &pod.podcast.title; + let known = backend + .as_deref() + .and_then(|url| lock.news_id(url, &pod.podcast.file)); + match known { + Some(id) => { + updates += 1; + info!("Update '{title}' (news #{id})"); } + None => info!("Publish '{title}'"), } } - - let bundle_json = serde_json::to_vec_pretty(&bundle)?; - upload_bytes( - &dctx, - ASSETS_BUCKET, - &object_path, - &bundle_json, - "application/json", - ) - .await?; - info!("Uploaded bundle to {}", record.storage_path); - - persist_deployment_record(ctx, &record).await?; - record_deployment(db, ctx, &record, true).await; - Ok(format!( - "Deployed {} podcast(s), {} timeline(s), {} asset(s)", - record.news_ids.len(), - record.timeline_ids.len(), - record.asset_paths.len() + "Dry run: {} podcast(s), {updates} update(s), {} new", + bundle.podcasts.len(), + bundle.podcasts.len() - updates )) } @@ -219,183 +203,366 @@ async fn record_deployment( .record_deployment(&DeployReport { project_id: ctx.project_id(), deployment_id: record.deployment_id.clone(), - storage_path: record.storage_path.clone(), news_count: record.news_ids.len() as i64, timeline_count: record.timeline_ids.len() as i64, asset_count: record.asset_paths.len() as i64, success, - dry_run: false, }) .await; } -pub async fn deploy_staging( - db: &DbManager, - ctx: &ProjectContext, - dry_run: bool, -) -> Result { - let bundle = load_bundle(ctx).await?; - let deployment_id = Uuid::new_v4().to_string(); - let podcasts = bundle.podcasts.len() as i64; - let timelines = bundle.timelines.len() as i64; +pub async fn rollback(ctx: &ProjectContext, deployment_id: &str) -> Result { + let api = auth::connect(ctx).await?.api; + let (record_path, record) = load_deployment_record(ctx, deployment_id).await?; + if !record.backend_url.is_empty() && record.backend_url != api.url() { + return Err(CiteError::Deploy(format!( + "Deployment '{deployment_id}' went to {}, but the project now points at {}", + record.backend_url, + api.url() + ))); + } - info!("Staging deployment: {deployment_id}"); + warn!("Rolling back deployment: {deployment_id}"); - if !dry_run { - db.sync_project(ctx, &bundle).await?; + for news_id in &record.news_ids { + api.delete("news", *news_id).await?; + info!("Cleared news {news_id}"); } - let _ = db - .record_deployment(&DeployReport { - project_id: ctx.project_id(), - deployment_id: deployment_id.clone(), - storage_path: String::new(), - news_count: podcasts, - timeline_count: timelines, - asset_count: 0, - success: !dry_run, - dry_run, - }) - .await; - if dry_run { - warn!("DRY RUN - no data written to cite.db"); - return Ok("Staging dry run complete".to_string()); + let storage_paths = record.asset_paths.iter().chain([&record.storage_path]); + for storage_path in storage_paths.filter(|p| !p.is_empty()) { + match api.delete_object(storage_path).await { + Ok(()) => info!("Cleared storage object {storage_path}"), + Err(e) => warn!("Failed to delete storage object {storage_path}: {e}"), + } } - persist_deployment_record( - ctx, - &DeploymentRecord { - deployment_id, - storage_path: String::new(), - news_ids: Vec::new(), - timeline_ids: Vec::new(), - asset_paths: Vec::new(), - }, - ) - .await?; + let mut lock = Lockfile::load(&ctx.root)?; + lock.remove_news_ids(api.url(), &record.news_ids); + lock.save(&ctx.root)?; - Ok(format!( - "Staged {podcasts} podcast(s), {timelines} timeline(s) to local database" - )) + if let Err(e) = tokio::fs::remove_file(&record_path).await { + warn!("Failed to remove local deployment record: {e}"); + } + + if !record.updated_news_ids.is_empty() { + warn!( + "{} news item(s) were updated in place and keep their deployed content; rollback only removes what a deployment created", + record.updated_news_ids.len() + ); + } + + Ok("Rollback complete".to_string()) } -async fn deploy_podcast( +async fn ensure_artist_owned(conn: &Connection, artist_id: Uuid) -> Result<(), CiteError> { + let rows = conn + .api + .select("artists", &format!("select=id,user_id&id=eq.{artist_id}")) + .await?; + let Some(artist) = rows.first() else { + return Err(CiteError::Auth(format!( + "Artist '{artist_id}' does not exist in the database. Create the artist before deploying." + ))); + }; + if let Some(user_id) = &conn.user_id + && artist["user_id"].as_str() != Some(user_id.as_str()) + { + return Err(CiteError::Auth(format!( + "Artist '{artist_id}' is not owned by the logged-in account. Use an artist_id listed by 'cite login'." + ))); + } + Ok(()) +} + +async fn deploy_bundle( + dctx: &DeployContext, + bundle: &ContentBundle, + category_ids: &[i64], + lock: &mut Lockfile, + record: &mut DeploymentRecord, +) -> Result<(), CiteError> { + let backend = dctx.api.url(); + let mut deployed = Vec::with_capacity(bundle.podcasts.len()); + for (pod, &category_id) in bundle.podcasts.iter().zip(category_ids) { + let file = pod.podcast.file.as_str(); + let known_id = lock.news_id(backend, file); + let existing = find_existing_news(dctx, known_id, &pod.podcast.title).await?; + let (news_id, created) = deploy_news(dctx, pod, category_id, existing, record).await?; + lock.set_news_id(backend, file, news_id); + deployed.push((pod, news_id, created)); + } + + let episode_ids: HashMap<&str, i64> = deployed + .iter() + .map(|(pod, news_id, _)| (pod.podcast.file.as_str(), *news_id)) + .collect(); + for (pod, news_id, created) in deployed { + if !created { + dctx.api + .delete_where("timeline_news", &format!("parent_news_id=eq.{news_id}")) + .await?; + } + let citation_groups: Vec<&BundleTimeline> = bundle + .timelines + .iter() + .filter(|tl| tl.podcast_id == pod.id) + .collect(); + let timeline = Timeline { + parent_news_id: news_id, + items: &pod.podcast.timeline, + citation_groups: &citation_groups, + episode_ids: &episode_ids, + }; + deploy_timeline(&dctx.api, &timeline, &mut record.timeline_ids).await?; + } + Ok(()) +} + +async fn find_existing_news( + dctx: &DeployContext, + known_id: Option, + title: &str, +) -> Result, CiteError> { + let select = |filter: String| async move { + let query = format!( + "{EXISTING_NEWS_COLUMNS}&artist_id=eq.{}&{filter}", + dctx.artist_id + ); + let rows = dctx.api.select("news", &query).await?; + Ok::<_, CiteError>(rows.first().and_then(ExistingNews::from_row)) + }; + + if let Some(id) = known_id { + let news = select(format!("id=eq.{id}")).await?; + if news.is_none() { + warn!("News #{id} in cite.lock for '{title}' no longer exists; publishing it again"); + } + return Ok(news); + } + + let by_title = format!("title=eq.{}&order=created_at.desc&limit=1", encode(title)); + let news = select(by_title).await?; + if let Some(news) = &news { + info!( + "Matched '{title}' to existing news #{} by title; recorded in cite.lock", + news.id + ); + } + Ok(news) +} + +async fn deploy_news( dctx: &DeployContext, podcast: &BundlePodcast, - pod_groups: &[&BundleTimeline], - artist_id: Uuid, - categories: &[Category], -) -> Result<(i64, Vec, Vec), CiteError> { + category_id: i64, + existing: Option, + record: &mut DeploymentRecord, +) -> Result<(i64, bool), CiteError> { + let api = &dctx.api; let title = &podcast.podcast.title; let content = podcast.content.as_deref(); - let category_id = - resolve_category_id(dctx, podcast.podcast.category.as_deref(), categories).await?; - let fallback_url = format!("cite://podcasts/{}", podcast.id); + let fallback_url = format!( + "cite://{}/{}/{}", + dctx.artist_id, dctx.project, podcast.podcast.file + ); let url_id = ensure_url_id( - dctx, + api, podcast.podcast.source_url.as_deref(), &fallback_url, content.map(word_count), ) .await?; - let news_id = insert_news_row(dctx, title, content, category_id, url_id, artist_id).await?; - info!("Created news item: {title} (id={news_id})"); + let summary = podcast + .podcast + .summary + .clone() + .or_else(|| summarize_content(content)); + let mut fields = row([ + ("title", Value::from(title.as_str())), + ("summary", summary.map_or(Value::Null, Value::String)), + ("category_id", Value::from(category_id)), + ("url_id", Value::from(url_id)), + ]); + + let created = existing.is_none(); + let (news_id, current_thumbnail, current_audio) = match existing { + Some(news) => { + api.update("news", news.id, &fields).await?; + record.updated_news_ids.push(news.id); + info!("Updated news item: {title} (id={})", news.id); + (news.id, news.thumbnail, news.podcast_url) + } + None => { + fields.insert( + "artist_id".into(), + Value::String(dctx.artist_id.to_string()), + ); + fields.insert("published_at".into(), Value::String(now_rfc3339())); + let id = api.insert("news", &fields).await?; + record.news_ids.push(id); + info!("Created news item: {title} (id={id})"); + (id, None, None) + } + }; + + let thumbnail = persist_thumbnail(dctx, podcast, news_id, current_thumbnail.as_deref()).await?; + if created { + record.asset_paths.extend(thumbnail.clone()); + } + if thumbnail != current_thumbnail { + let value = thumbnail.clone().map_or(Value::Null, Value::String); + api.update("news", news_id, &row([("thumbnail", value)])) + .await?; + delete_replaced_object(api, current_thumbnail.as_deref(), thumbnail.as_deref()).await; + } + + let audio = persist_audio(dctx, podcast, news_id, current_audio.as_deref()).await?; + if created { + record.asset_paths.extend(audio.clone()); + } + persist_podcast_row( + api, + podcast, + news_id, + title, + audio.as_deref(), + current_audio.is_some(), + ) + .await?; + delete_replaced_object(api, current_audio.as_deref(), audio.as_deref()).await; - let mut asset_paths = Vec::new(); + Ok((news_id, created)) +} - let thumb_stem = format!("{artist_id}/news_{news_id}"); - if let Some(asset) = upload_optional_asset( +async fn persist_thumbnail( + dctx: &DeployContext, + podcast: &BundlePodcast, + news_id: i64, + current: Option<&str>, +) -> Result, CiteError> { + sync_asset( dctx, podcast.podcast.thumbnail.as_deref(), ASSETS_BUCKET, - &thumb_stem, + &format!("news_{news_id}"), + current, ) - .await? - { - update_news_thumbnail(dctx, news_id, &asset.storage_path).await?; - info!("Uploaded thumbnail: {}", asset.storage_path); - asset_paths.push(asset.storage_path); - } + .await +} - let audio_stem = format!("{artist_id}/podcast_{news_id}"); - if let Some(audio) = upload_optional_asset( +async fn persist_audio( + dctx: &DeployContext, + podcast: &BundlePodcast, + news_id: i64, + current: Option<&str>, +) -> Result, CiteError> { + sync_asset( dctx, podcast.podcast.audio.as_deref(), PODCASTS_BUCKET, - &audio_stem, + &format!("podcast_{news_id}"), + current, ) - .await? - { - let duration_minutes = podcast - .audio_meta - .as_ref() - .map(|meta| meta.duration_secs / 60.0); - insert_podcast_row(dctx, news_id, title, &audio.storage_path, duration_minutes).await?; - info!("Created podcast: {title}"); - asset_paths.push(audio.storage_path); - } + .await +} - let timeline_ids = - deploy_timeline(dctx, news_id, &podcast.podcast.timeline, pod_groups).await?; +async fn persist_podcast_row( + api: &Supabase, + podcast: &BundlePodcast, + news_id: i64, + title: &str, + audio: Option<&str>, + had_audio: bool, +) -> Result<(), CiteError> { + match audio { + Some(path) => { + let duration_minutes = podcast + .audio_meta + .as_ref() + .map(|meta| meta.duration_secs / 60.0); + upsert_podcast_row(api, news_id, title, path, duration_minutes).await + } + None if had_audio => { + api.delete_where("podcasts", &format!("news_id=eq.{news_id}")) + .await + } + None => Ok(()), + } +} - Ok((news_id, timeline_ids, asset_paths)) +struct Timeline<'a> { + parent_news_id: i64, + items: &'a [TimelineItem], + citation_groups: &'a [&'a BundleTimeline], + episode_ids: &'a HashMap<&'a str, i64>, } async fn deploy_timeline( - dctx: &DeployContext, - parent_news_id: i64, - items: &[TimelineItem], - citation_groups: &[&BundleTimeline], -) -> Result, CiteError> { - let mut timeline_ids = Vec::new(); + api: &Supabase, + timeline: &Timeline<'_>, + timeline_ids: &mut Vec, +) -> Result<(), CiteError> { + let parent = timeline.parent_news_id; + let mut sort_order = 0_i64; - for item in items { - match item { + for item in timeline.items { + let child_id = match item { + TimelineItem::Citation(path) => { + let group = timeline.citation_groups.iter().find(|g| g.source == *path); + for entry in group.iter().flat_map(|g| &g.entries) { + if let Some(id) = deploy_event(api, parent, entry, sort_order + 1).await? { + sort_order += 1; + timeline_ids.push(id); + } + } + continue; + } + TimelineItem::Event(entry) => { + if let Some(id) = deploy_event(api, parent, entry, sort_order + 1).await? { + sort_order += 1; + timeline_ids.push(id); + } + continue; + } + TimelineItem::Episode(file) => { + *timeline.episode_ids.get(file.as_str()).ok_or_else(|| { + CiteError::Deploy(format!( + "Timeline entry '{file}' is not an episode of this project" + )) + })? + } TimelineItem::News(id) => { - if lookup_row_id(dctx, "news", "id", &id.to_string()) - .await? - .is_none() - { + if api.find_id("news", "id", &id.to_string()).await?.is_none() { return Err(CiteError::Deploy(format!( "Timeline news item {id} does not exist in the database" ))); } - let sort_order = (timeline_ids.len() + 1) as i64; - let row_id = insert_row( - dctx, - "timeline_news", - build_map([ - ("parent_news_id", Value::Number(parent_news_id.into())), - ("child_news_id", Value::Number((*id).into())), - ("sort_order", Value::Number(sort_order.into())), - ]), - ) - .await?; - info!("Linked news {parent_news_id} -> {id}"); - timeline_ids.push(row_id); - } - TimelineItem::Citation(path) => { - let Some(group) = citation_groups.iter().find(|g| g.source == *path) else { - continue; - }; - for entry in &group.entries { - let sort_order = (timeline_ids.len() + 1) as i64; - if let Some(id) = - deploy_citation_event(dctx, parent_news_id, entry, sort_order).await? - { - timeline_ids.push(id); - } - } + *id } + }; + if child_id == parent { + return Err(CiteError::Deploy(format!( + "News {parent} cannot list itself in its own timeline" + ))); } + + sort_order += 1; + let payload = row([ + ("parent_news_id", Value::from(parent)), + ("child_news_id", Value::from(child_id)), + ("sort_order", Value::from(sort_order)), + ]); + timeline_ids.push(api.insert("timeline_news", &payload).await?); + info!("Linked news {parent} -> {child_id}"); } - Ok(timeline_ids) + Ok(()) } -async fn deploy_citation_event( - dctx: &DeployContext, +async fn deploy_event( + api: &Supabase, parent_news_id: i64, entry: &TimelineEntry, sort_order: i64, @@ -406,78 +573,46 @@ async fn deploy_citation_event( return Ok(None); } - let mut payload = build_map([ - ("parent_news_id", Value::Number(parent_news_id.into())), - ("title", Value::String(title.to_string())), - ("sort_order", Value::Number(sort_order.into())), + let description = entry.description.as_deref().map(str::trim); + let mut payload = row([ + ("parent_news_id", Value::from(parent_news_id)), + ("title", Value::from(title)), + ("description", Value::from(description.unwrap_or_default())), + ("sort_order", Value::from(sort_order)), ]); - if let Some(summary) = entry - .summary - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - payload.insert("description".into(), Value::String(summary.to_string())); + if let Some(url) = entry.url.as_deref() { + let url_id = ensure_url_id(api, Some(url), url, None).await?; + payload.insert("url_id".into(), Value::from(url_id)); } - let url = entry - .url - .as_deref() - .map(str::trim) - .filter(|u| !u.is_empty()) - .or_else(|| { - entry - .link - .as_deref() - .map(str::trim) - .filter(|l| !l.is_empty()) - }); - if let Some(url) = url { - let url_id = ensure_url_id(dctx, Some(url), url, None).await?; - payload.insert("url_id".into(), Value::Number(url_id.into())); - } - - match entry.date.as_deref().and_then(event_date_rfc3339) { + match entry.event_date() { Some(event_date) => { payload.insert("event_date".into(), Value::String(event_date)); } None => { - if let Some(date) = entry.date.as_deref() { + if let Some(date) = entry.date.as_deref().filter(|d| !d.trim().is_empty()) { warn!("Unrecognized timeline date '{date}', deploying without event_date"); } } } + let id = api.insert("timeline_news", &payload).await?; info!("Deployed timeline event: {title}"); - insert_row(dctx, "timeline_news", payload).await.map(Some) + Ok(Some(id)) } -fn event_date_rfc3339(date: &str) -> Option { - let s = date.trim(); - let digits = s.as_bytes(); - let year_only = s.len() == 4 && digits[..4].iter().all(|b| b.is_ascii_digit()); - let year_month = s.len() == 7 - && digits[4] == b'-' - && digits[..4].iter().all(|b| b.is_ascii_digit()) - && digits[5..7].iter().all(|b| b.is_ascii_digit()); - - if year_only { - Some(format!("{s}-01-01T00:00:00Z")) - } else if year_month { - Some(format!("{s}-01T00:00:00Z")) - } else { - None - } +fn deployments_dir(ctx: &ProjectContext) -> PathBuf { + ctx.root.join(".cite").join("deployments") } async fn persist_deployment_record( ctx: &ProjectContext, record: &DeploymentRecord, ) -> Result<(), CiteError> { - let deployments_dir = ctx.build_dir().join("deployments"); - tokio::fs::create_dir_all(&deployments_dir).await?; - let path = deployments_dir.join(format!("{}.json", record.deployment_id)); + let dir = deployments_dir(ctx); + tokio::fs::create_dir_all(&dir).await?; + let path = dir.join(format!("{}.json", record.deployment_id)); tokio::fs::write(path, serde_json::to_vec_pretty(record)?).await?; Ok(()) } @@ -485,435 +620,60 @@ async fn persist_deployment_record( async fn load_deployment_record( ctx: &ProjectContext, deployment_id: &str, -) -> Result<(std::path::PathBuf, DeploymentRecord), CiteError> { - let path = ctx - .build_dir() - .join("deployments") - .join(format!("{}.json", deployment_id)); - let json = tokio::fs::read_to_string(&path).await.map_err(|_| { - CiteError::Deploy(format!( - "No local deployment record found for '{deployment_id}'. Run deploy first." - )) - })?; - let record: DeploymentRecord = serde_json::from_str(&json)?; - Ok((path, record)) -} - -async fn delete_row_by_id(dctx: &DeployContext, table: &str, id: i64) -> Result<(), CiteError> { - let url = format!("{}/rest/v1/{table}?id=eq.{id}", dctx.base_url); - let response = with_auth(dctx.client.delete(&url), &dctx.api_key, &dctx.bearer) - .send() - .await?; - ensure_success(response, format!("Failed to delete {table} row {id}")).await?; - Ok(()) -} - -async fn delete_storage_object(dctx: &DeployContext, storage_path: &str) -> Result<(), CiteError> { - let (bucket, object_path) = storage_path - .split_once('/') - .unwrap_or((ASSETS_BUCKET, storage_path)); - let url = format!("{}/storage/v1/object/{bucket}/{object_path}", dctx.base_url); - let response = with_auth(dctx.client.delete(&url), &dctx.api_key, &dctx.bearer) - .send() - .await?; - ensure_success( - response, - format!("Failed to delete storage object {storage_path}"), - ) - .await?; - Ok(()) -} - -fn resolve_backend_config(ctx: &ProjectContext) -> Result { - if let Some(backend) = &ctx.manifest.backend { - return Ok(backend.clone()); - } - let creds = credentials::load_credentials()?; - Ok(BackendConfig { - staging_url: Some(creds.url), - staging_service_key: Some(creds.api_key), - }) -} - -pub async fn rollback(ctx: &ProjectContext, deployment_id: &str) -> Result { - let backend = resolve_backend_config(ctx)?; - - let (record_path, record) = load_deployment_record(ctx, deployment_id).await?; - let dctx = build_context(ctx, &backend)?; - - warn!("Rolling back deployment: {deployment_id}"); - - for timeline_id in &record.timeline_ids { - delete_row_by_id(&dctx, "timeline_news", *timeline_id).await?; - info!("Cleared timeline event {timeline_id}"); - } - - for news_id in &record.news_ids { - delete_row_by_id(&dctx, "news", *news_id).await?; - info!("Cleared news {news_id}"); - } - - for asset_path in &record.asset_paths { - if let Err(e) = delete_storage_object(&dctx, asset_path).await { - warn!("Failed to delete asset {asset_path}: {e}"); - } else { - info!("Cleared asset {asset_path}"); +) -> Result<(PathBuf, DeploymentRecord), CiteError> { + let file = format!("{deployment_id}.json"); + let candidates = [ + deployments_dir(ctx).join(&file), + ctx.build_dir().join("deployments").join(&file), + ]; + for path in candidates { + if let Ok(json) = tokio::fs::read_to_string(&path).await { + let record: DeploymentRecord = serde_json::from_str(&json)?; + return Ok((path, record)); } } - - if let Err(e) = delete_storage_object(&dctx, &record.storage_path).await { - warn!("Failed to delete storage: {e}"); - } else { - info!("Cleared storage object"); - } - - if let Err(e) = tokio::fs::remove_file(&record_path).await { - warn!("Failed to remove local deployment record: {e}"); - } - - Ok("Rollback complete".to_string()) -} - -#[derive(Deserialize)] -struct TokenResponse { - access_token: String, - refresh_token: String, -} - -#[derive(Serialize, Deserialize, Debug)] -struct Session { - access_token: String, - refresh_token: String, - email: String, -} - -fn session_path() -> PathBuf { - crate::core::cite_home().join("session.json") -} - -fn load_session() -> Option { - let path = session_path(); - let content = std::fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() -} - -fn save_session(session: &Session) -> Result<(), CiteError> { - crate::core::write_private(&session_path(), &serde_json::to_vec_pretty(session)?)?; - Ok(()) -} - -fn resolve_bearer(backend: &BackendConfig, session: Option<&Session>) -> Result { - if let Some(s) = session { - return Ok(s.access_token.clone()); - } - match backend.staging_service_key.as_deref() { - Some(key) if !key.is_empty() => Ok(key.to_string()), - _ => Err(CiteError::Auth( - "Not logged in and no backend.staging_service_key configured. Run 'cite login' or set the key in cite.toml" - .to_string(), - )), - } -} - -pub async fn login( - creds: Option, - backend_config: Option, - email: Option, - password: Option, -) -> Result<(), CiteError> { - let creds = match creds { - Some(c) => c, - None => credentials::load_credentials().or_else(|_| { - if let Some(b) = backend_config { - Ok(credentials::SupabaseCredentials { - url: b.staging_url.unwrap_or_default(), - api_key: b.staging_service_key.unwrap_or_default(), - }) - } else { - prompt_credentials() - } - })?, - }; - - if creds.api_key.is_empty() { - return Err(CiteError::Auth( - "Supabase API key is required for login. Set it in ~/.cite/credentials.toml or CITE_SUPABASE_API_KEY env var." - .to_string(), - )); - } - - let backend = BackendConfig { - staging_url: Some(creds.url.clone()), - staging_service_key: Some(creds.api_key.clone()), - }; - - let email = match email { - Some(e) => e, - None => prompt_line("Email: ")?, - }; - let password = match password { - Some(p) => p, - None => prompt_line("Password: ")?, - }; - - let client = reqwest::Client::new(); - let url = format!("{}/auth/v1/token?grant_type=password", base_url(&backend)); - let response = client - .post(&url) - .header( - "apikey", - backend.staging_service_key.as_deref().unwrap_or_default(), - ) - .header("Content-Type", "application/json") - .json(&serde_json::json!({ "email": email, "password": password })) - .send() - .await?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(CiteError::Auth(format!( - "Login failed: HTTP {status} - {body}" - ))); - } - - let token: TokenResponse = serde_json::from_str(&body) - .map_err(|e| CiteError::Auth(format!("Invalid login response: {e}")))?; - - let session = Session { - access_token: token.access_token, - refresh_token: token.refresh_token, - email, - }; - save_session(&session)?; - info!("Logged in as {}", session.email); - - match fetch_user_artists(&client, &backend, &session.access_token).await { - Ok(artists) if artists.is_empty() => { - warn!("No artist linked to this account"); - match prompt_create_artist(&client, &backend, &session.access_token).await? { - Some((id, name)) => { - info!("Created artist '{name}' ({id})"); - } - None => { - info!("Skipped artist creation"); - } - } - } - Ok(artists) => { - info!("Associated artist(s):"); - for (id, name) in &artists { - info!(" - {name} ({id})"); - } - } - Err(e) => { - warn!("Could not fetch artists: {e}"); - } - } - - Ok(()) -} - -async fn fetch_user_artists( - client: &reqwest::Client, - backend: &BackendConfig, - token: &str, -) -> Result, CiteError> { - let url = format!("{}/rest/v1/artists?select=id,name", base_url(backend)); - let resp = with_auth( - client.get(&url), - backend.staging_service_key.as_deref().unwrap_or_default(), - token, - ) - .send() - .await?; - if !resp.status().is_success() { - return Err(CiteError::Auth(format!( - "Failed to fetch artists: HTTP {}", - resp.status() - ))); - } - let rows: Vec = resp.json().await?; - Ok(rows - .into_iter() - .filter_map(|r| { - let id = r.get("id")?.as_str()?.to_string(); - let name = r.get("name")?.as_str()?.to_string(); - Some((id, name)) - }) - .collect()) -} - -fn prompt_line(label: &str) -> Result { - print!("{label}"); - let _ = std::io::stdout().flush(); - let mut s = String::new(); - std::io::stdin().read_line(&mut s)?; - Ok(s.trim().to_string()) -} - -async fn prompt_create_artist( - client: &reqwest::Client, - backend: &BackendConfig, - token: &str, -) -> Result, CiteError> { - let name = prompt_line("Artist name: ")?; - if name.is_empty() { - return Ok(None); - } - let description = prompt_line("Description (optional): ")?; - let website = prompt_line("Website URL (optional): ")?; - - let mut payload = build_map([("name", Value::String(name.clone()))]); - if !description.is_empty() { - payload.insert("description".into(), Value::String(description)); - } - if !website.is_empty() { - payload.insert("website_url".into(), Value::String(website)); - } - - let url = format!("{}/rest/v1/artists", base_url(backend)); - let resp = with_auth( - client.post(&url), - backend.staging_service_key.as_deref().unwrap_or_default(), - token, - ) - .header("Prefer", "return=representation") - .json(&payload) - .send() - .await?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(CiteError::Auth(format!( - "Failed to create artist: HTTP {status} - {body}" - ))); - } - let row: Value = resp.json().await?; - let id = extract_uuid(&row).unwrap_or_default(); - Ok(Some((id, name))) -} - -fn build_map(fields: [(&str, Value); N]) -> serde_json::Map { - fields - .into_iter() - .map(|(key, value)| (key.to_string(), value)) - .collect() -} - -fn extract_id(value: &Value) -> Option { - match value { - Value::Array(rows) => rows - .first() - .and_then(|row| row.get("id").and_then(|id| id.as_i64())), - Value::Object(map) => map.get("id").and_then(|id| id.as_i64()), - _ => None, - } -} - -fn extract_uuid(value: &Value) -> Option { - match value { - Value::Array(rows) => rows - .first() - .and_then(|row| row.get("id").and_then(|id| id.as_str().map(String::from))), - Value::Object(map) => map.get("id").and_then(|id| id.as_str().map(String::from)), - _ => None, - } -} - -async fn lookup_row_id( - dctx: &DeployContext, - table: &str, - field: &str, - value: &str, -) -> Result, CiteError> { - let url = format!( - "{}/rest/v1/{table}?{field}=eq.{}", - dctx.base_url, - encode_url(value) - ); - let resp = with_auth(dctx.client.get(&url), &dctx.api_key, &dctx.bearer) - .send() - .await?; - if !resp.status().is_success() { - return Ok(None); - } - let rows: Vec = resp.json().await?; - Ok(rows - .first() - .and_then(|row| row.get("id").and_then(|id| id.as_i64()))) -} - -async fn insert_row( - dctx: &DeployContext, - table: &str, - payload: serde_json::Map, -) -> Result { - let url = format!("{}/rest/v1/{table}", dctx.base_url); - let response = with_auth(dctx.client.post(&url), &dctx.api_key, &dctx.bearer) - .header("Prefer", "return=representation") - .json(&payload) - .send() - .await?; - let response = ensure_success(response, format!("Failed to insert into {table}")).await?; - - let row: Value = response.json().await?; - extract_id(&row) - .ok_or_else(|| CiteError::Deploy(format!("Could not get {table} id from response"))) -} - -async fn ensure_artist_exists(dctx: &DeployContext, artist_id: Uuid) -> Result<(), CiteError> { - let url = format!("{}/rest/v1/artists?id=eq.{}", dctx.base_url, artist_id); - let resp = with_auth(dctx.client.get(&url), &dctx.api_key, &dctx.bearer) - .send() - .await?; - if !resp.status().is_success() { - return Err(CiteError::Auth(format!( - "Failed to verify artist {artist_id}: HTTP {}", - resp.status() - ))); - } - let rows: Vec = resp.json().await?; - if rows.is_empty() { - return Err(CiteError::Auth(format!( - "Artist '{artist_id}' does not exist in the database. Create the artist before deploying." - ))); - } - Ok(()) + Err(CiteError::Deploy(format!( + "No local deployment record found for '{deployment_id}'. Run deploy first." + ))) } -async fn fetch_categories(dctx: &DeployContext) -> Result, CiteError> { - let url = format!("{}/rest/v1/categories?select=id,name", dctx.base_url); - let resp = with_auth(dctx.client.get(&url), &dctx.api_key, &dctx.bearer) - .send() - .await?; - if !resp.status().is_success() { - return Err(CiteError::Deploy(format!( - "Failed to fetch categories: HTTP {}", - resp.status() - ))); - } - let rows: Vec = resp.json().await?; +async fn fetch_categories(api: &Supabase) -> Result, CiteError> { + let rows = api.select("categories", "select=id,name").await?; Ok(rows - .into_iter() + .iter() .filter_map(|row| { Some(Category { - id: row.get("id")?.as_i64()?, - name: row.get("name")?.as_str()?.to_string(), + id: row["id"].as_i64()?, + name: row["name"].as_str()?.to_string(), }) }) .collect()) } +async fn resolve_category_ids( + api: &Supabase, + podcasts: &[BundlePodcast], +) -> Result, CiteError> { + let mut categories = fetch_categories(api).await?; + let mut ids = Vec::with_capacity(podcasts.len()); + for pod in podcasts { + let Some(name) = pod.podcast.category.as_deref() else { + return Err(CiteError::Deploy(format!( + "Podcast '{}' has no category. Set 'category' in metadata (available: {})", + pod.podcast.title, + category_names(&categories) + ))); + }; + ids.push(resolve_category_id(api, name, &mut categories).await?); + } + Ok(ids) +} + async fn resolve_category_id( - dctx: &DeployContext, - category_name: Option<&str>, - categories: &[Category], + api: &Supabase, + name: &str, + categories: &mut Vec, ) -> Result { - let name = category_name - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_CATEGORY); - if let Some(category) = categories .iter() .find(|c| c.name.eq_ignore_ascii_case(name)) @@ -921,332 +681,163 @@ async fn resolve_category_id( return Ok(category.id); } - match insert_row( - dctx, - "categories", - build_map([ - ("name", Value::String(name.to_string())), - ( - "description", - Value::String("Created automatically by cite".to_string()), - ), - ]), - ) - .await - { - Ok(id) => Ok(id), - Err(_) => { - let available = fetch_categories(dctx).await?; - if let Some(category) = available.iter().find(|c| c.name.eq_ignore_ascii_case(name)) { - return Ok(category.id); - } - let names = available - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(", "); - Err(CiteError::Deploy(format!( - "Unknown category '{name}'. Available categories: {names}. Creating new categories requires elevated access." - ))) - } - } + let payload = row([ + ("name", Value::from(name)), + ("description", Value::from("Created automatically by cite")), + ]); + let id = api.insert("categories", &payload).await.map_err(|_| { + CiteError::Deploy(format!( + "Unknown category '{name}' (available: {}). Creating categories requires the service role key.", + category_names(categories) + )) + })?; + info!("Created category '{name}' (id={id})"); + categories.push(Category { + id, + name: name.to_string(), + }); + Ok(id) } -async fn ensure_domain_id( - dctx: &DeployContext, - domain_name: &str, -) -> Result, CiteError> { - if let Some(id) = lookup_row_id(dctx, "domains", "domain_name", domain_name).await? { - return Ok(Some(id)); - } - - // Domains are server-managed; a blocked insert is not fatal to the deployment. - match insert_row( - dctx, - "domains", - build_map([ - ("domain_name", Value::String(domain_name.to_string())), - ("is_trusted", Value::Bool(false)), - ]), - ) - .await - { - Ok(id) => Ok(Some(id)), - Err(_) => Ok(None), +fn category_names(categories: &[Category]) -> String { + categories + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(", ") +} + +async fn ensure_domain_id(api: &Supabase, domain_name: &str) -> Option { + if let Ok(Some(id)) = api.find_id("domains", "domain_name", domain_name).await { + return Some(id); } + let payload = row([ + ("domain_name", Value::from(domain_name)), + ("is_trusted", Value::Bool(false)), + ]); + api.insert("domains", &payload).await.ok() } async fn ensure_url_id( - dctx: &DeployContext, + api: &Supabase, source_url: Option<&str>, fallback_url: &str, word_count: Option, ) -> Result { - let url_value = source_url - .filter(|value| !value.trim().is_empty()) - .unwrap_or(fallback_url); - if let Some(id) = lookup_row_id(dctx, "urls", "url", url_value).await? { + let source_url = source_url.filter(|value| !value.trim().is_empty()); + let url_value = source_url.unwrap_or(fallback_url); + if let Some(id) = api.find_id("urls", "url", url_value).await? { return Ok(id); } - let mut payload = build_map([("url", Value::String(url_value.to_string()))]); + let mut payload = row([ + ("url", Value::from(url_value)), + ("accessed_at", Value::String(now_rfc3339())), + ]); if let Some(count) = word_count { - payload.insert("word_count".into(), Value::Number(count.into())); + payload.insert("word_count".into(), Value::from(count)); } - payload.insert("accessed_at".into(), Value::String(now_rfc3339())); - - if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty()) - && let Some(domain_name) = extract_domain_name(source_url) - && let Ok(Some(domain_id)) = ensure_domain_id(dctx, &domain_name).await + if let Some(domain_name) = source_url.and_then(extract_domain_name) + && let Some(domain_id) = ensure_domain_id(api, &domain_name).await { - payload.insert("domain_id".into(), Value::Number(domain_id.into())); + payload.insert("domain_id".into(), Value::from(domain_id)); } - insert_row(dctx, "urls", payload).await + api.insert("urls", &payload).await } -async fn upload_optional_asset( +async fn sync_asset( dctx: &DeployContext, asset: Option<&str>, bucket: &str, - object_stem: &str, -) -> Result, CiteError> { - let Some(asset_path) = asset.filter(|value| !value.trim().is_empty()) else { + stem: &str, + current: Option<&str>, +) -> Result, CiteError> { + let Some(asset) = asset.map(str::trim).filter(|a| !a.is_empty()) else { return Ok(None); }; - let local_path = dctx.root.join(asset_path); - if !local_path.exists() { - return Ok(None); - } - + let local_path = dctx.root.join(asset); let bytes = tokio::fs::read(&local_path).await?; - let ext = local_path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("bin"); - let object_path = format!("{object_stem}.{ext}"); - let mime = mime_for_extension(ext); - - let storage_path = upload_bytes(dctx, bucket, &object_path, &bytes, mime).await?; - - Ok(Some(UploadedAsset { storage_path })) -} - -async fn insert_news_row( - dctx: &DeployContext, - title: &str, - content: Option<&str>, - category_id: i64, - url_id: i64, - artist_id: Uuid, -) -> Result { - let mut payload = build_map([ - ("title", Value::String(title.to_string())), - ("category_id", Value::Number(category_id.into())), - ("url_id", Value::Number(url_id.into())), - ("artist_id", Value::String(artist_id.to_string())), - ("published_at", Value::String(now_rfc3339())), - ]); - - if let Some(summary) = summarize_content(content) { - payload.insert("summary".into(), Value::String(summary)); + let ext = file_extension(&local_path); + let hash = sha256_bytes(&bytes); + let object_path = format!("{}/{stem}-{}.{ext}", dctx.artist_id, &hash[..12]); + let storage_path = format!("{bucket}/{object_path}"); + if current == Some(storage_path.as_str()) { + return Ok(Some(storage_path)); } - - insert_row(dctx, "news", payload).await + dctx.api + .upload(bucket, &object_path, &bytes, mime_type(ext)) + .await + .map(Some) } -async fn update_row( - dctx: &DeployContext, - table: &str, - id: i64, - payload: serde_json::Map, -) -> Result<(), CiteError> { - let url = format!("{}/rest/v1/{table}?id=eq.{id}", dctx.base_url); - let response = with_auth(dctx.client.patch(&url), &dctx.api_key, &dctx.bearer) - .header("Prefer", "return=representation") - .json(&payload) - .send() - .await?; - ensure_success(response, format!("Failed to update {table} {id}")).await?; - Ok(()) +async fn delete_replaced_object(api: &Supabase, old: Option<&str>, new: Option<&str>) { + let Some(old) = old.filter(|old| Some(*old) != new) else { + return; + }; + match api.delete_object(old).await { + Ok(()) => info!("Removed replaced object {old}"), + Err(e) => warn!("Could not remove replaced object {old}: {e}"), + } } -async fn update_news_thumbnail( - dctx: &DeployContext, - news_id: i64, - thumbnail: &str, -) -> Result<(), CiteError> { - update_row( - dctx, - "news", - news_id, - build_map([("thumbnail", Value::String(thumbnail.to_string()))]), - ) - .await +fn file_extension(path: &Path) -> &str { + path.extension() + .and_then(|value| value.to_str()) + .unwrap_or("bin") } -async fn insert_podcast_row( - dctx: &DeployContext, +async fn upsert_podcast_row( + api: &Supabase, news_id: i64, title: &str, podcast_url: &str, duration_minutes: Option, ) -> Result<(), CiteError> { - let mut payload = build_map([ - ("news_id", Value::Number(news_id.into())), - ("title", Value::String(title.to_string())), - ("podcast_url", Value::String(podcast_url.to_string())), + let duration = duration_minutes + .filter(|d| *d > 0.0) + .and_then(|d| serde_json::Number::from_f64((d * 100.0).round() / 100.0)) + .map_or(Value::Null, Value::Number); + let payload = row([ + ("news_id", Value::from(news_id)), + ("title", Value::from(title)), + ("podcast_url", Value::from(podcast_url)), + ("duration_minutes", duration), ]); - - if let Some(duration) = duration_minutes.filter(|d| *d > 0.0) - && let Some(value) = serde_json::Number::from_f64((duration * 100.0).round() / 100.0) - { - payload.insert("duration_minutes".into(), Value::Number(value)); - } - - insert_row(dctx, "podcasts", payload).await?; - Ok(()) + api.upsert("podcasts", &payload, "news_id").await } fn extract_domain_name(source_url: &str) -> Option { let without_scheme = source_url .split_once("//") - .map(|(_, rest)| rest) - .unwrap_or(source_url); - let domain = without_scheme.split('/').next()?.trim(); - if domain.is_empty() { - None - } else { - Some(domain.to_string()) - } -} - -fn prompt_credentials() -> Result { - println!("No credentials found. Let's set them up."); - let url = prompt_line("Supabase URL: ")?; - let api_key = prompt_line("Supabase API key (anon/public): ")?; - let creds = credentials::SupabaseCredentials { url, api_key }; - credentials::save_credentials(&creds)?; - Ok(creds) + .map_or(source_url, |(_, rest)| rest); + let authority = without_scheme.split(['/', '?', '#']).next()?; + let host = authority.rsplit('@').next()?.split(':').next()?.trim(); + let host = host.to_lowercase(); + let host = host.strip_prefix("www.").unwrap_or(&host); + (!host.is_empty()).then(|| host.to_string()) } fn summarize_content(content: Option<&str>) -> Option { - let content = content?.trim(); - if content.is_empty() { + let text = plain_text(content?); + let words: Vec<&str> = text.split_whitespace().collect(); + if words.is_empty() { return None; } - let summary = content - .split_whitespace() - .take(50) - .collect::>() - .join(" "); - if summary.len() < content.len() { + let summary = words[..words.len().min(MAX_SUMMARY_WORDS)].join(" "); + if words.len() > MAX_SUMMARY_WORDS { Some(format!("{summary}...")) } else { Some(summary) } } -async fn upload_bytes( - dctx: &DeployContext, - bucket: &str, - object_path: &str, - bytes: &[u8], - mime: &str, -) -> Result { - let base_url = &dctx.base_url; - let url = format!("{base_url}/storage/v1/object/{bucket}/{object_path}"); - - let mut last_err = None; - for attempt in 0..3 { - let response = with_auth(dctx.client.post(&url), &dctx.api_key, &dctx.bearer) - .header("Content-Type", mime) - .body(bytes.to_vec()) - .send() - .await; - match response { - Ok(r) if r.status().is_success() => { - let storage_path = format!("{bucket}/{object_path}"); - info!("Uploaded {storage_path}"); - return Ok(storage_path); - } - Ok(r) => { - let status = r.status(); - let body = r.text().await.unwrap_or_default(); - last_err = Some(format!("HTTP {status} - {body}")); - } - Err(e) => last_err = Some(e.to_string()), - } - warn!( - "Upload attempt {}/3 failed: {}", - attempt + 1, - last_err.as_ref().unwrap() - ); - if attempt < 2 { - tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1))).await; - } - } - - Err(CiteError::Deploy(format!( - "Failed to upload {object_path} after 3 attempts: {}", - last_err.unwrap_or_default() - ))) -} - -fn mime_for_extension(ext: &str) -> &'static str { - match ext.to_lowercase().as_str() { - "md" => "text/markdown", - "rst" => "text/x-rst", - "json" => "application/json", - "mp3" => "audio/mpeg", - "wav" => "audio/wav", - "m4a" => "audio/mp4", - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "webp" => "image/webp", - _ => "application/octet-stream", - } -} - #[cfg(test)] mod tests { use super::*; - fn backend(url: &str, key: &str) -> BackendConfig { - BackendConfig { - staging_url: Some(url.to_string()), - staging_service_key: Some(key.to_string()), - } - } - - #[test] - fn test_resolve_bearer_uses_inline_when_no_session() { - assert_eq!( - resolve_bearer(&backend("https://x.supabase.co", "inline"), None).unwrap(), - "inline" - ); - } - - #[test] - fn test_resolve_bearer_prefers_session() { - let session = Session { - access_token: "user-jwt".into(), - refresh_token: "refresh".into(), - email: "a@b.c".into(), - }; - assert_eq!( - resolve_bearer(&backend("https://x.supabase.co", "inline"), Some(&session)).unwrap(), - "user-jwt" - ); - } - - #[test] - fn test_resolve_bearer_errors_when_empty() { - assert!(resolve_bearer(&backend("https://x.supabase.co", ""), None).is_err()); - } - #[test] fn test_extract_domain_name() { assert_eq!( @@ -1261,6 +852,14 @@ mod tests { extract_domain_name("scheme-less.example.com/foo"), Some("scheme-less.example.com".to_string()) ); + assert_eq!( + extract_domain_name("https://WWW.Reuters.com:443/tech?id=1"), + Some("reuters.com".to_string()) + ); + assert_eq!( + extract_domain_name("https://user@news.example.org#top"), + Some("news.example.org".to_string()) + ); assert_eq!(extract_domain_name(""), None); } @@ -1273,21 +872,13 @@ mod tests { assert!(s.len() < long.len()); assert_eq!(summarize_content(Some(" ")), None); assert_eq!(summarize_content(None), None); - } - - #[test] - fn test_event_date_rfc3339() { - assert_eq!( - event_date_rfc3339("2023").as_deref(), - Some("2023-01-01T00:00:00Z") - ); assert_eq!( - event_date_rfc3339(" 2023-03 ").as_deref(), - Some("2023-03-01T00:00:00Z") + summarize_content(Some( + "---\nk: v\n---\n# Title\n\nShort **episode**\nintro." + )) + .as_deref(), + Some("Short episode intro.") ); - assert_eq!(event_date_rfc3339("march"), None); - assert_eq!(event_date_rfc3339("2023-0"), None); - assert_eq!(event_date_rfc3339(""), None); } } @@ -1311,8 +902,8 @@ mod http_tests { }, build: BuildConfig::default(), backend: Some(BackendConfig { - staging_url: Some(staging_url.into()), - staging_service_key: Some("key".into()), + url: Some(staging_url.into()), + api_key: Some("key".into()), }), }; std::fs::write(dir.join("cite.toml"), toml::to_string(&manifest).unwrap()).unwrap(); @@ -1331,6 +922,12 @@ podcasts: - 77 - title: "Episode Two" file: content/episode-two.md + summary: Hand-written summary. + category: Tech + timeline: + - content/episode.md + - title: Written inline + date: 2025-05-22 "#, ) .unwrap(); @@ -1384,10 +981,19 @@ podcasts: let server = MockServer::start(); let base = server.base_url(); - let news = server.mock(|w, t| { - w.method(POST).path("/rest/v1/news"); + let news_one = server.mock(|w, t| { + w.method(POST) + .path("/rest/v1/news") + .body_contains("Episode One"); t.status(200).json_body(serde_json::json!([{ "id": 1 }])); }); + let news_two = server.mock(|w, t| { + w.method(POST) + .path("/rest/v1/news") + .body_contains("Episode Two") + .body_contains("Hand-written summary."); + t.status(200).json_body(serde_json::json!([{ "id": 2 }])); + }); let news_patch = server.mock(|w, t| { w.method(PATCH).path("/rest/v1/news"); t.status(200).json_body(serde_json::json!([{ "id": 1 }])); @@ -1462,7 +1068,11 @@ podcasts: .await .expect("deploy should succeed"); - assert_eq!(news.hits(), 2, "one news row per podcast"); + assert_eq!( + news_one.hits() + news_two.hits(), + 2, + "one news row per podcast" + ); assert_eq!( child_news_get.hits(), 1, @@ -1471,8 +1081,8 @@ podcasts: assert_eq!(news_patch.hits(), 1, "thumbnail patched after upload"); assert_eq!( categories_post.hits(), - 2, - "best-effort category insert per podcast (mock always succeeds)" + 1, + "unknown category created once and reused case-insensitively" ); assert_eq!( urls.hits(), @@ -1487,26 +1097,27 @@ podcasts: ); assert_eq!( timeline_child.hits(), - 1, - "news id ref linked as timeline row" + 2, + "news id and episode file linked as timeline rows" ); assert_eq!( timeline_inline.hits(), - 2, - "citation events deployed as inline events" + 3, + "citation events and the inline event deployed as events" ); - assert!(storage_post.hits() >= 1, "bundle/asset uploads"); + assert!(storage_post.hits() >= 1, "asset uploads"); assert!(artists_get.hits() >= 1, "artist existence checked"); + let records_dir = deployments_dir(&ctx); assert_eq!( - std::fs::read_dir(ctx.build_dir().join("deployments")) - .unwrap() - .count(), + std::fs::read_dir(&records_dir).unwrap().count(), 1, "deployment record persisted" ); + let lock = Lockfile::load(&ctx.root).unwrap(); + assert_eq!(lock.news_id(&base, "content/episode.md"), Some(1)); + assert_eq!(lock.news_id(&base, "content/episode-two.md"), Some(2)); - let deployments_dir = ctx.build_dir().join("deployments"); - let id = std::fs::read_dir(&deployments_dir) + let id = std::fs::read_dir(&records_dir) .unwrap() .next() .unwrap() @@ -1518,13 +1129,359 @@ podcasts: rollback(&ctx, &id).await.expect("rollback should succeed"); - assert_eq!(del_timeline.hits(), 3, "timeline rows deleted"); + assert_eq!( + del_timeline.hits(), + 0, + "timeline rows cascade with their news row" + ); assert_eq!(del_news.hits(), 2, "news deleted"); assert!(storage_del.hits() >= 1, "storage object deleted"); assert_eq!( - std::fs::read_dir(&deployments_dir).unwrap().count(), + std::fs::read_dir(&records_dir).unwrap().count(), 0, "local deployment record removed" ); + let lock = Lockfile::load(&ctx.root).unwrap(); + assert_eq!( + lock.news_id(&base, "content/episode.md"), + None, + "rolled-back news forgotten by cite.lock" + ); + } + + fn mock_lookups(server: &MockServer) { + server.mock(|w, t| { + w.method(GET).path("/rest/v1/categories"); + t.status(200) + .json_body(serde_json::json!([{ "id": 5, "name": "tech" }])); + }); + server.mock(|w, t| { + w.method(GET).path_contains("/rest/v1/artists"); + t.status(200) + .json_body(serde_json::json!([{ "id": "11111111-1111-1111-1111-111111111111" }])); + }); + server.mock(|w, t| { + w.method(GET).path_contains("/rest/v1/"); + t.status(200).json_body(serde_json::json!([])); + }); + } + + fn only_deployment_record(ctx: &ProjectContext) -> DeploymentRecord { + let dir = deployments_dir(ctx); + let mut entries = std::fs::read_dir(dir).unwrap(); + let path = entries.next().unwrap().unwrap().path(); + assert!(entries.next().is_none(), "exactly one deployment record"); + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() + } + + fn deployment_records(ctx: &ProjectContext) -> Vec { + std::fs::read_dir(deployments_dir(ctx)) + .unwrap() + .map(|entry| { + let json = std::fs::read_to_string(entry.unwrap().path()).unwrap(); + serde_json::from_str(&json).unwrap() + }) + .collect() + } + + #[tokio::test] + async fn test_redeploy_updates_existing_news() { + let server = MockServer::start(); + let existing = + |id: i64| serde_json::json!([{ "id": id, "thumbnail": null, "podcasts": null }]); + for (param, value, body) in [ + ("id", "eq.1", existing(1)), + ("id", "eq.2", existing(2)), + ("id", "eq.77", serde_json::json!([{ "id": 77 }])), + ("title", "eq.Episode One", existing(1)), + ] { + server.mock(|w, t| { + w.method(GET) + .path("/rest/v1/news") + .query_param(param, value); + t.status(200).json_body(body); + }); + } + mock_lookups(&server); + let news_post = server.mock(|w, t| { + w.method(POST).path("/rest/v1/news"); + t.status(200).json_body(serde_json::json!([{ "id": 2 }])); + }); + let republished = server.mock(|w, t| { + w.method(PATCH) + .path("/rest/v1/news") + .body_contains("published_at"); + t.status(200); + }); + let news_patch = server.mock(|w, t| { + w.method(PATCH).path("/rest/v1/news"); + t.status(200); + }); + let timeline_reset = server.mock(|w, t| { + w.method(DELETE) + .path("/rest/v1/timeline_news") + .query_param_exists("parent_news_id"); + t.status(200); + }); + server.mock(|w, t| { + w.method(POST); + t.status(200).json_body(serde_json::json!([{ "id": 9 }])); + }); + server.mock(|w, t| { + w.method(DELETE); + t.status(200); + }); + + let (_dir, ctx, db) = setup(&server.base_url()).await; + deploy(&db, &ctx, false).await.expect("first deploy"); + deploy(&db, &ctx, false).await.expect("second deploy"); + + assert_eq!(news_post.hits(), 1, "only Episode Two was ever inserted"); + assert!(news_patch.hits() >= 3, "news rows updated in place"); + assert_eq!(republished.hits(), 0, "published_at kept on update"); + assert_eq!(timeline_reset.hits(), 3, "timeline rebuilt per update"); + + let mut updated: Vec> = deployment_records(&ctx) + .into_iter() + .map(|r| { + assert!(r.news_ids.len() <= 1); + r.updated_news_ids + }) + .collect(); + updated.sort(); + assert_eq!(updated, vec![vec![1], vec![1, 2]]); + + let lock = Lockfile::load(&ctx.root).unwrap(); + let base = server.base_url(); + assert_eq!( + lock.news_id(&base, "content/episode.md"), + Some(1), + "adopted by title" + ); + assert_eq!(lock.news_id(&base, "content/episode-two.md"), Some(2)); + } + + #[tokio::test] + async fn test_lockfile_entry_wins_over_title_match() { + let server = MockServer::start(); + server.mock(|w, t| { + w.method(GET) + .path("/rest/v1/news") + .query_param("id", "eq.77"); + t.status(200).json_body(serde_json::json!([{ "id": 77 }])); + }); + let title_lookup = server.mock(|w, t| { + w.method(GET) + .path("/rest/v1/news") + .query_param_exists("title"); + t.status(200).json_body(serde_json::json!([{ "id": 1 }])); + }); + mock_lookups(&server); + let news_post = server.mock(|w, t| { + w.method(POST) + .path("/rest/v1/news") + .body_contains("Episode One"); + t.status(200).json_body(serde_json::json!([{ "id": 40 }])); + }); + let news_post_two = server.mock(|w, t| { + w.method(POST) + .path("/rest/v1/news") + .body_contains("Episode Two"); + t.status(200).json_body(serde_json::json!([{ "id": 41 }])); + }); + server.mock(|w, t| { + w.method(POST); + t.status(200).json_body(serde_json::json!([{ "id": 9 }])); + }); + server.mock(|w, t| { + w.method(PATCH); + t.status(200); + }); + + let (_dir, ctx, db) = setup(&server.base_url()).await; + let mut lock = Lockfile::default(); + for file in ["content/episode.md", "content/episode-two.md"] { + lock.set_news_id(&server.base_url(), file, 5); + } + lock.save(&ctx.root).unwrap(); + + deploy(&db, &ctx, false).await.expect("deploy"); + assert_eq!(news_post.hits() + news_post_two.hits(), 2); + assert_eq!(title_lookup.hits(), 0, "cite.lock is authoritative"); + + let lock = Lockfile::load(&ctx.root).unwrap(); + assert_eq!( + lock.news_id(&server.base_url(), "content/episode.md"), + Some(40) + ); + assert_eq!( + lock.news_id(&server.base_url(), "content/episode-two.md"), + Some(41) + ); + } + + #[tokio::test] + async fn test_failed_deploy_records_created_rows() { + let server = MockServer::start(); + mock_lookups(&server); + for table in ["urls", "domains", "news"] { + server.mock(|w, t| { + w.method(POST).path(format!("/rest/v1/{table}")); + t.status(200).json_body(serde_json::json!([{ "id": 1 }])); + }); + } + server.mock(|w, t| { + w.method(PATCH).path("/rest/v1/news"); + t.status(200).json_body(serde_json::json!([])); + }); + server.mock(|w, t| { + w.method(POST).path_contains("/storage/v1/object"); + t.status(200); + }); + server.mock(|w, t| { + w.method(POST).path("/rest/v1/podcasts"); + t.status(500).body("boom"); + }); + + let (_dir, ctx, db) = setup(&server.base_url()).await; + assert!(deploy(&db, &ctx, false).await.is_err()); + + let record = only_deployment_record(&ctx); + assert_eq!(record.news_ids, vec![1], "news row kept for rollback"); + assert_eq!(record.asset_paths.len(), 2, "thumbnail and audio kept"); + } + + #[tokio::test] + async fn test_missing_category_fails_before_any_insert() { + let server = MockServer::start(); + mock_lookups(&server); + let inserts = server.mock(|w, t| { + w.method(POST); + t.status(200).json_body(serde_json::json!([{ "id": 1 }])); + }); + + let (_dir, ctx, db) = setup(&server.base_url()).await; + let bundle_path = ctx.bundle_path(); + let mut bundle: Value = + serde_json::from_str(&std::fs::read_to_string(&bundle_path).unwrap()).unwrap(); + bundle["podcasts"][1] + .as_object_mut() + .unwrap() + .remove("category"); + std::fs::write(&bundle_path, bundle.to_string()).unwrap(); + + let err = deploy(&db, &ctx, false).await.unwrap_err(); + assert!(err.to_string().contains("has no category"), "{err}"); + assert!(err.to_string().contains("tech"), "lists available: {err}"); + assert_eq!(inserts.hits(), 0, "nothing written"); + } + #[tokio::test] + async fn test_dry_run_previews_from_lockfile_without_requests() { + let server = MockServer::start(); + let any = server.mock(|w, t| { + w.path_contains("/"); + t.status(500); + }); + let (_dir, ctx, db) = setup(&server.base_url()).await; + let mut lock = Lockfile::default(); + lock.set_news_id(&server.base_url(), "content/episode.md", 12); + lock.save(&ctx.root).unwrap(); + + let msg = deploy(&db, &ctx, true).await.expect("dry run"); + assert_eq!(msg, "Dry run: 2 podcast(s), 1 update(s), 1 new"); + assert_eq!(any.hits(), 0, "dry run never contacts Supabase"); + assert!(!deployments_dir(&ctx).exists(), "nothing recorded"); + } + + #[tokio::test] + async fn test_invalid_metadata_blocks_deploy_before_any_request() { + let server = MockServer::start(); + let any = server.mock(|w, t| { + w.path_contains("/"); + t.status(500); + }); + let (dir, _, db) = setup(&server.base_url()).await; + let metadata = std::fs::read_to_string(dir.path().join("metadata.yml")).unwrap(); + let long = "word ".repeat(MAX_SUMMARY_WORDS + 1); + std::fs::write( + dir.path().join("metadata.yml"), + metadata.replace("Hand-written summary.", &long), + ) + .unwrap(); + let ctx = ProjectContext::load(dir.path()).unwrap(); + + let err = deploy(&db, &ctx, false).await.unwrap_err().to_string(); + assert!(err.contains("summary has 51 words"), "{err}"); + assert_eq!(any.hits(), 0, "nothing sent"); + } + + #[tokio::test] + async fn test_deploy_rebuilds_changed_sources() { + let server = MockServer::start(); + let (dir, ctx, db) = setup(&server.base_url()).await; + std::fs::write( + dir.path().join("content/episode-two.md"), + "# Two\nRewritten.", + ) + .unwrap(); + + deploy(&db, &ctx, true).await.expect("dry run"); + let bundle = std::fs::read_to_string(ctx.bundle_path()).unwrap(); + assert!(bundle.contains("Rewritten."), "stale build not deployed"); + } + + #[tokio::test] + async fn test_rollback_refuses_another_supabase_project() { + let server = MockServer::start(); + let deletes = server.mock(|w, t| { + w.method(DELETE); + t.status(200); + }); + let (_dir, ctx, _db) = setup(&server.base_url()).await; + let record = DeploymentRecord { + deployment_id: "d1".into(), + backend_url: "https://other.supabase.co".into(), + storage_path: String::new(), + news_ids: vec![1], + updated_news_ids: Vec::new(), + timeline_ids: Vec::new(), + asset_paths: vec!["assets/x.png".into()], + }; + persist_deployment_record(&ctx, &record).await.unwrap(); + + let err = rollback(&ctx, "d1").await.unwrap_err().to_string(); + assert!(err.contains("went to https://other.supabase.co"), "{err}"); + assert_eq!(deletes.hits(), 0, "nothing deleted"); + assert!(rollback(&ctx, "unknown").await.is_err()); + } + + #[tokio::test] + async fn test_artist_must_belong_to_logged_in_user() { + let server = MockServer::start(); + server.mock(|w, t| { + w.method(GET).path("/rest/v1/artists"); + t.status(200).json_body(serde_json::json!([ + { "id": "11111111-1111-1111-1111-111111111111", "user_id": "owner" } + ])); + }); + let artist_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(); + let conn = |user_id: Option<&str>| Connection { + api: Supabase::new(reqwest::Client::new(), &server.base_url(), "k", "t"), + user_id: user_id.map(str::to_string), + }; + + assert!( + ensure_artist_owned(&conn(Some("owner")), artist_id) + .await + .is_ok() + ); + assert!( + ensure_artist_owned(&conn(None), artist_id).await.is_ok(), + "service key" + ); + let err = ensure_artist_owned(&conn(Some("someone-else")), artist_id) + .await + .unwrap_err(); + assert!(err.to_string().contains("not owned"), "{err}"); } } diff --git a/src/core/doctor.rs b/src/core/doctor.rs index def5d88..f119834 100644 --- a/src/core/doctor.rs +++ b/src/core/doctor.rs @@ -1,894 +1,774 @@ +//! `cite doctor`: deploy-blocking validation plus content-quality lints, all checked locally (ready when `errors` is empty). + use std::collections::{HashMap, HashSet}; +use std::hash::Hash; +use std::path::Path; use serde::Serialize; use tracing::{error, info, warn}; -use crate::core::CiteError; +use crate::core::bibtex; use crate::core::db::DbManager; +use crate::core::markdown::{split_frontmatter, word_count}; +use crate::core::media::{ + AUDIO_FORMATS, IMAGE_FORMATS, MAX_AUDIO_BYTES, MAX_IMAGE_BYTES, inspect_audio, inspect_image, +}; +use crate::core::metadata::{ + MAX_SUMMARY_WORDS, MAX_TITLE_CHARS, Podcast, TimelineEntry, TimelineItem, +}; use crate::core::project::ProjectContext; -#[derive(Serialize)] -pub enum DoctorOutcome { - Clean, - Findings { - errors: Vec, - warnings: Vec, - infos: Vec, - }, +#[derive(Debug, Default, Serialize)] +pub struct DoctorOutcome { + pub errors: Vec, + pub warnings: Vec, + pub infos: Vec, } impl DoctorOutcome { pub fn has_errors(&self) -> bool { - match self { - DoctorOutcome::Findings { errors, .. } => !errors.is_empty(), - _ => false, - } + !self.errors.is_empty() } pub fn has_warnings(&self) -> bool { - match self { - DoctorOutcome::Findings { warnings, .. } => !warnings.is_empty(), - _ => false, - } + !self.warnings.is_empty() } - pub fn merge(&mut self, other: DoctorOutcome) { - match other { - DoctorOutcome::Clean => {} - DoctorOutcome::Findings { - errors: new_errors, - warnings: new_warnings, - infos: new_infos, - } => { - for e in new_errors { - self.push_error(e); - } - for w in new_warnings { - self.push_warning(w); - } - for i in new_infos { - self.push_info(i); - } - } - } + pub fn emit(&self) { + self.errors.iter().for_each(|e| error!("{e}")); + self.warnings.iter().for_each(|w| warn!("{w}")); + self.infos.iter().for_each(|i| info!("{i}")); } - fn push_error(&mut self, msg: String) { - match self { - DoctorOutcome::Findings { errors, .. } => errors.push(msg), - DoctorOutcome::Clean => { - *self = DoctorOutcome::Findings { - errors: vec![msg], - warnings: Vec::new(), - infos: Vec::new(), - } - } - } + fn error(&mut self, msg: String) { + self.errors.push(msg); } - fn push_warning(&mut self, msg: String) { - match self { - DoctorOutcome::Findings { warnings, .. } => warnings.push(msg), - DoctorOutcome::Clean => { - *self = DoctorOutcome::Findings { - errors: Vec::new(), - warnings: vec![msg], - infos: Vec::new(), - } - } - } + fn warn(&mut self, msg: String) { + self.warnings.push(msg); } - fn push_info(&mut self, msg: String) { - match self { - DoctorOutcome::Findings { infos, .. } => infos.push(msg), - DoctorOutcome::Clean => { - *self = DoctorOutcome::Findings { - errors: Vec::new(), - warnings: Vec::new(), - infos: vec![msg], - } - } - } - } - - pub fn emit(&self) { - match self { - DoctorOutcome::Clean => {} - DoctorOutcome::Findings { - errors, - warnings, - infos, - } => { - for e in errors { - error!("{e}"); - } - for w in warnings { - warn!("{w}"); - } - for i in infos { - info!("{i}"); - } - } - } + fn info(&mut self, msg: String) { + self.infos.push(msg); } } -fn collect_findings( - errors: Vec, - warnings: Vec, - infos: Vec, -) -> DoctorOutcome { - if errors.is_empty() && warnings.is_empty() && infos.is_empty() { - DoctorOutcome::Clean - } else { - DoctorOutcome::Findings { - errors, - warnings, - infos, - } - } +pub async fn run(db: &DbManager, ctx: &ProjectContext) -> DoctorOutcome { + let mut out = DoctorOutcome::default(); + check_project(ctx, &mut out); + validate_into(ctx, &mut out); + lint(ctx, &mut out); + history(db, ctx, &mut out).await; + out } -pub async fn run(db: &DbManager, ctx: &ProjectContext) -> Result { - info!("Running diagnostics"); - - info!("Database: connected"); - let project_id = ctx.project_id(); - if let Ok(Some(_)) = db.load_cache(&project_id).await { - info!("Cache: present in database"); - } - - let mut outcome = validate_all(db, ctx).await; - outcome.merge(lint_all(ctx)); - - if ctx - .manifest - .backend - .as_ref() - .and_then(|b| b.staging_url.as_deref()) - .is_some_and(|s| !s.is_empty()) - { - info!("Backend configured for staging"); - } else { - outcome.push_warning( - "No backend configured in cite.toml — deploy will use credentials file or env vars" - .to_string(), - ); - } - - if ctx.root.join("cite.toml").exists() { - info!("cite.toml found"); - } - let metadata_file = &ctx.manifest.project.metadata_file; - if ctx.root.join(metadata_file).exists() { - info!("{metadata_file} found"); - } - - for dir in &["content", "assets/audio", "assets/image", "build"] { - let d = ctx.root.join(dir); - if d.is_dir() { - info!("{dir}/ exists"); - } else if *dir == "build" { - info!("{dir}/ missing (created by build)"); - } else { - info!("{dir}/ missing (will be created on init)"); - } - } - - if ctx.manifest.build.incremental { - info!("Incremental builds enabled"); - } - - if ctx.manifest.project.artist_id.is_empty() { - outcome.push_warning("Artist ID is empty — set it in [project] in cite.toml".to_string()); - } else { - info!("Artist ID: {}", ctx.manifest.project.artist_id); - } - - Ok(outcome) +pub fn validate(ctx: &ProjectContext) -> DoctorOutcome { + let mut out = DoctorOutcome::default(); + validate_into(ctx, &mut out); + out } -// ── Comprehensive Validation (PRD Section 12) ── - -async fn validate_all(db: &DbManager, ctx: &ProjectContext) -> DoctorOutcome { - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - let mut infos = Vec::new(); - - validate_project_structure(db, ctx, &mut errors, &mut warnings, &mut infos).await; - validate_metadata(ctx, &mut errors, &mut warnings); - validate_markdown(ctx, &mut errors, &mut warnings); - validate_audio(ctx, &mut errors, &mut warnings); - validate_images(ctx, &mut errors, &mut warnings); - validate_bibtex(ctx, &mut errors, &mut warnings); - validate_urls(ctx, &mut errors, &mut warnings); - - collect_findings(errors, warnings, infos) +fn validate_into(ctx: &ProjectContext, out: &mut DoctorOutcome) { + check_metadata(ctx, out); + for pod in &ctx.metadata.podcasts { + check_markdown(ctx, pod, out); + check_audio(ctx, pod, out); + check_image(ctx, pod, out); + check_bibtex(ctx, pod, out); + } } -async fn validate_project_structure( - db: &DbManager, - ctx: &ProjectContext, - errors: &mut Vec, - warnings: &mut Vec, - infos: &mut Vec, -) { - let required = [ - ("cite.toml", ctx.root.join("cite.toml")), - ( - &ctx.manifest.project.metadata_file, - ctx.root.join(&ctx.manifest.project.metadata_file), - ), - ]; - for (name, path) in &required { - if !path.exists() { - errors.push(format!( - "Required file '{name}' not found at {}", - path.display() +fn check_project(ctx: &ProjectContext, out: &mut DoctorOutcome) { + let project = &ctx.manifest.project; + for name in ["cite.toml", project.metadata_file.as_str()] { + if ctx.root.join(name).exists() { + out.info(format!("{name} found")); + } else { + out.error(format!( + "Required file '{name}' not found in {}", + ctx.root.display() )); } } - - let dirs = [ - ("content", ctx.content_dir()), - ("assets", ctx.root.join("assets")), - ("assets/image", ctx.root.join("assets/image")), - ("assets/audio", ctx.root.join("assets/audio")), - ]; - for (name, path) in &dirs { - if !path.is_dir() { - warnings.push(format!( - "Directory '{name}' does not exist at {}", - path.display() - )); + for dir in ["content", "assets/audio", "assets/image"] { + if !ctx.root.join(dir).is_dir() { + out.warn(format!("Directory '{dir}/' does not exist")); } } - let cite_dir = ctx.root.join(".cite"); - if cite_dir.is_dir() { - infos.push(".cite/ directory found".to_string()); + out.info(format!( + "Project: {} ({})", + project.name, + ctx.root.display() + )); + out.info(format!("Artist ID: {}", project.artist_id)); + out.info(format!("Podcasts: {}", ctx.metadata.podcasts.len())); + match project.artist_id.trim() { + "" => out.warn( + "Artist ID is empty — set artist_id in [project] in cite.toml to one of the artists 'cite login' lists" + .into(), + ), + id if uuid::Uuid::parse_str(id).is_err() => { + out.error("artist_id in cite.toml must be a valid UUID".into()); + } + _ => {} } - let project_id = ctx.project_id(); - if let Ok(stats) = db.get_project_stats(&project_id).await { - infos.push(format!( - "Database stats: {} builds, {} deployments", - stats.build_count, stats.deployment_count - )); + match ctx.manifest.backend.as_ref().and_then(|b| b.url.as_deref()) { + Some(url) if !url.is_empty() => out.info(format!("Backend: {url}")), + _ => out.info( + "No [backend] in cite.toml; deploy uses CITE_SUPABASE_* or ~/.cite/credentials.toml" + .into(), + ), } } -fn validate_metadata(ctx: &ProjectContext, errors: &mut Vec, warnings: &mut Vec) { +fn check_metadata(ctx: &ProjectContext, out: &mut DoctorOutcome) { let podcasts = &ctx.metadata.podcasts; if podcasts.is_empty() { - warnings.push("No podcasts defined in metadata".to_string()); + out.warn("No podcasts defined in metadata".into()); return; } - if !ctx.manifest.project.artist_id.is_empty() - && uuid::Uuid::parse_str(&ctx.manifest.project.artist_id).is_err() - { - errors.push("artist_id in cite.toml must be a valid UUID".to_string()); - } - let mut titles = HashSet::new(); let mut files = HashSet::new(); - + let mut source_urls = HashSet::new(); for (i, pod) in podcasts.iter().enumerate() { - if pod.title.trim().is_empty() { - errors.push(format!("Podcast #{} has empty title", i + 1)); + let name = &pod.title; + check_title(&format!("Podcast #{}", i + 1), name, out); + if !titles.insert(name.as_str()) { + out.error(format!("Duplicate podcast title: '{name}'")); + } + if pod.category.is_none() { + out.error(format!( + "Podcast '{name}' has no category — set one that exists in Supabase, e.g. 'category: Politics'" + )); } - - if pod.file.trim().is_empty() { - errors.push(format!("Podcast '{}' has empty file path", pod.title)); - } else { - if !titles.insert(pod.title.clone()) { - errors.push(format!("Duplicate podcast title: '{}'", pod.title)); - } - if !files.insert(pod.file.clone()) { - errors.push(format!( - "Duplicate file reference: '{}' in podcast '{}'", - pod.file, pod.title - )); - } - - let path = ctx.root.join(&pod.file); - if !path.exists() { - errors.push(format!( - "Podcast '{}' references file '{}' which does not exist", - pod.title, pod.file + if let Some(summary) = &pod.summary { + let words = word_count(summary); + if words > MAX_SUMMARY_WORDS as i64 { + out.error(format!( + "Podcast '{name}' summary has {words} words (at most {MAX_SUMMARY_WORDS})" )); } } - let mut citation_count = 0usize; - for item in &pod.timeline { - match item { - crate::core::metadata::TimelineItem::Citation(cit) => { - citation_count += 1; - if !ctx.root.join(cit).exists() { - errors.push(format!( - "Podcast '{}' references citation file '{cit}' which does not exist", - pod.title - )); - } - } - crate::core::metadata::TimelineItem::News(id) => { - if *id <= 0 { - errors.push(format!( - "Podcast '{}' has invalid timeline news id {id} (must be positive)", - pod.title - )); - } - } + if pod.file.trim().is_empty() { + out.error(format!("Podcast '{name}' has empty file path")); + } else if !files.insert(pod.file.as_str()) { + out.error(format!("Duplicate file '{}' in podcast '{name}'", pod.file)); + } + let paths = [ + ("file", Some(&pod.file)), + ("audio", pod.audio.as_ref()), + ("thumbnail", pod.thumbnail.as_ref()), + ]; + for (field, path) in paths { + if let Some(path) = path.filter(|p| !p.trim().is_empty()) + && !ctx.root.join(path).exists() + { + out.error(format!("Podcast '{name}' {field} '{path}' does not exist")); } } - if citation_count > 1 { - errors.push(format!( - "Podcast '{}' declares {citation_count} citation files (at most one)", - pod.title - )); - } - if let Some(ref audio) = pod.audio { - let audio_path = ctx.root.join(audio); - if !audio_path.exists() { - errors.push(format!( - "Podcast '{}' references audio file '{}' which does not exist", - pod.title, audio + if let Some(url) = pod.source_url.as_deref() { + if !is_web_url(url) { + out.error(format!( + "Podcast '{name}' source_url '{url}' is not a web link" )); } - } - - if let Some(ref thumb) = pod.thumbnail { - let thumb_path = ctx.root.join(thumb); - if !thumb_path.exists() { - errors.push(format!( - "Podcast '{}' references thumbnail file '{}' which does not exist", - pod.title, thumb - )); + if !source_urls.insert(url) { + out.warn(format!("Podcast '{name}' has duplicate source_url '{url}'")); } } + + check_timeline(ctx, pod, out); } } -fn validate_markdown(ctx: &ProjectContext, errors: &mut Vec, warnings: &mut Vec) { - for pod in &ctx.metadata.podcasts { - if pod.file.is_empty() { - continue; - } - let path = ctx.root.join(&pod.file); - if !path.exists() { - continue; - } - match std::fs::read_to_string(&path) { - Ok(content) => { - if content.trim().is_empty() { - errors.push(format!("Podcast '{}' has empty markdown file", pod.title)); +fn check_timeline(ctx: &ProjectContext, pod: &Podcast, out: &mut DoctorOutcome) { + let name = &pod.title; + let mut citations = 0; + for item in &pod.timeline { + match item { + TimelineItem::Citation(path) => { + citations += 1; + if !ctx.root.join(path).exists() { + out.error(format!( + "Podcast '{name}' citation file '{path}' does not exist" + )); } - let lines: Vec<&str> = content.lines().collect(); - if let Some(first) = lines.first() - && first.starts_with("---") - { - match lines - .iter() - .skip(1) - .position(|l| l.trim() == "---") - .map(|i| i + 1) - { - Some(end) => { - let frontmatter: Vec<&&str> = lines[1..end].iter().collect(); - if frontmatter.is_empty() { - warnings.push(format!( - "Podcast '{}' has empty YAML frontmatter", - pod.title - )); - } - let yaml = lines[1..end].join("\n"); - if let Err(e) = serde_yaml::from_str::(&yaml) { - errors.push(format!( - "Podcast '{}' has invalid YAML frontmatter: {e}", - pod.title - )); - } - } - None => { - warnings.push(format!( - "Podcast '{}' has unclosed YAML frontmatter", - pod.title - )); - } - } + } + TimelineItem::Episode(file) if *file == pod.file => { + out.error(format!("Podcast '{name}' lists itself in its timeline")); + } + TimelineItem::Episode(file) => { + if !ctx.metadata.podcasts.iter().any(|p| p.file == *file) { + out.error(format!( + "Podcast '{name}' timeline entry '{file}' is neither a .bib file nor another episode's file" + )); } } - Err(e) => { - errors.push(format!( - "Podcast '{}' file '{}' cannot be read: {e}", - pod.title, pod.file + TimelineItem::News(id) if *id <= 0 => { + out.error(format!( + "Podcast '{name}' timeline news id {id} must be positive" )); } + TimelineItem::News(_) => {} + TimelineItem::Event(event) => { + check_event(&format!("Podcast '{name}' timeline event"), event, out); + } } } + if citations > 1 { + out.error(format!( + "Podcast '{name}' lists {citations} .bib files (at most one)" + )); + } } -fn validate_audio(ctx: &ProjectContext, errors: &mut Vec, _warnings: &mut Vec) { - let supported = ["mp3", "wav", "flac", "ogg", "m4a"]; - - for pod in &ctx.metadata.podcasts { - let Some(ref audio) = pod.audio else { - continue; - }; - let path = ctx.root.join(audio); - if !path.exists() { - continue; - } - - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - if !supported.contains(&ext.as_str()) { - errors.push(format!( - "Podcast '{}' has unsupported audio format '.{ext}' (supported: mp3, wav, flac, ogg, m4a)", - pod.title - )); - } - - let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); - const MAX_AUDIO_SIZE: u64 = 500 * 1024 * 1024; - if size > MAX_AUDIO_SIZE { - errors.push(format!( - "Podcast '{}' audio file exceeds 500 MB ({} bytes)", - pod.title, size - )); - } +fn check_event(context: &str, event: &TimelineEntry, out: &mut DoctorOutcome) { + check_title(context, &event.title, out); + let label = format!("{context} '{}'", event.title.trim()); + if let Some(date) = event.date.as_deref() + && event.event_date().is_none() + { + out.warn(format!( + "{label} date '{date}' is not understood (e.g. 2024, 2024-05, May 2024, 2024-05-22); it deploys without a date" + )); + } + if let Some(url) = event.url.as_deref() + && !is_web_url(url) + { + out.error(format!("{label} url '{url}' is not a web link")); } } -fn validate_images(ctx: &ProjectContext, errors: &mut Vec, _warnings: &mut Vec) { - let supported = ["jpg", "jpeg", "png", "webp", "gif"]; - - for pod in &ctx.metadata.podcasts { - let Some(ref thumb) = pod.thumbnail else { - continue; - }; - let path = ctx.root.join(thumb); - if !path.exists() { - continue; - } - - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - if !supported.contains(&ext.as_str()) { - errors.push(format!( - "Podcast '{}' has unsupported image format '.{ext}' (supported: jpg, png, webp, gif)", - pod.title - )); - } - - let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); - const MAX_IMAGE_SIZE: u64 = 5 * 1024 * 1024; - if size > MAX_IMAGE_SIZE { - errors.push(format!( - "Podcast '{}' image file exceeds 5 MB ({} bytes)", - pod.title, size - )); - } - - if let Ok(meta) = crate::core::media::inspect_image(&path) - && meta.width > 0 - && meta.height > 0 - && (meta.width < 100 || meta.height < 100) - { - errors.push(format!( - "Podcast '{}' image is too small ({}x{}), minimum 100x100 pixels", - pod.title, meta.width, meta.height - )); - } +fn check_title(context: &str, title: &str, out: &mut DoctorOutcome) { + if title.trim().is_empty() { + out.error(format!("{context} has an empty title")); + } else if title.chars().count() > MAX_TITLE_CHARS { + out.error(format!( + "{context} '{}…' title is longer than {MAX_TITLE_CHARS} characters", + title.chars().take(40).collect::() + )); } } -fn validate_bibtex(ctx: &ProjectContext, errors: &mut Vec, warnings: &mut Vec) { - for pod in &ctx.metadata.podcasts { - let Some(citation) = pod.citation() else { - continue; - }; - let path = ctx.root.join(citation); - if !path.exists() { - continue; - } - match std::fs::read_to_string(&path) { - Ok(content) => { - if content.trim().is_empty() { - warnings.push(format!("Podcast '{}' has empty BibTeX file", pod.title)); - continue; - } - let entries = crate::core::compiler::parse_bibtex(&content); - if entries.is_empty() { - warnings.push(format!( - "Podcast '{}' BibTeX file parsed but no entries found", - pod.title - )); - } else { - let mut keys = HashSet::new(); - for entry in &entries { - if !keys.insert(entry.title.clone()) { - warnings.push(format!( - "Podcast '{}' BibTeX has duplicate entry: '{}'", - pod.title, entry.title - )); - } - } - } - } - Err(e) => { - errors.push(format!( - "Podcast '{}' BibTeX file '{}' cannot be read: {e}", - pod.title, citation +fn check_markdown(ctx: &ProjectContext, pod: &Podcast, out: &mut DoctorOutcome) { + let Some(content) = read_existing(ctx, &pod.file, &pod.title, out) else { + return; + }; + let name = &pod.title; + if content.trim().is_empty() { + out.error(format!("Podcast '{name}' has an empty Markdown file")); + } + match split_frontmatter(&content) { + (Some(yaml), _) if yaml.trim().is_empty() => { + out.warn(format!("Podcast '{name}' has empty YAML frontmatter")); + } + (Some(yaml), _) => { + if let Err(e) = serde_yaml::from_str::(yaml) { + out.error(format!( + "Podcast '{name}' has invalid YAML frontmatter: {e}" )); } } + (None, _) if content.starts_with("---") => { + out.warn(format!("Podcast '{name}' has unclosed YAML frontmatter")); + } + (None, _) => {} } } -fn validate_urls(ctx: &ProjectContext, errors: &mut Vec, warnings: &mut Vec) { - let mut urls = HashSet::new(); - - for pod in &ctx.metadata.podcasts { - if let Some(ref url) = pod.source_url - && !url.trim().is_empty() - { - if !url.starts_with("http://") - && !url.starts_with("https://") - && !url.starts_with("cite://") - { - errors.push(format!( - "Podcast '{}' has invalid source_url: '{url}' (must start with http://, https://, or cite://)", - pod.title - )); - } - if !urls.insert(url.clone()) { - warnings.push(format!( - "Podcast '{}' has duplicate source_url: '{url}'", - pod.title - )); - } - } +fn check_audio(ctx: &ProjectContext, pod: &Podcast, out: &mut DoctorOutcome) { + let Some(audio) = &pod.audio else { return }; + let path = ctx.root.join(audio); + if path.is_file() { + check_asset( + &pod.title, + "audio", + &path, + AUDIO_FORMATS, + MAX_AUDIO_BYTES, + out, + ); } } -// ── Lint Rules (PRD Section 13) ── - -pub fn lint_all(ctx: &ProjectContext) -> DoctorOutcome { - let mut warnings = Vec::new(); - let mut infos = Vec::new(); - - if ctx.metadata.podcasts.is_empty() { - warnings.push("No podcasts to lint".to_string()); - return collect_findings(Vec::new(), warnings, infos); +fn check_image(ctx: &ProjectContext, pod: &Podcast, out: &mut DoctorOutcome) { + let Some(thumb) = &pod.thumbnail else { return }; + let path = ctx.root.join(thumb); + if !path.is_file() { + return; + } + check_asset( + &pod.title, + "image", + &path, + IMAGE_FORMATS, + MAX_IMAGE_BYTES, + out, + ); + if let Ok(meta) = inspect_image(&path) + && meta.width > 0 + && meta.height > 0 + && (meta.width < 100 || meta.height < 100) + { + out.error(format!( + "Podcast '{}' image is too small ({}x{}), minimum 100x100 pixels", + pod.title, meta.width, meta.height + )); } +} - let mut all_content = Vec::new(); - let mut audio_durations = Vec::new(); - let mut audio_formats = Vec::new(); - let mut sample_rates = Vec::new(); - let mut bitrates = Vec::new(); - let mut image_sizes = Vec::new(); +fn check_asset( + title: &str, + kind: &str, + path: &Path, + formats: &[&str], + max_bytes: u64, + out: &mut DoctorOutcome, +) { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_lowercase(); + if !formats.contains(&ext.as_str()) { + out.error(format!( + "Podcast '{title}' has unsupported {kind} format '.{ext}' (supported: {})", + formats.join(", ") + )); + } + let size = std::fs::metadata(path).map_or(0, |m| m.len()); + if size > max_bytes { + out.error(format!( + "Podcast '{title}' {kind} file exceeds {} MB ({size} bytes)", + max_bytes / (1024 * 1024) + )); + } +} - for pod in &ctx.metadata.podcasts { - let path = ctx.root.join(&pod.file); - if !path.exists() { - continue; +fn check_bibtex(ctx: &ProjectContext, pod: &Podcast, out: &mut DoctorOutcome) { + let Some(citation) = pod.citation() else { + return; + }; + let Some(content) = read_existing(ctx, citation, &pod.title, out) else { + return; + }; + let name = &pod.title; + let entries = bibtex::parse(&content); + if entries.is_empty() { + out.warn(format!( + "Podcast '{name}' BibTeX file '{citation}' has no entries" + )); + } + let mut seen = HashSet::new(); + for entry in &entries { + if !seen.insert(entry.title.as_str()) { + out.warn(format!( + "Podcast '{name}' BibTeX has duplicate entry '{}'", + entry.title + )); } + check_event(&format!("'{citation}' entry"), entry, out); + } +} - let content_str = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => continue, - }; +fn read_existing( + ctx: &ProjectContext, + file: &str, + title: &str, + out: &mut DoctorOutcome, +) -> Option { + let path = ctx.root.join(file); + if file.trim().is_empty() || !path.is_file() { + return None; + } + std::fs::read_to_string(&path) + .map_err(|e| { + out.error(format!( + "Podcast '{title}' file '{file}' cannot be read: {e}" + )) + }) + .ok() +} - let word_count = content_str.split_whitespace().count(); - let reading_time = word_count.div_ceil(200); +fn is_web_url(url: &str) -> bool { + let host = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .and_then(|rest| rest.split(['/', '?', '#']).next()); + host.is_some_and(|h| !h.is_empty()) && !url.contains(char::is_whitespace) +} - if word_count < 100 { - warnings.push(format!( - "Podcast '{}' has low word count ({} words) — minimum recommended is 100", - pod.title, word_count - )); - } - if word_count > 50_000 { - warnings.push(format!( - "Podcast '{}' has very high word count ({} words) — maximum recommended is 50,000", - pod.title, word_count - )); - } +async fn history(db: &DbManager, ctx: &ProjectContext, out: &mut DoctorOutcome) { + let project_id = ctx.project_id(); + if let Ok(stats) = db.get_project_stats(&project_id).await + && let Some(last_built) = &stats.last_built + { + out.info(format!( + "Last build: {last_built} ({} words, {} timeline entries)", + stats.total_words, stats.timeline_count + )); + } + if let Ok(deploys) = db.get_deployment_history(&project_id).await + && let Some(last) = deploys.first() + { + out.info(format!( + "Last deploy: {} at {} ({})", + last.deployment_id, + last.deployed_at, + if last.success { "ok" } else { "failed" } + )); + } +} - if reading_time > 120 { - warnings.push(format!( - "Podcast '{}' reading time is over 2 hours", - pod.title - )); +fn lint(ctx: &ProjectContext, out: &mut DoctorOutcome) { + let mut contents = Vec::new(); + let mut audio = Vec::new(); + for pod in &ctx.metadata.podcasts { + let name = &pod.title; + if let Ok(content) = std::fs::read_to_string(ctx.root.join(&pod.file)) { + lint_content(pod, &content, out); + contents.push((name.as_str(), content)); + } + match &pod.audio { + Some(file) => { + if let Ok(meta) = inspect_audio(&ctx.root.join(file)) { + lint_audio(name, &meta, out); + audio.push((name.as_str(), meta)); + } + } + None => out.info(format!("Podcast '{name}' has no audio file (optional)")), } - - let has_h1 = content_str.lines().any(|l| l.starts_with("# ")); - let has_h2 = content_str.lines().any(|l| l.starts_with("## ")); - if !has_h1 && !has_h2 { - warnings.push(format!("Podcast '{}' has no H1 or H2 headings", pod.title)); - } else if !has_h1 { - warnings.push(format!( - "Podcast '{}' has no H1 heading — consider adding a title", - pod.title - )); + if let Some(file) = &pod.thumbnail + && let Ok(meta) = inspect_image(&ctx.root.join(file)) + { + lint_image(name, &meta, out); } + } - let (paragraph_count, short_paras) = content_str - .split("\n\n") - .map(str::trim) - .filter(|p| !p.is_empty()) - .fold((0usize, 0usize), |(total, short), p| { - let is_short = p.split_whitespace().count() < 20; - (total + 1, short + usize::from(is_short)) - }); - if short_paras > 0 && paragraph_count > 1 { - warnings.push(format!( - "Podcast '{}' has {short_paras} short paragraph(s) (< 20 words) — consider expanding", - pod.title + if let Some(format) = majority(audio.iter().map(|(_, m)| &m.format)) { + for (name, meta) in audio.iter().filter(|(_, m)| &m.format != format) { + out.warn(format!( + "Podcast '{name}' audio is '{}' while most episodes use '{format}'", + meta.format )); } - - if word_count > 500 { - let has_citation = pod.citation().is_some(); - if !has_citation { - warnings.push(format!( - "Podcast '{}' is long ({} words) but has no BibTeX citation file", - pod.title, word_count + } + if let Some(&rate) = majority(audio.iter().map(|(_, m)| &m.sample_rate_hz)) { + for (name, meta) in audio.iter() { + if meta.sample_rate_hz > 0 && meta.sample_rate_hz != rate { + out.warn(format!( + "Podcast '{name}' audio is {} Hz while most episodes use {rate} Hz", + meta.sample_rate_hz )); } } + } + lint_duplicate_paragraphs(&contents, out); +} - // Audio analysis - if let Some(ref audio) = pod.audio { - let audio_path = ctx.root.join(audio); - if audio_path.exists() { - infos.push(format!("Podcast '{}' has audio file: {}", pod.title, audio)); - - if let Ok(meta) = crate::core::media::inspect_audio(&audio_path) { - audio_durations.push(meta.duration_secs); - audio_formats.push(meta.format.clone()); - sample_rates.push(meta.sample_rate_hz); - bitrates.push(meta.bitrate_kbps); - - if meta.duration_secs < 60.0 && meta.duration_secs > 0.0 { - warnings.push(format!( - "Podcast '{}' audio is very short ({:.0}s)", - pod.title, meta.duration_secs - )); - } - if meta.duration_secs > 14400.0 { - warnings.push(format!( - "Podcast '{}' audio is very long ({:.0}s > 4 hours)", - pod.title, meta.duration_secs - )); - } - - if meta.bitrate_kbps > 0 && meta.bitrate_kbps < 128 { - warnings.push(format!( - "Podcast '{}' audio bitrate is low ({} kbps < 128)", - pod.title, meta.bitrate_kbps - )); - } - if meta.bitrate_kbps > 320 { - warnings.push(format!( - "Podcast '{}' audio bitrate is high ({} kbps > 320)", - pod.title, meta.bitrate_kbps - )); - } - - if meta.size_bytes > 200 * 1024 * 1024 { - warnings.push(format!( - "Podcast '{}' audio file is large ({} MB > 200 MB)", - pod.title, - meta.size_bytes / (1024 * 1024) - )); - } - } - } - } else { - infos.push(format!( - "Podcast '{}' has no audio file (audio is optional)", - pod.title - )); - } - - // Image analysis - if let Some(ref thumb) = pod.thumbnail { - let thumb_path = ctx.root.join(thumb); - if thumb_path.exists() - && let Ok(meta) = crate::core::media::inspect_image(&thumb_path) - { - image_sizes.push((meta.width, meta.height, meta.size_bytes)); - - if (meta.width > 0 && meta.width < 200) || (meta.height > 0 && meta.height < 200) { - warnings.push(format!( - "Podcast '{}' thumbnail is small ({}x{}) — minimum 200x200 recommended", - pod.title, meta.width, meta.height - )); - } - if meta.width > 8000 || meta.height > 8000 { - warnings.push(format!( - "Podcast '{}' thumbnail is very large ({}x{}) — maximum 8000x8000 recommended", - pod.title, meta.width, meta.height - )); - } +fn lint_content(pod: &Podcast, content: &str, out: &mut DoctorOutcome) { + let name = &pod.title; + let words = word_count(content); + if words < 100 { + out.warn(format!( + "Podcast '{name}' has low word count ({words} words) — minimum recommended is 100" + )); + } else if words > 50_000 { + out.warn(format!( + "Podcast '{name}' has very high word count ({words} words) — maximum recommended is 50,000" + )); + } + if words > 500 && pod.citation().is_none() && pod.inline_events().next().is_none() { + out.warn(format!( + "Podcast '{name}' is long ({words} words) but its timeline cites no sources" + )); + } - if meta.size_bytes > 3 * 1024 * 1024 { - warnings.push(format!( - "Podcast '{}' thumbnail is large ({} MB > 3 MB)", - pod.title, - meta.size_bytes / (1024 * 1024) - )); - } - } - } + let has_h1 = content.lines().any(|l| l.starts_with("# ")); + let has_h2 = content.lines().any(|l| l.starts_with("## ")); + if !has_h1 && !has_h2 { + out.warn(format!("Podcast '{name}' has no H1 or H2 headings")); + } else if !has_h1 { + out.warn(format!( + "Podcast '{name}' has no H1 heading — consider adding a title" + )); + } - all_content.push((&pod.title, content_str)); + let paragraphs: Vec<&str> = content + .split("\n\n") + .map(str::trim) + .filter(|p| !p.is_empty()) + .collect(); + let short = paragraphs.iter().filter(|p| word_count(p) < 20).count(); + if short > 0 && paragraphs.len() > 1 { + out.warn(format!( + "Podcast '{name}' has {short} short paragraph(s) (< 20 words) — consider expanding" + )); } +} - // Cross-podcast lint checks - if !audio_formats.is_empty() { - let mut counts = std::collections::HashMap::new(); - for fmt in &audio_formats { - *counts.entry(fmt.clone()).or_insert(0) += 1; - } - let majority = counts - .into_iter() - .max_by_key(|&(_, c)| c) - .map(|(f, _)| f) - .unwrap_or_else(|| audio_formats[0].clone()); - for (i, fmt) in audio_formats.iter().enumerate() { - if fmt != &majority { - warnings.push(format!( - "Audio format inconsistency: podcast {} uses '{}' while most use '{majority}'", - i + 1, - fmt - )); - } - } +fn lint_audio(name: &str, meta: &crate::core::media::AudioMeta, out: &mut DoctorOutcome) { + let secs = meta.duration_secs; + if secs > 0.0 && secs < 60.0 { + out.warn(format!("Podcast '{name}' audio is very short ({secs:.0}s)")); + } else if secs > 4.0 * 3600.0 { + out.warn(format!("Podcast '{name}' audio is longer than 4 hours")); + } + let kbps = meta.bitrate_kbps; + if kbps > 0 && kbps < 128 { + out.warn(format!( + "Podcast '{name}' audio bitrate is low ({kbps} kbps < 128)" + )); + } else if kbps > 320 { + out.warn(format!( + "Podcast '{name}' audio bitrate is high ({kbps} kbps > 320)" + )); + } + if meta.size_bytes > 50 * 1024 * 1024 { + out.warn(format!( + "Podcast '{name}' audio file is large ({} MB > 50 MB)", + meta.size_bytes / (1024 * 1024) + )); } +} - if sample_rates.len() >= 2 { - let mut counts = std::collections::HashMap::new(); - for &rate in &sample_rates { - *counts.entry(rate).or_insert(0) += 1; - } - let majority_rate = counts - .into_iter() - .max_by_key(|&(_, c)| c) - .map(|(r, _)| r) - .unwrap_or(sample_rates[0]); - for (i, &rate) in sample_rates.iter().enumerate() { - if rate > 0 && rate != majority_rate { - warnings.push(format!( - "Sample rate inconsistency: podcast {} uses {} Hz while most use {majority_rate} Hz", - i + 1, - rate - )); - } - } +fn lint_image(name: &str, meta: &crate::core::media::ImageMeta, out: &mut DoctorOutcome) { + let (w, h) = (meta.width, meta.height); + if (w > 0 && w < 200) || (h > 0 && h < 200) { + out.warn(format!( + "Podcast '{name}' thumbnail is small ({w}x{h}) — minimum 200x200 recommended" + )); + } else if w > 8000 || h > 8000 { + out.warn(format!( + "Podcast '{name}' thumbnail is very large ({w}x{h}) — maximum 8000x8000 recommended" + )); } + if meta.size_bytes > 3 * 1024 * 1024 { + out.warn(format!( + "Podcast '{name}' thumbnail is large ({} MB > 3 MB)", + meta.size_bytes / (1024 * 1024) + )); + } +} - // Detect paragraphs repeated across podcasts (single pass, hashed by paragraph text) +fn lint_duplicate_paragraphs(contents: &[(&str, String)], out: &mut DoctorOutcome) { let mut first_seen: HashMap<&str, usize> = HashMap::new(); - for (idx, (title, content)) in all_content.iter().enumerate() { + for (idx, (title, content)) in contents.iter().enumerate() { let mut reported = HashSet::new(); for (pi, para) in content.split("\n\n").enumerate() { - let trimmed = para.trim(); - if trimmed.len() <= 20 { + let para = para.trim(); + if para.len() <= 20 { continue; } - match first_seen.get(trimmed) { - Some(&owner) if owner != idx => { - if reported.insert(trimmed) { - warnings.push(format!( - "Duplicate paragraph found in '{}' and '{title}' (paragraph {})", - all_content[owner].0, - pi + 1 - )); - } + match first_seen.get(para) { + Some(&owner) if owner != idx && reported.insert(para) => { + out.warn(format!( + "Duplicate paragraph found in '{}' and '{title}' (paragraph {})", + contents[owner].0, + pi + 1 + )); } Some(_) => {} None => { - first_seen.insert(trimmed, idx); + first_seen.insert(para, idx); } } } } +} - collect_findings(Vec::new(), warnings, infos) +fn majority<'a, T: Eq + Hash + 'a>(values: impl Iterator) -> Option<&'a T> { + let mut counts: HashMap<&T, usize> = HashMap::new(); + for value in values { + *counts.entry(value).or_default() += 1; + } + if counts.values().sum::() < 2 { + return None; + } + counts.into_iter().max_by_key(|&(_, c)| c).map(|(v, _)| v) } #[cfg(test)] mod tests { use super::*; + fn project(metadata: &str) -> (tempfile::TempDir, ProjectContext) { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("cite.toml"), "[project]\nname = \"p\"\n").unwrap(); + std::fs::write(dir.path().join("metadata.yml"), metadata).unwrap(); + std::fs::create_dir_all(dir.path().join("content")).unwrap(); + std::fs::write(dir.path().join("content/a.md"), "# A\n\nText").unwrap(); + let ctx = ProjectContext::load(dir.path()).unwrap(); + (dir, ctx) + } + + fn metadata_findings(metadata: &str) -> DoctorOutcome { + let (_dir, ctx) = project(metadata); + let mut out = DoctorOutcome::default(); + check_metadata(&ctx, &mut out); + out + } + #[test] - fn test_doctor_outcome_clean() { - let o = DoctorOutcome::Clean; - assert!(!o.has_errors()); - assert!(!o.has_warnings()); + fn test_outcome_flags() { + let mut out = DoctorOutcome::default(); + assert!(!out.has_errors() && !out.has_warnings()); + out.warn("w".into()); + assert!(out.has_warnings() && !out.has_errors()); + out.error("e".into()); + assert!(out.has_errors()); } #[test] - fn test_doctor_outcome_push_error() { - let mut o = DoctorOutcome::Clean; - o.push_error("err1".into()); - assert!(o.has_errors()); - assert!(!o.has_warnings()); - if let DoctorOutcome::Findings { errors, .. } = &o { - assert_eq!(errors.len(), 1); - } else { - panic!("expected Findings"); - } + fn test_inline_events_are_validated() { + let out = metadata_findings( + r#" +podcasts: + - title: A + file: content/a.md + category: tech + timeline: + - title: Good event + date: 2025-05-22 + url: https://example.com/a + - title: "" + - title: Missing scheme is fine + url: example.com/b + - title: Bad link + url: see the paper + - title: Vague date + date: spring 2024 +"#, + ); + assert_eq!(out.errors.len(), 2, "{:?}", out.errors); + assert!(out.errors[0].contains("empty title"), "{:?}", out.errors); + assert!(out.errors[1].contains("not a web link"), "{:?}", out.errors); + assert_eq!(out.warnings.len(), 1, "{:?}", out.warnings); + assert!(out.warnings[0].contains("spring 2024")); } #[test] - fn test_doctor_outcome_push_warning_on_error() { - let mut o = DoctorOutcome::Findings { - errors: vec!["err1".into()], - warnings: vec![], - infos: vec![], - }; - o.push_warning("warn1".into()); - assert!(o.has_errors()); - assert!(o.has_warnings()); + fn test_summary_word_limit() { + let long = "word ".repeat(MAX_SUMMARY_WORDS + 1); + let out = metadata_findings(&format!( + "podcasts:\n - title: A\n file: content/a.md\n category: tech\n summary: {long}\n" + )); + assert_eq!(out.errors.len(), 1, "{:?}", out.errors); + assert!(out.errors[0].contains("summary")); } #[test] - fn test_doctor_outcome_merge() { - let mut o1 = DoctorOutcome::Clean; - let o2 = DoctorOutcome::Findings { - errors: vec!["e1".into(), "e2".into()], - warnings: vec!["w1".into()], - infos: vec!["i1".into()], - }; - o1.merge(o2); - assert!(o1.has_errors()); - assert!(o1.has_warnings()); - if let DoctorOutcome::Findings { - errors, - warnings, - infos, - } = &o1 - { - assert_eq!(errors.len(), 2); - assert_eq!(warnings.len(), 1); - assert_eq!(infos.len(), 1); - } else { - panic!("expected Findings"); - } + fn test_missing_assets_and_bad_source_url() { + let out = metadata_findings( + r#" +podcasts: + - title: A + file: content/a.md + category: tech + source_url: ftp://example.com + audio: assets/audio/missing.mp3 +"#, + ); + assert_eq!(out.errors.len(), 2, "{:?}", out.errors); + assert!(out.errors.iter().any(|e| e.contains("missing.mp3"))); + assert!(out.errors.iter().any(|e| e.contains("source_url"))); + } + + #[test] + fn test_missing_category_blocks_deploy() { + let out = metadata_findings("podcasts:\n - title: A\n file: content/a.md\n"); + assert_eq!(out.errors.len(), 1, "{:?}", out.errors); + assert!(out.errors[0].contains("no category")); + } + + #[test] + fn test_is_web_url() { + assert!(is_web_url("https://example.com/a?b#c")); + assert!(is_web_url("http://localhost:8080")); + assert!(!is_web_url("https://")); + assert!(!is_web_url("https://not a link")); + assert!(!is_web_url("ftp://example.com")); + } + + #[test] + fn test_timeline_structure_and_duplicates() { + let out = metadata_findings( + r#" +podcasts: + - title: A + file: content/a.md + category: tech + timeline: + - content/a.md + - content/unknown.md + - content/one.bib + - content/two.bib + - 0 + - title: A + file: content/a.md + category: tech +"#, + ); + let expected = [ + "lists itself", + "neither a .bib file nor another episode", + "citation file 'content/one.bib' does not exist", + "citation file 'content/two.bib' does not exist", + "news id 0 must be positive", + "lists 2 .bib files", + "Duplicate podcast title", + "Duplicate file", + ]; + for text in expected { + assert!( + out.errors.iter().any(|e| e.contains(text)), + "missing '{text}' in {:?}", + out.errors + ); + } + assert_eq!(out.errors.len(), expected.len(), "{:?}", out.errors); } #[test] - fn test_doctor_outcome_merge_clean() { - let mut o1 = DoctorOutcome::Findings { - errors: vec!["e1".into()], - warnings: vec![], - infos: vec![], - }; - o1.merge(DoctorOutcome::Clean); - assert!(o1.has_errors()); - assert_eq!( - match &o1 { - DoctorOutcome::Findings { errors, .. } => errors.len(), - _ => 0, - }, - 1 + fn test_content_lints() { + let para = "This paragraph is long enough to be compared across episodes."; + let (dir, _) = project("podcasts: []"); + std::fs::write( + dir.path().join("content/a.md"), + format!("## Only H2\n\n{para}"), + ) + .unwrap(); + std::fs::write(dir.path().join("content/b.md"), format!("# B\n\n{para}")).unwrap(); + std::fs::write( + dir.path().join("metadata.yml"), + "podcasts:\n - title: A\n file: content/a.md\n - title: B\n file: content/b.md\n", + ) + .unwrap(); + let ctx = ProjectContext::load(dir.path()).unwrap(); + let mut out = DoctorOutcome::default(); + lint(&ctx, &mut out); + + let warned = |text: &str| out.warnings.iter().any(|w| w.contains(text)); + assert!(warned("'A' has no H1 heading"), "{:?}", out.warnings); + assert!(!warned("'B' has no H1"), "{:?}", out.warnings); + assert!(warned("low word count"), "{:?}", out.warnings); + assert!( + warned("Duplicate paragraph found in 'A' and 'B'"), + "{:?}", + out.warnings ); + assert!(out.errors.is_empty(), "lints never block: {:?}", out.errors); + assert!(out.infos.iter().any(|i| i.contains("no audio file"))); + } + + #[test] + fn test_majority() { + assert_eq!(majority(["mp3", "mp3", "wav"].iter()), Some(&"mp3")); + assert_eq!(majority(["mp3"].iter()), None); } } diff --git a/src/core/report.rs b/src/core/error.rs similarity index 92% rename from src/core/report.rs rename to src/core/error.rs index 37fd7c5..9e6a64d 100644 --- a/src/core/report.rs +++ b/src/core/error.rs @@ -1,3 +1,4 @@ +//! Single error type shared by every command, with plain messages for CLI and TUI output. use thiserror::Error; #[derive(Error, Debug)] pub enum CiteError { diff --git a/src/core/upgrade.rs b/src/core/install.rs similarity index 75% rename from src/core/upgrade.rs rename to src/core/install.rs index 3d4fe1f..055089b 100644 --- a/src/core/upgrade.rs +++ b/src/core/install.rs @@ -1,3 +1,6 @@ +//! Installed binary management: self-update from releases and uninstall (lists all versions since the API skips prereleases). + +use std::io::Write; use std::path::{Path, PathBuf}; use serde::Deserialize; @@ -8,11 +11,6 @@ use crate::core::CiteError; const REPO: &str = "laezyAI/cite"; const BIN_NAME: &str = "cite"; -/// A parsed `major.minor.patch[-pre]` version with semver precedence. -/// -/// Field order drives the derived ordering: a release (`is_release = true`) sorts after -/// any prerelease of the same core version, and prerelease identifiers compare -/// numerically when numeric and lexically otherwise (numeric < alphanumeric). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct Version { core: (u64, u64, u64), @@ -79,7 +77,6 @@ struct Release { draft: bool, } -/// Self-update by running the cargo-dist installer published with the newest release. pub async fn upgrade() -> Result { let current = Version::parse(env!("CARGO_PKG_VERSION")) .ok_or_else(|| CiteError::Config("Invalid package version".into()))?; @@ -89,7 +86,6 @@ pub async fn upgrade() -> Result { .user_agent(concat!("cite/", env!("CARGO_PKG_VERSION"))) .build()?; - // `/releases/latest` skips prereleases, so pick the highest version from the list instead. let releases: Vec = client .get(format!( "https://api.github.com/repos/{REPO}/releases?per_page=30" @@ -158,7 +154,6 @@ fn installer_command() -> (&'static str, &'static str, &'static [&'static str]) } fn run_installer(script: &Path, interpreter: &str, args: &[&str]) -> Result<(), CiteError> { - // Windows cannot overwrite a running executable, but it can rename it out of the way. let parked = park_running_exe()?; let status = std::process::Command::new(interpreter) @@ -196,6 +191,77 @@ fn park_running_exe() -> Result, CiteError> { Ok(None) } +pub fn uninstall() -> Result<(), CiteError> { + let current_exe = std::env::current_exe() + .map_err(|e| CiteError::Config(format!("Cannot determine executable path: {e}")))?; + + let install_dir = current_exe + .parent() + .ok_or_else(|| CiteError::Config("Cannot determine install directory".into()))?; + + info!("cite installed at: {}", current_exe.display()); + + warn!("This will delete the binary. Shell config files might NOT be modified"); + print!("Are you sure? [y/N] "); + let _ = std::io::stdout().flush(); + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + match input.trim().to_lowercase().as_str() { + "y" | "yes" => {} + _ => { + warn!("Uninstall cancelled"); + return Ok(()); + } + } + + std::fs::remove_file(¤t_exe)?; + info!("Removed {}", current_exe.display()); + + if install_dir + .read_dir() + .map(|mut d| d.next().is_none()) + .unwrap_or(false) + { + let _ = std::fs::remove_dir(install_dir); + info!("Removed empty directory {}", install_dir.display()); + } + + let cite_dir = crate::core::cite_home(); + if cite_dir.exists() { + let _ = std::fs::remove_file(cite_dir.join("cite.db")); + let _ = std::fs::remove_file(cite_dir.join("session.json")); + let _ = std::fs::remove_file(cite_dir.join("credentials.toml")); + info!("Removed ~/.cite/cite.db, session.json, and credentials.toml"); + if std::fs::read_dir(&cite_dir) + .map(|mut d| d.next().is_none()) + .unwrap_or(true) + { + let _ = std::fs::remove_dir(&cite_dir); + info!("Removed empty ~/.cite directory"); + } + } + + let install_dir_str = install_dir.to_string_lossy(); + let found = crate::core::home_dir().is_some_and(|home| { + [".zshrc", ".bashrc", ".bash_profile", ".profile"] + .iter() + .any(|rc| { + std::fs::read_to_string(home.join(rc)) + .is_ok_and(|c| c.contains(install_dir_str.as_ref())) + }) + }); + + if found { + info!("Shell config files reference the install directory"); + info!(" Edit ~/.zshrc, ~/.bashrc, etc. and remove lines containing:"); + info!(" {install_dir_str}"); + info!(" Then restart your shell or run: source ~/.zshrc"); + } + info!("cite has been uninstalled"); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/core/lockfile.rs b/src/core/lockfile.rs new file mode 100644 index 0000000..bcdbf21 --- /dev/null +++ b/src/core/lockfile.rs @@ -0,0 +1,111 @@ +//! `cite.lock`: the news row each episode was deployed as, per Supabase project (committed like `Cargo.lock`). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::core::CiteError; + +pub const LOCKFILE_NAME: &str = "cite.lock"; + +const HEADER: &str = "\ +# Generated by `cite deploy`: the Supabase news id each episode was deployed as. +# Commit this file so every machine updates the same news rows. +# Removing an entry makes the next deploy publish that episode as a new item. + +"; + +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Lockfile { + backends: BTreeMap>, +} + +impl Lockfile { + fn path(root: &Path) -> PathBuf { + root.join(LOCKFILE_NAME) + } + + pub fn load(root: &Path) -> Result { + let path = Self::path(root); + match std::fs::read_to_string(&path) { + Ok(text) => toml::from_str(&text) + .map_err(|e| CiteError::Config(format!("{} is not valid: {e}", path.display()))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e.into()), + } + } + + pub fn save(&self, root: &Path) -> Result<(), CiteError> { + let body = toml::to_string(self)?; + std::fs::write(Self::path(root), format!("{HEADER}{body}"))?; + Ok(()) + } + + pub fn news_id(&self, backend: &str, file: &str) -> Option { + self.backends.get(backend)?.get(file).copied() + } + + pub fn set_news_id(&mut self, backend: &str, file: &str, news_id: i64) { + self.backends + .entry(backend.to_string()) + .or_default() + .insert(file.to_string(), news_id); + } + + pub fn remove_news_ids(&mut self, backend: &str, news_ids: &[i64]) { + if let Some(files) = self.backends.get_mut(backend) { + files.retain(|_, id| !news_ids.contains(id)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BACKEND: &str = "https://abc.supabase.co"; + + #[test] + fn test_round_trip_is_human_readable() { + let dir = tempfile::tempdir().unwrap(); + let mut lock = Lockfile::default(); + lock.set_news_id(BACKEND, "content/episode-one.md", 12); + lock.set_news_id(BACKEND, "content/episode-two.md", 13); + lock.save(dir.path()).unwrap(); + + let text = std::fs::read_to_string(dir.path().join(LOCKFILE_NAME)).unwrap(); + assert!(text.starts_with("# Generated by `cite deploy`")); + assert!(text.contains("[\"https://abc.supabase.co\"]"), "{text}"); + assert!(text.contains("\"content/episode-one.md\" = 12"), "{text}"); + + let loaded = Lockfile::load(dir.path()).unwrap(); + assert_eq!(loaded, lock); + assert_eq!(loaded.news_id(BACKEND, "content/episode-two.md"), Some(13)); + assert_eq!( + loaded.news_id("https://other.co", "content/episode-two.md"), + None + ); + } + + #[test] + fn test_missing_file_is_empty_and_invalid_file_errors() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(Lockfile::load(dir.path()).unwrap(), Lockfile::default()); + + std::fs::write(dir.path().join(LOCKFILE_NAME), "not = [valid").unwrap(); + let err = Lockfile::load(dir.path()).unwrap_err(); + assert!(err.to_string().contains("cite.lock"), "{err}"); + } + + #[test] + fn test_remove_news_ids() { + let mut lock = Lockfile::default(); + lock.set_news_id(BACKEND, "a.md", 1); + lock.set_news_id(BACKEND, "b.md", 2); + lock.remove_news_ids(BACKEND, &[1]); + assert_eq!(lock.news_id(BACKEND, "a.md"), None); + assert_eq!(lock.news_id(BACKEND, "b.md"), Some(2)); + } +} diff --git a/src/core/manifest.rs b/src/core/manifest.rs index 065b512..6da9023 100644 --- a/src/core/manifest.rs +++ b/src/core/manifest.rs @@ -1,3 +1,4 @@ +//! `cite.toml` shape: project identity, build settings, and the Supabase backend (accepts legacy `staging_*` keys). use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,8 +40,10 @@ impl Default for BuildConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct BackendConfig { - pub staging_url: Option, - pub staging_service_key: Option, + #[serde(alias = "staging_url")] + pub url: Option, + #[serde(alias = "staging_service_key")] + pub api_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -95,8 +98,8 @@ compiler_version = 1.0 incremental = false [backend] -staging_url = "https://example.com" -staging_service_key = "" +url = "https://example.com" +api_key = "" [compiler] enabled_extensions = ["tables"] @@ -116,11 +119,22 @@ strict = true assert_eq!(m.build.compiler_version, 1.0); assert!(!m.build.incremental); assert_eq!( - m.backend.as_ref().unwrap().staging_url.as_deref(), + m.backend.as_ref().unwrap().url.as_deref(), Some("https://example.com") ); } + #[test] + fn test_deserialize_legacy_backend_keys() { + let m: Manifest = toml::from_str( + "[backend]\nstaging_url = \"https://x.co\"\nstaging_service_key = \"k\"\n", + ) + .unwrap(); + let backend = m.backend.unwrap(); + assert_eq!(backend.url.as_deref(), Some("https://x.co")); + assert_eq!(backend.api_key.as_deref(), Some("k")); + } + #[test] fn test_deserialize_partial_applies_defaults() { let toml_str = r#" diff --git a/src/core/markdown.rs b/src/core/markdown.rs new file mode 100644 index 0000000..e3ca379 --- /dev/null +++ b/src/core/markdown.rs @@ -0,0 +1,99 @@ +//! Markdown helpers shared by the compiler, deploy summaries, and doctor (word counts, frontmatter, plain text). + +pub fn word_count(content: &str) -> i64 { + content.split_whitespace().count() as i64 +} + +pub fn split_frontmatter(markdown: &str) -> (Option<&str>, &str) { + let Some(rest) = markdown + .strip_prefix("---") + .and_then(|r| r.strip_prefix('\n').or_else(|| r.strip_prefix("\r\n"))) + else { + return (None, markdown); + }; + let mut offset = 0; + for line in rest.split_inclusive('\n') { + if line.trim_end() == "---" { + return (Some(&rest[..offset]), &rest[offset + line.len()..]); + } + offset += line.len(); + } + (None, markdown) +} + +pub fn plain_text(markdown: &str) -> String { + let (_, body) = split_frontmatter(markdown); + let mut in_code = false; + let mut words = Vec::new(); + for line in body.lines() { + let line = line.trim(); + if line.starts_with("```") || line.starts_with("~~~") { + in_code = !in_code; + continue; + } + if in_code || line.starts_with('#') { + continue; + } + let line = line.trim_start_matches(['>', '-', '*', '+', ' ']); + words.extend(unwrap_inline(line).split_whitespace().map(str::to_string)); + } + words.join(" ") +} + +fn unwrap_inline(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut rest = line; + while let Some(open) = rest.find('[') { + let Some(mid) = rest[open..].find("](").map(|i| open + i) else { + break; + }; + let Some(close) = rest[mid..].find(')').map(|i| mid + i) else { + break; + }; + let is_image = rest[..open].ends_with('!'); + out.push_str(&rest[..open - usize::from(is_image)]); + if !is_image { + out.push_str(&rest[open + 1..mid]); + } + rest = &rest[close + 1..]; + } + out.push_str(rest); + out.retain(|c| c != '*' && c != '`'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_word_count() { + assert_eq!(word_count("one two three\n"), 3); + assert_eq!(word_count(""), 0); + } + + #[test] + fn test_split_frontmatter() { + assert_eq!( + split_frontmatter("---\ntitle: Ep\n---\n# Body\n"), + (Some("title: Ep\n"), "# Body\n") + ); + assert_eq!(split_frontmatter("---\n---\nBody"), (Some(""), "Body")); + assert_eq!( + split_frontmatter("---\nnever closed"), + (None, "---\nnever closed") + ); + assert_eq!( + split_frontmatter("# No frontmatter"), + (None, "# No frontmatter") + ); + } + + #[test] + fn test_plain_text_strips_markdown() { + let md = "---\ntitle: Ep\n---\n# Episode One\n\nWelcome to **the** `show`.\n\n\ + ```rust\nlet x = 1;\n```\n- See [the docs](https://x.y) ![cover](c.png)\n> quoted"; + assert_eq!(plain_text(md), "Welcome to the show. See the docs quoted"); + assert_eq!(plain_text("--- not frontmatter"), "not frontmatter"); + } +} diff --git a/src/core/media.rs b/src/core/media.rs index bebc366..81b4a1a 100644 --- a/src/core/media.rs +++ b/src/core/media.rs @@ -1,3 +1,4 @@ +//! Audio and image inspection plus upload MIME types matching the Supabase buckets. use std::path::Path; use serde::{Deserialize, Serialize}; @@ -9,6 +10,25 @@ use symphonia::core::meta::MetadataOptions; use crate::core::CiteError; +pub const AUDIO_FORMATS: &[&str] = &["mp3", "wav", "m4a", "aac"]; +pub const MAX_AUDIO_BYTES: u64 = 100 * 1024 * 1024; +pub const IMAGE_FORMATS: &[&str] = &["jpg", "jpeg", "png", "webp", "gif"]; +pub const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024; + +pub fn mime_type(ext: &str) -> &'static str { + match ext.to_lowercase().as_str() { + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "m4a" => "audio/m4a", + "aac" => "audio/aac", + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + _ => "application/octet-stream", + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudioMeta { pub duration_secs: f64, @@ -30,22 +50,18 @@ pub struct ImageMeta { pub sha256: String, } -/// Full audio metadata, including a content hash (used in build bundles). pub fn extract_audio(path: &Path) -> Result { read_audio_meta(path, true) } -/// Audio metadata without hashing the file (cheap; `sha256` is left empty). pub fn inspect_audio(path: &Path) -> Result { read_audio_meta(path, false) } -/// Full image metadata, including a content hash (used in build bundles). pub fn extract_image(path: &Path) -> Result { read_image_meta(path, true) } -/// Image metadata from the header only (cheap; `sha256` is left empty). pub fn inspect_image(path: &Path) -> Result { read_image_meta(path, false) } @@ -96,7 +112,6 @@ fn read_audio_meta(path: &Path, with_hash: bool) -> Result }) } -/// Read container headers only; returns `None` for unreadable or non-audio files. fn probe_audio(path: &Path, ext: &str) -> Option { let file = std::fs::File::open(path).ok()?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); @@ -193,26 +208,76 @@ mod tests { ); } + fn wav(secs: u32, rate: u32) -> Vec { + let data_len = secs * rate * 2; + let mut out = Vec::new(); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data_len).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&rate.to_le_bytes()); + out.extend_from_slice(&(rate * 2).to_le_bytes()); + out.extend_from_slice(&2u16.to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&data_len.to_le_bytes()); + out.resize(out.len() + data_len as usize, 0); + out + } + + #[test] + fn test_audio_duration_and_format_are_probed() { + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("ep.WAV"); + std::fs::write(&f, wav(2, 8000)).unwrap(); + + let meta = extract_audio(&f).unwrap(); + assert!( + (meta.duration_secs - 2.0).abs() < 0.01, + "{}", + meta.duration_secs + ); + assert_eq!(meta.format, "wav"); + assert_eq!(meta.sample_rate_hz, 8000); + assert_eq!(meta.channels, 1); + assert_eq!(meta.bitrate_kbps, 128); + assert!(!meta.sha256.is_empty()); + assert!( + inspect_audio(&f).unwrap().sha256.is_empty(), + "inspect skips hashing" + ); + } + + #[test] + fn test_unreadable_audio_has_no_duration() { + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("fake.mp3"); + std::fs::write(&f, b"not audio").unwrap(); + let meta = inspect_audio(&f).unwrap(); + assert_eq!(meta.duration_secs, 0.0); + assert_eq!(meta.codec, "unknown"); + } + + #[test] + fn test_mime_type_matches_podcasts_bucket() { + assert_eq!(mime_type("MP3"), "audio/mpeg"); + assert_eq!(mime_type("m4a"), "audio/m4a"); + assert_eq!(mime_type("jpeg"), "image/jpeg"); + assert_eq!(mime_type("xyz"), "application/octet-stream"); + } + #[test] fn test_extract_image_png() { let dir = tempfile::tempdir().unwrap(); let f = dir.path().join("test.png"); let min_png = vec![ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // chunk length - 0x49, 0x48, 0x44, 0x52, // IHDR chunk - 0x00, 0x00, 0x00, 0x01, // width = 1 - 0x00, 0x00, 0x00, 0x01, // height = 1 - 0x08, 0x02, 0x00, 0x00, - 0x00, // bit depth, color type, compression, filter, interlace - 0x90, 0x77, 0x53, 0xDE, // CRC - 0x00, 0x00, 0x00, 0x0A, // chunk length - 0x49, 0x44, 0x41, 0x54, // IDAT chunk - 0x78, 0x9C, 0x62, 0x62, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, // compressed data - 0x4A, 0x2E, 0x2C, 0xE8, // CRC - 0x00, 0x00, 0x00, 0x00, // chunk length - 0x49, 0x45, 0x4E, 0x44, // IEND chunk - 0xAE, 0x42, 0x60, 0x82, // CRC + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, + 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9C, 0x62, 0x62, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x4A, 0x2E, 0x2C, 0xE8, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, ]; std::fs::write(&f, &min_png).unwrap(); let meta = extract_image(&f).unwrap(); @@ -227,7 +292,6 @@ mod tests { fn test_extract_image_jpeg() { let dir = tempfile::tempdir().unwrap(); let f = dir.path().join("test.jpg"); - // Minimal valid JPEG (SOI + EOI markers) let min_jpg = vec![ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, diff --git a/src/core/metadata.rs b/src/core/metadata.rs index 57ec397..11c1b61 100644 --- a/src/core/metadata.rs +++ b/src/core/metadata.rs @@ -1,18 +1,101 @@ -use serde::{Deserialize, Serialize}; +//! Hand-written `metadata.yml` episodes with schema limits enforced (chrono needs full years; empty files mean no episodes). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] +use std::fmt; + +use chrono::{Datelike, NaiveDate}; +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; + +pub const MAX_TITLE_CHARS: usize = 500; +pub const MAX_SUMMARY_WORDS: usize = 50; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(into = "RawTimelineItem")] pub enum TimelineItem { Citation(String), + Episode(String), News(i64), + Event(TimelineEntry), +} + +impl TimelineItem { + fn from_path(path: &str) -> Self { + let mut cleaned = path.trim().to_string(); + tidy_path(&mut cleaned); + if let Ok(id) = cleaned.parse() { + Self::News(id) + } else if cleaned.to_lowercase().ends_with(".bib") { + Self::Citation(cleaned) + } else { + Self::Episode(cleaned) + } + } +} + +#[derive(Serialize)] +#[serde(untagged)] +enum RawTimelineItem { + Path(String), + News(i64), + Event(TimelineEntry), +} + +impl From for RawTimelineItem { + fn from(item: TimelineItem) -> Self { + match item { + TimelineItem::Citation(path) | TimelineItem::Episode(path) => Self::Path(path), + TimelineItem::News(id) => Self::News(id), + TimelineItem::Event(entry) => Self::Event(entry), + } + } +} + +impl<'de> Deserialize<'de> for TimelineItem { + fn deserialize>(de: D) -> Result { + struct ItemVisitor; + + impl<'de> Visitor<'de> for ItemVisitor { + type Value = TimelineItem; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str( + "a .bib file, another episode's .md file, a news id, or an event with a title", + ) + } + + fn visit_str(self, path: &str) -> Result { + Ok(TimelineItem::from_path(path)) + } + + fn visit_i64(self, id: i64) -> Result { + Ok(TimelineItem::News(id)) + } + + fn visit_u64(self, id: u64) -> Result { + i64::try_from(id) + .map(TimelineItem::News) + .map_err(|_| E::custom(format!("news id {id} is too large"))) + } + + fn visit_map>(self, map: A) -> Result { + TimelineEntry::deserialize(de::value::MapAccessDeserializer::new(map)) + .map(TimelineItem::Event) + } + } + + de.deserialize_any(ItemVisitor) + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct Podcast { pub title: String, pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub source_url: Option, @@ -33,44 +116,185 @@ impl Podcast { pub fn citation(&self) -> Option<&str> { self.timeline.iter().find_map(|item| match item { TimelineItem::Citation(path) => Some(path.as_str()), - TimelineItem::News(_) => None, + _ => None, }) } + + pub fn inline_events(&self) -> impl Iterator { + self.timeline.iter().filter_map(|item| match item { + TimelineItem::Event(entry) => Some(entry), + _ => None, + }) + } + + fn tidy(&mut self) { + tidy_text(&mut self.title); + tidy_path(&mut self.file); + for field in [ + &mut self.summary, + &mut self.category, + &mut self.thumbnail, + &mut self.audio, + ] { + tidy_optional(field); + } + tidy_path_optional(&mut self.thumbnail); + tidy_path_optional(&mut self.audio); + tidy_url(&mut self.source_url); + for item in &mut self.timeline { + match item { + TimelineItem::Event(entry) => entry.tidy(), + TimelineItem::Citation(path) | TimelineItem::Episode(path) => { + tidy_text(path); + tidy_path(path); + } + TimelineItem::News(_) => {} + } + } + } } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] pub struct TimelineEntry { + #[serde(skip_serializing_if = "String::is_empty")] pub id: String, - pub date: Option, pub title: String, - pub summary: Option, + #[serde( + deserialize_with = "string_or_number", + skip_serializing_if = "Option::is_none" + )] + pub date: Option, + #[serde(alias = "summary", skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(alias = "link", skip_serializing_if = "Option::is_none")] pub url: Option, +} - #[serde(skip_serializing_if = "Option::is_none")] - pub link: Option, -} - -impl Default for TimelineEntry { - fn default() -> Self { - Self { - id: uuid::Uuid::new_v4().to_string(), - date: None, - title: String::new(), - summary: None, - url: None, - link: None, +impl TimelineEntry { + pub fn event_date(&self) -> Option { + let day = parse_date(self.date.as_deref()?)?; + Some(format!("{day}T00:00:00Z")) + } + + fn tidy(&mut self) { + tidy_text(&mut self.title); + tidy_optional(&mut self.date); + tidy_optional(&mut self.description); + tidy_url(&mut self.url); + } +} + +fn parse_date(text: &str) -> Option { + const FORMATS: [&str; 4] = ["%Y-%m-%d", "%d %B %Y", "%B %d, %Y", "%B %d %Y"]; + let text = text.trim().replace('/', "-"); + if text.len() == 4 && text.bytes().all(|b| b.is_ascii_digit()) { + return NaiveDate::from_ymd_opt(text.parse().ok()?, 1, 1); + } + [text.clone(), format!("{text}-01"), format!("1 {text}")] + .iter() + .find_map(|candidate| { + FORMATS + .iter() + .filter_map(|format| NaiveDate::parse_from_str(candidate, format).ok()) + .find(|day| text.contains(&format!("{:04}", day.year()))) + }) +} + +fn tidy_text(value: &mut String) { + let trimmed = value.trim(); + if trimmed.len() != value.len() { + *value = trimmed.to_string(); + } +} + +fn tidy_optional(value: &mut Option) { + if let Some(text) = value { + tidy_text(text); + } + if value.as_deref() == Some("") { + *value = None; + } +} + +fn tidy_path(value: &mut String) { + let mut text = value.trim().to_string(); + while let Some(rest) = text.strip_prefix("./").map(str::trim_start) { + text = rest.to_string(); + } + while text.contains("//") { + let mut cleaned = String::with_capacity(text.len()); + let mut last_slash = false; + for c in text.chars() { + if c == '/' { + if !last_slash { + cleaned.push(c); + } + last_slash = true; + } else { + cleaned.push(c); + last_slash = false; + } } + if cleaned.len() == text.len() { + break; + } + text = cleaned; } + *value = text; +} + +fn tidy_path_optional(value: &mut Option) { + if let Some(text) = value { + tidy_text(text); + tidy_path(text); + } + if value.as_deref() == Some("") { + *value = None; + } +} + +fn tidy_url(value: &mut Option) { + tidy_optional(value); + if let Some(url) = value + && !url.contains("://") + { + *url = format!("https://{url}"); + } +} + +fn string_or_number<'de, D: Deserializer<'de>>(de: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Value { + Text(String), + Number(i64), + } + Ok(Option::::deserialize(de)?.map(|v| match v { + Value::Text(s) => s, + Value::Number(n) => n.to_string(), + })) } #[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct Metadata { pub podcasts: Vec, } impl Metadata { + pub fn parse(yaml: &str) -> Result { + if yaml + .lines() + .all(|l| l.trim().is_empty() || l.trim_start().starts_with('#')) + { + return Ok(Self::default()); + } + let mut metadata: Self = serde_yaml::from_str(yaml)?; + metadata.podcasts.iter_mut().for_each(Podcast::tidy); + Ok(metadata) + } + pub fn referenced_files(&self) -> Vec { let mut files = Vec::new(); for p in &self.podcasts { @@ -99,27 +323,61 @@ mod tests { podcasts: - title: "Test Podcast" file: content/test.md + summary: A short hand-written summary. source_url: "https://example.com" category: "tech" timeline: - content/test.bib + - content/earlier-episode.md - 26 + - "27" + - title: Model released + date: 2025-05-22 + url: https://example.com/release + description: The release that started it. + - title: Founded + date: 2021 "#; let meta: Metadata = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(meta.podcasts.len(), 1); - assert_eq!(meta.podcasts[0].title, "Test Podcast"); + let podcast = &meta.podcasts[0]; + assert_eq!(podcast.title, "Test Podcast"); assert_eq!( - meta.podcasts[0].source_url.as_deref(), - Some("https://example.com") + podcast.summary.as_deref(), + Some("A short hand-written summary.") ); - assert_eq!( - meta.podcasts[0].timeline, - vec![ - TimelineItem::Citation("content/test.bib".to_string()), - TimelineItem::News(26), - ] + assert_eq!(podcast.source_url.as_deref(), Some("https://example.com")); + let timeline = vec![ + TimelineItem::Citation("content/test.bib".to_string()), + TimelineItem::Episode("content/earlier-episode.md".to_string()), + TimelineItem::News(26), + TimelineItem::News(27), + TimelineItem::Event(TimelineEntry { + title: "Model released".into(), + date: Some("2025-05-22".into()), + description: Some("The release that started it.".into()), + url: Some("https://example.com/release".into()), + ..Default::default() + }), + TimelineItem::Event(TimelineEntry { + title: "Founded".into(), + date: Some("2021".into()), + ..Default::default() + }), + ]; + assert_eq!(podcast.timeline, timeline); + assert_eq!(podcast.citation(), Some("content/test.bib")); + assert_eq!(podcast.inline_events().count(), 2); + + let yaml = serde_yaml::to_string(&meta).unwrap(); + assert!(yaml.contains("- content/earlier-episode.md"), "{yaml}"); + assert!(yaml.contains("- 26"), "{yaml}"); + assert!(yaml.contains("title: Founded"), "{yaml}"); + assert!( + !yaml.contains("id:"), + "compiler ids are not authored: {yaml}" ); - assert_eq!(meta.podcasts[0].citation(), Some("content/test.bib")); + let reparsed: Metadata = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(reparsed.podcasts[0].timeline, timeline); } #[test] @@ -130,17 +388,120 @@ podcasts: assert_eq!(meta.podcasts[0].citation(), None); } + #[test] + fn test_event_date() { + let at = |date: &str| { + TimelineEntry { + date: Some(date.into()), + ..Default::default() + } + .event_date() + }; + assert_eq!(at("2023").as_deref(), Some("2023-01-01T00:00:00Z")); + assert_eq!(at(" 2023-03 ").as_deref(), Some("2023-03-01T00:00:00Z")); + assert_eq!(at("2025-05-22").as_deref(), Some("2025-05-22T00:00:00Z")); + assert_eq!(at("2025/05/22").as_deref(), Some("2025-05-22T00:00:00Z")); + assert_eq!(at("May 2024").as_deref(), Some("2024-05-01T00:00:00Z")); + assert_eq!(at("22 May 2025").as_deref(), Some("2025-05-22T00:00:00Z")); + assert_eq!(at("Sep 3, 2025").as_deref(), Some("2025-09-03T00:00:00Z")); + assert_eq!( + at("September 3 2025").as_deref(), + Some("2025-09-03T00:00:00Z") + ); + assert_eq!(at("2025-02-30"), None); + assert_eq!(at("march"), None); + assert_eq!(at("2023-0"), None); + assert_eq!(at(""), None); + } + + #[test] + fn test_parse_tidies_hand_written_values() { + let meta = Metadata::parse( + r#" +podcasts: + - title: " Spaced title " + file: content/a.md + summary: "" + source_url: www.example.com/story + category: " Politics " + timeline: + - " content/refs.bib " + - title: Event + link: example.com/event + summary: Old key for description. +"#, + ) + .unwrap(); + let pod = &meta.podcasts[0]; + assert_eq!(pod.title, "Spaced title"); + assert_eq!(pod.summary, None); + assert_eq!(pod.category.as_deref(), Some("Politics")); + assert_eq!( + pod.source_url.as_deref(), + Some("https://www.example.com/story") + ); + assert_eq!(pod.citation(), Some("content/refs.bib")); + let event = pod.inline_events().next().unwrap(); + assert_eq!(event.url.as_deref(), Some("https://example.com/event")); + assert_eq!( + event.description.as_deref(), + Some("Old key for description.") + ); + } + + #[test] + fn test_parse_rejects_misspelled_keys() { + let err = Metadata::parse("podcasts:\n - title: T\n catgory: Politics\n") + .unwrap_err() + .to_string(); + assert!(err.contains("unknown field `catgory`"), "{err}"); + + let err = Metadata::parse("podcasts:\n - title: T\n timeline:\n - tilte: Event\n") + .unwrap_err() + .to_string(); + assert!(err.contains("unknown field `tilte`"), "{err}"); + assert!(err.contains("line 4"), "points at the event: {err}"); + } + + #[test] + fn test_parse_empty_file() { + assert!(Metadata::parse("").unwrap().podcasts.is_empty()); + assert!( + Metadata::parse("# only comments\n") + .unwrap() + .podcasts + .is_empty() + ); + } + + #[test] + fn test_parse_normalizes_dot_slash_paths() { + let meta = Metadata::parse( + "podcasts:\n - title: Ep\n file: ./content//ep.md\n thumbnail: ./assets/image//t.jpg\n timeline:\n - ' ./content/refs.bib '\n - ' ./content/other.md '\n", + ) + .unwrap(); + let pod = &meta.podcasts[0]; + assert_eq!(pod.file, "content/ep.md"); + assert_eq!(pod.thumbnail.as_deref(), Some("assets/image/t.jpg")); + assert_eq!( + pod.timeline, + vec![ + TimelineItem::Citation("content/refs.bib".to_string()), + TimelineItem::Episode("content/other.md".to_string()), + ] + ); + } + #[test] fn test_referenced_files_includes_all() { let meta = Metadata { podcasts: vec![Podcast { title: "P".into(), file: "content/p.md".into(), - source_url: None, - category: None, thumbnail: Some("assets/image/p.jpg".into()), audio: Some("assets/audio/p.mp3".into()), timeline: vec![TimelineItem::Citation("content/p.bib".into())], + ..Default::default() }], }; diff --git a/src/core/mod.rs b/src/core/mod.rs index 459ebf2..e0455db 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,37 +1,39 @@ +//! Core library: project loading, parsing, building, validating, deploying, and local state (home resolution and owner-only writes included). + +pub mod auth; +pub mod bibtex; pub mod cache; pub mod compiler; -pub mod credentials; pub mod db; pub mod deploy; pub mod doctor; +pub mod error; +pub mod install; +pub mod lockfile; pub mod manifest; +pub mod markdown; pub mod media; pub mod metadata; pub mod project; pub mod scaffold; -pub mod uninstall; -pub mod upgrade; +pub mod supabase; -pub mod report; -pub use report::CiteError; +pub use error::CiteError; use std::path::{Path, PathBuf}; -/// The user's home directory (`HOME`, falling back to `USERPROFILE` on Windows). pub fn home_dir() -> Option { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) } -/// Global state directory: `~/.cite`. pub fn cite_home() -> PathBuf { home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".cite") } -/// Write a file readable only by the current user (credentials, sessions). pub fn write_private(path: &Path, contents: &[u8]) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -46,7 +48,6 @@ pub fn write_private(path: &Path, contents: &[u8]) -> std::io::Result<()> { .truncate(true) .mode(0o600) .open(path)?; - // `mode` only applies on creation; tighten files written by older versions too. file.set_permissions(std::fs::Permissions::from_mode(0o600))?; file.write_all(contents) } diff --git a/src/core/project.rs b/src/core/project.rs index 12a8bf4..b2fc2ef 100644 --- a/src/core/project.rs +++ b/src/core/project.rs @@ -1,132 +1,10 @@ +//! Loaded project: `cite.toml` plus parsed metadata with build paths, discovery, and clean. use std::path::{Path, PathBuf}; use crate::core::CiteError; +use crate::core::db::DbManager; use crate::core::manifest::Manifest; use crate::core::metadata::Metadata; -use tracing::info; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BuildRecord { - pub project_id: String, - pub compiler_version: f64, - pub podcast_count: i64, - pub timeline_count: i64, - pub total_words: i64, - pub duration_ms: i64, - pub was_incremental: bool, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct DeployReport { - pub project_id: String, - pub deployment_id: String, - pub storage_path: String, - pub news_count: i64, - pub timeline_count: i64, - pub asset_count: i64, - pub success: bool, - pub dry_run: bool, -} - -/// A podcast record from the DB (with content) -#[derive(Debug, Clone)] -pub struct StoredPodcast { - pub title: String, - pub word_count: i64, - pub category: String, - pub file: String, - pub has_audio: bool, - pub has_thumbnail: bool, -} - -/// A timeline entry from the DB -#[derive(Debug, Clone)] -pub struct StoredTimeline { - pub date: Option, - pub title: String, - pub url: Option, - pub entry_type: Option, - pub link: Option, -} - -/// A deployment record from the DB -#[derive(Debug, Clone)] -pub struct StoredDeployment { - pub deployment_id: String, - pub deployed_at: String, - pub success: bool, - pub news_count: i64, - pub asset_count: i64, -} - -/// A build record from the DB -#[derive(Debug, Clone)] -pub struct StoredBuild { - pub podcast_count: i64, - pub timeline_count: i64, - pub total_words: i64, - pub duration_ms: i64, - pub was_incremental: bool, - pub built_at: String, -} - -/// Per-project analytics from the DB -#[derive(Debug, Clone)] -pub struct ProjectStats { - pub podcast_count: i64, - pub timeline_count: i64, - pub total_words: i64, - pub build_count: i64, - pub last_built: Option, - pub deployment_count: i64, - pub last_deployed: Option, -} - -/// Cross-project analytics from the DB -#[derive(Debug, Clone)] -pub struct AllStats { - pub project_count: i64, - pub total_podcasts: i64, - pub total_timelines: i64, - pub total_words: i64, - pub total_builds: i64, -} - -/// A podcast row with everything needed to restore project files -#[derive(Debug, Clone)] -pub struct RestoredPodcast { - pub id: String, - pub title: String, - pub file: String, - pub source_url: Option, - pub category: Option, - pub thumbnail: Option, - pub audio: Option, - pub citation_file: Option, - pub content: Option, -} - -/// A timeline row with its owning timeline group -#[derive(Debug, Clone)] -pub struct RestoredTimeline { - pub podcast_id: String, - pub date: Option, - pub title: String, - pub summary: Option, - pub url: Option, - pub link: Option, -} - -/// Project-level data for restoring an archived project -#[derive(Debug, Clone)] -pub struct RestoredProject { - pub name: String, - pub language: String, - pub artist_id: String, - pub metadata_file: String, - pub podcasts: Vec, - pub timelines: Vec, -} #[derive(Debug, Clone)] pub struct ProjectContext { @@ -149,12 +27,14 @@ impl ProjectContext { ))); } let toml_str = std::fs::read_to_string(&manifest_path)?; - let manifest: Manifest = toml::from_str(&toml_str)?; + let manifest: Manifest = + toml::from_str(&toml_str).map_err(|e| CiteError::Parse(format!("cite.toml: {e}")))?; - let meta_path = root.join(&manifest.project.metadata_file); + let meta_file = &manifest.project.metadata_file; + let meta_path = root.join(meta_file); let metadata = if meta_path.exists() { let yaml_str = std::fs::read_to_string(&meta_path)?; - serde_yaml::from_str(&yaml_str)? + Metadata::parse(&yaml_str).map_err(|e| CiteError::Parse(format!("{meta_file}: {e}")))? } else { Metadata::default() }; @@ -166,23 +46,25 @@ impl ProjectContext { }) } - pub fn content_dir(&self) -> PathBuf { - self.root.join("content") - } - pub fn build_dir(&self) -> PathBuf { self.root.join("build") } - pub fn content_files(&self) -> Vec { - self.metadata - .referenced_files() - .iter() + pub fn bundle_path(&self) -> PathBuf { + self.build_dir().join("content.json") + } + + pub fn source_files(&self) -> Vec { + let config = ["cite.toml", self.manifest.project.metadata_file.as_str()]; + let referenced = self.metadata.referenced_files(); + config + .into_iter() + .chain(referenced.iter().map(String::as_str)) .map(|f| self.root.join(f)) .collect() } - pub async fn clean(&self, db: &crate::core::db::DbManager) -> Result<(), CiteError> { + pub async fn clean(&self, db: &DbManager) -> Result<(), CiteError> { let build_dir = self.build_dir(); if build_dir.exists() { tokio::fs::remove_dir_all(&build_dir).await?; @@ -193,57 +75,6 @@ impl ProjectContext { } } -pub async fn print_status(db: &crate::core::db::DbManager, ctx: &ProjectContext) { - info!("Name: {}", ctx.manifest.project.name); - info!("Root: {}", ctx.root.display()); - info!("Artist ID: {}", ctx.manifest.project.artist_id); - if let Some(b) = &ctx.manifest.backend - && let Some(u) = &b.staging_url - { - info!("Staging: {u}"); - } - info!("Podcasts: {}", ctx.metadata.podcasts.len()); - - let project_id = ctx.project_id(); - - if let Ok(stats) = db.get_project_stats(&project_id).await { - info!("Total words: {}", stats.total_words); - info!("Timeline entries: {}", stats.timeline_count); - info!("Builds recorded: {}", stats.build_count); - if let Some(ref last) = stats.last_built { - info!("Last build: {last}"); - } - info!("Deployments: {}", stats.deployment_count); - if let Some(ref last) = stats.last_deployed { - info!("Last deploy: {last}"); - } - } - - if let Ok(builds) = db.get_build_history(&project_id).await - && let Some(b) = builds.first() - { - info!( - "Recent build: {} podcasts, {} timelines, {} words, {}ms ({})", - b.podcast_count, - b.timeline_count, - b.total_words, - b.duration_ms, - if b.was_incremental { "incr" } else { "full" }, - ); - } - - if let Ok(deploys) = db.get_deployment_history(&project_id).await - && let Some(d) = deploys.first() - { - info!( - "Recent deploy: {} at {} ({})", - d.deployment_id, - d.deployed_at, - if d.success { "ok" } else { "fail" }, - ); - } -} - pub fn discover_projects(root: &Path) -> Vec { let mut projects = Vec::new(); @@ -341,14 +172,6 @@ incremental = true assert_eq!(ctx.manifest.build.compiler_version, 1.0); } - #[test] - fn test_project_context_content_dir() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("cite.toml"), "[project]\nname = \"x\"\n").unwrap(); - let ctx = ProjectContext::load(dir.path()).unwrap(); - assert_eq!(ctx.content_dir(), dir.path().join("content")); - } - #[test] fn test_project_context_build_dir() { let dir = tempfile::tempdir().unwrap(); @@ -375,11 +198,7 @@ incremental = true assert!(ctx.build_dir().exists()); let db_path = dir.path().join("test.db"); let rt = tokio::runtime::Runtime::new().unwrap(); - let db = rt.block_on(async { - crate::core::db::DbManager::open_path(&db_path) - .await - .unwrap() - }); + let db = rt.block_on(async { DbManager::open_path(&db_path).await.unwrap() }); rt.block_on(ctx.clean(&db)).unwrap(); assert!(!ctx.build_dir().exists()); } diff --git a/src/core/scaffold.rs b/src/core/scaffold.rs index 7d1f155..59fcb97 100644 --- a/src/core/scaffold.rs +++ b/src/core/scaffold.rs @@ -1,10 +1,15 @@ +//! Project scaffolding: new projects from `cite init` and restores from the local snapshot (assets are re-added by hand). + +use std::collections::HashMap; use std::fs; use std::path::Path; use tracing::info; use crate::core::CiteError; -use crate::core::manifest::Manifest; -use crate::core::metadata::Metadata; +use crate::core::bibtex; +use crate::core::db::DbManager; +use crate::core::manifest::{Manifest, ProjectConfig}; +use crate::core::metadata::{Metadata, Podcast, TimelineEntry, TimelineItem}; const AUTO_GEN_HEADER: &str = r#"# This file was autogenerated by cite init # https://github.com/laezyAI/cite @@ -46,7 +51,12 @@ pub fn init_project(name: &str, root: &Path) -> Result<(), CiteError> { let gitignore_path = root.join(".gitignore"); if !gitignore_path.exists() { - fs::write(&gitignore_path, format!("{AUTO_GEN_HEADER}\nbuild/\n"))?; + fs::write( + &gitignore_path, + format!( + "{AUTO_GEN_HEADER}\n# Commit cite.lock; these are local build and deploy state.\nbuild/\n.cite/\n" + ), + )?; info!("Created file: .gitignore"); } else { info!("Skipped: .gitignore (already exists)"); @@ -55,6 +65,92 @@ pub fn init_project(name: &str, root: &Path) -> Result<(), CiteError> { Ok(()) } +pub struct RestoreSummary { + pub podcasts: usize, + pub timeline_events: usize, + pub warnings: Vec, +} + +pub async fn restore_project( + db: &DbManager, + project_id: &str, + target: &Path, +) -> Result { + let snapshot = db.get_restore_snapshot(project_id).await?; + fs::create_dir_all(target.join("content"))?; + + let manifest = Manifest { + project: ProjectConfig { + name: snapshot.name.clone(), + language: snapshot.language.clone(), + metadata_file: snapshot.metadata_file.clone(), + artist_id: snapshot.artist_id.clone(), + }, + ..Default::default() + }; + fs::write(target.join("cite.toml"), toml::to_string_pretty(&manifest)?)?; + + let mut citations_by_podcast: HashMap<&str, Vec<&TimelineEntry>> = HashMap::new(); + for timeline in snapshot.timelines.iter().filter(|t| t.from_citation) { + citations_by_podcast + .entry(timeline.podcast_id.as_str()) + .or_default() + .push(&timeline.entry); + } + + let mut warnings = Vec::new(); + let mut podcasts = Vec::new(); + for pod in snapshot.podcasts.iter().filter(|p| !p.file.is_empty()) { + match &pod.content { + Some(content) => write_file(&target.join(&pod.file), content)?, + None => warnings.push(format!("Content missing for '{}'; re-add it", pod.title)), + } + for asset in [&pod.audio, &pod.thumbnail].into_iter().flatten() { + warnings.push(format!("Re-add asset {asset}")); + } + let citation = pod.citation_file.as_deref().filter(|c| !c.is_empty()); + if let Some(citation) = citation { + let entries = citations_by_podcast.get(pod.id.as_str()).cloned(); + let bib = bibtex::render(entries.unwrap_or_default()); + write_file(&target.join(citation), &bib)?; + } + podcasts.push(pod.metadata.clone().unwrap_or_else(|| { + Podcast { + title: pod.title.clone(), + file: pod.file.clone(), + source_url: pod.source_url.clone(), + category: pod.category.clone(), + thumbnail: pod.thumbnail.clone(), + audio: pod.audio.clone(), + timeline: citation + .map(|file| vec![TimelineItem::Citation(file.to_string())]) + .unwrap_or_default(), + ..Default::default() + } + })); + } + + let metadata = Metadata { podcasts }; + fs::write( + target.join(&snapshot.metadata_file), + serde_yaml::to_string(&metadata)?, + )?; + + Ok(RestoreSummary { + podcasts: snapshot.podcasts.len(), + timeline_events: snapshot.timelines.len(), + warnings, + }) +} + +fn write_file(path: &Path, contents: &str) -> Result<(), CiteError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents)?; + Ok(()) +} + fn manifest_template(name: &str) -> Result { let manifest = Manifest::default_template(name); let body = toml::to_string(&manifest)?; @@ -66,13 +162,40 @@ fn metadata_template() -> Result { let body = serde_yaml::to_string(&meta)?; Ok(format!( "{AUTO_GEN_HEADER}# Add your podcast entries below. Each entry becomes one episode.\n\ -# title: Display title of the episode\n\ -# file: Path to the Markdown source\n\ -# source_url: Original URL (optional)\n\ -# category: Category name (optional)\n\ -# thumbnail: Path under assets/image (optional)\n\ -# audio: Path under assets/audio (optional)\n\ -# timeline: Mixed list of one BibTeX citation file and news item ids\n\ +# title: Display title of the episode (up to 500 characters)\n\ +# file: Path to the Markdown source. It identifies the episode: deploying again\n\ +# updates the same news item (recorded in cite.lock), renaming the file\n\ +# publishes a new one\n\ +# category: Category name, e.g. Politics (required; must exist in Supabase,\n\ +# upper/lower case does not matter)\n\ +# summary: Short summary shown in the app, up to 50 words (optional; defaults to\n\ +# the first 50 words of the Markdown file)\n\ +# source_url: Original article link, e.g. www.example.com/story (optional)\n\ +# thumbnail: Path under assets/image (optional)\n\ +# audio: Path under assets/audio (optional)\n\ +# timeline: Ordered list of related events; each entry is one of\n\ +# - title: Bill passes the Senate an event written here; date, url\n\ +# date: May 22, 2025 and description are optional\n\ +# url: example.com/story\n\ +# - content/refs.bib a BibTeX file (at most one) whose\n\ +# entries become events\n\ +# - content/other.md another episode of this project\n\ +# - 42 the id of a news item already published\n\ +#\n\ +# Dates can be written as 2025, 2025-05, May 2025, 2025-05-22, 22 May 2025 or May 22, 2025.\n\ +# A misspelled key is reported as an error rather than silently ignored.\n\ +#\n\ +# Example:\n\ +# podcasts:\n\ +# - title: The week in AI\n\ +# file: content/week-in-ai.md\n\ +# category: Artificial Intelligence\n\ +# source_url: https://example.com/week-in-ai\n\ +# audio: assets/audio/week-in-ai.mp3\n\ +# timeline:\n\ +# - title: New model released\n\ +# date: 2025-05-22\n\ +# url: https://example.com/release\n\ {}\n", body.trim_end(), )) @@ -82,6 +205,58 @@ fn metadata_template() -> Result { mod tests { use super::*; + #[tokio::test] + async fn test_restore_keeps_metadata_as_written() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + init_project("src", &src).unwrap(); + std::fs::write(src.join("content/ep.md"), "# Ep\nHello").unwrap(); + std::fs::write( + src.join("content/ep.bib"), + "@misc{a, title = {Paper}, year = {2020}}", + ) + .unwrap(); + let metadata = r#" +podcasts: + - title: Ep + file: content/ep.md + summary: Written by hand. + category: tech + timeline: + - content/ep.bib + - 26 + - title: Inline event + date: 2025-05-22 +"#; + std::fs::write(src.join("metadata.yml"), metadata).unwrap(); + let ctx = crate::core::project::ProjectContext::load(&src).unwrap(); + let db = DbManager::open_path(&dir.path().join("cite.db")) + .await + .unwrap(); + crate::core::compiler::compile(&db, &ctx, true) + .await + .unwrap(); + + let target = dir.path().join("restored"); + let summary = restore_project(&db, &ctx.project_id(), &target) + .await + .unwrap(); + assert_eq!( + summary.timeline_events, 2, + "citation entry and inline event" + ); + + let restored = crate::core::project::ProjectContext::load(&target).unwrap(); + let (a, b) = (&ctx.metadata.podcasts[0], &restored.metadata.podcasts[0]); + assert_eq!(b.summary, a.summary); + assert_eq!(b.timeline, a.timeline); + let bib = std::fs::read_to_string(target.join("content/ep.bib")).unwrap(); + assert!( + bib.contains("Paper") && !bib.contains("Inline event"), + "{bib}" + ); + } + #[test] fn test_init_project_creates_structure() { let dir = tempfile::tempdir().unwrap(); @@ -100,7 +275,6 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("existing"); init_project("existing", &root).unwrap(); - // second call should not error init_project("existing", &root).unwrap(); assert!(root.join("cite.toml").exists()); } diff --git a/src/core/supabase.rs b/src/core/supabase.rs new file mode 100644 index 0000000..aa99e38 --- /dev/null +++ b/src/core/supabase.rs @@ -0,0 +1,289 @@ +//! Supabase REST and Storage client with retries (PostgREST embeds one-to-one relations as objects or arrays). + +use std::time::Duration; + +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use reqwest::{Method, RequestBuilder, Response}; +use serde_json::{Map, Value}; +use tracing::{info, warn}; + +use crate::core::CiteError; + +pub type Row = Map; + +const UPLOAD_ATTEMPTS: u64 = 3; + +#[derive(Debug, Clone)] +pub struct Supabase { + client: reqwest::Client, + base_url: String, + api_key: String, + bearer: String, +} + +impl Supabase { + pub fn new(client: reqwest::Client, base_url: &str, api_key: &str, bearer: &str) -> Self { + Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), + api_key: api_key.to_string(), + bearer: bearer.to_string(), + } + } + + pub fn url(&self) -> &str { + &self.base_url + } + + fn request(&self, method: Method, path: &str) -> RequestBuilder { + self.client + .request(method, format!("{}{path}", self.base_url)) + .header("apikey", &self.api_key) + .header("Authorization", format!("Bearer {}", self.bearer)) + } + + pub async fn select(&self, table: &str, query: &str) -> Result, CiteError> { + let response = self + .request(Method::GET, &format!("/rest/v1/{table}?{query}")) + .send() + .await?; + let response = ensure_success(response, format!("Failed to read {table}")).await?; + Ok(response.json().await?) + } + + pub async fn find_id( + &self, + table: &str, + field: &str, + value: &str, + ) -> Result, CiteError> { + let query = format!("select=id&{field}=eq.{}", encode(value)); + let rows = self.select(table, &query).await?; + Ok(rows.first().and_then(|row| row["id"].as_i64())) + } + + pub async fn insert_returning(&self, table: &str, row: &Row) -> Result { + let response = self + .request(Method::POST, &format!("/rest/v1/{table}")) + .header("Prefer", "return=representation") + .json(row) + .send() + .await?; + let response = ensure_success(response, format!("Failed to insert into {table}")).await?; + Ok(match response.json().await? { + Value::Array(mut rows) if !rows.is_empty() => rows.swap_remove(0), + other => other, + }) + } + + pub async fn insert(&self, table: &str, row: &Row) -> Result { + self.insert_returning(table, row).await?["id"] + .as_i64() + .ok_or_else(|| CiteError::Deploy(format!("Could not get {table} id from response"))) + } + + pub async fn update(&self, table: &str, id: i64, row: &Row) -> Result<(), CiteError> { + let response = self + .request(Method::PATCH, &format!("/rest/v1/{table}?id=eq.{id}")) + .json(row) + .send() + .await?; + ensure_success(response, format!("Failed to update {table} {id}")).await?; + Ok(()) + } + + pub async fn upsert(&self, table: &str, row: &Row, on_conflict: &str) -> Result<(), CiteError> { + let response = self + .request( + Method::POST, + &format!("/rest/v1/{table}?on_conflict={on_conflict}"), + ) + .header("Prefer", "resolution=merge-duplicates") + .json(row) + .send() + .await?; + ensure_success(response, format!("Failed to upsert into {table}")).await?; + Ok(()) + } + + pub async fn delete(&self, table: &str, id: i64) -> Result<(), CiteError> { + self.delete_where(table, &format!("id=eq.{id}")).await + } + + pub async fn delete_where(&self, table: &str, filter: &str) -> Result<(), CiteError> { + let response = self + .request(Method::DELETE, &format!("/rest/v1/{table}?{filter}")) + .send() + .await?; + ensure_success( + response, + format!("Failed to delete from {table} ({filter})"), + ) + .await?; + Ok(()) + } + + pub async fn upload( + &self, + bucket: &str, + object_path: &str, + bytes: &[u8], + mime: &str, + ) -> Result { + let path = format!("/storage/v1/object/{bucket}/{object_path}"); + let mut last_err = String::new(); + for attempt in 1..=UPLOAD_ATTEMPTS { + let response = self + .request(Method::POST, &path) + .header("Content-Type", mime) + .header("x-upsert", "true") + .body(bytes.to_vec()) + .send() + .await; + match response { + Ok(r) if r.status().is_success() => { + let storage_path = format!("{bucket}/{object_path}"); + info!("Uploaded {storage_path}"); + return Ok(storage_path); + } + Ok(r) => { + let status = r.status(); + let body = r.text().await.unwrap_or_default(); + last_err = format!("HTTP {status} - {body}"); + } + Err(e) => last_err = e.to_string(), + } + warn!("Upload attempt {attempt}/{UPLOAD_ATTEMPTS} failed: {last_err}"); + if attempt < UPLOAD_ATTEMPTS { + tokio::time::sleep(Duration::from_millis(500 * attempt)).await; + } + } + Err(CiteError::Deploy(format!( + "Failed to upload {object_path} after {UPLOAD_ATTEMPTS} attempts: {last_err}" + ))) + } + + pub async fn delete_object(&self, storage_path: &str) -> Result<(), CiteError> { + let response = self + .request( + Method::DELETE, + &format!("/storage/v1/object/{storage_path}"), + ) + .send() + .await?; + ensure_success( + response, + format!("Failed to delete storage object {storage_path}"), + ) + .await?; + Ok(()) + } +} + +pub fn row(fields: [(&str, Value); N]) -> Row { + fields + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect() +} + +pub fn encode(value: &str) -> String { + utf8_percent_encode(value, NON_ALPHANUMERIC).to_string() +} + +async fn ensure_success( + response: Response, + context: impl std::fmt::Display, +) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(CiteError::Deploy(format!( + "{context}: HTTP {status} - {body}" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + + fn client(server: &MockServer) -> Supabase { + Supabase::new(reqwest::Client::new(), &server.base_url(), "key", "jwt") + } + + #[tokio::test] + async fn test_find_id_encodes_the_filter_and_sends_auth_headers() { + let server = MockServer::start(); + let found = server.mock(|w, t| { + w.method(GET) + .path("/rest/v1/urls") + .query_param("url", "eq.https://x.y/a?b=c&d") + .header("apikey", "key") + .header("Authorization", "Bearer jwt"); + t.status(200).json_body(serde_json::json!([{ "id": 7 }])); + }); + let id = client(&server) + .find_id("urls", "url", "https://x.y/a?b=c&d") + .await + .unwrap(); + assert_eq!(id, Some(7)); + found.assert(); + } + + #[tokio::test] + async fn test_failed_request_reports_status_and_body() { + let server = MockServer::start(); + server.mock(|w, t| { + w.method(POST).path("/rest/v1/news"); + t.status(403) + .body("new row violates row-level security policy"); + }); + let err = client(&server) + .insert("news", &row([("title", Value::from("T"))])) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("Failed to insert into news"), "{err}"); + assert!(err.contains("403"), "{err}"); + assert!(err.contains("row-level security"), "{err}"); + } + + #[tokio::test] + async fn test_upload_retries_then_gives_up() { + let server = MockServer::start(); + let attempts = server.mock(|w, t| { + w.method(POST) + .path("/storage/v1/object/podcasts/artist/ep.mp3") + .header("x-upsert", "true"); + t.status(500).body("busy"); + }); + let err = client(&server) + .upload("podcasts", "artist/ep.mp3", b"mp3", "audio/mpeg") + .await + .unwrap_err() + .to_string(); + assert_eq!(attempts.hits(), UPLOAD_ATTEMPTS as usize); + assert!( + err.contains("after 3 attempts") && err.contains("busy"), + "{err}" + ); + } + + #[tokio::test] + async fn test_upload_returns_the_storage_path_the_app_resolves() { + let server = MockServer::start(); + server.mock(|w, t| { + w.method(POST) + .path("/storage/v1/object/assets/artist/n.png"); + t.status(200); + }); + let path = client(&server) + .upload("assets", "artist/n.png", b"png", "image/png") + .await + .unwrap(); + assert_eq!(path, "assets/artist/n.png"); + } +} diff --git a/src/core/uninstall.rs b/src/core/uninstall.rs deleted file mode 100644 index 646a802..0000000 --- a/src/core/uninstall.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::io::Write; - -use tracing::{info, instrument, warn}; - -use crate::core::CiteError; - -#[instrument] -pub fn uninstall() -> Result<(), CiteError> { - let current_exe = std::env::current_exe() - .map_err(|e| CiteError::Config(format!("Cannot determine executable path: {e}")))?; - - let install_dir = current_exe - .parent() - .ok_or_else(|| CiteError::Config("Cannot determine install directory".into()))?; - - info!("cite installed at: {}", current_exe.display()); - - warn!("This will delete the binary. Shell config files might NOT be modified"); - print!("Are you sure? [y/N] "); - let _ = std::io::stdout().flush(); - - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - match input.trim().to_lowercase().as_str() { - "y" | "yes" => {} - _ => { - warn!("Uninstall cancelled"); - return Ok(()); - } - } - - std::fs::remove_file(¤t_exe)?; - info!("Removed {}", current_exe.display()); - - if install_dir - .read_dir() - .map(|mut d| d.next().is_none()) - .unwrap_or(false) - { - let _ = std::fs::remove_dir(install_dir); - info!("Removed empty directory {}", install_dir.display()); - } - - // Remove local database, session, and credentials - let cite_dir = crate::core::cite_home(); - if cite_dir.exists() { - let _ = std::fs::remove_file(cite_dir.join("cite.db")); - let _ = std::fs::remove_file(cite_dir.join("session.json")); - let _ = std::fs::remove_file(cite_dir.join("credentials.toml")); - info!("Removed ~/.cite/cite.db, session.json, and credentials.toml"); - if std::fs::read_dir(&cite_dir) - .map(|mut d| d.next().is_none()) - .unwrap_or(true) - { - let _ = std::fs::remove_dir(&cite_dir); - info!("Removed empty ~/.cite directory"); - } - } - - let install_dir_str = install_dir.to_string_lossy(); - let found = crate::core::home_dir().is_some_and(|home| { - [".zshrc", ".bashrc", ".bash_profile", ".profile"] - .iter() - .any(|rc| { - std::fs::read_to_string(home.join(rc)) - .is_ok_and(|c| c.contains(install_dir_str.as_ref())) - }) - }); - - if found { - info!("Shell config files reference the install directory"); - info!(" Edit ~/.zshrc, ~/.bashrc, etc. and remove lines containing:"); - info!(" {install_dir_str}"); - info!(" Then restart your shell or run: source ~/.zshrc"); - } - info!("cite has been uninstalled"); - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index fa62137..6162284 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +//! Binary entry: parses CLI args, runs the matching command, or opens the TUI when no command is given. mod cli; mod core; mod tui; @@ -6,7 +7,6 @@ use clap::Parser; use cli::Cli; use colored::Colorize; use std::io::Write; -use std::path::PathBuf; use tokio::sync::mpsc; use tracing::info; use tracing_subscriber::EnvFilter; @@ -50,8 +50,7 @@ async fn main() { }) .init(); info!("cite v{}", env!("CARGO_PKG_VERSION")); - let root = PathBuf::from(&cli.path); - if let Err(e) = tui::run_tui(log_rx, root).await { + if let Err(e) = tui::run_tui(log_rx, cli.path).await { eprintln!("{} {}", "error:".red().bold(), e); std::process::exit(1); } diff --git a/src/tui.rs b/src/tui.rs index 9fd6cd2..0632314 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +//! Standalone interactive dashboard running the same commands with projects, logs, and analytics panes. use std::path::{Path, PathBuf}; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -19,13 +19,10 @@ use tokio::task::JoinHandle; use tracing::{error, info, warn}; use crate::core::CiteError; -use crate::core::db::DbManager; -use crate::core::manifest::{Manifest, ProjectConfig}; -use crate::core::metadata::{Metadata, Podcast}; -use crate::core::project::{ - self, AllStats, ProjectContext, ProjectStats, RestoredTimeline, StoredBuild, StoredDeployment, - StoredTimeline, +use crate::core::db::{ + AllStats, DbManager, ProjectStats, StoredBuild, StoredDeployment, StoredPodcast, StoredTimeline, }; +use crate::core::project::{self, ProjectContext}; use crate::core::{compiler, deploy, doctor, scaffold}; const ACCENT: Color = Color::Cyan; @@ -35,7 +32,6 @@ const READY: Color = Color::Green; const MUTED: Color = Color::DarkGray; const ON_ACCENT: Color = Color::Black; -/// Oldest log lines are dropped past this cap so long sessions don't grow without bound. const MAX_LOG_LINES: usize = 5_000; struct TerminalGuard; @@ -87,14 +83,14 @@ pub const CMDS: &[Cmd] = &[ }, Cmd { label: "deploy", - desc: "Upload bundle and sync Supabase backend", - args_hint: "[--dry-run] [--staging]", + desc: "Validate, build if needed, and publish episodes to Supabase", + args_hint: "[--dry-run]", needs_project: true, id: CommandId::Deploy, }, Cmd { label: "rollback", - desc: "Roll back to a previous deployment", + desc: "Remove the news items and uploads a deployment created", args_hint: "", needs_project: true, id: CommandId::Rollback, @@ -115,6 +111,13 @@ pub enum Focus { Logs, } +const FOCUS_ORDER: [Focus; 4] = [ + Focus::Projects, + Focus::Commands, + Focus::Analytics, + Focus::Logs, +]; + #[derive(Clone, PartialEq)] pub enum ProjectItemKind { LocalHeader, @@ -132,7 +135,7 @@ pub struct ProjectItem { pub struct AnalyticsState { pub stats: Option, pub global: Option, - pub podcasts: Vec, + pub podcasts: Vec, pub timelines: Vec, pub builds: Vec, pub deploys: Vec, @@ -349,15 +352,6 @@ impl AppState { None } - fn focus_order(&self) -> Vec { - vec![ - Focus::Projects, - Focus::Commands, - Focus::Analytics, - Focus::Logs, - ] - } - fn filtered_commands(&self) -> Vec { let query = self.command_palette.query.to_lowercase(); CMDS.iter() @@ -509,13 +503,15 @@ impl AppState { if self.busy { return; } - let order = self.focus_order(); - let i = order.iter().position(|f| *f == self.focus).unwrap_or(0); - let n = order.len(); + let i = FOCUS_ORDER + .iter() + .position(|f| *f == self.focus) + .unwrap_or(0); + let n = FOCUS_ORDER.len(); self.focus = if key.code == KeyCode::Tab { - order[(i + 1) % n] + FOCUS_ORDER[(i + 1) % n] } else { - order[(i + n - 1) % n] + FOCUS_ORDER[(i + n - 1) % n] }; } KeyCode::Up => match self.focus { @@ -572,7 +568,7 @@ impl AppState { self.rebuild_project_items(); } ProjectItemKind::LocalProject(_) => { - info!(">> Selected project "); + info!(">> Selected project"); self.load_analytics_data().await; } ProjectItemKind::ArchivedProject(name) => { @@ -663,7 +659,7 @@ impl AppState { match id { CommandId::Init => exec_init(cwd, raw_args).await, CommandId::Build => exec_build(root, raw_args).await, - CommandId::Doctor => exec_doctor(root, raw_args).await, + CommandId::Doctor => exec_doctor(root).await, CommandId::Deploy => exec_deploy(root, raw_args).await, CommandId::Rollback => exec_rollback(root, raw_args).await, } @@ -683,14 +679,10 @@ impl AppState { .and_then(|r| r.file_name().map(|n| n.to_string_lossy().into_owned())) .unwrap_or_default(); match id { - CommandId::Deploy => { - let mode = if self.arg_input.split_whitespace().any(|w| w == "--staging") { - "local (staging)" - } else { - "Supabase" - }; - format!("Deploy '{target}' to {mode}?") + CommandId::Deploy if self.arg_input.split_whitespace().any(|w| w == "--dry-run") => { + format!("Preview deploying '{target}'?") } + CommandId::Deploy => format!("Deploy '{target}' to Supabase?"), CommandId::Rollback => { let id = self.arg_input.trim(); if id.is_empty() { @@ -794,12 +786,14 @@ impl AppState { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { let target = self.cwd.join(&name); match restore_archived(&target, &name, &self.db_projects).await { - Ok((podcasts, timelines, warnings)) => { + Ok(summary) => { info!( - "Restored project {name} at {} ({podcasts} podcast(s), {timelines} timeline event(s))", - target.display() + "Restored project {name} at {} ({} podcast(s), {} timeline event(s))", + target.display(), + summary.podcasts, + summary.timeline_events ); - for warning in warnings { + for warning in summary.warnings { warn!("{warning}"); } } @@ -867,153 +861,13 @@ async fn restore_archived( target: &Path, name: &str, db_projects: &[(String, String)], -) -> Result<(usize, usize, Vec), CiteError> { +) -> Result { let (_, project_id) = db_projects .iter() .find(|(n, _)| n == name) .ok_or_else(|| CiteError::Config(format!("No local record for '{name}'")))?; - let db = DbManager::open().await?; - let snapshot = db.get_restore_snapshot(project_id).await?; - - tokio::fs::create_dir_all(target).await?; - tokio::fs::create_dir_all(target.join("content")).await?; - - let manifest = Manifest { - project: ProjectConfig { - name: snapshot.name.clone(), - language: snapshot.language.clone(), - metadata_file: snapshot.metadata_file.clone(), - artist_id: snapshot.artist_id.clone(), - }, - ..Default::default() - }; - tokio::fs::write(target.join("cite.toml"), toml::to_string_pretty(&manifest)?).await?; - - let mut warnings = Vec::new(); - let mut podcasts_meta = Vec::new(); - - for pod in &snapshot.podcasts { - if pod.file.is_empty() { - continue; - } - match &pod.content { - Some(content) => { - let path = target.join(&pod.file); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(path, content).await?; - } - None => warnings.push(format!("Content missing for '{}'; re-add it", pod.title)), - } - if let Some(audio) = &pod.audio { - warnings.push(format!("Re-add audio asset {audio}")); - } - if let Some(thumbnail) = &pod.thumbnail { - warnings.push(format!("Re-add thumbnail asset {thumbnail}")); - } - podcasts_meta.push(Podcast { - title: pod.title.clone(), - file: pod.file.clone(), - source_url: pod.source_url.clone(), - category: pod.category.clone(), - thumbnail: pod.thumbnail.clone(), - audio: pod.audio.clone(), - timeline: match &pod.citation_file { - Some(file) => vec![crate::core::metadata::TimelineItem::Citation(file.clone())], - None => Vec::new(), - }, - }); - } - - let timelines_by_podcast: HashMap<&str, Vec<&RestoredTimeline>> = snapshot - .timelines - .iter() - .map(|tl| (tl.podcast_id.as_str(), tl)) - .fold(HashMap::new(), |mut groups, (id, tl)| { - groups.entry(id).or_default().push(tl); - groups - }); - - for pod in &snapshot.podcasts { - let Some(cit) = pod.citation_file.as_deref().filter(|c| !c.is_empty()) else { - continue; - }; - let entries = timelines_by_podcast - .get(pod.id.as_str()) - .cloned() - .unwrap_or_default(); - let path = target.join(cit); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(path, render_bibtex(&entries)).await?; - } - - let metadata_file = snapshot.metadata_file.clone(); - let metadata = Metadata { - podcasts: podcasts_meta, - }; - tokio::fs::write( - target.join(metadata_file), - serde_yaml::to_string(&metadata)?, - ) - .await?; - - Ok((snapshot.podcasts.len(), snapshot.timelines.len(), warnings)) -} - -fn render_bibtex(entries: &[&RestoredTimeline]) -> String { - const MONTHS: [&str; 12] = [ - "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", - ]; - - let mut out = String::new(); - for (i, entry) in entries.iter().enumerate() { - let year = entry - .date - .as_deref() - .and_then(|d| d.get(..4)) - .unwrap_or("0000"); - out.push_str(&format!("@misc{{restored{i},\n")); - out.push_str(&format!( - " title = {{{}}},\n", - sanitize_bib_value(&entry.title) - )); - out.push_str(&format!(" year = {{{year}}},\n")); - if let Some(month) = entry - .date - .as_deref() - .and_then(|d| d.get(5..7)) - .and_then(|m| m.parse::().ok()) - .and_then(|m| MONTHS.get(m.saturating_sub(1))) - { - out.push_str(&format!(" month = {{{month}}},\n")); - } - if let Some(summary) = &entry.summary { - out.push_str(&format!( - " abstract = {{{}}},\n", - sanitize_bib_value(summary) - )); - } - if let Some(url) = &entry.url { - out.push_str(&format!(" url = {{{url}}},\n")); - } - if let Some(link) = &entry.link { - out.push_str(&format!(" link = {{{link}}},\n")); - } - out.push_str("}\n\n"); - } - out -} - -fn sanitize_bib_value(value: &str) -> String { - value - .chars() - .filter(|c| !matches!(c, '{' | '}')) - .map(|c| if c == '\n' { ' ' } else { c }) - .collect() + scaffold::restore_project(&db, project_id, target).await } fn block(title: impl Into, focused: bool) -> Block<'static> { @@ -1175,26 +1029,19 @@ fn render_header(frame: &mut Frame, area: Rect, app: &AppState) { } fn render_body(frame: &mut Frame, area: Rect, app: &mut AppState) { - match app.mode { - TuiMode::Runner | TuiMode::CommandPalette => { - let [left, middle, right] = Layout::horizontal([ - Constraint::Max(20), - Constraint::Fill(2), - Constraint::Fill(1), - ]) - .areas(area); - - render_categorized_project_list(frame, left, app); - - let [cmd_area, logs_area] = - Layout::vertical([Constraint::Max(8), Constraint::Min(3)]).areas(middle); - - render_commands_pane(frame, cmd_area, app); - render_log(frame, logs_area, app); + let [left, middle, right] = Layout::horizontal([ + Constraint::Max(20), + Constraint::Fill(2), + Constraint::Fill(1), + ]) + .areas(area); + let [cmd_area, logs_area] = + Layout::vertical([Constraint::Max(8), Constraint::Min(3)]).areas(middle); - render_analytics_content(frame, right, app); - } - } + render_categorized_project_list(frame, left, app); + render_commands_pane(frame, cmd_area, app); + render_log(frame, logs_area, app); + render_analytics_content(frame, right, app); } fn render_categorized_project_list(frame: &mut Frame, area: Rect, app: &mut AppState) { @@ -1610,7 +1457,7 @@ fn push_section(lines: &mut Vec, title: &str, expanded: bool, rows: Vec Vec { +fn podcast_rows(podcasts: &[StoredPodcast]) -> Vec { podcasts .iter() .map(|p| { @@ -1639,7 +1486,7 @@ fn timeline_rows(timelines: &[StoredTimeline]) -> Vec { .as_ref() .map(|et| format!(" ({et})")) .unwrap_or_default(); - let linked = t.url.is_some() || t.link.is_some(); + let linked = t.url.is_some(); format!( "{} {}{}{}", t.date.as_deref().unwrap_or("??"), @@ -1825,49 +1672,48 @@ async fn exec_init(cwd: PathBuf, raw: String) { } async fn exec_build(root: Option, raw: String) { - let Some((ctx, db)) = load_project_context(root).await else { - return; - }; let force = raw.split_whitespace().any(|w| w == "--force"); - match compiler::compile(&db, &ctx, force).await { - Ok(outcome) => outcome.emit(), - Err(e) => error!("Build failed: {e}"), - } + run_with_context(root, "Build", |ctx, db| async move { + match compiler::compile(&db, &ctx, force).await { + Ok(outcome) => outcome.emit(), + Err(e) => error!("Build failed: {e}"), + } + }) + .await; } -async fn exec_doctor(root: Option, _raw: String) { - let Some((ctx, db)) = load_project_context(root).await else { - return; - }; - match doctor::run(&db, &ctx).await { - Ok(o) => { - o.emit(); - project::print_status(&db, &ctx).await; - if !o.has_errors() && !o.has_warnings() { - info!("Doctor check complete; no issues found"); - } +async fn exec_doctor(root: Option) { + run_with_context(root, "Doctor", |ctx, db| async move { + let outcome = doctor::run(&db, &ctx).await; + outcome.emit(); + if !outcome.has_errors() && !outcome.has_warnings() { + info!("Doctor check complete; no issues found"); } - Err(e) => error!("Doctor failed: {e}"), - } + }) + .await; } async fn exec_deploy(root: Option, raw: String) { - let Some((ctx, db)) = load_project_context(root).await else { - return; - }; let dry_run = raw.split_whitespace().any(|w| w == "--dry-run"); - let staging = raw.split_whitespace().any(|w| w == "--staging"); - if staging { - match deploy::deploy_staging(&db, &ctx, dry_run).await { - Ok(msg) => info!("{msg}"), - Err(e) => error!("Staging deploy failed: {e}"), - } - } else { + run_with_context(root, "Deploy", |ctx, db| async move { match deploy::deploy(&db, &ctx, dry_run).await { Ok(msg) => info!("{msg}"), Err(e) => error!("Deploy failed: {e}"), } - } + }) + .await; +} + +async fn run_with_context(root: Option, name: &str, run: F) +where + F: FnOnce(ProjectContext, DbManager) -> Fut, + Fut: std::future::Future, +{ + let Some((ctx, db)) = load_project_context(root).await else { + error!("{name} failed: no project selected"); + return; + }; + run(ctx, db).await; } async fn exec_rollback(root: Option, raw: String) { @@ -1880,9 +1726,9 @@ async fn exec_rollback(root: Option, raw: String) { error!("No project selected"); return; }; - let Ok(ctx) = ProjectContext::load(&root) else { - error!("Failed to load project"); - return; + let ctx = match ProjectContext::load(&root) { + Ok(ctx) => ctx, + Err(e) => return error!("Failed to load project: {e}"), }; match deploy::rollback(&ctx, id).await { Ok(msg) => info!("{msg}"), diff --git a/tests/integration.rs b/tests/integration.rs index 896aa44..34af36f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2,6 +2,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +const ARTIST: &str = "11111111-1111-1111-1111-111111111111"; + struct ProjectHarness { _dir: tempfile::TempDir, project: PathBuf, @@ -29,9 +31,14 @@ impl ProjectHarness { } fn cmd(args: &[&str], db_path: &Path) -> (String, String, bool) { + let home = db_path.parent().unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_cite")) .args(args) .env("CITE_DB_PATH", db_path.to_str().unwrap()) + .env("HOME", home) + .env("CITE_CREDS_PATH", home.join("no-credentials.toml")) + .env_remove("CITE_SUPABASE_URL") + .env_remove("CITE_SUPABASE_API_KEY") .output() .expect("Failed to run cite"); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); @@ -68,14 +75,24 @@ impl ProjectHarness { fs::write(path, text).unwrap(); } + fn set_artist(&self) { + let toml = format!( + "[project]\nname = \"{}\"\nartist_id = \"{ARTIST}\"\n", + self.name() + ); + fs::write(self.project.join("cite.toml"), toml).unwrap(); + } + + fn name(&self) -> &str { + self.project.file_name().unwrap().to_str().unwrap() + } + fn read_bundle(&self) -> serde_json::Value { let content = fs::read_to_string(self.project.join("build/content.json")).unwrap(); serde_json::from_str(&content).unwrap() } } -// ── init ──────────────────────────────────────────────────────── - #[test] fn init_creates_project_structure() { let h = ProjectHarness::new("my-project"); @@ -106,14 +123,6 @@ fn init_is_idempotent_on_existing_project() { assert!(stderr.contains("Skipped")); } -// ── doctor ──────────────────────────────────────────────────── - -#[test] -fn doctor_passes_on_empty_project() { - let h = ProjectHarness::new("empty"); - h.run_ok(&["doctor"]); -} - #[test] fn doctor_catches_missing_file() { let h = ProjectHarness::new("missing-file"); @@ -138,8 +147,6 @@ fn doctor_catches_missing_metadata() { assert!(stderr.contains("not found")); } -// ── doctor (includes lint checks) ─────────────────────────────── - #[test] fn doctor_warns_on_short_content() { let h = ProjectHarness::new("short-content"); @@ -149,15 +156,14 @@ fn doctor_warns_on_short_content() { podcasts: - title: "A" file: content/a.md + category: tech "#, ); let (_, stderr, ok) = h.run(&["doctor"]); assert!(ok); - assert!(stderr.contains("low word count") || stderr.contains("very short")); + assert!(stderr.contains("low word count"), "{stderr}"); } -// ── build ─────────────────────────────────────────────────────── - #[test] fn build_produces_valid_content_json() { let h = ProjectHarness::new("build-test"); @@ -171,22 +177,22 @@ podcasts: "#, ); - let re = regex::Regex::new(r#"(?m)^(artist_id\s*=\s*)"[^"]*""#).unwrap(); - let toml = fs::read_to_string(h.project.join("cite.toml")).unwrap(); - let toml = re.replace(&toml, r#"$1"alice_uuid""#).to_string(); - fs::write(h.project.join("cite.toml"), toml).unwrap(); + h.set_artist(); h.run_ok(&["build"]); let bundle = h.read_bundle(); assert!(h.project.join("build").exists()); assert_eq!(bundle["project"], "build-test"); assert_eq!(bundle["compiler_version"], 1.0); - assert_eq!(bundle["artist_id"], "alice_uuid"); + assert_eq!(bundle["artist_id"], ARTIST); assert_eq!(bundle["podcasts"].as_array().unwrap().len(), 1); let pod = &bundle["podcasts"][0]; assert!(!pod["id"].as_str().unwrap().is_empty()); assert_eq!(pod["title"], "My Podcast"); - assert_eq!(pod["content"], "# Hello World"); + assert_eq!( + pod["content"], "# Hello World", + "Markdown embedded verbatim" + ); } #[test] @@ -248,60 +254,31 @@ podcasts: ); assert_eq!(entries[1]["date"], "2024-01"); assert_eq!(entries[1]["url"], "https://example.com/follow-up"); - assert_eq!(entries[1]["link"], "https://example.com/news/related-story"); } #[test] -fn build_is_idempotent_with_cache() { +fn build_skips_unchanged_sources_and_rebuilds_changed_ones() { let h = ProjectHarness::new("cached-build"); - h.write_content("content/article.md", "# Same"); - h.write_metadata( - r#" -podcasts: - - title: "A" - file: content/article.md -"#, - ); - - h.run_ok(&["build"]); - let first = h.read_bundle(); - - let (_, stderr, ok) = h.run(&["build"]); - assert!(ok, "cached build: {stderr}"); - - h.run_ok(&["build", "--force"]); + h.write_content("content/a.md", "v1"); + h.write_metadata("podcasts:\n - title: A\n file: content/a.md\n"); - let second = h.read_bundle(); - let first_content = &first["podcasts"][0]["content"]; - let second_content = &second["podcasts"][0]["content"]; - assert_eq!( - first_content, second_content, - "force rebuild content should match" + let stderr = h.run_ok(&["build"]); + assert!(stderr.contains("Built 1 podcast"), "{stderr}"); + assert!( + !stderr.contains("(incremental)"), + "first build is full: {stderr}" ); - assert_eq!(first["project"], second["project"]); - assert_eq!(first["artist_id"], second["artist_id"]); -} -#[test] -fn build_does_not_resolve_wiki_links() { - let h = ProjectHarness::new("no-wiki-test"); - h.write_content("content/main.md", "See [[other-page]] for details"); - h.write_metadata( - r#" -podcasts: - - title: "Main" - file: content/main.md -"#, - ); + let stderr = h.run_ok(&["build"]); + assert!(stderr.contains("Nothing to rebuild"), "{stderr}"); - h.run_ok(&["build"]); + h.write_content("content/a.md", "v2"); + let stderr = h.run_ok(&["build"]); + assert!(stderr.contains("(incremental)"), "{stderr}"); + assert_eq!(h.read_bundle()["podcasts"][0]["content"], "v2"); - let bundle = h.read_bundle(); - let content = bundle["podcasts"][0]["content"].as_str().unwrap(); - assert!( - content.contains("[[other-page]]"), - "wiki-link should remain as-is, got: {content}" - ); + let stderr = h.run_ok(&["build", "--force"]); + assert!(stderr.contains("Built 1 podcast"), "{stderr}"); } #[test] @@ -313,31 +290,6 @@ fn build_empty_project_succeeds() { assert_eq!(pods.len(), 0, "template has no default podcast"); } -#[test] -fn build_force_rebuilds() { - let h = ProjectHarness::new("force-build"); - h.write_content("content/a.md", "v1"); - h.write_metadata( - r#" -podcasts: - - title: "A" - file: content/a.md -"#, - ); - h.run_ok(&["build"]); - let first = h.read_bundle(); - - h.run_ok(&["build", "--force"]); - let second = h.read_bundle(); - assert_eq!( - first["podcasts"][0]["content"], - second["podcasts"][0]["content"] - ); - assert_eq!(second["compiler_version"], 1.0); -} - -// ── status ────────────────────────────────────────────────────── - #[test] fn doctor_shows_project_info_with_status() { let h = ProjectHarness::new("status-test"); @@ -347,6 +299,7 @@ fn doctor_shows_project_info_with_status() { podcasts: - title: "A" file: content/a.md + category: tech "#, ); @@ -358,10 +311,9 @@ podcasts: let stderr = h.run_ok(&["doctor"]); assert!(stderr.contains("Podcasts: 1")); + assert!(stderr.contains("Last build:")); } -// ── doctor ────────────────────────────────────────────────────── - #[test] fn doctor_detects_missing_project() { let db_dir = tempfile::tempdir().unwrap(); @@ -375,15 +327,13 @@ fn doctor_detects_missing_project() { } #[test] -fn doctor_shows_project_health() { +fn doctor_passes_on_new_project() { let h = ProjectHarness::new("doctor-test"); let stderr = h.run_ok(&["doctor"]); assert!(stderr.contains("cite.toml found")); assert!(stderr.contains("metadata.yml found")); } -// ── clean ─────────────────────────────────────────────────────── - #[test] fn clean_removes_artifacts_and_is_idempotent() { let h = ProjectHarness::new("clean-test"); @@ -404,34 +354,66 @@ podcasts: h.run_ok(&["clean"]); } -// ── deploy ────────────────────────────────────────────────────── - #[test] fn deploy_fails_without_backend() { let h = ProjectHarness::new("no-backend"); + fs::write( + h.project.join("cite.toml"), + "[project]\nname = \"no-backend\"\nartist_id = \"11111111-1111-1111-1111-111111111111\"\n", + ) + .unwrap(); let (_, stderr, ok) = h.run(&["deploy"]); assert!(!ok); - assert!( - stderr.contains("No [backend]") - || stderr.contains("No build artifact") - || stderr.contains("No credentials found") - || stderr.contains("credentials") - ); + assert!(stderr.contains("No credentials found"), "{stderr}"); } #[test] -fn deploy_fails_without_build() { - let h = ProjectHarness::new("no-build"); +fn deploy_requires_valid_artist_id() { + let h = ProjectHarness::new("no-artist"); fs::write( h.project.join("cite.toml"), "[project]\nname = \"no-build\"\nversion = \"0.1.0\"\nlanguage = \"en\"\nmetadata_file = \"metadata.yml\"\nartist_id = \"\"\n\n[build]\ncompiler_version = 0.0\nincremental = true\noutput_format = \"json\"\n\n[backend]\nstaging_url = \"https://test.supabase.co\"\nstaging_service_key = \"test-key\"\n", ).unwrap(); let (_, stderr, ok) = h.run(&["deploy"]); assert!(!ok); - assert!(stderr.contains("No build artifact")); + assert!(stderr.contains("artist_id"), "{stderr}"); } -// ── rollback ──────────────────────────────────────────────────── +#[test] +fn deploy_refuses_invalid_metadata_before_connecting() { + let h = ProjectHarness::new("invalid-deploy"); + h.set_artist(); + h.write_content("content/a.md", "# A"); + h.write_metadata(&format!( + "podcasts:\n - title: A\n file: content/a.md\n summary: {}\n", + "word ".repeat(51) + )); + let (_, stderr, ok) = h.run(&["deploy"]); + assert!(!ok); + assert!(stderr.contains("Fix 2 problem(s)"), "{stderr}"); + assert!(stderr.contains("no category"), "{stderr}"); + assert!(stderr.contains("summary has 51 words"), "{stderr}"); + assert!( + !stderr.contains("credentials"), + "validated before connecting: {stderr}" + ); + assert!(!h.project.join("build").exists(), "nothing built"); +} + +#[test] +fn misspelled_metadata_key_is_reported_with_its_line() { + let h = ProjectHarness::new("typo"); + h.write_metadata("podcasts:\n - title: A\n file: content/a.md\n catgory: tech\n"); + for command in ["doctor", "build", "deploy"] { + let (_, stderr, ok) = h.run(&[command]); + assert!(!ok, "{command} must fail"); + assert!( + stderr.contains("metadata.yml: podcasts[0]: unknown field `catgory`") + && stderr.contains("line 4"), + "{command}: {stderr}" + ); + } +} #[test] fn rollback_fails_without_backend() { @@ -445,12 +427,10 @@ fn rollback_fails_without_backend() { ); } -// ── e2e ───────────────────────────────────────────────────────── - #[test] fn full_workflow_end_to_end() { let h = ProjectHarness::new("e2e"); - + h.set_artist(); h.write_content("content/ai.md", "# AI Article\nSome content about AI."); h.write_content("content/ml.md", "# ML Article\nSome content about ML."); h.write_metadata( @@ -459,6 +439,12 @@ podcasts: - title: "AI Article" file: content/ai.md category: tech + source_url: www.example.com/ai + timeline: + - content/ml.md + - title: Launch + date: May 22, 2025 + link: example.com/launch - title: "ML Article" file: content/ml.md category: tech @@ -466,16 +452,19 @@ podcasts: ); h.run_ok(&["doctor"]); - h.run_ok(&["build"]); - let bundle = h.read_bundle(); - assert_eq!(bundle["project"], "e2e"); - assert_eq!(bundle["podcasts"].as_array().unwrap().len(), 2); + let (stdout, stderr, ok) = h.run(&["deploy", "--dry-run"]); + assert!(ok, "{stderr}"); + assert!(stdout.contains("Dry run complete"), "{stdout}"); assert!( - bundle["podcasts"][0]["content"] - .as_str() - .unwrap() - .contains("AI") + stderr.contains("Dry run: 2 podcast(s), 0 update(s), 2 new"), + "{stderr}" ); + + let bundle = h.read_bundle(); + let ai = &bundle["podcasts"][0]; + assert_eq!(ai["source_url"], "https://www.example.com/ai"); + assert_eq!(ai["timeline"][0], "content/ml.md"); + assert_eq!(ai["timeline"][1]["url"], "https://example.com/launch"); assert!( bundle["podcasts"][1]["content"] .as_str() @@ -487,8 +476,6 @@ podcasts: assert!(!h.project.join("build").exists()); } -// ── cli basics ────────────────────────────────────────────────── - #[test] fn help_prints_usage() { let db_dir = tempfile::tempdir().unwrap(); @@ -515,6 +502,7 @@ fn json_flag_produces_valid_json() { podcasts: - title: "JSON Article" file: content/a.md + category: tech "#, ); let (stdout, _, ok) = h.run(&["doctor", "--json"]);