1use 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
14pub trait WorkspaceMetadataProvider: Send + Sync {
23 fn crate_for_file(&self, path: &WorkspaceFilePath) -> Option<CrateName>;
28
29 fn all_crates(&self) -> Vec<CrateName>;
31
32 fn crate_root(&self, crate_name: &CrateName) -> Option<PathBuf>;
34
35 fn workspace_root(&self) -> &Path;
37
38 fn crate_layout(&self, crate_name: &CrateName) -> Option<CrateLayout>;
50}
51
52#[derive(Debug, Error)]
54pub enum MetadataError {
55 #[error("manifest not found: {0}")]
57 ManifestNotFound(PathBuf),
58
59 #[error("cargo metadata failed: {0}")]
61 CargoMetadata(#[from] cargo_metadata::Error),
62
63 #[error("path is outside workspace: {0}")]
66 OutsideWorkspace(PathBuf),
67}
68
69#[derive(Debug, Clone)]
71pub struct CrateInfo {
72 pub name: String,
74 pub module_name: String,
76 pub manifest_path: Utf8PathBuf,
78 pub src_path: Utf8PathBuf,
80 pub is_workspace_member: bool,
82 pub entry_points: Vec<TargetInfo>,
84}
85
86#[derive(Debug, Clone)]
88pub struct TargetInfo {
89 pub name: String,
91 pub kind: TargetKind,
93 pub src_path: Utf8PathBuf,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum TargetKind {
100 Lib,
103 Bin,
105 Example,
107 Test,
109 Bench,
111 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#[derive(Debug)]
152pub struct CargoMetadataProvider {
153 workspace_root: Utf8PathBuf,
155 crates: HashMap<String, CrateInfo>,
157 path_to_crate: Vec<(Utf8PathBuf, String)>,
159 workspace_type: WorkspaceType,
161}
162
163impl CargoMetadataProvider {
164 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() .exec()?;
176
177 Self::from_metadata(metadata)
178 }
179
180 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 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 let src_path = src_path_absolute
203 .strip_prefix(&workspace_root)
204 .unwrap_or(&src_path_absolute)
205 .to_path_buf();
206
207 let entry_points: Vec<TargetInfo> = pkg
209 .targets
210 .iter()
211 .map(|target| {
212 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 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 path_to_crate.sort_by_key(|b| std::cmp::Reverse(b.0.as_str().len()));
245
246 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 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 pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
278 self.crates.get(name)
279 }
280
281 pub fn crate_layout(&self, crate_name: &CrateName) -> Option<CrateLayout> {
313 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 let src_path = info.src_path.as_str();
322
323 if src_path == "src" {
325 return Some(CrateLayout::Root);
326 }
327
328 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 if let Some(prefix) = src_path.strip_suffix("/src") {
339 return Some(CrateLayout::Custom {
340 prefix: PathBuf::from(prefix),
341 });
342 }
343
344 Some(CrateLayout::Custom {
346 prefix: PathBuf::from(src_path),
347 })
348 }
349
350 pub fn workspace_type(&self) -> WorkspaceType {
352 self.workspace_type
353 }
354
355 pub fn members(&self) -> impl Iterator<Item = &CrateInfo> {
357 self.crates.values().filter(|c| c.is_workspace_member)
358 }
359
360 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 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 for (src_path, _) in &self.path_to_crate {
384 if file_path.starts_with(src_path) {
385 let relative = file_path.strip_prefix(src_path).ok()?;
387 let relative_str = relative.as_str();
388
389 let module_path = relative_str.trim_end_matches(".rs");
391
392 let module_path = module_path.replace('/', "::");
394
395 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 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 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 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 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 for (src_path, module_name) in &self.path_to_crate {
455 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 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 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 CargoMetadataProvider::crate_layout(self, crate_name)
508 }
509}
510
511#[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 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 Some(CrateLayout::Root)
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::resolver::CrateLayout;
570
571 #[test]
576 fn test_crate_layout_root_crate() {
577 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 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 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 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 #[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}