1use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10use crate::providers::{find_preset, ProviderPreset};
11use crate::tools::episodic_recall::episodic_recall_summary;
12use crate::tools::facts::facts_summary;
13use crate::tools::memory::memory_summary;
14use crate::tools::memory::scratchpad_summary;
15
16#[derive(Debug, Clone)]
17pub struct Config {
18 pub workspace: PathBuf,
19 pub api_base: String,
20 pub api_key: Option<String>,
21 pub model: String,
22 pub provider_type: String,
23 pub preset: Option<String>,
26 pub max_steps: usize,
27 pub temperature: f64,
28 pub system_prompt: String,
29 pub retry_max: usize,
30 pub retry_initial_backoff_secs: u64,
31 pub retry_max_backoff_secs: u64,
32 pub shell_timeout_secs: u64,
33 pub headless: bool,
37 pub memory_summary_limit: usize,
38 pub thinking_budget: Option<u32>,
41 pub session_name: Option<String>,
43}
44
45impl Config {
46 pub fn from_env() -> Result<Self> {
68 let file_config = crate::config_file::FileConfig::load()
70 .map_err(|e| Error::Config {
71 message: format!("config file: {e}"),
72 })?
73 .unwrap_or_default();
74 let file_provider = file_config.provider.as_ref();
75 let file_agent = file_config.agent.as_ref();
76
77 let preset: Option<&'static ProviderPreset> =
80 match file_provider.and_then(|p| p.preset.as_deref()) {
81 None => None,
82 Some(id) => Some(find_preset(id).ok_or_else(|| {
83 let known: Vec<&str> = crate::providers::all_presets()
84 .iter()
85 .map(|p| p.id.as_str())
86 .collect();
87 Error::Config {
88 message: format!(
89 "provider.preset = {:?} not found in providers.toml. Valid ids: {}",
90 id,
91 known.join(", "),
92 ),
93 }
94 })?),
95 };
96
97 let workspace = std::env::var("RECURSIVE_WORKSPACE")
98 .map(PathBuf::from)
99 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
100
101 let api_base = std::env::var("RECURSIVE_API_BASE")
102 .or_else(|_| std::env::var("OPENAI_API_BASE"))
103 .ok()
104 .or_else(|| file_provider.and_then(|p| p.api_base.clone()))
105 .or_else(|| preset.map(|p| p.api_base.clone()))
106 .unwrap_or_else(|| "https://api.anthropic.com".into());
107
108 let api_key = std::env::var("RECURSIVE_API_KEY")
111 .or_else(|_| std::env::var("OPENAI_API_KEY"))
112 .ok()
113 .or_else(|| file_provider.and_then(|p| p.api_key.clone()))
114 .or_else(|| {
115 preset
116 .filter(|p| !p.key_env.is_empty())
117 .and_then(|p| std::env::var(&p.key_env).ok())
118 });
119
120 let model = std::env::var("RECURSIVE_MODEL")
121 .or_else(|_| std::env::var("OPENAI_MODEL"))
122 .ok()
123 .or_else(|| file_provider.and_then(|p| p.model.clone()))
124 .or_else(|| preset.map(|p| p.default_model.clone()))
125 .unwrap_or_else(|| "claude-sonnet-4-6".into());
126
127 let max_steps = std::env::var("RECURSIVE_MAX_STEPS")
128 .ok()
129 .and_then(|s| s.parse().ok())
130 .or_else(|| file_agent.and_then(|a| a.max_steps))
131 .unwrap_or(32);
132
133 let temperature = std::env::var("RECURSIVE_TEMPERATURE")
134 .ok()
135 .and_then(|s| s.parse().ok())
136 .or_else(|| file_agent.and_then(|a| a.temperature))
137 .unwrap_or(0.2);
138
139 let system_prompt = match std::env::var("RECURSIVE_SYSTEM_PROMPT_FILE") {
140 Ok(path) => std::fs::read_to_string(&path).map_err(|e| Error::Config {
141 message: format!("read system prompt {path}: {e}"),
142 })?,
143 Err(_) => default_system_prompt(),
144 };
145
146 let retry_max = std::env::var("RECURSIVE_RETRY_MAX")
147 .ok()
148 .and_then(|s| s.parse().ok())
149 .unwrap_or(2);
150 let retry_initial_backoff_secs = std::env::var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS")
151 .ok()
152 .and_then(|s| s.parse().ok())
153 .unwrap_or(1);
154 let retry_max_backoff_secs = std::env::var("RECURSIVE_RETRY_MAX_BACKOFF_SECS")
155 .ok()
156 .and_then(|s| s.parse().ok())
157 .unwrap_or(8);
158 let shell_timeout_secs = std::env::var("RECURSIVE_SHELL_TIMEOUT_SECS")
159 .ok()
160 .and_then(|s| s.parse().ok())
161 .or_else(|| file_agent.and_then(|a| a.shell_timeout_secs))
162 .unwrap_or(300);
163
164 let headless = std::env::var("RECURSIVE_HEADLESS")
165 .ok()
166 .map(|s| s == "1" || s.eq_ignore_ascii_case("true"))
167 .unwrap_or(false);
168
169 let provider_type = std::env::var("RECURSIVE_PROVIDER_TYPE")
170 .ok()
171 .or_else(|| file_provider.and_then(|p| p.provider_type.clone()))
172 .or_else(|| preset.map(|p| p.provider_type.clone()))
173 .unwrap_or_else(|| "anthropic".into());
174
175 let memory_summary_limit = std::env::var("RECURSIVE_MEMORY_SUMMARY_LIMIT")
176 .ok()
177 .and_then(|s| s.parse().ok())
178 .unwrap_or(5);
179
180 let mut layers: Vec<(String, String)> = Vec::new();
184
185 if let Some(user_memory) = load_user_memory() {
187 layers.push(("# User preferences".into(), user_memory));
188 }
189
190 if let Some(project_memory) = load_project_memory(&workspace) {
192 layers.push(("# Project memory".into(), project_memory));
193 }
194
195 let memory_block = memory_summary(&workspace, memory_summary_limit);
197 if !memory_block.is_empty() {
198 layers.push(("# Memory summary".into(), memory_block));
199 }
200
201 let scratchpad_block = scratchpad_summary(&workspace);
203 if !scratchpad_block.is_empty() {
204 layers.push(("# Scratchpad".into(), scratchpad_block));
205 }
206
207 let facts_block = facts_summary(&workspace, memory_summary_limit);
209 if !facts_block.is_empty() {
210 layers.push(("# Facts".into(), facts_block));
211 }
212
213 let episodic_block = episodic_recall_summary(&workspace, memory_summary_limit);
215 if !episodic_block.is_empty() {
216 layers.push(("# Episodic recall".into(), episodic_block));
217 }
218
219 let system_prompt = if layers.is_empty() {
221 system_prompt
222 } else {
223 let mut result = system_prompt;
224 result.push_str("\n\n---\n\n");
225 for (i, (heading, content)) in layers.iter().enumerate() {
226 if i > 0 {
227 result.push_str("\n\n");
228 }
229 result.push_str(heading);
230 result.push('\n');
231 result.push_str(content);
232 }
233 result
234 };
235
236 Ok(Self {
237 workspace,
238 api_base,
239 api_key,
240 model,
241 provider_type,
242 preset: preset.map(|p| p.id.clone()),
243 max_steps,
244 temperature,
245 system_prompt,
246 retry_max,
247 retry_initial_backoff_secs,
248 retry_max_backoff_secs,
249 shell_timeout_secs,
250 headless,
251 memory_summary_limit,
252 thinking_budget: None,
253 session_name: None,
254 })
255 }
256
257 pub fn require_api_key(&self) -> Result<&str> {
259 self.api_key.as_deref().ok_or_else(|| Error::Config {
260 message: "set RECURSIVE_API_KEY (or OPENAI_API_KEY), or set RECURSIVE_PROVIDER_TYPE and the matching provider's *_API_KEY env var".into(),
261 })
262 }
263
264 pub fn validate_for_agent(&self) -> std::result::Result<(), String> {
267 if self.api_key.is_none() || self.api_key.as_deref() == Some("") {
268 return Err("\
269Error: No API key configured.
270
271Set one of:
272 --api-key <KEY>
273 RECURSIVE_API_KEY=<KEY>
274 OPENAI_API_KEY=<KEY>
275
276Or use a preset (auto-fills api_base / model / type, pulls the key from
277the preset's env var like DEEPSEEK_API_KEY):
278 recursive init --provider deepseek
279 # or write ~/.recursive/config.toml:
280 [provider]
281 preset = \"deepseek\"
282
283Example:
284 recursive --api-key sk-xxx --model deepseek-chat run \"hello\"
285"
286 .to_string());
287 }
288 if !["openai", "anthropic"].contains(&self.provider_type.as_str()) {
289 return Err(format!(
290 "\
291Error: Unknown provider type '{}'.
292
293Supported providers: openai, anthropic
294Set via --provider, RECURSIVE_PROVIDER_TYPE, or by using
295`provider.preset = \"<id>\"` in ~/.recursive/config.toml.
296",
297 self.provider_type
298 ));
299 }
300 Ok(())
301 }
302}
303
304pub fn default_system_prompt() -> String {
305 [
306 "You are Recursive, a minimal but capable coding agent.",
307 "",
308 "Tools available: read_file, write_file, list_dir, run_shell, apply_patch, search_files.",
309 "Additional tools: estimate_tokens (estimate token count for text or file).",
310 "All file paths are workspace-relative; the sandbox will reject anything outside.",
311 "",
312 "Working principles:",
313 "- Read before you write. Skim relevant files (read_file, list_dir) before editing.",
314 "- Prefer apply_patch over write_file when modifying existing files. Use write_file only for new files or full rewrites.",
315 "- After any non-trivial code change, run the project's tests via run_shell and quote the result.",
316 "- If a tool call fails the same way twice, change approach instead of retrying.",
317 "- Stop calling tools and write a short final summary once the task is done.",
318 "",
319 "Patching with apply_patch:",
320 "- Use the V4A format (see AGENTS.md section 5 for the canonical reference).",
321 "- Each `*** Update File:` block must appear at most once per patch.",
322 "- The `@@ <anchor>` line cites an existing line; lines with leading space are unchanged context.",
323 "- Example (editing src/example.rs to add a new function):",
324 "```",
325 "*** Begin Patch",
326 "*** Update File: src/example.rs",
327 "@@ fn existing_function() {",
328 " fn existing_function();",
329 "",
330 "+fn new_function();",
331 "+",
332 " fn another_function();",
333 "*** End Patch",
334 "```",
335 "",
336 "Don't:",
337 "- Do not run `git checkout`, `git reset`, `git restore`, or any command that mutates the working tree. The orchestrator owns rollback.",
338 "- Do not edit source files via `sed -i`, `tail > file`, or `cat <<EOF`. Use apply_patch or write_file (whole file).",
339 "- 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.",
340 "",
341 "Output should be terse and concrete. Avoid filler.",
342 "",
343 "## Task List Management",
344 "",
345 "Use todo_write to track progress on complex tasks with 3 or more distinct steps.",
346 "",
347 "When to use:",
348 "- Create the list BEFORE starting work (capture requirements as todos)",
349 "- Update status in real-time as you work",
350 "- Mark exactly ONE task as in_progress at a time",
351 "- Mark completed IMMEDIATELY after finishing (not batched)",
352 "- ONLY mark completed when fully done (tests passing, no partial work)",
353 "- Clear the list (call with empty array) when all tasks are done",
354 "",
355 "When NOT to use:",
356 "- Single, straightforward tasks",
357 "- Purely conversational responses",
358 "- Tasks completable in less than 3 trivial steps",
359 "",
360 "## Planning Mode",
361 "",
362 "Use enter_plan_mode when:",
363 "- The task requires exploring 3+ files before deciding what to change",
364 "- The task touches architectural boundaries (new module, new trait, API change)",
365 "- You are unsure of the correct approach and want to discuss options first",
366 "",
367 "While in plan mode:",
368 "- Read files freely (read_file, list_dir, search_files)",
369 "- Think through trade-offs in your responses",
370 "- DO NOT call write_file, apply_patch, or run_shell",
371 "- When you have a clear plan, call exit_plan_mode with a markdown summary",
372 "",
373 "Your plan should include:",
374 "1. What you understand about the current code",
375 "2. The approach you propose and why",
376 "3. Files you will modify and how",
377 "4. How you will verify the change is correct",
378 ]
379 .join("\n")
380}
381
382const MAX_PROJECT_CONTEXT_SIZE: usize = 16 * 1024;
386
387const MAX_MEMORY_FILE_SIZE: usize = 8 * 1024;
389
390pub fn load_user_memory() -> Option<String> {
393 let home = std::env::var("HOME").ok()?;
394 let path = PathBuf::from(home).join(".recursive/memory/user.md");
395 load_memory_file(&path)
396}
397
398pub fn load_project_memory(workspace: &Path) -> Option<String> {
400 let path = workspace.join(".recursive/memory/project.md");
401 load_memory_file(&path)
402}
403
404fn load_memory_file(path: &Path) -> Option<String> {
406 if !path.is_file() {
407 return None;
408 }
409 let content = std::fs::read_to_string(path).ok()?;
410 if content.is_empty() {
411 return None;
412 }
413 if content.len() > MAX_MEMORY_FILE_SIZE {
414 Some(format!(
415 "{}\n\n[…truncated at 8KB]",
416 &content[..MAX_MEMORY_FILE_SIZE]
417 ))
418 } else {
419 Some(content)
420 }
421}
422
423pub fn load_project_context(workspace: &Path) -> Option<String> {
428 let path = workspace.join("AGENTS.md");
429 if !path.exists() {
430 return None;
431 }
432
433 let metadata = std::fs::metadata(&path).ok()?;
434 let file_size = metadata.len() as usize;
435
436 if file_size <= MAX_PROJECT_CONTEXT_SIZE {
437 let content = std::fs::read_to_string(&path).ok()?;
438 Some(content)
439 } else {
440 let mut file = std::fs::File::open(&path).ok()?;
442 use std::io::Read;
443 let mut buffer = vec![0u8; MAX_PROJECT_CONTEXT_SIZE];
444 let bytes_read = file.read(&mut buffer).ok()?;
445 buffer.truncate(bytes_read);
446 let content = String::from_utf8_lossy(&buffer).to_string();
447 let truncated_msg = format!(
448 "\n\n[…truncated, AGENTS.md is {} KB; consider trimming for fresh agent sessions]",
449 file_size / 1024
450 );
451 Some(content + &truncated_msg)
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn default_prompt_is_well_under_a_kilobyte() {
461 assert!(default_system_prompt().len() < 6144);
464 }
465
466 #[test]
467 fn default_prompt_mentions_apply_patch() {
468 assert!(default_system_prompt().contains("apply_patch"));
469 }
470
471 #[test]
472 fn default_prompt_mentions_run_shell_tests() {
473 let prompt = default_system_prompt();
474 assert!(prompt.contains("run_shell"));
475 assert!(prompt.contains("tests"));
476 }
477
478 #[test]
479 fn default_prompt_includes_new_sections() {
480 let prompt = default_system_prompt();
481 assert!(prompt.contains("apply_patch"));
482 assert!(prompt.contains("git checkout"));
483 assert!(prompt.contains("cargo test"));
484 assert!(prompt.contains("*** Begin Patch"));
485 }
486
487 #[test]
488 fn retry_defaults_match_old_policy() {
489 let config = Config {
491 workspace: PathBuf::from("."),
492 api_base: String::new(),
493 api_key: None,
494 model: String::new(),
495 provider_type: "openai".into(),
496 preset: None,
497 max_steps: 32,
498 temperature: 0.2,
499 system_prompt: String::new(),
500 retry_max: 2,
501 retry_initial_backoff_secs: 1,
502 retry_max_backoff_secs: 8,
503 shell_timeout_secs: 300,
504 headless: false,
505 memory_summary_limit: 5,
506 thinking_budget: None,
507 session_name: None,
508 };
509 assert_eq!(config.retry_max, 2);
510 assert_eq!(config.retry_initial_backoff_secs, 1);
511 assert_eq!(config.retry_max_backoff_secs, 8);
512 assert_eq!(config.shell_timeout_secs, 300);
513 }
514
515 #[test]
516 fn retry_env_overrides_apply() {
517 let original_max = std::env::var("RECURSIVE_RETRY_MAX");
519 let original_initial = std::env::var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS");
520 let original_max_backoff = std::env::var("RECURSIVE_RETRY_MAX_BACKOFF_SECS");
521
522 std::env::set_var("RECURSIVE_RETRY_MAX", "5");
524 std::env::set_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS", "2");
525 std::env::set_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS", "30");
526
527 std::env::set_var("RECURSIVE_MODEL", "test-model");
529 std::env::set_var("RECURSIVE_API_KEY", "test-key");
530
531 let config = Config::from_env().unwrap();
532
533 assert_eq!(config.retry_max, 5);
534 assert_eq!(config.retry_initial_backoff_secs, 2);
535 assert_eq!(config.retry_max_backoff_secs, 30);
536
537 std::env::remove_var("RECURSIVE_RETRY_MAX");
539 std::env::remove_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS");
540 std::env::remove_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS");
541 std::env::remove_var("RECURSIVE_MODEL");
542 std::env::remove_var("RECURSIVE_API_KEY");
543
544 if let Ok(v) = original_max {
545 std::env::set_var("RECURSIVE_RETRY_MAX", v);
546 }
547 if let Ok(v) = original_initial {
548 std::env::set_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS", v);
549 }
550 if let Ok(v) = original_max_backoff {
551 std::env::set_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS", v);
552 }
553 }
554
555 #[test]
562 fn shell_timeout_default_and_env_override() {
563 let original = std::env::var("RECURSIVE_SHELL_TIMEOUT_SECS").ok();
564 std::env::set_var("RECURSIVE_MODEL", "test-model");
565 std::env::set_var("RECURSIVE_API_KEY", "test-key");
566
567 std::env::remove_var("RECURSIVE_SHELL_TIMEOUT_SECS");
568 let config = Config::from_env().unwrap();
569 assert_eq!(config.shell_timeout_secs, 300);
570
571 std::env::set_var("RECURSIVE_SHELL_TIMEOUT_SECS", "42");
572 let config = Config::from_env().unwrap();
573 assert_eq!(config.shell_timeout_secs, 42);
574
575 if let Some(v) = original {
576 std::env::set_var("RECURSIVE_SHELL_TIMEOUT_SECS", v);
577 } else {
578 std::env::remove_var("RECURSIVE_SHELL_TIMEOUT_SECS");
579 }
580 }
581
582 #[test]
584 fn test_a_load_project_context_with_small_file() {
585 let tmp = tempfile::tempdir().expect("tempdir");
586 let path = tmp.path().join("AGENTS.md");
587 std::fs::write(&path, "# Project Context\n\nHello world").expect("write");
588
589 let content = load_project_context(tmp.path());
590 assert!(content.is_some());
591 assert!(content.unwrap().contains("Hello world"));
592 }
593
594 #[test]
595 fn test_b_load_project_context_truncates_large_file() {
596 let tmp = tempfile::tempdir().expect("tempdir");
597 let path = tmp.path().join("AGENTS.md");
598 let large_content = "x".repeat(20 * 1024);
600 std::fs::write(&path, large_content).expect("write");
601
602 let content = load_project_context(tmp.path());
603 assert!(content.is_some());
604 let c = content.unwrap();
605 assert!(c.contains("truncated"));
607 assert!(c.contains("20 KB"));
608 }
609
610 #[test]
611 fn test_c_load_project_context_none_when_missing() {
612 let tmp = tempfile::tempdir().expect("tempdir");
613 let content = load_project_context(tmp.path());
615 assert!(content.is_none());
616 }
617
618 #[test]
624 fn test_b_load_project_memory_returns_content() {
625 let tmp = tempfile::tempdir().expect("tempdir");
626
627 let mem_dir = tmp.path().join(".recursive/memory");
628 std::fs::create_dir_all(&mem_dir).expect("create dirs");
629 let path = mem_dir.join("project.md");
630 std::fs::write(&path, "This project uses AGPL license").expect("write");
631
632 let content = load_project_memory(tmp.path());
633 assert!(content.is_some());
634 assert!(content.unwrap().contains("AGPL"));
635 }
636
637 #[test]
638 fn test_c_files_exceeding_8kb_are_truncated() {
639 let tmp = tempfile::tempdir().expect("tempdir");
640
641 let mem_dir = tmp.path().join(".recursive/memory");
642 std::fs::create_dir_all(&mem_dir).expect("create dirs");
643 let path = mem_dir.join("project.md");
644 let large_content = "Hello world\n".repeat(800);
646 assert!(large_content.len() > 8192, "content must exceed 8KB");
647 std::fs::write(&path, &large_content).expect("write");
648
649 let content = load_project_memory(tmp.path());
650 assert!(content.is_some());
651 let c = content.unwrap();
652 assert!(c.contains("truncated at 8KB"));
654 assert!(c.len() < large_content.len());
656 }
657
658 #[test]
664 fn memory_home_dependent_tests() {
665 let tmp = tempfile::tempdir().expect("tempdir");
666 let _g = crate::test_util::PinnedHome::new(tmp.path());
667
668 {
670 let mem_dir = tmp.path().join(".recursive/memory");
671 std::fs::create_dir_all(&mem_dir).expect("create dirs");
672 std::fs::write(mem_dir.join("user.md"), "I prefer Python over Rust").expect("write");
673
674 let content = load_user_memory();
675 assert!(content.is_some());
676 assert!(content.unwrap().contains("Python"));
677 }
678
679 {
681 let mem_dir = tmp.path().join(".recursive/memory");
683 std::fs::remove_file(mem_dir.join("user.md")).expect("remove");
684
685 let user_mem = load_user_memory();
686 assert!(user_mem.is_none());
687
688 let project_mem = load_project_memory(tmp.path());
689 assert!(project_mem.is_none());
690 }
691
692 {
694 let mem_dir = tmp.path().join(".recursive/memory");
695 std::fs::create_dir_all(&mem_dir).expect("create dirs");
696 std::fs::write(mem_dir.join("user.md"), "I like Rust").expect("write");
697 std::fs::write(mem_dir.join("project.md"), "MIT license").expect("write");
698
699 std::env::set_var("RECURSIVE_MODEL", "test-model");
700 std::env::set_var("RECURSIVE_API_KEY", "test-key");
701 std::env::set_var("RECURSIVE_WORKSPACE", tmp.path().to_str().unwrap());
702
703 let config = Config::from_env().unwrap();
704 let prompt = config.system_prompt;
705
706 assert!(prompt.contains("# User preferences"));
707 assert!(prompt.contains("I like Rust"));
708 assert!(prompt.contains("# Project memory"));
709 assert!(prompt.contains("MIT license"));
710 assert!(prompt.contains("You are Recursive"));
711 }
712
713 {
718 let tmp2 = tempfile::tempdir().expect("tempdir");
719 std::env::set_var("HOME", tmp2.path().to_str().unwrap());
720
721 std::env::set_var("RECURSIVE_MODEL", "test-model");
722 std::env::set_var("RECURSIVE_API_KEY", "test-key");
723 std::env::set_var("RECURSIVE_WORKSPACE", tmp2.path().to_str().unwrap());
724
725 let config = Config::from_env().unwrap();
726 let prompt = config.system_prompt;
727
728 assert!(prompt.contains("You are Recursive"));
729 assert!(!prompt.contains("# User preferences"));
730 assert!(!prompt.contains("# Project memory"));
731 }
732 }
733
734 #[test]
737 fn headless_env_var_sets_config() {
738 let original_headless = std::env::var("RECURSIVE_HEADLESS").ok();
739 std::env::set_var("RECURSIVE_MODEL", "test-model");
740 std::env::set_var("RECURSIVE_API_KEY", "test-key");
741
742 {
744 std::env::remove_var("RECURSIVE_HEADLESS");
745 let config = Config::from_env().unwrap();
746 assert!(!config.headless);
747 }
748
749 {
751 std::env::set_var("RECURSIVE_HEADLESS", "1");
752 let config = Config::from_env().unwrap();
753 assert!(config.headless);
754 }
755
756 {
758 std::env::set_var("RECURSIVE_HEADLESS", "true");
759 let config = Config::from_env().unwrap();
760 assert!(config.headless);
761 }
762
763 {
765 std::env::set_var("RECURSIVE_HEADLESS", "0");
766 let config = Config::from_env().unwrap();
767 assert!(!config.headless);
768 }
769
770 std::env::remove_var("RECURSIVE_HEADLESS");
771 std::env::remove_var("RECURSIVE_MODEL");
772 std::env::remove_var("RECURSIVE_API_KEY");
773 if let Some(v) = original_headless {
774 std::env::set_var("RECURSIVE_HEADLESS", v);
775 }
776 }
777
778 #[test]
786 fn provider_preset_resolution_chain() {
787 use std::sync::OnceLock;
788 let _env_lock = crate::test_util::env_lock();
797 static HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
805 let tmp = HOME.get_or_init(|| tempfile::tempdir().expect("tempdir"));
806 let _g = crate::test_util::PinnedRecursiveHome::new(tmp.path());
807 let config_path = tmp.path().join(".recursive").join("config.toml");
808 std::fs::create_dir_all(config_path.parent().unwrap()).expect("mkdir");
809
810 let orig_api_key = std::env::var("RECURSIVE_API_KEY").ok();
812 let orig_openai_key = std::env::var("OPENAI_API_KEY").ok();
813 let orig_model = std::env::var("RECURSIVE_MODEL").ok();
814 let orig_openai_model = std::env::var("OPENAI_MODEL").ok();
815 let orig_api_base = std::env::var("RECURSIVE_API_BASE").ok();
816 let orig_deepseek = std::env::var("DEEPSEEK_API_KEY").ok();
817 let orig_provider = std::env::var("RECURSIVE_PROVIDER_TYPE").ok();
818
819 std::fs::write(
821 &config_path,
822 r#"[provider]
823preset = "deepseek"
824"#,
825 )
826 .expect("write config");
827
828 for v in &[
830 "RECURSIVE_API_KEY",
831 "OPENAI_API_KEY",
832 "RECURSIVE_MODEL",
833 "OPENAI_MODEL",
834 "RECURSIVE_API_BASE",
835 "DEEPSEEK_API_KEY",
836 "RECURSIVE_PROVIDER_TYPE",
837 ] {
838 std::env::remove_var(v);
839 }
840
841 {
843 let c = Config::from_env().expect("case 1");
844 assert_eq!(c.preset.as_deref(), Some("deepseek"));
845 assert_eq!(c.provider_type, "openai");
846 assert_eq!(c.api_base, "https://api.deepseek.com/v1");
847 assert_eq!(c.model, "deepseek-chat");
848 assert!(
849 c.api_key.is_none(),
850 "no key_env set, no env, no file → None"
851 );
852 }
853
854 {
857 std::env::set_var("DEEPSEEK_API_KEY", "sk-from-env");
858 std::fs::write(
859 &config_path,
860 r#"[provider]
861preset = "deepseek"
862api_key = "sk-from-file"
863"#,
864 )
865 .expect("rewrite config");
866 let c = Config::from_env().expect("case 2");
867 assert_eq!(
868 c.api_key.as_deref(),
869 Some("sk-from-file"),
870 "file api_key must win over preset.key_env env var"
871 );
872 }
873
874 {
877 std::env::set_var("RECURSIVE_API_KEY", "sk-generic-env");
878 let c = Config::from_env().expect("case 3");
879 assert_eq!(
880 c.api_key.as_deref(),
881 Some("sk-generic-env"),
882 "RECURSIVE_API_KEY must win over file api_key (existing behavior)"
883 );
884 }
885
886 {
889 std::env::remove_var("RECURSIVE_API_KEY");
890 std::env::remove_var("OPENAI_API_KEY");
891 std::fs::write(
892 &config_path,
893 r#"[provider]
894preset = "deepseek"
895"#,
896 )
897 .expect("rewrite config");
898 let c = Config::from_env().expect("case 4");
899 assert_eq!(
900 c.api_key.as_deref(),
901 Some("sk-from-env"),
902 "preset.key_env env var should be used when no explicit key anywhere"
903 );
904 }
905
906 {
908 std::env::remove_var("DEEPSEEK_API_KEY");
909 std::fs::write(
910 &config_path,
911 r#"[provider]
912preset = "this-is-not-a-preset"
913"#,
914 )
915 .expect("rewrite config");
916 let err = Config::from_env().expect_err("case 5 should fail");
917 let msg = format!("{err}");
918 assert!(msg.contains("this-is-not-a-preset"), "msg was: {msg}");
919 assert!(msg.contains("deepseek"), "msg should list valid ids: {msg}");
920 }
921
922 {
924 std::env::remove_var("DEEPSEEK_API_KEY");
925 std::env::set_var("OLLAMA_API_KEY", "should-be-ignored");
926 std::fs::write(
927 &config_path,
928 r#"[provider]
929preset = "ollama"
930"#,
931 )
932 .expect("rewrite config");
933 let c = Config::from_env().expect("case 6");
934 assert_eq!(c.preset.as_deref(), Some("ollama"));
935 assert!(
936 c.api_key.is_none(),
937 "ollama has key_env='' so the OLLAMA_API_KEY env must not be consulted"
938 );
939 }
940 std::env::remove_var("OLLAMA_API_KEY");
941
942 for (name, prev) in [
944 ("RECURSIVE_API_KEY", orig_api_key.as_deref()),
945 ("OPENAI_API_KEY", orig_openai_key.as_deref()),
946 ("RECURSIVE_MODEL", orig_model.as_deref()),
947 ("OPENAI_MODEL", orig_openai_model.as_deref()),
948 ("RECURSIVE_API_BASE", orig_api_base.as_deref()),
949 ("DEEPSEEK_API_KEY", orig_deepseek.as_deref()),
950 ("RECURSIVE_PROVIDER_TYPE", orig_provider.as_deref()),
951 ] {
952 if let Some(v) = prev {
953 std::env::set_var(name, v);
954 } else {
955 std::env::remove_var(name);
956 }
957 }
958 }
959}