Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: CI

# `release.yml` already runs `lint` on every tag push. This workflow
# covers everything else: pull requests targeting `main`, and direct
# pushes to any non-main branch (so the author sees a green check
# before opening the PR). `main` is protected — push events on main
# only come from squash-merges, which the PR's own check already
# validated.

on:
pull_request:
branches: [main]
push:
branches-ignore: [main]

permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
lint-and-test:
name: Lint & test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: .
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
# `Cargo.lock` is gitignored (see `.gitignore`), so `--locked`
# would fail on a fresh clone before `cargo` resolved deps.
# Match `release.yml`'s `lint` job exactly.
run: cargo test --workspace
- name: doc (non-failing signal)
# Best-effort docs build — no `-D warnings` yet. Once doc
# warnings are under control, promote this to a hard gate.
run: cargo doc --workspace --no-deps
continue-on-error: true
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.2.8"
version = "0.2.9"
edition = "2021"
license = "Apache-2.0"
repository = "https://github.com/pcr-developers/cli"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Run `pcr <cmd> --help` for examples. Full reference at [pcr.dev/docs](https://pc
registered projects, the live event log, and the draft → bundled → pushed pipeline:

```text
PCR.dev · start · v0.2.8 ✓ bhada@pcr.dev 14:08:42
PCR.dev · start · v0.2.9 ✓ bhada@pcr.dev 14:08:42

Capturing — 7 exchanges this session, 3 unbundled across 2 projects ▁▃▆█▇▅▂

Expand Down
25 changes: 22 additions & 3 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Releasing `pcr-dev`

> `main` is protected — direct pushes are rejected. All releases must
> go through a PR (`gh pr create … && gh pr merge --squash
> --delete-branch`) before the tag push step below.

This repo now produces two artifacts from a single Rust codebase rooted at
the repo root:

Expand All @@ -23,13 +27,28 @@ merge. The last Go release lives behind the `v0.1.17` tag if you ever need it.
# crates/pcr-napi/package.json — optionalDependencies entries
```

2. Commit, tag, push.
2. Land the bump via a PR, then tag the merge commit.

`main` is protected; direct pushes are rejected. Open a PR with the
version-bump commit, get the PR-CI workflow green, squash-merge, and
then tag the resulting merge commit:

```bash
# On a release branch:
git checkout -b release/v0.2.0
git add -A
git commit -m "release v0.2.0"
git tag v0.2.0
git push && git push --tags
git push -u origin HEAD

# Open + merge the PR (squash to keep a single release commit on main):
gh pr create --fill --base main
gh pr merge --squash --delete-branch

# Once the squash-merge lands, tag the merge commit on main and push:
git checkout main
git pull --ff-only
git tag -a v0.2.0 -m "v0.2.0"
git push origin v0.2.0
```

The tag push triggers `.github/workflows/release.yml`, which runs:
Expand Down
3 changes: 3 additions & 0 deletions crates/pcr-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ assert_cmd = "2.0"
predicates = "3.1"
tempfile = "3.10"
serde_json = { workspace = true }
# Already a workspace dep — reused here so the migration smoke test can
# build a v1-schema fixture DB and re-query it after migrations run.
rusqlite = { workspace = true }
76 changes: 76 additions & 0 deletions crates/pcr-cli/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Each integration test under `tests/` compiles as its own binary, so
// not every helper here will be referenced by every binary. Suppress
// the unused-helper warnings instead of sprinkling per-fn `allow`s.
#![allow(dead_code)]

//! Shared integration-test harness for the `pcr` binary.
//!
//! Every integration test in this crate spawns the built `pcr` binary
//! through `assert_cmd`. The factory below bundles the env scrubbing
//! and `$HOME` / `cwd` isolation each test needs so we don't drift
//! between tests when the rules of "what env makes pcr deterministic"
//! evolve.

use assert_cmd::Command;
use tempfile::TempDir;

/// Build a freshly scrubbed `pcr` command bound to an isolated `$HOME`.
///
/// The temp dir's lifetime is returned alongside the command — drop it
/// at the end of the test or the dir disappears mid-run on platforms
/// that reap deleted directories aggressively.
pub fn pcr() -> (Command, TempDir) {
let tmp = TempDir::new().expect("tempdir");
let cmd = build(&tmp);
(cmd, tmp)
}

/// Two-temp-dir bundle: one for `$HOME` (where `.pcr-dev/` lives), one
/// for the spawned process's cwd. Splitting them keeps the registered
/// project state separate from the working directory we're pretending
/// to be inside — useful when a test needs to assert that `pcr` reads
/// state from `$HOME` while running in an unrelated repo.
pub struct HomeFixture {
pub home: TempDir,
pub cwd: TempDir,
}

impl HomeFixture {
pub fn home_path(&self) -> &std::path::Path {
self.home.path()
}
pub fn cwd_path(&self) -> &std::path::Path {
self.cwd.path()
}
pub fn pcr_dir(&self) -> std::path::PathBuf {
self.home.path().join(".pcr-dev")
}
}

/// Build a `HomeFixture` with `$HOME/.pcr-dev/` pre-created so commands
/// don't have to mkdir on first boot.
pub fn home_fixture() -> HomeFixture {
let home = TempDir::new().expect("home tempdir");
let cwd = TempDir::new().expect("cwd tempdir");
std::fs::create_dir_all(home.path().join(".pcr-dev")).expect("pre-create pcr-dev");
HomeFixture { home, cwd }
}

/// Wire a `pcr` command against the given fixture: isolated `$HOME`,
/// the fixture's `cwd` as the process's working directory, and the
/// usual env scrubbing.
pub fn pcr_in(fx: &HomeFixture) -> Command {
let mut cmd = build(&fx.home);
cmd.current_dir(fx.cwd.path());
cmd
}

fn build(home: &TempDir) -> Command {
let mut cmd = Command::cargo_bin("pcr").expect("binary built");
cmd.env("HOME", home.path())
.env("USERPROFILE", home.path())
.env_remove("CI")
.env_remove("NO_COLOR")
.env_remove("CURSOR_AGENT");
cmd
}
125 changes: 125 additions & 0 deletions crates/pcr-cli/tests/cursor_ingestion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//! Cursor ingestion → drafts integration test (partial).
//!
//! ## Status: harness smoke + registered-project log roundtrip
//!
//! The original Step 1 plan asked for a full e2e test driving a Cursor
//! transcript through `pcr --plain bundle` into the drafts store, then
//! re-reading via `pcr --json log`. That turned out to be intractable
//! to fixture from a subprocess:
//!
//! - `crates/pcr-core/src/sources/cursor/watcher.rs` only iterates
//! `~/.cursor/projects/<slug>/agent-transcripts/*.jsonl` to discover
//! *session ids*. The actual prompt + response payloads live in
//! Cursor's `state.vscdb` SQLite database
//! (`~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
//! on macOS, `~/.config/Cursor/...` on Linux, `%APPDATA%/Cursor/...` on
//! Windows), parsed by `sources/cursor/db.rs::get_session_meta` from
//! the `cursorDiskKV` key-value table with `composerData:<sid>` and
//! `bubbleId:<composerId>:<bubbleId>` rows.
//! - Faking that DB inside a subprocess test is brittle: it requires
//! replicating Cursor's exact JSON-in-SQLite schema, OS-specific path
//! resolution, and the `headers_only` + per-bubble fan-out. Drift on
//! their side silently makes our test green for the wrong reason.
//!
//! The pragmatic fallback that lives below exercises:
//! - The shared `common` harness (HOME + cwd isolation).
//! - `projects.json` seeding (no `pcr init` needed — register direct).
//! - `pcr --json log` resolving the cwd to the registered project.
//!
//! TODO(repair/follow-up): if we ever need true Cursor ingestion
//! coverage, the right shape is a `crates/pcr-core/tests/`-style test
//! that calls `sources/cursor/watcher.rs::PromptScanner` directly with
//! a hand-built `state.vscdb` fixture, NOT a subprocess test through
//! `pcr bundle`.

mod common;

use common::{home_fixture, pcr_in};

fn seed_projects_json(fx: &common::HomeFixture, name: &str) {
// Match the on-disk shape `crates/pcr-core/src/projects.rs::Registry`
// serializes: `{ "projects": [ { path, cursorSlug, claudeSlug, name,
// registeredAt } ] }`. Keep the camelCase keys — the registry is
// shared with the (archived) Go build's `projects.json`.
//
// Canonicalize the cwd path: `tempfile::TempDir` on macOS hands back
// `/var/folders/...` but `pcr`'s `std::env::current_dir()` resolves
// through the `/private` symlink and reports `/private/var/...`.
// `project_context::resolve` does a literal string compare against
// `projects.json::path`, so we must write the post-symlink form.
let path = std::fs::canonicalize(fx.cwd_path())
.expect("canonicalize cwd")
.to_string_lossy()
.into_owned();
let cursor_slug = path.trim_start_matches('/').replace(['/', '.'], "-");
let claude_slug = path.replace('/', "-");
let registry = serde_json::json!({
"projects": [{
"path": path,
"cursorSlug": cursor_slug,
"claudeSlug": claude_slug,
"name": name,
"registeredAt": "2026-01-01T00:00:00Z",
}]
});
let projects_path = fx.pcr_dir().join("projects.json");
std::fs::write(
&projects_path,
serde_json::to_vec_pretty(&registry).unwrap(),
)
.expect("write projects.json");
}

#[test]
fn registered_project_log_returns_named_empty_store() {
let fx = home_fixture();
seed_projects_json(&fx, "test-proj");

let out = pcr_in(&fx)
.args(["--json", "log"])
.assert()
.success()
.get_output()
.clone();
let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
let s = String::from_utf8_lossy(&out.stdout);
panic!("expected JSON, got {s:?}: {e}")
});

assert_eq!(parsed["project_name"], "test-proj");
for key in ["pushed", "unpushed", "drafts"] {
let arr = parsed[key]
.as_array()
.unwrap_or_else(|| panic!("{key} not an array: {parsed}"));
assert!(arr.is_empty(), "{key} expected empty, got {arr:?}");
}
}

#[test]
fn registered_project_log_is_idempotent_across_runs() {
let fx = home_fixture();
seed_projects_json(&fx, "idem-proj");

let first = pcr_in(&fx)
.args(["--json", "log"])
.assert()
.success()
.get_output()
.clone()
.stdout;
let second = pcr_in(&fx)
.args(["--json", "log"])
.assert()
.success()
.get_output()
.clone()
.stdout;

// Byte-identical (or at least the parsed JSON matches). Use parsed
// comparison since the json! pretty-printer may shift trailing
// whitespace between runs.
let a: serde_json::Value = serde_json::from_slice(&first).unwrap();
let b: serde_json::Value = serde_json::from_slice(&second).unwrap();
assert_eq!(a, b, "log JSON should be stable across runs");
assert_eq!(a["project_name"], "idem-proj");
}
Loading
Loading