clonetty 0.0.1

Spawn a new Alacritty window cloning an existing terminal's working directory and its nested-shell environment stack, so Ctrl-D peels back one shell layer at a time.
//! Turning a captured [`Terminal`] into the `alacritty` command — and, for the
//! nested case, the synthesized shell rc files — that reproduce it in a new
//! window.

use crate::proc::{Level, Terminal};
use std::path::{Path, PathBuf};
use std::process::Command;

/// Window-/session-specific variables that must NOT be carried into the new
/// window: the freshly spawned Alacritty and shells set correct values for
/// these themselves, and copying the source window's would point the clone back
/// at the *old* window (`ALACRITTY_SOCKET`, `WINDOWID`) or lie about state
/// (`PWD`, `SHLVL`). We scrub them from every layer so the real shells manage
/// them.
const ENV_DENY_EXACT: &[&str] = &[
    "TERM",           // set by alacritty
    "COLORTERM",      // set by alacritty
    "WINDOWID",       // X11 window id of the old window
    "PWD",            // we pass --working-directory; the shell re-derives PWD
    "OLDPWD",         // stale directory history
    "SHLVL",          // each spawned shell increments this itself
    "_",              // last command run; meaningless in a new shell
    "TERM_SESSION_ID",
    "TMUX",           // inside-tmux markers
    "TMUX_PANE",
    "STY",            // inside-screen marker
];

/// Prefixes of env vars to drop. `ALACRITTY_*` (SOCKET, WINDOW_ID, LOG) all
/// point at the source window and must not leak into the new one.
const ENV_DENY_PREFIX: &[&str] = &["ALACRITTY_"];

/// True if `key` should be carried into the new window.
pub fn keep_env(key: &str) -> bool {
    !ENV_DENY_EXACT.contains(&key) && !ENV_DENY_PREFIX.iter().any(|p| key.starts_with(p))
}

/// A layer's environment with the window-specific vars scrubbed out, as
/// borrowed `(key, value)` pairs.
fn filtered(env: &Level) -> Vec<(&str, &str)> {
    env.iter()
        .filter(|(k, _)| keep_env(k))
        .map(|(k, v)| (k.as_str(), v.as_str()))
        .collect()
}

/// Everything needed to open the clone: the `alacritty` command, plus any rc
/// files that must be written to disk first (for the nested-stack case).
pub struct Plan {
    /// The `alacritty` invocation to run (or show under `--dry-run`).
    pub cmd: Command,
    /// Temp directory the rc files live in, if any. Created by the caller just
    /// before spawning; deleted by the new window's base shell on exit.
    pub tmp_dir: Option<PathBuf>,
    /// `(path, contents)` of each synthesized rc file to write before spawning.
    pub rc_files: Vec<(PathBuf, String)>,
    /// Human-readable summary of what the plan does (for `--dry-run`/warnings).
    pub summary: String,
}

/// Build the [`Plan`] for reproducing `term`.
///
/// * `reuse` asks the already-running Alacritty to open the window over its IPC
///   socket (`alacritty msg create-window`): fast, but the window inherits that
///   daemon's environment, so neither a custom env nor nested shells can be
///   reproduced.
/// * `base_only` culls the stack down to just the outermost login shell's
///   environment — a fresh Alacritty terminal (no `nix-shell`/subshell nesting)
///   opened at the same path.
/// * Otherwise (the default) we reconstruct the full nested shell stack via
///   env-delta rc files, so Ctrl-D peels back one layer at a time.
///
/// A trailing `command`, when present, runs in the innermost (leaf) environment
/// flattened into a single shell — nesting/peeling is moot for a one-shot
/// command.
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(),
        };
    }

    // Nesting is only meaningful for an interactive shell with >1 distinct layer.
    let nested = !base_only && command.is_empty() && term.levels.len() > 1;

    if !nested {
        // Single flattened shell: the clean base env (`--base`) or, by default,
        // the innermost shell's full env (which, for a command, is where it runs).
        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()),
        };
    }

    // --- Nested reconstruction via env-delta rc files. ---
    //
    // Alacritty is launched with the base login-shell env (level 0). It runs a
    // chain of nested bash shells, one per layer: each rc file applies that
    // layer's env delta and then *runs* (not `exec`s) the next shell as a child.
    // Because control returns to the parent when a child exits, Ctrl-D peels one
    // layer at a time; the final Ctrl-D at the base shell closes the window.
    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,
    }
}

/// The env delta from `prev` to `cur` (both scrubbed): variables that were
/// added or changed, and keys that were removed. This is one nested shell's
/// contribution — the vars a `nix-shell` (or any subshell) layered on.
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)
}

/// rc file for the base login shell (level 0): normal interactive setup, then
/// descend into the reconstructed nesting. It also owns cleanup of the temp dir
/// when the window finally closes.
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
}

/// rc file for a nested shell (level ≥ 1): re-run interactive setup, apply this
/// layer's env delta, then either descend to the next child or settle as the
/// interactive leaf.
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
}

/// A valid POSIX shell name (safe to `export`/`unset`). Env keys that aren't are
/// skipped rather than producing a broken rc line.
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 == '_')
}

/// Render a [`Command`] as a copy-pasteable shell line, for `--dry-run`. Note
/// this shows arguments only, not the environment (summarized separately).
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(" ")
}

/// Single-quote `s` for the shell, always quoting (for rc values, which may be
/// empty or contain anything).
fn sq(s: &str) -> String {
    format!("'{}'", s.replace('\'', r"'\''"))
}

/// Minimal single-quote shell escaping, quoting only when needed (for display).
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)
    }
}