#![deny(missing_docs)]
#![allow(clippy::result_large_err)]
#![allow(unused_assignments)]
use std::fmt::Display;
#[cfg(feature = "cargo-projects")]
use axoasset::serde_json;
use axoasset::{AxoassetError, LocalAsset};
use camino::{Utf8Path, Utf8PathBuf};
use errors::{AxoprojectError, ProjectError, Result};
use local_repo::LocalRepo;
use tracing::info;
#[cfg(feature = "cargo-projects")]
pub use guppy::PackageId;
pub mod changelog;
pub mod errors;
#[cfg(feature = "generic-projects")]
pub mod generic;
#[cfg(feature = "npm-projects")]
pub mod javascript;
pub mod local_repo;
mod repo;
#[cfg(feature = "cargo-projects")]
pub mod rust;
#[cfg(test)]
mod tests;
pub use crate::repo::GithubRepo;
use crate::repo::GithubRepoInput;
pub type SortedMap<K, V> = std::collections::BTreeMap<K, V>;
#[derive(Debug, Default)]
pub struct WorkspaceGraph {
pub repo: Option<LocalRepo>,
workspaces: Vec<WorkspaceInfo>,
packages: Vec<PackageInfo>,
workspace_workspace_children: SortedMap<WorkspaceIdx, Vec<WorkspaceIdx>>,
workspace_package_children: SortedMap<WorkspaceIdx, Vec<PackageIdx>>,
workspace_parents: SortedMap<WorkspaceIdx, WorkspaceIdx>,
package_parents: SortedMap<PackageIdx, WorkspaceIdx>,
}
impl WorkspaceGraph {
pub fn find(
start_dir: &Utf8Path,
clamp_to_dir: Option<&Utf8Path>,
) -> std::result::Result<Self, ProjectError> {
let local_repo = if let Some(dir) = clamp_to_dir {
LocalRepo::new("git", dir).ok()
} else {
None
};
Self::find_from_git_clamped(start_dir, local_repo, clamp_to_dir)
}
pub fn find_from_git(
start_dir: &Utf8Path,
local_repo: Option<LocalRepo>,
) -> std::result::Result<Self, ProjectError> {
let clamp_to_dir = local_repo.as_ref().map(|r| r.path.clone());
Self::find_from_git_clamped(start_dir, local_repo, clamp_to_dir.as_deref())
}
fn find_from_git_clamped(
start_dir: &Utf8Path,
local_repo: Option<LocalRepo>,
clamp_to_dir: Option<&Utf8Path>,
) -> std::result::Result<Self, ProjectError> {
let mut missing = vec![];
for ws in [
#[cfg(feature = "generic-projects")]
generic::get_workspace,
#[cfg(feature = "cargo-projects")]
rust::get_workspace,
] {
match ws(start_dir, clamp_to_dir) {
WorkspaceSearch::Found(ws) => {
let mut workspaces = Self {
repo: local_repo.clone(),
..Default::default()
};
workspaces.add_workspace(ws, None);
return Ok(workspaces);
}
WorkspaceSearch::Broken {
manifest_path: _,
cause,
} => {
return Err(ProjectError::ProjectBroken { cause });
}
WorkspaceSearch::Missing(e) => missing.push(e),
}
}
Err(ProjectError::ProjectMissing { sources: missing })
}
pub fn add_workspace(
&mut self,
mut workspace: WorkspaceStructure,
parent_workspace: Option<WorkspaceIdx>,
) {
let sub_workspaces = std::mem::take(&mut workspace.sub_workspaces);
let packages = std::mem::take(&mut workspace.packages);
let workspace_idx: WorkspaceIdx = WorkspaceIdx(self.workspaces.len());
self.workspaces.push(workspace.workspace);
if let Some(parent_workspace) = parent_workspace {
self.workspace_workspace_children
.entry(parent_workspace)
.or_default()
.push(workspace_idx);
self.workspace_parents
.insert(workspace_idx, parent_workspace);
}
for package in packages {
let package_idx = PackageIdx(self.packages.len());
self.packages.push(package);
self.workspace_package_children
.entry(workspace_idx)
.or_default()
.push(package_idx);
self.package_parents.insert(package_idx, workspace_idx);
}
for sub_workspace in sub_workspaces {
self.add_workspace(sub_workspace, Some(workspace_idx));
}
}
pub fn root_workspace_idx(&self) -> WorkspaceIdx {
WorkspaceIdx(0)
}
pub fn root_workspace(&self) -> &WorkspaceInfo {
self.workspace(self.root_workspace_idx())
}
pub fn workspace(&self, idx: WorkspaceIdx) -> &WorkspaceInfo {
&self.workspaces[idx.0]
}
pub fn package(&self, idx: PackageIdx) -> &PackageInfo {
&self.packages[idx.0]
}
pub fn workspace_mut(&mut self, idx: WorkspaceIdx) -> &mut WorkspaceInfo {
&mut self.workspaces[idx.0]
}
pub fn package_mut(&mut self, idx: PackageIdx) -> &mut PackageInfo {
&mut self.packages[idx.0]
}
pub fn workspace_for_package(&self, idx: PackageIdx) -> WorkspaceIdx {
self.package_parents[&idx]
}
pub fn direct_packages(
&self,
idx: WorkspaceIdx,
) -> impl Iterator<Item = (PackageIdx, &PackageInfo)> {
self.workspace_package_children
.get(&idx)
.map(|pkgs| &**pkgs)
.unwrap_or_default()
.iter()
.map(|p_idx| (*p_idx, &self.packages[p_idx.0]))
}
pub fn recursive_packages(
&self,
idx: WorkspaceIdx,
) -> impl Iterator<Item = (PackageIdx, &PackageInfo)> {
let mut working_set = vec![idx];
let mut package_indices = vec![];
while let Some(workspace_idx) = working_set.pop() {
if let Some(packages) = self.workspace_package_children.get(&workspace_idx) {
package_indices.extend(packages.iter().copied());
}
if let Some(sub_workspaces) = self.workspace_workspace_children.get(&workspace_idx) {
working_set.extend(sub_workspaces.iter().copied());
}
}
package_indices
.into_iter()
.map(|idx| (idx, self.package(idx)))
}
pub fn all_packages(&self) -> impl Iterator<Item = (PackageIdx, &PackageInfo)> {
self.packages
.iter()
.enumerate()
.map(|(idx, pkg)| (PackageIdx(idx), pkg))
}
pub fn all_workspace_indices(&self) -> impl Iterator<Item = WorkspaceIdx> {
(0..self.workspaces.len()).map(WorkspaceIdx)
}
pub fn repository_url(&self, packages: Option<&[PackageIdx]>) -> Result<Option<RepositoryUrl>> {
let package_list = if let Some(packages) = packages {
packages
.iter()
.map(|idx| self.package(*idx))
.collect::<Vec<_>>()
} else {
self.packages.iter().collect::<Vec<_>>()
};
RepositoryUrl::from_packages(package_list)
}
}
pub enum WorkspaceSearch {
Found(WorkspaceStructure),
Broken {
manifest_path: Utf8PathBuf,
cause: AxoprojectError,
},
Missing(AxoprojectError),
}
impl WorkspaceSearch {
pub fn into_result(self) -> Result<WorkspaceStructure> {
match self {
WorkspaceSearch::Found(val) => Ok(val),
WorkspaceSearch::Missing(val) => Err(val),
WorkspaceSearch::Broken {
manifest_path: _,
cause,
} => Err(cause),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum WorkspaceKind {
#[cfg(feature = "generic-projects")]
Generic,
#[cfg(feature = "cargo-projects")]
Rust,
#[cfg(feature = "npm-projects")]
Javascript,
}
pub struct WorkspaceStructure {
pub workspace: WorkspaceInfo,
pub sub_workspaces: Vec<WorkspaceStructure>,
pub packages: Vec<PackageInfo>,
}
#[derive(Debug)]
pub struct WorkspaceInfo {
pub kind: WorkspaceKind,
pub target_dir: Utf8PathBuf,
pub workspace_dir: Utf8PathBuf,
pub manifest_path: Utf8PathBuf,
pub dist_manifest_path: Option<Utf8PathBuf>,
pub root_auto_includes: AutoIncludes,
#[cfg(feature = "cargo-projects")]
pub cargo_metadata_table: Option<serde_json::Value>,
#[cfg(feature = "cargo-projects")]
pub cargo_profiles: rust::CargoProfiles,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Hash)]
pub struct RepositoryUrl(pub String);
impl std::ops::Deref for RepositoryUrl {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl RepositoryUrl {
pub fn from_string(url: impl Into<String>) -> Self {
let mut url = url.into();
if url.ends_with('/') {
url.pop();
}
Self(url)
}
pub fn from_packages<'a>(
packages: impl IntoIterator<Item = &'a PackageInfo>,
) -> Result<Option<Self>> {
let mut repo_url = None::<RepositoryUrl>;
let mut repo_url_origin = None::<Utf8PathBuf>;
for info in packages {
if let Some(new_url) = &info.repository_url {
let normalized_new_url = RepositoryUrl::from_string(new_url);
if let Some(cur_url) = &repo_url {
if &normalized_new_url == cur_url {
} else if cur_url.github_repo().ok() == normalized_new_url.github_repo().ok() {
} else {
return Err(AxoprojectError::InconsistentRepositoryKey {
file1: repo_url_origin.as_ref().unwrap().to_owned(),
url1: cur_url.0.clone(),
file2: info.manifest_path.clone(),
url2: normalized_new_url.0,
});
}
} else {
repo_url = Some(normalized_new_url);
repo_url_origin = Some(info.manifest_path.clone());
}
}
}
Ok(repo_url)
}
pub fn github_repo(&self) -> Result<GithubRepo> {
GithubRepoInput::new(self.0.clone())?.parse()
}
}
#[derive(Clone, Debug)]
pub struct PackageInfo {
pub true_name: String,
pub true_version: Option<Version>,
pub manifest_path: Utf8PathBuf,
pub dist_manifest_path: Option<Utf8PathBuf>,
pub package_root: Utf8PathBuf,
pub name: String,
pub version: Option<Version>,
pub description: Option<String>,
pub authors: Vec<String>,
pub license: Option<String>,
pub publish: bool,
pub keywords: Option<Vec<String>>,
pub repository_url: Option<String>,
pub homepage_url: Option<String>,
pub documentation_url: Option<String>,
pub readme_file: Option<Utf8PathBuf>,
pub license_files: Vec<Utf8PathBuf>,
pub changelog_file: Option<Utf8PathBuf>,
pub binaries: Vec<String>,
pub out_dir: Option<String>,
pub cstaticlibs: Vec<String>,
pub cdylibs: Vec<String>,
#[cfg(feature = "cargo-projects")]
pub cargo_metadata_table: Option<serde_json::Value>,
#[cfg(feature = "cargo-projects")]
pub cargo_package_id: Option<PackageId>,
#[cfg(feature = "cargo-projects")]
pub axoupdater_versions: Vec<(String, Version)>,
pub npm_scope: Option<String>,
pub build_command: Option<Vec<String>>,
pub dist: Option<bool>,
}
impl PackageInfo {
pub fn github_repo(&self) -> Result<Option<GithubRepo>> {
match self.repository_url.clone() {
None => Ok(None),
Some(url) => Ok(Some(GithubRepoInput::new(url)?.parse()?)),
}
}
pub fn web_url(&self) -> Result<Option<String>> {
Ok(self.github_repo()?.map(|repo| repo.web_url()))
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PackageIdx(pub usize);
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorkspaceIdx(pub usize);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Version {
#[cfg(feature = "generic-projects")]
Generic(semver::Version),
#[cfg(feature = "cargo-projects")]
Cargo(semver::Version),
#[cfg(feature = "npm-projects")]
Npm(node_semver::Version),
}
impl Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(feature = "generic-projects")]
Version::Generic(v) => v.fmt(f),
#[cfg(feature = "cargo-projects")]
Version::Cargo(v) => v.fmt(f),
#[cfg(feature = "npm-projects")]
Version::Npm(v) => v.fmt(f),
}
}
}
impl Version {
#[cfg(any(feature = "generic-projects", feature = "cargo-projects"))]
pub fn semver(&self) -> semver::Version {
#[allow(unreachable_patterns)]
match self {
#[cfg(feature = "generic-projects")]
Version::Generic(v) => v.clone(),
#[cfg(feature = "cargo-projects")]
Version::Cargo(v) => v.clone(),
#[cfg(feature = "npm-projects")]
Version::Npm(v) => v
.to_string()
.parse()
.expect("version wasn't in semver format"),
}
}
#[cfg(feature = "npm-projects")]
pub fn npm(&self) -> &node_semver::Version {
#[allow(irrefutable_let_patterns)]
if let Version::Npm(v) = self {
v
} else {
panic!("Version wasn't in the npm format")
}
}
pub fn is_stable(&self) -> bool {
match self {
#[cfg(feature = "generic-projects")]
Version::Generic(v) => v.pre.is_empty() && v.build.is_empty(),
#[cfg(feature = "cargo-projects")]
Version::Cargo(v) => v.pre.is_empty() && v.build.is_empty(),
#[cfg(feature = "npm-projects")]
Version::Npm(v) => v.pre_release.is_empty() && v.build.is_empty(),
}
}
pub fn stable_part(&self) -> Self {
match self {
#[cfg(feature = "generic-projects")]
Version::Generic(v) => {
Version::Generic(semver::Version::new(v.major, v.minor, v.patch))
}
#[cfg(feature = "cargo-projects")]
Version::Cargo(v) => Version::Cargo(semver::Version::new(v.major, v.minor, v.patch)),
#[cfg(feature = "npm-projects")]
Version::Npm(v) => Version::Npm(node_semver::Version {
major: v.major,
minor: v.minor,
patch: v.patch,
build: vec![],
pre_release: vec![],
}),
}
}
}
#[derive(Debug, Clone)]
pub struct AutoIncludes {
pub readme: Option<Utf8PathBuf>,
pub licenses: Vec<Utf8PathBuf>,
pub changelog: Option<Utf8PathBuf>,
}
pub fn find_auto_includes(dir: &Utf8Path) -> Result<AutoIncludes> {
find_auto_includes_inner(dir).map_err(|details| AxoprojectError::AutoIncludeSearch {
dir: dir.to_owned(),
details,
})
}
fn find_auto_includes_inner(dir: &Utf8Path) -> std::result::Result<AutoIncludes, std::io::Error> {
let mut includes = AutoIncludes {
readme: None,
licenses: vec![],
changelog: None,
};
let entries = dir.read_dir_utf8()?;
for entry in entries {
let entry = entry?;
let meta = entry.file_type()?;
if !meta.is_file() {
continue;
}
let file_name = entry.file_name();
if file_name.starts_with("README") {
if includes.readme.is_none() {
let path = entry.path().to_owned();
info!("Found README at {}", path);
includes.readme = Some(path);
} else {
info!("Ignoring duplicate candidate README at {}", entry.path());
}
} else if file_name.starts_with("LICENSE") || file_name.starts_with("UNLICENSE") {
let path = entry.path().to_owned();
info!("Found LICENSE at {}", path);
includes.licenses.push(path);
} else if file_name.starts_with("CHANGELOG") || file_name.starts_with("RELEASES") {
if includes.changelog.is_none() {
let path = entry.path().to_owned();
info!("Found CHANGELOG at {}", path);
includes.changelog = Some(path);
} else {
info!("Ignoring duplicate candidate CHANGELOG at {}", entry.path());
}
}
}
Ok(includes)
}
pub fn merge_auto_includes(info: &mut PackageInfo, auto_includes: &AutoIncludes) {
if info.readme_file.is_none() {
info.readme_file.clone_from(&auto_includes.readme);
}
if info.changelog_file.is_none() {
info.changelog_file.clone_from(&auto_includes.changelog);
}
if info.license_files.is_empty() {
info.license_files.clone_from(&auto_includes.licenses);
}
}
pub fn find_file(
name: &str,
start_dir: &Utf8Path,
clamp_to_dir: Option<&Utf8Path>,
) -> Result<Utf8PathBuf> {
let manifest = LocalAsset::search_ancestors(start_dir, name)?;
if let Some(root_dir) = clamp_to_dir {
let root_dir = if root_dir.is_relative() {
let current_dir = LocalAsset::current_dir()?;
current_dir.join(root_dir)
} else {
root_dir.to_owned()
};
let improperly_nested = pathdiff::diff_utf8_paths(&manifest, root_dir)
.map(|p| p.starts_with(".."))
.unwrap_or(true);
if improperly_nested {
Err(AxoassetError::SearchFailed {
start_dir: start_dir.to_owned(),
desired_filename: name.to_owned(),
})?;
}
}
Ok(manifest)
}