circuitpython_deploy/
file_ops.rs

1use crate::error::{CpdError, Result};
2use indicatif::{ProgressBar, ProgressStyle};
3use std::fs;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7pub struct FileOperations {
8    verbose: bool,
9}
10
11impl FileOperations {
12    pub fn new(verbose: bool) -> Self {
13        Self { verbose }
14    }
15
16    /// Copy a single file from source to destination
17    pub fn copy_file(&self, from: &Path, to: &Path) -> Result<()> {
18        if let Some(parent) = to.parent() {
19            fs::create_dir_all(parent).map_err(|e| {
20                if self.verbose {
21                    eprintln!("Failed to create directory {}: {}", parent.display(), e);
22                }
23                CpdError::Io(e)
24            })?;
25        }
26
27        fs::copy(from, to).map_err(|e| {
28            if self.verbose {
29                eprintln!("Failed to copy {} to {}: {}", from.display(), to.display(), e);
30            }
31            CpdError::FileCopyFailed {
32                from: from.display().to_string(),
33                to: to.display().to_string(),
34            }
35        })?;
36
37        // Preserve timestamps
38        if let Ok(metadata) = fs::metadata(from) {
39            if let Ok(modified) = metadata.modified() {
40                let _ = filetime::set_file_mtime(to, filetime::FileTime::from_system_time(modified));
41            }
42        }
43
44        if self.verbose {
45            println!("Copied: {} -> {}", from.display(), to.display());
46        }
47
48        Ok(())
49    }
50
51    /// Copy directory contents with progress tracking
52    pub fn copy_directory_contents(
53        &self,
54        from_dir: &Path,
55        to_dir: &Path,
56        filter: &dyn Fn(&Path) -> bool,
57        dry_run: bool,
58    ) -> Result<CopyResult> {
59        let mut files_to_copy = Vec::new();
60        let mut _total_size = 0u64;
61
62        // First pass: collect files and calculate total size
63        for entry in WalkDir::new(from_dir)
64            .into_iter()
65            .filter_map(|e| e.ok())
66            .filter(|e| e.file_type().is_file())
67        {
68            let path = entry.path();
69            if filter(path) {
70                if let Ok(metadata) = entry.metadata() {
71                    _total_size += metadata.len();
72                }
73                files_to_copy.push(path.to_path_buf());
74            }
75        }
76
77        let progress = if !dry_run && !files_to_copy.is_empty() {
78            let pb = ProgressBar::new(files_to_copy.len() as u64);
79            pb.set_style(
80                ProgressStyle::default_bar()
81                    .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")
82                    .unwrap()
83                    .progress_chars("##-"),
84            );
85            Some(pb)
86        } else {
87            None
88        };
89
90        let mut result = CopyResult {
91            files_copied: 0,
92            files_failed: 0,
93            bytes_copied: 0,
94            failed_files: Vec::new(),
95        };
96
97        // Second pass: copy files
98        for file_path in &files_to_copy {
99            let relative_path = file_path.strip_prefix(from_dir).unwrap();
100            let dest_path = to_dir.join(relative_path);
101
102            if let Some(pb) = &progress {
103                pb.set_message(format!("Copying {}", relative_path.display()));
104            }
105
106            if dry_run {
107                println!("Would copy: {} -> {}", file_path.display(), dest_path.display());
108                result.files_copied += 1;
109            } else {
110                match self.copy_file(file_path, &dest_path) {
111                    Ok(_) => {
112                        result.files_copied += 1;
113                        if let Ok(metadata) = fs::metadata(file_path) {
114                            result.bytes_copied += metadata.len();
115                        }
116                    }
117                    Err(e) => {
118                        result.files_failed += 1;
119                        result.failed_files.push((file_path.clone(), e.to_string()));
120                        
121                        // Continue with other files if error is recoverable
122                        if !e.is_recoverable() {
123                            if let Some(pb) = &progress {
124                                pb.finish_with_message("Deployment failed");
125                            }
126                            return Err(e);
127                        }
128                    }
129                }
130            }
131
132            if let Some(pb) = &progress {
133                pb.inc(1);
134            }
135        }
136
137        if let Some(pb) = &progress {
138            pb.finish_with_message("Deployment completed");
139        }
140
141        Ok(result)
142    }
143
144    /// Create a backup of the destination directory
145    pub fn create_backup(&self, source_dir: &Path, backup_dir: &Path) -> Result<()> {
146        if !source_dir.exists() {
147            return Ok(()); // Nothing to backup
148        }
149
150        fs::create_dir_all(backup_dir).map_err(|_| CpdError::BackupDirectoryCreationFailed {
151            path: backup_dir.display().to_string(),
152        })?;
153
154        let result = self.copy_directory_contents(
155            source_dir,
156            backup_dir,
157            &|_| true, // Backup everything
158            false,
159        )?;
160
161        if self.verbose {
162            println!(
163                "Backup completed: {} files, {} bytes",
164                result.files_copied,
165                format_bytes(result.bytes_copied)
166            );
167        }
168
169        Ok(())
170    }
171
172    /// Remove files that don't exist in source (for clean deployment)
173    #[allow(dead_code)]
174    pub fn clean_destination(&self, source_dir: &Path, dest_dir: &Path, filter: &dyn Fn(&Path) -> bool) -> Result<()> {
175        if !dest_dir.exists() {
176            return Ok(());
177        }
178
179        let mut files_to_remove = Vec::new();
180
181        for entry in WalkDir::new(dest_dir)
182            .into_iter()
183            .filter_map(|e| e.ok())
184            .filter(|e| e.file_type().is_file())
185        {
186            let dest_path = entry.path();
187            let relative_path = dest_path.strip_prefix(dest_dir).unwrap();
188            let source_path = source_dir.join(relative_path);
189
190            // If the file doesn't exist in source or would be filtered out, mark for removal
191            if !source_path.exists() || !filter(&source_path) {
192                files_to_remove.push(dest_path.to_path_buf());
193            }
194        }
195
196        for file_path in files_to_remove {
197            if let Err(e) = fs::remove_file(&file_path) {
198                if self.verbose {
199                    eprintln!("Failed to remove {}: {}", file_path.display(), e);
200                }
201            } else if self.verbose {
202                println!("Removed: {}", file_path.display());
203            }
204        }
205
206        Ok(())
207    }
208}
209
210#[derive(Debug)]
211pub struct CopyResult {
212    pub files_copied: usize,
213    pub files_failed: usize,
214    pub bytes_copied: u64,
215    pub failed_files: Vec<(PathBuf, String)>,
216}
217
218impl CopyResult {
219    #[allow(dead_code)]
220    pub fn is_success(&self) -> bool {
221        self.files_failed == 0
222    }
223
224    pub fn summary(&self) -> String {
225        if self.files_failed == 0 {
226            format!(
227                "Successfully copied {} files ({})",
228                self.files_copied,
229                format_bytes(self.bytes_copied)
230            )
231        } else {
232            format!(
233                "Copied {} files, {} failed ({})",
234                self.files_copied,
235                self.files_failed,
236                format_bytes(self.bytes_copied)
237            )
238        }
239    }
240}
241
242fn format_bytes(bytes: u64) -> String {
243    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
244    let mut size = bytes as f64;
245    let mut unit_index = 0;
246
247    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
248        size /= 1024.0;
249        unit_index += 1;
250    }
251
252    if unit_index == 0 {
253        format!("{} {}", bytes, UNITS[unit_index])
254    } else {
255        format!("{:.1} {}", size, UNITS[unit_index])
256    }
257}
258
259// Add filetime dependency to Cargo.toml for timestamp preservation
260extern crate filetime;
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_format_bytes() {
268        assert_eq!(format_bytes(0), "0 B");
269        assert_eq!(format_bytes(512), "512 B");
270        assert_eq!(format_bytes(1024), "1.0 KB");
271        assert_eq!(format_bytes(1536), "1.5 KB");
272        assert_eq!(format_bytes(1048576), "1.0 MB");
273        assert_eq!(format_bytes(1073741824), "1.0 GB");
274    }
275}