use crate::git_backend;
use crate::prelude::*;
use crate::state::run_cache::{HeadSnapshot, RunCache};
use crate::state::VersionInfo;
use serde_json::{json, Value};
use std::collections::BTreeMap;
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: "path_from_here",
description: "Path from the file being rendered to this project, e.g. '../../capdag/capdag-rs' — what a local dependency is written as",
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> {
gather_with_cache(identity, with_remote, &RunCache::new())
}
pub fn gather_with_cache(
identity: &ProjectIdentity<'_>,
with_remote: bool,
cache: &RunCache,
) -> 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, cache)?;
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()));
builder.missing(
"path_from_here",
"it is resolved while rendering, from the file being written".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,
cache: &RunCache,
) -> 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, root, cache)?;
gather_tags(builder, &repo, root, with_remote, cache)?;
Ok(())
}
fn gather_head(
builder: &mut FactBuilder,
repo: &gix::Repository,
root: &Path,
cache: &RunCache,
) -> Result<()> {
let head = cache.head(root, || {
let detached = git_backend::is_detached_head(repo)
.context("Failed to determine whether HEAD is detached")?;
let branch = if detached {
None
} else {
Some(
git_backend::current_branch(repo)
.context("Failed to read the current branch")?,
)
};
let commit = match repo.head_commit() {
Ok(commit) => {
let time = commit
.time()
.context("Failed to read the HEAD commit time")?;
Some((
commit.id().to_hex().to_string(),
time.seconds,
time.offset,
))
}
Err(_) => None,
};
Ok(HeadSnapshot {
detached,
branch,
commit,
})
})?;
builder.set("is_detached", json!(head.detached));
match &head.branch {
Some(branch) => builder.set("branch", json!(branch)),
None => builder.missing("branch", "HEAD is detached"),
}
let Some((id, seconds, offset)) = &head.commit else {
builder.missing_all(HEAD_FIELDS, "it has no commits yet");
return Ok(());
};
builder.set(
"short_commit",
json!(id.chars().take(7).collect::<String>()),
);
builder.set("commit", json!(id));
let offset = chrono::FixedOffset::east_opt(*offset)
.with_context(|| format!("HEAD commit carries an invalid timezone offset {offset}"))?;
let stamp = chrono::DateTime::from_timestamp(*seconds, 0)
.with_context(|| format!("HEAD commit carries an invalid timestamp {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,
root: &Path,
with_remote: bool,
cache: &RunCache,
) -> Result<()> {
let tags = cache.tags(root, || {
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") {
let nearest = cache.nearest_tag(root, || {
git_backend::nearest_tag(repo, None).context("Failed to describe HEAD")
})?;
match nearest.as_ref() {
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, root, cache).as_ref() {
Ok(remote) => remote.clone(),
Err(reason) => {
builder.missing_all(REMOTE_FIELDS, reason);
return Ok(());
}
};
builder.set("remote", json!(remote));
builder.set(
"remote_url",
json!(cache
.remote_url(root, &remote, || {
git_backend::remote_url(repo, &remote)
.with_context(|| format!("Failed to read the URL of remote '{remote}'"))
})?
.as_str()),
);
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 = cache.remote_tags(root, &remote, || {
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,
root: &Path,
cache: &RunCache,
) -> Arc<std::result::Result<String, String>> {
let remotes = cache.remotes(root, || {
git_backend::remotes(repo).map_err(|_| "it has no remotes configured".to_string())
});
Arc::new(match remotes.as_ref() {
Err(reason) => Err(reason.clone()),
Ok(remotes) if remotes.len() == 1 => Ok(remotes[0].clone()),
Ok(remotes) if remotes.iter().any(|remote| remote == "origin") => {
Ok("origin".to_string())
}
Ok(remotes) => Err(format!(
"it has several remotes ({}) and none named 'origin', so the one it publishes to is ambiguous",
remotes.join(", ")
)),
})
}
#[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 network_is_required_only_for_remote_fields() {
assert!(requires_network(["latest_remote_tag"]));
assert!(!requires_network(["latest_tag", "version", "commit"]));
}
}