use std::path::{Path, PathBuf};
use std::process::Command;
use alog::{MessageLevel, alog_channel, use_channel};
use anyhow::Context;
use_channel!("BOB");
pub struct HookRegistration {
pub capture_path: PathBuf,
#[allow(dead_code)]
pub lock_path: PathBuf,
pub marker_command: String,
}
fn bob_dir(workspace: &Path) -> PathBuf {
workspace.join(".bob")
}
fn escape_workspace(workspace: &Path) -> String {
workspace
.to_string_lossy()
.replace(['/', '\\', ':'], crate::config::PATH_DELIM)
}
fn capture_path(launcher_id: &str, workspace: &Path) -> anyhow::Result<PathBuf> {
let dir = crate::config::Config::launcher_state_dir(launcher_id)?;
Ok(dir.join(format!("{}.capture.json", escape_workspace(workspace))))
}
fn lock_path(launcher_id: &str, workspace: &Path) -> anyhow::Result<PathBuf> {
let dir = crate::config::Config::launcher_state_dir(launcher_id)?;
Ok(dir.join(format!("{}.lock", escape_workspace(workspace))))
}
pub fn shell_quote(s: &str) -> String {
#[cfg(unix)]
return shell_quote_unix(s);
#[cfg(windows)]
return shell_quote_windows(s);
}
#[cfg(unix)]
fn shell_quote_unix(s: &str) -> String {
if s.contains('\'') {
let parts: Vec<_> = s.split('\'').collect();
let escaped = parts.join("'\"'\"'");
format!("'{escaped}'")
} else {
format!("'{s}'")
}
}
#[cfg(windows)]
fn shell_quote_windows(s: &str) -> String {
if s.contains(|c: char| c == '"') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
format!("\"{s}\"")
}
}
pub fn build_marker_command(capture_path: &Path, lock_path: &Path) -> anyhow::Result<String> {
let exe = std::env::current_exe()?;
Ok(build_marker_command_with_exe(&exe, capture_path, lock_path))
}
pub fn build_marker_command_with_exe(exe: &Path, capture_path: &Path, lock_path: &Path) -> String {
let exe_q = shell_quote(exe.to_string_lossy().as_ref());
let cap_q = shell_quote(capture_path.to_string_lossy().as_ref());
let lock_q = shell_quote(lock_path.to_string_lossy().as_ref());
format!("{exe_q} internal bob-hook-capture {cap_q} --ancestor-lock {lock_q}")
}
pub fn read_settings(workspace: &Path) -> serde_json::Value {
let settings_file = bob_dir(workspace).join("settings.json");
match std::fs::read_to_string(&settings_file) {
Ok(content) => match content.trim().is_empty() {
true => serde_json::json!({}),
false => match serde_json::from_str(&content) {
Ok(value) => value,
Err(e) => {
alog_channel!(
MessageLevel::Debug,
"read_settings: failed to parse {}: {}",
settings_file.display(),
e
);
serde_json::json!({})
}
},
},
Err(_) => serde_json::json!({}), }
}
pub fn write_settings(workspace: &Path, value: &serde_json::Value) -> anyhow::Result<()> {
let bob_dir_path = bob_dir(workspace);
let settings_file = bob_dir_path.join("settings.json");
let serialized = serde_json::to_string_pretty(value)?;
std::fs::write(&settings_file, format!("{serialized}\n"))?;
Ok(())
}
pub fn hooks_session_start_contains(settings: &serde_json::Value, marker_command: &str) -> bool {
let session_start = settings
.get("hooks")
.and_then(|h| h.get("SessionStart"))
.and_then(|ss| ss.as_array());
session_start.is_some_and(|arr| {
arr.iter().any(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|hooks_arr| {
hooks_arr.iter().any(|hook| {
hook.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c == marker_command)
})
})
})
})
}
pub fn add_marker(settings: &mut serde_json::Value, marker_command: &str) {
if !settings.is_object() {
*settings = serde_json::json!({});
}
let new_group = serde_json::json!({
"hooks": [
{
"type": "command",
"command": marker_command,
"timeout": 5
}
]
});
let hooks = settings
.as_object_mut()
.expect("settings is an object")
.entry("hooks")
.or_insert_with(|| serde_json::Value::Object(Default::default()))
.as_object_mut()
.expect("hooks is an object");
let session_start = hooks
.entry("SessionStart")
.or_insert_with(|| serde_json::Value::Array(vec![]))
.as_array_mut()
.expect("SessionStart should be an array after or_insert_with");
session_start.push(new_group);
}
pub fn remove_marker(settings: &mut serde_json::Value, marker_command: &str) {
let hooks = match settings.get_mut("hooks") {
Some(hooks) => hooks,
None => return,
};
let session_start = match hooks.get_mut("SessionStart") {
Some(ss) => ss,
None => return,
};
let arr = match session_start.as_array_mut() {
Some(arr) => arr,
None => return,
};
let mut rebuilt = Vec::new();
for group in arr.drain(..) {
if let Some(hooks_arr) = group.get("hooks").and_then(|h| h.as_array()) {
let filtered: Vec<_> = hooks_arr
.iter()
.filter(|hook| hook.get("command").and_then(|c| c.as_str()) != Some(marker_command))
.cloned()
.collect();
if !filtered.is_empty() {
let mut new_group = group.clone();
if let Some(obj) = new_group.as_object_mut() {
obj.insert("hooks".to_string(), serde_json::Value::Array(filtered));
}
rebuilt.push(new_group);
}
}
}
*arr = rebuilt;
if arr.is_empty()
&& let Some(hooks_obj) = hooks.as_object_mut()
{
hooks_obj.remove("SessionStart");
}
}
pub fn is_pid_alive(pid: u32) -> bool {
#[cfg(unix)]
{
Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.status()
.is_ok_and(|s| s.success())
}
#[cfg(windows)]
{
let output = Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output();
matches!(output, Ok(o) if String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
}
}
#[cfg(unix)]
fn parent_pid(pid: u32) -> Result<Option<u32>, ()> {
let output = Command::new("ps")
.args(["-o", "ppid=", "-p", pid.to_string().as_str()])
.output()
.map_err(|_| ())?;
if !output.status.success() {
return Err(());
}
let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
if trimmed.is_empty() {
return Err(());
}
trimmed.parse::<u32>().map(Some).map_err(|_| ())
}
#[cfg(windows)]
fn parent_pid(pid: u32) -> Result<Option<u32>, ()> {
let script =
format!("(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').ParentProcessId");
let output = Command::new("powershell")
.args(&["-NoProfile", "-Command", &script])
.output()
.map_err(|_| ())?;
let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
if trimmed.is_empty() {
return Ok(None);
}
trimmed
.parse::<u32>()
.map(|ppid| Some(ppid))
.map_err(|_| ())
}
pub(crate) fn is_process_ancestor(expected_pid: u32, max_depth: usize) -> Option<bool> {
if std::process::id() == expected_pid {
return Some(true);
}
let mut pid = std::process::id();
for _ in 0..max_depth {
match parent_pid(pid) {
Ok(Some(0)) | Ok(None) => return Some(false), Ok(Some(p)) => {
if p == expected_pid {
return Some(true);
}
pid = p;
}
Err(()) => return None, }
}
Some(false) }
pub fn register_or_fail(launcher_id: &str, workspace: &Path) -> anyhow::Result<HookRegistration> {
let cp = capture_path(launcher_id, workspace)?;
let lp = lock_path(launcher_id, workspace)?;
let state_dir = crate::config::Config::launcher_state_dir(launcher_id)
.with_context(|| "failed to resolve launcher state directory for usage-tracking files")?;
std::fs::create_dir_all(&state_dir).with_context(|| {
format!(
"failed to create launcher state directory `{}`",
state_dir.display()
)
})?;
let mc = build_marker_command(&cp, &lp)?;
let mut settings = read_settings(workspace);
if hooks_session_start_contains(&settings, &mc) {
let lock_content = std::fs::read_to_string(&lp).unwrap_or_default();
let pid: u32 = lock_content.trim().parse().unwrap_or(0);
if pid != 0 && is_pid_alive(pid) {
anyhow::bail!(
"Another granite-cli-managed Bob session is already tracking usage in this workspace (pid {pid})."
);
}
alog_channel!(
MessageLevel::Warning,
"Reclaiming a stale usage-tracking hook left by a previous granite-cli process (pid {pid})."
);
}
add_marker(&mut settings, &mc);
if let Err(e) = write_settings(workspace, &settings) {
remove_marker_from_file(workspace, &mc);
return Err(e);
}
let pid = std::process::id();
if let Err(e) = std::fs::write(&lp, format!("{pid}")) {
remove_marker_from_file(workspace, &mc);
return Err(anyhow::Error::from(e));
}
Ok(HookRegistration {
capture_path: cp,
lock_path: lp,
marker_command: mc,
})
}
fn remove_marker_from_file(workspace: &Path, marker_command: &str) {
let mut settings = read_settings(workspace);
remove_marker(&mut settings, marker_command);
let _ = write_settings(workspace, &settings);
}
pub fn unregister(launcher_id: &str, workspace: &Path, marker_command: &str) {
let mut settings = read_settings(workspace);
remove_marker(&mut settings, marker_command);
let _ = write_settings(workspace, &settings);
match lock_path(launcher_id, workspace) {
Ok(lp) => {
let _ = std::fs::remove_file(&lp);
}
Err(e) => {
alog_channel!(
MessageLevel::Debug,
"unregister: could not resolve lock path: {}",
e
);
}
}
}
pub fn try_read_capture(capture_path: &Path) -> Option<String> {
let content = std::fs::read_to_string(capture_path).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
value.get("session_id")?.as_str().map(String::from)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, TestConfigHome};
#[cfg(unix)]
#[test]
fn shell_quote_simple_path_unquoted() {
assert_eq!(
shell_quote("/usr/local/bin/granite-cli"),
"'/usr/local/bin/granite-cli'"
);
}
#[cfg(unix)]
#[test]
fn shell_quote_path_with_spaces() {
let result = shell_quote("/path/with spaces/granite-cli");
assert_eq!(result, "'/path/with spaces/granite-cli'");
}
#[cfg(unix)]
#[test]
fn shell_quote_path_with_embedded_single_quote() {
let result = shell_quote("/path/with'quote/granite-cli");
assert_eq!(result, "'/path/with'\"'\"'quote/granite-cli'");
}
#[cfg(unix)]
#[test]
fn shell_quote_path_with_multiple_embedded_quotes() {
let result = shell_quote("/a'b'c");
assert_eq!(result, "'/a'\"'\"'b'\"'\"'c'");
}
#[cfg(windows)]
#[test]
fn shell_quote_simple_path_unquoted() {
assert_eq!(
shell_quote(r"C:\bin\granite-cli"),
"\"C:\\bin\\granite-cli\""
);
}
#[cfg(windows)]
#[test]
fn shell_quote_path_with_spaces() {
let result = shell_quote(r"C:\path\with spaces\granite-cli");
assert_eq!(result, "\"C:\\path\\with spaces\\granite-cli\"");
}
#[cfg(windows)]
#[test]
fn shell_quote_path_with_embedded_double_quote() {
let result = shell_quote(r#"C:\path\with"quote\granite-cli"#);
assert_eq!(result, "\"C:\\path\\with\"\"quote\\granite-cli\"");
}
#[test]
fn build_marker_command_with_exe_format() {
let exe = Path::new("/usr/local/bin/granite-cli");
let capture = Path::new("/tmp/ws/.bob/.granite-cli-usage-capture.json");
let lock = Path::new("/tmp/ws/.bob/.granite-cli-usage.lock");
let result = build_marker_command_with_exe(exe, capture, lock);
let quote = if cfg!(windows) { "\"" } else { "'" };
assert!(result.starts_with(quote));
assert!(result.contains("internal bob-hook-capture"));
assert!(result.contains(".granite-cli-usage-capture.json"));
assert!(result.contains("--ancestor-lock"));
assert!(result.contains(".granite-cli-usage.lock"));
}
#[cfg(unix)]
#[test]
fn build_marker_command_with_exe_embedded_quote() {
let exe = Path::new("/path/with'quote/granite-cli");
let capture = Path::new("/tmp/ws/.bob/.granite-cli-usage-capture.json");
let lock = Path::new("/tmp/ws/.bob/.granite-cli-usage.lock");
let result = build_marker_command_with_exe(exe, capture, lock);
assert!(result.contains("internal bob-hook-capture"));
assert!(result.contains("\"'\"'"));
}
#[cfg(windows)]
#[test]
fn build_marker_command_with_exe_embedded_quote() {
let exe = Path::new(r#"C:\path\with"quote\granite-cli"#);
let capture = Path::new(r"C:\ws\.bob\.granite-cli-usage-capture.json");
let lock = Path::new(r"C:\ws\.bob\.granite-cli-usage.lock");
let result = build_marker_command_with_exe(exe, capture, lock);
assert!(result.contains("internal bob-hook-capture"));
assert!(result.contains("with\"\"quote"));
}
#[test]
fn read_settings_empty_file_returns_empty_object() {
let tmp = tempfile::tempdir().unwrap();
let bob_dir_path = tmp.path().join(".bob");
std::fs::create_dir_all(&bob_dir_path).unwrap();
std::fs::write(bob_dir_path.join("settings.json"), "").unwrap();
let value = read_settings(tmp.path());
assert!(value.is_object());
assert!(value.as_object().unwrap().is_empty());
}
#[test]
fn read_settings_missing_file_returns_empty_object() {
let tmp = tempfile::tempdir().unwrap();
let value = read_settings(tmp.path());
assert!(value.is_object());
assert!(value.as_object().unwrap().is_empty());
}
#[test]
fn read_settings_invalid_json_returns_empty_object() {
let tmp = tempfile::tempdir().unwrap();
let bob_dir_path = tmp.path().join(".bob");
std::fs::create_dir_all(&bob_dir_path).unwrap();
std::fs::write(bob_dir_path.join("settings.json"), "not json {{{").unwrap();
let value = read_settings(tmp.path());
assert!(value.is_object());
assert!(value.as_object().unwrap().is_empty());
}
#[test]
fn read_settings_valid_json_returns_parsed_value() {
let tmp = tempfile::tempdir().unwrap();
let bob_dir_path = tmp.path().join(".bob");
std::fs::create_dir_all(&bob_dir_path).unwrap();
let content = serde_json::json!({
"existingKey": "existingValue",
"hooks": {"SessionStart": []}
});
std::fs::write(bob_dir_path.join("settings.json"), content.to_string()).unwrap();
let value = read_settings(tmp.path());
assert_eq!(value["existingKey"], "existingValue");
assert!(value["hooks"]["SessionStart"].is_array());
}
#[test]
fn write_settings_writes_file_when_dir_exists() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".bob")).unwrap();
let value = serde_json::json!({"key": "value"});
write_settings(tmp.path(), &value).unwrap();
let content =
std::fs::read_to_string(tmp.path().join(".bob").join("settings.json")).unwrap();
assert!(content.contains('\n'));
assert!(content.contains("\"key\""));
}
#[test]
fn session_start_contains_empty_hooks_array() {
let settings = serde_json::json!({"hooks": {"SessionStart": []}});
assert!(!hooks_session_start_contains(&settings, "anything"));
}
#[test]
fn session_start_contains_matching_command() {
let settings = serde_json::json!({
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "my-marker"}]}
]
}
});
assert!(hooks_session_start_contains(&settings, "my-marker"));
}
#[test]
fn session_start_contains_non_matching_command() {
let settings = serde_json::json!({
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "other-marker"}]}
]
}
});
assert!(!hooks_session_start_contains(&settings, "my-marker"));
}
#[test]
fn session_start_contains_missing_hooks_key_returns_false() {
let settings = serde_json::json!({});
assert!(!hooks_session_start_contains(&settings, "anything"));
}
#[test]
fn session_start_contains_missing_session_start_returns_false() {
let settings = serde_json::json!({"hooks": {}});
assert!(!hooks_session_start_contains(&settings, "anything"));
}
#[test]
fn session_start_contains_multiple_groups_finds_first() {
let settings = serde_json::json!({
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "first"}]},
{"hooks": [{"type": "command", "command": "second"}]}
]
}
});
assert!(hooks_session_start_contains(&settings, "second"));
}
#[test]
fn session_start_contains_deeply_nested_missing_command_field() {
let settings = serde_json::json!({
"hooks": {
"SessionStart": [
{"hooks": [{"type": "script", "script": "echo hi"}]}
]
}
});
assert!(!hooks_session_start_contains(&settings, "anything"));
}
#[test]
fn add_marker_to_empty_object_creces_hooks_and_session_start() {
let mut settings = serde_json::json!({});
add_marker(&mut settings, "my-marker");
let hooks = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(hooks.len(), 1);
let hook = &hooks[0]["hooks"][0];
assert_eq!(hook["type"], "command");
assert_eq!(hook["command"], "my-marker");
assert_eq!(hook["timeout"], 5);
}
#[test]
fn add_marker_preserves_unrelated_keys() {
let mut settings = serde_json::json!({
"otherKey": "otherValue",
"hooks": {
"PreToolUse": [{"hooks": [{"type": "command", "command": "user-hook"}]}]
}
});
add_marker(&mut settings, "my-marker");
assert_eq!(settings["otherKey"], "otherValue");
assert!(settings["hooks"]["PreToolUse"].is_array());
assert_eq!(
settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"user-hook"
);
let ss = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(ss.len(), 1);
assert_eq!(ss[0]["hooks"][0]["command"], "my-marker");
}
#[test]
fn add_marker_appends_to_existing_session_start() {
let mut settings = serde_json::json!({
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "existing-marker"}]}
]
}
});
add_marker(&mut settings, "my-marker");
let ss = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(ss.len(), 2);
assert_eq!(ss[0]["hooks"][0]["command"], "existing-marker");
assert_eq!(ss[1]["hooks"][0]["command"], "my-marker");
}
#[test]
fn remove_marker_removes_matching_group() {
let mut settings = serde_json::json!({
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "my-marker"}]}
]
}
});
remove_marker(&mut settings, "my-marker");
assert!(
settings["hooks"]["SessionStart"].is_null()
|| settings["hooks"]["SessionStart"]
.as_array()
.is_none_or(|a| a.is_empty())
);
}
#[test]
fn remove_marker_preserves_non_matching_groups() {
let mut settings = serde_json::json!({
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "my-marker"}]},
{"hooks": [{"type": "command", "command": "keep-me"}]}
]
}
});
remove_marker(&mut settings, "my-marker");
let ss = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(ss.len(), 1);
assert_eq!(ss[0]["hooks"][0]["command"], "keep-me");
}
#[test]
fn remove_marker_preserves_unrelated_keys() {
let mut settings = serde_json::json!({
"otherKey": "otherValue",
"hooks": {
"PreToolUse": [{"hooks": [{"type": "command", "command": "user-hook"}]}],
"SessionStart": [
{"hooks": [{"type": "command", "command": "my-marker"}]}
]
}
});
remove_marker(&mut settings, "my-marker");
assert_eq!(settings["otherKey"], "otherValue");
assert!(settings["hooks"]["PreToolUse"].is_array());
assert!(
settings["hooks"]["SessionStart"].is_null()
|| settings["hooks"]["SessionStart"]
.as_array()
.is_none_or(|a| a.is_empty())
);
}
#[test]
fn remove_marker_removes_only_matching_hook_within_group() {
let mut settings = serde_json::json!({
"hooks": {
"SessionStart": [
{
"hooks": [
{"type": "command", "command": "my-marker"},
{"type": "command", "command": "keep-me"}
]
}
]
}
});
remove_marker(&mut settings, "my-marker");
let ss = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(ss.len(), 1);
let hooks = &ss[0]["hooks"];
assert!(hooks.is_array());
assert_eq!(hooks.as_array().unwrap().len(), 1);
assert_eq!(hooks[0]["command"], "keep-me");
}
#[test]
fn remove_marker_on_empty_hooks_is_noop() {
let mut settings = serde_json::json!({});
remove_marker(&mut settings, "anything");
assert_eq!(settings, serde_json::json!({}));
}
#[test]
fn full_roundtrip_empty_settings() {
let mut settings = serde_json::json!({});
add_marker(&mut settings, "my-marker");
assert!(hooks_session_start_contains(&settings, "my-marker"));
remove_marker(&mut settings, "my-marker");
assert!(!hooks_session_start_contains(&settings, "my-marker"));
}
#[test]
fn full_roundtrip_preserves_unrelated_content() {
let mut settings = serde_json::json!({
"otherKey": "otherValue",
"hooks": {
"PreToolUse": [{"hooks": [{"type": "command", "command": "user-hook"}]}]
}
});
add_marker(&mut settings, "my-marker");
assert_eq!(settings["otherKey"], "otherValue");
assert_eq!(
settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"user-hook"
);
remove_marker(&mut settings, "my-marker");
assert_eq!(settings["otherKey"], "otherValue");
assert_eq!(
settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"user-hook"
);
assert!(!hooks_session_start_contains(&settings, "my-marker"));
}
#[test]
fn try_read_capture_missing_file_returns_none() {
assert!(try_read_capture(Path::new("/no/such/file.json")).is_none());
}
#[test]
fn try_read_capture_valid_session_start_returns_session_id() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("capture.json");
std::fs::write(
&path,
r#"{"hook_event_name": "SessionStart", "session_id": "task-123"}"#,
)
.unwrap();
assert_eq!(try_read_capture(&path), Some("task-123".to_string()));
}
#[test]
fn try_read_capture_wrong_event_returns_session_id() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("capture.json");
std::fs::write(
&path,
r#"{"hook_event_name": "PostToolUse", "session_id": "task-123"}"#,
)
.unwrap();
assert_eq!(try_read_capture(&path), Some("task-123".to_string()));
}
#[test]
fn try_read_capture_missing_session_id_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("capture.json");
std::fs::write(&path, r#"{"hook_event_name": "SessionStart"}"#).unwrap();
assert!(try_read_capture(&path).is_none());
}
#[test]
fn try_read_capture_invalid_json_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("capture.json");
std::fs::write(&path, "not json").unwrap();
assert!(try_read_capture(&path).is_none());
}
#[test]
fn try_read_capture_non_object_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("capture.json");
std::fs::write(&path, r#"[1, 2, 3]"#).unwrap();
assert!(try_read_capture(&path).is_none());
}
#[test]
fn is_pid_alive_current_process_is_alive() {
assert!(is_pid_alive(std::process::id()));
}
#[test]
fn is_pid_alive_unlikely_pid_is_not_alive() {
let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
.args(if cfg!(windows) {
&["/C", "exit", "0"][..]
} else {
&[][..]
})
.spawn()
.unwrap();
let pid = child.id();
child.wait().unwrap();
assert!(!is_pid_alive(pid));
}
#[cfg_attr(
windows,
ignore = "parent_pid's WMI-based Windows lookup is unreliable on GH's Windows CI runner -- see comment above"
)]
#[test]
fn parent_pid_returns_test_process_as_parent_of_spawned_child() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("release");
let marker_q = shell_quote(marker.to_string_lossy().as_ref());
#[cfg(unix)]
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg(format!("while [ ! -f {marker_q} ]; do sleep 0.05; done"))
.spawn()
.expect("failed to spawn polling shell");
#[cfg(windows)]
let mut child = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!("while (-not (Test-Path {marker_q})) {{ Start-Sleep -Milliseconds 50 }}"),
])
.spawn()
.expect("failed to spawn polling powershell");
let child_pid = child.id();
let result = parent_pid(child_pid);
std::fs::write(&marker, b"go").unwrap();
let _ = child.wait();
assert!(
result.is_ok(),
"parent_pid lookup for child {child_pid} should succeed"
);
assert_eq!(
result.unwrap(),
Some(std::process::id()),
"child's parent must be this test process"
);
}
#[test]
fn is_process_ancestor_finds_own_pid_in_descendant() {
#[cfg(unix)]
let mut child = std::process::Command::new("sh")
.args(["-c", "echo $$ && sleep 2"])
.stdout(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn child");
#[cfg(windows)]
let mut child = std::process::Command::new("cmd")
.args(&["/c", "echo 0 & timeout /t 2 >nul"])
.stdout(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn child");
std::thread::sleep(std::time::Duration::from_millis(200));
let _child_pid = child.id();
assert_eq!(
is_process_ancestor(std::process::id(), 16),
Some(true),
"our own PID is trivially an ancestor of ourselves"
);
assert_eq!(
is_process_ancestor(u32::MAX, 16),
Some(false),
"an unlikely PID should not be an ancestor"
);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn is_process_ancestor_owns_pid_at_depth_zero() {
assert_eq!(
is_process_ancestor(std::process::id(), 0),
Some(true),
"max_depth=0 should still check the current process first"
);
}
#[test]
fn is_process_ancestor_reaches_root_without_finding_unlikely_pid() {
let result = is_process_ancestor(999999999, 16);
assert_eq!(result, Some(false));
}
#[test]
fn escape_workspace_replaces_slashes_with_delim() {
let ws = Path::new("/home/user/projects/my-app");
let escaped = escape_workspace(ws);
assert_eq!(
escaped,
format!(
"{}---{}---{}---{}---{}",
"", "home", "user", "projects", "my-app"
)
);
}
#[test]
fn escape_workspace_root_path() {
let ws = Path::new("/");
let escaped = escape_workspace(ws);
assert_eq!(escaped, "---");
}
fn test_workspace() -> PathBuf {
let home = std::env::var("GRANITE_CLI_HOME").expect("GRANITE_CLI_HOME must be set");
PathBuf::from(home).join("test-workspace")
}
#[test]
fn capture_path_and_lock_path_resolve_under_launcher_state_dir() {
let _home = TestConfigHome::new();
std::fs::create_dir_all(std::env::var("GRANITE_CLI_HOME").unwrap()).unwrap();
Config::ensure_directories_for_test();
let workspace = test_workspace();
let cp = capture_path("test-bob", &workspace).unwrap();
let lp = lock_path("test-bob", &workspace).unwrap();
assert!(cp.starts_with(crate::config::Config::launcher_state_dir("test-bob").unwrap()));
assert!(lp.starts_with(crate::config::Config::launcher_state_dir("test-bob").unwrap()));
let escaped = escape_workspace(&workspace);
let cp_name = cp.file_name().unwrap().to_string_lossy();
let lp_name = lp.file_name().unwrap().to_string_lossy();
assert!(cp_name.starts_with(&escaped));
assert!(cp_name.ends_with(".capture.json"));
assert!(lp_name.starts_with(&escaped));
assert!(lp_name.ends_with(".lock"));
}
#[test]
fn register_or_fail_creates_state_dir_and_writes_files() {
let _home = TestConfigHome::new();
std::fs::create_dir_all(std::env::var("GRANITE_CLI_HOME").unwrap()).unwrap();
Config::ensure_directories_for_test();
let workspace = test_workspace();
let bob_dir = Path::new(&workspace).join(".bob");
std::fs::create_dir_all(&bob_dir).unwrap();
let reg = register_or_fail("test-bob-reg", &workspace).unwrap();
let state_dir = crate::config::Config::launcher_state_dir("test-bob-reg").unwrap();
assert!(state_dir.is_dir());
assert!(reg.lock_path.is_file());
let lock_content = std::fs::read_to_string(®.lock_path).unwrap();
assert_eq!(lock_content.trim(), std::process::id().to_string());
let settings = read_settings(&workspace);
assert!(hooks_session_start_contains(&settings, ®.marker_command));
}
#[test]
fn unregister_removes_marker_and_lockfile() {
let _home = TestConfigHome::new();
std::fs::create_dir_all(std::env::var("GRANITE_CLI_HOME").unwrap()).unwrap();
Config::ensure_directories_for_test();
let workspace = test_workspace();
let bob_dir = Path::new(&workspace).join(".bob");
std::fs::create_dir_all(&bob_dir).unwrap();
let reg = register_or_fail("test-bob-unreg", &workspace).unwrap();
let settings = read_settings(&workspace);
assert!(hooks_session_start_contains(&settings, ®.marker_command));
unregister("test-bob-unreg", &workspace, ®.marker_command);
let settings = read_settings(&workspace);
assert!(!hooks_session_start_contains(
&settings,
®.marker_command
));
assert!(!reg.lock_path.exists());
}
#[test]
fn unregister_silently_handles_missing_state_dir() {
unregister(
"nonexistent-launcher",
&PathBuf::from("/some/ws"),
"some-marker",
);
}
}