cordance-emit 0.1.1

Cordance target emitters: AGENTS.md, CLAUDE.md, .cursor/rules, .codex, axiom harness-target.
Documentation
//! Target emitters. Each emitter consumes the same `CordancePack` IR and
//! writes a deterministic, fence-aware file.
//!
//! Every emitter:
//! - Refuses to follow symlinks / Windows reparse points via
//!   [`cordance_core::fs::safe_write_with_mkdir`] (ancestor walk included).
//! - Merges fenced regions (`<!-- cordance:begin <key> -->`) so hand-edits
//!   outside fences survive regeneration.
//! - Sanitises target-controlled strings before fence interpolation so a
//!   hostile `cordance.toml` can't inject fake fence markers.
//!
//! # Golden path
//!
//! ```no_run
//! use camino::Utf8PathBuf;
//! use cordance_core::advise::AdviseReport;
//! use cordance_core::lock::SourceLock;
//! use cordance_core::pack::{CordancePack, PackTargets, ProjectIdentity};
//! use cordance_core::schema;
//! use cordance_emit::{TargetEmitter, agents_md::AgentsMdEmitter};
//!
//! let repo_root = Utf8PathBuf::from(".");
//! let pack = CordancePack {
//!     schema: schema::CORDANCE_PACK_V1.into(),
//!     project: ProjectIdentity {
//!         name: "my-project".into(),
//!         repo_root: repo_root.clone(),
//!         kind: "rust-workspace".into(),
//!         host_os: "linux".into(),
//!         axiom_pin: None,
//!     },
//!     sources: vec![],
//!     doctrine_pins: vec![],
//!     targets: PackTargets::all(),
//!     outputs: vec![],
//!     source_lock: SourceLock::empty(),
//!     advise: AdviseReport::empty(),
//!     residual_risk: vec!["claim_ceiling=candidate".into()],
//! };
//!
//! let outputs = AgentsMdEmitter.emit(&pack, &repo_root).expect("emit AGENTS.md");
//! for out in &outputs {
//!     println!("wrote {} ({} bytes)", out.path, out.bytes);
//! }
//! ```

#![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;

// The fence-syntax sanitiser now lives in `cordance-core::fence` next to
// `find_regions` / `replace_regions` — it is the same fence-syntax concern,
// not an emit-specific one. Emitters reference it directly as
// `cordance_core::fence::sanitise_fenced_value`. (Round-3 MEDIUM.)

#[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)
    }
}

/// Trait every target emitter implements.
pub trait TargetEmitter {
    /// Stable name of this target ("claude-code", "cursor", "codex", "axiom-harness-target").
    fn name(&self) -> &'static str;

    /// Render all outputs for this target.
    /// Returns (repo-relative path, content bytes) pairs.
    ///
    /// # Errors
    ///
    /// Returns `Err` if rendering fails (serialisation, fence parse, etc.).
    fn render(&self, pack: &CordancePack) -> Result<Vec<(Utf8PathBuf, Vec<u8>)>, EmitError>;

    /// Plan: compute the bytes the emitter *would* write, return `PackOutput`
    /// metadata (sha256, byte count). Pure — no filesystem mutation.
    ///
    /// `repo_root` is needed because the bytes a fence-bearing emitter
    /// would write are NOT simply the rendered bytes — they're the result
    /// of merging the rendered fenced regions into whatever non-fenced
    /// content already lives in the on-disk file. Round-4 bughunt #3:
    /// before this change, `plan` hashed the rendered bytes (no merge) while
    /// `emit` hashed the merged bytes. `cordance check` then rescanned via
    /// `plan` and saw drift on every fenced output that had any user-owned
    /// byte outside a fence. The merge logic is now shared via
    /// [`merge_for_emit`] and both `plan` and `emit` agree on the final
    /// `final_bytes`.
    ///
    /// # Errors
    ///
    /// Returns `Err` if rendering, UTF-8 validation, or fence parsing fails.
    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()
    }

    /// Write all rendered outputs into `repo_root`.
    ///
    /// # Errors
    ///
    /// Returns `Err` if any I/O or render step fails.
    ///
    /// # Merge semantics
    ///
    /// Delegates to [`merge_for_emit`], which encodes:
    /// - If `abs_path` does not exist: write the rendered bytes verbatim.
    /// - If the rendered content contains no fenced regions: overwrite the
    ///   target verbatim regardless of any stale fence markers on disk.
    /// - If the rendered content contains fenced regions and every region's
    ///   key is present in the existing file: do a batch fence replace,
    ///   preserving content outside the fences.
    /// - If the rendered content has any fence key that does not exist in the
    ///   existing file (key drift): log a warning and replace the file
    ///   verbatim. This is the "fail loud, do not silently drop content"
    ///   branch.
    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())?;

            // Round-5 redteam #1: a hostile target can plant the destination
            // as a symlink (POSIX) or reparse point (Windows junction /
            // symlink-file) pointing at any operator-owned file the process
            // can write to (`~/.ssh/authorized_keys`, `~/.bashrc`, …). Route
            // through `cordance_core::fs::safe_write_with_mkdir` so the
            // helper refuses to follow the link. The operator can remove the
            // link manually and re-run.
            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)
    }
}

/// Compute the bytes a fence-bearing emitter would write to `abs_path`.
///
/// Encodes the three-branch merge strategy described on
/// [`TargetEmitter::emit`]:
///   1. file absent → rendered bytes verbatim.
///   2. rendered content carries no fenced regions → rendered bytes verbatim.
///   3. fenced regions present and every key is also present in the existing
///      file → batch `replace_regions` of the existing file (preserves
///      non-fenced bytes the user owns).
///   4. fenced regions present but at least one key is missing on disk →
///      `tracing::warn!` and overwrite verbatim. This is the "fail loud, do
///      not silently drop content" branch.
///
/// Both [`TargetEmitter::plan`] and [`TargetEmitter::emit`] call this so the
/// sha256 a plan reports matches the sha256 an emit writes. Round-4
/// bughunt #3.
///
/// # Errors
///
/// Returns `EmitError::Io` if the existing file can't be read, or
/// `EmitError::Fence` if either the rendered content or the existing file
/// has unparseable fences.
fn merge_for_emit(
    rel_path: &Utf8PathBuf,
    rendered: Vec<u8>,
    abs_path: &std::path::Path,
) -> Result<Vec<u8>, EmitError> {
    // Non-UTF-8 emitter output is a bug, not a recoverable situation; fail
    // loudly instead of silently corrupting with `from_utf8_lossy`.
    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);
    }

    // All new keys exist in existing — do a batch fence replace.
    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 {
    // Per-emitter tests live in their own modules and in tests/.
}