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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions crates/lore-core/src/adapters/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
}

Expand Down Expand Up @@ -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]
Expand Down
46 changes: 33 additions & 13 deletions crates/lore-core/src/adapters/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}
Expand All @@ -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);
Expand All @@ -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]
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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")));
}
}
18 changes: 14 additions & 4 deletions crates/lore-core/src/adapters/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -270,7 +271,10 @@ mod tests {
Some("Fix repository discovery".to_string())
);
assert_eq!(title_from_text("<empty_tag>\n</empty_tag>"), None);
assert_eq!(title_from_text("<environment_context>\nOS: Mac\nCwd: /repo\n"), None);
assert_eq!(
title_from_text("<environment_context>\nOS: Mac\nCwd: /repo\n"),
None
);
assert_eq!(title_from_text("<skill>\nName: rust-dev\n"), None);
assert_eq!(
title_from_text("<context>repository info</context>\nRefactor SQLite queries"),
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 3 additions & 1 deletion crates/lore-core/tests/enrich.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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()
Expand Down
7 changes: 6 additions & 1 deletion crates/lore-core/tests/git_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
28 changes: 21 additions & 7 deletions crates/lore-core/tests/ingest_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
15 changes: 12 additions & 3 deletions crates/lore-core/tests/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
18 changes: 18 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -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@<version>` 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"
32 changes: 32 additions & 0 deletions scripts/toolchain-pin-check.sh
Original file line number Diff line number Diff line change
@@ -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@<version> 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"
Loading