memlay 0.1.3

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! `memlay sync`: fetch the configured team baseline and refresh baseline
//! memory knowledge without ever touching the checked-out branch (PRD ยง9.4).

use crate::cli::App;
use crate::errors::{err, ErrorCode};
use crate::index::Index;
use anyhow::Result;
use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SyncState {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_fetch_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub indexed_team_revision: Option<String>,
}

pub fn read_state(app: &App) -> SyncState {
    let path = app.repo.state_dir().join("state.json");
    std::fs::read_to_string(path)
        .ok()
        .and_then(|t| serde_json::from_str(&t).ok())
        .unwrap_or_default()
}

pub fn write_state(app: &App, state: &SyncState) -> Result<()> {
    let dir = app.repo.state_dir();
    std::fs::create_dir_all(&dir)?;
    std::fs::write(dir.join("state.json"), serde_json::to_string_pretty(state)?)?;
    Ok(())
}

/// Derive `(remote, branch)` fetch arguments from a baseline ref like
/// `refs/remotes/origin/main`.
fn fetch_args(baseline_ref: &str, remote: &str) -> Option<(String, String)> {
    let rest = baseline_ref.strip_prefix("refs/remotes/")?;
    let (r, branch) = rest.split_once('/')?;
    Some((
        if r.is_empty() {
            remote.to_string()
        } else {
            r.to_string()
        },
        branch.to_string(),
    ))
}

pub fn sync(app: &App, check_only: bool, all: bool, offline: bool) -> Result<()> {
    let baseline = app.config.team.baseline_ref.clone();
    let mut state = read_state(app);

    if check_only {
        return sync_check(app, &state);
    }

    let old_revision = state.indexed_team_revision.clone();

    if !offline {
        let fetched = if all {
            app.repo.run(&["fetch", "--all", "--quiet"]).map(|_| true)
        } else if let Some((remote, branch)) = fetch_args(&baseline, &app.config.team.remote) {
            app.repo
                .run(&["fetch", "--quiet", &remote, &branch])
                .map(|_| true)
        } else {
            // Baseline is a local ref (e.g. refs/heads/main); nothing to fetch.
            Ok(false)
        };
        match fetched {
            Ok(true) => {
                state.last_fetch_at =
                    Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
            }
            Ok(false) => {}
            Err(e) => {
                if !app.json {
                    eprintln!("warning: fetch failed ({e}); continuing with last known baseline");
                }
            }
        }
    }

    let new_revision = if app.repo.ref_exists(&baseline) {
        app.repo.records_tree_oid(&baseline)?
    } else {
        return Err(err(
            ErrorCode::TeamMemoryUnknown,
            format!("baseline ref '{baseline}' does not resolve after fetch; check [team] config"),
        ));
    };

    // What changed in shared team memory?
    let mut added: Vec<String> = Vec::new();
    let mut removed: Vec<String> = Vec::new();
    if let Some(old) = &old_revision {
        if old != &new_revision {
            if let Ok(out) =
                app.repo
                    .run(&["diff", "--name-status", old.as_str(), new_revision.as_str()])
            {
                for line in out.lines() {
                    let mut parts = line.split('\t');
                    let status = parts.next().unwrap_or("");
                    let path = parts.next_back().unwrap_or("");
                    match status.chars().next() {
                        Some('A') => added.push(path.to_string()),
                        Some('D') => removed.push(path.to_string()),
                        _ => {}
                    }
                }
            }
        }
    }

    state.indexed_team_revision = Some(new_revision.clone());
    write_state(app, &state)?;

    // Refresh the local index so baseline knowledge is available immediately.
    crate::index::update_all(&app.repo, &app.config)?;

    if app.json {
        println!(
            "{}",
            serde_json::json!({
                "baseline_ref": baseline,
                "previous_team_memory_revision": old_revision,
                "team_memory_revision": new_revision,
                "newly_shared_records": added,
                "removed_records": removed,
                "last_fetch_at": state.last_fetch_at,
            })
        );
    } else {
        match &old_revision {
            Some(old) if *old == new_revision => {
                println!(
                    "Team memory unchanged at {}",
                    &new_revision[..12.min(new_revision.len())]
                );
            }
            Some(old) => {
                println!(
                    "Team memory updated {} -> {}",
                    &old[..12.min(old.len())],
                    &new_revision[..12.min(new_revision.len())]
                );
                if !added.is_empty() {
                    println!("  {} newly shared record(s)", added.len());
                }
                if !removed.is_empty() {
                    println!(
                        "  {} record(s) removed from baseline (archive or history rewrite)",
                        removed.len()
                    );
                }
            }
            None => {
                println!(
                    "Team baseline indexed at {}",
                    &new_revision[..12.min(new_revision.len())]
                );
            }
        }
    }
    Ok(())
}

fn sync_check(app: &App, state: &SyncState) -> Result<()> {
    let baseline = &app.config.team.baseline_ref;
    if !app.repo.ref_exists(baseline) {
        return Err(err(
            ErrorCode::TeamMemoryUnknown,
            format!("baseline ref '{baseline}' does not resolve"),
        ));
    }
    let current = app.repo.records_tree_oid(baseline)?;
    let indexed = state.indexed_team_revision.as_deref();
    if indexed != Some(current.as_str()) {
        return Err(err(
            ErrorCode::TeamMemoryBehind,
            format!(
                "indexed baseline {} does not match current baseline {}; run 'memlay sync'",
                indexed.unwrap_or("(none)"),
                current
            ),
        ));
    }
    if let Some(fetch_at) = &state.last_fetch_at {
        if let Ok(t) = chrono::DateTime::parse_from_rfc3339(fetch_at) {
            let age = chrono::Utc::now().signed_duration_since(t.with_timezone(&chrono::Utc));
            if age.num_seconds() < 0
                || age.num_seconds() as u64 > app.config.team.max_fetch_age_seconds
            {
                return Err(err(
                    ErrorCode::TeamMemoryUnknown,
                    format!(
                        "last fetch at {fetch_at} exceeds max_fetch_age_seconds ({}); run 'memlay sync'",
                        app.config.team.max_fetch_age_seconds
                    ),
                ));
            }
        }
    } else {
        return Err(err(
            ErrorCode::TeamMemoryUnknown,
            "no recorded fetch; run 'memlay sync'",
        ));
    }
    if !app.json {
        println!("baseline current at {}", &current[..12.min(current.len())]);
    }
    Ok(())
}

/// Refresh index and stats for `memlay index update/rebuild/stats`.
pub fn index_update(app: &App) -> Result<Index> {
    crate::index::update_all(&app.repo, &app.config)
}