Skip to main content

cuenv_workspaces/
detection.rs

1//! Package manager detection module.
2//!
3//! This module provides functionality to detect which package managers are in use
4//! within a workspace by scanning for lockfiles, workspace configurations, and
5//! analyzing command strings.
6//!
7//! # Examples
8//!
9//! Detect package managers in a directory:
10//!
11//! ```no_run
12//! use cuenv_workspaces::detection::detect_package_managers;
13//! use std::path::Path;
14//!
15//! let root = Path::new("/path/to/workspace");
16//! let managers = detect_package_managers(root)?;
17//!
18//! for manager in managers {
19//!     println!("Detected: {}", manager);
20//! }
21//! # Ok::<(), cuenv_workspaces::Error>(())
22//! ```
23//!
24//! Detect from a command string:
25//!
26//! ```
27//! use cuenv_workspaces::detection::detect_from_command;
28//! use cuenv_workspaces::PackageManager;
29//!
30//! assert_eq!(detect_from_command("cargo build"), Some(PackageManager::Cargo));
31//! assert_eq!(detect_from_command("npm install"), Some(PackageManager::Npm));
32//! assert_eq!(detect_from_command("bun run test"), Some(PackageManager::Bun));
33//! ```
34
35use crate::core::types::PackageManager;
36use crate::error::{Error, Result};
37use std::collections::HashSet;
38use std::fs;
39use std::path::{Path, PathBuf};
40
41/// Detects all package managers present in the given directory.
42///
43/// This function scans for lockfiles and workspace configuration files,
44/// then returns a list of detected package managers ordered by confidence
45/// (highest first).
46///
47/// # Confidence scoring
48///
49/// - Lockfile + valid workspace config: 100
50/// - Lockfile only: 75
51/// - Valid workspace config only: 50
52///
53/// # Examples
54///
55/// ```no_run
56/// use cuenv_workspaces::detection::detect_package_managers;
57/// use std::path::Path;
58///
59/// let managers = detect_package_managers(Path::new("/workspace"))?;
60/// if !managers.is_empty() {
61///     println!("Primary package manager: {}", managers[0]);
62/// }
63/// # Ok::<(), cuenv_workspaces::Error>(())
64/// ```
65///
66/// # Errors
67///
68/// Returns an error if:
69/// - Directory cannot be accessed
70/// - Workspace config exists but is invalid
71pub fn detect_package_managers(root: &Path) -> Result<Vec<PackageManager>> {
72    tracing::debug!("Detecting package managers in: {}", root.display());
73
74    let mut detections = Vec::new();
75    let mut detected_managers = HashSet::new();
76
77    detect_from_lockfiles(root, &mut detections, &mut detected_managers)?;
78    detect_from_package_json(root, &mut detections, &mut detected_managers)?;
79    detect_from_workspace_configs(root, &mut detections, &mut detected_managers)?;
80
81    Ok(prioritize_managers(detections))
82}
83
84/// Detect package managers from lockfiles.
85fn detect_from_lockfiles(
86    root: &Path,
87    detections: &mut Vec<(PackageManager, u8)>,
88    detected_managers: &mut HashSet<PackageManager>,
89) -> Result<()> {
90    let lockfiles = find_lockfiles(root);
91    tracing::debug!("Found {} lockfile(s)", lockfiles.len());
92
93    for (manager, lockfile_path) in lockfiles {
94        let detected = process_lockfile(root, manager, &lockfile_path)?;
95        detections.push(detected);
96        detected_managers.insert(detected.0);
97    }
98    Ok(())
99}
100
101/// Process a single lockfile and return the detected manager with confidence.
102#[allow(clippy::cognitive_complexity)]
103fn process_lockfile(
104    root: &Path,
105    manager: PackageManager,
106    lockfile_path: &Path,
107) -> Result<(PackageManager, u8)> {
108    tracing::debug!(
109        "Processing lockfile: {} ({})",
110        lockfile_path.display(),
111        manager
112    );
113
114    let detected_manager = if matches!(manager, PackageManager::YarnClassic) {
115        detect_yarn_version(lockfile_path)?
116    } else {
117        manager
118    };
119
120    let has_valid_config = validate_workspace_config(root, detected_manager)?;
121    let confidence = calculate_confidence(true, has_valid_config);
122    tracing::debug!("Manager {} has confidence {}", detected_manager, confidence);
123
124    Ok((detected_manager, confidence))
125}
126
127/// Detect package manager from package.json if not already detected.
128fn detect_from_package_json(
129    root: &Path,
130    detections: &mut Vec<(PackageManager, u8)>,
131    detected_managers: &mut HashSet<PackageManager>,
132) -> Result<()> {
133    if let Some(manager) = detect_manager_from_package_json(root, detected_managers)? {
134        let confidence = calculate_confidence(false, true);
135        tracing::debug!(
136            "Manager {} detected via package.json (confidence {})",
137            manager,
138            confidence
139        );
140        detections.push((manager, confidence));
141        detected_managers.insert(manager);
142    }
143    Ok(())
144}
145
146/// Detect package managers from workspace configs without lockfiles.
147fn detect_from_workspace_configs(
148    root: &Path,
149    detections: &mut Vec<(PackageManager, u8)>,
150    detected_managers: &mut HashSet<PackageManager>,
151) -> Result<()> {
152    for manager in [PackageManager::Pnpm, PackageManager::Cargo] {
153        if is_manager_detected(detected_managers, manager) {
154            continue;
155        }
156
157        if validate_workspace_config(root, manager)? {
158            let confidence = calculate_confidence(false, true);
159            tracing::debug!(
160                "Manager {} detected via config only (confidence {})",
161                manager,
162                confidence
163            );
164            detections.push((manager, confidence));
165            detected_managers.insert(manager);
166        }
167    }
168    Ok(())
169}
170
171/// Infers the package manager from a command string.
172///
173/// Maps common command names to their corresponding package managers.
174///
175/// # Examples
176///
177/// ```
178/// use cuenv_workspaces::detection::detect_from_command;
179/// use cuenv_workspaces::PackageManager;
180///
181/// assert_eq!(detect_from_command("cargo"), Some(PackageManager::Cargo));
182/// assert_eq!(detect_from_command("npm"), Some(PackageManager::Npm));
183/// assert_eq!(detect_from_command("bun"), Some(PackageManager::Bun));
184/// assert_eq!(detect_from_command("pnpm"), Some(PackageManager::Pnpm));
185/// assert_eq!(detect_from_command("node"), Some(PackageManager::Npm));
186/// assert_eq!(detect_from_command("unknown"), None);
187/// ```
188pub fn detect_from_command(command: &str) -> Option<PackageManager> {
189    // Extract the first word (command name)
190    let cmd = command.split_whitespace().next().unwrap_or(command);
191
192    match cmd {
193        "cargo" => Some(PackageManager::Cargo),
194        "npm" | "npx" | "node" => Some(PackageManager::Npm),
195        "bun" | "bunx" => Some(PackageManager::Bun),
196        "pnpm" => Some(PackageManager::Pnpm),
197        "deno" => Some(PackageManager::Deno),
198        "yarn" => {
199            tracing::warn!(
200                "'yarn' command detected; defaulting to YarnClassic. For accurate version detection, use lockfile analysis via detect_yarn_version()."
201            );
202            Some(PackageManager::YarnClassic)
203        }
204        _ => None,
205    }
206}
207
208/// Combines filesystem and command-based detection with command hint prioritization.
209///
210/// If a command hint is provided, the corresponding package manager will be
211/// prioritized in the returned list if it was detected via filesystem scanning.
212///
213/// # Examples
214///
215/// ```no_run
216/// use cuenv_workspaces::detection::detect_with_command_hint;
217/// use std::path::Path;
218///
219/// // Detect with hint that we're using Bun
220/// let managers = detect_with_command_hint(
221///     Path::new("/workspace"),
222///     Some("bun run build")
223/// )?;
224///
225/// // If both Cargo and Bun were detected, Bun will be first
226/// # Ok::<(), cuenv_workspaces::Error>(())
227/// ```
228///
229/// # Errors
230///
231/// Returns an error if filesystem detection fails (e.g., unreadable directory).
232pub fn detect_with_command_hint(root: &Path, command: Option<&str>) -> Result<Vec<PackageManager>> {
233    let mut managers = detect_package_managers(root)?;
234
235    // If we have a command hint, try to prioritize that manager
236    if let Some(cmd) = command
237        && let Some(hinted_manager) = detect_from_command(cmd)
238    {
239        // Find the hinted manager in the list
240        if let Some(pos) = managers.iter().position(|m| *m == hinted_manager) {
241            // Move it to the front if it's not already there
242            if pos > 0 {
243                let manager = managers.remove(pos);
244                managers.insert(0, manager);
245                tracing::debug!("Prioritized {} based on command hint", manager);
246            }
247        }
248    }
249
250    Ok(managers)
251}
252
253/// Scans the root directory for package manager lockfiles.
254///
255/// Returns a list of tuples containing the detected package manager and the
256/// path to its lockfile.
257fn find_lockfiles(root: &Path) -> Vec<(PackageManager, PathBuf)> {
258    let mut lockfiles = Vec::new();
259
260    let candidates = [
261        PackageManager::Npm,
262        PackageManager::Bun,
263        PackageManager::Pnpm,
264        PackageManager::YarnClassic,
265        PackageManager::Cargo,
266        PackageManager::Deno,
267    ];
268
269    for manager in candidates {
270        let lockfile_path = root.join(manager.lockfile_name());
271        if lockfile_path.exists() {
272            lockfiles.push((manager, lockfile_path));
273        }
274    }
275
276    lockfiles
277}
278
279fn detect_manager_from_package_json(
280    root: &Path,
281    detected_managers: &HashSet<PackageManager>,
282) -> Result<Option<PackageManager>> {
283    let Some(package_json) = read_package_json(root)? else {
284        return Ok(None);
285    };
286
287    let hinted_manager = package_json
288        .get("packageManager")
289        .and_then(serde_json::Value::as_str)
290        .and_then(parse_package_manager_hint);
291
292    let manager = if let Some(manager) = hinted_manager {
293        manager
294    } else {
295        if has_js_manager(detected_managers) {
296            return Ok(None);
297        }
298        PackageManager::Npm
299    };
300
301    if is_manager_detected(detected_managers, manager) {
302        return Ok(None);
303    }
304
305    Ok(Some(manager))
306}
307
308fn read_package_json(root: &Path) -> Result<Option<serde_json::Value>> {
309    let path = root.join("package.json");
310    if !path.exists() {
311        return Ok(None);
312    }
313
314    let content = fs::read_to_string(&path).map_err(|e| Error::Io {
315        source: e,
316        path: Some(path.clone()),
317        operation: "reading workspace config".to_string(),
318    })?;
319
320    let parsed = serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
321        Error::InvalidWorkspaceConfig {
322            path: path.clone(),
323            message: format!("Invalid JSON: {e}"),
324        }
325    })?;
326
327    Ok(Some(parsed))
328}
329
330fn parse_package_manager_hint(hint: &str) -> Option<PackageManager> {
331    let trimmed = hint.trim();
332    if trimmed.is_empty() {
333        return None;
334    }
335
336    let (manager_name, version_part) = match trimmed.split_once('@') {
337        Some((name, version)) if !name.is_empty() => (name, version),
338        _ => (trimmed, ""),
339    };
340
341    let normalized_name = manager_name.trim().to_ascii_lowercase();
342
343    match normalized_name.as_str() {
344        "npm" => Some(PackageManager::Npm),
345        "bun" => Some(PackageManager::Bun),
346        "yarn" => {
347            let major = parse_major_version(version_part);
348            match major {
349                Some(value) if value < 2 => Some(PackageManager::YarnClassic),
350                _ => Some(PackageManager::YarnModern),
351            }
352        }
353        _ => None,
354    }
355}
356
357fn parse_major_version(input: &str) -> Option<u64> {
358    let trimmed = input.trim().trim_start_matches(['v', 'V']);
359    let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
360
361    if digits.is_empty() {
362        return None;
363    }
364
365    digits.parse::<u64>().ok()
366}
367
368fn is_manager_detected(
369    detected_managers: &HashSet<PackageManager>,
370    manager: PackageManager,
371) -> bool {
372    match manager {
373        PackageManager::YarnClassic | PackageManager::YarnModern => {
374            detected_managers.contains(&PackageManager::YarnClassic)
375                || detected_managers.contains(&PackageManager::YarnModern)
376        }
377        _ => detected_managers.contains(&manager),
378    }
379}
380
381fn has_js_manager(detected_managers: &HashSet<PackageManager>) -> bool {
382    detected_managers.contains(&PackageManager::Npm)
383        || detected_managers.contains(&PackageManager::Bun)
384        || detected_managers.contains(&PackageManager::Pnpm)
385        || detected_managers.contains(&PackageManager::YarnClassic)
386        || detected_managers.contains(&PackageManager::YarnModern)
387        || detected_managers.contains(&PackageManager::Deno)
388}
389
390/// Validates that a workspace configuration file exists and is parseable.
391///
392/// Returns `Ok(true)` if the config is valid, `Ok(false)` if it doesn't exist,
393/// or `Err` if it exists but is invalid.
394fn validate_workspace_config(root: &Path, manager: PackageManager) -> Result<bool> {
395    let config_path = root.join(manager.workspace_config_name());
396
397    // Check if file exists
398    if !config_path.exists() {
399        return Ok(false);
400    }
401
402    // Read the file content
403    let content = fs::read_to_string(&config_path).map_err(|e| Error::Io {
404        source: e,
405        path: Some(config_path.clone()),
406        operation: "reading workspace config".to_string(),
407    })?;
408
409    // Try to parse based on file type
410    match manager {
411        PackageManager::Npm
412        | PackageManager::Bun
413        | PackageManager::YarnClassic
414        | PackageManager::YarnModern
415        | PackageManager::Deno => {
416            // Parse as JSON (package.json or deno.json)
417            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
418                Error::InvalidWorkspaceConfig {
419                    path: config_path,
420                    message: format!("Invalid JSON: {e}"),
421                }
422            })?;
423            Ok(true)
424        }
425        PackageManager::Cargo => {
426            // Parse as TOML (Cargo.toml)
427            toml::from_str::<toml::Value>(&content).map_err(|e| Error::InvalidWorkspaceConfig {
428                path: config_path,
429                message: format!("Invalid TOML: {e}"),
430            })?;
431            Ok(true)
432        }
433        PackageManager::Pnpm => {
434            // Parse as YAML (pnpm-workspace.yaml)
435            serde_yaml::from_str::<serde_yaml::Value>(&content).map_err(|e| {
436                Error::InvalidWorkspaceConfig {
437                    path: config_path,
438                    message: format!("Invalid YAML: {e}"),
439                }
440            })?;
441            Ok(true)
442        }
443    }
444}
445
446/// Distinguishes between Yarn Classic (v1) and Yarn Modern (v2+).
447///
448/// Reads the lockfile format to determine the version:
449/// - Yarn Classic uses a custom format starting with `# THIS IS AN AUTOGENERATED FILE`
450/// - Yarn Modern (v2+) uses YAML format starting with `__metadata:`
451fn detect_yarn_version(lockfile_path: &Path) -> Result<PackageManager> {
452    let content = fs::read_to_string(lockfile_path).map_err(|e| Error::Io {
453        source: e,
454        path: Some(lockfile_path.to_path_buf()),
455        operation: "reading yarn.lock".to_string(),
456    })?;
457
458    // Check the first few lines to determine version
459    let first_lines: String = content.lines().take(5).collect::<Vec<_>>().join("\n");
460
461    if first_lines.contains("__metadata:") {
462        Ok(PackageManager::YarnModern)
463    } else if first_lines.contains("# yarn lockfile v1")
464        || first_lines.contains("# THIS IS AN AUTOGENERATED FILE")
465    {
466        Ok(PackageManager::YarnClassic)
467    } else {
468        // Default to Classic if we can't determine
469        tracing::warn!(
470            "Could not determine Yarn version from lockfile format, defaulting to Classic"
471        );
472        Ok(PackageManager::YarnClassic)
473    }
474}
475
476/// Calculates a confidence score (0-100) based on detection signals.
477///
478/// Scoring:
479/// - Lockfile + valid config: 100
480/// - Lockfile only: 75
481/// - Valid config only: 50
482/// - Neither: 0
483const fn calculate_confidence(has_lockfile: bool, has_valid_config: bool) -> u8 {
484    match (has_lockfile, has_valid_config) {
485        (true, true) => 100,
486        (true, false) => 75,
487        (false, true) => 50,
488        (false, false) => 0,
489    }
490}
491
492/// Sorts detected package managers by confidence score and secondary ordering.
493///
494/// Primary sort: confidence score (descending)
495/// Secondary sort: Cargo > Bun > pnpm > Yarn > npm
496fn prioritize_managers(detections: Vec<(PackageManager, u8)>) -> Vec<PackageManager> {
497    let mut sorted = detections;
498
499    // Sort by confidence (descending), then by manager priority
500    sorted.sort_by(|(m1, c1), (m2, c2)| {
501        // First compare by confidence
502        match c2.cmp(c1) {
503            std::cmp::Ordering::Equal => {
504                // If confidence is equal, use manager priority
505                manager_priority(*m1).cmp(&manager_priority(*m2))
506            }
507            other => other,
508        }
509    });
510
511    sorted.into_iter().map(|(m, _)| m).collect()
512}
513
514/// Returns a priority value for deterministic ordering when confidence is equal.
515///
516/// Lower values = higher priority
517const fn manager_priority(manager: PackageManager) -> u8 {
518    match manager {
519        PackageManager::Cargo => 0,
520        PackageManager::Deno => 1,
521        PackageManager::Bun => 2,
522        PackageManager::Pnpm => 3,
523        PackageManager::YarnModern => 4,
524        PackageManager::YarnClassic => 5,
525        PackageManager::Npm => 6,
526    }
527}
528
529#[cfg(test)]
530#[allow(clippy::match_same_arms)]
531mod tests {
532    use super::*;
533    use tempfile::TempDir;
534
535    // Helper function to create a test workspace directory
536    fn create_test_workspace() -> TempDir {
537        TempDir::new().expect("Failed to create temp dir")
538    }
539
540    // Helper function to create a lockfile
541    fn create_lockfile(dir: &Path, manager: PackageManager) -> PathBuf {
542        let lockfile_path = dir.join(manager.lockfile_name());
543        let content = match manager {
544            PackageManager::Npm => r#"{"lockfileVersion": 2}"#,
545            PackageManager::Bun => "binary content",
546            PackageManager::Pnpm => "lockfileVersion: '6.0'",
547            PackageManager::YarnClassic => "# yarn lockfile v1\n",
548            PackageManager::YarnModern => "__metadata:\n  version: 6\n",
549            PackageManager::Cargo => "[root]\n",
550            PackageManager::Deno => r#"{"version": "3"}"#,
551        };
552        fs::write(&lockfile_path, content).expect("Failed to write lockfile");
553        lockfile_path
554    }
555
556    // Helper function to create a workspace config
557    fn create_workspace_config(dir: &Path, manager: PackageManager) -> PathBuf {
558        let config_path = dir.join(manager.workspace_config_name());
559        let content = match manager {
560            PackageManager::Npm | PackageManager::Bun => {
561                r#"{"name": "test", "workspaces": ["packages/*"]}"#
562            }
563            PackageManager::YarnClassic | PackageManager::YarnModern => {
564                r#"{"name": "test", "workspaces": ["packages/*"]}"#
565            }
566            PackageManager::Pnpm => "packages:\n  - 'packages/*'\n",
567            PackageManager::Cargo => "[workspace]\nmembers = [\"crates/*\"]\n",
568            PackageManager::Deno => r#"{"name": "test", "workspace": ["packages/*"]}"#,
569        };
570        fs::write(&config_path, content).expect("Failed to write config");
571        config_path
572    }
573
574    #[test]
575    fn test_calculate_confidence() {
576        assert_eq!(calculate_confidence(true, true), 100);
577        assert_eq!(calculate_confidence(true, false), 75);
578        assert_eq!(calculate_confidence(false, true), 50);
579        assert_eq!(calculate_confidence(false, false), 0);
580    }
581
582    #[test]
583    fn test_prioritize_managers() {
584        let detections = vec![
585            (PackageManager::Npm, 75),
586            (PackageManager::Cargo, 100),
587            (PackageManager::Bun, 75),
588        ];
589
590        let result = prioritize_managers(detections);
591
592        assert_eq!(result[0], PackageManager::Cargo); // Highest confidence
593        assert_eq!(result[1], PackageManager::Bun); // Same confidence as npm, but higher priority
594        assert_eq!(result[2], PackageManager::Npm);
595    }
596
597    #[test]
598    fn test_prioritize_managers_equal_confidence() {
599        let detections = vec![
600            (PackageManager::Npm, 75),
601            (PackageManager::Bun, 75),
602            (PackageManager::Cargo, 75),
603        ];
604
605        let result = prioritize_managers(detections);
606
607        // With equal confidence, should be sorted by priority: Cargo > Bun > npm
608        assert_eq!(result[0], PackageManager::Cargo);
609        assert_eq!(result[1], PackageManager::Bun);
610        assert_eq!(result[2], PackageManager::Npm);
611    }
612
613    #[test]
614    fn test_detect_from_command() {
615        assert_eq!(detect_from_command("cargo"), Some(PackageManager::Cargo));
616        assert_eq!(detect_from_command("npm"), Some(PackageManager::Npm));
617        assert_eq!(detect_from_command("npx"), Some(PackageManager::Npm));
618        assert_eq!(detect_from_command("bun"), Some(PackageManager::Bun));
619        assert_eq!(detect_from_command("bunx"), Some(PackageManager::Bun));
620        assert_eq!(detect_from_command("pnpm"), Some(PackageManager::Pnpm));
621        assert_eq!(detect_from_command("deno"), Some(PackageManager::Deno));
622        assert_eq!(
623            detect_from_command("yarn"),
624            Some(PackageManager::YarnClassic)
625        );
626        assert_eq!(detect_from_command("node"), Some(PackageManager::Npm));
627    }
628
629    #[test]
630    fn test_detect_from_command_with_args() {
631        assert_eq!(
632            detect_from_command("cargo build"),
633            Some(PackageManager::Cargo)
634        );
635        assert_eq!(
636            detect_from_command("npm install"),
637            Some(PackageManager::Npm)
638        );
639        assert_eq!(
640            detect_from_command("bun run test"),
641            Some(PackageManager::Bun)
642        );
643        assert_eq!(
644            detect_from_command("bunx eslint"),
645            Some(PackageManager::Bun)
646        );
647        assert_eq!(
648            detect_from_command("npx prisma generate"),
649            Some(PackageManager::Npm)
650        );
651    }
652
653    #[test]
654    fn test_detect_from_command_unknown() {
655        assert_eq!(detect_from_command("unknown"), None);
656        assert_eq!(detect_from_command("make"), None);
657        assert_eq!(detect_from_command("python"), None);
658    }
659
660    #[test]
661    fn test_detect_yarn_classic() {
662        let temp_dir = create_test_workspace();
663        let lockfile_path = temp_dir.path().join("yarn.lock");
664
665        let classic_content = r#"# yarn lockfile v1
666# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
667
668package@^1.0.0:
669  version "1.0.0"
670"#;
671        fs::write(&lockfile_path, classic_content).unwrap();
672
673        let result = detect_yarn_version(&lockfile_path).unwrap();
674        assert_eq!(result, PackageManager::YarnClassic);
675    }
676
677    #[test]
678    fn test_detect_yarn_modern() {
679        let temp_dir = create_test_workspace();
680        let lockfile_path = temp_dir.path().join("yarn.lock");
681
682        let modern_content = r#"__metadata:
683  version: 6
684  cacheKey: 8
685
686"package@npm:^1.0.0":
687  version: 1.0.0
688"#;
689        fs::write(&lockfile_path, modern_content).unwrap();
690
691        let result = detect_yarn_version(&lockfile_path).unwrap();
692        assert_eq!(result, PackageManager::YarnModern);
693    }
694
695    #[test]
696    fn test_find_lockfiles_cargo() {
697        let temp_dir = create_test_workspace();
698        create_lockfile(temp_dir.path(), PackageManager::Cargo);
699
700        let result = find_lockfiles(temp_dir.path());
701
702        assert_eq!(result.len(), 1);
703        assert_eq!(result[0].0, PackageManager::Cargo);
704    }
705
706    #[test]
707    fn test_find_lockfiles_npm() {
708        let temp_dir = create_test_workspace();
709        create_lockfile(temp_dir.path(), PackageManager::Npm);
710
711        let result = find_lockfiles(temp_dir.path());
712
713        assert_eq!(result.len(), 1);
714        assert_eq!(result[0].0, PackageManager::Npm);
715    }
716
717    #[test]
718    fn test_find_lockfiles_multiple() {
719        let temp_dir = create_test_workspace();
720        create_lockfile(temp_dir.path(), PackageManager::Cargo);
721        create_lockfile(temp_dir.path(), PackageManager::Npm);
722
723        let result = find_lockfiles(temp_dir.path());
724
725        assert_eq!(result.len(), 2);
726        let managers: Vec<_> = result.iter().map(|(m, _)| *m).collect();
727        assert!(managers.contains(&PackageManager::Cargo));
728        assert!(managers.contains(&PackageManager::Npm));
729    }
730
731    #[test]
732    fn test_find_lockfiles_none() {
733        let temp_dir = create_test_workspace();
734
735        let result = find_lockfiles(temp_dir.path());
736
737        assert_eq!(result.len(), 0);
738    }
739
740    #[test]
741    fn test_find_lockfiles_bun_text() {
742        let temp_dir = create_test_workspace();
743
744        fs::write(temp_dir.path().join("bun.lock"), "text").unwrap();
745
746        let result = find_lockfiles(temp_dir.path());
747
748        // Should detect the text bun.lock
749        assert_eq!(result.len(), 1);
750        assert_eq!(result[0].0, PackageManager::Bun);
751        assert_eq!(result[0].1.file_name().unwrap(), "bun.lock");
752    }
753
754    #[test]
755    fn test_validate_workspace_config_cargo() {
756        let temp_dir = create_test_workspace();
757        create_workspace_config(temp_dir.path(), PackageManager::Cargo);
758
759        let result = validate_workspace_config(temp_dir.path(), PackageManager::Cargo).unwrap();
760        assert!(result);
761    }
762
763    #[test]
764    fn test_validate_workspace_config_package_json() {
765        let temp_dir = create_test_workspace();
766        create_workspace_config(temp_dir.path(), PackageManager::Npm);
767
768        let result = validate_workspace_config(temp_dir.path(), PackageManager::Npm).unwrap();
769        assert!(result);
770    }
771
772    #[test]
773    fn test_validate_workspace_config_pnpm() {
774        let temp_dir = create_test_workspace();
775        create_workspace_config(temp_dir.path(), PackageManager::Pnpm);
776
777        let result = validate_workspace_config(temp_dir.path(), PackageManager::Pnpm).unwrap();
778        assert!(result);
779    }
780
781    #[test]
782    fn test_validate_workspace_config_invalid_json() {
783        let temp_dir = create_test_workspace();
784        let config_path = temp_dir.path().join("package.json");
785        fs::write(&config_path, "{ invalid json }").unwrap();
786
787        let result = validate_workspace_config(temp_dir.path(), PackageManager::Npm);
788        assert!(result.is_err());
789        assert!(matches!(result, Err(Error::InvalidWorkspaceConfig { .. })));
790    }
791
792    #[test]
793    fn test_validate_workspace_config_invalid_toml() {
794        let temp_dir = create_test_workspace();
795        let config_path = temp_dir.path().join("Cargo.toml");
796        fs::write(&config_path, "[invalid toml").unwrap();
797
798        let result = validate_workspace_config(temp_dir.path(), PackageManager::Cargo);
799        assert!(result.is_err());
800        assert!(matches!(result, Err(Error::InvalidWorkspaceConfig { .. })));
801    }
802
803    #[test]
804    fn test_validate_workspace_config_invalid_yaml() {
805        let temp_dir = create_test_workspace();
806        let config_path = temp_dir.path().join("pnpm-workspace.yaml");
807        fs::write(&config_path, "invalid: yaml: content:").unwrap();
808
809        let result = validate_workspace_config(temp_dir.path(), PackageManager::Pnpm);
810        assert!(result.is_err());
811        assert!(matches!(result, Err(Error::InvalidWorkspaceConfig { .. })));
812    }
813
814    #[test]
815    fn test_validate_workspace_config_missing() {
816        let temp_dir = create_test_workspace();
817
818        let result = validate_workspace_config(temp_dir.path(), PackageManager::Npm).unwrap();
819        assert!(!result); // Should return false, not error
820    }
821
822    #[test]
823    fn test_detect_package_managers_cargo() {
824        let temp_dir = create_test_workspace();
825        create_lockfile(temp_dir.path(), PackageManager::Cargo);
826        create_workspace_config(temp_dir.path(), PackageManager::Cargo);
827
828        let result = detect_package_managers(temp_dir.path()).unwrap();
829
830        assert_eq!(result.len(), 1);
831        assert_eq!(result[0], PackageManager::Cargo);
832    }
833
834    #[test]
835    fn test_detect_package_managers_npm() {
836        let temp_dir = create_test_workspace();
837        create_lockfile(temp_dir.path(), PackageManager::Npm);
838        create_workspace_config(temp_dir.path(), PackageManager::Npm);
839
840        let result = detect_package_managers(temp_dir.path()).unwrap();
841
842        assert_eq!(result, vec![PackageManager::Npm]);
843    }
844
845    #[test]
846    fn test_detect_package_managers_multi() {
847        let temp_dir = create_test_workspace();
848
849        // Create both Cargo and Bun files
850        create_lockfile(temp_dir.path(), PackageManager::Cargo);
851        create_workspace_config(temp_dir.path(), PackageManager::Cargo);
852
853        // For Bun, we need to create package.json (same as npm config)
854        fs::write(
855            temp_dir.path().join("package.json"),
856            r#"{"name": "test", "workspaces": ["packages/*"]}"#,
857        )
858        .unwrap();
859        create_lockfile(temp_dir.path(), PackageManager::Bun);
860
861        let result = detect_package_managers(temp_dir.path()).unwrap();
862
863        // Cargo (lockfile + config = 100) and Bun (lockfile + config = 100) are detected
864        // We should have at least 2 with Cargo and Bun first (sorted by priority)
865        assert!(result.len() >= 2);
866        // Cargo and Bun both have confidence 100, sorted by priority: Cargo > Bun
867        assert_eq!(result[0], PackageManager::Cargo);
868        assert_eq!(result[1], PackageManager::Bun);
869    }
870
871    #[test]
872    fn test_detect_package_managers_lockfile_only() {
873        let temp_dir = create_test_workspace();
874        create_lockfile(temp_dir.path(), PackageManager::Cargo);
875        // No workspace config
876
877        let result = detect_package_managers(temp_dir.path()).unwrap();
878
879        assert_eq!(result.len(), 1);
880        assert_eq!(result[0], PackageManager::Cargo);
881    }
882
883    #[test]
884    fn test_detect_package_managers_config_only() {
885        let temp_dir = create_test_workspace();
886        create_workspace_config(temp_dir.path(), PackageManager::Cargo);
887        // No lockfile
888
889        let result = detect_package_managers(temp_dir.path()).unwrap();
890
891        assert_eq!(result.len(), 1);
892        assert_eq!(result[0], PackageManager::Cargo);
893    }
894
895    #[test]
896    fn test_detect_package_managers_empty_dir() {
897        let temp_dir = create_test_workspace();
898
899        let result = detect_package_managers(temp_dir.path()).unwrap();
900
901        assert_eq!(result.len(), 0);
902    }
903
904    #[test]
905    fn test_detect_with_command_hint() {
906        let temp_dir = create_test_workspace();
907
908        // Create both Cargo and Bun files
909        create_lockfile(temp_dir.path(), PackageManager::Cargo);
910        create_workspace_config(temp_dir.path(), PackageManager::Cargo);
911
912        fs::write(
913            temp_dir.path().join("package.json"),
914            r#"{"name": "test", "workspaces": ["packages/*"]}"#,
915        )
916        .unwrap();
917        create_lockfile(temp_dir.path(), PackageManager::Bun);
918
919        // Without hint, Cargo comes first (higher priority)
920        let result = detect_package_managers(temp_dir.path()).unwrap();
921        assert_eq!(result[0], PackageManager::Cargo);
922
923        // With Bun hint, Bun should come first
924        let result = detect_with_command_hint(temp_dir.path(), Some("bun run test")).unwrap();
925        assert_eq!(result[0], PackageManager::Bun);
926        assert_eq!(result[1], PackageManager::Cargo);
927    }
928
929    #[test]
930    fn test_detect_with_command_hint_not_detected() {
931        let temp_dir = create_test_workspace();
932        create_lockfile(temp_dir.path(), PackageManager::Cargo);
933        create_workspace_config(temp_dir.path(), PackageManager::Cargo);
934
935        // Hint for a manager that's not detected - should not affect results
936        let result = detect_with_command_hint(temp_dir.path(), Some("npm install")).unwrap();
937        assert_eq!(result.len(), 1);
938        assert_eq!(result[0], PackageManager::Cargo);
939    }
940
941    #[test]
942    fn test_detect_with_no_command_hint() {
943        let temp_dir = create_test_workspace();
944        create_lockfile(temp_dir.path(), PackageManager::Cargo);
945
946        let result = detect_with_command_hint(temp_dir.path(), None).unwrap();
947        assert_eq!(result.len(), 1);
948        assert_eq!(result[0], PackageManager::Cargo);
949    }
950
951    #[test]
952    fn test_yarn_version_detection_in_detect_package_managers() {
953        let temp_dir = create_test_workspace();
954
955        // Create a Yarn Modern lockfile
956        let lockfile_path = temp_dir.path().join("yarn.lock");
957        fs::write(&lockfile_path, "__metadata:\n  version: 6\n").unwrap();
958        create_workspace_config(temp_dir.path(), PackageManager::YarnModern);
959
960        let result = detect_package_managers(temp_dir.path()).unwrap();
961
962        assert_eq!(result, vec![PackageManager::YarnModern]);
963    }
964
965    #[test]
966    fn test_package_json_config_only_defaults_to_npm() {
967        let temp_dir = create_test_workspace();
968
969        fs::write(
970            temp_dir.path().join("package.json"),
971            r#"{"name":"example"}"#,
972        )
973        .unwrap();
974
975        let result = detect_package_managers(temp_dir.path()).unwrap();
976
977        assert_eq!(result, vec![PackageManager::Npm]);
978    }
979
980    #[test]
981    fn test_package_json_package_manager_hint_yarn_classic() {
982        let temp_dir = create_test_workspace();
983
984        fs::write(
985            temp_dir.path().join("package.json"),
986            r#"{"name":"example","packageManager":"yarn@1.22.0"}"#,
987        )
988        .unwrap();
989
990        let result = detect_package_managers(temp_dir.path()).unwrap();
991
992        assert_eq!(result, vec![PackageManager::YarnClassic]);
993    }
994
995    #[test]
996    fn test_package_json_package_manager_hint_yarn_modern() {
997        let temp_dir = create_test_workspace();
998
999        fs::write(
1000            temp_dir.path().join("package.json"),
1001            r#"{"name":"example","packageManager":"yarn@3.5.1"}"#,
1002        )
1003        .unwrap();
1004
1005        let result = detect_package_managers(temp_dir.path()).unwrap();
1006
1007        assert_eq!(result, vec![PackageManager::YarnModern]);
1008    }
1009
1010    #[test]
1011    fn test_package_json_package_manager_hint_bun() {
1012        let temp_dir = create_test_workspace();
1013
1014        fs::write(
1015            temp_dir.path().join("package.json"),
1016            r#"{"name":"example","packageManager":"bun@1.0.0"}"#,
1017        )
1018        .unwrap();
1019
1020        let result = detect_package_managers(temp_dir.path()).unwrap();
1021
1022        assert_eq!(result, vec![PackageManager::Bun]);
1023    }
1024
1025    #[test]
1026    fn test_yarn_modern_lockfile_does_not_duplicate_classic() {
1027        let temp_dir = create_test_workspace();
1028        create_lockfile(temp_dir.path(), PackageManager::YarnModern);
1029        fs::write(
1030            temp_dir.path().join("package.json"),
1031            r#"{"name":"example"}"#,
1032        )
1033        .unwrap();
1034
1035        let result = detect_package_managers(temp_dir.path()).unwrap();
1036
1037        assert_eq!(result, vec![PackageManager::YarnModern]);
1038    }
1039
1040    #[test]
1041    fn test_manager_priority() {
1042        assert!(manager_priority(PackageManager::Cargo) < manager_priority(PackageManager::Deno));
1043        assert!(manager_priority(PackageManager::Deno) < manager_priority(PackageManager::Bun));
1044        assert!(manager_priority(PackageManager::Bun) < manager_priority(PackageManager::Pnpm));
1045        assert!(
1046            manager_priority(PackageManager::Pnpm) < manager_priority(PackageManager::YarnModern)
1047        );
1048        assert!(
1049            manager_priority(PackageManager::YarnModern)
1050                < manager_priority(PackageManager::YarnClassic)
1051        );
1052        assert!(
1053            manager_priority(PackageManager::YarnClassic) < manager_priority(PackageManager::Npm)
1054        );
1055    }
1056}