use crate::git_backend;
use crate::prelude::*;
use crate::state::VersionInfo;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
pub struct FieldSpec {
pub name: &'static str,
pub description: &'static str,
pub network: bool,
}
pub const FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "alias",
description: "Tera alias this project is registered under",
network: false,
},
FieldSpec {
name: "name",
description: "Project name from its .wsb/state.json",
network: false,
},
FieldSpec {
name: "path",
description: "Absolute path to the project root",
network: false,
},
FieldSpec {
name: "relative_path",
description: "Path from the workspace root, e.g. 'ext/gtest' for a submodule",
network: false,
},
FieldSpec {
name: "version",
description: "Full calculated version from version.txt, e.g. '1.649.0'",
network: false,
},
FieldSpec {
name: "major_version",
description: "Major component, prefixed, e.g. 'v1'",
network: false,
},
FieldSpec {
name: "minor_version",
description: "Minor component as a number, e.g. 649",
network: false,
},
FieldSpec {
name: "patch_version",
description: "Patch component as a number",
network: false,
},
FieldSpec {
name: "latest_tag",
description: "Highest tag in this clone, verbatim, e.g. 'v1.648.6494'",
network: false,
},
FieldSpec {
name: "latest_version",
description: "latest_tag without its leading 'v' — the pin-ready form",
network: false,
},
FieldSpec {
name: "nearest_tag",
description: "Newest tag reachable from HEAD (git describe)",
network: false,
},
FieldSpec {
name: "tag_count",
description: "Number of tags in this clone, as a number",
network: false,
},
FieldSpec {
name: "latest_remote_tag",
description: "Highest tag that exists on the remote (contacts the network)",
network: true,
},
FieldSpec {
name: "latest_remote_version",
description: "latest_remote_tag without its leading 'v' (contacts the network)",
network: true,
},
FieldSpec {
name: "commit",
description: "Full HEAD commit hash",
network: false,
},
FieldSpec {
name: "short_commit",
description: "Abbreviated HEAD commit hash (7 characters)",
network: false,
},
FieldSpec {
name: "commit_date",
description: "HEAD commit date as YYYY-MM-DD",
network: false,
},
FieldSpec {
name: "commit_datetime",
description: "HEAD commit timestamp as RFC 3339",
network: false,
},
FieldSpec {
name: "branch",
description: "Checked-out branch name",
network: false,
},
FieldSpec {
name: "is_detached",
description: "Whether HEAD is detached, as a boolean",
network: false,
},
FieldSpec {
name: "is_dirty",
description: "Whether the worktree has uncommitted changes, as a boolean",
network: false,
},
FieldSpec {
name: "remote",
description: "Name of the remote the project publishes to",
network: false,
},
FieldSpec {
name: "remote_url",
description: "Fetch URL of that remote",
network: false,
},
];
pub fn is_known_field(name: &str) -> bool {
FIELDS.iter().any(|field| field.name == name)
}
pub fn requires_network<'a, I: IntoIterator<Item = &'a str>>(fields: I) -> bool {
fields.into_iter().any(|name| {
FIELDS
.iter()
.any(|field| field.name == name && field.network)
})
}
fn field_catalogue() -> String {
FIELDS
.iter()
.map(|field| field.name)
.collect::<Vec<_>>()
.join(", ")
}
pub struct ProjectIdentity<'a> {
pub alias: &'a str,
pub root: &'a Path,
pub scan_root: &'a Path,
pub version: &'a VersionInfo,
pub name: Option<&'a str>,
}
pub struct ProjectFacts {
label: String,
values: BTreeMap<String, Value>,
unavailable: BTreeMap<String, String>,
pub resolved_remote: bool,
}
impl ProjectFacts {
pub fn values(&self) -> &BTreeMap<String, Value> {
&self.values
}
pub fn unavailable(&self) -> &BTreeMap<String, String> {
&self.unavailable
}
pub fn require(&self, field: &str) -> Result<()> {
if self.values.contains_key(field) {
return Ok(());
}
if let Some(reason) = self.unavailable.get(field) {
bail!("{} has no '{}': {}", self.label, field, reason);
}
bail!(
"'{}' is not a wstemplate project field.\nAvailable fields: {}",
field,
field_catalogue()
);
}
}
struct FactBuilder {
label: String,
values: BTreeMap<String, Value>,
unavailable: BTreeMap<String, String>,
}
impl FactBuilder {
fn set(&mut self, field: &str, value: Value) {
debug_assert!(is_known_field(field), "undeclared field '{field}'");
self.values.insert(field.to_string(), value);
}
fn missing(&mut self, field: &str, reason: impl Into<String>) {
debug_assert!(is_known_field(field), "undeclared field '{field}'");
self.unavailable.insert(field.to_string(), reason.into());
}
fn missing_all(&mut self, fields: &[&str], reason: &str) {
for field in fields {
self.missing(field, reason.to_string());
}
}
}
const GIT_FIELDS: &[&str] = &[
"latest_tag",
"latest_version",
"nearest_tag",
"tag_count",
"latest_remote_tag",
"latest_remote_version",
"commit",
"short_commit",
"commit_date",
"commit_datetime",
"branch",
"is_detached",
"is_dirty",
"remote",
"remote_url",
];
const HEAD_FIELDS: &[&str] = &[
"commit",
"short_commit",
"commit_date",
"commit_datetime",
"is_dirty",
"nearest_tag",
];
const REMOTE_FIELDS: &[&str] = &[
"remote",
"remote_url",
"latest_remote_tag",
"latest_remote_version",
];
pub fn gather(identity: &ProjectIdentity<'_>, with_remote: bool) -> Result<ProjectFacts> {
let mut builder = FactBuilder {
label: format!(
"Project '{}' at {}",
identity.alias,
identity.root.display()
),
values: BTreeMap::new(),
unavailable: BTreeMap::new(),
};
gather_identity(&mut builder, identity)?;
gather_version(&mut builder, identity.version);
gather_git(&mut builder, identity.root, with_remote)?;
Ok(ProjectFacts {
label: builder.label,
values: builder.values,
unavailable: builder.unavailable,
resolved_remote: with_remote,
})
}
fn gather_identity(builder: &mut FactBuilder, identity: &ProjectIdentity<'_>) -> Result<()> {
builder.set("alias", json!(identity.alias));
builder.set("path", json!(identity.root.display().to_string()));
match identity.root.strip_prefix(identity.scan_root) {
Ok(relative) if relative.as_os_str().is_empty() => builder.set("relative_path", json!(".")),
Ok(relative) => {
let text = relative
.to_str()
.with_context(|| {
format!(
"Path of project '{}' is not valid UTF-8: {}",
identity.alias,
identity.root.display()
)
})?
.replace(std::path::MAIN_SEPARATOR, "/");
builder.set("relative_path", json!(text));
}
Err(_) => builder.missing(
"relative_path",
format!(
"it lives outside the workspace root {}",
identity.scan_root.display()
),
),
}
let name = match identity.name {
Some(name) => Some(name.to_string()),
None => read_project_name(identity.root)?,
};
match name {
Some(name) => builder.set("name", json!(name)),
None => builder.missing(
"name",
"its .wsb/state.json records no project_name — run 'wsb init' there",
),
}
Ok(())
}
fn read_project_name(root: &Path) -> Result<Option<String>> {
#[derive(Deserialize)]
struct NameOnly {
#[serde(default)]
project_name: Option<String>,
}
let state_path = root.join(".wsb").join("state.json");
if !state_path.is_file() {
return Ok(None);
}
let content = std::fs::read_to_string(&state_path)
.with_context(|| format!("Cannot read {}", state_path.display()))?;
let state: NameOnly = serde_json::from_str(&content)
.with_context(|| format!("Cannot parse {}", state_path.display()))?;
Ok(state.project_name.filter(|name| !name.trim().is_empty()))
}
fn gather_version(builder: &mut FactBuilder, version: &VersionInfo) {
builder.set("version", json!(version.full_version));
builder.set("major_version", json!(version.major_version));
builder.set("minor_version", json!(version.minor_version));
builder.set("patch_version", json!(version.patch_version));
}
fn gather_git(builder: &mut FactBuilder, root: &Path, with_remote: bool) -> Result<()> {
let repo = match git_backend::open_repo(root) {
Ok(repo) => repo,
Err(_) => {
builder.missing_all(GIT_FIELDS, "it is not a git repository");
return Ok(());
}
};
gather_head(builder, &repo)?;
gather_tags(builder, &repo, with_remote)?;
Ok(())
}
fn gather_head(builder: &mut FactBuilder, repo: &gix::Repository) -> Result<()> {
let detached = git_backend::is_detached_head(repo)
.context("Failed to determine whether HEAD is detached")?;
builder.set("is_detached", json!(detached));
if detached {
builder.missing("branch", "HEAD is detached");
} else {
builder.set(
"branch",
json!(git_backend::current_branch(repo).context("Failed to read the current branch")?),
);
}
let commit = match repo.head_commit() {
Ok(commit) => commit,
Err(_) => {
builder.missing_all(HEAD_FIELDS, "it has no commits yet");
return Ok(());
}
};
let id = commit.id().to_hex().to_string();
builder.set(
"short_commit",
json!(id.chars().take(7).collect::<String>()),
);
builder.set("commit", json!(id));
let time = commit
.time()
.context("Failed to read the HEAD commit time")?;
let offset = chrono::FixedOffset::east_opt(time.offset).with_context(|| {
format!(
"HEAD commit carries an invalid timezone offset {}",
time.offset
)
})?;
let stamp = chrono::DateTime::from_timestamp(time.seconds, 0)
.with_context(|| format!("HEAD commit carries an invalid timestamp {}", time.seconds))?
.with_timezone(&offset);
builder.set("commit_date", json!(stamp.format("%Y-%m-%d").to_string()));
builder.set("commit_datetime", json!(stamp.to_rfc3339()));
builder.set(
"is_dirty",
json!(!git_backend::is_clean(repo).context("Failed to read the worktree status")?),
);
Ok(())
}
fn gather_tags(builder: &mut FactBuilder, repo: &gix::Repository, with_remote: bool) -> Result<()> {
let tags = git_backend::list_tags(repo).context("Failed to list tags")?;
builder.set("tag_count", json!(tags.len()));
match tags.first() {
Some(tag) => {
builder.set("latest_tag", json!(tag));
set_tag_version(builder, "latest_version", "latest_tag", tag);
}
None => {
builder.missing("latest_tag", "it has no tags");
builder.missing("latest_version", "it has no tags");
}
}
if builder.values.contains_key("commit") {
match git_backend::nearest_tag(repo, None).context("Failed to describe HEAD")? {
Some(tag) => builder.set("nearest_tag", json!(tag)),
None => builder.missing("nearest_tag", "no tag is reachable from HEAD"),
}
}
let remote = match publishing_remote(repo)? {
Ok(remote) => remote,
Err(reason) => {
builder.missing_all(REMOTE_FIELDS, &reason);
return Ok(());
}
};
builder.set("remote", json!(remote));
builder.set(
"remote_url",
json!(git_backend::remote_url(repo, &remote)
.with_context(|| format!("Failed to read the URL of remote '{remote}'"))?),
);
if !with_remote {
let reason = "the remote was not contacted — reference the field in a \
template, or pass --remote, to resolve it";
builder.missing("latest_remote_tag", reason);
builder.missing("latest_remote_version", reason);
return Ok(());
}
let remote_tags = git_backend::remote_tags(repo, &remote)
.with_context(|| format!("Failed to list tags on remote '{remote}'"))?;
match remote_tags.first() {
Some(tag) => {
builder.set("latest_remote_tag", json!(tag));
set_tag_version(builder, "latest_remote_version", "latest_remote_tag", tag);
}
None => {
let reason = format!("remote '{remote}' has no tags");
builder.missing("latest_remote_tag", reason.clone());
builder.missing("latest_remote_version", reason);
}
}
Ok(())
}
fn set_tag_version(builder: &mut FactBuilder, field: &str, source_field: &str, tag: &str) {
let candidate = tag.strip_prefix('v').unwrap_or(tag);
if candidate.starts_with(|c: char| c.is_ascii_digit()) {
builder.set(field, json!(candidate));
} else {
builder.missing(
field,
format!("{source_field} is '{tag}', which is not a version number"),
);
}
}
#[allow(clippy::type_complexity)]
fn publishing_remote(repo: &gix::Repository) -> Result<std::result::Result<String, String>> {
let remotes = match git_backend::remotes(repo) {
Ok(remotes) => remotes,
Err(_) => return Ok(Err("it has no remotes configured".to_string())),
};
if remotes.len() == 1 {
return Ok(Ok(remotes[0].clone()));
}
if remotes.iter().any(|remote| remote == "origin") {
return Ok(Ok("origin".to_string()));
}
Ok(Err(format!(
"it has several remotes ({}) and none named 'origin', so the one it publishes to is ambiguous",
remotes.join(", ")
)))
}
#[derive(Default, Debug)]
pub struct FieldReferences {
pub own: BTreeSet<String>,
pub by_alias: BTreeMap<String, BTreeSet<String>>,
}
impl FieldReferences {
pub fn for_alias(&self, alias: &str) -> BTreeSet<String> {
self.by_alias.get(alias).cloned().unwrap_or_default()
}
}
pub fn referenced_fields(template_text: &str) -> FieldReferences {
let mut refs = FieldReferences::default();
let peer = regex::Regex::new(r"\bprojects\.(\w+)\.(\w+)").expect("static regex must compile");
for capture in peer.captures_iter(template_text) {
refs.by_alias
.entry(capture[1].to_string())
.or_default()
.insert(capture[2].to_string());
}
let own = regex::Regex::new(r"\bproject\.(\w+)").expect("static regex must compile");
for capture in own.captures_iter(template_text) {
refs.own.insert(capture[1].to_string());
}
refs
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn version() -> VersionInfo {
VersionInfo {
major_version: "v1".to_string(),
minor_version: 2,
patch_version: 3,
full_version: "1.2.3".to_string(),
}
}
fn facts_for(root: &Path, scan_root: &Path) -> ProjectFacts {
let version = version();
gather(
&ProjectIdentity {
alias: "demo",
root,
scan_root,
version: &version,
name: None,
},
false,
)
.unwrap()
}
fn seed_repo(root: &Path) -> gix::Repository {
let repo = git_backend::init_repository(root).unwrap();
git_backend::configure_local_user(&repo, "Test User", "test@example.com").unwrap();
fs::write(root.join("README.md"), "hello\n").unwrap();
git_backend::stage_paths(&repo, &["README.md".to_string()]).unwrap();
git_backend::commit_all(&repo, "initial").unwrap();
git_backend::open_repo(root).unwrap()
}
#[test]
fn every_declared_field_is_either_valued_or_explained() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
seed_repo(&root);
let facts = facts_for(&root, workspace.path());
for field in FIELDS {
assert!(
facts.values.contains_key(field.name) || facts.unavailable.contains_key(field.name),
"field '{}' is neither resolved nor explained",
field.name
);
}
}
#[test]
fn a_project_without_tags_explains_the_absence_instead_of_stamping_empty() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
seed_repo(&root);
let facts = facts_for(&root, workspace.path());
assert!(!facts.values.contains_key("latest_tag"));
let err = facts.require("latest_tag").unwrap_err().to_string();
assert!(err.contains("has no tags"), "got: {err}");
assert!(
err.contains("demo"),
"error must name the project, got: {err}"
);
}
#[test]
fn the_newest_tag_wins_over_older_and_non_version_tags() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
let repo = seed_repo(&root);
git_backend::create_tag(&repo, "v1.9.0", "old").unwrap();
git_backend::create_tag(&repo, "v1.10.0", "new").unwrap();
git_backend::create_tag(&repo, "nightly", "not a version").unwrap();
let facts = facts_for(&root, workspace.path());
assert_eq!(facts.values["latest_tag"], json!("v1.10.0"));
assert_eq!(facts.values["latest_version"], json!("1.10.0"));
assert_eq!(facts.values["tag_count"], json!(3));
}
#[test]
fn a_non_version_tag_has_no_pin_ready_form() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
let repo = seed_repo(&root);
git_backend::create_tag(&repo, "nightly", "not a version").unwrap();
let facts = facts_for(&root, workspace.path());
assert_eq!(facts.values["latest_tag"], json!("nightly"));
let err = facts.require("latest_version").unwrap_err().to_string();
assert!(
err.contains("not a version number"),
"a non-version tag must not become a pin, got: {err}"
);
}
#[test]
fn network_fields_are_withheld_until_a_template_asks_for_them() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
seed_repo(&root);
let facts = facts_for(&root, workspace.path());
let err = facts.require("latest_remote_tag").unwrap_err().to_string();
assert!(
err.contains("remote was not contacted") || err.contains("no remotes"),
"got: {err}"
);
}
#[test]
fn a_plain_directory_reports_why_git_facts_are_missing() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
let facts = facts_for(&root, workspace.path());
let err = facts.require("commit").unwrap_err().to_string();
assert!(err.contains("not a git repository"), "got: {err}");
assert_eq!(facts.values["version"], json!("1.2.3"));
assert_eq!(facts.values["relative_path"], json!("proj"));
}
#[test]
fn nested_projects_report_their_path_from_the_workspace_root() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("ext").join("gtest");
fs::create_dir_all(&root).unwrap();
let facts = facts_for(&root, workspace.path());
assert_eq!(facts.values["relative_path"], json!("ext/gtest"));
}
#[test]
fn an_unknown_field_lists_the_ones_that_exist() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
let facts = facts_for(&root, workspace.path());
let err = facts
.require("published_yesterday")
.unwrap_err()
.to_string();
assert!(
err.contains("is not a wstemplate project field"),
"got: {err}"
);
assert!(err.contains("latest_tag"), "got: {err}");
}
#[test]
fn head_facts_describe_the_checked_out_commit() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
let repo = seed_repo(&root);
let head = repo.head_commit().unwrap().id().to_hex().to_string();
let facts = facts_for(&root, workspace.path());
assert_eq!(facts.values["commit"], json!(head));
assert_eq!(facts.values["short_commit"], json!(head[..7].to_string()));
assert_eq!(facts.values["is_detached"], json!(false));
assert_eq!(facts.values["is_dirty"], json!(false));
assert_eq!(
facts.values["commit_date"].as_str().unwrap().len(),
"YYYY-MM-DD".len()
);
}
#[test]
fn a_dirty_worktree_is_reported_as_dirty() {
let workspace = TempDir::new().unwrap();
let root = workspace.path().join("proj");
fs::create_dir_all(&root).unwrap();
seed_repo(&root);
fs::write(root.join("README.md"), "changed\n").unwrap();
let facts = facts_for(&root, workspace.path());
assert_eq!(facts.values["is_dirty"], json!(true));
}
#[test]
fn field_references_separate_own_from_peer_projects() {
let refs = referenced_fields(
"{{ project.version }} {{ projects.lib.latest_tag }} {{ projects.lib.name }}",
);
assert!(refs.own.contains("version"));
assert_eq!(refs.for_alias("lib").len(), 2);
assert!(refs.for_alias("lib").contains("latest_tag"));
assert!(
!refs.own.contains("lib"),
"'projects.lib' must not be read as 'project.lib'"
);
}
#[test]
fn network_is_required_only_for_remote_fields() {
assert!(requires_network(["latest_remote_tag"]));
assert!(!requires_network(["latest_tag", "version", "commit"]));
}
}