use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
path::{Path, PathBuf},
process::Command,
};
use ggen_marketplace::packs_registry::dependency_graph::DependencyGraph;
use serde::{Deserialize, Serialize};
use crate::{
config::{GgenConfig, PackRef},
error::{AppError, Result},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pack {
pub name: String,
pub version: String,
pub description: String,
pub dependencies: BTreeMap<String, String>,
pub semantic_types: BTreeSet<String>,
pub provides: BTreeSet<String>,
pub requires: BTreeSet<String>,
pub root: PathBuf,
pub ontology_path: PathBuf,
pub extra_ontology_paths: Vec<(String, PathBuf)>,
pub template_paths: Vec<PathBuf>,
pub lock: bool,
}
#[derive(Debug, Deserialize)]
struct PackToml {
pack: PackMeta,
#[serde(default)]
dependencies: BTreeMap<String, String>,
#[serde(default)]
capabilities: PackCapabilities,
#[serde(flatten)]
extra: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct PackCapabilities {
#[serde(default)]
types: BTreeSet<String>,
#[serde(default)]
provides: BTreeSet<String>,
#[serde(default)]
requires: BTreeSet<String>,
}
#[derive(Debug, Deserialize)]
struct PackMeta {
name: String,
version: String,
description: String,
#[serde(default)]
deprecated: bool,
#[serde(default)]
superseded_by: Vec<String>,
#[serde(flatten)]
extra: BTreeMap<String, toml::Value>,
}
pub fn resolve(config: &GgenConfig, config_root: &Path) -> Result<Vec<Pack>> {
resolve_inner(config, config_root, true)
}
pub fn resolve_read_only(config: &GgenConfig, config_root: &Path) -> Result<Vec<Pack>> {
resolve_inner(config, config_root, false)
}
fn resolve_inner(
config: &GgenConfig, config_root: &Path, allow_network: bool,
) -> Result<Vec<Pack>> {
let mut packs = Vec::with_capacity(config.packs.len());
for (name, pack_ref) in &config.packs {
match pack_ref {
PackRef::Git {
git,
version,
subdir,
} => {
let clone_root =
resolve_git_pack_dir(name, git, version, config_root, allow_network)?;
let root = match subdir {
Some(sub) => {
let joined = clone_root.join(sub);
if !joined.is_dir() {
return Err(AppError::fm_pack(
13,
format!(
"pack `{name}`: `subdir` `{}` does not exist inside the \
cloned repository `{git}` (checked out at `{version}`). \
Remediation: fix the [packs] subdir entry, or omit it if \
the pack lives at the repository root.",
sub.display()
),
));
}
joined
}
None => clone_root,
};
packs.push(resolve_pack_dir(name, &root)?);
}
PackRef::Path {
path,
extra_ontologies,
lock,
} => {
let root = config_root.join(path);
if !root.is_dir() {
return Err(AppError::fm_pack(
1,
format!(
"pack `{name}`: directory `{}` does not exist. \
Remediation: fix the [packs] path or vendor the pack.",
root.display()
),
));
}
let mut pack = resolve_pack_dir(name, &root)?;
pack.lock = *lock;
for extra in extra_ontologies {
let extra_path = config_root.join(extra);
if !extra_path.is_file() {
return Err(AppError::fm_pack(
4,
format!(
"pack `{name}`: extra ontology `{}` missing at `{}`. \
Remediation: fix the [packs] extra_ontologies entry.",
extra.display(),
extra_path.display()
),
));
}
pack.extra_ontology_paths
.push((extra.to_string_lossy().into_owned(), extra_path));
}
packs.push(pack);
}
}
}
validate_dependency_graph(&packs)?;
Ok(packs)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ScopeDepth {
Local,
Direct,
TwoLevel,
Transitive,
Global,
}
pub fn dependency_scope<'a>(
packs: &'a [Pack], subject: &str, depth: ScopeDepth,
) -> Result<Vec<&'a Pack>> {
let by_name: BTreeMap<&str, &Pack> = packs
.iter()
.map(|pack| (pack.name.as_str(), pack))
.collect();
let Some(subject_pack) = by_name.get(subject).copied() else {
return Err(AppError::fm_pack(
17,
format!(
"dependency scope subject '{subject}' is not among the resolved packs. \
Remediation: resolve the pack first or use an admitted resolved subject."
),
));
};
if depth == ScopeDepth::Global {
let mut rest: Vec<&Pack> = packs.iter().filter(|pack| pack.name != subject).collect();
rest.sort_by(|a, b| a.name.cmp(&b.name));
let mut scoped = Vec::with_capacity(packs.len());
scoped.push(subject_pack);
scoped.extend(rest);
return Ok(scoped);
}
let max_depth = match depth {
ScopeDepth::Local => 0,
ScopeDepth::Direct => 1,
ScopeDepth::TwoLevel => 2,
ScopeDepth::Transitive => usize::MAX,
ScopeDepth::Global => unreachable!("handled above"),
};
let capability_edges: BTreeMap<String, Vec<String>> = {
let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (requirer, provider) in admitted_capability_edges(packs) {
map.entry(requirer).or_default().push(provider);
}
map
};
let mut scoped = Vec::new();
let mut seen = BTreeSet::new();
let mut queue = VecDeque::from([(subject.to_string(), 0usize)]);
while let Some((name, current_depth)) = queue.pop_front() {
if !seen.insert(name.clone()) {
continue;
}
let pack = by_name.get(name.as_str()).copied().ok_or_else(|| {
AppError::fm_pack(
14,
format!(
"pack '{subject}' dependency scope references undeclared pack '{name}'. \
Remediation: add '{name}' to the consumer's [packs] table."
),
)
})?;
scoped.push(pack);
if current_depth >= max_depth {
continue;
}
for dependency in pack.dependencies.keys() {
queue.push_back((dependency.clone(), current_depth.saturating_add(1)));
}
if let Some(providers) = capability_edges.get(name.as_str()) {
for provider in providers {
queue.push_back(((*provider).clone(), current_depth.saturating_add(1)));
}
}
}
Ok(scoped)
}
fn validate_dependency_graph(packs: &[Pack]) -> Result<()> {
let by_name: BTreeMap<&str, &Pack> = packs
.iter()
.map(|pack| (pack.name.as_str(), pack))
.collect();
let mut graph = DependencyGraph::new();
for pack in packs {
graph.add_node(&pack.name);
for (dependency_name, requirement) in &pack.dependencies {
if dependency_name.trim().is_empty() || requirement.trim().is_empty() {
return Err(AppError::fm_pack(
15,
format!(
"pack '{}' has an empty dependency name or version requirement. \
Remediation: declare each [dependencies] entry as \
pack-name = version-or-requirement.",
pack.name
),
));
}
let dependency = by_name
.get(dependency_name.as_str())
.copied()
.ok_or_else(|| {
AppError::fm_pack(
14,
format!(
"pack '{}' requires '{dependency_name}' ({requirement}), but that \
pack is not declared in the consumer's [packs] table. Resolution \
is fail-closed and does not auto-install dependencies. \
Remediation: add '{dependency_name}' to ggen.toml [packs].",
pack.name
),
)
})?;
let matches = dependency_requirement_matches(requirement, &dependency.version)
.map_err(|reason| {
AppError::fm_pack(
15,
format!(
"pack '{}' dependency '{dependency_name}' requirement \
'{requirement}' cannot be admitted against resolved version '{}': \
{reason}. Remediation: align the dependency requirement and the \
resolved pack version.",
pack.name, dependency.version
),
)
})?;
if !matches {
return Err(AppError::fm_pack(
15,
format!(
"pack '{}' requires '{dependency_name}' '{requirement}', but the \
resolved version is '{}'. Remediation: select a satisfying pack \
version or change the declared requirement.",
pack.name, dependency.version
),
));
}
graph.add_edge(&pack.name, dependency_name);
}
}
graph.detect_cycles().map_err(|e| {
AppError::fm_pack(
16,
format!(
"declared pack dependency graph is cyclic: {e}. \
Remediation: remove at least one dependency edge so the graph is acyclic."
),
)
})?;
let _capability_edges = admitted_capability_edges(packs);
validate_capability_requirements(packs)
}
fn urn_capability_edges(packs: &[Pack]) -> Vec<(String, String)> {
let declared: BTreeSet<&str> = packs.iter().map(|p| p.name.as_str()).collect();
let mut edges = BTreeSet::new();
for pack in packs {
for required in &pack.requires {
let Some(provider) = required.strip_prefix("urn:ggen:pack:") else {
continue;
};
let provider = provider.trim();
if provider == pack.name || !declared.contains(provider) {
continue;
}
edges.insert((pack.name.clone(), provider.to_string()));
}
}
edges.into_iter().collect()
}
fn admitted_capability_edges(packs: &[Pack]) -> Vec<(String, String)> {
let capability_edges = urn_capability_edges(packs);
if capability_edges.is_empty() {
return capability_edges;
}
let mut graph = DependencyGraph::new();
for pack in packs {
graph.add_node(&pack.name);
}
for pack in packs {
for dependency in pack.dependencies.keys() {
graph.add_edge(&pack.name, dependency);
}
}
for (requirer, provider) in &capability_edges {
graph.add_edge(requirer, provider);
}
if graph.detect_cycles().is_err() {
return Vec::new();
}
capability_edges
}
fn validate_capability_requirements(packs: &[Pack]) -> Result<()> {
let declared: BTreeSet<&str> = packs.iter().map(|p| p.name.as_str()).collect();
for pack in packs {
if pack.requires.is_empty() {
continue;
}
let (urn_requires, closure_requires): (Vec<&str>, Vec<&str>) = pack
.requires
.iter()
.map(String::as_str)
.partition(|required| required.starts_with("urn:ggen:pack:"));
let unsatisfied_urn: Vec<&str> = urn_requires
.iter()
.filter(|required| !declared.contains(required.trim_start_matches("urn:ggen:pack:")))
.copied()
.collect();
if !unsatisfied_urn.is_empty() {
return Err(AppError::fm_pack(
18,
format!(
"pack '{}' requires capability/capabilities [{}], but the consuming \
project does not declare the referenced pack(s). Remediation: add the \
pack(s) to this project's [packs] table or remove the unsatisfied \
requirement.",
pack.name,
unsatisfied_urn.join(", ")
),
));
}
let closure = dependency_scope(packs, &pack.name, ScopeDepth::Transitive)?;
let available: BTreeSet<&str> = closure
.iter()
.flat_map(|candidate| candidate.provides.iter().map(String::as_str))
.collect();
let missing: Vec<&str> = closure_requires
.iter()
.copied()
.filter(|required| !available.contains(required))
.collect();
if !missing.is_empty() {
return Err(AppError::fm_pack(
18,
format!(
"pack '{}' requires capability/capabilities [{}], but no provider exists \
in its declared dependency closure. Ambient/global providers are not \
admitted as hidden dependencies. Remediation: add a dependency that \
provides the capability or remove the unsatisfied requirement.",
pack.name,
missing.join(", ")
),
));
}
}
Ok(())
}
fn dependency_requirement_matches(
requirement: &str, resolved_version: &str,
) -> std::result::Result<bool, String> {
let requirement = requirement.trim();
let uses_semver_requirement = requirement
.chars()
.any(|c| matches!(c, '^' | '~' | '>' | '<' | '=' | '*' | ',') || c.is_whitespace());
if !uses_semver_requirement {
return Ok(requirement == resolved_version);
}
let requirement = semver::VersionReq::parse(requirement)
.map_err(|e| format!("invalid version requirement: {e}"))?;
let version = semver::Version::parse(resolved_version)
.map_err(|e| format!("resolved version is not SemVer: {e}"))?;
Ok(requirement.matches(&version))
}
const GIT_PIN_FILE: &str = ".ggen-git-pin";
fn resolve_git_pack_dir(
name: &str, git: &str, version: &str, config_root: &Path, allow_network: bool,
) -> Result<PathBuf> {
let cache_dir = config_root.join(".ggen-v2/git-packs").join(name);
let pin_path = cache_dir.join(GIT_PIN_FILE);
if let Ok(pinned) = std::fs::read_to_string(&pin_path) {
if pinned.trim() == version {
return Ok(cache_dir);
}
}
if !allow_network {
return Err(AppError::fm_pack(
12,
format!(
"pack `{name}`: git pack is not cached at the pinned `{version}` (cache \
missing, corrupt, or pinned to a different version), and this operation is \
read-only and does not perform network I/O. \
Remediation: run `ggen sync run` once so the pack is cloned and pinned, or \
vendor it locally with {{ path = \"…\" }}."
),
));
}
clone_and_pin_git_pack(name, git, version, &cache_dir, &pin_path)?;
Ok(cache_dir)
}
const GIT_ENV_LEAK_VARS: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_QUARANTINE_PATH",
"GIT_COMMON_DIR",
"GIT_PREFIX",
];
fn git_command() -> Command {
let mut command = Command::new("git");
for var in GIT_ENV_LEAK_VARS {
command.env_remove(var);
}
command
}
fn clone_and_pin_git_pack(
name: &str, git: &str, version: &str, cache_dir: &Path, pin_path: &Path,
) -> Result<()> {
if cache_dir.exists() {
std::fs::remove_dir_all(cache_dir).map_err(|e| {
AppError::fm_pack(
10,
format!(
"pack `{name}`: could not clear stale git cache `{}`: {e}",
cache_dir.display()
),
)
})?;
}
let cache_parent = cache_dir.parent().ok_or_else(|| {
AppError::fm_pack(
10,
format!(
"pack `{name}`: git cache directory `{}` has no parent directory",
cache_dir.display()
),
)
})?;
std::fs::create_dir_all(cache_parent).map_err(|e| {
AppError::fm_pack(
10,
format!("pack `{name}`: could not create git cache directory: {e}"),
)
})?;
let clone = git_command()
.args([
"clone",
"--quiet",
git,
cache_dir.to_str().unwrap_or_default(),
])
.output()
.map_err(|e| {
AppError::fm_pack(
10,
format!(
"pack `{name}`: `git` not runnable (is it on $PATH?): {e}. \
Remediation: install git, or vendor the pack locally with {{ path = \"…\" }}."
),
)
})?;
if !clone.status.success() {
return Err(AppError::fm_pack(
10,
format!(
"pack `{name}`: `git clone {git}` failed: {}\
Remediation: verify the git URL is reachable and correct.",
String::from_utf8_lossy(&clone.stderr)
),
));
}
let checkout = git_command()
.args([
"-C",
cache_dir.to_str().unwrap_or_default(),
"checkout",
"--quiet",
version,
])
.output()
.map_err(|e| {
AppError::fm_pack(
11,
format!("pack `{name}`: `git checkout {version}` not runnable: {e}"),
)
})?;
if !checkout.status.success() {
return Err(AppError::fm_pack(
11,
format!(
"pack `{name}`: `git checkout {version}` failed: {}\
Remediation: verify `version` names an existing tag, branch, or commit.",
String::from_utf8_lossy(&checkout.stderr)
),
));
}
std::fs::write(pin_path, version).map_err(|e| {
AppError::fm_pack(
10,
format!("pack `{name}`: could not write git pin marker: {e}"),
)
})?;
Ok(())
}
fn resolve_pack_dir(name: &str, root: &Path) -> Result<Pack> {
let root = root.to_path_buf();
let manifest_path = root.join("pack.toml");
let manifest_raw = std::fs::read_to_string(&manifest_path).map_err(|e| {
AppError::fm_pack(
2,
format!(
"pack `{name}`: pack.toml unreadable at `{}`: {e}. \
Remediation: every pack must ship a pack.toml.",
manifest_path.display()
),
)
})?;
let manifest: PackToml = star_toml::from_str(&manifest_raw).map_err(|e| {
AppError::fm_pack(
3,
format!(
"pack `{name}`: invalid pack.toml at `{}`: {e}. \
Remediation: fix the TOML syntax or remove unknown keys.",
manifest_path.display()
),
)
})?;
let ontology_path = root.join("ontology.ttl");
if !ontology_path.is_file() {
return Err(AppError::fm_pack(
4,
format!(
"pack `{name}`: ontology.ttl missing at `{}`. \
Remediation: every pack must ship an ontology.ttl.",
ontology_path.display()
),
));
}
let templates_dir = root.join("templates");
let mut template_paths: Vec<PathBuf> = Vec::new();
if templates_dir.is_dir() {
collect_pack_tmpl_paths(name, &templates_dir, &mut template_paths)?;
}
template_paths.sort();
if template_paths.is_empty() {
return Err(AppError::fm_pack(
5,
format!(
"pack `{name}`: zero templates under `{}`. \
Remediation: a pack must ship at least one templates/*.tmpl.",
templates_dir.display()
),
));
}
let _ = &manifest.pack.name;
let _ = &manifest.pack.deprecated;
let _ = &manifest.pack.superseded_by;
let _ = &manifest.pack.extra;
let _ = &manifest.extra;
Ok(Pack {
name: name.to_string(),
version: manifest.pack.version,
description: manifest.pack.description,
dependencies: manifest.dependencies,
semantic_types: manifest.capabilities.types,
provides: manifest.capabilities.provides,
requires: manifest.capabilities.requires,
root,
ontology_path,
extra_ontology_paths: Vec::new(),
template_paths,
lock: true,
})
}
fn collect_pack_tmpl_paths(name: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in std::fs::read_dir(dir).map_err(|e| {
AppError::fm_pack(
5,
format!(
"pack `{name}`: could not list `{}` while discovering templates: {e}.",
dir.display()
),
)
})? {
let entry = entry.map_err(|e| {
AppError::fm_pack(
5,
format!(
"pack `{name}`: could not read a directory entry under `{}` while \
discovering templates: {e}",
dir.display()
),
)
})?;
let path = entry.path();
if path.is_dir() {
collect_pack_tmpl_paths(name, &path, out)?;
} else if path.is_file() && path.extension().is_some_and(|e| e == "tmpl") {
out.push(path);
}
}
Ok(())
}
pub fn content_hash(pack: &Pack) -> Result<[u8; 32]> {
let mut entries: Vec<(String, PathBuf)> = Vec::new();
collect_pack_files_sorted(&pack.name, &pack.root, &pack.root, &mut entries)?;
for (declared, path) in &pack.extra_ontology_paths {
entries.push((declared.clone(), path.clone()));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = blake3::Hasher::new();
for (rel, path) in &entries {
let bytes = std::fs::read(path).map_err(|e| {
AppError::fm_pack(
6,
format!(
"pack `{}`: file `{}` unreadable while hashing: {e}. \
Remediation: do not mutate a pack during sync.",
pack.name,
path.display()
),
)
})?;
hasher.update(rel.as_bytes());
hasher.update(&bytes);
}
Ok(*hasher.finalize().as_bytes())
}
pub fn pack_digest_sha256(pack: &Pack) -> Result<[u8; 32]> {
use sha2::Digest as _;
let mut entries: Vec<(String, PathBuf)> = Vec::new();
collect_pack_files_sorted(&pack.name, &pack.root, &pack.root, &mut entries)?;
for (declared, path) in &pack.extra_ontology_paths {
entries.push((declared.clone(), path.clone()));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = sha2::Sha256::new();
hasher.update(b"ggen-pack-v1\0");
for (rel, path) in &entries {
let bytes = std::fs::read(path).map_err(|e| {
AppError::fm_pack(
6,
format!(
"pack `{}`: file `{}` unreadable while hashing: {e}. \
Remediation: do not mutate a pack during sync.",
pack.name,
path.display()
),
)
})?;
hasher.update(u64::try_from(rel.len()).unwrap_or(u64::MAX).to_be_bytes());
hasher.update(rel.as_bytes());
hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
hasher.update(&bytes);
}
Ok(hasher.finalize().into())
}
fn collect_pack_files_sorted(
pack_name: &str, root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>,
) -> Result<()> {
let read_dir = std::fs::read_dir(dir).map_err(|e| {
AppError::fm_pack(
6,
format!(
"pack `{pack_name}`: could not list `{}` while hashing: {e}. \
Remediation: do not mutate a pack during sync.",
dir.display()
),
)
})?;
let mut entries: Vec<std::fs::DirEntry> = Vec::new();
for entry in read_dir {
let entry = entry.map_err(|e| {
AppError::fm_pack(
6,
format!(
"pack `{pack_name}`: could not read a directory entry under `{}` while \
hashing: {e}",
dir.display()
),
)
})?;
entries.push(entry);
}
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
if path.is_dir() {
if path.file_name().and_then(|n| n.to_str()) == Some(".git") {
continue;
}
collect_pack_files_sorted(pack_name, root, &path, out)?;
} else {
out.push((rel_string(&path, root), path));
}
}
Ok(())
}
pub const LOCK_FILE_NAME: &str = "ggen.lock";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockEntry {
pub name: String,
pub source: String,
pub content_hash: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LockDoc {
#[serde(default)]
packs: std::collections::BTreeMap<String, LockDocEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LockDocEntry {
source: String,
content_hash: String,
}
#[must_use]
pub fn source_string(pack_ref: &PackRef) -> String {
match pack_ref {
PackRef::Path { path, .. } => format!("path:{}", path.display()),
PackRef::Git {
git,
version,
subdir: Some(sub),
} => format!("git:{git}@{version}#{}", sub.display()),
PackRef::Git {
git,
version,
subdir: None,
} => format!("git:{git}@{version}"),
}
}
pub fn lock_entries(config: &GgenConfig, packs: &[Pack]) -> Result<Vec<LockEntry>> {
let mut entries = Vec::with_capacity(packs.len());
for pack in packs {
if !pack.lock {
continue;
}
let source = config
.packs
.get(&pack.name)
.map(source_string)
.unwrap_or_default();
let hash = content_hash(pack)?;
entries.push(LockEntry {
name: pack.name.clone(),
source,
content_hash: format!("blake3:{}", crate::sync::hex32(&hash)),
});
}
Ok(entries)
}
pub fn check_lock(root: &Path, entries: &[LockEntry]) -> Result<()> {
let lock_path = root.join(LOCK_FILE_NAME);
if !lock_path.is_file() {
return Ok(());
}
let raw = std::fs::read_to_string(&lock_path).map_err(|e| {
AppError::fm_pack(
9,
format!("ggen.lock unreadable at `{}`: {e}", lock_path.display()),
)
})?;
let doc: LockDoc = star_toml::from_str(&raw).map_err(|e| {
AppError::fm_pack(
9,
format!(
"ggen.lock malformed at `{}`: {e}. \
Remediation: fix or delete the lockfile.",
lock_path.display()
),
)
})?;
for entry in entries {
if let Some(locked) = doc.packs.get(&entry.name) {
if locked.content_hash != entry.content_hash {
return Err(AppError::fm_pack(
8,
format!(
"pack `{}` (source `{}`) content hash mismatch: ggen.lock \
has `{}` but the pack on disk hashes to `{}`. \
Remediation: restore the pack, or delete ggen.lock \
to intentionally re-lock.",
entry.name, locked.source, locked.content_hash, entry.content_hash
),
));
}
}
}
Ok(())
}
pub fn write_lock(root: &Path, entries: &[LockEntry]) -> Result<()> {
use std::fmt::Write as _;
let mut sorted: Vec<&LockEntry> = entries.iter().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
let mut out = String::from("# ggen.lock — generated by `ggen sync`. Do not edit.\n");
for entry in sorted {
let _ = write!(
out,
"\n[packs.{}]\nsource = \"{}\"\ncontent_hash = \"{}\"\n",
entry.name, entry.source, entry.content_hash
);
}
let lock_path = root.join(LOCK_FILE_NAME);
match std::fs::read_to_string(&lock_path) {
Ok(existing) if existing == out => return Ok(()),
Ok(_) | Err(_) => {}
}
std::fs::write(lock_path, out)?;
Ok(())
}
fn rel_string(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use tempfile::TempDir;
use super::*;
fn entry(name: &str) -> LockEntry {
LockEntry {
name: name.to_string(),
source: "path:../pack".to_string(),
content_hash: "blake3:deadbeef".to_string(),
}
}
#[test]
fn write_lock_is_idempotent_when_content_is_unchanged() {
let dir = TempDir::new().expect("tempdir");
let entries = vec![entry("widget")];
write_lock(dir.path(), &entries).expect("first write");
let mtime_1 = std::fs::metadata(dir.path().join(LOCK_FILE_NAME))
.expect("metadata")
.modified()
.expect("mtime");
std::thread::sleep(std::time::Duration::from_millis(20));
write_lock(dir.path(), &entries).expect("second write");
let mtime_2 = std::fs::metadata(dir.path().join(LOCK_FILE_NAME))
.expect("metadata")
.modified()
.expect("mtime");
assert_eq!(
mtime_1, mtime_2,
"unchanged lockfile content must not be rewritten"
);
}
#[test]
fn write_lock_rewrites_when_content_changes() {
let dir = TempDir::new().expect("tempdir");
write_lock(dir.path(), &[entry("widget")]).expect("first write");
write_lock(dir.path(), &[entry("widget"), entry("gadget")]).expect("second write");
let contents =
std::fs::read_to_string(dir.path().join(LOCK_FILE_NAME)).expect("read lockfile");
assert!(contents.contains("gadget"));
}
fn bare_pack(root: &Path) -> Pack {
Pack {
name: "vector-pack".to_string(),
version: "1.0.0".to_string(),
description: String::new(),
dependencies: BTreeMap::new(),
semantic_types: BTreeSet::new(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
root: root.to_path_buf(),
ontology_path: root.join("ontology.ttl"),
extra_ontology_paths: Vec::new(),
template_paths: Vec::new(),
lock: true,
}
}
#[test]
fn pack_digest_sha256_matches_rfc38_known_vector_single_file() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("a"), b"b").expect("write a");
let digest = pack_digest_sha256(&bare_pack(dir.path())).expect("digest");
assert_eq!(
crate::sync::hex32(&digest),
"00d88d571d0897f7fd57322b08baaadcaafa73b0803b1d24b22c9b3ecdeee1cc"
);
}
#[test]
fn pack_digest_sha256_matches_rfc38_known_vector_sorted_entries() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("ab"), b"two").expect("write ab");
std::fs::write(dir.path().join("a"), b"one").expect("write a");
let digest = pack_digest_sha256(&bare_pack(dir.path())).expect("digest");
assert_eq!(
crate::sync::hex32(&digest),
"87a71f12dd37328ac4dc6a3c6b16152db766572216883c7b870c9a64d869a54a"
);
}
#[test]
fn pack_digest_sha256_ignores_git_directory() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("a"), b"b").expect("write a");
let before = pack_digest_sha256(&bare_pack(dir.path())).expect("digest before");
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(&git_dir).expect("mkdir .git");
std::fs::write(git_dir.join("reflog"), b"wall-clock noise").expect("write reflog");
let after = pack_digest_sha256(&bare_pack(dir.path())).expect("digest after");
assert_eq!(before, after, ".git contents must not affect PackDigest");
}
fn git(args: &[&str], cwd: &Path) {
let out = super::git_command()
.args(args)
.current_dir(cwd)
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn scratch_git_source(dir: &Path) {
std::fs::create_dir_all(dir).expect("mkdir source");
git(&["init", "--quiet"], dir);
git(&["config", "user.email", "test@example.com"], dir);
git(&["config", "user.name", "Test"], dir);
std::fs::write(dir.join("marker.txt"), "v1\n").expect("write marker");
git(&["add", "."], dir);
git(&["commit", "--quiet", "-m", "v1"], dir);
git(&["tag", "v1"], dir);
std::fs::write(dir.join("marker.txt"), "v2\n").expect("write marker");
git(&["add", "."], dir);
git(&["commit", "--quiet", "-m", "v2"], dir);
git(&["tag", "v2"], dir);
}
#[test]
fn resolve_git_pack_dir_clones_and_pins_the_requested_version() {
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_git_source(&source);
let config_root = TempDir::new().expect("tempdir");
let cache = resolve_git_pack_dir(
"widget",
source.to_str().expect("utf8 path"),
"v1",
config_root.path(),
true,
)
.expect("clone succeeds");
assert_eq!(
std::fs::read_to_string(cache.join("marker.txt")).expect("marker"),
"v1\n"
);
assert_eq!(
std::fs::read_to_string(cache.join(GIT_PIN_FILE)).expect("pin"),
"v1"
);
}
#[test]
fn resolve_git_pack_dir_reuses_the_cache_when_version_is_unchanged() {
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_git_source(&source);
let config_root = TempDir::new().expect("tempdir");
let url = source.to_str().expect("utf8 path");
let cache = resolve_git_pack_dir("widget", url, "v1", config_root.path(), true)
.expect("first clone");
std::fs::write(cache.join("sentinel.txt"), "still here").expect("write sentinel");
let cache2 = resolve_git_pack_dir("widget", url, "v1", config_root.path(), true)
.expect("second call");
assert!(
cache2.join("sentinel.txt").is_file(),
"unchanged version must reuse the cache, not re-clone"
);
}
#[test]
fn resolve_git_pack_dir_reclones_when_version_changes() {
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_git_source(&source);
let config_root = TempDir::new().expect("tempdir");
let url = source.to_str().expect("utf8 path");
let cache = resolve_git_pack_dir("widget", url, "v1", config_root.path(), true)
.expect("first clone");
std::fs::write(cache.join("sentinel.txt"), "will be wiped").expect("write sentinel");
let cache2 =
resolve_git_pack_dir("widget", url, "v2", config_root.path(), true).expect("re-clone");
assert!(
!cache2.join("sentinel.txt").exists(),
"a changed version must wipe and re-clone the cache"
);
assert_eq!(
std::fs::read_to_string(cache2.join("marker.txt")).expect("marker"),
"v2\n"
);
assert_eq!(
std::fs::read_to_string(cache2.join(GIT_PIN_FILE)).expect("pin"),
"v2"
);
}
#[test]
fn resolve_git_pack_dir_refuses_a_bad_version_with_a_typed_error() {
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_git_source(&source);
let config_root = TempDir::new().expect("tempdir");
let err = resolve_git_pack_dir(
"widget",
source.to_str().expect("utf8 path"),
"does-not-exist",
config_root.path(),
true,
)
.expect_err("bad version must refuse");
let msg = err.to_string();
assert!(msg.contains("FM-PACK-011"), "{msg}");
assert!(msg.contains("checkout"), "{msg}");
}
#[test]
fn resolve_git_pack_dir_refuses_to_mutate_when_allow_network_is_false_and_cache_is_missing() {
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_git_source(&source);
let config_root = TempDir::new().expect("tempdir");
let cache_dir = config_root.path().join(".ggen-v2/git-packs/widget");
let err = resolve_git_pack_dir(
"widget",
source.to_str().expect("utf8 path"),
"v2",
config_root.path(),
false,
)
.expect_err("a dry-run/read-only resolve of an uncached git pack must refuse");
let msg = err.to_string();
assert!(msg.contains("FM-PACK-012"), "{msg}");
assert!(
!cache_dir.exists(),
"refusing must not create the cache directory -- no clone may have been attempted"
);
}
#[test]
fn resolve_git_pack_dir_reuses_an_already_pinned_cache_when_allow_network_is_false() {
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_git_source(&source);
let config_root = TempDir::new().expect("tempdir");
let url = source.to_str().expect("utf8 path");
resolve_git_pack_dir("widget", url, "v1", config_root.path(), true)
.expect("real sync clones and pins v1");
let cache = resolve_git_pack_dir("widget", url, "v1", config_root.path(), false)
.expect("read-only resolve of an already-pinned version must succeed");
assert_eq!(
std::fs::read_to_string(cache.join("marker.txt")).expect("marker"),
"v1\n"
);
}
#[test]
fn sync_dry_run_does_not_clone_an_uncached_git_pack() {
use crate::sync::{sync, SyncOptions};
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_git_source(&source);
let url = source.to_str().expect("utf8 path");
let project = TempDir::new().expect("tempdir");
let root = project.path();
std::fs::write(
root.join("ggen.toml"),
format!(
r#"[project]
name = "dry-run-git-pack-fixture"
[ontology]
source = "ontology.ttl"
[templates]
dir = "templates"
[packs.widget]
git = "{url}"
version = "v2"
"#
),
)
.expect("write ggen.toml");
std::fs::write(root.join("ontology.ttl"), "").expect("write ontology");
std::fs::create_dir_all(root.join("templates")).expect("mkdir templates");
let git_pack_cache = root.join(".ggen-v2/git-packs/widget");
assert!(
!git_pack_cache.exists(),
"fixture must start with no git-pack cache"
);
let err = sync(
root,
SyncOptions {
dry_run: true,
..Default::default()
},
)
.expect_err("dry-run sync must refuse to clone an uncached git pack");
assert!(err.to_string().contains("FM-PACK-012"), "{err}");
assert!(
!git_pack_cache.exists(),
"dry-run sync must not have created the git-pack cache directory \
(no clone may have been attempted): {}",
git_pack_cache.display()
);
assert!(
!root.join(".ggen-v2/receipt.json").exists(),
"dry-run sync must not write a receipt either"
);
}
fn scratch_monorepo_git_source(dir: &Path) {
std::fs::create_dir_all(dir).expect("mkdir source");
git(&["init", "--quiet"], dir);
git(&["config", "user.email", "test@example.com"], dir);
git(&["config", "user.name", "Test"], dir);
std::fs::write(dir.join("README.md"), "monorepo root, not a pack\n").expect("write README");
let pack_dir = dir.join("packs/widget-pack");
std::fs::create_dir_all(pack_dir.join("templates")).expect("mkdir pack templates");
std::fs::write(
pack_dir.join("pack.toml"),
"[pack]\nname = \"widget-pack\"\nversion = \"1.0.0\"\ndescription = \"test\"\n",
)
.expect("write pack.toml");
std::fs::write(
pack_dir.join("ontology.ttl"),
"@prefix dom: <http://example.com/ontology#> .\ndom:Widget a dom:DomainClass .\n",
)
.expect("write ontology.ttl");
std::fs::write(
pack_dir.join("templates/widget.rs.tmpl"),
"---\nto: src/widget.rs\n---\n// generated\n",
)
.expect("write template");
git(&["add", "."], dir);
git(&["commit", "--quiet", "-m", "v1"], dir);
git(&["tag", "v1"], dir);
}
fn git_pack_config(
url: &str, version: &str, subdir: Option<&str>,
) -> crate::config::GgenConfig {
crate::config::GgenConfig {
project: crate::config::Project {
name: "fixture".to_string(),
},
ontology: crate::config::Ontology {
source: PathBuf::from("ontology.ttl"),
imports: Vec::new(),
prefixes: std::collections::BTreeMap::new(),
},
packs: std::collections::BTreeMap::from([(
"widget-pack".to_string(),
PackRef::Git {
git: url.to_string(),
version: version.to_string(),
subdir: subdir.map(PathBuf::from),
},
)]),
templates: crate::config::Templates {
dir: PathBuf::from("templates"),
aggregate_modules: false,
consumer_mode: false,
},
law: crate::config::Law::default(),
}
}
#[test]
fn resolve_git_pack_with_subdir_pulls_one_pack_out_of_a_monorepo_clone() {
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_monorepo_git_source(&source);
let url = source.to_str().expect("utf8 path");
let config_root_a = TempDir::new().expect("tempdir");
let config_a = git_pack_config(url, "v1", None);
let err = resolve(&config_a, config_root_a.path())
.expect_err("no subdir + no pack.toml at clone root must refuse");
assert!(
err.to_string().contains("pack.toml"),
"must cite the missing pack.toml: {err}"
);
let config_root_b = TempDir::new().expect("tempdir");
let config_b = git_pack_config(url, "v1", Some("packs/widget-pack"));
let packs = resolve(&config_b, config_root_b.path())
.expect("subdir must resolve the real pack inside the monorepo clone");
assert_eq!(packs.len(), 1);
assert_eq!(packs[0].name, "widget-pack");
assert_eq!(packs[0].version, "1.0.0");
assert_eq!(packs[0].template_paths.len(), 1);
}
#[test]
fn resolve_git_pack_with_nonexistent_subdir_refuses_with_a_typed_error() {
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_monorepo_git_source(&source);
let url = source.to_str().expect("utf8 path");
let config_root = TempDir::new().expect("tempdir");
let config = git_pack_config(url, "v1", Some("packs/does-not-exist"));
let err = resolve(&config, config_root.path())
.expect_err("a nonexistent subdir must refuse, not silently fail some other way");
let msg = err.to_string();
assert!(msg.contains("FM-PACK-013"), "{msg}");
assert!(msg.contains("does-not-exist"), "{msg}");
}
#[test]
fn sync_resolves_a_git_pack_via_subdir_end_to_end() {
use crate::sync::{sync, SyncOptions};
let scratch = TempDir::new().expect("tempdir");
let source = scratch.path().join("source");
scratch_monorepo_git_source(&source);
let url = source.to_str().expect("utf8 path");
let project = TempDir::new().expect("tempdir");
let root = project.path();
std::fs::write(
root.join("ggen.toml"),
format!(
r#"[project]
name = "subdir-git-pack-fixture"
[ontology]
source = "ontology.ttl"
[templates]
dir = "templates"
[packs.widget-pack]
git = "{url}"
version = "v1"
subdir = "packs/widget-pack"
"#
),
)
.expect("write ggen.toml");
std::fs::write(root.join("ontology.ttl"), "").expect("write ontology");
std::fs::create_dir_all(root.join("templates")).expect("mkdir templates");
let report = sync(
root,
SyncOptions {
dry_run: false,
..Default::default()
},
)
.expect("sync must resolve the git pack via its subdir and generate its output");
assert!(
report.written.iter().any(|p| p == "src/widget.rs"),
"the pack's own template must have rendered: {:?}",
report.written
);
assert!(
root.join("src/widget.rs").is_file(),
"generated file must actually exist on disk"
);
}
#[test]
fn resolve_pack_dir_accepts_author_metadata_and_extension_tables_beyond_the_closed_schema() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("pack.toml"),
r#"[pack]
name = "widget"
version = "1.0.0"
description = "d"
category = "infrastructure"
author = "someone"
license = "MIT"
production_ready = false
[pack.metadata]
tags = ["a", "b"]
[authority]
consequential_do = false
"#,
)
.expect("write pack.toml");
std::fs::write(dir.path().join("ontology.ttl"), "").expect("write ontology");
std::fs::create_dir_all(dir.path().join("templates")).expect("mkdir templates");
std::fs::write(dir.path().join("templates").join("t.tmpl"), "x").expect("write template");
let pack = resolve_pack_dir("widget", dir.path()).expect(
"author metadata and an unrecognized top-level table must not refuse pack resolution",
);
assert_eq!(pack.version, "1.0.0");
assert_eq!(pack.description, "d");
}
#[test]
fn resolve_pack_dir_still_refuses_genuinely_invalid_toml() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("pack.toml"), "not = [valid toml").expect("write pack.toml");
std::fs::write(dir.path().join("ontology.ttl"), "").expect("write ontology");
std::fs::create_dir_all(dir.path().join("templates")).expect("mkdir templates");
std::fs::write(dir.path().join("templates").join("t.tmpl"), "x").expect("write template");
let err = resolve_pack_dir("widget", dir.path())
.expect_err("syntactically invalid TOML must still refuse");
assert!(
format!("{err}").contains("FM-PACK-003"),
"expected FM-PACK-003, got: {err}"
);
}
}