Skip to main content

reef/
state.rs

1//! State file management for `reef persist state` mode.
2//!
3//! Persists exported variables across bash invocations by writing them to a
4//! file as `export KEY='value'` statements that bash can source on the next run.
5
6use std::fs;
7use std::path::Path;
8
9use crate::env_diff;
10
11/// Write exported variables to a state file as bash `export` statements.
12///
13/// Parses null-separated env output (from `env -0`), filters out bash
14/// internals, and writes `export KEY='value'` lines that bash can source.
15///
16/// # Errors
17///
18/// Returns [`std::io::Error`] if writing to `path` fails (e.g., permission
19/// denied, directory does not exist, disk full).
20pub fn save_state(path: &Path, env_data: &str) -> std::io::Result<()> {
21    let mut output = String::with_capacity(env_data.len());
22
23    for entry in env_data.split('\0') {
24        let entry = entry.trim_start_matches('\n');
25        if entry.is_empty() {
26            continue;
27        }
28        if let Some(eq_pos) = entry.find('=') {
29            let key = &entry[..eq_pos];
30            let value = &entry[eq_pos + 1..];
31
32            if key.is_empty()
33                || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
34            {
35                continue;
36            }
37            if env_diff::should_skip_var(key) {
38                continue;
39            }
40
41            output.push_str("export ");
42            output.push_str(key);
43            output.push_str("='");
44            for &b in value.as_bytes() {
45                if b == b'\'' {
46                    output.push_str("'\\''");
47                } else {
48                    output.push(b as char);
49                }
50            }
51            output.push_str("'\n");
52        }
53    }
54
55    fs::write(path, output)
56}
57
58/// Build a bash prefix that sources the state file if it exists.
59#[must_use]
60pub fn state_prefix(path: &Path) -> String {
61    let p = path.display();
62    format!("[ -f '{p}' ] && source '{p}'\n")
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use std::fs;
69
70    #[test]
71    fn save_and_read_state() {
72        let dir = std::env::temp_dir();
73        let path = dir.join("reef-test-state");
74
75        let env_data = "FOO=bar\0MY_VAR=hello world\0";
76        save_state(&path, env_data).unwrap();
77
78        let content = fs::read_to_string(&path).unwrap();
79        assert!(content.contains("export FOO='bar'"));
80        assert!(content.contains("export MY_VAR='hello world'"));
81
82        fs::remove_file(&path).ok();
83    }
84
85    #[test]
86    fn save_state_escapes_quotes() {
87        let dir = std::env::temp_dir();
88        let path = dir.join("reef-test-state-quotes");
89
90        let env_data = "QUOTED=it's a test\0";
91        save_state(&path, env_data).unwrap();
92
93        let content = fs::read_to_string(&path).unwrap();
94        assert!(content.contains("export QUOTED='it'\\''s a test'"));
95
96        fs::remove_file(&path).ok();
97    }
98
99    #[test]
100    fn save_state_skips_bash_internals() {
101        let dir = std::env::temp_dir();
102        let path = dir.join("reef-test-state-skip");
103
104        let env_data = "BASH_VERSION=5.2\0REAL_VAR=keep\0SHLVL=1\0";
105        save_state(&path, env_data).unwrap();
106
107        let content = fs::read_to_string(&path).unwrap();
108        assert!(!content.contains("BASH_VERSION"));
109        assert!(!content.contains("SHLVL"));
110        assert!(content.contains("export REAL_VAR='keep'"));
111
112        fs::remove_file(&path).ok();
113    }
114
115    #[test]
116    fn state_prefix_format() {
117        let path = Path::new("/tmp/reef-state-12345");
118        let prefix = state_prefix(path);
119        assert_eq!(
120            prefix,
121            "[ -f '/tmp/reef-state-12345' ] && source '/tmp/reef-state-12345'\n"
122        );
123    }
124}