use std::ops::{Deref, DerefMut};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, bail};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
mod cargo;
mod git;
mod github;
mod linked_versions;
mod npm;
mod prepare;
mod template;
const MAX_CONFIG_BYTES: u64 = 256 * 1024;
pub use cargo::CargoConfig;
pub use git::{GitConfig, SignedCommitsMode, Strategy, TagFormat};
pub use github::GitHubConfig;
pub use linked_versions::{LinkedVersionGroup, LinkedVersionsConfig};
pub use npm::{NpmAccess, NpmConfig};
pub use prepare::{DependencyBump, PrepareConfig};
pub(crate) use template::render_init_template;
use crate::package_manager::{self, CargoAdapter, NpmAdapter, PackageManagerAdapter, Project};
use crate::path::AbsolutePath;
async fn resolve_root(
path: &Option<String>,
git_workdir: &AbsolutePath,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<AbsolutePath> {
match path {
Some(p) => git_workdir.subpath(p, fs).await.with_context(|| {
format!("resolve_root: path '{p}' does not exist or escapes repository root")
}),
None => Ok(git_workdir.clone()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct GlobalConfig {
pub disable_dependency_cycle_warnings: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ignore: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum PackageManager {
Npm,
Cargo,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ConfigData {
#[serde(default)]
pub global: GlobalConfig,
#[serde(default)]
pub npm: NpmConfig,
#[serde(default)]
pub cargo: CargoConfig,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub github: GitHubConfig,
#[serde(default, rename = "linked-versions")]
pub linked_versions: LinkedVersionsConfig,
#[serde(default)]
pub prepare: PrepareConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Config {
pub(crate) data: ConfigData,
}
impl Deref for Config {
type Target = ConfigData;
fn deref(&self) -> &ConfigData {
&self.data
}
}
impl DerefMut for Config {
fn deref_mut(&mut self) -> &mut ConfigData {
&mut self.data
}
}
impl Config {
pub fn new() -> Self {
Self {
data: ConfigData::default(),
}
}
pub fn with_global(mut self, config: GlobalConfig) -> Self {
self.data.global = config;
self
}
pub fn with_npm(mut self, config: NpmConfig) -> Self {
self.data.npm = config;
self
}
pub fn with_cargo(mut self, config: CargoConfig) -> Self {
self.data.cargo = config;
self
}
pub fn with_git(mut self, config: GitConfig) -> Self {
self.data.git = config;
self
}
pub fn with_github(mut self, config: GitHubConfig) -> Self {
self.data.github = config;
self
}
pub fn with_linked_versions(mut self, config: LinkedVersionsConfig) -> Self {
self.data.linked_versions = config;
self
}
pub fn with_prepare(mut self, config: PrepareConfig) -> Self {
self.data.prepare = config;
self
}
pub fn enabled_package_managers(&self) -> impl Iterator<Item = PackageManager> {
let mut managers = Vec::new();
if self.data.npm.enabled {
managers.push(PackageManager::Npm);
}
if self.data.cargo.enabled {
managers.push(PackageManager::Cargo);
}
managers.into_iter()
}
pub fn create_adapters(
&self,
env: &crate::Env,
) -> anyhow::Result<Vec<Arc<dyn PackageManagerAdapter>>> {
let workdir = env.git().path();
Ok(self
.enabled_package_managers()
.map(|pm| -> Arc<dyn PackageManagerAdapter> {
match pm {
PackageManager::Npm => Arc::new(NpmAdapter::new(
self.data.npm.clone(),
workdir.clone(),
env.clone(),
)),
PackageManager::Cargo => Arc::new(CargoAdapter::new(
self.data.cargo.clone(),
workdir.clone(),
env.clone(),
)),
}
})
.collect())
}
pub async fn load_projects_for_adapters(
&self,
adapters: &[Arc<dyn PackageManagerAdapter>],
) -> anyhow::Result<Vec<Project>> {
let all_projects = package_manager::enumerate_projects(adapters.to_vec()).await?;
validate_workspace_version_linking(&all_projects, &self.data.linked_versions)?;
let ignore_patterns = self
.data
.global
.ignore
.iter()
.map(|p| {
glob::Pattern::new(p).with_context(|| format!("Invalid ignore glob pattern: {p:?}"))
})
.collect::<anyhow::Result<Vec<_>>>()?;
let pattern_matched: Vec<bool> = ignore_patterns
.iter()
.map(|pat| all_projects.iter().any(|p| pat.matches(p.name())))
.collect();
let projects: Vec<Project> = all_projects
.iter()
.filter(|project| {
!ignore_patterns
.iter()
.any(|pat| pat.matches(project.name()))
})
.cloned()
.collect();
for (matched, raw) in pattern_matched.iter().zip(self.data.global.ignore.iter()) {
if !matched {
log::warn!("Ignore pattern {raw:?} did not match any project");
}
}
if projects.is_empty() {
if all_projects.is_empty() {
bail!(
"No projects found. Check that your package manager configuration is correct."
);
} else {
bail!(
"All {} project(s) were excluded by [global].ignore patterns. \
Check that your ignore patterns are not too broad.",
all_projects.len()
);
}
}
Ok(projects)
}
pub async fn load_projects(&self, env: &crate::Env) -> anyhow::Result<Vec<Project>> {
let (_, filtered) = self.load_projects_partitioned(env).await?;
Ok(filtered)
}
pub async fn load_all_projects(&self, env: &crate::Env) -> anyhow::Result<Vec<Project>> {
let (all, _) = self.load_projects_partitioned(env).await?;
Ok(all)
}
pub async fn load_projects_partitioned(
&self,
env: &crate::Env,
) -> anyhow::Result<(Vec<Project>, Vec<Project>)> {
let adapters = self.create_adapters(env)?;
let all = package_manager::enumerate_projects(adapters).await?;
validate_workspace_version_linking(&all, &self.data.linked_versions)?;
let ignore_patterns = self
.data
.global
.ignore
.iter()
.map(|p| {
glob::Pattern::new(p).with_context(|| format!("Invalid ignore glob pattern: {p:?}"))
})
.collect::<anyhow::Result<Vec<_>>>()?;
let pattern_matched: Vec<bool> = ignore_patterns
.iter()
.map(|pat| all.iter().any(|p| pat.matches(p.name())))
.collect();
let filtered: Vec<Project> = all
.iter()
.filter(|p| !ignore_patterns.iter().any(|pat| pat.matches(p.name())))
.cloned()
.collect();
for (matched, raw) in pattern_matched.iter().zip(self.data.global.ignore.iter()) {
if !matched {
log::warn!("Ignore pattern {raw:?} did not match any project");
}
}
if filtered.is_empty() {
if all.is_empty() {
bail!(
"No projects found. Check that your package manager configuration is correct."
);
} else {
bail!(
"All {} project(s) were excluded by [global].ignore patterns. \
Check that your ignore patterns are not too broad.",
all.len()
);
}
}
Ok((all, filtered))
}
pub async fn save(
&self,
fs: &dyn crate::filesystem::Filesystem,
git_root: &AbsolutePath,
) -> anyhow::Result<PathBuf> {
let config_path = config_path(git_root);
let parent = git_root.child(".cursus");
fs.create_dir_all(&parent)
.await
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
let contents = toml::to_string_pretty(&self.data).context("Failed to serialize config")?;
fs.write(&config_path, contents.as_bytes())
.await
.with_context(|| format!("Failed to create config: {}", config_path.display()))?;
Ok(config_path.into_path_buf())
}
}
fn config_path(git_workdir: &AbsolutePath) -> AbsolutePath {
git_workdir.child(".cursus/config.toml")
}
pub async fn exists(
fs: &dyn crate::filesystem::Filesystem,
git_root: &AbsolutePath,
) -> anyhow::Result<bool> {
fs.exists(&config_path(git_root)).await
}
pub async fn load(
fs: &dyn crate::filesystem::Filesystem,
git_root: &AbsolutePath,
) -> anyhow::Result<Option<Config>> {
if !fs.exists(&config_path(git_root)).await? {
return Ok(None);
}
let path = config_path(git_root);
let size = fs.file_size(&path).await?;
if size > MAX_CONFIG_BYTES {
bail!(
"Config file {} is too large ({size} bytes, limit is {MAX_CONFIG_BYTES} bytes)",
path.display()
);
}
let contents = fs
.read_to_string(&path)
.await
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let data: ConfigData =
toml::from_str(&contents).with_context(|| "Failed to parse config.toml")?;
let mut config = Config { data };
if config.enabled_package_managers().next().is_none() {
bail!("Configuration must have at least one package manager enabled");
}
config.data.git.resolve_defaults(config.data.github.enabled);
Ok(Some(config))
}
fn validate_workspace_version_linking(
projects: &[package_manager::Project],
linked: &LinkedVersionsConfig,
) -> anyhow::Result<()> {
let ws_projects: Vec<&str> = projects
.iter()
.filter(|p| p.workspace_version())
.map(|p| p.name())
.collect();
if ws_projects.is_empty() {
return Ok(());
}
if linked.is_global() {
return Ok(());
}
let all_names: Vec<&str> = projects.iter().map(|p| p.name()).collect();
let groups = linked.resolve_groups(&all_names)?;
let mut group_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut unlinked: Vec<&str> = Vec::new();
for name in &ws_projects {
let group_idx = groups.iter().position(|g| g.iter().any(|n| n == name));
match group_idx {
Some(idx) => {
group_indices.insert(idx);
}
None => unlinked.push(name),
}
}
if !unlinked.is_empty() {
bail!(
"The following packages use version.workspace = true but are not in any \
linked-versions group: {}. All packages sharing a workspace version must \
be in the same linked-versions group (or use [linked-versions] enabled = true \
for global linking).",
unlinked.join(", ")
);
}
if group_indices.len() > 1 {
bail!(
"Packages using version.workspace = true are spread across {} different \
linked-versions groups. All packages sharing a workspace version must be \
in the same linked-versions group.",
group_indices.len()
);
}
Ok(())
}
#[cfg(test)]
mod tests;