sublime_pkg_tools 0.0.27

Package and version management toolkit for Node.js projects with changeset support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
//! File-to-package mapping for changes analysis.
//!
//! **What**: Provides functionality to map changed files to their containing packages,
//! supporting both single-package and monorepo project structures.
//!
//! **How**: Uses the `MonorepoDetector` from `sublime_standard_tools` to detect project
//! structure, then maps each file to its owning package by checking if the file path
//! is under a package directory. Implements caching to optimize repeated lookups.
//!
//! **Why**: To efficiently determine which packages are affected by file changes, enabling
//! accurate change analysis and version calculation in both simple and complex project structures.
//!
//! # Features
//!
//! - **Monorepo Support**: Automatically detects and handles npm/yarn/pnpm/bun workspaces
//! - **Single Package**: Handles standard single-package projects
//! - **Caching**: Caches monorepo structure and file mappings for performance
//! - **Root Files**: Handles files in the workspace root that don't belong to any package
//! - **Path Normalization**: Handles relative and absolute paths correctly
//!
//! # Examples
//!
//! ## Basic file mapping
//!
//! ```rust,ignore
//! use sublime_pkg_tools::changes::mapping::PackageMapper;
//! use sublime_standard_tools::filesystem::FileSystemManager;
//! use std::path::PathBuf;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let workspace_root = PathBuf::from(".");
//! let fs = FileSystemManager::new();
//!
//! let mut mapper = PackageMapper::new(workspace_root, fs);
//!
//! let files = vec![
//!     PathBuf::from("packages/core/src/index.ts"),
//!     PathBuf::from("packages/utils/src/helper.ts"),
//! ];
//!
//! let package_files = mapper.map_files_to_packages(&files).await?;
//!
//! for (package_name, files) in package_files {
//!     println!("Package '{}' has {} changed files", package_name, files.len());
//! }
//! # Ok(())
//! # }
//! ```
//!
//! ## Finding package for a single file
//!
//! ```rust,ignore
//! use sublime_pkg_tools::changes::mapping::PackageMapper;
//! use sublime_standard_tools::filesystem::FileSystemManager;
//! use std::path::PathBuf;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let workspace_root = PathBuf::from(".");
//! let fs = FileSystemManager::new();
//!
//! let mut mapper = PackageMapper::new(workspace_root, fs);
//!
//! let file = PathBuf::from("packages/core/src/index.ts");
//! if let Some(package_name) = mapper.find_package_for_file(&file).await? {
//!     println!("File belongs to package: {}", package_name);
//! }
//! # Ok(())
//! # }
//! ```

use crate::config::PackageToolsConfig;
use crate::error::{ChangesError, ChangesResult};
use crate::types::PackageInfo;
use package_json::PackageJson;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use sublime_standard_tools::config::MonorepoConfig;
use sublime_standard_tools::filesystem::{AsyncFileSystem, FileSystemManager};
use sublime_standard_tools::monorepo::{
    MonorepoDescriptor, MonorepoDetector, MonorepoDetectorTrait,
};

/// Maps files to their containing packages with caching support.
///
/// The `PackageMapper` analyzes the project structure (single-package or monorepo)
/// and provides efficient mapping from file paths to package names. It caches both
/// the monorepo structure and individual file mappings for optimal performance.
///
/// # Architecture
///
/// - **Lazy Detection**: Monorepo structure is detected on first use
/// - **Two-Level Cache**: Caches both the monorepo descriptor and file-to-package mappings
/// - **Absolute Path Handling**: Normalizes all paths relative to workspace root
///
/// # Cache Strategy
///
/// - **Monorepo Cache**: `Option<MonorepoDescriptor>` cached after first detection
/// - **File Mapping Cache**: `HashMap<PathBuf, Option<String>>` for individual file lookups
/// - **Cache Invalidation**: Create a new mapper instance to invalidate caches
///
/// # Examples
///
/// ```rust,ignore
/// use sublime_pkg_tools::changes::mapping::PackageMapper;
/// use sublime_standard_tools::filesystem::FileSystemManager;
/// use std::path::PathBuf;
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let workspace_root = PathBuf::from(".");
/// let fs = FileSystemManager::new();
///
/// let mut mapper = PackageMapper::new(workspace_root.clone(), fs);
///
/// // First call detects monorepo structure and caches it
/// let files = vec![PathBuf::from("packages/core/src/index.ts")];
/// let result = mapper.map_files_to_packages(&files).await?;
///
/// // Subsequent calls use cached structure for better performance
/// let more_files = vec![PathBuf::from("packages/utils/src/helper.ts")];
/// let result2 = mapper.map_files_to_packages(&more_files).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct PackageMapper<F = FileSystemManager>
where
    F: AsyncFileSystem + Clone + Send + Sync + 'static,
{
    /// Root directory of the workspace.
    workspace_root: PathBuf,

    /// Filesystem abstraction for file operations.
    fs: F,

    /// Monorepo detector for structure analysis.
    monorepo_detector: MonorepoDetector<F>,

    /// Cached monorepo descriptor (None means not yet detected or single-package).
    pub(crate) cached_monorepo: Option<Option<MonorepoDescriptor>>,

    /// Cache mapping file paths to package names.
    /// Value is `Option<String>` where `None` means file doesn't belong to any package.
    pub(crate) file_cache: HashMap<PathBuf, Option<String>>,
}

impl PackageMapper<FileSystemManager> {
    /// Creates a new `PackageMapper` with the default filesystem.
    ///
    /// # Arguments
    ///
    /// * `workspace_root` - Root directory of the workspace
    /// * `fs` - Filesystem instance for file operations
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use sublime_standard_tools::filesystem::FileSystemManager;
    /// use std::path::PathBuf;
    ///
    /// let workspace_root = PathBuf::from(".");
    /// let fs = FileSystemManager::new();
    /// let mapper = PackageMapper::new(workspace_root, fs);
    /// ```
    #[must_use]
    pub fn new(workspace_root: PathBuf, fs: FileSystemManager) -> Self {
        let monorepo_detector = MonorepoDetector::with_filesystem(fs.clone());

        Self {
            workspace_root,
            fs,
            monorepo_detector,
            cached_monorepo: None,
            file_cache: HashMap::new(),
        }
    }

    /// Creates a new `PackageMapper` with the default filesystem and custom config.
    ///
    /// This constructor uses workspace patterns from the provided configuration,
    /// ensuring that packages in directories like `playground/*` are discovered
    /// correctly when specified in `repo.config.toml`.
    ///
    /// # Arguments
    ///
    /// * `workspace_root` - Root directory of the workspace
    /// * `fs` - Filesystem instance for file operations
    /// * `config` - Package tools configuration containing workspace patterns
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use sublime_pkg_tools::config::PackageToolsConfig;
    /// use sublime_standard_tools::filesystem::FileSystemManager;
    /// use std::path::PathBuf;
    ///
    /// let workspace_root = PathBuf::from(".");
    /// let fs = FileSystemManager::new();
    /// let config = PackageToolsConfig::default();
    /// let mapper = PackageMapper::new_with_config(workspace_root, fs, &config);
    /// ```
    #[must_use]
    pub fn new_with_config(
        workspace_root: PathBuf,
        fs: FileSystemManager,
        config: &PackageToolsConfig,
    ) -> Self {
        let monorepo_config = Self::build_monorepo_config(config);
        let monorepo_detector =
            MonorepoDetector::with_filesystem_and_config(fs.clone(), monorepo_config);

        Self {
            workspace_root,
            fs,
            monorepo_detector,
            cached_monorepo: None,
            file_cache: HashMap::new(),
        }
    }

    /// Builds a `MonorepoConfig` from the `PackageToolsConfig`.
    ///
    /// This method creates a `MonorepoConfig` that includes workspace patterns from
    /// the `repo.config.toml` file, ensuring all workspace directories are discovered.
    ///
    /// # Arguments
    ///
    /// * `config` - The package tools configuration containing workspace patterns
    ///
    /// # Returns
    ///
    /// Returns a `MonorepoConfig` with merged workspace patterns.
    #[must_use]
    fn build_monorepo_config(config: &PackageToolsConfig) -> MonorepoConfig {
        let mut monorepo_config = config.standard_config.monorepo.clone();

        // Merge workspace patterns from repo.config.toml if available
        if let Some(ref workspace) = config.workspace
            && !workspace.patterns.is_empty()
        {
            for pattern in &workspace.patterns {
                if !monorepo_config.workspace_patterns.contains(pattern) {
                    monorepo_config.workspace_patterns.push(pattern.clone());
                }
            }
        }

        monorepo_config
    }
}

impl<F> PackageMapper<F>
where
    F: AsyncFileSystem + Clone + Send + Sync + 'static,
{
    /// Creates a new `PackageMapper` with a custom filesystem.
    ///
    /// This allows using mock filesystems for testing.
    ///
    /// # Arguments
    ///
    /// * `workspace_root` - Root directory of the workspace
    /// * `fs` - Custom filesystem implementation
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use std::path::PathBuf;
    ///
    /// # async fn example<F>(fs: F) -> Result<(), Box<dyn std::error::Error>>
    /// # where F: AsyncFileSystem + Clone + Send + Sync + 'static
    /// # {
    /// let workspace_root = PathBuf::from(".");
    /// let mapper = PackageMapper::with_filesystem(workspace_root, fs);
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub fn with_filesystem(workspace_root: PathBuf, fs: F) -> Self {
        let monorepo_detector = MonorepoDetector::with_filesystem(fs.clone());

        Self {
            workspace_root,
            fs,
            monorepo_detector,
            cached_monorepo: None,
            file_cache: HashMap::new(),
        }
    }

    /// Creates a new `PackageMapper` with a custom filesystem and config.
    ///
    /// This constructor uses workspace patterns from the provided configuration,
    /// ensuring that packages in all configured directories are discovered correctly.
    ///
    /// # Arguments
    ///
    /// * `workspace_root` - Root directory of the workspace
    /// * `fs` - Custom filesystem implementation
    /// * `config` - Package tools configuration containing workspace patterns
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use sublime_pkg_tools::config::PackageToolsConfig;
    /// use std::path::PathBuf;
    ///
    /// # async fn example<F>(fs: F, config: &PackageToolsConfig) -> Result<(), Box<dyn std::error::Error>>
    /// # where F: AsyncFileSystem + Clone + Send + Sync + 'static
    /// # {
    /// let workspace_root = PathBuf::from(".");
    /// let mapper = PackageMapper::with_filesystem_and_config(workspace_root, fs, config);
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub fn with_filesystem_and_config(
        workspace_root: PathBuf,
        fs: F,
        config: &PackageToolsConfig,
    ) -> Self {
        let monorepo_config = Self::build_monorepo_config_generic(config);
        let monorepo_detector =
            MonorepoDetector::with_filesystem_and_config(fs.clone(), monorepo_config);

        Self {
            workspace_root,
            fs,
            monorepo_detector,
            cached_monorepo: None,
            file_cache: HashMap::new(),
        }
    }

    /// Builds a `MonorepoConfig` from the `PackageToolsConfig` (generic version).
    ///
    /// This method creates a `MonorepoConfig` that includes workspace patterns from
    /// the `repo.config.toml` file, ensuring all workspace directories are discovered.
    ///
    /// # Arguments
    ///
    /// * `config` - The package tools configuration containing workspace patterns
    ///
    /// # Returns
    ///
    /// Returns a `MonorepoConfig` with merged workspace patterns.
    #[must_use]
    fn build_monorepo_config_generic(config: &PackageToolsConfig) -> MonorepoConfig {
        let mut monorepo_config = config.standard_config.monorepo.clone();

        // Merge workspace patterns from repo.config.toml if available
        if let Some(ref workspace) = config.workspace
            && !workspace.patterns.is_empty()
        {
            for pattern in &workspace.patterns {
                if !monorepo_config.workspace_patterns.contains(pattern) {
                    monorepo_config.workspace_patterns.push(pattern.clone());
                }
            }
        }

        monorepo_config
    }

    /// Maps a list of files to their containing packages.
    ///
    /// Returns a HashMap where keys are package names and values are lists of files
    /// belonging to that package. Files that don't belong to any package are omitted.
    ///
    /// # Arguments
    ///
    /// * `files` - List of file paths to map (can be relative or absolute)
    ///
    /// # Returns
    ///
    /// A HashMap mapping package names to lists of file paths.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Monorepo detection fails
    /// - Filesystem operations fail
    /// - package.json files cannot be read or parsed
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use sublime_standard_tools::filesystem::FileSystemManager;
    /// use std::path::PathBuf;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let workspace_root = PathBuf::from(".");
    /// let fs = FileSystemManager::new();
    /// let mut mapper = PackageMapper::new(workspace_root, fs);
    ///
    /// let files = vec![
    ///     PathBuf::from("packages/core/src/index.ts"),
    ///     PathBuf::from("packages/utils/src/helper.ts"),
    ///     PathBuf::from("README.md"),
    /// ];
    ///
    /// let package_files = mapper.map_files_to_packages(&files).await?;
    ///
    /// for (package_name, pkg_files) in package_files {
    ///     println!("Package '{}' has files:", package_name);
    ///     for file in pkg_files {
    ///         println!("  - {}", file.display());
    ///     }
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub async fn map_files_to_packages(
        &mut self,
        files: &[PathBuf],
    ) -> ChangesResult<HashMap<String, Vec<PathBuf>>> {
        // Ensure we have detected the project structure
        self.ensure_monorepo_detected().await?;

        let mut package_files: HashMap<String, Vec<PathBuf>> = HashMap::new();

        for file in files {
            // Normalize path relative to workspace root
            let normalized_path = self.normalize_path(file)?;

            // Find which package owns this file
            if let Some(package_name) = self.find_package_for_file(&normalized_path).await? {
                package_files.entry(package_name).or_default().push(normalized_path);
            }
            // Files not belonging to any package are silently omitted
        }

        Ok(package_files)
    }

    /// Finds the package that contains a specific file.
    ///
    /// Returns `Some(package_name)` if the file belongs to a package,
    /// or `None` if the file is outside all packages or is a root file.
    ///
    /// # Arguments
    ///
    /// * `file` - Path to the file (can be relative or absolute)
    ///
    /// # Returns
    ///
    /// - `Ok(Some(package_name))` if file belongs to a package
    /// - `Ok(None)` if file doesn't belong to any package (e.g., root files)
    /// - `Err(...)` if detection fails
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Monorepo detection fails
    /// - Filesystem operations fail
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use sublime_standard_tools::filesystem::FileSystemManager;
    /// use std::path::PathBuf;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let workspace_root = PathBuf::from(".");
    /// let fs = FileSystemManager::new();
    /// let mut mapper = PackageMapper::new(workspace_root, fs);
    ///
    /// // File in a package
    /// let file = PathBuf::from("packages/core/src/index.ts");
    /// if let Some(pkg) = mapper.find_package_for_file(&file).await? {
    ///     println!("File belongs to: {}", pkg);
    /// }
    ///
    /// // Root file
    /// let root_file = PathBuf::from("README.md");
    /// if mapper.find_package_for_file(&root_file).await?.is_none() {
    ///     println!("File is a root file");
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub async fn find_package_for_file(&mut self, file: &Path) -> ChangesResult<Option<String>> {
        // Normalize the path
        let normalized_path = self.normalize_path(file)?;

        // Check cache first
        if let Some(cached_result) = self.file_cache.get(&normalized_path) {
            return Ok(cached_result.clone());
        }

        // Ensure monorepo is detected
        self.ensure_monorepo_detected().await?;

        // Find the package
        let package_name = self.find_package_for_file_impl(&normalized_path).await?;

        // Cache the result
        self.file_cache.insert(normalized_path, package_name.clone());

        Ok(package_name)
    }

    /// Gets all packages in the workspace.
    ///
    /// Returns a list of `PackageInfo` for all packages in the workspace.
    /// For single-package projects, returns a single package.
    /// For monorepos, returns all workspace packages.
    ///
    /// # Returns
    ///
    /// A vector of `PackageInfo` instances for all packages.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Monorepo detection fails
    /// - package.json files cannot be read or parsed
    /// - No packages are found
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use sublime_standard_tools::filesystem::FileSystemManager;
    /// use std::path::PathBuf;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let workspace_root = PathBuf::from(".");
    /// let fs = FileSystemManager::new();
    /// let mut mapper = PackageMapper::new(workspace_root, fs);
    ///
    /// let packages = mapper.get_all_packages().await?;
    /// println!("Found {} packages:", packages.len());
    /// for pkg in packages {
    ///     println!("  - {} at {}", pkg.name(), pkg.path().display());
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub async fn get_all_packages(&mut self) -> ChangesResult<Vec<PackageInfo>> {
        self.ensure_monorepo_detected().await?;

        if let Some(Some(monorepo)) = &self.cached_monorepo {
            // Monorepo: convert all workspace packages to PackageInfo
            let mut packages = Vec::new();
            for wp in monorepo.packages() {
                packages.push(self.workspace_package_to_package_info(wp).await?);
            }

            if packages.is_empty() {
                return Err(ChangesError::NoPackagesFound {
                    workspace_root: self.workspace_root.clone(),
                });
            }

            Ok(packages)
        } else {
            // Single package: read root package.json
            let package_info = self.read_root_package().await?;
            Ok(vec![package_info])
        }
    }

    /// Clears all caches, forcing re-detection on next operation.
    ///
    /// This is useful when the workspace structure may have changed
    /// (e.g., packages added/removed).
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use sublime_standard_tools::filesystem::FileSystemManager;
    /// use std::path::PathBuf;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let workspace_root = PathBuf::from(".");
    /// let fs = FileSystemManager::new();
    /// let mut mapper = PackageMapper::new(workspace_root, fs);
    ///
    /// // Use mapper...
    /// let _ = mapper.get_all_packages().await?;
    ///
    /// // Clear caches to force re-detection
    /// mapper.clear_cache();
    ///
    /// // Next call will re-detect structure
    /// let _ = mapper.get_all_packages().await?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn clear_cache(&mut self) {
        self.cached_monorepo = None;
        self.file_cache.clear();
    }

    /// Returns whether this workspace is a monorepo.
    ///
    /// # Returns
    ///
    /// - `Ok(true)` if workspace is a monorepo
    /// - `Ok(false)` if workspace is a single-package project
    ///
    /// # Errors
    ///
    /// Returns an error if monorepo detection fails.
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use sublime_pkg_tools::changes::mapping::PackageMapper;
    /// use sublime_standard_tools::filesystem::FileSystemManager;
    /// use std::path::PathBuf;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let workspace_root = PathBuf::from(".");
    /// let fs = FileSystemManager::new();
    /// let mut mapper = PackageMapper::new(workspace_root, fs);
    ///
    /// if mapper.is_monorepo().await? {
    ///     println!("This is a monorepo workspace");
    /// } else {
    ///     println!("This is a single-package project");
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub async fn is_monorepo(&mut self) -> ChangesResult<bool> {
        self.ensure_monorepo_detected().await?;
        Ok(self.cached_monorepo.as_ref().is_some_and(|m| m.is_some()))
    }

    /// Ensures monorepo structure has been detected and cached.
    ///
    /// This method is called internally before operations that need the
    /// project structure information.
    async fn ensure_monorepo_detected(&mut self) -> ChangesResult<()> {
        if self.cached_monorepo.is_none() {
            // Detect monorepo structure
            let monorepo_result =
                self.monorepo_detector.detect_monorepo(&self.workspace_root).await;

            match monorepo_result {
                Ok(descriptor) => {
                    // Successfully detected as monorepo
                    self.cached_monorepo = Some(Some(descriptor));
                }
                Err(_) => {
                    // Not a monorepo (or detection failed) - treat as single package
                    // This is normal for single-package projects
                    self.cached_monorepo = Some(None);
                }
            }
        }

        Ok(())
    }

    /// Internal implementation for finding package for a file.
    ///
    /// This assumes the path is already normalized and monorepo is detected.
    async fn find_package_for_file_impl(&self, file: &Path) -> ChangesResult<Option<String>> {
        if let Some(Some(monorepo)) = &self.cached_monorepo {
            // Monorepo: use find_package_for_path
            // Convert relative path to absolute and canonicalize to handle symlinks
            let absolute_file = if file.is_absolute() {
                file.to_path_buf()
            } else {
                self.workspace_root.join(file)
            };

            // Canonicalize to handle symlinks (e.g., /var -> /private/var on macOS)
            let canonical_file = absolute_file.canonicalize().unwrap_or(absolute_file);

            if let Some(workspace_package) = monorepo.find_package_for_path(&canonical_file) {
                return Ok(Some(workspace_package.name.clone()));
            }

            // File is not under any package (root file)
            Ok(None)
        } else {
            // Single package: all files belong to the root package
            let package_info = self.read_root_package().await?;
            Ok(Some(package_info.name().to_string()))
        }
    }

    /// Normalizes a file path relative to the workspace root.
    ///
    /// Handles both relative and absolute paths, ensuring the returned path
    /// is relative to the workspace root.
    pub(crate) fn normalize_path(&self, path: &Path) -> ChangesResult<PathBuf> {
        if path.is_absolute() {
            // Strip workspace root prefix
            path.strip_prefix(&self.workspace_root).map(|p| p.to_path_buf()).map_err(|_| {
                ChangesError::FileOutsideWorkspace {
                    path: path.to_path_buf(),
                    workspace_root: self.workspace_root.clone(),
                }
            })
        } else {
            // Already relative
            Ok(path.to_path_buf())
        }
    }

    /// Reads the root package.json and creates a PackageInfo.
    async fn read_root_package(&self) -> ChangesResult<PackageInfo> {
        let package_json_path = self.workspace_root.join("package.json");

        // Read package.json
        let content = self.fs.read_file_string(&package_json_path).await.map_err(|e| {
            ChangesError::FileSystemError {
                path: package_json_path.clone(),
                reason: format!("Failed to read package.json: {}", e),
            }
        })?;

        // Parse as PackageJson
        let package_json: PackageJson =
            serde_json::from_str(&content).map_err(|e| ChangesError::PackageJsonParseError {
                path: package_json_path.clone(),
                reason: e.to_string(),
            })?;

        // Create PackageInfo
        Ok(PackageInfo::new(package_json, None, self.workspace_root.clone()))
    }

    /// Converts a WorkspacePackage to PackageInfo by reading its package.json.
    async fn workspace_package_to_package_info(
        &self,
        workspace_package: &sublime_standard_tools::monorepo::WorkspacePackage,
    ) -> ChangesResult<PackageInfo> {
        // Read the package.json for this workspace package
        let package_json_path = workspace_package.absolute_path.join("package.json");

        // Read package.json content
        let content = self.fs.read_file_string(&package_json_path).await.map_err(|e| {
            ChangesError::FileSystemError {
                path: package_json_path.clone(),
                reason: format!("Failed to read package.json: {}", e),
            }
        })?;

        // Parse as PackageJson
        let package_json: PackageJson =
            serde_json::from_str(&content).map_err(|e| ChangesError::PackageJsonParseError {
                path: package_json_path.clone(),
                reason: e.to_string(),
            })?;

        // Create PackageInfo with workspace context
        Ok(PackageInfo::new(
            package_json,
            Some(workspace_package.clone()),
            workspace_package.absolute_path.clone(),
        ))
    }
}