@@ -31,6 +31,53 @@ pub struct PostRunFingerprint {
3131 /// Paths inferred from fspy during execution with their content fingerprints.
3232 /// Only populated when `input_config.includes_auto` is true.
3333 pub inferred_inputs : HashMap < RelativePathBuf , PathFingerprint > ,
34+
35+ /// Env vars observed via runner-aware IPC `getEnv` with `tracked: true`.
36+ /// Key is the env name; value is the env value at execution time (or
37+ /// `None` if unset). Validated at cache lookup by comparing against the
38+ /// current parent env.
39+ pub tracked_envs : BTreeMap < Str , Option < Str > > ,
40+
41+ /// Glob-pattern env queries (`getEnvs`) made with `tracked: true`.
42+ /// Outer key is the glob pattern, inner map is the match-set at
43+ /// execution time (name → value). Validated at cache lookup by
44+ /// re-matching against the current parent env and comparing the
45+ /// resulting set.
46+ pub tracked_env_globs : BTreeMap < Str , BTreeMap < Str , Str > > ,
47+ }
48+
49+ /// A mismatch between the stored post-run fingerprint and the current state.
50+ #[ expect(
51+ clippy:: enum_variant_names,
52+ reason = "all three variants describe different kinds of post-run changes; \
53+ dropping the `Changed` suffix on any one of them would be misleading"
54+ ) ]
55+ #[ derive( Debug , Clone ) ]
56+ pub enum PostRunMismatch {
57+ /// An inferred input file or directory changed.
58+ InputChanged { kind : InputChangeKind , path : RelativePathBuf } ,
59+ /// A tool-tracked env var changed value (or was added/removed).
60+ TrackedEnvChanged { name : Str , old : Option < Str > , new : Option < Str > } ,
61+ /// A tool-tracked env glob's match-set changed between runs. The glob
62+ /// still matches the same pattern, but added/removed/mutated entries.
63+ TrackedEnvGlobChanged { pattern : Str , diff : EnvGlobDiff } ,
64+ }
65+
66+ /// Per-pattern diff between stored and current match-set. Each map is a
67+ /// subset of the symmetric difference keyed by env name. `changed` holds
68+ /// names present in both with differing values; `added` / `removed` are
69+ /// exclusive to one side.
70+ #[ derive( Debug , Clone , Default , Serialize , Deserialize ) ]
71+ pub struct EnvGlobDiff {
72+ pub added : BTreeMap < Str , Str > ,
73+ pub removed : BTreeMap < Str , Str > ,
74+ pub changed : BTreeMap < Str , ( Str , Str ) > ,
75+ }
76+
77+ impl EnvGlobDiff {
78+ fn is_empty ( & self ) -> bool {
79+ self . added . is_empty ( ) && self . removed . is_empty ( ) && self . changed . is_empty ( )
80+ }
3481}
3582
3683/// Fingerprint for a single path (file or directory)
@@ -69,11 +116,15 @@ impl PostRunFingerprint {
69116 /// * `inferred_path_reads` - Map of paths that were read during execution (from fspy)
70117 /// * `base_dir` - Workspace root for resolving relative paths
71118 /// * `globbed_inputs` - Prerun glob fingerprint; paths here are skipped
119+ /// * `tracked_envs` - Tool-requested env vars (name → value), validated on lookup
120+ /// * `tracked_env_globs` - Tool-requested env globs (pattern → matches), validated on lookup
72121 #[ tracing:: instrument( level = "debug" , skip_all, name = "create_post_run_fingerprint" ) ]
73122 pub fn create (
74123 inferred_path_reads : & HashMap < RelativePathBuf , PathRead > ,
75124 base_dir : & AbsolutePath ,
76125 globbed_inputs : & BTreeMap < RelativePathBuf , u64 > ,
126+ tracked_envs : BTreeMap < Str , Option < Str > > ,
127+ tracked_env_globs : BTreeMap < Str , BTreeMap < Str , Str > > ,
77128 ) -> anyhow:: Result < Self > {
78129 let inferred_inputs = inferred_path_reads
79130 . par_iter ( )
@@ -85,16 +136,13 @@ impl PostRunFingerprint {
85136 } )
86137 . collect :: < anyhow:: Result < HashMap < _ , _ > > > ( ) ?;
87138
88- Ok ( Self { inferred_inputs } )
139+ Ok ( Self { inferred_inputs, tracked_envs , tracked_env_globs } )
89140 }
90141
91- /// Validates the fingerprint against current filesystem state.
92- /// Returns `Some((kind, path))` if an input changed , `None` if all valid.
142+ /// Validates the fingerprint against current filesystem and env state.
143+ /// Returns `Some(mismatch)` on the first divergence , `None` if all valid.
93144 #[ tracing:: instrument( level = "debug" , skip_all, name = "validate_post_run_fingerprint" ) ]
94- pub fn validate (
95- & self ,
96- base_dir : & AbsolutePath ,
97- ) -> anyhow:: Result < Option < ( InputChangeKind , RelativePathBuf ) > > {
145+ pub fn validate ( & self , base_dir : & AbsolutePath ) -> anyhow:: Result < Option < PostRunMismatch > > {
98146 let input_mismatch = self . inferred_inputs . par_iter ( ) . find_map_any (
99147 |( input_relative_path, path_fingerprint) | {
100148 let input_full_path = Arc :: < AbsolutePath > :: from ( base_dir. join ( input_relative_path) ) ;
@@ -120,12 +168,83 @@ impl PostRunFingerprint {
120168 } else {
121169 input_relative_path. clone ( )
122170 } ;
123- Some ( Ok ( ( kind, path) ) )
171+ Some ( Ok ( PostRunMismatch :: InputChanged { kind, path } ) )
124172 }
125173 } ,
126174 ) ;
127- input_mismatch. transpose ( )
175+ if let Some ( result) = input_mismatch {
176+ return result. map ( Some ) ;
177+ }
178+
179+ // Validate tracked envs against the current parent env.
180+ for ( name, stored_value) in & self . tracked_envs {
181+ let current_value =
182+ std:: env:: var_os ( name. as_str ( ) ) . and_then ( |v| v. to_str ( ) . map ( Str :: from) ) ;
183+ if current_value. as_ref ( ) != stored_value. as_ref ( ) {
184+ return Ok ( Some ( PostRunMismatch :: TrackedEnvChanged {
185+ name : name. clone ( ) ,
186+ old : stored_value. clone ( ) ,
187+ new : current_value,
188+ } ) ) ;
189+ }
190+ }
191+
192+ // Validate tracked env globs: re-enumerate parent env for each
193+ // pattern and diff against the stored match-set.
194+ for ( pattern, stored_matches) in & self . tracked_env_globs {
195+ let current_matches = match_env_glob ( pattern. as_str ( ) ) ?;
196+ let diff = diff_env_glob ( stored_matches, & current_matches) ;
197+ if !diff. is_empty ( ) {
198+ return Ok ( Some ( PostRunMismatch :: TrackedEnvGlobChanged {
199+ pattern : pattern. clone ( ) ,
200+ diff,
201+ } ) ) ;
202+ }
203+ }
204+
205+ Ok ( None )
206+ }
207+ }
208+
209+ /// Build the current match-set for `pattern` by enumerating
210+ /// `std::env::vars_os()` and keeping UTF-8 names whose representation matches
211+ /// the glob. Mirrors the server-side match (see
212+ /// `vite_task_server::Recorder::get_envs`).
213+ fn match_env_glob ( pattern : & str ) -> anyhow:: Result < BTreeMap < Str , Str > > {
214+ let set = vite_glob:: GlobPatternSet :: new ( std:: iter:: once ( pattern) ) ?;
215+ Ok ( std:: env:: vars_os ( )
216+ . filter_map ( |( name, value) | {
217+ let name_str = name. to_str ( ) ?. to_owned ( ) ;
218+ let value_str = value. to_str ( ) ?. to_owned ( ) ;
219+ if set. is_match ( & name_str) {
220+ Some ( ( Str :: from ( name_str. as_str ( ) ) , Str :: from ( value_str. as_str ( ) ) ) )
221+ } else {
222+ None
223+ }
224+ } )
225+ . collect ( ) )
226+ }
227+
228+ /// Compute the diff of two match-sets for the same glob pattern.
229+ fn diff_env_glob ( stored : & BTreeMap < Str , Str > , current : & BTreeMap < Str , Str > ) -> EnvGlobDiff {
230+ let mut diff = EnvGlobDiff :: default ( ) ;
231+ for ( name, stored_value) in stored {
232+ match current. get ( name) {
233+ None => {
234+ diff. removed . insert ( name. clone ( ) , stored_value. clone ( ) ) ;
235+ }
236+ Some ( current_value) if current_value != stored_value => {
237+ diff. changed . insert ( name. clone ( ) , ( stored_value. clone ( ) , current_value. clone ( ) ) ) ;
238+ }
239+ Some ( _) => { }
240+ }
241+ }
242+ for ( name, current_value) in current {
243+ if !stored. contains_key ( name) {
244+ diff. added . insert ( name. clone ( ) , current_value. clone ( ) ) ;
245+ }
128246 }
247+ diff
129248}
130249
131250/// Determine the kind of change between two differing path fingerprints.
0 commit comments