1use super::{AiBackend, AiEvent, AiRequest, AiResponse};
2use anyhow::{Context, Result};
3use async_trait::async_trait;
4use tokio::process::Command;
5use tokio::sync::mpsc;
6
7const DEFAULT_MODEL: &str = "gpt-4.1";
8
9const ALLOWED_TOOLS: &[&str] = &[
13 "shell(git:diff)",
14 "shell(git:log)",
15 "shell(git:show)",
16 "shell(git:status)",
17 "shell(git:ls-files)",
18 "shell(git:rev-parse)",
19 "shell(git:branch)",
20 "shell(git:cat-file)",
21 "shell(git:rev-list)",
22 "shell(git:shortlog)",
23 "shell(git:blame)",
24];
25
26pub struct CopilotBackend {
27 model: Option<String>,
28 debug: bool,
29}
30
31impl CopilotBackend {
32 pub fn new(model: Option<String>, debug: bool) -> Self {
33 Self { model, debug }
34 }
35}
36
37fn build_system_prompt(base: &str, json_schema: Option<&str>) -> String {
39 match json_schema {
40 Some(schema) => format!(
41 "{base}\n\n\
42 You MUST respond with valid JSON matching this schema:\n\
43 ```json\n{schema}\n```\n\n\
44 Respond ONLY with the JSON object, no markdown fences, no explanation."
45 ),
46 None => base.to_string(),
47 }
48}
49
50#[async_trait]
51impl AiBackend for CopilotBackend {
52 fn name(&self) -> &str {
53 "copilot"
54 }
55
56 async fn is_available(&self) -> bool {
57 Command::new("gh")
58 .args(["copilot", "--version"])
59 .output()
60 .await
61 .is_ok_and(|o| o.status.success())
62 }
63
64 async fn request(
65 &self,
66 req: &AiRequest,
67 _events: Option<mpsc::UnboundedSender<AiEvent>>,
68 ) -> Result<AiResponse> {
69 let model = self.model.as_deref().unwrap_or(DEFAULT_MODEL);
70 let system = build_system_prompt(&req.system_prompt, req.json_schema.as_deref());
71
72 let combined_prompt = format!("{system}\n\n{}", req.user_prompt);
74
75 let mut cmd = Command::new("gh");
76 cmd.current_dir(&req.working_dir)
77 .arg("copilot")
78 .arg("-p")
79 .arg(&combined_prompt)
80 .arg("-s")
81 .arg("--model")
82 .arg(model);
83
84 for tool in ALLOWED_TOOLS {
86 cmd.arg("--allow-tool").arg(tool);
87 }
88
89 cmd.arg("--no-custom-instructions").arg("--autopilot");
90
91 if self.debug {
92 eprintln!("[DEBUG] Calling gh copilot (model={model})");
93 }
94
95 let output = cmd.output().await.context("failed to run gh copilot")?;
96
97 let raw = String::from_utf8_lossy(&output.stdout).to_string();
98 let stderr = String::from_utf8_lossy(&output.stderr);
99
100 if self.debug {
101 eprintln!("[DEBUG] gh copilot exit code: {}", output.status);
102 eprintln!(
103 "[DEBUG] Raw response (first 500 chars): {}",
104 &raw[..raw.len().min(500)]
105 );
106 if !stderr.is_empty() {
107 eprintln!("[DEBUG] Stderr: {stderr}");
108 }
109 }
110
111 if !output.status.success() {
112 anyhow::bail!(crate::error::SrAiError::AiBackend(format!(
113 "gh copilot failed (exit {}): {}",
114 output.status,
115 stderr.trim()
116 )));
117 }
118
119 let text = extract_json(&raw).unwrap_or(raw);
121
122 Ok(AiResponse { text, usage: None })
123 }
124}
125
126pub(crate) fn extract_json(raw: &str) -> Option<String> {
128 let trimmed = raw.trim();
129
130 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
132 return Some(trimmed.to_string());
133 }
134
135 if let Some(start) = trimmed.find("```json") {
137 let after = &trimmed[start + 7..];
138 if let Some(end) = after.find("```") {
139 let json_str = after[..end].trim();
140 if serde_json::from_str::<serde_json::Value>(json_str).is_ok() {
141 return Some(json_str.to_string());
142 }
143 }
144 }
145
146 if let Some(start) = trimmed.find("```") {
148 let after = &trimmed[start + 3..];
149 let after = if let Some(nl) = after.find('\n') {
150 &after[nl + 1..]
151 } else {
152 after
153 };
154 if let Some(end) = after.find("```") {
155 let json_str = after[..end].trim();
156 if serde_json::from_str::<serde_json::Value>(json_str).is_ok() {
157 return Some(json_str.to_string());
158 }
159 }
160 }
161
162 for (open, close) in [("{", "}"), ("[", "]")] {
164 if let Some(start) = trimmed.find(open)
165 && let Some(end) = trimmed.rfind(close)
166 && end > start
167 {
168 let candidate = &trimmed[start..=end];
169 if serde_json::from_str::<serde_json::Value>(candidate).is_ok() {
170 return Some(candidate.to_string());
171 }
172 }
173 }
174
175 None
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
185 fn extract_direct_json() {
186 let input = r#"{"commits": []}"#;
187 assert_eq!(extract_json(input), Some(input.to_string()));
188 }
189
190 #[test]
191 fn extract_from_json_fences() {
192 let input = "Here is the plan:\n```json\n{\"commits\": []}\n```\nDone.";
193 assert_eq!(extract_json(input), Some(r#"{"commits": []}"#.to_string()));
194 }
195
196 #[test]
197 fn extract_from_plain_fences() {
198 let input = "Result:\n```\n{\"commits\": [{\"order\": 1}]}\n```";
199 assert_eq!(
200 extract_json(input),
201 Some(r#"{"commits": [{"order": 1}]}"#.to_string())
202 );
203 }
204
205 #[test]
206 fn extract_from_surrounding_text() {
207 let input = "The result is {\"commits\": []} and that's it.";
208 assert_eq!(extract_json(input), Some(r#"{"commits": []}"#.to_string()));
209 }
210
211 #[test]
212 fn extract_array_json() {
213 let input = "Here: [1, 2, 3] done";
214 assert_eq!(extract_json(input), Some("[1, 2, 3]".to_string()));
215 }
216
217 #[test]
218 fn extract_returns_none_for_invalid() {
219 assert_eq!(extract_json("no json here"), None);
220 assert_eq!(extract_json(""), None);
221 assert_eq!(extract_json("{not valid json}"), None);
222 }
223
224 #[test]
225 fn extract_with_whitespace() {
226 let input = " \n {\"key\": \"value\"} \n ";
227 assert_eq!(extract_json(input), Some(r#"{"key": "value"}"#.to_string()));
228 }
229
230 #[test]
233 fn system_prompt_without_schema() {
234 let result = build_system_prompt("You are a commit assistant.", None);
235 assert_eq!(result, "You are a commit assistant.");
236 }
237
238 #[test]
239 fn system_prompt_with_schema() {
240 let schema = r#"{"type": "object"}"#;
241 let result = build_system_prompt("Base prompt.", Some(schema));
242 assert!(result.starts_with("Base prompt."));
243 assert!(result.contains("You MUST respond with valid JSON"));
244 assert!(result.contains(schema));
245 assert!(result.contains("no markdown fences"));
246 }
247
248 #[test]
251 fn backend_name() {
252 let backend = CopilotBackend::new(None, false);
253 assert_eq!(backend.name(), "copilot");
254 }
255
256 #[test]
257 fn default_model_constant() {
258 assert_eq!(DEFAULT_MODEL, "gpt-4.1");
259 }
260
261 #[test]
264 fn system_prompt_preserves_multiline_base() {
265 let base = "Line one.\nLine two.\nLine three.";
266 let result = build_system_prompt(base, None);
267 assert_eq!(result, base);
268
269 let with_schema = build_system_prompt(base, Some("{}"));
270 assert!(with_schema.starts_with(base));
271 }
272}