1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5use crate::config::config_dir;
6use crate::error::Error;
7use crate::learn_payload::{create_payload, is_payload_shaped, payload_dir, remove_payload};
8pub use crate::learn_prompt::SYSTEM_PROMPT;
9pub use crate::learn_status::learn_status_path;
11use crate::learn_url::ApiUrl;
12
13const MAX_HINT_LENGTH: usize = 1000;
19
20const MAX_COMMAND_LENGTH: usize = 100;
22
23const MAX_FILENAME_COMPONENT: usize = 50;
25
26#[derive(Deserialize)]
27struct ConfigFile {
28 learn: Option<LearnConfig>,
29}
30
31#[derive(Deserialize, Clone)]
35pub struct LearnConfig {
36 pub provider: String,
38
39 pub model: String,
41
42 pub api_key_env: String,
44}
45
46pub(crate) struct LearnParams<'a> {
48 pub config: &'a LearnConfig,
49 pub api_key: &'a str,
50 pub base_url: &'a str,
51 pub patterns_dir: &'a Path,
52 pub learn_status_path: &'a Path,
53 pub hint: Option<&'a str>,
54}
55
56#[derive(Deserialize)]
58struct LearnData {
59 command: String,
60 output: String,
61 exit_code: i64,
62 hint: Option<String>,
63}
64
65impl Default for LearnConfig {
66 fn default() -> Self {
67 detect_provider()
68 }
69}
70
71fn detect_provider() -> LearnConfig {
73 detect_provider_with(|key| std::env::var(key).ok())
74}
75
76fn detect_provider_with<F: Fn(&str) -> Option<String>>(env_lookup: F) -> LearnConfig {
78 if env_lookup("ANTHROPIC_API_KEY").is_some() {
79 LearnConfig {
80 provider: "anthropic".into(),
81 model: "claude-haiku-4-5".into(),
82 api_key_env: "ANTHROPIC_API_KEY".into(),
83 }
84 } else {
85 LearnConfig {
87 provider: "anthropic".into(),
88 model: "claude-haiku-4-5".into(),
89 api_key_env: "ANTHROPIC_API_KEY".into(),
90 }
91 }
92}
93
94pub fn patterns_dir() -> PathBuf {
100 config_dir().join("patterns")
101}
102
103pub fn load_learn_config() -> Result<LearnConfig, Error> {
108 let path = config_dir().join("config.toml");
109 if !path.exists() {
110 return Ok(LearnConfig::default());
111 }
112 let content = std::fs::read_to_string(&path)
113 .map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
114 let cf: ConfigFile =
115 toml::from_str(&content).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
116 Ok(cf.learn.unwrap_or_default())
117}
118
119pub(crate) fn run_learn_with_config(
126 params: &LearnParams,
127 command: &str,
128 output: &str,
129 exit_code: i32,
130) -> Result<(), Error> {
131 let hint = match params.hint {
132 Some(h) if h.len() > MAX_HINT_LENGTH => {
133 return Err(Error::Learn(format!(
134 "--hint too long ({} > {} chars)",
135 h.len(),
136 MAX_HINT_LENGTH
137 )));
138 }
139 h => h,
140 };
141
142 let api_url = ApiUrl::parse(params.base_url)?;
143 let truncated_command = crate::learn_utils::truncate_utf8(command, MAX_COMMAND_LENGTH);
144
145 let user_msg = if let Some(h) = hint {
146 format!(
147 "Command: {truncated_command}\nExit code: {exit_code}\nHint: {h}\nOutput:\n{}",
148 truncate_for_prompt(output)
149 )
150 } else {
151 format!(
152 "Command: {truncated_command}\nExit code: {exit_code}\nOutput:\n{}",
153 truncate_for_prompt(output)
154 )
155 };
156
157 let get_response = |msg: &str| -> Result<String, Error> {
158 match params.config.provider.as_str() {
159 "anthropic" => call_anthropic(&api_url, params.api_key, ¶ms.config.model, msg),
160 other => Err(Error::Learn(format!("unknown provider: {other}"))),
161 }
162 };
163
164 let mut last_err;
166 let toml = get_response(&user_msg)?;
167 let clean = crate::learn_utils::strip_fences(&toml);
168 if validate_pattern_toml_with_limits(&clean).is_ok() {
169 std::fs::create_dir_all(params.patterns_dir).map_err(|e| Error::Learn(e.to_string()))?;
170 let filename = format!("{}.toml", label(command));
171 let path = params.patterns_dir.join(&filename);
172 std::fs::write(&path, &clean).map_err(|e| Error::Learn(e.to_string()))?;
173 let _ = crate::learn_status::write_learn_status(
174 params.learn_status_path,
175 &label(command),
176 &path,
177 );
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::learn_status::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(
215 command,
216 output,
217 exit_code,
218 None,
219 &crate::learn_status::learn_status_path(),
220 )
221}
222
223fn run_learn_with_hint(
227 command: &str,
228 output: &str,
229 exit_code: i32,
230 hint: Option<&str>,
231 status_path: &Path,
232) -> Result<(), Error> {
233 let config = load_learn_config()?;
234
235 let api_key = crate::api_key::non_empty_key(std::env::var(&config.api_key_env).ok())
236 .ok_or_else(|| {
237 Error::Learn(format!(
238 "Set {} environment variable to use `oo learn`",
239 config.api_key_env
240 ))
241 })?;
242
243 let base_url = std::env::var("ANTHROPIC_API_URL")
244 .unwrap_or_else(|_| "https://api.anthropic.com/v1/messages".to_string());
245
246 let params = LearnParams {
247 config: &config,
248 api_key: &api_key,
249 base_url: &base_url,
250 patterns_dir: &patterns_dir(),
251 learn_status_path: status_path,
252 hint,
253 };
254
255 run_learn_with_config(¶ms, command, output, exit_code)
256}
257
258pub fn spawn_background(
260 command: &str,
261 output: &str,
262 exit_code: i32,
263 hint: Option<&str>,
264) -> Result<(), Error> {
265 let exe = std::env::current_exe().map_err(|e| Error::Learn(e.to_string()))?;
266
267 let dir = payload_dir();
270 let mut data = serde_json::json!({
271 "command": command,
272 "output": output,
273 "exit_code": exit_code,
274 });
275 if let Some(h) = hint {
276 data["hint"] = serde_json::Value::String(h.to_string());
277 }
278 let tmp_path =
281 create_payload(&dir, &data.to_string()).map_err(|e| Error::Learn(e.to_string()))?;
282
283 std::process::Command::new(exe)
285 .arg("_learn_bg")
286 .arg(&tmp_path)
287 .arg(&dir)
288 .stdin(std::process::Stdio::null())
289 .stdout(std::process::Stdio::null())
290 .stderr(std::process::Stdio::null())
291 .spawn()
292 .map_err(|e| Error::Learn(e.to_string()))?;
293
294 tmp_path.keep().map_err(|e| Error::Learn(e.to_string()))?;
299
300 Ok(())
301}
302
303pub fn run_background(
311 data_path: &Path,
312 payload_dir: &Path,
313 status_path: &Path,
314) -> Result<(), Error> {
315 let path = data_path;
316 let shaped = is_payload_shaped(path, payload_dir);
321 let read = if shaped {
322 std::fs::read_to_string(path).map_err(|e| Error::Learn(e.to_string()))
323 } else {
324 Err(Error::Learn(format!(
325 "not a learn payload: {}",
326 path.display()
327 )))
328 };
329 let removal = shaped.then(|| remove_payload(path).err()).flatten();
330
331 let mut cmd_label = String::from("learn");
334 let result = read.and_then(|content| {
335 let data: LearnData =
336 serde_json::from_str(&content).map_err(|e| Error::Learn(e.to_string()))?;
337 cmd_label = label(&data.command);
338 let exit_code = i32::try_from(data.exit_code).map_err(|_| {
340 Error::Learn(format!(
341 "exit_code out of range for i32: {}",
342 data.exit_code
343 ))
344 })?;
345 run_learn_with_hint(
346 &data.command,
347 &data.output,
348 exit_code,
349 data.hint.as_deref(),
350 status_path,
351 )
352 });
353
354 let result = match removal {
355 None => result,
356 Some(e) => {
357 let msg = format!("could not remove payload {}: {e}", path.display());
358 Err(Error::Learn(match result {
359 Ok(()) => msg,
360 Err(learn_err) => format!("{msg}; {learn_err}"),
361 }))
362 }
363 };
364
365 if let Err(ref e) = result {
367 let _ = crate::learn_status::write_learn_status_failure(
368 status_path,
369 &cmd_label,
370 &e.to_string(),
371 );
372 }
373
374 result
375}
376
377fn call_anthropic(
382 base_url: &ApiUrl,
383 api_key: &str,
384 model: &str,
385 user_msg: &str,
386) -> Result<String, Error> {
387 let body = serde_json::json!({
388 "model": model,
389 "max_tokens": 1024,
390 "temperature": 0.0,
391 "system": SYSTEM_PROMPT,
392 "messages": [{"role": "user", "content": user_msg}],
393 });
394
395 use std::time::Duration;
396
397 let agent: ureq::Agent = ureq::Agent::config_builder()
401 .timeout_global(Some(Duration::from_secs(30)))
402 .timeout_connect(Some(Duration::from_secs(10)))
403 .max_redirects(0)
404 .build()
405 .into();
406
407 let mut response = agent
408 .post(base_url.uri().clone())
409 .header("x-api-key", api_key)
410 .header("anthropic-version", "2023-06-01")
411 .header("content-type", "application/json")
412 .send_json(&body)
413 .map_err(|e| Error::Learn(format!("Anthropic API error: {e}")))?;
414
415 let status = response.status().as_u16();
416 if (300..400).contains(&status) {
417 return Err(Error::Learn(format!(
418 "unexpected redirect (status {status}): redirects are not followed; ANTHROPIC_API_URL must point directly at the API endpoint"
419 )));
420 }
421
422 let response: serde_json::Value = response
423 .body_mut()
424 .read_json()
425 .map_err(|e| Error::Learn(format!("response parse error: {e}")))?;
426
427 response["content"][0]["text"]
428 .as_str()
429 .map(|s| s.to_string())
430 .ok_or_else(|| Error::Learn("unexpected Anthropic response format".into()))
431}
432
433fn label(command: &str) -> String {
438 let mut words = command.split_whitespace();
439 let first = words
440 .next()
441 .unwrap_or("unknown")
442 .rsplit('/')
443 .next()
444 .unwrap_or("unknown");
445
446 let sanitized_first: String = first
449 .chars()
450 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
451 .take(MAX_FILENAME_COMPONENT)
452 .collect();
453
454 if sanitized_first.is_empty() {
455 return "unknown".to_string();
456 }
457
458 match words.next() {
460 Some(second) if !second.starts_with('-') => {
461 let sanitized_second: String = second
464 .chars()
465 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
466 .take(MAX_FILENAME_COMPONENT)
467 .collect();
468 if sanitized_second.is_empty() {
469 sanitized_first
470 } else {
471 format!("{sanitized_first}-{sanitized_second}")
472 }
473 }
474 _ => sanitized_first,
475 }
476}
477
478fn truncate_for_prompt(output: &str) -> &str {
479 crate::learn_utils::truncate_utf8(output, 4000)
480}
481
482fn validate_pattern_toml_with_limits(toml_str: &str) -> Result<(), Error> {
486 crate::pattern::validate_pattern_regexes(toml_str)
487 .map_err(|e| Error::Learn(format!("pattern validation: {e}")))
488}
489
490#[cfg(test)]
492#[path = "learn_tests.rs"]
493mod tests;
494
495#[cfg(test)]
496#[path = "learn_prompt_tests.rs"]
497mod prompt_tests;