use async_trait::async_trait;
use cargo_metadata::{Metadata, MetadataCommand};
use command_group::CommandGroup;
use governor_application::error::{ApplicationError, ApplicationResult};
use governor_application::ports::{
ChangelogUpdate, CommandPort, CommandReport, OwnerPackage, OwnersWorkspace, ReleasePackage,
ReleaseWorkspace, VersionUpdate, WorkspacePort,
};
use governor_core::domain::changelog::Changelog;
use governor_core::domain::version::SemanticVersion;
use governor_owners::{PackageOwnersConfig, WorkspaceOwnersConfig};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Deserialize)]
struct GovernorMetadata {
owners: Option<OwnersMetadata>,
}
#[derive(Debug, Deserialize)]
struct OwnersMetadata {
#[serde(default)]
users: Vec<String>,
#[serde(default)]
groups: HashMap<String, Vec<String>>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CargoWorkspaceAdapter;
impl CargoWorkspaceAdapter {
fn metadata(workspace_path: &Path) -> ApplicationResult<Metadata> {
let mut command = MetadataCommand::new();
command.current_dir(workspace_path);
command.no_deps();
command.exec().map_err(|error| {
ApplicationError::Workspace(format!("failed to load cargo metadata: {error}"))
})
}
fn cargo_toml_path(workspace_path: &Path) -> PathBuf {
workspace_path.join("Cargo.toml")
}
fn changelog_path(workspace_path: &Path) -> PathBuf {
workspace_path.join("CHANGELOG.md")
}
fn workspace_name(metadata: &Metadata) -> String {
metadata
.workspace_root
.file_name()
.map_or_else(|| "workspace".to_string(), std::string::ToString::to_string)
}
fn parse_workspace_version(value: &toml::Value) -> ApplicationResult<String> {
value
.get("workspace")
.and_then(|workspace| workspace.get("package"))
.and_then(|package| package.get("version"))
.or_else(|| {
value
.get("package")
.and_then(|package| package.get("version"))
})
.and_then(toml::Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| {
ApplicationError::Workspace(
"no shared workspace version found in Cargo.toml".to_string(),
)
})
}
fn read_workspace_version(workspace_path: &Path) -> ApplicationResult<SemanticVersion> {
let content = std::fs::read_to_string(Self::cargo_toml_path(workspace_path))?;
let value: toml::Value = toml::from_str(&content).map_err(|error| {
ApplicationError::Workspace(format!("failed to parse Cargo.toml: {error}"))
})?;
let version = Self::parse_workspace_version(&value)?;
SemanticVersion::parse(&version).map_err(|error| {
ApplicationError::Workspace(format!("failed to parse workspace version: {error}"))
})
}
fn parse_owners_from_manifest(
manifest_path: &Path,
) -> ApplicationResult<Option<PackageOwnersConfig>> {
let content = std::fs::read_to_string(manifest_path)?;
let value: toml::Value = toml::from_str(&content).map_err(|error| {
ApplicationError::Workspace(format!(
"failed to parse {}: {error}",
manifest_path.display()
))
})?;
let governor = value
.get("package")
.and_then(|package| package.get("metadata"))
.and_then(|metadata| metadata.get("governor"));
let metadata: Option<GovernorMetadata> = governor
.map(|governor| {
governor.clone().try_into().map_err(|error| {
ApplicationError::Workspace(format!(
"failed to parse governor metadata in {}: {error}",
manifest_path.display()
))
})
})
.transpose()?;
Ok(metadata.and_then(|meta| {
meta.owners.map(|owners| PackageOwnersConfig {
users: owners.users,
groups: owners.groups.into_keys().collect(),
..Default::default()
})
}))
}
fn parse_workspace_owners(metadata: &Metadata) -> Option<WorkspaceOwnersConfig> {
metadata
.workspace_metadata
.as_object()
.and_then(|workspace_metadata| {
workspace_metadata
.get("governor")
.and_then(|governor| governor.get("owners"))
.and_then(|owners| {
serde_json::from_value::<OwnersMetadata>(owners.clone()).ok()
})
.map(|owners| WorkspaceOwnersConfig {
users: owners.users,
groups: owners.groups,
})
})
}
fn insert_changelog_section(
existing: &str,
version: &SemanticVersion,
rendered: &str,
) -> String {
let date = chrono::Utc::now().date_naive();
let version_marker = format!("## [{version}]");
let version_header = format!("## [{version}] - {date}");
if existing.contains(&version_marker) {
return existing.to_string();
}
existing.find("## [Unreleased]").map_or_else(
|| {
let mut updated = String::new();
updated.push_str("# Changelog\n\n## [Unreleased]\n\n");
updated.push_str(&version_header);
updated.push_str(rendered);
updated.push('\n');
updated.push_str(existing);
updated
},
|unreleased_pos| {
let after_header = existing[unreleased_pos..]
.find('\n')
.map_or(existing.len(), |offset| unreleased_pos + offset + 1);
let mut updated = String::new();
updated.push_str(&existing[..after_header]);
updated.push('\n');
updated.push_str(&version_header);
updated.push_str(rendered);
updated.push('\n');
updated.push_str(&existing[after_header..]);
updated
},
)
}
fn check_args(check: &str) -> ApplicationResult<Vec<&'static str>> {
match check {
"test" => Ok(vec!["cargo", "test", "--all-features"]),
"clippy" => Ok(vec![
"cargo",
"clippy",
"--all-targets",
"--all-features",
"--",
"-D",
"warnings",
]),
"fmt" => Ok(vec!["cargo", "fmt", "--check"]),
"doc" => Ok(vec!["cargo", "doc", "--no-deps", "--all-features"]),
"build" => Ok(vec!["cargo", "build", "--all-features"]),
other => Err(ApplicationError::InvalidArguments(format!(
"unknown check `{other}`"
))),
}
}
fn update_workspace_dependency_versions(value: &mut toml::Value, version: &SemanticVersion) {
let Some(dependencies) = value
.get_mut("workspace")
.and_then(toml::Value::as_table_mut)
.and_then(|workspace| workspace.get_mut("dependencies"))
.and_then(toml::Value::as_table_mut)
else {
return;
};
for (_, dependency) in dependencies.iter_mut() {
let Some(table) = dependency.as_table_mut() else {
continue;
};
let has_local_path = table
.get("path")
.and_then(toml::Value::as_str)
.is_some_and(|path| path.starts_with("crates/"));
if has_local_path {
table.insert(
"version".to_string(),
toml::Value::String(version.to_string()),
);
}
}
}
fn sync_local_workspace_dependency_versions_in_text(
rendered: &str,
version: &SemanticVersion,
) -> String {
let mut output_lines = Vec::new();
let mut section_lines = Vec::new();
let mut dependency_section = false;
let mut local_dependency_section = false;
let flush_section = |output_lines: &mut Vec<String>,
section_lines: &mut Vec<String>,
dependency_section: bool,
local_dependency_section: bool| {
if dependency_section && local_dependency_section {
for line in section_lines.iter_mut() {
if line.trim().starts_with("version = ") {
*line = format!("version = \"{version}\"");
}
}
}
output_lines.append(section_lines);
};
for line in rendered.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if !section_lines.is_empty() {
flush_section(
&mut output_lines,
&mut section_lines,
dependency_section,
local_dependency_section,
);
}
dependency_section = trimmed.starts_with("[workspace.dependencies.")
&& trimmed != "[workspace.dependencies]";
local_dependency_section = false;
section_lines.push(line.to_string());
continue;
}
if dependency_section && trimmed.starts_with("path = \"crates/") {
local_dependency_section = true;
}
section_lines.push(line.to_string());
}
if !section_lines.is_empty() {
flush_section(
&mut output_lines,
&mut section_lines,
dependency_section,
local_dependency_section,
);
}
let mut output = output_lines.join("\n");
output.push('\n');
output
}
}
#[async_trait]
impl WorkspacePort for CargoWorkspaceAdapter {
fn name(&self) -> &'static str {
"cargo_workspace"
}
async fn load_release_workspace(
&self,
workspace_path: &Path,
) -> ApplicationResult<ReleaseWorkspace> {
let metadata = Self::metadata(workspace_path)?;
let current_version = Self::read_workspace_version(workspace_path)?;
let workspace_root = metadata.workspace_root.clone().into_std_path_buf();
let workspace_members = metadata
.workspace_members
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
let packages = metadata
.packages
.iter()
.filter(|package| workspace_members.contains(&package.id.to_string()))
.map(|package| {
let version =
SemanticVersion::parse(&package.version.to_string()).map_err(|error| {
ApplicationError::Workspace(format!(
"failed to parse package version {}: {error}",
package.version
))
})?;
Ok(ReleasePackage {
name: package.name.clone(),
version,
manifest_path: package.manifest_path.as_std_path().to_path_buf(),
dependencies: package
.dependencies
.iter()
.filter(|dependency| {
dependency
.path
.as_ref()
.is_some_and(|path| path.starts_with(&metadata.workspace_root))
})
.map(|dependency| dependency.name.clone())
.collect(),
publish: !package.publish.as_ref().is_some_and(Vec::is_empty),
})
})
.collect::<ApplicationResult<Vec<_>>>()?;
Ok(ReleaseWorkspace {
root: workspace_root,
name: Self::workspace_name(&metadata),
current_version,
packages,
})
}
async fn load_owners_workspace(
&self,
workspace_path: &Path,
) -> ApplicationResult<OwnersWorkspace> {
let metadata = Self::metadata(workspace_path)?;
let workspace_members = metadata
.workspace_members
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
let packages = metadata
.packages
.iter()
.filter(|package| workspace_members.contains(&package.id.to_string()))
.map(|package| {
Ok(OwnerPackage {
name: package.name.clone(),
owners: Self::parse_owners_from_manifest(package.manifest_path.as_std_path())?,
})
})
.collect::<ApplicationResult<Vec<_>>>()?;
Ok(OwnersWorkspace {
root: metadata.workspace_root.clone().into_std_path_buf(),
workspace: Self::parse_workspace_owners(&metadata),
packages,
})
}
async fn update_workspace_version(
&self,
workspace_path: &Path,
version: &SemanticVersion,
dry_run: bool,
) -> ApplicationResult<VersionUpdate> {
let cargo_toml_path = Self::cargo_toml_path(workspace_path);
let content = std::fs::read_to_string(&cargo_toml_path)?;
let mut value: toml::Value = toml::from_str(&content).map_err(|error| {
ApplicationError::Workspace(format!("failed to parse Cargo.toml: {error}"))
})?;
let version_value = toml::Value::String(version.to_string());
if let Some(workspace) = value
.get_mut("workspace")
.and_then(toml::Value::as_table_mut)
{
if let Some(package) = workspace
.get_mut("package")
.and_then(toml::Value::as_table_mut)
{
package.insert("version".to_string(), version_value);
}
} else if let Some(package) = value.get_mut("package").and_then(toml::Value::as_table_mut) {
package.insert(
"version".to_string(),
toml::Value::String(version.to_string()),
);
}
Self::update_workspace_dependency_versions(&mut value, version);
let mut modified_files = vec!["Cargo.toml".to_string()];
let cargo_lock_path = workspace_path.join("Cargo.lock");
if cargo_lock_path.exists() {
modified_files.push("Cargo.lock".to_string());
}
if !dry_run {
let rendered = toml::to_string_pretty(&value).map_err(|error| {
ApplicationError::Workspace(format!("failed to render Cargo.toml: {error}"))
})?;
let rendered =
Self::sync_local_workspace_dependency_versions_in_text(&rendered, version);
std::fs::write(&cargo_toml_path, rendered)?;
if cargo_lock_path.exists() {
let output = Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(workspace_path)
.output()?;
if !output.status.success() {
return Err(ApplicationError::Workspace(format!(
"failed to update Cargo.lock: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
}
}
Ok(VersionUpdate { modified_files })
}
async fn update_changelog(
&self,
workspace_path: &Path,
changelog: &Changelog,
dry_run: bool,
) -> ApplicationResult<ChangelogUpdate> {
let path = Self::changelog_path(workspace_path);
let existing = std::fs::read_to_string(&path)
.unwrap_or_else(|_| "# Changelog\n\n## [Unreleased]\n".to_string());
let rendered = changelog.format_keep_a_changelog();
let updated = Self::insert_changelog_section(&existing, &changelog.version, &rendered);
let changed = updated != existing;
if changed && !dry_run {
std::fs::write(&path, updated)?;
}
Ok(ChangelogUpdate {
updated: changed,
modified_files: if changed {
vec!["CHANGELOG.md".to_string()]
} else {
Vec::new()
},
})
}
}
#[async_trait]
impl CommandPort for CargoWorkspaceAdapter {
fn name(&self) -> &'static str {
"cargo_command_runner"
}
async fn run_check(
&self,
workspace_path: &Path,
check: &str,
capture_output: bool,
) -> ApplicationResult<CommandReport> {
let args = Self::check_args(check)?;
let mut command = Command::new(args[0]);
command.args(&args[1..]).current_dir(workspace_path);
let output = if capture_output {
command.output()?
} else {
command
.stdout(Stdio::null())
.stderr(Stdio::null())
.group_spawn()
.map_err(|error| {
ApplicationError::Workspace(format!("failed to spawn {check}: {error}"))
})?
.wait_with_output()
.map_err(|error| {
ApplicationError::Workspace(format!("failed to wait for {check}: {error}"))
})?
};
Ok(CommandReport {
name: check.to_string(),
success: output.status.success(),
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::CargoWorkspaceAdapter;
use governor_core::domain::version::SemanticVersion;
#[test]
fn test_insert_changelog_section_does_not_duplicate_existing_version() {
let version = SemanticVersion::parse("5.0.0").expect("semantic version");
let existing = "# Changelog\n\n## [Unreleased]\n\n## [5.0.0] - 2026-04-12\n\n### Added\n- Existing entry\n";
let rendered = "\n### Added\n- Generated entry\n";
let updated = CargoWorkspaceAdapter::insert_changelog_section(existing, &version, rendered);
assert_eq!(
updated.matches("## [5.0.0]").count(),
1,
"existing changelog version sections must not be duplicated"
);
assert!(updated.contains("Existing entry"));
assert!(!updated.contains("Generated entry"));
}
}