Skip to main content

recursive/
config.rs

1//! Runtime configuration.
2//!
3//! All of these can be overridden via env vars or CLI flags. Sensible
4//! defaults make the binary runnable with just `RECURSIVE_API_KEY` and
5//! `RECURSIVE_MODEL` set.
6
7use 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    /// Preset id resolved from `provider.preset` in the config file.
24    /// `None` when the user did not opt in (or the file was absent).
25    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    /// Run in headless mode: interactive tools go through external hooks
34    /// instead of waiting for terminal input. If no hook approves the call,
35    /// the tool is auto-denied.
36    pub headless: bool,
37    pub memory_summary_limit: usize,
38    /// Extended thinking budget for models that support it (e.g. Anthropic claude-3-7).
39    /// `None` = model default; `Some(0)` = disable thinking; `Some(n)` = budget_tokens.
40    pub thinking_budget: Option<u32>,
41    /// Optional display name for the session, shown in the /resume picker.
42    pub session_name: Option<String>,
43}
44
45impl Config {
46    /// Load from environment, with config file (~/.recursive/config.toml) as fallback.
47    ///
48    /// Precedence (highest first), applied to `api_base` / `api_key` / `model` /
49    /// `provider_type`:
50    ///
51    ///   1. env var (e.g. `RECURSIVE_API_BASE`, `OPENAI_API_KEY`)
52    ///   2. explicit field in the file's `[provider]` section
53    ///   3. preset field — `provider.preset = "<id>"` looks up the bundled
54    ///      `providers.toml` and takes its `api_base` / `default_model` /
55    ///      `provider_type`. For `api_key`, step 3 instead consults
56    ///      `std::env::var(preset.key_env)` (e.g. `DEEPSEEK_API_KEY`).
57    ///   4. hardcoded default
58    ///
59    /// The api_key chain is asymmetric: the *generic* env vars in step 1
60    /// (`RECURSIVE_API_KEY` / `OPENAI_API_KEY`) rank above the file's explicit
61    /// `api_key`, but a preset's *specific* env var in step 3 ranks below it.
62    /// Inverting step 3 would silently override a user's `api_key = "sk-old"`
63    /// whenever `DEEPSEEK_API_KEY` happened to be in their shell.
64    ///
65    /// The API key is optional here so commands that don't need the LLM
66    /// (e.g. `tools`, `config`) still run.
67    pub fn from_env() -> Result<Self> {
68        // Load file config (lowest priority, used as fallback)
69        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        // Resolve preset (if any). Unknown id is a hard error so users
78        // see a typo at startup rather than silent default-fallback.
79        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        // api_key chain: generic env (above file) → file explicit → preset's
109        // key_env (below file, so explicit file wins) → None.
110        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        // Assemble system prompt with memory layers.
181        // Order: most stable first (user.md), most volatile last (memory_summary).
182        // This helps LLM prefix caching.
183        let mut layers: Vec<(String, String)> = Vec::new();
184
185        // Layer 1: User preferences (global, ~/.recursive/memory/user.md)
186        if let Some(user_memory) = load_user_memory() {
187            layers.push(("# User preferences".into(), user_memory));
188        }
189
190        // Layer 2: Project memory (workspace-local, agent-writable)
191        if let Some(project_memory) = load_project_memory(&workspace) {
192            layers.push(("# Project memory".into(), project_memory));
193        }
194
195        // Layer 3: Memory summary (volatile, changes each run)
196        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        // Layer 4: Scratchpad summary
202        let scratchpad_block = scratchpad_summary(&workspace);
203        if !scratchpad_block.is_empty() {
204            layers.push(("# Scratchpad".into(), scratchpad_block));
205        }
206
207        // Layer 5: Facts summary
208        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        // Layer 6: Episodic recall summary
214        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        // Build the final system prompt: base prompt + layers
220        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    /// Return the API key or a descriptive error if none was configured.
258    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    /// Validate that the config has enough information to run the agent.
265    /// Returns a user-friendly error message if not.
266    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
382/// Maximum size for project context file (AGENTS.md) in bytes.
383/// 16 KB is enough for a detailed project context without blowing
384/// the context window.
385const MAX_PROJECT_CONTEXT_SIZE: usize = 16 * 1024;
386
387/// Maximum size for memory files (user.md, project.md) in bytes.
388const MAX_MEMORY_FILE_SIZE: usize = 8 * 1024;
389
390/// Load user-global memory from ~/.recursive/memory/user.md.
391/// Returns None if the file doesn't exist. Caps at 8KB.
392pub 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
398/// Load project-local memory (agent-writable) from workspace/.recursive/memory/project.md.
399pub fn load_project_memory(workspace: &Path) -> Option<String> {
400    let path = workspace.join(".recursive/memory/project.md");
401    load_memory_file(&path)
402}
403
404/// Load a memory file with an 8KB cap.
405fn 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
423/// Load project context from AGENTS.md at workspace root.
424///
425/// Returns the file content if present, truncated to 16 KB with a
426/// marker if larger. Returns None if absent.
427pub 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        // File is too large: read first 16 KB and append truncation marker
441        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        // Goal-167 added a task-list section; Goal-165 added Planning Mode.
462        // Bump the limit to 6 KiB to accommodate both additions.
463        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        // Ensure defaults match the hardcoded RetryPolicy::default()
490        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        // Save original env values
518        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        // Set custom values
523        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        // We need to also set required env vars to avoid errors
528        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        // Restore original env values
538        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    // NOTE: both shell_timeout_* checks live in ONE test on purpose.
556    // `cargo test` runs tests in parallel threads and `set_var` /
557    // `remove_var` are process-global, so splitting them creates a
558    // race (one test sees the other's value). MiniMax's goal-23 run
559    // burned 50 steps discovering this exact race; lesson recorded in
560    // AGENTS.md section 5.
561    #[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    // Tests for load_project_context
583    #[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        // Write 20 KB of content
599        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        // Should contain truncation marker
606        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        // No AGENTS.md file
614        let content = load_project_context(tmp.path());
615        assert!(content.is_none());
616    }
617
618    // --- Memory layer tests ---
619    //
620    // NOTE: Tests that set HOME are consolidated into ONE test to avoid
621    // races with parallel test execution (set_var is process-global).
622
623    #[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        // Write 10 KB of content
645        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        // Should contain truncation marker
653        assert!(c.contains("truncated at 8KB"));
654        // Should not contain the full content
655        assert!(c.len() < large_content.len());
656    }
657
658    // Consolidated test for all HOME-dependent memory checks.
659    // These must be ONE test because set_var("HOME", ...) is process-global
660    // and parallel tests would race on it. The PinnedHome guard holds the
661    // cross-module env lock for the whole body, so other tests that read
662    // HOME (e.g. facts, migrate, paths) cannot observe a torn-down state.
663    #[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        // Test A: load_user_memory returns content
669        {
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        // Test D: missing files return None
680        {
681            // Remove the user.md we just created
682            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        // Test E: system prompt assembly includes all available layers
693        {
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        // Test F: no memory files behaves identically. We re-pin HOME to
714        // a fresh tempdir for this case; the previous PinnedHome guard
715        // still holds the env lock, so we mutate HOME directly and the
716        // outer guard will restore it on drop.
717        {
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    // ── Goal-199: headless env var test ──────────────────────────────────
735
736    #[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        // RECURSIVE_HEADLESS not set → default false
743        {
744            std::env::remove_var("RECURSIVE_HEADLESS");
745            let config = Config::from_env().unwrap();
746            assert!(!config.headless);
747        }
748
749        // RECURSIVE_HEADLESS=1 → true
750        {
751            std::env::set_var("RECURSIVE_HEADLESS", "1");
752            let config = Config::from_env().unwrap();
753            assert!(config.headless);
754        }
755
756        // RECURSIVE_HEADLESS=true → true
757        {
758            std::env::set_var("RECURSIVE_HEADLESS", "true");
759            let config = Config::from_env().unwrap();
760            assert!(config.headless);
761        }
762
763        // RECURSIVE_HEADLESS=0 → false
764        {
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    // ── Goal: provider.preset resolution chain ─────────────────────────
779    //
780    // ONE consolidated test on purpose. Per .dev/AGENTS.md §5 and the
781    // `shell_timeout_default_and_env_override` precedent at lines 495-514,
782    // env-mutating tests cannot be split: `set_var` / `remove_var` are
783    // process-global and parallel tests would race on them. PinnedHome
784    // (test_util) holds the env lock for the whole body.
785    #[test]
786    fn provider_preset_resolution_chain() {
787        use std::sync::OnceLock;
788        // Hold the process-wide env lock for the whole test body. The
789        // test mutates RECURSIVE_API_KEY / RECURSIVE_MODEL /
790        // DEEPSEEK_API_KEY / etc. via raw std::env::set_var — those
791        // are not protected by PinnedRecursiveHome (which only pins
792        // RECURSIVE_HOME), so without this lock the test races with
793        // state::tests::detect_model_name_falls_back_to_config_file
794        // and runtime_builder::tests::offline_mode_and_config_file_resolution
795        // under parallel test load.
796        let _env_lock = crate::test_util::env_lock();
797        // One PinnedRecursiveHome for the whole test; sequential sections
798        // mutate env under its lock. We use PinnedRecursiveHome (not
799        // PinnedHome) so the test reads its own config.toml via the
800        // RECURSIVE_HOME short-circuit in `config_file_path` — pinning
801        // HOME alone still races with tests that mutate HOME without
802        // holding the env lock, because `dirs::home_dir` re-resolves
803        // through getpwuid_r on macOS.
804        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        // Save originals so we can restore on Drop-ish (test exit).
811        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        // Write a config that says: use the deepseek preset.
820        std::fs::write(
821            &config_path,
822            r#"[provider]
823preset = "deepseek"
824"#,
825        )
826        .expect("write config");
827
828        // Clear everything we touch.
829        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        // Case 1: preset fills all four fields when no env / no explicit file.
842        {
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        // Case 2: explicit `api_key` in file beats preset's key_env env var.
855        // This is the asymmetric step-3-bellow-file-explicit guarantee.
856        {
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        // Case 3: RECURSIVE_API_KEY (generic, step 1) beats explicit file api_key.
875        // This is pre-existing behavior — regression guard.
876        {
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        // Case 4: preset's key_env env var is consulted only when file
887        // api_key is absent AND no generic env is set.
888        {
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        // Case 5: unknown preset id → Error::Config with id list.
907        {
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        // Case 6: ollama (key_env == "") skips env lookup, api_key stays None.
923        {
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        // Restore originals.
943        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}