use anyhow::{Context, Result, bail};
use std::fs;
use std::path::{Path, PathBuf};
pub type Level = Vec<(String, String)>;
#[derive(Debug)]
pub struct Terminal {
pub cwd: PathBuf,
pub levels: Vec<Level>,
pub found_alacritty: bool,
}
impl Terminal {
pub fn current() -> Result<Self> {
let cwd = std::env::current_dir().context("reading current working directory")?;
let self_env: Level = std::env::vars().collect();
let start = ppid_of(std::process::id()).unwrap_or_else(std::process::id);
let (chain, found_alacritty) = ancestor_chain(start);
let mut levels = levels_from_chain(&chain, self_env.clone());
if levels.is_empty() {
levels.push(self_env);
}
Ok(Self { cwd, levels, found_alacritty })
}
pub fn from_pid(pid: u32) -> Result<Self> {
let leaf = resolve_shell_pid(pid)?;
let cwd = read_cwd(leaf)?;
let leaf_env = leaf_witness_env(leaf)?;
let (chain, found_alacritty) = ancestor_chain(leaf);
let mut levels = levels_from_chain(&chain, leaf_env.clone());
if levels.is_empty() {
levels.push(leaf_env);
}
Ok(Self { cwd, levels, found_alacritty })
}
}
pub fn read_comm(pid: u32) -> Result<String> {
let raw = fs::read_to_string(format!("/proc/{pid}/comm"))
.with_context(|| format!("reading /proc/{pid}/comm (does pid {pid} exist?)"))?;
Ok(raw.trim_end().to_string())
}
pub fn read_cwd(pid: u32) -> Result<PathBuf> {
fs::read_link(format!("/proc/{pid}/cwd")).with_context(|| {
format!("reading working directory of pid {pid} (owned by another user?)")
})
}
pub fn read_environ(pid: u32) -> Result<Vec<(String, String)>> {
let raw = fs::read(format!("/proc/{pid}/environ"))
.with_context(|| format!("reading environment of pid {pid} (owned by another user?)"))?;
Ok(parse_environ(&raw))
}
fn parse_environ(raw: &[u8]) -> Vec<(String, String)> {
raw.split(|&b| b == 0)
.filter(|entry| !entry.is_empty())
.filter_map(|entry| {
let text = String::from_utf8_lossy(entry);
let (key, value) = text.split_once('=')?;
Some((key.to_string(), value.to_string()))
})
.collect()
}
pub fn ppid_of(pid: u32) -> Option<u32> {
let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
status
.lines()
.find_map(|line| line.strip_prefix("PPid:")?.trim().parse().ok())
}
pub fn ancestor_chain(start: u32) -> (Vec<u32>, bool) {
let mut chain = Vec::new();
let mut cur = start;
let mut found_alacritty = false;
for _ in 0..256 {
if read_comm(cur).ok().as_deref() == Some("alacritty") {
found_alacritty = true;
break;
}
chain.push(cur);
match ppid_of(cur) {
Some(parent) if parent > 1 => cur = parent,
_ => break,
}
}
chain.reverse(); (chain, found_alacritty)
}
fn levels_from_chain(chain: &[u32], leaf_env: Level) -> Vec<Level> {
let n = chain.len();
let mut levels: Vec<Level> = Vec::new();
for i in 0..n {
let env = if i + 1 == n {
leaf_env.clone()
} else if let Some(rec) = recorded_env(chain[i]) {
rec
} else if i == 0 {
match read_environ(chain[0]) {
Ok(env) => env,
Err(_) => continue,
}
} else {
match read_environ(chain[i + 1]) {
Ok(env) => env,
Err(_) => continue,
}
};
if levels.last().is_some_and(|prev| same_env(prev, &env)) {
continue;
}
levels.push(env);
}
levels
}
fn leaf_witness_env(leaf: u32) -> Result<Level> {
if let Ok(kids) = children_of(leaf) {
for (child, _) in kids {
if let Ok(env) = read_environ(child) {
return Ok(env);
}
}
}
if let Some(env) = recorded_env(leaf) {
return Ok(env);
}
read_environ(leaf)
}
fn recorder_dir() -> Option<PathBuf> {
match std::env::var("XDG_RUNTIME_DIR") {
Ok(dir) if !dir.is_empty() => Some(PathBuf::from(dir).join("clonetty")),
_ => Some(PathBuf::from(format!("/run/user/{}/clonetty", real_uid()?))),
}
}
fn real_uid() -> Option<u32> {
let status = fs::read_to_string("/proc/self/status").ok()?;
status
.lines()
.find_map(|line| line.strip_prefix("Uid:")?.split_whitespace().next()?.parse().ok())
}
fn recorded_env(pid: u32) -> Option<Level> {
let raw = fs::read(recorder_dir()?.join(pid.to_string())).ok()?;
let env = parse_environ(&raw);
(!env.is_empty()).then_some(env)
}
fn same_env(a: &[(String, String)], b: &[(String, String)]) -> bool {
if a.len() != b.len() {
return false;
}
let mut a: Vec<&(String, String)> = a.iter().collect();
let mut b: Vec<&(String, String)> = b.iter().collect();
a.sort();
b.sort();
a == b
}
pub fn children_of(pid: u32) -> Result<Vec<(u32, String)>> {
let mut kids = Vec::new();
for entry in fs::read_dir("/proc").context("scanning /proc")? {
let entry = entry?;
let Some(child) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
if let Some((name, ppid)) = read_name_and_ppid(&entry.path())
&& ppid == pid
{
kids.push((child, name));
}
}
kids.sort_by_key(|(pid, _)| *pid);
Ok(kids)
}
fn read_name_and_ppid(proc_dir: &Path) -> Option<(String, u32)> {
let status = fs::read_to_string(proc_dir.join("status")).ok()?;
let mut name = None;
let mut ppid = None;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Name:") {
name = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix("PPid:") {
ppid = rest.trim().parse().ok();
}
if name.is_some() && ppid.is_some() {
break;
}
}
Some((name?, ppid?))
}
const SHELL_NAMES: &[&str] = &[
"bash", "sh", "dash", "ash", "zsh", "fish", "nu", "ksh", "mksh", "tcsh", "csh",
];
fn is_shell(name: &str) -> bool {
SHELL_NAMES.contains(&name)
}
pub fn descend_to_leaf(mut pid: u32) -> u32 {
for _ in 0..256 {
let shell_kids: Vec<u32> = children_of(pid)
.unwrap_or_default()
.into_iter()
.filter(|(_, name)| is_shell(name))
.map(|(child, _)| child)
.collect();
match shell_kids.as_slice() {
[next] => pid = *next,
_ => break, }
}
pid
}
pub fn resolve_shell_pid(pid: u32) -> Result<u32> {
if read_comm(pid)? != "alacritty" {
return Ok(pid);
}
let kids = children_of(pid)?;
match kids.as_slice() {
[] => bail!("alacritty pid {pid} has no child process to clone"),
[(only, _)] => Ok(descend_to_leaf(*only)),
many => {
let mut msg = format!(
"alacritty pid {pid} hosts {} windows; re-run with the shell PID of the one you want:\n",
many.len()
);
for (child, name) in many {
let where_ = read_cwd(*child)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "?".to_string());
msg.push_str(&format!(" --pid {child:<7} {name:<16} {where_}\n"));
}
bail!(msg.trim_end().to_string())
}
}
}