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