Skip to main content

cc_toolgate/eval/
context.rs

1//! Per-segment command context: tokenization, env var extraction, and redirection detection.
2
3use crate::parse::Redirection;
4
5/// Context for evaluating a single command segment.
6#[derive(Debug)]
7pub struct CommandContext<'a> {
8    /// The full command text of this segment.
9    pub raw: &'a str,
10    /// The base command name (e.g. "git", "ls", "cargo").
11    pub base_command: String,
12    /// All words in the command (tokenized via shlex).
13    pub words: Vec<String>,
14    /// Leading KEY=VALUE environment variable assignments.
15    pub env_vars: Vec<(String, String)>,
16    /// Detected output redirection, if any.
17    pub redirection: Option<Redirection>,
18    /// Environment variables accumulated from prior segments in a compound command
19    /// (e.g. `export FOO=bar ; git push` makes FOO=bar available to the git push segment).
20    pub accumulated_env: std::collections::HashMap<String, String>,
21}
22
23impl<'a> CommandContext<'a> {
24    /// Build a CommandContext from a raw command string.
25    pub fn from_command(raw: &'a str) -> Self {
26        let base_command = crate::parse::base_command(raw);
27        let env_vars = crate::parse::env_vars(raw);
28        let words = crate::parse::tokenize(raw);
29        let redirection = crate::parse::has_output_redirection(raw);
30
31        Self {
32            raw,
33            base_command,
34            words,
35            env_vars,
36            redirection,
37            accumulated_env: std::collections::HashMap::new(),
38        }
39    }
40
41    /// Check if all required env var entries are satisfied.
42    ///
43    /// For each entry, checks the command's inline env vars first (exact key+value match),
44    /// then falls back to the process environment (`std::env::var`).
45    /// Returns true only if ALL entries match. Some entries may come from inline env
46    /// and others from the process environment — each is checked independently.
47    ///
48    /// Config values are shell-expanded (`~`, `$HOME`, `$VAR`) before comparison,
49    /// since shells expand these in env assignments before they reach the process.
50    pub fn env_satisfies(&self, required: &std::collections::HashMap<String, String>) -> bool {
51        required.iter().all(|(key, value)| {
52            let expanded = match shellexpand::full(value) {
53                Ok(v) => v,
54                Err(e) => {
55                    log::warn!("shellexpand failed for config_env {key}={value}: {e}");
56                    std::borrow::Cow::Borrowed(value.as_str())
57                }
58            };
59            // Check inline env vars first (may contain literal ~ or expanded path)
60            if let Some((_, v)) = self.env_vars.iter().find(|(k, _)| k == key) {
61                return v == value || v == expanded.as_ref();
62            }
63            // Check accumulated env from prior compound-command segments
64            if let Some(v) = self.accumulated_env.get(key) {
65                return v == value || v == expanded.as_ref();
66            }
67            // Fall back to process environment (shell will have expanded already)
68            std::env::var(key).is_ok_and(|v| v == *value || v == expanded.as_ref())
69        })
70    }
71
72    /// Get words after skipping env vars and the base command.
73    pub fn args(&self) -> &[String] {
74        // Skip env var tokens and the base command itself
75        let skip = self.env_vars.len() + 1; // each env var is one token in shlex, plus the command
76        if self.words.len() > skip {
77            &self.words[skip..]
78        } else {
79            &[]
80        }
81    }
82
83    /// Check if any word matches a flag.
84    pub fn has_flag(&self, flag: &str) -> bool {
85        self.words.iter().any(|w| w == flag)
86    }
87
88    /// Check if any word matches any of the given flags.
89    pub fn has_any_flag(&self, flags: &[&str]) -> bool {
90        self.words.iter().any(|w| flags.contains(&w.as_str()))
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::collections::HashMap;
98
99    /// Panic unless running under nextest (process-per-test isolation).
100    ///
101    /// Tests that call `std::env::set_var` / `remove_var` are unsound under
102    /// `cargo test`, which runs tests concurrently in a single process.
103    /// Nextest sets `NEXTEST=1` in every child process.
104    fn require_nextest() {
105        assert!(
106            std::env::var("NEXTEST").is_ok(),
107            "this test mutates process env and requires nextest (cargo nextest run)"
108        );
109    }
110
111    #[test]
112    fn env_satisfies_inline_exact() {
113        let ctx = CommandContext::from_command("FOO=bar git push");
114        let req = HashMap::from([("FOO".into(), "bar".into())]);
115        assert!(ctx.env_satisfies(&req));
116    }
117
118    #[test]
119    fn env_satisfies_inline_wrong_value() {
120        let ctx = CommandContext::from_command("FOO=baz git push");
121        let req = HashMap::from([("FOO".into(), "bar".into())]);
122        assert!(!ctx.env_satisfies(&req));
123    }
124
125    #[test]
126    fn env_satisfies_inline_missing() {
127        let ctx = CommandContext::from_command("git push");
128        let req = HashMap::from([("FOO".into(), "bar".into())]);
129        // No inline var, no process env → false
130        assert!(!ctx.env_satisfies(&req));
131    }
132
133    #[test]
134    fn env_satisfies_process_env() {
135        require_nextest();
136        let key = "CC_TOOLGATE_TEST_PROCESS_ENV";
137        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
138        unsafe { std::env::set_var(key, "expected_value") };
139        let ctx = CommandContext::from_command("git push");
140        let req = HashMap::from([(key.into(), "expected_value".into())]);
141        assert!(ctx.env_satisfies(&req));
142        unsafe { std::env::remove_var(key) };
143    }
144
145    #[test]
146    fn env_satisfies_process_env_wrong_value() {
147        require_nextest();
148        let key = "CC_TOOLGATE_TEST_WRONG_VALUE";
149        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
150        unsafe { std::env::set_var(key, "actual") };
151        let ctx = CommandContext::from_command("git push");
152        let req = HashMap::from([(key.into(), "expected".into())]);
153        assert!(!ctx.env_satisfies(&req));
154        unsafe { std::env::remove_var(key) };
155    }
156
157    #[test]
158    fn env_satisfies_multi_source_one_inline_one_process() {
159        require_nextest();
160        let key_process = "CC_TOOLGATE_TEST_MULTI_PROC";
161        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
162        unsafe { std::env::set_var(key_process, "/correct/path") };
163        let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
164        let req = HashMap::from([
165            ("INLINE_VAR".into(), "correct".into()),
166            (key_process.into(), "/correct/path".into()),
167        ]);
168        assert!(ctx.env_satisfies(&req));
169        unsafe { std::env::remove_var(key_process) };
170    }
171
172    #[test]
173    fn env_satisfies_multi_source_one_missing() {
174        // No env mutation — safe under cargo test
175        let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
176        let req = HashMap::from([
177            ("INLINE_VAR".into(), "correct".into()),
178            ("MISSING_VAR".into(), "value".into()),
179        ]);
180        assert!(!ctx.env_satisfies(&req));
181    }
182
183    #[test]
184    fn env_satisfies_multi_source_one_wrong() {
185        require_nextest();
186        let key_process = "CC_TOOLGATE_TEST_MULTI_WRONG";
187        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
188        unsafe { std::env::set_var(key_process, "/wrong/path") };
189        let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
190        let req = HashMap::from([
191            ("INLINE_VAR".into(), "correct".into()),
192            (key_process.into(), "/correct/path".into()),
193        ]);
194        assert!(!ctx.env_satisfies(&req));
195        unsafe { std::env::remove_var(key_process) };
196    }
197
198    #[test]
199    fn env_satisfies_tilde_expansion() {
200        require_nextest();
201        let key = "CC_TOOLGATE_TEST_TILDE";
202        let home = std::env::var("HOME").unwrap();
203        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
204        unsafe { std::env::set_var(key, format!("{home}/foo")) };
205        let ctx = CommandContext::from_command("git push");
206        let req = HashMap::from([(key.into(), "~/foo".into())]);
207        assert!(ctx.env_satisfies(&req));
208        unsafe { std::env::remove_var(key) };
209    }
210
211    #[test]
212    fn env_satisfies_empty_map() {
213        let ctx = CommandContext::from_command("git push");
214        assert!(ctx.env_satisfies(&HashMap::new()));
215    }
216
217    // ── Collision tests ──
218    //
219    // These two tests use the SAME env var key with DIFFERENT expected values.
220    // Under nextest (process-per-test), both pass reliably because each process
221    // has its own environment. Under `cargo test` (shared process, concurrent
222    // threads), one would see the other's write and produce a wrong result.
223
224    const COLLISION_KEY: &str = "CC_TOOLGATE_TEST_COLLISION";
225
226    #[test]
227    fn env_collision_value_alpha() {
228        require_nextest();
229        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
230        unsafe { std::env::set_var(COLLISION_KEY, "alpha") };
231        // Spin briefly to widen the race window under concurrent execution
232        std::thread::sleep(std::time::Duration::from_millis(5));
233        let ctx = CommandContext::from_command("git push");
234        let req = HashMap::from([(COLLISION_KEY.into(), "alpha".into())]);
235        assert!(
236            ctx.env_satisfies(&req),
237            "expected 'alpha', env was tampered"
238        );
239        unsafe { std::env::remove_var(COLLISION_KEY) };
240    }
241
242    #[test]
243    fn env_collision_value_beta() {
244        require_nextest();
245        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
246        unsafe { std::env::set_var(COLLISION_KEY, "beta") };
247        std::thread::sleep(std::time::Duration::from_millis(5));
248        let ctx = CommandContext::from_command("git push");
249        let req = HashMap::from([(COLLISION_KEY.into(), "beta".into())]);
250        assert!(ctx.env_satisfies(&req), "expected 'beta', env was tampered");
251        unsafe { std::env::remove_var(COLLISION_KEY) };
252    }
253}