use crate::tools::ensure_user_profile_bin_paths;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, thiserror::Error)]
pub enum HerdrError {
#[error("Not running inside Herdr (HERDR_ENV != 1)")]
NotInsideHerdr,
#[error("Herdr binary not found: {0}")]
BinaryNotFound(String),
#[error("Herdr command '{command}' failed (exit code {code:?}): {stderr}")]
CommandFailed {
command: String,
code: Option<i32>,
stderr: String,
},
#[error("Failed to parse Herdr JSON output: {0}")]
Json(#[from] serde_json::Error),
#[error("Herdr API returned error: {0}")]
Api(String),
#[error("Missing expected field '{0}' in Herdr response")]
MissingResponseField(&'static str),
#[error("Missing context from Herdr environment: {0}")]
MissingContext(String),
#[error("I/O error while interacting with Herdr: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CreatedTab {
pub(crate) tab_id: String,
pub(crate) root_pane_id: String,
pub(crate) label: String,
pub(crate) workspace_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TabInfo {
pub(crate) tab_id: String,
pub(crate) label: Option<String>,
pub(crate) workspace_id: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct HerdrTabCreateResponse {
result: Option<HerdrTabCreateResult>,
error: Option<HerdrErrorPayload>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct HerdrTabCreateResult {
root_pane: Option<HerdrPaneInfo>,
tab: Option<HerdrTabInfo>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct HerdrPaneInfo {
pane_id: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct HerdrTabInfo {
tab_id: String,
label: Option<String>,
workspace_id: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct HerdrTabGetResponse {
result: Option<HerdrTabGetResult>,
error: Option<HerdrErrorPayload>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct HerdrTabGetResult {
tab: Option<HerdrTabInfo>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct HerdrResponseEnvelope {
error: Option<HerdrErrorPayload>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct HerdrErrorPayload {
message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Herdr {
binary: PathBuf,
workspace_id: Option<String>,
tab_id: Option<String>,
pane_id: Option<String>,
socket_path: Option<PathBuf>,
}
impl Herdr {
#[must_use]
pub fn is_inside_herdr() -> bool {
Self::is_inside_herdr_with(|k| std::env::var(k).ok())
}
#[must_use]
pub(crate) fn is_inside_herdr_with<F>(var_getter: F) -> bool
where
F: Fn(&str) -> Option<String>,
{
var_getter("HERDR_ENV").is_some_and(|val| val == "1")
}
#[must_use]
pub fn from_env() -> Option<Self> {
if !Self::is_inside_herdr() {
return None;
}
Self::from_env_with(|k| std::env::var(k).ok())
}
#[must_use]
pub(crate) fn from_env_with<F>(var_getter: F) -> Option<Self>
where
F: Fn(&str) -> Option<String>,
{
if !Self::is_inside_herdr_with(&var_getter) {
return None;
}
let binary = var_getter("HERDR_BIN_PATH")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("herdr"));
Some(Self {
binary,
workspace_id: var_getter("HERDR_WORKSPACE_ID"),
tab_id: var_getter("HERDR_TAB_ID"),
pane_id: var_getter("HERDR_PANE_ID"),
socket_path: var_getter("HERDR_SOCKET_PATH").map(PathBuf::from),
})
}
#[must_use]
pub(crate) fn pane_id(&self) -> Option<&str> {
self.pane_id.as_deref()
}
fn execute_cmd(&self, args: &[&str]) -> Result<String, HerdrError> {
let mut cmd = Command::new(&self.binary);
cmd.args(args);
ensure_user_profile_bin_paths(&mut cmd);
let output = {
let mut attempts = 0;
loop {
match cmd.output() {
Ok(out) => break Ok(out),
Err(err) if err.raw_os_error() == Some(26) && attempts < 10 => {
attempts += 1;
std::thread::sleep(std::time::Duration::from_millis(20));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
break Err(HerdrError::BinaryNotFound(
self.binary.to_string_lossy().into_owned(),
));
}
Err(err) => break Err(HerdrError::Io(err)),
}
}
}?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
if let Ok(err_envelope) = serde_json::from_str::<HerdrResponseEnvelope>(&stderr)
.or_else(|_| serde_json::from_str::<HerdrResponseEnvelope>(&stdout))
&& let Some(err_obj) = err_envelope.error
{
return Err(HerdrError::Api(err_obj.message));
}
return Err(HerdrError::CommandFailed {
command: format!("{} {}", self.binary.to_string_lossy(), args.join(" ")),
code: output.status.code(),
stderr: if stderr.trim().is_empty() {
stdout
} else {
stderr
},
});
}
Ok(stdout)
}
pub(crate) fn get_tab(&self, tab_id: &str) -> Result<TabInfo, HerdrError> {
let stdout = self.execute_cmd(&["tab", "get", tab_id])?;
let resp: HerdrTabGetResponse = serde_json::from_str(&stdout)?;
if let Some(err) = resp.error {
return Err(HerdrError::Api(err.message));
}
let tab_info = resp
.result
.and_then(|r| r.tab)
.ok_or(HerdrError::MissingResponseField("result.tab"))?;
Ok(TabInfo {
tab_id: tab_info.tab_id,
label: tab_info.label,
workspace_id: tab_info.workspace_id,
})
}
pub(crate) fn current_tab(&self) -> Result<TabInfo, HerdrError> {
let tab_id = self
.tab_id
.as_deref()
.ok_or_else(|| HerdrError::MissingContext("HERDR_TAB_ID is not set".to_string()))?;
self.get_tab(tab_id)
}
pub(crate) fn current_tab_label(&self) -> Result<Option<String>, HerdrError> {
Ok(self.current_tab()?.label)
}
#[must_use]
pub(crate) fn is_current_tab_named(&self, expected_name: &str) -> bool {
self.current_tab_label()
.ok()
.flatten()
.is_some_and(|label| label == expected_name)
}
pub(crate) fn create_tab(
&self,
label: &str,
cwd: Option<&Path>,
focus: bool,
) -> Result<CreatedTab, HerdrError> {
let mut args = vec!["tab", "create", "--label", label];
if focus {
args.push("--focus");
} else {
args.push("--no-focus");
}
let cwd_str;
if let Some(c) = cwd {
cwd_str = c.to_string_lossy().to_string();
args.extend(["--cwd", &cwd_str]);
}
if let Some(ws) = &self.workspace_id {
args.extend(["--workspace", ws]);
}
let stdout = self.execute_cmd(&args)?;
let resp: HerdrTabCreateResponse = serde_json::from_str(&stdout)?;
if let Some(err) = resp.error {
return Err(HerdrError::Api(err.message));
}
let result = resp
.result
.ok_or(HerdrError::MissingResponseField("result"))?;
let root_pane = result
.root_pane
.ok_or(HerdrError::MissingResponseField("result.root_pane"))?;
let tab = result
.tab
.ok_or(HerdrError::MissingResponseField("result.tab"))?;
Ok(CreatedTab {
tab_id: tab.tab_id,
root_pane_id: root_pane.pane_id,
label: tab.label.unwrap_or_else(|| label.to_string()),
workspace_id: tab.workspace_id,
})
}
pub(crate) fn report_agent(
&self,
pane_id: &str,
agent: &str,
state: &str,
) -> Result<(), HerdrError> {
self.execute_cmd(&[
"pane",
"report-agent",
"--source",
"aiw",
"--agent",
agent,
"--state",
state,
pane_id,
])?;
Ok(())
}
pub(crate) fn inform_agent(&self, pane_id: &str, agent: &str) -> Result<(), HerdrError> {
self.report_agent(pane_id, agent, "working")
}
pub(crate) fn execute_in_pane(&self, pane_id: &str, command: &str) -> Result<(), HerdrError> {
self.execute_cmd(&["pane", "run", pane_id, command])?;
Ok(())
}
pub(crate) fn open_workspace_tab_and_execute(
&self,
workspace_name: &str,
command: &str,
agent: &str,
cwd: Option<&Path>,
) -> Result<CreatedTab, HerdrError> {
let tab = self.create_tab(workspace_name, cwd, true)?;
self.inform_agent(&tab.root_pane_id, agent)?;
std::thread::sleep(std::time::Duration::from_millis(300));
let pane_command = format!("{command}; exit");
self.execute_in_pane(&tab.root_pane_id, &pane_command)?;
Ok(tab)
}
fn execute_in_place(&self, command: &str, cwd: Option<&Path>) -> Result<(), HerdrError> {
let mut cmd = Command::new("sh");
cmd.args(["-c", command]);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
ensure_user_profile_bin_paths(&mut cmd);
let status = cmd.status()?;
if !status.success() {
return Err(HerdrError::CommandFailed {
command: command.to_string(),
code: status.code(),
stderr: String::new(),
});
}
Ok(())
}
pub fn run_command(
&self,
workspace_name: &str,
command: &str,
agent: &str,
cwd: Option<&Path>,
) -> Result<(), HerdrError> {
if self.is_current_tab_named(workspace_name) {
if let Some(pane_id) = self.pane_id()
&& let Err(err) = self.inform_agent(pane_id, agent)
{
eprintln!("Warning: failed to inform Herdr about agent: {err}");
}
self.execute_in_place(command, cwd)
} else {
self.open_workspace_tab_and_execute(workspace_name, command, agent, cwd)?;
Ok(())
}
}
}
#[cfg(test)]
impl Herdr {
pub(crate) fn new(binary: PathBuf) -> Self {
Self {
binary,
workspace_id: None,
tab_id: None,
pane_id: None,
socket_path: None,
}
}
pub(crate) fn with_tab_id(mut self, tab_id: impl Into<String>) -> Self {
self.tab_id = Some(tab_id.into());
self
}
pub(crate) fn with_pane_id(mut self, pane_id: impl Into<String>) -> Self {
self.pane_id = Some(pane_id.into());
self
}
pub(crate) fn with_workspace_id(mut self, workspace_id: impl Into<String>) -> Self {
self.workspace_id = Some(workspace_id.into());
self
}
pub(crate) fn binary(&self) -> &Path {
&self.binary
}
pub(crate) fn workspace_id(&self) -> Option<&str> {
self.workspace_id.as_deref()
}
pub(crate) fn tab_id(&self) -> Option<&str> {
self.tab_id.as_deref()
}
pub(crate) fn socket_path(&self) -> Option<&Path> {
self.socket_path.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use googletest::prelude::*;
use tempfile::NamedTempFile;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn create_mock_script(script_content: &str) -> (tempfile::TempDir, PathBuf) {
let temp_dir = tempfile::tempdir().expect("tempdir");
let script_path = temp_dir.path().join("mock_herdr.sh");
std::fs::write(&script_path, script_content).expect("write mock script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&script_path, perms).expect("set permissions");
}
(temp_dir, script_path)
}
#[googletest::test]
fn is_inside_herdr_detects_herdr_env_variable() {
expect_that!(
Herdr::is_inside_herdr_with(|k| if k == "HERDR_ENV" {
Some("1".to_string())
} else {
None
}),
is_true()
);
expect_that!(
Herdr::is_inside_herdr_with(|k| if k == "HERDR_ENV" {
Some("0".to_string())
} else {
None
}),
is_false()
);
expect_that!(Herdr::is_inside_herdr_with(|_| None), is_false());
}
#[googletest::test]
fn from_env_returns_some_when_herdr_env_is_1_and_none_otherwise() {
let vars = [
("HERDR_ENV", "1"),
("HERDR_PANE_ID", "w1:p1"),
("HERDR_TAB_ID", "w1:t1"),
("HERDR_WORKSPACE_ID", "w1"),
];
let get_var = |k: &str| {
vars.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| (*v).to_string())
};
let detected = Herdr::from_env_with(get_var);
expect_that!(detected, some(anything()));
let Some(herdr) = detected else {
return;
};
expect_that!(herdr.pane_id(), some(eq("w1:p1")));
expect_that!(herdr.tab_id(), some(eq("w1:t1")));
expect_that!(herdr.workspace_id(), some(eq("w1")));
expect_that!(Herdr::from_env_with(|_| None), none());
}
#[googletest::test]
fn from_env_populates_all_environment_context() {
let vars = [
("HERDR_ENV", "1"),
("HERDR_BIN_PATH", "/custom/bin/herdr"),
("HERDR_WORKSPACE_ID", "ws-42"),
("HERDR_TAB_ID", "ws-42:t9"),
("HERDR_PANE_ID", "ws-42:p9"),
("HERDR_SOCKET_PATH", "/custom/socket.sock"),
];
let get_var = |k: &str| {
vars.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| (*v).to_string())
};
let detected = Herdr::from_env_with(get_var);
expect_that!(detected, some(anything()));
let Some(herdr) = detected else {
return;
};
expect_that!(herdr.binary(), eq(Path::new("/custom/bin/herdr")));
expect_that!(herdr.workspace_id(), some(eq("ws-42")));
expect_that!(herdr.tab_id(), some(eq("ws-42:t9")));
expect_that!(herdr.pane_id(), some(eq("ws-42:p9")));
expect_that!(
herdr.socket_path(),
some(eq(Path::new("/custom/socket.sock")))
);
}
#[googletest::test]
fn create_tab_parses_json_response_and_returns_created_tab() -> TestResult {
let script = r#"#!/bin/sh
if [ "$1" = "tab" ] && [ "$2" = "create" ]; then
echo '{"id":"cli:tab:create","result":{"root_pane":{"pane_id":"w1:p8"},"tab":{"label":"my-workspace","tab_id":"w1:t8","workspace_id":"w1"},"type":"tab_created"}}'
exit 0
fi
echo "Unexpected args: $*" >&2
exit 1
"#;
let (_dir, script_path) = create_mock_script(script);
let herdr = Herdr::new(script_path).with_workspace_id("w1");
let res = herdr.create_tab("my-workspace", Some(Path::new("/my/cwd")), true);
expect_that!(res, ok(anything()));
let created = res?;
expect_that!(&created.tab_id, eq("w1:t8"));
expect_that!(&created.root_pane_id, eq("w1:p8"));
expect_that!(&created.label, eq("my-workspace"));
expect_that!(created.workspace_id.as_deref(), some(eq("w1")));
Ok(())
}
#[googletest::test]
fn get_tab_and_is_current_tab_named_checks_label() -> TestResult {
let script = r#"#!/bin/sh
if [ "$1" = "tab" ] && [ "$2" = "get" ] && [ "$3" = "w1:t3" ]; then
echo '{"id":"cli:tab:get","result":{"tab":{"label":"feature-branch","number":3,"tab_id":"w1:t3","workspace_id":"w1"},"type":"tab_info"}}'
exit 0
fi
echo "Unexpected args: $*" >&2
exit 1
"#;
let (_dir, script_path) = create_mock_script(script);
let herdr = Herdr::new(script_path).with_tab_id("w1:t3");
expect_that!(herdr.is_current_tab_named("feature-branch"), is_true());
expect_that!(herdr.is_current_tab_named("different-branch"), is_false());
let tab = herdr.get_tab("w1:t3")?;
expect_that!(tab.label.as_deref(), some(eq("feature-branch")));
Ok(())
}
#[googletest::test]
fn report_agent_and_inform_agent_sends_expected_cli_arguments() -> TestResult {
let log_file = NamedTempFile::new()?;
let log_path = log_file.path().to_string_lossy().to_string();
let script = format!(
r#"#!/bin/sh
echo "$*" >> "{log_path}"
echo '{{"id":"cli:pane:report_agent","result":{{"type":"ok"}}}}'
exit 0
"#
);
let (_dir, script_path) = create_mock_script(&script);
let herdr = Herdr::new(script_path);
herdr.inform_agent("w1:p99", "agy")?;
let logged = std::fs::read_to_string(&log_path)?;
expect_that!(
&logged,
contains_substring("pane report-agent --source aiw --agent agy --state working w1:p99")
);
Ok(())
}
#[googletest::test]
fn execute_in_pane_sends_command_via_pane_run() -> TestResult {
let log_file = NamedTempFile::new()?;
let log_path = log_file.path().to_string_lossy().to_string();
let script = format!(
r#"#!/bin/sh
echo "$*" >> "{log_path}"
echo '{{"id":"cli:pane:run","result":{{"type":"ok"}}}}'
exit 0
"#
);
let (_dir, script_path) = create_mock_script(&script);
let herdr = Herdr::new(script_path);
herdr.execute_in_pane("w1:p55", "echo hello world")?;
let logged = std::fs::read_to_string(&log_path)?;
expect_that!(
&logged,
contains_substring("pane run w1:p55 echo hello world")
);
Ok(())
}
#[googletest::test]
fn run_command_decides_to_create_tab_when_not_in_workspace_tab() -> TestResult {
let log_file = NamedTempFile::new()?;
let log_path = log_file.path().to_string_lossy().to_string();
let script = format!(
r#"#!/bin/sh
echo "$*" >> "{log_path}"
if [ "$1" = "tab" ] && [ "$2" = "get" ]; then
echo '{{"id":"cli:tab:get","result":{{"tab":{{"label":"other-tab","tab_id":"w1:t1","workspace_id":"w1"}},"type":"tab_info"}}}}'
exit 0
fi
if [ "$1" = "tab" ] && [ "$2" = "create" ]; then
echo '{{"id":"cli:tab:create","result":{{"root_pane":{{"pane_id":"w1:p12"}},"tab":{{"label":"ws-test","tab_id":"w1:t12","workspace_id":"w1"}},"type":"tab_created"}}}}'
exit 0
fi
echo '{{"result":{{"type":"ok"}}}}'
exit 0
"#
);
let (_dir, script_path) = create_mock_script(&script);
let herdr = Herdr::new(script_path).with_tab_id("w1:t1");
let res = herdr.run_command(
"ws-test",
"fence -- echo 1",
"agy",
Some(Path::new("/tmp/test")),
);
expect_that!(res, ok(anything()));
let logged = std::fs::read_to_string(&log_path)?;
expect_that!(
&logged,
contains_substring("tab create --label ws-test --focus --cwd /tmp/test")
);
expect_that!(
&logged,
contains_substring("pane report-agent --source aiw --agent agy --state working w1:p12")
);
expect_that!(
&logged,
contains_substring("pane run w1:p12 fence -- echo 1; exit")
);
Ok(())
}
#[googletest::test]
fn run_command_decides_to_run_in_place_when_already_in_workspace_tab() -> TestResult {
let log_file = NamedTempFile::new()?;
let log_path = log_file.path().to_string_lossy().to_string();
let script = format!(
r#"#!/bin/sh
echo "$*" >> "{log_path}"
if [ "$1" = "tab" ] && [ "$2" = "get" ]; then
echo '{{"id":"cli:tab:get","result":{{"tab":{{"label":"ws-current","tab_id":"w1:t5","workspace_id":"w1"}},"type":"tab_info"}}}}'
exit 0
fi
echo '{{"result":{{"type":"ok"}}}}'
exit 0
"#
);
let (_dir, script_path) = create_mock_script(&script);
let herdr = Herdr::new(script_path)
.with_tab_id("w1:t5")
.with_pane_id("w1:p5");
let res = herdr.run_command("ws-current", "true", "agy", None);
expect_that!(res, ok(anything()));
let logged = std::fs::read_to_string(&log_path)?;
expect_that!(
&logged,
contains_substring("pane report-agent --source aiw --agent agy --state working w1:p5")
);
expect_that!(&logged, not(contains_substring("tab create")));
Ok(())
}
#[googletest::test]
fn command_failure_with_api_error_parses_error_message() {
let script = r#"#!/bin/sh
echo '{"error":{"code":"tab_not_found","message":"tab w1:t99 not found"},"id":"cli:tab:get"}' >&2
exit 1
"#;
let (_dir, script_path) = create_mock_script(script);
let herdr = Herdr::new(script_path);
let res = herdr.get_tab("w1:t99");
expect_that!(
res,
matches_pattern!(Err(matches_pattern!(HerdrError::Api(eq(
"tab w1:t99 not found"
)))))
);
}
#[googletest::test]
fn binary_not_found_returns_clear_error() {
let herdr = Herdr::new(PathBuf::from("/nonexistent/path/to/herdr"));
let res = herdr.get_tab("w1:t1");
expect_that!(
res,
matches_pattern!(Err(matches_pattern!(
HerdrError::BinaryNotFound(anything())
)))
);
}
}