Skip to main content

murk_cli/
env.rs

1//! Environment and `.env` file handling.
2
3use std::env;
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7
8use age::secrecy::SecretString;
9
10/// Keys to skip when importing from a .env file.
11const IMPORT_SKIP: &[&str] = &["MURK_KEY", "MURK_KEY_FILE", "MURK_VAULT"];
12
13/// File mode for `.env`: owner read/write only.
14#[cfg(unix)]
15const SECRET_FILE_MODE: u32 = 0o600;
16
17/// Bitmask for group/other permission bits.
18#[cfg(unix)]
19const WORLD_READABLE_MASK: u32 = 0o077;
20
21/// Resolve the secret key from `MURK_KEY` or `MURK_KEY_FILE`.
22/// `MURK_KEY` takes priority; `MURK_KEY_FILE` reads the key from a file.
23/// Returns the key wrapped in `SecretString` so it is zeroized on drop.
24pub fn resolve_key() -> Result<SecretString, String> {
25    if let Ok(k) = env::var("MURK_KEY") {
26        if !k.is_empty() {
27            return Ok(SecretString::from(k));
28        }
29    }
30    if let Ok(path) = env::var("MURK_KEY_FILE") {
31        return fs::read_to_string(&path)
32            .map(|contents| SecretString::from(contents.trim().to_string()))
33            .map_err(|e| format!("cannot read MURK_KEY_FILE ({path}): {e}"));
34    }
35    Err("MURK_KEY not set. Add it to .env and load with direnv or `eval $(cat .env)`. Alternatively, set MURK_KEY_FILE to a path containing the key".into())
36}
37
38/// Parse a .env file into key-value pairs.
39/// Skips comments, blank lines, `MURK_*` keys, and strips quotes and `export` prefixes.
40pub fn parse_env(contents: &str) -> Vec<(String, String)> {
41    let mut pairs = Vec::new();
42
43    for line in contents.lines() {
44        let line = line.trim();
45
46        if line.is_empty() || line.starts_with('#') {
47            continue;
48        }
49
50        let line = line.strip_prefix("export ").unwrap_or(line);
51
52        let Some((key, value)) = line.split_once('=') else {
53            continue;
54        };
55
56        let key = key.trim();
57        let value = value.trim();
58
59        // Strip surrounding quotes.
60        let value = value
61            .strip_prefix('"')
62            .and_then(|v| v.strip_suffix('"'))
63            .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
64            .unwrap_or(value);
65
66        if key.is_empty() || IMPORT_SKIP.contains(&key) {
67            continue;
68        }
69
70        pairs.push((key.into(), value.into()));
71    }
72
73    pairs
74}
75
76/// Warn if `.env` has loose permissions (Unix only).
77pub fn warn_env_permissions() {
78    #[cfg(unix)]
79    {
80        use std::os::unix::fs::PermissionsExt;
81        let env_path = Path::new(".env");
82        if env_path.exists()
83            && let Ok(meta) = fs::metadata(env_path)
84        {
85            let mode = meta.permissions().mode();
86            if mode & WORLD_READABLE_MASK != 0 {
87                eprintln!(
88                    "\x1b[1;33mwarning:\x1b[0m .env is readable by others (mode {:o}). Run: \x1b[1mchmod 600 .env\x1b[0m",
89                    mode & 0o777
90                );
91            }
92        }
93    }
94}
95
96/// Read MURK_KEY from `.env` file if present.
97///
98/// Checks for both `export MURK_KEY=...` and `MURK_KEY=...` forms.
99/// Returns the key value or `None` if not found.
100pub fn read_key_from_dotenv() -> Option<String> {
101    let contents = fs::read_to_string(".env").ok()?;
102    for line in contents.lines() {
103        let trimmed = line.trim();
104        if let Some(key) = trimmed.strip_prefix("export MURK_KEY=") {
105            return Some(key.to_string());
106        }
107        if let Some(key) = trimmed.strip_prefix("MURK_KEY=") {
108            return Some(key.to_string());
109        }
110    }
111    None
112}
113
114/// Check whether `.env` already contains a `MURK_KEY` line.
115pub fn dotenv_has_murk_key() -> bool {
116    let env_path = Path::new(".env");
117    if !env_path.exists() {
118        return false;
119    }
120    let contents = fs::read_to_string(env_path).unwrap_or_default();
121    contents
122        .lines()
123        .any(|l| l.starts_with("MURK_KEY=") || l.starts_with("export MURK_KEY="))
124}
125
126/// Write a MURK_KEY to `.env`, removing any existing MURK_KEY lines.
127/// On Unix, sets file permissions to 600 atomically at creation time to
128/// prevent a TOCTOU window where the secret key is world-readable.
129/// On non-Unix platforms, permissions are not hardened.
130pub fn write_key_to_dotenv(secret_key: &str) -> Result<(), String> {
131    let env_path = Path::new(".env");
132
133    // Read existing content (minus any MURK_KEY lines).
134    let existing = if env_path.exists() {
135        let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
136        let filtered: Vec<&str> = contents
137            .lines()
138            .filter(|l| !l.starts_with("MURK_KEY=") && !l.starts_with("export MURK_KEY="))
139            .collect();
140        filtered.join("\n") + "\n"
141    } else {
142        String::new()
143    };
144
145    let full_content = format!("{existing}export MURK_KEY={secret_key}\n");
146
147    // Write the file with restricted permissions from the start (Unix).
148    #[cfg(unix)]
149    {
150        use std::os::unix::fs::OpenOptionsExt;
151        let mut file = fs::OpenOptions::new()
152            .create(true)
153            .write(true)
154            .truncate(true)
155            .mode(SECRET_FILE_MODE)
156            .open(env_path)
157            .map_err(|e| format!("opening .env: {e}"))?;
158        file.write_all(full_content.as_bytes())
159            .map_err(|e| format!("writing .env: {e}"))?;
160    }
161
162    #[cfg(not(unix))]
163    {
164        fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
165    }
166
167    Ok(())
168}
169
170/// Status of `.envrc` after writing.
171#[derive(Debug, PartialEq, Eq)]
172pub enum EnvrcStatus {
173    /// `.envrc` already contained `murk export`.
174    AlreadyPresent,
175    /// Appended murk export line to existing `.envrc`.
176    Appended,
177    /// Created a new `.envrc` file.
178    Created,
179}
180
181/// Write a `.envrc` file for direnv integration.
182///
183/// If `.envrc` exists and already contains `murk export`, returns `AlreadyPresent`.
184/// If it exists but doesn't, appends the line. Otherwise creates the file.
185pub fn write_envrc(vault_name: &str) -> Result<EnvrcStatus, String> {
186    let envrc = Path::new(".envrc");
187    let murk_line = format!("eval \"$(murk export --vault {vault_name})\"");
188
189    if envrc.exists() {
190        let contents = fs::read_to_string(envrc).map_err(|e| format!("reading .envrc: {e}"))?;
191        if contents.contains("murk export") {
192            return Ok(EnvrcStatus::AlreadyPresent);
193        }
194        let mut file = fs::OpenOptions::new()
195            .append(true)
196            .open(envrc)
197            .map_err(|e| format!("writing .envrc: {e}"))?;
198        writeln!(file, "\n{murk_line}").map_err(|e| format!("writing .envrc: {e}"))?;
199        Ok(EnvrcStatus::Appended)
200    } else {
201        fs::write(envrc, format!("{murk_line}\n")).map_err(|e| format!("writing .envrc: {e}"))?;
202        Ok(EnvrcStatus::Created)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::sync::Mutex;
210
211    /// Tests that mutate MURK_KEY / MURK_KEY_FILE env vars must hold this lock
212    /// to avoid racing with each other (cargo test runs tests in parallel).
213    static ENV_LOCK: Mutex<()> = Mutex::new(());
214
215    /// Tests that call `std::env::set_current_dir` must hold this lock to
216    /// prevent CWD races (the working directory is process-global state).
217    static CWD_LOCK: Mutex<()> = Mutex::new(());
218
219    #[test]
220    fn parse_env_empty() {
221        assert!(parse_env("").is_empty());
222    }
223
224    #[test]
225    fn parse_env_comments_and_blanks() {
226        let input = "# comment\n\n  # another\n";
227        assert!(parse_env(input).is_empty());
228    }
229
230    #[test]
231    fn parse_env_basic() {
232        let input = "FOO=bar\nBAZ=qux\n";
233        let pairs = parse_env(input);
234        assert_eq!(
235            pairs,
236            vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())]
237        );
238    }
239
240    #[test]
241    fn parse_env_double_quotes() {
242        let pairs = parse_env("KEY=\"hello world\"\n");
243        assert_eq!(pairs, vec![("KEY".into(), "hello world".into())]);
244    }
245
246    #[test]
247    fn parse_env_single_quotes() {
248        let pairs = parse_env("KEY='hello world'\n");
249        assert_eq!(pairs, vec![("KEY".into(), "hello world".into())]);
250    }
251
252    #[test]
253    fn parse_env_export_prefix() {
254        let pairs = parse_env("export FOO=bar\n");
255        assert_eq!(pairs, vec![("FOO".into(), "bar".into())]);
256    }
257
258    #[test]
259    fn parse_env_skips_murk_keys() {
260        let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
261        let pairs = parse_env(input);
262        assert_eq!(pairs, vec![("KEEP".into(), "yes".into())]);
263    }
264
265    #[test]
266    fn parse_env_equals_in_value() {
267        let pairs = parse_env("URL=postgres://host?opt=1\n");
268        assert_eq!(pairs, vec![("URL".into(), "postgres://host?opt=1".into())]);
269    }
270
271    #[test]
272    fn parse_env_no_equals_skipped() {
273        let pairs = parse_env("not-a-valid-line\nKEY=val\n");
274        assert_eq!(pairs, vec![("KEY".into(), "val".into())]);
275    }
276
277    // ── New edge-case tests ──
278
279    #[test]
280    fn parse_env_empty_value() {
281        let pairs = parse_env("KEY=\n");
282        assert_eq!(pairs, vec![("KEY".into(), String::new())]);
283    }
284
285    #[test]
286    fn parse_env_trailing_whitespace() {
287        let pairs = parse_env("KEY=value   \n");
288        assert_eq!(pairs, vec![("KEY".into(), "value".into())]);
289    }
290
291    #[test]
292    fn parse_env_unicode_value() {
293        let pairs = parse_env("KEY=hello🔐world\n");
294        assert_eq!(pairs, vec![("KEY".into(), "hello🔐world".into())]);
295    }
296
297    #[test]
298    fn parse_env_empty_key_skipped() {
299        let pairs = parse_env("=value\n");
300        assert!(pairs.is_empty());
301    }
302
303    #[test]
304    fn parse_env_mixed_quotes_unmatched() {
305        // Mismatched quotes are not stripped.
306        let pairs = parse_env("KEY=\"hello'\n");
307        assert_eq!(pairs, vec![("KEY".into(), "\"hello'".into())]);
308    }
309
310    #[test]
311    fn parse_env_multiple_murk_vars() {
312        // All three MURK_ vars are skipped, other vars kept.
313        let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
314        let pairs = parse_env(input);
315        assert_eq!(
316            pairs,
317            vec![("A".into(), "1".into()), ("B".into(), "2".into())]
318        );
319    }
320
321    #[test]
322    fn resolve_key_from_env() {
323        let _lock = ENV_LOCK.lock().unwrap();
324        let key = "AGE-SECRET-KEY-1TEST";
325        unsafe { env::set_var("MURK_KEY", key) };
326        let result = resolve_key();
327        unsafe { env::remove_var("MURK_KEY") };
328
329        let secret = result.unwrap();
330        use age::secrecy::ExposeSecret;
331        assert_eq!(secret.expose_secret(), key);
332    }
333
334    #[test]
335    fn resolve_key_from_file() {
336        let _lock = ENV_LOCK.lock().unwrap();
337        unsafe { env::remove_var("MURK_KEY") };
338
339        let path = std::env::temp_dir().join("murk_test_key_file");
340        std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
341
342        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
343        let result = resolve_key();
344        unsafe { env::remove_var("MURK_KEY_FILE") };
345        std::fs::remove_file(&path).ok();
346
347        let secret = result.unwrap();
348        use age::secrecy::ExposeSecret;
349        assert_eq!(secret.expose_secret(), "AGE-SECRET-KEY-1FROMFILE");
350    }
351
352    #[test]
353    fn resolve_key_file_not_found() {
354        let _lock = ENV_LOCK.lock().unwrap();
355        unsafe { env::remove_var("MURK_KEY") };
356        unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
357        let result = resolve_key();
358        unsafe { env::remove_var("MURK_KEY_FILE") };
359
360        assert!(result.is_err());
361        assert!(result.unwrap_err().contains("cannot read MURK_KEY_FILE"));
362    }
363
364    #[test]
365    fn resolve_key_neither_set() {
366        let _lock = ENV_LOCK.lock().unwrap();
367        unsafe { env::remove_var("MURK_KEY") };
368        unsafe { env::remove_var("MURK_KEY_FILE") };
369        let result = resolve_key();
370
371        assert!(result.is_err());
372        assert!(result.unwrap_err().contains("MURK_KEY not set"));
373    }
374
375    #[test]
376    fn resolve_key_empty_string_treated_as_unset() {
377        let _lock = ENV_LOCK.lock().unwrap();
378        unsafe { env::set_var("MURK_KEY", "") };
379        unsafe { env::remove_var("MURK_KEY_FILE") };
380        let result = resolve_key();
381        unsafe { env::remove_var("MURK_KEY") };
382
383        assert!(result.is_err());
384        assert!(result.unwrap_err().contains("MURK_KEY not set"));
385    }
386
387    #[test]
388    fn resolve_key_murk_key_takes_priority_over_file() {
389        let _lock = ENV_LOCK.lock().unwrap();
390        let direct_key = "AGE-SECRET-KEY-1DIRECT";
391        let file_key = "AGE-SECRET-KEY-1FILE";
392
393        let path = std::env::temp_dir().join("murk_test_key_priority");
394        std::fs::write(&path, format!("{file_key}\n")).unwrap();
395
396        unsafe { env::set_var("MURK_KEY", direct_key) };
397        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
398        let result = resolve_key();
399        unsafe { env::remove_var("MURK_KEY") };
400        unsafe { env::remove_var("MURK_KEY_FILE") };
401        std::fs::remove_file(&path).ok();
402
403        let secret = result.unwrap();
404        use age::secrecy::ExposeSecret;
405        assert_eq!(secret.expose_secret(), direct_key);
406    }
407
408    #[cfg(unix)]
409    #[test]
410    fn warn_env_permissions_no_warning_on_secure_file() {
411        let _cwd = CWD_LOCK.lock().unwrap();
412        use std::os::unix::fs::PermissionsExt;
413
414        let dir = std::env::temp_dir().join("murk_test_perms");
415        let _ = std::fs::remove_dir_all(&dir);
416        std::fs::create_dir_all(&dir).unwrap();
417        let env_path = dir.join(".env");
418        std::fs::write(&env_path, "KEY=val\n").unwrap();
419        std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
420
421        // Just verify it doesn't panic — output goes to stderr.
422        let original_dir = std::env::current_dir().unwrap();
423        std::env::set_current_dir(&dir).unwrap();
424        warn_env_permissions();
425        std::env::set_current_dir(original_dir).unwrap();
426
427        std::fs::remove_dir_all(&dir).unwrap();
428    }
429
430    #[test]
431    fn read_key_from_dotenv_export_form() {
432        let _cwd = CWD_LOCK.lock().unwrap();
433        let dir = std::env::temp_dir().join("murk_test_read_dotenv_export");
434        let _ = std::fs::remove_dir_all(&dir);
435        std::fs::create_dir_all(&dir).unwrap();
436        let env_path = dir.join(".env");
437        std::fs::write(&env_path, "export MURK_KEY=AGE-SECRET-KEY-1ABC\n").unwrap();
438
439        let original_dir = std::env::current_dir().unwrap();
440        std::env::set_current_dir(&dir).unwrap();
441        let result = read_key_from_dotenv();
442        std::env::set_current_dir(original_dir).unwrap();
443
444        assert_eq!(result, Some("AGE-SECRET-KEY-1ABC".into()));
445        std::fs::remove_dir_all(&dir).unwrap();
446    }
447
448    #[test]
449    fn read_key_from_dotenv_bare_form() {
450        let _cwd = CWD_LOCK.lock().unwrap();
451        let dir = std::env::temp_dir().join("murk_test_read_dotenv_bare");
452        let _ = std::fs::remove_dir_all(&dir);
453        std::fs::create_dir_all(&dir).unwrap();
454        let env_path = dir.join(".env");
455        std::fs::write(&env_path, "MURK_KEY=AGE-SECRET-KEY-1XYZ\n").unwrap();
456
457        let original_dir = std::env::current_dir().unwrap();
458        std::env::set_current_dir(&dir).unwrap();
459        let result = read_key_from_dotenv();
460        std::env::set_current_dir(original_dir).unwrap();
461
462        assert_eq!(result, Some("AGE-SECRET-KEY-1XYZ".into()));
463        std::fs::remove_dir_all(&dir).unwrap();
464    }
465
466    #[test]
467    fn read_key_from_dotenv_missing_file() {
468        let _cwd = CWD_LOCK.lock().unwrap();
469        let dir = std::env::temp_dir().join("murk_test_read_dotenv_missing");
470        let _ = std::fs::remove_dir_all(&dir);
471        std::fs::create_dir_all(&dir).unwrap();
472
473        let original_dir = std::env::current_dir().unwrap();
474        std::env::set_current_dir(&dir).unwrap();
475        let result = read_key_from_dotenv();
476        std::env::set_current_dir(original_dir).unwrap();
477
478        assert_eq!(result, None);
479        std::fs::remove_dir_all(&dir).unwrap();
480    }
481
482    #[test]
483    fn dotenv_has_murk_key_true() {
484        let _cwd = CWD_LOCK.lock().unwrap();
485        let dir = std::env::temp_dir().join("murk_test_has_key_true");
486        let _ = std::fs::remove_dir_all(&dir);
487        std::fs::create_dir_all(&dir).unwrap();
488        std::fs::write(dir.join(".env"), "MURK_KEY=test\n").unwrap();
489
490        let original_dir = std::env::current_dir().unwrap();
491        std::env::set_current_dir(&dir).unwrap();
492        assert!(dotenv_has_murk_key());
493        std::env::set_current_dir(original_dir).unwrap();
494
495        std::fs::remove_dir_all(&dir).unwrap();
496    }
497
498    #[test]
499    fn dotenv_has_murk_key_false() {
500        let _cwd = CWD_LOCK.lock().unwrap();
501        let dir = std::env::temp_dir().join("murk_test_has_key_false");
502        let _ = std::fs::remove_dir_all(&dir);
503        std::fs::create_dir_all(&dir).unwrap();
504        std::fs::write(dir.join(".env"), "OTHER=val\n").unwrap();
505
506        let original_dir = std::env::current_dir().unwrap();
507        std::env::set_current_dir(&dir).unwrap();
508        assert!(!dotenv_has_murk_key());
509        std::env::set_current_dir(original_dir).unwrap();
510
511        std::fs::remove_dir_all(&dir).unwrap();
512    }
513
514    #[test]
515    fn dotenv_has_murk_key_no_file() {
516        let _cwd = CWD_LOCK.lock().unwrap();
517        let dir = std::env::temp_dir().join("murk_test_has_key_nofile");
518        let _ = std::fs::remove_dir_all(&dir);
519        std::fs::create_dir_all(&dir).unwrap();
520
521        let original_dir = std::env::current_dir().unwrap();
522        std::env::set_current_dir(&dir).unwrap();
523        assert!(!dotenv_has_murk_key());
524        std::env::set_current_dir(original_dir).unwrap();
525
526        std::fs::remove_dir_all(&dir).unwrap();
527    }
528
529    #[test]
530    fn write_key_to_dotenv_creates_new() {
531        let _cwd = CWD_LOCK.lock().unwrap();
532        let dir = std::env::temp_dir().join("murk_test_write_key_new");
533        let _ = std::fs::remove_dir_all(&dir);
534        std::fs::create_dir_all(&dir).unwrap();
535
536        let original_dir = std::env::current_dir().unwrap();
537        std::env::set_current_dir(&dir).unwrap();
538        write_key_to_dotenv("AGE-SECRET-KEY-1NEW").unwrap();
539
540        let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
541        assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1NEW"));
542
543        std::env::set_current_dir(original_dir).unwrap();
544        std::fs::remove_dir_all(&dir).unwrap();
545    }
546
547    #[test]
548    fn write_key_to_dotenv_replaces_existing() {
549        let _cwd = CWD_LOCK.lock().unwrap();
550        let dir = std::env::temp_dir().join("murk_test_write_key_replace");
551        let _ = std::fs::remove_dir_all(&dir);
552        std::fs::create_dir_all(&dir).unwrap();
553        std::fs::write(
554            dir.join(".env"),
555            "OTHER=keep\nMURK_KEY=old\nexport MURK_KEY=also_old\n",
556        )
557        .unwrap();
558
559        let original_dir = std::env::current_dir().unwrap();
560        std::env::set_current_dir(&dir).unwrap();
561        write_key_to_dotenv("AGE-SECRET-KEY-1REPLACED").unwrap();
562
563        let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
564        assert!(contents.contains("OTHER=keep"));
565        assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1REPLACED"));
566        assert!(!contents.contains("MURK_KEY=old"));
567        assert!(!contents.contains("also_old"));
568
569        std::env::set_current_dir(original_dir).unwrap();
570        std::fs::remove_dir_all(&dir).unwrap();
571    }
572
573    #[cfg(unix)]
574    #[test]
575    fn write_key_to_dotenv_permissions_are_600() {
576        let _cwd = CWD_LOCK.lock().unwrap();
577        use std::os::unix::fs::PermissionsExt;
578
579        let dir = std::env::temp_dir().join("murk_test_write_key_perms");
580        let _ = std::fs::remove_dir_all(&dir);
581        std::fs::create_dir_all(&dir).unwrap();
582
583        let original_dir = std::env::current_dir().unwrap();
584        std::env::set_current_dir(&dir).unwrap();
585
586        // Create new .env — should be 0o600 from the start.
587        write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST").unwrap();
588        let meta = std::fs::metadata(dir.join(".env")).unwrap();
589        assert_eq!(
590            meta.permissions().mode() & 0o777,
591            SECRET_FILE_MODE,
592            "new .env should be created with mode 600"
593        );
594
595        // Replace existing — should still be 0o600.
596        write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST2").unwrap();
597        let meta = std::fs::metadata(dir.join(".env")).unwrap();
598        assert_eq!(
599            meta.permissions().mode() & 0o777,
600            SECRET_FILE_MODE,
601            "rewritten .env should maintain mode 600"
602        );
603
604        std::env::set_current_dir(original_dir).unwrap();
605        std::fs::remove_dir_all(&dir).unwrap();
606    }
607
608    #[test]
609    fn write_envrc_creates_new() {
610        let _cwd = CWD_LOCK.lock().unwrap();
611        let dir = std::env::temp_dir().join("murk_test_envrc_new");
612        let _ = std::fs::remove_dir_all(&dir);
613        std::fs::create_dir_all(&dir).unwrap();
614
615        let original_dir = std::env::current_dir().unwrap();
616        std::env::set_current_dir(&dir).unwrap();
617        let status = write_envrc(".murk").unwrap();
618        assert_eq!(status, EnvrcStatus::Created);
619
620        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
621        assert!(contents.contains("murk export --vault .murk"));
622
623        std::env::set_current_dir(original_dir).unwrap();
624        std::fs::remove_dir_all(&dir).unwrap();
625    }
626
627    #[test]
628    fn write_envrc_appends() {
629        let _cwd = CWD_LOCK.lock().unwrap();
630        let dir = std::env::temp_dir().join("murk_test_envrc_append");
631        let _ = std::fs::remove_dir_all(&dir);
632        std::fs::create_dir_all(&dir).unwrap();
633        std::fs::write(dir.join(".envrc"), "existing content\n").unwrap();
634
635        let original_dir = std::env::current_dir().unwrap();
636        std::env::set_current_dir(&dir).unwrap();
637        let status = write_envrc(".murk").unwrap();
638        assert_eq!(status, EnvrcStatus::Appended);
639
640        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
641        assert!(contents.contains("existing content"));
642        assert!(contents.contains("murk export"));
643
644        std::env::set_current_dir(original_dir).unwrap();
645        std::fs::remove_dir_all(&dir).unwrap();
646    }
647
648    #[test]
649    fn write_envrc_already_present() {
650        let _cwd = CWD_LOCK.lock().unwrap();
651        let dir = std::env::temp_dir().join("murk_test_envrc_present");
652        let _ = std::fs::remove_dir_all(&dir);
653        std::fs::create_dir_all(&dir).unwrap();
654        std::fs::write(
655            dir.join(".envrc"),
656            "eval \"$(murk export --vault .murk)\"\n",
657        )
658        .unwrap();
659
660        let original_dir = std::env::current_dir().unwrap();
661        std::env::set_current_dir(&dir).unwrap();
662        let status = write_envrc(".murk").unwrap();
663        assert_eq!(status, EnvrcStatus::AlreadyPresent);
664
665        std::env::set_current_dir(original_dir).unwrap();
666        std::fs::remove_dir_all(&dir).unwrap();
667    }
668}