From b195364b46ab488eb4422a0000754c489a95fe9f Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 22 Aug 2026 16:42:22 -0500 Subject: [PATCH 1/2] fix(ci): restore fmt and clippy gates under current stable `cargo fmt --all -- --check` and `cargo clippy -D warnings` both failed on main under rustc 1.97.1. No source change caused this: `useless_concat` and `unneeded_struct_pattern` are recent clippy lints, and CI pins nothing. Three clippy fixes, all in test code and all semantics-preserving: - codex.rs: two single-argument `concat!(..)` calls collapsed to plain string literals (clippy::useless_concat). - ingest_file.rs: `IngestOutcome::Skipped { .. }` -> `IngestOutcome::Skipped`; it is a unit variant (clippy::unneeded_struct_pattern). Then `cargo fmt --all`, which rewrapped 18 over-width assertion lines across the two adapters, adapters/common.rs, and four integration tests. Mechanical line-wrapping at the 100-column limit only. No production code touched. Verified green: fmt, clippy (lore-core/lore-ipc and lore-app), cargo test --locked --all-targets (346 pass / 4 ignored), no_network_in_archive, no_egress, egress-check.sh, IPC bindings in sync, npm lint/typecheck/test (148 pass). Follow-up: pinning the toolchain so this cannot recur. --- crates/lore-core/src/adapters/claude_code.rs | 10 ++++- crates/lore-core/src/adapters/codex.rs | 46 ++++++++++++++------ crates/lore-core/src/adapters/common.rs | 18 ++++++-- crates/lore-core/tests/enrich.rs | 4 +- crates/lore-core/tests/git_capture.rs | 7 ++- crates/lore-core/tests/ingest_file.rs | 28 +++++++++--- crates/lore-core/tests/recovery.rs | 15 +++++-- 7 files changed, 97 insertions(+), 31 deletions(-) diff --git a/crates/lore-core/src/adapters/claude_code.rs b/crates/lore-core/src/adapters/claude_code.rs index 170c262..7c9af8b 100644 --- a/crates/lore-core/src/adapters/claude_code.rs +++ b/crates/lore-core/src/adapters/claude_code.rs @@ -918,7 +918,10 @@ mod tests { assert_eq!(session.status, crate::model::ParseStatus::Ok); assert_eq!(session.tool_calls.len(), 3); assert_eq!(session.file_events.len(), 1); - assert_eq!(session.file_events[0].tool_native_call_id.as_deref(), Some("c_empty")); + assert_eq!( + session.file_events[0].tool_native_call_id.as_deref(), + Some("c_empty") + ); assert_eq!(session.file_events[0].path, ""); } @@ -953,7 +956,10 @@ mod tests { ); let session = ClaudeCodeAdapter::new().parse_str(jsonl, "null-id-tools"); assert_eq!(session.status, crate::model::ParseStatus::Partial); - assert!(session.notes.iter().any(|n| n.message == "tool_use without id")); + assert!(session + .notes + .iter() + .any(|n| n.message == "tool_use without id")); } #[test] diff --git a/crates/lore-core/src/adapters/codex.rs b/crates/lore-core/src/adapters/codex.rs index be75478..c0b3285 100644 --- a/crates/lore-core/src/adapters/codex.rs +++ b/crates/lore-core/src/adapters/codex.rs @@ -1183,7 +1183,11 @@ mod tests { ); let s = CodexAdapter::new().parse_str(content, "unknown-item"); assert_eq!(s.status, crate::model::ParseStatus::Partial); - assert_eq!(s.messages.len(), 1, "known message before unknown item is preserved"); + assert_eq!( + s.messages.len(), + 1, + "known message before unknown item is preserved" + ); } #[test] @@ -1196,11 +1200,19 @@ mod tests { assert_eq!(s.status, crate::model::ParseStatus::Ok); assert_eq!(s.file_events.len(), 2); - let empty_event = s.file_events.iter().find(|e| e.path == "src/empty.txt").unwrap(); + let empty_event = s + .file_events + .iter() + .find(|e| e.path == "src/empty.txt") + .unwrap(); assert_eq!(empty_event.change_kind, FileChangeKind::Create); assert_eq!(empty_event.patch_text.as_deref(), Some("")); - let null_event = s.file_events.iter().find(|e| e.path == "src/null.txt").unwrap(); + let null_event = s + .file_events + .iter() + .find(|e| e.path == "src/null.txt") + .unwrap(); assert_eq!(null_event.change_kind, FileChangeKind::Create); assert_eq!(null_event.patch_text, None); } @@ -1218,7 +1230,10 @@ mod tests { assert_eq!(s.messages[0].role, Role::System); assert_eq!(s.messages[0].parts.len(), 1); - assert_eq!(s.messages[0].parts[0].text.as_deref(), Some("system prompt")); + assert_eq!( + s.messages[0].parts[0].text.as_deref(), + Some("system prompt") + ); assert_eq!(s.messages[1].role, Role::User); assert_eq!(s.messages[1].parts.len(), 2); @@ -1227,7 +1242,10 @@ mod tests { assert_eq!(s.messages[2].role, Role::User); assert_eq!(s.messages[2].parts.len(), 1); - assert_eq!(s.messages[2].parts[0].text.as_deref(), Some("fallback to user")); + assert_eq!( + s.messages[2].parts[0].text.as_deref(), + Some("fallback to user") + ); } #[test] @@ -1245,17 +1263,13 @@ mod tests { #[test] fn title_derivation_from_multipart_user_prompt_and_synthetic_flag() { - let content = concat!( - "{\"type\":\"response_item\",\"timestamp\":\"2026-08-11T10:00:00.000Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"text\":\"Refactor session store\"}]}}\n" - ); + let content = "{\"type\":\"response_item\",\"timestamp\":\"2026-08-11T10:00:00.000Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"text\":\"Refactor session store\"}]}}\n"; let s = CodexAdapter::new().parse_str(content, "multipart-title"); assert_eq!(s.status, crate::model::ParseStatus::Ok); assert_eq!(s.title.as_deref(), Some("Refactor session store")); assert!(s.title_is_synthetic); - let system_only = concat!( - "{\"type\":\"response_item\",\"timestamp\":\"2026-08-11T10:00:00.000Z\",\"payload\":{\"type\":\"message\",\"role\":\"system\",\"content\":\"system prompt\"}}\n" - ); + let system_only = "{\"type\":\"response_item\",\"timestamp\":\"2026-08-11T10:00:00.000Z\",\"payload\":{\"type\":\"message\",\"role\":\"system\",\"content\":\"system prompt\"}}\n"; let s2 = CodexAdapter::new().parse_str(system_only, "system-only"); assert_eq!(s2.title, None); assert!(!s2.title_is_synthetic); @@ -1302,7 +1316,10 @@ mod tests { assert_eq!(s.messages.len(), 1); assert_eq!(s.messages[0].parts.len(), 2); assert_eq!(s.messages[0].parts[0].kind, PartKind::Thinking); - assert_eq!(s.messages[0].parts[0].text.as_deref(), Some("Planning steps")); + assert_eq!( + s.messages[0].parts[0].text.as_deref(), + Some("Planning steps") + ); assert_eq!(s.messages[0].parts[1].kind, PartKind::Opaque); } @@ -1335,6 +1352,9 @@ mod tests { let content = "{\"type\":\"response_item\",\"timestamp\":\"2026-08-11T10:00:00.000Z\",\"payload\":{\"type\":\"\"}}\n"; let s = CodexAdapter::new().parse_str(content, "empty-type"); assert_eq!(s.status, crate::model::ParseStatus::Partial); - assert!(s.notes.iter().any(|n| n.message.contains("unknown response_item"))); + assert!(s + .notes + .iter() + .any(|n| n.message.contains("unknown response_item"))); } } diff --git a/crates/lore-core/src/adapters/common.rs b/crates/lore-core/src/adapters/common.rs index 10e4200..9209973 100644 --- a/crates/lore-core/src/adapters/common.rs +++ b/crates/lore-core/src/adapters/common.rs @@ -249,7 +249,8 @@ mod tests { assert_eq!(unified_diff_line_counts(context_only), Some((0, 0))); // Git headers only diff (0 additions, 0 deletions) - let headers_only = "diff --git a/f.rs b/f.rs\nindex 0000000..1234567\n--- a/f.rs\n+++ b/f.rs\n"; + let headers_only = + "diff --git a/f.rs b/f.rs\nindex 0000000..1234567\n--- a/f.rs\n+++ b/f.rs\n"; assert_eq!(unified_diff_line_counts(headers_only), Some((0, 0))); // Hunk headers only (0 additions, 0 deletions) @@ -270,7 +271,10 @@ mod tests { Some("Fix repository discovery".to_string()) ); assert_eq!(title_from_text("\n"), None); - assert_eq!(title_from_text("\nOS: Mac\nCwd: /repo\n"), None); + assert_eq!( + title_from_text("\nOS: Mac\nCwd: /repo\n"), + None + ); assert_eq!(title_from_text("\nName: rust-dev\n"), None); assert_eq!( title_from_text("repository info\nRefactor SQLite queries"), @@ -441,11 +445,17 @@ mod tests { "Users/test/file.txt" ); assert_eq!(sanitize_path("./src/./main.rs"), "src/main.rs"); - assert_eq!(sanitize_path("/absolute/path/file.rs"), "absolute/path/file.rs"); + assert_eq!( + sanitize_path("/absolute/path/file.rs"), + "absolute/path/file.rs" + ); assert_eq!(sanitize_path("a/b/c/../../d.rs"), "a/d.rs"); assert_eq!(sanitize_path("src/app/"), "src/app"); assert_eq!(sanitize_path(r"src\app\"), "src/app"); - assert_eq!(sanitize_path(r"\\server\share\file.rs"), "server/share/file.rs"); + assert_eq!( + sanitize_path(r"\\server\share\file.rs"), + "server/share/file.rs" + ); assert_eq!(sanitize_path(".../src/lib.rs"), ".../src/lib.rs"); assert_eq!(sanitize_path("..../src/lib.rs"), "..../src/lib.rs"); assert_eq!(sanitize_path("a///b///c.rs"), "a/b/c.rs"); diff --git a/crates/lore-core/tests/enrich.rs b/crates/lore-core/tests/enrich.rs index 1dfd093..7453fd3 100644 --- a/crates/lore-core/tests/enrich.rs +++ b/crates/lore-core/tests/enrich.rs @@ -442,7 +442,9 @@ fn multi_segment_session_in_same_repo_resolves_to_single_repository() { // Both segments must link to the EXACT same repository row. let repo_ids: Vec = conn - .prepare("SELECT repository_id FROM session_segment WHERE session_id = ?1 ORDER BY seq_start") + .prepare( + "SELECT repository_id FROM session_segment WHERE session_id = ?1 ORDER BY seq_start", + ) .unwrap() .query_map([&sid], |r| r.get(0)) .unwrap() diff --git a/crates/lore-core/tests/git_capture.rs b/crates/lore-core/tests/git_capture.rs index 6feebbb..3d60d64 100644 --- a/crates/lore-core/tests/git_capture.rs +++ b/crates/lore-core/tests/git_capture.rs @@ -225,7 +225,12 @@ fn capture_handles_multiple_remotes_and_commits() { // Add multiple remotes with credentials git( dir.path(), - &["remote", "add", "upstream", "https://token@gitlab.com/group/proj.git"], + &[ + "remote", + "add", + "upstream", + "https://token@gitlab.com/group/proj.git", + ], ); git( dir.path(), diff --git a/crates/lore-core/tests/ingest_file.rs b/crates/lore-core/tests/ingest_file.rs index 7baebc9..1046b84 100644 --- a/crates/lore-core/tests/ingest_file.rs +++ b/crates/lore-core/tests/ingest_file.rs @@ -337,18 +337,32 @@ fn identical_reingest_of_multi_segment_session_is_a_noop() { fs::write(&path, content).unwrap(); let outcome1 = ingest(&conn, &path); - assert!(matches!(outcome1, IngestOutcome::Ingested { change: ChangeClass::New, .. })); - - let segments1: i64 = conn.query_row("SELECT count(*) FROM session_segment", [], |r| r.get(0)).unwrap(); - let messages1: i64 = conn.query_row("SELECT count(*) FROM message", [], |r| r.get(0)).unwrap(); + assert!(matches!( + outcome1, + IngestOutcome::Ingested { + change: ChangeClass::New, + .. + } + )); + + let segments1: i64 = conn + .query_row("SELECT count(*) FROM session_segment", [], |r| r.get(0)) + .unwrap(); + let messages1: i64 = conn + .query_row("SELECT count(*) FROM message", [], |r| r.get(0)) + .unwrap(); assert_eq!(segments1, 2); assert_eq!(messages1, 2); let outcome2 = ingest(&conn, &path); - assert!(matches!(outcome2, IngestOutcome::Skipped { .. })); + assert!(matches!(outcome2, IngestOutcome::Skipped)); - let segments2: i64 = conn.query_row("SELECT count(*) FROM session_segment", [], |r| r.get(0)).unwrap(); - let messages2: i64 = conn.query_row("SELECT count(*) FROM message", [], |r| r.get(0)).unwrap(); + let segments2: i64 = conn + .query_row("SELECT count(*) FROM session_segment", [], |r| r.get(0)) + .unwrap(); + let messages2: i64 = conn + .query_row("SELECT count(*) FROM message", [], |r| r.get(0)) + .unwrap(); assert_eq!(segments1, segments2); assert_eq!(messages1, messages2); } diff --git a/crates/lore-core/tests/recovery.rs b/crates/lore-core/tests/recovery.rs index 4dbe736..b8769fc 100644 --- a/crates/lore-core/tests/recovery.rs +++ b/crates/lore-core/tests/recovery.rs @@ -250,9 +250,18 @@ fn recover_archive_quarantines_all_sidecar_files_including_wal_shm_journal() { let q_dir = quarantine_path.parent().unwrap(); assert_eq!(std::fs::read(&quarantine_path).unwrap(), b"corrupted db"); - assert_eq!(std::fs::read(q_dir.join(format!("{q_stem}-wal"))).unwrap(), b"wal data"); - assert_eq!(std::fs::read(q_dir.join(format!("{q_stem}-shm"))).unwrap(), b"shm data"); - assert_eq!(std::fs::read(q_dir.join(format!("{q_stem}-journal"))).unwrap(), b"journal data"); + assert_eq!( + std::fs::read(q_dir.join(format!("{q_stem}-wal"))).unwrap(), + b"wal data" + ); + assert_eq!( + std::fs::read(q_dir.join(format!("{q_stem}-shm"))).unwrap(), + b"shm data" + ); + assert_eq!( + std::fs::read(q_dir.join(format!("{q_stem}-journal"))).unwrap(), + b"journal data" + ); // All original files in archive_dir must be cleanly moved assert!(!db.exists()); From 26e9b060bbc72a4714f322de0452cd3d82edd554 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 22 Aug 2026 16:44:59 -0500 Subject: [PATCH 2/2] build(ci): pin the Rust toolchain and guard against pin drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI ran `dtolnay/rust-toolchain@stable` with `cargo clippy -- -D warnings`, so any new lint in a stable release broke the build on a commit that changed nothing. That is not hypothetical: it is exactly how main went red under 1.97.1 (previous commit). Pinning turns a toolchain upgrade into an explicit, reviewable change. - rust-toolchain.toml pins 1.97.1 with rustfmt/clippy and the minimal profile. rustup honors it for every cargo invocation, locally and in CI. - Both workflows now reference dtolnay/rust-toolchain@1.97.1 rather than @stable, so the runner pre-installs the same compiler rustup would fetch (the action does not read rust-toolchain.toml — verified against its README; @1.97.1 is a valid ref). - scripts/toolchain-pin-check.sh fails if the toml channel, the workflow action refs, and the active rustc are not all the same version. Wired in as the first step of the rust-core job, ahead of fmt/clippy. Verified it passes when aligned and fails when the toml and workflows disagree. This is not the MSRV. `rust-version = "1.90"` in Cargo.toml still declares the minimum supported compiler; this pins the one we build and lint with. Verified: pin check, fmt, clippy (both invocations), cargo test --locked --all-targets (346 pass / 4 ignored), both workflow files parse as YAML. --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/release.yml | 2 +- README.md | 2 ++ rust-toolchain.toml | 18 ++++++++++++++++++ scripts/toolchain-pin-check.sh | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 rust-toolchain.toml create mode 100755 scripts/toolchain-pin-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2a62b9..49da71c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 # pinned; keep in sync with rust-toolchain.toml with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 + - name: Toolchain pin is consistent + run: ./scripts/toolchain-pin-check.sh - name: Format run: cargo fmt --all -- --check - name: Clippy (deny warnings) @@ -57,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 # pinned; keep in sync with rust-toolchain.toml with: components: clippy - uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3f9d68..82f854a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 # pinned; keep in sync with rust-toolchain.toml - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v4 with: diff --git a/README.md b/README.md index 5e637b8..a8c2244 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Everything in the archive stays on the machine. V0 has no accounts, telemetry, c You will need Rust, Node.js, the Xcode command-line tools, and the Tauri 2 prerequisites for macOS. +The Rust toolchain is pinned in `rust-toolchain.toml`, so rustup selects the right compiler automatically. CI lints with `-D warnings`, and pinning keeps a new clippy lint in a stable release from breaking the build on an unrelated commit. + ```bash npm install npm run build diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..c3677e4 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,18 @@ +# Pinned Rust toolchain — the single source of truth for local development. +# +# Why pin: CI runs `cargo clippy -- -D warnings`, so every new clippy lint in a +# stable release turns into a build failure on a commit that changed nothing. +# That is exactly how `main` broke on rustc 1.97.1 (`useless_concat` and +# `unneeded_struct_pattern` were both new). Pinning makes a toolchain upgrade an +# explicit, reviewable change instead of an ambient one. +# +# To upgrade: bump `channel` here AND the `dtolnay/rust-toolchain@` refs +# in .github/workflows/{ci,release}.yml. CI's "toolchain pin is consistent" step +# fails if the two ever disagree. +# +# Note this is NOT the MSRV. `rust-version` in Cargo.toml (1.90) is the minimum +# supported compiler; this is the exact one we build and lint with. +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/scripts/toolchain-pin-check.sh b/scripts/toolchain-pin-check.sh new file mode 100755 index 0000000..440129f --- /dev/null +++ b/scripts/toolchain-pin-check.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Toolchain pin consistency guard. +# +# The Rust version is pinned in two places that cannot reference each other: +# 1. rust-toolchain.toml — what rustup uses locally and in CI. +# 2. dtolnay/rust-toolchain@ refs in .github/workflows/*.yml — what +# the CI runner pre-installs (the action does not read the toml file). +# +# If those drift, CI silently pre-installs one compiler while rustup builds with +# another. This fails the build instead. Also verifies the active rustc actually +# matches, so a stale local override is caught too. +set -euo pipefail +cd "$(dirname "$0")/.." + +fail() { echo "FAIL: $*" >&2; exit 1; } + +pinned="$(sed -n 's/^channel[[:space:]]*=[[:space:]]*"\(.*\)"/\1/p' rust-toolchain.toml)" +[ -n "$pinned" ] || fail "could not read [toolchain] channel from rust-toolchain.toml" +echo "rust-toolchain.toml pins: $pinned" + +# Every workflow reference to the action must name the same version. +refs="$(grep -rhoE 'dtolnay/rust-toolchain@[^ ]+' .github/workflows/ | sed 's|.*@||' | sort -u)" +[ -n "$refs" ] || fail "no dtolnay/rust-toolchain reference found in .github/workflows/" +while read -r ref; do + [ "$ref" = "$pinned" ] || fail "workflow uses dtolnay/rust-toolchain@$ref but rust-toolchain.toml pins $pinned" +done <<< "$refs" +echo "ok: all workflow action refs are @$pinned" + +# And the compiler actually in use must be that version. +active="$(rustc --version | awk '{print $2}')" +[ "$active" = "$pinned" ] || fail "active rustc is $active but rust-toolchain.toml pins $pinned" +echo "ok: active rustc is $active"