Files
driftwood/src/core/footprint.rs

490 lines
16 KiB
Rust

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<DiscoveredPath>,
}
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<DiscoveredPath> {
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<String> {
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);
}
}