use crate::prelude::*;
use regex::Regex;
use std::fs;
use tera::{Context as TeraContext, Tera};
use walkdir::WalkDir;
use crate::state::{StateConfig, VersionInfo};
#[derive(Debug)]
pub struct RenderedTemplate {
pub source_path: PathBuf,
pub output_path: PathBuf,
}
pub struct WstemplateEngine {
current_version: VersionInfo,
current_name: Option<String>,
self_alias: String,
project_root: PathBuf,
scan_root: PathBuf,
}
impl WstemplateEngine {
pub fn new(
current_version: VersionInfo,
current_name: Option<String>,
self_alias: String,
project_root: PathBuf,
scan_root: PathBuf,
) -> Self {
Self {
current_version,
current_name,
self_alias,
project_root,
scan_root,
}
}
pub fn derive_alias(path: &Path) -> String {
let base = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project");
let raw: String = base
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
let mut slug = String::with_capacity(raw.len());
let mut prev_underscore = true; for ch in raw.chars() {
if ch == '_' {
if !prev_underscore {
slug.push('_');
}
prev_underscore = true;
} else {
slug.push(ch);
prev_underscore = false;
}
}
let slug = slug.trim_end_matches('_').to_string();
if slug.is_empty() {
return "project".to_string();
}
if slug.starts_with(|c: char| c.is_ascii_digit()) {
format!("p_{}", slug)
} else {
slug
}
}
pub fn discover_all(&self) -> Result<Vec<PathBuf>> {
discover_in(&self.scan_root)
}
pub fn discover_project_roots(&self) -> Result<HashMap<String, PathBuf>> {
find_all_project_roots(&self.scan_root)
}
pub fn discover_relevant(
&self,
project_roots: &HashMap<String, PathBuf>,
) -> Result<Vec<PathBuf>> {
let all = self.discover_all()?;
let mut relevant = Vec::new();
for path in all {
if self.is_own_template(&path, project_roots) {
relevant.push(path);
continue;
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Cannot read template {}", path.display()))?;
if references_alias(&content, &self.self_alias) {
relevant.push(path);
}
}
Ok(relevant)
}
pub fn render_relevant(&self) -> Result<Vec<RenderedTemplate>> {
let project_roots = find_all_project_roots(&self.scan_root)?;
let relevant = self.discover_relevant(&project_roots)?;
let mut rendered = Vec::new();
for path in &relevant {
let content = fs::read_to_string(path)
.with_context(|| format!("Cannot read template {}", path.display()))?;
let is_own = self.is_own_template(path, &project_roots);
let owning_version = self.find_owning_project_version(path, &project_roots, is_own)?;
let owning_name = if is_own {
self.current_name.as_deref()
} else {
None };
let ctx = build_context_for_template(
&content,
path,
&owning_version,
owning_name,
&self.self_alias,
&self.current_version,
self.current_name.as_deref(),
&project_roots,
)
.with_context(|| format!("Cannot build render context for {}", path.display()))?;
let r = render_one_with_content(path, &content, &ctx)
.with_context(|| format!("Cannot render template {}", path.display()))?;
info!(
"wstemplate: {} -> {}",
r.source_path.display(),
r.output_path.display()
);
rendered.push(r);
}
Ok(rendered)
}
}
impl WstemplateEngine {
fn is_own_template(
&self,
template_path: &Path,
project_roots: &HashMap<String, PathBuf>,
) -> bool {
if !template_path.starts_with(&self.project_root) {
return false;
}
let self_depth = self.project_root.components().count();
for root in project_roots.values() {
if root == &self.project_root {
continue;
}
if template_path.starts_with(root) && root.components().count() > self_depth {
return false; }
}
true
}
fn find_owning_project_version(
&self,
template_path: &Path,
project_roots: &HashMap<String, PathBuf>,
is_own: bool,
) -> Result<VersionInfo> {
if is_own {
return Ok(self.current_version.clone());
}
if let Some(root) = find_most_specific_root(template_path, project_roots) {
return read_version_from_path(&root);
}
anyhow::bail!(
"Cannot determine the owning project for template at {}. \
The template is not under any project registered in {}",
template_path.display(),
self.scan_root.display()
);
}
}
#[derive(Deserialize)]
struct StateSnapshot {
project_root: PathBuf,
#[serde(default)]
wstemplate_entries: Vec<StateEntry>,
}
#[derive(Deserialize)]
struct StateEntry {
alias: String,
#[allow(dead_code)]
root: PathBuf,
}
fn find_all_project_roots(scan_root: &Path) -> Result<HashMap<String, PathBuf>> {
let mut map: HashMap<String, PathBuf> = HashMap::new();
for entry in WalkDir::new(scan_root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
if e.depth() == 0 {
return true;
}
let name = e.file_name().to_str().unwrap_or("");
if name.starts_with('.') && name != ".wsb" {
return false;
}
!matches!(name, "target" | "node_modules" | "__pycache__")
})
{
let entry = match entry {
Ok(e) => e,
Err(_) => continue, };
if !entry.file_type().is_file() {
continue;
}
if entry.file_name() != "state.json" {
continue;
}
let is_in_ws = entry
.path()
.parent()
.and_then(|p| p.file_name())
.map(|n| n == ".wsb")
.unwrap_or(false);
if !is_in_ws {
continue;
}
let content = match fs::read_to_string(entry.path()) {
Ok(c) => c,
Err(_) => continue, };
let snapshot: StateSnapshot = match serde_json::from_str(&content) {
Ok(s) => s,
Err(_) => continue, };
if snapshot.wstemplate_entries.is_empty() {
let alias = WstemplateEngine::derive_alias(&snapshot.project_root);
if let Some(existing) = map.get(&alias) {
anyhow::bail!(
"Duplicate wstemplate alias '{}': registered by both {} and {}. \
Each alias must be unique across the workspace.",
alias,
existing.display(),
snapshot.project_root.display()
);
}
map.insert(alias, snapshot.project_root.clone());
continue;
}
for ws_entry in &snapshot.wstemplate_entries {
if let Some(existing) = map.get(&ws_entry.alias) {
anyhow::bail!(
"Duplicate wstemplate alias '{}': registered by both {} and {}. \
Each alias must be unique across the workspace.",
ws_entry.alias,
existing.display(),
snapshot.project_root.display()
);
}
map.insert(ws_entry.alias.clone(), snapshot.project_root.clone());
}
}
Ok(map)
}
fn find_most_specific_root(
path: &Path,
project_roots: &HashMap<String, PathBuf>,
) -> Option<PathBuf> {
project_roots
.values()
.filter(|root| path.starts_with(root.as_path()))
.max_by_key(|root| root.components().count())
.cloned()
}
pub fn read_version_from_path(project_root: &Path) -> Result<VersionInfo> {
let version_file = project_root.join("version.txt");
if !version_file.exists() {
materialize_version_file(project_root)?;
}
let content = fs::read_to_string(&version_file).with_context(|| {
format!(
"Cannot read version.txt for project at {} after attempting automatic regeneration.",
project_root.display()
)
})?;
parse_version_string(content.trim())
}
fn materialize_version_file(project_root: &Path) -> Result<()> {
let (config, version_info) = if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| {
let handle = tokio::runtime::Handle::current();
let config = handle.block_on(StateConfig::load_async(project_root));
let version_info = handle.block_on(crate::state::calculate_workspace_version_async(project_root));
Ok::<_, anyhow::Error>((config, version_info))
})?
} else {
(
StateConfig::load(project_root),
crate::state::calculate_workspace_version(project_root),
)
};
let config = config.with_context(|| {
format!(
"failed to load workspace state for project at {} while regenerating version.txt",
project_root.display()
)
})?;
let version_info = version_info.with_context(|| {
format!(
"failed to calculate workspace version for project at {} while regenerating version.txt",
project_root.display()
)
})?;
crate::state::update_version_file_in_repo(&version_info, &config, project_root).with_context(|| {
format!(
"failed to write version.txt for project at {} during automatic regeneration",
project_root.display()
)
})?;
Ok(())
}
pub fn parse_version_string(s: &str) -> Result<VersionInfo> {
let parts: Vec<&str> = s.splitn(3, '.').collect();
anyhow::ensure!(
parts.len() == 3,
"Invalid version string '{}': expected major.minor.patch",
s
);
let major: u32 = parts[0]
.parse()
.with_context(|| format!("Invalid major in version string '{}'", s))?;
let minor: u32 = parts[1]
.parse()
.with_context(|| format!("Invalid minor in version string '{}'", s))?;
let patch: u32 = parts[2]
.parse()
.with_context(|| format!("Invalid patch in version string '{}'", s))?;
Ok(VersionInfo {
major_version: format!("v{}", major),
minor_version: minor,
patch_version: patch,
full_version: s.to_string(),
})
}
fn references_alias(template_text: &str, alias: &str) -> bool {
template_text.contains(&format!("projects.{}", alias))
}
fn extract_referenced_aliases(template_text: &str) -> Vec<String> {
let re = Regex::new(r"\bprojects\.(\w+)").expect("static regex must compile");
let mut aliases: Vec<String> = re
.captures_iter(template_text)
.map(|cap| cap[1].to_string())
.collect();
aliases.sort();
aliases.dedup();
aliases
}
fn project_map(vi: &VersionInfo, name: Option<&str>) -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("version".to_string(), vi.full_version.clone());
m.insert("major_version".to_string(), vi.major_version.clone());
m.insert("minor_version".to_string(), vi.minor_version.to_string());
m.insert("patch_version".to_string(), vi.patch_version.to_string());
if let Some(n) = name {
m.insert("name".to_string(), n.to_string());
}
m
}
#[allow(clippy::too_many_arguments)]
fn build_context_for_template(
template_text: &str,
template_path: &Path,
owning_version: &VersionInfo,
owning_name: Option<&str>,
self_alias: &str,
self_version: &VersionInfo,
self_name: Option<&str>,
project_roots: &HashMap<String, PathBuf>,
) -> Result<TeraContext> {
let mut ctx = TeraContext::new();
ctx.insert("project", &project_map(owning_version, owning_name));
let mut projects: HashMap<String, HashMap<String, String>> = HashMap::new();
projects.insert(self_alias.to_string(), project_map(self_version, self_name));
for alias in extract_referenced_aliases(template_text) {
if alias == self_alias {
continue; }
let root = project_roots.get(&alias).ok_or_else(|| {
let mut known: Vec<&str> = project_roots.keys().map(|s| s.as_str()).collect();
known.sort();
anyhow::anyhow!(
"Template {} references alias '{}' which cannot be resolved.\n\
Known aliases: [{}]\n\
Either the project is missing a .wsb/state.json or its alias doesn't match.",
template_path.display(),
alias,
known.join(", "),
)
})?;
let version = read_version_from_path(root)
.with_context(|| format!("Cannot resolve version for alias '{}'", alias))?;
projects.insert(alias, project_map(&version, None));
}
ctx.insert("projects", &projects);
let now = chrono::Local::now();
let mut datetime: HashMap<String, String> = HashMap::new();
datetime.insert("iso".to_string(), now.to_rfc3339());
datetime.insert("date".to_string(), now.format("%Y-%m-%d").to_string());
datetime.insert("time".to_string(), now.format("%H:%M:%S").to_string());
datetime.insert("year".to_string(), now.format("%Y").to_string());
datetime.insert("month".to_string(), now.format("%m").to_string());
datetime.insert("day".to_string(), now.format("%d").to_string());
ctx.insert("datetime", &datetime);
Ok(ctx)
}
fn render_one_with_content(
template_path: &Path,
content: &str,
ctx: &TeraContext,
) -> Result<RenderedTemplate> {
let path_str = template_path.to_string_lossy();
anyhow::ensure!(
path_str.ends_with(".wstemplate"),
"Template path does not end with .wstemplate: {}",
template_path.display()
);
let output_path = PathBuf::from(&path_str[..path_str.len() - ".wstemplate".len()]);
let mut tera = Tera::default();
let tpl_name = template_path.display().to_string();
tera.add_raw_template(&tpl_name, content)
.with_context(|| format!("Tera parse error in {}", template_path.display()))?;
let rendered = tera
.render(&tpl_name, ctx)
.with_context(|| format!("Tera render error in {}", template_path.display()))?;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"Cannot create parent directory for {}",
output_path.display()
)
})?;
}
fs::write(&output_path, &rendered)
.with_context(|| format!("Cannot write {}", output_path.display()))?;
Ok(RenderedTemplate {
source_path: template_path.to_path_buf(),
output_path,
})
}
fn discover_in(root: &Path) -> Result<Vec<PathBuf>> {
crate::search_backend::discover_files_with_suffix(root, ".wstemplate")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use crate::state::StateConfig;
use tempfile::TempDir;
fn make_vi(version: &str) -> VersionInfo {
let parts: Vec<&str> = version.splitn(3, '.').collect();
let major: u32 = parts[0].parse().unwrap_or(0);
let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let patch: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
VersionInfo {
major_version: format!("v{}", major),
minor_version: minor,
patch_version: patch,
full_version: version.to_string(),
}
}
fn make_project(
parent: &Path,
name: &str,
alias: &str,
version: &str,
scan_root: &Path,
) -> PathBuf {
let project_dir = parent.join(name);
fs::create_dir_all(&project_dir).unwrap();
fs::create_dir_all(project_dir.join(".wsb")).unwrap();
fs::write(project_dir.join("version.txt"), version).unwrap();
let state = serde_json::json!({
"version": 1,
"project_root": project_dir.to_str().unwrap(),
"project_name": name,
"tools": {},
"wstemplate_entries": [
{ "alias": alias, "root": scan_root.to_str().unwrap() }
]
});
fs::write(
project_dir.join(".wsb").join("state.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.unwrap();
project_dir
}
#[test]
fn test_derive_alias_simple_name() {
assert_eq!(
WstemplateEngine::derive_alias(Path::new("/path/to/myapp")),
"myapp"
);
}
#[test]
fn test_derive_alias_hyphenated() {
assert_eq!(
WstemplateEngine::derive_alias(Path::new("/path/to/my-service")),
"my_service"
);
}
#[test]
fn test_derive_alias_spaces_and_mixed_case() {
assert_eq!(
WstemplateEngine::derive_alias(Path::new("/path/API Service")),
"api_service"
);
}
#[test]
fn test_derive_alias_leading_digit() {
assert_eq!(
WstemplateEngine::derive_alias(Path::new("/path/123project")),
"p_123project"
);
}
#[test]
fn test_derive_alias_collapses_multiple_separators() {
assert_eq!(
WstemplateEngine::derive_alias(Path::new("/path/my--app__v2")),
"my_app_v2"
);
}
#[test]
fn test_derive_alias_empty_basename_returns_project() {
assert_eq!(WstemplateEngine::derive_alias(Path::new("/")), "project");
}
#[test]
fn test_derive_alias_is_deterministic() {
let path = Path::new("/some/dir/my-cool-project");
assert_eq!(
WstemplateEngine::derive_alias(path),
WstemplateEngine::derive_alias(path)
);
}
#[test]
fn test_parse_version_string_valid() {
let vi = parse_version_string("1.23.456").unwrap();
assert_eq!(vi.full_version, "1.23.456");
assert_eq!(vi.major_version, "v1");
assert_eq!(vi.minor_version, 23);
assert_eq!(vi.patch_version, 456);
}
#[test]
fn test_parse_version_string_zeros() {
let vi = parse_version_string("0.0.0").unwrap();
assert_eq!(vi.full_version, "0.0.0");
assert_eq!(vi.major_version, "v0");
assert_eq!(vi.minor_version, 0);
assert_eq!(vi.patch_version, 0);
}
#[test]
fn test_parse_version_string_missing_patch_fails() {
assert!(parse_version_string("1.2").is_err());
}
#[test]
fn test_parse_version_string_non_numeric_fails() {
assert!(parse_version_string("a.b.c").is_err());
}
#[test]
fn test_parse_version_string_empty_fails() {
assert!(parse_version_string("").is_err());
}
#[test]
fn test_read_version_from_path_success() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("version.txt"), "2.100.5000\n").unwrap();
let vi = read_version_from_path(dir.path()).unwrap();
assert_eq!(vi.full_version, "2.100.5000");
assert_eq!(vi.minor_version, 100);
}
#[test]
fn test_read_version_from_path_missing_fails_hard() {
let dir = TempDir::new().unwrap();
let err = read_version_from_path(dir.path()).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("regenerating version.txt"),
"error must mention regeneration attempt, got: {}",
msg
);
assert!(
msg.contains("git repository"),
"error must explain why regeneration failed, got: {}",
msg
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_read_version_from_path_regenerates_missing_version_for_managed_project() {
let dir = TempDir::new().unwrap();
let root = dir.path();
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();
}
_ => panic!("unsupported git test helper command: {args:?}"),
}
}
run_git(root, &["init"]);
run_git(root, &["config", "user.name", "Test User"]);
run_git(root, &["config", "user.email", "test@example.com"]);
fs::write(root.join("README.md"), "hello\n").unwrap();
run_git(root, &["add", "README.md"]);
run_git(root, &["commit", "-m", "initial"]);
let _ = StateConfig::load_async(root).await.unwrap();
let db_path = root.join(".wsb/wsb.db");
let config = crate::persist::global::DatabaseConfig::new(&db_path);
let service = crate::persist::global::DatabaseService::new(&config).unwrap();
let conn = service.get_connection().unwrap();
conn.execute(
"UPDATE projects SET major_version = 7 WHERE id = (SELECT id FROM projects LIMIT 1)",
[],
).unwrap();
let expected = crate::state::calculate_workspace_version_async(root).await.unwrap();
let version_path = root.join("version.txt");
if version_path.exists() {
fs::remove_file(&version_path).unwrap();
}
let actual = read_version_from_path(root).unwrap();
assert_eq!(actual.full_version, expected.full_version);
assert_eq!(fs::read_to_string(version_path).unwrap().trim(), expected.full_version);
}
#[test]
fn test_find_all_project_roots_discovers_projects() {
let workspace = TempDir::new().unwrap();
make_project(
workspace.path(),
"alpha",
"alpha",
"1.0.0",
workspace.path(),
);
make_project(workspace.path(), "beta", "beta", "2.0.0", workspace.path());
let roots = find_all_project_roots(workspace.path()).unwrap();
assert!(roots.contains_key("alpha"), "must find alpha");
assert!(roots.contains_key("beta"), "must find beta");
assert_eq!(roots["alpha"], workspace.path().join("alpha"));
assert_eq!(roots["beta"], workspace.path().join("beta"));
}
#[test]
fn test_find_all_project_roots_skips_target_directories() {
let workspace = TempDir::new().unwrap();
make_project(
workspace.path(),
"real_proj",
"real_proj",
"1.0.0",
workspace.path(),
);
let target_ws = workspace.path().join("target").join(".wsb");
fs::create_dir_all(&target_ws).unwrap();
let fake_state = serde_json::json!({
"version": 1,
"project_root": workspace.path().join("target").to_str().unwrap(),
"project_name": "fake",
"tools": {},
"wstemplate_entries": [{ "alias": "should_be_skipped", "root": workspace.path().to_str().unwrap() }]
});
fs::write(
target_ws.join("state.json"),
serde_json::to_string_pretty(&fake_state).unwrap(),
)
.unwrap();
let roots = find_all_project_roots(workspace.path()).unwrap();
assert!(
roots.contains_key("real_proj"),
"real project must be found"
);
assert!(
!roots.contains_key("should_be_skipped"),
"projects inside target/ must be skipped"
);
}
#[test]
fn test_find_all_project_roots_defaults_alias_when_entries_are_empty() {
let workspace = TempDir::new().unwrap();
let project_root = workspace.path().join("wsb");
let state_dir = project_root.join(".wsb");
fs::create_dir_all(&state_dir).unwrap();
fs::write(project_root.join("version.txt"), "1.0.0").unwrap();
fs::write(
state_dir.join("state.json"),
serde_json::json!({
"version": 1,
"project_root": project_root.to_str().unwrap(),
"project_name": "wsb",
"tools": {},
"wstemplate_entries": [],
"completion_hint_shown": true
})
.to_string(),
)
.unwrap();
let roots = find_all_project_roots(workspace.path()).unwrap();
assert_eq!(roots.get("wsb"), Some(&project_root));
}
#[test]
fn test_find_all_project_roots_duplicate_alias_fails_hard() {
let workspace = TempDir::new().unwrap();
make_project(
workspace.path(),
"proj_a",
"shared_alias",
"1.0.0",
workspace.path(),
);
make_project(
workspace.path(),
"proj_b",
"shared_alias",
"2.0.0",
workspace.path(),
);
let err = find_all_project_roots(workspace.path()).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("shared_alias"),
"error must name the duplicate alias, got: {}",
msg
);
}
#[test]
fn test_discover_all_finds_wstemplate_files() {
let workspace = TempDir::new().unwrap();
let proj = make_project(
workspace.path(),
"myapp",
"myapp",
"1.0.0",
workspace.path(),
);
fs::write(
proj.join("Cargo.toml.wstemplate"),
"version = \"{{ project.version }}\"",
)
.unwrap();
fs::write(proj.join("README.md"), "not a template").unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("myapp".to_string()),
"myapp".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let found = engine.discover_all().unwrap();
assert_eq!(found.len(), 1);
assert!(found[0].ends_with("Cargo.toml.wstemplate"));
}
#[test]
fn test_discover_relevant_includes_own_templates() {
let workspace = TempDir::new().unwrap();
let proj = make_project(
workspace.path(),
"self_proj",
"self_proj",
"1.0.0",
workspace.path(),
);
let other = make_project(
workspace.path(),
"other",
"other",
"2.0.0",
workspace.path(),
);
fs::write(proj.join("a.txt.wstemplate"), "{{ project.version }}").unwrap();
fs::write(other.join("b.txt.wstemplate"), "{{ project.version }}").unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("self_proj".to_string()),
"self_proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let project_roots = find_all_project_roots(workspace.path()).unwrap();
let relevant = engine.discover_relevant(&project_roots).unwrap();
assert_eq!(relevant.len(), 1, "only own template must be included");
assert!(relevant[0].starts_with(&proj));
}
#[test]
fn test_discover_relevant_includes_foreign_template_referencing_self() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "lib", "lib", "3.0.0", workspace.path());
let consumer = make_project(workspace.path(), "app", "app", "1.0.0", workspace.path());
fs::write(
consumer.join("Cargo.toml.wstemplate"),
"dep_version = \"{{ projects.lib.version }}\"",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("3.0.0"),
Some("lib".to_string()),
"lib".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let project_roots = find_all_project_roots(workspace.path()).unwrap();
let relevant = engine.discover_relevant(&project_roots).unwrap();
assert_eq!(relevant.len(), 1, "consumer's template must be included");
assert!(relevant[0].starts_with(&consumer));
}
#[test]
fn test_discover_relevant_skips_unrelated_templates() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "lib", "lib", "1.0.0", workspace.path());
let unrelated = make_project(
workspace.path(),
"other",
"other",
"2.0.0",
workspace.path(),
);
fs::write(unrelated.join("x.txt.wstemplate"), "{{ project.version }}").unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("lib".to_string()),
"lib".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let project_roots = find_all_project_roots(workspace.path()).unwrap();
let relevant = engine.discover_relevant(&project_roots).unwrap();
assert!(relevant.is_empty(), "unrelated template must be skipped");
}
#[test]
fn test_discover_relevant_root_project_does_not_claim_subproject_templates() {
let workspace = TempDir::new().unwrap();
let root = make_project(workspace.path(), "root", "root", "9.0.0", workspace.path());
let sub = make_project(&root, "sub", "sub", "1.0.0", workspace.path());
fs::write(root.join("root.txt.wstemplate"), "{{ project.version }}").unwrap();
fs::write(sub.join("sub.txt.wstemplate"), "{{ project.version }}").unwrap();
let engine = WstemplateEngine::new(
make_vi("9.0.0"),
Some("root".to_string()),
"root".to_string(),
root.clone(),
workspace.path().to_path_buf(),
);
let project_roots = find_all_project_roots(workspace.path()).unwrap();
let relevant = engine.discover_relevant(&project_roots).unwrap();
assert_eq!(
relevant.len(),
1,
"sub's template must not be claimed by root"
);
assert!(
relevant[0].ends_with("root.txt.wstemplate"),
"only root's own template, got: {}",
relevant[0].display()
);
}
#[test]
fn test_render_root_project_does_not_clobber_subproject_versions() {
let workspace = TempDir::new().unwrap();
let root = make_project(workspace.path(), "root", "root", "9.0.0", workspace.path());
let sub = make_project(&root, "sub", "sub", "1.5.100", workspace.path());
fs::write(
sub.join("file.txt.wstemplate"),
"root={{ projects.root.version }} own={{ project.version }}",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("9.0.0"),
Some("root".to_string()),
"root".to_string(),
root.clone(),
workspace.path().to_path_buf(),
);
let rendered = engine.render_relevant().unwrap();
assert_eq!(rendered.len(), 1, "sub's template references root");
let content = fs::read_to_string(sub.join("file.txt")).unwrap();
assert!(
content.contains("root=9.0.0"),
"root version must be the freshly computed one, got: {}",
content
);
assert!(
content.contains("own=1.5.100"),
"own version must come from sub's version.txt (not root's), got: {}",
content
);
}
#[test]
fn test_render_relevant_renders_own_template_with_current_version() {
let workspace = TempDir::new().unwrap();
let proj = make_project(
workspace.path(),
"mylib",
"mylib",
"5.10.2000",
workspace.path(),
);
fs::write(
proj.join("Cargo.toml.wstemplate"),
"version = \"{{ project.version }}\"",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("5.10.2000"),
Some("mylib".to_string()),
"mylib".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let rendered = engine.render_relevant().unwrap();
assert_eq!(rendered.len(), 1);
let content = fs::read_to_string(proj.join("Cargo.toml")).unwrap();
assert_eq!(content, "version = \"5.10.2000\"");
}
#[test]
fn test_render_relevant_resolves_peer_alias_dynamically() {
let workspace = TempDir::new().unwrap();
let lib = make_project(workspace.path(), "lib", "lib", "3.0.0", workspace.path());
let app = make_project(workspace.path(), "app", "app", "1.0.0", workspace.path());
fs::write(
app.join("Cargo.toml.wstemplate"),
"lib_dep = \"{{ projects.lib.version }}\"\nown = \"{{ project.version }}\"",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("3.0.0"),
Some("lib".to_string()),
"lib".to_string(),
lib.clone(),
workspace.path().to_path_buf(),
);
let rendered = engine.render_relevant().unwrap();
assert_eq!(rendered.len(), 1, "app's template must be rendered");
let content = fs::read_to_string(app.join("Cargo.toml")).unwrap();
assert!(
content.contains("lib_dep = \"3.0.0\""),
"lib version must be the freshly-computed one"
);
assert!(
content.contains("own = \"1.0.0\""),
"own version must come from app's version.txt"
);
}
#[test]
fn test_render_relevant_fails_hard_on_unresolvable_alias() {
let workspace = TempDir::new().unwrap();
let proj = make_project(
workspace.path(),
"myapp",
"myapp",
"1.0.0",
workspace.path(),
);
fs::write(
proj.join("file.txt.wstemplate"),
"v = \"{{ projects.ghost_dep.version }}\"",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("myapp".to_string()),
"myapp".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let err = engine.render_relevant().unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("ghost_dep"),
"error must name the unresolvable alias, got: {}",
msg
);
assert!(
msg.contains("Known aliases:"),
"error must list known aliases, got: {}",
msg
);
assert!(
msg.contains("myapp"),
"known aliases must include myapp, got: {}",
msg
);
}
#[test]
fn test_render_relevant_fails_hard_when_version_txt_missing() {
let workspace = TempDir::new().unwrap();
let lib = make_project(workspace.path(), "lib", "lib", "1.0.0", workspace.path());
let app = make_project(workspace.path(), "app", "app", "1.0.0", workspace.path());
fs::remove_file(app.join("version.txt")).unwrap();
fs::write(
app.join("file.txt.wstemplate"),
"v = \"{{ projects.lib.version }}\"\nown = \"{{ project.version }}\"",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("lib".to_string()),
"lib".to_string(),
lib.clone(),
workspace.path().to_path_buf(),
);
let err = engine.render_relevant().unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("version.txt"),
"error must mention version.txt, got: {}",
msg
);
}
#[test]
fn test_render_relevant_own_template_does_not_need_version_txt() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "proj", "proj", "7.0.0", workspace.path());
fs::write(
proj.join("version.h.wstemplate"),
"#define VERSION \"{{ project.version }}\"",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("7.0.0"),
None,
"proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let rendered = engine.render_relevant().unwrap();
assert_eq!(rendered.len(), 1);
let content = fs::read_to_string(proj.join("version.h")).unwrap();
assert_eq!(content, "#define VERSION \"7.0.0\"");
}
}