Skip to main content

bijux_cli/features/install/
state.rs

1#![forbid(unsafe_code)]
2//! Mutable installation state helpers.
3
4use std::io::Write as _;
5use std::path::{Path, PathBuf};
6use std::{fs, io};
7
8use super::compatibility::CompatibilityError;
9use super::io::atomic_write_text;
10
11/// Acquire process lock for mutable state operations.
12pub fn acquire_state_lock(lock_path: &Path) -> Result<StateLockGuard, CompatibilityError> {
13    if let Some(parent) = lock_path.parent() {
14        fs::create_dir_all(parent)?;
15    }
16
17    match fs::OpenOptions::new().create_new(true).write(true).open(lock_path) {
18        Ok(mut file) => {
19            file.write_all(b"bijux-cli lock\n")?;
20            file.sync_all()?;
21            Ok(StateLockGuard { path: lock_path.to_path_buf() })
22        }
23        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
24            Err(CompatibilityError::LockHeld(lock_path.to_path_buf()))
25        }
26        Err(error) => Err(CompatibilityError::Io(error)),
27    }
28}
29
30/// Guard that removes the lock path when dropped.
31#[derive(Debug)]
32pub struct StateLockGuard {
33    path: PathBuf,
34}
35
36impl Drop for StateLockGuard {
37    fn drop(&mut self) {
38        let _ = fs::remove_file(&self.path);
39    }
40}
41
42/// Ensure history file exists and parent directory is present.
43pub fn ensure_history_file(path: &Path) -> Result<(), CompatibilityError> {
44    if let Some(parent) = path.parent() {
45        fs::create_dir_all(parent)?;
46    }
47
48    match fs::symlink_metadata(path) {
49        Ok(metadata) => {
50            if metadata.file_type().is_symlink() && fs::metadata(path).is_err() {
51                return Err(CompatibilityError::Io(io::Error::new(
52                    io::ErrorKind::InvalidInput,
53                    format!("history path is a broken symlink: {}", path.display()),
54                )));
55            }
56            if !metadata.file_type().is_file() {
57                return Err(CompatibilityError::Io(io::Error::new(
58                    io::ErrorKind::InvalidInput,
59                    format!("history path is not a regular file: {}", path.display()),
60                )));
61            }
62        }
63        Err(error) if error.kind() == io::ErrorKind::NotFound => {
64            let mut file = fs::OpenOptions::new().create_new(true).write(true).open(path)?;
65            file.write_all(b"[]\n")?;
66            file.sync_all()?;
67        }
68        Err(error) => return Err(CompatibilityError::Io(error)),
69    }
70
71    if fs::metadata(path)?.len() == 0 {
72        let mut file =
73            fs::OpenOptions::new().write(true).truncate(true).create(false).open(path)?;
74        file.write_all(b"[]\n")?;
75        file.sync_all()?;
76    }
77
78    Ok(())
79}
80
81/// Ensure plugin directory exists.
82pub fn ensure_plugins_dir(path: &Path) -> Result<(), CompatibilityError> {
83    fs::create_dir_all(path)?;
84    Ok(())
85}
86
87/// Apply deterministic compatibility migrations to config text.
88pub fn run_config_migrations(
89    config_path: &Path,
90    current_version: u32,
91) -> Result<(), CompatibilityError> {
92    if current_version == 0 || !config_path.exists() {
93        return Ok(());
94    }
95
96    let text = fs::read_to_string(config_path)?;
97    let mut migrated = text.clone();
98
99    // Normalize UTF-8 BOM and mixed line endings to keep parser behavior stable.
100    if let Some(stripped) = migrated.strip_prefix('\u{feff}') {
101        migrated = stripped.to_string();
102    }
103    migrated = migrated.replace("\r\n", "\n").replace('\r', "\n");
104
105    if migrated != text {
106        atomic_write_text(config_path, &migrated)?;
107    }
108
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114    use std::fs;
115    use std::path::PathBuf;
116    use std::time::{SystemTime, UNIX_EPOCH};
117
118    use super::super::compatibility::CompatibilityError;
119    use super::{acquire_state_lock, ensure_history_file, run_config_migrations};
120
121    fn make_temp_dir(name: &str) -> PathBuf {
122        let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos();
123        let path = std::env::temp_dir().join(format!("bijux-install-state-{name}-{nanos}"));
124        fs::create_dir_all(&path).expect("mkdir");
125        path
126    }
127
128    #[test]
129    fn lock_contention_reports_lock_held_and_unlocks_on_drop() {
130        let temp = make_temp_dir("lock-contention");
131        let lock_path = temp.join("state.lock");
132
133        let guard = acquire_state_lock(&lock_path).expect("first lock");
134        assert!(lock_path.exists(), "lock file should be created");
135        let content = fs::read_to_string(&lock_path).expect("lock content");
136        assert!(content.contains("bijux-cli lock"));
137        let err = acquire_state_lock(&lock_path).expect_err("second lock should fail");
138        assert!(matches!(err, CompatibilityError::LockHeld(_)));
139
140        drop(guard);
141        assert!(!lock_path.exists(), "lock file should be removed on drop");
142
143        let _guard2 = acquire_state_lock(&lock_path).expect("lock should be reusable after drop");
144    }
145
146    #[test]
147    fn config_migrations_normalize_line_endings_and_bom() {
148        let temp = make_temp_dir("config-migrations");
149        let path = temp.join("config.env");
150        fs::write(&path, "\u{feff}BIJUXCLI_ALPHA=1\r\nBIJUXCLI_BETA=2\r").expect("seed");
151
152        run_config_migrations(&path, 1).expect("migrate");
153
154        let migrated = fs::read_to_string(&path).expect("read migrated");
155        assert_eq!(migrated, "BIJUXCLI_ALPHA=1\nBIJUXCLI_BETA=2\n");
156    }
157
158    #[test]
159    fn ensure_history_file_rejects_directory_paths() {
160        let temp = make_temp_dir("history-dir-shape");
161        let path = temp.join("history");
162        fs::create_dir_all(&path).expect("seed directory");
163        let err = ensure_history_file(&path).expect_err("must fail");
164        assert!(matches!(err, CompatibilityError::Io(_)));
165    }
166
167    #[test]
168    fn ensure_history_file_initializes_empty_files() {
169        let temp = make_temp_dir("history-empty");
170        let path = temp.join("history.json");
171        fs::write(&path, "").expect("seed empty");
172        ensure_history_file(&path).expect("ensure");
173        let text = fs::read_to_string(path).expect("read");
174        assert_eq!(text, "[]\n");
175    }
176
177    #[cfg(unix)]
178    #[test]
179    fn ensure_history_file_rejects_broken_symlink_paths() {
180        use std::os::unix::fs::symlink;
181
182        let temp = make_temp_dir("history-broken-link");
183        let path = temp.join("history.json");
184        symlink("/tmp/does-not-exist-bijux-history-install", &path).expect("symlink");
185        let err = ensure_history_file(&path).expect_err("must fail");
186        assert!(matches!(err, CompatibilityError::Io(_)));
187    }
188}