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 argv = argv_for(target, &session_id);
118 exec_harness(target.name(), &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 "pi" => return Some(Harness::Pi),
136 _ => {} }
138 }
139 for step in &path.steps {
140 let actor = &step.step.actor;
141 if actor.starts_with("agent:claude-code") {
142 return Some(Harness::Claude);
143 }
144 if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") {
145 return Some(Harness::Gemini);
146 }
147 if actor.starts_with("agent:codex") {
148 return Some(Harness::Codex);
149 }
150 if actor.starts_with("agent:opencode") {
151 return Some(Harness::Opencode);
152 }
153 if actor.starts_with("agent:pi") {
154 return Some(Harness::Pi);
155 }
156 }
157 None
158}
159
160pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> {
164 if g.paths.is_empty() {
165 anyhow::bail!("resume needs a `Path`; expected one path, got an empty graph");
166 }
167 if g.paths.len() > 1 {
168 anyhow::bail!(
169 "resume needs a single `Path`; input is a graph with {} paths. \
170 Pick one with `path query …` or split first.",
171 g.paths.len()
172 );
173 }
174 let path = match &g.paths[0] {
175 PathOrRef::Path(p) => p.as_ref(),
176 PathOrRef::Ref(_) => anyhow::bail!(
177 "resume needs an inline `Path`; got a $ref. Resolve it first with `path import` or fetch the document."
178 ),
179 };
180 let has_agent = path
181 .steps
182 .iter()
183 .any(|s| s.step.actor.starts_with("agent:"));
184 if !has_agent {
185 anyhow::bail!(
186 "no agent session in input — `path resume` only works on harness-derived paths"
187 );
188 }
189 Ok(path)
190}
191
192pub(crate) fn resolve_input(
196 args: &ResumeArgs,
197) -> Result<(Graph, Option<crate::cmd_share::Harness>)> {
198 let raw = args.input.as_str();
199
200 enum Shape<'a> {
201 PathbaseUrl(&'a str),
202 PathbaseShorthand(&'a str),
203 FilePath(&'a str),
204 CacheId(&'a str),
205 }
206
207 let shape = if raw.starts_with("http://") || raw.starts_with("https://") {
208 Shape::PathbaseUrl(raw)
209 } else if looks_like_pathbase_shorthand(raw) {
210 Shape::PathbaseShorthand(raw)
211 } else if std::path::Path::new(raw).is_file() {
212 Shape::FilePath(raw)
213 } else {
214 Shape::CacheId(raw)
215 };
216
217 let graph: Graph = match shape {
218 Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => {
219 let cache_id = crate::cmd_import::pathbase_cache_id_of(u, args.url.as_deref())?;
225 if !args.force
226 && !args.no_cache
227 && let Ok(cache_path) = crate::cmd_cache::cache_path(&cache_id)
228 && cache_path.exists()
229 {
230 let json = std::fs::read_to_string(&cache_path)
231 .with_context(|| format!("read {}", cache_path.display()))?;
232 eprintln!("Resolved {} → {} (cached)", raw, cache_id);
233 Graph::from_json(&json)
234 .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))?
235 } else {
236 let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?;
237 if !args.no_cache {
238 crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?;
242 eprintln!("Resolved {} → {}", raw, derived.cache_id);
243 }
244 derived.doc
245 }
246 }
247 Shape::FilePath(p) => {
248 let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?;
249 Graph::from_json(&json)
250 .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))?
251 }
252 Shape::CacheId(id) => {
253 let file = crate::cmd_cache::cache_ref(id).map_err(|e| {
254 anyhow::anyhow!(
255 "couldn't resolve `{}` as a URL, file path, or cache id: {}",
256 raw,
257 e
258 )
259 })?;
260 let json = std::fs::read_to_string(&file)
261 .with_context(|| format!("read {}", file.display()))?;
262 Graph::from_json(&json)
263 .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))?
264 }
265 };
266
267 let harness = graph.single_path().and_then(infer_source_harness);
268 Ok((graph, harness))
269}
270
271pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool {
274 let dirs: Vec<std::path::PathBuf> = match path_override {
275 Some(p) => vec![p.to_path_buf()],
276 None => std::env::var_os("PATH")
277 .map(|p| std::env::split_paths(&p).collect())
278 .unwrap_or_default(),
279 };
280 for d in dirs {
281 let candidate = d.join(name);
282 if candidate.is_file() {
283 return true;
284 }
285 #[cfg(windows)]
286 {
287 let exe = d.join(format!("{name}.exe"));
288 if exe.is_file() {
289 return true;
290 }
291 }
292 }
293 false
294}
295
296const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[
297 crate::cmd_share::Harness::Claude,
298 crate::cmd_share::Harness::Gemini,
299 crate::cmd_share::Harness::Codex,
300 crate::cmd_share::Harness::Opencode,
301 crate::cmd_share::Harness::Pi,
302];
303
304pub(crate) fn pick_harness(
312 arg: Option<HarnessArg>,
313 source: Option<crate::cmd_share::Harness>,
314 path_override: Option<&std::path::Path>,
315) -> Result<crate::cmd_share::Harness> {
316 use crate::cmd_share::Harness;
317
318 if let Some(a) = arg {
319 let h = Harness::from_arg(a);
320 if !binary_on_path(h.name(), path_override) {
321 anyhow::bail!(
322 "harness `{}` isn't on PATH; install it or pick another with `--harness`",
323 h.name()
324 );
325 }
326 return Ok(h);
327 }
328
329 let installed: Vec<Harness> = ALL_HARNESSES
330 .iter()
331 .copied()
332 .filter(|h| binary_on_path(h.name(), path_override))
333 .collect();
334
335 if installed.is_empty() {
336 anyhow::bail!(
337 "no installed harnesses found on PATH; install one of: claude, gemini, codex, opencode, pi"
338 );
339 }
340
341 interactive_pick(&installed, source)
342}
343
344fn interactive_pick(
345 installed: &[crate::cmd_share::Harness],
346 source: Option<crate::cmd_share::Harness>,
347) -> Result<crate::cmd_share::Harness> {
348 if !crate::fuzzy::available() {
349 let hint = if crate::fuzzy::embedded_picker_available() {
350 "rerun in a terminal"
351 } else {
352 "install `fzf` (or build with the default `embedded-picker` feature) and rerun in a terminal"
353 };
354 anyhow::bail!("interactive picker requires a TTY; pass `--harness <X>` or {hint}");
355 }
356 let mut lines: Vec<String> = Vec::with_capacity(installed.len());
357 for h in installed {
358 let suffix = if Some(*h) == source { " (source)" } else { "" };
359 lines.push(format!("{}{}", h.symbol(), suffix));
360 }
361
362 let header = match source {
363 Some(s) => format!("pick a harness to resume in (source: {})", s.name()),
364 None => "pick a harness to resume in".to_string(),
365 };
366
367 let opts = crate::fuzzy::PickOptions {
368 with_nth: "1..",
369 header: Some(&header),
370 ..Default::default()
371 };
372 let selected = match crate::fuzzy::pick(&lines, &opts)
373 .map_err(|e| anyhow::anyhow!("fzf failed: {}", e))?
374 {
375 crate::fuzzy::PickResult::Selected(rows) => rows.into_iter().next().unwrap_or_default(),
376 crate::fuzzy::PickResult::Cancelled => std::process::exit(130),
377 crate::fuzzy::PickResult::NoMatch => {
378 anyhow::bail!("fzf returned no match — picker UI was empty?");
379 }
380 };
381
382 for h in installed {
383 if selected.starts_with(h.symbol()) {
384 return Ok(*h);
385 }
386 }
387 anyhow::bail!("picker returned an unrecognized row: {selected}")
388}
389
390pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> Vec<String> {
393 use crate::cmd_share::Harness;
394 match harness {
395 Harness::Claude => vec!["-r".into(), session_id.into()],
396 Harness::Gemini => vec!["--resume".into(), session_id.into()],
397 Harness::Codex => vec!["resume".into(), session_id.into()],
398 Harness::Opencode => vec!["--session".into(), session_id.into()],
399 Harness::Pi => vec!["--session".into(), session_id.into()],
400 }
401}
402
403pub(crate) fn project_into_harness(
406 path: &TPath,
407 harness: crate::cmd_share::Harness,
408 cwd: &std::path::Path,
409) -> Result<String> {
410 use crate::cmd_share::Harness;
411 match harness {
412 Harness::Claude => crate::cmd_export::project_claude(path, cwd),
413 Harness::Gemini => crate::cmd_export::project_gemini(path, cwd),
414 Harness::Codex => crate::cmd_export::project_codex(path, cwd),
415 Harness::Opencode => crate::cmd_export::project_opencode(path, cwd),
416 Harness::Pi => crate::cmd_export::project_pi(path, cwd),
417 }
418}
419
420#[derive(Debug, Clone, Default)]
422pub struct CapturedExec {
423 pub binary: String,
424 pub args: Vec<String>,
425 pub cwd: std::path::PathBuf,
426}
427
428pub trait ExecStrategy {
431 fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>;
432}
433
434pub struct RealExec;
438
439impl ExecStrategy for RealExec {
440 fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> {
441 let mut cmd = std::process::Command::new(binary);
442 cmd.args(args);
443 cmd.current_dir(cwd);
444
445 eprintln!(
446 "Resuming: {} {} (cwd: {})",
447 binary,
448 args.join(" "),
449 cwd.display()
450 );
451
452 #[cfg(unix)]
453 {
454 use std::os::unix::process::CommandExt;
455 let err = cmd.exec();
457 anyhow::bail!(
458 "couldn't exec `{}`: {}. Recipe: {} {} (run from {})",
459 binary,
460 err,
461 binary,
462 args.join(" "),
463 cwd.display()
464 );
465 }
466 #[cfg(not(unix))]
467 {
468 let status = cmd
469 .spawn()
470 .with_context(|| format!("spawn {}", binary))?
471 .wait()
472 .with_context(|| format!("wait for {}", binary))?;
473 std::process::exit(status.code().unwrap_or(1));
474 }
475 }
476}
477
478#[derive(Default)]
481pub struct RecordingExec {
482 inner: std::sync::Mutex<CapturedExec>,
483}
484
485impl RecordingExec {
486 pub fn captured(&self) -> CapturedExec {
487 self.inner.lock().unwrap().clone()
488 }
489}
490
491impl ExecStrategy for RecordingExec {
492 fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> {
493 let mut g = self.inner.lock().unwrap();
494 *g = CapturedExec {
495 binary: binary.to_string(),
496 args: args.to_vec(),
497 cwd: cwd.to_path_buf(),
498 };
499 Ok(())
500 }
501}
502
503pub(crate) fn exec_harness(
504 binary: &str,
505 args: &[String],
506 cwd: &std::path::Path,
507 strategy: &dyn ExecStrategy,
508) -> Result<()> {
509 strategy.exec(binary, args, cwd)
510}
511
512fn looks_like_pathbase_shorthand(s: &str) -> bool {
513 if s.starts_with('.') || s.starts_with('/') {
517 return false;
518 }
519 let segs: Vec<&str> = s.split('/').collect();
520 segs.len() == 3
521 && segs
522 .iter()
523 .all(|s| !s.is_empty() && !s.contains(char::is_whitespace))
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() {
532 let _env = crate::config::TEST_ENV_LOCK
533 .lock()
534 .unwrap_or_else(|e| e.into_inner());
535 let _home = scoped_home_for_resume();
536 let _path_guard = ScopedPathForResume::with_binaries(&["claude"]);
537 let cwd = tempfile::tempdir().unwrap();
538 let doc_file = cwd.path().join("doc.json");
539
540 let mut path = make_convo_path_for_resume("claude-code://resume-test-session");
543 path.steps[0].step.actor = "agent:claude-code".to_string();
546
547 let graph = toolpath::v1::Graph::from_path(path);
548 std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap();
549
550 let args = ResumeArgs {
551 input: doc_file.to_string_lossy().to_string(),
552 cwd: Some(cwd.path().to_path_buf()),
553 harness: Some(HarnessArg::Claude),
554 no_cache: false,
555 force: false,
556 url: None,
557 };
558
559 let recorder = RecordingExec::default();
560 run_with_strategy(args, &recorder).unwrap();
561
562 let cap = recorder.captured();
563 assert_eq!(cap.binary, "claude");
564 assert_eq!(cap.args[0], "-r");
565 assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap());
566 }
567
568 use crate::cmd_share::Harness;
569 use toolpath::v1::{Graph, PathMeta, PathOrRef};
570
571 fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step {
572 toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z")
573 .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new")
574 }
575
576 fn make_path_with_actor(actor: &str) -> toolpath::v1::Path {
577 use toolpath::v1::{Path, PathIdentity};
578 let step = make_step_with_actor("s1", actor);
579 Path {
580 path: PathIdentity {
581 id: "p1".to_string(),
582 base: None,
583 head: "s1".to_string(),
584 graph_ref: None,
585 },
586 steps: vec![step],
587 meta: None,
588 }
589 }
590
591 #[test]
592 fn infer_source_harness_meta_source_wins() {
593 let mut path = make_path_with_actor("agent:codex");
594 path.meta = Some(PathMeta {
595 source: Some("claude-code".to_string()),
596 ..Default::default()
597 });
598 assert_eq!(infer_source_harness(&path), Some(Harness::Claude));
599 }
600
601 #[test]
602 fn infer_source_harness_meta_source_unknown_falls_through_to_actor() {
603 let mut path = make_path_with_actor("agent:gemini-cli");
604 path.meta = Some(PathMeta {
605 source: Some("something-bespoke".to_string()),
606 ..Default::default()
607 });
608 assert_eq!(infer_source_harness(&path), Some(Harness::Gemini));
609 }
610
611 #[test]
612 fn infer_source_harness_actor_sniff_codex() {
613 let path = make_path_with_actor("agent:codex");
614 assert_eq!(infer_source_harness(&path), Some(Harness::Codex));
615 }
616
617 #[test]
618 fn infer_source_harness_actor_sniff_opencode() {
619 let path = make_path_with_actor("agent:opencode");
620 assert_eq!(infer_source_harness(&path), Some(Harness::Opencode));
621 }
622
623 #[test]
624 fn infer_source_harness_actor_sniff_pi() {
625 let path = make_path_with_actor("agent:pi");
626 assert_eq!(infer_source_harness(&path), Some(Harness::Pi));
627 }
628
629 #[test]
630 fn infer_source_harness_returns_none_when_no_signal() {
631 let path = make_path_with_actor("human:alex");
632 assert_eq!(infer_source_harness(&path), None);
633 }
634
635 #[test]
636 fn ensure_path_with_agent_accepts_single_path_with_agent_actor() {
637 let g = Graph::from_path(make_path_with_actor("agent:claude-code"));
638 assert!(ensure_path_with_agent(&g).is_ok());
639 }
640
641 #[test]
642 fn ensure_path_with_agent_rejects_empty_graph() {
643 let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
644 g.paths.clear();
645 let err = ensure_path_with_agent(&g).unwrap_err();
646 assert!(err.to_string().contains("expected"));
647 assert!(err.to_string().contains("empty"));
648 }
649
650 #[test]
651 fn ensure_path_with_agent_rejects_multi_path_graph() {
652 let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
653 g.paths.push(PathOrRef::Path(Box::new(make_path_with_actor(
654 "agent:claude-code",
655 ))));
656 let err = ensure_path_with_agent(&g).unwrap_err();
657 let s = err.to_string();
658 assert!(s.contains("single `Path`"), "actual: {s}");
659 assert!(s.contains("2 paths"), "actual: {s}");
660 }
661
662 #[test]
663 fn ensure_path_with_agent_rejects_agentless_path() {
664 let g = Graph::from_path(make_path_with_actor("human:alex"));
665 let err = ensure_path_with_agent(&g).unwrap_err();
666 assert!(err.to_string().contains("no agent session"));
667 }
668
669 #[test]
670 fn ensure_path_with_agent_rejects_path_ref_only_graph() {
671 use toolpath::v1::PathRef;
672 let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
673 g.paths = vec![PathOrRef::Ref(PathRef {
674 ref_url: "$ref://something".into(),
675 })];
676 let err = ensure_path_with_agent(&g).unwrap_err();
677 assert!(err.to_string().contains("inline `Path`"), "actual: {}", err);
678 }
679
680 #[test]
681 fn resolve_input_file_path() {
682 let tmp = tempfile::tempdir().unwrap();
683 let p = tmp.path().join("doc.json");
684 let graph = toolpath::v1::Graph::from_path(make_path_with_actor("agent:claude-code"));
685 std::fs::write(&p, graph.to_json().unwrap()).unwrap();
686
687 let args = ResumeArgs {
688 input: p.to_string_lossy().to_string(),
689 cwd: None,
690 harness: None,
691 no_cache: false,
692 force: false,
693 url: None,
694 };
695 let (g, harness) = resolve_input(&args).unwrap();
696 let _path = ensure_path_with_agent(&g).unwrap();
697 assert_eq!(harness, Some(Harness::Claude));
698 }
699
700 #[test]
701 fn resolve_input_url_dispatches_to_pathbase_fetch() {
702 let _env = crate::config::TEST_ENV_LOCK
703 .lock()
704 .unwrap_or_else(|e| e.into_inner());
705 use crate::cmd_pathbase::tests::MockServer;
706 let body = {
707 let mut path = make_path_with_actor("agent:codex");
708 path.meta = Some(toolpath::v1::PathMeta {
709 source: Some("codex".to_string()),
710 ..Default::default()
711 });
712 toolpath::v1::Graph::from_path(path).to_json().unwrap()
713 };
714 let body_static: &'static str = Box::leak(body.into_boxed_str());
716 let server = MockServer::start("HTTP/1.1 200 OK", body_static);
717
718 let args = ResumeArgs {
719 input: format!(
720 "{}/u/alex/repos/pathstash/graphs/fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537",
721 server.base()
722 ),
723 cwd: None,
724 harness: None,
725 no_cache: true, force: false,
727 url: None,
728 };
729 let (g, harness) = resolve_input(&args).unwrap();
730 let _ = ensure_path_with_agent(&g).unwrap();
731 assert_eq!(harness, Some(Harness::Codex));
732 }
733
734 #[test]
735 fn resolve_input_url_uses_cache_on_hit_without_refetching() {
736 let _env = crate::config::TEST_ENV_LOCK
743 .lock()
744 .unwrap_or_else(|e| e.into_inner());
745
746 let cfg_dir = tempfile::tempdir().unwrap();
749 let prev_cfg = std::env::var_os("TOOLPATH_CONFIG_DIR");
750 unsafe {
751 std::env::set_var("TOOLPATH_CONFIG_DIR", cfg_dir.path());
752 }
753
754 const FIXTURE_UUID: &str = "fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537";
757 let cache_id = format!("pathbase-alex-pathstash-{FIXTURE_UUID}");
758 let cache_id = cache_id.as_str();
759 let documents = cfg_dir.path().join("documents");
760 std::fs::create_dir_all(&documents).unwrap();
761 let cached_graph = {
762 let mut path = make_path_with_actor("agent:codex");
763 path.meta = Some(toolpath::v1::PathMeta {
764 source: Some("codex".to_string()),
765 ..Default::default()
766 });
767 toolpath::v1::Graph::from_path(path)
768 };
769 std::fs::write(
770 documents.join(format!("{cache_id}.json")),
771 cached_graph.to_json().unwrap(),
772 )
773 .unwrap();
774
775 use crate::cmd_pathbase::tests::MockServer;
777 let server = MockServer::start("HTTP/1.1 500 Internal Server Error", "boom");
778
779 let args = ResumeArgs {
780 input: format!(
781 "{}/u/alex/repos/pathstash/graphs/{FIXTURE_UUID}",
782 server.base()
783 ),
784 cwd: None,
785 harness: None,
786 no_cache: false,
787 force: false,
788 url: None,
789 };
790 let result = resolve_input(&args);
791
792 unsafe {
794 match prev_cfg {
795 Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v),
796 None => std::env::remove_var("TOOLPATH_CONFIG_DIR"),
797 }
798 }
799
800 let (g, harness) = result.expect("resolve_input should reuse cache without refetching");
801 let _ = ensure_path_with_agent(&g).unwrap();
802 assert_eq!(harness, Some(Harness::Codex));
803 }
804
805 #[test]
806 fn resolve_input_unresolvable_errors_clearly() {
807 let _env = crate::config::TEST_ENV_LOCK
808 .lock()
809 .unwrap_or_else(|e| e.into_inner());
810 let args = ResumeArgs {
811 input: "definitely/not/a/real/cache/id".to_string(),
812 cwd: None,
813 harness: None,
814 no_cache: false,
815 force: false,
816 url: None,
817 };
818 let err = resolve_input(&args).unwrap_err();
819 let s = err.to_string();
820 assert!(s.contains("couldn't resolve"), "actual: {s}");
821 }
822
823 fn fake_path_with(binaries: &[&str]) -> tempfile::TempDir {
824 let td = tempfile::tempdir().unwrap();
825 for b in binaries {
826 let p = td.path().join(b);
827 std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap();
828 #[cfg(unix)]
829 {
830 use std::os::unix::fs::PermissionsExt;
831 let mut perm = std::fs::metadata(&p).unwrap().permissions();
832 perm.set_mode(0o755);
833 std::fs::set_permissions(&p, perm).unwrap();
834 }
835 }
836 td
837 }
838
839 #[test]
840 fn binary_on_path_finds_present_binary() {
841 let td = fake_path_with(&["claude"]);
842 assert!(binary_on_path("claude", Some(td.path())));
843 assert!(!binary_on_path("gemini", Some(td.path())));
844 }
845
846 #[test]
847 fn pick_harness_explicit_arg_validates_path() {
848 let td = fake_path_with(&["claude"]);
849 let result = pick_harness(Some(HarnessArg::Claude), None, Some(td.path()));
850 assert_eq!(result.unwrap(), Harness::Claude);
851
852 let err = pick_harness(Some(HarnessArg::Gemini), None, Some(td.path())).unwrap_err();
853 assert!(err.to_string().contains("`gemini` isn't on PATH"));
854 }
855
856 #[test]
857 fn pick_harness_zero_installed_errors() {
858 let td = fake_path_with(&[]);
859 let err = pick_harness(None, Some(Harness::Claude), Some(td.path())).unwrap_err();
860 assert!(
861 err.to_string().contains("no installed harnesses")
862 || err.to_string().contains("no harnesses on PATH"),
863 "actual: {}",
864 err
865 );
866 }
867
868 #[test]
869 fn argv_for_returns_harness_specific_shape() {
870 assert_eq!(
871 argv_for(Harness::Claude, "abc"),
872 vec!["-r".to_string(), "abc".to_string()]
873 );
874 assert_eq!(
875 argv_for(Harness::Gemini, "abc"),
876 vec!["--resume".to_string(), "abc".to_string()]
877 );
878 assert_eq!(
879 argv_for(Harness::Codex, "abc"),
880 vec!["resume".to_string(), "abc".to_string()]
881 );
882 assert_eq!(
883 argv_for(Harness::Opencode, "abc"),
884 vec!["--session".to_string(), "abc".to_string()]
885 );
886 assert_eq!(
887 argv_for(Harness::Pi, "abc"),
888 vec!["--session".to_string(), "abc".to_string()]
889 );
890 }
891
892 #[test]
893 fn project_into_harness_claude_round_trip() {
894 let _env = crate::config::TEST_ENV_LOCK
895 .lock()
896 .unwrap_or_else(|e| e.into_inner());
897 let _home = scoped_home_for_resume();
898 let cwd = tempfile::tempdir().unwrap();
899 let path = make_convo_path_for_resume("claude-code://resume-test-session");
900
901 let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap();
902 assert!(!session_id.is_empty());
903 }
904
905 fn make_convo_path_for_resume(artifact_key: &str) -> toolpath::v1::Path {
909 use std::collections::HashMap;
910 let mut extra = HashMap::new();
911 extra.insert("role".to_string(), serde_json::json!("user"));
912 extra.insert("text".to_string(), serde_json::json!("hello"));
913 let step = toolpath::v1::Step {
914 step: toolpath::v1::StepIdentity {
915 id: "s1".to_string(),
916 parents: vec![],
917 actor: "human:test".to_string(),
918 timestamp: "2026-01-01T00:00:00Z".to_string(),
919 },
920 change: {
921 let mut m = HashMap::new();
922 m.insert(
923 artifact_key.to_string(),
924 toolpath::v1::ArtifactChange {
925 raw: None,
926 structural: Some(toolpath::v1::StructuralChange {
927 change_type: "conversation.append".to_string(),
928 extra,
929 }),
930 },
931 );
932 m
933 },
934 meta: None,
935 };
936 toolpath::v1::Path {
937 path: toolpath::v1::PathIdentity {
938 id: "test-path".to_string(),
939 base: None,
940 head: "s1".to_string(),
941 graph_ref: None,
942 },
943 steps: vec![step],
944 meta: None,
945 }
946 }
947
948 fn scoped_home_for_resume() -> ScopedHomeForResume {
949 ScopedHomeForResume::new()
950 }
951
952 struct ScopedPathForResume {
953 _bin_dir: tempfile::TempDir,
954 prev: Option<std::ffi::OsString>,
955 }
956
957 impl ScopedPathForResume {
958 fn with_binaries(binaries: &[&str]) -> Self {
961 let bin_dir = fake_path_with(binaries);
962 let prev = std::env::var_os("PATH");
963 let new_path = std::env::join_paths(
964 std::iter::once(bin_dir.path().to_path_buf())
965 .chain(std::env::split_paths(&prev.clone().unwrap_or_default())),
966 )
967 .unwrap();
968 unsafe {
969 std::env::set_var("PATH", new_path);
970 }
971 Self {
972 _bin_dir: bin_dir,
973 prev,
974 }
975 }
976 }
977
978 impl Drop for ScopedPathForResume {
979 fn drop(&mut self) {
980 unsafe {
981 match &self.prev {
982 Some(v) => std::env::set_var("PATH", v),
983 None => std::env::remove_var("PATH"),
984 }
985 }
986 }
987 }
988
989 struct ScopedHomeForResume {
990 _td: tempfile::TempDir,
991 prev: Option<std::ffi::OsString>,
992 }
993
994 impl ScopedHomeForResume {
995 fn new() -> Self {
996 let td = tempfile::tempdir().unwrap();
997 let prev = std::env::var_os("HOME");
998 unsafe {
999 std::env::set_var("HOME", td.path());
1000 }
1001 Self { _td: td, prev }
1002 }
1003 }
1004
1005 impl Drop for ScopedHomeForResume {
1006 fn drop(&mut self) {
1007 unsafe {
1008 match &self.prev {
1009 Some(v) => std::env::set_var("HOME", v),
1010 None => std::env::remove_var("HOME"),
1011 }
1012 }
1013 }
1014 }
1015
1016 #[test]
1017 fn exec_strategy_recording_captures_invocation() {
1018 let recorder = RecordingExec::default();
1019 let strategy: &dyn ExecStrategy = &recorder;
1020 exec_harness(
1021 "claude",
1022 &["-r".into(), "abc123".into()],
1023 std::path::Path::new("/tmp/x"),
1024 strategy,
1025 )
1026 .unwrap();
1027
1028 let captured = recorder.captured();
1029 assert_eq!(captured.binary, "claude");
1030 assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]);
1031 assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x"));
1032 }
1033}