use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use crate::conventional_commit;
use crate::git::Git;
pub fn derive_changeset(
commit_message: &str,
project_names: &[&str],
) -> anyhow::Result<Option<Changeset>> {
let commit = conventional_commit::parse(commit_message)?;
let Some(change_type) = commit.change_type() else {
return Ok(None);
};
let message = match &commit.body {
Some(body) => format!("{}\n\n{body}", commit.description),
None => commit.description.clone(),
};
let packages: BTreeMap<String, ChangeType> = project_names
.iter()
.map(|name| ((*name).to_string(), change_type))
.collect();
Ok(Some(Changeset::new(packages, Some(message))))
}
pub fn filter_changeset_paths(paths: &[String]) -> Vec<&str> {
paths
.iter()
.filter(|path| {
Path::new(path.as_str())
.file_name()
.and_then(|n| n.to_str())
.is_some_and(is_changeset_filename)
})
.map(|s| s.as_str())
.collect()
}
pub fn is_changeset_filename(filename: &str) -> bool {
let p = Path::new(filename);
p.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
&& !p
.file_stem()
.is_some_and(|stem| stem.eq_ignore_ascii_case("readme"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum ChangeType {
Major,
Minor,
Patch,
}
impl PartialOrd for ChangeType {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ChangeType {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl std::fmt::Display for ChangeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Major => write!(f, "major"),
Self::Minor => write!(f, "minor"),
Self::Patch => write!(f, "patch"),
}
}
}
impl ChangeType {
fn rank(self) -> u8 {
match self {
Self::Patch => 0,
Self::Minor => 1,
Self::Major => 2,
}
}
pub(crate) fn next(self) -> Self {
match self {
Self::Major => Self::Minor,
Self::Minor => Self::Patch,
Self::Patch => Self::Major,
}
}
pub(crate) fn prev(self) -> Self {
match self {
Self::Major => Self::Patch,
Self::Minor => Self::Major,
Self::Patch => Self::Minor,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Changeset {
pub packages: BTreeMap<String, ChangeType>,
pub message: Option<String>,
}
impl Changeset {
pub fn new(packages: BTreeMap<String, ChangeType>, message: Option<String>) -> Self {
Self { packages, message }
}
pub fn generate_filename() -> String {
let name = petname::petname(3, "-").unwrap_or_else(|| "unnamed-changeset".to_string());
format!("{name}.md")
}
pub fn format(&self) -> anyhow::Result<String> {
let toml_str = toml::to_string(&self.packages)
.context("Failed to serialize changeset packages to TOML")?;
let body = self.message.as_deref().unwrap_or_default();
Ok(format!("+++\n{toml_str}+++\n\n{body}\n"))
}
pub fn parse(input: &str) -> anyhow::Result<Self> {
let input: std::borrow::Cow<str> = if input.contains('\r') {
input.replace("\r\n", "\n").into()
} else {
input.into()
};
let rest = input
.strip_prefix("+++\n")
.context("Missing opening +++ delimiter")?;
let (toml_section, body) = rest
.split_once("+++\n")
.or_else(|| rest.strip_suffix("+++").map(|t| (t, "")))
.context("Missing closing +++ delimiter")?;
let packages: BTreeMap<String, ChangeType> =
toml::from_str(toml_section).context("Invalid TOML frontmatter")?;
let trimmed = body.trim();
let message = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
Ok(Self { packages, message })
}
pub(crate) async fn write(
&self,
git: &dyn Git,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<PathBuf> {
let cursus_dir = git.path().child(".cursus");
fs.create_dir_all(&cursus_dir)
.await
.with_context(|| format!("Failed to create directory: {}", cursus_dir.display()))?;
let filename = Self::generate_filename();
let path = cursus_dir.child(filename);
let content = self.format()?;
fs.write(&path, content.as_bytes())
.await
.with_context(|| format!("Failed to write changeset: {}", path.display()))?;
Ok(path.into_path_buf())
}
pub(crate) async fn read_all(
env: &crate::Env,
) -> anyhow::Result<Vec<(crate::path::AbsolutePath, Self)>> {
let git = env.git();
let fs = env.fs();
let cursus_dir = git.path().child(".cursus");
if !fs.is_dir(&cursus_dir).await? {
return Ok(Vec::new());
}
let pattern = cursus_dir
.join("*.md")
.to_str()
.context("Invalid UTF-8 in .cursus path")?
.to_string();
let paths = fs.glob(&pattern).await?;
let mut result = Vec::new();
for path in paths {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !is_changeset_filename(filename) {
continue;
}
let abs_path = crate::path::AbsolutePath::new(&path)?;
let contents = fs
.read_to_string(&abs_path)
.await
.with_context(|| format!("Failed to read changeset: {}", path.display()))?;
let changeset = Self::parse(&contents)
.with_context(|| format!("Failed to parse changeset: {}", path.display()))?;
result.push((abs_path, changeset));
}
Ok(result)
}
pub async fn consume(
&self,
path: &crate::path::AbsolutePath,
released_packages: &BTreeSet<String>,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<()> {
let remaining: BTreeMap<String, ChangeType> = self
.packages
.iter()
.filter(|(name, _)| !released_packages.contains(*name))
.map(|(name, ct)| (name.clone(), *ct))
.collect();
if remaining.len() == self.packages.len() {
return Ok(());
}
if remaining.is_empty() {
fs.remove_file(path)
.await
.with_context(|| format!("Failed to delete changeset: {}", path.display()))?;
} else {
let rewritten = Self::new(remaining, self.message.clone());
let content = rewritten.format()?;
fs.write(path, content.as_bytes())
.await
.with_context(|| format!("Failed to rewrite changeset: {}", path.display()))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests;