#![deny(clippy::all)]
#![forbid(clippy::indexing_slicing)]
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use monochange_core::AdapterDiscovery;
use monochange_core::DependencyKind;
use monochange_core::Ecosystem;
use monochange_core::EcosystemAdapter;
use monochange_core::LockfileCommandExecution;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::PackageDependency;
use monochange_core::PackageRecord;
use monochange_core::PublishState;
use monochange_core::ShellConfig;
use monochange_core::normalize_path;
use monochange_publish::PublishRequest;
use monochange_publish::go_module_path;
use semver::Version;
use walkdir::DirEntry;
use walkdir::WalkDir;
pub const GO_MOD_FILE: &str = "go.mod";
pub const GO_SUM_FILE: &str = "go.sum";
pub struct GoAdapter;
#[must_use]
pub const fn adapter() -> GoAdapter {
GoAdapter
}
impl EcosystemAdapter for GoAdapter {
fn ecosystem(&self) -> Ecosystem {
Ecosystem::Go
}
fn discover(&self, root: &Path) -> MonochangeResult<AdapterDiscovery> {
discover_go_modules(root)
}
fn load_configured(
&self,
_root: &Path,
_package_path: &Path,
) -> MonochangeResult<Option<PackageRecord>> {
Ok(None)
}
fn supported_versioned_file_kind(&self, path: &Path) -> bool {
supported_versioned_file_kind(path).is_some()
}
fn validate_versioned_file(
&self,
full_path: &Path,
display_path: &str,
custom_fields: Option<&[String]>,
) -> MonochangeResult<()> {
validate_versioned_file(full_path, display_path, custom_fields)
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GoVersionedFileKind {
GoMod,
GoSum,
}
#[must_use]
pub fn supported_versioned_file_kind(path: &Path) -> Option<GoVersionedFileKind> {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
match file_name {
GO_MOD_FILE => Some(GoVersionedFileKind::GoMod),
GO_SUM_FILE => Some(GoVersionedFileKind::GoSum),
_ => None,
}
}
pub fn discover_lockfiles(package: &PackageRecord) -> Vec<PathBuf> {
let manifest_dir = package
.manifest_path
.parent()
.map_or_else(|| package.workspace_root.clone(), Path::to_path_buf);
[manifest_dir.join(GO_SUM_FILE)]
.into_iter()
.filter(|path| path.exists())
.collect()
}
pub fn default_lockfile_commands(package: &PackageRecord) -> Vec<LockfileCommandExecution> {
let manifest_dir = package
.manifest_path
.parent()
.unwrap_or(&package.workspace_root)
.to_path_buf();
vec![LockfileCommandExecution {
command: "go mod tidy".to_string(),
cwd: manifest_dir,
shell: ShellConfig::None,
}]
}
pub fn write_go_placeholder_manifest(dir: &Path, request: &PublishRequest) -> MonochangeResult<()> {
let contents = format!(
"module {}
go 1.22
",
go_module_path(request)
);
fs::write(dir.join("go.mod"), contents)
.map_err(|error| MonochangeError::Io(format!("failed to write go.mod: {error}")))
}
pub fn update_go_mod_text(
contents: &str,
versioned_deps: &std::collections::BTreeMap<String, String>,
) -> String {
if versioned_deps.is_empty() {
return contents.to_string();
}
let mut result = String::with_capacity(contents.len());
for line in contents.lines() {
let updated = update_require_line(line, versioned_deps);
result.push_str(&updated);
result.push('\n');
}
if !contents.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
fn update_require_line(
line: &str,
versioned_deps: &std::collections::BTreeMap<String, String>,
) -> String {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with("//")
|| trimmed.starts_with("module ")
|| trimmed.starts_with("go ")
|| trimmed.starts_with("replace ")
|| trimmed.starts_with("exclude ")
|| trimmed.starts_with("retract ")
|| trimmed == "require ("
|| trimmed == ")"
|| trimmed == "require"
{
return line.to_string();
}
let parts: Vec<&str> = if let Some(rest) = trimmed.strip_prefix("require ") {
rest.split_whitespace().collect()
} else {
trimmed.split_whitespace().collect()
};
if parts.len() < 2 {
return line.to_string();
}
let module_path = parts.first().copied().unwrap_or_default();
let module_name = module_path.rsplit('/').next().unwrap_or(module_path);
let clean_name = module_name
.strip_prefix('v')
.and_then(|rest| {
rest.chars()
.all(|ch| ch.is_ascii_digit())
.then_some(module_name)
})
.map_or(module_name, |_| {
module_path.rsplit('/').nth(1).unwrap_or(module_path)
});
if let Some(new_version) = versioned_deps.get(clean_name) {
let go_version = if new_version.starts_with('v') {
new_version.clone()
} else {
format!("v{new_version}")
};
let prefix = &line[..line.len() - line.trim_start().len()];
let comment = if let Some(comment_start) = trimmed.find("//") {
let after_version = &trimmed[comment_start..];
format!(" {after_version}")
} else {
String::new()
};
if trimmed.starts_with("require ") {
format!("{prefix}require {module_path} {go_version}{comment}")
} else {
format!("{prefix}{module_path} {go_version}{comment}")
}
} else {
line.to_string()
}
}
#[tracing::instrument(skip_all)]
pub fn discover_go_modules(root: &Path) -> MonochangeResult<AdapterDiscovery> {
let mut packages = Vec::new();
let mut warnings = Vec::new();
for go_mod_path in find_all_go_mod_files(root) {
match parse_go_module(&go_mod_path, root) {
Ok(Some(package)) => packages.push(package),
Ok(None) => {}
Err(error) => {
warnings.push(format!("skipped {}: {error}", go_mod_path.display()));
}
}
}
packages.sort_by(|left, right| left.id.cmp(&right.id));
packages.dedup_by(|left, right| left.id == right.id);
tracing::debug!(packages = packages.len(), "discovered go modules");
Ok(AdapterDiscovery { packages, warnings })
}
fn parse_go_module(go_mod_path: &Path, root: &Path) -> MonochangeResult<Option<PackageRecord>> {
let contents = fs::read_to_string(go_mod_path).map_err(|error| {
MonochangeError::Io(format!("failed to read {}: {error}", go_mod_path.display()))
})?;
let module_path = parse_module_path(&contents);
let Some(module_path) = module_path else {
return Ok(None);
};
let name = derive_module_name(&module_path);
let manifest_dir = go_mod_path.parent().unwrap_or_else(|| Path::new("."));
let mut record = PackageRecord::new(
Ecosystem::Go,
&name,
normalize_path(go_mod_path),
normalize_path(root),
None, PublishState::Public,
);
record
.metadata
.insert("module_path".to_string(), module_path.clone());
let normalized_dir = normalize_path(manifest_dir);
let normalized_root = normalize_path(root);
let relative_path = normalized_dir
.strip_prefix(&normalized_root)
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string();
if !relative_path.is_empty() && relative_path != "." {
record
.metadata
.insert("relative_path".to_string(), relative_path);
}
record.declared_dependencies = parse_require_directives(&contents);
Ok(Some(record))
}
fn parse_module_path(contents: &str) -> Option<String> {
for line in contents.lines() {
let trimmed = line.trim();
if let Some(path) = trimmed.strip_prefix("module ") {
return Some(path.trim().to_string());
}
}
None
}
fn derive_module_name(module_path: &str) -> String {
let segments: Vec<&str> = module_path.split('/').collect();
for segment in segments.iter().rev() {
if !is_major_version_suffix(segment) {
return (*segment).to_string();
}
}
module_path.to_string()
}
fn is_major_version_suffix(segment: &str) -> bool {
segment.strip_prefix('v').is_some_and(|rest| {
!rest.is_empty()
&& rest.chars().all(|ch| ch.is_ascii_digit())
&& rest.parse::<u64>().is_ok_and(|n| n >= 2)
})
}
fn parse_require_directives(contents: &str) -> Vec<PackageDependency> {
let mut deps = Vec::new();
let mut in_require_block = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed == "require (" {
in_require_block = true;
continue;
}
if trimmed == ")" {
in_require_block = false;
continue;
}
if let Some(rest) = trimmed.strip_prefix("require ") {
if !rest.starts_with('(')
&& let Some(dep) = parse_require_entry(rest)
{
deps.push(dep);
}
continue;
}
if in_require_block && let Some(dep) = parse_require_entry(trimmed) {
deps.push(dep);
}
}
deps
}
fn parse_require_entry(entry: &str) -> Option<PackageDependency> {
let parts: Vec<&str> = entry.split_whitespace().collect();
if parts.len() < 2 {
return None;
}
let module_path = *parts.first()?;
let version_str = *parts.get(1)?;
let is_indirect = parts.contains(&"indirect");
let name = derive_module_name(module_path);
let constraint = version_str
.strip_prefix('v')
.map(ToString::to_string)
.or_else(|| Some(version_str.to_string()));
let kind = if is_indirect {
DependencyKind::Development
} else {
DependencyKind::Runtime
};
Some(PackageDependency {
name,
kind,
version_constraint: constraint,
optional: false,
source_field: Some("require".to_string()),
})
}
pub fn parse_go_version(version_str: &str) -> Option<Version> {
let stripped = version_str.strip_prefix('v').unwrap_or(version_str);
Version::parse(stripped).ok()
}
fn find_all_go_mod_files(root: &Path) -> Vec<PathBuf> {
WalkDir::new(root)
.into_iter()
.filter_entry(should_descend)
.filter_map(Result::ok)
.filter(|entry| entry.file_name() == GO_MOD_FILE)
.map(DirEntry::into_path)
.map(|path| normalize_path(&path))
.collect()
}
fn should_descend(entry: &DirEntry) -> bool {
let file_name = entry.file_name().to_string_lossy();
!matches!(
file_name.as_ref(),
".git" | "vendor" | "node_modules" | "target" | ".devenv" | "book" | "testdata"
)
}
pub fn validate_versioned_file(
_full_path: &Path,
_display_path: &str,
_custom_fields: Option<&[String]>,
) -> MonochangeResult<()> {
Ok(())
}
#[must_use]
pub fn default_dependency_version_prefix() -> &'static str {
""
}
#[must_use]
pub fn default_dependency_fields() -> &'static [&'static str] {
&["require"]
}
#[cfg(test)]
#[path = "__tests__/lib_tests.rs"]
mod tests;