Initial upload: DotMigrate dotfiles migration tool with CI/CD
Some checks failed
CI / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-02-04 09:53:03 +00:00
parent 1eba94b36a
commit 79f03a66ab

273
src/detect/mod.rs Normal file
View File

@@ -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<String>,
pub permissions: Option<u32>,
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<String>, Option<u32>, 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<Vec<DetectedDotfile>> {
let mut detected: HashSet<PathBuf> = 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<DetectedDotfile> = 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<PathBuf>) -> 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<PathBuf>) -> Result<()> {
let system_dirs: Vec<PathBuf> = 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<PathBuf>) -> 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")),
}
}
}