diff --git a/src/detect/mod.rs b/src/detect/mod.rs new file mode 100644 index 0000000..e268bbb --- /dev/null +++ b/src/detect/mod.rs @@ -0,0 +1,273 @@ +use crate::config::Config; +use crate::cli::Platform; +use glob::glob; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; +use sha2::{Sha256, Digest}; +use std::fs; +use std::io::{self, Read}; +use anyhow::{Context, Result}; + +#[derive(Debug, Clone)] +pub struct DetectedDotfile { + pub path: PathBuf, + pub content_hash: Option, + pub permissions: Option, + pub size: u64, + pub platform: Platform, +} + +impl DetectedDotfile { + pub fn new(path: PathBuf) -> Self { + let (hash, perms, size) = Self::get_file_info(&path); + Self { + path, + content_hash: hash, + permissions: perms, + size, + platform: Platform::current(), + } + } + + fn get_file_info(path: &PathBuf) -> (Option, Option, u64) { + let metadata = match fs::metadata(path) { + Ok(m) => m, + Err(_) => return (None, None, 0), + }; + + let size = metadata.len(); + + let perms = if metadata.permissions().readonly() { + Some(0o444) + } else { + Some(0o644) + }; + + let mut file = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return (None, perms, size), + }; + + let mut hasher = Sha256::new(); + let mut buffer = Vec::new(); + match file.read_to_end(&mut buffer) { + Ok(_) => { + hasher.update(&buffer); + let hash = format!("{:x}", hasher.finalize()); + (Some(hash), perms, size) + } + Err(_) => (None, perms, size), + } + } +} + +pub struct Detector<'a> { + config: &'a Config, + home_dir: PathBuf, + platform: Platform, +} + +impl<'a> Detector<'a> { + pub fn new(config: &'a Config) -> Self { + let home_dir = dirs::home_dir() + .expect("Home directory not found"); + let platform = Platform::current(); + + Self { + config, + home_dir, + platform, + } + } + + pub fn detect_dotfiles(&self, include_system: bool) -> Result> { + let mut detected: HashSet = HashSet::new(); + + if include_system { + self.scan_system_directories(&mut detected) + .context("Failed to scan system directories")?; + } + + self.scan_home_directory(&mut detected) + .context("Failed to scan home directory")?; + + self.scan_config_patterns(&mut detected) + .context("Failed to scan config patterns")?; + + let mut result: Vec = detected + .into_iter() + .filter(|p| self.should_include(p)) + .map(DetectedDotfile::new) + .collect(); + + result.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(result) + } + + fn scan_home_directory(&self, detected: &mut HashSet) -> Result<()> { + let walker = WalkDir::new(&self.home_dir) + .max_depth(self.config.detect.scan_depth) + .follow_links(false); + + for entry in walker { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + let relative = self.get_relative_path(path); + if self.is_hidden_or_config(&relative) { + detected.insert(path.to_path_buf()); + } + } + } + + Ok(()) + } + + fn scan_system_directories(&self, detected: &mut HashSet) -> Result<()> { + let system_dirs: Vec = match self.platform { + Platform::Linux | Platform::Wsl => vec![ + PathBuf::from("/etc"), + PathBuf::from("/usr/local"), + ], + Platform::Macos => vec![ + PathBuf::from("/etc"), + PathBuf::from("/usr/local"), + PathBuf::from("/Library/Application Support"), + ], + }; + + for dir in &system_dirs { + if dir.exists() { + let walker = WalkDir::new(dir) + .max_depth(3) + .follow_links(false); + + for entry in walker { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + let relative = path.strip_prefix("/") + .unwrap_or_else(|| path) + .to_path_buf(); + if self.is_hidden_or_config(&relative) { + detected.insert(path.to_path_buf()); + } + } + } + } + } + + Ok(()) + } + + fn scan_config_patterns(&self, detected: &mut HashSet) -> Result<()> { + for pattern in &self.config.detect.patterns { + let full_pattern = self.home_dir.join(pattern); + if let Some(pattern_str) = full_pattern.to_str() { + for entry in glob(pattern_str)? { + match entry { + Ok(path) => { + if path.is_file() { + detected.insert(path); + } + } + Err(e) => { + log::debug!("Glob pattern error: {}", e); + } + } + } + } + } + + Ok(()) + } + + fn is_hidden_or_config(&self, path: &Path) -> bool { + if let Some(name) = path.file_name() { + if name.to_string_lossy().starts_with('.') { + return true; + } + } + + let path_str = path.to_string_lossy(); + if path_str.contains(".config/") || path_str.contains(".local/") { + return true; + } + + if self.platform == Platform::Macos { + if path_str.contains("Library/Application Support/") { + return true; + } + } + + false + } + + fn get_relative_path(&self, path: &Path) -> PathBuf { + if let Ok(rel) = path.strip_prefix(&self.home_dir) { + rel.to_path_buf() + } else { + path.to_path_buf() + } + } + + fn should_include(&self, path: &Path) -> bool { + let path_str = path.to_string_lossy(); + + for exclude in &self.config.detect.exclude_patterns { + if path_str.contains(exclude) { + return false; + } + } + + true + } +} + +impl Platform { + pub fn current() -> Self { + match std::env::consts::OS { + "linux" => { + if std::env::var("WSL_DISTRO_NAME").is_ok() { + Platform::Wsl + } else { + Platform::Linux + } + } + "macos" => Platform::Macos, + "windows" => Platform::Wsl, + _ => Platform::Linux, + } + } + + pub fn config_dir(&self) -> PathBuf { + match self { + Platform::Linux => dirs::home_dir() + .map(|p| p.join(".config")) + .unwrap_or_else(|| PathBuf::from(".config")), + Platform::Macos => dirs::home_dir() + .map(|p| p.join("Library/Application Support")) + .unwrap_or_else(|| PathBuf::from("Library/Application Support")), + Platform::Wsl => dirs::home_dir() + .map(|p| p.join(".config")) + .unwrap_or_else(|| PathBuf::from(".config")), + } + } + + pub fn data_dir(&self) -> PathBuf { + match self { + Platform::Linux => dirs::home_dir() + .map(|p| p.join(".local/share")) + .unwrap_or_else(|| PathBuf::from(".local/share")), + Platform::Macos => dirs::home_dir() + .map(|p| p.join("Library/Application Support")) + .unwrap_or_else(|| PathBuf::from("Library/Application Support")), + Platform::Wsl => dirs::home_dir() + .map(|p| p.join(".local/share")) + .unwrap_or_else(|| PathBuf::from(".local/share")), + } + } +}