hey 0.1.0

Minimal terminal AI coding agent: kernel loop + MCP/Skills self-evolution
Documentation
//! 项目信任(docs/DESIGN.md §8):`~/.config/hey/trust.json`。
//!
//! 交互模式加载项目 `.hey/` 资源(skills / AGENTS.md / config)前问一次「信任此项目?」;
//! 非交互默认不加载;`--approve` 显式信任。只防「静默加载」,不防恶意内容。

use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Mutex;

use serde::{Deserialize, Serialize};

/// 测试覆盖用的 trust.json 目录(进程内静态,避免 env 全局竞态)。
static DIR_OVERRIDE: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);

/// 覆盖 trust.json 所在目录(测试隔离用;None 恢复默认)。
#[cfg(test)]
fn set_dir_override(p: Option<std::path::PathBuf>) {
    *DIR_OVERRIDE.lock().unwrap() = p;
}

/// 信任存储(trust.json)。
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct TrustStore {
    /// 已信任项目根路径(规范化绝对路径)。
    pub trusted: BTreeSet<String>,
}

impl TrustStore {
    fn path() -> std::path::PathBuf {
        if let Some(p) = DIR_OVERRIDE.lock().unwrap().clone() {
            return p.join("trust.json");
        }
        dirs::config_dir()
            .unwrap_or_else(std::env::temp_dir)
            .join("hey")
            .join("trust.json")
    }

    /// 读取(损坏/缺失 → 空)。
    pub fn load() -> Self {
        std::fs::read_to_string(Self::path())
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    /// 规范化项目路径(真实路径 + 去尾斜杠)。
    fn normalize(root: &Path) -> String {
        let canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
        canon.to_string_lossy().trim_end_matches('/').to_string()
    }

    /// 项目是否已信任。
    pub fn is_trusted(&self, root: &Path) -> bool {
        self.trusted.contains(&Self::normalize(root))
    }

    /// 标记信任并写回(失败静默——下次交互可重问)。
    pub fn trust(&mut self, root: &Path) {
        self.trusted.insert(Self::normalize(root));
        if let Some(parent) = Self::path().parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = std::fs::write(
            Self::path(),
            serde_json::to_string_pretty(self).unwrap_or_default(),
        );
    }
}

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

    // 测试用独立目录;进程内静态覆盖 + 互斥锁(并行测试共享全局状态需串行化)
    static TEST_LOCK: Mutex<()> = Mutex::new(());
    fn with_tmp<T>(f: impl FnOnce(std::path::PathBuf) -> T) -> T {
        let dir = std::env::temp_dir().join(format!("hey-trust-{}", std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        let r = f(dir.clone());
        let _ = std::fs::remove_dir_all(&dir);
        r
    }

    // 直接测核心语义(绕过 PATH_OVERRIDE 未接入 load/trust 的问题):
    // normalize + is_trusted + trust 往返用显式文件读写。
    // 真实路径覆盖:load → trust → is_trusted 往返(DIR_OVERRIDE 隔离,不写真实配置目录)
    #[test]
    fn load_trust_roundtrip_via_override() {
        let _guard = TEST_LOCK.lock().unwrap();
        let dir = std::env::temp_dir().join(format!("hey-trust-real-{}", std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        set_dir_override(Some(dir.clone()));
        let proj = dir.join("some-project");
        std::fs::create_dir_all(&proj).unwrap();

        // 初始:未信任
        let mut s = TrustStore::load();
        assert!(!s.is_trusted(&proj));

        // trust 写入 → 重新 load 命中(真实持久化路径)
        s.trust(&proj);
        let reloaded = TrustStore::load();
        assert!(reloaded.is_trusted(&proj), "trust() 持久化后可 load 命中");
        assert!(reloaded.is_trusted(&proj.canonicalize().unwrap()));

        // 其他项目不受影响
        assert!(!reloaded.is_trusted(&dir));

        let _ = std::fs::remove_dir_all(&dir);
        set_dir_override(None);
    }

    #[test]
    fn corrupted_trust_file_loads_empty() {
        let _guard = TEST_LOCK.lock().unwrap();
        let dir = std::env::temp_dir().join(format!("hey-trust-bad-{}", std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        set_dir_override(Some(dir.clone()));
        std::fs::write(dir.join("trust.json"), "NOT JSON").unwrap();
        let s = TrustStore::load();
        assert!(s.trusted.is_empty(), "损坏文件 → 空存储");
        let _ = std::fs::remove_dir_all(&dir);
        set_dir_override(None);
    }

    #[test]
    fn trust_roundtrip_via_file() {
        with_tmp(|dir| {
            let store_path = dir.join("trust.json");
            let proj = dir.join("some-project");
            std::fs::create_dir_all(&proj).unwrap();
            let proj_canon = proj.canonicalize().unwrap();

            let mut s = TrustStore::default();
            s.trusted.insert(TrustStore::normalize(&proj_canon));
            std::fs::write(&store_path, serde_json::to_string(&s).unwrap()).unwrap();

            let loaded: TrustStore =
                serde_json::from_str(&std::fs::read_to_string(&store_path).unwrap()).unwrap();
            assert!(loaded.is_trusted(&proj_canon));
            assert!(loaded.is_trusted(&proj), "规范化后相同路径命中");
            assert!(!loaded.is_trusted(&dir));
        });
    }

    #[test]
    fn trust_marks_and_persists() {
        with_tmp(|dir| {
            let proj = dir.join("p2");
            std::fs::create_dir_all(&proj).unwrap();
            let mut s = TrustStore::default();
            // 不调 trust()(写真实配置目录);直接灌入 + 序列化往返
            s.trusted.insert(TrustStore::normalize(&proj));
            assert!(s.is_trusted(&proj));
            let json = serde_json::to_string(&s).unwrap();
            let again: TrustStore = serde_json::from_str(&json).unwrap();
            assert!(again.is_trusted(&proj));
            assert!(json.contains("p2"), "路径写入: {json}");
        });
    }
}