ryo_symbol/resolver.rs
1//! Workspace path resolver for normalizing and validating paths
2
3use std::path::{Component, Path, PathBuf};
4use std::sync::Arc;
5
6use crate::crate_name::CrateName;
7use crate::error::ResolveError;
8use crate::file_path::WorkspaceFilePath;
9use crate::metadata::WorkspaceMetadataProvider;
10use crate::path::SymbolPath;
11use crate::symbol_resolver::SymbolPathResolver;
12
13/// Entry point type for a crate
14///
15/// Determines whether the crate root is `lib.rs` or `main.rs`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum EntryPoint {
18 /// Library crate: `src/lib.rs`
19 #[default]
20 Lib,
21 /// Binary crate: `src/main.rs`
22 Bin,
23}
24
25impl EntryPoint {
26 /// Get the file name for this entry point
27 pub fn file_name(&self) -> &'static str {
28 match self {
29 Self::Lib => "lib.rs",
30 Self::Bin => "main.rs",
31 }
32 }
33
34 /// Infer entry point from a file path
35 ///
36 /// Checks if the path ends with `main.rs` or `lib.rs`.
37 pub fn from_path(path: &std::path::Path) -> Self {
38 if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
39 if file_name == "main.rs" {
40 return Self::Bin;
41 }
42 }
43 Self::Lib
44 }
45}
46
47/// Type of workspace structure
48///
49/// Determined by `[workspace]` section in Cargo.toml:
50/// - `Workspace`: Has `[workspace]` section (multi-crate or single-crate in subdirectory)
51/// - `Crate`: No `[workspace]` section (single crate at root)
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum WorkspaceType {
54 /// Multi-crate workspace or single crate in subdirectory
55 /// File layout: `crates/{crate}/src/*.rs` or `{crate}/src/*.rs`
56 #[default]
57 Workspace,
58 /// Single crate at workspace root
59 /// File layout: `src/*.rs`
60 Crate,
61}
62
63/// Layout of a crate within a workspace
64///
65/// Determines the file path prefix for a crate's source files.
66/// Used by `SymbolPathResolver` to convert `SymbolPath` → `WorkspaceFilePath`.
67///
68/// # Examples
69///
70/// ```ignore
71/// // Single crate at workspace root: src/lib.rs
72/// CrateLayout::Root
73///
74/// // Standard workspace: crates/my-crate/src/lib.rs
75/// CrateLayout::InCrates { crate_dir_name: "my-crate".to_string() }
76///
77/// // Custom path: packages/core/src/lib.rs
78/// CrateLayout::Custom { prefix: PathBuf::from("packages/core") }
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq, Default)]
81pub enum CrateLayout {
82 /// Crate at workspace root: `src/*.rs`
83 #[default]
84 Root,
85 /// Crate in `crates/` directory: `crates/{crate_dir_name}/src/*.rs`
86 InCrates {
87 /// Directory name (may differ from crate name due to hyphens)
88 crate_dir_name: String,
89 },
90 /// Crate at custom path: `{prefix}/src/*.rs`
91 Custom {
92 /// Path prefix relative to workspace root
93 prefix: PathBuf,
94 },
95}
96
97impl CrateLayout {
98 /// Create layout for a crate in `crates/` directory
99 pub fn in_crates(crate_dir_name: impl Into<String>) -> Self {
100 Self::InCrates {
101 crate_dir_name: crate_dir_name.into(),
102 }
103 }
104
105 /// Create layout for a crate at custom path
106 pub fn custom(prefix: impl Into<PathBuf>) -> Self {
107 Self::Custom {
108 prefix: prefix.into(),
109 }
110 }
111
112 /// Get the source directory path relative to workspace root
113 ///
114 /// Returns the path to the `src/` directory for this crate.
115 pub fn src_dir(&self) -> PathBuf {
116 match self {
117 Self::Root => PathBuf::from("src"),
118 Self::InCrates { crate_dir_name } => {
119 PathBuf::from(format!("crates/{}/src", crate_dir_name))
120 }
121 Self::Custom { prefix } => prefix.join("src"),
122 }
123 }
124
125 /// Convert a crate-relative path to a workspace-relative path.
126 ///
127 /// This is essential for converting generator output paths (e.g., `"src/lib.rs"`)
128 /// to workspace-relative paths (e.g., `"crates/my-crate/src/lib.rs"`).
129 ///
130 /// # Arguments
131 ///
132 /// * `crate_relative` - Path relative to crate root (e.g., `"src/lib.rs"`, `"src/foo/bar.rs"`)
133 ///
134 /// # Returns
135 ///
136 /// Path relative to workspace root.
137 ///
138 /// # Examples
139 ///
140 /// ```
141 /// use ryo_symbol::CrateLayout;
142 /// use std::path::PathBuf;
143 ///
144 /// // Root layout: path unchanged
145 /// let root = CrateLayout::Root;
146 /// assert_eq!(root.to_workspace_relative("src/lib.rs"), PathBuf::from("src/lib.rs"));
147 ///
148 /// // InCrates layout: prepend crates/{name}/
149 /// let in_crates = CrateLayout::in_crates("my-crate");
150 /// assert_eq!(
151 /// in_crates.to_workspace_relative("src/lib.rs"),
152 /// PathBuf::from("crates/my-crate/src/lib.rs")
153 /// );
154 ///
155 /// // Custom layout: prepend custom prefix
156 /// let custom = CrateLayout::custom("packages/core");
157 /// assert_eq!(
158 /// custom.to_workspace_relative("src/lib.rs"),
159 /// PathBuf::from("packages/core/src/lib.rs")
160 /// );
161 /// ```
162 pub fn to_workspace_relative(&self, crate_relative: impl AsRef<Path>) -> PathBuf {
163 let crate_relative = crate_relative.as_ref();
164
165 match self {
166 Self::Root => {
167 // Root layout: path is already workspace-relative
168 crate_relative.to_path_buf()
169 }
170 Self::InCrates { crate_dir_name } => {
171 // InCrates: prepend "crates/{name}/"
172 PathBuf::from(format!("crates/{}", crate_dir_name)).join(crate_relative)
173 }
174 Self::Custom { prefix } => {
175 // Custom: prepend the custom prefix
176 prefix.join(crate_relative)
177 }
178 }
179 }
180
181 /// Infer layout from a WorkspaceFilePath
182 ///
183 /// Analyzes the path structure to determine the crate layout.
184 pub fn from_workspace_file_path(path: &WorkspaceFilePath) -> Self {
185 let path_str = path.as_relative().to_string_lossy();
186
187 // Check for crates/{name}/src/ pattern
188 if let Some(idx) = path_str.find("crates/") {
189 let after_crates = &path_str[idx + 7..];
190 if let Some(end_idx) = after_crates.find('/') {
191 let crate_dir_name = &after_crates[..end_idx];
192 return Self::InCrates {
193 crate_dir_name: crate_dir_name.to_string(),
194 };
195 }
196 }
197
198 // Check for direct src/ (root layout)
199 if path_str.starts_with("src/") {
200 return Self::Root;
201 }
202
203 // Check for custom prefix (anything before /src/)
204 if let Some(idx) = path_str.find("/src/") {
205 let prefix = &path_str[..idx];
206 return Self::Custom {
207 prefix: PathBuf::from(prefix),
208 };
209 }
210
211 // Default to root
212 Self::Root
213 }
214}
215
216/// Workspace path resolver
217///
218/// Normalizes and validates paths relative to a workspace root.
219/// This is the **only** way to create `WorkspaceFilePath` instances.
220///
221/// # Responsibilities
222/// - Convert any path (absolute/relative) to `WorkspaceFilePath`
223/// - Normalize paths (resolve `..` and `.`)
224/// - Validate paths are within workspace
225/// - Share workspace_root via `Arc<Path>` for efficient cloning
226///
227/// # Example
228/// ```ignore
229/// let resolver = WorkspacePathResolver::new("/home/user/project".into());
230///
231/// // Absolute path
232/// let p1 = resolver.resolve("/home/user/project/src/lib.rs")?;
233///
234/// // Relative path (resolved from CWD)
235/// let p2 = resolver.resolve("./src/../src/lib.rs")?;
236///
237/// // Strict mode (also checks file existence)
238/// let p3 = resolver.resolve_strict("src/lib.rs")?;
239/// ```
240#[derive(Debug, Clone)]
241pub struct WorkspacePathResolver {
242 workspace_root: Arc<Path>,
243 workspace_type: WorkspaceType,
244}
245
246impl WorkspacePathResolver {
247 /// Create a new resolver with the given workspace root
248 ///
249 /// Defaults to `WorkspaceType::Workspace`. Use `with_type` for explicit control.
250 pub fn new(workspace_root: PathBuf) -> Self {
251 Self {
252 workspace_root: Arc::from(workspace_root),
253 workspace_type: WorkspaceType::default(),
254 }
255 }
256
257 /// Create a new resolver with explicit workspace type
258 pub fn with_type(workspace_root: PathBuf, workspace_type: WorkspaceType) -> Self {
259 Self {
260 workspace_root: Arc::from(workspace_root),
261 workspace_type,
262 }
263 }
264
265 /// Get the workspace type
266 pub fn workspace_type(&self) -> WorkspaceType {
267 self.workspace_type
268 }
269
270 /// Resolve any path to a WorkspaceFilePath with provider-based crate resolution
271 ///
272 /// - Absolute path → convert to relative from workspace_root
273 /// - Relative path → resolve from CWD, then convert
274 /// - `..` / `.` → resolved
275 /// - Outside workspace → error
276 /// - crate_name → resolved from provider
277 pub fn resolve_with_provider<P: WorkspaceMetadataProvider>(
278 &self,
279 path: impl AsRef<Path>,
280 provider: &P,
281 ) -> Result<WorkspaceFilePath, ResolveError> {
282 let path = path.as_ref();
283
284 // 1. Convert to absolute path
285 let absolute = if path.is_absolute() {
286 path.to_path_buf()
287 } else {
288 std::env::current_dir()?.join(path)
289 };
290
291 // 2. Normalize (resolve .. and ., no I/O)
292 let normalized = normalize_path(&absolute);
293
294 // 3. Convert to relative path from workspace_root
295 let relative = normalized
296 .strip_prefix(&*self.workspace_root)
297 .map_err(|_| ResolveError::OutsideWorkspace {
298 path: normalized.clone(),
299 workspace: self.workspace_root.to_path_buf(),
300 })?
301 .to_path_buf();
302
303 // 4. Create temporary path to resolve crate name
304 // We need to create a temporary WorkspaceFilePath to pass to the provider
305 // Use a placeholder crate name first, then resolve the real one
306 let temp_crate = CrateName::new_unchecked("__temp__");
307 let temp_path = WorkspaceFilePath::new_unchecked(
308 relative.clone(),
309 Arc::clone(&self.workspace_root),
310 temp_crate,
311 );
312
313 // 5. Resolve crate name from provider
314 let crate_name = provider
315 .crate_for_file(&temp_path)
316 .ok_or_else(|| ResolveError::CrateNotFound(normalized.clone()))?;
317
318 Ok(WorkspaceFilePath::new_unchecked(
319 relative,
320 Arc::clone(&self.workspace_root),
321 crate_name,
322 ))
323 }
324
325 /// Resolve with file existence check (strict mode)
326 pub fn resolve_strict_with_provider<P: WorkspaceMetadataProvider>(
327 &self,
328 path: impl AsRef<Path>,
329 provider: &P,
330 ) -> Result<WorkspaceFilePath, ResolveError> {
331 let workspace_path = self.resolve_with_provider(path, provider)?;
332 let absolute = workspace_path.to_absolute();
333
334 if !absolute.exists() {
335 return Err(ResolveError::FileNotFound(absolute));
336 }
337
338 Ok(workspace_path)
339 }
340
341 /// Resolve from a path that's already relative to workspace root (with explicit crate name)
342 ///
343 /// This skips CWD resolution and directly creates a WorkspaceFilePath.
344 /// Useful when you already have a known-good relative path and crate name.
345 pub fn resolve_relative_with_crate(
346 &self,
347 relative: impl AsRef<Path>,
348 crate_name: CrateName,
349 ) -> WorkspaceFilePath {
350 let relative = relative.as_ref();
351 let normalized = normalize_path(relative);
352 WorkspaceFilePath::new_unchecked(normalized, Arc::clone(&self.workspace_root), crate_name)
353 }
354
355 /// Resolve from a path that's already relative to workspace root (with provider)
356 ///
357 /// This skips CWD resolution and uses the provider to resolve the crate name.
358 pub fn resolve_relative_with_provider<P: WorkspaceMetadataProvider>(
359 &self,
360 relative: impl AsRef<Path>,
361 provider: &P,
362 ) -> Option<WorkspaceFilePath> {
363 let relative = relative.as_ref();
364 let normalized = normalize_path(relative);
365
366 // Create temporary path to resolve crate name
367 let temp_crate = CrateName::new_unchecked("__temp__");
368 let temp_path = WorkspaceFilePath::new_unchecked(
369 normalized.clone(),
370 Arc::clone(&self.workspace_root),
371 temp_crate,
372 );
373
374 // Resolve crate name from provider
375 let crate_name = provider.crate_for_file(&temp_path)?;
376
377 Some(WorkspaceFilePath::new_unchecked(
378 normalized,
379 Arc::clone(&self.workspace_root),
380 crate_name,
381 ))
382 }
383
384 /// Get the workspace root
385 pub fn workspace_root(&self) -> &Path {
386 &self.workspace_root
387 }
388
389 /// Get the workspace root as Arc (for deserialization)
390 pub fn workspace_root_arc(&self) -> Arc<Path> {
391 Arc::clone(&self.workspace_root)
392 }
393
394 // ========== Module to File Resolution ==========
395
396 /// Resolve module path to file path
397 ///
398 /// This method centralizes the logic for determining which file a module belongs to.
399 /// It handles the distinction between:
400 /// - **Crate root (depth 1)**: span points to the actual file (main.rs/lib.rs)
401 /// - **Sub-modules (depth > 1)**: span points to declaration site (`mod foo;` in lib.rs),
402 /// so path-based inference is needed to get the actual file (foo.rs)
403 ///
404 /// # Arguments
405 ///
406 /// - `module_path`: The symbol path of the module
407 /// - `crate_name`: The crate name for path inference
408 /// - `span_file`: Optional span file (use for crate root to preserve main.rs vs lib.rs)
409 ///
410 /// # Example
411 ///
412 /// ```ignore
413 /// let resolver = WorkspacePathResolver::new("/workspace".into());
414 ///
415 /// // For crate root with span → uses span file (preserves main.rs)
416 /// let file = resolver.module_to_file(&module_path, &crate_name, Some(&span_file));
417 ///
418 /// // For sub-module → uses path-based inference
419 /// let file = resolver.module_to_file(&module_path, &crate_name, None);
420 /// ```
421 pub fn module_to_file(
422 &self,
423 module_path: &SymbolPath,
424 crate_name: &CrateName,
425 span_file: Option<&WorkspaceFilePath>,
426 ) -> WorkspaceFilePath {
427 // Crate root (depth 1): span points to actual file (main.rs/lib.rs)
428 if module_path.depth() == 1 {
429 if let Some(span_file) = span_file {
430 return span_file.clone();
431 }
432 }
433
434 // Sub-module or no span: use path-based inference
435 let symbol_resolver = SymbolPathResolver::from_crate_name(crate_name.clone());
436
437 // Create a virtual child to get the containing file
438 // (e.g., crate::storage → crate::storage::_ → src/storage.rs)
439 if let Ok(virtual_child) = module_path.child("_") {
440 symbol_resolver.to_workspace_file_path(&virtual_child, self.workspace_root_arc())
441 } else {
442 symbol_resolver.to_workspace_file_path(module_path, self.workspace_root_arc())
443 }
444 }
445
446 // ========== Simplified API (infers crate_name from path) ==========
447
448 /// Resolve any path to a WorkspaceFilePath (infers crate_name from path)
449 ///
450 /// This is a simplified API that attempts to infer the crate name from the path.
451 /// It looks for `crates/<crate-name>/` in the path structure.
452 ///
453 /// For more control, use `resolve_with_provider` or `resolve_relative_with_crate`.
454 pub fn resolve(&self, path: impl AsRef<Path>) -> Result<WorkspaceFilePath, ResolveError> {
455 let path = path.as_ref();
456
457 // 1. Convert to absolute path
458 let absolute = if path.is_absolute() {
459 path.to_path_buf()
460 } else {
461 std::env::current_dir()?.join(path)
462 };
463
464 // 2. Normalize (resolve .. and ., no I/O)
465 let normalized = normalize_path(&absolute);
466
467 // 3. Convert to relative path from workspace_root
468 let relative = normalized
469 .strip_prefix(&*self.workspace_root)
470 .map_err(|_| ResolveError::OutsideWorkspace {
471 path: normalized.clone(),
472 workspace: self.workspace_root.to_path_buf(),
473 })?
474 .to_path_buf();
475
476 // 4. Infer crate name from path
477 let crate_name = infer_crate_name(&relative);
478
479 Ok(WorkspaceFilePath::new_unchecked(
480 relative,
481 Arc::clone(&self.workspace_root),
482 crate_name,
483 ))
484 }
485
486 /// Resolve from a path that's already relative to workspace root (infers crate_name)
487 ///
488 /// This is a simplified API that skips CWD resolution and infers the crate name.
489 pub fn resolve_relative(&self, relative: impl AsRef<Path>) -> Option<WorkspaceFilePath> {
490 let relative = relative.as_ref();
491 let normalized = normalize_path(relative);
492 let crate_name = infer_crate_name(&normalized);
493
494 Some(WorkspaceFilePath::new_unchecked(
495 normalized,
496 Arc::clone(&self.workspace_root),
497 crate_name,
498 ))
499 }
500
501 // ========== Validation API ==========
502
503 /// Validate that a `crate::` prefixed path is unambiguous in this workspace
504 ///
505 /// In a multi-crate workspace (`WorkspaceType::Workspace`), paths starting with
506 /// `crate::` are ambiguous because it's unclear which crate is being referred to.
507 ///
508 /// # Arguments
509 ///
510 /// - `path`: The path string to validate (e.g., "crate::domain::model")
511 /// - `workspace_members`: List of workspace member paths for error message (e.g., ["crates/core", "crates/api"])
512 ///
513 /// # Returns
514 ///
515 /// - `Ok(())` if the path is unambiguous (single crate or doesn't start with `crate::`)
516 /// - `Err(ResolveError::AmbiguousCratePath)` if ambiguous in multi-crate workspace
517 ///
518 /// # Example
519 ///
520 /// ```ignore
521 /// let resolver = WorkspacePathResolver::with_type(root, WorkspaceType::Workspace);
522 /// let members = vec!["crates/core".to_string(), "crates/api".to_string()];
523 ///
524 /// // This will error in multi-crate workspace
525 /// resolver.validate_crate_path("crate::domain", &members)?;
526 ///
527 /// // These are OK
528 /// resolver.validate_crate_path("core::domain", &members)?; // explicit crate name
529 /// resolver.validate_crate_path("src/domain.rs", &members)?; // file path
530 /// ```
531 pub fn validate_crate_path(
532 &self,
533 path: &str,
534 workspace_members: &[String],
535 ) -> Result<(), ResolveError> {
536 // Only validate for multi-crate workspaces
537 if self.workspace_type != WorkspaceType::Workspace {
538 return Ok(());
539 }
540
541 // Only validate paths starting with "crate::"
542 if !path.starts_with("crate::") {
543 return Ok(());
544 }
545
546 // Single-member workspace is not ambiguous
547 if workspace_members.len() <= 1 {
548 return Ok(());
549 }
550
551 // Multi-crate workspace with crate:: path is ambiguous
552 let first_member = workspace_members.first().cloned().unwrap_or_default();
553 let crate_name = first_member
554 .split('/')
555 .next_back()
556 .unwrap_or(&first_member)
557 .to_string();
558
559 // Extract module path from "crate::xxx" -> "xxx"
560 let module_suffix = path.strip_prefix("crate::").unwrap_or("");
561 let example_file_path = if module_suffix.is_empty() {
562 format!("{}/src/lib.rs", first_member)
563 } else {
564 format!(
565 "{}/src/{}.rs",
566 first_member,
567 module_suffix.replace("::", "/")
568 )
569 };
570
571 Err(ResolveError::AmbiguousCratePath {
572 path: path.to_string(),
573 example_crate_path: first_member,
574 example_file_path,
575 example_crate_name: crate_name,
576 })
577 }
578}
579
580/// Infer crate name from a relative path
581///
582/// Looks for `crates/<crate-name>/` pattern in the path.
583/// Falls back to "crate" if pattern not found.
584fn infer_crate_name(path: &Path) -> CrateName {
585 let path_str = path.to_string_lossy();
586
587 // Look for "crates/<name>/" pattern
588 if let Some(idx) = path_str.find("crates/") {
589 let after_crates = &path_str[idx + 7..];
590 if let Some(end_idx) = after_crates.find('/') {
591 let crate_name = &after_crates[..end_idx];
592 return CrateName::new_unchecked(crate_name);
593 }
594 }
595
596 // Fallback: use first component if it looks like a crate (has src/)
597 if path_str.contains("/src/") || path_str.starts_with("src/") {
598 // Path is already in crate root, use "crate" as default
599 return CrateName::new_unchecked("crate");
600 }
601
602 // Default fallback
603 CrateName::new_unchecked("crate")
604}
605
606/// Normalize a path without I/O (resolve `..` and `.`)
607///
608/// # Precondition
609/// For best results, input should be an absolute path.
610/// Relative paths with `..` that exceed the root will have those
611/// components silently dropped.
612fn normalize_path(path: &Path) -> PathBuf {
613 let mut components = Vec::new();
614
615 for comp in path.components() {
616 match comp {
617 Component::ParentDir => {
618 // `/foo/..` → `/`
619 // Don't pop RootDir or Prefix (Windows)
620 if let Some(Component::Normal(_)) = components.last() {
621 components.pop();
622 }
623 }
624 Component::CurDir => {
625 // `.` is skipped
626 }
627 c => components.push(c),
628 }
629 }
630
631 components.iter().collect()
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn test_normalize_path() {
640 assert_eq!(
641 normalize_path(Path::new("/foo/bar/../baz")),
642 PathBuf::from("/foo/baz")
643 );
644 assert_eq!(
645 normalize_path(Path::new("/foo/./bar")),
646 PathBuf::from("/foo/bar")
647 );
648 assert_eq!(
649 normalize_path(Path::new("/foo/bar/../../baz")),
650 PathBuf::from("/baz")
651 );
652 assert_eq!(
653 normalize_path(Path::new("foo/bar/../baz")),
654 PathBuf::from("foo/baz")
655 );
656 }
657
658 #[test]
659 fn test_resolve_relative_with_crate() {
660 let resolver = WorkspacePathResolver::new(PathBuf::from("/workspace"));
661 let crate_name = CrateName::new_for_test("my_crate");
662
663 let path = resolver.resolve_relative_with_crate("src/lib.rs", crate_name);
664 assert_eq!(path.as_relative(), Path::new("src/lib.rs"));
665 assert_eq!(path.workspace_root(), Path::new("/workspace"));
666 assert_eq!(path.crate_name().as_str(), "my_crate");
667 }
668
669 #[test]
670 fn test_resolve_relative_with_dots_and_crate() {
671 let resolver = WorkspacePathResolver::new(PathBuf::from("/workspace"));
672 let crate_name = CrateName::new_for_test("my_crate");
673
674 let path = resolver.resolve_relative_with_crate("src/../src/./lib.rs", crate_name);
675 assert_eq!(path.as_relative(), Path::new("src/lib.rs"));
676 assert_eq!(path.crate_name().as_str(), "my_crate");
677 }
678
679 #[test]
680 fn test_resolve_relative_with_provider() {
681 use crate::metadata::MockMetadataProvider;
682
683 let resolver = WorkspacePathResolver::new(PathBuf::from("/workspace"));
684 let provider = MockMetadataProvider::new("/workspace", "test_crate");
685
686 let path = resolver
687 .resolve_relative_with_provider("src/lib.rs", &provider)
688 .unwrap();
689 assert_eq!(path.as_relative(), Path::new("src/lib.rs"));
690 assert_eq!(path.crate_name().as_str(), "test_crate");
691 }
692
693 // ========================================================================
694 // CrateLayout::to_workspace_relative tests
695 // ========================================================================
696
697 #[test]
698 fn test_crate_layout_to_workspace_relative_root() {
699 // Root layout: src/lib.rs stays as src/lib.rs
700 let layout = CrateLayout::Root;
701 let result = layout.to_workspace_relative("src/lib.rs");
702 assert_eq!(result, PathBuf::from("src/lib.rs"));
703 }
704
705 #[test]
706 fn test_crate_layout_to_workspace_relative_in_crates() {
707 // InCrates layout: src/lib.rs → crates/my-crate/src/lib.rs
708 let layout = CrateLayout::in_crates("my-crate");
709 let result = layout.to_workspace_relative("src/lib.rs");
710 assert_eq!(result, PathBuf::from("crates/my-crate/src/lib.rs"));
711 }
712
713 #[test]
714 fn test_crate_layout_to_workspace_relative_in_crates_nested() {
715 // InCrates layout: src/foo/bar.rs → crates/my-crate/src/foo/bar.rs
716 let layout = CrateLayout::in_crates("my-crate");
717 let result = layout.to_workspace_relative("src/foo/bar.rs");
718 assert_eq!(result, PathBuf::from("crates/my-crate/src/foo/bar.rs"));
719 }
720
721 #[test]
722 fn test_crate_layout_to_workspace_relative_custom() {
723 // Custom layout: src/lib.rs → packages/core/src/lib.rs
724 let layout = CrateLayout::custom("packages/core");
725 let result = layout.to_workspace_relative("src/lib.rs");
726 assert_eq!(result, PathBuf::from("packages/core/src/lib.rs"));
727 }
728
729 #[test]
730 fn test_crate_layout_to_workspace_relative_main_rs() {
731 // Binary: src/main.rs → crates/my-cli/src/main.rs
732 let layout = CrateLayout::in_crates("my-cli");
733 let result = layout.to_workspace_relative("src/main.rs");
734 assert_eq!(result, PathBuf::from("crates/my-cli/src/main.rs"));
735 }
736}