use std::path::{Path, PathBuf}; use super::database::Database; /// A discovered data/config/cache path for an AppImage. #[derive(Debug, Clone)] pub struct DiscoveredPath { pub path: PathBuf, pub path_type: PathType, pub discovery_method: DiscoveryMethod, pub confidence: Confidence, pub size_bytes: u64, pub exists: bool, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum PathType { Config, Data, Cache, State, Other, } impl PathType { pub fn as_str(&self) -> &'static str { match self { PathType::Config => "config", PathType::Data => "data", PathType::Cache => "cache", PathType::State => "state", PathType::Other => "other", } } pub fn label(&self) -> &'static str { match self { PathType::Config => "Configuration", PathType::Data => "Data", PathType::Cache => "Cache", PathType::State => "State", PathType::Other => "Other", } } pub fn icon_name(&self) -> &'static str { match self { PathType::Config => "preferences-system-symbolic", PathType::Data => "folder-documents-symbolic", PathType::Cache => "user-trash-symbolic", PathType::State => "document-properties-symbolic", PathType::Other => "folder-symbolic", } } } #[derive(Debug, Clone, Copy, PartialEq)] pub enum DiscoveryMethod { /// Matched by desktop entry ID or WM class DesktopId, /// Matched by app name in XDG directory NameMatch, /// Matched by executable name ExecMatch, /// Matched by binary name extracted from AppImage BinaryMatch, } impl DiscoveryMethod { pub fn as_str(&self) -> &'static str { match self { DiscoveryMethod::DesktopId => "desktop_id", DiscoveryMethod::NameMatch => "name_match", DiscoveryMethod::ExecMatch => "exec_match", DiscoveryMethod::BinaryMatch => "binary_match", } } } #[derive(Debug, Clone, Copy, PartialEq)] pub enum Confidence { High, Medium, Low, } impl Confidence { pub fn as_str(&self) -> &'static str { match self { Confidence::High => "high", Confidence::Medium => "medium", Confidence::Low => "low", } } pub fn badge_class(&self) -> &'static str { match self { Confidence::High => "success", Confidence::Medium => "warning", Confidence::Low => "neutral", } } } /// Summary of an AppImage's disk footprint. #[derive(Debug, Clone, Default)] pub struct FootprintSummary { pub appimage_size: u64, pub config_size: u64, pub data_size: u64, pub cache_size: u64, pub state_size: u64, pub other_size: u64, pub paths: Vec, } impl FootprintSummary { pub fn total_size(&self) -> u64 { self.appimage_size + self.config_size + self.data_size + self.cache_size + self.state_size + self.other_size } pub fn data_total(&self) -> u64 { self.config_size + self.data_size + self.cache_size + self.state_size + self.other_size } } /// Discover config/data/cache paths for an AppImage by searching XDG directories /// for name variations. pub fn discover_app_paths( app_name: Option<&str>, filename: &str, desktop_entry_content: Option<&str>, ) -> Vec { let mut results = Vec::new(); let mut seen = std::collections::HashSet::new(); // Build search terms from available identity information let mut search_terms: Vec<(String, DiscoveryMethod, Confidence)> = Vec::new(); // From desktop entry: extract desktop file ID and WM class if let Some(content) = desktop_entry_content { if let Some(wm_class) = extract_desktop_key(content, "StartupWMClass") { let lower = wm_class.to_lowercase(); search_terms.push((lower.clone(), DiscoveryMethod::DesktopId, Confidence::High)); search_terms.push((wm_class.clone(), DiscoveryMethod::DesktopId, Confidence::High)); } if let Some(exec) = extract_desktop_key(content, "Exec") { // Extract just the binary name from the Exec line let binary = exec.split_whitespace().next().unwrap_or(&exec); let binary_name = Path::new(binary) .file_name() .and_then(|n| n.to_str()) .unwrap_or(binary); if !binary_name.is_empty() && binary_name != "AppRun" { let lower = binary_name.to_lowercase(); search_terms.push((lower, DiscoveryMethod::ExecMatch, Confidence::Medium)); } } } // From app name if let Some(name) = app_name { let lower = name.to_lowercase(); // Remove spaces and special chars for directory matching let sanitized = lower.replace(' ', "").replace('-', ""); search_terms.push((lower.clone(), DiscoveryMethod::NameMatch, Confidence::Medium)); if sanitized != lower { search_terms.push((sanitized, DiscoveryMethod::NameMatch, Confidence::Low)); } // Also try with hyphens let hyphenated = lower.replace(' ', "-"); if hyphenated != lower { search_terms.push((hyphenated, DiscoveryMethod::NameMatch, Confidence::Medium)); } } // From filename (strip .AppImage extension and version suffixes) let stem = filename .strip_suffix(".AppImage") .or_else(|| filename.strip_suffix(".appimage")) .unwrap_or(filename); // Strip version suffix like -1.2.3 or _v1.2 let base = strip_version_suffix(stem); let lower = base.to_lowercase(); search_terms.push((lower, DiscoveryMethod::BinaryMatch, Confidence::Low)); // XDG base directories let home = match std::env::var("HOME") { Ok(h) => PathBuf::from(h), Err(_) => return results, }; let xdg_config = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) .unwrap_or_else(|_| home.join(".config")); let xdg_data = std::env::var("XDG_DATA_HOME") .map(PathBuf::from) .unwrap_or_else(|_| home.join(".local/share")); let xdg_cache = std::env::var("XDG_CACHE_HOME") .map(PathBuf::from) .unwrap_or_else(|_| home.join(".cache")); let xdg_state = std::env::var("XDG_STATE_HOME") .map(PathBuf::from) .unwrap_or_else(|_| home.join(".local/state")); let search_dirs = [ (&xdg_config, PathType::Config), (&xdg_data, PathType::Data), (&xdg_cache, PathType::Cache), (&xdg_state, PathType::State), ]; // Also search legacy dotfiles in $HOME for (term, method, confidence) in &search_terms { // Search XDG directories for (base_dir, path_type) in &search_dirs { if !base_dir.exists() { continue; } // Try exact match and case-insensitive match let entries = match std::fs::read_dir(base_dir) { Ok(e) => e, Err(_) => continue, }; for entry in entries.flatten() { let entry_name = entry.file_name(); let entry_str = entry_name.to_string_lossy(); let entry_lower = entry_str.to_lowercase(); if entry_lower == *term || entry_lower.starts_with(&format!("{}.", term)) || entry_lower.starts_with(&format!("{}-", term)) { let full_path = entry.path(); if seen.contains(&full_path) { continue; } seen.insert(full_path.clone()); let size = dir_size(&full_path); results.push(DiscoveredPath { path: full_path, path_type: *path_type, discovery_method: *method, confidence: *confidence, size_bytes: size, exists: true, }); } } } // Search for legacy dotfiles/dotdirs in $HOME (e.g., ~/.appname) let dotdir = home.join(format!(".{}", term)); if dotdir.exists() && !seen.contains(&dotdir) { seen.insert(dotdir.clone()); let size = dir_size(&dotdir); results.push(DiscoveredPath { path: dotdir, path_type: PathType::Config, discovery_method: *method, confidence: *confidence, size_bytes: size, exists: true, }); } } // Sort: high confidence first, then by path type results.sort_by(|a, b| { let conf_ord = confidence_rank(&a.confidence).cmp(&confidence_rank(&b.confidence)); if conf_ord != std::cmp::Ordering::Equal { return conf_ord; } a.path_type.as_str().cmp(b.path_type.as_str()) }); results } /// Discover paths and store them in the database. pub fn discover_and_store(db: &Database, appimage_id: i64, record: &crate::core::database::AppImageRecord) { let paths = discover_app_paths( record.app_name.as_deref(), &record.filename, record.desktop_entry_content.as_deref(), ); if let Err(e) = db.clear_app_data_paths(appimage_id) { log::warn!("Failed to clear app data paths for id {}: {}", appimage_id, e); } for dp in &paths { if let Err(e) = db.insert_app_data_path( appimage_id, &dp.path.to_string_lossy(), dp.path_type.as_str(), dp.discovery_method.as_str(), dp.confidence.as_str(), dp.size_bytes as i64, ) { log::warn!("Failed to insert app data path '{}' for id {}: {}", dp.path.display(), appimage_id, e); } } } /// Get a complete footprint summary for an AppImage. pub fn get_footprint(db: &Database, appimage_id: i64, appimage_size: u64) -> FootprintSummary { let stored = db.get_app_data_paths(appimage_id).unwrap_or_default(); let mut summary = FootprintSummary { appimage_size, ..Default::default() }; for record in &stored { let dp = DiscoveredPath { path: PathBuf::from(&record.path), path_type: match record.path_type.as_str() { "config" => PathType::Config, "data" => PathType::Data, "cache" => PathType::Cache, "state" => PathType::State, _ => PathType::Other, }, discovery_method: match record.discovery_method.as_str() { "desktop_id" => DiscoveryMethod::DesktopId, "name_match" => DiscoveryMethod::NameMatch, "exec_match" => DiscoveryMethod::ExecMatch, _ => DiscoveryMethod::BinaryMatch, }, confidence: match record.confidence.as_str() { "high" => Confidence::High, "medium" => Confidence::Medium, _ => Confidence::Low, }, size_bytes: record.size_bytes as u64, exists: Path::new(&record.path).exists(), }; match dp.path_type { PathType::Config => summary.config_size += dp.size_bytes, PathType::Data => summary.data_size += dp.size_bytes, PathType::Cache => summary.cache_size += dp.size_bytes, PathType::State => summary.state_size += dp.size_bytes, PathType::Other => summary.other_size += dp.size_bytes, } summary.paths.push(dp); } summary } // --- Helpers --- fn extract_desktop_key<'a>(content: &'a str, key: &str) -> Option { for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed != "[Desktop Entry]" { break; // Only look in [Desktop Entry] section } if let Some(rest) = trimmed.strip_prefix(key) { let rest = rest.trim_start(); if let Some(value) = rest.strip_prefix('=') { return Some(value.trim().to_string()); } } } None } fn strip_version_suffix(name: &str) -> &str { // Strip trailing version patterns like -1.2.3, _v2.0, -x86_64 // Check for known arch suffixes first (may contain underscores) for suffix in &["-x86_64", "-aarch64", "-arm64", "-x86", "_x86_64", "_aarch64"] { if let Some(stripped) = name.strip_suffix(suffix) { return strip_version_suffix(stripped); } } // Find last hyphen or underscore followed by a digit or 'v' if let Some(pos) = name.rfind(|c: char| c == '-' || c == '_') { let after = &name[pos + 1..]; if after.starts_with(|c: char| c.is_ascii_digit() || c == 'v') { return &name[..pos]; } } name } /// Calculate the total size of a file or directory recursively. pub fn dir_size_pub(path: &Path) -> u64 { dir_size(path) } fn dir_size(path: &Path) -> u64 { // Use symlink_metadata to avoid following symlinks outside the tree if let Ok(meta) = path.symlink_metadata() { if meta.is_file() { return meta.len(); } if meta.is_symlink() { return 0; } } let mut total = 0u64; if let Ok(entries) = std::fs::read_dir(path) { for entry in entries.flatten() { let ft = match entry.file_type() { Ok(ft) => ft, Err(_) => continue, }; // Skip symlinks to avoid counting external files or recursing out of tree if ft.is_symlink() { continue; } if ft.is_file() { total += entry.metadata().map(|m| m.len()).unwrap_or(0); } else if ft.is_dir() { total += dir_size(&entry.path()); } } } total } fn confidence_rank(c: &Confidence) -> u8 { match c { Confidence::High => 0, Confidence::Medium => 1, Confidence::Low => 2, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_strip_version_suffix() { assert_eq!(strip_version_suffix("MyApp-1.2.3"), "MyApp"); assert_eq!(strip_version_suffix("MyApp_v2.0"), "MyApp"); assert_eq!(strip_version_suffix("MyApp-x86_64"), "MyApp"); assert_eq!(strip_version_suffix("MyApp"), "MyApp"); assert_eq!(strip_version_suffix("My-App"), "My-App"); } #[test] fn test_extract_desktop_key() { let content = "[Desktop Entry]\nName=Test App\nExec=/usr/bin/test --flag\nStartupWMClass=testapp\n\n[Actions]\nNew=new"; assert_eq!(extract_desktop_key(content, "Name"), Some("Test App".into())); assert_eq!(extract_desktop_key(content, "Exec"), Some("/usr/bin/test --flag".into())); assert_eq!(extract_desktop_key(content, "StartupWMClass"), Some("testapp".into())); // Should not find keys in other sections assert_eq!(extract_desktop_key(content, "New"), None); } #[test] fn test_path_type_labels() { assert_eq!(PathType::Config.as_str(), "config"); assert_eq!(PathType::Data.as_str(), "data"); assert_eq!(PathType::Cache.as_str(), "cache"); assert_eq!(PathType::Cache.label(), "Cache"); } #[test] fn test_confidence_badge() { assert_eq!(Confidence::High.badge_class(), "success"); assert_eq!(Confidence::Medium.badge_class(), "warning"); assert_eq!(Confidence::Low.badge_class(), "neutral"); } #[test] fn test_footprint_summary_totals() { let summary = FootprintSummary { appimage_size: 100, config_size: 10, data_size: 20, cache_size: 30, state_size: 5, other_size: 0, paths: Vec::new(), }; assert_eq!(summary.total_size(), 165); assert_eq!(summary.data_total(), 65); } }