use std::path::{Path, PathBuf};
pub struct ResumeInfo {
pub program: &'static str,
pub args: Vec<String>,
pub cwd: Option<PathBuf>,
pub verified_cwd: bool,
pub note: Option<String>,
}
impl ResumeInfo {
pub fn command_line(&self) -> String {
let mut s = String::from(self.program);
for a in &self.args {
s.push(' ');
s.push_str(a);
}
s
}
}
pub fn for_session(tool: &str, path: &Path, project: &str) -> Option<ResumeInfo> {
let (cwd, verified_cwd) = safe_cwd(tool, path, project);
match tool {
"claude-code" => {
let p = path.to_string_lossy();
if p.contains("/subagents/") {
let comps: Vec<String> = path
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
let idx = comps.iter().position(|c| c == "subagents")?;
let parent = comps.get(idx.checked_sub(1)?)?.clone();
if !looks_like_uuid(&parent) {
return None;
}
return Some(ResumeInfo {
program: "claude",
args: vec!["--resume".into(), parent],
cwd,
verified_cwd,
note: Some("this is a subagent transcript; resuming its parent session".into()),
});
}
let id = path.file_stem()?.to_string_lossy().into_owned();
looks_like_uuid(&id).then(|| ResumeInfo {
program: "claude",
args: vec!["--resume".into(), id],
cwd,
verified_cwd,
note: None,
})
}
"codex" => {
let stem = path.file_stem()?.to_string_lossy().into_owned();
let id = stem.get(stem.len().checked_sub(36)?..)?.to_string();
looks_like_uuid(&id).then(|| ResumeInfo {
program: "codex",
args: vec!["resume".into(), id],
cwd,
verified_cwd,
note: None,
})
}
_ => None,
}
}
fn safe_cwd(tool: &str, path: &Path, project: &str) -> (Option<PathBuf>, bool) {
if project.is_empty() || !project.starts_with('/') {
return (None, true);
}
let Ok(canon) = std::fs::canonicalize(project) else {
return (Some(PathBuf::from(project)), false);
};
if !canon.is_dir() {
return (Some(canon), false);
}
let verified = if tool == "claude-code" {
let folder = path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned());
folder.as_deref()
== Some(crate::migrate::claude_project_folder(&canon.to_string_lossy()).as_str())
} else {
false
};
(Some(canon), verified)
}
fn looks_like_uuid(s: &str) -> bool {
s.len() == 36
&& s.bytes().enumerate().all(|(i, b)| match i {
8 | 13 | 18 | 23 => b == b'-',
_ => b.is_ascii_hexdigit(),
})
}
#[cfg(test)]
mod tests {
use super::*;
const ID: &str = "0a000000-0000-4000-8000-000000000001.jsonl";
#[test]
fn resume_only_trusts_a_cwd_it_can_verify() {
let base = std::env::temp_dir().join(format!("sw-resume-{}", std::process::id()));
let proj = base.join("realproj");
std::fs::create_dir_all(&proj).unwrap();
let canon = std::fs::canonicalize(&proj)
.unwrap()
.to_string_lossy()
.to_string();
let good = base
.join(crate::migrate::claude_project_folder(&canon))
.join(ID);
assert!(
for_session("claude-code", &good, &canon)
.unwrap()
.verified_cwd
);
let bad = base.join("-totally-different").join(ID);
assert!(
!for_session("claude-code", &bad, &canon)
.unwrap()
.verified_cwd
);
let cdx = Path::new("/x/rollout-1-0a000000-0000-4000-8000-000000000001.jsonl");
assert!(
!for_session("codex", cdx, "/no/such/dir/here")
.unwrap()
.verified_cwd
);
let cdx2 = Path::new("/x/rollout-1-0a000000-0000-4000-8000-000000000001.jsonl");
assert!(!for_session("codex", cdx2, &canon).unwrap().verified_cwd);
std::fs::remove_dir_all(&base).ok();
}
}