aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGolden-Pigeon <goldenpigeon2000@gmail.com>2026-05-22 02:39:02 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:43:30 +0300
commit1daf54ecbf960c42d612ed4ad2764045a606fb30 (patch)
treec6ee19c7e8032ac77f5c00bc17bc6a1bd93d2272 /src
parent0148c1379b76c5c0a9d05588c07dcb06ec32f93f (diff)
feat: show named session command hints
refs #199
Diffstat (limited to 'src')
-rw-r--r--src/client/mod.rs115
-rw-r--r--src/server/autodetect.rs53
-rw-r--r--src/session.rs52
-rw-r--r--src/update.rs61
4 files changed, 266 insertions, 15 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 11273061..1ac05201 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -154,7 +154,11 @@ impl std::fmt::Display for ClientError {
write!(f, "\nRun `{reattach_command}` to reattach")?;
} else {
write!(f, "detached from server")?;
- write!(f, "\nRun `herdr` to reattach")?;
+ write!(
+ f,
+ "\nRun `{}` to reattach",
+ crate::session::local_attach_command()
+ )?;
}
}
_ => {
@@ -1125,6 +1129,65 @@ fn init_logging() {
#[cfg(test)]
mod tests {
use super::*;
+ use std::ffi::OsString;
+ use std::sync::{Mutex, OnceLock};
+
+ fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
+
+ fn restore_env_var(key: &str, value: Option<OsString>) {
+ if let Some(value) = value {
+ std::env::set_var(key, value);
+ } else {
+ std::env::remove_var(key);
+ }
+ }
+
+ struct EnvVarGuard {
+ key: &'static str,
+ previous: Option<OsString>,
+ }
+
+ impl EnvVarGuard {
+ fn set(key: &'static str, value: &str) -> Self {
+ let previous = std::env::var_os(key);
+ std::env::set_var(key, value);
+ Self { key, previous }
+ }
+ }
+
+ impl Drop for EnvVarGuard {
+ fn drop(&mut self) {
+ restore_env_var(self.key, self.previous.clone());
+ }
+ }
+
+ struct EnvVarsRemovedGuard {
+ previous: Vec<(&'static str, Option<OsString>)>,
+ }
+
+ impl EnvVarsRemovedGuard {
+ fn new(keys: &[&'static str]) -> Self {
+ let previous: Vec<_> = keys
+ .iter()
+ .map(|key| (*key, std::env::var_os(key)))
+ .collect();
+ for key in keys {
+ std::env::remove_var(key);
+ }
+ Self { previous }
+ }
+ }
+
+ impl Drop for EnvVarsRemovedGuard {
+ fn drop(&mut self) {
+ for (key, value) in self.previous.clone() {
+ restore_env_var(key, value);
+ }
+ }
+ }
#[test]
fn clipboard_image_paste_bridge_triggers_on_ctrl_v_and_empty_paste() {
@@ -1296,6 +1359,56 @@ mod tests {
}
#[test]
+ fn client_error_display_detached_default_session_reattach_hint() {
+ let _guard = env_lock().lock().unwrap();
+ let _env = EnvVarsRemovedGuard::new(&[
+ crate::remote::REATTACH_COMMAND_ENV_VAR,
+ crate::session::SESSION_ENV_VAR,
+ ]);
+ let err = ClientError::ServerShutdown {
+ reason: Some("detached".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("Run `herdr` to reattach"),
+ "should suggest default reattach command: {msg}"
+ );
+ }
+
+ #[test]
+ fn client_error_display_detached_named_session_reattach_hint() {
+ let _guard = env_lock().lock().unwrap();
+ let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]);
+ let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work");
+ let err = ClientError::ServerShutdown {
+ reason: Some("detached".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("Run `herdr session attach work` to reattach"),
+ "should suggest named session reattach command: {msg}"
+ );
+ }
+
+ #[test]
+ fn client_error_display_detached_remote_reattach_hint_takes_precedence() {
+ let _guard = env_lock().lock().unwrap();
+ let _remote_env = EnvVarGuard::set(
+ crate::remote::REATTACH_COMMAND_ENV_VAR,
+ "herdr --remote host --session work",
+ );
+ let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work");
+ let err = ClientError::ServerShutdown {
+ reason: Some("detached".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("Run `herdr --remote host --session work` to reattach"),
+ "should prefer remote reattach command: {msg}"
+ );
+ }
+
+ #[test]
fn client_error_display_connection_lost() {
let err =
ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
index 976d2583..c278329d 100644
--- a/src/server/autodetect.rs
+++ b/src/server/autodetect.rs
@@ -85,10 +85,12 @@ fn read_server_status() -> io::Result<Option<crate::api::RuntimeStatus>> {
}
fn validate_running_server_compatibility() -> io::Result<()> {
+ let stop_command = crate::session::local_stop_command();
+ let attach_command = crate::session::local_attach_command();
let Some(status) = read_server_status()? else {
- return Err(io::Error::other(
- "a herdr server is listening, but its status API is unavailable. Try `herdr server stop`; if that fails, stop the old server process manually, then run `herdr` again.",
- ));
+ return Err(io::Error::other(format!(
+ "a herdr server is listening, but its status API is unavailable. Try `{stop_command}`; if that fails, stop the old server process manually, then run `{attach_command}` again."
+ )));
};
if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) {
@@ -96,7 +98,7 @@ fn validate_running_server_compatibility() -> io::Result<()> {
}
Err(io::Error::other(format!(
- "herdr server is running from v{} / protocol {}, but this client is v{} / protocol {}.\nStop the old server with `herdr server stop`, then run `herdr` again.",
+ "herdr server is running from v{} / protocol {}, but this client is v{} / protocol {}.\nStop the old server with `{stop_command}`, then run `{attach_command}` again.",
status.version.as_deref().unwrap_or("unknown"),
status
.protocol
@@ -425,4 +427,47 @@ mod tests {
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
let _ = std::fs::remove_dir_all(dir);
}
+
+ #[test]
+ fn validate_running_server_compatibility_names_session_commands_for_protocol_mismatch() {
+ let _guard = env_lock().lock().unwrap();
+ let dir = unique_test_dir("named-protocol");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("api.sock");
+ let listener = UnixListener::bind(&path).unwrap();
+ std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &path);
+ std::env::set_var(crate::session::SESSION_ENV_VAR, "work");
+ crate::session::clear_explicit_session_for_test();
+ let handle = std::thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut request = String::new();
+ BufReader::new(stream.try_clone().unwrap())
+ .read_line(&mut request)
+ .unwrap();
+ assert!(request.contains("ping"));
+ let body = format!(
+ "{{\"id\":\"autodetect:server:status\",\"result\":{{\"type\":\"pong\",\"version\":\"0.5.5\",\"protocol\":{}}}}}\n",
+ crate::protocol::PROTOCOL_VERSION + 1
+ );
+ stream.write_all(body.as_bytes()).unwrap();
+ stream.flush().unwrap();
+ });
+
+ let err = validate_running_server_compatibility().unwrap_err();
+ let message = err.to_string();
+
+ let _ = handle.join();
+ assert!(
+ message.contains("Stop the old server with `herdr session stop work`"),
+ "unexpected error: {message}"
+ );
+ assert!(
+ message.contains("then run `herdr session attach work` again"),
+ "unexpected error: {message}"
+ );
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
+ let _ = std::fs::remove_dir_all(dir);
+ }
}
diff --git a/src/session.rs b/src/session.rs
index b164fecc..f00797c0 100644
--- a/src/session.rs
+++ b/src/session.rs
@@ -92,6 +92,20 @@ pub fn active_name() -> Option<String> {
.filter(|name| validate_name(name).is_ok())
}
+pub fn local_attach_command() -> String {
+ match active_name() {
+ Some(name) => format!("herdr session attach {name}"),
+ None => "herdr".to_string(),
+ }
+}
+
+pub fn local_stop_command() -> String {
+ match active_name() {
+ Some(name) => format!("herdr session stop {name}"),
+ None => "herdr server stop".to_string(),
+ }
+}
+
pub fn explicit_session_requested() -> bool {
EXPLICIT_SESSION_REQUESTED.load(Ordering::Relaxed)
}
@@ -476,6 +490,44 @@ mod tests {
}
#[test]
+ fn local_attach_command_uses_default_launch_for_default_session() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::remove_var(SESSION_ENV_VAR);
+
+ assert_eq!(local_attach_command(), "herdr");
+ }
+
+ #[test]
+ fn local_attach_command_uses_session_attach_for_named_session() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::set_var(SESSION_ENV_VAR, "work");
+
+ assert_eq!(local_attach_command(), "herdr session attach work");
+
+ std::env::remove_var(SESSION_ENV_VAR);
+ }
+
+ #[test]
+ fn local_stop_command_uses_server_stop_for_default_session() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::remove_var(SESSION_ENV_VAR);
+
+ assert_eq!(local_stop_command(), "herdr server stop");
+
+ std::env::remove_var(SESSION_ENV_VAR);
+ }
+
+ #[test]
+ fn local_stop_command_uses_session_stop_for_named_session() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::set_var(SESSION_ENV_VAR, "work");
+
+ assert_eq!(local_stop_command(), "herdr session stop work");
+
+ std::env::remove_var(SESSION_ENV_VAR);
+ }
+
+ #[test]
fn explicit_session_socket_ignores_inherited_socket_override() {
let _guard = env_lock().lock().unwrap();
let config_home =
diff --git a/src/update.rs b/src/update.rs
index 98943424..f37a9265 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -462,8 +462,9 @@ fn prompt_to_stop_server_before_update(
if !io::stdin().is_terminal() {
if requires_stop {
return Err(format!(
- "a herdr server is running and updating to v{} requires stopping it; run `herdr server stop`, then run `herdr update` again",
- release.version
+ "a herdr server is running and updating to v{} requires stopping it; run `{}`, then run `herdr update` again",
+ release.version,
+ crate::session::local_stop_command()
));
}
@@ -533,10 +534,10 @@ fn plan_running_server_update(
) -> Result<Option<RunningServerUpdatePlan>, String> {
let Some(server) = read_running_server_info()? else {
if client_protocol_server_is_running() {
- return Err(
- "a herdr server is listening, but its status API is unavailable; try `herdr server stop`, or stop the old server process manually, then run `herdr update` again"
- .to_string(),
- );
+ return Err(format!(
+ "a herdr server is listening, but its status API is unavailable; try `{}`, or stop the old server process manually, then run `herdr update` again",
+ crate::session::local_stop_command()
+ ));
}
return Ok(None);
};
@@ -560,10 +561,10 @@ fn stop_running_server_for_update(
prompt_to_stop_server_before_update(&plan.server, release, plan.requires_stop)?;
if !stop_server {
if plan.requires_stop {
- return Err(
- "update cancelled; stop the running herdr server with `herdr server stop`, then run `herdr update` again"
- .to_string(),
- );
+ return Err(format!(
+ "update cancelled; stop the running herdr server with `{}`, then run `herdr update` again",
+ crate::session::local_stop_command()
+ ));
}
return Ok(false);
}
@@ -1185,8 +1186,14 @@ mod tests {
atomic::{AtomicBool, Ordering},
Arc,
};
+ use std::sync::{Mutex, OnceLock};
use std::thread;
+ fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
+
fn unique_test_socket_path(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1356,6 +1363,40 @@ mod tests {
}
#[test]
+ fn noninteractive_update_requires_stop_names_session_stop_command() {
+ let _guard = env_lock().lock().unwrap();
+ assert!(
+ !io::stdin().is_terminal(),
+ "this test relies on noninteractive test stdin"
+ );
+ std::env::set_var(crate::session::SESSION_ENV_VAR, "work");
+ crate::session::clear_explicit_session_for_test();
+ let server = crate::api::RuntimeStatus {
+ version: Some("0.5.5".to_string()),
+ protocol: Some(2),
+ };
+ let release = ReleaseInfo {
+ version: Version::parse("0.5.6").unwrap(),
+ target_protocol: Some(3),
+ download_url: "https://example.com/herdr".to_string(),
+ notes_body: "### Changed\n- One".to_string(),
+ };
+
+ let err = prompt_to_stop_server_before_update(&server, &release, true).unwrap_err();
+
+ assert!(
+ err.contains("run `herdr session stop work`"),
+ "unexpected error: {err}"
+ );
+ assert!(
+ err.contains("then run `herdr update` again"),
+ "unexpected error: {err}"
+ );
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
+ }
+
+ #[test]
fn client_protocol_server_is_running_at_detects_live_socket() {
let socket_path = unique_test_socket_path("client-live");
let listener = UnixListener::bind(&socket_path).unwrap();