use crate::config::Config;
use anyhow::{anyhow, Result};
use fs_err as fs;
use regex::Regex;
use serde_json::Value as Json;
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Lang {
Js,
Py,
Rust,
}
impl Lang {
pub fn as_str(&self) -> &'static str {
match self {
Lang::Js => "js",
Lang::Py => "python",
Lang::Rust => "rust",
}
}
}
#[derive(Debug, Clone)]
pub struct Package {
pub name: String,
pub path: PathBuf,
pub lang: Lang,
}
#[derive(Debug, Default)]
pub struct DetectReport {
pub js: bool,
pub py: bool,
pub rust: bool,
}
pub fn detect(root: &Path) -> DetectReport {
DetectReport {
js: root.join("package.json").exists(),
py: root.join("pyproject.toml").exists()
|| root.join("setup.cfg").exists()
|| root.join("setup.py").exists(),
rust: root.join("Cargo.toml").exists(),
}
}
pub fn looks_like_monorepo(root: &Path) -> Result<bool> {
let has_pnpm_ws = root.join("pnpm-workspace.yaml").exists();
let has_pkg_ws = {
let pj = root.join("package.json");
if pj.exists() {
if let Ok(s) = fs::read_to_string(&pj) {
if let Ok(v) = serde_json::from_str::<Json>(&s) {
v.get("workspaces").is_some()
} else {
false
}
} else {
false
}
} else {
false
}
};
let rust_ws = root.join("Cargo.toml").exists()
&& is_workspace(root).unwrap_or(false);
let py_multi = find_python_packages(root, 2)?.len() > 1;
Ok(has_pnpm_ws || has_pkg_ws || rust_ws || py_multi)
}
pub fn discover_packages(root: &Path) -> Result<Vec<Package>> {
let mut out: Vec<Package> = vec![];
if root.join("Cargo.toml").exists() {
if is_workspace(root)? {
for m in workspace_members(root)? {
let p = m;
if p.join("Cargo.toml").exists() {
let name =
read_rust_name(&p).unwrap_or_else(|| {
p.file_name()
.unwrap_or(OsStr::new("crate"))
.to_string_lossy()
.to_string()
});
out.push(Package {
name,
path: p,
lang: Lang::Rust,
});
}
}
} else {
let name = read_rust_name(root).unwrap_or_else(|| {
root.file_name()
.unwrap_or(OsStr::new("crate"))
.to_string_lossy()
.to_string()
});
out.push(Package {
name,
path: root.to_path_buf(),
lang: Lang::Rust,
});
}
}
let js_roots = discover_js_packages(root, 3)?;
for p in js_roots {
if let Some(name) = read_js_name(&p) {
out.push(Package {
name,
path: p,
lang: Lang::Js,
});
}
}
for p in find_python_packages(root, 3)? {
let name = read_python_name(&p).unwrap_or_else(|| {
p.file_name()
.unwrap_or(OsStr::new("py"))
.to_string_lossy()
.to_string()
});
out.push(Package {
name,
path: p,
lang: Lang::Py,
});
}
dedup_by_path(&mut out);
Ok(out)
}
fn dedup_by_path(v: &mut Vec<Package>) {
v.sort_by(|a, b| a.path.cmp(&b.path));
v.dedup_by(|a, b| a.path == b.path && a.lang == b.lang);
}
fn read_js_name(root: &Path) -> Option<String> {
let pj = root.join("package.json");
if !pj.exists() {
return None;
}
let s = fs::read_to_string(&pj).ok()?;
let v: Json = serde_json::from_str(&s).ok()?;
v.get("name")
.and_then(|x| x.as_str())
.map(|s| s.to_string())
}
fn read_rust_name(root: &Path) -> Option<String> {
let f = root.join("Cargo.toml");
if !f.exists() {
return None;
}
let s = fs::read_to_string(f).ok()?;
let v: toml::Value = toml::from_str(&s).ok()?;
v.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(|s| s.to_string())
}
fn read_python_name(root: &Path) -> Option<String> {
let py = root.join("pyproject.toml");
if py.exists() {
let s = fs::read_to_string(py).ok()?;
let v: toml::Value = toml::from_str(&s).ok()?;
if let Some(n) = v
.get("project")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
{
return Some(n.to_string());
}
if let Some(n) = v
.get("tool")
.and_then(|t| t.get("poetry"))
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
{
return Some(n.to_string());
}
}
let scfg = root.join("setup.cfg");
if scfg.exists() {
let s = fs::read_to_string(scfg).ok()?;
let re =
Regex::new(r"(?m)^\s*name\s*=\s*([A-Za-z0-9._-]+)\s*$")
.ok()?;
if let Some(c) = re.captures(&s) {
return Some(c[1].to_string());
}
}
let spy = root.join("setup.py");
if spy.exists() {
let s = fs::read_to_string(spy).ok()?;
let re = Regex::new(r#"name\s*=\s*"([^"]+)""#).ok()?;
if let Some(c) = re.captures(&s) {
return Some(c[1].to_string());
}
}
None
}
pub fn read_current_version(root: &Path) -> Result<String> {
if root.join("package.json").exists() {
if let Ok(s) = fs::read_to_string(root.join("package.json")) {
if let Ok(v) = serde_json::from_str::<Json>(&s) {
if let Some(s) =
v.get("version").and_then(|x| x.as_str())
{
if !s.is_empty() {
return Ok(s.to_string());
}
}
}
}
}
if root.join("pyproject.toml").exists() {
let t = fs::read_to_string(root.join("pyproject.toml"))?;
if let Ok(v) = toml::from_str::<toml::Value>(&t) {
if let Some(s) = v
.get("tool")
.and_then(|t| t.get("poetry"))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
return Ok(s.to_string());
}
if let Some(s) = v
.get("project")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
return Ok(s.to_string());
}
}
}
if root.join("setup.cfg").exists() {
let s = fs::read_to_string(root.join("setup.cfg"))?;
let re = Regex::new(
r"(?m)^\s*version\s*=\s*([0-9A-Za-z.\-+]+)\s*$",
)?;
if let Some(c) = re.captures(&s) {
return Ok(c[1].to_string());
}
}
if root.join("setup.py").exists() {
let s = fs::read_to_string(root.join("setup.py"))?;
let re = Regex::new(r#"version\s*=\s*"([0-9A-Za-z.\-+]+)""#)?;
if let Some(c) = re.captures(&s) {
return Ok(c[1].to_string());
}
}
if root.join("Cargo.toml").exists() && !is_workspace(root)? {
let s = fs::read_to_string(root.join("Cargo.toml"))?;
let re = Regex::new(
r#"(?m)^\s*version\s*=\s*"([0-9A-Za-z.\-+]+)"\s*$"#,
)?;
if let Some(c) = re.captures(&s) {
return Ok(c[1].to_string());
}
}
let tag = crate::util::run_capture(
"git",
&[
"tag",
"--list",
"v[0-9]*.[0-9]*.[0-9]*",
"--sort=-creatordate",
],
)
.unwrap_or_default();
if let Some(first) = tag.lines().next() {
return Ok(first.trim_start_matches('v').to_string());
}
Ok("0.0.0".to_string())
}
fn is_workspace(root: &Path) -> Result<bool> {
let s = fs::read_to_string(root.join("Cargo.toml"))?;
Ok(Regex::new(r"(?m)^\[workspace]")?.is_match(&s))
}
fn workspace_members(root: &Path) -> Result<Vec<PathBuf>> {
let s = fs::read_to_string(root.join("Cargo.toml"))?;
let v: toml::Value = toml::from_str(&s)?;
let mut out = vec![];
if let Some(ws) = v.get("workspace") {
if let Some(members) =
ws.get("members").and_then(|x| x.as_array())
{
for m in members {
if let Some(p) = m.as_str() {
out.push(root.join(p));
}
}
}
}
Ok(out)
}
pub fn write_versions(root: &Path, new: &str) -> Result<bool> {
let mut touched = false;
let pkg = root.join("package.json");
if pkg.exists() {
touched = true;
let s = fs::read_to_string(&pkg)?;
let mut v: Json = serde_json::from_str(&s)?;
v["version"] = Json::String(new.to_string());
fs::write(&pkg, serde_json::to_string_pretty(&v)? + "\n")?;
refresh_js_locks(root).ok();
}
let pyproj = root.join("pyproject.toml");
let setup_cfg = root.join("setup.cfg");
let setup_py = root.join("setup.py");
if pyproj.exists() || setup_cfg.exists() || setup_py.exists() {
let mut wrote = false;
if pyproj.exists() {
let s = fs::read_to_string(&pyproj)?;
let mut out = String::new();
let mut in_poetry = false;
let mut in_project = false;
let mut done = false;
let re_version_in_table = Regex::new(
r#"(?m)^\s*version\s*=\s*"[0-9A-Za-z.\-+]+"\s*$"#,
)?;
for line in s.lines() {
let mut l = line.to_string();
if l.trim_start().starts_with('[') {
in_poetry = l.trim() == "[tool.poetry]";
in_project = l.trim() == "[project]";
}
if !done
&& (in_poetry || in_project)
&& re_version_in_table.is_match(&l)
{
l = re_version_in_table
.replace(&l, format!("version = \"{}\"", new))
.to_string();
done = true;
}
out.push_str(&l);
out.push('\n');
}
if done {
fs::write(&pyproj, out)?;
wrote = true;
}
}
if !wrote && setup_cfg.exists() {
let s = fs::read_to_string(&setup_cfg)?;
let re_setup_cfg = Regex::new(
r#"(?m)^(\s*version\s*=\s*)([0-9A-Za-z.\-+]+)(\s*)$"#,
)?;
if re_setup_cfg.is_match(&s) {
let out = re_setup_cfg
.replace(&s, format!("${{1}}{}${{3}}", new))
.to_string();
fs::write(&setup_cfg, out)?;
wrote = true;
}
}
if !wrote && setup_py.exists() {
let s = fs::read_to_string(&setup_py)?;
let re_setup_py = Regex::new(
r#"(?m)^(.*version\s*=\s*")([0-9A-Za-z.\-+]+)("\s*,?.*)$"#,
)?;
if re_setup_py.is_match(&s) {
let out = re_setup_py
.replace(&s, format!("${{1}}{}${{3}}", new))
.to_string();
fs::write(&setup_py, out)?;
wrote = true;
}
}
touched |= wrote;
}
let cargo = root.join("Cargo.toml");
if cargo.exists() {
if is_workspace(root)? {
let members = workspace_members(root)?;
for m in members {
let f = m.join("Cargo.toml");
if f.exists() {
write_rust_version_file(&f, new).ok();
}
}
write_rust_version_file(&cargo, new).ok();
} else {
write_rust_version_file(&cargo, new).ok();
}
touched = true;
}
Ok(touched)
}
pub fn write_one_package_version(
pkg: &Package,
new: &str,
) -> Result<()> {
match pkg.lang {
Lang::Js => {
let pj = pkg.path.join("package.json");
if !pj.exists() {
return Err(anyhow!(
"package.json not found for {}",
pkg.name
));
}
let s = fs::read_to_string(&pj)?;
let mut v: Json = serde_json::from_str(&s)?;
v["version"] = Json::String(new.to_string());
fs::write(&pj, serde_json::to_string_pretty(&v)? + "\n")?;
refresh_js_locks(&pkg.path).ok();
Ok(())
}
Lang::Py => {
let mut wrote = false;
let pyproj = pkg.path.join("pyproject.toml");
if pyproj.exists() {
let s = fs::read_to_string(&pyproj)?;
let mut out = String::new();
let mut in_poetry = false;
let mut in_project = false;
let mut done = false;
let re = Regex::new(
r#"(?m)^\s*version\s*=\s*"[0-9A-Za-z.\-+]+"\s*$"#,
)?;
for line in s.lines() {
let mut l = line.to_string();
if l.trim_start().starts_with('[') {
in_poetry = l.trim() == "[tool.poetry]";
in_project = l.trim() == "[project]";
}
if !done
&& (in_poetry || in_project)
&& re.is_match(&l)
{
l = re
.replace(
&l,
format!("version = \"{}\"", new),
)
.to_string();
done = true;
}
out.push_str(&l);
out.push('\n');
}
if done {
fs::write(&pyproj, out)?;
wrote = true;
}
}
if !wrote {
let scfg = pkg.path.join("setup.cfg");
if scfg.exists() {
let s = fs::read_to_string(&scfg)?;
let re = Regex::new(
r#"(?m)^(\s*version\s*=\s*)([0-9A-Za-z.\-+]+)(\s*)$"#,
)?;
if re.is_match(&s) {
let out = re
.replace(
&s,
format!("${{1}}{}${{3}}", new),
)
.to_string();
fs::write(&scfg, out)?;
wrote = true;
}
}
}
if !wrote {
let spy = pkg.path.join("setup.py");
if spy.exists() {
let s = fs::read_to_string(&spy)?;
let re = Regex::new(
r#"(?m)^(.*version\s*=\s*")([0-9A-Za-z.\-+]+)("\s*,?.*)$"#,
)?;
if re.is_match(&s) {
let out = re
.replace(
&s,
format!("${{1}}{}${{3}}", new),
)
.to_string();
fs::write(&spy, out)?;
wrote = true;
}
}
}
if !wrote {
return Err(anyhow!(
"no python version field found for {}",
pkg.name
));
}
Ok(())
}
Lang::Rust => {
let f = pkg.path.join("Cargo.toml");
if !f.exists() {
return Err(anyhow!(
"Cargo.toml not found for {}",
pkg.name
));
}
write_rust_version_file(&f, new)
}
}
}
fn write_rust_version_file(f: &Path, new: &str) -> Result<()> {
let s = fs::read_to_string(f)?;
let re = Regex::new(
r#"(?m)^\s*version\s*=\s*"[0-9A-Za-z.\-+]+"\s*$"#,
)?;
if !re.is_match(&s) {
eprintln!("[warn] no version field found in {}", f.display());
return Ok(());
}
let out =
re.replace(&s, format!("version = \"{}\"", new)).to_string();
fs::write(f, out)?;
eprintln!("[ok] bumped {} to {}", f.display(), new);
Ok(())
}
fn refresh_js_locks(root: &Path) -> Result<()> {
let has_pnpm_lock = root.join("pnpm-lock.yaml").exists();
let has_yarn_lock = root.join("yarn.lock").exists();
let has_npm_lock = root.join("package-lock.json").exists();
if has_pnpm_lock && which("pnpm") {
crate::util::run_quiet(
"pnpm",
&["install", "--lockfile-only"],
"pnpm lock refresh",
)
.ok();
}
if has_yarn_lock
&& which("yarn")
&& !crate::util::run_status(
"yarn",
&["install", "--mode", "update-lockfile"],
)
{
crate::util::run_status("yarn", &["install"]);
}
if has_npm_lock && which("npm") {
crate::util::run_quiet(
"npm",
&["install", "--package-lock-only"],
"npm lock refresh",
)
.ok();
}
Ok(())
}
fn which(cmd: &str) -> bool {
which::which(cmd).is_ok()
}
fn discover_js_packages(
root: &Path,
max_depth: usize,
) -> Result<Vec<PathBuf>> {
let mut out = vec![];
let mut q: VecDeque<(PathBuf, usize)> = VecDeque::new();
q.push_back((root.to_path_buf(), 0));
while let Some((dir, d)) = q.pop_front() {
if d > max_depth {
continue;
}
if skip_dir(&dir) {
continue;
}
let pj = dir.join("package.json");
if pj.exists() {
out.push(dir.clone());
}
for e in fs::read_dir(&dir)? {
let e = e?;
if e.file_type()?.is_dir() {
q.push_back((e.path(), d + 1));
}
}
}
Ok(out)
}
fn find_python_packages(
root: &Path,
max_depth: usize,
) -> Result<Vec<PathBuf>> {
let mut out = vec![];
let mut q: VecDeque<(PathBuf, usize)> = VecDeque::new();
q.push_back((root.to_path_buf(), 0));
while let Some((dir, d)) = q.pop_front() {
if d > max_depth {
continue;
}
if skip_dir(&dir) {
continue;
}
let has_py = dir.join("pyproject.toml").exists()
|| dir.join("setup.cfg").exists()
|| dir.join("setup.py").exists();
if has_py {
out.push(dir.clone());
}
for e in fs::read_dir(&dir)? {
let e = e?;
if e.file_type()?.is_dir() {
q.push_back((e.path(), d + 1));
}
}
}
Ok(out)
}
fn skip_dir(p: &Path) -> bool {
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
return matches!(
name,
"node_modules"
| "target"
| ".git"
| "dist"
| "build"
| ".venv"
| "venv"
);
}
false
}
pub fn match_file_to_package<'a>(
packages: &'a [Package],
file: &Path,
) -> Option<(&'a Package, Lang)> {
let file = norm(file);
let mut best: Option<&Package> = None;
for p in packages {
let pp = norm(&p.path);
if file.starts_with(&pp) {
best = match best {
None => Some(p),
Some(cur) => {
if pp.components().count()
> norm(&cur.path).components().count()
{
Some(p)
} else {
Some(cur)
}
}
};
}
}
best.map(|b| (b, b.lang.clone()))
}
fn norm(p: &Path) -> PathBuf {
use std::path::Component::*;
let root = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."));
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
};
let mut out = PathBuf::new();
for comp in abs.components() {
match comp {
CurDir => {}
ParentDir => {
out.pop();
}
RootDir => {
out.push(Path::new("/"));
}
Normal(s) => out.push(s),
Prefix(prefix) => out.push(prefix.as_os_str()),
}
}
out
}
impl Package {
pub fn tag_id(&self) -> String {
match self.lang {
Lang::Js => {
let base = if self.name.starts_with('@') {
self.name.trim_start_matches('@').to_string()
} else {
self.name.clone()
};
base.replace('/', "_")
}
_ => self.name.clone(),
}
}
}
pub fn make_pkg_tag(
pkg: &Package,
version: &str,
cfg: &Config,
) -> String {
let id = pkg.tag_id();
let name = &pkg.name;
let ver = if cfg.include_v_prefix {
format!("v{}", version)
} else {
version.to_string()
};
cfg.pkg_tag_template
.replace("{{id}}", &id)
.replace("{{name}}", name)
.replace("{{version}}", &ver)
}
pub fn last_tag_for_package(
pkg: &Package,
cfg: &Config,
) -> Result<Option<String>> {
let id = pkg.tag_id();
let name = &pkg.name;
let v_glob = if cfg.include_v_prefix { "v*" } else { "*" };
let pat = cfg
.pkg_tag_template
.replace("{{id}}", &id)
.replace("{{name}}", name)
.replace("{{version}}", v_glob);
let tags = crate::git::list_tags(&pat)?;
Ok(tags.first().cloned())
}
pub fn read_current_name(root: &Path) -> Option<String> {
if let Some(n) = read_js_name(root) {
return Some(n);
}
if let Some(n) = read_python_name(root) {
return Some(n);
}
if root.join("Cargo.toml").exists()
&& !is_workspace(root).ok().unwrap_or(false)
{
if let Some(n) = read_rust_name(root) {
return Some(n);
}
}
root.file_name().map(|s| s.to_string_lossy().to_string())
}