#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used)]
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use camino::Utf8PathBuf;
use cordance_core::pack::{CordancePack, PackOutput};
use sha2::{Digest, Sha256};
pub mod agents_md;
pub mod claude_md;
pub mod codex;
pub mod cursor;
pub mod evidence_map;
pub mod harness_target;
pub mod pack_json;
#[derive(Debug, thiserror::Error)]
pub enum EmitError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("serialisation error: {0}")]
Serde(#[from] serde_json::Error),
#[error("fence error: {0}")]
Fence(cordance_core::fence::FenceError),
}
impl From<cordance_core::fence::FenceError> for EmitError {
fn from(e: cordance_core::fence::FenceError) -> Self {
Self::Fence(e)
}
}
pub trait TargetEmitter {
fn name(&self) -> &'static str;
fn render(&self, pack: &CordancePack) -> Result<Vec<(Utf8PathBuf, Vec<u8>)>, EmitError>;
fn plan(
&self,
pack: &CordancePack,
repo_root: &Utf8PathBuf,
) -> Result<Vec<PackOutput>, EmitError> {
let rendered = self.render(pack)?;
rendered
.into_iter()
.map(|(rel_path, bytes)| {
let abs_path = repo_root.join(&rel_path);
let final_bytes = merge_for_emit(&rel_path, bytes, abs_path.as_std_path())?;
let sha256 = hex::encode(Sha256::digest(&final_bytes));
Ok(PackOutput {
path: rel_path,
target: self.name().to_string(),
sha256,
bytes: final_bytes.len() as u64,
managed: true,
source_anchors: vec![],
})
})
.collect()
}
fn emit(
&self,
pack: &CordancePack,
repo_root: &Utf8PathBuf,
) -> Result<Vec<PackOutput>, EmitError> {
let rendered = self.render(pack)?;
let mut outputs = Vec::new();
for (rel_path, bytes) in rendered {
let abs_path = repo_root.join(&rel_path);
let final_bytes = merge_for_emit(&rel_path, bytes, abs_path.as_std_path())?;
cordance_core::fs::safe_write_with_mkdir(abs_path.as_std_path(), &final_bytes)
.map_err(EmitError::Io)?;
let sha256 = hex::encode(Sha256::digest(&final_bytes));
outputs.push(PackOutput {
path: rel_path,
target: self.name().to_string(),
sha256,
bytes: final_bytes.len() as u64,
managed: true,
source_anchors: vec![],
});
}
Ok(outputs)
}
}
fn merge_for_emit(
rel_path: &Utf8PathBuf,
rendered: Vec<u8>,
abs_path: &std::path::Path,
) -> Result<Vec<u8>, EmitError> {
let content_str = std::str::from_utf8(&rendered)
.map_err(|e| EmitError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
let new_regions = cordance_core::fence::find_regions(content_str)?;
cordance_core::fs::precheck_no_reparse_point_ancestor(abs_path).map_err(EmitError::Io)?;
if !abs_path.exists() {
return Ok(rendered);
}
if new_regions.is_empty() {
return Ok(rendered);
}
let existing = std::fs::read_to_string(abs_path).map_err(EmitError::Io)?;
let existing_regions = cordance_core::fence::find_regions(&existing)?;
let existing_keys: std::collections::HashSet<&str> =
existing_regions.iter().map(|r| r.key.as_str()).collect();
let any_new_key_missing = new_regions
.iter()
.any(|r| !existing_keys.contains(r.key.as_str()));
if any_new_key_missing {
let new_keys: Vec<&str> = new_regions.iter().map(|r| r.key.as_str()).collect();
let existing_keys_vec: Vec<&str> =
existing_regions.iter().map(|r| r.key.as_str()).collect();
tracing::warn!(
path = %rel_path,
new_keys = ?new_keys,
existing_keys = ?existing_keys_vec,
"fence key drift; replacing file verbatim instead of merging"
);
return Ok(rendered);
}
let pairs: Vec<(&str, &str)> = new_regions
.iter()
.map(|r| (r.key.as_str(), r.body.as_str()))
.collect();
Ok(cordance_core::fence::replace_regions(&existing, &pairs).into_bytes())
}
#[cfg(test)]
mod tests {
}