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
275/// Discover all directories containing an env.cue file, regardless of package.
276///
277/// This scans the workspace for any `env.cue` files and returns their
278/// canonical parent directories. Use when a command intends to operate over the
279/// entire workspace scope (e.g., `sync -A`), independent of CUE package names.
280#[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}