bijux_cli/features/install/
state.rs1#![forbid(unsafe_code)]
2use 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
11pub 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#[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
42pub 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
81pub fn ensure_plugins_dir(path: &Path) -> Result<(), CompatibilityError> {
83 fs::create_dir_all(path)?;
84 Ok(())
85}
86
87pub 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 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}