Skip to main content

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}