use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::config::config_dir;
use crate::error::Error;
use crate::learn_payload::{create_payload, is_payload_shaped, payload_dir, remove_payload};
pub use crate::learn_prompt::SYSTEM_PROMPT;
pub use crate::learn_status::learn_status_path;
use crate::learn_url::ApiUrl;
const MAX_HINT_LENGTH: usize = 1000;
const MAX_COMMAND_LENGTH: usize = 100;
const MAX_FILENAME_COMPONENT: usize = 50;
#[derive(Deserialize)]
struct ConfigFile {
learn: Option<LearnConfig>,
}
#[derive(Deserialize, Clone)]
pub struct LearnConfig {
pub provider: String,
pub model: String,
pub api_key_env: String,
}
pub(crate) struct LearnParams<'a> {
pub config: &'a LearnConfig,
pub api_key: &'a str,
pub base_url: &'a str,
pub patterns_dir: &'a Path,
pub learn_status_path: &'a Path,
pub hint: Option<&'a str>,
}
#[derive(Deserialize)]
struct LearnData {
command: String,
output: String,
exit_code: i64,
hint: Option<String>,
}
impl Default for LearnConfig {
fn default() -> Self {
detect_provider()
}
}
fn detect_provider() -> LearnConfig {
detect_provider_with(|key| std::env::var(key).ok())
}
fn detect_provider_with<F: Fn(&str) -> Option<String>>(env_lookup: F) -> LearnConfig {
if env_lookup("ANTHROPIC_API_KEY").is_some() {
LearnConfig {
provider: "anthropic".into(),
model: "claude-haiku-4-5".into(),
api_key_env: "ANTHROPIC_API_KEY".into(),
}
} else {
LearnConfig {
provider: "anthropic".into(),
model: "claude-haiku-4-5".into(),
api_key_env: "ANTHROPIC_API_KEY".into(),
}
}
}
pub fn patterns_dir() -> PathBuf {
config_dir().join("patterns")
}
pub fn load_learn_config() -> Result<LearnConfig, Error> {
let path = config_dir().join("config.toml");
if !path.exists() {
return Ok(LearnConfig::default());
}
let content = std::fs::read_to_string(&path)
.map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
let cf: ConfigFile =
toml::from_str(&content).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
Ok(cf.learn.unwrap_or_default())
}
pub(crate) fn run_learn_with_config(
params: &LearnParams,
command: &str,
output: &str,
exit_code: i32,
) -> Result<(), Error> {
let hint = match params.hint {
Some(h) if h.len() > MAX_HINT_LENGTH => {
return Err(Error::Learn(format!(
"--hint too long ({} > {} chars)",
h.len(),
MAX_HINT_LENGTH
)));
}
h => h,
};
let api_url = ApiUrl::parse(params.base_url)?;
let truncated_command = crate::learn_utils::truncate_utf8(command, MAX_COMMAND_LENGTH);
let user_msg = if let Some(h) = hint {
format!(
"Command: {truncated_command}\nExit code: {exit_code}\nHint: {h}\nOutput:\n{}",
truncate_for_prompt(output)
)
} else {
format!(
"Command: {truncated_command}\nExit code: {exit_code}\nOutput:\n{}",
truncate_for_prompt(output)
)
};
let get_response = |msg: &str| -> Result<String, Error> {
match params.config.provider.as_str() {
"anthropic" => call_anthropic(&api_url, params.api_key, ¶ms.config.model, msg),
other => Err(Error::Learn(format!("unknown provider: {other}"))),
}
};
let mut last_err;
let toml = get_response(&user_msg)?;
let clean = crate::learn_utils::strip_fences(&toml);
if validate_pattern_toml_with_limits(&clean).is_ok() {
std::fs::create_dir_all(params.patterns_dir).map_err(|e| Error::Learn(e.to_string()))?;
let filename = format!("{}.toml", label(command));
let path = params.patterns_dir.join(&filename);
std::fs::write(&path, &clean).map_err(|e| Error::Learn(e.to_string()))?;
let _ = crate::learn_status::write_learn_status(
params.learn_status_path,
&label(command),
&path,
);
return Ok(());
}
last_err = "initial TOML validation failed".to_string();
for _ in 0..2 {
let retry_msg = format!(
"Your previous TOML was invalid: {last_err}. Here is what you returned:\n{clean}\nOutput ONLY the corrected TOML, nothing else."
);
let toml = get_response(&retry_msg)?;
let clean = crate::learn_utils::strip_fences(&toml);
if validate_pattern_toml_with_limits(&clean).is_ok() {
std::fs::create_dir_all(params.patterns_dir)
.map_err(|e| Error::Learn(e.to_string()))?;
let filename = format!("{}.toml", label(command));
let path = params.patterns_dir.join(&filename);
std::fs::write(&path, &clean).map_err(|e| Error::Learn(e.to_string()))?;
let _ = crate::learn_status::write_learn_status(
params.learn_status_path,
&label(command),
&path,
);
return Ok(());
}
last_err = "retry TOML validation failed".to_string();
}
Err(Error::Learn(format!("failed after 3 attempts: {last_err}")))
}
pub fn run_learn(command: &str, output: &str, exit_code: i32) -> Result<(), Error> {
run_learn_with_hint(
command,
output,
exit_code,
None,
&crate::learn_status::learn_status_path(),
)
}
fn run_learn_with_hint(
command: &str,
output: &str,
exit_code: i32,
hint: Option<&str>,
status_path: &Path,
) -> Result<(), Error> {
let config = load_learn_config()?;
let api_key = crate::api_key::non_empty_key(std::env::var(&config.api_key_env).ok())
.ok_or_else(|| {
Error::Learn(format!(
"Set {} environment variable to use `oo learn`",
config.api_key_env
))
})?;
let base_url = std::env::var("ANTHROPIC_API_URL")
.unwrap_or_else(|_| "https://api.anthropic.com/v1/messages".to_string());
let params = LearnParams {
config: &config,
api_key: &api_key,
base_url: &base_url,
patterns_dir: &patterns_dir(),
learn_status_path: status_path,
hint,
};
run_learn_with_config(¶ms, command, output, exit_code)
}
pub fn spawn_background(
command: &str,
output: &str,
exit_code: i32,
hint: Option<&str>,
) -> Result<(), Error> {
let exe = std::env::current_exe().map_err(|e| Error::Learn(e.to_string()))?;
let dir = payload_dir();
let mut data = serde_json::json!({
"command": command,
"output": output,
"exit_code": exit_code,
});
if let Some(h) = hint {
data["hint"] = serde_json::Value::String(h.to_string());
}
let tmp_path =
create_payload(&dir, &data.to_string()).map_err(|e| Error::Learn(e.to_string()))?;
std::process::Command::new(exe)
.arg("_learn_bg")
.arg(&tmp_path)
.arg(&dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| Error::Learn(e.to_string()))?;
tmp_path.keep().map_err(|e| Error::Learn(e.to_string()))?;
Ok(())
}
pub fn run_background(
data_path: &Path,
payload_dir: &Path,
status_path: &Path,
) -> Result<(), Error> {
let path = data_path;
let shaped = is_payload_shaped(path, payload_dir);
let read = if shaped {
std::fs::read_to_string(path).map_err(|e| Error::Learn(e.to_string()))
} else {
Err(Error::Learn(format!(
"not a learn payload: {}",
path.display()
)))
};
let removal = shaped.then(|| remove_payload(path).err()).flatten();
let mut cmd_label = String::from("learn");
let result = read.and_then(|content| {
let data: LearnData =
serde_json::from_str(&content).map_err(|e| Error::Learn(e.to_string()))?;
cmd_label = label(&data.command);
let exit_code = i32::try_from(data.exit_code).map_err(|_| {
Error::Learn(format!(
"exit_code out of range for i32: {}",
data.exit_code
))
})?;
run_learn_with_hint(
&data.command,
&data.output,
exit_code,
data.hint.as_deref(),
status_path,
)
});
let result = match removal {
None => result,
Some(e) => {
let msg = format!("could not remove payload {}: {e}", path.display());
Err(Error::Learn(match result {
Ok(()) => msg,
Err(learn_err) => format!("{msg}; {learn_err}"),
}))
}
};
if let Err(ref e) = result {
let _ = crate::learn_status::write_learn_status_failure(
status_path,
&cmd_label,
&e.to_string(),
);
}
result
}
fn call_anthropic(
base_url: &ApiUrl,
api_key: &str,
model: &str,
user_msg: &str,
) -> Result<String, Error> {
let body = serde_json::json!({
"model": model,
"max_tokens": 1024,
"temperature": 0.0,
"system": SYSTEM_PROMPT,
"messages": [{"role": "user", "content": user_msg}],
});
use std::time::Duration;
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.timeout_connect(Some(Duration::from_secs(10)))
.max_redirects(0)
.build()
.into();
let mut response = agent
.post(base_url.uri().clone())
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.send_json(&body)
.map_err(|e| Error::Learn(format!("Anthropic API error: {e}")))?;
let status = response.status().as_u16();
if (300..400).contains(&status) {
return Err(Error::Learn(format!(
"unexpected redirect (status {status}): redirects are not followed; ANTHROPIC_API_URL must point directly at the API endpoint"
)));
}
let response: serde_json::Value = response
.body_mut()
.read_json()
.map_err(|e| Error::Learn(format!("response parse error: {e}")))?;
response["content"][0]["text"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| Error::Learn("unexpected Anthropic response format".into()))
}
fn label(command: &str) -> String {
let mut words = command.split_whitespace();
let first = words
.next()
.unwrap_or("unknown")
.rsplit('/')
.next()
.unwrap_or("unknown");
let sanitized_first: String = first
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.take(MAX_FILENAME_COMPONENT)
.collect();
if sanitized_first.is_empty() {
return "unknown".to_string();
}
match words.next() {
Some(second) if !second.starts_with('-') => {
let sanitized_second: String = second
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.take(MAX_FILENAME_COMPONENT)
.collect();
if sanitized_second.is_empty() {
sanitized_first
} else {
format!("{sanitized_first}-{sanitized_second}")
}
}
_ => sanitized_first,
}
}
fn truncate_for_prompt(output: &str) -> &str {
crate::learn_utils::truncate_utf8(output, 4000)
}
fn validate_pattern_toml_with_limits(toml_str: &str) -> Result<(), Error> {
crate::pattern::validate_pattern_regexes(toml_str)
.map_err(|e| Error::Learn(format!("pattern validation: {e}")))
}
#[cfg(test)]
#[path = "learn_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "learn_prompt_tests.rs"]
mod prompt_tests;