use crate::prelude::*;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use tera::{Context as TeraContext, Tera};
use walkdir::WalkDir;
use crate::state::project_facts::{self, ProjectFacts, ProjectIdentity};
use crate::state::template_refs::TemplateReferences;
use crate::state::{StateConfig, VersionInfo};
use crate::workspace_state::WorkspaceState;
#[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 read_references(&path, &content)?.references_alias(&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();
let mut facts_cache: HashMap<String, ProjectFacts> = HashMap::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 references = read_references(path, &content)?;
let ctx = self
.build_context(&references, path, is_own, &project_roots, &mut facts_cache)
.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_alias(
&self,
template_path: &Path,
project_roots: &HashMap<String, PathBuf>,
is_own: bool,
) -> Result<String> {
if is_own {
return Ok(self.self_alias.clone());
}
let root = find_most_specific_root(template_path, project_roots).ok_or_else(|| {
anyhow::anyhow!(
"Cannot determine the owning project for template at {}. \
The template is not under any project registered in {}",
template_path.display(),
self.scan_root.display()
)
})?;
let mut aliases: Vec<&String> = project_roots
.iter()
.filter(|(_, candidate)| *candidate == &root)
.map(|(alias, _)| alias)
.collect();
aliases.sort();
aliases
.first()
.map(|alias| (*alias).clone())
.with_context(|| {
format!(
"Project at {} owns template {} but registers no alias",
root.display(),
template_path.display()
)
})
}
fn facts_for<'cache>(
&self,
alias: &str,
fields: &BTreeSet<String>,
project_roots: &HashMap<String, PathBuf>,
template_path: &Path,
cache: &'cache mut HashMap<String, ProjectFacts>,
) -> Result<&'cache ProjectFacts> {
let with_remote = project_facts::requires_network(fields.iter().map(String::as_str));
let usable = cache
.get(alias)
.is_some_and(|facts| facts.resolved_remote || !with_remote);
if !usable {
let facts = if alias == self.self_alias {
project_facts::gather(
&ProjectIdentity {
alias,
root: &self.project_root,
scan_root: &self.scan_root,
version: &self.current_version,
name: self.current_name.as_deref(),
},
with_remote,
)?
} else {
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))?;
project_facts::gather(
&ProjectIdentity {
alias,
root,
scan_root: &self.scan_root,
version: &version,
name: None,
},
with_remote,
)?
};
cache.insert(alias.to_string(), facts);
}
cache
.get(alias)
.context("facts were just resolved for this alias")
}
fn build_context(
&self,
references: &TemplateReferences,
template_path: &Path,
is_own: bool,
project_roots: &HashMap<String, PathBuf>,
cache: &mut HashMap<String, ProjectFacts>,
) -> Result<TeraContext> {
let owning_alias = self.find_owning_alias(template_path, project_roots, is_own)?;
let mut wanted: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for alias in references.aliases() {
let fields = wanted.entry(alias.to_string()).or_default();
if let Some(referenced) = references.peer_fields(alias) {
fields.extend(referenced.keys().cloned());
}
}
wanted
.entry(owning_alias.clone())
.or_default()
.extend(references.own_fields().keys().cloned());
wanted.entry(self.self_alias.clone()).or_default();
if references.iterates_projects() {
for alias in project_roots.keys() {
wanted.entry(alias.clone()).or_default();
}
}
let mut projects: HashMap<String, BTreeMap<String, Value>> = HashMap::new();
for (alias, fields) in &wanted {
let facts = self.facts_for(alias, fields, project_roots, template_path, cache)?;
if let Some(referenced) = references.peer_fields(alias) {
for (field, line) in referenced {
facts.require(field).with_context(|| {
format!("{}:{} cannot be rendered", template_path.display(), line)
})?;
}
}
if alias == &owning_alias {
for (field, line) in references.own_fields() {
facts.require(field).with_context(|| {
format!("{}:{} cannot be rendered", template_path.display(), line)
})?;
}
}
projects.insert(alias.clone(), facts.values().clone());
}
let mut ctx = TeraContext::new();
ctx.insert(
"project",
projects
.get(&owning_alias)
.context("owning project facts must have been resolved")?,
);
ctx.insert("projects", &projects);
ctx.insert("datetime", &datetime_map());
Ok(ctx)
}
}
fn read_references(template_path: &Path, content: &str) -> Result<TemplateReferences> {
TemplateReferences::parse(content)
.with_context(|| format!("Cannot parse template {}", template_path.display()))
}
fn datetime_map() -> HashMap<String, Value> {
let now = chrono::Local::now();
let mut datetime: HashMap<String, Value> = HashMap::new();
datetime.insert("iso".to_string(), json!(now.to_rfc3339()));
datetime.insert(
"date".to_string(),
json!(now.format("%Y-%m-%d").to_string()),
);
datetime.insert(
"time".to_string(),
json!(now.format("%H:%M:%S").to_string()),
);
datetime.insert("year".to_string(), json!(now.format("%Y").to_string()));
datetime.insert("month".to_string(), json!(now.format("%m").to_string()));
datetime.insert("day".to_string(), json!(now.format("%d").to_string()));
datetime.insert("timestamp".to_string(), json!(now.timestamp()));
datetime
}
#[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, workspace_state, 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 workspace_state = WorkspaceState::load(project_root);
let version_info = handle.block_on(crate::state::calculate_workspace_version_async(
project_root,
));
Ok::<_, anyhow::Error>((config, workspace_state, version_info))
})?
} else {
(
StateConfig::load(project_root),
WorkspaceState::load(project_root),
crate::state::calculate_workspace_version(project_root),
)
};
let config = config.with_context(|| {
format!(
"failed to load workspace config for project at {} while regenerating version.txt",
project_root.display()
)
})?;
let workspace_state = workspace_state.with_context(|| {
format!(
"failed to load workspace state for project at {} while regenerating version.txt",
project_root.display()
)
})?;
let mut version_info = version_info.with_context(|| {
format!(
"failed to calculate workspace version for project at {} while regenerating version.txt",
project_root.display()
)
})?;
version_info.full_version = workspace_state.format_version(&version_info, None);
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_part = parts[2]
.split_once('-')
.map(|(patch, _suffix)| patch)
.unwrap_or(parts[2]);
let patch: u32 = patch_part
.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 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 crate::state::StateConfig;
use std::fs;
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
}
fn make_git_project(
parent: &Path,
name: &str,
alias: &str,
version: &str,
scan_root: &Path,
) -> PathBuf {
let project_dir = make_project(parent, name, alias, version, scan_root);
let repo = crate::git_backend::init_repository(&project_dir).unwrap();
crate::git_backend::configure_local_user(&repo, "Test User", "test@example.com").unwrap();
fs::write(project_dir.join("README.md"), "hello\n").unwrap();
crate::git_backend::stage_all(&repo).unwrap();
crate::git_backend::commit_all(&repo, "initial").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_with_suffix() {
let vi = parse_version_string("1.23.456-release").unwrap();
assert_eq!(vi.full_version, "1.23.456-release");
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("workspace state"),
"error must name the precondition that failed, got: {}",
msg
);
assert!(
msg.contains(&dir.path().display().to_string()),
"error must identify which project 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();
crate::workspace_state::WorkspaceState::initialize(root, Some("test-project"))
.unwrap()
.save(root)
.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_stamps_the_published_tag_not_the_version_being_built() {
let workspace = TempDir::new().unwrap();
let dep = make_git_project(workspace.path(), "dep", "dep", "1.649.0", workspace.path());
let app = make_project(workspace.path(), "app", "app", "1.0.0", workspace.path());
let repo = crate::git_backend::open_repo(&dep).unwrap();
crate::git_backend::create_tag(&repo, "v1.648.6494", "release").unwrap();
fs::write(
app.join("deps.txt.wstemplate"),
"published={{ projects.dep.latest_version }}\nbuilding={{ projects.dep.version }}\ntag={{ projects.dep.latest_tag }}",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.649.0"),
Some("dep".to_string()),
"dep".to_string(),
dep.clone(),
workspace.path().to_path_buf(),
);
engine.render_relevant().unwrap();
let content = fs::read_to_string(app.join("deps.txt")).unwrap();
assert!(
content.contains("published=1.648.6494"),
"the pin must name the published tag, got: {content}"
);
assert!(
content.contains("building=1.649.0"),
"the calculated version must stay available, got: {content}"
);
assert!(
content.contains("tag=v1.648.6494"),
"latest_tag must be verbatim, got: {content}"
);
}
#[test]
fn test_render_reports_why_a_field_does_not_apply() {
let workspace = TempDir::new().unwrap();
let dep = make_git_project(workspace.path(), "dep", "dep", "1.0.0", workspace.path());
let app = make_project(workspace.path(), "app", "app", "1.0.0", workspace.path());
fs::write(
app.join("deps.txt.wstemplate"),
"dep = \"{{ projects.dep.latest_version }}\"",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("dep".to_string()),
"dep".to_string(),
dep.clone(),
workspace.path().to_path_buf(),
);
let err = format!("{:#}", engine.render_relevant().unwrap_err());
assert!(err.contains("latest_version"), "got: {err}");
assert!(err.contains("has no tags"), "got: {err}");
assert!(
!app.join("deps.txt").exists(),
"a template that cannot resolve must not produce a half-written file"
);
}
#[test]
fn test_render_rejects_an_unknown_field_before_writing_anything() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "proj", "proj", "1.0.0", workspace.path());
fs::write(
proj.join("out.txt.wstemplate"),
"{{ project.release_date_of_next_version }}",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("proj".to_string()),
"proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let err = format!("{:#}", engine.render_relevant().unwrap_err());
assert!(
err.contains("is not a wstemplate project field"),
"got: {err}"
);
assert!(
err.contains("latest_tag"),
"the error must list what is available, got: {err}"
);
}
#[test]
fn test_render_exposes_identity_and_head_state_of_a_referenced_project() {
let workspace = TempDir::new().unwrap();
let ext = workspace.path().join("ext");
fs::create_dir_all(&ext).unwrap();
let dep = make_git_project(&ext, "gtest", "gtest", "1.0.0", workspace.path());
let app = make_project(workspace.path(), "app", "app", "1.0.0", workspace.path());
fs::write(
app.join("deps.txt.wstemplate"),
"path={{ projects.gtest.relative_path }}\ncommit={{ projects.gtest.short_commit }}\n\
branch={{ projects.gtest.branch }}\nname={{ projects.gtest.name }}\n\
dirty={{ projects.gtest.is_dirty }}",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("gtest".to_string()),
"gtest".to_string(),
dep.clone(),
workspace.path().to_path_buf(),
);
engine.render_relevant().unwrap();
let content = fs::read_to_string(app.join("deps.txt")).unwrap();
assert!(
content.contains("path=ext/gtest"),
"a nested project must report its path from the workspace root, got: {content}"
);
assert!(
content.contains("name=gtest"),
"peer names must resolve from their own state.json, got: {content}"
);
assert!(content.contains("dirty=false"), "got: {content}");
let head = crate::git_backend::open_repo(&dep)
.unwrap()
.head_commit()
.unwrap()
.id()
.to_hex()
.to_string();
assert!(
content.contains(&format!("commit={}", &head[..7])),
"got: {content}"
);
let branch =
crate::git_backend::current_branch(&crate::git_backend::open_repo(&dep).unwrap())
.unwrap();
assert!(
content.contains(&format!("branch={branch}")),
"got: {content}"
);
}
#[test]
fn test_booleans_render_as_booleans_so_conditionals_work() {
let workspace = TempDir::new().unwrap();
let proj = make_git_project(workspace.path(), "proj", "proj", "1.0.0", workspace.path());
fs::write(
proj.join("out.txt.wstemplate"),
"{% if project.is_dirty %}dirty{% else %}clean{% endif %}",
)
.unwrap();
let repo = crate::git_backend::open_repo(&proj).unwrap();
crate::git_backend::stage_all(&repo).unwrap();
crate::git_backend::commit_all(&repo, "add template").unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("proj".to_string()),
"proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
engine.render_relevant().unwrap();
assert_eq!(fs::read_to_string(proj.join("out.txt")).unwrap(), "clean");
}
#[test]
fn test_render_leaves_the_output_files_own_syntax_alone() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "proj", "proj", "2.5.0", workspace.path());
fs::write(
proj.join("pyproject.toml.wstemplate"),
"[project]\nname = \"demo\"\nversion = \"{{ project.version }}\"\n\n\
[project.optional-dependencies]\ndev = [\"pytest\"]\n\n\
[project.urls]\nHome = \"https://example.invalid\"\n",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("2.5.0"),
Some("proj".to_string()),
"proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
engine.render_relevant().unwrap();
let content = fs::read_to_string(proj.join("pyproject.toml")).unwrap();
assert!(content.contains("version = \"2.5.0\""), "got: {content}");
assert!(
content.contains("[project.optional-dependencies]"),
"the section header must survive untouched, got: {content}"
);
assert!(content.contains("[project.urls]"), "got: {content}");
}
#[test]
fn test_a_quoted_alias_inside_a_block_does_not_resolve_a_project() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "proj", "proj", "1.0.0", workspace.path());
fs::write(
proj.join("out.txt.wstemplate"),
"{{ \"projects.ghost.version\" }} is the syntax, {{ project.version }} is the value",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("proj".to_string()),
"proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
engine.render_relevant().unwrap();
let content = fs::read_to_string(proj.join("out.txt")).unwrap();
assert_eq!(
content,
"projects.ghost.version is the syntax, 1.0.0 is the value"
);
}
#[test]
fn test_selection_ignores_a_literal_mention_of_the_alias() {
let workspace = TempDir::new().unwrap();
let lib = make_project(workspace.path(), "lib", "lib", "1.0.0", workspace.path());
let docs = make_project(workspace.path(), "docs", "docs", "1.0.0", workspace.path());
fs::write(
docs.join("guide.md.wstemplate"),
"Write projects.lib.version to stamp the library version.\n\
This file is version {{ project.version }}.\n",
)
.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 rendered = engine.render_relevant().unwrap();
assert!(
rendered.is_empty(),
"a prose mention must not make the template relevant, rendered: {rendered:?}"
);
assert!(!docs.join("guide.md").exists());
}
#[test]
fn test_a_raw_block_documents_syntax_without_resolving_it() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "proj", "proj", "3.0.0", workspace.path());
fs::write(
proj.join("guide.md.wstemplate"),
"Use {% raw %}{{ projects.ghost.version }}{% endraw %} in your template.\n\
Rendered with proj {{ project.version }}.\n",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("3.0.0"),
Some("proj".to_string()),
"proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
engine.render_relevant().unwrap();
let content = fs::read_to_string(proj.join("guide.md")).unwrap();
assert!(
content.contains("{{ projects.ghost.version }}"),
"raw content must reach the output verbatim, got: {content}"
);
assert!(content.contains("proj 3.0.0"), "got: {content}");
}
#[test]
fn test_iterating_projects_sees_every_project_not_just_the_named_ones() {
let workspace = TempDir::new().unwrap();
let alpha = make_project(
workspace.path(),
"alpha",
"alpha",
"1.0.0",
workspace.path(),
);
make_project(workspace.path(), "beta", "beta", "2.0.0", workspace.path());
fs::write(
alpha.join("manifest.txt.wstemplate"),
"{% for alias, entry in projects %}{{ alias }}={{ entry.version }}\n{% endfor %}",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("alpha".to_string()),
"alpha".to_string(),
alpha.clone(),
workspace.path().to_path_buf(),
);
engine.render_relevant().unwrap();
let content = fs::read_to_string(alpha.join("manifest.txt")).unwrap();
assert!(content.contains("alpha=1.0.0"), "got: {content}");
assert!(
content.contains("beta=2.0.0"),
"iteration must see projects no reference names, got: {content}"
);
}
#[test]
fn test_a_malformed_template_names_the_file_and_the_line() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "proj", "proj", "1.0.0", workspace.path());
fs::write(
proj.join("out.txt.wstemplate"),
"fine\n{{ project.version\nnever closed\n",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("proj".to_string()),
"proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let err = format!("{:#}", engine.render_relevant().unwrap_err());
assert!(err.contains("out.txt.wstemplate"), "got: {err}");
assert!(err.contains("line 2"), "got: {err}");
assert!(err.contains("never closed"), "got: {err}");
}
#[test]
fn test_an_unresolvable_field_points_at_the_line_that_uses_it() {
let workspace = TempDir::new().unwrap();
let proj = make_project(workspace.path(), "proj", "proj", "1.0.0", workspace.path());
fs::write(
proj.join("out.txt.wstemplate"),
"version = {{ project.version }}\nname = {{ project.name }}\ntag = {{ project.latest_tag }}\n",
)
.unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("proj".to_string()),
"proj".to_string(),
proj.clone(),
workspace.path().to_path_buf(),
);
let err = format!("{:#}", engine.render_relevant().unwrap_err());
assert!(
err.contains("out.txt.wstemplate:3"),
"the error must point at the offending line, got: {err}"
);
assert!(err.contains("not a git repository"), "got: {err}");
}
#[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\"");
}
}