use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
use super::watcher::projects_dir;
const LIVE_THRESHOLD: Duration = Duration::from_secs(10);
pub(crate) fn encode_project_path(abs_path: &Path) -> String {
abs_path
.to_string_lossy()
.chars()
.map(|c| if c == '/' || c == '.' { '-' } else { c })
.collect()
}
pub(crate) fn project_dir_for(abs_path: &Path) -> Option<PathBuf> {
Some(projects_dir()?.join(encode_project_path(abs_path)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RelocationMode {
Move,
Copy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RelocationOp {
pub(crate) from: PathBuf,
pub(crate) to: PathBuf,
pub(crate) is_dir: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct RelocationPlan {
#[allow(dead_code)]
pub(crate) session_id: String,
pub(crate) mode: RelocationMode,
pub(crate) ops: Vec<RelocationOp>,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionInfo {
pub(crate) id: String,
#[allow(dead_code)]
pub(crate) jsonl_path: PathBuf,
pub(crate) modified: SystemTime,
pub(crate) has_sidecar: bool,
}
pub(crate) fn plan_relocation(
session_id: &str,
src_dir: &Path,
dest_dir: &Path,
has_sidecar: bool,
mode: RelocationMode,
) -> RelocationPlan {
let mut ops = vec![RelocationOp {
from: src_dir.join(format!("{session_id}.jsonl")),
to: dest_dir.join(format!("{session_id}.jsonl")),
is_dir: false,
}];
if has_sidecar {
ops.push(RelocationOp {
from: src_dir.join(session_id),
to: dest_dir.join(session_id),
is_dir: true,
});
}
RelocationPlan {
session_id: session_id.to_string(),
mode,
ops,
}
}
pub(crate) fn enumerate_sessions(src_dir: &Path) -> Result<Vec<SessionInfo>> {
let entries = match fs::read_dir(src_dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e).with_context(|| format!("failed to read {}", src_dir.display())),
};
let mut sidecar_dirs: HashSet<String> = HashSet::new();
let mut transcripts: Vec<(String, PathBuf)> = Vec::new();
for entry in entries {
let entry =
entry.with_context(|| format!("failed to read entry in {}", src_dir.display()))?;
let file_type = entry
.file_type()
.with_context(|| format!("failed to stat {}", entry.path().display()))?;
let name = entry.file_name();
let name = name.to_string_lossy();
if file_type.is_dir() {
sidecar_dirs.insert(name.into_owned());
} else if file_type.is_file() {
if let Some(id) = name.strip_suffix(".jsonl") {
transcripts.push((id.to_string(), entry.path()));
}
}
}
let mut sessions = Vec::new();
for (id, jsonl_path) in transcripts {
let Ok(metadata) = fs::metadata(&jsonl_path) else {
continue; };
let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
sessions.push(SessionInfo {
has_sidecar: sidecar_dirs.contains(&id),
id,
jsonl_path,
modified,
});
}
sessions.sort_by_key(|s| std::cmp::Reverse(s.modified));
Ok(sessions)
}
pub(crate) fn is_likely_live(modified: SystemTime, now: SystemTime) -> bool {
match now.duration_since(modified) {
Ok(elapsed) => elapsed <= LIVE_THRESHOLD,
Err(_) => true, }
}
pub(crate) fn destination_collision(session: &SessionInfo, dest_dir: &Path) -> Option<String> {
let jsonl = dest_dir.join(format!("{}.jsonl", session.id));
if jsonl.exists() {
return Some(format!("{}.jsonl", session.id));
}
if session.has_sidecar {
let dir = dest_dir.join(&session.id);
if dir.exists() {
return Some(format!("{}/", session.id));
}
}
None
}
pub(crate) fn execute_relocation(plan: &RelocationPlan, dest_dir: &Path) -> Result<()> {
fs::create_dir_all(dest_dir)
.with_context(|| format!("failed to create {}", dest_dir.display()))?;
for op in &plan.ops {
match plan.mode {
RelocationMode::Move => {
fs::rename(&op.from, &op.to).with_context(|| {
format!(
"failed to move {} to {}",
op.from.display(),
op.to.display()
)
})?;
}
RelocationMode::Copy if op.is_dir => {
copy_dir_recursive(&op.from, &op.to).with_context(|| {
format!(
"failed to copy {} to {}",
op.from.display(),
op.to.display()
)
})?;
}
RelocationMode::Copy => {
fs::copy(&op.from, &op.to).with_context(|| {
format!(
"failed to copy {} to {}",
op.from.display(),
op.to.display()
)
})?;
}
}
}
Ok(())
}
fn copy_dir_recursive(from: &Path, to: &Path) -> std::io::Result<()> {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
let dest = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&entry.path(), &dest)?;
} else {
fs::copy(entry.path(), &dest)?;
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::time::UNIX_EPOCH;
#[test]
fn encode_project_path_replaces_slashes_and_dots() {
assert_eq!(
encode_project_path(Path::new("/Users/x/wrk/omni-dev")),
"-Users-x-wrk-omni-dev"
);
assert_eq!(
encode_project_path(Path::new("/Users/x/Downloads/Dot.dot")),
"-Users-x-Downloads-Dot-dot"
);
assert_eq!(encode_project_path(Path::new("/a/.work")), "-a--work");
}
#[test]
fn plan_relocation_is_transcript_first_and_includes_sidecar_only_when_present() {
let plan = plan_relocation(
"abc123",
Path::new("/src"),
Path::new("/dest"),
true,
RelocationMode::Move,
);
assert_eq!(plan.ops.len(), 2);
assert_eq!(plan.ops[0].from, PathBuf::from("/src/abc123.jsonl"));
assert!(!plan.ops[0].is_dir);
assert_eq!(plan.ops[1].from, PathBuf::from("/src/abc123"));
assert!(plan.ops[1].is_dir);
let plan_no_sidecar = plan_relocation(
"abc123",
Path::new("/src"),
Path::new("/dest"),
false,
RelocationMode::Copy,
);
assert_eq!(plan_no_sidecar.ops.len(), 1);
}
#[test]
fn enumerate_sessions_returns_empty_for_a_missing_folder() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
assert!(enumerate_sessions(&missing).unwrap().is_empty());
}
#[test]
fn enumerate_sessions_finds_transcripts_newest_first_and_detects_sidecars() {
let dir = tempfile::tempdir().unwrap();
let older = dir.path().join("older.jsonl");
let newer = dir.path().join("newer.jsonl");
fs::write(&older, "{}").unwrap();
std::thread::sleep(Duration::from_millis(10));
fs::write(&newer, "{}").unwrap();
fs::create_dir(dir.path().join("newer")).unwrap();
let sessions = enumerate_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].id, "newer");
assert!(sessions[0].has_sidecar);
assert_eq!(sessions[1].id, "older");
assert!(!sessions[1].has_sidecar);
}
#[test]
fn is_likely_live_window_boundaries() {
let now = SystemTime::now();
assert!(is_likely_live(now, now));
assert!(is_likely_live(now - Duration::from_secs(5), now));
assert!(!is_likely_live(now - Duration::from_secs(60), now));
assert!(is_likely_live(now + Duration::from_secs(30), now));
}
#[test]
fn destination_collision_detects_jsonl_and_sidecar_clashes() {
let dir = tempfile::tempdir().unwrap();
let session = SessionInfo {
id: "abc".to_string(),
jsonl_path: PathBuf::from("/src/abc.jsonl"),
modified: UNIX_EPOCH,
has_sidecar: true,
};
assert_eq!(destination_collision(&session, dir.path()), None);
fs::write(dir.path().join("abc.jsonl"), "{}").unwrap();
assert_eq!(
destination_collision(&session, dir.path()),
Some("abc.jsonl".to_string())
);
fs::remove_file(dir.path().join("abc.jsonl")).unwrap();
fs::create_dir(dir.path().join("abc")).unwrap();
assert_eq!(
destination_collision(&session, dir.path()),
Some("abc/".to_string())
);
}
#[test]
fn execute_relocation_move_relocates_transcript_and_sidecar() {
let root = tempfile::tempdir().unwrap();
let src = root.path().join("src");
let dest = root.path().join("dest");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("abc.jsonl"), "transcript").unwrap();
fs::create_dir(src.join("abc")).unwrap();
fs::write(src.join("abc").join("tool.json"), "sidecar").unwrap();
let plan = plan_relocation("abc", &src, &dest, true, RelocationMode::Move);
execute_relocation(&plan, &dest).unwrap();
assert!(!src.join("abc.jsonl").exists());
assert!(!src.join("abc").exists());
assert!(dest.join("abc.jsonl").exists());
assert!(dest.join("abc").join("tool.json").exists());
}
#[test]
fn execute_relocation_copy_leaves_the_source_in_place() {
let root = tempfile::tempdir().unwrap();
let src = root.path().join("src");
let dest = root.path().join("dest");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("abc.jsonl"), "transcript").unwrap();
let plan = plan_relocation("abc", &src, &dest, false, RelocationMode::Copy);
execute_relocation(&plan, &dest).unwrap();
assert!(src.join("abc.jsonl").exists(), "source must remain");
assert!(dest.join("abc.jsonl").exists());
}
#[test]
fn execute_relocation_move_stops_after_the_transcript_when_the_sidecar_move_fails() {
let root = tempfile::tempdir().unwrap();
let src = root.path().join("src");
let dest = root.path().join("dest");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("abc.jsonl"), "transcript").unwrap();
let plan = plan_relocation("abc", &src, &dest, true, RelocationMode::Move);
assert!(execute_relocation(&plan, &dest).is_err());
assert!(
dest.join("abc.jsonl").exists(),
"transcript must have moved before the sidecar op failed"
);
assert!(!src.join("abc.jsonl").exists());
}
}