1use crate::hash;
2use crate::scanner::EntryKind;
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
8pub struct FileStatus {
9 pub exists: bool,
10 pub content_modified: bool,
11 pub owner_changed: bool,
12 pub group_changed: bool,
13 pub mode_changed: bool,
14}
15
16impl FileStatus {
17 pub fn ok() -> Self {
18 Self {
19 exists: true,
20 content_modified: false,
21 owner_changed: false,
22 group_changed: false,
23 mode_changed: false,
24 }
25 }
26
27 pub fn missing() -> Self {
28 Self {
29 exists: false,
30 content_modified: false,
31 owner_changed: false,
32 group_changed: false,
33 mode_changed: false,
34 }
35 }
36
37 pub fn is_ok(&self) -> bool {
38 self.exists
39 && !self.content_modified
40 && !self.owner_changed
41 && !self.group_changed
42 && !self.mode_changed
43 }
44
45 pub fn is_missing(&self) -> bool {
46 !self.exists
47 }
48
49 pub fn is_modified(&self) -> bool {
50 self.content_modified
51 }
52
53 pub fn has_metadata_drift(&self) -> bool {
54 self.owner_changed || self.group_changed || self.mode_changed
55 }
56}
57
58const STATE_FILE: &str = "dotm-state.json";
59
60#[derive(Debug, Default, Serialize, Deserialize)]
61pub struct DeployState {
62 #[serde(skip)]
63 state_dir: PathBuf,
64 entries: Vec<DeployEntry>,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68pub struct DeployEntry {
69 pub target: PathBuf,
70 pub staged: PathBuf,
71 pub source: PathBuf,
72 pub content_hash: String,
73 #[serde(default)]
74 pub original_hash: Option<String>,
75 pub kind: EntryKind,
76 pub package: String,
77 #[serde(default)]
78 pub owner: Option<String>,
79 #[serde(default)]
80 pub group: Option<String>,
81 #[serde(default)]
82 pub mode: Option<String>,
83 #[serde(default)]
84 pub original_owner: Option<String>,
85 #[serde(default)]
86 pub original_group: Option<String>,
87 #[serde(default)]
88 pub original_mode: Option<String>,
89}
90
91impl DeployState {
92 pub fn new(state_dir: &Path) -> Self {
93 Self {
94 state_dir: state_dir.to_path_buf(),
95 ..Default::default()
96 }
97 }
98
99 pub fn load(state_dir: &Path) -> Result<Self> {
100 Self::migrate_storage(state_dir)?;
101 let path = state_dir.join(STATE_FILE);
102 if !path.exists() {
103 return Ok(Self::new(state_dir));
104 }
105 let content = std::fs::read_to_string(&path)
106 .with_context(|| format!("failed to read state file: {}", path.display()))?;
107 let mut state: DeployState = serde_json::from_str(&content)
108 .with_context(|| format!("failed to parse state file: {}", path.display()))?;
109 state.state_dir = state_dir.to_path_buf();
110 Ok(state)
111 }
112
113 pub fn save(&self) -> Result<()> {
114 std::fs::create_dir_all(&self.state_dir)
115 .with_context(|| format!("failed to create state directory: {}", self.state_dir.display()))?;
116 let path = self.state_dir.join(STATE_FILE);
117 let content = serde_json::to_string_pretty(self)?;
118 std::fs::write(&path, content)
119 .with_context(|| format!("failed to write state file: {}", path.display()))?;
120 Ok(())
121 }
122
123 pub fn record(&mut self, entry: DeployEntry) {
124 self.entries.push(entry);
125 }
126
127 pub fn entries(&self) -> &[DeployEntry] {
128 &self.entries
129 }
130
131 pub fn check_entry_status(&self, entry: &DeployEntry) -> FileStatus {
132 if !entry.target.exists() && !entry.target.is_symlink() {
133 return FileStatus::missing();
134 }
135
136 let mut status = FileStatus::ok();
137
138 if entry.staged.exists() {
139 if let Ok(current_hash) = hash::hash_file(&entry.staged)
140 && current_hash != entry.content_hash
141 {
142 status.content_modified = true;
143 }
144 } else {
145 return FileStatus::missing();
146 }
147
148 if let Ok((current_owner, current_group, current_mode)) =
150 crate::metadata::read_file_metadata(&entry.target)
151 {
152 if let Some(ref expected_owner) = entry.owner {
153 if current_owner != *expected_owner {
154 status.owner_changed = true;
155 }
156 }
157 if let Some(ref expected_group) = entry.group {
158 if current_group != *expected_group {
159 status.group_changed = true;
160 }
161 }
162 if let Some(ref expected_mode) = entry.mode {
163 if current_mode != *expected_mode {
164 status.mode_changed = true;
165 }
166 }
167 }
168
169 status
170 }
171
172 pub fn originals_dir(&self) -> PathBuf {
173 self.state_dir.join("originals")
174 }
175
176 pub fn store_original(&self, content_hash: &str, content: &[u8]) -> Result<()> {
177 let dir = self.originals_dir();
178 std::fs::create_dir_all(&dir)
179 .with_context(|| format!("failed to create originals directory: {}", dir.display()))?;
180 let path = dir.join(content_hash);
181 if !path.exists() {
182 std::fs::write(&path, content)
183 .with_context(|| format!("failed to store original: {}", path.display()))?;
184 }
185 Ok(())
186 }
187
188 pub fn load_original(&self, content_hash: &str) -> Result<Vec<u8>> {
189 let path = self.originals_dir().join(content_hash);
190 std::fs::read(&path)
191 .with_context(|| format!("failed to load original content: {}", path.display()))
192 }
193
194 pub fn deployed_dir(&self) -> PathBuf {
195 self.state_dir.join("deployed")
196 }
197
198 pub fn store_deployed(&self, content_hash: &str, content: &[u8]) -> Result<()> {
199 let dir = self.deployed_dir();
200 std::fs::create_dir_all(&dir)
201 .with_context(|| format!("failed to create deployed directory: {}", dir.display()))?;
202 let path = dir.join(content_hash);
203 if !path.exists() {
204 std::fs::write(&path, content)
205 .with_context(|| format!("failed to store deployed content: {}", path.display()))?;
206 }
207 Ok(())
208 }
209
210 pub fn load_deployed(&self, content_hash: &str) -> Result<Vec<u8>> {
211 let path = self.deployed_dir().join(content_hash);
212 std::fs::read(&path)
213 .with_context(|| format!("failed to load deployed content: {}", path.display()))
214 }
215
216 pub fn migrate_storage(state_dir: &Path) -> Result<()> {
217 let originals = state_dir.join("originals");
218 let deployed = state_dir.join("deployed");
219 if originals.is_dir() && !deployed.exists() {
220 std::fs::rename(&originals, &deployed)
221 .with_context(|| "failed to migrate originals/ to deployed/")?;
222 }
223 Ok(())
224 }
225
226 pub fn restore(&self, package_filter: Option<&str>) -> Result<usize> {
231 let mut restored = 0;
232
233 for entry in &self.entries {
234 if let Some(filter) = package_filter {
235 if entry.package != filter {
236 continue;
237 }
238 }
239
240 if let Some(ref orig_hash) = entry.original_hash {
241 let original_content = self.load_original(orig_hash)?;
243 std::fs::write(&entry.target, &original_content)
244 .with_context(|| format!("failed to restore: {}", entry.target.display()))?;
245
246 if entry.original_owner.is_some() || entry.original_group.is_some() {
248 let _ = crate::metadata::apply_ownership(
249 &entry.target,
250 entry.original_owner.as_deref(),
251 entry.original_group.as_deref(),
252 );
253 }
254 if let Some(ref orig_mode) = entry.original_mode {
255 let _ = crate::deployer::apply_permission_override(&entry.target, orig_mode);
256 }
257
258 restored += 1;
259 } else {
260 if entry.target.exists() || entry.target.is_symlink() {
262 std::fs::remove_file(&entry.target)
263 .with_context(|| format!("failed to remove: {}", entry.target.display()))?;
264 cleanup_empty_parents(&entry.target);
265 restored += 1;
266 }
267 }
268
269 if entry.staged != entry.target && entry.staged.exists() {
271 std::fs::remove_file(&entry.staged)
272 .with_context(|| format!("failed to remove staged: {}", entry.staged.display()))?;
273 cleanup_empty_parents(&entry.staged);
274 }
275 }
276
277 if package_filter.is_none() {
279 let deployed = self.deployed_dir();
280 if deployed.is_dir() {
281 let _ = std::fs::remove_dir_all(&deployed);
282 }
283 let originals = self.originals_dir();
284 if originals.is_dir() {
285 let _ = std::fs::remove_dir_all(&originals);
286 }
287 let state_path = self.state_dir.join(STATE_FILE);
288 if state_path.exists() {
289 std::fs::remove_file(&state_path)?;
290 }
291 }
292
293 Ok(restored)
294 }
295
296 pub fn undeploy(&self) -> Result<usize> {
298 let mut removed = 0;
299
300 for entry in &self.entries {
301 if entry.target.is_symlink() || entry.target.exists() {
302 std::fs::remove_file(&entry.target)
303 .with_context(|| format!("failed to remove target: {}", entry.target.display()))?;
304 cleanup_empty_parents(&entry.target);
305 removed += 1;
306 }
307
308 if entry.staged.exists() {
309 std::fs::remove_file(&entry.staged)
310 .with_context(|| format!("failed to remove staged file: {}", entry.staged.display()))?;
311 cleanup_empty_parents(&entry.staged);
312 }
313 }
314
315 let originals = self.originals_dir();
317 if originals.is_dir() {
318 let _ = std::fs::remove_dir_all(&originals);
319 }
320
321 let deployed = self.deployed_dir();
323 if deployed.is_dir() {
324 let _ = std::fs::remove_dir_all(&deployed);
325 }
326
327 let state_path = self.state_dir.join(STATE_FILE);
329 if state_path.exists() {
330 std::fs::remove_file(&state_path)?;
331 }
332
333 Ok(removed)
334 }
335}
336
337fn cleanup_empty_parents(path: &Path) {
338 let mut current = path.parent();
339 while let Some(parent) = current {
340 if parent == Path::new("") || parent == Path::new("/") {
341 break;
342 }
343 match std::fs::read_dir(parent) {
344 Ok(mut entries) => {
345 if entries.next().is_none() {
346 let _ = std::fs::remove_dir(parent);
347 current = parent.parent();
348 } else {
349 break;
350 }
351 }
352 Err(_) => break,
353 }
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use tempfile::TempDir;
361
362 #[test]
363 fn deployed_dir_is_separate_from_originals() {
364 let dir = TempDir::new().unwrap();
365 let state = DeployState::new(dir.path());
366 assert_ne!(state.originals_dir(), state.deployed_dir());
367 assert!(state.originals_dir().ends_with("originals"));
368 assert!(state.deployed_dir().ends_with("deployed"));
369 }
370
371 #[test]
372 fn store_and_load_deployed_content() {
373 let dir = TempDir::new().unwrap();
374 let state = DeployState::new(dir.path());
375 state.store_deployed("abc123", b"deployed file content").unwrap();
376 let loaded = state.load_deployed("abc123").unwrap();
377 assert_eq!(loaded, b"deployed file content");
378 }
379
380 #[test]
381 fn store_and_load_original_content() {
382 let dir = TempDir::new().unwrap();
383 let state = DeployState::new(dir.path());
384 state.store_original("orig456", b"original pre-existing content").unwrap();
385 let loaded = state.load_original("orig456").unwrap();
386 assert_eq!(loaded, b"original pre-existing content");
387 }
388
389 #[test]
390 fn migrate_renames_originals_to_deployed() {
391 let dir = TempDir::new().unwrap();
392 let originals = dir.path().join("originals");
393 std::fs::create_dir_all(&originals).unwrap();
394 std::fs::write(originals.join("hash1"), "content1").unwrap();
395
396 DeployState::migrate_storage(dir.path()).unwrap();
397
398 assert!(!originals.exists());
399 let deployed = dir.path().join("deployed");
400 assert!(deployed.exists());
401 assert_eq!(std::fs::read_to_string(deployed.join("hash1")).unwrap(), "content1");
402 }
403
404 #[test]
405 fn migrate_noop_if_deployed_exists() {
406 let dir = TempDir::new().unwrap();
407 let deployed = dir.path().join("deployed");
408 std::fs::create_dir_all(&deployed).unwrap();
409 std::fs::write(deployed.join("hash1"), "existing").unwrap();
410
411 let originals = dir.path().join("originals");
412 std::fs::create_dir_all(&originals).unwrap();
413 std::fs::write(originals.join("hash1"), "should not replace").unwrap();
414
415 DeployState::migrate_storage(dir.path()).unwrap();
416
417 assert_eq!(std::fs::read_to_string(deployed.join("hash1")).unwrap(), "existing");
418 }
419}