memlay 0.1.4

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! `.memlay/config.toml` parsing and defaults (PRD ยง19).

use crate::errors::{err, ErrorCode};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub schema_version: u32,
    pub record_format: String,
    pub team: TeamConfig,
    pub index: IndexConfig,
    pub retrieval: RetrievalConfig,
    pub memory: MemoryConfig,
    pub archive: ArchiveConfig,
    pub backfill: BackfillConfig,
    pub stats: StatsConfig,
    pub output: OutputConfig,
    pub audit: AuditConfig,
    pub redaction: RedactionConfig,
    pub integrations: IntegrationsConfig,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            schema_version: 1,
            record_format: "mrf1".into(),
            team: TeamConfig::default(),
            index: IndexConfig::default(),
            retrieval: RetrievalConfig::default(),
            memory: MemoryConfig::default(),
            archive: ArchiveConfig::default(),
            backfill: BackfillConfig::default(),
            stats: StatsConfig::default(),
            output: OutputConfig::default(),
            audit: AuditConfig::default(),
            redaction: RedactionConfig::default(),
            integrations: IntegrationsConfig::default(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TeamConfig {
    pub baseline_ref: String,
    pub remote: String,
    pub require_current_for_context: bool,
    pub max_fetch_age_seconds: u64,
    pub require_change_record_in_ci: bool,
    pub auto_fetch_on_mcp_start: bool,
    pub auto_fetch_interval_seconds: u64,
}

impl Default for TeamConfig {
    fn default() -> Self {
        Self {
            baseline_ref: "refs/remotes/origin/main".into(),
            remote: "origin".into(),
            require_current_for_context: false,
            max_fetch_age_seconds: 86_400,
            require_change_record_in_ci: true,
            auto_fetch_on_mcp_start: true,
            auto_fetch_interval_seconds: 900,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IndexConfig {
    pub watch: bool,
    pub roots: Vec<String>,
    pub max_file_bytes: u64,
    pub include_untracked: bool,
    pub respect_gitignore: bool,
    pub languages: Vec<String>,
    pub max_files_warning: u64,
}

impl Default for IndexConfig {
    fn default() -> Self {
        Self {
            watch: true,
            roots: vec![".".into()],
            max_file_bytes: 2_000_000,
            include_untracked: true,
            respect_gitignore: true,
            languages: vec![
                "typescript".into(),
                "javascript".into(),
                "python".into(),
                "rust".into(),
                "go".into(),
                "lua".into(),
            ],
            max_files_warning: 100_000,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RetrievalConfig {
    pub default_token_budget: u32,
    pub max_token_budget: u32,
    pub graph_hops: u32,
    pub max_results_per_type: u32,
    pub semantic_fallback: bool,
}

impl Default for RetrievalConfig {
    fn default() -> Self {
        Self {
            default_token_budget: 1_000,
            max_token_budget: 8_000,
            graph_hops: 1,
            max_results_per_type: 8,
            semantic_fallback: false,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryConfig {
    pub summary_max_chars: u32,
    pub details_max_chars: u32,
    pub strict_immutability: bool,
    pub canonical_format: String,
    pub duplicate_key_warn_score: f64,
    pub duplicate_key_block_score: f64,
    pub max_records_per_session_warning: u32,
    pub max_records_per_session_without_justification: u32,
}

impl Default for MemoryConfig {
    fn default() -> Self {
        Self {
            summary_max_chars: 500,
            details_max_chars: 12_000,
            strict_immutability: true,
            canonical_format: "mly".into(),
            duplicate_key_warn_score: 0.72,
            duplicate_key_block_score: 0.88,
            max_records_per_session_warning: 5,
            max_records_per_session_without_justification: 10,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ArchiveConfig {
    /// Reserved P1 capability; P0 still validates committed `.mle` metadata.
    pub enabled: bool,
    pub remote: String,
    pub auto_fetch_details: bool,
    pub retain_epoch_summaries: bool,
}

impl Default for ArchiveConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            remote: "origin".into(),
            auto_fetch_details: false,
            retain_epoch_summaries: true,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BackfillConfig {
    pub include_docs_by_default: bool,
    pub max_candidates_per_cluster: u32,
    pub providers: BackfillProviders,
}

impl Default for BackfillConfig {
    fn default() -> Self {
        Self {
            include_docs_by_default: false,
            max_candidates_per_cluster: 5,
            providers: BackfillProviders::default(),
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BackfillProviders {
    pub github: ProviderToggle,
    pub llm: LlmProvider,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProviderToggle {
    /// P1; invokes authenticated local tooling only when explicitly enabled.
    pub enabled: bool,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LlmProvider {
    /// P1; explicit provider/configuration required.
    pub enabled: bool,
    pub send_source_bodies: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StatsConfig {
    pub enabled: bool,
    pub retain_days: u32,
    pub store_task_text: bool,
}

impl Default for StatsConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            retain_days: 180,
            store_task_text: false,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct OutputConfig {
    pub default_context_format: String,
}

impl Default for OutputConfig {
    fn default() -> Self {
        Self {
            default_context_format: "mcf".into(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AuditConfig {
    pub level: String,
    pub write_git_refs: bool,
    pub remote: String,
    pub auto_push: bool,
    pub max_event_bytes: u64,
}

impl Default for AuditConfig {
    fn default() -> Self {
        Self {
            level: "standard".into(),
            write_git_refs: true,
            remote: "origin".into(),
            auto_push: false,
            max_event_bytes: 262_144,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RedactionConfig {
    pub enabled: bool,
    pub patterns: Vec<String>,
}

impl Default for RedactionConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            patterns: vec![],
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IntegrationsConfig {
    pub codex: bool,
    pub claude: bool,
}

impl Default for IntegrationsConfig {
    fn default() -> Self {
        Self {
            codex: true,
            claude: true,
        }
    }
}

pub const CONFIG_REL_PATH: &str = ".memlay/config.toml";

impl Config {
    pub fn load(repo_root: &Path) -> Result<Config> {
        let path = repo_root.join(CONFIG_REL_PATH);
        if !path.exists() {
            return Err(err(
                ErrorCode::NotInitialized,
                format!("{CONFIG_REL_PATH} not found; run 'memlay init' first"),
            ));
        }
        let text = std::fs::read_to_string(&path).map_err(|e| {
            err(
                ErrorCode::InvalidConfig,
                format!("cannot read {CONFIG_REL_PATH}: {e}"),
            )
        })?;
        let cfg: Config = toml::from_str(&text)
            .map_err(|e| err(ErrorCode::InvalidConfig, format!("{CONFIG_REL_PATH}: {e}")))?;
        cfg.validate()?;
        Ok(cfg)
    }

    pub fn validate(&self) -> Result<()> {
        if self.schema_version != 1 {
            return Err(err(
                ErrorCode::InvalidConfig,
                format!(
                    "unsupported schema_version {}; this build supports 1",
                    self.schema_version
                ),
            ));
        }
        if self.record_format != "mrf1" {
            return Err(err(
                ErrorCode::InvalidConfig,
                format!(
                    "unsupported record_format '{}'; this build supports 'mrf1'",
                    self.record_format
                ),
            ));
        }
        if !(0.0..=1.0).contains(&self.memory.duplicate_key_warn_score)
            || !(0.0..=1.0).contains(&self.memory.duplicate_key_block_score)
        {
            return Err(err(
                ErrorCode::InvalidConfig,
                "memory.duplicate_key_*_score values must be between 0.0 and 1.0",
            ));
        }
        if self.retrieval.default_token_budget < 200 {
            return Err(err(
                ErrorCode::InvalidConfig,
                "retrieval.default_token_budget must be at least 200",
            ));
        }
        Ok(())
    }

    /// The documented default config file content written by `memlay init`.
    pub fn default_toml() -> String {
        let mut out = String::from(
            "# Memlay project configuration. Committed alongside the code it describes.\n# All values shown are the documented defaults (PRD section 19).\n\n",
        );
        out.push_str(
            &toml::to_string_pretty(&Config::default()).expect("default config serializes"),
        );
        out
    }
}

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

    #[test]
    fn default_round_trips() {
        let text = Config::default_toml();
        let cfg: Config = toml::from_str(&text).unwrap();
        cfg.validate().unwrap();
        assert_eq!(cfg.team.baseline_ref, "refs/remotes/origin/main");
        assert!(!cfg.archive.enabled);
    }

    #[test]
    fn unknown_field_rejected_with_context() {
        let e = toml::from_str::<Config>("nonsense = 1\n")
            .unwrap_err()
            .to_string();
        assert!(e.contains("nonsense"), "{e}");
    }

    #[test]
    fn invalid_scores_rejected() {
        let mut cfg = Config::default();
        cfg.memory.duplicate_key_block_score = 1.5;
        assert!(cfg.validate().is_err());
    }
}