use anyhow::{anyhow, Result};
use fs_err as fs;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lock {
pub version: u32,
pub repo_id: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub packages: Vec<LockedPackage>,
pub last_txn: Option<Txn>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedPackage {
pub name: String,
pub path: String,
pub last_release: Option<PackageRelease>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageRelease {
pub tag: String,
pub version: String,
pub sha: String,
pub trailers: Trailers,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Trailers {
pub id: Option<String>,
pub pkg: Option<String>,
pub ver: Option<String>,
pub sha: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Txn {
pub id: String,
pub started_at: chrono::DateTime<chrono::Utc>,
pub packages: Vec<TxnPkg>,
pub completed: bool,
pub rolled_back: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxnPkg {
pub name: String,
pub from: Option<String>,
pub to: Option<String>,
pub created_tags: Vec<String>,
}
pub fn path(root: &Path) -> PathBuf {
root.join("rlls.lock")
}
pub fn exists(root: &Path) -> bool {
path(root).exists()
}
pub fn load(root: &Path) -> Result<Lock> {
let p = path(root);
let s = fs::read_to_string(&p)?;
let l: Lock = toml::from_str(&s)?;
if l.version == 0 {
return Err(anyhow!(
"invalid rlls.lock: version must be > 0"
));
}
if l.repo_id.trim().is_empty() {
return Err(anyhow!("invalid rlls.lock: repo_id missing"));
}
Ok(l)
}
pub fn save(root: &Path, lock: &Lock, force: bool) -> Result<()> {
let p = path(root);
if p.exists() && !force {
return Err(anyhow!(
"rlls.lock already exists. rerun with --force to overwrite"
));
}
let s = toml::to_string_pretty(lock)?;
fs::write(p, s)?;
Ok(())
}
pub fn rebuild(root: &Path, force: bool) -> Result<()> {
rebuild_with(root, force, None, &[])
}
pub fn rebuild_with(
root: &Path,
force: bool,
_from_file: Option<&Path>,
baselines: &[String],
) -> Result<()> {
let cfg = crate::config::load("rlls.toml")
.or_else(|_| crate::config::load(".rllsrc"))?;
let mut baseline_map: BTreeMap<String, String> = BTreeMap::new();
for b in baselines {
if let Some((name, r)) = b.split_once(':') {
baseline_map.insert(name.to_string(), r.to_string());
}
}
let root_fs = std::env::current_dir()?;
let discovered = crate::project::discover_packages(&root_fs)?;
let mut locked_packages: Vec<LockedPackage> = vec![];
for name in &cfg.packages {
if let Some(pkg) = discovered.iter().find(|p| p.name == *name)
{
let baseline =
select_baseline(name, &cfg, &baseline_map)?;
if baseline.is_none() {
return Err(anyhow!(
"package '{}' has no usable tag; pass --baseline {}:REF or add migration.legacy_tag_patterns",
name, name
));
}
locked_packages.push(LockedPackage {
name: name.clone(),
path: path_rel(&root_fs, &pkg.path),
last_release: baseline,
});
} else {
return Err(anyhow!(
"configured package '{}' not found in repo",
name
));
}
}
let repo_id = crate::git::rev_parse("HEAD")
.ok_or_else(|| anyhow!("not a git repo"))?;
let lock = Lock {
version: 1,
repo_id,
created_at: chrono::Utc::now(),
packages: locked_packages,
last_txn: None,
};
save(root, &lock, force)
}
fn path_rel(root: &Path, p: &Path) -> String {
match p.strip_prefix(root) {
Ok(rel) => rel.to_string_lossy().to_string(),
Err(_) => p.to_string_lossy().to_string(),
}
}
fn select_baseline(
name: &str,
cfg: &crate::config::Config,
baseline_map: &BTreeMap<String, String>,
) -> Result<Option<PackageRelease>> {
if let Some(r) = baseline_map.get(name) {
return materialize_ref(name, r);
}
let tag = newest_reachable_tag_for(name, cfg)?;
if let Some(tag) = tag {
let sha = tag_commit_sha(&tag)
.ok_or_else(|| anyhow!("cannot resolve tag {}", tag))?;
let ver = tag
.split('@')
.next_back()
.unwrap_or("")
.trim_start_matches('v')
.to_string();
return Ok(Some(PackageRelease {
tag,
version: ver,
sha,
trailers: Default::default(),
}));
}
Ok(None)
}
fn materialize_ref(
name: &str,
r: &str,
) -> Result<Option<PackageRelease>> {
let (tag, sha, ver): (Option<String>, String, String);
if r.starts_with('v') {
let t = format!("{}@{}", name, r);
let s = tag_commit_sha(&t)
.ok_or_else(|| anyhow!("unknown tag {}", t))?;
tag = Some(t);
sha = s;
ver = r.trim_start_matches('v').to_string();
} else if r.len() >= 7 && r.chars().all(|c| c.is_ascii_hexdigit())
{
tag = None;
sha = r.to_string();
ver = "0.0.0".to_string();
} else if let Some(stripped) =
r.strip_prefix(&format!("{}:", name))
{
return materialize_ref(name, stripped);
} else {
let t = format!("{}@{}", name, r);
let s = tag_commit_sha(&t)
.ok_or_else(|| anyhow!("unknown tag {}", t))?;
tag = Some(t);
ver = r.trim_start_matches('v').to_string();
sha = s;
}
Ok(Some(PackageRelease {
tag: tag.unwrap_or_default(),
version: ver,
sha,
trailers: Default::default(),
}))
}
fn newest_reachable_tag_for(
name: &str,
cfg: &crate::config::Config,
) -> Result<Option<String>> {
let pattern = format!("{}@v*", name);
let mut candidates = crate::git::list_tags(&pattern)?;
if let Some(h) = cfg.migration.get(name) {
if let Some(pats) = &h.legacy_tag_patterns {
for p in pats {
let mut v = crate::git::list_tags(p)?;
candidates.append(&mut v);
}
}
}
let head = crate::git::head_commit()
.ok_or_else(|| anyhow!("no HEAD"))?;
let mut reachable: Vec<String> = vec![];
for t in candidates {
if let Some(tag_sha) = tag_commit_sha(&t) {
if crate::git::is_ancestor(&tag_sha, &head) {
reachable.push(t);
}
}
}
if reachable.is_empty() {
return Ok(None);
}
let mut by_ver: BTreeMap<String, Vec<String>> = BTreeMap::new();
for t in &reachable {
let v = tag_version_core(t);
by_ver.entry(v).or_default().push(t.clone());
}
let mut max_key = None;
for (ver, tags) in &by_ver {
if max_key.as_ref().map(|s: &String| ver > s).unwrap_or(true)
{
max_key = Some(ver.clone());
}
if tags.len() > 1 {
return Err(anyhow!("legacy patterns collide at the same version {}; disambiguate migration patterns", ver));
}
}
reachable.sort_by_key(|a| semver_key(a));
Ok(reachable.pop())
}
fn semver_key(tag: &str) -> (u64, u64, u64, String) {
let v = tag
.split('@')
.next_back()
.unwrap_or("")
.trim_start_matches('v');
let mut parts = v.split('.');
let maj = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let min = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let pat = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
(maj, min, pat, v.to_string())
}
fn tag_version_core(tag: &str) -> String {
tag.split('@')
.next_back()
.unwrap_or("")
.trim_start_matches('v')
.to_string()
}
fn tag_commit_sha(tag: &str) -> Option<String> {
let escaped = tag.replace('/', "_");
crate::git::tag_commit(&escaped)
}
pub fn verify(root: &Path) -> Result<()> {
let l = load(root)?;
let head = crate::git::head_commit()
.ok_or_else(|| anyhow!("no HEAD"))?;
for p in &l.packages {
if let Some(rel) = &p.last_release {
if !crate::git::is_ancestor(&rel.sha, &head) {
return Err(anyhow!(
"package '{}' baseline {} is not reachable from HEAD",
p.name, rel.tag
));
}
}
}
Ok(())
}
pub fn synthesize_tags(
_root: &Path,
_baselines: &[String],
) -> Result<()> {
let mut any = false;
for b in _baselines {
if let Some((name, r)) = b.split_once(':') {
let tag = format!("{}@{}", name, r);
let msg =
format!("release {}", r.trim_start_matches('v'));
let sha = tag_commit_sha(&tag)
.or_else(|| crate::git::rev_parse(r));
let target =
if let Some(s) = sha { s } else { r.to_string() };
crate::git::create_annotated_tag_at(
&tag,
Some(&msg),
&target,
)?;
any = true;
}
}
if !any {
return Err(anyhow!(
"no baselines provided; use --baseline NAME:REF"
));
}
Ok(())
}