1use crate::ai::prompts;
6use crate::ai::responses::{
7 self, DuplicatesResponse, EvaluationResponse, GenerateChildrenResponse,
8 ImproveDescriptionResponse, SuggestRelationshipsResponse,
9};
10use crate::models::{Requirement, RequirementsStore};
11use std::path::PathBuf;
12use std::process::Command;
13use thiserror::Error;
14use uuid::Uuid;
15
16#[derive(Error, Debug)]
18pub enum AiError {
19 #[error("Claude CLI not found at {0}")]
20 CliNotFound(PathBuf),
21
22 #[error("Claude CLI execution failed: {0}")]
23 CliExecFailed(String),
24
25 #[error("API key missing")]
26 ApiKeyMissing,
27
28 #[error("API request failed: {0}")]
29 ApiRequestFailed(String),
30
31 #[error("Invalid response from AI: {0}")]
32 InvalidResponse(String),
33
34 #[error("Rate limited - please wait before retrying")]
35 RateLimited,
36
37 #[error("Context too large for AI model")]
38 ContextTooLarge,
39
40 #[error("Requirement not found: {0}")]
41 RequirementNotFound(Uuid),
42
43 #[error("AI integration not available")]
44 NotAvailable,
45}
46
47#[derive(Debug, Clone)]
49pub enum AiMode {
50 ClaudeCli { path: PathBuf },
52 DirectApi { api_key: String },
54 Disabled,
56}
57
58impl Default for AiMode {
59 fn default() -> Self {
60 AiMode::Disabled
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct AiClient {
67 mode: AiMode,
68}
69
70impl Default for AiClient {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl AiClient {
77 pub fn new() -> Self {
79 let mode = Self::detect_mode();
80 Self { mode }
81 }
82
83 pub fn with_mode(mode: AiMode) -> Self {
85 Self { mode }
86 }
87
88 fn detect_mode() -> AiMode {
90 if let Some(path) = Self::find_claude_cli() {
92 return AiMode::ClaudeCli { path };
93 }
94
95 if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
97 if !api_key.is_empty() {
98 return AiMode::DirectApi { api_key };
99 }
100 }
101
102 AiMode::Disabled
103 }
104
105 fn find_claude_cli() -> Option<PathBuf> {
107 let candidates = [
109 "claude",
111 "/usr/local/bin/claude",
113 "/usr/bin/claude",
114 ];
115
116 if let Ok(output) = Command::new("which").arg("claude").output() {
118 if output.status.success() {
119 let path_str = String::from_utf8_lossy(&output.stdout);
120 let path = PathBuf::from(path_str.trim());
121 if path.exists() {
122 return Some(path);
123 }
124 }
125 }
126
127 for candidate in candidates {
129 let path = PathBuf::from(candidate);
130 if path.exists() {
131 return Some(path);
132 }
133 }
134
135 if let Ok(home) = std::env::var("HOME") {
137 let npm_global = PathBuf::from(home).join(".npm-global/bin/claude");
138 if npm_global.exists() {
139 return Some(npm_global);
140 }
141 }
142
143 None
144 }
145
146 pub fn is_available(&self) -> bool {
148 match &self.mode {
149 AiMode::ClaudeCli { path } => path.exists(),
150 AiMode::DirectApi { api_key } => !api_key.is_empty(),
151 AiMode::Disabled => false,
152 }
153 }
154
155 pub fn mode(&self) -> &AiMode {
157 &self.mode
158 }
159
160 pub fn mode_description(&self) -> String {
162 match &self.mode {
163 AiMode::ClaudeCli { path } => format!("Claude CLI ({})", path.display()),
164 AiMode::DirectApi { .. } => "Direct API".to_string(),
165 AiMode::Disabled => "Disabled".to_string(),
166 }
167 }
168
169 pub fn evaluate_requirement(
171 &self,
172 req: &Requirement,
173 store: &RequirementsStore,
174 ) -> Result<EvaluationResponse, AiError> {
175 let prompt = prompts::build_evaluation_prompt(req, store);
176 let response = self.send_request(&prompt)?;
177 responses::parse_evaluation_response(&response)
178 }
179
180 pub fn find_duplicates(
182 &self,
183 req: &Requirement,
184 store: &RequirementsStore,
185 ) -> Result<DuplicatesResponse, AiError> {
186 let prompt = prompts::build_duplicates_prompt(req, store);
187 let response = self.send_request(&prompt)?;
188 responses::parse_duplicates_response(&response)
189 }
190
191 pub fn suggest_relationships(
193 &self,
194 req: &Requirement,
195 store: &RequirementsStore,
196 ) -> Result<SuggestRelationshipsResponse, AiError> {
197 let prompt = prompts::build_relationships_prompt(req, store);
198 let response = self.send_request(&prompt)?;
199 responses::parse_relationships_response(&response)
200 }
201
202 pub fn improve_description(
204 &self,
205 req: &Requirement,
206 store: &RequirementsStore,
207 ) -> Result<ImproveDescriptionResponse, AiError> {
208 let prompt = prompts::build_improve_prompt(req, store);
209 let response = self.send_request(&prompt)?;
210 responses::parse_improve_response(&response)
211 }
212
213 pub fn generate_children(
215 &self,
216 req: &Requirement,
217 store: &RequirementsStore,
218 ) -> Result<GenerateChildrenResponse, AiError> {
219 let prompt = prompts::build_generate_children_prompt(req, store);
220 let response = self.send_request(&prompt)?;
221 responses::parse_generate_children_response(&response)
222 }
223
224 fn send_request(&self, prompt: &str) -> Result<String, AiError> {
226 match &self.mode {
227 AiMode::ClaudeCli { path } => self.send_cli_request(path, prompt),
228 AiMode::DirectApi { api_key: _ } => {
229 Err(AiError::NotAvailable)
231 }
232 AiMode::Disabled => Err(AiError::NotAvailable),
233 }
234 }
235
236 fn send_cli_request(&self, cli_path: &PathBuf, prompt: &str) -> Result<String, AiError> {
238 let output = Command::new(cli_path)
241 .arg("--print")
242 .arg("-p")
243 .arg(prompt)
244 .output()
245 .map_err(|e| AiError::CliExecFailed(e.to_string()))?;
246
247 if !output.status.success() {
248 let stderr = String::from_utf8_lossy(&output.stderr);
249 return Err(AiError::CliExecFailed(format!(
250 "Exit code: {:?}, stderr: {}",
251 output.status.code(),
252 stderr
253 )));
254 }
255
256 let response = String::from_utf8_lossy(&output.stdout).to_string();
257
258 if response.is_empty() {
259 return Err(AiError::InvalidResponse(
260 "Empty response from CLI".to_string(),
261 ));
262 }
263
264 Ok(response)
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn test_mode_detection() {
274 let client = AiClient::new();
275 let _ = client.is_available();
277 let _ = client.mode_description();
278 }
279
280 #[test]
281 fn test_disabled_mode() {
282 let client = AiClient::with_mode(AiMode::Disabled);
283 assert!(!client.is_available());
284 assert_eq!(client.mode_description(), "Disabled");
285 }
286}