relay_knowledge/domain/code/
workspace.rs1use serde::{Deserialize, Serialize};
2
3use super::{DomainError, error::required_text};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum CodeMonorepoWorkspaceFormat {
9 Pnpm,
11 GoModules,
13 CargoWorkspace,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct CodeMonorepoWorkspace {
20 pub format: CodeMonorepoWorkspaceFormat,
22 pub root_path: String,
24 pub workspace_file_path: String,
26 pub members: Vec<CodeWorkspaceMember>,
28}
29
30impl CodeMonorepoWorkspace {
31 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct CodeWorkspaceMember {
56 pub package_name: String,
58 pub relative_path: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct CodeWorkspacePackageMapping {
69 pub package_name: String,
71 pub ecosystem: String,
73 pub repository_id: String,
75 pub source_scope: String,
77 pub confidence_basis_points: u32,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct CodeWorkspaceDetectionConfig {
84 pub enabled: bool,
86 pub supported_formats: Vec<CodeMonorepoWorkspaceFormat>,
88}
89
90impl CodeWorkspaceDetectionConfig {
91 pub fn disabled() -> Self {
94 Self {
95 enabled: false,
96 supported_formats: Self::all_supported_formats(),
97 }
98 }
99
100 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}