use std::{fs, path::PathBuf, str};
use repo::{Repository, ThreadManager};
use serde_json::Value;
use tempfile::TempDir;
use super::{assert_json_recovery_advice_fields, heddle, heddle_output};
fn setup_thread(name: &str) -> TempDir {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("base.txt"), "base").unwrap();
heddle(&["capture", "-m", "init"], Some(temp.path())).unwrap();
heddle(&["thread", "create", name], Some(temp.path())).unwrap();
heddle(&["thread", "switch", name], Some(temp.path())).unwrap();
temp
}
fn detach_head_to_current_state(path: &std::path::Path) {
let repo = Repository::open(path).unwrap();
let head = repo
.head()
.unwrap()
.expect("repo should have a current state before detaching");
fs::write(
path.join(".heddle").join("HEAD"),
format!("{}\n", head.to_string_full()),
)
.unwrap();
}
#[test]
fn thread_show_without_arg_resolves_current_thread() {
let repo = setup_thread("probe");
let omitted = heddle(&["--output", "json", "thread", "show"], Some(repo.path()))
.expect("thread show should succeed without a positional when HEAD is attached");
let with_arg = heddle(
&["--output", "json", "thread", "show", "probe"],
Some(repo.path()),
)
.expect("thread show with explicit positional should still succeed");
let omitted: Value = serde_json::from_str(&omitted).unwrap();
let with_arg: Value = serde_json::from_str(&with_arg).unwrap();
assert_eq!(
omitted["name"], with_arg["name"],
"omitted positional should resolve to the same thread as explicit"
);
assert_eq!(omitted["name"].as_str(), Some("probe"));
}
#[test]
fn thread_captures_without_arg_resolves_current_thread() {
let repo = setup_thread("probe");
heddle(
&["--output", "json", "thread", "captures"],
Some(repo.path()),
)
.expect("thread captures should succeed without a positional when HEAD is attached");
}
#[test]
fn thread_show_without_arg_errors_when_no_current_thread() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("base.txt"), "base").unwrap();
heddle(&["capture", "-m", "init"], Some(temp.path())).unwrap();
let repo = Repository::open(temp.path()).unwrap();
let head = repo
.head()
.unwrap()
.expect("repo should have a current state after snapshot");
fs::write(
temp.path().join(".heddle").join("HEAD"),
format!("{}\n", head.to_string_full()),
)
.unwrap();
drop(repo);
let err = heddle(&["thread", "show"], Some(temp.path()))
.expect_err("thread show should fail when HEAD has no attached thread");
assert!(
err.contains("No current thread; pass <THREAD>"),
"expected the explicit fallback error message; got: {err}"
);
assert!(
err.contains("heddle thread show <THREAD>"),
"expected guidance on how to recover; got: {err}"
);
let json_output = heddle_output(&["--output", "json", "thread", "show"], Some(temp.path()))
.expect("thread show JSON failure should run");
assert!(
!json_output.status.success(),
"thread show should fail when HEAD has no attached thread"
);
let stderr = str::from_utf8(&json_output.stderr).expect("stderr should be utf8");
let envelope: Value = serde_json::from_str(stderr.trim()).expect("stderr should be JSON");
assert_eq!(envelope["kind"], "no_current_thread");
assert!(
envelope["error"]
.as_str()
.is_some_and(|error| error.contains("pass <THREAD>")),
"thread show JSON error should name the missing selector: {envelope}"
);
assert_eq!(envelope["primary_command"], "heddle thread show <THREAD>");
assert_json_recovery_advice_fields(&envelope, stderr);
}
#[test]
fn thread_current_detached_head_uses_typed_advice() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("base.txt"), "base").unwrap();
heddle(&["capture", "-m", "init"], Some(temp.path())).unwrap();
detach_head_to_current_state(temp.path());
let output = heddle_output(
&["--output", "json", "thread", "current"],
Some(temp.path()),
)
.expect("thread current JSON failure should run");
assert!(
!output.status.success(),
"thread current should fail when HEAD has no attached thread"
);
assert!(
output.stdout.is_empty(),
"JSON failure should keep stdout quiet: {}",
str::from_utf8(&output.stdout).unwrap_or("")
);
let stderr = str::from_utf8(&output.stderr).expect("stderr should be utf8");
let envelope: Value = serde_json::from_str(stderr.trim()).expect("stderr should be JSON");
assert_eq!(envelope["kind"], "no_current_thread");
assert_eq!(envelope["primary_command"], "heddle thread list");
assert_json_recovery_advice_fields(&envelope, stderr);
}
#[test]
fn thread_cd_without_available_worktree_uses_typed_advice() {
let temp = setup_thread("cd-target");
let repo = Repository::open(temp.path()).unwrap();
let manager = ThreadManager::new(repo.heddle_dir());
let mut thread = manager
.load("cd-target")
.unwrap()
.expect("thread record exists after create");
thread.execution_path = PathBuf::new();
manager.save(&thread).unwrap();
drop(repo);
let output = heddle_output(&["thread", "cd", "cd-target"], Some(temp.path()))
.expect("thread cd failure should run");
assert!(
!output.status.success(),
"thread cd should fail when the thread has no checkout path"
);
assert!(
output.stdout.is_empty(),
"thread cd failure should keep stdout quiet: {}",
str::from_utf8(&output.stdout).unwrap_or("")
);
let stderr = str::from_utf8(&output.stderr).expect("stderr should be utf8");
assert!(
stderr.contains("no available filesystem checkout")
&& stderr.contains("heddle thread show cd-target"),
"thread cd should surface typed advice text: {stderr}"
);
}
#[test]
fn thread_show_without_arg_resolves_via_execution_path_when_detached() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("base.txt"), "base").unwrap();
heddle(&["capture", "-m", "init"], Some(temp.path())).unwrap();
heddle(&["thread", "create", "feat/probe"], Some(temp.path())).unwrap();
heddle(&["thread", "switch", "feat/probe"], Some(temp.path())).unwrap();
let repo = Repository::open(temp.path()).unwrap();
let manager = ThreadManager::new(repo.heddle_dir());
let mut thread = manager
.load("feat/probe")
.unwrap()
.expect("thread record exists after create");
thread.execution_path = temp.path().to_path_buf();
manager.save(&thread).unwrap();
let head = repo
.head()
.unwrap()
.expect("repo should have a current state after snapshot");
fs::write(
temp.path().join(".heddle").join("HEAD"),
format!("{}\n", head.to_string_full()),
)
.unwrap();
drop(repo);
let with_arg = heddle(
&["--output", "json", "thread", "show", "feat/probe"],
Some(temp.path()),
)
.expect("thread show with positional should succeed");
let with_arg: Value = serde_json::from_str(&with_arg).unwrap();
assert_eq!(with_arg["name"].as_str(), Some("feat/probe"));
let omitted = heddle(&["--output", "json", "thread", "show"], Some(temp.path())).expect(
"thread show without positional must resolve via execution_path when HEAD is detached",
);
let omitted: Value = serde_json::from_str(&omitted).unwrap();
assert_eq!(
omitted["name"].as_str(),
Some("feat/probe"),
"execution-path fallback should resolve to the seeded thread; got {omitted}"
);
}
#[test]
fn thread_refresh_without_arg_does_not_require_positional() {
let repo = setup_thread("probe");
let result = heddle(&["thread", "refresh"], Some(repo.path()));
if let Err(err) = result {
assert!(
!err.contains("required arguments were not provided"),
"thread refresh should not require <THREAD> at the clap layer; got: {err}"
);
assert!(
!err.contains("<THREAD>"),
"thread refresh should not surface <THREAD> as a missing argument; got: {err}"
);
}
}