1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6use crate::config::config_dir;
7use crate::error::Error;
8pub use crate::learn_prompt::SYSTEM_PROMPT;
9
10const MAX_HINT_LENGTH: usize = 1000;
16
17const MAX_COMMAND_LENGTH: usize = 100;
19
20const MAX_FILENAME_COMPONENT: usize = 50;
22
23#[derive(Deserialize)]
24struct ConfigFile {
25 learn: Option<LearnConfig>,
26}
27
28#[derive(Deserialize, Clone)]
32pub struct LearnConfig {
33 pub provider: String,
35
36 pub model: String,
38
39 pub api_key_env: String,
41}
42
43pub(crate) struct LearnParams<'a> {
45 pub config: &'a LearnConfig,
46 pub api_key: &'a str,
47 pub base_url: &'a str,
48 pub patterns_dir: &'a Path,
49 pub learn_status_path: &'a Path,
50 pub hint: Option<&'a str>,
51}
52
53#[derive(Deserialize)]
55struct LearnData {
56 command: String,
57 output: String,
58 exit_code: i64,
59 hint: Option<String>,
60}
61
62impl Default for LearnConfig {
63 fn default() -> Self {
64 detect_provider()
65 }
66}
67
68fn detect_provider() -> LearnConfig {
70 detect_provider_with(|key| std::env::var(key).ok())
71}
72
73fn detect_provider_with<F: Fn(&str) -> Option<String>>(env_lookup: F) -> LearnConfig {
75 if env_lookup("ANTHROPIC_API_KEY").is_some() {
76 LearnConfig {
77 provider: "anthropic".into(),
78 model: "claude-haiku-4-5".into(),
79 api_key_env: "ANTHROPIC_API_KEY".into(),
80 }
81 } else {
82 LearnConfig {
84 provider: "anthropic".into(),
85 model: "claude-haiku-4-5".into(),
86 api_key_env: "ANTHROPIC_API_KEY".into(),
87 }
88 }
89}
90
91pub fn patterns_dir() -> PathBuf {
97 config_dir().join("patterns")
98}
99
100pub fn learn_status_path() -> PathBuf {
102 config_dir().join("learn-status.log")
103}
104
105pub fn load_learn_config() -> Result<LearnConfig, Error> {
110 let path = config_dir().join("config.toml");
111 if !path.exists() {
112 return Ok(LearnConfig::default());
113 }
114 let content = std::fs::read_to_string(&path)
115 .map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
116 let cf: ConfigFile =
117 toml::from_str(&content).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
118 Ok(cf.learn.unwrap_or_default())
119}
120
121pub(crate) fn run_learn_with_config(
128 params: &LearnParams,
129 command: &str,
130 output: &str,
131 exit_code: i32,
132) -> Result<(), Error> {
133 let hint = match params.hint {
134 Some(h) if h.len() > MAX_HINT_LENGTH => {
135 return Err(Error::Learn(format!(
136 "--hint too long ({} > {} chars)",
137 h.len(),
138 MAX_HINT_LENGTH
139 )));
140 }
141 h => h,
142 };
143
144 let truncated_command = crate::learn_utils::truncate_utf8(command, MAX_COMMAND_LENGTH);
145
146 let user_msg = if let Some(h) = hint {
147 format!(
148 "Command: {truncated_command}\nExit code: {exit_code}\nHint: {h}\nOutput:\n{}",
149 truncate_for_prompt(output)
150 )
151 } else {
152 format!(
153 "Command: {truncated_command}\nExit code: {exit_code}\nOutput:\n{}",
154 truncate_for_prompt(output)
155 )
156 };
157
158 let get_response = |msg: &str| -> Result<String, Error> {
159 match params.config.provider.as_str() {
160 "anthropic" => {
161 call_anthropic(params.base_url, params.api_key, ¶ms.config.model, msg)
162 }
163 other => Err(Error::Learn(format!("unknown provider: {other}"))),
164 }
165 };
166
167 let mut last_err;
169 let toml = get_response(&user_msg)?;
170 let clean = crate::learn_utils::strip_fences(&toml);
171 if validate_pattern_toml_with_limits(&clean).is_ok() {
172 std::fs::create_dir_all(params.patterns_dir).map_err(|e| Error::Learn(e.to_string()))?;
173 let filename = format!("{}.toml", label(command));
174 let path = params.patterns_dir.join(&filename);
175 std::fs::write(&path, &clean).map_err(|e| Error::Learn(e.to_string()))?;
176 let _ =
177 crate::commands::write_learn_status(params.learn_status_path, &label(command), &path);
178 return Ok(());
179 }
180 last_err = "initial TOML validation failed".to_string();
181
182 for _ in 0..2 {
184 let retry_msg = format!(
185 "Your previous TOML was invalid: {last_err}. Here is what you returned:\n{clean}\nOutput ONLY the corrected TOML, nothing else."
186 );
187 let toml = get_response(&retry_msg)?;
188 let clean = crate::learn_utils::strip_fences(&toml);
189 if validate_pattern_toml_with_limits(&clean).is_ok() {
190 std::fs::create_dir_all(params.patterns_dir)
191 .map_err(|e| Error::Learn(e.to_string()))?;
192 let filename = format!("{}.toml", label(command));
193 let path = params.patterns_dir.join(&filename);
194 std::fs::write(&path, &clean).map_err(|e| Error::Learn(e.to_string()))?;
195 let _ = crate::commands::write_learn_status(
196 params.learn_status_path,
197 &label(command),
198 &path,
199 );
200 return Ok(());
201 }
202 last_err = "retry TOML validation failed".to_string();
203 }
204
205 Err(Error::Learn(format!("failed after 3 attempts: {last_err}")))
206}
207
208pub fn run_learn(command: &str, output: &str, exit_code: i32) -> Result<(), Error> {
214 run_learn_with_hint(command, output, exit_code, None)
215}
216
217fn run_learn_with_hint(
221 command: &str,
222 output: &str,
223 exit_code: i32,
224 hint: Option<&str>,
225) -> Result<(), Error> {
226 let config = load_learn_config()?;
227
228 let api_key = std::env::var(&config.api_key_env).map_err(|_| {
229 Error::Learn(format!(
230 "Set {} environment variable to use `oo learn`",
231 config.api_key_env
232 ))
233 })?;
234
235 let base_url = std::env::var("ANTHROPIC_API_URL")
236 .unwrap_or_else(|_| "https://api.anthropic.com/v1/messages".to_string());
237
238 validate_anthropic_url(&base_url)?;
239
240 let params = LearnParams {
241 config: &config,
242 api_key: &api_key,
243 base_url: &base_url,
244 patterns_dir: &patterns_dir(),
245 learn_status_path: &learn_status_path(),
246 hint,
247 };
248
249 run_learn_with_config(¶ms, command, output, exit_code)
250}
251
252pub fn spawn_background(
254 command: &str,
255 output: &str,
256 exit_code: i32,
257 hint: Option<&str>,
258) -> Result<(), Error> {
259 let exe = std::env::current_exe().map_err(|e| Error::Learn(e.to_string()))?;
260
261 let mut tmp = tempfile::NamedTempFile::new().map_err(|e| Error::Learn(e.to_string()))?;
264 let mut data = serde_json::json!({
265 "command": command,
266 "output": output,
267 "exit_code": exit_code,
268 });
269 if let Some(h) = hint {
270 data["hint"] = serde_json::Value::String(h.to_string());
271 }
272 tmp.write_all(data.to_string().as_bytes())
273 .map_err(|e| Error::Learn(e.to_string()))?;
274
275 let tmp_path = tmp.into_temp_path();
278
279 std::process::Command::new(exe)
281 .arg("_learn_bg")
282 .arg(&tmp_path)
283 .stdin(std::process::Stdio::null())
284 .stdout(std::process::Stdio::null())
285 .stderr(std::process::Stdio::null())
286 .spawn()
287 .map_err(|e| Error::Learn(e.to_string()))?;
288
289 tmp_path.keep().map_err(|e| Error::Learn(e.to_string()))?;
294
295 Ok(())
296}
297
298pub fn run_background(data_path: &str) -> Result<(), Error> {
300 let path = Path::new(data_path);
301 let content = std::fs::read_to_string(path).map_err(|e| Error::Learn(e.to_string()))?;
302 let data: LearnData =
303 serde_json::from_str(&content).map_err(|e| Error::Learn(e.to_string()))?;
304
305 let command = &data.command;
306 let output = &data.output;
307
308 let exit_code = i32::try_from(data.exit_code).map_err(|_| {
310 Error::Learn(format!(
311 "exit_code out of range for i32: {}",
312 data.exit_code
313 ))
314 })?;
315
316 let hint = data.hint.as_deref();
317
318 let result = run_learn_with_hint(command, output, exit_code, hint);
319
320 let _ = std::fs::remove_file(path);
322
323 if let Err(ref e) = result {
324 let cmd_label = label(command);
325 let status_path = learn_status_path();
326 let _ =
327 crate::commands::write_learn_status_failure(&status_path, &cmd_label, &e.to_string());
328 }
329
330 result
331}
332
333fn call_anthropic(
338 base_url: &str,
339 api_key: &str,
340 model: &str,
341 user_msg: &str,
342) -> Result<String, Error> {
343 let body = serde_json::json!({
344 "model": model,
345 "max_tokens": 1024,
346 "temperature": 0.0,
347 "system": SYSTEM_PROMPT,
348 "messages": [{"role": "user", "content": user_msg}],
349 });
350
351 use std::time::Duration;
352
353 let agent: ureq::Agent = ureq::Agent::config_builder()
355 .timeout_global(Some(Duration::from_secs(30)))
356 .timeout_connect(Some(Duration::from_secs(10)))
357 .build()
358 .into();
359
360 let response: serde_json::Value = agent
361 .post(base_url)
362 .header("x-api-key", api_key)
363 .header("anthropic-version", "2023-06-01")
364 .header("content-type", "application/json")
365 .send_json(&body)
366 .map_err(|e| Error::Learn(format!("Anthropic API error: {e}")))?
367 .body_mut()
368 .read_json()
369 .map_err(|e| Error::Learn(format!("response parse error: {e}")))?;
370
371 response["content"][0]["text"]
372 .as_str()
373 .map(|s| s.to_string())
374 .ok_or_else(|| Error::Learn("unexpected Anthropic response format".into()))
375}
376
377fn label(command: &str) -> String {
382 let mut words = command.split_whitespace();
383 let first = words
384 .next()
385 .unwrap_or("unknown")
386 .rsplit('/')
387 .next()
388 .unwrap_or("unknown");
389
390 let sanitized_first: String = first
393 .chars()
394 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
395 .take(MAX_FILENAME_COMPONENT)
396 .collect();
397
398 if sanitized_first.is_empty() {
399 return "unknown".to_string();
400 }
401
402 match words.next() {
404 Some(second) if !second.starts_with('-') => {
405 let sanitized_second: String = second
408 .chars()
409 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
410 .take(MAX_FILENAME_COMPONENT)
411 .collect();
412 if sanitized_second.is_empty() {
413 sanitized_first
414 } else {
415 format!("{sanitized_first}-{sanitized_second}")
416 }
417 }
418 _ => sanitized_first,
419 }
420}
421
422fn truncate_for_prompt(output: &str) -> &str {
423 crate::learn_utils::truncate_utf8(output, 4000)
424}
425
426fn validate_anthropic_url(url: &str) -> Result<(), Error> {
428 if url.starts_with("https://") {
429 return Ok(());
430 }
431 if let Some(rest) = url.strip_prefix("http://") {
434 let host = rest.split([':', '/']).next().unwrap_or("");
435 if host == "localhost" || host == "127.0.0.1" {
436 return Ok(());
437 }
438 }
439 Err(Error::Learn(format!(
440 "ANTHROPIC_API_URL must use HTTPS (got: {url}). HTTP is only allowed for localhost/127.0.0.1."
441 )))
442}
443
444fn validate_pattern_toml_with_limits(toml_str: &str) -> Result<(), Error> {
448 crate::pattern::validate_pattern_regexes(toml_str)
449 .map_err(|e| Error::Learn(format!("pattern validation: {e}")))
450}
451
452#[cfg(test)]
454#[path = "learn_tests.rs"]
455mod tests;
456
457#[cfg(test)]
458#[path = "learn_prompt_tests.rs"]
459mod prompt_tests;