use std::io::Read;
use std::process::ExitCode;
use serde::Deserialize;
use serde_json::json;
use substrate::{BondRelation, SporeBond};
use crate::api::Output;
use crate::site::SiteDir;
use crate::HyphaError;
use super::{load_draft, save_draft};
pub const BOND_SYNC_DRIFT_EXIT_CODE: u8 = 7;
#[derive(Debug, Deserialize)]
struct BondSyncSpecEntry {
id: String,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
with: Option<serde_json::Value>,
#[serde(default)]
uri: Option<String>,
}
enum EntryDiff {
Add(SporeBond),
Update(SporeBond),
Unchanged(SporeBond),
}
impl EntryDiff {
fn bond(&self) -> &SporeBond {
match self {
EntryDiff::Add(b) | EntryDiff::Update(b) | EntryDiff::Unchanged(b) => b,
}
}
}
struct ReconcileResult {
new_bonds: Vec<SporeBond>,
per_entry: Vec<EntryDiff>,
to_remove: Vec<SporeBond>,
}
fn reconcile_bonds(
existing: &[SporeBond],
target_relation: &BondRelation,
desired: Vec<SporeBond>,
) -> ReconcileResult {
let mut consumed = vec![false; existing.len()];
let mut match_idx = Vec::with_capacity(desired.len());
for bond in &desired {
let idx = existing
.iter()
.enumerate()
.find(|(i, b)| !consumed[*i] && b.uri == bond.uri)
.map(|(i, _)| i)
.or_else(|| {
existing
.iter()
.enumerate()
.find(|(i, b)| {
!consumed[*i]
&& &b.relation == target_relation
&& b.id.as_deref() == bond.id.as_deref()
})
.map(|(i, _)| i)
});
if let Some(i) = idx {
consumed[i] = true;
}
match_idx.push(idx);
}
let per_entry: Vec<EntryDiff> = desired
.iter()
.zip(match_idx.iter())
.map(|(bond, idx)| match idx {
None => EntryDiff::Add(bond.clone()),
Some(i) if existing[*i] == *bond => EntryDiff::Unchanged(bond.clone()),
Some(_) => EntryDiff::Update(bond.clone()),
})
.collect();
let to_remove: Vec<SporeBond> = existing
.iter()
.enumerate()
.filter(|(i, b)| !consumed[*i] && &b.relation == target_relation)
.map(|(_, b)| b.clone())
.collect();
let insertion_index = existing.iter().position(|b| &b.relation == target_relation);
let mut new_bonds = Vec::with_capacity(existing.len() + desired.len());
let mut inserted = false;
for (i, bond) in existing.iter().enumerate() {
if Some(i) == insertion_index {
new_bonds.extend(desired.iter().cloned());
inserted = true;
}
if consumed[i] {
continue;
}
if &bond.relation == target_relation {
continue;
}
new_bonds.push(bond.clone());
}
if !inserted {
new_bonds.extend(desired.iter().cloned());
}
ReconcileResult {
new_bonds,
per_entry,
to_remove,
}
}
fn read_spec_source(spec: &str) -> Result<String, HyphaError> {
if spec == "-" {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf).map_err(|e| {
HyphaError::new(
"bond_spec_read_failed",
format!("Failed to read --spec from stdin: {}", e),
)
})?;
Ok(buf)
} else {
std::fs::read_to_string(spec).map_err(|e| {
HyphaError::new(
"bond_spec_read_failed",
format!("Failed to read --spec file '{}': {}", spec, e),
)
})
}
}
fn resolve_bond_uri(
entry_id: &str,
domain: Option<&str>,
site_path: Option<&str>,
) -> Result<String, HyphaError> {
let domain = domain.ok_or_else(|| {
HyphaError::with_hint(
"missing_domain",
format!(
"Spec entry '{}' has no \"uri\" and --domain was not given for resolution",
entry_id
),
"pass --domain (and --site-path for a non-default site) so sync can resolve URIs, or give an explicit \"uri\" for this entry",
)
})?;
let site = SiteDir::from_args(domain, site_path);
if site.exists() {
if let Some(hash) = crate::mycelium::find_local_spore_hash(&site, domain, entry_id) {
return Ok(substrate::build_spore_uri(domain, &hash));
}
}
let cwd = std::env::current_dir().map_err(|e| {
HyphaError::new(
"dir_error",
format!("Failed to get working directory: {}", e),
)
})?;
let sibling_dir = cwd
.parent()
.map(|p| p.join(entry_id))
.filter(|p| p.join("spore.core.json").exists());
let Some(sibling_dir) = sibling_dir else {
return Err(HyphaError::with_hint(
"bond_uri_unresolved",
format!(
"Could not resolve URI for spec entry '{}': not found in {}'s mycelium inventory, and no sibling spore.core.json at ../{}",
entry_id, domain, entry_id
),
"release the dependency first, or give an explicit \"uri\" for this entry",
));
};
super::release::build_release_plan(domain, site_path, sibling_dir, crate::time::now_epoch_ms())
.map(|plan| plan.uri)
.map_err(|e| {
HyphaError::with_hint(
"bond_uri_unresolved",
format!(
"Could not compute dry-run URI for spec entry '{}': {}",
entry_id, e.message
),
"release the dependency first, or give an explicit \"uri\" for this entry",
)
})
}
pub fn handle_bond_sync(
out: &Output,
relation: BondRelation,
spec: &str,
domain: Option<String>,
site_path: Option<String>,
check: bool,
) -> ExitCode {
let spec_content = match read_spec_source(spec) {
Ok(c) => c,
Err(e) => return out.error_hypha(&e),
};
let entries: Vec<BondSyncSpecEntry> = match serde_json::from_str(&spec_content) {
Ok(v) => v,
Err(e) => {
return out.error_hint(
"bond_spec_invalid",
&format!("Invalid --spec JSON: {}", e),
Some("--spec must be a JSON array of {id, reason?, with?, uri?} objects, or \"-\" to read stdin"),
)
}
};
for entry in &entries {
if entry.id.trim().is_empty() {
return out.error(
"bond_spec_invalid",
"Each spec entry must have a non-empty \"id\"",
);
}
}
let mut desired = Vec::with_capacity(entries.len());
for entry in &entries {
let uri = match entry.uri.as_deref() {
Some(u) if !u.is_empty() => u.to_string(),
_ => match resolve_bond_uri(&entry.id, domain.as_deref(), site_path.as_deref()) {
Ok(u) => u,
Err(e) => return out.error_hypha(&e),
},
};
desired.push(SporeBond {
relation: relation.clone(),
uri,
id: Some(entry.id.clone()),
reason: entry.reason.clone(),
with: entry.with.clone(),
});
}
let (spore_core_path, mut draft) = match load_draft() {
Ok(v) => v,
Err((code, msg)) => return out.error(&code, &msg),
};
let ReconcileResult {
new_bonds,
per_entry,
to_remove,
} = reconcile_bonds(&draft.bonds, &relation, desired);
let to_add: Vec<&SporeBond> = per_entry
.iter()
.filter(|e| matches!(e, EntryDiff::Add(_)))
.map(EntryDiff::bond)
.collect();
let to_update: Vec<&SporeBond> = per_entry
.iter()
.filter(|e| matches!(e, EntryDiff::Update(_)))
.map(EntryDiff::bond)
.collect();
let unchanged: Vec<&SporeBond> = per_entry
.iter()
.filter(|e| matches!(e, EntryDiff::Unchanged(_)))
.map(EntryDiff::bond)
.collect();
if check {
let drift = !to_add.is_empty() || !to_update.is_empty() || !to_remove.is_empty();
let result = json!({
"relation": relation.as_str(),
"drift": drift,
"to_add": to_add,
"to_update": to_update,
"to_remove": to_remove,
"unchanged": unchanged,
});
return if drift {
out.ok_with_code(result, BOND_SYNC_DRIFT_EXIT_CODE)
} else {
out.ok(result)
};
}
let applied: Vec<&SporeBond> = per_entry
.iter()
.filter(|e| !matches!(e, EntryDiff::Unchanged(_)))
.map(EntryDiff::bond)
.collect();
let result = json!({
"relation": relation.as_str(),
"applied": applied,
"removed": to_remove,
"unchanged": unchanged,
});
draft.bonds = new_bonds;
if let Err(e) = save_draft(&spore_core_path, &draft) {
return out.error_hypha(&e);
}
out.ok(result)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn bond(relation: BondRelation, uri: &str, id: &str) -> SporeBond {
SporeBond {
relation,
uri: uri.to_string(),
id: Some(id.to_string()),
reason: None,
with: None,
}
}
#[test]
fn reconcile_empty_spec_clears_relation_and_keeps_others() {
let existing = vec![
bond(BondRelation::Follows, "cmn://d/b3.a", "a"),
bond(BondRelation::DependsOn, "cmn://d/b3.b", "b"),
];
let result = reconcile_bonds(&existing, &BondRelation::Follows, vec![]);
assert_eq!(result.new_bonds, vec![existing[1].clone()]);
assert_eq!(result.to_remove, vec![existing[0].clone()]);
assert!(result.per_entry.is_empty());
}
#[test]
fn reconcile_is_idempotent_on_second_pass() {
let existing = vec![bond(BondRelation::DependsOn, "cmn://d/b3.other", "other")];
let desired = vec![bond(BondRelation::Follows, "cmn://d/b3.a", "a")];
let first = reconcile_bonds(&existing, &BondRelation::Follows, desired.clone());
assert_eq!(first.to_remove.len(), 0);
assert_eq!(first.per_entry.len(), 1);
assert!(matches!(first.per_entry[0], EntryDiff::Add(_)));
let second = reconcile_bonds(&first.new_bonds, &BondRelation::Follows, desired);
assert_eq!(second.to_remove.len(), 0);
assert!(matches!(second.per_entry[0], EntryDiff::Unchanged(_)));
assert_eq!(second.new_bonds, first.new_bonds);
}
#[test]
fn reconcile_places_group_at_first_pre_existing_target_bond() {
let existing = vec![
bond(BondRelation::DependsOn, "cmn://d/b3.x", "x"),
bond(BondRelation::Follows, "cmn://d/b3.old", "old"),
bond(BondRelation::DependsOn, "cmn://d/b3.y", "y"),
];
let desired = vec![bond(BondRelation::Follows, "cmn://d/b3.new", "new")];
let result = reconcile_bonds(&existing, &BondRelation::Follows, desired);
let uris: Vec<&str> = result.new_bonds.iter().map(|b| b.uri.as_str()).collect();
assert_eq!(uris, vec!["cmn://d/b3.x", "cmn://d/b3.new", "cmn://d/b3.y"]);
}
#[test]
fn reconcile_appends_group_when_no_pre_existing_target_bonds() {
let existing = vec![bond(BondRelation::DependsOn, "cmn://d/b3.x", "x")];
let desired = vec![bond(BondRelation::Follows, "cmn://d/b3.new", "new")];
let result = reconcile_bonds(&existing, &BondRelation::Follows, desired);
let uris: Vec<&str> = result.new_bonds.iter().map(|b| b.uri.as_str()).collect();
assert_eq!(uris, vec!["cmn://d/b3.x", "cmn://d/b3.new"]);
}
}