Implement Driftwood AppImage manager - Phases 1 and 2

Phase 1 - Application scaffolding:
- GTK4/libadwaita application window with AdwNavigationView
- GSettings-backed window state persistence
- GResource-compiled CSS and schema
- Library view with grid/list toggle, search, sorting, filtering
- Detail view with file info, desktop integration controls
- Preferences window with scan directories, theme, behavior settings
- CLI with list, scan, integrate, remove, clean, inspect commands
- AppImage discovery, metadata extraction, desktop integration
- Orphaned desktop entry detection and cleanup
- AppImage packaging script

Phase 2 - Intelligence layer:
- Database schema v2 with migration for status tracking columns
- FUSE detection engine (libfuse2/3, fusermount, /dev/fuse, AppImageLauncher)
- Wayland awareness engine (session type, toolkit detection, XWayland)
- Update info parsing from AppImage ELF sections (.upd_info)
- GitHub/GitLab Releases API integration for update checking
- Update download with progress tracking and atomic apply
- Launch wrapper with FUSE auto-detection and usage tracking
- Duplicate and multi-version detection with recommendations
- Dashboard with system health, library stats, disk usage
- Update check dialog (single and batch)
- Duplicate resolution dialog
- Status badges on library cards and detail view
- Extended CLI: status, check-updates, duplicates, launch commands

49 tests passing across all modules.
This commit is contained in:
lashman
2026-02-26 23:04:27 +02:00
parent 588b1b1525
commit fa28955919
33 changed files with 10401 additions and 0 deletions

777
src/core/database.rs Normal file
View File

@@ -0,0 +1,777 @@
use rusqlite::{params, Connection, Result as SqlResult};
use std::path::PathBuf;
pub struct Database {
conn: Connection,
}
#[derive(Debug, Clone)]
pub struct AppImageRecord {
pub id: i64,
pub path: String,
pub filename: String,
pub app_name: Option<String>,
pub app_version: Option<String>,
pub appimage_type: Option<i32>,
pub size_bytes: i64,
pub sha256: Option<String>,
pub icon_path: Option<String>,
pub desktop_file: Option<String>,
pub integrated: bool,
pub integrated_at: Option<String>,
pub is_executable: bool,
pub desktop_entry_content: Option<String>,
pub categories: Option<String>,
pub description: Option<String>,
pub developer: Option<String>,
pub architecture: Option<String>,
pub first_seen: String,
pub last_scanned: String,
pub file_modified: Option<String>,
// Phase 2 fields
pub fuse_status: Option<String>,
pub wayland_status: Option<String>,
pub update_info: Option<String>,
pub update_type: Option<String>,
pub latest_version: Option<String>,
pub update_checked: Option<String>,
pub update_url: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone)]
pub struct OrphanedEntry {
pub id: i64,
pub desktop_file: String,
pub original_path: Option<String>,
pub app_name: Option<String>,
pub detected_at: String,
pub cleaned: bool,
}
#[derive(Debug, Clone)]
pub struct LaunchEvent {
pub id: i64,
pub appimage_id: i64,
pub launched_at: String,
pub source: String,
}
#[derive(Debug, Clone)]
pub struct UpdateHistoryEntry {
pub id: i64,
pub appimage_id: i64,
pub from_version: Option<String>,
pub to_version: Option<String>,
pub update_method: Option<String>,
pub download_size: Option<i64>,
pub updated_at: String,
pub success: bool,
}
fn db_path() -> PathBuf {
let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("driftwood");
std::fs::create_dir_all(&data_dir).ok();
data_dir.join("driftwood.db")
}
impl Database {
pub fn open() -> SqlResult<Self> {
let path = db_path();
let conn = Connection::open(&path)?;
let db = Self { conn };
db.init_schema()?;
Ok(db)
}
pub fn open_in_memory() -> SqlResult<Self> {
let conn = Connection::open_in_memory()?;
let db = Self { conn };
db.init_schema()?;
Ok(db)
}
fn init_schema(&self) -> SqlResult<()> {
// Phase 1 base tables
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS appimages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
app_name TEXT,
app_version TEXT,
appimage_type INTEGER,
size_bytes INTEGER NOT NULL DEFAULT 0,
sha256 TEXT,
icon_path TEXT,
desktop_file TEXT,
integrated INTEGER NOT NULL DEFAULT 0,
integrated_at TEXT,
is_executable INTEGER NOT NULL DEFAULT 0,
desktop_entry_content TEXT,
categories TEXT,
description TEXT,
developer TEXT,
architecture TEXT,
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_scanned TEXT NOT NULL DEFAULT (datetime('now')),
file_modified TEXT
);
CREATE TABLE IF NOT EXISTS orphaned_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
desktop_file TEXT NOT NULL,
original_path TEXT,
app_name TEXT,
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
cleaned INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS scan_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_type TEXT NOT NULL,
directories TEXT,
found INTEGER NOT NULL DEFAULT 0,
new_count INTEGER NOT NULL DEFAULT 0,
removed INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
scanned_at TEXT NOT NULL DEFAULT (datetime('now'))
);"
)?;
// Check current schema version and migrate
let count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM schema_version",
[],
|row| row.get(0),
)?;
let current_version = if count == 0 {
self.conn.execute(
"INSERT INTO schema_version (version) VALUES (?1)",
params![1],
)?;
1
} else {
self.conn.query_row(
"SELECT version FROM schema_version LIMIT 1",
[],
|row| row.get::<_, i32>(0),
)?
};
if current_version < 2 {
self.migrate_to_v2()?;
}
Ok(())
}
fn migrate_to_v2(&self) -> SqlResult<()> {
// Add Phase 2 columns to appimages table
let phase2_columns = [
"fuse_status TEXT",
"wayland_status TEXT",
"update_info TEXT",
"update_type TEXT",
"latest_version TEXT",
"update_checked TEXT",
"update_url TEXT",
"notes TEXT",
];
for col in &phase2_columns {
let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col);
// Ignore errors from columns that already exist
self.conn.execute_batch(&sql).ok();
}
// Phase 2 tables
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS launch_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
launched_at TEXT NOT NULL DEFAULT (datetime('now')),
source TEXT NOT NULL DEFAULT 'desktop_entry'
);
CREATE TABLE IF NOT EXISTS update_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
from_version TEXT,
to_version TEXT,
update_method TEXT,
download_size INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
success INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS duplicate_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
canonical_name TEXT NOT NULL,
duplicate_type TEXT,
detected_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS duplicate_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER REFERENCES duplicate_groups(id) ON DELETE CASCADE,
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
is_recommended INTEGER NOT NULL DEFAULT 0
);"
)?;
// Update schema version
self.conn.execute(
"UPDATE schema_version SET version = ?1",
params![2],
)?;
Ok(())
}
pub fn upsert_appimage(
&self,
path: &str,
filename: &str,
appimage_type: Option<i32>,
size_bytes: i64,
is_executable: bool,
file_modified: Option<&str>,
) -> SqlResult<i64> {
self.conn.execute(
"INSERT INTO appimages (path, filename, appimage_type, size_bytes, is_executable, file_modified)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(path) DO UPDATE SET
filename = excluded.filename,
appimage_type = excluded.appimage_type,
size_bytes = excluded.size_bytes,
is_executable = excluded.is_executable,
file_modified = excluded.file_modified,
last_scanned = datetime('now')",
params![path, filename, appimage_type, size_bytes, is_executable, file_modified],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn update_metadata(
&self,
id: i64,
app_name: Option<&str>,
app_version: Option<&str>,
description: Option<&str>,
developer: Option<&str>,
categories: Option<&str>,
architecture: Option<&str>,
icon_path: Option<&str>,
desktop_entry_content: Option<&str>,
) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET
app_name = ?2,
app_version = ?3,
description = ?4,
developer = ?5,
categories = ?6,
architecture = ?7,
icon_path = ?8,
desktop_entry_content = ?9
WHERE id = ?1",
params![
id, app_name, app_version, description, developer,
categories, architecture, icon_path, desktop_entry_content,
],
)?;
Ok(())
}
pub fn update_sha256(&self, id: i64, sha256: &str) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET sha256 = ?2 WHERE id = ?1",
params![id, sha256],
)?;
Ok(())
}
pub fn set_integrated(
&self,
id: i64,
integrated: bool,
desktop_file: Option<&str>,
) -> SqlResult<()> {
let integrated_at = if integrated {
Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string())
} else {
None
};
self.conn.execute(
"UPDATE appimages SET integrated = ?2, desktop_file = ?3, integrated_at = ?4 WHERE id = ?1",
params![id, integrated, desktop_file, integrated_at],
)?;
Ok(())
}
const APPIMAGE_COLUMNS: &str =
"id, path, filename, app_name, app_version, appimage_type,
size_bytes, sha256, icon_path, desktop_file, integrated,
integrated_at, is_executable, desktop_entry_content,
categories, description, developer, architecture,
first_seen, last_scanned, file_modified,
fuse_status, wayland_status, update_info, update_type,
latest_version, update_checked, update_url, notes";
fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result<AppImageRecord> {
Ok(AppImageRecord {
id: row.get(0)?,
path: row.get(1)?,
filename: row.get(2)?,
app_name: row.get(3)?,
app_version: row.get(4)?,
appimage_type: row.get(5)?,
size_bytes: row.get(6)?,
sha256: row.get(7)?,
icon_path: row.get(8)?,
desktop_file: row.get(9)?,
integrated: row.get(10)?,
integrated_at: row.get(11)?,
is_executable: row.get(12)?,
desktop_entry_content: row.get(13)?,
categories: row.get(14)?,
description: row.get(15)?,
developer: row.get(16)?,
architecture: row.get(17)?,
first_seen: row.get(18)?,
last_scanned: row.get(19)?,
file_modified: row.get(20)?,
fuse_status: row.get(21)?,
wayland_status: row.get(22)?,
update_info: row.get(23)?,
update_type: row.get(24)?,
latest_version: row.get(25)?,
update_checked: row.get(26)?,
update_url: row.get(27)?,
notes: row.get(28)?,
})
}
pub fn get_all_appimages(&self) -> SqlResult<Vec<AppImageRecord>> {
let sql = format!(
"SELECT {} FROM appimages ORDER BY app_name COLLATE NOCASE, filename",
Self::APPIMAGE_COLUMNS
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map([], Self::row_to_record)?;
rows.collect()
}
pub fn get_appimage_by_id(&self, id: i64) -> SqlResult<Option<AppImageRecord>> {
let sql = format!(
"SELECT {} FROM appimages WHERE id = ?1",
Self::APPIMAGE_COLUMNS
);
let mut stmt = self.conn.prepare(&sql)?;
let mut rows = stmt.query_map(params![id], Self::row_to_record)?;
Ok(rows.next().transpose()?)
}
pub fn get_appimage_by_path(&self, path: &str) -> SqlResult<Option<AppImageRecord>> {
let sql = format!(
"SELECT {} FROM appimages WHERE path = ?1",
Self::APPIMAGE_COLUMNS
);
let mut stmt = self.conn.prepare(&sql)?;
let mut rows = stmt.query_map(params![path], Self::row_to_record)?;
Ok(rows.next().transpose()?)
}
pub fn remove_appimage(&self, id: i64) -> SqlResult<()> {
self.conn.execute("DELETE FROM appimages WHERE id = ?1", params![id])?;
Ok(())
}
pub fn remove_missing_appimages(&self) -> SqlResult<Vec<AppImageRecord>> {
let all = self.get_all_appimages()?;
let mut removed = Vec::new();
for record in all {
if !std::path::Path::new(&record.path).exists() {
self.remove_appimage(record.id)?;
removed.push(record);
}
}
Ok(removed)
}
pub fn add_orphaned_entry(
&self,
desktop_file: &str,
original_path: Option<&str>,
app_name: Option<&str>,
) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO orphaned_entries (desktop_file, original_path, app_name) VALUES (?1, ?2, ?3)",
params![desktop_file, original_path, app_name],
)?;
Ok(())
}
pub fn get_orphaned_entries(&self) -> SqlResult<Vec<OrphanedEntry>> {
let mut stmt = self.conn.prepare(
"SELECT id, desktop_file, original_path, app_name, detected_at, cleaned
FROM orphaned_entries WHERE cleaned = 0"
)?;
let rows = stmt.query_map([], |row| {
Ok(OrphanedEntry {
id: row.get(0)?,
desktop_file: row.get(1)?,
original_path: row.get(2)?,
app_name: row.get(3)?,
detected_at: row.get(4)?,
cleaned: row.get(5)?,
})
})?;
rows.collect()
}
pub fn mark_orphan_cleaned(&self, id: i64) -> SqlResult<()> {
self.conn.execute(
"UPDATE orphaned_entries SET cleaned = 1 WHERE id = ?1",
params![id],
)?;
Ok(())
}
pub fn log_scan(
&self,
scan_type: &str,
directories: &[String],
found: i32,
new_count: i32,
removed: i32,
duration_ms: i64,
) -> SqlResult<()> {
let dirs_joined = directories.join(";");
self.conn.execute(
"INSERT INTO scan_log (scan_type, directories, found, new_count, removed, duration_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![scan_type, dirs_joined, found, new_count, removed, duration_ms],
)?;
Ok(())
}
pub fn appimage_count(&self) -> SqlResult<i64> {
self.conn.query_row("SELECT COUNT(*) FROM appimages", [], |row| row.get(0))
}
// --- Phase 2: Status updates ---
pub fn update_fuse_status(&self, id: i64, status: &str) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET fuse_status = ?2 WHERE id = ?1",
params![id, status],
)?;
Ok(())
}
pub fn update_wayland_status(&self, id: i64, status: &str) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET wayland_status = ?2 WHERE id = ?1",
params![id, status],
)?;
Ok(())
}
pub fn update_update_info(
&self,
id: i64,
update_info: Option<&str>,
update_type: Option<&str>,
) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET update_info = ?2, update_type = ?3 WHERE id = ?1",
params![id, update_info, update_type],
)?;
Ok(())
}
pub fn set_update_available(
&self,
id: i64,
latest_version: Option<&str>,
update_url: Option<&str>,
) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET latest_version = ?2, update_url = ?3,
update_checked = datetime('now') WHERE id = ?1",
params![id, latest_version, update_url],
)?;
Ok(())
}
pub fn clear_update_available(&self, id: i64) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET latest_version = NULL, update_url = NULL,
update_checked = datetime('now') WHERE id = ?1",
params![id],
)?;
Ok(())
}
pub fn get_appimages_with_updates(&self) -> SqlResult<Vec<AppImageRecord>> {
let sql = format!(
"SELECT {} FROM appimages WHERE latest_version IS NOT NULL
ORDER BY app_name COLLATE NOCASE, filename",
Self::APPIMAGE_COLUMNS
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map([], Self::row_to_record)?;
rows.collect()
}
// --- Phase 2: Launch tracking ---
pub fn record_launch(&self, appimage_id: i64, source: &str) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO launch_events (appimage_id, source) VALUES (?1, ?2)",
params![appimage_id, source],
)?;
Ok(())
}
pub fn get_launch_count(&self, appimage_id: i64) -> SqlResult<i64> {
self.conn.query_row(
"SELECT COUNT(*) FROM launch_events WHERE appimage_id = ?1",
params![appimage_id],
|row| row.get(0),
)
}
pub fn get_last_launched(&self, appimage_id: i64) -> SqlResult<Option<String>> {
self.conn.query_row(
"SELECT MAX(launched_at) FROM launch_events WHERE appimage_id = ?1",
params![appimage_id],
|row| row.get(0),
)
}
pub fn get_launch_events(&self, appimage_id: i64) -> SqlResult<Vec<LaunchEvent>> {
let mut stmt = self.conn.prepare(
"SELECT id, appimage_id, launched_at, source
FROM launch_events WHERE appimage_id = ?1
ORDER BY launched_at DESC"
)?;
let rows = stmt.query_map(params![appimage_id], |row| {
Ok(LaunchEvent {
id: row.get(0)?,
appimage_id: row.get(1)?,
launched_at: row.get(2)?,
source: row.get(3)?,
})
})?;
rows.collect()
}
// --- Phase 2: Update history ---
pub fn record_update(
&self,
appimage_id: i64,
from_version: Option<&str>,
to_version: Option<&str>,
update_method: Option<&str>,
download_size: Option<i64>,
success: bool,
) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO update_history
(appimage_id, from_version, to_version, update_method, download_size, success)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![appimage_id, from_version, to_version, update_method, download_size, success],
)?;
Ok(())
}
pub fn get_update_history(&self, appimage_id: i64) -> SqlResult<Vec<UpdateHistoryEntry>> {
let mut stmt = self.conn.prepare(
"SELECT id, appimage_id, from_version, to_version, update_method,
download_size, updated_at, success
FROM update_history WHERE appimage_id = ?1
ORDER BY updated_at DESC"
)?;
let rows = stmt.query_map(params![appimage_id], |row| {
Ok(UpdateHistoryEntry {
id: row.get(0)?,
appimage_id: row.get(1)?,
from_version: row.get(2)?,
to_version: row.get(3)?,
update_method: row.get(4)?,
download_size: row.get(5)?,
updated_at: row.get(6)?,
success: row.get(7)?,
})
})?;
rows.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_and_query() {
let db = Database::open_in_memory().unwrap();
assert_eq!(db.appimage_count().unwrap(), 0);
db.upsert_appimage(
"/home/user/Apps/test.AppImage",
"test.AppImage",
Some(2),
1024000,
true,
None,
).unwrap();
assert_eq!(db.appimage_count().unwrap(), 1);
let all = db.get_all_appimages().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].filename, "test.AppImage");
assert_eq!(all[0].size_bytes, 1024000);
assert!(all[0].is_executable);
}
#[test]
fn test_upsert_updates_existing() {
let db = Database::open_in_memory().unwrap();
db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 2000, true, None).unwrap();
assert_eq!(db.appimage_count().unwrap(), 1);
let record = db.get_appimage_by_path("/path/test.AppImage").unwrap().unwrap();
assert_eq!(record.size_bytes, 2000);
}
#[test]
fn test_metadata_update() {
let db = Database::open_in_memory().unwrap();
let id = db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
db.update_metadata(
id,
Some("Test App"),
Some("1.0.0"),
Some("A test application"),
Some("Test Dev"),
Some("Utility;Development"),
Some("x86_64"),
Some("/path/to/icon.png"),
Some("[Desktop Entry]\nName=Test App"),
).unwrap();
let record = db.get_appimage_by_id(id).unwrap().unwrap();
assert_eq!(record.app_name.as_deref(), Some("Test App"));
assert_eq!(record.app_version.as_deref(), Some("1.0.0"));
assert_eq!(record.architecture.as_deref(), Some("x86_64"));
}
#[test]
fn test_integration_toggle() {
let db = Database::open_in_memory().unwrap();
let id = db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
assert!(!db.get_appimage_by_id(id).unwrap().unwrap().integrated);
db.set_integrated(id, true, Some("/path/to/desktop")).unwrap();
let record = db.get_appimage_by_id(id).unwrap().unwrap();
assert!(record.integrated);
assert!(record.integrated_at.is_some());
db.set_integrated(id, false, None).unwrap();
assert!(!db.get_appimage_by_id(id).unwrap().unwrap().integrated);
}
#[test]
fn test_orphaned_entries() {
let db = Database::open_in_memory().unwrap();
db.add_orphaned_entry("/path/to/desktop", Some("/path/app.AppImage"), Some("App")).unwrap();
let orphans = db.get_orphaned_entries().unwrap();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].app_name.as_deref(), Some("App"));
db.mark_orphan_cleaned(orphans[0].id).unwrap();
assert_eq!(db.get_orphaned_entries().unwrap().len(), 0);
}
#[test]
fn test_scan_log() {
let db = Database::open_in_memory().unwrap();
db.log_scan("manual", &["~/Applications".into()], 5, 3, 0, 250).unwrap();
}
#[test]
fn test_phase2_status_updates() {
let db = Database::open_in_memory().unwrap();
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
db.update_fuse_status(id, "fully_functional").unwrap();
db.update_wayland_status(id, "native").unwrap();
db.update_update_info(id, Some("gh-releases-zsync|user|repo|latest|*.zsync"), Some("github")).unwrap();
db.set_update_available(id, Some("2.0.0"), Some("https://example.com/app-2.0.AppImage")).unwrap();
let record = db.get_appimage_by_id(id).unwrap().unwrap();
assert_eq!(record.fuse_status.as_deref(), Some("fully_functional"));
assert_eq!(record.wayland_status.as_deref(), Some("native"));
assert_eq!(record.update_type.as_deref(), Some("github"));
assert_eq!(record.latest_version.as_deref(), Some("2.0.0"));
assert!(record.update_checked.is_some());
// Updates available query
let with_updates = db.get_appimages_with_updates().unwrap();
assert_eq!(with_updates.len(), 1);
db.clear_update_available(id).unwrap();
let record = db.get_appimage_by_id(id).unwrap().unwrap();
assert!(record.latest_version.is_none());
}
#[test]
fn test_launch_tracking() {
let db = Database::open_in_memory().unwrap();
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
assert_eq!(db.get_launch_count(id).unwrap(), 0);
assert!(db.get_last_launched(id).unwrap().is_none());
db.record_launch(id, "desktop_entry").unwrap();
db.record_launch(id, "cli").unwrap();
assert_eq!(db.get_launch_count(id).unwrap(), 2);
assert!(db.get_last_launched(id).unwrap().is_some());
let events = db.get_launch_events(id).unwrap();
assert_eq!(events.len(), 2);
let sources: Vec<&str> = events.iter().map(|e| e.source.as_str()).collect();
assert!(sources.contains(&"desktop_entry"));
assert!(sources.contains(&"cli"));
}
#[test]
fn test_update_history() {
let db = Database::open_in_memory().unwrap();
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
db.record_update(id, Some("1.0"), Some("2.0"), Some("full_download"), Some(50_000_000), true).unwrap();
let history = db.get_update_history(id).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].from_version.as_deref(), Some("1.0"));
assert_eq!(history[0].to_version.as_deref(), Some("2.0"));
assert!(history[0].success);
}
}