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::run_cache::RunCache;
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 lockfile: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Produced {
pub source_path: PathBuf,
pub output_path: PathBuf,
pub content: String,
pub own: bool,
}
pub struct WstemplateEngine {
current_version: VersionInfo,
current_name: Option<String>,
self_alias: String,
project_root: PathBuf,
scan_root: PathBuf,
submodule_roots: Arc<Vec<PathBuf>>,
cache: Arc<RunCache>,
}
impl WstemplateEngine {
pub fn new(
current_version: VersionInfo,
current_name: Option<String>,
self_alias: String,
project_root: PathBuf,
scan_root: PathBuf,
) -> Self {
Self::with_cache(
current_version,
current_name,
self_alias,
project_root,
scan_root,
RunCache::new(),
)
}
pub fn with_cache(
current_version: VersionInfo,
current_name: Option<String>,
self_alias: String,
project_root: PathBuf,
scan_root: PathBuf,
cache: Arc<RunCache>,
) -> Self {
let submodule_roots =
cache.submodule_roots(&project_root, || submodule_roots_of(&project_root));
Self {
current_version,
current_name,
self_alias,
project_root,
scan_root,
submodule_roots,
cache,
}
}
pub fn cache(&self) -> &Arc<RunCache> {
&self.cache
}
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<Arc<Vec<PathBuf>>> {
self.cache
.templates_under(&self.scan_root, || discover_in(&self.scan_root))
}
pub fn discover_project_roots(&self) -> Result<Arc<HashMap<String, PathBuf>>> {
self.cache
.project_roots(&self.scan_root, || 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.iter() {
if self.is_own_template(path, project_roots) {
relevant.push(path.clone());
continue;
}
if self.references(path)?.references_alias(&self.self_alias) {
relevant.push(path.clone());
}
}
Ok(relevant)
}
pub fn render_relevant(&self) -> Result<Vec<RenderedTemplate>> {
let produced = self.produce_relevant()?;
self.write_produced(produced)
}
pub fn write_produced(&self, produced: Vec<Produced>) -> Result<Vec<RenderedTemplate>> {
let mut rendered = Vec::with_capacity(produced.len());
for one in produced {
write_output(&one.output_path, &one.content)?;
let lockfile = if one.own {
follow_into_lockfile(&one.output_path, &one.content)?
} else {
None
};
self.cache.note_written(&one.output_path);
info!(
"wstemplate: {} -> {}",
one.source_path.display(),
one.output_path.display()
);
rendered.push(RenderedTemplate {
source_path: one.source_path,
output_path: one.output_path,
lockfile,
});
}
Ok(rendered)
}
pub fn produce_relevant(&self) -> Result<Vec<Produced>> {
let project_roots = self.discover_project_roots()?;
let relevant = self.discover_relevant(&project_roots)?;
let mut produced = Vec::new();
let mut facts_cache: HashMap<String, ProjectFacts> = HashMap::new();
for path in &relevant {
let content = self.text(path)?;
let is_own = self.is_own_template(path, &project_roots);
let references = self.references(path)?;
let ctx = self
.build_context(&references, path, is_own, &project_roots, &mut facts_cache)
.with_context(|| format!("Cannot build render context for {}", path.display()))?;
produced.push(
produce_one(path, &content, &ctx, is_own)
.with_context(|| format!("Cannot render template {}", path.display()))?,
);
}
Ok(produced)
}
}
const RENDERED_FIELDS: &[&str] = &["path_from_here"];
impl WstemplateEngine {
fn text(&self, path: &Path) -> Result<Arc<String>> {
self.cache.template_text(path, || {
fs::read_to_string(path)
.with_context(|| format!("Cannot read template {}", path.display()))
})
}
fn references(&self, path: &Path) -> Result<Arc<TemplateReferences>> {
let text = self.text(path)?;
self.cache
.template_references(path, || read_references(path, &text))
}
fn is_own_template(
&self,
template_path: &Path,
project_roots: &HashMap<String, PathBuf>,
) -> bool {
if !is_under(template_path, &self.project_root) {
return false;
}
if self
.submodule_roots
.iter()
.any(|sm| is_under(template_path, sm))
{
return false;
}
let own = resolved(&self.project_root);
let self_depth = own.components().count();
for root in project_roots.values() {
let root = resolved(root);
if root == own {
continue;
}
if is_under(template_path, &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_with_cache(
&ProjectIdentity {
alias,
root: &self.project_root,
scan_root: &self.scan_root,
version: &self.current_version,
name: self.current_name.as_deref(),
},
with_remote,
&self.cache,
)?
} 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_with_cache(
&ProjectIdentity {
alias,
root,
scan_root: &self.scan_root,
version: &version,
name: None,
},
with_remote,
&self.cache,
)?
};
cache.insert(alias.to_string(), facts);
}
cache
.get(alias)
.context("facts were just resolved for this alias")
}
fn declaration(&self, references: &TemplateReferences) -> Result<serde_json::Value> {
let path = super::stamp_config::StampConfig::declared_file(&self.scan_root)?;
let text = std::fs::read_to_string(&path).with_context(|| {
format!("cannot read the workspace declaration at {}", path.display())
})?;
let declaration: serde_json::Value = serde_json::from_str(&text)
.with_context(|| format!("{} is not valid JSON", path.display()))?;
for (asked, line) in references.declared_paths() {
let mut here = &declaration;
let mut walked: Vec<&str> = Vec::new();
for segment in asked.split('.') {
let Some(next) = here.get(segment) else {
let found = match here.as_object() {
Some(map) => format!(
"'declared{}{}' has: {}",
if walked.is_empty() { "" } else { "." },
walked.join("."),
map.keys().cloned().collect::<Vec<_>>().join(", ")
),
None => format!(
"'declared.{}' is not something with named parts",
walked.join(".")
),
};
bail!(
"line {line}: the workspace declares no 'declared.{asked}'.\n{found}"
);
};
walked.push(segment);
here = next;
}
}
Ok(declaration)
}
fn build_facts(
&self,
template_output_dir: &Path,
template_path: &Path,
) -> Result<BTreeMap<String, Value>> {
let root = super::stamp_config::StampConfig::build_root(&self.scan_root)
.with_context(|| format!("{}: the build root", template_path.display()))?;
let relative = path_between(template_output_dir, &root).with_context(|| {
format!(
"{}: there is no relative path from here to the build root {}",
template_path.display(),
root.display()
)
})?;
Ok(BTreeMap::from([("root_from_here".to_string(), json!(relative))]))
}
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 template_output_dir = template_path
.parent()
.context("a template path always has a parent directory")?;
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 {
if RENDERED_FIELDS.contains(&field.as_str()) {
continue;
}
facts.require(field).with_context(|| {
format!("{}:{} cannot be rendered", template_path.display(), line)
})?;
}
}
if alias == &owning_alias {
for (field, line) in references.own_fields() {
if RENDERED_FIELDS.contains(&field.as_str()) {
continue;
}
facts.require(field).with_context(|| {
format!("{}:{} cannot be rendered", template_path.display(), line)
})?;
}
}
let mut values = facts.values().clone();
if let Some(path) = values.get("path").and_then(|path| path.as_str()) {
match path_between(template_output_dir, Path::new(path)) {
Some(relative) => {
values.insert("path_from_here".to_string(), json!(relative));
}
None => bail!(
"{}: there is no relative path from here to project '{alias}' at {path}",
template_path.display()
),
}
}
projects.insert(alias.clone(), values);
}
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());
ctx.insert("env", &environment_map(references.env_names(), self.cache.environment()));
if !references.declared_paths().is_empty() {
ctx.insert("declared", &self.declaration(references)?);
}
if !references.build_fields().is_empty() {
ctx.insert("build", &self.build_facts(template_output_dir, template_path)?);
}
Ok(ctx)
}
}
fn environment_map(
named: &std::collections::BTreeSet<String>,
run: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut map: BTreeMap<String, String> = std::env::vars().collect();
map.extend(run.iter().map(|(name, value)| (name.clone(), value.clone())));
for name in named {
map.entry(name.clone()).or_default();
}
map
}
fn path_between(from: &Path, to: &Path) -> Option<String> {
let from = canonical_as_far_as_exists(from);
let to = canonical_as_far_as_exists(to);
let mut shared = 0;
let from_parts: Vec<_> = from.components().collect();
let to_parts: Vec<_> = to.components().collect();
while shared < from_parts.len()
&& shared < to_parts.len()
&& from_parts[shared] == to_parts[shared]
{
shared += 1;
}
if shared == 0 {
return None;
}
let mut path = String::new();
for _ in shared..from_parts.len() {
path.push_str("../");
}
for (at, part) in to_parts[shared..].iter().enumerate() {
if at > 0 {
path.push('/');
}
path.push_str(&part.as_os_str().to_string_lossy());
}
if path.is_empty() {
return Some(".".to_string());
}
Some(path.trim_end_matches('/').to_string())
}
fn canonical_as_far_as_exists(path: &Path) -> PathBuf {
let mut existing = path.to_path_buf();
let mut rest: Vec<std::ffi::OsString> = Vec::new();
loop {
if let Ok(canonical) = crate::platform::canonicalize(&existing) {
return rest.iter().rev().fold(canonical, |p, part| p.join(part));
}
match (existing.file_name().map(|n| n.to_os_string()), existing.parent()) {
(Some(name), Some(parent)) => {
rest.push(name);
existing = parent.to_path_buf();
}
_ => return path.to_path_buf(),
}
}
}
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
}
fn submodule_roots_of(project_root: &Path) -> Vec<PathBuf> {
let Ok(repo) = crate::git_backend::discover_repo(project_root) else {
return Vec::new();
};
let Ok(Some(submodules)) = repo.submodules() else {
return Vec::new();
};
submodules
.filter_map(|sm| sm.path().ok().map(|p| project_root.join(p.to_string())))
.collect()
}
pub fn render_dependencies(
root: &Path,
repositories: &[PathBuf],
) -> Result<BTreeMap<PathBuf, BTreeSet<PathBuf>>> {
let projects = find_all_project_roots(root)?;
let owner = |path: &Path| -> Option<PathBuf> {
repositories
.iter()
.filter(|repository| path.starts_with(repository))
.max_by_key(|repository| repository.components().count())
.cloned()
};
let mut dependencies: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
for template in crate::search_backend::discover_files_with_suffix(root, ".wstemplate")? {
let Some(repository) = owner(&template) else { continue };
let text = fs::read_to_string(&template)
.with_context(|| format!("reading {}", template.display()))?;
let references = TemplateReferences::parse(&text)
.with_context(|| format!("reading the references of {}", template.display()))?;
let reads = dependencies.entry(repository.clone()).or_default();
if references.iterates_projects() {
reads.extend(repositories.iter().filter(|other| **other != repository).cloned());
}
for alias in references.aliases() {
if let Some(read) = projects.get(alias).and_then(|project| owner(project)) {
if read != repository {
reads.insert(read);
}
}
}
}
Ok(dependencies)
}
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;
}
if e.file_type().is_dir() && e.path().parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".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 project_root = entry
.path()
.parent()
.and_then(Path::parent)
.expect("a .wsb/state.json has a project directory")
.to_path_buf();
let state = crate::workspace_state::WorkspaceState::load(&project_root)?;
let alias = state.effective_wstemplate_entry().alias;
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(),
project_root.display()
);
}
map.insert(alias, project_root);
}
Ok(map)
}
fn find_most_specific_root(
path: &Path,
project_roots: &HashMap<String, PathBuf>,
) -> Option<PathBuf> {
project_roots
.values()
.filter(|root| is_under(path, root))
.max_by_key(|root| resolved(root).components().count())
.cloned()
}
fn is_under(path: &Path, root: &Path) -> bool {
resolved(path).starts_with(resolved(root))
}
fn resolved(path: &Path) -> PathBuf {
crate::platform::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
}
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 produce_one(template_path: &Path, content: &str, ctx: &TeraContext, own: bool) -> Result<Produced> {
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()))?;
Ok(Produced {
source_path: template_path.to_path_buf(),
output_path,
content: rendered,
own,
})
}
fn write_output(output_path: &Path, content: &str) -> Result<()> {
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, content)
.with_context(|| format!("Cannot write {}", output_path.display()))
}
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;
#[test]
fn test12358_a_rendered_go_mod_carries_its_requirements_into_go_sum() {
let dir = committed(&[(
"go.sum",
"github.com/stretchr/testify v1.8.4 h1:TESTIFY=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:TESTIFYMOD=\n",
)]);
let manifest = dir.path().join("go.mod");
let rendered = "module github.com/machinefabric/tagged-urn-go\n// version: 1.54.451\n\ngo 1.22\n\n\
// the relations run on lungo-go\nrequire github.com/machinefabric/lungo-go v1.78.3254\n\n\
require (\n\tgithub.com/stretchr/testify v1.8.4\n\texample.com/local v0.0.0 // indirect\n)\n\n\
replace example.com/local => ../local\n";
let asked = std::cell::RefCell::new(Vec::new());
let download = |module: &str, version: &str| -> Result<GoModule> {
asked.borrow_mut().push(format!("{module}@{version}"));
Ok(match module {
"github.com/machinefabric/lungo-go" => GoModule {
sum: "h1:LUNGO=".into(),
go_mod_sum: "h1:LUNGOMOD=".into(),
requires: vec![("example.com/dep".into(), "v1.0.0".into())],
},
"example.com/dep" => GoModule {
sum: "h1:DEP=".into(),
go_mod_sum: "h1:DEPMOD=".into(),
requires: Vec::new(),
},
other => panic!("{other} was asked for, and go.sum already records it or it is local"),
})
};
let sum = follow_into_go_sum(&manifest, rendered, &download).unwrap();
assert_eq!(sum, Some(dir.path().join("go.sum")));
let text = fs::read_to_string(dir.path().join("go.sum")).unwrap();
for line in [
"github.com/machinefabric/lungo-go v1.78.3254 h1:LUNGO=",
"github.com/machinefabric/lungo-go v1.78.3254/go.mod h1:LUNGOMOD=",
"example.com/dep v1.0.0/go.mod h1:DEPMOD=",
"github.com/stretchr/testify v1.8.4 h1:TESTIFY=",
] {
assert!(text.lines().any(|l| l == line), "go.sum lacks `{line}`:\n{text}");
}
assert!(!text.contains("example.com/dep v1.0.0 h1:"), "a dependency's module hash is not what the graph needs");
assert!(!text.contains("example.com/local"), "a local replacement has no hash");
asked.borrow_mut().clear();
follow_into_go_sum(&manifest, rendered, &download).unwrap();
assert!(asked.borrow().is_empty(), "a recorded requirement asked again: {:?}", asked.borrow());
let bare = tempfile::tempdir().unwrap();
let none = follow_into_go_sum(&bare.path().join("go.mod"), rendered, &download).unwrap();
assert_eq!(none, None);
assert!(!bare.path().join("go.sum").exists(), "a go.sum was made where the module keeps none");
assert!(asked.borrow().is_empty(), "and nothing was asked for it");
let quoted = "module m\nrequire (\n\t\"gopkg.in/check.v1\" v0.0.0-20161208181325-20d25e280405\n)\nreplace \"example.com/x\" => \"../x\"\n";
assert_eq!(
go_mod_requirements(quoted),
(
vec![("gopkg.in/check.v1".to_string(), "v0.0.0-20161208181325-20d25e280405".to_string())],
vec!["example.com/x".to_string()]
)
);
}
fn committed(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for (name, text) in files {
fs::write(dir.path().join(name), text).unwrap();
}
let git = |args: &[&str]| {
let out = crate::git_backend::git_command().arg("-C").arg(dir.path()).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "-q"]);
git(&["add", "-A"]);
git(&["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "fixture"]);
dir
}
#[test]
fn test12359_a_rendered_package_json_brings_its_tracked_lock_along() {
let lock = r#"{"name":"ui","version":"1.2.3","lockfileVersion":3,"packages":{"":{"name":"ui","version":"1.2.3","dependencies":{"capdag":"file:../capdag-js"}}}}"#;
let repo = committed(&[("package-lock.json", lock)]);
let manifest = repo.path().join("package.json");
let relocked = std::cell::Cell::new(0);
let relock = |_: &Path| -> Result<()> {
relocked.set(relocked.get() + 1);
Ok(())
};
let published = r#"{"name":"ui","version":"1.2.3","dependencies":{"capdag":"^1.243.691"}}"#;
let returned = follow_into_package_lock(&manifest, published, &relock).unwrap();
assert_eq!(relocked.get(), 1, "a lock naming another specification is brought up to date");
assert_eq!(returned, Some(repo.path().join("package-lock.json")), "and is staged with its manifest");
let same = r#"{"name":"ui","version":"1.2.3","dependencies":{"capdag":"file:../capdag-js"}}"#;
follow_into_package_lock(&manifest, same, &relock).unwrap();
assert_eq!(relocked.get(), 1, "a lock that agrees is not rewritten");
let next = r#"{"name":"ui","version":"1.2.4","dependencies":{"capdag":"file:../capdag-js"}}"#;
follow_into_package_lock(&manifest, next, &relock).unwrap();
assert_eq!(relocked.get(), 2, "a lock naming another version of its own package is brought up to date");
let untracked = tempfile::tempdir().unwrap();
fs::write(untracked.path().join("package-lock.json"), lock).unwrap();
let none = follow_into_package_lock(&untracked.path().join("package.json"), published, &relock).unwrap();
assert_eq!((none, relocked.get()), (None, 2), "an untracked lock is this machine's own");
}
#[test]
fn test12671_a_lock_does_not_keep_a_link_for_what_its_manifest_takes_from_the_registry() {
let lock = r#"{"name":"site","version":"1.0.0","lockfileVersion":3,"packages":{
"":{"name":"site","version":"1.0.0","dependencies":{"capdag":"^1.426.27","left-pad":"^1.3.0"},"devDependencies":{"kit":"file:../kit"}},
"../capdag/capdag-js":{"name":"capdag","version":"1.427.0","dependencies":{"tagged-urn":"^1.129.4"}},
"../capdag/capdag-js/node_modules/peggy":{"version":"5.1.0"},
"../capdag/capdag-js-old":{"name":"unrelated","version":"1.0.0"},
"../kit":{"name":"kit","version":"2.0.0"},
"node_modules/capdag":{"resolved":"../capdag/capdag-js","link":true},
"node_modules/kit":{"resolved":"../kit","link":true},
"node_modules/left-pad":{"version":"1.3.0","resolved":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}}}"#;
let repo = committed(&[("package-lock.json", lock)]);
let manifest = repo.path().join("package.json");
let lock_path = repo.path().join("package-lock.json");
let seen_by_npm = std::cell::RefCell::new(Vec::<String>::new());
let relock = |dir: &Path| -> Result<()> {
let held: Value = serde_json::from_str(&fs::read_to_string(dir.join("package-lock.json"))?)?;
let mut keys: Vec<String> = held["packages"].as_object().unwrap().keys().cloned().collect();
keys.sort();
*seen_by_npm.borrow_mut() = keys;
Ok(())
};
let published = r#"{"name":"site","version":"1.0.0","dependencies":{"capdag":"^1.426.27","left-pad":"^1.3.0"},"devDependencies":{"kit":"file:../kit"}}"#;
follow_into_package_lock(&manifest, published, &relock).unwrap();
assert_eq!(
*seen_by_npm.borrow(),
["", "../capdag/capdag-js-old", "../kit", "node_modules/kit", "node_modules/left-pad"],
"npm is asked with the link, its folder and what was under it gone — and nothing else: \
a folder whose name only begins the same, a link the manifest does ask for, and a registry resolution all stay"
);
fs::write(&lock_path, lock.replace(r#""capdag":"^1.426.27""#, r#""capdag":"file:../capdag/capdag-js""#)).unwrap();
seen_by_npm.borrow_mut().clear();
let local = r#"{"name":"site","version":"1.0.0","dependencies":{"capdag":"file:../capdag/capdag-js","left-pad":"^1.3.0"},"devDependencies":{"kit":"file:../kit"}}"#;
follow_into_package_lock(&manifest, local, &relock).unwrap();
assert!(seen_by_npm.borrow().is_empty(), "a link the manifest names is in agreement, and nothing is rewritten");
assert!(fs::read_to_string(&lock_path).unwrap().contains(r#""node_modules/capdag":{"resolved":"../capdag/capdag-js","link":true}"#));
}
#[test]
fn test12360_a_rendered_package_swift_brings_its_tracked_resolution_along() {
let resolved = r#"{"pins":[{"identity":"lungo-swift","kind":"remoteSourceControl","location":"https://github.com/machinefabric/lungo-swift.git","state":{"revision":"abc","version":"1.78.3254"}}],"version":2}"#;
let repo = committed(&[("Package.resolved", resolved)]);
let manifest = repo.path().join("Package.swift");
let resolves = std::cell::Cell::new(0);
let resolve = |_: &Path| -> Result<()> {
resolves.set(resolves.get() + 1);
Ok(())
};
let swift = |lungo: &str, extra: &str| {
format!(
"let package = Package(dependencies: [\n .package(url: \"https://github.com/machinefabric/lungo-swift\", exact: \"{lungo}\"),\n{extra} .package(url: \"https://github.com/unrelentingtech/SwiftCBOR.git\", from: \"0.4.7\"),\n])\n"
)
};
follow_into_package_resolved(&manifest, &swift("1.78.3254", ""), &resolve).unwrap();
assert_eq!(resolves.get(), 0, "every exact pin is resolved — `.git` or not — and `from:` is SwiftPM's");
follow_into_package_resolved(&manifest, &swift("1.79.0", ""), &resolve).unwrap();
assert_eq!(resolves.get(), 1, "an exact pin at another version is resolved again");
let missing = " .package(url: \"https://github.com/machinefabric/tagged-urn-objc.git\", exact: \"1.50.545\"),\n";
follow_into_package_resolved(&manifest, &swift("1.78.3254", missing), &resolve).unwrap();
assert_eq!(resolves.get(), 2, "an exact pin the resolution does not hold is resolved");
let local = "let package = Package(dependencies: [ .package(path: \"../lungo-swift\") ])\n";
follow_into_package_resolved(&manifest, local, &resolve).unwrap();
assert_eq!(resolves.get(), 2, "a local package is not pinned by version");
}
#[test]
fn test11292_a_rendered_manifest_carries_its_version_into_the_lockfile() {
let directory = tempfile::tempdir().expect("a scratch directory");
let manifest = directory.path().join("Cargo.toml");
let lock = directory.path().join("Cargo.lock");
fs::write(
&lock,
"version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n\n\
[[package]]\nname = \"wsb\"\nversion = \"1.324.5836\"\ndependencies = [\n \"other\",\n]\n",
)
.expect("writes");
let rendered = "[package]\nname = \"wsb\"\nversion = \"1.325.5840\"\nedition = \"2021\"\n";
fs::write(&manifest, rendered).expect("writes");
follow_into_lockfile(&manifest, rendered).expect("the lockfile follows");
let after = fs::read_to_string(&lock).expect("reads");
assert!(after.contains("version = \"1.325.5840\""), "{after}");
assert!(!after.contains("1.324.5836"), "the stale version is gone:\n{after}");
assert!(after.contains("name = \"other\"\nversion = \"0.1.0\""), "{after}");
assert!(after.contains("dependencies = [\n \"other\",\n]"), "{after}");
}
#[test]
fn test11360_a_workspace_member_updates_the_workspace_lockfile() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"daemon\"]\nresolver = \"2\"\n",
)
.unwrap();
let lock = root.join("Cargo.lock");
fs::write(
&lock,
"version = 4\n\n[[package]]\nname = \"wsb-daemon\"\nversion = \"1.325.5840\"\n",
)
.unwrap();
let member = root.join("daemon");
fs::create_dir_all(&member).unwrap();
let manifest = member.join("Cargo.toml");
let rendered = "[package]\nname = \"wsb-daemon\"\nversion = \"1.328.5871\"\n";
fs::write(&manifest, rendered).unwrap();
follow_into_lockfile(&manifest, rendered).expect("the workspace lockfile follows");
let after = fs::read_to_string(&lock).unwrap();
assert!(after.contains("version = \"1.328.5871\""), "{after}");
assert!(!after.contains("1.325.5840"), "the stale version is gone:\n{after}");
assert!(!member.join("Cargo.lock").exists());
}
#[test]
fn test11361_a_standalone_crate_updates_its_own_lockfile() {
let place = TempDir::new().unwrap();
let root = place.path();
let manifest = root.join("Cargo.toml");
let lock = root.join("Cargo.lock");
fs::write(
&lock,
"version = 4\n\n[[package]]\nname = \"alone\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let rendered = "[package]\nname = \"alone\"\nversion = \"0.2.0\"\n";
fs::write(&manifest, rendered).unwrap();
follow_into_lockfile(&manifest, rendered).expect("its own lockfile follows");
assert!(fs::read_to_string(&lock).unwrap().contains("0.2.0"));
}
#[test]
fn test11362_a_member_never_reaches_past_its_workspace_root() {
let outer = TempDir::new().unwrap();
let root = outer.path();
let intruder = root.join("Cargo.lock");
fs::write(
&intruder,
"version = 4\n\n[[package]]\nname = \"member\"\nversion = \"9.9.9\"\n",
)
.unwrap();
fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = [\"inner\"]\n").unwrap();
let inner = root.join("inner");
fs::create_dir_all(&inner).unwrap();
let manifest = inner.join("Cargo.toml");
let rendered = "[package]\nname = \"member\"\nversion = \"1.0.0\"\n";
fs::write(&manifest, rendered).unwrap();
follow_into_lockfile(&manifest, rendered).expect("nothing to do");
assert!(fs::read_to_string(&intruder).unwrap().contains("1.0.0"));
}
#[test]
fn test11293_only_a_manifest_with_a_version_and_a_lockfile_is_followed() {
let directory = tempfile::tempdir().expect("a scratch directory");
let other = directory.path().join("package.json");
fs::write(&other, "{}").expect("writes");
follow_into_lockfile(&other, "{}").expect("nothing to do");
let manifest = directory.path().join("Cargo.toml");
let rendered = "[package]\nname = \"solo\"\nversion = \"1.0.0\"\n";
fs::write(&manifest, rendered).expect("writes");
follow_into_lockfile(&manifest, rendered).expect("nothing to do");
assert!(!directory.path().join("Cargo.lock").exists(), "none is created");
let lock = directory.path().join("Cargo.lock");
let original = "version = 4\n\n[[package]]\nname = \"member\"\nversion = \"2.0.0\"\n";
fs::write(&lock, original).expect("writes");
let virtual_root = "[workspace]\nmembers = [\"member\"]\nresolver = \"2\"\n";
fs::write(&manifest, virtual_root).expect("writes");
follow_into_lockfile(&manifest, virtual_root).expect("nothing to do");
assert_eq!(fs::read_to_string(&lock).expect("reads"), original);
}
#[test]
fn test12306_a_workspace_version_follows_into_every_inheriting_member() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
for (dir, manifest) in [
("crates/a", "[package]\nname = \"a\"\nversion.workspace = true\n"),
("crates/b", "[package]\nname = \"b\"\nversion = { workspace = true }\n"),
("tools/own", "[package]\nname = \"own\"\nversion = \"0.3.0\"\n"),
] {
fs::create_dir_all(root.join(dir)).unwrap();
fs::write(root.join(dir).join("Cargo.toml"), manifest).unwrap();
}
let lock = root.join("Cargo.lock");
fs::write(
&lock,
"version = 4\n\n[[package]]\nname = \"a\"\nversion = \"1.0.0\"\n\n\
[[package]]\nname = \"b\"\nversion = \"1.0.0\"\n\n\
[[package]]\nname = \"own\"\nversion = \"0.2.0\"\n\n\
[[package]]\nname = \"elsewhere\"\nversion = \"1.0.0\"\n",
)
.unwrap();
let manifest = root.join("Cargo.toml");
let rendered = "[workspace]\nmembers = [\"crates/*\", \"tools/own\"]\n\n\
[workspace.package]\nversion = \"1.1.0\"\n";
fs::write(&manifest, rendered).unwrap();
follow_into_lockfile(&manifest, rendered).expect("the lockfile follows");
let after = fs::read_to_string(&lock).unwrap();
assert!(after.contains("name = \"a\"\nversion = \"1.1.0\""), "{after}");
assert!(after.contains("name = \"b\"\nversion = \"1.1.0\""), "{after}");
assert!(after.contains("name = \"own\"\nversion = \"0.2.0\""), "its own version is its own:\n{after}");
assert!(after.contains("name = \"elsewhere\"\nversion = \"1.0.0\""), "not a member:\n{after}");
let globbed = "[workspace]\nmembers = [\"cr*/a\"]\n\n[workspace.package]\nversion = \"1.2.0\"\n";
let err = follow_into_lockfile(&manifest, globbed).unwrap_err();
assert!(format!("{err:#}").contains("cr*/a"), "{err:#}");
}
#[test]
fn test12309_the_governing_lock_is_named_whether_or_not_it_changed() {
let dir = TempDir::new().unwrap();
let manifest = dir.path().join("Cargo.toml");
let lock = dir.path().join("Cargo.lock");
let rendered = "[package]\nname = \"crate\"\nversion = \"2.0.0\"\n";
fs::write(&manifest, rendered).unwrap();
fs::write(&lock, "version = 4\n\n[[package]]\nname = \"crate\"\nversion = \"1.0.0\"\n").unwrap();
assert_eq!(follow_into_lockfile(&manifest, rendered).unwrap(), Some(lock.clone()), "rewritten");
assert!(fs::read_to_string(&lock).unwrap().contains("2.0.0"));
assert_eq!(
follow_into_lockfile(&manifest, rendered).unwrap(),
Some(lock.clone()),
"already in agreement, and still the lock the commit must carry"
);
fs::remove_file(&lock).unwrap();
assert_eq!(follow_into_lockfile(&manifest, rendered).unwrap(), None);
}
use tempfile::TempDir;
pub(super) 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(),
}
}
pub(super) 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_name": name,
"tools": {},
"wstemplate_entries": [
{ "alias": alias, "root": crate::workspace_state::relative_to(scan_root, &project_dir).unwrap() }
]
});
fs::write(
project_dir.join(".wsb").join("state.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.unwrap();
project_dir
}
pub(super) 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_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_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 test12441_only_the_projects_own_locks_are_followed() {
let workspace = TempDir::new().unwrap();
let lib = make_project(workspace.path(), "lib", "lib", "2.0.0", workspace.path());
let app = make_project(workspace.path(), "app", "app", "3.0.0", workspace.path());
fs::write(
lib.join("Cargo.toml.wstemplate"),
"[package]\nname = \"lib\"\nversion = \"{{ project.version }}\"\n",
)
.unwrap();
fs::write(lib.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"lib\"\nversion = \"1.0.0\"\n").unwrap();
fs::write(
app.join("Cargo.toml.wstemplate"),
"[package]\nname = \"app\"\nversion = \"{{ project.version }}\"\n# lib {{ projects.lib.version }}\n",
)
.unwrap();
let app_lock = "version = 4\n\n[[package]]\nname = \"app\"\nversion = \"0.0.1\"\n";
fs::write(app.join("Cargo.lock"), app_lock).unwrap();
let engine = WstemplateEngine::new(
make_vi("2.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(), 2, "its own template and the peer's that references it");
assert!(
fs::read_to_string(lib.join("Cargo.lock")).unwrap().contains("version = \"2.0.0\""),
"the project's own lock follows its manifest"
);
assert!(
fs::read_to_string(app.join("Cargo.toml")).unwrap().contains("# lib 2.0.0"),
"the peer's manifest is rendered with the project's version"
);
assert_eq!(
fs::read_to_string(app.join("Cargo.lock")).unwrap(),
app_lock,
"the peer's lock is the peer's to follow"
);
}
#[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\"");
}
#[test]
fn test8962_a_template_inside_a_submodule_is_not_the_parents() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
run_git(root, &["init", "-q", "-b", "main"]);
run_git(root, &["config", "user.email", "t@t"]);
run_git(root, &["config", "user.name", "t"]);
fs::write(root.join("README"), "parent\n").unwrap();
run_git(root, &["add", "README"]);
run_git(root, &["commit", "-qm", "parent"]);
let child_src = TempDir::new().unwrap();
run_git(child_src.path(), &["init", "-q", "-b", "main"]);
run_git(child_src.path(), &["config", "user.email", "t@t"]);
run_git(child_src.path(), &["config", "user.name", "t"]);
fs::write(
child_src.path().join("package.json.wstemplate"),
"{\"version\": \"{{ project.version }}\"}",
)
.unwrap();
fs::write(child_src.path().join("version.txt"), "2.0.0").unwrap();
run_git(child_src.path(), &["add", "."]);
run_git(child_src.path(), &["commit", "-qm", "child"]);
run_git(
root,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
"-q",
child_src.path().to_str().unwrap(),
"child",
],
);
fs::create_dir_all(root.join(".wsb")).unwrap();
let engine = WstemplateEngine::new(
make_vi("1.0.0"),
Some("parent".to_string()),
"parent".to_string(),
root.to_path_buf(),
root.to_path_buf(),
);
let template = root.join("child/package.json.wstemplate");
assert!(template.is_file(), "the submodule's template is present");
let project_roots = find_all_project_roots(root).unwrap();
assert!(
!project_roots
.values()
.any(|p| p == &root.join("child")),
"the submodule is deliberately unregistered — this is the fresh-clone state"
);
assert!(
!engine.is_own_template(&template, &project_roots),
"an unregistered submodule's template must not be claimed by the parent; \
claiming it stamps the parent's version into the submodule's manifest"
);
fs::write(root.join("own.txt.wstemplate"), "{{ project.version }}").unwrap();
assert!(
engine.is_own_template(&root.join("own.txt.wstemplate"), &project_roots),
"the parent still owns the templates that are actually its own"
);
}
fn run_git(dir: &Path, args: &[&str]) {
let out = crate::git_backend::git_command()
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
}
#[cfg(test)]
mod environment_tests {
use super::tests::{make_git_project, make_project, make_vi};
use super::*;
use std::fs;
use tempfile::TempDir;
fn render(root: &Path, project: &Path, alias: &str) -> Result<Vec<RenderedTemplate>> {
WstemplateEngine::new(
make_vi("1.0.0"),
Some(alias.to_string()),
alias.to_string(),
project.to_path_buf(),
root.to_path_buf(),
)
.render_relevant()
}
#[test]
fn test10100_the_environment_chooses_which_section_is_rendered() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
let lib = make_git_project(root, "thelib", "thelib", "2.5.0", root);
crate::git_backend::create_tag(
&crate::git_backend::open_repo(&lib).unwrap(),
"v2.4.0",
"released",
)
.unwrap();
let app = make_project(root, "theapp", "theapp", "1.0.0", root);
fs::write(
app.join("Cargo.toml.wstemplate"),
"[dependencies]\n\
{%- if env.WSB_TEST_LOCAL_LIBS %}\n\
thelib = { path = \"{{ projects.thelib.path_from_here }}\" }\n\
{%- else %}\n\
thelib = \"{{ projects.thelib.latest_version }}\"\n\
{%- endif %}\n",
)
.unwrap();
std::env::remove_var("WSB_TEST_LOCAL_LIBS");
render(root, &app, "theapp").expect("the ordinary render");
let published = fs::read_to_string(app.join("Cargo.toml")).unwrap();
assert!(published.contains("thelib = \"2.4.0\""), "{published}");
assert!(!published.contains("path ="), "{published}");
std::env::set_var("WSB_TEST_LOCAL_LIBS", "1");
render(root, &app, "theapp").expect("the local render");
let local = fs::read_to_string(app.join("Cargo.toml")).unwrap();
std::env::remove_var("WSB_TEST_LOCAL_LIBS");
assert!(local.contains("thelib = { path = \"../thelib\" }"), "{local}");
assert!(!local.contains("2.4.0"), "{local}");
}
#[test]
fn test10101_an_empty_variable_reads_as_unset_and_a_value_is_compared() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
let app = make_project(root, "theapp", "theapp", "1.0.0", root);
fs::write(
app.join("state.txt.wstemplate"),
"presence={% if env.WSB_TEST_MODE %}yes{% else %}no{% endif %} \
local={% if env.WSB_TEST_MODE == \"local\" %}yes{% else %}no{% endif %} \
absent={% if not env.WSB_TEST_MODE %}yes{% else %}no{% endif %}\n",
)
.unwrap();
let read = |value: Option<&str>| {
match value {
Some(text) => std::env::set_var("WSB_TEST_MODE", text),
None => std::env::remove_var("WSB_TEST_MODE"),
}
render(root, &app, "theapp").expect("the render");
fs::read_to_string(app.join("state.txt")).unwrap()
};
assert_eq!(read(None).trim(), "presence=no local=no absent=yes");
assert_eq!(
read(Some("")).trim(),
"presence=no local=no absent=yes",
"an empty value is the same state as no value, as it is in a shell"
);
assert_eq!(read(Some("local")).trim(), "presence=yes local=yes absent=no");
assert_eq!(
read(Some("0")).trim(),
"presence=yes local=no absent=no",
"'0' is a value that was set, not a denial"
);
std::env::remove_var("WSB_TEST_MODE");
}
#[test]
fn test20010_a_runs_variable_is_its_own() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
let app = make_project(root, "theapp", "theapp", "1.0.0", root);
fs::write(
app.join("state.txt.wstemplate"),
"{% if env.WSB_TEST_RUN_ONLY %}{{ env.WSB_TEST_RUN_ONLY }}{% else %}unset{% endif %}\n",
)
.unwrap();
let render_with = |cache: Arc<RunCache>| {
WstemplateEngine::with_cache(
make_vi("1.0.0"),
Some("theapp".to_string()),
"theapp".to_string(),
app.clone(),
root.to_path_buf(),
cache,
)
.render_relevant()
.expect("the render");
fs::read_to_string(app.join("state.txt")).unwrap()
};
std::env::remove_var("WSB_TEST_RUN_ONLY");
assert_eq!(render_with(RunCache::new()).trim(), "unset");
assert_eq!(render_with(RunCache::with_variable("WSB_TEST_RUN_ONLY", "local")).trim(), "local");
assert!(
std::env::var_os("WSB_TEST_RUN_ONLY").is_none(),
"the run's variable leaked into the process's environment"
);
std::env::set_var("WSB_TEST_RUN_ONLY", "process");
let both = render_with(RunCache::with_variable("WSB_TEST_RUN_ONLY", "run"));
std::env::remove_var("WSB_TEST_RUN_ONLY");
assert_eq!(both.trim(), "run", "the run's own value must win over the process's");
}
#[test]
fn test10102_a_local_path_is_relative_to_the_file_being_written() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
make_git_project(root, "thelib", "thelib", "2.5.0", root);
let app = make_project(root, "theapp", "theapp", "1.0.0", root);
let member = app.join("crates").join("inner");
fs::create_dir_all(&member).unwrap();
fs::write(
member.join("Cargo.toml.wstemplate"),
"path = \"{{ projects.thelib.path_from_here }}\"\n",
)
.unwrap();
render(root, &app, "theapp").expect("the render");
let written = fs::read_to_string(member.join("Cargo.toml")).unwrap();
assert!(
written.contains("path = \"../../../thelib\""),
"two levels down needs two more steps up: {written}"
);
}
}
pub fn lockfile_followed_by(manifest: &Path) -> Option<PathBuf> {
let beside = |name: &str| manifest.parent().map(|dir| dir.join(name));
match manifest.file_name().and_then(|n| n.to_str()) {
Some("go.mod") => beside("go.sum"),
Some("package.json") => beside("package-lock.json"),
Some("Package.swift") => beside("Package.resolved"),
Some("Cargo.toml") => governing_lockfile(manifest),
_ => None,
}
}
pub fn follow_into_lockfile(manifest: &Path, content: &str) -> Result<Option<PathBuf>> {
match manifest.file_name().and_then(|n| n.to_str()) {
Some("go.mod") => return follow_into_go_sum(manifest, content, &download_go_module),
Some("package.json") => return follow_into_package_lock(manifest, content, &relock_npm),
Some("Package.swift") => return follow_into_package_resolved(manifest, content, &resolve_swift),
_ => {}
}
if manifest.file_name().and_then(|n| n.to_str()) != Some("Cargo.toml") {
return Ok(None);
}
let versioned = versioned_packages(manifest, content)?;
if versioned.is_empty() {
return Ok(None);
}
let Some(lock) = governing_lockfile(manifest) else {
return Ok(None);
};
let mut text = std::fs::read_to_string(&lock)
.with_context(|| format!("reading {}", lock.display()))?;
let mut changed = false;
for (name, version) in &versioned {
if let Some(updated) = lockfile_with_version(&text, name, version) {
text = updated;
changed = true;
}
}
if changed {
write_output(&lock, &text)?;
}
Ok(Some(lock))
}
fn versioned_packages(manifest: &Path, content: &str) -> Result<Vec<(String, String)>> {
let parsed: toml::Value = toml::from_str(content)
.with_context(|| format!("{} is not a valid manifest", manifest.display()))?;
let shared = parsed
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.map(str::to_string);
let mut versioned = Vec::new();
if let Some(package) = parsed.get("package") {
if let Some(name) = package.get("name").and_then(|n| n.as_str()) {
match package.get("version") {
Some(toml::Value::String(version)) if !version.is_empty() => {
versioned.push((name.to_string(), version.clone()))
}
Some(inherited) if inherits_workspace(inherited) => {
if let Some(version) = &shared {
versioned.push((name.to_string(), version.clone()));
}
}
_ => {}
}
}
}
let Some(version) = shared else {
return Ok(versioned);
};
let root = manifest.parent().expect("a manifest path has a directory");
let members = parsed
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
.cloned()
.unwrap_or_default();
for member in members {
let Some(member) = member.as_str() else {
bail!("{}: a workspace member is not a path: {member}", manifest.display());
};
for directory in member_directories(root, member)
.with_context(|| format!("{}: member {member}", manifest.display()))?
{
let path = directory.join("Cargo.toml");
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}, a member of {}", path.display(), manifest.display()))?;
let member_manifest: toml::Value = toml::from_str(&text)
.with_context(|| format!("{} is not a valid manifest", path.display()))?;
let Some(package) = member_manifest.get("package") else {
continue;
};
if package.get("version").is_some_and(inherits_workspace) {
let name = package.get("name").and_then(|n| n.as_str()).ok_or_else(|| {
anyhow!("{} inherits the workspace's version but names no package", path.display())
})?;
versioned.push((name.to_string(), version.clone()));
}
}
}
Ok(versioned)
}
fn inherits_workspace(value: &toml::Value) -> bool {
value.get("workspace").and_then(|w| w.as_bool()) == Some(true)
}
fn member_directories(root: &Path, member: &str) -> Result<Vec<PathBuf>> {
if let Some(parent) = member.strip_suffix("/*") {
if !parent.contains(['*', '?', '[']) {
let mut found = Vec::new();
for entry in std::fs::read_dir(root.join(parent))
.with_context(|| format!("reading {}", root.join(parent).display()))?
{
let path = entry?.path();
if path.join("Cargo.toml").is_file() {
found.push(path);
}
}
found.sort();
return Ok(found);
}
}
if member.contains(['*', '?', '[']) {
bail!("the member pattern '{member}' is not followed: name the directory, or end it in /*");
}
Ok(vec![root.join(member)])
}
pub struct GoModule {
pub sum: String,
pub go_mod_sum: String,
pub requires: Vec<(String, String)>,
}
pub fn follow_into_go_sum(
manifest: &Path,
content: &str,
download: &dyn Fn(&str, &str) -> Result<GoModule>,
) -> Result<Option<PathBuf>> {
let dir = manifest.parent().unwrap_or(Path::new("."));
let sum_path = dir.join("go.sum");
if !sum_path.is_file() || !tracked(&sum_path) {
return Ok(None);
}
let existing = std::fs::read_to_string(&sum_path)
.with_context(|| format!("reading {}", sum_path.display()))?;
let mut lines: std::collections::BTreeSet<String> =
existing.lines().filter(|l| !l.trim().is_empty()).map(str::to_string).collect();
let has = |lines: &std::collections::BTreeSet<String>, module: &str, version: &str| {
lines.iter().any(|l| l.starts_with(&format!("{module} {version} ")))
};
let has_go_mod = |lines: &std::collections::BTreeSet<String>, module: &str, version: &str| {
lines.iter().any(|l| l.starts_with(&format!("{module} {version}/go.mod ")))
};
let (requires, replaced) = go_mod_requirements(content);
let mut changed = false;
let mut graph: Vec<(String, String)> = Vec::new();
for (module, version) in requires {
if replaced.contains(&module) {
continue;
}
if has(&lines, &module, &version) && has_go_mod(&lines, &module, &version) {
continue;
}
let found = download(&module, &version)?;
lines.insert(format!("{module} {version} {}", found.sum));
lines.insert(format!("{module} {version}/go.mod {}", found.go_mod_sum));
graph.extend(found.requires);
changed = true;
}
let mut seen = std::collections::BTreeSet::new();
while let Some((module, version)) = graph.pop() {
if !seen.insert((module.clone(), version.clone())) || has_go_mod(&lines, &module, &version) {
continue;
}
let found = download(&module, &version)?;
lines.insert(format!("{module} {version}/go.mod {}", found.go_mod_sum));
graph.extend(found.requires);
changed = true;
}
if changed {
let text: String = lines.iter().map(|l| format!("{l}\n")).collect();
write_output(&sum_path, &text)?;
}
Ok(sum_path.is_file().then_some(sum_path))
}
pub fn go_mod_requirements(content: &str) -> (Vec<(String, String)>, Vec<String>) {
let mut requires = Vec::new();
let mut replaced = Vec::new();
let mut block: Option<&str> = None;
for raw in content.lines() {
let line = raw.split("//").next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
if let Some(kind) = block {
if line == ")" {
block = None;
continue;
}
match kind {
"require" => push_requirement(line, &mut requires),
"replace" => push_local_replace(line, &mut replaced),
_ => {}
}
continue;
}
let (word, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
let rest = rest.trim();
match (word, rest) {
("require" | "replace", "(") => block = Some(word),
("require", rest) => push_requirement(rest, &mut requires),
("replace", rest) => push_local_replace(rest, &mut replaced),
_ => {}
}
}
(requires, replaced)
}
fn go_mod_word(word: &str) -> String {
word.trim_matches(|c| c == '"' || c == '`').to_string()
}
fn push_requirement(line: &str, into: &mut Vec<(String, String)>) {
let mut words = line.split_whitespace();
if let (Some(module), Some(version)) = (words.next(), words.next()) {
into.push((go_mod_word(module), go_mod_word(version)));
}
}
fn push_local_replace(line: &str, into: &mut Vec<String>) {
if let Some((from, to)) = line.split_once("=>") {
let target = go_mod_word(to.trim());
if target.starts_with('.') || target.starts_with('/') {
if let Some(module) = from.split_whitespace().next() {
into.push(go_mod_word(module));
}
}
}
}
fn download_go_module(module: &str, version: &str) -> Result<GoModule> {
let scratch = tempfile::tempdir().context("creating a directory to ask go from")?;
let output = crate::git_backend::command_without_repo_scope("go")
.args(["mod", "download", "-json"])
.arg(format!("{module}@{version}"))
.env("GOFLAGS", "-mod=mod")
.env("GOPRIVATE", "github.com/machinefabric/*")
.current_dir(scratch.path())
.output()
.with_context(|| format!("running go mod download for {module}@{version} (is go installed?)"))?;
let answer: serde_json::Value = serde_json::from_slice(&output.stdout).with_context(|| {
format!(
"go mod download {module}@{version} gave no answer: {}",
String::from_utf8_lossy(&output.stderr).trim()
)
})?;
if let Some(error) = answer.get("Error").and_then(|e| e.as_str()) {
anyhow::bail!("go.sum cannot record {module} {version}: {error}");
}
let field = |name: &str| {
answer
.get(name)
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("go mod download {module}@{version} reported no {name}"))
};
let go_mod = std::fs::read_to_string(field("GoMod")?).unwrap_or_default();
Ok(GoModule {
sum: field("Sum")?,
go_mod_sum: field("GoModSum")?,
requires: go_mod_requirements(&go_mod).0,
})
}
fn tracked(path: &Path) -> bool {
let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else {
return false;
};
crate::git_backend::git_command()
.arg("-C")
.arg(dir)
.args(["ls-files", "--error-unmatch", "--"])
.arg(name)
.output()
.is_ok_and(|out| out.status.success())
}
pub fn follow_into_package_lock(
manifest: &Path,
content: &str,
relock: &dyn Fn(&Path) -> Result<()>,
) -> Result<Option<PathBuf>> {
let dir = manifest.parent().unwrap_or(Path::new("."));
let lock_path = dir.join("package-lock.json");
if !lock_path.is_file() || !tracked(&lock_path) {
return Ok(None);
}
let rendered: Value = serde_json::from_str(content)
.with_context(|| format!("{} is not JSON", manifest.display()))?;
let lock: Value = serde_json::from_str(&fs::read_to_string(&lock_path)?)
.with_context(|| format!("{} is not JSON", lock_path.display()))?;
let recorded = lock.pointer("/packages/").cloned().unwrap_or(Value::Null);
let specifications_agree = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
.iter()
.all(|section| {
let wanted = rendered.get(section).cloned().unwrap_or_else(|| json!({}));
let held = recorded.get(section).cloned().unwrap_or_else(|| json!({}));
wanted == held
});
let version = rendered.get("version");
let version_agrees = version.is_none()
|| (lock.get("version") == version && recorded.get("version") == version);
let stale_links = links_held_for_registry_dependencies(&rendered, &lock);
let agrees = specifications_agree && version_agrees && stale_links.is_empty();
if !agrees {
if !stale_links.is_empty() {
let without = without_links(lock, &stale_links);
let mut text = serde_json::to_string_pretty(&without)
.with_context(|| format!("writing {}", lock_path.display()))?;
text.push('\n');
fs::write(&lock_path, text).with_context(|| format!("writing {}", lock_path.display()))?;
}
relock(dir)?;
}
Ok(Some(lock_path))
}
fn names_a_local_path(specification: &str) -> bool {
["file:", "link:", "./", "../", "/", "~/"].iter().any(|prefix| specification.starts_with(prefix))
}
fn links_held_for_registry_dependencies(manifest: &Value, lock: &Value) -> Vec<(String, String)> {
let Some(packages) = lock.get("packages").and_then(Value::as_object) else {
return Vec::new();
};
let mut held = Vec::new();
for section in ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] {
let Some(wanted) = manifest.get(section).and_then(Value::as_object) else { continue };
for (name, specification) in wanted {
if specification.as_str().is_none_or(names_a_local_path) {
continue;
}
let key = format!("node_modules/{name}");
let Some(entry) = packages.get(&key) else { continue };
if entry.get("link").and_then(Value::as_bool) != Some(true) {
continue;
}
let target = entry.get("resolved").and_then(Value::as_str).unwrap_or_default().to_string();
if !held.iter().any(|(known, _): &(String, String)| *known == key) {
held.push((key, target));
}
}
}
held
}
fn without_links(mut lock: Value, links: &[(String, String)]) -> Value {
if let Some(packages) = lock.get_mut("packages").and_then(Value::as_object_mut) {
packages.retain(|key, _| {
!links.iter().any(|(link, target)| {
key == link || (!target.is_empty() && (key == target || key.starts_with(&format!("{target}/"))))
})
});
}
lock
}
fn relock_npm(dir: &Path) -> Result<()> {
let output = crate::git_backend::program_without_repo_scope("npm")
.args(["install", "--package-lock-only", "--prefer-online", "--ignore-scripts", "--no-audit", "--no-fund"])
.current_dir(dir)
.output()
.with_context(|| format!("running npm in {} to bring its lock up to date (is npm installed?)", dir.display()))?;
if !output.status.success() {
anyhow::bail!(
"npm could not bring {}/package-lock.json up to date with its manifest: {}",
dir.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
pub fn follow_into_package_resolved(
manifest: &Path,
content: &str,
resolve: &dyn Fn(&Path) -> Result<()>,
) -> Result<Option<PathBuf>> {
let dir = manifest.parent().unwrap_or(Path::new("."));
let resolved_path = dir.join("Package.resolved");
if !resolved_path.is_file() || !tracked(&resolved_path) {
return Ok(None);
}
let resolved: Value = serde_json::from_str(&fs::read_to_string(&resolved_path)?)
.with_context(|| format!("{} is not JSON", resolved_path.display()))?;
let pins: Vec<(String, String)> = resolved
.get("pins")
.and_then(Value::as_array)
.map(|pins| {
pins.iter()
.filter_map(|pin| {
Some((
swift_location(pin.get("location")?.as_str()?),
pin.pointer("/state/version")?.as_str()?.to_string(),
))
})
.collect()
})
.unwrap_or_default();
let agrees = exact_swift_pins(content)
.iter()
.all(|(url, version)| pins.iter().any(|(at, pinned)| *at == swift_location(url) && pinned == version));
if !agrees {
resolve(dir)?;
}
Ok(Some(resolved_path))
}
pub fn exact_swift_pins(content: &str) -> Vec<(String, String)> {
let mut pins = Vec::new();
for line in content.lines() {
let code = without_line_comment(line);
let (Some(url_at), Some(exact_at)) = (code.find("url:"), code.find("exact:")) else {
continue;
};
let quoted = |from: usize| -> Option<String> {
let rest = &code[from..];
let start = rest.find('"')? + 1;
let end = rest[start..].find('"')?;
Some(rest[start..start + end].to_string())
};
if let (Some(url), Some(version)) = (quoted(url_at), quoted(exact_at)) {
pins.push((url, version));
}
}
pins
}
fn without_line_comment(line: &str) -> &str {
let mut quoted = false;
let bytes = line.as_bytes();
for (at, byte) in bytes.iter().enumerate() {
match byte {
b'"' => quoted = !quoted,
b'/' if !quoted && bytes.get(at + 1) == Some(&b'/') => return &line[..at],
_ => {}
}
}
line
}
fn swift_location(url: &str) -> String {
url.trim_end_matches('/').trim_end_matches(".git").to_ascii_lowercase()
}
fn resolve_swift(dir: &Path) -> Result<()> {
let output = crate::git_backend::command_without_repo_scope("swift")
.args(["package", "resolve"])
.current_dir(dir)
.output()
.with_context(|| format!("running swift package resolve in {} (is Swift installed?)", dir.display()))?;
if !output.status.success() {
anyhow::bail!(
"swift could not resolve {} against its manifest: {}",
dir.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
fn governing_lockfile(manifest: &Path) -> Option<PathBuf> {
let start = manifest.parent()?;
let mut here = Some(start);
while let Some(directory) = here {
let candidate = directory.join("Cargo.toml");
let Ok(text) = std::fs::read_to_string(&candidate) else {
break;
};
if declares_workspace(&text) {
let lock = directory.join("Cargo.lock");
return lock.is_file().then_some(lock);
}
here = directory.parent();
}
let lock = start.join("Cargo.lock");
lock.is_file().then_some(lock)
}
fn declares_workspace(content: &str) -> bool {
content.lines().any(|line| {
let line = line.trim();
line == "[workspace]" || line.starts_with("[workspace.")
})
}
fn lockfile_with_version(lock: &str, name: &str, version: &str) -> Option<String> {
let wanted = format!("name = \"{name}\"");
let mut lines: Vec<String> = lock.lines().map(str::to_string).collect();
let mut changed = false;
for index in 0..lines.len() {
if lines[index].trim() != wanted {
continue;
}
for candidate in lines.iter_mut().skip(index + 1) {
let trimmed = candidate.trim();
if trimmed.is_empty() || trimmed.starts_with("[[") {
break;
}
if let Some(rest) = trimmed.strip_prefix("version = ") {
if rest.trim().trim_matches('"') == version {
break;
}
*candidate = format!("version = \"{version}\"");
changed = true;
break;
}
}
}
if !changed {
return None;
}
let mut text = lines.join("\n");
if lock.ends_with('\n') {
text.push('\n');
}
Some(text)
}
#[cfg(test)]
mod declaration_tests {
use super::tests::{make_project, make_vi};
use super::*;
use std::fs;
use tempfile::TempDir;
fn workspace_with(declaration: &str, template: &str) -> (TempDir, Result<Vec<RenderedTemplate>>) {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
fs::create_dir_all(root.join(".wsb")).unwrap();
fs::write(
root.join(super::super::stamp_config::FILE),
r#"{"declared": "workspace.json", "build_root": ".wsb/disposable/build"}"#,
)
.unwrap();
fs::write(root.join("workspace.json"), declaration).unwrap();
let app = make_project(root, "theapp", "theapp", "1.0.0", root);
fs::write(app.join("requirements.txt.wstemplate"), template).unwrap();
let rendered = WstemplateEngine::new(
make_vi("1.0.0"),
Some("theapp".to_string()),
"theapp".to_string(),
app,
root.to_path_buf(),
)
.render_relevant();
(workspace, rendered)
}
#[test]
fn test11354_a_declared_value_is_rendered() {
let (_workspace, rendered) = workspace_with(
r#"{"mg_test": {"bootstrap": {"published_capdag": "1.308.0"}}}"#,
"capdag=={{ declared.mg_test.bootstrap.published_capdag }}\n",
);
let rendered = rendered.expect("the declaration should render");
assert_eq!(rendered.len(), 1);
assert_eq!(
fs::read_to_string(&rendered[0].output_path).unwrap(),
"capdag==1.308.0\n"
);
}
#[test]
fn test11355_an_undeclared_path_is_named_with_its_line() {
let (_workspace, rendered) = workspace_with(
r#"{"mg_test": {"bootstrap": {"published_capdag": "1.308.0"}}}"#,
"# a comment\ncapdag=={{ declared.mg_test.bootstrap.published_capdog }}\n",
);
let error = rendered.expect_err("an undeclared path must be refused");
let said = format!("{error:#}");
assert!(said.contains("line 2"), "{said}");
assert!(
said.contains("declared.mg_test.bootstrap.published_capdog"),
"{said}"
);
assert!(said.contains("published_capdag"), "{said}");
}
#[test]
fn test11356_a_path_through_a_leaf_is_refused() {
let (_workspace, rendered) = workspace_with(
r#"{"mg_test": {"bootstrap": {"published_capdag": "1.308.0"}}}"#,
"{{ declared.mg_test.bootstrap.published_capdag.major }}\n",
);
let error = rendered.expect_err("a path through a leaf must be refused");
let said = format!("{error:#}");
assert!(said.contains("not something with named parts"), "{said}");
}
#[test]
fn test12300_the_build_root_is_named_from_the_rendered_file() {
let (workspace, rendered) = workspace_with(
"{}",
"path = \"{{ build.root_from_here }}/lungo/distribution\"\n",
);
let rendered = rendered.expect("the build root should render");
let text = fs::read_to_string(&rendered[0].output_path).unwrap();
assert_eq!(text, "path = \"../.wsb/disposable/build/lungo/distribution\"\n");
assert!(!workspace.path().join(".wsb/disposable").exists(), "nothing was built");
drop(workspace);
}
#[test]
fn test12301_an_unknown_build_field_is_refused() {
let (_workspace, rendered) = workspace_with("{}", "{{ build.root }}\n");
let said = format!("{:#}", rendered.expect_err("an unknown build field must be refused"));
assert!(said.contains("root_from_here"), "{said}");
}
#[test]
fn test11357_a_template_that_asks_for_nothing_needs_no_declaration() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
let app = make_project(root, "theapp", "theapp", "1.0.0", root);
fs::write(app.join("version.txt.wstemplate"), "{{ project.version }}\n").unwrap();
let rendered = WstemplateEngine::new(
make_vi("1.0.0"),
Some("theapp".to_string()),
"theapp".to_string(),
app,
root.to_path_buf(),
)
.render_relevant()
.expect("a template with no declaration reference should render");
assert_eq!(fs::read_to_string(&rendered[0].output_path).unwrap(), "1.0.0\n");
}
#[test]
fn test11358_a_template_that_asks_needs_the_declaration_to_exist() {
let workspace = TempDir::new().unwrap();
let root = workspace.path();
fs::create_dir_all(root.join(".wsb")).unwrap();
fs::write(
root.join(super::super::stamp_config::FILE),
r#"{"declared": "declared.json"}"#,
)
.unwrap();
let app = make_project(root, "theapp", "theapp", "1.0.0", root);
fs::write(
app.join("requirements.txt.wstemplate"),
"{{ declared.mg_test.bootstrap.published_capdag }}\n",
)
.unwrap();
let error = WstemplateEngine::new(
make_vi("1.0.0"),
Some("theapp".to_string()),
"theapp".to_string(),
app,
root.to_path_buf(),
)
.render_relevant()
.expect_err("a template asking for a declaration that is not there must fail");
let said = format!("{error:#}");
assert!(said.contains("cannot read the workspace declaration"), "{said}");
}
}
#[cfg(test)]
mod take_back_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test11392_a_restored_manifest_updates_the_lockfile() {
let place = TempDir::new().unwrap();
let root = place.path();
let manifest = root.join("Cargo.toml");
let lock = root.join("Cargo.lock");
fs::write(
&lock,
"version = 4\n\n[[package]]\nname = \"thing\"\nversion = \"1.332.5937\"\n",
)
.unwrap();
let restored = "[package]\nname = \"thing\"\nversion = \"1.331.5923\"\n";
fs::write(&manifest, restored).unwrap();
follow_into_lockfile(&manifest, restored).expect("the lockfile follows a take-back");
let after = fs::read_to_string(&lock).unwrap();
assert!(after.contains("1.331.5923"), "{after}");
assert!(
!after.contains("1.332.5937"),
"the lock must not be left ahead of the manifest:\n{after}"
);
}
}
#[cfg(all(test, unix))]
mod spelled_differently_tests {
use super::*;
#[test]
fn test12105_a_template_is_owned_through_a_linked_path() {
let tmp = tempfile::TempDir::new().unwrap();
let real = tmp.path().join("real");
let fleet = real.join("fleet");
std::fs::create_dir_all(fleet.join("mid")).unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let roots: HashMap<String, PathBuf> = [
("fleet".to_string(), crate::platform::canonicalize(&fleet).unwrap()),
("mid".to_string(), crate::platform::canonicalize(&fleet.join("mid")).unwrap()),
]
.into_iter()
.collect();
let template = link.join("fleet/fleet.toml.wstemplate");
std::fs::write(&template, "x").unwrap();
let nested = link.join("fleet/mid/mid.toml.wstemplate");
std::fs::write(&nested, "x").unwrap();
assert_eq!(
find_most_specific_root(&template, &roots),
Some(crate::platform::canonicalize(&fleet).unwrap()),
"a template found through a link was not owned by the project it is in"
);
assert_eq!(
find_most_specific_root(&nested, &roots),
Some(crate::platform::canonicalize(&fleet.join("mid")).unwrap())
);
}
}