mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
* fix(cli): refuse a broken send address instead of typing it into your pane (#538) A lone positional that starts with `%` but fails parse_pane (`%3x`) used to fall through to the text branch: the typo was typed into the caller's own pane and any --key followed it there, so one wrong character redirected an interrupt to whatever the caller was looking at. The guard now propagates the parse error when the `%` is followed by a digit — "clearly tried to write an address" — and leaves `%`-led text whose second character is not a digit (`%s/foo/bar/`, `%!sort`) on the text path it always was, per the review's narrowing. The explicit address slot also accepts bare ids now: `pane ls --json` prints `83`, not `%83`, and refusing the bare form made the workaround for the typo hole (`"%${TTY7_PANE#%}"`) uglier than the hole. This matches what pane_from_env already accepted and closes the missing-`%` variant of the same mistake. Tests cover the branch with a `Context { pane: Some("5") }` — every existing send test used `Context::default()`, where the fallback errors OUTSIDE_SHELL before the guard is reachable, which is why the hole had no test. Also correct the `ws rm` docs (#539): the reference claimed its panes become orphans found via `pane ls --all`, but the code has hung them up since #319; only a hang-up failure (reported by pane id) leaves orphans. The site reference, the skill reference, and `ws rm --help` now say so. * fix(cli): keep the send guard to what actually looks like an address The narrowing was described more widely than it works: a digit-led token that fails to parse (`3x`) still types, only `%` then a digit refuses, so the reference and the skill both promised an error that never comes. Say what the code does and point at the two-argument form as the way to type an address-shaped string anyway. Now that the `%` is optional, `parse_pane` also has to be stricter than `u64::from_str`, which accepts a leading `+`: a bare `+5` meant as text would otherwise address pane 5. An address is digits and nothing else, and `pane_from_env` delegates rather than repeating the read. The broken-address arm parsed twice and ended in an `unreachable!` that a future edit could walk into; one match on the parse result carries the error out directly. A lone bare id is the one behaviour this takes away, so it says how to type the number instead, and a test pins that it never quietly presses a key at the pane the id names. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
239 lines
7.9 KiB
Rust
239 lines
7.9 KiB
Rust
use anyhow::{Result, anyhow, bail};
|
|
use tty7_core::core::session::WorkspaceId;
|
|
|
|
pub const ENV_PANE: &str = "TTY7_PANE";
|
|
pub const ENV_WS: &str = "TTY7_WS";
|
|
/// The server's config dir, not a socket path — the server publishes the
|
|
/// directory so both endpoints resolve through tty7_core's own derivation
|
|
/// rather than a second one here. Inherited by this process, so
|
|
/// `ControlClient::connect` / `PaneClient::local` already land on the right
|
|
/// server without the CLI touching a path at all.
|
|
pub const ENV_CONFIG_DIR: &str = "TTY7_CONFIG_DIR";
|
|
|
|
pub const OUTSIDE_SHELL: &str = "not inside a tty7 shell — pass an explicit %pane/@tab/workspace";
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Context {
|
|
pub pane: Option<String>,
|
|
pub ws: Option<String>,
|
|
pub config_dir: Option<String>,
|
|
}
|
|
|
|
impl Context {
|
|
pub fn from_env() -> Context {
|
|
Context {
|
|
pane: std::env::var(ENV_PANE).ok().filter(|v| !v.is_empty()),
|
|
ws: std::env::var(ENV_WS).ok().filter(|v| !v.is_empty()),
|
|
config_dir: std::env::var(ENV_CONFIG_DIR).ok().filter(|v| !v.is_empty()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum TabAddress {
|
|
Ordinal(u64),
|
|
Id(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum WorkspaceAddress {
|
|
Id(WorkspaceId),
|
|
Named(String),
|
|
}
|
|
|
|
pub fn parse_pane(s: &str) -> Result<u64> {
|
|
// The `%` is optional: `tty7 pane ls --json` hands back bare ids, so a
|
|
// copied `83` must address the same pane as `%83` — refusing it sent
|
|
// people to workarounds uglier than the typo this guard exists to catch
|
|
// (#538). `pane_from_env` already read both shapes; this aligns the
|
|
// explicit slot with it.
|
|
let digits = s.strip_prefix('%').unwrap_or(s);
|
|
let not_an_address = || anyhow!("'{s}' is not a pane address — panes look like %42");
|
|
// Digits and nothing else. `u64::from_str` also accepts a leading `+`, and
|
|
// now that the `%` is optional that would quietly turn a `send +5` meant as
|
|
// text into a keystroke aimed at pane 5.
|
|
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
|
return Err(not_an_address());
|
|
}
|
|
// Still parsed, not just counted: an id past u64 is no pane either.
|
|
digits.parse().map_err(|_| not_an_address())
|
|
}
|
|
|
|
pub fn parse_tab(s: &str) -> Result<TabAddress> {
|
|
let body = s
|
|
.strip_prefix('@')
|
|
.ok_or_else(|| anyhow!("'{s}' is not a tab address — tabs look like @7"))?;
|
|
if body.is_empty() {
|
|
bail!("'{s}' is not a tab address — tabs look like @7");
|
|
}
|
|
if let Ok(n) = body.parse::<u64>() {
|
|
return Ok(TabAddress::Ordinal(n));
|
|
}
|
|
if looks_like_uuid(body) {
|
|
return Ok(TabAddress::Id(body.to_string()));
|
|
}
|
|
bail!("'{s}' is not a tab address — @7 as numbered by `tty7 ls`, or @<full tab id>");
|
|
}
|
|
|
|
fn looks_like_uuid(s: &str) -> bool {
|
|
s.len() == 36
|
|
&& s.char_indices().all(|(i, c)| match i {
|
|
8 | 13 | 18 | 23 => c == '-',
|
|
_ => c.is_ascii_hexdigit(),
|
|
})
|
|
}
|
|
|
|
pub fn parse_workspace(s: &str) -> WorkspaceAddress {
|
|
match s.parse::<WorkspaceId>() {
|
|
Ok(id) => WorkspaceAddress::Id(id),
|
|
Err(_) => WorkspaceAddress::Named(s.to_string()),
|
|
}
|
|
}
|
|
|
|
pub fn pane_or_context(explicit: Option<&str>, ctx: &Context) -> Result<u64> {
|
|
if let Some(s) = explicit {
|
|
return parse_pane(s);
|
|
}
|
|
match &ctx.pane {
|
|
Some(v) => pane_from_env(v),
|
|
None => bail!(OUTSIDE_SHELL),
|
|
}
|
|
}
|
|
|
|
fn pane_from_env(v: &str) -> Result<u64> {
|
|
// The same read as parse_pane, delegated rather than repeated so the two
|
|
// cannot drift; only the error differs, because "pass %pane" cannot fix an
|
|
// inherited env value.
|
|
parse_pane(v).map_err(|_| {
|
|
anyhow!("{ENV_PANE}='{v}' is not a pane id; unset it or pass %pane explicitly")
|
|
})
|
|
}
|
|
|
|
pub fn workspace_or_context(explicit: Option<&str>, ctx: &Context) -> Result<WorkspaceAddress> {
|
|
if let Some(s) = explicit {
|
|
return Ok(parse_workspace(s));
|
|
}
|
|
match &ctx.ws {
|
|
Some(v) => Ok(parse_workspace(v)),
|
|
None => bail!(OUTSIDE_SHELL),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn shell_context() -> Context {
|
|
Context {
|
|
pane: Some("42".into()),
|
|
ws: Some("0d4e1a54-0000-4000-8000-000000000001".into()),
|
|
config_dir: Some("C:\\Users\\me\\AppData\\Roaming\\tty7".into()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_three_address_shapes_parse_apart() {
|
|
assert_eq!(parse_pane("%42").unwrap(), 42);
|
|
assert_eq!(parse_tab("@7").unwrap(), TabAddress::Ordinal(7));
|
|
assert_eq!(
|
|
parse_workspace("api"),
|
|
WorkspaceAddress::Named("api".into())
|
|
);
|
|
let id = "0d4e1a54-0000-4000-8000-000000000001";
|
|
assert_eq!(
|
|
parse_workspace(id),
|
|
WorkspaceAddress::Id(id.parse().unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_tab_id_is_also_an_address() {
|
|
let id = "0d4e1a54-0000-4000-8000-000000000002";
|
|
assert_eq!(
|
|
parse_tab(&format!("@{id}")).unwrap(),
|
|
TabAddress::Id(id.into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bare_pane_id_addresses_the_same_pane_as_the_marked_one() {
|
|
// `tty7 pane ls --json` prints bare ids; refusing them here pushed
|
|
// people into "%${TTY7_PANE#%}" contortions (#538).
|
|
assert_eq!(parse_pane("42").unwrap(), 42);
|
|
assert_eq!(parse_pane("%42").unwrap(), 42);
|
|
}
|
|
|
|
#[test]
|
|
fn an_address_is_digits_and_nothing_else() {
|
|
// `u64::from_str` takes a leading `+`; an address must not, or a bare
|
|
// `+5` handed to `send` as text would address pane 5 instead (#538).
|
|
for not_a_pane in ["+5", "%+5", "-5", " 5", "5 ", "", "%", "5.0"] {
|
|
assert!(
|
|
parse_pane(not_a_pane).is_err(),
|
|
"'{not_a_pane}' must not read as an address"
|
|
);
|
|
}
|
|
// Past u64 is no pane either, however digit-shaped.
|
|
assert!(parse_pane("99999999999999999999999").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_addresses_say_what_they_should_look_like() {
|
|
let err = parse_pane("abc").unwrap_err().to_string();
|
|
assert!(err.contains("%42"), "the fix is shown: {err}");
|
|
let err = parse_pane("%abc").unwrap_err().to_string();
|
|
assert!(err.contains("%42"), "{err}");
|
|
let err = parse_tab("@build").unwrap_err().to_string();
|
|
assert!(err.contains("@7"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn omitted_addresses_fall_back_to_the_injected_context() {
|
|
let ctx = shell_context();
|
|
assert_eq!(pane_or_context(None, &ctx).unwrap(), 42);
|
|
assert_eq!(
|
|
workspace_or_context(None, &ctx).unwrap(),
|
|
WorkspaceAddress::Id("0d4e1a54-0000-4000-8000-000000000001".parse().unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_explicit_address_beats_the_context() {
|
|
let ctx = shell_context();
|
|
assert_eq!(pane_or_context(Some("%7"), &ctx).unwrap(), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn env_pane_values_may_carry_the_percent_or_not() {
|
|
let bare = Context {
|
|
pane: Some("42".into()),
|
|
..Context::default()
|
|
};
|
|
let marked = Context {
|
|
pane: Some("%42".into()),
|
|
..Context::default()
|
|
};
|
|
assert_eq!(pane_or_context(None, &bare).unwrap(), 42);
|
|
assert_eq!(pane_or_context(None, &marked).unwrap(), 42);
|
|
}
|
|
|
|
#[test]
|
|
fn outside_a_tty7_shell_the_error_names_the_fix() {
|
|
let ctx = Context::default();
|
|
let err = pane_or_context(None, &ctx).unwrap_err().to_string();
|
|
assert_eq!(err, OUTSIDE_SHELL);
|
|
let err = workspace_or_context(None, &ctx).unwrap_err().to_string();
|
|
assert_eq!(err, OUTSIDE_SHELL);
|
|
}
|
|
|
|
#[test]
|
|
fn a_broken_env_pane_is_reported_not_silently_ignored() {
|
|
let ctx = Context {
|
|
pane: Some("not-a-pane".into()),
|
|
..Context::default()
|
|
};
|
|
let err = pane_or_context(None, &ctx).unwrap_err().to_string();
|
|
assert!(err.contains("TTY7_PANE"), "{err}");
|
|
}
|
|
}
|