Skip to main content

raysense/
workspace.rs

1/*
2 *   Copyright (c) 2025-2026 Anton Kundenko <singaraiona@gmail.com>
3 *   All rights reserved.
4 *
5 *   Permission is hereby granted, free of charge, to any person obtaining a copy
6 *   of this software and associated documentation files (the "Software"), to deal
7 *   in the Software without restriction, including without limitation the rights
8 *   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 *   copies of the Software, and to permit persons to whom the Software is
10 *   furnished to do so, subject to the following conditions:
11 *
12 *   The above copyright notice and this permission notice shall be included in all
13 *   copies or substantial portions of the Software.
14 *
15 *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 *   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 *   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 *   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 *   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 *   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 *   SOFTWARE.
22 */
23
24//! Package-manager workspace discovery. Reads the manifest files declared
25//! by each language plugin (`Cargo.toml` for Rust today; npm `package.json`,
26//! Go `go.work`, uv `pyproject.toml` later) and produces a `WorkspaceMap`
27//! that lets the scanner classify cross-crate imports as `Local` and
28//! resolve `crate::` against the importing member's `src/` root.
29//!
30//! This module is data-only: it doesn't change resolution. Slice 5 wires
31//! the discovery into the scan loop without consuming the map; slices 6
32//! and 7 follow up with the resolution and classification changes.
33
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36
37use crate::health::RaysenseConfig;
38
39/// Map from a workspace member's *crate name* (the one used in `use
40/// my_crate::Foo`) to its on-disk layout. Empty when the project has no
41/// manifest the configured plugins recognize, or when none of the
42/// plugins declare a `workspace_manifest_files` entry.
43#[derive(Debug, Clone, Default)]
44pub struct WorkspaceMap {
45    pub members_by_crate: HashMap<String, MemberCrate>,
46    pub member_src_dirs: Vec<PathBuf>,
47}
48
49/// One workspace member crate. `manifest_dir` is relative to the scan
50/// root; `src_dir` is the `src/` directory that holds its sources.
51#[derive(Debug, Clone)]
52pub struct MemberCrate {
53    pub crate_name: String,
54    pub manifest_dir: PathBuf,
55    pub src_dir: PathBuf,
56}
57
58/// Discover the workspace layout under `root` by consulting each
59/// configured plugin's `workspace_manifest_files`. Currently only the
60/// Cargo manifest format is parsed; other manifests added later become
61/// new arms in the dispatcher below without changing call sites.
62pub fn discover(root: &Path, config: &RaysenseConfig) -> WorkspaceMap {
63    let mut map = WorkspaceMap::default();
64    for plugin in &config.scan.plugins {
65        for file_name in &plugin.workspace_manifest_files {
66            if file_name.eq_ignore_ascii_case("Cargo.toml") {
67                discover_cargo_workspace(root, &mut map);
68            }
69        }
70    }
71    // Synthesize the rust default for projects without a configured
72    // plugin (the most common case for built-in language support).
73    let cargo_manifest = root.join("Cargo.toml");
74    if cargo_manifest.is_file() && map.members_by_crate.is_empty() {
75        discover_cargo_workspace(root, &mut map);
76    }
77    map
78}
79
80fn discover_cargo_workspace(root: &Path, map: &mut WorkspaceMap) {
81    let manifest_path = root.join("Cargo.toml");
82    let Ok(text) = std::fs::read_to_string(&manifest_path) else {
83        return;
84    };
85    let Ok(parsed) = text.parse::<toml::Table>() else {
86        return;
87    };
88
89    let mut member_paths: Vec<PathBuf> = Vec::new();
90    if let Some(workspace) = parsed.get("workspace").and_then(|v| v.as_table()) {
91        if let Some(members) = workspace.get("members").and_then(|v| v.as_array()) {
92            for entry in members {
93                let Some(member) = entry.as_str() else {
94                    continue;
95                };
96                if member.contains('*') {
97                    // v1: literal paths only. Glob patterns are common in
98                    // real workspaces; deferring expansion to a follow-up
99                    // slice keeps this one small and predictable.
100                    continue;
101                }
102                member_paths.push(PathBuf::from(member));
103            }
104        }
105    }
106
107    // A root manifest with `[package]` is itself a member, even when it
108    // also declares a `[workspace]` (the common "virtual + package"
109    // shape). Push the root path so it gets the same treatment below.
110    if parsed.get("package").is_some() {
111        member_paths.push(PathBuf::from("."));
112    }
113
114    for relative in member_paths {
115        let manifest_dir_abs = root.join(&relative);
116        let member_manifest = manifest_dir_abs.join("Cargo.toml");
117        let Ok(member_text) = std::fs::read_to_string(&member_manifest) else {
118            continue;
119        };
120        let Ok(member_parsed) = member_text.parse::<toml::Table>() else {
121            continue;
122        };
123        let Some(crate_name) = member_parsed
124            .get("package")
125            .and_then(|v| v.as_table())
126            .and_then(|t| t.get("name"))
127            .and_then(|v| v.as_str())
128        else {
129            continue;
130        };
131        if !manifest_dir_abs.join("src").is_dir() {
132            continue;
133        }
134        // Store paths relative to the scan root so the scanner's by-path
135        // index (also keyed by scan-relative paths) can match the
136        // candidates we generate. A root manifest's `[package]` collapses
137        // to plain `src`.
138        let src_dir = if relative == PathBuf::from(".") {
139            PathBuf::from("src")
140        } else {
141            relative.join("src")
142        };
143        let member = MemberCrate {
144            crate_name: crate_name.to_string(),
145            manifest_dir: relative.clone(),
146            src_dir: src_dir.clone(),
147        };
148        map.members_by_crate.insert(crate_name.to_string(), member);
149        map.member_src_dirs.push(src_dir);
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::health::RaysenseConfig;
157    use std::fs;
158    use std::time::{SystemTime, UNIX_EPOCH};
159
160    fn temp_workspace_root(name: &str) -> PathBuf {
161        let nanos = SystemTime::now()
162            .duration_since(UNIX_EPOCH)
163            .unwrap()
164            .as_nanos();
165        let path = std::env::temp_dir().join(format!("raysense_workspace_{name}_{nanos}"));
166        let _ = fs::remove_dir_all(&path);
167        fs::create_dir_all(&path).unwrap();
168        path
169    }
170
171    #[test]
172    fn discovers_cargo_workspace_members() {
173        let root = temp_workspace_root("cargo_members");
174        fs::write(
175            root.join("Cargo.toml"),
176            "[workspace]\nmembers = [\"crates/alpha\", \"crates/beta\"]\n",
177        )
178        .unwrap();
179        for (member, name) in [("crates/alpha", "alpha"), ("crates/beta", "beta")] {
180            let manifest_dir = root.join(member);
181            fs::create_dir_all(manifest_dir.join("src")).unwrap();
182            fs::write(
183                manifest_dir.join("Cargo.toml"),
184                format!("[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n"),
185            )
186            .unwrap();
187            fs::write(manifest_dir.join("src/lib.rs"), "").unwrap();
188        }
189
190        let map = discover(&root, &RaysenseConfig::default());
191        let alpha = map
192            .members_by_crate
193            .get("alpha")
194            .expect("alpha member is discovered");
195        assert_eq!(alpha.crate_name, "alpha");
196        assert!(alpha.src_dir.ends_with("crates/alpha/src"));
197        assert!(map.members_by_crate.contains_key("beta"));
198
199        fs::remove_dir_all(&root).unwrap();
200    }
201
202    #[test]
203    fn discovers_single_crate_root_package() {
204        let root = temp_workspace_root("cargo_single");
205        fs::write(
206            root.join("Cargo.toml"),
207            "[package]\nname = \"solo\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
208        )
209        .unwrap();
210        fs::create_dir_all(root.join("src")).unwrap();
211        fs::write(root.join("src/lib.rs"), "").unwrap();
212
213        let map = discover(&root, &RaysenseConfig::default());
214        let solo = map
215            .members_by_crate
216            .get("solo")
217            .expect("root [package] is treated as a workspace member");
218        assert_eq!(solo.manifest_dir, PathBuf::from("."));
219
220        fs::remove_dir_all(&root).unwrap();
221    }
222
223    #[test]
224    fn skips_glob_workspace_members_for_now() {
225        let root = temp_workspace_root("cargo_glob");
226        fs::write(
227            root.join("Cargo.toml"),
228            "[workspace]\nmembers = [\"crates/*\"]\n",
229        )
230        .unwrap();
231        fs::create_dir_all(root.join("crates/alpha/src")).unwrap();
232        fs::write(
233            root.join("crates/alpha/Cargo.toml"),
234            "[package]\nname = \"alpha\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
235        )
236        .unwrap();
237
238        let map = discover(&root, &RaysenseConfig::default());
239        assert!(
240            map.members_by_crate.is_empty(),
241            "glob expansion is intentionally deferred to a follow-up slice"
242        );
243
244        fs::remove_dir_all(&root).unwrap();
245    }
246}