Skip to main content

cuenv_core/cue/
discovery.rs

1//! env.cue file detection and package validation.
2//!
3//! Provides utilities for finding and validating env.cue files
4//! within a CUE module hierarchy.
5
6use crate::{Error, Result};
7use ignore::WalkBuilder;
8use std::fmt::Display;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// Compute relative path from module root to target directory.
13///
14/// Returns `"."` if the paths are equal or if stripping fails.
15///
16/// # Examples
17///
18/// ```
19/// use std::path::Path;
20/// use cuenv_core::cue::discovery::compute_relative_path;
21///
22/// let module_root = Path::new("/repo");
23/// let target = Path::new("/repo/services/api");
24/// assert_eq!(compute_relative_path(target, module_root), "services/api");
25///
26/// // Same path returns "."
27/// assert_eq!(compute_relative_path(module_root, module_root), ".");
28/// ```
29#[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/// Adjust a meta key path by replacing `"./"` prefix with the relative path.
44///
45/// Meta keys are formatted as `"instance_path/field_path"`. When an instance
46/// path starts with `"./"`, this function replaces it with the actual relative
47/// path from the module root.
48///
49/// # Examples
50///
51/// ```
52/// use cuenv_core::cue::discovery::adjust_meta_key_path;
53///
54/// assert_eq!(adjust_meta_key_path("./tasks/build", "services/api"), "services/api/tasks/build");
55/// assert_eq!(adjust_meta_key_path("other/path", "services/api"), "other/path");
56/// ```
57#[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/// Format a list of evaluation errors into a human-readable summary.
67///
68/// Each error is formatted as `"  path: error_message"` on its own line.
69///
70/// # Examples
71///
72/// ```
73/// use std::path::PathBuf;
74/// use cuenv_core::cue::discovery::format_eval_errors;
75///
76/// let errors = vec![
77///     (PathBuf::from("/repo/a"), "syntax error"),
78///     (PathBuf::from("/repo/b"), "missing field"),
79/// ];
80/// let summary = format_eval_errors(&errors);
81/// assert!(summary.contains("/repo/a: syntax error"));
82/// assert!(summary.contains("/repo/b: missing field"));
83/// ```
84#[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/// Status of env.cue file detection.
94#[derive(Debug)]
95#[allow(missing_docs)]
96pub enum EnvFileStatus {
97    /// No env.cue present in the directory
98    Missing,
99    /// env.cue exists but does not match the expected package
100    PackageMismatch { found_package: Option<String> },
101    /// env.cue exists and matches the expected package. Contains canonical directory path.
102    Match(PathBuf),
103}
104
105/// Locate env.cue in `path` and ensure it declares the expected package.
106///
107/// # Errors
108///
109/// Returns an error if the env.cue file cannot be read or path canonicalization fails.
110pub 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
132/// Detect the CUE package name from a file.
133///
134/// Returns `Ok(None)` if no package declaration is found.
135pub 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/// Find the CUE module root by walking up from `start`.
163///
164/// Looks for a `cue.mod/` directory. Returns `None` if no cue.mod is found
165/// (will walk to filesystem root).
166#[must_use]
167pub fn find_cue_module_root(start: &Path) -> Option<PathBuf> {
168    let mut current = normalize_path(start).ok()?;
169
170    // Canonicalize to resolve symlinks
171    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
185/// Walk up from `start` collecting directories containing env.cue files.
186///
187/// Stops at the CUE module root (directory containing `cue.mod/`) or filesystem root.
188/// Returns directories in order from root to leaf (ancestor first).
189///
190/// # Errors
191///
192/// Returns an error if the current directory cannot be obtained or paths cannot be resolved.
193pub 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(&current, 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/// Discover all directories containing env.cue files with matching package.
230///
231/// Uses the `ignore` crate to walk the filesystem while respecting `.gitignore`.
232/// Returns directories (not file paths) that contain env.cue files with the
233/// expected package declaration.
234///
235/// # Arguments
236/// * `module_root` - The CUE module root directory (must contain cue.mod/)
237/// * `expected_package` - The CUE package name to filter for
238///
239/// # Returns
240/// A vector of directory paths (relative to module_root) containing matching env.cue files.
241/// The paths are suitable for use with `cuengine::evaluate_module` with `TargetDir` option.
242#[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}