use crate::core::config::WorkspaceConfig;
use anyhow::{Context, Result};
use std::path::Path;
use toml_edit::{DocumentMut, Item, value};
pub fn cli_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn check_alef_toml_version(workspace: &WorkspaceConfig) -> Result<()> {
let Some(pin) = workspace.alef_version.as_deref() else {
return Ok(());
};
let cli = cli_version();
let (Ok(pin_v), Ok(cli_v)) = (semver::Version::parse(pin), semver::Version::parse(cli)) else {
tracing::warn!(
"alef.toml `[workspace] alef_version = \"{pin}\"` is not valid semver; it will be reset to the running CLI version {cli}"
);
return Ok(());
};
match cli_v.cmp(&pin_v) {
std::cmp::Ordering::Greater => {
tracing::info!("Upgrading alef pin {pin} → {cli} (running a newer alef)");
}
std::cmp::Ordering::Less => {
tracing::warn!(
"Running alef {cli} is older than the pinned alef_version {pin} in alef.toml; \
the pin will be lowered to {cli}"
);
}
std::cmp::Ordering::Equal => {}
}
Ok(())
}
pub fn write_alef_toml_version(config_path: &Path) -> Result<()> {
let cli = cli_version();
let content =
std::fs::read_to_string(config_path).with_context(|| format!("failed to read {}", config_path.display()))?;
let mut doc: DocumentMut = content
.parse()
.with_context(|| format!("failed to parse {} as TOML", config_path.display()))?;
if !doc.contains_key("workspace") {
let mut tbl = toml_edit::Table::new();
tbl.set_implicit(false);
doc.insert("workspace", Item::Table(tbl));
}
let workspace = doc["workspace"]
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("[workspace] in {} is not a table", config_path.display()))?;
let already_current = workspace
.get("alef_version")
.and_then(|v| v.as_str())
.map(|s| s == cli)
.unwrap_or(false);
if already_current {
return Ok(());
}
workspace["alef_version"] = value(cli);
let new_content = doc.to_string();
std::fs::write(config_path, &new_content).with_context(|| format!("failed to write {}", config_path.display()))?;
tracing::info!("Updated {} `[workspace] alef_version` to {cli}", config_path.display());
Ok(())
}
fn is_alef_hooks_repo_line(line: &str) -> bool {
let trimmed = line.trim_start();
if !trimmed.starts_with("- repo:") {
return false;
}
let Some((_, url)) = trimmed.split_once(':') else {
return false;
};
let url = url.trim().trim_end_matches('/').to_ascii_lowercase();
let path = url.strip_suffix(".git").unwrap_or(&url);
path.rsplit_once('/').is_some_and(|(_, tail)| tail == "alef")
}
pub fn sync_precommit_alef_rev(repo_root: &Path) -> Result<()> {
let path = repo_root.join(".pre-commit-config.yaml");
let Ok(content) = std::fs::read_to_string(&path) else {
return Ok(()); };
let cli = cli_version();
let new_rev = format!("v{cli}");
let mut lines: Vec<String> = content.lines().map(str::to_owned).collect();
let Some(repo_idx) = lines.iter().position(|l| is_alef_hooks_repo_line(l)) else {
return Ok(()); };
let mut rev_idx = None;
for (idx, line) in lines.iter().enumerate().skip(repo_idx + 1) {
let trimmed = line.trim_start();
if trimmed.starts_with("- repo:") {
break;
}
if trimmed.starts_with("rev:") {
rev_idx = Some(idx);
break;
}
}
let Some(rev_idx) = rev_idx else {
return Ok(()); };
let line = &lines[rev_idx];
let indent_len = line.len() - line.trim_start().len();
let indent = line[..indent_len].to_owned();
let raw_value = line.trim_start().strip_prefix("rev:").unwrap_or("").trim();
let quoted = raw_value.starts_with('"');
let old_rev = raw_value.trim_matches('"').to_owned();
if old_rev == new_rev {
return Ok(()); }
let parse = |s: &str| semver::Version::parse(s.trim_start_matches('v')).ok();
match (parse(&old_rev), parse(cli)) {
(Some(old), Some(new)) if new > old => {
tracing::info!(
"Upgrading alef pre-commit hook rev {old_rev} → {new_rev} in {}",
path.display()
);
}
(Some(old), Some(new)) if new < old => {
tracing::warn!(
"Lowering alef pre-commit hook rev {old_rev} → {new_rev} in {} (running an older alef)",
path.display()
);
}
_ => {}
}
let new_value = if quoted { format!("\"{new_rev}\"") } else { new_rev };
lines[rev_idx] = format!("{indent}rev: {new_value}");
let mut out = lines.join("\n");
if content.ends_with('\n') {
out.push('\n');
}
std::fs::write(&path, out).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn workspace_with_version(v: Option<&str>) -> WorkspaceConfig {
let mut toml = String::new();
if let Some(version) = v {
toml.push_str(&format!("alef_version = \"{version}\"\n"));
}
toml::from_str(&toml).expect("valid workspace config")
}
#[test]
fn missing_pin_is_compatible() {
let ws = workspace_with_version(None);
assert!(check_alef_toml_version(&ws).is_ok());
}
#[test]
fn pin_equal_to_cli_passes() {
let ws = workspace_with_version(Some(cli_version()));
assert!(check_alef_toml_version(&ws).is_ok());
}
#[test]
fn pin_lower_than_cli_passes() {
let ws = workspace_with_version(Some("0.0.1"));
assert!(check_alef_toml_version(&ws).is_ok());
}
#[test]
fn pin_higher_than_cli_warns_not_errors() {
let ws = workspace_with_version(Some("999.0.0"));
assert!(
check_alef_toml_version(&ws).is_ok(),
"a downgrade must warn, not hard-error"
);
}
#[test]
fn pin_invalid_semver_warns_not_errors() {
let ws = workspace_with_version(Some("not-a-version"));
assert!(
check_alef_toml_version(&ws).is_ok(),
"an unparseable pin must warn and continue, not error"
);
}
#[test]
fn write_replaces_existing_workspace_alef_version() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("alef.toml");
fs::write(
&path,
"[workspace]\nalef_version = \"0.0.1\"\nlanguages = []\n\n[[crates]]\nname = \"x\"\nsources = []\n",
)
.unwrap();
write_alef_toml_version(&path).expect("write ok");
let updated = fs::read_to_string(&path).unwrap();
assert!(
updated.contains(&format!("alef_version = \"{}\"", cli_version())),
"alef.toml must contain CLI version after write: {updated}"
);
assert!(!updated.contains("0.0.1"), "old version must be gone: {updated}");
}
#[test]
fn write_inserts_pin_when_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("alef.toml");
fs::write(
&path,
"[workspace]\nlanguages = []\n\n[[crates]]\nname = \"x\"\nsources = []\n",
)
.unwrap();
write_alef_toml_version(&path).expect("write ok");
let updated = fs::read_to_string(&path).unwrap();
assert!(
updated.contains(&format!("alef_version = \"{}\"", cli_version())),
"pin must appear in [workspace]: {updated}"
);
}
#[test]
fn write_creates_workspace_section_when_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("alef.toml");
fs::write(&path, "[[crates]]\nname = \"x\"\nsources = []\n").unwrap();
write_alef_toml_version(&path).expect("write ok");
let updated = fs::read_to_string(&path).unwrap();
assert!(
updated.contains("[workspace]"),
"[workspace] must be inserted: {updated}"
);
assert!(
updated.contains(&format!("alef_version = \"{}\"", cli_version())),
"alef_version must be set under [workspace]: {updated}"
);
}
#[test]
fn write_does_not_clobber_dependency_version_specs() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("alef.toml");
fs::write(
&path,
"[workspace]\nalef_version = \"0.0.1\"\nlanguages = []\n\n[[crates]]\nname = \"x\"\nsources = []\n\n[crates.extra_dependencies.something]\nversion = \"1.2.3\"\n",
)
.unwrap();
write_alef_toml_version(&path).expect("write ok");
let updated = fs::read_to_string(&path).unwrap();
assert!(
updated.contains("version = \"1.2.3\""),
"dependency version under [crates.extra_dependencies.something] must be untouched: {updated}"
);
assert!(
!updated.contains("alef_version = \"0.0.1\""),
"old alef_version must be replaced: {updated}"
);
}
#[test]
fn precommit_sync_bumps_alef_rev_and_leaves_other_repos_untouched() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".pre-commit-config.yaml");
fs::write(
&path,
"repos:\n - repo: https://github.com/astral-sh/ruff-pre-commit\n rev: v0.14.0\n hooks:\n - id: ruff\n - repo: https://github.com/sample_crate-dev/alef\n rev: v0.0.1\n hooks:\n - id: alef-verify\n - id: alef-sync-versions\n",
)
.unwrap();
sync_precommit_alef_rev(dir.path()).expect("sync ok");
let updated = fs::read_to_string(&path).unwrap();
assert!(
updated.contains(&format!("rev: v{}", cli_version())),
"alef hook rev must be bumped to v<cli>: {updated}"
);
assert!(!updated.contains("rev: v0.0.1"), "old alef rev must be gone: {updated}");
assert!(
updated.contains("rev: v0.14.0"),
"the ruff hook's rev must be untouched: {updated}"
);
}
#[test]
fn precommit_sync_preserves_quoted_rev_style() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".pre-commit-config.yaml");
fs::write(
&path,
"repos:\n - repo: https://github.com/sample_crate-dev/alef\n rev: \"v0.0.1\"\n hooks:\n - id: alef-verify\n",
)
.unwrap();
sync_precommit_alef_rev(dir.path()).expect("sync ok");
let updated = fs::read_to_string(&path).unwrap();
assert!(
updated.contains(&format!("rev: \"v{}\"", cli_version())),
"quoted rev style must be preserved: {updated}"
);
}
#[test]
fn precommit_sync_noops_when_absent_or_local() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(sync_precommit_alef_rev(dir.path()).is_ok());
let path = dir.path().join(".pre-commit-config.yaml");
let local = "repos:\n - repo: local\n hooks:\n - id: alef-verify\n entry: alef verify\n language: system\n";
fs::write(&path, local).unwrap();
sync_precommit_alef_rev(dir.path()).expect("sync ok");
assert_eq!(
fs::read_to_string(&path).unwrap(),
local,
"local-hook config must be untouched"
);
}
}