safe_chains/targets/
opencode.rs1use 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}