use std::fs;
use std::path::Path;
use serde_json::Value;
use super::canonical::canonical_json;
use super::folder::{
config_record, copy_unmodeled, encode_config, encode_jobs_file, encode_subscriptions_file,
write_executions, Flavor, LoadedHome, ProfileIo,
};
use crate::ontology::{ArtifactFidelity, Fidelity};
use crate::world::Profile;
use crate::Result;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
pub file: String,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct HermesReport {
pub written: Vec<ArtifactFidelity>,
pub refused: Vec<Refusal>,
}
fn write_atomic(path: &Path, text: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_file_name(format!(
"{}.tmp-{}",
path.file_name().unwrap().to_string_lossy(),
std::process::id()
));
fs::write(&tmp, text)?;
fs::rename(&tmp, path)?;
Ok(())
}
pub fn from_hermes(home: &Path) -> Result<LoadedHome> {
super::folder::load_home(home, Flavor::Hermes)
}
pub fn to_hermes(loaded: &LoadedHome, dest: &Path, only: Option<&str>) -> Result<HermesReport> {
let mut report = HermesReport::default();
let empty = ProfileIo {
raw: Default::default(),
snapshot: Default::default(),
source_dir: None,
flavor: Flavor::Orchestrator,
jobs_form: None,
routes_at_top: false,
borrowed_from: None,
lenders: Vec::new(),
};
for (name, profile) in &loaded.world.profiles {
if only.is_some_and(|o| o != name) {
continue;
}
let dir = if name == "default" {
dest.to_path_buf()
} else {
dest.join("profiles").join(name)
};
fs::create_dir_all(dir.join("cron"))?;
let meta = loaded.io.get(name).unwrap_or(&empty);
let rel = |p: &str| {
if name == "default" {
p.to_string()
} else {
format!("profiles/{name}/{p}")
}
};
let unchanged =
|file: &str, record: &Value| meta.snapshot.get(file) == Some(&canonical_json(record));
fn emit_file(
written: &mut Vec<ArtifactFidelity>,
dir: &Path,
path: String,
file: &str,
text: &str,
tier: Fidelity,
) -> Result<()> {
write_atomic(&dir.join(file), text)?;
written.push(ArtifactFidelity {
path,
fidelity: tier,
loss: Vec::new(),
});
Ok(())
}
macro_rules! emit {
($file:expr, $text:expr, $tier:expr) => {
emit_file(&mut report.written, &dir, rel($file), $file, $text, $tier)?
};
}
let cfg_record = config_record(profile);
let cfg_empty = profile.routes.is_empty()
&& profile.channels.is_empty()
&& profile.residue.config.is_empty()
&& profile.worker.is_none()
&& profile.home.is_none()
&& profile.expiry == Default::default();
let hermes_bytes = meta.flavor == Flavor::Hermes;
if hermes_bytes
&& unchanged("config.yaml", &cfg_record)
&& meta.raw.contains_key("config.yaml")
{
emit!(
"config.yaml",
&meta.raw["config.yaml"],
Fidelity::ByteLossless
);
} else if !cfg_empty || meta.raw.contains_key("config.yaml") {
emit!(
"config.yaml",
&encode_config(profile, Some(meta), Some(&loaded.vault), Flavor::Hermes),
Fidelity::Semantic
);
}
if let Some(persona) = &profile.persona {
let src_name = if meta.raw.contains_key("SOUL.md") {
"SOUL.md"
} else {
"AGENTS.md"
};
let record = serde_json::to_value(&profile.persona).unwrap();
if unchanged(src_name, &record) && meta.raw.contains_key(src_name) {
emit!(
"SOUL.md",
&meta.raw[src_name],
if src_name == "SOUL.md" {
Fidelity::ByteLossless
} else {
Fidelity::Semantic
}
);
} else {
emit!(
"SOUL.md",
persona.text.as_deref().unwrap_or(""),
Fidelity::Semantic
);
}
}
let jobs_record: Vec<Value> = profile
.jobs
.values()
.map(|j| serde_json::to_value(j).unwrap())
.collect();
if !profile.jobs.is_empty() || meta.raw.contains_key("cron/jobs.json") {
if unchanged("cron/jobs.json", &Value::Array(jobs_record))
&& meta.raw.contains_key("cron/jobs.json")
{
emit!(
"cron/jobs.json",
&meta.raw["cron/jobs.json"],
Fidelity::ByteLossless
);
} else {
let mut view: Profile = profile.clone();
for job in view.jobs.values_mut() {
if let Some(w) = &job.workdir {
if !w.starts_with('/') {
job.workdir = Some(dir.join(w).display().to_string());
}
}
}
emit!(
"cron/jobs.json",
&encode_jobs_file(&view, Some(meta)),
Fidelity::ByteLossless
);
}
}
let src_exec = meta
.source_dir
.as_ref()
.map(|d| d.join("cron/executions.db"));
if !profile.fires.is_empty() || src_exec.as_ref().is_some_and(|p| p.exists()) {
let target = dir.join("cron/executions.db");
let fires_record = serde_json::to_value(&profile.fires).unwrap();
if unchanged("cron/executions.db", &fires_record)
&& src_exec.as_ref().is_some_and(|p| p.exists())
{
fs::copy(src_exec.as_ref().unwrap(), &target)?;
} else {
let tmp =
target.with_file_name(format!("executions.db.tmp-{}", std::process::id()));
let _ = fs::remove_file(&tmp);
write_executions(&tmp, &profile.fires)?;
fs::rename(&tmp, &target)?;
}
report
.written
.push(ArtifactFidelity::byte(rel("cron/executions.db")));
}
let subs_record: Vec<Value> = profile
.subscriptions
.values()
.map(|s| serde_json::to_value(s).unwrap())
.collect();
if !profile.subscriptions.is_empty() || meta.raw.contains_key("webhook_subscriptions.json")
{
if hermes_bytes
&& unchanged("webhook_subscriptions.json", &Value::Array(subs_record))
&& meta.raw.contains_key("webhook_subscriptions.json")
{
emit!(
"webhook_subscriptions.json",
&meta.raw["webhook_subscriptions.json"],
Fidelity::ByteLossless
);
} else {
emit!(
"webhook_subscriptions.json",
&encode_subscriptions_file(profile, Some(&loaded.vault)),
Fidelity::ByteLossless
);
}
}
if meta.borrowed_from.is_none() {
let state_record = serde_json::json!({ "bindings": profile.bindings, "obligations": profile.obligations });
let src_state = meta.source_dir.as_ref().map(|d| d.join("state.db"));
let lenders_unchanged = meta.lenders.iter().all(|n| {
let p = &loaded.world.profiles[n];
loaded.io.get(n).and_then(|m| m.snapshot.get("state.db")) == Some(&canonical_json(&serde_json::json!({ "bindings": p.bindings, "obligations": p.obligations })))
});
if src_state.as_ref().is_some_and(|p| p.exists()) && meta.flavor == Flavor::Hermes {
if unchanged("state.db", &state_record) && lenders_unchanged {
fs::copy(src_state.as_ref().unwrap(), dir.join("state.db"))?;
report.written.push(ArtifactFidelity::byte(rel("state.db")));
} else {
report.refused.push(Refusal { file: rel("state.db"), reason: "bindings/obligations changed since import; writing Hermes sessions is behind the UNI-22 stability gate".into() });
}
} else if !profile.bindings.is_empty() || !profile.obligations.is_empty() {
report.refused.push(Refusal { file: rel("state.db"), reason: "no Hermes session store to carry these rows into; writing Hermes sessions is behind the UNI-22 stability gate".into() });
}
}
copy_unmodeled(profile, meta, &dir)?;
for f in &profile.residue.files {
report.written.push(ArtifactFidelity::byte(rel(f)));
}
}
Ok(report)
}