use crate::proc::{Level, Terminal};
use std::path::{Path, PathBuf};
use std::process::Command;
const ENV_DENY_EXACT: &[&str] = &[
"TERM", "COLORTERM", "WINDOWID", "PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID",
"TMUX", "TMUX_PANE",
"STY", ];
const ENV_DENY_PREFIX: &[&str] = &["ALACRITTY_"];
pub fn keep_env(key: &str) -> bool {
!ENV_DENY_EXACT.contains(&key) && !ENV_DENY_PREFIX.iter().any(|p| key.starts_with(p))
}
fn filtered(env: &Level) -> Vec<(&str, &str)> {
env.iter()
.filter(|(k, _)| keep_env(k))
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect()
}
pub struct Plan {
pub cmd: Command,
pub tmp_dir: Option<PathBuf>,
pub rc_files: Vec<(PathBuf, String)>,
pub summary: String,
}
pub fn build_plan(term: &Terminal, command: &[String], base_only: bool, reuse: bool) -> Plan {
if reuse {
let mut cmd = Command::new("alacritty");
cmd.args(["msg", "create-window", "--working-directory"])
.arg(&term.cwd);
if !command.is_empty() {
cmd.arg("-e").args(command);
}
return Plan {
cmd,
tmp_dir: None,
rc_files: Vec::new(),
summary: "reuse: `alacritty msg create-window` — uses the running \
alacritty's own environment (source env and nesting not \
reproduced)"
.into(),
};
}
let nested = !base_only && command.is_empty() && term.levels.len() > 1;
if !nested {
let env = if base_only {
filtered(&term.levels[0])
} else {
filtered(term.levels.last().expect("at least one level"))
};
let mut cmd = Command::new("alacritty");
cmd.arg("--working-directory").arg(&term.cwd);
cmd.env_clear();
for (k, v) in &env {
cmd.env(k, v);
}
if !command.is_empty() {
cmd.arg("-e").args(command);
}
let which = if base_only {
"base login-shell"
} else {
"single-shell"
};
return Plan {
cmd,
tmp_dir: None,
rc_files: Vec::new(),
summary: format!("{which} environment: {} variables copied", env.len()),
};
}
let base = filtered(&term.levels[0]);
let n = term.levels.len();
let tmp_dir = std::env::temp_dir().join(format!("clonetty-{}", std::process::id()));
let rc_path = |i: usize| tmp_dir.join(format!("rc_{i}"));
let mut rc_files = Vec::new();
let mut summary = format!(
"nested reconstruction: {n} shell levels — Ctrl-D peels one at a time\n \
level 0 (base login shell): {} variables",
base.len()
);
for i in 0..n {
let is_leaf = i + 1 == n;
let child = (!is_leaf).then(|| rc_path(i + 1));
let content = if i == 0 {
rc_base(&tmp_dir, child.as_deref())
} else {
let (changed, removed) = delta(&term.levels[i - 1], &term.levels[i]);
summary.push_str(&format!(
"\n level {i}: {} vars set/changed, {} unset (delta from level {})",
changed.len(),
removed.len(),
i - 1
));
rc_level(i, &changed, &removed, child.as_deref())
};
rc_files.push((rc_path(i), content));
}
let mut cmd = Command::new("alacritty");
cmd.arg("--working-directory").arg(&term.cwd);
cmd.env_clear();
for (k, v) in &base {
cmd.env(k, v);
}
cmd.arg("-e")
.arg("bash")
.arg("--rcfile")
.arg(rc_path(0))
.arg("-i");
Plan {
cmd,
tmp_dir: Some(tmp_dir),
rc_files,
summary,
}
}
fn delta<'a>(prev: &'a Level, cur: &'a Level) -> (Vec<(&'a str, &'a str)>, Vec<&'a str>) {
let prev = filtered(prev);
let cur = filtered(cur);
let lookup = |env: &[(&'a str, &'a str)], key: &str| -> Option<&'a str> {
env.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
};
let changed = cur
.iter()
.copied()
.filter(|(k, v)| lookup(&prev, k) != Some(*v))
.collect();
let removed = prev
.iter()
.map(|(k, _)| *k)
.filter(|k| lookup(&cur, k).is_none())
.collect();
(changed, removed)
}
fn rc_base(tmp_dir: &Path, child: Option<&Path>) -> String {
let mut s = String::new();
s.push_str("# clonetty: reconstructed shell stack — base login shell (level 0).\n");
s.push_str(&format!(
"__clonetty_dir={}\n",
sq(&tmp_dir.to_string_lossy())
));
s.push_str("trap 'rm -rf -- \"$__clonetty_dir\"' EXIT\n");
s.push_str("# Normal interactive setup for a fresh terminal.\n");
s.push_str("[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n");
if let Some(child) = child {
s.push_str("# Descend into the reconstructed nested shells; Ctrl-D peels back to here.\n");
s.push_str(&format!("bash --rcfile {} -i\n", sq(&child.to_string_lossy())));
}
s
}
fn rc_level(i: usize, changed: &[(&str, &str)], removed: &[&str], child: Option<&Path>) -> String {
let mut s = format!("# clonetty: reconstructed shell stack — level {i}.\n");
s.push_str("[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n");
for (k, v) in changed {
if is_valid_name(k) {
s.push_str(&format!("export {k}={}\n", sq(v)));
}
}
for k in removed {
if is_valid_name(k) {
s.push_str(&format!("unset {k}\n"));
}
}
if let Some(child) = child {
s.push_str(&format!("bash --rcfile {} -i\n", sq(&child.to_string_lossy())));
}
s
}
fn is_valid_name(key: &str) -> bool {
let mut chars = key.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn describe(cmd: &Command) -> String {
let mut parts = vec![cmd.get_program().to_string_lossy().into_owned()];
for arg in cmd.get_args() {
parts.push(shell_quote(&arg.to_string_lossy()));
}
parts.join(" ")
}
fn sq(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn shell_quote(s: &str) -> String {
if !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./=:".contains(c))
{
s.to_string()
} else {
sq(s)
}
}