Skip to main content

relay_knowledge/domain/code/
workspace.rs

1use serde::{Deserialize, Serialize};
2
3use super::{DomainError, error::required_text};
4
5/// Recognised monorepo workspace manifest formats.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum CodeMonorepoWorkspaceFormat {
9    /// pnpm workspace: `pnpm-workspace.yaml` or `package.json` with `workspaces` field.
10    Pnpm,
11    /// Go multi-module workspace: `go.work` or multiple `go.mod` files.
12    GoModules,
13    /// Rust workspace: `Cargo.toml` with a `[workspace]` section.
14    CargoWorkspace,
15}
16
17/// A detected monorepo workspace that groups multiple packages under a common root.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct CodeMonorepoWorkspace {
20    /// Format of the workspace manifest.
21    pub format: CodeMonorepoWorkspaceFormat,
22    /// Absolute path to the workspace root directory on the canonical host.
23    pub root_path: String,
24    /// Absolute path to the workspace definition file (e.g. `pnpm-workspace.yaml`, `go.work`).
25    pub workspace_file_path: String,
26    /// Packages discovered inside the workspace.
27    pub members: Vec<CodeWorkspaceMember>,
28}
29
30impl CodeMonorepoWorkspace {
31    /// Validates that the workspace contains at least two member packages
32    /// and that every required text field is non-empty after trimming.
33    pub fn validate(&self) -> Result<(), DomainError> {
34        let _ = required_text("root_path", &self.root_path)?;
35        let _ = required_text("workspace_file_path", &self.workspace_file_path)?;
36
37        if self.members.len() < 2 {
38            return Err(DomainError::invalid(
39                "members",
40                "monorepo workspace must contain at least 2 member packages",
41            ));
42        }
43
44        for member in &self.members {
45            let _ = required_text("member.package_name", &member.package_name)?;
46            let _ = required_text("member.relative_path", &member.relative_path)?;
47        }
48
49        Ok(())
50    }
51}
52
53/// A single package member inside a monorepo workspace.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct CodeWorkspaceMember {
56    /// Canonical package name as declared in the manifest (e.g. `@scope/pkg`, `gosdk`).
57    pub package_name: String,
58    /// Relative path from the workspace root to this package directory.
59    pub relative_path: String,
60}
61
62/// Maps a workspace member's package name to an indexed repository scope.
63///
64/// This is the bridge record that the cross-repo resolver uses to translate
65/// an unresolved import module into a candidate source scope and repository
66/// after workspace detection has grouped the packages.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct CodeWorkspacePackageMapping {
69    /// Package name as discovered from the workspace manifest.
70    pub package_name: String,
71    /// Target ecosystem derived from the workspace format (e.g. `"go"`, `"rust"`, `"npm"`).
72    pub ecosystem: String,
73    /// Repository identifier the indexed scope belongs to.
74    pub repository_id: String,
75    /// Source scope the resolved target lives in (`git_snapshot:<hash>`).
76    pub source_scope: String,
77    /// Confidence in basis points (0–10 000) that this mapping is correct.
78    pub confidence_basis_points: u32,
79}
80
81/// Configuration controlling automated monorepo workspace detection.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct CodeWorkspaceDetectionConfig {
84    /// Whether automatic workspace detection is active.
85    pub enabled: bool,
86    /// Workspace manifest formats that the detector should look for.
87    pub supported_formats: Vec<CodeMonorepoWorkspaceFormat>,
88}
89
90impl CodeWorkspaceDetectionConfig {
91    /// Returns a disabled configuration that still records the supported
92    /// formats to use when callers opt in.
93    pub fn disabled() -> Self {
94        Self {
95            enabled: false,
96            supported_formats: Self::all_supported_formats(),
97        }
98    }
99
100    /// Enables workspace detection for every supported manifest format.
101    pub fn enabled_all() -> Self {
102        Self {
103            enabled: true,
104            supported_formats: Self::all_supported_formats(),
105        }
106    }
107
108    fn all_supported_formats() -> Vec<CodeMonorepoWorkspaceFormat> {
109        vec![
110            CodeMonorepoWorkspaceFormat::Pnpm,
111            CodeMonorepoWorkspaceFormat::GoModules,
112            CodeMonorepoWorkspaceFormat::CargoWorkspace,
113        ]
114    }
115}
116
117impl Default for CodeWorkspaceDetectionConfig {
118    fn default() -> Self {
119        Self::disabled()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    // ── Construction helpers ──────────────────────────────────────────
128
129    fn pnpm_workspace() -> CodeMonorepoWorkspace {
130        CodeMonorepoWorkspace {
131            format: CodeMonorepoWorkspaceFormat::Pnpm,
132            root_path: "/repos/monorepo".to_owned(),
133            workspace_file_path: "/repos/monorepo/pnpm-workspace.yaml".to_owned(),
134            members: vec![
135                CodeWorkspaceMember {
136                    package_name: "@scope/core".to_owned(),
137                    relative_path: "packages/core".to_owned(),
138                },
139                CodeWorkspaceMember {
140                    package_name: "@scope/utils".to_owned(),
141                    relative_path: "packages/utils".to_owned(),
142                },
143            ],
144        }
145    }
146
147    fn go_workspace() -> CodeMonorepoWorkspace {
148        CodeMonorepoWorkspace {
149            format: CodeMonorepoWorkspaceFormat::GoModules,
150            root_path: "/repos/go-svc".to_owned(),
151            workspace_file_path: "/repos/go-svc/go.work".to_owned(),
152            members: vec![
153                CodeWorkspaceMember {
154                    package_name: "example.com/svc/api".to_owned(),
155                    relative_path: "api".to_owned(),
156                },
157                CodeWorkspaceMember {
158                    package_name: "example.com/svc/core".to_owned(),
159                    relative_path: "core".to_owned(),
160                },
161            ],
162        }
163    }
164
165    // ── CodeMonorepoWorkspaceFormat serde round-trip ──────────────────
166
167    #[test]
168    fn workspace_format_serde_round_trip() {
169        let cases = [
170            (CodeMonorepoWorkspaceFormat::Pnpm, "\"pnpm\""),
171            (CodeMonorepoWorkspaceFormat::GoModules, "\"go_modules\""),
172            (
173                CodeMonorepoWorkspaceFormat::CargoWorkspace,
174                "\"cargo_workspace\"",
175            ),
176        ];
177
178        for (format, expected_json) in cases {
179            let json = serde_json::to_string(&format).expect("serialize format");
180            assert_eq!(json, expected_json);
181
182            let round_tripped: CodeMonorepoWorkspaceFormat =
183                serde_json::from_str(&json).expect("deserialize format");
184            assert_eq!(round_tripped, format);
185        }
186    }
187
188    // ── CodeMonorepoWorkspace serde round-trip ────────────────────────
189
190    #[test]
191    fn workspace_serde_round_trip() {
192        let workspace = pnpm_workspace();
193        let json = serde_json::to_string_pretty(&workspace).expect("serialize workspace");
194        let round_tripped: CodeMonorepoWorkspace =
195            serde_json::from_str(&json).expect("deserialize workspace");
196        assert_eq!(round_tripped, workspace);
197    }
198
199    #[test]
200    fn workspace_serde_go_modules() {
201        let workspace = go_workspace();
202        let json = serde_json::to_string(&workspace).expect("serialize go workspace");
203        let round_tripped: CodeMonorepoWorkspace =
204            serde_json::from_str(&json).expect("deserialize go workspace");
205        assert_eq!(round_tripped, workspace);
206    }
207
208    // ── CodeMonorepoWorkspace::validate ───────────────────────────────
209
210    #[test]
211    fn validate_succeeds_for_valid_workspace() {
212        pnpm_workspace()
213            .validate()
214            .expect("two-member workspace should validate");
215        go_workspace()
216            .validate()
217            .expect("two-member go workspace should validate");
218    }
219
220    #[test]
221    fn validate_rejects_empty_members() {
222        let workspace = CodeMonorepoWorkspace {
223            format: CodeMonorepoWorkspaceFormat::CargoWorkspace,
224            root_path: "/repos/ws".to_owned(),
225            workspace_file_path: "/repos/ws/Cargo.toml".to_owned(),
226            members: vec![],
227        };
228
229        let err = workspace
230            .validate()
231            .expect_err("empty members should fail validation");
232        assert!(
233            err.to_string().contains("at least 2"),
234            "expected at-least-2 message, got: {err}"
235        );
236    }
237
238    #[test]
239    fn validate_rejects_single_member() {
240        let workspace = CodeMonorepoWorkspace {
241            format: CodeMonorepoWorkspaceFormat::CargoWorkspace,
242            root_path: "/repos/ws".to_owned(),
243            workspace_file_path: "/repos/ws/Cargo.toml".to_owned(),
244            members: vec![CodeWorkspaceMember {
245                package_name: "my-crate".to_owned(),
246                relative_path: ".".to_owned(),
247            }],
248        };
249
250        let err = workspace
251            .validate()
252            .expect_err("single-member workspace should fail validation");
253        assert!(
254            err.to_string().contains("at least 2"),
255            "expected at-least-2 message, got: {err}"
256        );
257    }
258
259    #[test]
260    fn validate_rejects_empty_root_path() {
261        let mut workspace = pnpm_workspace();
262        workspace.root_path = "  ".to_owned();
263        let err = workspace
264            .validate()
265            .expect_err("blank root_path should fail");
266        assert!(err.to_string().contains("root_path"));
267    }
268
269    #[test]
270    fn validate_rejects_empty_workspace_file_path() {
271        let mut workspace = pnpm_workspace();
272        workspace.workspace_file_path = String::new();
273        let err = workspace
274            .validate()
275            .expect_err("empty workspace_file_path should fail");
276        assert!(err.to_string().contains("workspace_file_path"));
277    }
278
279    #[test]
280    fn validate_rejects_member_with_blank_name() {
281        let mut workspace = pnpm_workspace();
282        workspace.members[0].package_name = "\t".to_owned();
283        let err = workspace
284            .validate()
285            .expect_err("blank member package name should fail");
286        assert!(err.to_string().contains("package_name"));
287    }
288
289    #[test]
290    fn validate_rejects_member_with_blank_path() {
291        let mut workspace = go_workspace();
292        workspace.members[1].relative_path = "\n  ".to_owned();
293        let err = workspace
294            .validate()
295            .expect_err("blank member relative path should fail");
296        assert!(err.to_string().contains("relative_path"));
297    }
298
299    // ── CodeWorkspaceMember serde round-trip ──────────────────────────
300
301    #[test]
302    fn workspace_member_serde_round_trip() {
303        let member = CodeWorkspaceMember {
304            package_name: "@scope/pkg".to_owned(),
305            relative_path: "packages/pkg".to_owned(),
306        };
307        let json = serde_json::to_string(&member).expect("serialize member");
308        let round_tripped: CodeWorkspaceMember =
309            serde_json::from_str(&json).expect("deserialize member");
310        assert_eq!(round_tripped, member);
311    }
312
313    // ── CodeWorkspacePackageMapping construction and serde ────────────
314
315    #[test]
316    fn package_mapping_serde_round_trip() {
317        let mapping = CodeWorkspacePackageMapping {
318            package_name: "@scope/core".to_owned(),
319            ecosystem: "npm".to_owned(),
320            repository_id: "repo-1".to_owned(),
321            source_scope: "git_snapshot:abcdef1234567890".to_owned(),
322            confidence_basis_points: 10_000,
323        };
324        let json = serde_json::to_string_pretty(&mapping).expect("serialize mapping");
325        let round_tripped: CodeWorkspacePackageMapping =
326            serde_json::from_str(&json).expect("deserialize mapping");
327        assert_eq!(round_tripped, mapping);
328    }
329
330    // ── CodeWorkspaceDetectionConfig ──────────────────────────────────
331
332    #[test]
333    fn detection_config_serde_round_trip() {
334        let config = CodeWorkspaceDetectionConfig {
335            enabled: true,
336            supported_formats: vec![
337                CodeMonorepoWorkspaceFormat::Pnpm,
338                CodeMonorepoWorkspaceFormat::CargoWorkspace,
339            ],
340        };
341        let json = serde_json::to_string_pretty(&config).expect("serialize config");
342        let round_tripped: CodeWorkspaceDetectionConfig =
343            serde_json::from_str(&json).expect("deserialize config");
344        assert_eq!(round_tripped, config);
345
346        // Verify JSON contains snake_case format names.
347        assert!(json.contains("\"pnpm\""));
348        assert!(json.contains("\"cargo_workspace\""));
349    }
350
351    #[test]
352    fn detection_config_disabled_default() {
353        let config = CodeWorkspaceDetectionConfig {
354            enabled: false,
355            supported_formats: vec![CodeMonorepoWorkspaceFormat::GoModules],
356        };
357        let json = serde_json::to_string(&config).expect("serialize disabled config");
358        let parsed: CodeWorkspaceDetectionConfig =
359            serde_json::from_str(&json).expect("deserialize disabled config");
360        assert!(!parsed.enabled);
361        assert_eq!(parsed.supported_formats.len(), 1);
362    }
363}