fob_cli/commands/
utils.rs

1//! Shared utilities for command implementations.
2//!
3//! This module provides common functionality used across multiple commands:
4//!
5//! - Path resolution and validation
6//! - Configuration loading and merging
7//! - Directory cleaning operations
8//! - Entry point validation
9//! - Package manager detection
10
11use crate::error::{BuildError, CliError, Result};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15/// Resolve a path relative to a working directory.
16///
17/// If the path is absolute, returns it unchanged. Otherwise, joins it with
18/// the working directory.
19///
20/// # Arguments
21///
22/// * `path` - Path to resolve
23/// * `cwd` - Working directory to resolve relative to
24///
25/// # Returns
26///
27/// Absolute path
28pub fn resolve_path(path: &Path, cwd: &Path) -> PathBuf {
29    if path.is_absolute() {
30        path.to_path_buf()
31    } else {
32        cwd.join(path)
33    }
34}
35
36/// Validate that an entry point file exists.
37///
38/// # Arguments
39///
40/// * `entry` - Path to entry point file
41///
42/// # Errors
43///
44/// Returns `BuildError::EntryNotFound` if the file doesn't exist.
45pub fn validate_entry(entry: &Path) -> Result<()> {
46    if !entry.exists() {
47        return Err(BuildError::EntryNotFound(entry.to_path_buf()).into());
48    }
49
50    if !entry.is_file() {
51        return Err(CliError::InvalidArgument(format!(
52            "Entry point is not a file: {}",
53            entry.display()
54        )));
55    }
56
57    Ok(())
58}
59
60/// Clean an output directory by removing all its contents.
61///
62/// Creates the directory if it doesn't exist. If it exists, removes all files
63/// and subdirectories within it.
64///
65/// # Arguments
66///
67/// * `out_dir` - Directory to clean
68///
69/// # Errors
70///
71/// Returns I/O errors if directory operations fail.
72///
73/// # Safety
74///
75/// This function performs destructive filesystem operations. It validates that
76/// the path exists and is actually a directory before removing contents to
77/// prevent accidental data loss.
78pub fn clean_output_dir(out_dir: &Path) -> Result<()> {
79    if out_dir.exists() {
80        if !out_dir.is_dir() {
81            return Err(CliError::InvalidArgument(format!(
82                "Output path exists but is not a directory: {}",
83                out_dir.display()
84            )));
85        }
86
87        // Remove all contents but keep the directory itself
88        for entry in fs::read_dir(out_dir)? {
89            let entry = entry?;
90            let path = entry.path();
91
92            if path.is_dir() {
93                fs::remove_dir_all(&path)?;
94            } else {
95                fs::remove_file(&path)?;
96            }
97        }
98    } else {
99        // Create directory if it doesn't exist
100        fs::create_dir_all(out_dir)?;
101    }
102
103    Ok(())
104}
105
106/// Ensure an output directory exists, creating it if necessary.
107///
108/// # Arguments
109///
110/// * `out_dir` - Directory to ensure exists
111///
112/// # Errors
113///
114/// Returns I/O errors if directory creation fails.
115pub fn ensure_output_dir(out_dir: &Path) -> Result<()> {
116    if !out_dir.exists() {
117        fs::create_dir_all(out_dir)?;
118    } else if !out_dir.is_dir() {
119        return Err(CliError::InvalidArgument(format!(
120            "Output path exists but is not a directory: {}",
121            out_dir.display()
122        )));
123    }
124
125    Ok(())
126}
127
128/// Get the current working directory.
129///
130/// # Errors
131///
132/// Returns I/O error if current directory cannot be determined.
133pub fn get_cwd() -> Result<PathBuf> {
134    std::env::current_dir().map_err(|e| {
135        CliError::Io(std::io::Error::new(
136            e.kind(),
137            format!("Failed to get current directory: {}", e),
138        ))
139    })
140}
141
142/// Detect which package manager is being used in a project.
143///
144/// Checks for lock files in order of preference: pnpm > yarn > npm.
145///
146/// # Arguments
147///
148/// * `project_dir` - Directory to check for lock files
149///
150/// # Returns
151///
152/// Package manager name ("pnpm", "yarn", or "npm")
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum PackageManager {
155    Npm,
156    Yarn,
157    Pnpm,
158    Bun,
159}
160
161impl PackageManager {
162    /// Detect package manager from lock files.
163    ///
164    /// Detection order (highest priority first):
165    /// 1. `pnpm-lock.yaml` → pnpm
166    /// 2. `yarn.lock` → yarn
167    /// 3. `bun.lockb` → bun
168    /// 4. Default to npm (also covers package-lock.json)
169    pub fn detect(project_dir: &Path) -> Self {
170        if project_dir.join("pnpm-lock.yaml").exists() {
171            PackageManager::Pnpm
172        } else if project_dir.join("yarn.lock").exists() {
173            PackageManager::Yarn
174        } else if project_dir.join("bun.lockb").exists() {
175            PackageManager::Bun
176        } else {
177            PackageManager::Npm
178        }
179    }
180
181    /// Get the command name for this package manager.
182    pub fn command(&self) -> &'static str {
183        match self {
184            PackageManager::Npm => "npm",
185            PackageManager::Yarn => "yarn",
186            PackageManager::Pnpm => "pnpm",
187            PackageManager::Bun => "bun",
188        }
189    }
190
191    /// Get the install command for this package manager.
192    pub fn install_cmd(&self) -> &'static str {
193        match self {
194            PackageManager::Npm => "npm install",
195            PackageManager::Yarn => "yarn install",
196            PackageManager::Pnpm => "pnpm install",
197            PackageManager::Bun => "bun install",
198        }
199    }
200}
201
202impl std::fmt::Display for PackageManager {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        write!(f, "{}", self.command())
205    }
206}
207
208/// Walks up the directory tree to find the nearest package.json.
209///
210/// Starts from `start_dir` and traverses parent directories until:
211/// - A `package.json` file is found (returns the containing directory)
212/// - The filesystem root is reached (returns None)
213///
214/// # Arguments
215/// * `start_dir` - Directory to begin searching from
216///
217/// # Returns
218/// * `Some(PathBuf)` - Directory containing package.json
219/// * `None` - No package.json found up to filesystem root
220///
221/// # Examples
222/// ```
223/// # use std::path::Path;
224/// # use fob_cli::commands::utils::find_package_json;
225/// let root = find_package_json(Path::new("/project/src/components"));
226/// // Returns Some("/project") if /project/package.json exists
227/// ```
228pub fn find_package_json(start_dir: &Path) -> Option<PathBuf> {
229    let mut current = start_dir;
230
231    loop {
232        let package_json_path = current.join("package.json");
233
234        if package_json_path.exists() && package_json_path.is_file() {
235            return Some(current.to_path_buf());
236        }
237
238        // Try to move to parent directory
239        match current.parent() {
240            Some(parent) => current = parent,
241            None => {
242                // Reached filesystem root without finding package.json
243                return None;
244            }
245        }
246    }
247}
248
249/// Resolves the project root directory using smart auto-detection.
250///
251/// Resolution priority (highest to lowest):
252/// 1. Explicit `--cwd` flag if provided
253/// 2. Directory containing the entry point's package.json
254/// 3. Nearest package.json walking up from process.cwd()
255/// 4. Fallback to process.cwd() with warning
256///
257/// # Arguments
258/// * `explicit_cwd` - Optional directory from `--cwd` flag
259/// * `entry_point` - Optional entry file path for detection
260///
261/// # Returns
262/// * `Ok(PathBuf)` - Resolved absolute project root path
263/// * `Err(CliError)` - If explicit cwd is invalid or paths cannot be resolved
264///
265/// # Errors
266/// - Explicit cwd doesn't exist or isn't a directory
267/// - Cannot determine current working directory
268///
269/// # Examples
270/// ```no_run
271/// # use std::path::Path;
272/// # use fob_cli::commands::utils::resolve_project_root;
273/// // With explicit cwd (highest priority)
274/// let root = resolve_project_root(Some(Path::new("/my/project")), None)?;
275///
276/// // With entry point detection
277/// let root = resolve_project_root(None, Some("./src/index.ts"))?;
278///
279/// // Auto-detection from current directory
280/// let root = resolve_project_root(None, None)?;
281/// # Ok::<(), Box<dyn std::error::Error>>(())
282/// ```
283pub fn resolve_project_root(
284    explicit_cwd: Option<&Path>,
285    entry_point: Option<&str>,
286) -> Result<PathBuf> {
287    use crate::ui;
288
289    // Priority 1: Explicit --cwd flag (user override)
290    if let Some(cwd_path) = explicit_cwd {
291        let absolute = if cwd_path.is_absolute() {
292            cwd_path.to_path_buf()
293        } else {
294            std::env::current_dir()
295                .map_err(CliError::Io)?
296                .join(cwd_path)
297        };
298
299        if !absolute.exists() {
300            return Err(CliError::InvalidArgument(format!(
301                "Specified --cwd directory does not exist: {}",
302                absolute.display()
303            )));
304        }
305
306        if !absolute.is_dir() {
307            return Err(CliError::InvalidArgument(format!(
308                "Specified --cwd is not a directory: {}",
309                absolute.display()
310            )));
311        }
312
313        ui::info(&format!(
314            "Using project root: {} (from --cwd flag)",
315            absolute.display()
316        ));
317        return Ok(absolute);
318    }
319
320    // Priority 2: Entry point's package.json
321    if let Some(entry) = entry_point {
322        let current_dir = std::env::current_dir().map_err(CliError::Io)?;
323
324        let entry_path = if Path::new(entry).is_absolute() {
325            PathBuf::from(entry)
326        } else {
327            current_dir.join(entry)
328        };
329
330        // Get the directory containing the entry file
331        if let Some(entry_dir) = entry_path.parent() {
332            if let Some(package_root) = find_package_json(entry_dir) {
333                ui::info(&format!(
334                    "Using project root: {} (detected from entry point's package.json)",
335                    package_root.display()
336                ));
337                return Ok(package_root);
338            }
339        }
340    }
341
342    // Priority 3: Current directory's package.json
343    let current_dir = std::env::current_dir().map_err(CliError::Io)?;
344
345    if let Some(package_root) = find_package_json(&current_dir) {
346        ui::info(&format!(
347            "Using project root: {} (auto-detected from package.json)",
348            package_root.display()
349        ));
350        return Ok(package_root);
351    }
352
353    // Priority 4: Fallback to current directory with warning
354    ui::warning(&format!(
355        "No package.json found. Using current directory: {}",
356        current_dir.display()
357    ));
358    ui::info("Consider using --cwd to specify your project root explicitly.");
359
360    Ok(current_dir)
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use std::fs::File;
367    use tempfile::TempDir;
368
369    #[test]
370    fn test_resolve_path_absolute() {
371        let abs_path = PathBuf::from("/absolute/path");
372        let cwd = PathBuf::from("/some/dir");
373
374        let resolved = resolve_path(&abs_path, &cwd);
375        assert_eq!(resolved, abs_path);
376    }
377
378    #[test]
379    fn test_resolve_path_relative() {
380        let rel_path = PathBuf::from("relative/path");
381        let cwd = PathBuf::from("/some/dir");
382
383        let resolved = resolve_path(&rel_path, &cwd);
384        assert_eq!(resolved, PathBuf::from("/some/dir/relative/path"));
385    }
386
387    #[test]
388    fn test_validate_entry_exists() {
389        let temp_dir = TempDir::new().unwrap();
390        let entry_path = temp_dir.path().join("index.ts");
391        File::create(&entry_path).unwrap();
392
393        assert!(validate_entry(&entry_path).is_ok());
394    }
395
396    #[test]
397    fn test_validate_entry_not_found() {
398        let temp_dir = TempDir::new().unwrap();
399        let entry_path = temp_dir.path().join("nonexistent.ts");
400
401        let result = validate_entry(&entry_path);
402        assert!(result.is_err());
403        assert!(matches!(
404            result.unwrap_err(),
405            CliError::Build(BuildError::EntryNotFound { .. })
406        ));
407    }
408
409    #[test]
410    fn test_validate_entry_not_file() {
411        let temp_dir = TempDir::new().unwrap();
412
413        let result = validate_entry(temp_dir.path());
414        assert!(result.is_err());
415    }
416
417    #[test]
418    fn test_clean_output_dir_creates_if_missing() {
419        let temp_dir = TempDir::new().unwrap();
420        let out_dir = temp_dir.path().join("dist");
421
422        assert!(!out_dir.exists());
423        clean_output_dir(&out_dir).unwrap();
424        assert!(out_dir.exists());
425        assert!(out_dir.is_dir());
426    }
427
428    #[test]
429    fn test_clean_output_dir_removes_contents() {
430        let temp_dir = TempDir::new().unwrap();
431        let out_dir = temp_dir.path().join("dist");
432        fs::create_dir(&out_dir).unwrap();
433
434        // Create some files and directories
435        File::create(out_dir.join("file1.js")).unwrap();
436        File::create(out_dir.join("file2.js")).unwrap();
437        fs::create_dir(out_dir.join("subdir")).unwrap();
438        File::create(out_dir.join("subdir/file3.js")).unwrap();
439
440        clean_output_dir(&out_dir).unwrap();
441
442        // Directory should exist but be empty
443        assert!(out_dir.exists());
444        assert!(out_dir.is_dir());
445        assert_eq!(fs::read_dir(&out_dir).unwrap().count(), 0);
446    }
447
448    #[test]
449    fn test_clean_output_dir_not_a_directory() {
450        let temp_dir = TempDir::new().unwrap();
451        let file_path = temp_dir.path().join("not_a_dir");
452        File::create(&file_path).unwrap();
453
454        let result = clean_output_dir(&file_path);
455        assert!(result.is_err());
456    }
457
458    #[test]
459    fn test_ensure_output_dir_creates() {
460        let temp_dir = TempDir::new().unwrap();
461        let out_dir = temp_dir.path().join("new_dir");
462
463        ensure_output_dir(&out_dir).unwrap();
464        assert!(out_dir.exists());
465        assert!(out_dir.is_dir());
466    }
467
468    #[test]
469    fn test_ensure_output_dir_exists() {
470        let temp_dir = TempDir::new().unwrap();
471        let out_dir = temp_dir.path().join("existing");
472        fs::create_dir(&out_dir).unwrap();
473
474        // Should succeed without error
475        ensure_output_dir(&out_dir).unwrap();
476    }
477
478    #[test]
479    fn test_get_cwd() {
480        let cwd = get_cwd().unwrap();
481        assert!(cwd.is_absolute());
482    }
483
484    #[test]
485    fn test_package_manager_detect_pnpm() {
486        let temp_dir = TempDir::new().unwrap();
487        File::create(temp_dir.path().join("pnpm-lock.yaml")).unwrap();
488
489        assert_eq!(
490            PackageManager::detect(temp_dir.path()),
491            PackageManager::Pnpm
492        );
493    }
494
495    #[test]
496    fn test_package_manager_detect_yarn() {
497        let temp_dir = TempDir::new().unwrap();
498        File::create(temp_dir.path().join("yarn.lock")).unwrap();
499
500        assert_eq!(
501            PackageManager::detect(temp_dir.path()),
502            PackageManager::Yarn
503        );
504    }
505
506    #[test]
507    fn test_package_manager_detect_npm() {
508        let temp_dir = TempDir::new().unwrap();
509        // No lock file defaults to npm
510
511        assert_eq!(PackageManager::detect(temp_dir.path()), PackageManager::Npm);
512    }
513
514    #[test]
515    fn test_package_manager_pnpm_prefers_over_yarn() {
516        let temp_dir = TempDir::new().unwrap();
517        File::create(temp_dir.path().join("pnpm-lock.yaml")).unwrap();
518        File::create(temp_dir.path().join("yarn.lock")).unwrap();
519
520        // pnpm should win
521        assert_eq!(
522            PackageManager::detect(temp_dir.path()),
523            PackageManager::Pnpm
524        );
525    }
526
527    #[test]
528    fn test_package_manager_commands() {
529        assert_eq!(PackageManager::Npm.command(), "npm");
530        assert_eq!(PackageManager::Yarn.command(), "yarn");
531        assert_eq!(PackageManager::Pnpm.command(), "pnpm");
532    }
533
534    #[test]
535    fn test_package_manager_install_cmd() {
536        assert_eq!(PackageManager::Npm.install_cmd(), "npm install");
537        assert_eq!(PackageManager::Yarn.install_cmd(), "yarn install");
538        assert_eq!(PackageManager::Pnpm.install_cmd(), "pnpm install");
539    }
540
541    #[test]
542    fn test_find_package_json_in_current_dir() {
543        let temp = TempDir::new().unwrap();
544        let package_json = temp.path().join("package.json");
545        File::create(&package_json).unwrap();
546
547        let result = find_package_json(temp.path());
548        assert_eq!(result, Some(temp.path().to_path_buf()));
549    }
550
551    #[test]
552    fn test_find_package_json_walks_up() {
553        let temp = TempDir::new().unwrap();
554        let package_json = temp.path().join("package.json");
555        File::create(&package_json).unwrap();
556
557        let nested = temp.path().join("src").join("components");
558        fs::create_dir_all(&nested).unwrap();
559
560        let result = find_package_json(&nested);
561        assert_eq!(result, Some(temp.path().to_path_buf()));
562    }
563
564    #[test]
565    fn test_find_package_json_stops_at_first() {
566        let temp = TempDir::new().unwrap();
567
568        // Create nested package.json files
569        let root_package = temp.path().join("package.json");
570        File::create(&root_package).unwrap();
571
572        let nested = temp.path().join("packages").join("app");
573        fs::create_dir_all(&nested).unwrap();
574        let nested_package = nested.join("package.json");
575        File::create(&nested_package).unwrap();
576
577        // Should find the nearest one
578        let result = find_package_json(&nested);
579        assert_eq!(result, Some(nested.clone()));
580    }
581
582    #[test]
583    fn test_resolve_project_root_explicit_cwd() {
584        let temp = TempDir::new().unwrap();
585
586        let result = resolve_project_root(Some(temp.path()), None).unwrap();
587        assert_eq!(result, temp.path());
588    }
589
590    #[test]
591    fn test_resolve_project_root_explicit_cwd_invalid() {
592        let invalid_path = Path::new("/this/path/definitely/does/not/exist/12345");
593
594        let result = resolve_project_root(Some(invalid_path), None);
595        assert!(result.is_err());
596        assert!(result.unwrap_err().to_string().contains("does not exist"));
597    }
598
599    #[test]
600    fn test_resolve_project_root_from_entry() {
601        let temp = TempDir::new().unwrap();
602        let package_json = temp.path().join("package.json");
603        File::create(&package_json).unwrap();
604
605        let src = temp.path().join("src");
606        fs::create_dir_all(&src).unwrap();
607        let entry = src.join("index.ts");
608        File::create(&entry).unwrap();
609
610        // Change to temp directory for relative path resolution
611        let original = std::env::current_dir().unwrap();
612        std::env::set_current_dir(temp.path()).unwrap();
613
614        let result = resolve_project_root(None, Some("src/index.ts")).unwrap();
615        // Canonicalize both paths for comparison (handles macOS /var -> /private/var symlink)
616        let expected = temp.path().canonicalize().unwrap();
617        assert_eq!(result.canonicalize().unwrap(), expected);
618
619        std::env::set_current_dir(original).unwrap();
620    }
621
622    #[test]
623    fn test_resolve_project_root_fallback() {
624        // When no package.json exists, should return current dir with warning
625        let result = resolve_project_root(None, None);
626        assert!(result.is_ok());
627    }
628
629    #[test]
630    fn test_detect_package_manager_pnpm() {
631        let temp = TempDir::new().unwrap();
632        File::create(temp.path().join("pnpm-lock.yaml")).unwrap();
633
634        assert_eq!(PackageManager::detect(temp.path()), PackageManager::Pnpm);
635    }
636
637    #[test]
638    fn test_detect_package_manager_yarn() {
639        let temp = TempDir::new().unwrap();
640        File::create(temp.path().join("yarn.lock")).unwrap();
641
642        assert_eq!(PackageManager::detect(temp.path()), PackageManager::Yarn);
643    }
644
645    #[test]
646    fn test_detect_package_manager_bun() {
647        let temp = TempDir::new().unwrap();
648        File::create(temp.path().join("bun.lockb")).unwrap();
649
650        assert_eq!(PackageManager::detect(temp.path()), PackageManager::Bun);
651    }
652
653    #[test]
654    fn test_detect_package_manager_npm() {
655        let temp = TempDir::new().unwrap();
656        File::create(temp.path().join("package-lock.json")).unwrap();
657
658        assert_eq!(PackageManager::detect(temp.path()), PackageManager::Npm);
659    }
660
661    #[test]
662    fn test_detect_package_manager_default_npm() {
663        let temp = TempDir::new().unwrap();
664        // No lockfile - should default to npm
665
666        assert_eq!(PackageManager::detect(temp.path()), PackageManager::Npm);
667    }
668
669    #[test]
670    fn test_detect_package_manager_priority() {
671        let temp = TempDir::new().unwrap();
672        // Create multiple lockfiles - pnpm should win
673        File::create(temp.path().join("pnpm-lock.yaml")).unwrap();
674        File::create(temp.path().join("yarn.lock")).unwrap();
675        File::create(temp.path().join("package-lock.json")).unwrap();
676
677        assert_eq!(PackageManager::detect(temp.path()), PackageManager::Pnpm);
678    }
679}