crabmate 0.4.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

fn load_cursor_rule_documents(
    cwd: &Path,
    rules_dir: &str,
    include_agents_md: bool,
) -> Result<Vec<(String, String)>, String> {
    let rules_dir = rules_dir.trim();
    if rules_dir.is_empty() {
        return Err("配置错误:cursor_rules_dir 不能为空".to_string());
    }
    let dir_path = {
        let p = Path::new(rules_dir);
        if p.is_absolute() {
            p.to_path_buf()
        } else {
            cwd.join(p)
        }
    };
    if dir_path.exists() && !dir_path.is_dir() {
        return Err(format!(
            "配置错误:cursor_rules_dir \"{}\" 不是目录",
            dir_path.display()
        ));
    }

    let mut files: Vec<PathBuf> = Vec::new();
    if include_agents_md {
        let agents = cwd.join("AGENTS.md");
        if agents.is_file() {
            files.push(agents);
        }
    }
    // 避免 `is_dir` 与 `read_dir` 之间的 TOCTOU:并行测试里可能临时删掉 cwd 下的规则目录。
    let mut mdc_files: Vec<PathBuf> = match std::fs::read_dir(&dir_path) {
        Ok(entries) => entries
            .filter_map(Result::ok)
            .map(|e| e.path())
            .filter(|p| p.is_file())
            .filter(|p| {
                p.extension()
                    .and_then(|s| s.to_str())
                    .is_some_and(|ext| ext.eq_ignore_ascii_case("mdc"))
            })
            .collect(),
        Err(e) if e.kind() == ErrorKind::NotFound => Vec::new(),
        Err(e) => {
            return Err(format!(
                "无法读取 cursor_rules_dir \"{}\": {}",
                dir_path.display(),
                e
            ));
        }
    };
    mdc_files.sort();
    files.extend(mdc_files);

    let mut out: Vec<(String, String)> = Vec::new();
    for path in files {
        let content = std::fs::read_to_string(&path)
            .map_err(|e| format!("无法读取规则文件 \"{}\": {}", path.display(), e))?;
        if content.trim().is_empty() {
            continue;
        }
        let display_path = path
            .strip_prefix(cwd)
            .map(|p| p.display().to_string())
            .unwrap_or_else(|_| path.display().to_string());
        out.push((display_path, content));
    }
    Ok(out)
}

fn render_cursor_rules_appendix(docs: &[(String, String)], max_chars: usize) -> String {
    if docs.is_empty() {
        return String::new();
    }
    let mut body = String::from(
        "【项目规则(Cursor-like)】\n以下内容来自工作区规则文件;若与更高优先级指令冲突,以更高优先级为准。\n",
    );
    for (path, content) in docs {
        body.push_str("\n\n---\n");
        body.push_str(&format!("规则文件: {}\n\n", path));
        body.push_str(content.trim());
    }
    if body.chars().count() <= max_chars {
        return body;
    }
    let mut truncated = crate::cm_config::text_util::truncate_str_to_max_chars(&body, max_chars);
    truncated.push_str(
        "\n\n[提示] 规则内容已按 cursor_rules_max_chars 截断。后续不得假定未出现在本 system 中的规则条文;不得仅凭「应有某规则」下结论。",
    );
    truncated
}

pub(super) fn merge_system_prompt_with_cursor_rules(
    system_prompt: String,
    cursor_rules_enabled: bool,
    cursor_rules_dir: &str,
    cursor_rules_include_agents_md: bool,
    cursor_rules_max_chars: usize,
) -> Result<String, String> {
    if !cursor_rules_enabled {
        return Ok(system_prompt);
    }
    let cwd = std::env::current_dir().map_err(|e| format!("无法获取当前工作目录: {}", e))?;
    let docs = load_cursor_rule_documents(&cwd, cursor_rules_dir, cursor_rules_include_agents_md)?;
    if docs.is_empty() {
        return Ok(system_prompt);
    }
    let appendix = render_cursor_rules_appendix(&docs, cursor_rules_max_chars);
    if appendix.is_empty() {
        return Ok(system_prompt);
    }
    Ok(format!("{}\n\n{}", system_prompt.trim_end(), appendix))
}

#[cfg(test)]
mod tests {
    use super::merge_system_prompt_with_cursor_rules;
    use std::path::{Path, PathBuf};
    use std::sync::Mutex;

    /// `merge_system_prompt_with_cursor_rules` 依赖进程 `cwd`;并行跑测会导致偶发读错目录。
    static CURSOR_RULES_CWD_TEST_MUTEX: Mutex<()> = Mutex::new(());

    struct CwdGuard {
        prev: PathBuf,
    }

    impl CwdGuard {
        fn change_to(path: &Path) -> Self {
            let prev = std::env::current_dir().expect("get cwd");
            std::env::set_current_dir(path).expect("set cwd");
            Self { prev }
        }
    }

    impl Drop for CwdGuard {
        fn drop(&mut self) {
            let _ = std::env::set_current_dir(&self.prev);
        }
    }

    fn temp_workspace(name: &str) -> PathBuf {
        let ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("time")
            .as_nanos();
        let dir = std::env::temp_dir().join(format!(
            "crabmate-cursor-rules-test-{}-{}-{}",
            name,
            std::process::id(),
            ts
        ));
        std::fs::create_dir_all(&dir).expect("mkdir temp");
        dir
    }

    #[test]
    fn merge_system_prompt_appends_agents_and_sorted_rules() {
        let _g = CURSOR_RULES_CWD_TEST_MUTEX
            .lock()
            .expect("cursor_rules cwd test lock");
        let ws = temp_workspace("merge-order");
        std::fs::write(
            ws.join("AGENTS.md"),
            "agents_rule: must follow project instruction",
        )
        .expect("write agents");
        let rules_dir = ws.join(".cursor/rules");
        std::fs::create_dir_all(&rules_dir).expect("mkdir rules");
        std::fs::write(rules_dir.join("b_rule.mdc"), "b-rule").expect("write b");
        std::fs::write(rules_dir.join("a_rule.mdc"), "a-rule").expect("write a");
        let merged = {
            let _cwd = CwdGuard::change_to(&ws);
            merge_system_prompt_with_cursor_rules(
                "BASE_PROMPT".to_string(),
                true,
                ".cursor/rules",
                true,
                20_000,
            )
            .expect("merge ok")
        };

        assert!(merged.starts_with("BASE_PROMPT"));
        let p_agents = merged.find("规则文件: AGENTS.md").expect("agents marker");
        let p_a = merged.find("a_rule.mdc").expect("a marker");
        let p_b = merged.find("b_rule.mdc").expect("b marker");
        assert!(p_agents < p_a);
        assert!(p_a < p_b);
        assert!(merged.contains("agents_rule: must follow project instruction"));
        assert!(merged.contains("a-rule"));
        assert!(merged.contains("b-rule"));

        let _ = std::fs::remove_dir_all(&ws);
    }

    #[test]
    fn merge_system_prompt_truncates_when_over_limit() {
        let _g = CURSOR_RULES_CWD_TEST_MUTEX
            .lock()
            .expect("cursor_rules cwd test lock");
        let ws = temp_workspace("merge-truncate");
        let rules_dir = ws.join(".cursor/rules");
        std::fs::create_dir_all(&rules_dir).expect("mkdir rules");
        std::fs::write(rules_dir.join("rule.mdc"), "x".repeat(4096)).expect("write rule");
        let merged = {
            let _cwd = CwdGuard::change_to(&ws);
            merge_system_prompt_with_cursor_rules(
                "BASE_PROMPT".to_string(),
                true,
                ".cursor/rules",
                false,
                180,
            )
            .expect("merge ok")
        };

        assert!(merged.contains("BASE_PROMPT"));
        assert!(merged.contains("规则内容已按 cursor_rules_max_chars 截断"));
        assert!(merged.len() > "BASE_PROMPT".len());

        let _ = std::fs::remove_dir_all(&ws);
    }
}