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
275#[must_use]
281pub fn discover_all_env_cue_directories(module_root: &Path) -> Vec<PathBuf> {
282 let mut directories = Vec::new();
283 let mut entries_visited: u64 = 0;
284
285 let walker = WalkBuilder::new(module_root)
286 .follow_links(false)
287 .standard_filters(true)
288 .build();
289
290 for result in walker {
291 entries_visited += 1;
292 let Ok(entry) = result else { continue };
293 let path = entry.path();
294 if !is_env_cue_file(path) {
295 continue;
296 }
297 if let Some(dir) = path.parent()
298 && let Ok(canonical) = dir.canonicalize()
299 {
300 tracing::debug!(dir = %canonical.display(), "discovered env.cue directory");
301 directories.push(canonical);
302 }
303 }
304
305 tracing::info!(
306 module_root = %module_root.display(),
307 entries_visited,
308 discovered = directories.len(),
309 "discover_all_env_cue_directories complete"
310 );
311
312 directories
313}
314
315fn matches_package(path: &Path, expected_package: &str) -> bool {
316 let Ok(package_name) = detect_package_name(path) else {
317 return false;
318 };
319
320 package_name.as_deref() == Some(expected_package)
321}
322
323fn is_env_cue_file(path: &Path) -> bool {
324 path.file_name() == Some("env.cue".as_ref())
325}
326
327fn resolve_start_path(start: &Path) -> Result<PathBuf> {
328 normalize_path(start)?
329 .canonicalize()
330 .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {e}")))
331}
332
333fn normalize_path(path: &Path) -> Result<PathBuf> {
334 if path.is_absolute() {
335 Ok(path.to_path_buf())
336 } else {
337 std::env::current_dir()
338 .map_err(|e| Error::configuration(format!("Failed to get current directory: {e}")))
339 .map(|cwd| cwd.join(path))
340 }
341}
342
343fn strip_comments(source: &str) -> String {
344 let mut result = String::with_capacity(source.len());
345 let mut chars = source.chars().peekable();
346
347 while let Some(ch) = chars.next() {
348 if ch == '/' {
349 match chars.peek() {
350 Some('/') => {
351 chars.next();
352 for next in chars.by_ref() {
353 if next == '\n' {
354 result.push('\n');
355 break;
356 }
357 }
358 continue;
359 }
360 Some('*') => {
361 chars.next();
362 let mut prev = '\0';
363 for next in chars.by_ref() {
364 if prev == '*' && next == '/' {
365 break;
366 }
367 prev = next;
368 }
369 continue;
370 }
371 _ => {}
372 }
373 }
374
375 result.push(ch);
376 }
377
378 result
379}
380
381#[cfg(test)]
382mod tests {
383 use super::{
384 EnvFileStatus, adjust_meta_key_path, compute_relative_path, detect_package_name,
385 find_ancestor_env_files, find_cue_module_root, find_env_file, format_eval_errors,
386 strip_comments,
387 };
388 use std::fs;
389 use std::io::Write;
390 use std::path::{Path, PathBuf};
391 use tempfile::{NamedTempFile, TempDir};
392
393 #[test]
394 fn strip_comments_removes_line_and_block_comments() {
395 let source = r#"
396// line comment
397/* block
398comment */
399package cuenv // inline
400 "#;
401 let cleaned = strip_comments(source);
402 assert!(cleaned.contains("package cuenv"));
403 assert!(!cleaned.contains("line comment"));
404 assert!(!cleaned.contains("block"));
405 }
406
407 #[test]
408 fn detect_package_name_finds_package() {
409 let mut file = NamedTempFile::new().unwrap();
410 writeln!(file, "// comment\npackage cuenv // inline\n\nenv: {{}}").unwrap();
411
412 let package = detect_package_name(Path::new(file.path())).unwrap();
413 assert_eq!(package, Some("cuenv".to_string()));
414 }
415
416 #[test]
417 fn detect_package_name_handles_missing() {
418 let mut file = NamedTempFile::new().unwrap();
419 writeln!(file, "// only comments").unwrap();
420 let package = detect_package_name(Path::new(file.path())).unwrap();
421 assert!(package.is_none());
422 }
423
424 #[test]
425 fn find_env_file_detects_package_mismatch() {
426 let temp_dir = TempDir::new().unwrap();
427 fs::write(temp_dir.path().join("env.cue"), "package other\n").unwrap();
428
429 let status = find_env_file(temp_dir.path(), "cuenv").unwrap();
430 match status {
431 EnvFileStatus::PackageMismatch { found_package } => {
432 assert_eq!(found_package.as_deref(), Some("other"));
433 }
434 _ => panic!("Expected package mismatch status"),
435 }
436 }
437
438 #[test]
439 fn find_cue_module_root_finds_cue_mod() {
440 let temp_dir = TempDir::new().unwrap();
441 let root = temp_dir.path();
442
443 fs::create_dir_all(root.join("cue.mod")).unwrap();
444
445 let nested = root.join("apps/site/src");
446 fs::create_dir_all(&nested).unwrap();
447
448 let found = find_cue_module_root(&nested);
449 assert!(found.is_some());
450 assert_eq!(found.unwrap(), root.canonicalize().unwrap());
451 }
452
453 #[test]
454 fn find_cue_module_root_returns_none_when_missing() {
455 let temp_dir = TempDir::new().unwrap();
456 let result = find_cue_module_root(temp_dir.path());
457 assert!(result.is_none());
458 }
459
460 #[test]
461 fn find_ancestor_env_files_collects_all_ancestors() {
462 let temp_dir = TempDir::new().unwrap();
463 let root = temp_dir.path();
464
465 fs::create_dir_all(root.join("cue.mod")).unwrap();
466 fs::write(root.join("env.cue"), "package cuenv\n").unwrap();
467
468 fs::create_dir_all(root.join("apps")).unwrap();
469 fs::write(root.join("apps/env.cue"), "package cuenv\n").unwrap();
470
471 fs::create_dir_all(root.join("apps/site")).unwrap();
472 fs::write(root.join("apps/site/env.cue"), "package cuenv\n").unwrap();
473
474 let ancestors = find_ancestor_env_files(&root.join("apps/site"), "cuenv").unwrap();
475
476 assert_eq!(ancestors.len(), 3);
477 assert_eq!(ancestors[0], root.canonicalize().unwrap());
478 assert_eq!(ancestors[1], root.join("apps").canonicalize().unwrap());
479 assert_eq!(ancestors[2], root.join("apps/site").canonicalize().unwrap());
480 }
481
482 #[test]
483 fn find_ancestor_env_files_stops_at_cue_mod() {
484 let temp_dir = TempDir::new().unwrap();
485 let root = temp_dir.path();
486
487 fs::write(root.join("env.cue"), "package cuenv\n").unwrap();
488
489 fs::create_dir_all(root.join("monorepo/cue.mod")).unwrap();
490 fs::write(root.join("monorepo/env.cue"), "package cuenv\n").unwrap();
491
492 fs::create_dir_all(root.join("monorepo/apps")).unwrap();
493 fs::write(root.join("monorepo/apps/env.cue"), "package cuenv\n").unwrap();
494
495 let ancestors = find_ancestor_env_files(&root.join("monorepo/apps"), "cuenv").unwrap();
496
497 assert_eq!(ancestors.len(), 2);
498 assert_eq!(ancestors[0], root.join("monorepo").canonicalize().unwrap());
499 assert_eq!(
500 ancestors[1],
501 root.join("monorepo/apps").canonicalize().unwrap()
502 );
503 }
504
505 #[test]
506 fn find_ancestor_env_files_skips_wrong_package() {
507 let temp_dir = TempDir::new().unwrap();
508 let root = temp_dir.path();
509
510 fs::create_dir_all(root.join("cue.mod")).unwrap();
511 fs::write(root.join("env.cue"), "package cuenv\n").unwrap();
512
513 fs::create_dir_all(root.join("apps")).unwrap();
514 fs::write(root.join("apps/env.cue"), "package other\n").unwrap();
515
516 fs::create_dir_all(root.join("apps/site")).unwrap();
517 fs::write(root.join("apps/site/env.cue"), "package cuenv\n").unwrap();
518
519 let ancestors = find_ancestor_env_files(&root.join("apps/site"), "cuenv").unwrap();
520
521 assert_eq!(ancestors.len(), 2);
522 assert_eq!(ancestors[0], root.canonicalize().unwrap());
523 assert_eq!(ancestors[1], root.join("apps/site").canonicalize().unwrap());
524 }
525
526 #[test]
527 fn compute_relative_path_basic() {
528 let module_root = Path::new("/repo");
529 let target = Path::new("/repo/services/api");
530 assert_eq!(compute_relative_path(target, module_root), "services/api");
531 }
532
533 #[test]
534 fn compute_relative_path_same_path() {
535 let path = Path::new("/repo");
536 assert_eq!(compute_relative_path(path, path), ".");
537 }
538
539 #[test]
540 fn compute_relative_path_unrelated_paths() {
541 let module_root = Path::new("/repo");
542 let target = Path::new("/other/path");
543 assert_eq!(compute_relative_path(target, module_root), ".");
544 }
545
546 #[test]
547 fn adjust_meta_key_path_with_dot_slash_prefix() {
548 assert_eq!(
549 adjust_meta_key_path("./tasks/build", "services/api"),
550 "services/api/tasks/build"
551 );
552 }
553
554 #[test]
555 fn adjust_meta_key_path_without_prefix() {
556 assert_eq!(
557 adjust_meta_key_path("other/path", "services/api"),
558 "other/path"
559 );
560 }
561
562 #[test]
563 fn adjust_meta_key_path_only_replaces_first_occurrence() {
564 assert_eq!(adjust_meta_key_path("./a/./b", "rel"), "rel/a/./b");
565 }
566
567 #[test]
568 fn format_eval_errors_empty() {
569 let errors: Vec<(PathBuf, String)> = vec![];
570 assert_eq!(format_eval_errors(&errors), "");
571 }
572
573 #[test]
574 fn format_eval_errors_single() {
575 let errors = vec![(PathBuf::from("/repo/a"), "syntax error")];
576 assert_eq!(format_eval_errors(&errors), " /repo/a: syntax error");
577 }
578
579 #[test]
580 fn format_eval_errors_multiple() {
581 let errors = vec![
582 (PathBuf::from("/repo/a"), "syntax error"),
583 (PathBuf::from("/repo/b"), "missing field"),
584 ];
585 let result = format_eval_errors(&errors);
586 assert!(result.contains("/repo/a: syntax error"));
587 assert!(result.contains("/repo/b: missing field"));
588 assert!(result.contains('\n'));
589 }
590}