use serde::{Deserialize, Serialize};
use super::{DomainError, error::required_text};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CodeMonorepoWorkspaceFormat {
Pnpm,
GoModules,
CargoWorkspace,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeMonorepoWorkspace {
pub format: CodeMonorepoWorkspaceFormat,
pub root_path: String,
pub workspace_file_path: String,
pub members: Vec<CodeWorkspaceMember>,
}
impl CodeMonorepoWorkspace {
pub fn validate(&self) -> Result<(), DomainError> {
let _ = required_text("root_path", &self.root_path)?;
let _ = required_text("workspace_file_path", &self.workspace_file_path)?;
if self.members.len() < 2 {
return Err(DomainError::invalid(
"members",
"monorepo workspace must contain at least 2 member packages",
));
}
for member in &self.members {
let _ = required_text("member.package_name", &member.package_name)?;
let _ = required_text("member.relative_path", &member.relative_path)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeWorkspaceMember {
pub package_name: String,
pub relative_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeWorkspacePackageMapping {
pub package_name: String,
pub ecosystem: String,
pub repository_id: String,
pub source_scope: String,
pub confidence_basis_points: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeWorkspaceDetectionConfig {
pub enabled: bool,
pub supported_formats: Vec<CodeMonorepoWorkspaceFormat>,
}
impl CodeWorkspaceDetectionConfig {
pub fn disabled() -> Self {
Self {
enabled: false,
supported_formats: Self::all_supported_formats(),
}
}
pub fn enabled_all() -> Self {
Self {
enabled: true,
supported_formats: Self::all_supported_formats(),
}
}
fn all_supported_formats() -> Vec<CodeMonorepoWorkspaceFormat> {
vec![
CodeMonorepoWorkspaceFormat::Pnpm,
CodeMonorepoWorkspaceFormat::GoModules,
CodeMonorepoWorkspaceFormat::CargoWorkspace,
]
}
}
impl Default for CodeWorkspaceDetectionConfig {
fn default() -> Self {
Self::disabled()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pnpm_workspace() -> CodeMonorepoWorkspace {
CodeMonorepoWorkspace {
format: CodeMonorepoWorkspaceFormat::Pnpm,
root_path: "/repos/monorepo".to_owned(),
workspace_file_path: "/repos/monorepo/pnpm-workspace.yaml".to_owned(),
members: vec![
CodeWorkspaceMember {
package_name: "@scope/core".to_owned(),
relative_path: "packages/core".to_owned(),
},
CodeWorkspaceMember {
package_name: "@scope/utils".to_owned(),
relative_path: "packages/utils".to_owned(),
},
],
}
}
fn go_workspace() -> CodeMonorepoWorkspace {
CodeMonorepoWorkspace {
format: CodeMonorepoWorkspaceFormat::GoModules,
root_path: "/repos/go-svc".to_owned(),
workspace_file_path: "/repos/go-svc/go.work".to_owned(),
members: vec![
CodeWorkspaceMember {
package_name: "example.com/svc/api".to_owned(),
relative_path: "api".to_owned(),
},
CodeWorkspaceMember {
package_name: "example.com/svc/core".to_owned(),
relative_path: "core".to_owned(),
},
],
}
}
#[test]
fn workspace_format_serde_round_trip() {
let cases = [
(CodeMonorepoWorkspaceFormat::Pnpm, "\"pnpm\""),
(CodeMonorepoWorkspaceFormat::GoModules, "\"go_modules\""),
(
CodeMonorepoWorkspaceFormat::CargoWorkspace,
"\"cargo_workspace\"",
),
];
for (format, expected_json) in cases {
let json = serde_json::to_string(&format).expect("serialize format");
assert_eq!(json, expected_json);
let round_tripped: CodeMonorepoWorkspaceFormat =
serde_json::from_str(&json).expect("deserialize format");
assert_eq!(round_tripped, format);
}
}
#[test]
fn workspace_serde_round_trip() {
let workspace = pnpm_workspace();
let json = serde_json::to_string_pretty(&workspace).expect("serialize workspace");
let round_tripped: CodeMonorepoWorkspace =
serde_json::from_str(&json).expect("deserialize workspace");
assert_eq!(round_tripped, workspace);
}
#[test]
fn workspace_serde_go_modules() {
let workspace = go_workspace();
let json = serde_json::to_string(&workspace).expect("serialize go workspace");
let round_tripped: CodeMonorepoWorkspace =
serde_json::from_str(&json).expect("deserialize go workspace");
assert_eq!(round_tripped, workspace);
}
#[test]
fn validate_succeeds_for_valid_workspace() {
pnpm_workspace()
.validate()
.expect("two-member workspace should validate");
go_workspace()
.validate()
.expect("two-member go workspace should validate");
}
#[test]
fn validate_rejects_empty_members() {
let workspace = CodeMonorepoWorkspace {
format: CodeMonorepoWorkspaceFormat::CargoWorkspace,
root_path: "/repos/ws".to_owned(),
workspace_file_path: "/repos/ws/Cargo.toml".to_owned(),
members: vec![],
};
let err = workspace
.validate()
.expect_err("empty members should fail validation");
assert!(
err.to_string().contains("at least 2"),
"expected at-least-2 message, got: {err}"
);
}
#[test]
fn validate_rejects_single_member() {
let workspace = CodeMonorepoWorkspace {
format: CodeMonorepoWorkspaceFormat::CargoWorkspace,
root_path: "/repos/ws".to_owned(),
workspace_file_path: "/repos/ws/Cargo.toml".to_owned(),
members: vec![CodeWorkspaceMember {
package_name: "my-crate".to_owned(),
relative_path: ".".to_owned(),
}],
};
let err = workspace
.validate()
.expect_err("single-member workspace should fail validation");
assert!(
err.to_string().contains("at least 2"),
"expected at-least-2 message, got: {err}"
);
}
#[test]
fn validate_rejects_empty_root_path() {
let mut workspace = pnpm_workspace();
workspace.root_path = " ".to_owned();
let err = workspace
.validate()
.expect_err("blank root_path should fail");
assert!(err.to_string().contains("root_path"));
}
#[test]
fn validate_rejects_empty_workspace_file_path() {
let mut workspace = pnpm_workspace();
workspace.workspace_file_path = String::new();
let err = workspace
.validate()
.expect_err("empty workspace_file_path should fail");
assert!(err.to_string().contains("workspace_file_path"));
}
#[test]
fn validate_rejects_member_with_blank_name() {
let mut workspace = pnpm_workspace();
workspace.members[0].package_name = "\t".to_owned();
let err = workspace
.validate()
.expect_err("blank member package name should fail");
assert!(err.to_string().contains("package_name"));
}
#[test]
fn validate_rejects_member_with_blank_path() {
let mut workspace = go_workspace();
workspace.members[1].relative_path = "\n ".to_owned();
let err = workspace
.validate()
.expect_err("blank member relative path should fail");
assert!(err.to_string().contains("relative_path"));
}
#[test]
fn workspace_member_serde_round_trip() {
let member = CodeWorkspaceMember {
package_name: "@scope/pkg".to_owned(),
relative_path: "packages/pkg".to_owned(),
};
let json = serde_json::to_string(&member).expect("serialize member");
let round_tripped: CodeWorkspaceMember =
serde_json::from_str(&json).expect("deserialize member");
assert_eq!(round_tripped, member);
}
#[test]
fn package_mapping_serde_round_trip() {
let mapping = CodeWorkspacePackageMapping {
package_name: "@scope/core".to_owned(),
ecosystem: "npm".to_owned(),
repository_id: "repo-1".to_owned(),
source_scope: "git_snapshot:abcdef1234567890".to_owned(),
confidence_basis_points: 10_000,
};
let json = serde_json::to_string_pretty(&mapping).expect("serialize mapping");
let round_tripped: CodeWorkspacePackageMapping =
serde_json::from_str(&json).expect("deserialize mapping");
assert_eq!(round_tripped, mapping);
}
#[test]
fn detection_config_serde_round_trip() {
let config = CodeWorkspaceDetectionConfig {
enabled: true,
supported_formats: vec![
CodeMonorepoWorkspaceFormat::Pnpm,
CodeMonorepoWorkspaceFormat::CargoWorkspace,
],
};
let json = serde_json::to_string_pretty(&config).expect("serialize config");
let round_tripped: CodeWorkspaceDetectionConfig =
serde_json::from_str(&json).expect("deserialize config");
assert_eq!(round_tripped, config);
assert!(json.contains("\"pnpm\""));
assert!(json.contains("\"cargo_workspace\""));
}
#[test]
fn detection_config_disabled_default() {
let config = CodeWorkspaceDetectionConfig {
enabled: false,
supported_formats: vec![CodeMonorepoWorkspaceFormat::GoModules],
};
let json = serde_json::to_string(&config).expect("serialize disabled config");
let parsed: CodeWorkspaceDetectionConfig =
serde_json::from_str(&json).expect("deserialize disabled config");
assert!(!parsed.enabled);
assert_eq!(parsed.supported_formats.len(), 1);
}
}