use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use areev_cal::{AreevFacade, CalStoreFacade};
use areev_core::error::Hash;
use areev_core::types::{Grain, GrainType};
use areev_store::Areev;
use serde_json::{json, Map, Value};
use crate::{flag, need};
use std::collections::HashMap;
struct Entry {
file: String,
id: String,
grain_type: String,
fields: Map<String, Value>,
expected: Option<String>,
}
struct Pack {
dir: PathBuf,
name: String,
version: String,
namespace: Option<String>,
blobs: BTreeMap<String, (String, Vec<u8>, String)>,
entries: Vec<Entry>,
bundle: Option<String>,
expect: Vec<(String, String)>,
registry: BTreeMap<String, String>,
}
pub fn run_pack(
m: Option<Areev>,
ns: &str,
flags: &HashMap<String, String>,
positional: &[String],
) -> Result<(), String> {
let sub = positional.first().map(|s| s.as_str()).unwrap_or("");
let json_out = flag(flags, "format").as_deref() == Some("json");
match sub {
"validate" => {
let dir = pack_dir(flags, positional)?;
validate(&dir, json_out)
}
"install" => {
let dir = pack_dir(flags, positional)?;
let m = m.ok_or("pack install needs a memory — pass --db <file|dsn>")?;
install(m, ns, &dir, flags, json_out)
}
"export" => {
let m = m.ok_or("pack export needs a memory — pass --db <file|dsn>")?;
export(m, ns, flags, json_out)
}
other => Err(format!(
"unknown pack subcommand '{other}' (validate|install|export)"
)),
}
}
fn pack_dir(flags: &HashMap<String, String>, positional: &[String]) -> Result<PathBuf, String> {
let raw = positional
.get(1)
.cloned()
.or_else(|| flag(flags, "pack"))
.ok_or("name the pack directory: areev pack <verb> <dir>")?;
let dir = PathBuf::from(&raw);
if !dir.join("pack.json").is_file() {
return Err(format!("{raw}: no pack.json here — a pack is a directory with a manifest"));
}
Ok(dir)
}
fn read_pack(dir: &Path) -> Result<Pack, String> {
let manifest_path = dir.join("pack.json");
let raw = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("{}: {e}", manifest_path.display()))?;
let manifest: Value = serde_json::from_str(&raw)
.map_err(|e| format!("{}: not JSON: {e}", manifest_path.display()))?;
let name = manifest
.get("pack")
.and_then(Value::as_str)
.ok_or("pack.json names no \"pack\"")?
.to_string();
let version = manifest
.get("version")
.and_then(Value::as_str)
.unwrap_or("0.0.0")
.to_string();
let namespace = manifest
.get("namespace")
.and_then(Value::as_str)
.map(str::to_string);
let mut blobs = BTreeMap::new();
if let Some(map) = manifest.get("blobs").and_then(Value::as_object) {
for (key, path) in map {
let rel = path
.as_str()
.ok_or_else(|| format!("blobs.{key}: expected a path string"))?;
let full = dir.join(rel);
let bytes = std::fs::read(&full)
.map_err(|e| format!("blobs.{key} ({}): {e}", full.display()))?;
let addr = areev_core::format::header::content_address(&bytes);
blobs.insert(key.clone(), (rel.to_string(), bytes, format!("cas://sha256:{}", addr.to_hex())));
}
}
let bundle = manifest
.get("bundle")
.and_then(Value::as_str)
.map(str::to_string);
let mut expect = Vec::new();
if let Some(list) = manifest.get("expect").and_then(Value::as_array) {
for e in list {
let hash = e
.get("hash")
.or_else(|| e.get("expected_hash"))
.and_then(Value::as_str)
.ok_or("expect entries need a \"hash\"")?;
let label = e
.get("name")
.and_then(Value::as_str)
.unwrap_or("grain")
.to_string();
expect.push((label, hash.to_ascii_lowercase()));
}
}
let mut registry = BTreeMap::new();
for (section, prefix) in [("queries", "qry:"), ("templates", "tpl:")] {
if let Some(map) = manifest.get(section).and_then(Value::as_object) {
for (name, body) in map {
let text = match body {
Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
};
registry.insert(format!("{prefix}{name}"), text);
}
}
}
let mut entries = Vec::new();
for item in manifest.get("grains").and_then(Value::as_array).unwrap_or(&Vec::new()) {
let (rel, expected) = match item {
Value::String(s) => (s.clone(), None),
Value::Object(o) => (
o.get("file")
.and_then(Value::as_str)
.ok_or("a grain entry needs a \"file\"")?
.to_string(),
o.get("expected_hash")
.and_then(Value::as_str)
.map(|h| h.trim().to_ascii_lowercase()),
),
other => return Err(format!("grains: expected a path or an object, got {other}")),
};
let full = dir.join(&rel);
let text = std::fs::read_to_string(&full)
.map_err(|e| format!("{}: {e}", full.display()))?;
let doc: Value = serde_json::from_str(&text)
.map_err(|e| format!("{}: not JSON: {e}", full.display()))?;
let mut fields = doc
.as_object()
.cloned()
.ok_or_else(|| format!("{}: expected one JSON object", full.display()))?;
let grain_type = fields
.remove("type")
.or_else(|| fields.remove("grain_type"))
.and_then(|v| v.as_str().map(str::to_string))
.ok_or_else(|| format!("{}: names no \"type\"", full.display()))?;
if namespace.is_some() && !fields.contains_key("namespace") {
fields.insert("namespace".into(), json!(namespace.clone().unwrap()));
}
resolve_blob_refs(&mut fields, &blobs, &rel)?;
let id = fields
.remove("id")
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| {
Path::new(&rel)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&rel)
.to_string()
});
entries.push(Entry { file: rel, id, grain_type, fields, expected });
}
if entries.is_empty() && bundle.is_none() && registry.is_empty() {
return Err("pack.json names neither \"grains\" nor \"bundle\" — a pack that seeds \
nothing installs nothing"
.into());
}
Ok(Pack {
dir: dir.to_path_buf(),
name,
version,
namespace,
blobs,
entries,
bundle,
expect,
registry,
})
}
fn resolve_blob_refs(
value: &mut Map<String, Value>,
blobs: &BTreeMap<String, (String, Vec<u8>, String)>,
file: &str,
) -> Result<(), String> {
fn walk(
v: &mut Value,
blobs: &BTreeMap<String, (String, Vec<u8>, String)>,
file: &str,
) -> Result<(), String> {
match v {
Value::String(s) => {
if let Some(name) = s.strip_prefix("blob:") {
let (_, _, addr) = blobs.get(name).ok_or_else(|| {
format!("{file}: names blob:{name}, which pack.json does not declare")
})?;
*s = addr.clone();
}
}
Value::Array(items) => {
for item in items {
walk(item, blobs, file)?;
}
}
Value::Object(map) => {
for (_, item) in map.iter_mut() {
walk(item, blobs, file)?;
}
}
_ => {}
}
Ok(())
}
for (_, v) in value.iter_mut() {
walk(v, blobs, file)?;
}
Ok(())
}
fn resolve_grain_refs(
value: &mut Map<String, Value>,
minted: &BTreeMap<String, String>,
file: &str,
) -> Result<(), String> {
fn walk(v: &mut Value, minted: &BTreeMap<String, String>, file: &str) -> Result<(), String> {
match v {
Value::String(s) => {
if let Some(id) = s.strip_prefix("grain:").filter(|i| !i.contains(':')) {
let hash = minted.get(id).ok_or_else(|| {
format!(
"{file}: names grain:{id}, which no earlier grain in this pack \
minted — a reference is a content address, so the grain it names \
must be built first; list it earlier in \"grains\""
)
})?;
*s = hash.clone();
}
}
Value::Array(items) => {
for item in items {
walk(item, minted, file)?;
}
}
Value::Object(map) => {
for (_, item) in map.iter_mut() {
walk(item, minted, file)?;
}
}
_ => {}
}
Ok(())
}
for (_, v) in value.iter_mut() {
walk(v, minted, file)?;
}
Ok(())
}
struct AddressSink;
impl areev_cal::json_build::GrainSink for AddressSink {
type Out = Hash;
fn consume<G: Grain + Clone + 'static>(self, grain: &G) -> areev_core::error::Result<Hash> {
areev_core::format::serialize::serialize_grain(grain).map(|(_, h)| h)
}
}
fn resolve_addresses(pack: &mut Pack) -> Result<Vec<(String, String, String)>, String> {
let mut out = Vec::with_capacity(pack.entries.len());
let mut mismatches = Vec::new();
let mut minted: BTreeMap<String, String> = BTreeMap::new();
for e in pack.entries.iter_mut() {
resolve_grain_refs(&mut e.fields, &minted, &e.file)?;
let hash =
areev_cal::json_build::build_grain_from_json(&e.grain_type, &e.fields, AddressSink)
.map_err(|err| format!("{}: {err}", e.file))?
.to_hex();
minted.insert(e.id.clone(), hash.clone());
if let Some(want) = &e.expected {
if want != &hash {
mismatches.push(format!(
" {} ({}): expected {want}, builds to {hash}",
e.file, e.grain_type
));
}
}
out.push((e.file.clone(), e.grain_type.clone(), hash));
}
if !mismatches.is_empty() {
return Err(format!(
"this pack does not build what its manifest says it builds:\n{}\n\
A content address covers the whole grain, so a mismatch means the \
declaration changed — including any code blob it names, since the \
address of the bytes is part of the plan. Nothing was written. Fix the \
pack, or update expected_hash deliberately and re-point whatever named \
the old hash (triggers do NOT follow heads).",
mismatches.join("\n")
));
}
Ok(out)
}
fn validate(dir: &Path, json_out: bool) -> Result<(), String> {
let mut pack = read_pack(dir)?;
let addressed =
if pack.bundle.is_some() { Vec::new() } else { resolve_addresses(&mut pack)? };
let mut warnings = Vec::new();
for e in &pack.entries {
if e.grain_type == "tool" {
check_tool(e, &mut warnings)?;
}
}
if json_out {
println!(
"{}",
json!({
"pack": pack.name,
"version": pack.version,
"namespace": pack.namespace,
"grains": addressed.iter().map(|(f, t, h)| json!({"file": f, "type": t, "hash": h})).collect::<Vec<_>>(),
"blobs": pack.blobs.iter().map(|(k, (p, b, a))| json!({"name": k, "file": p, "bytes": b.len(), "address": a})).collect::<Vec<_>>(),
"bundle": pack.bundle,
"registry": pack.registry.keys().collect::<Vec<_>>(),
"warnings": warnings,
"ok": true,
})
);
return Ok(());
}
println!("pack {} {} — valid", pack.name, pack.version);
for (name, (path, bytes, addr)) in &pack.blobs {
println!(" blob {name:16} {addr} ({} bytes, {path})", bytes.len());
}
for (file, ty, hash) in &addressed {
println!(" grain {ty:16} {hash} ({file})");
}
for key in pack.registry.keys() {
println!(" meta {key}");
}
if let Some(b) = &pack.bundle {
println!(" bundle {b}");
}
for w in &warnings {
eprintln!("areev: pack: {w}");
}
let pins = pins(&pack);
if !pins.is_empty() {
println!(
"\nPin the code before running anything from this pack:\n --allow-executor {}",
pins.join(",")
);
}
Ok(())
}
fn check_tool(e: &Entry, warnings: &mut Vec<String>) -> Result<(), String> {
let runtime = e.fields.get("runtime").and_then(Value::as_str);
let uri = e.fields.get("executor_uri").and_then(Value::as_str);
if let Some(caps) = e.fields.get("capabilities") {
if runtime != Some("wasm32-areev-io") {
return Err(format!(
"{}: declares capabilities but runtime is {:?} — capabilities require \
runtime \"wasm32-areev-io\"",
e.file,
runtime.unwrap_or("native")
));
}
areev_core::types::capability::Declaration::parse(caps)
.map_err(|err| format!("{}: malformed capabilities: {err}", e.file))?;
} else if runtime == Some("wasm32-areev-io") {
return Err(format!(
"{}: declares runtime \"wasm32-areev-io\" but no capabilities — a capability \
runtime with an empty declaration can reach nothing",
e.file
));
}
if runtime.is_some() && uri.is_none() {
return Err(format!(
"{}: declares a runtime but names no executor_uri — a runtime routes a code \
blob, and there is none",
e.file
));
}
if uri.is_some_and(|u| !u.starts_with("cas://sha256:")) {
warnings.push(format!(
"{}: executor_uri is not a content address — this host can only dispatch \
cas://sha256:<64 hex>",
e.file
));
}
Ok(())
}
fn install(
m: Areev,
ns: &str,
dir: &Path,
flags: &HashMap<String, String>,
json_out: bool,
) -> Result<(), String> {
let mut pack = read_pack(dir)?;
let ns = pack.namespace.clone().unwrap_or_else(|| ns.to_string());
let facade = AreevFacade::with_session(m, Some(ns.clone()), None);
if let Some(bundle) = &pack.bundle {
return install_bundle(&facade, &pack, bundle, json_out);
}
let addressed = resolve_addresses(&mut pack)?;
let mut warnings = Vec::new();
for e in &pack.entries {
if e.grain_type == "tool" {
check_tool(e, &mut warnings)?;
}
}
if flag(flags, "dry-run").is_some() {
println!("pack {} {} — would install {} grains, {} blobs (dry run)",
pack.name, pack.version, addressed.len(), pack.blobs.len());
return Ok(());
}
let mut stored_blobs = Vec::new();
for (name, (_, bytes, addr)) in &pack.blobs {
let stored = facade
.with_store(|s| s.put_blob(bytes))
.map_err(|e| format!("storing blob {name}: {e}"))?;
if &stored != addr {
return Err(format!(
"blob {name} stored as {stored} but addressed as {addr} — refusing"
));
}
stored_blobs.push((name.clone(), stored));
}
for (key, body) in &pack.registry {
facade
.with_store(|s| s.meta_put(key, body))
.map_err(|e| format!("installing {key}: {e}"))?;
}
let mut written = Vec::new();
for (e, (_, ty, expected)) in pack.entries.iter().zip(addressed.iter()) {
let hash = facade
.cal_add(&e.grain_type, &e.fields)
.map_err(|err| format!("{}: {err}", e.file))?;
let hex = hash.to_hex();
if &hex != expected {
return Err(format!(
"{}: built to {expected} but stored as {hex} — refusing, because \
validate no longer describes install",
e.file
));
}
written.push(json!({ "file": e.file, "type": ty, "hash": hex }));
}
if json_out {
println!(
"{}",
json!({
"pack": pack.name, "version": pack.version, "namespace": ns,
"grains": written,
"blobs": stored_blobs.iter().map(|(n, a)| json!({"name": n, "address": a})).collect::<Vec<_>>(),
"allow_executor": pins(&pack),
"warnings": warnings,
})
);
} else {
println!("installed pack {} {} into ns '{}'", pack.name, pack.version, ns);
for key in pack.registry.keys() {
println!(" meta {key}");
}
for (name, addr) in &stored_blobs {
println!(" blob {name:16} {addr}");
}
for w in &written {
println!(
" grain {:16} {} ({})",
w["type"].as_str().unwrap_or(""),
w["hash"].as_str().unwrap_or(""),
w["file"].as_str().unwrap_or("")
);
}
for w in &warnings {
eprintln!("areev: pack: {w}");
}
let pins = pins(&pack);
if !pins.is_empty() {
println!(
"\nNothing code-carrying runs until this host pins it:\n --allow-executor {}",
pins.join(",")
);
}
}
Ok(())
}
fn pins(pack: &Pack) -> Vec<String> {
let mut out: Vec<String> = pack
.entries
.iter()
.filter_map(|e| e.fields.get("executor_uri").and_then(Value::as_str))
.map(|u| u.trim_start_matches("cas://sha256:").to_string())
.collect();
out.sort();
out.dedup();
out
}
fn install_bundle(
facade: &AreevFacade,
pack: &Pack,
bundle: &str,
json_out: bool,
) -> Result<(), String> {
let path = pack.dir.join(bundle);
let stats = facade
.with_store(|s| s.import_bundle(path.to_str().unwrap_or(bundle)))
.map_err(|e| format!("importing {}: {e}", path.display()))?;
let mut missing = Vec::new();
for (label, hash) in &pack.expect {
let h = Hash::from_hex(hash).map_err(|e| format!("expect {label}: {e}"))?;
if facade.with_store(|s| s.get(&h)).is_err() {
missing.push(format!(" {label}: {hash}"));
}
}
if !missing.is_empty() {
return Err(format!(
"the bundle imported, but the pack does not contain what it says it does:\n{}\n\
A bundle replicates ops; an expectation names a content address. \
The two disagreeing means the bundle was cut from a different memory \
than the manifest describes.",
missing.join("\n")
));
}
if json_out {
println!(
"{}",
json!({
"pack": pack.name, "version": pack.version,
"applied": stats.applied, "skipped": stats.skipped,
"expect": pack.expect.iter().map(|(l, h)| json!({"name": l, "hash": h})).collect::<Vec<_>>(),
})
);
} else {
println!(
"installed pack {} {} from {bundle}: {} ops applied, {} skipped; {} expectations met",
pack.name,
pack.version,
stats.applied,
stats.skipped,
pack.expect.len()
);
}
Ok(())
}
const PACKABLE: &[GrainType] = &[
GrainType::Tool,
GrainType::Workflow,
GrainType::Trigger,
GrainType::Fact,
GrainType::Skill,
];
fn export(
m: Areev,
ns: &str,
flags: &HashMap<String, String>,
json_out: bool,
) -> Result<(), String> {
let out = PathBuf::from(need(flags, "out")?);
let name = flag(flags, "name").unwrap_or_else(|| ns.to_string());
let version = flag(flags, "pack-version").unwrap_or_else(|| "1.0.0".into());
let format = flag(flags, "pack-format").unwrap_or_else(|| "source".into());
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
let facade = AreevFacade::with_session(m, Some(ns.to_string()), None);
if format == "bundle" {
return export_bundle(&facade, &out, &name, &version, ns, json_out);
}
if format != "source" {
return Err(format!("--pack-format: expected source|bundle, got {format:?}"));
}
std::fs::create_dir_all(out.join("grains")).map_err(|e| e.to_string())?;
let limit: usize = flag(flags, "limit").and_then(|v| v.parse().ok()).unwrap_or(10_000);
let mut grains = Vec::new();
let mut blobs: BTreeMap<String, (String, String)> = BTreeMap::new();
let mut files = Vec::new();
let mut skipped = Vec::new();
for ty in PACKABLE {
let found = facade
.with_store(|s| s.recent(ns, Some(*ty), limit))
.map_err(|e| e.to_string())?;
for g in found {
if *ty == GrainType::Tool && g.get_str("kind") != Some("definition") {
continue;
}
let hash = g.hash.to_hex();
let mut doc = Map::new();
doc.insert("type".into(), json!(type_name(*ty)));
for (k, v) in &g.fields {
if v.is_null() {
continue;
}
doc.insert(k.clone(), v.clone());
}
if let Some(uri) = doc.get("executor_uri").and_then(Value::as_str).map(str::to_string) {
match facade.with_store(|s| s.get_blob(&uri)) {
Ok(bytes) => {
let key = doc
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or("code")
.replace(['.', '/', ' '], "_");
let rel = format!("blobs/{key}.wasm");
std::fs::create_dir_all(out.join("blobs")).map_err(|e| e.to_string())?;
std::fs::write(out.join(&rel), &bytes)
.map_err(|e| format!("{rel}: {e}"))?;
blobs.insert(key.clone(), (rel, uri.clone()));
doc.insert("executor_uri".into(), json!(format!("blob:{key}")));
}
Err(e) => skipped.push(format!(
"{hash}: names {uri}, which this memory does not hold ({e}) — \
exported as a bare address"
)),
}
}
grains.push((hash, type_name(*ty).to_string(), doc));
}
}
grains.sort_by_key(|(_, ty, _)| match ty.as_str() {
"tool" => 0,
"skill" => 1,
"fact" => 2,
"workflow" => 3,
"trigger" => 4,
_ => 5,
});
let mut manifest_grains = Vec::new();
let mut unreproducible = Vec::new();
let mut used: BTreeMap<String, usize> = BTreeMap::new();
for (i, (hash, ty, doc)) in grains.iter().enumerate() {
let label = doc
.get("tool_name")
.or_else(|| doc.get("name"))
.or_else(|| doc.get("connector"))
.and_then(Value::as_str)
.unwrap_or(ty)
.replace(['.', '/', ' '], "-");
let label = match used.entry(format!("{ty}-{label}")).or_insert(0) {
n if *n == 0 => {
*n += 1;
label
}
n => {
*n += 1;
format!("{label}-{}", &hash[..8])
}
};
let rel = format!("grains/{:03}-{ty}-{label}.json", (i + 1) * 10);
let mut fields = doc.clone();
fields.remove("type");
let mut resolved = fields.clone();
let blob_map: BTreeMap<String, (String, Vec<u8>, String)> = blobs
.iter()
.map(|(k, (rel, addr))| (k.clone(), (rel.clone(), Vec::new(), addr.clone())))
.collect();
resolve_blob_refs(&mut resolved, &blob_map, &rel)?;
let rebuilt = areev_cal::json_build::build_grain_from_json(ty, &resolved, AddressSink)
.map(|h| h.to_hex());
match rebuilt {
Ok(h) if &h == hash => {
std::fs::write(
out.join(&rel),
serde_json::to_string_pretty(&Value::Object(doc.clone())).unwrap() + "\n",
)
.map_err(|e| format!("{rel}: {e}"))?;
manifest_grains.push(json!({ "file": rel, "expected_hash": hash }));
files.push(rel);
}
Ok(h) => unreproducible.push(format!(" {hash} ({ty}) rebuilds as {h}")),
Err(e) => unreproducible.push(format!(" {hash} ({ty}) will not rebuild: {e}")),
}
}
if !unreproducible.is_empty() {
return Err(format!(
"these grains do not round-trip through the pack format, so the pack was not \
written:\n{}\n\
A source pack is re-BUILT at install, so anything it cannot reproduce byte for \
byte would install at a different address than it was exported from. Export \
this memory with --pack-format bundle instead, which replicates the grains \
themselves.",
unreproducible.join("\n")
));
}
let mut queries = Map::new();
let mut templates = Map::new();
for (prefix, out) in [("qry:", &mut queries), ("tpl:", &mut templates)] {
for (key, body) in facade
.with_store(|s| s.meta_scan(prefix))
.map_err(|e| e.to_string())?
{
let name = key.trim_start_matches(prefix).to_string();
out.insert(name, serde_json::from_str(&body).unwrap_or(json!(body)));
}
}
let mut manifest = Map::new();
manifest.insert("pack".into(), json!(name));
manifest.insert("version".into(), json!(version));
manifest.insert("namespace".into(), json!(ns));
if !blobs.is_empty() {
manifest.insert(
"blobs".into(),
Value::Object(
blobs.iter().map(|(k, (rel, _))| (k.clone(), json!(rel))).collect::<Map<_, _>>(),
),
);
}
manifest.insert("grains".into(), json!(manifest_grains));
if !queries.is_empty() {
manifest.insert("queries".into(), Value::Object(queries));
}
if !templates.is_empty() {
manifest.insert("templates".into(), Value::Object(templates));
}
let manifest = Value::Object(manifest);
std::fs::write(
out.join("pack.json"),
serde_json::to_string_pretty(&manifest).unwrap() + "\n",
)
.map_err(|e| e.to_string())?;
for s in &skipped {
eprintln!("areev: pack: {s}");
}
if json_out {
println!(
"{}",
json!({ "pack": name, "version": version, "out": out.display().to_string(),
"grains": files.len(), "blobs": blobs.len(), "format": "source" })
);
} else {
println!(
"exported pack {name} {version} → {} ({} grains, {} blobs)",
out.display(),
files.len(),
blobs.len()
);
}
Ok(())
}
fn export_bundle(
facade: &AreevFacade,
out: &Path,
name: &str,
version: &str,
ns: &str,
json_out: bool,
) -> Result<(), String> {
let bundle_rel = "pack.mgb";
let bundle_path = out.join(bundle_rel);
let stats = facade
.with_store(|s| s.bundle_since(0, bundle_path.to_str().unwrap_or(bundle_rel)))
.map_err(|e| format!("bundling: {e}"))?;
let mut expect = Vec::new();
for ty in [GrainType::Workflow, GrainType::Trigger] {
for g in facade
.with_store(|s| s.recent(ns, Some(ty), 1000))
.map_err(|e| e.to_string())?
{
let label = g
.get_str("name")
.unwrap_or(type_name(ty))
.to_string();
expect.push(json!({ "name": label, "kind": type_name(ty), "hash": g.hash.to_hex() }));
}
}
let manifest = json!({
"pack": name, "version": version, "namespace": ns,
"bundle": bundle_rel, "expect": expect,
});
std::fs::write(
out.join("pack.json"),
serde_json::to_string_pretty(&manifest).unwrap() + "\n",
)
.map_err(|e| e.to_string())?;
if json_out {
println!(
"{}",
json!({ "pack": name, "version": version, "out": out.display().to_string(),
"format": "bundle", "ops": stats.ops, "bytes": stats.bytes,
"expect": expect.len() })
);
} else {
println!(
"exported pack {name} {version} → {} ({} ops, {} bytes, {} expectations)",
out.display(),
stats.ops,
stats.bytes,
expect.len()
);
}
Ok(())
}
fn type_name(t: GrainType) -> &'static str {
match t {
GrainType::Tool => "tool",
GrainType::Workflow => "workflow",
GrainType::Trigger => "trigger",
GrainType::Fact => "fact",
GrainType::Skill => "skill",
other => other.as_str(),
}
}