use crate::error::{Error, Result};
use crate::permissions::PermissionMode;
use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub fn config_file_path() -> Option<PathBuf> {
if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
return Some(PathBuf::from(custom).join(".recursive").join("config.toml"));
}
dirs::home_dir().map(|h| h.join(".recursive").join("config.toml"))
}
#[derive(Debug, Default, Deserialize)]
pub struct FileConfig {
pub provider: Option<ProviderSection>,
pub agent: Option<AgentSection>,
pub permissions: Option<PermissionsSection>,
#[serde(default)]
pub sandbox: Option<SandboxSection>,
#[serde(default)]
pub search: Option<SearchSection>,
#[serde(default)]
pub stuck: Option<StuckSection>,
#[serde(default)]
pub limits: Option<LimitsSection>,
}
#[derive(Debug, Deserialize)]
pub struct ProviderSection {
#[serde(rename = "type")]
pub provider_type: Option<String>,
pub api_key: Option<String>,
pub api_base: Option<String>,
pub model: Option<String>,
#[serde(default)]
pub preset: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AgentSection {
pub max_steps: Option<usize>,
#[serde(default)]
pub max_tokens: Option<u32>,
pub temperature: Option<f64>,
pub shell_timeout_secs: Option<u64>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct SandboxSection {
#[serde(default)]
pub extra_dirs: Vec<String>,
#[serde(default)]
pub extra_readonly_dirs: Vec<String>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct SearchSection {
pub provider: Option<String>,
pub api_key: Option<String>,
pub jina_key: Option<String>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct StuckSection {
pub window: Option<usize>,
pub error_rate: Option<f64>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct LimitsSection {
pub max_search_rounds: Option<usize>,
pub subagent_max_depth: Option<usize>,
pub max_concurrent_runs: Option<usize>,
pub goal_eval_transcript_tail: Option<usize>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct PermissionsSection {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub interactive: Vec<String>,
#[serde(default)]
pub plan: Vec<String>,
#[serde(default)]
pub mode: Option<PermissionMode>,
}
impl FileConfig {
pub fn load() -> Result<Option<Self>> {
let path = match config_file_path() {
Some(p) => p,
None => return Ok(None),
};
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path).map_err(Error::Io)?;
let config: FileConfig = toml::from_str(&content).map_err(|e| Error::Config {
message: format!("failed to parse config file {}: {}", path.display(), e),
})?;
Ok(Some(config))
}
}
pub fn set_value(key: &str, value: &str) -> Result<()> {
if key == "provider.api_key" || key.starts_with("provider.api_key.") {
return Err(Error::Config {
message: "refusing to persist provider.api_key to ~/.recursive/config.toml.\n\
\n\
The binary reads API keys from the process env at runtime,\n\
never from the config file. Use one of:\n\
\n \
• export DEEPSEEK_API_KEY='<key>' in your shell rc, or\n \
• `recursive config set-secret <ENV_NAME> <KEY>` to write\n \
a 0600 shell-sourceable file at ~/.recursive/secrets.env\n\
\n\
(set the env var, or `source ~/.recursive/secrets.env`\n\
from your shell rc, before running `recursive`.)"
.into(),
});
}
let path = config_file_path().ok_or_else(|| Error::Config {
message: "cannot determine home directory".into(),
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
let content = if path.exists() {
std::fs::read_to_string(&path).map_err(Error::Io)?
} else {
String::new()
};
let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
let parts: Vec<&str> = key.splitn(2, '.').collect();
match parts.as_slice() {
[section, field] => {
let table = doc
.entry(*section)
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if let toml::Value::Table(t) = table {
t.insert(field.to_string(), smart_value(value));
}
}
[field] => {
doc.insert(field.to_string(), smart_value(value));
}
_ => {
return Err(Error::Config {
message: format!("invalid key format: {key}"),
})
}
}
let toml_str = toml::to_string_pretty(&doc).map_err(|e| Error::Config {
message: format!("failed to serialize config: {}", e),
})?;
std::fs::write(&path, toml_str).map_err(Error::Io)?;
Ok(())
}
pub fn secrets_env_path() -> Option<PathBuf> {
if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
return Some(PathBuf::from(custom).join(".recursive").join("secrets.env"));
}
dirs::home_dir().map(|h| h.join(".recursive").join("secrets.env"))
}
pub fn set_secret(env_name: &str, value: &str) -> Result<()> {
let path = secrets_env_path().ok_or_else(|| Error::Config {
message: "cannot determine home directory".into(),
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let prefix = format!("export {env_name}=");
let new_line = format!("{prefix}'{}'", shell_single_quote(value));
let mut lines: Vec<String> = existing.lines().map(String::from).collect();
let mut found = false;
for line in lines.iter_mut() {
if line.trim_start().starts_with(&prefix) {
*line = new_line.clone();
found = true;
break;
}
}
if !found {
if !existing.is_empty() && !existing.ends_with('\n') {
lines.push(String::new());
}
lines.push(new_line);
}
let mut content = String::new();
if existing.is_empty() {
content.push_str(
"# Generated by `recursive init` / `recursive config set-secret`.\n\
# Source this from your shell rc to load API keys into the env:\n\
# source ~/.recursive/secrets.env\n\
# File mode is 0600 (owner read/write only).\n",
);
}
content.push_str(&lines.join("\n"));
content.push('\n');
std::fs::write(&path, content).map_err(Error::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms).map_err(Error::Io)?;
}
Ok(())
}
fn shell_single_quote(s: &str) -> String {
s.replace('\'', "'\\''")
}
fn smart_value(s: &str) -> toml::Value {
if let Ok(i) = s.parse::<i64>() {
toml::Value::Integer(i)
} else if let Ok(f) = s.parse::<f64>() {
toml::Value::Float(f)
} else if s == "true" || s == "false" {
toml::Value::Boolean(s == "true")
} else {
toml::Value::String(s.to_string())
}
}
pub fn load_layered_permissions(workspace: &Path) -> LayeredPermissionsConfig {
let mut config = LayeredPermissionsConfig::default();
let user_config = std::env::var_os("RECURSIVE_HOME")
.map(|custom| PathBuf::from(custom).join(".recursive").join("config.toml"))
.or_else(|| dirs::home_dir().map(|h| h.join(".recursive").join("config.toml")));
if let Some(path) = user_config {
if let Some(layer) = load_permission_layer(&path, RuleSource::User) {
config.layers.push(layer);
}
}
let project_path = workspace.join(".recursive").join("config.toml");
if let Some(layer) = load_permission_layer(&project_path, RuleSource::Project) {
config.layers.push(layer);
}
config.layers.push(PermissionLayer {
source: RuleSource::Session,
..Default::default()
});
config
}
fn load_permission_layer(path: &Path, source: RuleSource) -> Option<PermissionLayer> {
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let file_config: FileConfig = toml::from_str(&content).ok()?;
let section = file_config.permissions?;
Some(PermissionLayer {
source,
allow: section.allow,
deny: section.deny,
interactive: section.interactive,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_returns_none_when_missing() {
let result = FileConfig::load_from(Path::new("/nonexistent/path.toml")).unwrap();
assert!(result.is_none());
}
#[test]
fn load_parses_valid_toml() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"
[provider]
type = "openai"
api_key = "sk-test"
api_base = "https://api.deepseek.com"
model = "deepseek-chat"
[agent]
max_steps = 64
temperature = 0.5
"#,
)
.unwrap();
let config = FileConfig::load_from(tmp.path()).unwrap();
assert!(config.is_some());
let c = config.unwrap();
let p = c.provider.unwrap();
assert_eq!(p.provider_type.as_deref(), Some("openai"));
assert_eq!(p.api_key.as_deref(), Some("sk-test"));
assert_eq!(p.api_base.as_deref(), Some("https://api.deepseek.com"));
assert_eq!(p.model.as_deref(), Some("deepseek-chat"));
let a = c.agent.unwrap();
assert_eq!(a.max_steps, Some(64));
assert_eq!(a.temperature, Some(0.5));
}
#[test]
fn load_errors_on_malformed() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "this is [[[not valid toml").unwrap();
let result = FileConfig::load_from(tmp.path());
assert!(result.is_err());
}
#[test]
fn smart_value_parses_types() {
assert_eq!(smart_value("42"), toml::Value::Integer(42));
assert_eq!(smart_value("0.5"), toml::Value::Float(0.5));
assert_eq!(smart_value("true"), toml::Value::Boolean(true));
assert_eq!(smart_value("hello"), toml::Value::String("hello".into()));
}
#[test]
fn parse_provider_section_with_preset() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"
[provider]
preset = "deepseek"
"#,
)
.unwrap();
let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
let p = config.provider.unwrap();
assert_eq!(p.preset.as_deref(), Some("deepseek"));
assert!(p.provider_type.is_none());
assert!(p.api_base.is_none());
assert!(p.model.is_none());
assert!(p.api_key.is_none());
}
#[test]
fn parse_search_stuck_limits_sections() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"
[search]
provider = "brave"
api_key = "BSA-test-key"
jina_key = "jina-test-key"
[stuck]
window = 15
error_rate = 0.7
[limits]
max_search_rounds = 5
subagent_max_depth = 3
max_concurrent_runs = 16
goal_eval_transcript_tail = 20
"#,
)
.unwrap();
let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
let s = config.search.unwrap();
assert_eq!(s.provider.as_deref(), Some("brave"));
assert_eq!(s.api_key.as_deref(), Some("BSA-test-key"));
assert_eq!(s.jina_key.as_deref(), Some("jina-test-key"));
let st = config.stuck.unwrap();
assert_eq!(st.window, Some(15));
assert_eq!(st.error_rate, Some(0.7));
let l = config.limits.unwrap();
assert_eq!(l.max_search_rounds, Some(5));
assert_eq!(l.subagent_max_depth, Some(3));
assert_eq!(l.max_concurrent_runs, Some(16));
assert_eq!(l.goal_eval_transcript_tail, Some(20));
}
#[test]
fn search_stuck_limits_are_optional() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"
[provider]
model = "test-model"
"#,
)
.unwrap();
let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
assert!(config.search.is_none());
assert!(config.stuck.is_none());
assert!(config.limits.is_none());
}
#[test]
fn set_value_preset_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(&path, "[provider]\nmodel = \"x\"\n").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let mut doc: toml::Table = content.parse().unwrap();
let parts: Vec<&str> = "provider.preset".splitn(2, '.').collect();
if let [section, field] = parts.as_slice() {
let table = doc
.entry(*section)
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if let toml::Value::Table(t) = table {
t.insert(field.to_string(), smart_value("anthropic"));
}
}
std::fs::write(&path, toml::to_string_pretty(&doc).unwrap()).unwrap();
let loaded = FileConfig::load_from(&path).unwrap().unwrap();
assert_eq!(
loaded.provider.unwrap().preset.as_deref(),
Some("anthropic")
);
}
#[test]
fn set_value_creates_file_and_writes() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
std::fs::create_dir_all(tmp.path()).unwrap();
let content = String::new();
let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
let parts: Vec<&str> = "provider.model".splitn(2, '.').collect();
if let [section, field] = parts.as_slice() {
let table = doc
.entry(*section)
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if let toml::Value::Table(t) = table {
t.insert(field.to_string(), smart_value("deepseek-chat"));
}
}
let output = toml::to_string_pretty(&doc).unwrap();
std::fs::write(&path, &output).unwrap();
let loaded = FileConfig::load_from(&path).unwrap().unwrap();
assert_eq!(
loaded.provider.unwrap().model.as_deref(),
Some("deepseek-chat")
);
}
#[test]
fn test_load_layered_permissions_session_layer_always_present() {
let tmp = tempfile::tempdir().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let fake_home = tmp.path().join("home");
std::fs::create_dir_all(&fake_home).unwrap();
let _pin = crate::test_util::PinnedHome::new(&fake_home);
let config = load_layered_permissions(&workspace);
assert!(config
.layers
.iter()
.any(|l| l.source == RuleSource::Session));
assert_eq!(config.layers.len(), 1);
assert_eq!(config.layers[0].source, RuleSource::Session);
}
#[test]
fn test_load_layered_permissions_loads_user_and_project() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let project = tmp.path().join("project");
std::fs::create_dir_all(home.join(".recursive")).unwrap();
std::fs::write(
home.join(".recursive").join("config.toml"),
r#"
[permissions]
allow = ["Read"]
deny = ["Bash"]
"#,
)
.unwrap();
std::fs::create_dir_all(project.join(".recursive")).unwrap();
std::fs::write(
project.join(".recursive").join("config.toml"),
r#"
[permissions]
allow = ["Write"]
interactive = ["delete_file"]
"#,
)
.unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(&home);
let config = load_layered_permissions(&project);
assert_eq!(config.layers.len(), 3);
assert_eq!(config.layers[0].source, RuleSource::User);
assert_eq!(config.layers[1].source, RuleSource::Project);
assert_eq!(config.layers[2].source, RuleSource::Session);
assert_eq!(config.layers[0].allow, vec!["Read"]);
assert_eq!(config.layers[0].deny, vec!["Bash"]);
assert_eq!(config.layers[1].allow, vec!["Write"]);
assert_eq!(config.layers[1].interactive, vec!["delete_file"]);
}
#[test]
fn set_value_refuses_provider_api_key() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
let err = set_value(
"provider.api_key",
"sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.expect_err("set_value must refuse provider.api_key");
let msg = format!("{err}");
assert!(
msg.contains("refusing to persist"),
"error should mention refusal; got: {msg}"
);
assert!(
msg.contains("DEEPSEEK_API_KEY") || msg.contains("set-secret"),
"error should steer the user to env var or set-secret; got: {msg}"
);
let path = config_file_path().unwrap();
assert!(
!path.exists(),
"config.toml must not be created when refusing the write (path={})",
path.display()
);
}
#[test]
fn set_value_refuses_provider_api_key_dotted_subkey() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
let err = set_value("provider.api_key.something", "sk-abc")
.expect_err("set_value must refuse dotted variants of provider.api_key");
let msg = format!("{err}");
assert!(msg.contains("refusing to persist"), "got: {msg}");
}
#[test]
fn set_value_allows_non_secret_provider_keys() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
set_value("provider.model", "deepseek-chat").expect("model must write");
set_value("provider.api_base", "https://api.deepseek.com").expect("api_base must write");
set_value("provider.preset", "deepseek").expect("preset must write");
let path = config_file_path().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("deepseek-chat"));
assert!(content.contains("https://api.deepseek.com"));
assert!(content.contains("deepseek"));
assert!(
!content.contains("api_key"),
"no api_key line should be present, got:\n{content}"
);
}
#[test]
fn set_secret_writes_to_secrets_env_with_0600_perms() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
set_secret(
"DEEPSEEK_API_KEY",
"sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.expect("set_secret should succeed");
let path = secrets_env_path().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("export DEEPSEEK_API_KEY='sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa'"),
"expected shell-sourceable line, got:\n{content}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600 perms, got {mode:o}");
}
let cfg_path = config_file_path().unwrap();
assert!(
!cfg_path.exists(),
"config.toml must not be created as a side effect of set_secret"
);
}
#[test]
fn set_secret_updates_existing_line_in_place() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
set_secret(
"DEEPSEEK_API_KEY",
"sk-old-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap();
set_secret(
"DEEPSEEK_API_KEY",
"sk-new-key-bbbbbbbbbbbbbbbbbbbbbbbbbbbb",
)
.unwrap();
let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
assert!(
!content.contains("sk-old-key"),
"old value must be replaced, not duplicated; got:\n{content}"
);
assert!(
content.contains("sk-new-key"),
"new value must be present; got:\n{content}"
);
let count = content
.lines()
.filter(|l| l.contains("DEEPSEEK_API_KEY"))
.count();
assert_eq!(count, 1, "expected exactly one DEEPSEEK_API_KEY line");
}
#[test]
fn set_secret_preserves_other_env_vars() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
set_secret(
"DEEPSEEK_API_KEY",
"sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap();
set_secret("OPENAI_API_KEY", "sk-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
assert!(content.contains("DEEPSEEK_API_KEY="));
assert!(content.contains("OPENAI_API_KEY="));
}
#[test]
fn set_value_top_level_key_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
set_value("timeout", "30").expect("set_value with a top-level key must succeed");
let path = config_file_path().unwrap();
assert!(path.exists(), "config.toml must be created");
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("timeout"),
"config.toml must contain the top-level key; got:\n{content}"
);
}
#[test]
fn set_value_dotted_key_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
set_value("provider.model", "gpt-4o").expect("set_value with a dotted key must succeed");
let path = config_file_path().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("[provider]"),
"dotted key must produce a [provider] section; got:\n{content}"
);
assert!(
content.contains("model"),
"model field must appear in the config; got:\n{content}"
);
let loaded = FileConfig::load_from(&path).unwrap().unwrap();
assert_eq!(loaded.provider.unwrap().model.as_deref(), Some("gpt-4o"));
}
#[test]
fn set_secret_escapes_single_quotes() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
let weird = "sk-foo'; rm -rf ~; 'bar";
set_secret("DEEPSEEK_API_KEY", weird).unwrap();
let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
assert!(
content.contains("'\\''"),
"single quote should be escaped as '\\''; got:\n{content}"
);
}
#[test]
fn set_secret_first_write_has_no_blank_export_prefix() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
set_secret(
"DEEPSEEK_API_KEY",
"sk-first-write-aaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap();
let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
assert!(
content.contains("export DEEPSEEK_API_KEY='sk-first-write-aaaaaaaaaaaaaaaaaaaaaaaa'"),
"first secret must be written; got:\n{content}"
);
assert!(
!content.contains("\n\nexport DEEPSEEK_API_KEY"),
"first write must not insert a blank line before the export; got:\n{content}"
);
}
#[test]
fn set_secret_appends_without_extra_blank_when_file_has_trailing_newline() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
let path = secrets_env_path().unwrap();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, "export OTHER_KEY='keep-me'\n").unwrap();
set_secret(
"DEEPSEEK_API_KEY",
"sk-fixture-dddddddddddddddddddddddddddd",
)
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("export OTHER_KEY='keep-me'\nexport DEEPSEEK_API_KEY="),
"exports must be separated by a single newline; got:\n{content}"
);
assert!(
!content.contains("\n\nexport DEEPSEEK_API_KEY"),
"must not insert a blank line before the appended export; got:\n{content}"
);
}
#[test]
fn set_secret_appends_when_file_lacks_trailing_newline() {
let tmp = tempfile::tempdir().unwrap();
let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
let path = secrets_env_path().unwrap();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, "export OTHER_KEY='keep-me'").unwrap();
set_secret(
"DEEPSEEK_API_KEY",
"sk-fixture-cccccccccccccccccccccccccccc",
)
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("export OTHER_KEY='keep-me'\n"),
"existing line must keep its identity (newline inserted); got:\n{content}"
);
assert!(
content.contains("export DEEPSEEK_API_KEY="),
"new secret must be on its own line; got:\n{content}"
);
assert!(
!content.contains("keep-me'export"),
"must not glue the new export onto the previous line; got:\n{content}"
);
}
}