reliary-agent 0.8.12

Grammar-free symbol intelligence — CLI and MCP server.
/// Configuration cascade: env var > project config > global config > default
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, PartialEq)]
pub enum GateMode {
    Fast,
    Reactive,
    Strict,
}

impl GateMode {
    pub fn as_str(&self) -> &'static str {
        match self {
            GateMode::Fast => "fast",
            GateMode::Reactive => "reactive",
            GateMode::Strict => "strict",
        }
    }

    pub fn from_str(s: &str) -> Self {
        match s.trim().to_lowercase().as_str() {
            "fast" => GateMode::Fast,
            "strict" => GateMode::Strict,
            _ => GateMode::Reactive,
        }
    }
}

/// The layer from which a config value was resolved.
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigSource {
    Env,
    Project,
    Global,
    Default,
}

impl ConfigSource {
    pub fn as_str(&self) -> &'static str {
        match self {
            ConfigSource::Env => "env",
            ConfigSource::Project => "project .reliary/config.json",
            ConfigSource::Global => "global ~/.reliary/config.json",
            ConfigSource::Default => "default",
        }
    }
}

/// A resolved config value with its source.
#[derive(Debug, Clone)]
pub struct ResolvedMode {
    pub value: GateMode,
    pub source: ConfigSource,
}

/// A resolved feature with its source.
#[derive(Debug, Clone)]
pub struct ResolvedFeature {
    pub name: String,
    pub enabled: bool,
    pub source: ConfigSource,
}

const CONFIG_FILENAME: &str = ".reliary/config.json";

pub fn global_config_path() -> PathBuf {
    let home = std::env::var("HOME")
        .ok()
        .map(PathBuf::from)
        .or_else(dirs::home_dir)
        .unwrap_or_else(|| PathBuf::from("."));
    home.join(CONFIG_FILENAME)
}

pub fn project_config_path(workdir: &str) -> PathBuf {
    PathBuf::from(workdir).join(CONFIG_FILENAME)
}

/// Read a config file into a FLAT `dotted.key -> string` map.
///
/// The config format supports both shapes and they must agree:
///   * nested — `{"mode":"fast","features":{"compress":false}}`
///   * flat   — `{"mode":"fast","features.compress":"false"}`
///
/// `set_config` writes the flat form; a hand-written or older file may use the
/// nested form. The old code deserialized into `HashMap<String, String>`, so a
/// nested object made serde fail and `unwrap_or_default()` silently discarded
/// the ENTIRE config — including `mode`. Nested objects are now flattened to
/// dotted keys so every reader sees the same map.
fn read_config_file(path: &PathBuf) -> HashMap<String, String> {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => return HashMap::new(),
    };
    let value: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return HashMap::new(),
    };
    let mut out = HashMap::new();
    flatten_into("", &value, &mut out);
    out
}

/// Flatten a JSON object into dotted keys. Scalars become their string form
/// (booleans/numbers included); nested objects recurse with a `prefix.`.
fn flatten_into(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
    match value {
        serde_json::Value::Object(map) => {
            for (k, v) in map {
                let key = if prefix.is_empty() { k.clone() } else { format!("{}.{}", prefix, k) };
                flatten_into(&key, v, out);
            }
        }
        serde_json::Value::String(s) => { out.insert(prefix.to_string(), s.clone()); }
        serde_json::Value::Bool(b) => { out.insert(prefix.to_string(), b.to_string()); }
        serde_json::Value::Number(n) => { out.insert(prefix.to_string(), n.to_string()); }
        serde_json::Value::Null => { out.insert(prefix.to_string(), String::new()); }
        serde_json::Value::Array(_) => { /* arrays are not a supported config value */ }
    }
}

pub fn write_config_file(path: &Path, config: &HashMap<String, String>) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("Cannot create config dir: {}", e))?;
    }
    let content = serde_json::to_string_pretty(config).map_err(|e| format!("Cannot serialize config: {}", e))?;
    reliary_core::atomic_write(path.to_str().unwrap_or("config.json"), &content)
}

/// Resolve mode with source tracking.
pub fn resolve_mode_with_source(workdir: Option<&str>) -> ResolvedMode {
    if let Ok(env_mode) = std::env::var("RELIARY_MODE") {
        if !env_mode.is_empty() {
            return ResolvedMode {
                value: GateMode::from_str(&env_mode),
                source: ConfigSource::Env,
            };
        }
    }

    if let Some(wd) = workdir {
        let project_cfg = read_config_file(&project_config_path(wd));
        if let Some(mode) = project_cfg.get("mode") {
            return ResolvedMode {
                value: GateMode::from_str(mode),
                source: ConfigSource::Project,
            };
        }
    }

    let global_cfg = read_config_file(&global_config_path());
    if let Some(mode) = global_cfg.get("mode") {
        return ResolvedMode {
            value: GateMode::from_str(mode),
            source: ConfigSource::Global,
        };
    }

    ResolvedMode {
        value: GateMode::Strict,
        source: ConfigSource::Default,
    }
}

/// Resolve mode (simple API, no source tracking).
pub fn resolve_mode(workdir: Option<&str>) -> GateMode {
    resolve_mode_with_source(workdir).value
}

/// Resolve feature flags with source tracking.
/// Single source of truth for feature names and their default state.
/// Bug 38/44: previously duplicated in main.rs valid_keys and gate.js.
pub const FEATURE_DEFAULTS: &[(&str, bool)] = &[
    ("compress", true),
    ("convWindow", true),
    ("readEnrichment", true),
    ("editMerge", false),
    ("healEdit", true),
    ("priorInjection", false),
];

/// Public list of valid config keys (used by CLI validation)
pub const VALID_CONFIG_KEYS: &[&str] = &[
    "mode",
    "features.compress",
    "features.convWindow",
    "features.readEnrichment",
    "features.editMerge",
    "features.healEdit",
    "features.priorInjection",
    "apiMode",
    "privacyMode",
    "apiBaseUrl",
];

pub fn resolve_features_with_source(workdir: Option<&str>) -> Vec<ResolvedFeature> {
    let defaults: Vec<(&str, bool)> = FEATURE_DEFAULTS.to_vec();

    // Parse env var
    let mut env_overrides: HashMap<String, bool> = HashMap::new();
    if let Ok(env_features) = std::env::var("RELIARY_FEATURES") {
        for part in env_features.split(',') {
            let part = part.trim();
            if let Some(feat) = part.strip_prefix('+') {
                env_overrides.insert(feat.to_string(), true);
            } else if let Some(feat) = part.strip_prefix('-') {
                env_overrides.insert(feat.to_string(), false);
            } else if !part.is_empty() {
                env_overrides.insert(part.to_string(), true);
            }
        }
    }

    // Read config files
    let mut project_features: HashMap<String, bool> = HashMap::new();
    let mut global_features: HashMap<String, bool> = HashMap::new();

    if let Some(wd) = workdir {
        let project_cfg = read_config_file(&project_config_path(wd));
        for (key, val) in &project_cfg {
            if let Some(feat) = key.strip_prefix("features.") {
                if let Ok(b) = val.parse::<bool>() {
                    project_features.insert(feat.to_string(), b);
                }
            }
        }
    }
    let global_cfg = read_config_file(&global_config_path());
    for (key, val) in &global_cfg {
        if let Some(feat) = key.strip_prefix("features.") {
            if let Ok(b) = val.parse::<bool>() {
                global_features.insert(feat.to_string(), b);
            }
        }
    }

    // Resolve each feature with source priority
    defaults.into_iter().map(|(name, default_val)| {
        if let Some(val) = env_overrides.get(name) {
            ResolvedFeature { name: name.to_string(), enabled: *val, source: ConfigSource::Env }
        } else if let Some(val) = project_features.get(name) {
            ResolvedFeature { name: name.to_string(), enabled: *val, source: ConfigSource::Project }
        } else if let Some(val) = global_features.get(name) {
            ResolvedFeature { name: name.to_string(), enabled: *val, source: ConfigSource::Global }
        } else {
            ResolvedFeature { name: name.to_string(), enabled: default_val, source: ConfigSource::Default }
        }
    }).collect()
}

/// Resolve features (simple API, no source tracking).
#[allow(dead_code)]
pub fn resolve_features(workdir: Option<&str>) -> Vec<(String, bool)> {
    resolve_features_with_source(workdir)
        .into_iter()
        .map(|f| (f.name, f.enabled))
        .collect()
}

pub fn set_config(key: &str, value: &str, project: bool, root: Option<&str>) -> String {
    // Bug 45: validate values for known keys before storing
    if key.starts_with("features.") && !matches!(value, "true" | "false") {
        return format!("Error: feature value must be 'true' or 'false', got '{}'", value);
    }
    if key == "mode" && !matches!(value, "fast" | "reactive" | "strict") {
        return format!("Error: mode must be 'fast', 'reactive', or 'strict', got '{}'", value);
    }
    let path = if project {
        let base = root.unwrap_or(".");
        project_config_path(base)
    } else {
        global_config_path()
    };
    let mut cfg = read_config_file(&path);
    cfg.insert(key.to_string(), value.to_string());
    match write_config_file(&path, &cfg) {
        Ok(()) => {
            let location = if project { "project" } else { "global" };
            let root_msg = if project && root.is_some() {
                format!(" for {}", root.unwrap_or_default())
            } else {
                String::new()
            };
            format!("Set {} = {} in {}{}", key, value, location, root_msg)
        }
        Err(e) => format!("Error: {}", e),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Mutex, LazyLock};

    /// Serializes config tests because they mutate process-global env vars.
    static CONFIG_TEST_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));

    fn isolated_test<F>(f: F)
    where
        F: FnOnce(),
    {
        use std::sync::atomic::{AtomicU64, Ordering};
        static TEST_CTR: AtomicU64 = AtomicU64::new(0);
        let ctr = TEST_CTR.fetch_add(1, Ordering::Relaxed);
        let _guard = CONFIG_TEST_MUTEX.lock().unwrap(); // GUARDED: intentional - test mutex
        std::env::remove_var("RELIARY_MODE");
        std::env::remove_var("RELIARY_FEATURES");
        let tmp = std::env::temp_dir().join(format!("reliary_config_test_{}_{}", std::process::id(), ctr));
        let _ = std::fs::create_dir_all(&tmp);  // GUARDED: intentional — test code, lock held across I/O
        let _home_guard = crate::init::set_test_home(tmp.clone());
        f();
        let _ = std::fs::remove_dir_all(&tmp);  // GUARDED: intentional — test code, lock held across I/O
    }

    #[test]
    fn test_resolve_mode_default() {
        isolated_test(|| {
            let result = resolve_mode_with_source(Some("/tmp/nonexistent_test_dir_12345"));
            assert_ne!(result.source, ConfigSource::Env);
            // v0.6.8: default is now strict (was reactive)
            assert_eq!(result.value, GateMode::Strict);
        });
    }

    #[test]
    fn test_resolve_mode_env() {
        isolated_test(|| {
            std::env::set_var("RELIARY_MODE", "fast");
            let result = resolve_mode_with_source(None);
            assert_eq!(result.value, GateMode::Fast);
            assert_eq!(result.source, ConfigSource::Env);
        });
    }

    #[test]
    fn test_resolve_features_default() {
        isolated_test(|| {
            let features = resolve_features_with_source(Some("/nonexistent"));
            let compress = features.iter().find(|f| f.name == "compress").unwrap();
            assert!(compress.enabled);
            assert_eq!(compress.source, ConfigSource::Default);

            let edit_merge = features.iter().find(|f| f.name == "editMerge").unwrap();
            assert!(!edit_merge.enabled);
            assert_eq!(edit_merge.source, ConfigSource::Default);
        });
    }

    #[test]
    fn test_resolve_features_env_override() {
        isolated_test(|| {
            std::env::set_var("RELIARY_FEATURES", "-compress,+editMerge");
            let features = resolve_features_with_source(Some("/nonexistent"));
            let compress = features.iter().find(|f| f.name == "compress").unwrap();
            assert!(!compress.enabled);
            assert_eq!(compress.source, ConfigSource::Env);

            let edit_merge = features.iter().find(|f| f.name == "editMerge").unwrap();
            assert!(edit_merge.enabled);
            assert_eq!(edit_merge.source, ConfigSource::Env);
        });
    }
}