1use crate::error::{Error, Result};
7use crate::permissions::PermissionMode;
8use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
9use serde::Deserialize;
10use std::path::{Path, PathBuf};
11
12pub fn config_file_path() -> Option<PathBuf> {
22 if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
23 return Some(PathBuf::from(custom).join(".recursive").join("config.toml"));
24 }
25 dirs::home_dir().map(|h| h.join(".recursive").join("config.toml"))
26}
27
28#[derive(Debug, Default, Deserialize)]
30pub struct FileConfig {
31 pub provider: Option<ProviderSection>,
32 pub agent: Option<AgentSection>,
33 pub permissions: Option<PermissionsSection>,
37 #[serde(default)]
41 pub sandbox: Option<SandboxSection>,
42 #[serde(default)]
45 pub search: Option<SearchSection>,
46 #[serde(default)]
49 pub stuck: Option<StuckSection>,
50 #[serde(default)]
53 pub limits: Option<LimitsSection>,
54}
55
56#[derive(Debug, Deserialize)]
58pub struct ProviderSection {
59 #[serde(rename = "type")]
60 pub provider_type: Option<String>,
61 pub api_key: Option<String>,
62 pub api_base: Option<String>,
63 pub model: Option<String>,
64 #[serde(default)]
69 pub preset: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
74pub struct AgentSection {
75 pub max_steps: Option<usize>,
76 #[serde(default)]
80 pub max_tokens: Option<u32>,
81 pub temperature: Option<f64>,
82 pub shell_timeout_secs: Option<u64>,
83}
84
85#[derive(Debug, Default, Deserialize, Clone)]
100pub struct SandboxSection {
101 #[serde(default)]
102 pub extra_dirs: Vec<String>,
103 #[serde(default)]
104 pub extra_readonly_dirs: Vec<String>,
105}
106
107#[derive(Debug, Default, Deserialize, Clone)]
123pub struct SearchSection {
124 pub provider: Option<String>,
127 pub api_key: Option<String>,
133 pub jina_key: Option<String>,
136}
137
138#[derive(Debug, Default, Deserialize, Clone)]
150pub struct StuckSection {
151 pub window: Option<usize>,
154 pub error_rate: Option<f64>,
157}
158
159#[derive(Debug, Default, Deserialize, Clone)]
172pub struct LimitsSection {
173 pub max_search_rounds: Option<usize>,
177 pub subagent_max_depth: Option<usize>,
180 pub max_concurrent_runs: Option<usize>,
183 pub goal_eval_transcript_tail: Option<usize>,
187}
188
189#[derive(Debug, Default, Deserialize, Clone)]
190pub struct PermissionsSection {
191 #[serde(default)]
192 pub allow: Vec<String>,
193 #[serde(default)]
194 pub deny: Vec<String>,
195 #[serde(default)]
196 pub interactive: Vec<String>,
197 #[serde(default)]
199 pub plan: Vec<String>,
200 #[serde(default)]
205 pub mode: Option<PermissionMode>,
206}
207
208impl FileConfig {
209 pub fn load() -> Result<Option<Self>> {
213 let path = match config_file_path() {
214 Some(p) => p,
215 None => return Ok(None),
216 };
217 Self::load_from(&path)
218 }
219
220 pub fn load_from(path: &Path) -> Result<Option<Self>> {
222 if !path.exists() {
223 return Ok(None);
224 }
225 let content = std::fs::read_to_string(path).map_err(Error::Io)?;
226 let config: FileConfig = toml::from_str(&content).map_err(|e| Error::Config {
227 message: format!("failed to parse config file {}: {}", path.display(), e),
228 })?;
229 Ok(Some(config))
230 }
231}
232
233pub fn set_value(key: &str, value: &str) -> Result<()> {
237 if key == "provider.api_key" || key.starts_with("provider.api_key.") {
246 return Err(Error::Config {
247 message: "refusing to persist provider.api_key to ~/.recursive/config.toml.\n\
248 \n\
249 The binary reads API keys from the process env at runtime,\n\
250 never from the config file. Use one of:\n\
251 \n \
252 • export DEEPSEEK_API_KEY='<key>' in your shell rc, or\n \
253 • `recursive config set-secret <ENV_NAME> <KEY>` to write\n \
254 a 0600 shell-sourceable file at ~/.recursive/secrets.env\n\
255 \n\
256 (set the env var, or `source ~/.recursive/secrets.env`\n\
257 from your shell rc, before running `recursive`.)"
258 .into(),
259 });
260 }
261
262 let path = config_file_path().ok_or_else(|| Error::Config {
263 message: "cannot determine home directory".into(),
264 })?;
265
266 if let Some(parent) = path.parent() {
268 std::fs::create_dir_all(parent).map_err(Error::Io)?;
269 }
270
271 let content = if path.exists() {
273 std::fs::read_to_string(&path).map_err(Error::Io)?
274 } else {
275 String::new()
276 };
277
278 let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
279
280 let parts: Vec<&str> = key.splitn(2, '.').collect();
282 match parts.as_slice() {
283 [section, field] => {
284 let table = doc
285 .entry(*section)
286 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
287 if let toml::Value::Table(t) = table {
288 t.insert(field.to_string(), smart_value(value));
289 }
290 }
291 [field] => {
292 doc.insert(field.to_string(), smart_value(value));
293 }
294 _ => {
295 return Err(Error::Config {
296 message: format!("invalid key format: {key}"),
297 })
298 }
299 }
300
301 let toml_str = toml::to_string_pretty(&doc).map_err(|e| Error::Config {
302 message: format!("failed to serialize config: {}", e),
303 })?;
304 std::fs::write(&path, toml_str).map_err(Error::Io)?;
305 Ok(())
306}
307
308pub fn secrets_env_path() -> Option<PathBuf> {
311 if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
312 return Some(PathBuf::from(custom).join(".recursive").join("secrets.env"));
313 }
314 dirs::home_dir().map(|h| h.join(".recursive").join("secrets.env"))
315}
316
317pub fn set_secret(env_name: &str, value: &str) -> Result<()> {
326 let path = secrets_env_path().ok_or_else(|| Error::Config {
327 message: "cannot determine home directory".into(),
328 })?;
329
330 if let Some(parent) = path.parent() {
331 std::fs::create_dir_all(parent).map_err(Error::Io)?;
332 }
333
334 let existing = std::fs::read_to_string(&path).unwrap_or_default();
338 let prefix = format!("export {env_name}=");
339 let new_line = format!("{prefix}'{}'", shell_single_quote(value));
340
341 let mut lines: Vec<String> = existing.lines().map(String::from).collect();
342 let mut found = false;
343 for line in lines.iter_mut() {
344 if line.trim_start().starts_with(&prefix) {
345 *line = new_line.clone();
346 found = true;
347 break;
348 }
349 }
350 if !found {
351 if !existing.is_empty() && !existing.ends_with('\n') {
352 lines.push(String::new());
354 }
355 lines.push(new_line);
356 }
357
358 let mut content = String::new();
359 if existing.is_empty() {
360 content.push_str(
361 "# Generated by `recursive init` / `recursive config set-secret`.\n\
362 # Source this from your shell rc to load API keys into the env:\n\
363 # source ~/.recursive/secrets.env\n\
364 # File mode is 0600 (owner read/write only).\n",
365 );
366 }
367 content.push_str(&lines.join("\n"));
368 content.push('\n');
369
370 std::fs::write(&path, content).map_err(Error::Io)?;
371
372 #[cfg(unix)]
375 {
376 use std::os::unix::fs::PermissionsExt;
377 let perms = std::fs::Permissions::from_mode(0o600);
378 std::fs::set_permissions(&path, perms).map_err(Error::Io)?;
379 }
380
381 Ok(())
382}
383
384fn shell_single_quote(s: &str) -> String {
388 s.replace('\'', "'\\''")
389}
390
391fn smart_value(s: &str) -> toml::Value {
393 if let Ok(i) = s.parse::<i64>() {
394 toml::Value::Integer(i)
395 } else if let Ok(f) = s.parse::<f64>() {
396 toml::Value::Float(f)
397 } else if s == "true" || s == "false" {
398 toml::Value::Boolean(s == "true")
399 } else {
400 toml::Value::String(s.to_string())
401 }
402}
403
404pub fn load_layered_permissions(workspace: &Path) -> LayeredPermissionsConfig {
414 let mut config = LayeredPermissionsConfig::default();
415
416 let user_config = std::env::var_os("RECURSIVE_HOME")
421 .map(|custom| PathBuf::from(custom).join(".recursive").join("config.toml"))
422 .or_else(|| dirs::home_dir().map(|h| h.join(".recursive").join("config.toml")));
423 if let Some(path) = user_config {
424 if let Some(layer) = load_permission_layer(&path, RuleSource::User) {
425 config.layers.push(layer);
426 }
427 }
428
429 let project_path = workspace.join(".recursive").join("config.toml");
431 if let Some(layer) = load_permission_layer(&project_path, RuleSource::Project) {
432 config.layers.push(layer);
433 }
434
435 config.layers.push(PermissionLayer {
437 source: RuleSource::Session,
438 ..Default::default()
439 });
440
441 config
442}
443
444fn load_permission_layer(path: &Path, source: RuleSource) -> Option<PermissionLayer> {
448 if !path.exists() {
449 return None;
450 }
451 let content = std::fs::read_to_string(path).ok()?;
452 let file_config: FileConfig = toml::from_str(&content).ok()?;
455 let section = file_config.permissions?;
456 Some(PermissionLayer {
457 source,
458 allow: section.allow,
459 deny: section.deny,
460 interactive: section.interactive,
461 })
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn load_returns_none_when_missing() {
470 let result = FileConfig::load_from(Path::new("/nonexistent/path.toml")).unwrap();
471 assert!(result.is_none());
472 }
473
474 #[test]
475 fn load_parses_valid_toml() {
476 let tmp = tempfile::NamedTempFile::new().unwrap();
477 std::fs::write(
478 tmp.path(),
479 r#"
480[provider]
481type = "openai"
482api_key = "sk-test"
483api_base = "https://api.deepseek.com"
484model = "deepseek-chat"
485
486[agent]
487max_steps = 64
488temperature = 0.5
489"#,
490 )
491 .unwrap();
492
493 let config = FileConfig::load_from(tmp.path()).unwrap();
494 assert!(config.is_some());
495 let c = config.unwrap();
496 let p = c.provider.unwrap();
497 assert_eq!(p.provider_type.as_deref(), Some("openai"));
498 assert_eq!(p.api_key.as_deref(), Some("sk-test"));
499 assert_eq!(p.api_base.as_deref(), Some("https://api.deepseek.com"));
500 assert_eq!(p.model.as_deref(), Some("deepseek-chat"));
501 let a = c.agent.unwrap();
502 assert_eq!(a.max_steps, Some(64));
503 assert_eq!(a.temperature, Some(0.5));
504 }
505
506 #[test]
507 fn load_errors_on_malformed() {
508 let tmp = tempfile::NamedTempFile::new().unwrap();
509 std::fs::write(tmp.path(), "this is [[[not valid toml").unwrap();
510 let result = FileConfig::load_from(tmp.path());
511 assert!(result.is_err());
512 }
513
514 #[test]
515 fn smart_value_parses_types() {
516 assert_eq!(smart_value("42"), toml::Value::Integer(42));
517 assert_eq!(smart_value("0.5"), toml::Value::Float(0.5));
518 assert_eq!(smart_value("true"), toml::Value::Boolean(true));
519 assert_eq!(smart_value("hello"), toml::Value::String("hello".into()));
520 }
521
522 #[test]
523 fn parse_provider_section_with_preset() {
524 let tmp = tempfile::NamedTempFile::new().unwrap();
525 std::fs::write(
526 tmp.path(),
527 r#"
528[provider]
529preset = "deepseek"
530"#,
531 )
532 .unwrap();
533
534 let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
535 let p = config.provider.unwrap();
536 assert_eq!(p.preset.as_deref(), Some("deepseek"));
537 assert!(p.provider_type.is_none());
539 assert!(p.api_base.is_none());
540 assert!(p.model.is_none());
541 assert!(p.api_key.is_none());
542 }
543
544 #[test]
545 fn parse_search_stuck_limits_sections() {
546 let tmp = tempfile::NamedTempFile::new().unwrap();
547 std::fs::write(
548 tmp.path(),
549 r#"
550[search]
551provider = "brave"
552api_key = "BSA-test-key"
553jina_key = "jina-test-key"
554
555[stuck]
556window = 15
557error_rate = 0.7
558
559[limits]
560max_search_rounds = 5
561subagent_max_depth = 3
562max_concurrent_runs = 16
563goal_eval_transcript_tail = 20
564"#,
565 )
566 .unwrap();
567
568 let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
569
570 let s = config.search.unwrap();
572 assert_eq!(s.provider.as_deref(), Some("brave"));
573 assert_eq!(s.api_key.as_deref(), Some("BSA-test-key"));
574 assert_eq!(s.jina_key.as_deref(), Some("jina-test-key"));
575
576 let st = config.stuck.unwrap();
578 assert_eq!(st.window, Some(15));
579 assert_eq!(st.error_rate, Some(0.7));
580
581 let l = config.limits.unwrap();
583 assert_eq!(l.max_search_rounds, Some(5));
584 assert_eq!(l.subagent_max_depth, Some(3));
585 assert_eq!(l.max_concurrent_runs, Some(16));
586 assert_eq!(l.goal_eval_transcript_tail, Some(20));
587 }
588
589 #[test]
590 fn search_stuck_limits_are_optional() {
591 let tmp = tempfile::NamedTempFile::new().unwrap();
593 std::fs::write(
594 tmp.path(),
595 r#"
596[provider]
597model = "test-model"
598"#,
599 )
600 .unwrap();
601
602 let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
603 assert!(config.search.is_none());
604 assert!(config.stuck.is_none());
605 assert!(config.limits.is_none());
606 }
607
608 #[test]
609 fn set_value_preset_round_trips() {
610 let tmp = tempfile::tempdir().unwrap();
612 let path = tmp.path().join("config.toml");
613 std::fs::create_dir_all(tmp.path()).unwrap();
614 std::fs::write(&path, "[provider]\nmodel = \"x\"\n").unwrap();
615
616 let content = std::fs::read_to_string(&path).unwrap();
619 let mut doc: toml::Table = content.parse().unwrap();
620 let parts: Vec<&str> = "provider.preset".splitn(2, '.').collect();
621 if let [section, field] = parts.as_slice() {
622 let table = doc
623 .entry(*section)
624 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
625 if let toml::Value::Table(t) = table {
626 t.insert(field.to_string(), smart_value("anthropic"));
627 }
628 }
629 std::fs::write(&path, toml::to_string_pretty(&doc).unwrap()).unwrap();
630
631 let loaded = FileConfig::load_from(&path).unwrap().unwrap();
632 assert_eq!(
633 loaded.provider.unwrap().preset.as_deref(),
634 Some("anthropic")
635 );
636 }
637
638 #[test]
639 fn set_value_creates_file_and_writes() {
640 let tmp = tempfile::tempdir().unwrap();
641 let path = tmp.path().join("config.toml");
642
643 std::fs::create_dir_all(tmp.path()).unwrap();
645 let content = String::new();
647 let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
648
649 let parts: Vec<&str> = "provider.model".splitn(2, '.').collect();
650 if let [section, field] = parts.as_slice() {
651 let table = doc
652 .entry(*section)
653 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
654 if let toml::Value::Table(t) = table {
655 t.insert(field.to_string(), smart_value("deepseek-chat"));
656 }
657 }
658
659 let output = toml::to_string_pretty(&doc).unwrap();
660 std::fs::write(&path, &output).unwrap();
661
662 let loaded = FileConfig::load_from(&path).unwrap().unwrap();
664 assert_eq!(
665 loaded.provider.unwrap().model.as_deref(),
666 Some("deepseek-chat")
667 );
668 }
669
670 #[test]
671 fn test_load_layered_permissions_session_layer_always_present() {
672 let tmp = tempfile::tempdir().unwrap();
673 let workspace = tmp.path().join("workspace");
674 std::fs::create_dir_all(&workspace).unwrap();
675 let fake_home = tmp.path().join("home");
676 std::fs::create_dir_all(&fake_home).unwrap();
677
678 let _pin = crate::test_util::PinnedHome::new(&fake_home);
682 let config = load_layered_permissions(&workspace);
683
684 assert!(config
686 .layers
687 .iter()
688 .any(|l| l.source == RuleSource::Session));
689 assert_eq!(config.layers.len(), 1);
691 assert_eq!(config.layers[0].source, RuleSource::Session);
692 }
693
694 #[test]
695 fn test_load_layered_permissions_loads_user_and_project() {
696 let tmp = tempfile::tempdir().unwrap();
697 let home = tmp.path().join("home");
698 let project = tmp.path().join("project");
699
700 std::fs::create_dir_all(home.join(".recursive")).unwrap();
702 std::fs::write(
703 home.join(".recursive").join("config.toml"),
704 r#"
705[permissions]
706allow = ["Read"]
707deny = ["Bash"]
708"#,
709 )
710 .unwrap();
711
712 std::fs::create_dir_all(project.join(".recursive")).unwrap();
714 std::fs::write(
715 project.join(".recursive").join("config.toml"),
716 r#"
717[permissions]
718allow = ["Write"]
719interactive = ["delete_file"]
720"#,
721 )
722 .unwrap();
723
724 let _pin = crate::test_util::PinnedRecursiveHome::new(&home);
730
731 let config = load_layered_permissions(&project);
732
733 assert_eq!(config.layers.len(), 3);
735 assert_eq!(config.layers[0].source, RuleSource::User);
736 assert_eq!(config.layers[1].source, RuleSource::Project);
737 assert_eq!(config.layers[2].source, RuleSource::Session);
738
739 assert_eq!(config.layers[0].allow, vec!["Read"]);
741 assert_eq!(config.layers[0].deny, vec!["Bash"]);
742
743 assert_eq!(config.layers[1].allow, vec!["Write"]);
745 assert_eq!(config.layers[1].interactive, vec!["delete_file"]);
746 }
747
748 #[test]
757 fn set_value_refuses_provider_api_key() {
758 let tmp = tempfile::tempdir().unwrap();
759 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
760
761 let err = set_value(
762 "provider.api_key",
763 "sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
764 )
765 .expect_err("set_value must refuse provider.api_key");
766 let msg = format!("{err}");
767 assert!(
768 msg.contains("refusing to persist"),
769 "error should mention refusal; got: {msg}"
770 );
771 assert!(
772 msg.contains("DEEPSEEK_API_KEY") || msg.contains("set-secret"),
773 "error should steer the user to env var or set-secret; got: {msg}"
774 );
775
776 let path = config_file_path().unwrap();
778 assert!(
779 !path.exists(),
780 "config.toml must not be created when refusing the write (path={})",
781 path.display()
782 );
783 }
784
785 #[test]
786 fn set_value_refuses_provider_api_key_dotted_subkey() {
787 let tmp = tempfile::tempdir().unwrap();
788 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
789
790 let err = set_value("provider.api_key.something", "sk-abc")
791 .expect_err("set_value must refuse dotted variants of provider.api_key");
792 let msg = format!("{err}");
793 assert!(msg.contains("refusing to persist"), "got: {msg}");
794 }
795
796 #[test]
797 fn set_value_allows_non_secret_provider_keys() {
798 let tmp = tempfile::tempdir().unwrap();
801 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
802
803 set_value("provider.model", "deepseek-chat").expect("model must write");
804 set_value("provider.api_base", "https://api.deepseek.com").expect("api_base must write");
805 set_value("provider.preset", "deepseek").expect("preset must write");
806
807 let path = config_file_path().unwrap();
808 let content = std::fs::read_to_string(&path).unwrap();
809 assert!(content.contains("deepseek-chat"));
810 assert!(content.contains("https://api.deepseek.com"));
811 assert!(content.contains("deepseek"));
812 assert!(
814 !content.contains("api_key"),
815 "no api_key line should be present, got:\n{content}"
816 );
817 }
818
819 #[test]
820 fn set_secret_writes_to_secrets_env_with_0600_perms() {
821 let tmp = tempfile::tempdir().unwrap();
822 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
823
824 set_secret(
825 "DEEPSEEK_API_KEY",
826 "sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
827 )
828 .expect("set_secret should succeed");
829
830 let path = secrets_env_path().unwrap();
831 let content = std::fs::read_to_string(&path).unwrap();
832 assert!(
833 content.contains("export DEEPSEEK_API_KEY='sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa'"),
834 "expected shell-sourceable line, got:\n{content}"
835 );
836
837 #[cfg(unix)]
841 {
842 use std::os::unix::fs::PermissionsExt;
843 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
844 assert_eq!(mode, 0o600, "expected 0600 perms, got {mode:o}");
845 }
846
847 let cfg_path = config_file_path().unwrap();
849 assert!(
850 !cfg_path.exists(),
851 "config.toml must not be created as a side effect of set_secret"
852 );
853 }
854
855 #[test]
856 fn set_secret_updates_existing_line_in_place() {
857 let tmp = tempfile::tempdir().unwrap();
858 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
859
860 set_secret(
861 "DEEPSEEK_API_KEY",
862 "sk-old-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
863 )
864 .unwrap();
865 set_secret(
866 "DEEPSEEK_API_KEY",
867 "sk-new-key-bbbbbbbbbbbbbbbbbbbbbbbbbbbb",
868 )
869 .unwrap();
870
871 let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
872 assert!(
873 !content.contains("sk-old-key"),
874 "old value must be replaced, not duplicated; got:\n{content}"
875 );
876 assert!(
877 content.contains("sk-new-key"),
878 "new value must be present; got:\n{content}"
879 );
880 let count = content
882 .lines()
883 .filter(|l| l.contains("DEEPSEEK_API_KEY"))
884 .count();
885 assert_eq!(count, 1, "expected exactly one DEEPSEEK_API_KEY line");
886 }
887
888 #[test]
889 fn set_secret_preserves_other_env_vars() {
890 let tmp = tempfile::tempdir().unwrap();
891 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
892
893 set_secret(
894 "DEEPSEEK_API_KEY",
895 "sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
896 )
897 .unwrap();
898 set_secret("OPENAI_API_KEY", "sk-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
899
900 let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
901 assert!(content.contains("DEEPSEEK_API_KEY="));
902 assert!(content.contains("OPENAI_API_KEY="));
903 }
904
905 #[test]
908 fn set_value_top_level_key_roundtrips() {
909 let tmp = tempfile::tempdir().unwrap();
914 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
915
916 set_value("timeout", "30").expect("set_value with a top-level key must succeed");
919
920 let path = config_file_path().unwrap();
921 assert!(path.exists(), "config.toml must be created");
922 let content = std::fs::read_to_string(&path).unwrap();
923 assert!(
924 content.contains("timeout"),
925 "config.toml must contain the top-level key; got:\n{content}"
926 );
927 }
928
929 #[test]
930 fn set_value_dotted_key_roundtrips() {
931 let tmp = tempfile::tempdir().unwrap();
936 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
937
938 set_value("provider.model", "gpt-4o").expect("set_value with a dotted key must succeed");
939
940 let path = config_file_path().unwrap();
941 let content = std::fs::read_to_string(&path).unwrap();
942 assert!(
944 content.contains("[provider]"),
945 "dotted key must produce a [provider] section; got:\n{content}"
946 );
947 assert!(
948 content.contains("model"),
949 "model field must appear in the config; got:\n{content}"
950 );
951 let loaded = FileConfig::load_from(&path).unwrap().unwrap();
953 assert_eq!(loaded.provider.unwrap().model.as_deref(), Some("gpt-4o"));
954 }
955
956 #[test]
957 fn set_secret_escapes_single_quotes() {
958 let tmp = tempfile::tempdir().unwrap();
962 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
963
964 let weird = "sk-foo'; rm -rf ~; 'bar";
965 set_secret("DEEPSEEK_API_KEY", weird).unwrap();
966 let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
967 assert!(
972 content.contains("'\\''"),
973 "single quote should be escaped as '\\''; got:\n{content}"
974 );
975 }
976
977 #[test]
978 fn set_secret_first_write_has_no_blank_export_prefix() {
979 let tmp = tempfile::tempdir().unwrap();
982 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
983
984 set_secret(
985 "DEEPSEEK_API_KEY",
986 "sk-first-write-aaaaaaaaaaaaaaaaaaaaaaaa",
987 )
988 .unwrap();
989
990 let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
991 assert!(
992 content.contains("export DEEPSEEK_API_KEY='sk-first-write-aaaaaaaaaaaaaaaaaaaaaaaa'"),
993 "first secret must be written; got:\n{content}"
994 );
995 assert!(
996 !content.contains("\n\nexport DEEPSEEK_API_KEY"),
997 "first write must not insert a blank line before the export; got:\n{content}"
998 );
999 }
1000
1001 #[test]
1002 fn set_secret_appends_without_extra_blank_when_file_has_trailing_newline() {
1003 let tmp = tempfile::tempdir().unwrap();
1006 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
1007
1008 let path = secrets_env_path().unwrap();
1009 if let Some(parent) = path.parent() {
1010 std::fs::create_dir_all(parent).unwrap();
1011 }
1012 std::fs::write(&path, "export OTHER_KEY='keep-me'\n").unwrap();
1013
1014 set_secret(
1015 "DEEPSEEK_API_KEY",
1016 "sk-fixture-dddddddddddddddddddddddddddd",
1017 )
1018 .unwrap();
1019
1020 let content = std::fs::read_to_string(&path).unwrap();
1021 assert!(
1022 content.contains("export OTHER_KEY='keep-me'\nexport DEEPSEEK_API_KEY="),
1023 "exports must be separated by a single newline; got:\n{content}"
1024 );
1025 assert!(
1026 !content.contains("\n\nexport DEEPSEEK_API_KEY"),
1027 "must not insert a blank line before the appended export; got:\n{content}"
1028 );
1029 }
1030
1031 #[test]
1032 fn set_secret_appends_when_file_lacks_trailing_newline() {
1033 let tmp = tempfile::tempdir().unwrap();
1037 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
1038
1039 let path = secrets_env_path().unwrap();
1040 if let Some(parent) = path.parent() {
1041 std::fs::create_dir_all(parent).unwrap();
1042 }
1043 std::fs::write(&path, "export OTHER_KEY='keep-me'").unwrap();
1045
1046 set_secret(
1047 "DEEPSEEK_API_KEY",
1048 "sk-fixture-cccccccccccccccccccccccccccc",
1049 )
1050 .unwrap();
1051
1052 let content = std::fs::read_to_string(&path).unwrap();
1053 assert!(
1054 content.contains("export OTHER_KEY='keep-me'\n"),
1055 "existing line must keep its identity (newline inserted); got:\n{content}"
1056 );
1057 assert!(
1058 content.contains("export DEEPSEEK_API_KEY="),
1059 "new secret must be on its own line; got:\n{content}"
1060 );
1061 assert!(
1062 !content.contains("keep-me'export"),
1063 "must not glue the new export onto the previous line; got:\n{content}"
1064 );
1065 }
1066}