use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_yaml_bw::Value;
use super::manifest::{BundleManifest, ResolvedReferencePolicy, ResolvedTargetSummary};
#[derive(Debug, Clone)]
pub struct BuildState {
pub root: PathBuf,
pub build_dir: PathBuf,
pub manifest: BundleManifest,
pub lock: crate::project::BundleLock,
pub bundle_yaml: String,
pub resolved_files: Vec<(String, String)>,
pub setup_files: Vec<(String, String)>,
pub asset_files: Vec<(String, Vec<u8>)>,
}
pub fn build_state(root: &Path) -> Result<BuildState> {
let lock = crate::project::read_bundle_lock(root)
.with_context(|| format!("read {}", root.join(crate::project::LOCK_FILE).display()))?;
let bundle_yaml_raw = fs::read_to_string(root.join(crate::project::WORKSPACE_ROOT_FILE))
.with_context(|| {
format!(
"read {}",
root.join(crate::project::WORKSPACE_ROOT_FILE).display()
)
})?;
let raw_doc = parse_yaml_document(&bundle_yaml_raw);
let bundle_id = yaml_string(&raw_doc, "bundle_id").unwrap_or_else(|| lock.bundle_id.clone());
let bundle_name = yaml_string(&raw_doc, "bundle_name").unwrap_or_else(|| bundle_id.clone());
let requested_mode =
yaml_string(&raw_doc, "mode").unwrap_or_else(|| lock.requested_mode.clone());
let locale = yaml_string(&raw_doc, "locale").unwrap_or_else(|| "en".to_string());
let app_packs = yaml_string_list(&raw_doc, "app_packs");
let extension_providers = yaml_string_list(&raw_doc, "extension_providers");
let catalogs = yaml_string_list(&raw_doc, "remote_catalogs");
let hooks = yaml_string_list(&raw_doc, "hooks");
let subscriptions = yaml_string_list(&raw_doc, "subscriptions");
let capabilities = yaml_string_list(&raw_doc, "capabilities");
let workspace = crate::project::read_bundle_workspace(root).ok();
let copy_targets: Vec<crate::project::MaterializedCopyTarget> = workspace
.as_ref()
.map(crate::project::app_pack_copy_targets)
.unwrap_or_default();
let reference_rewrites: Vec<(String, String)> = copy_targets
.iter()
.map(|t| {
let dest_str = t.destination.display().to_string();
let dest_norm = dest_str.replace('\\', "/");
(t.reference.clone(), dest_norm)
})
.collect();
let bundle_yaml = rewrite_app_pack_references_in_yaml(&bundle_yaml_raw, &reference_rewrites);
let resolved_files = collect_files(root, &root.join("resolved"))?;
let setup_files = collect_named_files(root, &lock.setup_state_files)?;
let asset_files = collect_asset_files(root)?;
let resolved_targets = resolved_files
.iter()
.filter_map(|(name, contents)| parse_resolved_target(name, contents))
.collect();
let manifest = BundleManifest {
format_version: crate::build::BUILD_FORMAT_VERSION.to_string(),
bundle_id,
bundle_name,
requested_mode,
locale,
artifact_extension: crate::build::FUTURE_ARTIFACT_EXTENSION.to_string(),
generated_resolved_files: resolved_files
.iter()
.map(|(name, _)| name.clone())
.collect(),
generated_setup_files: setup_files.iter().map(|(name, _)| name.clone()).collect(),
app_packs,
extension_providers,
catalogs,
hooks,
subscriptions,
capabilities,
resolved_targets,
};
let build_dir = root
.join(crate::build::BUILD_STATE_DIR)
.join(&manifest.bundle_id)
.join("normalized");
Ok(BuildState {
root: root.to_path_buf(),
build_dir,
manifest,
lock,
bundle_yaml,
resolved_files,
setup_files,
asset_files,
})
}
pub fn load_build_state(build_dir: &Path) -> Result<BuildState> {
let manifest_raw = fs::read_to_string(build_dir.join("bundle-manifest.json"))?;
let lock_raw = fs::read_to_string(build_dir.join("bundle-lock.json"))?;
let bundle_yaml = fs::read_to_string(build_dir.join("bundle.yaml"))?;
let manifest = serde_json::from_str::<BundleManifest>(&manifest_raw)?;
let lock = serde_json::from_str::<crate::project::BundleLock>(&lock_raw)?;
let resolved_files = collect_files(build_dir, &build_dir.join("resolved"))?;
let setup_files = collect_files(build_dir, &build_dir.join("state").join("setup"))?;
let asset_files = collect_asset_files(build_dir)?;
Ok(BuildState {
root: build_dir.to_path_buf(),
build_dir: build_dir.to_path_buf(),
manifest,
lock,
bundle_yaml,
resolved_files,
setup_files,
asset_files,
})
}
fn collect_asset_files(root: &Path) -> Result<Vec<(String, Vec<u8>)>> {
let mut files = Vec::new();
for relative_root in ["packs", "providers"] {
let dir = root.join(relative_root);
if !dir.exists() {
continue;
}
for entry in walk(&dir)? {
if entry.extension().and_then(|value| value.to_str()) != Some("gtpack") {
continue;
}
let rel = entry
.strip_prefix(root)
.unwrap_or(&entry)
.display()
.to_string();
files.push((rel, fs::read(&entry)?));
}
}
for relative_root in [".providers", "assets", "tenants"] {
let dir = root.join(relative_root);
if !dir.exists() {
continue;
}
for entry in walk(&dir)? {
let rel = entry
.strip_prefix(root)
.unwrap_or(&entry)
.display()
.to_string();
files.push((rel, fs::read(&entry)?));
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
files.dedup_by(|left, right| left.0 == right.0);
Ok(files)
}
fn collect_files(root: &Path, dir: &Path) -> Result<Vec<(String, String)>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in walk(dir)? {
let rel = entry
.strip_prefix(root)
.unwrap_or(&entry)
.display()
.to_string();
let contents = fs::read_to_string(&entry)?;
files.push((rel, contents));
}
files.sort_by(|a, b| a.0.cmp(&b.0));
Ok(files)
}
fn collect_named_files(root: &Path, names: &[String]) -> Result<Vec<(String, String)>> {
let mut files = Vec::new();
for name in names {
let path = root.join(name);
if path.exists() {
files.push((name.clone(), fs::read_to_string(path)?));
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
Ok(files)
}
fn walk(dir: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
out.extend(walk(&path)?);
} else {
out.push(path);
}
}
out.sort();
Ok(out)
}
fn parse_yaml_document(raw: &str) -> Option<Value> {
serde_yaml_bw::from_str(raw).ok()
}
fn rewrite_app_pack_references_in_yaml(raw: &str, rewrites: &[(String, String)]) -> String {
if rewrites.is_empty() {
return raw.to_string();
}
let mut out = raw.to_string();
for (original, replacement) in rewrites {
if original == replacement {
continue;
}
out = out.replace(original, replacement);
}
out
}
fn yaml_string(doc: &Option<Value>, key: &str) -> Option<String> {
doc.as_ref()?.get(key)?.as_str().map(ToOwned::to_owned)
}
fn yaml_string_list(doc: &Option<Value>, key: &str) -> Vec<String> {
match doc.as_ref().and_then(|value| value.get(key)) {
Some(Value::Sequence(items)) => items
.iter()
.filter_map(|item| item.as_str().map(ToOwned::to_owned))
.collect(),
Some(Value::Null(_)) | None => Vec::new(),
Some(Value::String(value, _)) => vec![value.clone()],
_ => Vec::new(),
}
}
fn parse_resolved_target(path: &str, raw: &str) -> Option<ResolvedTargetSummary> {
let doc = parse_yaml_document(raw)?;
let tenant = doc.get("tenant")?.as_str()?.to_string();
let default_policy = doc
.get("policy")
.and_then(|value| value.get("default"))
.and_then(Value::as_str)
.unwrap_or("forbidden")
.to_string();
let tenant_gmap = doc
.get("policy")
.and_then(|value| value.get("source"))
.and_then(|value| value.get("tenant_gmap"))
.and_then(Value::as_str)?
.to_string();
let team_gmap = doc
.get("policy")
.and_then(|value| value.get("source"))
.and_then(|value| value.get("team_gmap"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let team = doc
.get("team")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
Some(ResolvedTargetSummary {
path: path.to_string(),
tenant,
team,
default_policy,
tenant_gmap,
team_gmap,
app_pack_policies: parse_reference_policies(&doc),
})
}
fn parse_reference_policies(doc: &Value) -> Vec<ResolvedReferencePolicy> {
let Some(Value::Sequence(items)) = doc.get("app_packs") else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
let reference = item.get("reference")?.as_str()?.to_string();
let policy = item
.get("policy")
.and_then(Value::as_str)
.unwrap_or("unset")
.to_string();
Some(ResolvedReferencePolicy { reference, policy })
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrite_replaces_app_pack_references_in_yaml() {
let raw = "schema_version: 1
bundle_id: demo
app_packs:
- file://./testing.gtpack
app_pack_mappings:
- reference: file://./testing.gtpack
scope: global
";
let rewrites = vec![(
"file://./testing.gtpack".to_string(),
"file://./packs/testing.gtpack".to_string(),
)];
let out = rewrite_app_pack_references_in_yaml(raw, &rewrites);
assert!(
out.contains("- file://./packs/testing.gtpack"),
"rewritten app_packs entry missing: {out}"
);
assert!(
out.contains("reference: file://./packs/testing.gtpack"),
"rewritten mapping reference missing: {out}"
);
assert!(
!out.contains("file://./testing.gtpack"),
"original reference still present: {out}"
);
}
#[test]
fn rewrite_skips_no_op_replacements() {
let raw = "app_packs:\n - file://./packs/foo.gtpack\n";
let rewrites = vec![(
"file://./packs/foo.gtpack".to_string(),
"file://./packs/foo.gtpack".to_string(),
)];
let out = rewrite_app_pack_references_in_yaml(raw, &rewrites);
assert_eq!(out, raw);
}
#[test]
fn rewrite_leaves_unrelated_references_alone() {
let raw = "app_packs:
- file://./testing.gtpack
extension_providers:
- oci://ghcr.io/example/messaging:latest
- oci://ghcr.io/example/state:latest
";
let rewrites = vec![(
"file://./testing.gtpack".to_string(),
"file://./packs/testing.gtpack".to_string(),
)];
let out = rewrite_app_pack_references_in_yaml(raw, &rewrites);
assert!(out.contains("file://./packs/testing.gtpack"));
assert!(out.contains("oci://ghcr.io/example/messaging:latest"));
assert!(out.contains("oci://ghcr.io/example/state:latest"));
}
#[test]
fn rewrite_handles_empty_input_list() {
let raw = "app_packs: []\n";
let rewrites: Vec<(String, String)> = Vec::new();
let out = rewrite_app_pack_references_in_yaml(raw, &rewrites);
assert_eq!(out, raw);
}
}