use super::architect_pane_env::{PICKED, RETARGET};
use super::architect_pane_parse::{Hit, Target, tmux_hits};
use super::tmux_exact_target::TmuxObject;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Pane {
pub(crate) pane: String,
pub(crate) window: String,
pub(crate) session: String,
pub(crate) name: String,
pub(crate) architect: bool,
pub(crate) marked: bool,
}
pub(crate) trait PaneProbe {
fn architect_live(&self) -> Result<bool, String>;
fn panes(&self, server: &[String]) -> Result<Vec<Pane>, String>;
fn current_pane(&self) -> Option<String>;
fn objects(&self, _server: &[String]) -> Result<Vec<TmuxObject>, String> {
Err("this probe lists no tmux objects".into())
}
}
type Listing = Result<Vec<Pane>, String>;
pub(crate) const ARCHITECT_PANE_RULE: &str = "architect-pane";
pub(crate) fn evaluate_architect_pane(command: &str, probe: &dyn PaneProbe) -> Option<String> {
let hits = tmux_hits(command);
if hits.is_empty() || probe.architect_live() == Ok(false) {
return None;
}
let current = (!command.contains("TMUX"))
.then(|| probe.current_pane())
.flatten();
let mut listed: Vec<(&[String], Listing)> = Vec::new();
for hit in &hits {
if let Some(why) = hit.opaque {
return Some(unresolved(hit, "", why));
}
if !listed.iter().any(|(s, _)| *s == hit.server.as_slice()) {
listed.push((&hit.server, probe.panes(&hit.server)));
}
let panes = match listed.iter().find(|(s, _)| *s == hit.server.as_slice()) {
Some((_, Ok(panes))) => panes,
Some((_, Err(err))) => return Some(unresolved(hit, "", &format!("tmux: {err}"))),
None => return Some(unresolved(hit, "", "no pane list")),
};
if !panes.iter().any(|p| p.architect) {
continue;
}
if hit.retargeted {
return Some(unresolved(hit, "", RETARGET));
}
for target in &hit.targets {
match reaches_architect(target, panes, current.as_deref()) {
Ok(false) => {}
Ok(true) => return Some(reached(hit, target)),
Err(why) => return Some(unresolved(hit, &shown(target), why)),
}
}
}
None
}
fn reaches_architect(
target: &Target,
panes: &[Pane],
current: Option<&str>,
) -> Result<bool, &'static str> {
let sessions = match target {
Target::Server => return Ok(true),
Target::Dynamic(_) => return Err("the shell expands it"),
Target::Picked => return Err(PICKED),
Target::Current => current_sessions(panes, current)?,
Target::Marked => sessions_where(panes, |p| p.marked),
Target::Literal(text) => literal_sessions(text, panes, current)?,
};
Ok(panes
.iter()
.any(|p| p.architect && sessions.contains(&p.session.as_str())))
}
fn current_sessions<'a>(
panes: &'a [Pane],
current: Option<&str>,
) -> Result<Vec<&'a str>, &'static str> {
let current = current.ok_or("the current pane is unknown")?;
let found = sessions_where(panes, |p| p.pane == current);
if found.is_empty() {
return Err("the current pane is not on this server");
}
Ok(found)
}
fn sessions_where(panes: &[Pane], keep: impl Fn(&Pane) -> bool) -> Vec<&str> {
panes
.iter()
.filter(|p| keep(p))
.map(|p| p.session.as_str())
.collect()
}
fn literal_sessions<'a>(
text: &str,
panes: &'a [Pane],
current: Option<&str>,
) -> Result<Vec<&'a str>, &'static str> {
if text.contains(['{', '}', '~', '!', '^', '*', '?', '[', ']', '#']) || text == "=" {
return Err("it is a special token, glob or format");
}
if text.starts_with(['+', '-']) {
return Err("it is a relative token");
}
let (session, rest) = match text.split_once(':') {
Some((session, rest)) => (Some(session), rest),
None => (None, text),
};
let mut out = match session {
Some(part) => session_part(part, panes, current)?,
None if text.starts_with(['%', '@', '$']) => id_sessions(text, panes)?,
None => {
let name = text.split('.').next().unwrap_or_default();
let mut found = current_sessions(panes, current)?;
found.extend(session_part(name, panes, current)?);
found
}
};
for id in rest.split('.').filter(|w| w.starts_with(['%', '@'])) {
out.extend(id_sessions(id, panes)?);
}
Ok(out)
}
fn session_part<'a>(
part: &str,
panes: &'a [Pane],
current: Option<&str>,
) -> Result<Vec<&'a str>, &'static str> {
if part.is_empty() {
return current_sessions(panes, current);
}
if part.starts_with(['%', '@', '$']) {
return id_sessions(part, panes);
}
if let Some(exact) = part.strip_prefix('=') {
return Ok(sessions_where(panes, |p| p.name == exact));
}
Ok(sessions_where(panes, |p| p.name.starts_with(part)))
}
fn id_sessions<'a>(id: &str, panes: &'a [Pane]) -> Result<Vec<&'a str>, &'static str> {
let id = id.split('.').next().unwrap_or_default();
if id.len() < 2 || !id[1..].bytes().all(|b| b.is_ascii_digit()) {
return Err("it is not a whole `%N`, `@N` or `$N` id");
}
Ok(sessions_where(panes, |p| {
p.pane == id || p.window == id || p.session == id
}))
}
fn shown(target: &Target) -> String {
match target {
Target::Literal(text) | Target::Dynamic(text) => text.clone(),
Target::Current => "(the current pane)".into(),
Target::Marked => "(the marked or current pane)".into(),
Target::Server => "(the whole server)".into(),
Target::Picked => "(a pane tmux picks)".into(),
}
}
const REMEDY: &str = "Only the Architect's main thread may type into, replace or kill its \
pane; PM-to-Architect messages do not go through the pane (owner ruling 2026-09-29). \
`TRUSTY_MPM_PM_UNRESTRICTED` / `TRUSTY_MPM_DISABLE_HOOKS` do not lift this floor. Aim \
other tmux commands at an exact target (`=name`, `%N`, `@N`).";
fn reached(hit: &Hit, target: &Target) -> String {
format!(
"Hard-floor deny (#8902): `tmux {}` targets `{}`, which reaches the Architect's tmux \
pane, and this session is not the Architect. {REMEDY}",
hit.verb,
shown(target)
)
}
fn unresolved(hit: &Hit, target: &str, why: &str) -> String {
let target = if target.is_empty() {
String::new()
} else {
format!(" targets `{target}`, which")
};
let token = hit.token.as_ref().map_or(String::new(), |t| {
format!(
" The text it cannot read is `{t}`; if the command runs no tmux, rewrite that \
text with ordinary `'…'` or `\"…\"` quoting."
)
});
format!(
"Hard-floor deny (#8902): `{}`{target} cannot be resolved ({why}). While an \
Architect runs, a tmux command the guard cannot resolve counts as aimed at its pane. \
{REMEDY}{token}",
format!("tmux {}", hit.verb).trim_end()
)
}
#[cfg(test)]
#[path = "architect_pane_tests.rs"]
mod tests;