difflore-core 0.3.0

Core library for the difflore CLI — rule store, retrieval, MCP server, hooks, cloud sync. Not intended for direct use; depend on `difflore-cli` instead.
//! Static rule export: project the repo-scoped rule set into an
//! AUTO-GENERATED marker block inside agent context files (`AGENTS.md`,
//! `CLAUDE.md`).
//!
//! The export is a point-in-time *projection* of the live corpus — it goes
//! stale as rules evolve and it cannot match rules to the file being edited.
//! Every generated block therefore carries an upgrade hint pointing at
//! `difflore agents install` (live, diff-aware injection via MCP/hooks).
//!
//! Split by concern:
//!   - [`collect`] — which rules participate (repo-scope predicate shared with
//!     `recall`, per-format engine gate, `--local-only` source filter).
//!   - [`writeback`] — the marker-block upsert engine (idempotent, atomic,
//!     user content outside the markers is never touched).
//!
//! Rendering of a single rule lives in
//! [`crate::context::rule_render::render_rule_export`] so export, recall, and
//! the MCP serve path keep one "← learned from" provenance grammar.

pub mod collect;
pub mod writeback;

pub use collect::{
    ExportCollectOptions, ExportCollection, ExportRule, collect_rules_for_export,
    collect_rules_for_export_with_scopes, is_explicit_local_rule, repo_scope_matches,
};
pub use writeback::{
    BEGIN_MARKER, END_MARKER, MarkerBlockWrite, WriteAction, WriteOutcome, has_marker_block,
    upsert_marker_block,
};

use crate::context::rule_render::{RuleExportRenderInput, render_rule_export};

/// Header metadata stamped into the generated block. `generated_at_utc` is
/// deliberately *excluded* from the content hash so an unchanged corpus
/// re-exported later short-circuits to `Unchanged` instead of churning the
/// file with a new timestamp.
pub struct ExportBlockMeta<'a> {
    /// CLI binary version (`difflore export vX.Y.Z`).
    pub tool_version: &'a str,
    /// RFC3339 UTC timestamp of this export run.
    pub generated_at_utc: &'a str,
    pub rule_count: usize,
    /// Repo scopes the export was filtered to (detected git remotes).
    pub repo_scopes: &'a [String],
    /// True when team/cloud-synced rules were excluded (`--local-only`).
    pub local_only: bool,
}

/// Stable 12-hex-char SHA-1 of the rendered rules body. Embedded in the block
/// header and used by [`writeback::upsert_marker_block`] to skip rewrites when
/// nothing changed.
#[must_use]
pub fn export_content_hash(body: &str) -> String {
    use sha1::{Digest, Sha1};
    let mut hasher = Sha1::new();
    hasher.update(body.as_bytes());
    let digest = hasher.finalize();
    let mut hex = String::with_capacity(12);
    for byte in digest.iter().take(6) {
        hex.push_str(&format!("{byte:02x}"));
    }
    hex
}

/// Render the rules body for one export target: every rule through the shared
/// [`render_rule_export`] template, separated by `---` rules. No rank scores —
/// a static file has no query to rank against.
#[must_use]
pub fn render_export_body(rules: &[ExportRule]) -> String {
    if rules.is_empty() {
        return "_No DiffLore rules are in scope for this repo yet. Run `difflore import-reviews` to capture review memory._\n".to_owned();
    }
    let blocks: Vec<String> = rules
        .iter()
        .map(|rule| {
            render_rule_export(&RuleExportRenderInput {
                name: &rule.name,
                repo_scope: rule.repo_scope.as_deref(),
                description: &rule.description,
                check_prompt: rule.check_prompt.as_deref(),
                examples: (!rule.examples.is_empty()).then_some(rule.examples.as_slice()),
            })
        })
        .collect();
    blocks.join("\n---\n\n")
}

/// Assemble the full marker-delimited block (BEGIN..END inclusive, `\n` line
/// endings) from header metadata and a pre-rendered rules body.
#[must_use]
pub fn build_export_block(meta: &ExportBlockMeta<'_>, body: &str) -> String {
    let scope_label = if meta.repo_scopes.is_empty() {
        "(no git remote detected)".to_owned()
    } else {
        meta.repo_scopes.join(", ")
    };
    let mode_segment = if meta.local_only {
        " | mode: local-only"
    } else {
        ""
    };
    let hash = export_content_hash(body);
    format!(
        "{begin}\n\
         <!-- AUTO-GENERATED by difflore export v{version} - do not edit between the BEGIN/END DIFFLORE RULES markers; the next export overwrites this section. -->\n\
         <!-- generated-at: {generated_at} | rules: {count} | content-hash: {hash} | repo-scope: {scope_label}{mode_segment} -->\n\
         <!-- Static snapshot: it goes stale as team rules evolve. Run `difflore agents install` for live diff-aware rule injection. -->\n\
         \n\
         # DiffLore team rules\n\
         \n\
         {body}\n\
         {end}",
        begin = BEGIN_MARKER,
        version = meta.tool_version,
        generated_at = meta.generated_at_utc,
        count = meta.rule_count,
        end = END_MARKER,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    fn meta<'a>(generated_at: &'a str, scopes: &'a [String]) -> ExportBlockMeta<'a> {
        ExportBlockMeta {
            tool_version: "9.9.9",
            generated_at_utc: generated_at,
            rule_count: 2,
            repo_scopes: scopes,
            local_only: false,
        }
    }

    #[test]
    fn content_hash_is_stable_and_body_sensitive() {
        assert_eq!(export_content_hash("body"), export_content_hash("body"));
        assert_ne!(export_content_hash("body"), export_content_hash("body2"));
        assert_eq!(export_content_hash("body").len(), 12);
    }

    #[test]
    fn block_embeds_header_fields_and_markers() {
        let scopes = vec!["acme/widgets".to_owned()];
        let block = build_export_block(&meta("2026-06-11T00:00:00Z", &scopes), "RULES BODY");
        assert!(block.starts_with(BEGIN_MARKER));
        assert!(block.ends_with(END_MARKER));
        assert!(block.contains("AUTO-GENERATED by difflore export v9.9.9"));
        assert!(block.contains("generated-at: 2026-06-11T00:00:00Z"));
        assert!(block.contains("rules: 2"));
        assert!(block.contains(&format!(
            "content-hash: {}",
            export_content_hash("RULES BODY")
        )));
        assert!(block.contains("repo-scope: acme/widgets"));
        assert!(block.contains("difflore agents install"));
        assert!(block.contains("RULES BODY"));
    }

    #[test]
    fn block_hash_ignores_generated_at_churn() {
        // The embedded hash covers the rules body only, so a re-export of an
        // unchanged corpus keeps the same hash and short-circuits the write.
        let scopes = vec!["acme/widgets".to_owned()];
        let a = build_export_block(&meta("2026-06-11T00:00:00Z", &scopes), "BODY");
        let b = build_export_block(&meta("2026-06-12T13:37:00Z", &scopes), "BODY");
        let hash = format!("content-hash: {}", export_content_hash("BODY"));
        assert!(a.contains(&hash));
        assert!(b.contains(&hash));
    }

    #[test]
    fn block_marks_local_only_mode_and_missing_remote() {
        let block = build_export_block(
            &ExportBlockMeta {
                tool_version: "1.0.0",
                generated_at_utc: "2026-06-11T00:00:00Z",
                rule_count: 0,
                repo_scopes: &[],
                local_only: true,
            },
            "BODY",
        );
        assert!(block.contains("mode: local-only"));
        assert!(block.contains("repo-scope: (no git remote detected)"));
    }

    #[test]
    fn empty_body_explains_and_points_at_import() {
        let body = render_export_body(&[]);
        assert!(body.contains("difflore import-reviews"));
    }
}