Skip to main content

double_o/
learn.rs

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;
9/// Kept for callers of the published `double_o` crate; new code uses `learn_status`.
10pub use crate::learn_status::learn_status_path;
11use crate::learn_url::ApiUrl;
12
13// ---------------------------------------------------------------------------
14// Config & Validation limits
15// ---------------------------------------------------------------------------
16
17/// Maximum allowed length for hint text to prevent payload bloat.
18const MAX_HINT_LENGTH: usize = 1000;
19
20/// Maximum allowed length for command text used in LLM prompt.
21const MAX_COMMAND_LENGTH: usize = 100;
22
23/// Maximum allowed length for a single filename component after sanitization.
24const MAX_FILENAME_COMPONENT: usize = 50;
25
26#[derive(Deserialize)]
27struct ConfigFile {
28    learn: Option<LearnConfig>,
29}
30
31/// Configuration for the `oo learn` LLM integration.
32///
33/// Specifies the LLM provider, model, and environment variable for the API key.
34#[derive(Deserialize, Clone)]
35pub struct LearnConfig {
36    /// LLM provider name (currently only "anthropic" is supported).
37    pub provider: String,
38
39    /// Model identifier (e.g., "claude-haiku-4-5").
40    pub model: String,
41
42    /// Environment variable containing the API key.
43    pub api_key_env: String,
44}
45
46/// Testable variant of learn config and paths — avoids env var mutation.
47pub(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/// Typed struct for the JSON data passed to the background learn process.
57#[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
71// Auto-detect provider from available API keys (checked in priority order).
72fn detect_provider() -> LearnConfig {
73    detect_provider_with(|key| std::env::var(key).ok())
74}
75
76// Testable variant — accepts a closure for env lookup to avoid env mutation in tests.
77fn 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        // Default to anthropic; will fail at runtime if no key is set.
86        LearnConfig {
87            provider: "anthropic".into(),
88            model: "claude-haiku-4-5".into(),
89            api_key_env: "ANTHROPIC_API_KEY".into(),
90        }
91    }
92}
93
94/// Get the directory containing user-defined patterns.
95///
96/// Returns `<config dir>/oo/patterns` (`~/.config/oo/patterns` on Linux,
97/// `~/Library/Application Support/oo/patterns` on macOS), or `OO_CONFIG_DIR/patterns`
98/// when `OO_CONFIG_DIR` is set to an absolute path (other values are ignored with a warning).
99pub fn patterns_dir() -> PathBuf {
100    config_dir().join("patterns")
101}
102
103/// Load learn configuration from `config.toml` in the config directory
104/// (see `patterns_dir` for the platform paths).
105///
106/// Returns the default configuration if the file doesn't exist.
107pub 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
119// ---------------------------------------------------------------------------
120// Background learning
121/// Run the learn flow with explicit config and base URL — testable variant.
122///
123/// This internal function bypasses `load_learn_config()` and env var lookup,
124/// making it suitable for testing without environment mutation.
125pub(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, &params.config.model, msg),
160            other => Err(Error::Learn(format!("unknown provider: {other}"))),
161        }
162    };
163
164    // First attempt
165    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    // Up to 2 retries
183    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
208/// Run the learn flow: call LLM, validate + save pattern.
209///
210/// Loads configuration from environment, calls the LLM to generate a pattern,
211/// validates the result, and saves the pattern to disk. Retries up to 2 times
212/// if the LLM returns invalid TOML.
213pub 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
223/// Internal variant of run_learn that accepts an optional hint.
224///
225/// Used by run_background to pass through agent-provided hints.
226fn 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(&params, command, output, exit_code)
256}
257
258/// Spawn the learning process in the background by re-exec'ing ourselves.
259pub 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    // A random tempfile name avoids PID-based predictable filenames
268    // (symlink/TOCTOU attacks).
269    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    // Kept on disk (not deleted by the TempPath drop) until the child spawns;
279    // `keep()` below stops the parent deleting it.
280    let tmp_path =
281        create_payload(&dir, &data.to_string()).map_err(|e| Error::Learn(e.to_string()))?;
282
283    // Spawn detached child
284    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    // Prevent the parent from deleting the temp file on drop. On a loaded
295    // system the child process may not have opened the file yet by the time
296    // the parent exits this function. `keep()` makes the file persist on disk
297    // until the child cleans it up in `run_background` (below).
298    tmp_path.keep().map_err(|e| Error::Learn(e.to_string()))?;
299
300    Ok(())
301}
302
303/// Entry point for the background learn child process.
304///
305/// `payload_dir` is the directory the parent created the payload in; the name
306/// check uses it rather than this process's environment.
307///
308/// `data_path` is the payload file path (a [`Path`], so a non-UTF-8 TMPDIR
309/// works fully); see issue #213.
310pub fn run_background(
311    data_path: &Path,
312    payload_dir: &Path,
313    status_path: &Path,
314) -> Result<(), Error> {
315    let path = data_path;
316    // argv is untrusted: only a payload-shaped name directly in `payload_dir` is
317    // read or removed, which rejects paths like /dev/zero. It does not reject a
318    // FIFO with such a name, which would block the read. The payload can hold
319    // command output (possibly secret), so it is removed right after the read.
320    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    // Every failure below reaches the status log, labelled with the command once
332    // it is known; "learn" covers a payload that could not be parsed.
333    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        // Explicit bounds check to avoid silent truncation i64→i32
339        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    // Silent when the status cannot be written: the detached child has no terminal.
366    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
377// ---------------------------------------------------------------------------
378// LLM API calls
379// ---------------------------------------------------------------------------
380
381fn 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    // Configure Agent with explicit timeout to prevent hanging on API calls.
398    // Redirects are refused: ureq would replay x-api-key to the Location target.
399    // max_redirects(0) makes ureq return a 3xx as an ordinary response, so the status check below refuses it.
400    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
433// ---------------------------------------------------------------------------
434// Helpers
435// ---------------------------------------------------------------------------
436
437fn 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    // Sanitize first word: keep only ASCII alphanumeric and hyphens,
447    // prevent path traversal, dotfiles, special chars, overlong filenames.
448    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    // Include the second word only when it is a subcommand (not a flag).
459    match words.next() {
460        Some(second) if !second.starts_with('-') => {
461            // Sanitize: keep only ASCII alphanumeric and hyphens to ensure
462            // the label is safe as a filename component.
463            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
482/// Validate a TOML pattern string using the same regex limits as TOML loading.
483///
484/// Uses `validate_pattern_regexes` from pattern::toml module for consistency.
485fn 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// Tests live in separate files to keep this module under 500 lines.
491#[cfg(test)]
492#[path = "learn_tests.rs"]
493mod tests;
494
495#[cfg(test)]
496#[path = "learn_prompt_tests.rs"]
497mod prompt_tests;