use crate::prelude::*;
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use regex::Regex;
use rusqlite::params;
use std::fs;
fn db_root_for(repo_root: &Path) -> PathBuf {
crate::workspace::find_super_root_or_project(repo_root)
}
fn db_path_for(repo_root: &Path) -> PathBuf {
db_root_for(repo_root).join(".wsb/wsb.db")
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StateConfig {
pub version: u32,
pub enabled: bool,
pub version_file: String,
#[serde(default = "default_auto_detect")]
pub auto_detect_project_files: bool,
#[serde(default)]
pub project_files: Vec<String>,
}
fn default_auto_detect() -> bool {
true
}
impl Default for StateConfig {
fn default() -> Self {
Self {
version: 1,
enabled: true,
version_file: "version.txt".to_string(),
auto_detect_project_files: true,
project_files: Vec::new(),
}
}
}
impl StateConfig {
pub fn load(repo_root: &Path) -> Result<Self> {
if tokio::runtime::Handle::try_current().is_ok() {
anyhow::bail!("StateConfig::load cannot be called from an async runtime; use StateConfig::load_async");
}
let db_path = db_path_for(repo_root);
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async { load_state_config_from_db(&db_path, repo_root).await })
}
pub fn save(&self, repo_root: &Path) -> Result<()> {
if tokio::runtime::Handle::try_current().is_ok() {
anyhow::bail!("StateConfig::save cannot be called from an async runtime; use StateConfig::save_async");
}
let db_path = db_path_for(repo_root);
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async { save_state_config_to_db(&db_path, self).await })
}
pub async fn load_async(repo_root: &Path) -> Result<Self> {
let db_path = db_path_for(repo_root);
load_state_config_from_db(&db_path, repo_root).await
}
pub async fn save_async(&self, repo_root: &Path) -> Result<()> {
let db_path = db_path_for(repo_root);
save_state_config_to_db(&db_path, self).await
}
}
pub fn calculate_workspace_version(repo_root: &Path) -> Result<VersionInfo> {
calculate_workspace_version_cached(repo_root, &crate::state::RunCache::new())
}
pub fn calculate_workspace_version_cached(
repo_root: &Path,
cache: &crate::state::RunCache,
) -> Result<VersionInfo> {
if tokio::runtime::Handle::try_current().is_ok() {
anyhow::bail!(
"calculate_workspace_version cannot be called from an async runtime; use calculate_workspace_version_async"
);
}
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(calculate_workspace_version_async_cached(repo_root, cache))
}
pub async fn calculate_workspace_version_async(repo_root: &Path) -> Result<VersionInfo> {
calculate_workspace_version_async_cached(repo_root, &crate::state::RunCache::new()).await
}
pub async fn calculate_workspace_version_async_cached(
repo_root: &Path,
cache: &crate::state::RunCache,
) -> Result<VersionInfo> {
let major_version = load_project_major_version_async(repo_root).await?;
VersionInfo::calculate_with_major_in_repo_cached(major_version, repo_root, cache)
}
async fn load_project_major_version_async(repo_root: &Path) -> Result<u32> {
let db_path = db_path_for(repo_root);
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent)?;
}
let service = open_initialized_state_db(&db_path).await?;
let major_from_db = tokio::task::spawn_blocking({
let service = service.clone();
move || {
let conn = service.get_connection()?;
let result: Result<i64, rusqlite::Error> =
conn.query_row("SELECT major_version FROM projects LIMIT 1", [], |row| {
row.get::<usize, i64>(0)
});
Ok::<Option<u32>, anyhow::Error>(result.ok().map(|v| v as u32))
}
})
.await??;
if let Some(major) = major_from_db {
return Ok(major);
}
read_major_version_from_file(repo_root).ok_or_else(|| {
anyhow::anyhow!(
"No project row in database and no version.txt found at {}. \
Run 'wsb init' or 'wsb update' to initialize version tracking.",
repo_root.display()
)
})
}
#[derive(Debug, Clone)]
pub struct VersionInfo {
pub major_version: String,
pub minor_version: u32,
pub patch_version: u32,
pub full_version: String,
}
#[derive(Debug, Clone)]
pub struct VersionCalculationInfo {
pub major_version: u32,
pub total_commits: u32,
pub changes_since_release: u32,
pub last_release_tag: Option<String>,
pub git_root: Option<PathBuf>,
pub calculation_method: String,
}
impl VersionInfo {
pub fn calculate_with_major_in_repo(major: u32, repo_root: &Path) -> Result<Self> {
Self::calculate_with_major_in_repo_cached(major, repo_root, &crate::state::RunCache::new())
}
pub fn calculate_with_major_in_repo_cached(
major: u32,
repo_root: &Path,
cache: &crate::state::RunCache,
) -> Result<Self> {
let (mut minor_version, mut patch_version) = version_counts(repo_root, major, cache)?;
let tree = crate::fleet::git::SubmoduleTree::discover_allow_empty(repo_root)?;
for node in tree.top_down() {
if !node.initialized {
continue;
}
let (commits, changes) = version_counts(&node.abs_path, major, cache)?;
minor_version += commits;
patch_version += changes;
}
let full_version = format!("{}.{}.{}", major, minor_version, patch_version);
let major_version = format!("v{}", major);
Ok(Self {
major_version,
minor_version,
patch_version,
full_version,
})
}
pub fn get_calculation_info_in_repo(
major: u32,
repo_root: &Path,
) -> Result<VersionCalculationInfo> {
let cache = crate::state::RunCache::new();
let (total_commits, changes_since_release) = version_counts(repo_root, major, &cache)?;
let last_release_tag = release_tags_in_repo(repo_root, major, &cache)?.first().cloned();
let git_root = get_git_root_in_repo(repo_root).ok();
Ok(VersionCalculationInfo {
major_version: major,
total_commits,
changes_since_release,
last_release_tag,
git_root,
calculation_method: "Database major + git commits + changes since release".to_string(),
})
}
}
pub fn validate_git_tag(tag: &str) -> Result<()> {
if tag.is_empty() {
anyhow::bail!("Tag cannot be empty");
}
if tag.chars().any(|c| c.is_whitespace()) {
anyhow::bail!("Tag cannot contain whitespace: '{}'", tag);
}
let invalid_chars = ['~', '^', ':', '?', '*', '[', '\\'];
for c in invalid_chars {
if tag.contains(c) {
anyhow::bail!("Tag contains invalid git ref character '{}': '{}'", c, tag);
}
}
if tag.contains("..") {
anyhow::bail!("Tag cannot contain '..': '{}'", tag);
}
if tag.contains("@{") {
anyhow::bail!("Tag cannot contain '@{{}}': '{}'", tag);
}
if tag.contains("//") {
anyhow::bail!("Tag cannot contain '//': '{}'", tag);
}
if tag.starts_with('.') || tag.starts_with('/') {
anyhow::bail!("Tag cannot start with '.' or '/': '{}'", tag);
}
if tag.ends_with('.') || tag.ends_with('/') || tag.ends_with(".lock") {
anyhow::bail!("Tag cannot end with '.', '/', or '.lock': '{}'", tag);
}
Ok(())
}
pub fn update_version_file_in_repo(
version_info: &VersionInfo,
config: &StateConfig,
repo_root: &Path,
) -> Result<bool> {
if config.version_file.is_empty() {
info!("No version file configured, skipping version file update");
return Ok(false);
}
let version_file_path = if Path::new(&config.version_file).is_absolute() {
PathBuf::from(&config.version_file)
} else {
repo_root.join(&config.version_file)
};
let current_version_content = if version_file_path.exists() {
fs::read_to_string(&version_file_path)
.unwrap_or_default()
.trim()
.to_string()
} else {
String::new()
};
if current_version_content == version_info.full_version {
info!(
"Version {} is already up to date",
version_info.full_version
);
return Ok(false);
}
fs::write(
&version_file_path,
format!("{}\n", version_info.full_version),
)
.with_context(|| format!("Failed to write version to {}", version_file_path.display()))?;
if config.auto_detect_project_files {
match detect_project_files(repo_root) {
Ok(project_files) => {
if !project_files.is_empty() {
match update_project_files(version_info, &project_files) {
Ok(updated_files) => {
if !updated_files.is_empty() {
info!("Updated project files: {}", updated_files.join(", "));
}
}
Err(e) => {
warn!("Failed to update some project files: {}", e);
crate::errln!("Warning: Failed to update some project files: {}", e);
}
}
}
}
Err(e) => {
warn!("Failed to detect project files: {}", e);
crate::errln!("Warning: Failed to detect project files: {}", e);
}
}
}
if !config.project_files.is_empty() {
let manual_files: Vec<ProjectFile> = config
.project_files
.iter()
.filter_map(|file_path| {
let full_path = repo_root.join(file_path);
if full_path.exists() {
detect_file_type(&full_path).map(|file_type| ProjectFile {
path: full_path,
file_type,
})
} else {
crate::errln!("Warning: Configured project file not found: {}", file_path);
None
}
})
.collect();
if !manual_files.is_empty() {
match update_project_files(version_info, &manual_files) {
Ok(updated_files) => {
if !updated_files.is_empty() {
crate::outln!(
"Updated configured project files: {}",
updated_files.join(", ")
);
}
}
Err(e) => {
crate::errln!("Warning: Failed to update configured project files: {}", e);
}
}
}
}
Ok(true)
}
fn detect_file_type(path: &Path) -> Option<ProjectFileType> {
let filename = path.file_name()?.to_str()?;
match filename {
"Cargo.toml" => Some(ProjectFileType::CargoToml),
"package.json" => Some(ProjectFileType::PackageJson),
"pyproject.toml" => Some(ProjectFileType::PyprojectToml),
"setup.py" => Some(ProjectFileType::SetupPy),
"composer.json" => Some(ProjectFileType::ComposerJson),
"pubspec.yaml" => Some(ProjectFileType::PubspecYaml),
"pom.xml" => Some(ProjectFileType::PomXml),
"build.gradle" => Some(ProjectFileType::BuildGradle),
"build.gradle.kts" => Some(ProjectFileType::BuildGradleKts),
"CMakeLists.txt" => Some(ProjectFileType::CMakeLists),
"Package.swift" => Some(ProjectFileType::PackageSwift),
"go.mod" => Some(ProjectFileType::GoMod),
"mix.exs" => Some(ProjectFileType::MixExs),
"build.sbt" => Some(ProjectFileType::BuildSbt),
"shard.yml" => Some(ProjectFileType::ShardYml),
"Project.toml" => Some(ProjectFileType::JuliaProject),
_ => {
if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
match extension {
"json" => Some(ProjectFileType::PackageJson),
"gemspec" => Some(ProjectFileType::Gemspec),
"csproj" => Some(ProjectFileType::Csproj),
_ => None,
}
} else {
None
}
}
}
}
pub fn is_git_repository_in(repo_root: &Path) -> bool {
crate::git_backend::is_git_repository(repo_root)
}
pub fn get_git_root_in_repo(repo_root: &Path) -> Result<PathBuf> {
crate::git_backend::repo_root(repo_root)
}
#[derive(Debug, Clone)]
pub struct ProjectFile {
pub path: PathBuf,
pub file_type: ProjectFileType,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProjectFileType {
CargoToml,
PackageJson,
PyprojectToml,
SetupPy,
ComposerJson,
PubspecYaml,
PomXml,
BuildGradle,
BuildGradleKts,
CMakeLists,
PackageSwift,
Gemspec,
Csproj,
GoMod,
MixExs,
BuildSbt,
ShardYml,
JuliaProject,
}
impl ProjectFileType {
pub fn file_name(&self) -> &'static str {
match self {
ProjectFileType::CargoToml => "Cargo.toml",
ProjectFileType::PackageJson => "package.json",
ProjectFileType::PyprojectToml => "pyproject.toml",
ProjectFileType::SetupPy => "setup.py",
ProjectFileType::ComposerJson => "composer.json",
ProjectFileType::PubspecYaml => "pubspec.yaml",
ProjectFileType::PomXml => "pom.xml",
ProjectFileType::BuildGradle => "build.gradle",
ProjectFileType::BuildGradleKts => "build.gradle.kts",
ProjectFileType::CMakeLists => "CMakeLists.txt",
ProjectFileType::PackageSwift => "Package.swift",
ProjectFileType::Gemspec => "*.gemspec",
ProjectFileType::Csproj => "*.csproj",
ProjectFileType::GoMod => "go.mod",
ProjectFileType::MixExs => "mix.exs",
ProjectFileType::BuildSbt => "build.sbt",
ProjectFileType::ShardYml => "shard.yml",
ProjectFileType::JuliaProject => "Project.toml",
}
}
}
pub fn detect_project_files(repo_root: &Path) -> Result<Vec<ProjectFile>> {
let mut project_files = Vec::new();
let exact_file_types = [
ProjectFileType::CargoToml,
ProjectFileType::PackageJson,
ProjectFileType::PyprojectToml,
ProjectFileType::SetupPy,
ProjectFileType::ComposerJson,
ProjectFileType::PubspecYaml,
ProjectFileType::PomXml,
ProjectFileType::BuildGradle,
ProjectFileType::BuildGradleKts,
ProjectFileType::CMakeLists,
ProjectFileType::PackageSwift,
ProjectFileType::GoMod,
ProjectFileType::MixExs,
ProjectFileType::BuildSbt,
ProjectFileType::ShardYml,
ProjectFileType::JuliaProject,
];
for file_type in &exact_file_types {
let file_path = repo_root.join(file_type.file_name());
if file_path.exists() {
project_files.push(ProjectFile {
path: file_path,
file_type: file_type.clone(),
});
}
}
if let Ok(entries) = std::fs::read_dir(repo_root) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
match ext {
"gemspec" => {
project_files.push(ProjectFile {
path,
file_type: ProjectFileType::Gemspec,
});
}
"csproj" => {
project_files.push(ProjectFile {
path,
file_type: ProjectFileType::Csproj,
});
}
_ => {}
}
}
}
}
}
Ok(project_files)
}
pub fn update_project_files(
version_info: &VersionInfo,
project_files: &[ProjectFile],
) -> Result<Vec<String>> {
let mut updated_files = Vec::new();
for project_file in project_files {
match update_project_file(version_info, project_file) {
Ok(()) => {
updated_files.push(project_file.path.display().to_string());
}
Err(e) => {
crate::errln!(
"Warning: Failed to update {}: {}",
project_file.path.display(),
e
);
}
}
}
Ok(updated_files)
}
pub fn update_project_file(version_info: &VersionInfo, project_file: &ProjectFile) -> Result<()> {
let content = fs::read_to_string(&project_file.path)
.with_context(|| format!("Failed to read {}", project_file.path.display()))?;
let updated_content = match project_file.file_type {
ProjectFileType::CargoToml => update_cargo_toml(&content, &version_info.full_version)?,
ProjectFileType::PackageJson => update_package_json(&content, &version_info.full_version)?,
ProjectFileType::PyprojectToml => {
update_pyproject_toml(&content, &version_info.full_version)?
}
ProjectFileType::SetupPy => update_setup_py(&content, &version_info.full_version)?,
ProjectFileType::ComposerJson => {
update_composer_json(&content, &version_info.full_version)?
}
ProjectFileType::PubspecYaml => update_pubspec_yaml(&content, &version_info.full_version)?,
ProjectFileType::PomXml => update_pom_xml(&content, &version_info.full_version)?,
ProjectFileType::BuildGradle => update_build_gradle(&content, &version_info.full_version)?,
ProjectFileType::BuildGradleKts => {
update_build_gradle_kts(&content, &version_info.full_version)?
}
ProjectFileType::CMakeLists => update_cmake_lists(&content, &version_info.full_version)?,
ProjectFileType::PackageSwift => {
update_package_swift(&content, &version_info.full_version)?
}
ProjectFileType::Gemspec => update_gemspec(&content, &version_info.full_version)?,
ProjectFileType::Csproj => update_csproj(&content, &version_info.full_version)?,
ProjectFileType::GoMod => update_go_mod(&content, &version_info.full_version)?,
ProjectFileType::MixExs => update_mix_exs(&content, &version_info.full_version)?,
ProjectFileType::BuildSbt => update_build_sbt(&content, &version_info.full_version)?,
ProjectFileType::ShardYml => update_shard_yml(&content, &version_info.full_version)?,
ProjectFileType::JuliaProject => {
update_julia_project(&content, &version_info.full_version)?
}
};
fs::write(&project_file.path, updated_content)
.with_context(|| format!("Failed to write updated {}", project_file.path.display()))?;
Ok(())
}
fn update_cargo_toml(content: &str, version: &str) -> Result<String> {
let mut parsed: toml::Value = content.parse().context("Failed to parse Cargo.toml")?;
if let Some(package) = parsed.get_mut("package").and_then(|p| p.as_table_mut()) {
package.insert(
"version".to_string(),
toml::Value::String(version.to_string()),
);
}
Ok(toml::to_string(&parsed)?)
}
fn update_package_json(content: &str, version: &str) -> Result<String> {
let mut parsed: serde_json::Value =
serde_json::from_str(content).context("Failed to parse package.json")?;
if let Some(obj) = parsed.as_object_mut() {
obj.insert(
"version".to_string(),
serde_json::Value::String(version.to_string()),
);
}
Ok(serde_json::to_string_pretty(&parsed)?)
}
fn update_pyproject_toml(content: &str, version: &str) -> Result<String> {
let mut parsed: toml::Value = content.parse().context("Failed to parse pyproject.toml")?;
if let Some(tool) = parsed.get_mut("tool") {
if let Some(poetry) = tool.get_mut("poetry").and_then(|p| p.as_table_mut()) {
poetry.insert(
"version".to_string(),
toml::Value::String(version.to_string()),
);
}
}
if let Some(project) = parsed.get_mut("project").and_then(|p| p.as_table_mut()) {
project.insert(
"version".to_string(),
toml::Value::String(version.to_string()),
);
}
Ok(toml::to_string(&parsed)?)
}
fn update_setup_py(content: &str, version: &str) -> Result<String> {
let version_regex = Regex::new(r#"version\s*=\s*["'][^"']*["']"#)
.context("Failed to create regex for setup.py")?;
let updated = version_regex.replace_all(content, &format!(r#"version="{}""#, version));
Ok(updated.to_string())
}
fn update_composer_json(content: &str, version: &str) -> Result<String> {
let mut parsed: serde_json::Value =
serde_json::from_str(content).context("Failed to parse composer.json")?;
if let Some(obj) = parsed.as_object_mut() {
obj.insert(
"version".to_string(),
serde_json::Value::String(version.to_string()),
);
}
Ok(serde_json::to_string_pretty(&parsed)?)
}
fn update_pubspec_yaml(content: &str, version: &str) -> Result<String> {
let version_regex =
Regex::new(r"(?m)^version:\s*.*$").context("Failed to create regex for pubspec.yaml")?;
let updated = version_regex.replace_all(content, &format!("version: {}", version));
Ok(updated.to_string())
}
fn update_pom_xml(content: &str, version: &str) -> Result<String> {
let version_regex =
Regex::new(r"<version>[^<]*</version>").context("Failed to create regex for pom.xml")?;
let updated = version_regex.replace(content, &format!("<version>{}</version>", version));
Ok(updated.to_string())
}
fn update_build_gradle(content: &str, version: &str) -> Result<String> {
let version_regex = Regex::new(r#"version\s*=\s*['"][^'"]*['"]"#)
.context("Failed to create regex for build.gradle")?;
let updated = version_regex.replace_all(content, &format!(r#"version = '{}'"#, version));
Ok(updated.to_string())
}
fn update_cmake_lists(content: &str, version: &str) -> Result<String> {
let version_regex = Regex::new(r"(?i)project\s*\([^)]*VERSION\s+[^\s)]+")
.context("Failed to create regex for CMakeLists.txt")?;
let updated = version_regex.replace_all(content, |caps: ®ex::Captures| {
let matched = caps.get(0).unwrap().as_str();
let version_part_regex = Regex::new(r"VERSION\s+[^\s)]+").unwrap();
version_part_regex
.replace(matched, &format!("VERSION {}", version))
.to_string()
});
Ok(updated.to_string())
}
fn update_build_gradle_kts(content: &str, version: &str) -> Result<String> {
let version_regex = Regex::new(r#"version\s*=\s*"[^"]*""#)
.context("Failed to create regex for build.gradle.kts")?;
let updated = version_regex.replace_all(content, &format!(r#"version = "{}""#, version));
Ok(updated.to_string())
}
fn update_package_swift(content: &str, version: &str) -> Result<String> {
let version_regex = Regex::new(r#"//\s*version:\s*[^\n]*"#)
.context("Failed to create regex for Package.swift version comment")?;
let updated = if version_regex.is_match(content) {
version_regex
.replace_all(content, &format!("// version: {}", version))
.to_string()
} else {
let pkg_version_regex = Regex::new(r#"version\s*:\s*"[^"]*""#)
.context("Failed to create regex for Package.swift")?;
if pkg_version_regex.is_match(content) {
pkg_version_regex
.replace_all(content, &format!(r#"version: "{}""#, version))
.to_string()
} else {
format!("// version: {}\n{}", version, content)
}
};
Ok(updated)
}
fn update_gemspec(content: &str, version: &str) -> Result<String> {
let version_regex = Regex::new(r#"(\w+)\.version\s*=\s*["'][^"']*["']"#)
.context("Failed to create regex for gemspec")?;
let updated = version_regex.replace_all(content, |caps: ®ex::Captures| {
let var_name = caps.get(1).unwrap().as_str();
format!(r#"{}.version = "{}""#, var_name, version)
});
Ok(updated.to_string())
}
fn update_csproj(content: &str, version: &str) -> Result<String> {
let version_regex = Regex::new(r"<Version>[^<]*</Version>")
.context("Failed to create regex for csproj Version")?;
let updated = if version_regex.is_match(content) {
version_regex
.replace_all(content, &format!("<Version>{}</Version>", version))
.to_string()
} else {
let pkg_version_regex = Regex::new(r"<PackageVersion>[^<]*</PackageVersion>")
.context("Failed to create regex for csproj PackageVersion")?;
if pkg_version_regex.is_match(content) {
pkg_version_regex
.replace_all(
content,
&format!("<PackageVersion>{}</PackageVersion>", version),
)
.to_string()
} else {
let asm_version_regex = Regex::new(r"<AssemblyVersion>[^<]*</AssemblyVersion>")
.context("Failed to create regex for csproj AssemblyVersion")?;
asm_version_regex
.replace_all(
content,
&format!("<AssemblyVersion>{}</AssemblyVersion>", version),
)
.to_string()
}
};
Ok(updated)
}
fn update_go_mod(content: &str, version: &str) -> Result<String> {
let version_regex = Regex::new(r"//\s*version:\s*[^\n]*")
.context("Failed to create regex for go.mod version comment")?;
let updated = if version_regex.is_match(content) {
version_regex
.replace_all(content, &format!("// version: {}", version))
.to_string()
} else {
let module_regex =
Regex::new(r"(module\s+[^\n]+)").context("Failed to create regex for go.mod module")?;
module_regex
.replace(content, &format!("$1\n// version: {}", version))
.to_string()
};
Ok(updated)
}
fn update_mix_exs(content: &str, version: &str) -> Result<String> {
let version_regex =
Regex::new(r#"version:\s*"[^"]*""#).context("Failed to create regex for mix.exs")?;
let updated = version_regex.replace_all(content, &format!(r#"version: "{}""#, version));
Ok(updated.to_string())
}
fn update_build_sbt(content: &str, version: &str) -> Result<String> {
let version_regex =
Regex::new(r#"version\s*:=\s*"[^"]*""#).context("Failed to create regex for build.sbt")?;
let updated = version_regex.replace_all(content, &format!(r#"version := "{}""#, version));
Ok(updated.to_string())
}
fn update_shard_yml(content: &str, version: &str) -> Result<String> {
let version_regex =
Regex::new(r"(?m)^version:\s*.*$").context("Failed to create regex for shard.yml")?;
let updated = version_regex.replace_all(content, &format!("version: {}", version));
Ok(updated.to_string())
}
fn update_julia_project(content: &str, version: &str) -> Result<String> {
let mut parsed: toml::Value = content
.parse()
.context("Failed to parse Julia Project.toml")?;
if let Some(table) = parsed.as_table_mut() {
table.insert(
"version".to_string(),
toml::Value::String(version.to_string()),
);
}
Ok(toml::to_string(&parsed)?)
}
async fn load_state_config_from_db(
db_path: &std::path::Path,
project_root: &Path,
) -> Result<StateConfig> {
let service = open_initialized_state_db(db_path).await?;
let major_from_file = read_major_version_from_file(project_root);
tokio::task::spawn_blocking({
let service = service.clone();
move || {
let conn = service.get_connection()?;
let mut stmt = conn.prepare(
r#"
SELECT version_file, auto_detect_project_files, project_files
FROM projects
LIMIT 1
"#,
)?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
let version_file: String = row.get(0)?;
let auto_detect: i32 = row.get(1)?;
let project_files_json: Option<String> = row.get(2)?;
let project_files: Vec<String> = if let Some(json_str) = project_files_json {
serde_json::from_str(&json_str).unwrap_or_default()
} else {
Vec::new()
};
Ok(StateConfig {
version: 1,
enabled: true,
version_file,
auto_detect_project_files: auto_detect != 0,
project_files,
})
} else {
let initial_major = major_from_file.unwrap_or(0);
let default_config = StateConfig::default();
create_default_project_with_config_sync(&conn, &default_config, initial_major)?;
Ok(default_config)
}
}
})
.await?
}
async fn save_state_config_to_db(db_path: &std::path::Path, config: &StateConfig) -> Result<()> {
let service = open_initialized_state_db(db_path).await?;
let project_files_json = serde_json::to_string(&config.project_files)?;
let auto_detect = if config.auto_detect_project_files {
1
} else {
0
};
let version_file = config.version_file.clone();
tokio::task::spawn_blocking({
let service = service.clone();
move || {
let conn = service.get_connection()?;
conn.execute(
r#"
UPDATE projects
SET version_file = ?1,
auto_detect_project_files = ?2,
project_files = ?3,
updated_at = datetime('now')
WHERE id = (SELECT id FROM projects LIMIT 1)
"#,
params![version_file, auto_detect, project_files_json],
)?;
Ok(())
}
})
.await?
}
async fn open_initialized_state_db(
db_path: &Path,
) -> Result<Arc<crate::persist::global::DatabaseService>> {
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent)?;
}
let config = crate::persist::global::DatabaseConfig::new(db_path);
let service = crate::persist::global::DatabaseService::new(&config)
.map_err(|e| anyhow::anyhow!("Failed to create entities service: {}", e))?;
Ok(Arc::new(service))
}
fn create_default_project_with_config_sync(
conn: &PooledConnection<SqliteConnectionManager>,
config: &StateConfig,
major_version: u32,
) -> Result<()> {
let project_files_json = serde_json::to_string(&config.project_files)?;
conn.execute(
r#"
INSERT INTO projects (
id, code, name, description, status, major_version,
version_file, auto_detect_project_files, project_files
) VALUES (
'P001', 'P001', 'Default Project', 'Auto-created project', 'active', ?4,
?1, ?2, ?3
)
"#,
params![
&config.version_file,
config.auto_detect_project_files as i32,
project_files_json,
major_version as i32
],
)?;
Ok(())
}
pub fn read_major_version_from_file(project_root: &Path) -> Option<u32> {
let version_path = project_root.join("version.txt");
let content = fs::read_to_string(&version_path).ok()?;
let first_segment = content.trim().split('.').next()?;
first_segment.trim_start_matches('v').parse().ok()
}
fn version_counts(
repo_root: &Path,
major: u32,
cache: &crate::state::RunCache,
) -> Result<(u32, u32)> {
let tags = release_tags_in_repo(repo_root, major, cache)?;
cache.version_counts(repo_root, major, || {
let repo = crate::git_backend::discover_repo(repo_root)?;
let pending = cache.is_being_committed(repo_root) || holds_changes_of_its_own(&repo, repo_root, cache)?;
crate::git_backend::version_counts(&repo, &tags, pending)
})
}
fn holds_changes_of_its_own(
repo: &gix::Repository,
repo_root: &Path,
cache: &crate::state::RunCache,
) -> Result<bool> {
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("{} has no working tree", repo_root.display()))?;
let workdir = crate::platform::canonicalize(workdir)
.with_context(|| format!("resolving the working tree of {}", repo_root.display()))?;
let project_root = crate::platform::canonicalize(repo_root)
.with_context(|| format!("resolving {}", repo_root.display()))?;
let written = update_output_paths(&project_root, &workdir, cache)?;
let entries = crate::git_backend::status_entries(repo)?;
Ok(entries
.iter()
.filter(|entry| entry.status != crate::git_backend::FileStatusKind::Ignored)
.any(|entry| !written.contains(&workdir.join(&entry.path))))
}
pub fn files_an_update_writes(repo_root: &Path) -> Result<std::collections::HashSet<PathBuf>> {
let place = crate::platform::canonicalize(repo_root)
.with_context(|| format!("resolving {}", repo_root.display()))?;
update_output_paths(&place, &place, &crate::state::RunCache::new())
}
fn update_output_paths(
project_root: &Path,
workdir: &Path,
cache: &crate::state::RunCache,
) -> Result<std::collections::HashSet<PathBuf>> {
let mut written = std::collections::HashSet::new();
let version_file = configured_version_file(project_root)?;
if !version_file.is_empty() {
written.insert(if Path::new(&version_file).is_absolute() {
PathBuf::from(&version_file)
} else {
project_root.join(&version_file)
});
}
let templates = cache.templates_under(workdir, || {
crate::search_backend::discover_files_with_suffix(workdir, ".wstemplate")
})?;
for template in templates.iter() {
let text = template.to_string_lossy();
let output = PathBuf::from(&text[..text.len() - ".wstemplate".len()]);
if let Some(lock) = crate::state::wstemplate::lockfile_followed_by(&output) {
written.insert(lock);
}
written.insert(output);
}
Ok(written)
}
fn configured_version_file(project_root: &Path) -> Result<String> {
let db_path = db_path_for(project_root);
if !db_path.exists() {
return Ok(StateConfig::default().version_file);
}
let conn = rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("opening {} to read the version file", db_path.display()))?;
let has_projects: bool = conn.query_row(
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'projects')",
[],
|row| row.get(0),
)?;
if !has_projects {
return Ok(StateConfig::default().version_file);
}
match conn.query_row("SELECT version_file FROM projects LIMIT 1", [], |row| row.get::<_, String>(0)) {
Ok(version_file) => Ok(version_file),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(StateConfig::default().version_file),
Err(e) => Err(anyhow::anyhow!("reading the version file from {}: {e}", db_path.display())),
}
}
fn release_tags_in_repo(
repo_root: &Path,
major: u32,
cache: &crate::state::RunCache,
) -> Result<Vec<String>> {
let tags = cache.discovered_tags(repo_root, || {
let repo = crate::git_backend::discover_repo(repo_root)?;
crate::git_backend::list_tags(&repo)
})?;
Ok(tags
.iter()
.filter(|tag| tag.starts_with(&format!("v{major}.")))
.cloned()
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_state_config_default() {
let config = StateConfig::default();
assert_eq!(config.version, 1);
assert!(config.enabled);
assert_eq!(config.version_file, "version.txt");
}
fn create_temp_state_config_file(dir: &Path, config: &StateConfig) -> Result<()> {
let config_path = dir.join(".state.json");
std::fs::write(&config_path, serde_json::to_string_pretty(config)?)?;
Ok(())
}
fn load_state_config_file_only(dir: &Path) -> Result<StateConfig> {
let config_path = dir.join(".state.json");
let content = std::fs::read_to_string(&config_path)?;
Ok(serde_json::from_str(&content)?)
}
#[test]
fn test_state_config_save_load() {
let temp_dir = TempDir::new().unwrap();
let config = StateConfig::default();
create_temp_state_config_file(temp_dir.path(), &config).unwrap();
let loaded_config = load_state_config_file_only(temp_dir.path()).unwrap();
assert_eq!(config.version, loaded_config.version);
assert_eq!(config.enabled, loaded_config.enabled);
assert_eq!(config.version_file, loaded_config.version_file);
}
#[test]
fn test_version_info_format() {
let version_info = VersionInfo {
major_version: "v1.0".to_string(),
minor_version: 5,
patch_version: 100,
full_version: "1.0.5.100".to_string(),
};
assert_eq!(version_info.full_version, "1.0.5.100");
}
#[test]
fn test11180_git_repository_is_answered_about_the_path_named() {
let temp = TempDir::new().unwrap();
let repo = temp.path().join("repo");
let plain = temp.path().join("plain");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&plain).unwrap();
crate::git_backend::init_repository(&repo).unwrap();
assert!(
is_git_repository_in(&repo),
"a directory that was just initialised is a repository"
);
assert!(
!is_git_repository_in(&plain),
"a plain directory beside it is not, and asking about one must not answer about \
the other"
);
}
#[test]
fn test_project_file_type_file_name() {
assert_eq!(ProjectFileType::CargoToml.file_name(), "Cargo.toml");
assert_eq!(ProjectFileType::PackageJson.file_name(), "package.json");
assert_eq!(ProjectFileType::PyprojectToml.file_name(), "pyproject.toml");
assert_eq!(ProjectFileType::SetupPy.file_name(), "setup.py");
assert_eq!(ProjectFileType::ComposerJson.file_name(), "composer.json");
assert_eq!(ProjectFileType::PubspecYaml.file_name(), "pubspec.yaml");
assert_eq!(ProjectFileType::PomXml.file_name(), "pom.xml");
assert_eq!(ProjectFileType::BuildGradle.file_name(), "build.gradle");
assert_eq!(
ProjectFileType::BuildGradleKts.file_name(),
"build.gradle.kts"
);
assert_eq!(ProjectFileType::CMakeLists.file_name(), "CMakeLists.txt");
assert_eq!(ProjectFileType::PackageSwift.file_name(), "Package.swift");
assert_eq!(ProjectFileType::Gemspec.file_name(), "*.gemspec");
assert_eq!(ProjectFileType::Csproj.file_name(), "*.csproj");
assert_eq!(ProjectFileType::GoMod.file_name(), "go.mod");
assert_eq!(ProjectFileType::MixExs.file_name(), "mix.exs");
assert_eq!(ProjectFileType::BuildSbt.file_name(), "build.sbt");
assert_eq!(ProjectFileType::ShardYml.file_name(), "shard.yml");
assert_eq!(ProjectFileType::JuliaProject.file_name(), "Project.toml");
}
#[test]
fn test_detect_file_type() {
let temp_dir = TempDir::new().unwrap();
let cargo_path = temp_dir.path().join("Cargo.toml");
assert_eq!(
detect_file_type(&cargo_path),
Some(ProjectFileType::CargoToml)
);
let package_path = temp_dir.path().join("package.json");
assert_eq!(
detect_file_type(&package_path),
Some(ProjectFileType::PackageJson)
);
let custom_json_path = temp_dir.path().join("custom.json");
assert_eq!(
detect_file_type(&custom_json_path),
Some(ProjectFileType::PackageJson)
);
let unknown_path = temp_dir.path().join("unknown.txt");
assert_eq!(detect_file_type(&unknown_path), None);
}
#[test]
fn test_detect_project_files() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"",
)
.unwrap();
fs::write(
temp_dir.path().join("package.json"),
"{\"Name\": \"test\", \"version\": \"1.0.0\"}",
)
.unwrap();
let project_files = detect_project_files(temp_dir.path()).unwrap();
assert_eq!(project_files.len(), 2);
let file_types: Vec<_> = project_files.iter().map(|f| &f.file_type).collect();
assert!(file_types.contains(&&ProjectFileType::CargoToml));
assert!(file_types.contains(&&ProjectFileType::PackageJson));
}
#[test]
fn test_update_cargo_toml() {
let content = r#"[package]
name = "test-package"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = "1.0"
"#;
let updated = update_cargo_toml(content, "1.2.3").unwrap();
assert!(updated.contains("version = \"1.2.3\""));
assert!(updated.contains("name = \"test-package\""));
assert!(updated.contains("serde = \"1.0\""));
}
#[test]
fn test_update_package_json() {
let content = r#"{
"name": "test-package",
"version": "1.0.0",
"description": "A test package",
"main": "index.js"
}"#;
let updated = update_package_json(content, "2.1.0").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&updated).unwrap();
assert_eq!(parsed["version"], "2.1.0");
assert_eq!(parsed["name"], "test-package");
}
#[test]
fn test_update_pyproject_toml() {
let content = r#"[tool.poetry]
name = "test-package"
version = "0.1.0"
description = "A test package"
[project]
name = "test-package"
version = "0.1.0"
"#;
let updated = update_pyproject_toml(content, "1.5.2").unwrap();
assert!(updated.contains("version = \"1.5.2\""));
let version_count = updated.matches("version = \"1.5.2\"").count();
assert_eq!(version_count, 2);
}
#[test]
fn test_update_setup_py() {
let content = r#"from setuptools import setup
setup(
name="test-package",
version="0.1.0",
description="A test package",
author="Test Author",
packages=["test_package"],
)
"#;
let updated = update_setup_py(content, "2.0.1").unwrap();
assert!(updated.contains("version=\"2.0.1\""));
assert!(updated.contains("name=\"test-package\""));
}
#[test]
fn test_update_composer_json() {
let content = r#"{
"name": "vendor/package",
"version": "1.0.0",
"description": "A test package",
"type": "library"
}"#;
let updated = update_composer_json(content, "1.5.0").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&updated).unwrap();
assert_eq!(parsed["version"], "1.5.0");
assert_eq!(parsed["name"], "vendor/package");
}
#[test]
fn test_update_pubspec_yaml() {
let content = r#"name: test_package
description: A test Flutter package
version: 1.0.0+1
environment:
sdk: ">=2.12.0 <4.0.0"
"#;
let updated = update_pubspec_yaml(content, "2.1.0+5").unwrap();
assert!(updated.contains("version: 2.1.0+5"));
assert!(updated.contains("name: test_package"));
}
#[test]
fn test_update_pom_xml() {
let content = r#"<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>test-project</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
</project>
"#;
let updated = update_pom_xml(content, "2.3.1").unwrap();
assert!(updated.contains("<version>2.3.1</version>"));
assert!(updated.contains("<artifactId>test-project</artifactId>"));
}
#[test]
fn test_update_build_gradle() {
let content = r#"plugins {
id 'java'
}
group = 'com.example'
version = '1.0.0'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
"#;
let updated = update_build_gradle(content, "3.2.1").unwrap();
assert!(updated.contains("version = '3.2.1'"));
assert!(updated.contains("group = 'com.example'"));
}
#[test]
fn test_update_cmake_lists() {
let content = r#"cmake_minimum_required(VERSION 3.16)
project(TestProject
VERSION 1.0.0
DESCRIPTION "A test project"
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 17)
"#;
let updated = update_cmake_lists(content, "2.1.5").unwrap();
assert!(updated.contains("VERSION 2.1.5"));
assert!(updated.contains("project(TestProject"));
}
#[test]
fn test_state_config_with_auto_detect() {
let temp_dir = TempDir::new().unwrap();
let config = StateConfig {
version: 1,
enabled: true,
version_file: "version.txt".to_string(),
auto_detect_project_files: true,
project_files: vec!["custom.toml".to_string()],
};
create_temp_state_config_file(temp_dir.path(), &config).unwrap();
let loaded_config = load_state_config_file_only(temp_dir.path()).unwrap();
assert_eq!(loaded_config.auto_detect_project_files, true);
assert_eq!(loaded_config.project_files, vec!["custom.toml"]);
}
#[test]
fn test_state_config_default_auto_detect() {
let temp_dir = TempDir::new().unwrap();
let config_content = r#"{
"version": 1,
"enabled": true,
"version_file": "version.txt"
}"#;
std::fs::write(temp_dir.path().join(".state.json"), config_content).unwrap();
let loaded_config = load_state_config_file_only(temp_dir.path()).unwrap();
assert_eq!(loaded_config.auto_detect_project_files, true);
assert!(loaded_config.project_files.is_empty());
}
#[test]
fn test11181_a_version_is_written_into_the_project_named() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("target");
let elsewhere = temp.path().join("elsewhere");
std::fs::create_dir_all(&target).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
std::fs::write(target.join("version.txt"), "1.0.0\n").unwrap();
std::fs::write(elsewhere.join("version.txt"), "9.9.9\n").unwrap();
let config = StateConfig::default();
let version_info = VersionInfo {
major_version: "v1".to_string(),
minor_version: 5,
patch_version: 100,
full_version: "1.5.100".to_string(),
};
let wrote = update_version_file_in_repo(&version_info, &config, &target).unwrap();
assert!(wrote, "the version changed, so it must have been written");
assert_eq!(
fs::read_to_string(target.join("version.txt")).unwrap().trim(),
"1.5.100"
);
assert_eq!(
fs::read_to_string(elsewhere.join("version.txt")).unwrap().trim(),
"9.9.9",
"no other project's version file may be touched"
);
}
#[test]
fn test11182_an_unchanged_version_is_not_rewritten() {
let temp = TempDir::new().unwrap();
let project = temp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join("version.txt"), "1.5.100\n").unwrap();
let config = StateConfig::default();
let version_info = VersionInfo {
major_version: "v1".to_string(),
minor_version: 5,
patch_version: 100,
full_version: "1.5.100".to_string(),
};
assert!(
!update_version_file_in_repo(&version_info, &config, &project).unwrap(),
"the recorded version already is this one, so nothing was written"
);
assert_eq!(
fs::read_to_string(project.join("version.txt")).unwrap(),
"1.5.100\n",
"the file must be left exactly as it was, trailing newline included"
);
}
fn run_git(repo_root: &Path, args: &[&str]) {
let repo = match args {
["init"] => crate::git_backend::init_repository(repo_root).unwrap(),
_ => crate::git_backend::open_repo(repo_root).unwrap(),
};
match args {
["init"] => {}
["config", "user.name", name] => {
let email = repo
.config_snapshot()
.string("user.email")
.map(|value| value.to_string())
.unwrap_or_else(|| "test@example.com".to_string());
crate::git_backend::configure_local_user(&repo, name, &email).unwrap();
}
["config", "user.email", email] => {
let name = repo
.config_snapshot()
.string("user.name")
.map(|value| value.to_string())
.unwrap_or_else(|| "Test User".to_string());
crate::git_backend::configure_local_user(&repo, &name, email).unwrap();
}
["add", path] => {
crate::git_backend::stage_paths(&repo, &[path.to_string()]).unwrap();
}
["commit", "-m", message] => {
crate::git_backend::commit_all(&repo, message).unwrap();
}
["tag", name] => {
crate::git_backend::create_tag(&repo, name, name).unwrap();
}
_ => panic!("unsupported git test helper command: {args:?}"),
}
}
#[tokio::test]
async fn test_calculate_workspace_version_uses_project_major_version_from_db() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
run_git(repo_root, &["init"]);
run_git(repo_root, &["config", "user.name", "Test User"]);
run_git(repo_root, &["config", "user.email", "test@example.com"]);
fs::write(repo_root.join("README.md"), "hello\n").unwrap();
run_git(repo_root, &["add", "README.md"]);
run_git(repo_root, &["commit", "-m", "initial"]);
let _ = StateConfig::load_async(repo_root).await.unwrap();
let db_path = db_path_for(repo_root);
let config = crate::persist::global::DatabaseConfig::new(&db_path);
let service = crate::persist::global::DatabaseService::new(&config).unwrap();
tokio::task::spawn_blocking({
let service = service.clone();
move || {
let conn = service.get_connection().unwrap();
conn.execute(
"UPDATE projects SET major_version = 7 WHERE id = (SELECT id FROM projects LIMIT 1)",
[],
)
.unwrap();
}
})
.await
.unwrap();
let expected = VersionInfo::calculate_with_major_in_repo(7, repo_root).unwrap();
let actual = calculate_workspace_version_async(repo_root).await.unwrap();
assert_eq!(actual.major_version, "v7");
assert_eq!(actual.full_version, expected.full_version);
}
#[test]
fn test_calculate_with_major_in_repo_uses_target_repo_history() {
let outer = TempDir::new().unwrap();
let outer_root = outer.path();
run_git(outer_root, &["init"]);
crate::workspace::exclude_state_dir(outer_root).unwrap();
run_git(outer_root, &["config", "user.name", "Test User"]);
run_git(outer_root, &["config", "user.email", "test@example.com"]);
fs::write(outer_root.join("README.md"), "outer\n").unwrap();
run_git(outer_root, &["add", "README.md"]);
run_git(outer_root, &["commit", "-m", "outer initial"]);
let inner = TempDir::new().unwrap();
let inner_root = inner.path();
run_git(inner_root, &["init"]);
crate::workspace::exclude_state_dir(inner_root).unwrap();
run_git(inner_root, &["config", "user.name", "Test User"]);
run_git(inner_root, &["config", "user.email", "test@example.com"]);
fs::write(inner_root.join("README.md"), "inner\n").unwrap();
run_git(inner_root, &["add", "README.md"]);
run_git(inner_root, &["commit", "-m", "inner initial"]);
fs::write(inner_root.join("CHANGELOG.md"), "more\n").unwrap();
run_git(inner_root, &["add", "CHANGELOG.md"]);
run_git(inner_root, &["commit", "-m", "inner second"]);
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(outer_root).unwrap();
let outer_info = VersionInfo::calculate_with_major_in_repo(1, outer_root);
let inner_info = VersionInfo::calculate_with_major_in_repo(1, inner_root);
std::env::set_current_dir(&original_dir).unwrap();
let outer_info = outer_info.unwrap();
let inner_info = inner_info.unwrap();
assert_eq!((outer_info.minor_version, outer_info.patch_version), (0, 0));
assert_eq!((inner_info.minor_version, inner_info.patch_version), (1, 1));
}
#[test]
fn test12553_a_version_survives_its_own_commit_and_tag() {
let dir = TempDir::new().unwrap();
let root = dir.path();
run_git(root, &["init"]);
crate::workspace::exclude_state_dir(root).unwrap();
run_git(root, &["config", "user.name", "Test User"]);
run_git(root, &["config", "user.email", "test@example.com"]);
fs::write(root.join("README.md"), "one\n").unwrap();
run_git(root, &["add", "README.md"]);
run_git(root, &["commit", "-m", "first"]);
let version = |what: &str| {
VersionInfo::calculate_with_major_in_repo(1, root)
.unwrap_or_else(|e| panic!("{what}: {e}"))
.full_version
};
fs::write(root.join("feature.txt"), "a\n").unwrap();
let while_committing = version("while committing");
run_git(root, &["add", "feature.txt"]);
run_git(root, &["commit", "-m", "feature"]);
assert_eq!(version("committed"), while_committing, "the commit moved its own version");
run_git(root, &["tag", &format!("v{while_committing}")]);
assert_eq!(version("tagged"), while_committing, "the release tag moved the version it names");
fs::write(root.join("version.txt"), format!("{while_committing}\n")).unwrap();
fs::write(root.join("Cargo.toml.wstemplate"), "[package]\n").unwrap();
run_git(root, &["add", "Cargo.toml.wstemplate"]);
run_git(root, &["commit", "-m", "template"]);
let with_template = version("with a template");
fs::write(root.join("Cargo.toml"), "[package]\nversion = \"local\"\n").unwrap();
assert_eq!(version("rendered"), with_template, "a rendered manifest moved the version");
fs::write(root.join("feature.txt"), "b\n").unwrap();
assert_ne!(version("changed"), with_template, "a change of the tree's own did not move the version");
}
#[test]
fn test12563_a_submodule_version_survives_its_render() {
let git = |dir: &Path, args: &[&str]| {
let output = crate::git_backend::git_command()
.args(args)
.current_dir(dir)
.output()
.expect("git runs");
assert!(
output.status.success(),
"git {args:?} in {}: {}",
dir.display(),
String::from_utf8_lossy(&output.stderr)
);
};
let dir = TempDir::new().unwrap();
let root = crate::platform::canonicalize(&dir.path()).unwrap();
let seed = root.join("seed");
fs::create_dir_all(&seed).unwrap();
git(&seed, &["init", "--quiet", "-b", "main"]);
git(&seed, &["config", "user.name", "Test User"]);
git(&seed, &["config", "user.email", "test@example.com"]);
fs::write(seed.join("README.md"), "one\n").unwrap();
fs::write(seed.join("version.txt"), "1.0.0\n").unwrap();
git(&seed, &["add", "-A"]);
git(&seed, &["commit", "--quiet", "-m", "first"]);
let workspace = root.join("ws");
fs::create_dir_all(&workspace).unwrap();
git(&workspace, &["init", "--quiet", "-b", "main"]);
crate::workspace::exclude_state_dir(&workspace).unwrap();
git(&workspace, &["config", "user.name", "Test User"]);
git(&workspace, &["config", "user.email", "test@example.com"]);
git(
&workspace,
&["-c", "protocol.file.allow=always", "submodule", "add", "--quiet", &seed.display().to_string(), "sub"],
);
git(&workspace, &["commit", "--quiet", "-m", "add sub"]);
let sub = workspace.join("sub");
crate::workspace::exclude_state_dir(&sub).unwrap();
assert!(sub.join(".git").is_file(), "the submodule is not laid out as the workspace's are");
let version = || VersionInfo::calculate_with_major_in_repo(1, &sub).unwrap().full_version;
let committed = version();
fs::write(sub.join("version.txt"), "1.9.9\n").unwrap();
assert_eq!(version(), committed, "a rendered version file moved a submodule's version");
fs::write(sub.join("README.md"), "two\n").unwrap();
assert_ne!(version(), committed, "a change of the submodule's own did not move its version");
}
#[test]
fn test12571_a_commit_of_outputs_alone_gets_the_version_it_will_have() {
let dir = TempDir::new().unwrap();
let root = dir.path();
run_git(root, &["init"]);
crate::workspace::exclude_state_dir(root).unwrap();
run_git(root, &["config", "user.name", "Test User"]);
run_git(root, &["config", "user.email", "test@example.com"]);
fs::write(root.join("README.md"), "one\n").unwrap();
run_git(root, &["add", "README.md"]);
run_git(root, &["commit", "-m", "first"]);
fs::write(root.join("version.txt"), "rendered before\n").unwrap();
run_git(root, &["add", "version.txt"]);
run_git(root, &["commit", "-m", "second"]);
fs::write(root.join("version.txt"), "rendered now\n").unwrap();
let committing = crate::state::RunCache::new();
committing.commit_is_being_made_in(root);
let given = VersionInfo::calculate_with_major_in_repo_cached(1, root, &committing).unwrap().full_version;
run_git(root, &["add", "version.txt"]);
run_git(root, &["commit", "-m", "the render"]);
let landed = VersionInfo::calculate_with_major_in_repo(1, root).unwrap().full_version;
assert_eq!(given, landed, "the commit was given another version than it has");
}
#[test]
fn test_update_cmake_lists_preserves_project_name() {
let content = r#"cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)
"#;
let updated = update_cmake_lists(content, "2.0.0").unwrap();
assert!(updated.contains("project(MyProject"));
assert!(updated.contains("VERSION 2.0.0"));
assert!(updated.contains("LANGUAGES CXX"));
}
#[tokio::test]
async fn test_version_calculation_with_git_tags() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
run_git(repo_root, &["init"]);
run_git(repo_root, &["config", "user.name", "Test User"]);
run_git(repo_root, &["config", "user.email", "test@example.com"]);
fs::write(repo_root.join("README.md"), "initial\n").unwrap();
run_git(repo_root, &["add", "README.md"]);
run_git(repo_root, &["commit", "-m", "initial commit"]);
run_git(repo_root, &["tag", "v1.0.0"]);
fs::write(repo_root.join("README.md"), "update\n").unwrap();
run_git(repo_root, &["add", "README.md"]);
run_git(repo_root, &["commit", "-m", "new feature"]);
let _ = StateConfig::load_async(repo_root).await.unwrap();
let db_path = db_path_for(repo_root);
let config = crate::persist::global::DatabaseConfig::new(&db_path);
let service = crate::persist::global::DatabaseService::new(&config).unwrap();
tokio::task::spawn_blocking({
let service = service.clone();
move || {
let conn = service.get_connection().unwrap();
conn.execute(
"UPDATE projects SET major_version = 1 WHERE id = (SELECT id FROM projects LIMIT 1)",
[],
)
.unwrap();
}
})
.await
.unwrap();
let version = calculate_workspace_version_async(repo_root).await.unwrap();
assert_eq!(version.major_version, "v1");
assert_eq!(version.patch_version, 1);
}
#[test]
fn test_read_major_version_from_file_extracts_major_segment() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
assert_eq!(read_major_version_from_file(project_root), None);
fs::write(project_root.join("version.txt"), "5.3.1").unwrap();
assert_eq!(read_major_version_from_file(project_root), Some(5));
fs::write(project_root.join("version.txt"), "0.0.0").unwrap();
assert_eq!(read_major_version_from_file(project_root), Some(0));
fs::write(project_root.join("version.txt"), "12.1.99\n").unwrap();
assert_eq!(read_major_version_from_file(project_root), Some(12));
fs::write(project_root.join("version.txt"), "v3.0.0").unwrap();
assert_eq!(read_major_version_from_file(project_root), Some(3));
fs::write(project_root.join("version.txt"), "not-a-version").unwrap();
assert_eq!(read_major_version_from_file(project_root), None);
}
}