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}