490 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|