use std::{cmp::Reverse, collections::BTreeMap, path::PathBuf}; use tokio::fs; use crate::adapters::AppInfo; /// A platform-level cache of app metadata discovered from live devices. #[derive(Debug, Clone, Default)] pub struct AppCache { /// The normalized platform identifier for the cache file. pub platform: String, /// The apps currently known for that platform. pub apps: Vec, } /// Persisted store for per-platform app cache files. #[derive(Debug, Clone)] pub struct AppCacheStore { root_dir: PathBuf, } impl AppCacheStore { /// Create a cache store rooted at the given directory. pub fn new(root_dir: PathBuf) -> Self { Self { root_dir } } /// Load the app cache for a single platform or return an empty cache when absent. pub async fn load_platform(&self, platform: &str) -> anyhow::Result { let path = self.platform_path(platform); let apps = match fs::read_to_string(&path).await { Ok(contents) => serde_json::from_str::>(&contents)?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(error) => return Err(error.into()), }; Ok(AppCache { platform: platform.to_string(), apps, }) } /// Persist the app list for a platform. pub async fn save_platform(&self, platform: &str, apps: &[AppInfo]) -> anyhow::Result<()> { fs::create_dir_all(&self.root_dir).await?; let contents = serde_json::to_string_pretty(apps)?; fs::write(self.platform_path(platform), contents).await?; Ok(()) } /// Merge newly seen apps into the normalized, de-duplicated platform cache. pub async fn record_platform_apps( &self, platform: &str, apps: Vec, ) -> anyhow::Result { let mut deduped = BTreeMap::new(); for app in self.load_platform(platform).await?.apps { deduped.insert(app.platform_id.clone(), app); } for app in apps { deduped.insert(app.platform_id.clone(), app); } let apps: Vec = deduped.into_values().collect(); self.save_platform(platform, &apps).await?; Ok(AppCache { platform: platform.to_string(), apps, }) } /// Resolve an app by case-insensitive name or exact ID from the persisted platform cache. pub async fn find_app(&self, platform: &str, query: &str) -> anyhow::Result> { let cache = self.load_platform(platform).await?; let normalized = normalize_query(query); if let Some(app) = cache.apps.iter().find(|app| { app.platform_id == query || app.id == query || normalize_query(&app.name) == normalized }) { return Ok(Some(app.clone())); } Ok(cache .apps .into_iter() .filter_map(|app| fuzzy_match_score(&normalized, &app).map(|score| (score, app))) .max_by_key(|(score, app)| (*score, Reverse(app.name.len()))) .map(|(_, app)| app)) } /// Remove the persisted app cache for a platform. pub async fn clear_platform(&self, platform: &str) -> anyhow::Result<()> { match fs::remove_file(self.platform_path(platform)).await { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error.into()), } } fn platform_path(&self, platform: &str) -> PathBuf { self.root_dir.join(format!("{platform}.apps.json")) } } fn normalize_query(value: &str) -> String { value .chars() .filter(|character| character.is_ascii_alphanumeric()) .flat_map(char::to_lowercase) .collect() } fn fuzzy_match_score(query: &str, app: &AppInfo) -> Option { if query.is_empty() { return None; } let name = normalize_query(&app.name); let id = normalize_query(&app.id); let platform_id = normalize_query(&app.platform_id); if name.starts_with(query) || id.starts_with(query) || platform_id.starts_with(query) { return Some(3); } if name.contains(query) || id.contains(query) || platform_id.contains(query) { return Some(2); } if is_subsequence(query, &name) { return Some(1); } None } fn is_subsequence(needle: &str, haystack: &str) -> bool { let mut needle_chars = needle.chars(); let mut current = needle_chars.next(); for hay in haystack.chars() { if current == Some(hay) { current = needle_chars.next(); if current.is_none() { return true; } } } false } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn cache_round_trips_and_resolves_names() { let temp_dir = tempfile::tempdir().expect("temp dir should exist"); let store = AppCacheStore::new(temp_dir.path().join("cache")); let apps = vec![ AppInfo { id: "12".to_string(), name: "Netflix".to_string(), version: None, platform_id: "12".to_string(), }, AppInfo { id: "837".to_string(), name: "YouTube".to_string(), version: None, platform_id: "837".to_string(), }, ]; store .record_platform_apps("roku", apps.clone()) .await .expect("apps should save"); let loaded = store.load_platform("roku").await.expect("apps should load"); assert_eq!(loaded.apps, apps); let resolved = store .find_app("roku", "youtube") .await .expect("app lookup should work") .expect("youtube should exist"); assert_eq!(resolved.platform_id, "837"); } #[tokio::test] async fn record_platform_apps_merges_existing_entries() { let temp_dir = tempfile::tempdir().expect("temp dir should exist"); let store = AppCacheStore::new(temp_dir.path().join("cache")); store .record_platform_apps( "roku", vec![AppInfo { id: "12".to_string(), name: "Netflix".to_string(), version: None, platform_id: "12".to_string(), }], ) .await .expect("first cache should save"); store .record_platform_apps( "roku", vec![AppInfo { id: "837".to_string(), name: "YouTube".to_string(), version: None, platform_id: "837".to_string(), }], ) .await .expect("second cache should merge"); let loaded = store.load_platform("roku").await.expect("apps should load"); assert_eq!(loaded.apps.len(), 2); } #[tokio::test] async fn clear_platform_removes_persisted_cache() { let temp_dir = tempfile::tempdir().expect("temp dir should exist"); let store = AppCacheStore::new(temp_dir.path().join("cache")); store .save_platform( "roku", &[AppInfo { id: "12".to_string(), name: "Netflix".to_string(), version: None, platform_id: "12".to_string(), }], ) .await .expect("apps should save"); store .clear_platform("roku") .await .expect("cache should clear"); let loaded = store.load_platform("roku").await.expect("apps should load"); assert!(loaded.apps.is_empty()); } #[tokio::test] async fn find_app_supports_fuzzy_name_matches() { let temp_dir = tempfile::tempdir().expect("temp dir should exist"); let store = AppCacheStore::new(temp_dir.path().join("cache")); store .record_platform_apps( "roku", vec![ AppInfo { id: "592369".to_string(), name: "Jellyfin".to_string(), version: None, platform_id: "592369".to_string(), }, AppInfo { id: "12".to_string(), name: "Netflix".to_string(), version: None, platform_id: "12".to_string(), }, ], ) .await .expect("apps should save"); let resolved = store .find_app("roku", "jelly") .await .expect("lookup should work") .expect("jellyfin should resolve"); assert_eq!(resolved.name, "Jellyfin"); } }