Skip to main content

ryo_symbol/
metadata.rs

1//! Workspace metadata provider - CargoMetadataProvider implementation
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use camino::{Utf8Path, Utf8PathBuf};
7use cargo_metadata::{Metadata, MetadataCommand};
8use thiserror::Error;
9
10use crate::crate_name::CrateName;
11use crate::file_path::WorkspaceFilePath;
12use crate::resolver::{CrateLayout, WorkspaceType};
13
14/// Workspace metadata provider trait
15///
16/// Provides metadata about the workspace, such as crate information.
17/// This trait abstracts away the details of how workspace information
18/// is obtained.
19///
20/// # Thread Safety
21/// Implementations must be `Send + Sync` for use in multi-threaded contexts.
22pub trait WorkspaceMetadataProvider: Send + Sync {
23    /// Get the crate name for a file path
24    ///
25    /// Returns the crate that contains the given file, or None if
26    /// the file is not part of any known crate.
27    fn crate_for_file(&self, path: &WorkspaceFilePath) -> Option<CrateName>;
28
29    /// Get all crate names in the workspace
30    fn all_crates(&self) -> Vec<CrateName>;
31
32    /// Get the root directory of a crate
33    fn crate_root(&self, crate_name: &CrateName) -> Option<PathBuf>;
34
35    /// Get the workspace root directory
36    fn workspace_root(&self) -> &Path;
37
38    /// Get the CrateLayout for a crate.
39    ///
40    /// Determines the directory structure of a crate within the workspace.
41    /// Used for converting crate-relative paths to workspace-relative paths.
42    ///
43    /// # Returns
44    ///
45    /// - `Some(CrateLayout::Root)` - Crate at workspace root
46    /// - `Some(CrateLayout::InCrates { .. })` - Crate in `crates/` directory
47    /// - `Some(CrateLayout::Custom { .. })` - Crate at custom path
48    /// - `None` - Crate not found
49    fn crate_layout(&self, crate_name: &CrateName) -> Option<CrateLayout>;
50}
51
52/// Error type for CargoMetadataProvider
53#[derive(Debug, Error)]
54pub enum MetadataError {
55    /// The expected `Cargo.toml` file does not exist at the given path.
56    #[error("manifest not found: {0}")]
57    ManifestNotFound(PathBuf),
58
59    /// `cargo metadata` invocation failed (I/O, parse, or non-zero exit).
60    #[error("cargo metadata failed: {0}")]
61    CargoMetadata(#[from] cargo_metadata::Error),
62
63    /// The requested path is not within the resolved workspace root, so no
64    /// crate mapping can be produced for it.
65    #[error("path is outside workspace: {0}")]
66    OutsideWorkspace(PathBuf),
67}
68
69/// Information about a crate in the workspace
70#[derive(Debug, Clone)]
71pub struct CrateInfo {
72    /// Crate name (e.g., "ryo-app")
73    pub name: String,
74    /// Module name (hyphens → underscores, e.g., "ryo_app")
75    pub module_name: String,
76    /// Path to Cargo.toml
77    pub manifest_path: Utf8PathBuf,
78    /// Root source directory (typically "src")
79    pub src_path: Utf8PathBuf,
80    /// Is this a workspace member (vs external dependency)
81    pub is_workspace_member: bool,
82    /// Entry points (lib.rs, main.rs, etc.) from Cargo targets
83    pub entry_points: Vec<TargetInfo>,
84}
85
86/// Information about a cargo target (lib, bin, etc.)
87#[derive(Debug, Clone)]
88pub struct TargetInfo {
89    /// Target name
90    pub name: String,
91    /// Target kind ("lib", "bin", "example", "test", "bench")
92    pub kind: TargetKind,
93    /// Path to source file (e.g., src/lib.rs, src/main.rs)
94    pub src_path: Utf8PathBuf,
95}
96
97/// Kind of cargo target
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum TargetKind {
100    /// Library target (`[lib]`), including rlib / dylib / cdylib / staticlib
101    /// / proc-macro flavors collapsed into one bucket.
102    Lib,
103    /// Binary target (`[[bin]]`).
104    Bin,
105    /// Example target under `examples/`.
106    Example,
107    /// Integration test target under `tests/`.
108    Test,
109    /// Benchmark target under `benches/`.
110    Bench,
111    /// Any cargo target kind not recognized by this crate (forward-compat
112    /// catch-all for new Cargo target kinds added upstream).
113    Other,
114}
115
116impl TargetKind {
117    fn from_cargo_kinds(kinds: &[cargo_metadata::TargetKind]) -> Self {
118        use cargo_metadata::TargetKind as CK;
119        for kind in kinds {
120            match kind {
121                CK::Lib | CK::RLib | CK::DyLib | CK::CDyLib | CK::StaticLib | CK::ProcMacro => {
122                    return Self::Lib
123                }
124                CK::Bin => return Self::Bin,
125                CK::Example => return Self::Example,
126                CK::Test => return Self::Test,
127                CK::Bench => return Self::Bench,
128                _ => {}
129            }
130        }
131        Self::Other
132    }
133}
134
135/// Cargo metadata based workspace provider
136///
137/// This is the default implementation of `WorkspaceMetadataProvider`.
138/// It uses `cargo metadata` to discover workspace structure and
139/// map file paths to crate names.
140///
141/// # Example
142/// ```ignore
143/// use ryo_symbol::{CargoMetadataProvider, WorkspaceMetadataProvider};
144///
145/// let provider = CargoMetadataProvider::from_manifest("Cargo.toml")?;
146///
147/// // Get crate name for a file
148/// let path = provider.resolver().resolve("crates/ryo-app/src/lib.rs")?;
149/// let crate_name = provider.crate_for_file(&path);
150/// ```
151#[derive(Debug)]
152pub struct CargoMetadataProvider {
153    /// Workspace root directory
154    workspace_root: Utf8PathBuf,
155    /// All crates in the workspace (by name)
156    crates: HashMap<String, CrateInfo>,
157    /// File path prefix → crate name mapping (for fast lookup)
158    path_to_crate: Vec<(Utf8PathBuf, String)>,
159    /// Workspace type (Workspace or Crate)
160    workspace_type: WorkspaceType,
161}
162
163impl CargoMetadataProvider {
164    /// Create a provider from a Cargo.toml path
165    pub fn from_manifest(manifest_path: impl AsRef<Path>) -> Result<Self, MetadataError> {
166        let manifest_path = manifest_path.as_ref();
167
168        if !manifest_path.exists() {
169            return Err(MetadataError::ManifestNotFound(manifest_path.to_path_buf()));
170        }
171
172        let metadata = MetadataCommand::new()
173            .manifest_path(manifest_path)
174            .no_deps() // Only workspace members, faster
175            .exec()?;
176
177        Self::from_metadata(metadata)
178    }
179
180    /// Create a provider from a directory containing Cargo.toml
181    pub fn from_directory(dir: impl AsRef<Path>) -> Result<Self, MetadataError> {
182        let dir = dir.as_ref();
183        let manifest_path = dir.join("Cargo.toml");
184        Self::from_manifest(manifest_path)
185    }
186
187    /// Create a provider from pre-fetched metadata
188    pub fn from_metadata(metadata: Metadata) -> Result<Self, MetadataError> {
189        let workspace_root = metadata.workspace_root.clone();
190        let workspace_members: std::collections::HashSet<_> =
191            metadata.workspace_members.iter().collect();
192
193        let mut crates = HashMap::new();
194        let mut path_to_crate = Vec::new();
195
196        for pkg in &metadata.packages {
197            let is_member = workspace_members.contains(&pkg.id);
198            let manifest_dir = pkg.manifest_path.parent().unwrap_or(&pkg.manifest_path);
199            let src_path_absolute = manifest_dir.join("src");
200
201            // Convert to relative path from workspace root
202            let src_path = src_path_absolute
203                .strip_prefix(&workspace_root)
204                .unwrap_or(&src_path_absolute)
205                .to_path_buf();
206
207            // Collect entry points from targets
208            let entry_points: Vec<TargetInfo> = pkg
209                .targets
210                .iter()
211                .map(|target| {
212                    // Convert target src_path to relative
213                    let target_src_relative = target
214                        .src_path
215                        .strip_prefix(&workspace_root)
216                        .unwrap_or(&target.src_path)
217                        .to_path_buf();
218                    TargetInfo {
219                        name: target.name.clone(),
220                        kind: TargetKind::from_cargo_kinds(&target.kind),
221                        src_path: target_src_relative,
222                    }
223                })
224                .collect();
225
226            let info = CrateInfo {
227                name: pkg.name.clone(),
228                module_name: pkg.name.replace('-', "_"),
229                manifest_path: pkg.manifest_path.clone(),
230                src_path: src_path.clone(),
231                is_workspace_member: is_member,
232                entry_points,
233            };
234
235            // Only index workspace members for path resolution
236            if is_member {
237                path_to_crate.push((src_path, info.module_name.clone()));
238            }
239
240            crates.insert(pkg.name.clone(), info);
241        }
242
243        // Sort by path length (longest first) for prefix matching
244        path_to_crate.sort_by_key(|b| std::cmp::Reverse(b.0.as_str().len()));
245
246        // Determine workspace type:
247        // - Multiple members → Workspace
248        // - Single member at workspace_root (manifest in root) → Crate
249        // - Single member in subdirectory → Workspace
250        let workspace_type = if workspace_members.len() > 1 {
251            WorkspaceType::Workspace
252        } else if let Some(pkg) = metadata
253            .packages
254            .iter()
255            .find(|p| workspace_members.contains(&p.id))
256        {
257            // Check if the single member's manifest is at workspace root
258            let manifest_dir = pkg.manifest_path.parent().unwrap_or(&pkg.manifest_path);
259            if manifest_dir == workspace_root {
260                WorkspaceType::Crate
261            } else {
262                WorkspaceType::Workspace
263            }
264        } else {
265            WorkspaceType::Workspace
266        };
267
268        Ok(Self {
269            workspace_root,
270            crates,
271            path_to_crate,
272            workspace_type,
273        })
274    }
275
276    /// Get crate info by name
277    pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
278        self.crates.get(name)
279    }
280
281    /// Get the CrateLayout for a crate by module name.
282    ///
283    /// Determines the directory structure of a crate within the workspace
284    /// based on its `src_path`. This is essential for converting crate-relative
285    /// paths (e.g., `"src/lib.rs"`) to workspace-relative paths
286    /// (e.g., `"crates/my-crate/src/lib.rs"`).
287    ///
288    /// # Returns
289    ///
290    /// - `Some(CrateLayout::Root)` - Crate at workspace root (`src/lib.rs`)
291    /// - `Some(CrateLayout::InCrates { .. })` - Crate in `crates/` directory
292    /// - `Some(CrateLayout::Custom { .. })` - Crate at custom path
293    /// - `None` - Crate not found in workspace
294    ///
295    /// # Example
296    ///
297    /// ```ignore
298    /// let provider = CargoMetadataProvider::from_directory(".")?;
299    /// let crate_name = CrateName::new("my_crate")?;
300    ///
301    /// match provider.crate_layout(&crate_name) {
302    ///     Some(CrateLayout::Root) => println!("Crate at workspace root"),
303    ///     Some(CrateLayout::InCrates { crate_dir_name }) => {
304    ///         println!("Crate in crates/{}", crate_dir_name);
305    ///     }
306    ///     Some(CrateLayout::Custom { prefix }) => {
307    ///         println!("Crate at {}", prefix.display());
308    ///     }
309    ///     None => println!("Crate not found"),
310    /// }
311    /// ```
312    pub fn crate_layout(&self, crate_name: &CrateName) -> Option<CrateLayout> {
313        // Find crate by module name (underscores)
314        let module_name = crate_name.to_module_name();
315        let info = self
316            .crates
317            .values()
318            .find(|c| c.module_name == module_name && c.is_workspace_member)?;
319
320        // Analyze src_path to determine layout
321        let src_path = info.src_path.as_str();
322
323        // Pattern 1: "src" → Root (crate at workspace root)
324        if src_path == "src" {
325            return Some(CrateLayout::Root);
326        }
327
328        // Pattern 2: "crates/{name}/src" → InCrates
329        if let Some(rest) = src_path.strip_prefix("crates/") {
330            if let Some(crate_dir) = rest.strip_suffix("/src") {
331                return Some(CrateLayout::InCrates {
332                    crate_dir_name: crate_dir.to_string(),
333                });
334            }
335        }
336
337        // Pattern 3: "{prefix}/src" → Custom
338        if let Some(prefix) = src_path.strip_suffix("/src") {
339            return Some(CrateLayout::Custom {
340                prefix: PathBuf::from(prefix),
341            });
342        }
343
344        // Fallback: treat entire src_path as custom prefix
345        Some(CrateLayout::Custom {
346            prefix: PathBuf::from(src_path),
347        })
348    }
349
350    /// Get the workspace type
351    pub fn workspace_type(&self) -> WorkspaceType {
352        self.workspace_type
353    }
354
355    /// Get all workspace members
356    pub fn members(&self) -> impl Iterator<Item = &CrateInfo> {
357        self.crates.values().filter(|c| c.is_workspace_member)
358    }
359
360    /// Check if a path is within the workspace
361    pub fn is_in_workspace(&self, path: impl AsRef<Path>) -> bool {
362        let path = path.as_ref();
363        path.to_str()
364            .map(|s| Utf8Path::new(s).starts_with(&self.workspace_root))
365            .unwrap_or(false)
366    }
367
368    /// Get the module path within a crate for a file
369    ///
370    /// Example: "crates/ryo-app/src/config/mod.rs" → "config"
371    pub fn module_path_for_file(&self, file_path: impl AsRef<Path>) -> Option<String> {
372        let file_path = file_path.as_ref();
373        let file_path_str = file_path.to_str()?;
374        let file_path = Utf8Path::new(file_path_str);
375
376        let file_path = if file_path.is_relative() {
377            self.workspace_root.join(file_path)
378        } else {
379            file_path.to_path_buf()
380        };
381
382        // Find matching crate
383        for (src_path, _) in &self.path_to_crate {
384            if file_path.starts_with(src_path) {
385                // Get relative path within src
386                let relative = file_path.strip_prefix(src_path).ok()?;
387                let relative_str = relative.as_str();
388
389                // Remove .rs extension
390                let module_path = relative_str.trim_end_matches(".rs");
391
392                // Convert path separators to ::
393                let module_path = module_path.replace('/', "::");
394
395                // Handle lib.rs and mod.rs
396                let module_path = if module_path == "lib" || module_path.is_empty() {
397                    String::new()
398                } else if module_path.ends_with("::mod") {
399                    module_path.trim_end_matches("::mod").to_string()
400                } else {
401                    module_path
402                };
403
404                return Some(module_path);
405            }
406        }
407
408        None
409    }
410
411    /// Get the full symbol path for a file
412    ///
413    /// Example: "crates/ryo-app/src/config/mod.rs" → "ryo_app::config"
414    pub fn symbol_path_for_file(&self, file_path: impl AsRef<Path>) -> Option<String> {
415        let file_path = file_path.as_ref();
416        let file_path_str = file_path.to_str()?;
417        let utf8_path = Utf8Path::new(file_path_str);
418
419        let canonical_path = if utf8_path.is_relative() {
420            self.workspace_root.join(utf8_path)
421        } else {
422            utf8_path.to_path_buf()
423        };
424
425        // Find crate name
426        for (src_path, module_name) in &self.path_to_crate {
427            if canonical_path.starts_with(src_path) {
428                let module_path = self.module_path_for_file(file_path)?;
429                return if module_path.is_empty() {
430                    Some(module_name.clone())
431                } else {
432                    Some(format!("{}::{}", module_name, module_path))
433                };
434            }
435        }
436
437        None
438    }
439
440    /// Get internal crate name lookup (for trait implementation)
441    fn crate_name_for_path(&self, file_path: &Path) -> Option<&str> {
442        let file_path_str = file_path.to_str()?;
443        let file_path = Utf8Path::new(file_path_str);
444
445        // Convert to absolute path
446        let file_path_absolute = if file_path.is_relative() {
447            self.workspace_root.join(file_path)
448        } else {
449            file_path.to_path_buf()
450        };
451
452        // Find matching crate by src path prefix
453        // Note: path_to_crate contains relative paths (relative to workspace_root)
454        for (src_path, module_name) in &self.path_to_crate {
455            // Convert src_path to absolute for comparison
456            let src_path_absolute = self.workspace_root.join(src_path);
457            if file_path_absolute.starts_with(&src_path_absolute) {
458                return Some(module_name.as_str());
459            }
460        }
461
462        None
463    }
464}
465
466impl WorkspaceMetadataProvider for CargoMetadataProvider {
467    fn crate_for_file(&self, path: &WorkspaceFilePath) -> Option<CrateName> {
468        let absolute = path.to_absolute();
469        let module_name = self.crate_name_for_path(&absolute)?;
470        Some(CrateName::new_unchecked(module_name))
471    }
472
473    fn all_crates(&self) -> Vec<CrateName> {
474        self.crates
475            .values()
476            .filter(|c| c.is_workspace_member)
477            .map(|c| CrateName::new_unchecked(&c.module_name))
478            .collect()
479    }
480
481    fn crate_root(&self, crate_name: &CrateName) -> Option<PathBuf> {
482        // Try to find by module name first (underscores)
483        let module_name = crate_name.to_module_name();
484
485        for info in self.crates.values() {
486            if info.module_name == module_name {
487                return info
488                    .manifest_path
489                    .parent()
490                    .map(|p| PathBuf::from(p.as_str()));
491            }
492        }
493
494        // Try by cargo name (hyphens)
495        self.crates
496            .get(crate_name.as_str())
497            .and_then(|info| info.manifest_path.parent())
498            .map(|p| PathBuf::from(p.as_str()))
499    }
500
501    fn workspace_root(&self) -> &Path {
502        self.workspace_root.as_std_path()
503    }
504
505    fn crate_layout(&self, crate_name: &CrateName) -> Option<CrateLayout> {
506        // Delegate to the inherent method
507        CargoMetadataProvider::crate_layout(self, crate_name)
508    }
509}
510
511/// Mock metadata provider for testing
512///
513/// Provides a simple in-memory implementation of `WorkspaceMetadataProvider`
514/// that returns a fixed crate name for all files.
515///
516/// # Example
517/// ```ignore
518/// use ryo_symbol::{MockMetadataProvider, WorkspaceMetadataProvider};
519///
520/// let provider = MockMetadataProvider::new("/workspace", "mylib");
521/// let path = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace");
522/// assert_eq!(provider.crate_for_file(&path).unwrap().as_str(), "mylib");
523/// ```
524#[cfg(any(test, feature = "test-utils"))]
525#[derive(Debug, Clone)]
526pub struct MockMetadataProvider {
527    workspace_root: PathBuf,
528    crate_name: CrateName,
529}
530
531#[cfg(any(test, feature = "test-utils"))]
532impl MockMetadataProvider {
533    /// Create a new mock provider with a fixed crate name
534    pub fn new(workspace_root: impl Into<PathBuf>, crate_name: impl AsRef<str>) -> Self {
535        Self {
536            workspace_root: workspace_root.into(),
537            crate_name: CrateName::new_unchecked(crate_name.as_ref()),
538        }
539    }
540}
541
542#[cfg(any(test, feature = "test-utils"))]
543impl WorkspaceMetadataProvider for MockMetadataProvider {
544    fn crate_for_file(&self, _path: &WorkspaceFilePath) -> Option<CrateName> {
545        Some(self.crate_name.clone())
546    }
547
548    fn all_crates(&self) -> Vec<CrateName> {
549        vec![self.crate_name.clone()]
550    }
551
552    fn crate_root(&self, _crate_name: &CrateName) -> Option<PathBuf> {
553        Some(self.workspace_root.clone())
554    }
555
556    fn workspace_root(&self) -> &Path {
557        &self.workspace_root
558    }
559
560    fn crate_layout(&self, _crate_name: &CrateName) -> Option<CrateLayout> {
561        // Mock always returns Root layout
562        Some(CrateLayout::Root)
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::resolver::CrateLayout;
570
571    // ========================================================================
572    // crate_layout() tests - TDD RED phase
573    // ========================================================================
574
575    #[test]
576    fn test_crate_layout_root_crate() {
577        // Single crate at workspace root: src/lib.rs
578        // src_path = "src" (relative to workspace root)
579        let provider = create_test_provider_with_src_path("my_crate", "src");
580        let crate_name = CrateName::new_unchecked("my_crate");
581
582        let layout = provider.crate_layout(&crate_name);
583
584        assert_eq!(layout, Some(CrateLayout::Root));
585    }
586
587    #[test]
588    fn test_crate_layout_in_crates_directory() {
589        // Crate in crates/ directory: crates/my-crate/src/lib.rs
590        // src_path = "crates/my-crate/src" (relative to workspace root)
591        let provider = create_test_provider_with_src_path("my_crate", "crates/my-crate/src");
592        let crate_name = CrateName::new_unchecked("my_crate");
593
594        let layout = provider.crate_layout(&crate_name);
595
596        assert_eq!(
597            layout,
598            Some(CrateLayout::InCrates {
599                crate_dir_name: "my-crate".to_string()
600            })
601        );
602    }
603
604    #[test]
605    fn test_crate_layout_custom_path() {
606        // Crate at custom path: packages/core/src/lib.rs
607        // src_path = "packages/core/src" (relative to workspace root)
608        let provider = create_test_provider_with_src_path("core", "packages/core/src");
609        let crate_name = CrateName::new_unchecked("core");
610
611        let layout = provider.crate_layout(&crate_name);
612
613        assert_eq!(
614            layout,
615            Some(CrateLayout::Custom {
616                prefix: PathBuf::from("packages/core")
617            })
618        );
619    }
620
621    #[test]
622    fn test_crate_layout_unknown_crate_returns_none() {
623        let provider = create_test_provider_with_src_path("my_crate", "src");
624        let crate_name = CrateName::new_unchecked("unknown_crate");
625
626        let layout = provider.crate_layout(&crate_name);
627
628        assert_eq!(layout, None);
629    }
630
631    /// Helper: Create a test provider with specified src_path
632    fn create_test_provider_with_src_path(
633        module_name: &str,
634        src_path: &str,
635    ) -> CargoMetadataProvider {
636        let workspace_root = Utf8PathBuf::from("/workspace");
637        let mut crates = HashMap::new();
638
639        let info = CrateInfo {
640            name: module_name.replace('_', "-"),
641            module_name: module_name.to_string(),
642            manifest_path: Utf8PathBuf::from(format!(
643                "/workspace/{}/Cargo.toml",
644                src_path.trim_end_matches("/src")
645            )),
646            src_path: Utf8PathBuf::from(src_path),
647            is_workspace_member: true,
648            entry_points: vec![],
649        };
650
651        crates.insert(info.name.clone(), info.clone());
652
653        let path_to_crate = vec![(Utf8PathBuf::from(src_path), module_name.to_string())];
654
655        CargoMetadataProvider {
656            workspace_root,
657            crates,
658            path_to_crate,
659            workspace_type: WorkspaceType::Workspace,
660        }
661    }
662
663    // ========================================================================
664    // Existing tests
665    // ========================================================================
666
667    #[test]
668    fn test_crate_info() {
669        let info = CrateInfo {
670            name: "ryo-app".to_string(),
671            module_name: "ryo_app".to_string(),
672            manifest_path: Utf8PathBuf::from("/test/Cargo.toml"),
673            src_path: Utf8PathBuf::from("/test/src"),
674            is_workspace_member: true,
675            entry_points: vec![TargetInfo {
676                name: "ryo_app".to_string(),
677                kind: TargetKind::Lib,
678                src_path: Utf8PathBuf::from("/test/src/lib.rs"),
679            }],
680        };
681
682        assert_eq!(info.module_name, "ryo_app");
683        assert!(info.is_workspace_member);
684        assert_eq!(info.entry_points.len(), 1);
685    }
686
687    #[test]
688    fn test_mock_provider() {
689        let provider = MockMetadataProvider::new("/workspace", "mylib");
690        let path = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace", "mylib");
691
692        assert_eq!(provider.crate_for_file(&path).unwrap().as_str(), "mylib");
693        assert_eq!(provider.workspace_root(), Path::new("/workspace"));
694        assert_eq!(provider.all_crates().len(), 1);
695    }
696}