1use std::path::Path;
4
5use anyhow::{Context, Result};
6use tracing::{debug, warn};
7
8#[derive(Debug)]
9pub struct ClaudeSetupResult {
10 pub is_complete: bool,
11 pub reason: Option<String>,
12 pub validated_slugs: Vec<String>,
13}
14
15#[derive(Debug)]
16pub struct ClaudeImplResult {
17 pub success: bool,
18 pub exit_code: i32,
19 pub stdout: String,
20 pub stderr: String,
21}
22
23pub async fn mock_agent(repo_dir: &Path) -> Result<String> {
25 let uuid = uuid::Uuid::new_v4().to_string();
26
27 let bullshit_path = repo_dir.join("bullshit.txt");
28 tokio::fs::write(&bullshit_path, &uuid)
29 .await
30 .with_context(|| format!("Failed to write {}", bullshit_path.display()))?;
31
32 warn!(
33 "[DEV MODE] Claude call skipped — wrote UUID to {}",
34 bullshit_path.display()
35 );
36 Ok(uuid)
37}
38
39pub fn resolve_claude_executable(claude_bin: Option<&str>) -> String {
41 claude_bin
42 .map(|s| s.to_string())
43 .or_else(|| std::env::var("ROBSON_CLAUDE_BIN").ok())
44 .unwrap_or_else(|| "claude".to_string())
45}
46
47pub fn compose_setup_prompt(
48 jira_key: &str,
49 summary: &str,
50 description: &str,
51 comments: &[String],
52 available_slugs: &[&str],
53) -> String {
54 let comments_section = if comments.is_empty() {
55 "No comments.".to_string()
56 } else {
57 comments
58 .iter()
59 .enumerate()
60 .map(|(i, c)| format!("Comment {}:\n{}", i + 1, c))
61 .collect::<Vec<_>>()
62 .join("\n\n")
63 };
64
65 let slugs_list = available_slugs.join(", ");
66
67 format!(
68 r#"You are a software project manager reviewing a Jira task.
69
70## Task: {jira_key}
71**Summary:** {summary}
72
73**Description:**
74{description}
75
76**Comments:**
77{comments_section}
78
79## Known Repositories
80The following repository slugs are registered in the system:
81{slugs_list}
82
83## Your Task
841. Determine if this task is **well-defined** (has clear acceptance criteria, scope, and enough detail to implement).
852. Identify which of the known repository slugs are referenced or implied by the task description.
86
87Respond ONLY with a JSON block in the following format:
88
89```json
90{{
91 "is_complete": true,
92 "reason": null,
93 "slugs": ["repo-slug-1", "repo-slug-2"]
94}}
95```
96
97If the task is NOT well-defined, set `"is_complete": false` and explain the issue in `"reason"`. The `"slugs"` array must only contain slugs from the known repositories list above.
98"#,
99 jira_key = jira_key,
100 summary = summary,
101 description = description,
102 comments_section = comments_section,
103 slugs_list = slugs_list,
104 )
105}
106
107pub fn compose_impl_prompt(
108 jira_key: &str,
109 summary: &str,
110 description: &str,
111 comments: &[String],
112 developer_focus: Option<&str>,
113 custom_prompt: Option<&str>,
114) -> String {
115 let mut parts: Vec<String> = Vec::new();
116
117 if let Some(focus) = developer_focus {
118 if !focus.trim().is_empty() {
119 parts.push(format!("## Developer Focus\n{}", focus));
120 }
121 }
122
123 if let Some(prompt) = custom_prompt {
124 if !prompt.trim().is_empty() {
125 parts.push(format!("## Additional Instructions\n{}", prompt));
126 }
127 }
128
129 parts.push(format!(
130 "## Task: {}\n**Summary:** {}\n\n**Description:**\n{}",
131 jira_key, summary, description
132 ));
133
134 if !comments.is_empty() {
135 let comments_text = comments
136 .iter()
137 .enumerate()
138 .map(|(i, c)| format!("Comment {}:\n{}", i + 1, c))
139 .collect::<Vec<_>>()
140 .join("\n\n");
141 parts.push(format!("## Comments\n{}", comments_text));
142 }
143
144 parts.join("\n\n")
145}
146
147pub fn compose_test_correction_prompt() -> String {
148 "Move all tests to the crate's tests/ folder. Tests must never be placed inside src/ files using #[test] or #[cfg(test)]. Create or use the existing tests/ directory at the crate root for all test code.".to_string()
149}
150
151pub fn compose_docker_impl_prompt(
152 jira_key: &str,
153 summary: &str,
154 description: &str,
155 comments: &[String],
156 developer_focus: Option<&str>,
157 custom_prompt: Option<&str>,
158) -> String {
159 let base = compose_impl_prompt(
160 jira_key,
161 summary,
162 description,
163 comments,
164 developer_focus,
165 custom_prompt,
166 );
167 format!(
168 "{}\n\n## Execution Requirements\nRead the `AGENTS.md` file in this repository to understand project conventions, how to run tests, and how to run lint. After implementing the task, run tests and lint as described in `AGENTS.md`. Iterate and fix any failures until all tests and lint checks pass before finishing.",
169 base
170 )
171}
172
173pub fn parse_setup_output(stdout: &str) -> Result<ClaudeSetupResult> {
174 let json_str = extract_json_block(stdout)
175 .ok_or_else(|| anyhow::anyhow!("No JSON block found in Claude output"))?;
176
177 let value: serde_json::Value =
178 serde_json::from_str(json_str).context("Failed to parse JSON from Claude output")?;
179
180 let is_complete = value
181 .get("is_complete")
182 .and_then(|v| v.as_bool())
183 .ok_or_else(|| anyhow::anyhow!("Missing 'is_complete' field in Claude JSON output"))?;
184
185 let reason = value
186 .get("reason")
187 .and_then(|v| v.as_str())
188 .map(|s| s.to_string());
189
190 let validated_slugs = value
191 .get("slugs")
192 .and_then(|v| v.as_array())
193 .map(|arr| {
194 arr.iter()
195 .filter_map(|s| s.as_str().map(|s| s.to_string()))
196 .collect::<Vec<_>>()
197 })
198 .unwrap_or_default();
199
200 Ok(ClaudeSetupResult {
201 is_complete,
202 reason,
203 validated_slugs,
204 })
205}
206
207fn extract_json_block(text: &str) -> Option<&str> {
208 if let Some(start) = text.find("```json") {
209 let after_fence = &text[start + 7..];
210 if let Some(end) = after_fence.find("```") {
211 return Some(after_fence[..end].trim());
212 }
213 }
214 if let Some(start) = text.find('{') {
215 if let Some(end) = text.rfind('}') {
216 if end >= start {
217 return Some(&text[start..=end]);
218 }
219 }
220 }
221 None
222}
223
224pub async fn evaluate_task(
225 prompt: &str,
226 claude_bin: Option<&str>,
227 dev: bool,
228) -> Result<ClaudeSetupResult> {
229 if dev {
230 warn!("[DEV MODE] Claude setup evaluation skipped — returning is_complete: true");
231 return Ok(ClaudeSetupResult {
232 is_complete: true,
233 reason: None,
234 validated_slugs: vec![],
235 });
236 }
237
238 let bin = resolve_claude_executable(claude_bin);
239 let output = tokio::process::Command::new(&bin)
240 .args(["--allowedTools", "", "--print", prompt])
241 .stdout(std::process::Stdio::piped())
242 .stderr(std::process::Stdio::piped())
243 .output()
244 .await
245 .with_context(|| format!("Failed to invoke Claude Code CLI ({})", bin))?;
246
247 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
248 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
249
250 for line in stdout.lines() {
251 if !line.trim().is_empty() {
252 debug!("[claude:setup] {}", line);
253 }
254 }
255
256 if !output.status.success() {
257 anyhow::bail!(
258 "Claude process exited with code {:?}: {}",
259 output.status.code(),
260 stderr.trim()
261 );
262 }
263
264 parse_setup_output(&stdout)
265}
266
267pub async fn implement_in_repo(
271 prompt: &str,
272 working_dir: &Path,
273 dev: bool,
274 claude_bin: Option<&str>,
275) -> Result<ClaudeImplResult> {
276 if dev {
277 let uuid = mock_agent(working_dir).await?;
278 return Ok(ClaudeImplResult {
279 success: true,
280 exit_code: 0,
281 stdout: uuid,
282 stderr: String::new(),
283 });
284 }
285
286 let bin = resolve_claude_executable(claude_bin);
287 let output = tokio::process::Command::new(&bin)
288 .args(["--allowedTools", "Read,Write,Edit", "--print", prompt])
289 .current_dir(working_dir)
290 .stdout(std::process::Stdio::piped())
291 .stderr(std::process::Stdio::piped())
292 .output()
293 .await
294 .with_context(|| {
295 format!(
296 "Failed to invoke Claude Code CLI for implementation ({})",
297 bin
298 )
299 })?;
300
301 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
302 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
303 let exit_code = output.status.code().unwrap_or(-1);
304 let success = output.status.success();
305
306 for line in stdout.lines() {
307 if !line.trim().is_empty() {
308 debug!("[claude:impl] {}", line);
309 }
310 }
311
312 Ok(ClaudeImplResult {
313 success,
314 exit_code,
315 stdout,
316 stderr,
317 })
318}
319
320pub const DEFAULT_CLAUDE_API_BASE: &str = "https://api.anthropic.com";
321
322fn claude_api_base() -> String {
323 std::env::var("ROBSON_CLAUDE_API_BASE").unwrap_or_else(|_| DEFAULT_CLAUDE_API_BASE.to_string())
324}
325
326fn require_api_key() -> Result<String> {
327 std::env::var("ROBSON_CLAUDE_API_KEY")
328 .context("ROBSON_CLAUDE_API_KEY is not set (required for chat)")
329}
330
331pub async fn chat(prompt: &str, system: &str) -> Result<String> {
332 let api_key = require_api_key()?;
333 let api_base = claude_api_base();
334 let model = resolve_claude_executable(None);
335 chat_with_credentials(prompt, system, &model, &api_key, &api_base).await
336}
337
338pub async fn chat_with_credentials(
339 prompt: &str,
340 system: &str,
341 model: &str,
342 api_key: &str,
343 api_base: &str,
344) -> Result<String> {
345 let client = reqwest::Client::new();
346 let url = format!("{}/v1/messages", api_base.trim_end_matches('/'));
347
348 let body = serde_json::json!({
349 "model": model,
350 "max_tokens": 4096,
351 "system": system,
352 "messages": [{"role": "user", "content": prompt}]
353 });
354
355 let resp = client
356 .post(&url)
357 .header("x-api-key", api_key)
358 .header("anthropic-version", "2023-06-01")
359 .json(&body)
360 .send()
361 .await
362 .with_context(|| format!("Failed to call Claude API ({})", url))?;
363
364 let status = resp.status();
365 let body_text = resp
366 .text()
367 .await
368 .context("Failed to read Claude API response body")?;
369
370 if !status.is_success() {
371 anyhow::bail!(
372 "Claude API returned {}: {}",
373 status.as_u16(),
374 body_text.trim()
375 );
376 }
377
378 let value: serde_json::Value =
379 serde_json::from_str(&body_text).context("Claude response was not valid JSON")?;
380
381 let text = value
382 .pointer("/content/0/text")
383 .and_then(|v| v.as_str())
384 .ok_or_else(|| {
385 anyhow::anyhow!("Unexpected Claude API response shape: missing content[0].text")
386 })?
387 .to_string();
388
389 Ok(text)
390}