Skip to main content

safe_chains/targets/
opencode.rs

1use std::path::{Path, PathBuf};
2
3use serde_json::{Map, Value};
4
5use super::{InstallOutcome, Target};
6
7pub struct OpenCodeTarget;
8
9impl Target for OpenCodeTarget {
10    fn name(&self) -> &'static str {
11        "opencode"
12    }
13
14    fn display_name(&self) -> &'static str {
15        "OpenCode"
16    }
17
18    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
19        vec![home.join(".config").join("opencode")]
20    }
21
22    fn install(&self, _home: &Path) -> Result<InstallOutcome, String> {
23        Ok(InstallOutcome::Skipped {
24            reason: "OpenCode integration generates `opencode.json` to stdout; \
25                     run `safe-chains --opencode-config > opencode.json` from the project root"
26                .to_string(),
27        })
28    }
29}
30
31pub fn render_opencode_json_in(dir: &Path, patterns: &[String]) -> String {
32    let mut root: Map<String, Value> = std::fs::read_to_string(dir.join("opencode.json"))
33        .ok()
34        .and_then(|s| serde_json::from_str(&s).ok())
35        .and_then(|v: Value| v.as_object().cloned())
36        .unwrap_or_else(|| {
37            let mut m = Map::new();
38            m.insert(
39                "$schema".to_string(),
40                Value::String("https://opencode.ai/config.json".to_string()),
41            );
42            m
43        });
44
45    let mut bash = Map::new();
46    bash.insert("*".to_string(), Value::String("ask".to_string()));
47    for pat in patterns {
48        bash.insert(pat.clone(), Value::String("allow".to_string()));
49    }
50
51    let permission = root
52        .entry("permission")
53        .or_insert_with(|| Value::Object(Map::new()));
54    if !permission.is_object() {
55        *permission = Value::Object(Map::new());
56    }
57    if let Value::Object(perm_map) = permission {
58        perm_map.insert("bash".to_string(), Value::Object(bash));
59    }
60
61    let mut out = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default();
62    out.push('\n');
63    out
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn render_emits_valid_json() {
72        let dir = tempfile::tempdir().unwrap();
73        let patterns = vec!["ls".to_string(), "git status".to_string()];
74        let json = render_opencode_json_in(dir.path(), &patterns);
75        let parsed: Value = serde_json::from_str(&json).unwrap();
76        assert_eq!(
77            parsed.pointer("/permission/bash/ls").and_then(|v| v.as_str()),
78            Some("allow"),
79        );
80        assert_eq!(
81            parsed.pointer("/permission/bash/*").and_then(|v| v.as_str()),
82            Some("ask"),
83        );
84    }
85
86    #[test]
87    fn render_includes_schema() {
88        let dir = tempfile::tempdir().unwrap();
89        let json = render_opencode_json_in(dir.path(), &[]);
90        let parsed: Value = serde_json::from_str(&json).unwrap();
91        assert_eq!(
92            parsed.get("$schema").and_then(|v| v.as_str()),
93            Some("https://opencode.ai/config.json"),
94        );
95    }
96
97    #[test]
98    fn render_trailing_newline() {
99        let dir = tempfile::tempdir().unwrap();
100        let json = render_opencode_json_in(dir.path(), &[]);
101        assert!(json.ends_with('\n'));
102    }
103
104    #[test]
105    fn render_merges_existing_config() {
106        let dir = tempfile::tempdir().unwrap();
107        let path = dir.path().join("opencode.json");
108        std::fs::write(
109            &path,
110            r#"{"$schema": "https://opencode.ai/config.json", "model": "sonnet"}"#,
111        )
112        .unwrap();
113        let json = render_opencode_json_in(dir.path(), &["ls".to_string()]);
114        let parsed: Value = serde_json::from_str(&json).unwrap();
115        assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("sonnet"));
116        assert_eq!(
117            parsed.pointer("/permission/bash/ls").and_then(|v| v.as_str()),
118            Some("allow"),
119        );
120    }
121
122    #[test]
123    fn install_returns_skip_with_guidance() {
124        let dir = tempfile::tempdir().unwrap();
125        let outcome = OpenCodeTarget.install(dir.path()).unwrap();
126        match outcome {
127            InstallOutcome::Skipped { reason } => {
128                assert!(reason.contains("opencode.json"));
129            }
130            other => panic!("expected Skipped, got {:?}", std::mem::discriminant(&other)),
131        }
132    }
133}