1use crate::{Error, Result};
7use ignore::WalkBuilder;
8use std::fmt::Display;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[must_use]
30pub fn compute_relative_path(target: &Path, module_root: &Path) -> String {
31 target.strip_prefix(module_root).map_or_else(
32 |_| ".".to_string(),
33 |p| {
34 if p.as_os_str().is_empty() {
35 ".".to_string()
36 } else {
37 p.to_string_lossy().to_string()
38 }
39 },
40 )
41}
42
43#[must_use]
58pub fn adjust_meta_key_path(meta_key: &str, relative_path: &str) -> String {
59 if meta_key.starts_with("./") {
60 meta_key.replacen("./", &format!("{relative_path}/"), 1)
61 } else {
62 meta_key.to_string()
63 }
64}
65
66#[must_use]
85pub fn format_eval_errors<E: Display>(errors: &[(PathBuf, E)]) -> String {
86 errors
87 .iter()
88 .map(|(dir, e)| format!(" {}: {e}", dir.display()))
89 .collect::<Vec<_>>()
90 .join("\n")
91}
92
93#[derive(Debug)]
95#[allow(missing_docs)]
96pub enum EnvFileStatus {
97 Missing,
99 PackageMismatch { found_package: Option<String> },
101 Match(PathBuf),
103}
104
105pub fn find_env_file(path: &Path, expected_package: &str) -> Result<EnvFileStatus> {
111 let directory = normalize_path(path)?;
112
113 let env_file = directory.join("env.cue");
114 if !env_file.exists() {
115 return Ok(EnvFileStatus::Missing);
116 }
117
118 let package_name = detect_package_name(&env_file)?;
119 if package_name.as_deref() != Some(expected_package) {
120 return Ok(EnvFileStatus::PackageMismatch {
121 found_package: package_name,
122 });
123 }
124
125 let canonical = directory
126 .canonicalize()
127 .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {e}")))?;
128
129 Ok(EnvFileStatus::Match(canonical))
130}
131
132pub fn detect_package_name(path: &Path) -> Result<Option<String>> {
136 let contents = fs::read_to_string(path)
137 .map_err(|e| Error::configuration(format!("Failed to read {}: {e}", path.display())))?;
138
139 let cleaned = strip_comments(contents.trim_start_matches('\u{feff}'));
140
141 for line in cleaned.lines() {
142 let trimmed = line.trim();
143
144 if trimmed.is_empty() {
145 continue;
146 }
147
148 if let Some(rest) = trimmed.strip_prefix("package ") {
149 if let Some(name) = rest.split_whitespace().next()
150 && !name.is_empty()
151 {
152 return Ok(Some(name.to_string()));
153 }
154 return Ok(None);
155 }
156 break;
157 }
158
159 Ok(None)
160}
161
162#[must_use]
167pub fn find_cue_module_root(start: &Path) -> Option<PathBuf> {
168 let mut current = normalize_path(start).ok()?;
169
170 current = current.canonicalize().ok()?;
172
173 loop {
174 if current.join("cue.mod").is_dir() {
175 return Some(current);
176 }
177
178 match current.parent() {
179 Some(parent) => current = parent.to_path_buf(),
180 None => return None,
181 }
182 }
183}
184
185pub fn find_ancestor_env_files(start: &Path, expected_package: &str) -> Result<Vec<PathBuf>> {
194 let start_canonical = resolve_start_path(start)?;
195 let module_root = find_cue_module_root(&start_canonical);
196
197 let ancestors =
198 collect_ancestor_env_files(start_canonical, module_root.as_deref(), expected_package)?;
199 Ok(ancestors)
200}
201
202fn collect_ancestor_env_files(
203 start: PathBuf,
204 module_root: Option<&Path>,
205 expected_package: &str,
206) -> Result<Vec<PathBuf>> {
207 let mut ancestors = Vec::new();
208 let mut current = start;
209
210 loop {
211 if let EnvFileStatus::Match(dir) = find_env_file(¤t, expected_package)? {
212 ancestors.push(dir);
213 }
214
215 if module_root.is_some_and(|root| current == root) {
216 break;
217 }
218
219 match current.parent() {
220 Some(parent) => current = parent.to_path_buf(),
221 None => break,
222 }
223 }
224
225 ancestors.reverse();
226 Ok(ancestors)
227}
228
229#[must_use]
243pub fn discover_env_cue_directories(module_root: &Path, expected_package: &str) -> Vec<PathBuf> {
244 let mut directories = Vec::new();
245
246 let walker = WalkBuilder::new(module_root)
247 .follow_links(false)
248 .standard_filters(true)
249 .build();
250
251 for result in walker {
252 let Ok(entry) = result else {
253 continue;
254 };
255
256 let path = entry.path();
257 if !is_env_cue_file(path) {
258 continue;
259 }
260
261 if !matches_package(path, expected_package) {
262 continue;
263 }
264
265 if let Some(dir) = path.parent()
266 && let Ok(canonical) = dir.canonicalize()
267 {
268 directories.push(canonical);
269 }
270 }
271
272 directories
273}
274
275fn matches_package(path: &Path, expected_package: &str) -> bool {
276 let Ok(package_name) = detect_package_name(path) else {
277 return false;
278 };
279
280 package_name.as_deref() == Some(expected_package)
281}
282
283fn is_env_cue_file(path: &Path) -> bool {
284 path.file_name() == Some("env.cue".as_ref())
285}
286
287fn resolve_start_path(start: &Path) -> Result<PathBuf> {
288 normalize_path(start)?
289 .canonicalize()
290 .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {e}")))
291}
292
293fn normalize_path(path: &Path) -> Result<PathBuf> {
294 if path.is_absolute() {
295 Ok(path.to_path_buf())
296 } else {
297 std::env::current_dir()
298 .map_err(|e| Error::configuration(format!("Failed to get current directory: {e}")))
299 .map(|cwd| cwd.join(path))
300 }
301}
302
303fn strip_comments(source: &str) -> String {
304 let mut result = String::with_capacity(source.len());
305 let mut chars = source.chars().peekable();
306
307 while let Some(ch) = chars.next() {
308 if ch == '/' {
309 match chars.peek() {
310 Some('/') => {
311 chars.next();
312 for next in chars.by_ref() {
313 if next == '\n' {
314 result.push('\n');
315 break;
316 }
317 }
318 continue;
319 }
320 Some('*') => {
321 chars.next();
322 let mut prev = '\0';
323 for next in chars.by_ref() {
324 if prev == '*' && next == '/' {
325 break;
326 }
327 prev = next;
328 }
329 continue;
330 }
331 _ => {}
332 }
333 }
334
335 result.push(ch);
336 }
337
338 result
339}
340
341#[cfg(test)]
342mod tests {
343 use super::{
344 EnvFileStatus, adjust_meta_key_path, compute_relative_path, detect_package_name,
345 find_ancestor_env_files, find_cue_module_root, find_env_file, format_eval_errors,
346 strip_comments,
347 };
348 use std::fs;
349 use std::io::Write;
350 use std::path::{Path, PathBuf};
351 use tempfile::{NamedTempFile, TempDir};
352
353 #[test]
354 fn strip_comments_removes_line_and_block_comments() {
355 let source = r#"
356// line comment
357/* block
358comment */
359package cuenv // inline
360 "#;
361 let cleaned = strip_comments(source);
362 assert!(cleaned.contains("package cuenv"));
363 assert!(!cleaned.contains("line comment"));
364 assert!(!cleaned.contains("block"));
365 }
366
367 #[test]
368 fn detect_package_name_finds_package() {
369 let mut file = NamedTempFile::new().unwrap();
370 writeln!(file, "// comment\npackage cuenv // inline\n\nenv: {{}}").unwrap();
371
372 let package = detect_package_name(Path::new(file.path())).unwrap();
373 assert_eq!(package, Some("cuenv".to_string()));
374 }
375
376 #[test]
377 fn detect_package_name_handles_missing() {
378 let mut file = NamedTempFile::new().unwrap();
379 writeln!(file, "// only comments").unwrap();
380 let package = detect_package_name(Path::new(file.path())).unwrap();
381 assert!(package.is_none());
382 }
383
384 #[test]
385 fn find_env_file_detects_package_mismatch() {
386 let temp_dir = TempDir::new().unwrap();
387 fs::write(temp_dir.path().join("env.cue"), "package other\n").unwrap();
388
389 let status = find_env_file(temp_dir.path(), "cuenv").unwrap();
390 match status {
391 EnvFileStatus::PackageMismatch { found_package } => {
392 assert_eq!(found_package.as_deref(), Some("other"));
393 }
394 _ => panic!("Expected package mismatch status"),
395 }
396 }
397
398 #[test]
399 fn find_cue_module_root_finds_cue_mod() {
400 let temp_dir = TempDir::new().unwrap();
401 let root = temp_dir.path();
402
403 fs::create_dir_all(root.join("cue.mod")).unwrap();
404
405 let nested = root.join("apps/site/src");
406 fs::create_dir_all(&nested).unwrap();
407
408 let found = find_cue_module_root(&nested);
409 assert!(found.is_some());
410 assert_eq!(found.unwrap(), root.canonicalize().unwrap());
411 }
412
413 #[test]
414 fn find_cue_module_root_returns_none_when_missing() {
415 let temp_dir = TempDir::new().unwrap();
416 let result = find_cue_module_root(temp_dir.path());
417 assert!(result.is_none());
418 }
419
420 #[test]
421 fn find_ancestor_env_files_collects_all_ancestors() {
422 let temp_dir = TempDir::new().unwrap();
423 let root = temp_dir.path();
424
425 fs::create_dir_all(root.join("cue.mod")).unwrap();
426 fs::write(root.join("env.cue"), "package cuenv\n").unwrap();
427
428 fs::create_dir_all(root.join("apps")).unwrap();
429 fs::write(root.join("apps/env.cue"), "package cuenv\n").unwrap();
430
431 fs::create_dir_all(root.join("apps/site")).unwrap();
432 fs::write(root.join("apps/site/env.cue"), "package cuenv\n").unwrap();
433
434 let ancestors = find_ancestor_env_files(&root.join("apps/site"), "cuenv").unwrap();
435
436 assert_eq!(ancestors.len(), 3);
437 assert_eq!(ancestors[0], root.canonicalize().unwrap());
438 assert_eq!(ancestors[1], root.join("apps").canonicalize().unwrap());
439 assert_eq!(ancestors[2], root.join("apps/site").canonicalize().unwrap());
440 }
441
442 #[test]
443 fn find_ancestor_env_files_stops_at_cue_mod() {
444 let temp_dir = TempDir::new().unwrap();
445 let root = temp_dir.path();
446
447 fs::write(root.join("env.cue"), "package cuenv\n").unwrap();
448
449 fs::create_dir_all(root.join("monorepo/cue.mod")).unwrap();
450 fs::write(root.join("monorepo/env.cue"), "package cuenv\n").unwrap();
451
452 fs::create_dir_all(root.join("monorepo/apps")).unwrap();
453 fs::write(root.join("monorepo/apps/env.cue"), "package cuenv\n").unwrap();
454
455 let ancestors = find_ancestor_env_files(&root.join("monorepo/apps"), "cuenv").unwrap();
456
457 assert_eq!(ancestors.len(), 2);
458 assert_eq!(ancestors[0], root.join("monorepo").canonicalize().unwrap());
459 assert_eq!(
460 ancestors[1],
461 root.join("monorepo/apps").canonicalize().unwrap()
462 );
463 }
464
465 #[test]
466 fn find_ancestor_env_files_skips_wrong_package() {
467 let temp_dir = TempDir::new().unwrap();
468 let root = temp_dir.path();
469
470 fs::create_dir_all(root.join("cue.mod")).unwrap();
471 fs::write(root.join("env.cue"), "package cuenv\n").unwrap();
472
473 fs::create_dir_all(root.join("apps")).unwrap();
474 fs::write(root.join("apps/env.cue"), "package other\n").unwrap();
475
476 fs::create_dir_all(root.join("apps/site")).unwrap();
477 fs::write(root.join("apps/site/env.cue"), "package cuenv\n").unwrap();
478
479 let ancestors = find_ancestor_env_files(&root.join("apps/site"), "cuenv").unwrap();
480
481 assert_eq!(ancestors.len(), 2);
482 assert_eq!(ancestors[0], root.canonicalize().unwrap());
483 assert_eq!(ancestors[1], root.join("apps/site").canonicalize().unwrap());
484 }
485
486 #[test]
487 fn compute_relative_path_basic() {
488 let module_root = Path::new("/repo");
489 let target = Path::new("/repo/services/api");
490 assert_eq!(compute_relative_path(target, module_root), "services/api");
491 }
492
493 #[test]
494 fn compute_relative_path_same_path() {
495 let path = Path::new("/repo");
496 assert_eq!(compute_relative_path(path, path), ".");
497 }
498
499 #[test]
500 fn compute_relative_path_unrelated_paths() {
501 let module_root = Path::new("/repo");
502 let target = Path::new("/other/path");
503 assert_eq!(compute_relative_path(target, module_root), ".");
504 }
505
506 #[test]
507 fn adjust_meta_key_path_with_dot_slash_prefix() {
508 assert_eq!(
509 adjust_meta_key_path("./tasks/build", "services/api"),
510 "services/api/tasks/build"
511 );
512 }
513
514 #[test]
515 fn adjust_meta_key_path_without_prefix() {
516 assert_eq!(
517 adjust_meta_key_path("other/path", "services/api"),
518 "other/path"
519 );
520 }
521
522 #[test]
523 fn adjust_meta_key_path_only_replaces_first_occurrence() {
524 assert_eq!(adjust_meta_key_path("./a/./b", "rel"), "rel/a/./b");
525 }
526
527 #[test]
528 fn format_eval_errors_empty() {
529 let errors: Vec<(PathBuf, String)> = vec![];
530 assert_eq!(format_eval_errors(&errors), "");
531 }
532
533 #[test]
534 fn format_eval_errors_single() {
535 let errors = vec![(PathBuf::from("/repo/a"), "syntax error")];
536 assert_eq!(format_eval_errors(&errors), " /repo/a: syntax error");
537 }
538
539 #[test]
540 fn format_eval_errors_multiple() {
541 let errors = vec![
542 (PathBuf::from("/repo/a"), "syntax error"),
543 (PathBuf::from("/repo/b"), "missing field"),
544 ];
545 let result = format_eval_errors(&errors);
546 assert!(result.contains("/repo/a: syntax error"));
547 assert!(result.contains("/repo/b: missing field"));
548 assert!(result.contains('\n'));
549 }
550}