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