use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::providers::{find_preset, ProviderPreset};
use crate::tools::episodic_recall::episodic_recall_summary;
use crate::tools::facts::facts_summary;
use crate::tools::memory::memory_summary;
use crate::tools::memory::scratchpad_summary;
#[derive(Debug, Clone)]
pub struct Config {
pub workspace: PathBuf,
pub api_base: String,
pub api_key: Option<String>,
pub model: String,
pub provider_type: String,
pub preset: Option<String>,
pub max_steps: usize,
pub temperature: f64,
pub system_prompt: String,
pub retry_max: usize,
pub retry_initial_backoff_secs: u64,
pub retry_max_backoff_secs: u64,
pub shell_timeout_secs: u64,
pub headless: bool,
pub memory_summary_limit: usize,
pub thinking_budget: Option<u32>,
pub session_name: Option<String>,
}
impl Config {
pub fn from_env() -> Result<Self> {
let file_config = crate::config_file::FileConfig::load()
.map_err(|e| Error::Config {
message: format!("config file: {e}"),
})?
.unwrap_or_default();
let file_provider = file_config.provider.as_ref();
let file_agent = file_config.agent.as_ref();
let preset: Option<&'static ProviderPreset> =
match file_provider.and_then(|p| p.preset.as_deref()) {
None => None,
Some(id) => Some(find_preset(id).ok_or_else(|| {
let known: Vec<&str> = crate::providers::all_presets()
.iter()
.map(|p| p.id.as_str())
.collect();
Error::Config {
message: format!(
"provider.preset = {:?} not found in providers.toml. Valid ids: {}",
id,
known.join(", "),
),
}
})?),
};
let workspace = std::env::var("RECURSIVE_WORKSPACE")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let api_base = std::env::var("RECURSIVE_API_BASE")
.or_else(|_| std::env::var("OPENAI_API_BASE"))
.ok()
.or_else(|| file_provider.and_then(|p| p.api_base.clone()))
.or_else(|| preset.map(|p| p.api_base.clone()))
.unwrap_or_else(|| "https://api.anthropic.com".into());
let api_key = std::env::var("RECURSIVE_API_KEY")
.or_else(|_| std::env::var("OPENAI_API_KEY"))
.ok()
.or_else(|| file_provider.and_then(|p| p.api_key.clone()))
.or_else(|| {
preset
.filter(|p| !p.key_env.is_empty())
.and_then(|p| std::env::var(&p.key_env).ok())
});
let model = std::env::var("RECURSIVE_MODEL")
.or_else(|_| std::env::var("OPENAI_MODEL"))
.ok()
.or_else(|| file_provider.and_then(|p| p.model.clone()))
.or_else(|| preset.map(|p| p.default_model.clone()))
.unwrap_or_else(|| "claude-sonnet-4-6".into());
let max_steps = std::env::var("RECURSIVE_MAX_STEPS")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| file_agent.and_then(|a| a.max_steps))
.unwrap_or(32);
let temperature = std::env::var("RECURSIVE_TEMPERATURE")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| file_agent.and_then(|a| a.temperature))
.unwrap_or(0.2);
let system_prompt = match std::env::var("RECURSIVE_SYSTEM_PROMPT_FILE") {
Ok(path) => std::fs::read_to_string(&path).map_err(|e| Error::Config {
message: format!("read system prompt {path}: {e}"),
})?,
Err(_) => default_system_prompt(),
};
let retry_max = std::env::var("RECURSIVE_RETRY_MAX")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2);
let retry_initial_backoff_secs = std::env::var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let retry_max_backoff_secs = std::env::var("RECURSIVE_RETRY_MAX_BACKOFF_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8);
let shell_timeout_secs = std::env::var("RECURSIVE_SHELL_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| file_agent.and_then(|a| a.shell_timeout_secs))
.unwrap_or(300);
let headless = std::env::var("RECURSIVE_HEADLESS")
.ok()
.map(|s| s == "1" || s.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let provider_type = std::env::var("RECURSIVE_PROVIDER_TYPE")
.ok()
.or_else(|| file_provider.and_then(|p| p.provider_type.clone()))
.or_else(|| preset.map(|p| p.provider_type.clone()))
.unwrap_or_else(|| "anthropic".into());
let memory_summary_limit = std::env::var("RECURSIVE_MEMORY_SUMMARY_LIMIT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(5);
let mut layers: Vec<(String, String)> = Vec::new();
if let Some(user_memory) = load_user_memory() {
layers.push(("# User preferences".into(), user_memory));
}
if let Some(project_memory) = load_project_memory(&workspace) {
layers.push(("# Project memory".into(), project_memory));
}
let memory_block = memory_summary(&workspace, memory_summary_limit);
if !memory_block.is_empty() {
layers.push(("# Memory summary".into(), memory_block));
}
let scratchpad_block = scratchpad_summary(&workspace);
if !scratchpad_block.is_empty() {
layers.push(("# Scratchpad".into(), scratchpad_block));
}
let facts_block = facts_summary(&workspace, memory_summary_limit);
if !facts_block.is_empty() {
layers.push(("# Facts".into(), facts_block));
}
let episodic_block = episodic_recall_summary(&workspace, memory_summary_limit);
if !episodic_block.is_empty() {
layers.push(("# Episodic recall".into(), episodic_block));
}
let system_prompt = if layers.is_empty() {
system_prompt
} else {
let mut result = system_prompt;
result.push_str("\n\n---\n\n");
for (i, (heading, content)) in layers.iter().enumerate() {
if i > 0 {
result.push_str("\n\n");
}
result.push_str(heading);
result.push('\n');
result.push_str(content);
}
result
};
Ok(Self {
workspace,
api_base,
api_key,
model,
provider_type,
preset: preset.map(|p| p.id.clone()),
max_steps,
temperature,
system_prompt,
retry_max,
retry_initial_backoff_secs,
retry_max_backoff_secs,
shell_timeout_secs,
headless,
memory_summary_limit,
thinking_budget: None,
session_name: None,
})
}
pub fn require_api_key(&self) -> Result<&str> {
self.api_key.as_deref().ok_or_else(|| Error::Config {
message: "set RECURSIVE_API_KEY (or OPENAI_API_KEY), or set RECURSIVE_PROVIDER_TYPE and the matching provider's *_API_KEY env var".into(),
})
}
pub fn validate_for_agent(&self) -> std::result::Result<(), String> {
if self.api_key.is_none() || self.api_key.as_deref() == Some("") {
return Err("\
Error: No API key configured.
Set one of:
--api-key <KEY>
RECURSIVE_API_KEY=<KEY>
OPENAI_API_KEY=<KEY>
Or use a preset (auto-fills api_base / model / type, pulls the key from
the preset's env var like DEEPSEEK_API_KEY):
recursive init --provider deepseek
# or write ~/.recursive/config.toml:
[provider]
preset = \"deepseek\"
Example:
recursive --api-key sk-xxx --model deepseek-chat run \"hello\"
"
.to_string());
}
if !["openai", "anthropic"].contains(&self.provider_type.as_str()) {
return Err(format!(
"\
Error: Unknown provider type '{}'.
Supported providers: openai, anthropic
Set via --provider, RECURSIVE_PROVIDER_TYPE, or by using
`provider.preset = \"<id>\"` in ~/.recursive/config.toml.
",
self.provider_type
));
}
Ok(())
}
}
pub fn default_system_prompt() -> String {
[
"You are Recursive, a minimal but capable coding agent.",
"",
"Tools available: read_file, write_file, list_dir, run_shell, apply_patch, search_files.",
"Additional tools: estimate_tokens (estimate token count for text or file).",
"All file paths are workspace-relative; the sandbox will reject anything outside.",
"",
"Working principles:",
"- Read before you write. Skim relevant files (read_file, list_dir) before editing.",
"- Prefer apply_patch over write_file when modifying existing files. Use write_file only for new files or full rewrites.",
"- After any non-trivial code change, run the project's tests via run_shell and quote the result.",
"- If a tool call fails the same way twice, change approach instead of retrying.",
"- Stop calling tools and write a short final summary once the task is done.",
"",
"Patching with apply_patch:",
"- Use the V4A format (see AGENTS.md section 5 for the canonical reference).",
"- Each `*** Update File:` block must appear at most once per patch.",
"- The `@@ <anchor>` line cites an existing line; lines with leading space are unchanged context.",
"- Example (editing src/example.rs to add a new function):",
"```",
"*** Begin Patch",
"*** Update File: src/example.rs",
"@@ fn existing_function() {",
" fn existing_function();",
"",
"+fn new_function();",
"+",
" fn another_function();",
"*** End Patch",
"```",
"",
"Don't:",
"- Do not run `git checkout`, `git reset`, `git restore`, or any command that mutates the working tree. The orchestrator owns rollback.",
"- Do not edit source files via `sed -i`, `tail > file`, or `cat <<EOF`. Use apply_patch or write_file (whole file).",
"- Verify behavior via `cargo test`, never via `cargo run | jq`. Cargo build noise on a fresh tree breaks jq parsing and burns your step budget.",
"",
"Output should be terse and concrete. Avoid filler.",
"",
"## Task List Management",
"",
"Use todo_write to track progress on complex tasks with 3 or more distinct steps.",
"",
"When to use:",
"- Create the list BEFORE starting work (capture requirements as todos)",
"- Update status in real-time as you work",
"- Mark exactly ONE task as in_progress at a time",
"- Mark completed IMMEDIATELY after finishing (not batched)",
"- ONLY mark completed when fully done (tests passing, no partial work)",
"- Clear the list (call with empty array) when all tasks are done",
"",
"When NOT to use:",
"- Single, straightforward tasks",
"- Purely conversational responses",
"- Tasks completable in less than 3 trivial steps",
"",
"## Planning Mode",
"",
"Use enter_plan_mode when:",
"- The task requires exploring 3+ files before deciding what to change",
"- The task touches architectural boundaries (new module, new trait, API change)",
"- You are unsure of the correct approach and want to discuss options first",
"",
"While in plan mode:",
"- Read files freely (read_file, list_dir, search_files)",
"- Think through trade-offs in your responses",
"- DO NOT call write_file, apply_patch, or run_shell",
"- When you have a clear plan, call exit_plan_mode with a markdown summary",
"",
"Your plan should include:",
"1. What you understand about the current code",
"2. The approach you propose and why",
"3. Files you will modify and how",
"4. How you will verify the change is correct",
]
.join("\n")
}
const MAX_PROJECT_CONTEXT_SIZE: usize = 16 * 1024;
const MAX_MEMORY_FILE_SIZE: usize = 8 * 1024;
pub fn load_user_memory() -> Option<String> {
let home = std::env::var("HOME").ok()?;
let path = PathBuf::from(home).join(".recursive/memory/user.md");
load_memory_file(&path)
}
pub fn load_project_memory(workspace: &Path) -> Option<String> {
let path = workspace.join(".recursive/memory/project.md");
load_memory_file(&path)
}
fn load_memory_file(path: &Path) -> Option<String> {
if !path.is_file() {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
if content.is_empty() {
return None;
}
if content.len() > MAX_MEMORY_FILE_SIZE {
Some(format!(
"{}\n\n[…truncated at 8KB]",
&content[..MAX_MEMORY_FILE_SIZE]
))
} else {
Some(content)
}
}
pub fn load_project_context(workspace: &Path) -> Option<String> {
let path = workspace.join("AGENTS.md");
if !path.exists() {
return None;
}
let metadata = std::fs::metadata(&path).ok()?;
let file_size = metadata.len() as usize;
if file_size <= MAX_PROJECT_CONTEXT_SIZE {
let content = std::fs::read_to_string(&path).ok()?;
Some(content)
} else {
let mut file = std::fs::File::open(&path).ok()?;
use std::io::Read;
let mut buffer = vec![0u8; MAX_PROJECT_CONTEXT_SIZE];
let bytes_read = file.read(&mut buffer).ok()?;
buffer.truncate(bytes_read);
let content = String::from_utf8_lossy(&buffer).to_string();
let truncated_msg = format!(
"\n\n[…truncated, AGENTS.md is {} KB; consider trimming for fresh agent sessions]",
file_size / 1024
);
Some(content + &truncated_msg)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_prompt_is_well_under_a_kilobyte() {
assert!(default_system_prompt().len() < 6144);
}
#[test]
fn default_prompt_mentions_apply_patch() {
assert!(default_system_prompt().contains("apply_patch"));
}
#[test]
fn default_prompt_mentions_run_shell_tests() {
let prompt = default_system_prompt();
assert!(prompt.contains("run_shell"));
assert!(prompt.contains("tests"));
}
#[test]
fn default_prompt_includes_new_sections() {
let prompt = default_system_prompt();
assert!(prompt.contains("apply_patch"));
assert!(prompt.contains("git checkout"));
assert!(prompt.contains("cargo test"));
assert!(prompt.contains("*** Begin Patch"));
}
#[test]
fn retry_defaults_match_old_policy() {
let config = Config {
workspace: PathBuf::from("."),
api_base: String::new(),
api_key: None,
model: String::new(),
provider_type: "openai".into(),
preset: None,
max_steps: 32,
temperature: 0.2,
system_prompt: String::new(),
retry_max: 2,
retry_initial_backoff_secs: 1,
retry_max_backoff_secs: 8,
shell_timeout_secs: 300,
headless: false,
memory_summary_limit: 5,
thinking_budget: None,
session_name: None,
};
assert_eq!(config.retry_max, 2);
assert_eq!(config.retry_initial_backoff_secs, 1);
assert_eq!(config.retry_max_backoff_secs, 8);
assert_eq!(config.shell_timeout_secs, 300);
}
#[test]
fn retry_env_overrides_apply() {
let original_max = std::env::var("RECURSIVE_RETRY_MAX");
let original_initial = std::env::var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS");
let original_max_backoff = std::env::var("RECURSIVE_RETRY_MAX_BACKOFF_SECS");
std::env::set_var("RECURSIVE_RETRY_MAX", "5");
std::env::set_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS", "2");
std::env::set_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS", "30");
std::env::set_var("RECURSIVE_MODEL", "test-model");
std::env::set_var("RECURSIVE_API_KEY", "test-key");
let config = Config::from_env().unwrap();
assert_eq!(config.retry_max, 5);
assert_eq!(config.retry_initial_backoff_secs, 2);
assert_eq!(config.retry_max_backoff_secs, 30);
std::env::remove_var("RECURSIVE_RETRY_MAX");
std::env::remove_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS");
std::env::remove_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS");
std::env::remove_var("RECURSIVE_MODEL");
std::env::remove_var("RECURSIVE_API_KEY");
if let Ok(v) = original_max {
std::env::set_var("RECURSIVE_RETRY_MAX", v);
}
if let Ok(v) = original_initial {
std::env::set_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS", v);
}
if let Ok(v) = original_max_backoff {
std::env::set_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS", v);
}
}
#[test]
fn shell_timeout_default_and_env_override() {
let original = std::env::var("RECURSIVE_SHELL_TIMEOUT_SECS").ok();
std::env::set_var("RECURSIVE_MODEL", "test-model");
std::env::set_var("RECURSIVE_API_KEY", "test-key");
std::env::remove_var("RECURSIVE_SHELL_TIMEOUT_SECS");
let config = Config::from_env().unwrap();
assert_eq!(config.shell_timeout_secs, 300);
std::env::set_var("RECURSIVE_SHELL_TIMEOUT_SECS", "42");
let config = Config::from_env().unwrap();
assert_eq!(config.shell_timeout_secs, 42);
if let Some(v) = original {
std::env::set_var("RECURSIVE_SHELL_TIMEOUT_SECS", v);
} else {
std::env::remove_var("RECURSIVE_SHELL_TIMEOUT_SECS");
}
}
#[test]
fn test_a_load_project_context_with_small_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("AGENTS.md");
std::fs::write(&path, "# Project Context\n\nHello world").expect("write");
let content = load_project_context(tmp.path());
assert!(content.is_some());
assert!(content.unwrap().contains("Hello world"));
}
#[test]
fn test_b_load_project_context_truncates_large_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("AGENTS.md");
let large_content = "x".repeat(20 * 1024);
std::fs::write(&path, large_content).expect("write");
let content = load_project_context(tmp.path());
assert!(content.is_some());
let c = content.unwrap();
assert!(c.contains("truncated"));
assert!(c.contains("20 KB"));
}
#[test]
fn test_c_load_project_context_none_when_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
let content = load_project_context(tmp.path());
assert!(content.is_none());
}
#[test]
fn test_b_load_project_memory_returns_content() {
let tmp = tempfile::tempdir().expect("tempdir");
let mem_dir = tmp.path().join(".recursive/memory");
std::fs::create_dir_all(&mem_dir).expect("create dirs");
let path = mem_dir.join("project.md");
std::fs::write(&path, "This project uses AGPL license").expect("write");
let content = load_project_memory(tmp.path());
assert!(content.is_some());
assert!(content.unwrap().contains("AGPL"));
}
#[test]
fn test_c_files_exceeding_8kb_are_truncated() {
let tmp = tempfile::tempdir().expect("tempdir");
let mem_dir = tmp.path().join(".recursive/memory");
std::fs::create_dir_all(&mem_dir).expect("create dirs");
let path = mem_dir.join("project.md");
let large_content = "Hello world\n".repeat(800);
assert!(large_content.len() > 8192, "content must exceed 8KB");
std::fs::write(&path, &large_content).expect("write");
let content = load_project_memory(tmp.path());
assert!(content.is_some());
let c = content.unwrap();
assert!(c.contains("truncated at 8KB"));
assert!(c.len() < large_content.len());
}
#[test]
fn memory_home_dependent_tests() {
let tmp = tempfile::tempdir().expect("tempdir");
let _g = crate::test_util::PinnedHome::new(tmp.path());
{
let mem_dir = tmp.path().join(".recursive/memory");
std::fs::create_dir_all(&mem_dir).expect("create dirs");
std::fs::write(mem_dir.join("user.md"), "I prefer Python over Rust").expect("write");
let content = load_user_memory();
assert!(content.is_some());
assert!(content.unwrap().contains("Python"));
}
{
let mem_dir = tmp.path().join(".recursive/memory");
std::fs::remove_file(mem_dir.join("user.md")).expect("remove");
let user_mem = load_user_memory();
assert!(user_mem.is_none());
let project_mem = load_project_memory(tmp.path());
assert!(project_mem.is_none());
}
{
let mem_dir = tmp.path().join(".recursive/memory");
std::fs::create_dir_all(&mem_dir).expect("create dirs");
std::fs::write(mem_dir.join("user.md"), "I like Rust").expect("write");
std::fs::write(mem_dir.join("project.md"), "MIT license").expect("write");
std::env::set_var("RECURSIVE_MODEL", "test-model");
std::env::set_var("RECURSIVE_API_KEY", "test-key");
std::env::set_var("RECURSIVE_WORKSPACE", tmp.path().to_str().unwrap());
let config = Config::from_env().unwrap();
let prompt = config.system_prompt;
assert!(prompt.contains("# User preferences"));
assert!(prompt.contains("I like Rust"));
assert!(prompt.contains("# Project memory"));
assert!(prompt.contains("MIT license"));
assert!(prompt.contains("You are Recursive"));
}
{
let tmp2 = tempfile::tempdir().expect("tempdir");
std::env::set_var("HOME", tmp2.path().to_str().unwrap());
std::env::set_var("RECURSIVE_MODEL", "test-model");
std::env::set_var("RECURSIVE_API_KEY", "test-key");
std::env::set_var("RECURSIVE_WORKSPACE", tmp2.path().to_str().unwrap());
let config = Config::from_env().unwrap();
let prompt = config.system_prompt;
assert!(prompt.contains("You are Recursive"));
assert!(!prompt.contains("# User preferences"));
assert!(!prompt.contains("# Project memory"));
}
}
#[test]
fn headless_env_var_sets_config() {
let original_headless = std::env::var("RECURSIVE_HEADLESS").ok();
std::env::set_var("RECURSIVE_MODEL", "test-model");
std::env::set_var("RECURSIVE_API_KEY", "test-key");
{
std::env::remove_var("RECURSIVE_HEADLESS");
let config = Config::from_env().unwrap();
assert!(!config.headless);
}
{
std::env::set_var("RECURSIVE_HEADLESS", "1");
let config = Config::from_env().unwrap();
assert!(config.headless);
}
{
std::env::set_var("RECURSIVE_HEADLESS", "true");
let config = Config::from_env().unwrap();
assert!(config.headless);
}
{
std::env::set_var("RECURSIVE_HEADLESS", "0");
let config = Config::from_env().unwrap();
assert!(!config.headless);
}
std::env::remove_var("RECURSIVE_HEADLESS");
std::env::remove_var("RECURSIVE_MODEL");
std::env::remove_var("RECURSIVE_API_KEY");
if let Some(v) = original_headless {
std::env::set_var("RECURSIVE_HEADLESS", v);
}
}
#[test]
fn provider_preset_resolution_chain() {
use std::sync::OnceLock;
let _env_lock = crate::test_util::env_lock();
static HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
let tmp = HOME.get_or_init(|| tempfile::tempdir().expect("tempdir"));
let _g = crate::test_util::PinnedRecursiveHome::new(tmp.path());
let config_path = tmp.path().join(".recursive").join("config.toml");
std::fs::create_dir_all(config_path.parent().unwrap()).expect("mkdir");
let orig_api_key = std::env::var("RECURSIVE_API_KEY").ok();
let orig_openai_key = std::env::var("OPENAI_API_KEY").ok();
let orig_model = std::env::var("RECURSIVE_MODEL").ok();
let orig_openai_model = std::env::var("OPENAI_MODEL").ok();
let orig_api_base = std::env::var("RECURSIVE_API_BASE").ok();
let orig_deepseek = std::env::var("DEEPSEEK_API_KEY").ok();
let orig_provider = std::env::var("RECURSIVE_PROVIDER_TYPE").ok();
std::fs::write(
&config_path,
r#"[provider]
preset = "deepseek"
"#,
)
.expect("write config");
for v in &[
"RECURSIVE_API_KEY",
"OPENAI_API_KEY",
"RECURSIVE_MODEL",
"OPENAI_MODEL",
"RECURSIVE_API_BASE",
"DEEPSEEK_API_KEY",
"RECURSIVE_PROVIDER_TYPE",
] {
std::env::remove_var(v);
}
{
let c = Config::from_env().expect("case 1");
assert_eq!(c.preset.as_deref(), Some("deepseek"));
assert_eq!(c.provider_type, "openai");
assert_eq!(c.api_base, "https://api.deepseek.com/v1");
assert_eq!(c.model, "deepseek-chat");
assert!(
c.api_key.is_none(),
"no key_env set, no env, no file → None"
);
}
{
std::env::set_var("DEEPSEEK_API_KEY", "sk-from-env");
std::fs::write(
&config_path,
r#"[provider]
preset = "deepseek"
api_key = "sk-from-file"
"#,
)
.expect("rewrite config");
let c = Config::from_env().expect("case 2");
assert_eq!(
c.api_key.as_deref(),
Some("sk-from-file"),
"file api_key must win over preset.key_env env var"
);
}
{
std::env::set_var("RECURSIVE_API_KEY", "sk-generic-env");
let c = Config::from_env().expect("case 3");
assert_eq!(
c.api_key.as_deref(),
Some("sk-generic-env"),
"RECURSIVE_API_KEY must win over file api_key (existing behavior)"
);
}
{
std::env::remove_var("RECURSIVE_API_KEY");
std::env::remove_var("OPENAI_API_KEY");
std::fs::write(
&config_path,
r#"[provider]
preset = "deepseek"
"#,
)
.expect("rewrite config");
let c = Config::from_env().expect("case 4");
assert_eq!(
c.api_key.as_deref(),
Some("sk-from-env"),
"preset.key_env env var should be used when no explicit key anywhere"
);
}
{
std::env::remove_var("DEEPSEEK_API_KEY");
std::fs::write(
&config_path,
r#"[provider]
preset = "this-is-not-a-preset"
"#,
)
.expect("rewrite config");
let err = Config::from_env().expect_err("case 5 should fail");
let msg = format!("{err}");
assert!(msg.contains("this-is-not-a-preset"), "msg was: {msg}");
assert!(msg.contains("deepseek"), "msg should list valid ids: {msg}");
}
{
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::set_var("OLLAMA_API_KEY", "should-be-ignored");
std::fs::write(
&config_path,
r#"[provider]
preset = "ollama"
"#,
)
.expect("rewrite config");
let c = Config::from_env().expect("case 6");
assert_eq!(c.preset.as_deref(), Some("ollama"));
assert!(
c.api_key.is_none(),
"ollama has key_env='' so the OLLAMA_API_KEY env must not be consulted"
);
}
std::env::remove_var("OLLAMA_API_KEY");
for (name, prev) in [
("RECURSIVE_API_KEY", orig_api_key.as_deref()),
("OPENAI_API_KEY", orig_openai_key.as_deref()),
("RECURSIVE_MODEL", orig_model.as_deref()),
("OPENAI_MODEL", orig_openai_model.as_deref()),
("RECURSIVE_API_BASE", orig_api_base.as_deref()),
("DEEPSEEK_API_KEY", orig_deepseek.as_deref()),
("RECURSIVE_PROVIDER_TYPE", orig_provider.as_deref()),
] {
if let Some(v) = prev {
std::env::set_var(name, v);
} else {
std::env::remove_var(name);
}
}
}
}