1#![cfg(not(target_os = "emscripten"))]
42
43use anyhow::{Context, Result};
44use clap::Args;
45use std::path::PathBuf;
46
47use crate::harness::Harness;
48
49#[derive(Args, Debug)]
50pub struct ResumeArgs {
51 pub input: String,
56
57 #[arg(short = 'C', long)]
61 pub cwd: Option<PathBuf>,
62
63 #[arg(long, value_enum)]
65 pub harness: Option<Harness>,
66
67 #[arg(long)]
71 pub no_cache: bool,
72
73 #[arg(long)]
77 pub force: bool,
78
79 #[arg(long)]
82 pub url: Option<String>,
83}
84
85pub fn run(args: ResumeArgs) -> Result<()> {
86 run_with_strategy(args, &RealExec)
87}
88
89pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> {
92 let (graph, source_harness) = resolve_input(&args)?;
93 let path = ensure_path_with_agent(&graph)?;
94
95 let cwd = match args.cwd.as_ref() {
96 Some(p) => {
97 std::fs::canonicalize(p).with_context(|| format!("resolve cwd path {}", p.display()))?
98 }
99 None => std::env::current_dir()?,
100 };
101
102 let target = pick_harness(args.harness, source_harness, None)?;
103 eprintln!(
104 "Picked harness: {}{}",
105 target.name(),
106 if Some(target) == source_harness {
107 " (source)"
108 } else {
109 ""
110 }
111 );
112
113 let session_id = project_into_harness(path, target, &cwd)?;
114 let (binary, argv) = invocation_for(target, &session_id, &cwd);
115 exec_harness(&binary, &argv, &cwd, exec)
116}
117
118use toolpath::v1::{Graph, Path as TPath, PathOrRef};
119
120pub(crate) fn infer_source_harness(path: &TPath) -> Option<Harness> {
124 let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref());
125 if let Some(source) = meta_source {
126 match source {
127 "claude-code" => return Some(Harness::Claude),
128 "gemini-cli" => return Some(Harness::Gemini),
129 "codex" => return Some(Harness::Codex),
130 "copilot" => return Some(Harness::Copilot),
131 "opencode" => return Some(Harness::Opencode),
132 "cursor" => return Some(Harness::Cursor),
133 "pi" => return Some(Harness::Pi),
134 _ => {} }
136 }
137 for step in &path.steps {
138 let actor = &step.step.actor;
139 if actor.starts_with("agent:claude-code") {
140 return Some(Harness::Claude);
141 }
142 if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") {
143 return Some(Harness::Gemini);
144 }
145 if actor.starts_with("agent:codex") {
146 return Some(Harness::Codex);
147 }
148 if actor.starts_with("agent:copilot") {
149 return Some(Harness::Copilot);
150 }
151 if actor.starts_with("agent:opencode") {
152 return Some(Harness::Opencode);
153 }
154 if actor.starts_with("agent:cursor") {
155 return Some(Harness::Cursor);
156 }
157 if actor.starts_with("agent:pi") {
158 return Some(Harness::Pi);
159 }
160 }
161 None
162}
163
164pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> {
168 if g.paths.is_empty() {
169 anyhow::bail!("resume needs a `Path`; expected one path, got an empty graph");
170 }
171 if g.paths.len() > 1 {
172 anyhow::bail!(
173 "resume needs a single `Path`; input is a graph with {} paths. \
174 Pick one with `path query …` or split first.",
175 g.paths.len()
176 );
177 }
178 let path = match &g.paths[0] {
179 PathOrRef::Path(p) => p.as_ref(),
180 PathOrRef::Ref(_) => anyhow::bail!(
181 "resume needs an inline `Path`; got a $ref. Resolve it first with `path import` or fetch the document."
182 ),
183 };
184 let has_agent = path
185 .steps
186 .iter()
187 .any(|s| s.step.actor.starts_with("agent:"));
188 if !has_agent {
189 anyhow::bail!(
190 "no agent session in input — `path resume` only works on harness-derived paths"
191 );
192 }
193 Ok(path)
194}
195
196pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option<Harness>)> {
200 let raw = args.input.as_str();
201
202 enum Shape<'a> {
203 PathbaseUrl(&'a str),
204 PathbaseShorthand(&'a str),
205 FilePath(&'a str),
206 CacheId(&'a str),
207 }
208
209 let shape = if raw.starts_with("http://") || raw.starts_with("https://") {
210 Shape::PathbaseUrl(raw)
211 } else if looks_like_pathbase_shorthand(raw) {
212 Shape::PathbaseShorthand(raw)
213 } else if std::path::Path::new(raw).is_file() {
214 Shape::FilePath(raw)
215 } else {
216 Shape::CacheId(raw)
217 };
218
219 let graph: Graph = match shape {
220 Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => {
221 let (_, ref_) = crate::derive::parse_pathbase_ref(u, args.url.as_deref())?;
227 let cache_id = crate::cache::pathbase_cache_id(&ref_.owner, &ref_.repo, &ref_.id);
228 if !args.force
229 && !args.no_cache
230 && let Ok(cache_path) = crate::cache::cache_path(&cache_id)
231 && cache_path.exists()
232 {
233 let json = std::fs::read_to_string(&cache_path)
234 .with_context(|| format!("read {}", cache_path.display()))?;
235 eprintln!("Resolved {} → {} (cached)", raw, cache_id);
236 Graph::from_json(&json)
237 .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))?
238 } else {
239 let derived = crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref())?;
240 if !args.no_cache {
241 crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?;
245 eprintln!("Resolved {} → {}", raw, derived.cache_id);
246 }
247 derived.doc
248 }
249 }
250 Shape::FilePath(p) => {
251 let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?;
252 Graph::from_json(&json)
253 .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))?
254 }
255 Shape::CacheId(id) => {
256 let file = crate::cache::cache_ref(id).map_err(|e| {
257 anyhow::anyhow!(
258 "couldn't resolve `{}` as a URL, file path, or cache id: {}",
259 raw,
260 e
261 )
262 })?;
263 let json = std::fs::read_to_string(&file)
264 .with_context(|| format!("read {}", file.display()))?;
265 Graph::from_json(&json)
266 .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))?
267 }
268 };
269
270 let harness = graph.single_path().and_then(infer_source_harness);
271 Ok((graph, harness))
272}
273
274pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool {
277 let dirs: Vec<std::path::PathBuf> = match path_override {
278 Some(p) => vec![p.to_path_buf()],
279 None => std::env::var_os("PATH")
280 .map(|p| std::env::split_paths(&p).collect())
281 .unwrap_or_default(),
282 };
283 for d in dirs {
284 let candidate = d.join(name);
285 if candidate.is_file() {
286 return true;
287 }
288 #[cfg(windows)]
289 {
290 let exe = d.join(format!("{name}.exe"));
291 if exe.is_file() {
292 return true;
293 }
294 }
295 }
296 false
297}
298
299pub(crate) fn harness_available(harness: Harness, path_override: Option<&std::path::Path>) -> bool {
304 if binary_on_path(harness.name(), path_override) {
305 return true;
306 }
307 if harness == Harness::Cursor {
308 #[cfg(target_os = "macos")]
309 {
310 return binary_on_path("open", path_override);
311 }
312 #[cfg(all(unix, not(target_os = "macos")))]
313 {
314 return binary_on_path("xdg-open", path_override);
315 }
316 }
317 false
318}
319
320pub(crate) fn pick_harness(
328 arg: Option<Harness>,
329 source: Option<Harness>,
330 path_override: Option<&std::path::Path>,
331) -> Result<Harness> {
332 if let Some(h) = arg {
333 if !harness_available(h, path_override) {
334 anyhow::bail!(
335 "harness `{}` isn't on PATH; install it or pick another with `--harness`",
336 h.name()
337 );
338 }
339 return Ok(h);
340 }
341
342 let installed: Vec<Harness> = Harness::ALL
343 .iter()
344 .copied()
345 .filter(|h| harness_available(*h, path_override))
346 .collect();
347
348 if installed.is_empty() {
349 anyhow::bail!(
350 "no installed harnesses found on PATH; install one of: claude, gemini, codex, opencode, cursor, pi"
351 );
352 }
353
354 interactive_pick(&installed, source)
355}
356
357fn interactive_pick(installed: &[Harness], source: Option<Harness>) -> Result<Harness> {
358 if !crate::fuzzy::available() {
359 let hint = if crate::fuzzy::embedded_picker_available() {
360 "rerun in a terminal"
361 } else {
362 "install `fzf` (or build with the default `embedded-picker` feature) and rerun in a terminal"
363 };
364 anyhow::bail!("interactive picker requires a TTY; pass `--harness <X>` or {hint}");
365 }
366 let mut lines: Vec<String> = Vec::with_capacity(installed.len());
367 for h in installed {
368 let suffix = if Some(*h) == source { " (source)" } else { "" };
369 lines.push(format!("{}{}", h.padded_name(), suffix));
370 }
371
372 let header = match source {
373 Some(s) => format!("pick a harness to resume in (source: {})", s.name()),
374 None => "pick a harness to resume in".to_string(),
375 };
376
377 let opts = crate::fuzzy::PickOptions {
378 with_nth: "1..",
379 header: Some(&header),
380 ..Default::default()
381 };
382 let selected = match crate::fuzzy::pick(&lines, &opts)
383 .map_err(|e| anyhow::anyhow!("fzf failed: {}", e))?
384 {
385 crate::fuzzy::PickResult::Selected(rows) => rows.into_iter().next().unwrap_or_default(),
386 crate::fuzzy::PickResult::Cancelled => std::process::exit(130),
387 crate::fuzzy::PickResult::NoMatch => {
388 anyhow::bail!("fzf returned no match — picker UI was empty?");
389 }
390 };
391
392 let picked_name = selected.split_whitespace().next().unwrap_or_default();
393 for h in installed {
394 if picked_name == h.name() {
395 return Ok(*h);
396 }
397 }
398 anyhow::bail!("picker returned an unrecognized row: {selected}")
399}
400
401pub(crate) fn argv_for(harness: Harness, session_id: &str) -> Vec<String> {
404 match harness {
405 Harness::Claude => vec!["-r".into(), session_id.into()],
406 Harness::Gemini => vec!["--resume".into(), session_id.into()],
407 Harness::Codex => vec!["resume".into(), session_id.into()],
408 Harness::Copilot => vec!["--resume".into(), session_id.into()],
409 Harness::Opencode => vec!["--session".into(), session_id.into()],
410 Harness::Cursor => {
414 let _ = session_id;
415 vec![".".into()]
416 }
417 Harness::Pi => vec!["--session".into(), session_id.into()],
418 }
419}
420
421pub(crate) fn invocation_for(
422 harness: Harness,
423 session_id: &str,
424 cwd: &std::path::Path,
425) -> (String, Vec<String>) {
426 if harness == Harness::Cursor {
427 return cursor_invocation(cwd);
428 }
429 (harness.name().to_string(), argv_for(harness, session_id))
430}
431
432fn cursor_invocation(cwd: &std::path::Path) -> (String, Vec<String>) {
433 let workspace = cwd.to_string_lossy().into_owned();
434 if binary_on_path("cursor", None) {
435 ("cursor".to_string(), vec![workspace])
436 } else {
437 #[cfg(target_os = "macos")]
438 {
439 (
440 "open".to_string(),
441 vec!["-a".into(), "Cursor".into(), workspace],
442 )
443 }
444 #[cfg(all(unix, not(target_os = "macos")))]
445 {
446 ("xdg-open".to_string(), vec![workspace])
447 }
448 #[cfg(not(unix))]
449 {
450 ("cursor".to_string(), vec![workspace])
451 }
452 }
453}
454
455pub(crate) fn project_into_harness(
458 path: &TPath,
459 harness: Harness,
460 cwd: &std::path::Path,
461) -> Result<String> {
462 match harness {
463 Harness::Claude => crate::cmd_export::project_claude(path, cwd),
464 Harness::Gemini => crate::cmd_export::project_gemini(path, cwd),
465 Harness::Codex => crate::cmd_export::project_codex(path, cwd),
466 Harness::Copilot => crate::cmd_export::project_copilot(path, cwd),
467 Harness::Opencode => crate::cmd_export::project_opencode(path, cwd),
468 Harness::Cursor => crate::cmd_export::project_cursor(path, cwd),
469 Harness::Pi => crate::cmd_export::project_pi(path, cwd),
470 }
471}
472
473#[derive(Debug, Clone, Default)]
475pub struct CapturedExec {
476 pub binary: String,
477 pub args: Vec<String>,
478 pub cwd: std::path::PathBuf,
479}
480
481pub trait ExecStrategy {
484 fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>;
485}
486
487pub struct RealExec;
491
492impl ExecStrategy for RealExec {
493 fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> {
494 let mut cmd = std::process::Command::new(binary);
495 cmd.args(args);
496 cmd.current_dir(cwd);
497
498 eprintln!(
499 "Resuming: {} {} (cwd: {})",
500 binary,
501 args.join(" "),
502 cwd.display()
503 );
504
505 #[cfg(unix)]
506 {
507 use std::os::unix::process::CommandExt;
508 let err = cmd.exec();
510 anyhow::bail!(
511 "couldn't exec `{}`: {}. Recipe: {} {} (run from {})",
512 binary,
513 err,
514 binary,
515 args.join(" "),
516 cwd.display()
517 );
518 }
519 #[cfg(not(unix))]
520 {
521 let status = cmd
522 .spawn()
523 .with_context(|| format!("spawn {}", binary))?
524 .wait()
525 .with_context(|| format!("wait for {}", binary))?;
526 std::process::exit(status.code().unwrap_or(1));
527 }
528 }
529}
530
531#[derive(Default)]
534pub struct RecordingExec {
535 inner: std::sync::Mutex<CapturedExec>,
536}
537
538impl RecordingExec {
539 pub fn captured(&self) -> CapturedExec {
540 self.inner.lock().unwrap().clone()
541 }
542}
543
544impl ExecStrategy for RecordingExec {
545 fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> {
546 let mut g = self.inner.lock().unwrap();
547 *g = CapturedExec {
548 binary: binary.to_string(),
549 args: args.to_vec(),
550 cwd: cwd.to_path_buf(),
551 };
552 Ok(())
553 }
554}
555
556pub(crate) fn exec_harness(
557 binary: &str,
558 args: &[String],
559 cwd: &std::path::Path,
560 strategy: &dyn ExecStrategy,
561) -> Result<()> {
562 strategy.exec(binary, args, cwd)
563}
564
565fn looks_like_pathbase_shorthand(s: &str) -> bool {
566 if s.starts_with('.') || s.starts_with('/') {
570 return false;
571 }
572 let segs: Vec<&str> = s.split('/').collect();
573 segs.len() == 3
574 && segs
575 .iter()
576 .all(|s| !s.is_empty() && !s.contains(char::is_whitespace))
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() {
585 let _env = crate::config::TEST_ENV_LOCK
586 .lock()
587 .unwrap_or_else(|e| e.into_inner());
588 let _home = scoped_home_for_resume();
589 let _path_guard = ScopedPathForResume::with_binaries(&["claude"]);
590 let cwd = tempfile::tempdir().unwrap();
591 let doc_file = cwd.path().join("doc.json");
592
593 let mut path = make_convo_path_for_resume("claude-code://resume-test-session");
596 path.steps[0].step.actor = "agent:claude-code".to_string();
599
600 let graph = toolpath::v1::Graph::from_path(path);
601 std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap();
602
603 let args = ResumeArgs {
604 input: doc_file.to_string_lossy().to_string(),
605 cwd: Some(cwd.path().to_path_buf()),
606 harness: Some(Harness::Claude),
607 no_cache: false,
608 force: false,
609 url: None,
610 };
611
612 let recorder = RecordingExec::default();
613 run_with_strategy(args, &recorder).unwrap();
614
615 let cap = recorder.captured();
616 assert_eq!(cap.binary, "claude");
617 assert_eq!(cap.args[0], "-r");
618 assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap());
619 }
620
621 use toolpath::v1::{Graph, PathMeta, PathOrRef};
622
623 fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step {
624 toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z")
625 .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new")
626 }
627
628 fn make_path_with_actor(actor: &str) -> toolpath::v1::Path {
629 use toolpath::v1::{Path, PathIdentity};
630 let step = make_step_with_actor("s1", actor);
631 Path {
632 path: PathIdentity {
633 id: "p1".to_string(),
634 base: None,
635 head: "s1".to_string(),
636 graph_ref: None,
637 },
638 steps: vec![step],
639 meta: None,
640 }
641 }
642
643 #[test]
644 fn infer_source_harness_meta_source_wins() {
645 let mut path = make_path_with_actor("agent:codex");
646 path.meta = Some(PathMeta {
647 source: Some("claude-code".to_string()),
648 ..Default::default()
649 });
650 assert_eq!(infer_source_harness(&path), Some(Harness::Claude));
651 }
652
653 #[test]
654 fn infer_source_harness_meta_source_unknown_falls_through_to_actor() {
655 let mut path = make_path_with_actor("agent:gemini-cli");
656 path.meta = Some(PathMeta {
657 source: Some("something-bespoke".to_string()),
658 ..Default::default()
659 });
660 assert_eq!(infer_source_harness(&path), Some(Harness::Gemini));
661 }
662
663 #[test]
664 fn infer_source_harness_actor_sniff_codex() {
665 let path = make_path_with_actor("agent:codex");
666 assert_eq!(infer_source_harness(&path), Some(Harness::Codex));
667 }
668
669 #[test]
670 fn infer_source_harness_actor_sniff_opencode() {
671 let path = make_path_with_actor("agent:opencode");
672 assert_eq!(infer_source_harness(&path), Some(Harness::Opencode));
673 }
674
675 #[test]
676 fn infer_source_harness_actor_sniff_pi() {
677 let path = make_path_with_actor("agent:pi");
678 assert_eq!(infer_source_harness(&path), Some(Harness::Pi));
679 }
680
681 #[test]
682 fn infer_source_harness_returns_none_when_no_signal() {
683 let path = make_path_with_actor("human:alex");
684 assert_eq!(infer_source_harness(&path), None);
685 }
686
687 #[test]
688 fn ensure_path_with_agent_accepts_single_path_with_agent_actor() {
689 let g = Graph::from_path(make_path_with_actor("agent:claude-code"));
690 assert!(ensure_path_with_agent(&g).is_ok());
691 }
692
693 #[test]
694 fn ensure_path_with_agent_rejects_empty_graph() {
695 let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
696 g.paths.clear();
697 let err = ensure_path_with_agent(&g).unwrap_err();
698 assert!(err.to_string().contains("expected"));
699 assert!(err.to_string().contains("empty"));
700 }
701
702 #[test]
703 fn ensure_path_with_agent_rejects_multi_path_graph() {
704 let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
705 g.paths.push(PathOrRef::Path(Box::new(make_path_with_actor(
706 "agent:claude-code",
707 ))));
708 let err = ensure_path_with_agent(&g).unwrap_err();
709 let s = err.to_string();
710 assert!(s.contains("single `Path`"), "actual: {s}");
711 assert!(s.contains("2 paths"), "actual: {s}");
712 }
713
714 #[test]
715 fn ensure_path_with_agent_rejects_agentless_path() {
716 let g = Graph::from_path(make_path_with_actor("human:alex"));
717 let err = ensure_path_with_agent(&g).unwrap_err();
718 assert!(err.to_string().contains("no agent session"));
719 }
720
721 #[test]
722 fn ensure_path_with_agent_rejects_path_ref_only_graph() {
723 use toolpath::v1::PathRef;
724 let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
725 g.paths = vec![PathOrRef::Ref(PathRef {
726 ref_url: "$ref://something".into(),
727 })];
728 let err = ensure_path_with_agent(&g).unwrap_err();
729 assert!(err.to_string().contains("inline `Path`"), "actual: {}", err);
730 }
731
732 #[test]
733 fn resolve_input_file_path() {
734 let tmp = tempfile::tempdir().unwrap();
735 let p = tmp.path().join("doc.json");
736 let graph = toolpath::v1::Graph::from_path(make_path_with_actor("agent:claude-code"));
737 std::fs::write(&p, graph.to_json().unwrap()).unwrap();
738
739 let args = ResumeArgs {
740 input: p.to_string_lossy().to_string(),
741 cwd: None,
742 harness: None,
743 no_cache: false,
744 force: false,
745 url: None,
746 };
747 let (g, harness) = resolve_input(&args).unwrap();
748 let _path = ensure_path_with_agent(&g).unwrap();
749 assert_eq!(harness, Some(Harness::Claude));
750 }
751
752 #[test]
753 fn resolve_input_url_dispatches_to_pathbase_fetch() {
754 let _env = crate::config::TEST_ENV_LOCK
755 .lock()
756 .unwrap_or_else(|e| e.into_inner());
757 use crate::cmd_pathbase::tests::MockServer;
758 let body = {
759 let mut path = make_path_with_actor("agent:codex");
760 path.meta = Some(toolpath::v1::PathMeta {
761 source: Some("codex".to_string()),
762 ..Default::default()
763 });
764 toolpath::v1::Graph::from_path(path).to_json().unwrap()
765 };
766 let body_static: &'static str = Box::leak(body.into_boxed_str());
768 let server = MockServer::start("HTTP/1.1 200 OK", body_static);
769
770 let args = ResumeArgs {
771 input: format!(
772 "{}/u/alex/repos/pathstash/graphs/fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537",
773 server.base()
774 ),
775 cwd: None,
776 harness: None,
777 no_cache: true, force: false,
779 url: None,
780 };
781 let (g, harness) = resolve_input(&args).unwrap();
782 let _ = ensure_path_with_agent(&g).unwrap();
783 assert_eq!(harness, Some(Harness::Codex));
784 }
785
786 #[test]
787 fn resolve_input_url_uses_cache_on_hit_without_refetching() {
788 let _env = crate::config::TEST_ENV_LOCK
795 .lock()
796 .unwrap_or_else(|e| e.into_inner());
797
798 let cfg_dir = tempfile::tempdir().unwrap();
801 let prev_cfg = std::env::var_os("TOOLPATH_CONFIG_DIR");
802 unsafe {
803 std::env::set_var("TOOLPATH_CONFIG_DIR", cfg_dir.path());
804 }
805
806 const FIXTURE_UUID: &str = "fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537";
809 let cache_id = format!("pathbase-alex-pathstash-{FIXTURE_UUID}");
810 let cache_id = cache_id.as_str();
811 let documents = cfg_dir.path().join("documents");
812 std::fs::create_dir_all(&documents).unwrap();
813 let cached_graph = {
814 let mut path = make_path_with_actor("agent:codex");
815 path.meta = Some(toolpath::v1::PathMeta {
816 source: Some("codex".to_string()),
817 ..Default::default()
818 });
819 toolpath::v1::Graph::from_path(path)
820 };
821 std::fs::write(
822 documents.join(format!("{cache_id}.json")),
823 cached_graph.to_json().unwrap(),
824 )
825 .unwrap();
826
827 use crate::cmd_pathbase::tests::MockServer;
829 let server = MockServer::start("HTTP/1.1 500 Internal Server Error", "boom");
830
831 let args = ResumeArgs {
832 input: format!(
833 "{}/u/alex/repos/pathstash/graphs/{FIXTURE_UUID}",
834 server.base()
835 ),
836 cwd: None,
837 harness: None,
838 no_cache: false,
839 force: false,
840 url: None,
841 };
842 let result = resolve_input(&args);
843
844 unsafe {
846 match prev_cfg {
847 Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v),
848 None => std::env::remove_var("TOOLPATH_CONFIG_DIR"),
849 }
850 }
851
852 let (g, harness) = result.expect("resolve_input should reuse cache without refetching");
853 let _ = ensure_path_with_agent(&g).unwrap();
854 assert_eq!(harness, Some(Harness::Codex));
855 }
856
857 #[test]
858 fn resolve_input_unresolvable_errors_clearly() {
859 let _env = crate::config::TEST_ENV_LOCK
860 .lock()
861 .unwrap_or_else(|e| e.into_inner());
862 let args = ResumeArgs {
863 input: "definitely/not/a/real/cache/id".to_string(),
864 cwd: None,
865 harness: None,
866 no_cache: false,
867 force: false,
868 url: None,
869 };
870 let err = resolve_input(&args).unwrap_err();
871 let s = err.to_string();
872 assert!(s.contains("couldn't resolve"), "actual: {s}");
873 }
874
875 fn fake_path_with(binaries: &[&str]) -> tempfile::TempDir {
876 let td = tempfile::tempdir().unwrap();
877 for b in binaries {
878 let p = td.path().join(b);
879 std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap();
880 #[cfg(unix)]
881 {
882 use std::os::unix::fs::PermissionsExt;
883 let mut perm = std::fs::metadata(&p).unwrap().permissions();
884 perm.set_mode(0o755);
885 std::fs::set_permissions(&p, perm).unwrap();
886 }
887 }
888 td
889 }
890
891 #[test]
892 fn binary_on_path_finds_present_binary() {
893 let td = fake_path_with(&["claude"]);
894 assert!(binary_on_path("claude", Some(td.path())));
895 assert!(!binary_on_path("gemini", Some(td.path())));
896 }
897
898 #[test]
899 fn pick_harness_explicit_arg_validates_path() {
900 let td = fake_path_with(&["claude"]);
901 let result = pick_harness(Some(Harness::Claude), None, Some(td.path()));
902 assert_eq!(result.unwrap(), Harness::Claude);
903
904 let err = pick_harness(Some(Harness::Gemini), None, Some(td.path())).unwrap_err();
905 assert!(err.to_string().contains("`gemini` isn't on PATH"));
906 }
907
908 #[cfg(target_os = "macos")]
909 #[test]
910 fn cursor_available_via_open_fallback_on_macos() {
911 let td = fake_path_with(&["open"]);
912 assert!(harness_available(Harness::Cursor, Some(td.path())));
913 let picked = pick_harness(Some(Harness::Cursor), None, Some(td.path()));
914 assert_eq!(picked.unwrap(), Harness::Cursor);
915 }
916
917 #[test]
918 fn cursor_unavailable_when_no_launcher_at_all() {
919 let td = fake_path_with(&["claude"]);
920 assert!(!harness_available(Harness::Cursor, Some(td.path())));
921 }
922
923 #[test]
924 fn cursor_invocation_includes_workspace_path() {
925 let cwd = std::path::PathBuf::from("/tmp/some-workspace");
926 let (binary, argv) = invocation_for(Harness::Cursor, "ignored-session-id", &cwd);
927 assert!(
928 argv.iter().any(|a| a == "/tmp/some-workspace"),
929 "workspace path must appear in argv; got {argv:?}",
930 );
931 assert!(
932 matches!(binary.as_str(), "cursor" | "open" | "xdg-open"),
933 "expected cursor/open/xdg-open, got {binary:?}",
934 );
935 }
936
937 #[test]
938 fn pick_harness_zero_installed_errors() {
939 let td = fake_path_with(&[]);
940 let err = pick_harness(None, Some(Harness::Claude), Some(td.path())).unwrap_err();
941 assert!(
942 err.to_string().contains("no installed harnesses")
943 || err.to_string().contains("no harnesses on PATH"),
944 "actual: {}",
945 err
946 );
947 }
948
949 #[test]
950 fn argv_for_returns_harness_specific_shape() {
951 assert_eq!(
952 argv_for(Harness::Claude, "abc"),
953 vec!["-r".to_string(), "abc".to_string()]
954 );
955 assert_eq!(
956 argv_for(Harness::Gemini, "abc"),
957 vec!["--resume".to_string(), "abc".to_string()]
958 );
959 assert_eq!(
960 argv_for(Harness::Codex, "abc"),
961 vec!["resume".to_string(), "abc".to_string()]
962 );
963 assert_eq!(
964 argv_for(Harness::Opencode, "abc"),
965 vec!["--session".to_string(), "abc".to_string()]
966 );
967 assert_eq!(
968 argv_for(Harness::Pi, "abc"),
969 vec!["--session".to_string(), "abc".to_string()]
970 );
971 }
972
973 #[test]
974 fn project_into_harness_claude_round_trip() {
975 let _env = crate::config::TEST_ENV_LOCK
976 .lock()
977 .unwrap_or_else(|e| e.into_inner());
978 let _home = scoped_home_for_resume();
979 let cwd = tempfile::tempdir().unwrap();
980 let path = make_convo_path_for_resume("claude-code://resume-test-session");
981
982 let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap();
983 assert!(!session_id.is_empty());
984 }
985
986 fn make_convo_path_for_resume(artifact_key: &str) -> toolpath::v1::Path {
990 use std::collections::HashMap;
991 let mut extra = HashMap::new();
992 extra.insert("role".to_string(), serde_json::json!("user"));
993 extra.insert("text".to_string(), serde_json::json!("hello"));
994 let step = toolpath::v1::Step {
995 step: toolpath::v1::StepIdentity {
996 id: "s1".to_string(),
997 parents: vec![],
998 actor: "human:test".to_string(),
999 timestamp: "2026-01-01T00:00:00Z".to_string(),
1000 },
1001 change: {
1002 let mut m = HashMap::new();
1003 m.insert(
1004 artifact_key.to_string(),
1005 toolpath::v1::ArtifactChange {
1006 raw: None,
1007 structural: Some(toolpath::v1::StructuralChange {
1008 change_type: "conversation.append".to_string(),
1009 extra,
1010 }),
1011 },
1012 );
1013 m
1014 },
1015 meta: None,
1016 };
1017 toolpath::v1::Path {
1018 path: toolpath::v1::PathIdentity {
1019 id: "test-path".to_string(),
1020 base: None,
1021 head: "s1".to_string(),
1022 graph_ref: None,
1023 },
1024 steps: vec![step],
1025 meta: None,
1026 }
1027 }
1028
1029 fn scoped_home_for_resume() -> ScopedHomeForResume {
1030 ScopedHomeForResume::new()
1031 }
1032
1033 struct ScopedPathForResume {
1034 _bin_dir: tempfile::TempDir,
1035 prev: Option<std::ffi::OsString>,
1036 }
1037
1038 impl ScopedPathForResume {
1039 fn with_binaries(binaries: &[&str]) -> Self {
1042 let bin_dir = fake_path_with(binaries);
1043 let prev = std::env::var_os("PATH");
1044 let new_path = std::env::join_paths(
1045 std::iter::once(bin_dir.path().to_path_buf())
1046 .chain(std::env::split_paths(&prev.clone().unwrap_or_default())),
1047 )
1048 .unwrap();
1049 unsafe {
1050 std::env::set_var("PATH", new_path);
1051 }
1052 Self {
1053 _bin_dir: bin_dir,
1054 prev,
1055 }
1056 }
1057 }
1058
1059 impl Drop for ScopedPathForResume {
1060 fn drop(&mut self) {
1061 unsafe {
1062 match &self.prev {
1063 Some(v) => std::env::set_var("PATH", v),
1064 None => std::env::remove_var("PATH"),
1065 }
1066 }
1067 }
1068 }
1069
1070 struct ScopedHomeForResume {
1071 _td: tempfile::TempDir,
1072 prev: Option<std::ffi::OsString>,
1073 }
1074
1075 impl ScopedHomeForResume {
1076 fn new() -> Self {
1077 let td = tempfile::tempdir().unwrap();
1078 let prev = std::env::var_os("HOME");
1079 unsafe {
1080 std::env::set_var("HOME", td.path());
1081 }
1082 Self { _td: td, prev }
1083 }
1084 }
1085
1086 impl Drop for ScopedHomeForResume {
1087 fn drop(&mut self) {
1088 unsafe {
1089 match &self.prev {
1090 Some(v) => std::env::set_var("HOME", v),
1091 None => std::env::remove_var("HOME"),
1092 }
1093 }
1094 }
1095 }
1096
1097 #[test]
1098 fn exec_strategy_recording_captures_invocation() {
1099 let recorder = RecordingExec::default();
1100 let strategy: &dyn ExecStrategy = &recorder;
1101 exec_harness(
1102 "claude",
1103 &["-r".into(), "abc123".into()],
1104 std::path::Path::new("/tmp/x"),
1105 strategy,
1106 )
1107 .unwrap();
1108
1109 let captured = recorder.captured();
1110 assert_eq!(captured.binary, "claude");
1111 assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]);
1112 assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x"));
1113 }
1114}