Skip to main content

double_o/
learn.rs

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
10// ---------------------------------------------------------------------------
11// Config & Validation limits
12// ---------------------------------------------------------------------------
13
14/// Maximum allowed length for hint text to prevent payload bloat.
15const MAX_HINT_LENGTH: usize = 1000;
16
17/// Maximum allowed length for command text used in LLM prompt.
18const MAX_COMMAND_LENGTH: usize = 100;
19
20/// Maximum allowed length for a single filename component after sanitization.
21const MAX_FILENAME_COMPONENT: usize = 50;
22
23#[derive(Deserialize)]
24struct ConfigFile {
25    learn: Option<LearnConfig>,
26}
27
28/// Configuration for the `oo learn` LLM integration.
29///
30/// Specifies the LLM provider, model, and environment variable for the API key.
31#[derive(Deserialize, Clone)]
32pub struct LearnConfig {
33    /// LLM provider name (currently only "anthropic" is supported).
34    pub provider: String,
35
36    /// Model identifier (e.g., "claude-haiku-4-5").
37    pub model: String,
38
39    /// Environment variable containing the API key.
40    pub api_key_env: String,
41}
42
43/// Testable variant of learn config and paths — avoids env var mutation.
44pub(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/// Typed struct for the JSON data passed to the background learn process.
54#[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
68// Auto-detect provider from available API keys (checked in priority order).
69fn detect_provider() -> LearnConfig {
70    detect_provider_with(|key| std::env::var(key).ok())
71}
72
73// Testable variant — accepts a closure for env lookup to avoid env mutation in tests.
74fn 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        // Default to anthropic; will fail at runtime if no key is set.
83        LearnConfig {
84            provider: "anthropic".into(),
85            model: "claude-haiku-4-5".into(),
86            api_key_env: "ANTHROPIC_API_KEY".into(),
87        }
88    }
89}
90
91/// Get the directory containing user-defined patterns.
92///
93/// Returns `<config dir>/oo/patterns` (`~/.config/oo/patterns` on Linux,
94/// `~/Library/Application Support/oo/patterns` on macOS), or `OO_CONFIG_DIR/patterns`
95/// when `OO_CONFIG_DIR` is set.
96pub fn patterns_dir() -> PathBuf {
97    config_dir().join("patterns")
98}
99
100/// Path to the one-line status file written by the background learn process.
101pub fn learn_status_path() -> PathBuf {
102    config_dir().join("learn-status.log")
103}
104
105/// Load learn configuration from `config.toml` in the config directory
106/// (see `patterns_dir` for the platform paths).
107///
108/// Returns the default configuration if the file doesn't exist.
109pub 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
121// ---------------------------------------------------------------------------
122// Background learning
123/// Run the learn flow with explicit config and base URL — testable variant.
124///
125/// This internal function bypasses `load_learn_config()` and env var lookup,
126/// making it suitable for testing without environment mutation.
127pub(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, &params.config.model, msg)
162            }
163            other => Err(Error::Learn(format!("unknown provider: {other}"))),
164        }
165    };
166
167    // First attempt
168    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    // 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::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
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(command, output, exit_code, None)
215}
216
217/// Internal variant of run_learn that accepts an optional hint.
218///
219/// Used by run_background to pass through agent-provided hints.
220fn 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(&params, command, output, exit_code)
250}
251
252/// Spawn the learning process in the background by re-exec'ing ourselves.
253pub 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    // Use a secure named temp file to avoid PID-based predictable filenames
262    // (symlink/TOCTOU attacks). The file is kept alive until the child spawns.
263    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    // Convert to TempPath: closes the file handle but keeps the file on disk
276    // until the TempPath is dropped — after the child has been spawned.
277    let tmp_path = tmp.into_temp_path();
278
279    // Spawn detached child
280    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    // Prevent the parent from deleting the temp file on drop. On a loaded
290    // system the child process may not have opened the file yet by the time
291    // the parent exits this function. `keep()` makes the file persist on disk
292    // until the child cleans it up in `run_background` (below).
293    tmp_path.keep().map_err(|e| Error::Learn(e.to_string()))?;
294
295    Ok(())
296}
297
298/// Entry point for the background learn child process.
299pub 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    // Explicit bounds check to avoid silent truncation i64→i32
309    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    // Clean up temp file
321    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
333// ---------------------------------------------------------------------------
334// LLM API calls
335// ---------------------------------------------------------------------------
336
337fn 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    // Configure Agent with explicit timeout to prevent hanging on API calls
354    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
377// ---------------------------------------------------------------------------
378// Helpers
379// ---------------------------------------------------------------------------
380
381fn 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    // Sanitize first word: keep only ASCII alphanumeric and hyphens,
391    // prevent path traversal, dotfiles, special chars, overlong filenames.
392    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    // Include the second word only when it is a subcommand (not a flag).
403    match words.next() {
404        Some(second) if !second.starts_with('-') => {
405            // Sanitize: keep only ASCII alphanumeric and hyphens to ensure
406            // the label is safe as a filename component.
407            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
426/// Validate ANTHROPIC_API_URL uses HTTPS (with localhost exceptions).
427fn validate_anthropic_url(url: &str) -> Result<(), Error> {
428    if url.starts_with("https://") {
429        return Ok(());
430    }
431    // HTTP only allowed for localhost/127.0.0.1
432    // Extract host portion: "http://HOST:port/path" or "http://HOST/path"
433    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
444/// Validate a TOML pattern string using the same regex limits as TOML loading.
445///
446/// Uses `validate_pattern_regexes` from pattern::toml module for consistency.
447fn 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// Tests live in separate files to keep this module under 500 lines.
453#[cfg(test)]
454#[path = "learn_tests.rs"]
455mod tests;
456
457#[cfg(test)]
458#[path = "learn_prompt_tests.rs"]
459mod prompt_tests;