Skip to main content

tear_types/
spawn_env.rs

1//! [`SpawnEnv`] — the typed seam an embedder (mado) uses to stamp its
2//! OWN capability env + working directory onto every child PTY tear
3//! spawns, applied AFTER the backend's inherited + fallback env.
4//!
5//! **Why this exists** (operator report 2026-06-12: "vim is grey +
6//! wrong font in the embedded-tear default window"): tear-core's
7//! `InProcess::spawn_pty_for` inherits the daemon/process env and
8//! stamps conservative fallbacks (`TERM=xterm-256color`, no
9//! `TERMINFO`). The embedder (mado) advertises a richer capability set
10//! (`TERM=xterm-ghostty` + a vendored `TERMINFO` + `COLORTERM`) that
11//! the local-PTY path already projected — but the embedded-tear spawn
12//! had no way to push those through, so vim there saw no truecolor
13//! (grey) + the wrong terminfo. `SpawnEnv` is that channel: the
14//! embedder hands tear a typed override set, tear applies it AFTER the
15//! inherited env so the embedder's `TERM` wins over the fallback, and
16//! also stamps `PWD` consistently with the cwd (so a child shell can
17//! never inherit a stale parent `PWD`).
18//!
19//! The override is OURS, not the backend's: tear does not invent these
20//! values. It carries the embedder's intent verbatim. This keeps
21//! tear-core agnostic about WHAT capabilities the embedder advertises
22//! while still letting the embedder be the source of truth.
23
24/// A typed env + cwd override an embedder stamps on every child PTY.
25/// Applied by the spawn backend AFTER the inherited + fallback env, so
26/// each `(key, value)` here OVERRIDES whatever the backend defaulted.
27///
28/// `Serialize`/`Deserialize` so a tear-client can push the override
29/// across the daemon wire (`Request::SetSpawnEnv`) — closing the gap
30/// where the daemon-spawned child only saw the daemon's own env, never
31/// the embedder's capability projection (truecolor/terminfo).
32#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct SpawnEnv {
34    /// Env pairs that override the backend's inherited/fallback env.
35    /// Order is preserved; later duplicates win (the backend's `env`
36    /// vec is a positional list `CommandBuilder` walks in order).
37    pub overrides: Vec<(String, String)>,
38    /// Working directory for the child. When `Some`, the spawn backend
39    /// also stamps `PWD=<dir>` so a child shell's `$PWD` matches its
40    /// real cwd (the cwd-handshake hygiene). `None` leaves the
41    /// backend's cwd untouched.
42    pub cwd: Option<String>,
43}
44
45impl SpawnEnv {
46    /// An empty override — the backend's inherited + fallback env is
47    /// used verbatim (the pre-seam behaviour).
48    #[must_use]
49    pub fn none() -> Self {
50        Self::default()
51    }
52
53    /// Build from typed env pairs (the embedder's capability
54    /// projection) with no cwd override.
55    #[must_use]
56    pub fn from_overrides(overrides: Vec<(String, String)>) -> Self {
57        Self {
58            overrides,
59            cwd: None,
60        }
61    }
62
63    /// Set the working directory the child spawns in (and the `PWD`
64    /// the backend stamps to match it).
65    #[must_use]
66    pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
67        self.cwd = cwd;
68        self
69    }
70
71    /// Whether this override carries anything — an empty override lets
72    /// the backend skip the apply pass entirely.
73    #[must_use]
74    pub fn is_empty(&self) -> bool {
75        self.overrides.is_empty() && self.cwd.is_none()
76    }
77
78    /// Apply this override onto a backend-built `env` vec IN PLACE:
79    /// every override key replaces an existing entry (or pushes a new
80    /// one), then `PWD` is reconciled with the cwd — stamped when a
81    /// cwd is set, removed when it is not, so a child can never inherit
82    /// a stale parent `PWD`. This is the ONE place the apply order is
83    /// defined; both the in-process and daemon backends call it.
84    pub fn apply_to(&self, env: &mut Vec<(String, String)>) {
85        for (k, v) in &self.overrides {
86            if let Some(slot) = env.iter_mut().find(|(ek, _)| ek == k) {
87                slot.1.clone_from(v);
88            } else {
89                env.push((k.clone(), v.clone()));
90            }
91        }
92        // PWD hygiene: a child shell trusts inherited PWD. Stamp it to
93        // the real cwd when we have one; otherwise strip any inherited
94        // PWD so a stale parent PWD can never leak (cwd handshake,
95        // operator report 2026-06-12).
96        match &self.cwd {
97            Some(dir) => {
98                if let Some(slot) = env.iter_mut().find(|(k, _)| k == "PWD") {
99                    slot.1.clone_from(dir);
100                } else {
101                    env.push(("PWD".to_owned(), dir.clone()));
102                }
103            }
104            None => env.retain(|(k, _)| k != "PWD"),
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::SpawnEnv;
112
113    #[test]
114    fn override_replaces_an_existing_key() {
115        let mut env = vec![
116            ("TERM".to_owned(), "xterm-256color".to_owned()),
117            ("PATH".to_owned(), "/usr/bin".to_owned()),
118        ];
119        SpawnEnv::from_overrides(vec![("TERM".to_owned(), "xterm-ghostty".to_owned())])
120            .apply_to(&mut env);
121        assert_eq!(
122            env.iter().find(|(k, _)| k == "TERM").map(|(_, v)| v.as_str()),
123            Some("xterm-ghostty"),
124            "the embedder's TERM must override the backend fallback"
125        );
126        // PATH untouched; no duplicate TERM.
127        assert_eq!(env.iter().filter(|(k, _)| k == "TERM").count(), 1);
128    }
129
130    #[test]
131    fn override_pushes_a_missing_key() {
132        let mut env = vec![("PATH".to_owned(), "/usr/bin".to_owned())];
133        SpawnEnv::from_overrides(vec![("COLORTERM".to_owned(), "truecolor".to_owned())])
134            .apply_to(&mut env);
135        assert_eq!(
136            env.iter()
137                .find(|(k, _)| k == "COLORTERM")
138                .map(|(_, v)| v.as_str()),
139            Some("truecolor")
140        );
141    }
142
143    #[test]
144    fn cwd_some_stamps_pwd_to_match() {
145        let mut env = vec![("PWD".to_owned(), "/stale/parent".to_owned())];
146        SpawnEnv::none()
147            .with_cwd(Some("/real/child".to_owned()))
148            .apply_to(&mut env);
149        assert_eq!(
150            env.iter().find(|(k, _)| k == "PWD").map(|(_, v)| v.as_str()),
151            Some("/real/child"),
152            "a set cwd must overwrite a stale inherited PWD"
153        );
154    }
155
156    #[test]
157    fn cwd_none_strips_any_inherited_pwd() {
158        let mut env = vec![("PWD".to_owned(), "/stale/parent".to_owned())];
159        SpawnEnv::none().apply_to(&mut env);
160        assert!(
161            !env.iter().any(|(k, _)| k == "PWD"),
162            "no cwd → no inherited PWD may leak to the child"
163        );
164    }
165
166    #[test]
167    fn empty_override_is_a_noop_except_pwd_strip() {
168        // The pre-seam env had no PWD; an empty SpawnEnv leaves it
169        // exactly as-is.
170        let mut env = vec![("TERM".to_owned(), "xterm-256color".to_owned())];
171        let before = env.clone();
172        SpawnEnv::none().apply_to(&mut env);
173        assert_eq!(env, before);
174    }
175}