mod cargo;
pub mod matching;
mod npm;
pub use cargo::CargoAdapter;
pub use npm::NpmAdapter;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use semver::Version;
use crate::filesystem::Filesystem;
use crate::model::config::Config;
use crate::path::AbsolutePath;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectInfo {
pub name: String,
pub path: AbsolutePath,
pub version: Version,
pub publishable: bool,
pub dependency_names: Vec<String>,
pub publishconfig_provenance: Option<bool>,
pub workspace_version: bool,
}
#[cfg(test)]
impl ProjectInfo {
pub fn for_test(name: &str, path: AbsolutePath) -> Self {
use semver::{BuildMetadata, Prerelease};
Self {
name: name.to_string(),
path,
version: Version {
major: 0,
minor: 0,
patch: 0,
pre: Prerelease::new("development").unwrap(),
build: BuildMetadata::EMPTY,
},
publishable: true,
dependency_names: Vec::new(),
publishconfig_provenance: None,
workspace_version: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PublishOutcome {
Published,
AlreadyPublished,
}
pub struct Project {
info: ProjectInfo,
adapter: Arc<dyn PackageManagerAdapter>,
}
impl std::fmt::Debug for Project {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Project")
.field("name", &self.info.name)
.field("path", &self.info.path)
.finish_non_exhaustive()
}
}
impl Clone for Project {
fn clone(&self) -> Self {
Self {
info: self.info.clone(),
adapter: Arc::clone(&self.adapter),
}
}
}
impl PartialEq for Project {
fn eq(&self, other: &Self) -> bool {
self.info == other.info
}
}
impl Eq for Project {}
impl Project {
pub fn name(&self) -> &str {
&self.info.name
}
pub fn path(&self) -> &AbsolutePath {
&self.info.path
}
pub fn version(&self) -> &Version {
&self.info.version
}
pub async fn write_version(
&self,
version: &Version,
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>> {
self.adapter
.write_version(&self.info, version, dry_run)
.await
}
pub async fn publish(&self) -> anyhow::Result<PublishOutcome> {
self.adapter.publish(&self.info).await
}
pub async fn registry_name(&self) -> &str {
self.adapter.registry_name().await
}
pub fn is_publishable(&self) -> bool {
self.info.publishable
}
pub fn is_releasable_under(&self, config: &Config) -> bool {
self.is_publishable()
|| config
.git
.publish_private_packages()
.iter()
.any(|p| p == self.name())
}
pub async fn is_prepared_for_release(&self, fs: &dyn Filesystem) -> anyhow::Result<bool> {
fs.exists(&self.path().child("CHANGELOG.md")).await
}
pub fn dependency_names(&self) -> &[String] {
&self.info.dependency_names
}
pub fn workspace_version(&self) -> bool {
self.info.workspace_version
}
pub async fn update_dependency_version(
&self,
dependency_name: &str,
new_version: &Version,
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>> {
self.adapter
.update_dependency_version(&self.info, dependency_name, new_version, dry_run)
.await
}
pub async fn manifest_path(&self) -> std::path::PathBuf {
self.info.path.join(self.adapter.manifest_filename().await)
}
#[cfg(test)]
pub fn new_test(name: &str, path: &str) -> Self {
use crate::command::test_support::RecordingCommandRunner;
Self::new_test_with_runner(name, path, Arc::new(RecordingCommandRunner::new(0)))
}
#[cfg(test)]
pub fn with_workspace_version(mut self, workspace_version: bool) -> Self {
self.info.workspace_version = workspace_version;
self
}
#[cfg(test)]
pub fn new_test_with_deps(name: &str, version: &str, deps: Vec<&str>) -> Self {
let mut p = Self::new_test_with_version(name, version.parse().unwrap());
p.info.dependency_names = deps.into_iter().map(str::to_string).collect();
p
}
#[cfg(test)]
pub fn new_test_with_version(name: &str, version: semver::Version) -> Self {
use crate::command::test_support::RecordingCommandRunner;
use crate::model::config::NpmConfig;
let runner =
Arc::new(RecordingCommandRunner::new(0)) as Arc<dyn crate::command::CommandRunner>;
let env = crate::Env::new(
Arc::clone(&runner),
Arc::new(crate::filesystem::LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
runner,
crate::path::AbsolutePath::new("/tmp").unwrap(),
)),
);
let path = format!("/nonexistent/packages/{name}");
Self {
info: ProjectInfo {
name: name.to_string(),
path: AbsolutePath::new(&path).unwrap(),
version,
publishable: true,
dependency_names: Vec::new(),
publishconfig_provenance: None,
workspace_version: false,
},
adapter: Arc::new(NpmAdapter::new(
NpmConfig::default(),
AbsolutePath::new("/nonexistent").unwrap(),
env,
)),
}
}
#[cfg(test)]
pub fn new_test_with_runner(
name: &str,
path: &str,
runner: Arc<crate::command::test_support::RecordingCommandRunner>,
) -> Self {
use crate::command::CommandRunner;
use crate::model::config::NpmConfig;
let runner = runner as Arc<dyn CommandRunner>;
let env = crate::Env::new(
Arc::clone(&runner),
Arc::new(crate::filesystem::LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
runner,
crate::path::AbsolutePath::new("/tmp").unwrap(),
)),
);
Self {
info: ProjectInfo::for_test(name, AbsolutePath::new(path).unwrap()),
adapter: Arc::new(NpmAdapter::new(
NpmConfig::default(),
AbsolutePath::new("/nonexistent").unwrap(),
env,
)),
}
}
#[cfg(test)]
pub fn new_test_not_publishable(name: &str, path: &str) -> Self {
let mut p = Self::new_test(name, path);
p.info.publishable = false;
p
}
}
#[async_trait]
pub trait PackageManagerAdapter: Send + Sync + std::fmt::Debug {
async fn enumerate_projects(&self) -> anyhow::Result<Vec<ProjectInfo>>;
async fn write_version(
&self,
project: &ProjectInfo,
version: &Version,
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>>;
async fn update_lock_file(&self) -> anyhow::Result<Option<PathBuf>>;
async fn publish(&self, project: &ProjectInfo) -> anyhow::Result<PublishOutcome>;
async fn registry_name(&self) -> &str;
async fn manifest_filename(&self) -> &str;
async fn update_dependency_version(
&self,
project: &ProjectInfo,
dependency_name: &str,
new_version: &Version,
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>>;
}
pub(crate) fn semver_range_prefix(version_range: &str) -> &str {
let digit_pos = version_range
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map_or(version_range.len(), |(i, _)| i);
&version_range[..digit_pos]
}
mod dependency_graph;
pub use dependency_graph::DependencyGraph;
pub fn validate_package_names(
projects: &[Project],
package_names: &[String],
) -> anyhow::Result<()> {
for name in package_names {
if !projects.iter().any(|p| p.name() == name) {
anyhow::bail!("Unknown package: {name}");
}
}
Ok(())
}
pub fn filter_projects_by_name(
projects: &[Project],
package_names: &[String],
) -> anyhow::Result<Vec<Project>> {
if package_names.is_empty() {
return Ok(projects.to_vec());
}
package_names
.iter()
.map(|name| {
projects
.iter()
.find(|p| p.name() == name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Unknown package: {name}"))
})
.collect()
}
pub async fn enumerate_projects(
adapters: impl IntoIterator<Item = Arc<dyn PackageManagerAdapter>>,
) -> anyhow::Result<Vec<Project>> {
let mut all_projects = Vec::new();
for adapter in adapters {
let infos = adapter.enumerate_projects().await?;
for info in infos {
all_projects.push(Project {
info,
adapter: Arc::clone(&adapter),
});
}
}
Ok(all_projects)
}
pub fn build_dependency_graph(projects: &[Project]) -> anyhow::Result<DependencyGraph> {
let project_names: std::collections::HashSet<_> = projects.iter().map(|p| p.name()).collect();
let mut adjacency = std::collections::HashMap::new();
for project in projects {
let mut dependencies = Vec::new();
for dep_name in project.dependency_names() {
if project_names.contains(dep_name.as_str()) {
dependencies.push(dep_name.clone());
}
}
adjacency.insert(project.name().to_string(), dependencies);
}
Ok(DependencyGraph::from_adjacency(adjacency))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::sync::Arc;
use crate::command::CommandRunner;
use crate::command::test_support::RecordingCommandRunner;
use crate::model::config::NpmConfig;
use super::*;
#[test]
fn project_equality() {
let p1 = Project::new_test("test", "/nonexistent/packages/test");
let p2 = Project::new_test("test", "/nonexistent/packages/test");
let p3 = Project::new_test("other", "/nonexistent/packages/other");
assert_eq!(p1, p2);
assert_ne!(p1, p3);
}
#[test]
fn project_debug() {
let project = Project::new_test("my-package", "/nonexistent/packages/my-package");
let debug = format!("{:?}", project);
assert!(debug.contains("my-package"));
}
#[test]
fn project_clone() {
let project = Project::new_test("test", "/nonexistent/src");
let cloned = project.clone();
assert_eq!(project, cloned);
}
#[test]
fn project_getters() {
let project = Project::new_test("my-pkg", "/nonexistent/packages/my-pkg");
assert_eq!(project.name(), "my-pkg");
assert_eq!(
project.path().as_path(),
Path::new("/nonexistent/packages/my-pkg")
);
}
#[tokio::test]
async fn project_registry_name_delegates_to_adapter() {
let project = Project::new_test("my-app", "/nonexistent");
assert_eq!(project.registry_name().await, "npm");
}
#[tokio::test]
async fn enumerate_projects_attaches_adapter() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"name": "test", "version": "0.1.0"}"#,
)
.unwrap();
let adapter: Arc<dyn PackageManagerAdapter> = Arc::new(NpmAdapter::new(
NpmConfig::default(),
AbsolutePath::new(dir.path()).unwrap(),
crate::Env::new(
Arc::new(RecordingCommandRunner::new(0)) as Arc<dyn CommandRunner>,
Arc::new(crate::filesystem::LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
Arc::new(RecordingCommandRunner::new(0)) as Arc<dyn CommandRunner>,
crate::path::AbsolutePath::new("/tmp").unwrap(),
)),
),
));
let projects = enumerate_projects([adapter.clone()]).await.unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].name(), "test");
assert!(Arc::strong_count(&projects[0].adapter) >= 2);
}
#[tokio::test]
async fn enumerate_projects_flattens_multiple_adapters() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"name": "npm-pkg", "version": "0.1.0"}"#,
)
.unwrap();
let adapter1: Arc<dyn PackageManagerAdapter> = Arc::new(NpmAdapter::new(
NpmConfig::default(),
AbsolutePath::new(dir.path()).unwrap(),
crate::Env::new(
Arc::new(RecordingCommandRunner::new(0)) as Arc<dyn CommandRunner>,
Arc::new(crate::filesystem::LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
Arc::new(RecordingCommandRunner::new(0)) as Arc<dyn CommandRunner>,
crate::path::AbsolutePath::new("/tmp").unwrap(),
)),
),
));
let adapter2: Arc<dyn PackageManagerAdapter> = Arc::new(NpmAdapter::new(
NpmConfig::default(),
AbsolutePath::new(dir.path()).unwrap(),
crate::Env::new(
Arc::new(RecordingCommandRunner::new(0)) as Arc<dyn CommandRunner>,
Arc::new(crate::filesystem::LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
Arc::new(RecordingCommandRunner::new(0)) as Arc<dyn CommandRunner>,
crate::path::AbsolutePath::new("/tmp").unwrap(),
)),
),
));
let projects = enumerate_projects([adapter1, adapter2]).await.unwrap();
assert_eq!(projects.len(), 2);
assert_eq!(projects[0].name(), "npm-pkg");
assert_eq!(projects[1].name(), "npm-pkg");
}
#[tokio::test]
async fn enumerate_projects_empty_adapters_returns_empty() {
let _dir = tempfile::tempdir().unwrap();
let adapters: [Arc<dyn PackageManagerAdapter>; 0] = [];
let projects = enumerate_projects(adapters).await.unwrap();
assert!(projects.is_empty());
}
#[test]
fn filter_projects_empty_names_returns_all() {
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
];
let result = filter_projects_by_name(&projects, &[]).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn filter_projects_selects_matching() {
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
Project::new_test("c", "/nonexistent/packages/c"),
];
let names = vec!["b".to_string(), "c".to_string()];
let result = filter_projects_by_name(&projects, &names).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].name(), "b");
assert_eq!(result[1].name(), "c");
}
#[test]
fn filter_projects_unknown_name_returns_error() {
let projects = vec![Project::new_test("a", "/nonexistent/packages/a")];
let names = vec!["nonexistent".to_string()];
let result = filter_projects_by_name(&projects, &names);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unknown package: nonexistent")
);
}
#[test]
fn validate_package_names_all_known_returns_ok() {
let projects = vec![
Project::new_test("a", "/nonexistent/packages/a"),
Project::new_test("b", "/nonexistent/packages/b"),
];
assert!(validate_package_names(&projects, &["a".to_string(), "b".to_string()]).is_ok());
}
#[test]
fn validate_package_names_empty_list_returns_ok() {
let projects = vec![Project::new_test("a", "/nonexistent/packages/a")];
assert!(validate_package_names(&projects, &[]).is_ok());
}
#[test]
fn validate_package_names_unknown_name_returns_error() {
let projects = vec![Project::new_test("a", "/nonexistent/packages/a")];
let result = validate_package_names(&projects, &["unknown".to_string()]);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unknown package: unknown")
);
}
#[test]
fn is_releasable_under_publishable_project_is_releasable() {
let project = Project::new_test("my-lib", "/nonexistent/packages/my-lib");
let config = crate::model::config::Config::new();
assert!(project.is_releasable_under(&config));
}
#[test]
fn is_releasable_under_non_publishable_not_listed_is_not_releasable() {
let project =
Project::new_test_not_publishable("private-tool", "/nonexistent/packages/private-tool");
let config = crate::model::config::Config::new();
assert!(!project.is_releasable_under(&config));
}
#[test]
fn is_releasable_under_non_publishable_listed_is_releasable() {
let project =
Project::new_test_not_publishable("private-tool", "/nonexistent/packages/private-tool");
let config = crate::model::config::Config::new().with_git(
crate::model::config::GitConfig::default()
.with_publish_private_packages(vec!["private-tool".to_string()]),
);
assert!(project.is_releasable_under(&config));
}
#[test]
fn is_releasable_under_non_publishable_different_name_listed_is_not_releasable() {
let project =
Project::new_test_not_publishable("private-tool", "/nonexistent/packages/private-tool");
let config = crate::model::config::Config::new().with_git(
crate::model::config::GitConfig::default()
.with_publish_private_packages(vec!["other-tool".to_string()]),
);
assert!(!project.is_releasable_under(&config));
}
#[tokio::test]
async fn is_prepared_for_release_returns_true_when_changelog_exists() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("CHANGELOG.md"), "# Changelog").unwrap();
let project = Project::new_test("my-lib", dir.path().to_str().unwrap());
let fs = crate::filesystem::LocalFilesystem;
assert!(project.is_prepared_for_release(&fs).await.unwrap());
}
#[tokio::test]
async fn is_prepared_for_release_returns_false_when_changelog_absent() {
let dir = tempfile::tempdir().unwrap();
let project = Project::new_test("my-lib", dir.path().to_str().unwrap());
let fs = crate::filesystem::LocalFilesystem;
assert!(!project.is_prepared_for_release(&fs).await.unwrap());
}
}