chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "jcode-import-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
hex = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,652 @@
|
||||
//! Rank the repositories a user works in most, from the working directories
|
||||
//! recorded across their agent sessions (jcode + imported Claude/Codex/etc.).
|
||||
//!
|
||||
//! The motivating use case is one-time keybinding setup: during auto-import we
|
||||
//! want to guess a user's top project directories so we can offer to bind global
|
||||
//! launch hotkeys (e.g. `Cmd+[`, `Cmd+]`) to "open jcode here". The ranking is a
|
||||
//! pure function over `(working_dir, last_used)` observations so it can be unit
|
||||
//! tested without touching the filesystem, and a thin [`resolve_git_root`]
|
||||
//! helper folds subdirectories into their repository root.
|
||||
//!
|
||||
//! Ranking weights raw frequency by recency: a repo touched heavily last week
|
||||
//! beats one touched a year ago. The score uses an exponential recency decay so
|
||||
//! the result reflects where the user *currently* works, not their lifetime
|
||||
//! history.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
/// One observed session location: the working directory it ran in, and when it
|
||||
/// was last active. `last_used` is optional because some imported sources lack a
|
||||
/// reliable timestamp; those sessions still contribute frequency, just with the
|
||||
/// most-decayed recency weight.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionLocation {
|
||||
pub working_dir: String,
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl SessionLocation {
|
||||
pub fn new(working_dir: impl Into<String>, last_used: Option<DateTime<Utc>>) -> Self {
|
||||
Self {
|
||||
working_dir: working_dir.into(),
|
||||
last_used,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ranked repository candidate produced by [`rank_repositories`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RankedRepo {
|
||||
/// Absolute path to the repository root (or the raw working dir when no git
|
||||
/// root could be resolved).
|
||||
pub path: String,
|
||||
/// Number of sessions that mapped to this repo (after folding subdirs).
|
||||
pub session_count: usize,
|
||||
/// Recency-weighted score used for ordering. Higher is "more active".
|
||||
pub score: f64,
|
||||
/// Most recent session timestamp seen for this repo, if any.
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Tunables for [`rank_repositories`]. Defaults are chosen for the keybinding
|
||||
/// use case (a handful of top repos, recency-biased).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RankOptions {
|
||||
/// Recency half-life: a session this many days old contributes half the
|
||||
/// weight of a brand-new one. Sessions without a timestamp are treated as
|
||||
/// `floor_weight`.
|
||||
pub half_life_days: f64,
|
||||
/// Minimum recency weight for a session (also used for timestamp-less
|
||||
/// sessions) so old-but-frequent repos still register.
|
||||
pub floor_weight: f64,
|
||||
/// Paths to exclude entirely (exact match after normalization). Typically
|
||||
/// the user's home directory, which is noise from launching jcode at `$HOME`.
|
||||
pub excluded_paths: Vec<PathBuf>,
|
||||
/// When true, only keep candidates whose resolved path is an actual git
|
||||
/// root (i.e. [`resolve_git_root`] found a `.git`). Raw, non-repo working
|
||||
/// dirs are dropped. The resolver is injected via `rank_repositories_with`.
|
||||
pub require_git_root: bool,
|
||||
}
|
||||
|
||||
impl Default for RankOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
half_life_days: 21.0,
|
||||
floor_weight: 0.05,
|
||||
excluded_paths: Vec::new(),
|
||||
require_git_root: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `dir` to the root of the git repository that contains it by walking
|
||||
/// upward until a `.git` entry is found. Returns `None` if no ancestor contains
|
||||
/// `.git` (the directory is not inside a repository).
|
||||
///
|
||||
/// This touches the filesystem; the ranking core takes the resolver as a
|
||||
/// parameter so it stays pure and testable.
|
||||
pub fn resolve_git_root(dir: &Path) -> Option<PathBuf> {
|
||||
let mut cur = Some(dir);
|
||||
while let Some(p) = cur {
|
||||
if p.join(".git").exists() {
|
||||
return Some(p.to_path_buf());
|
||||
}
|
||||
cur = p.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Recency weight for a single session given the reference "now".
|
||||
fn recency_weight(last_used: Option<DateTime<Utc>>, now: DateTime<Utc>, opts: &RankOptions) -> f64 {
|
||||
let Some(ts) = last_used else {
|
||||
return opts.floor_weight;
|
||||
};
|
||||
let age_days = (now - ts).num_seconds().max(0) as f64 / 86_400.0;
|
||||
// Exponential decay: weight = 0.5 ^ (age / half_life).
|
||||
let decayed = 0.5_f64.powf(age_days / opts.half_life_days.max(0.000_1));
|
||||
decayed.max(opts.floor_weight)
|
||||
}
|
||||
|
||||
/// Rank repositories from session locations, resolving each working dir to its
|
||||
/// repository root via `resolve_root`. `now` anchors the recency decay (pass
|
||||
/// `Utc::now()` in production; a fixed value in tests).
|
||||
///
|
||||
/// `resolve_root` returns the repo root for a working dir, or `None` when the
|
||||
/// dir is not inside a repository. When `opts.require_git_root` is set, those
|
||||
/// `None` results are dropped; otherwise the raw working dir is used as its own
|
||||
/// "repo" so non-git project folders still rank.
|
||||
pub fn rank_repositories_with<F>(
|
||||
locations: &[SessionLocation],
|
||||
now: DateTime<Utc>,
|
||||
opts: &RankOptions,
|
||||
mut resolve_root: F,
|
||||
) -> Vec<RankedRepo>
|
||||
where
|
||||
F: FnMut(&Path) -> Option<PathBuf>,
|
||||
{
|
||||
struct Acc {
|
||||
count: usize,
|
||||
score: f64,
|
||||
last_used: Option<DateTime<Utc>>,
|
||||
}
|
||||
let excluded: Vec<PathBuf> = opts
|
||||
.excluded_paths
|
||||
.iter()
|
||||
.map(|p| normalize_path(p))
|
||||
.collect();
|
||||
|
||||
// Cache resolver results per distinct working dir so we do not re-stat the
|
||||
// same directory once per session.
|
||||
let mut resolved_cache: HashMap<String, Option<PathBuf>> = HashMap::new();
|
||||
let mut acc: HashMap<PathBuf, Acc> = HashMap::new();
|
||||
|
||||
for loc in locations {
|
||||
let wd = loc.working_dir.trim();
|
||||
if wd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let raw = PathBuf::from(wd);
|
||||
let root = resolved_cache
|
||||
.entry(wd.to_string())
|
||||
.or_insert_with(|| resolve_root(&raw))
|
||||
.clone();
|
||||
let repo = match root {
|
||||
Some(r) => normalize_path(&r),
|
||||
None => {
|
||||
if opts.require_git_root {
|
||||
continue;
|
||||
}
|
||||
normalize_path(&raw)
|
||||
}
|
||||
};
|
||||
if excluded.iter().any(|e| e == &repo) {
|
||||
continue;
|
||||
}
|
||||
let weight = recency_weight(loc.last_used, now, opts);
|
||||
let entry = acc.entry(repo).or_insert(Acc {
|
||||
count: 0,
|
||||
score: 0.0,
|
||||
last_used: None,
|
||||
});
|
||||
entry.count += 1;
|
||||
entry.score += weight;
|
||||
entry.last_used = match (entry.last_used, loc.last_used) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, b) => b,
|
||||
};
|
||||
}
|
||||
|
||||
let mut ranked: Vec<RankedRepo> = acc
|
||||
.into_iter()
|
||||
.map(|(path, a)| RankedRepo {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
session_count: a.count,
|
||||
score: a.score,
|
||||
last_used: a.last_used,
|
||||
})
|
||||
.collect();
|
||||
|
||||
ranked.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
// Tie-break: more sessions, then most recently used, then path for
|
||||
// deterministic ordering.
|
||||
.then(b.session_count.cmp(&a.session_count))
|
||||
.then(b.last_used.cmp(&a.last_used))
|
||||
.then(a.path.cmp(&b.path))
|
||||
});
|
||||
ranked
|
||||
}
|
||||
|
||||
/// Convenience wrapper that resolves git roots from the real filesystem via
|
||||
/// [`resolve_git_root`]. Subdirectories of the same repo are folded together.
|
||||
pub fn rank_repositories(
|
||||
locations: &[SessionLocation],
|
||||
now: DateTime<Utc>,
|
||||
opts: &RankOptions,
|
||||
) -> Vec<RankedRepo> {
|
||||
rank_repositories_with(locations, now, opts, resolve_git_root)
|
||||
}
|
||||
|
||||
/// Normalize a path for comparison: strip a single trailing slash and collapse
|
||||
/// the macOS `/private` symlink prefix that `std::env::current_dir` sometimes
|
||||
/// reports for `/tmp` and `/var`, so the same repo seen under both spellings
|
||||
/// folds together.
|
||||
fn normalize_path(p: &Path) -> PathBuf {
|
||||
let s = p.to_string_lossy();
|
||||
let trimmed = s.trim_end_matches('/');
|
||||
let trimmed = if trimmed.is_empty() { "/" } else { trimmed };
|
||||
if let Some(rest) = trimmed.strip_prefix("/private/") {
|
||||
PathBuf::from(format!("/{rest}"))
|
||||
} else {
|
||||
PathBuf::from(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for callers that have a [`Duration`]-based half-life preference.
|
||||
pub fn half_life_from_duration(d: Duration) -> f64 {
|
||||
d.num_seconds() as f64 / 86_400.0
|
||||
}
|
||||
|
||||
/// A planned global launch hotkey: a chord plus the directory it should open
|
||||
/// jcode in. Produced by [`build_launch_hotkey_plan`] from a ranking, then
|
||||
/// persisted to config so the mapping is baked once and does not move around.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PlannedHotkey {
|
||||
/// jcode-style chord string, e.g. `cmd+;` or `cmd+[`.
|
||||
pub chord: String,
|
||||
/// Absolute directory the hotkey opens jcode in.
|
||||
pub dir: String,
|
||||
/// Short human label (usually the repo's directory name) for notices.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// The default chord assigned to each launch-hotkey slot, in slot order.
|
||||
///
|
||||
/// Slot meaning (see [`build_launch_hotkey_plan`]):
|
||||
/// 0 = top repo, 1 = home, 2 = repo #2, 3 = repo #3, 4 = repo #4.
|
||||
pub const DEFAULT_LAUNCH_HOTKEY_CHORDS: [&str; 5] = ["cmd+;", "cmd+'", "cmd+[", "cmd+]", "cmd+\\"];
|
||||
|
||||
/// Build the default launch-hotkey plan from a ranking and the user's home dir.
|
||||
///
|
||||
/// The layout follows the product spec: the most-active repo gets `Cmd+;`, home
|
||||
/// gets `Cmd+'`, and the next three repos get `Cmd+[`, `Cmd+]`, `Cmd+\`. `home`
|
||||
/// is always slot 1 even if it also appears in the ranking, and it is skipped
|
||||
/// from the repo slots so we never bind two chords to the same directory.
|
||||
///
|
||||
/// `chords` lets callers override the default chord sequence (e.g. from config);
|
||||
/// pass [`DEFAULT_LAUNCH_HOTKEY_CHORDS`] for the standard layout. Only as many
|
||||
/// hotkeys as there are available chords and repos are produced.
|
||||
pub fn build_launch_hotkey_plan(
|
||||
home: &Path,
|
||||
ranked: &[RankedRepo],
|
||||
chords: &[&str],
|
||||
) -> Vec<PlannedHotkey> {
|
||||
let home_norm = normalize_path(home);
|
||||
// Top repos, excluding home itself, in rank order.
|
||||
let repos: Vec<&RankedRepo> = ranked
|
||||
.iter()
|
||||
.filter(|r| normalize_path(Path::new(&r.path)) != home_norm)
|
||||
.collect();
|
||||
|
||||
// Slot order interleaves the top repo, then home, then the remaining repos.
|
||||
// dirs[i] is the directory for chord slot i.
|
||||
let mut dirs: Vec<(String, String)> = Vec::new();
|
||||
if let Some(top) = repos.first() {
|
||||
dirs.push((top.path.clone(), dir_label(&top.path)));
|
||||
}
|
||||
dirs.push((home.to_string_lossy().into_owned(), "home".to_string()));
|
||||
for repo in repos.iter().skip(1) {
|
||||
dirs.push((repo.path.clone(), dir_label(&repo.path)));
|
||||
}
|
||||
|
||||
chords
|
||||
.iter()
|
||||
.zip(dirs)
|
||||
.map(|(chord, (dir, label))| PlannedHotkey {
|
||||
chord: (*chord).to_string(),
|
||||
dir,
|
||||
label,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Final path component as a short label, falling back to the full path.
|
||||
fn dir_label(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// Scan a jcode sessions directory and extract one [`SessionLocation`] per
|
||||
/// session file that records a `working_dir`.
|
||||
///
|
||||
/// This is deliberately lightweight: it reads each `*.json` (skipping `.bak`
|
||||
/// siblings), pulls just the `working_dir` string and uses the file's mtime as
|
||||
/// the recency timestamp, and skips anything it cannot parse. Returns an empty
|
||||
/// vec if the directory is missing. The function is filesystem-facing but kept
|
||||
/// here so callers get session collection + ranking from one module.
|
||||
pub fn collect_jcode_session_locations(sessions_dir: &Path) -> Vec<SessionLocation> {
|
||||
use std::fs;
|
||||
|
||||
let Ok(entries) = fs::read_dir(sessions_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
continue;
|
||||
};
|
||||
let Some(wd) = value.get("working_dir").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if wd.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let last_used = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0));
|
||||
out.push(SessionLocation::new(wd, last_used));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the baked launch-hotkey plan from a jcode sessions directory.
|
||||
///
|
||||
/// Convenience wrapper that scans `sessions_dir`, ranks the repos (excluding
|
||||
/// `home`, requiring real git roots), and assigns the default chord layout. Pass
|
||||
/// `now` for the recency anchor. Returns the planned chord -> directory entries
|
||||
/// (empty if there is nothing to bind beyond home, which still gets `Cmd+;`).
|
||||
pub fn plan_launch_hotkeys_from_sessions(
|
||||
sessions_dir: &Path,
|
||||
home: &Path,
|
||||
now: DateTime<Utc>,
|
||||
) -> Vec<PlannedHotkey> {
|
||||
let locations = collect_jcode_session_locations(sessions_dir);
|
||||
let opts = RankOptions {
|
||||
excluded_paths: vec![home.to_path_buf()],
|
||||
..RankOptions::default()
|
||||
};
|
||||
let ranked = rank_repositories(&locations, now, &opts);
|
||||
build_launch_hotkey_plan(home, &ranked, &DEFAULT_LAUNCH_HOTKEY_CHORDS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn ts(y: i32, m: u32, d: u32) -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Resolver that pretends every path under one of `roots` belongs to that
|
||||
/// root, mimicking git-root folding without touching disk.
|
||||
fn fake_resolver(roots: &'static [&'static str]) -> impl FnMut(&Path) -> Option<PathBuf> {
|
||||
move |p: &Path| {
|
||||
let s = p.to_string_lossy().to_string();
|
||||
roots
|
||||
.iter()
|
||||
.filter(|r| s == **r || s.starts_with(&format!("{r}/")))
|
||||
.max_by_key(|r| r.len())
|
||||
.map(|r| PathBuf::from(*r))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folds_subdirectories_into_repo_root() {
|
||||
let now = ts(2026, 6, 25);
|
||||
let locs = vec![
|
||||
SessionLocation::new("/u/jeremy/proj", Some(ts(2026, 6, 24))),
|
||||
SessionLocation::new("/u/jeremy/proj/crates/a", Some(ts(2026, 6, 24))),
|
||||
SessionLocation::new("/u/jeremy/proj/crates/b", Some(ts(2026, 6, 23))),
|
||||
];
|
||||
let ranked = rank_repositories_with(
|
||||
&locs,
|
||||
now,
|
||||
&RankOptions::default(),
|
||||
fake_resolver(&["/u/jeremy/proj"]),
|
||||
);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
assert_eq!(ranked[0].path, "/u/jeremy/proj");
|
||||
assert_eq!(ranked[0].session_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recency_outranks_raw_frequency() {
|
||||
let now = ts(2026, 6, 25);
|
||||
// `old` has many sessions but all a year old; `fresh` has fewer but
|
||||
// recent. With a 21-day half-life, fresh should win.
|
||||
let mut locs = vec![];
|
||||
for _ in 0..20 {
|
||||
locs.push(SessionLocation::new("/repo/old", Some(ts(2025, 6, 25))));
|
||||
}
|
||||
for _ in 0..4 {
|
||||
locs.push(SessionLocation::new("/repo/fresh", Some(ts(2026, 6, 24))));
|
||||
}
|
||||
let ranked = rank_repositories_with(
|
||||
&locs,
|
||||
now,
|
||||
&RankOptions::default(),
|
||||
fake_resolver(&["/repo/old", "/repo/fresh"]),
|
||||
);
|
||||
assert_eq!(ranked[0].path, "/repo/fresh");
|
||||
assert_eq!(ranked[1].path, "/repo/old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_paths_are_dropped() {
|
||||
let now = ts(2026, 6, 25);
|
||||
let locs = vec![
|
||||
SessionLocation::new("/home/jeremy", Some(ts(2026, 6, 24))),
|
||||
SessionLocation::new("/home/jeremy/work", Some(ts(2026, 6, 24))),
|
||||
];
|
||||
let opts = RankOptions {
|
||||
excluded_paths: vec![PathBuf::from("/home/jeremy")],
|
||||
..RankOptions::default()
|
||||
};
|
||||
let ranked = rank_repositories_with(
|
||||
&locs,
|
||||
now,
|
||||
&opts,
|
||||
fake_resolver(&["/home/jeremy", "/home/jeremy/work"]),
|
||||
);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
assert_eq!(ranked[0].path, "/home/jeremy/work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_dirs_dropped_when_required() {
|
||||
let now = ts(2026, 6, 25);
|
||||
let locs = vec![
|
||||
SessionLocation::new("/repo/a", Some(ts(2026, 6, 24))),
|
||||
SessionLocation::new("/not/a/repo", Some(ts(2026, 6, 24))),
|
||||
];
|
||||
let ranked = rank_repositories_with(
|
||||
&locs,
|
||||
now,
|
||||
&RankOptions::default(),
|
||||
fake_resolver(&["/repo/a"]),
|
||||
);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
assert_eq!(ranked[0].path, "/repo/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_dirs_kept_when_not_required() {
|
||||
let now = ts(2026, 6, 25);
|
||||
let locs = vec![SessionLocation::new("/not/a/repo", Some(ts(2026, 6, 24)))];
|
||||
let opts = RankOptions {
|
||||
require_git_root: false,
|
||||
..RankOptions::default()
|
||||
};
|
||||
let ranked = rank_repositories_with(&locs, now, &opts, |_| None);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
assert_eq!(ranked[0].path, "/not/a/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_prefix_folds_with_plain_path() {
|
||||
let now = ts(2026, 6, 25);
|
||||
let locs = vec![
|
||||
SessionLocation::new("/private/tmp/x", Some(ts(2026, 6, 24))),
|
||||
SessionLocation::new("/tmp/x", Some(ts(2026, 6, 24))),
|
||||
];
|
||||
let opts = RankOptions {
|
||||
require_git_root: false,
|
||||
..RankOptions::default()
|
||||
};
|
||||
let ranked = rank_repositories_with(&locs, now, &opts, |_| None);
|
||||
assert_eq!(ranked.len(), 1, "private/ and plain path should fold");
|
||||
assert_eq!(ranked[0].path, "/tmp/x");
|
||||
assert_eq!(ranked[0].session_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestampless_sessions_still_count_with_floor_weight() {
|
||||
let now = ts(2026, 6, 25);
|
||||
let locs = vec![
|
||||
SessionLocation::new("/repo/a", None),
|
||||
SessionLocation::new("/repo/a", None),
|
||||
];
|
||||
let opts = RankOptions {
|
||||
require_git_root: false,
|
||||
..RankOptions::default()
|
||||
};
|
||||
let ranked = rank_repositories_with(&locs, now, &opts, |_| None);
|
||||
assert_eq!(ranked[0].session_count, 2);
|
||||
assert!(ranked[0].score > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_working_dirs_are_ignored() {
|
||||
let now = ts(2026, 6, 25);
|
||||
let locs = vec![
|
||||
SessionLocation::new("", Some(ts(2026, 6, 24))),
|
||||
SessionLocation::new(" ", Some(ts(2026, 6, 24))),
|
||||
];
|
||||
let ranked = rank_repositories_with(&locs, now, &RankOptions::default(), |_| {
|
||||
Some(PathBuf::from("/x"))
|
||||
});
|
||||
assert!(ranked.is_empty());
|
||||
}
|
||||
|
||||
fn repo(path: &str, score: f64) -> RankedRepo {
|
||||
RankedRepo {
|
||||
path: path.to_string(),
|
||||
session_count: 1,
|
||||
score,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_assigns_top_repo_home_then_next_repos() {
|
||||
let ranked = vec![
|
||||
repo("/u/jeremy/jcode", 600.0),
|
||||
repo("/u/jeremy/scrollwm", 100.0),
|
||||
repo("/u/jeremy/sideproj", 50.0),
|
||||
repo("/u/jeremy/fourth", 10.0),
|
||||
repo("/u/jeremy/fifth", 5.0),
|
||||
];
|
||||
let plan = build_launch_hotkey_plan(
|
||||
Path::new("/u/jeremy"),
|
||||
&ranked,
|
||||
&DEFAULT_LAUNCH_HOTKEY_CHORDS,
|
||||
);
|
||||
// Slots: top, home, #2, #3, #4 -> 5 chords total.
|
||||
assert_eq!(plan.len(), 5);
|
||||
assert_eq!(plan[0].chord, "cmd+;");
|
||||
assert_eq!(plan[0].dir, "/u/jeremy/jcode");
|
||||
assert_eq!(plan[1].chord, "cmd+'");
|
||||
assert_eq!(plan[1].dir, "/u/jeremy");
|
||||
assert_eq!(plan[1].label, "home");
|
||||
assert_eq!(plan[2].chord, "cmd+[");
|
||||
assert_eq!(plan[2].dir, "/u/jeremy/scrollwm");
|
||||
assert_eq!(plan[3].chord, "cmd+]");
|
||||
assert_eq!(plan[3].dir, "/u/jeremy/sideproj");
|
||||
assert_eq!(plan[4].chord, "cmd+\\");
|
||||
assert_eq!(plan[4].dir, "/u/jeremy/fourth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_skips_home_if_it_appears_in_ranking() {
|
||||
let ranked = vec![
|
||||
repo("/u/jeremy", 999.0), // home ranked #1, should not take a repo slot
|
||||
repo("/u/jeremy/jcode", 600.0),
|
||||
];
|
||||
let plan = build_launch_hotkey_plan(
|
||||
Path::new("/u/jeremy"),
|
||||
&ranked,
|
||||
&DEFAULT_LAUNCH_HOTKEY_CHORDS,
|
||||
);
|
||||
// Top repo slot is jcode (home filtered out), then home gets cmd+'.
|
||||
assert_eq!(plan[0].dir, "/u/jeremy/jcode");
|
||||
assert_eq!(plan[1].dir, "/u/jeremy");
|
||||
// No duplicate dir bound to two chords.
|
||||
let mut dirs: Vec<&str> = plan.iter().map(|p| p.dir.as_str()).collect();
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
assert_eq!(dirs.len(), plan.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_truncates_to_available_repos() {
|
||||
let ranked = vec![repo("/u/jeremy/only", 600.0)];
|
||||
let plan = build_launch_hotkey_plan(
|
||||
Path::new("/u/jeremy"),
|
||||
&ranked,
|
||||
&DEFAULT_LAUNCH_HOTKEY_CHORDS,
|
||||
);
|
||||
// top repo + home only.
|
||||
assert_eq!(plan.len(), 2);
|
||||
assert_eq!(plan[0].dir, "/u/jeremy/only");
|
||||
assert_eq!(plan[1].dir, "/u/jeremy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_with_no_repos_still_binds_home() {
|
||||
let plan =
|
||||
build_launch_hotkey_plan(Path::new("/u/jeremy"), &[], &DEFAULT_LAUNCH_HOTKEY_CHORDS);
|
||||
assert_eq!(plan.len(), 1);
|
||||
assert_eq!(plan[0].chord, "cmd+;");
|
||||
assert_eq!(plan[0].dir, "/u/jeremy");
|
||||
assert_eq!(plan[0].label, "home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_reads_working_dirs_and_skips_junk() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let write = |name: &str, body: &str| {
|
||||
std::fs::write(dir.path().join(name), body).unwrap();
|
||||
};
|
||||
write(
|
||||
"session_a.json",
|
||||
r#"{"working_dir":"/Users/jeremy/jcode-github","id":"a"}"#,
|
||||
);
|
||||
write(
|
||||
"session_b.json",
|
||||
r#"{"working_dir":"/Users/jeremy/scrollwm","id":"b"}"#,
|
||||
);
|
||||
// No working_dir -> skipped.
|
||||
write("session_c.json", r#"{"id":"c"}"#);
|
||||
// .bak sibling -> skipped (not .json suffix match).
|
||||
write("session_a.bak", r#"{"working_dir":"/should/not/count"}"#);
|
||||
// Not JSON -> skipped.
|
||||
write("notes.json", "this is not json");
|
||||
|
||||
let mut locs = collect_jcode_session_locations(dir.path());
|
||||
locs.sort_by(|a, b| a.working_dir.cmp(&b.working_dir));
|
||||
let dirs: Vec<&str> = locs.iter().map(|l| l.working_dir.as_str()).collect();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
vec!["/Users/jeremy/jcode-github", "/Users/jeremy/scrollwm"]
|
||||
);
|
||||
// mtime-derived timestamp is populated.
|
||||
assert!(locs.iter().all(|l| l.last_used.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_missing_dir_is_empty() {
|
||||
let locs = collect_jcode_session_locations(Path::new("/nonexistent/jcode/sessions"));
|
||||
assert!(locs.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Adversarial / edge-case tests for the Claude Code JSONL parser and the
|
||||
//! parent_uuid ordering logic in `jcode-import-core`.
|
||||
//!
|
||||
//! These assert the *target* (correct) behavior agreed with the swarm
|
||||
//! coordinator:
|
||||
//! * A malformed shape inside a single block must NEVER drop the whole line.
|
||||
//! * `tool_result.content` may be a string, `null`, or an array of blocks.
|
||||
//! * Array `image` blocks flatten to a compact `[image]` placeholder, NOT
|
||||
//! their base64 payload (no transcript / context bloat).
|
||||
//! * `ordered_claude_code_message_entries` must terminate on cycles and never
|
||||
//! drop or duplicate messages; sidechain entries are excluded.
|
||||
//!
|
||||
//! Pure functions only (no filesystem / env), so safe to run in parallel.
|
||||
//! Authored by swarm Worker C (dolphin).
|
||||
|
||||
use jcode_import_core::{
|
||||
ClaudeCodeContent, ClaudeCodeContentBlock, ClaudeCodeEntry, claude_text_from_content,
|
||||
ordered_claude_code_message_entries,
|
||||
};
|
||||
|
||||
fn parse(line: &str) -> Result<ClaudeCodeEntry, serde_json::Error> {
|
||||
serde_json::from_str::<ClaudeCodeEntry>(line)
|
||||
}
|
||||
|
||||
/// A single JSONL line must always deserialize, regardless of block shape.
|
||||
fn parse_ok(line: &str) -> ClaudeCodeEntry {
|
||||
parse(line).unwrap_or_else(|e| {
|
||||
panic!("line should never be dropped, but failed to parse: {e}\nline: {line}")
|
||||
})
|
||||
}
|
||||
|
||||
fn first_tool_result_content(entry: ClaudeCodeEntry) -> String {
|
||||
let ClaudeCodeContent::Blocks(blocks) = entry.message.expect("message present").content else {
|
||||
panic!("expected block content");
|
||||
};
|
||||
for block in blocks {
|
||||
if let ClaudeCodeContentBlock::ToolResult { content, .. } = block {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
panic!("no tool_result block found");
|
||||
}
|
||||
|
||||
fn ordered_uuids(entries: &[ClaudeCodeEntry]) -> Vec<Option<&str>> {
|
||||
ordered_claude_code_message_entries(entries)
|
||||
.iter()
|
||||
.map(|e| e.uuid.as_deref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge case 1: newer tool_result.content schema variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tool_result_array_text_imports_text() {
|
||||
// Array form with a single text block -> the text must be preserved.
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"hello world"}]}]}}"#;
|
||||
let content = first_tool_result_content(parse_ok(line));
|
||||
assert_eq!(content, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_array_mixed_text_and_image_uses_placeholder_not_base64() {
|
||||
// Array form with text + image. Target behavior: keep the text, replace the
|
||||
// image with a compact "[image]" placeholder, and NEVER embed base64.
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"hello"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"BASE64DATA"}}]}]}}"#;
|
||||
let content = first_tool_result_content(parse_ok(line));
|
||||
assert!(
|
||||
content.contains("hello"),
|
||||
"text must survive, got {content:?}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("[image]"),
|
||||
"image must reduce to a placeholder, got {content:?}"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("BASE64DATA"),
|
||||
"base64 image payload must NOT be embedded, got {content:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_array_image_only_uses_placeholder() {
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"BASE64DATA"}}]}]}}"#;
|
||||
let content = first_tool_result_content(parse_ok(line));
|
||||
assert_eq!(content, "[image]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_content_null_parses_to_empty_not_dropped() {
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":null}]}}"#;
|
||||
assert_eq!(first_tool_result_content(parse_ok(line)), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_content_missing_parses_to_empty_not_dropped() {
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1"}]}}"#;
|
||||
assert_eq!(first_tool_result_content(parse_ok(line)), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_content_plain_string_still_supported() {
|
||||
// Regression guard: legacy string form must keep working.
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"plain string result"}]}}"#;
|
||||
assert_eq!(
|
||||
first_tool_result_content(parse_ok(line)),
|
||||
"plain string result"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_array_nested_content_string_blocks_preserved() {
|
||||
// Some array blocks carry their text under a "content" string field rather
|
||||
// than "text". Target: preserve that text too.
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"tool_result","content":"nested output"}]}]}}"#;
|
||||
let content = first_tool_result_content(parse_ok(line));
|
||||
assert!(
|
||||
content.contains("nested output"),
|
||||
"nested string content must survive, got {content:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Large base64 image: explicit memory-bloat guard (edge case 9)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn large_base64_image_does_not_bloat_imported_text() {
|
||||
// A 100 KiB base64 image must NOT end up in the imported text. The target
|
||||
// is the compact "[image]" placeholder, so content stays tiny.
|
||||
let big = "Q".repeat(100_000);
|
||||
let line = format!(
|
||||
r#"{{"type":"user","uuid":"u1","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1","content":[{{"type":"text","text":"screenshot"}},{{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{big}"}}}}]}}]}}}}"#
|
||||
);
|
||||
let content = first_tool_result_content(parse_ok(&line));
|
||||
assert!(
|
||||
!content.contains(&big),
|
||||
"base64 payload must not be embedded verbatim"
|
||||
);
|
||||
assert!(
|
||||
content.len() < 1_000,
|
||||
"imported tool_result text must stay compact, got {} bytes",
|
||||
content.len()
|
||||
);
|
||||
assert!(content.contains("screenshot"));
|
||||
assert!(content.contains("[image]"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge case 2: thinking / redacted_thinking / unknown blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn thinking_block_without_signature_parses() {
|
||||
let line = r#"{"type":"assistant","uuid":"a1","message":{"role":"assistant","content":[{"type":"thinking","thinking":"reasoning here"}]}}"#;
|
||||
let entry = parse_ok(line);
|
||||
assert_eq!(
|
||||
claude_text_from_content(&entry.message.unwrap().content).as_deref(),
|
||||
Some("reasoning here")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_block_with_signature_parses() {
|
||||
let line = r#"{"type":"assistant","uuid":"a1","message":{"role":"assistant","content":[{"type":"thinking","thinking":"reasoning","signature":"sig-abc"}]}}"#;
|
||||
let entry = parse_ok(line);
|
||||
assert_eq!(
|
||||
claude_text_from_content(&entry.message.unwrap().content).as_deref(),
|
||||
Some("reasoning")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_thinking_block_is_skipped_not_dropped() {
|
||||
// redacted_thinking is an unknown variant -> serde(other) => Unknown.
|
||||
// The line must still parse and the accompanying text block must survive.
|
||||
let line = r#"{"type":"assistant","uuid":"a1","message":{"role":"assistant","content":[{"type":"redacted_thinking","data":"REDACTED"},{"type":"text","text":"after redaction"}]}}"#;
|
||||
let entry = parse_ok(line);
|
||||
let ClaudeCodeContent::Blocks(blocks) = entry.message.unwrap().content else {
|
||||
panic!("expected blocks");
|
||||
};
|
||||
assert!(matches!(blocks[0], ClaudeCodeContentBlock::Unknown));
|
||||
assert!(matches!(blocks[1], ClaudeCodeContentBlock::Text { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_block_type_does_not_drop_sibling_text() {
|
||||
// An unknown block type (e.g. server_tool_use / web_search_tool_result)
|
||||
// must degrade to Unknown and NOT cause the whole line to fail.
|
||||
let line = r#"{"type":"assistant","uuid":"a1","message":{"role":"assistant","content":[{"type":"server_tool_use","id":"s1","name":"web_search","input":{}},{"type":"text","text":"visible answer"}]}}"#;
|
||||
let entry = parse_ok(line);
|
||||
assert_eq!(
|
||||
claude_text_from_content(&entry.message.unwrap().content).as_deref(),
|
||||
Some("visible answer")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge case 3: message.content shapes and unusual roles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn message_content_plain_string_parses() {
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"just a string"}}"#;
|
||||
let entry = parse_ok(line);
|
||||
assert!(matches!(
|
||||
entry.message.unwrap().content,
|
||||
ClaudeCodeContent::Text(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_content_missing_defaults_to_empty() {
|
||||
let line = r#"{"type":"user","uuid":"u1","message":{"role":"user"}}"#;
|
||||
let entry = parse_ok(line);
|
||||
assert!(matches!(
|
||||
entry.message.unwrap().content,
|
||||
ClaudeCodeContent::Empty
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_with_system_role_parses_but_is_excluded_from_ordering() {
|
||||
// role/type = system should parse fine, but ordered_* only keeps
|
||||
// user/assistant entries.
|
||||
let entries = vec![
|
||||
parse_ok(
|
||||
r#"{"type":"system","uuid":"sys","message":{"role":"system","content":"system notice"}}"#,
|
||||
),
|
||||
parse_ok(r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"hi"}}"#),
|
||||
];
|
||||
assert_eq!(ordered_uuids(&entries), vec![Some("u1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_with_tool_role_is_excluded_from_ordering() {
|
||||
let entries = vec![
|
||||
parse_ok(
|
||||
r#"{"type":"tool","uuid":"tl","message":{"role":"tool","content":"tool chatter"}}"#,
|
||||
),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"a1","message":{"role":"assistant","content":"answer"}}"#,
|
||||
),
|
||||
];
|
||||
assert_eq!(ordered_uuids(&entries), vec![Some("a1")]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge case 4: sidechain entries excluded from ordering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sidechain_entries_excluded_from_message_ordering() {
|
||||
let entries = vec![
|
||||
parse_ok(r#"{"type":"user","uuid":"m1","message":{"role":"user","content":"main"}}"#),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"s1","parentUuid":"m1","isSidechain":true,"message":{"role":"assistant","content":"sidechain noise"}}"#,
|
||||
),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"m2","parentUuid":"m1","message":{"role":"assistant","content":"main reply"}}"#,
|
||||
),
|
||||
];
|
||||
// Sidechain s1 must not appear; main chain m1 -> m2 must.
|
||||
assert_eq!(ordered_uuids(&entries), vec![Some("m1"), Some("m2")]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge case 5: parent_uuid chains - branching, cycles, multi-root, self-parent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parent_chain_cycle_does_not_infinite_loop_and_keeps_all() {
|
||||
// a -> b -> a (mutual parent cycle). The visited-set must terminate and
|
||||
// every message must appear exactly once.
|
||||
let entries = vec![
|
||||
parse_ok(
|
||||
r#"{"type":"user","uuid":"a","parentUuid":"b","message":{"role":"user","content":"A"}}"#,
|
||||
),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"b","parentUuid":"a","message":{"role":"assistant","content":"B"}}"#,
|
||||
),
|
||||
];
|
||||
let ordered = ordered_uuids(&entries);
|
||||
assert_eq!(
|
||||
ordered.len(),
|
||||
2,
|
||||
"cycle must not drop or duplicate: {ordered:?}"
|
||||
);
|
||||
assert!(ordered.contains(&Some("a")));
|
||||
assert!(ordered.contains(&Some("b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_parent_entry_appears_exactly_once() {
|
||||
let entries = vec![parse_ok(
|
||||
r#"{"type":"assistant","uuid":"x","parentUuid":"x","message":{"role":"assistant","content":"self"}}"#,
|
||||
)];
|
||||
assert_eq!(ordered_uuids(&entries), vec![Some("x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branching_chain_keeps_all_children() {
|
||||
// root r with two children c1 and c2 (a fork). Both children must survive.
|
||||
let entries = vec![
|
||||
parse_ok(r#"{"type":"user","uuid":"r","message":{"role":"user","content":"R"}}"#),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"c1","parentUuid":"r","message":{"role":"assistant","content":"C1"}}"#,
|
||||
),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"c2","parentUuid":"r","message":{"role":"assistant","content":"C2"}}"#,
|
||||
),
|
||||
];
|
||||
let ordered = ordered_uuids(&entries);
|
||||
assert_eq!(
|
||||
ordered.len(),
|
||||
3,
|
||||
"branch must keep all entries: {ordered:?}"
|
||||
);
|
||||
assert_eq!(ordered[0], Some("r"), "root must come first");
|
||||
assert!(ordered.contains(&Some("c1")));
|
||||
assert!(ordered.contains(&Some("c2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_roots_all_preserved() {
|
||||
let entries = vec![
|
||||
parse_ok(r#"{"type":"user","uuid":"r1","message":{"role":"user","content":"R1"}}"#),
|
||||
parse_ok(r#"{"type":"user","uuid":"r2","message":{"role":"user","content":"R2"}}"#),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"k1","parentUuid":"r1","message":{"role":"assistant","content":"K1"}}"#,
|
||||
),
|
||||
];
|
||||
let ordered = ordered_uuids(&entries);
|
||||
assert_eq!(ordered.len(), 3, "multi-root must keep all: {ordered:?}");
|
||||
for id in [Some("r1"), Some("r2"), Some("k1")] {
|
||||
assert!(ordered.contains(&id), "missing {id:?} in {ordered:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_root_orphan_chain_is_not_dropped() {
|
||||
// All entries reference parents that do NOT exist among message entries.
|
||||
// They should be treated as roots (orphans) and preserved, not dropped.
|
||||
let entries = vec![
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"o1","parentUuid":"ghost","message":{"role":"assistant","content":"O1"}}"#,
|
||||
),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"o2","parentUuid":"phantom","message":{"role":"assistant","content":"O2"}}"#,
|
||||
),
|
||||
];
|
||||
let ordered = ordered_uuids(&entries);
|
||||
assert_eq!(ordered.len(), 2, "orphans must be preserved: {ordered:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_uuids_do_not_loop_forever() {
|
||||
// Two entries share uuid "d" with a self/parent reference. Must terminate
|
||||
// and not duplicate the visited node unboundedly.
|
||||
let entries = vec![
|
||||
parse_ok(r#"{"type":"user","uuid":"d","message":{"role":"user","content":"first"}}"#),
|
||||
parse_ok(
|
||||
r#"{"type":"assistant","uuid":"d","parentUuid":"d","message":{"role":"assistant","content":"dup"}}"#,
|
||||
),
|
||||
];
|
||||
let ordered = ordered_uuids(&entries);
|
||||
// The visited-set keys on uuid, so the duplicate uuid collapses. Important
|
||||
// property: it terminates and does not explode.
|
||||
assert!(
|
||||
ordered.len() <= 2,
|
||||
"must not duplicate unboundedly: {ordered:?}"
|
||||
);
|
||||
assert!(ordered.contains(&Some("d")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_linear_chain_orders_correctly() {
|
||||
// 500-deep linear chain must order head->tail and never drop nodes.
|
||||
let mut entries = Vec::new();
|
||||
entries.push(parse_ok(
|
||||
r#"{"type":"user","uuid":"n0","message":{"role":"user","content":"start"}}"#,
|
||||
));
|
||||
for i in 1..500 {
|
||||
let line = format!(
|
||||
r#"{{"type":"assistant","uuid":"n{i}","parentUuid":"n{}","message":{{"role":"assistant","content":"m{i}"}}}}"#,
|
||||
i - 1
|
||||
);
|
||||
entries.push(parse_ok(&line));
|
||||
}
|
||||
let ordered = ordered_claude_code_message_entries(&entries);
|
||||
assert_eq!(ordered.len(), 500);
|
||||
assert_eq!(ordered.first().unwrap().uuid.as_deref(), Some("n0"));
|
||||
assert_eq!(ordered.last().unwrap().uuid.as_deref(), Some("n499"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge case 6: malformed JSON lines must be skipped individually
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn malformed_json_line_fails_to_parse_in_isolation() {
|
||||
// A genuinely malformed JSON line returns Err (the importer's line loop
|
||||
// skips just this one and keeps going). Good lines around it are unaffected
|
||||
// (validated at the importer level in the jcode-base integration test).
|
||||
assert!(parse("{this is not valid json}").is_err());
|
||||
assert!(parse("").is_err());
|
||||
// ...but a well-formed line still parses.
|
||||
assert!(
|
||||
parse(r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"ok"}}"#).is_ok()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user