memlay 0.1.2

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! Authoritative team memory: baseline revisions, the three memory layers,
//! and sync state (PRD §9).

use crate::config::Config;
use crate::gitx::{Repo, EMPTY_TREE_OID};
use crate::records::store::LoadedRecord;
use anyhow::Result;
use std::collections::HashSet;

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Layer {
    Team,
    Branch,
    Working,
}

impl Layer {
    pub fn as_str(self) -> &'static str {
        match self {
            Layer::Team => "team",
            Layer::Branch => "branch",
            Layer::Working => "working",
        }
    }
}

/// `Behind`/`Ahead`/`Diverged` are part of the stable response contract
/// (PRD §9.3); they are emitted once ahead/behind derivation against the
/// fetched remote lands with the P1 freshness follow-up.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SyncState {
    Current,
    Behind,
    Ahead,
    Diverged,
    OfflineUnknown,
    Unconfigured,
}

impl SyncState {
    pub fn as_str(self) -> &'static str {
        match self {
            SyncState::Current => "current",
            SyncState::Behind => "behind",
            SyncState::Ahead => "ahead",
            SyncState::Diverged => "diverged",
            SyncState::OfflineUnknown => "offline-unknown",
            SyncState::Unconfigured => "unconfigured",
        }
    }
}

/// Revision metadata included in every context response (PRD §9.3).
#[derive(Debug, Clone, serde::Serialize)]
pub struct Revisions {
    pub baseline_ref: String,
    pub team_memory_revision: String,
    pub local_head: Option<String>,
    pub branch_memory_revision: String,
    pub working_memory_revision: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_fetch_at: Option<String>,
    pub sync_state: SyncState,
    pub shared_memory_exact: bool,
    pub memlay_version: String,
    pub record_format_version: u32,
}

/// Which layer each record belongs to, keyed by repository-relative path.
#[derive(Debug, Default)]
pub struct LayerMap {
    team_paths: HashSet<String>,
    branch_paths: HashSet<String>,
    head_paths: HashSet<String>,
}

impl LayerMap {
    /// Record paths in the baseline but absent from the current HEAD tree —
    /// shared memory this branch has not merged yet.
    pub fn baseline_only_count(&self) -> usize {
        self.team_paths
            .iter()
            .filter(|p| !self.head_paths.contains(*p))
            .count()
    }

    /// Record paths committed on this branch but absent from the baseline.
    pub fn branch_only_count(&self) -> usize {
        self.branch_paths.len()
    }
}

impl LayerMap {
    pub fn layer_of(&self, rel_path: &str) -> Layer {
        if self.team_paths.contains(rel_path) {
            Layer::Team
        } else if self.branch_paths.contains(rel_path) {
            Layer::Branch
        } else {
            Layer::Working
        }
    }
}

fn tree_paths(repo: &Repo, at_ref: &str) -> Vec<String> {
    repo.run(&[
        "ls-tree",
        "-r",
        "--name-only",
        at_ref,
        "--",
        ".memlay/records",
    ])
    .map(|out| out.lines().map(|l| l.to_string()).collect())
    .unwrap_or_default()
}

/// Compute the layer of every record path visible in the checkout (PRD §9.2):
/// team = visible in the baseline ref; branch = committed on the current
/// branch but absent from the baseline; working = uncommitted.
pub fn layer_map(repo: &Repo, cfg: &Config) -> LayerMap {
    let mut map = LayerMap::default();
    let baseline = &cfg.team.baseline_ref;
    if repo.ref_exists(baseline) {
        map.team_paths = tree_paths(repo, baseline).into_iter().collect();
    }
    if repo.head_oid().is_some() {
        map.head_paths = tree_paths(repo, "HEAD").into_iter().collect();
        map.branch_paths = map
            .head_paths
            .iter()
            .filter(|p| !map.team_paths.contains(*p))
            .cloned()
            .collect();
    }
    map
}

/// Derived hash over the uncommitted (working-layer) record contents:
/// blake3 of sorted `(path, content-hash)` pairs (PRD §9.3).
pub fn working_memory_revision(
    repo_root: &std::path::Path,
    records: &[LoadedRecord],
    layers: &LayerMap,
) -> String {
    let mut entries: Vec<(String, String)> = records
        .iter()
        .filter(|r| layers.layer_of(&r.rel_path) == Layer::Working)
        .map(|r| {
            let abs = repo_root.join(r.rel_path.replace('/', std::path::MAIN_SEPARATOR_STR));
            let content_hash = std::fs::read(&abs)
                .map(|b| blake3::hash(&b).to_hex().to_string())
                .unwrap_or_default();
            (r.rel_path.clone(), content_hash)
        })
        .collect();
    if entries.is_empty() {
        return "empty".to_string();
    }
    entries.sort();
    let mut hasher = blake3::Hasher::new();
    for (path, hash) in entries {
        hasher.update(path.as_bytes());
        hasher.update(b"\0");
        hasher.update(hash.as_bytes());
        hasher.update(b"\n");
    }
    hasher.finalize().to_hex().to_string()[..16].to_string()
}

/// Read the fetch timestamp recorded by `memlay sync` (or fall back to git's
/// FETCH_HEAD mtime when present).
pub fn last_fetch_at(repo: &Repo) -> Option<String> {
    let state = repo.state_dir().join("state.json");
    if let Ok(text) = std::fs::read_to_string(&state) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
            if let Some(t) = v.get("last_fetch_at").and_then(|t| t.as_str()) {
                return Some(t.to_string());
            }
        }
    }
    let fetch_head = repo.git_dir.join("FETCH_HEAD");
    let meta = std::fs::metadata(fetch_head).ok()?;
    let modified: chrono::DateTime<chrono::Utc> = meta.modified().ok()?.into();
    Some(modified.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
}

pub fn compute_revisions(
    repo: &Repo,
    cfg: &Config,
    records: &[LoadedRecord],
    layers: &LayerMap,
) -> Result<Revisions> {
    let baseline = cfg.team.baseline_ref.clone();
    let baseline_resolves = repo.ref_exists(&baseline);
    let team_memory_revision = if baseline_resolves {
        repo.records_tree_oid(&baseline)?
    } else {
        EMPTY_TREE_OID.to_string()
    };
    let branch_memory_revision = if repo.head_oid().is_some() {
        repo.records_tree_oid("HEAD")
            .unwrap_or_else(|_| EMPTY_TREE_OID.to_string())
    } else {
        EMPTY_TREE_OID.to_string()
    };
    let last_fetch = last_fetch_at(repo);

    let fetch_fresh = last_fetch
        .as_deref()
        .and_then(|f| chrono::DateTime::parse_from_rfc3339(f).ok())
        .map(|t| {
            let age = chrono::Utc::now().signed_duration_since(t.with_timezone(&chrono::Utc));
            age.num_seconds() >= 0 && (age.num_seconds() as u64) <= cfg.team.max_fetch_age_seconds
        })
        .unwrap_or(false);

    // behind/ahead/diverged compare this checkout's committed records against
    // the baseline tree (PRD §9.3); offline-unknown means the trees agree but
    // remote freshness cannot be verified.
    let behind = layers.baseline_only_count() > 0;
    let ahead = layers.branch_only_count() > 0;
    let sync_state = if !baseline_resolves {
        SyncState::Unconfigured
    } else if behind && ahead {
        SyncState::Diverged
    } else if behind {
        SyncState::Behind
    } else if ahead {
        SyncState::Ahead
    } else if fetch_fresh {
        SyncState::Current
    } else {
        SyncState::OfflineUnknown
    };

    Ok(Revisions {
        baseline_ref: baseline,
        team_memory_revision,
        local_head: repo.head_oid(),
        branch_memory_revision,
        working_memory_revision: working_memory_revision(&repo.root, records, layers),
        last_fetch_at: last_fetch,
        sync_state,
        shared_memory_exact: sync_state == SyncState::Current,
        memlay_version: env!("CARGO_PKG_VERSION").to_string(),
        record_format_version: crate::records::RECORD_FORMAT_VERSION,
    })
}