/ src / selection.rs
selection.rs
  1  use crate::path_cache::PathCache;
  2  use crate::workspace::{PackageInfo, WorkspaceManager};
  3  use std::collections::HashMap;
  4  use std::path::{Path, PathBuf};
  5  use std::sync::Arc;
  6  
  7  const AUTO_DISCOVERY_PRIORITY: &[&str] = &[
  8      ".env.local",
  9      ".env.development",
 10      ".env.dev",
 11      ".env",
 12      ".env.test",
 13      ".env.staging",
 14      ".env.production",
 15      ".env.prod",
 16  ];
 17  
 18  pub struct ActiveFileSelector {
 19      workspace_root: PathBuf,
 20      path_cache: Arc<PathCache>,
 21  }
 22  
 23  impl ActiveFileSelector {
 24      pub fn new(workspace_root: &Path, path_cache: Arc<PathCache>) -> Self {
 25          Self {
 26              workspace_root: workspace_root.to_path_buf(),
 27              path_cache,
 28          }
 29      }
 30  
 31      pub fn resolve_patterns(&self, base_dir: &Path, patterns: &[String]) -> Vec<PathBuf> {
 32          let mut result = Vec::new();
 33  
 34          for pattern in patterns {
 35              let full_pattern = if pattern.starts_with('/') || pattern.starts_with("./") {
 36                  pattern.clone()
 37              } else {
 38                  format!("{}/{}", base_dir.display(), pattern)
 39              };
 40  
 41              let glob_pattern = if let Some(stripped) = full_pattern.strip_prefix("./") {
 42                  stripped
 43              } else if full_pattern.starts_with('/') {
 44                  &full_pattern
 45              } else {
 46                  full_pattern.as_str()
 47              };
 48  
 49              let pattern_str = glob_pattern.to_string();
 50              match glob::glob_with(
 51                  &pattern_str,
 52                  glob::MatchOptions {
 53                      case_sensitive: true,
 54                      require_literal_separator: false,
 55                      require_literal_leading_dot: false,
 56                  },
 57              ) {
 58                  Ok(entries) => {
 59                      let mut matches: Vec<PathBuf> = entries
 60                          .filter_map(|entry| entry.ok())
 61                          .filter(|path| path.is_file())
 62                          .collect();
 63  
 64                      if matches.is_empty() {
 65                          tracing::warn!(
 66                              "No files found matching pattern '{}' in '{}', glob pattern was '{}'",
 67                              pattern,
 68                              base_dir.display(),
 69                              pattern_str
 70                          );
 71                      } else {
 72                          matches.sort();
 73                          result.extend(matches);
 74                      }
 75                  }
 76                  Err(e) => {
 77                      tracing::warn!(
 78                          "Failed to parse glob pattern '{}': {}",
 79                          pattern,
 80                          e.to_string()
 81                      );
 82                  }
 83              }
 84          }
 85  
 86          result
 87      }
 88  
 89      pub fn auto_discover_files(
 90          &self,
 91          package_root: &Path,
 92          packages: Vec<PackageInfo>,
 93      ) -> Vec<PathBuf> {
 94          let mut result = Vec::new();
 95  
 96          let is_monorepo = packages.len() > 1 || package_root != self.workspace_root;
 97  
 98          if is_monorepo {
 99              for env_file_name in AUTO_DISCOVERY_PRIORITY {
100                  let root_env_path = self.workspace_root.join(env_file_name);
101                  if root_env_path.exists() {
102                      result.push(root_env_path);
103                      break;
104                  }
105              }
106          }
107  
108          for env_file_name in AUTO_DISCOVERY_PRIORITY {
109              let package_env_path = package_root.join(env_file_name);
110              if package_env_path.exists() {
111                  result.push(package_env_path);
112                  break;
113              }
114          }
115  
116          result
117      }
118  
119      pub fn compute_active_files(
120          &self,
121          file_path: &Path,
122          global_patterns: Option<&[String]>,
123          directory_scoped: &HashMap<PathBuf, Vec<String>>,
124          workspace: &WorkspaceManager,
125      ) -> Vec<PathBuf> {
126          let canonical_file = self.path_cache.canonicalize(file_path);
127  
128          let mut result = Vec::new();
129  
130          if let Some(patterns) = global_patterns {
131              if patterns.is_empty() {
132                  let context = workspace.context_for_file(file_path);
133                  if let Some(ctx) = context {
134                      result.extend(
135                          self.auto_discover_files(&ctx.package_root, workspace.packages().to_vec()),
136                      );
137                  }
138              } else {
139                  result.extend(self.resolve_patterns(&self.workspace_root, patterns));
140              }
141          } else {
142              let context = workspace.context_for_file(file_path);
143              if let Some(ctx) = context {
144                  result.extend(self.auto_discover_files(&ctx.package_root, workspace.packages()));
145              }
146          }
147  
148          let mut best_match: Option<(&PathBuf, Vec<String>)> = None;
149          for (scope_dir, patterns) in directory_scoped {
150              let canonical_scope = self.path_cache.canonicalize(scope_dir);
151              if canonical_file.starts_with(&canonical_scope) {
152                  match &best_match {
153                      None => best_match = Some((scope_dir, patterns.clone())),
154                      Some((existing_dir, _)) => {
155                          if canonical_scope.as_os_str().len() > existing_dir.as_os_str().len() {
156                              best_match = Some((scope_dir, patterns.clone()));
157                          }
158                      }
159                  }
160              }
161          }
162  
163          if let Some((scope_dir, patterns)) = best_match {
164              if patterns.is_empty() {
165                  let context = workspace.context_for_file(scope_dir);
166                  if let Some(ctx) = context {
167                      result.extend(
168                          self.auto_discover_files(&ctx.package_root, workspace.packages().to_vec()),
169                      );
170                  }
171              } else {
172                  result.extend(self.resolve_patterns(scope_dir, &patterns));
173              }
174          }
175  
176          result
177      }
178  }
179  
180  #[cfg(test)]
181  mod tests {
182      use super::*;
183      use compact_str::CompactString;
184      use std::fs;
185  
186      fn setup_test_workspace() -> tempfile::TempDir {
187          let dir = tempfile::tempdir().unwrap();
188          let workspace_root = dir.path();
189  
190          fs::create_dir_all(workspace_root.join("packages/app1")).unwrap();
191          fs::create_dir_all(workspace_root.join("packages/app2")).unwrap();
192  
193          dir
194      }
195  
196      #[test]
197      fn test_auto_discovery_single_package() {
198          let temp_dir = setup_test_workspace();
199          let workspace_root = temp_dir.path();
200  
201          let env_path = workspace_root.join(".env");
202          fs::write(&env_path, "TEST=value").unwrap();
203  
204          let path_cache = Arc::new(PathCache::new());
205          let selector = ActiveFileSelector::new(workspace_root, path_cache);
206          let packages = vec![PackageInfo {
207              name: Some(CompactString::new("root")),
208              root: workspace_root.to_path_buf(),
209              relative_path: CompactString::new("."),
210          }];
211  
212          let result = selector.auto_discover_files(workspace_root, packages);
213          assert_eq!(result.len(), 1);
214          assert_eq!(result[0], env_path);
215      }
216  
217      #[test]
218      fn test_auto_discovery_priority_order() {
219          let temp_dir = setup_test_workspace();
220          let workspace_root = temp_dir.path();
221  
222          let env_local = workspace_root.join(".env.local");
223          fs::write(&env_local, "TEST=local").unwrap();
224  
225          let env_dev = workspace_root.join(".env.dev");
226          fs::write(&env_dev, "TEST=dev").unwrap();
227  
228          let path_cache = Arc::new(PathCache::new());
229          let selector = ActiveFileSelector::new(workspace_root, path_cache);
230          let packages = vec![PackageInfo {
231              name: Some(CompactString::new("root")),
232              root: workspace_root.to_path_buf(),
233              relative_path: CompactString::new("."),
234          }];
235  
236          let result = selector.auto_discover_files(workspace_root, packages);
237          assert_eq!(result.len(), 1);
238          assert_eq!(result[0], env_local);
239      }
240  
241      #[test]
242      fn test_auto_discovery_monorepo() {
243          let temp_dir = setup_test_workspace();
244          let workspace_root = temp_dir.path();
245  
246          let root_env = workspace_root.join(".env");
247          fs::write(&root_env, "GLOBAL=value").unwrap();
248  
249          let app1_root = workspace_root.join("packages/app1");
250          let app1_env = app1_root.join(".env");
251          fs::write(&app1_env, "APP=app1").unwrap();
252  
253          let path_cache = Arc::new(PathCache::new());
254          let selector = ActiveFileSelector::new(workspace_root, path_cache);
255          let packages = vec![
256              PackageInfo {
257                  name: Some(CompactString::new("app1")),
258                  root: app1_root.clone(),
259                  relative_path: CompactString::new("packages/app1"),
260              },
261              PackageInfo {
262                  name: Some(CompactString::new("app2")),
263                  root: workspace_root.join("packages/app2"),
264                  relative_path: CompactString::new("packages/app2"),
265              },
266          ];
267  
268          let result = selector.auto_discover_files(&app1_root, packages);
269          assert_eq!(result.len(), 2);
270          assert!(result.contains(&root_env));
271          assert!(result.contains(&app1_env));
272      }
273  
274      #[test]
275      fn test_resolve_patterns_simple() {
276          let temp_dir = setup_test_workspace();
277          let workspace_root = temp_dir.path();
278  
279          let env1 = workspace_root.join(".env");
280          fs::write(&env1, "TEST=1").unwrap();
281  
282          let env2 = workspace_root.join(".env.local");
283          fs::write(&env2, "TEST=2").unwrap();
284  
285          let path_cache = Arc::new(PathCache::new());
286          let selector = ActiveFileSelector::new(workspace_root, path_cache);
287          let result = selector.resolve_patterns(workspace_root, &[".env*".to_string()]);
288  
289          assert_eq!(result.len(), 2);
290          assert!(result.contains(&env1));
291          assert!(result.contains(&env2));
292      }
293  
294      #[test]
295      fn test_resolve_patterns_sorting() {
296          let temp_dir = setup_test_workspace();
297          let workspace_root = temp_dir.path();
298  
299          let env_b = workspace_root.join(".env.b");
300          fs::write(&env_b, "TEST=b").unwrap();
301  
302          let env_a = workspace_root.join(".env.a");
303          fs::write(&env_a, "TEST=a").unwrap();
304  
305          let path_cache = Arc::new(PathCache::new());
306          let selector = ActiveFileSelector::new(workspace_root, path_cache);
307          let result = selector.resolve_patterns(workspace_root, &[".env.*".to_string()]);
308  
309          assert_eq!(result.len(), 2);
310          assert_eq!(result[0], env_a);
311          assert_eq!(result[1], env_b);
312      }
313  }