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