From 9a1833d7924373c5d13abc5e4d5bffe5e92b90d6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 09:17:17 -0700 Subject: [PATCH] Marketplace: item diff, manager, refresh and update detection Adds diff::item_diff (similar), MarketplaceManager with snapshots, persisted sync reports, the gh-login slot and a repo lock held across fetches (pre-flight F11a), refresh_marketplace, load_cached_snapshot, compute_updates, pins_by_marketplace, head_for, and the GitFixture test helper on top of git::test_support (F3). AppState gains marketplace. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.lock | 7 + app/src-tauri/Cargo.toml | 1 + app/src-tauri/src/lib.rs | 9 + app/src-tauri/src/marketplace/diff.rs | 198 +++++++ app/src-tauri/src/marketplace/mod.rs | 555 +++++++++++++++++- app/src-tauri/src/marketplace/test_support.rs | 90 +++ 6 files changed, 859 insertions(+), 1 deletion(-) create mode 100644 app/src-tauri/src/marketplace/diff.rs create mode 100644 app/src-tauri/src/marketplace/test_support.rs diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 3cca5a6..ed81dbc 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -5576,6 +5576,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "0.3.11" @@ -6569,6 +6575,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "similar", "tar", "tauri", "tauri-build", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 6a70f4e..9ffeb45 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ zeroize = "1" # container. Already in the tree transitively (reqwest), and the point of # using it rather than hand-rolling is parity with the frontend's `new URL()`. url = "2" +similar = "2" # Marketplace repos are fetched on the host into a bare cache (spec §3). # Blocking client + rustls: no git binary or OpenSSL needed on the host. gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 34aea21..3f7b255 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -49,6 +49,7 @@ pub struct AppState { /// preview is not actually binding on what gets applied. pub pending_settings_import: Arc>>, + pub marketplace: Arc, } // ───────────────────────────────────────────────────────────────────────────── @@ -225,6 +226,13 @@ pub fn run() { let exec_manager = Arc::new(ExecSessionManager::new()); let auth_bridge = Arc::new(AuthBridgeManager::new()); let lifecycle = Arc::new(Lifecycle::new()); + let marketplace = Arc::new(marketplace::MarketplaceManager::new( + dirs::data_dir() + .map(|d| d.join("triple-c")) + .unwrap_or_else(|| std::env::temp_dir().join("triple-c")), + )); + let marketplace_setup = marketplace.clone(); + let _ = &marketplace_setup; // Clone Arcs for the setup closure (web terminal auto-start) let projects_store_setup = projects_store.clone(); @@ -243,6 +251,7 @@ pub fn run() { web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), lifecycle, pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)), + marketplace, }) .manage(file_viewer::registry::ViewerRegistry::default()) .setup(move |app| { diff --git a/app/src-tauri/src/marketplace/diff.rs b/app/src-tauri/src/marketplace/diff.rs new file mode 100644 index 0000000..e9a6451 --- /dev/null +++ b/app/src-tauri/src/marketplace/diff.rs @@ -0,0 +1,198 @@ +//! Text diff of one item between two commits, for the "Update" review. + +use std::collections::BTreeMap; +use std::path::Path; + +use similar::TextDiff; + +use super::catalog::{item_files, ItemFile}; +use super::tree::GitTree; +use crate::models::marketplace::{FileChange, FileDiff, ItemKind}; + +/// Files of `kind`/`key` at `commit`, or an empty list when the item does not +/// exist (or is not installable) at that commit — a removal upstream then reads +/// as every file removed rather than as an error. +fn files_at( + repo_path: &Path, + kind: ItemKind, + key: &str, + commit: &str, +) -> Result, String> { + let tree = GitTree::open(repo_path, commit)?; + Ok(item_files(&tree, kind, key).unwrap_or_default()) +} + +pub fn item_diff( + repo_path: &Path, + kind: ItemKind, + key: &str, + from_commit: &str, + to_commit: &str, +) -> Result, String> { + let old = files_at(repo_path, kind, key, from_commit)?; + let new = files_at(repo_path, kind, key, to_commit)?; + Ok(diff_files(&old, &new)) +} + +fn as_text(data: &[u8]) -> Option<&str> { + if data.contains(&0) { + return None; + } + std::str::from_utf8(data).ok() +} + +fn unified(path: &str, old: &str, new: &str) -> String { + TextDiff::from_lines(old, new) + .unified_diff() + .context_radius(3) + .header(&format!("a/{path}"), &format!("b/{path}")) + .to_string() +} + +/// Per-file diff, sorted by path; files identical in content and mode are left out. +pub(crate) fn diff_files(old: &[ItemFile], new: &[ItemFile]) -> Vec { + let old: BTreeMap<&str, &ItemFile> = old.iter().map(|f| (f.rel_path.as_str(), f)).collect(); + let new: BTreeMap<&str, &ItemFile> = new.iter().map(|f| (f.rel_path.as_str(), f)).collect(); + let mut paths: Vec<&str> = old.keys().chain(new.keys()).copied().collect(); + paths.sort_unstable(); + paths.dedup(); + + let mut out = Vec::new(); + for path in paths { + match (old.get(path), new.get(path)) { + (Some(o), Some(n)) => { + if o.data == n.data && o.executable == n.executable { + continue; + } + let text = match (as_text(&o.data), as_text(&n.data)) { + (Some(a), Some(b)) => { + let mut s = String::new(); + if o.executable != n.executable { + s.push_str(&format!( + "# executable: {} -> {}\n", + o.executable, n.executable + )); + } + s.push_str(&unified(path, a, b)); + Some(s) + } + _ => None, + }; + out.push(FileDiff { + path: path.to_string(), + change: FileChange::Modified, + unified: text, + }); + } + (Some(o), None) => out.push(FileDiff { + path: path.to_string(), + change: FileChange::Removed, + unified: as_text(&o.data).map(|a| unified(path, a, "")), + }), + (None, Some(n)) => out.push(FileDiff { + path: path.to_string(), + change: FileChange::Added, + unified: as_text(&n.data).map(|b| unified(path, "", b)), + }), + (None, None) => {} + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::marketplace::git; + use crate::marketplace::test_support::GitFixture; + + fn f(path: &str, text: &str, executable: bool) -> ItemFile { + ItemFile { + rel_path: path.to_string(), + data: text.as_bytes().to_vec(), + executable, + } + } + + #[test] + fn unchanged_files_are_omitted_and_changes_are_classified() { + let old = vec![ + f("a.md", "one\n", false), + f("gone.sh", "x\n", true), + f("same", "s\n", false), + ]; + let new = vec![ + f("a.md", "two\n", false), + f("new.txt", "n\n", false), + f("same", "s\n", false), + ]; + let diffs = diff_files(&old, &new); + let summary: Vec<(&str, FileChange)> = diffs + .iter() + .map(|d| (d.path.as_str(), d.change.clone())) + .collect(); + assert_eq!( + summary, + vec![ + ("a.md", FileChange::Modified), + ("gone.sh", FileChange::Removed), + ("new.txt", FileChange::Added), + ] + ); + let a = diffs[0].unified.as_deref().unwrap(); + assert!(a.contains("-one") && a.contains("+two"), "{a}"); + } + + #[test] + fn binary_files_have_no_text_diff() { + let old = vec![ItemFile { + rel_path: "b.bin".into(), + data: vec![0, 1, 2], + executable: false, + }]; + let new = vec![ItemFile { + rel_path: "b.bin".into(), + data: vec![0, 1, 3], + executable: false, + }]; + let diffs = diff_files(&old, &new); + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].unified, None); + } + + #[test] + fn an_executable_bit_change_is_reported() { + let old = vec![f("run.sh", "echo\n", false)]; + let new = vec![f("run.sh", "echo\n", true)]; + let diffs = diff_files(&old, &new); + assert_eq!(diffs.len(), 1); + assert!(diffs[0] + .unified + .as_deref() + .unwrap() + .contains("executable: false -> true")); + } + + #[test] + fn item_diff_reads_both_commits_from_the_cache() { + let Some(fx) = GitFixture::new() else { return }; + let c1 = fx.with_all_kinds(); + fx.write( + "hooks/notify-on-stop/notify.sh", + "#!/bin/sh\ncurl https://example.invalid\n", + ); + let c2 = fx.commit("change hook"); + let data = tempfile::tempdir().unwrap(); + let repo = git::cache_path(data.path(), "m1"); + git::fetch(&repo, &fx.url(), None, None).unwrap(); + + let diffs = item_diff(&repo, ItemKind::Hook, "notify-on-stop", &c1, &c2).unwrap(); + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].path, "notify.sh"); + assert!(diffs[0] + .unified + .as_deref() + .unwrap() + .contains("+curl https://example.invalid")); + } +} diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs index 84695ed..0c89ff9 100644 --- a/app/src-tauri/src/marketplace/mod.rs +++ b/app/src-tauri/src/marketplace/mod.rs @@ -1,6 +1,559 @@ -//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`. +//! Marketplaces: git repos of agents, skills, commands, hooks and plugins that +//! are fetched on the host and synced into containers. See +//! `docs/superpowers/specs/2026-09-27-marketplace-design.md`. pub mod auth; pub mod catalog; +pub mod diff; pub mod git; pub mod tree; +#[cfg(test)] +pub(crate) mod test_support; + +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use tokio::sync::oneshot; + +use crate::models::marketplace::{ + CatalogItem, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport, +}; +use crate::models::{AppSettings, Project}; +use catalog::{item_fingerprint, parse_catalog}; +use tree::GitTree; + +/// Emitted after every container sync, payload `{ project_id, report }`. +pub const SYNC_FINISHED_EVENT: &str = "marketplace-sync-finished"; + +pub struct MarketplaceManager { + data_root: PathBuf, + snapshots: Mutex>, + reports: Mutex>, + gh_login_cancel: tokio::sync::Mutex>>, + /// Serialises writers of the bare caches (fetch, pins, cache removal) so + /// concurrent refreshes never race on gix ref locks (pre-flight F11a). + repo_lock: tokio::sync::Mutex<()>, +} + +/// Project ids become file names; anything outside this set is not persisted. +fn safe_file_stem(id: &str) -> bool { + !id.is_empty() + && id.len() <= 128 + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +impl MarketplaceManager { + /// `data_root` is `/triple-c`. + pub fn new(data_root: PathBuf) -> Self { + Self { + data_root, + snapshots: Mutex::new(HashMap::new()), + reports: Mutex::new(HashMap::new()), + gh_login_cancel: tokio::sync::Mutex::new(None), + repo_lock: tokio::sync::Mutex::new(()), + } + } + + pub fn data_root(&self) -> &Path { + &self.data_root + } + + /// Hold while writing to any marketplace cache (fetch, `git::set_pins`, + /// removing a cache). + pub fn repo_lock(&self) -> &tokio::sync::Mutex<()> { + &self.repo_lock + } + + pub fn snapshot(&self, marketplace_id: &str) -> Option { + self.snapshots.lock().unwrap().get(marketplace_id).cloned() + } + + pub fn put_snapshot(&self, snap: MarketplaceSnapshot) { + self.snapshots + .lock() + .unwrap() + .insert(snap.marketplace_id.clone(), snap); + } + + pub fn remove_snapshot(&self, marketplace_id: &str) { + self.snapshots.lock().unwrap().remove(marketplace_id); + } + + fn report_path(&self, project_id: &str) -> PathBuf { + self.data_root + .join("marketplace-sync") + .join(format!("{project_id}.json")) + } + + pub fn report(&self, project_id: &str) -> Option { + if let Some(r) = self.reports.lock().unwrap().get(project_id) { + return Some(r.clone()); + } + if !safe_file_stem(project_id) { + return None; + } + let text = std::fs::read_to_string(self.report_path(project_id)).ok()?; + let report: SyncReport = serde_json::from_str(&text).ok()?; + self.reports + .lock() + .unwrap() + .insert(project_id.to_string(), report.clone()); + Some(report) + } + + pub fn put_report(&self, project_id: &str, report: SyncReport) { + self.reports + .lock() + .unwrap() + .insert(project_id.to_string(), report.clone()); + if !safe_file_stem(project_id) { + return; + } + let path = self.report_path(project_id); + let write = || -> std::io::Result<()> { + std::fs::create_dir_all(path.parent().unwrap())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(&report).unwrap_or_default())?; + std::fs::rename(&tmp, &path) + }; + if let Err(e) = write() { + log::warn!( + "Could not persist the marketplace sync report for {}: {}", + project_id, + e + ); + } + } + + /// Claim (`Some`) or release (`None`) the single gh-login slot. Claiming + /// fails while another login holds it. + pub async fn set_gh_login_cancel(&self, tx: Option>) -> bool { + let mut slot = self.gh_login_cancel.lock().await; + match tx { + Some(tx) => { + if slot.is_some() { + return false; + } + *slot = Some(tx); + true + } + None => { + *slot = None; + true + } + } + } + + pub async fn cancel_gh_login(&self) { + if let Some(tx) = self.gh_login_cancel.lock().await.take() { + let _ = tx.send(()); + } + } +} + +/// Head commit for a marketplace: the in-memory snapshot's, else the cache's. +pub fn head_for(mgr: &MarketplaceManager, m: &Marketplace) -> Option { + mgr.snapshot(&m.id).and_then(|s| s.head_commit).or_else(|| { + git::cached_head(&git::cache_path(mgr.data_root(), &m.id)) + .ok() + .flatten() + }) +} + +fn parse_at(repo: &Path, commit: &str) -> Result, String> { + let tree = GitTree::open(repo, commit)?; + Ok(parse_catalog(&tree)) +} + +/// Snapshot from the cache alone (no network): startup, and after an install +/// when nothing is in memory. `fetched_at` stays `None`. +pub fn load_cached_snapshot( + mgr: &MarketplaceManager, + marketplace: &Marketplace, +) -> MarketplaceSnapshot { + let repo = git::cache_path(mgr.data_root(), &marketplace.id); + let mut snap = MarketplaceSnapshot { + marketplace_id: marketplace.id.clone(), + ..Default::default() + }; + match git::cached_head(&repo) { + Ok(Some(head)) => match parse_at(&repo, &head) { + Ok(items) => { + snap.head_commit = Some(head); + snap.items = items; + } + Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")), + }, + Ok(None) => {} + Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")), + } + snap +} + +/// Keep the previous items and head (in memory, else from the cache) and +/// record why this refresh failed. +fn failed_snapshot( + mgr: &MarketplaceManager, + m: &Marketplace, + message: String, +) -> MarketplaceSnapshot { + let mut snap = mgr + .snapshot(&m.id) + .unwrap_or_else(|| load_cached_snapshot(mgr, m)); + snap.fetch_error = Some(message); + mgr.put_snapshot(snap.clone()); + snap +} + +/// Refresh one marketplace: resolve the credential, fetch (blocking task, under +/// the repo lock), parse the catalog at head and store the snapshot. On failure +/// the previous items and head are kept and `fetch_error` is set. +pub async fn refresh_marketplace( + mgr: &MarketplaceManager, + settings: &AppSettings, + marketplace_id: &str, +) -> MarketplaceSnapshot { + let Some(m) = settings + .marketplaces + .iter() + .find(|m| m.id == marketplace_id) + .cloned() + else { + return MarketplaceSnapshot { + marketplace_id: marketplace_id.to_string(), + fetch_error: Some("This marketplace is no longer configured.".to_string()), + ..Default::default() + }; + }; + let account = m + .account_id + .as_ref() + .and_then(|id| settings.marketplace_accounts.iter().find(|a| &a.id == id)) + .cloned(); + let cred = match &account { + Some(a) => match auth::resolve_credential(a).await { + Ok(c) => Some(c), + Err(e) => return failed_snapshot(mgr, &m, e), + }, + None => None, + }; + + let repo = git::cache_path(mgr.data_root(), &m.id); + let (url, branch) = (m.url.clone(), m.branch.clone()); + let joined = { + let _repo_guard = mgr.repo_lock.lock().await; + tokio::task::spawn_blocking(move || { + let head = git::fetch(&repo, &url, branch.as_deref(), cred)?; + let items = parse_at(&repo, &head).map_err(git::FetchError::Other)?; + Ok::<_, git::FetchError>((head, items)) + }) + .await + }; + + match joined { + Ok(Ok((head, items))) => { + let snap = MarketplaceSnapshot { + marketplace_id: m.id.clone(), + head_commit: Some(head), + fetched_at: Some(chrono::Utc::now().to_rfc3339()), + fetch_error: None, + items, + }; + mgr.put_snapshot(snap.clone()); + snap + } + Ok(Err(e)) => failed_snapshot( + mgr, + &m, + auth::describe_fetch_error(&e, account.as_ref(), &m.url), + ), + Err(e) => failed_snapshot(mgr, &m, format!("The refresh task failed: {e}")), + } +} + +fn item_changed(repo: &Path, inst: &MarketplaceInstall, head: &str) -> Result { + let old = GitTree::open(repo, &inst.commit)?; + let new = GitTree::open(repo, head)?; + Ok(item_fingerprint(&old, inst.kind, &inst.key)? + != item_fingerprint(&new, inst.kind, &inst.key)?) +} + +/// Every install (global + all projects) whose item fingerprint at head +/// differs from its pin. Installs whose pin is not in the cache are skipped. +pub fn compute_updates( + mgr: &MarketplaceManager, + settings: &AppSettings, + projects: &[Project], +) -> Vec { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + let all = settings + .global_marketplace_installs + .iter() + .chain(projects.iter().flat_map(|p| p.marketplace_installs.iter())); + for inst in all { + if !seen.insert((inst.item_ref(), inst.commit.clone())) { + continue; + } + let Some(m) = settings + .marketplaces + .iter() + .find(|m| m.id == inst.marketplace_id) + else { + continue; + }; + let Some(head) = head_for(mgr, m) else { + continue; + }; + if head == inst.commit { + continue; + } + let repo = git::cache_path(mgr.data_root(), &m.id); + match item_changed(&repo, inst, &head) { + Ok(true) => out.push(ItemUpdate { + item: inst.item_ref(), + pinned: inst.commit.clone(), + head, + }), + Ok(false) => {} + Err(e) => log::debug!("Update check skipped for {}: {}", inst.key, e), + } + } + out +} + +/// All commits referenced by installs, per marketplace (for `git::set_pins`). +pub fn pins_by_marketplace( + settings: &AppSettings, + projects: &[Project], +) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + let all = settings + .global_marketplace_installs + .iter() + .chain(projects.iter().flat_map(|p| p.marketplace_installs.iter())); + for inst in all { + map.entry(inst.marketplace_id.clone()) + .or_default() + .insert(inst.commit.clone()); + } + map.into_iter() + .map(|(k, v)| (k, v.into_iter().collect())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::marketplace::test_support::GitFixture; + use crate::models::marketplace::{ItemKind, Marketplace, MarketplaceInstall}; + + fn settings_with(url: &str) -> AppSettings { + let mut s = AppSettings::default(); + s.marketplaces.push(Marketplace { + id: "m1".into(), + name: "Test".into(), + url: url.into(), + branch: None, + account_id: None, + }); + s + } + + fn install(kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall { + MarketplaceInstall { + marketplace_id: "m1".into(), + kind, + key: key.into(), + commit: commit.into(), + } + } + + #[tokio::test] + async fn refresh_parses_the_catalog_at_head() { + let Some(fx) = GitFixture::new() else { return }; + let c1 = fx.with_all_kinds(); + let data = tempfile::tempdir().unwrap(); + let mgr = MarketplaceManager::new(data.path().to_path_buf()); + + let snap = refresh_marketplace(&mgr, &settings_with(&fx.url()), "m1").await; + + assert_eq!(snap.fetch_error, None); + assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str())); + assert!(snap.fetched_at.is_some()); + let mut keys: Vec = snap + .items + .iter() + .map(|i| format!("{:?}:{}", i.kind, i.key)) + .collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "Agent:code-reviewer", + "Command:example-command", + "Hook:notify-on-stop", + "Plugin:example-plugin", + "Skill:example-skill", + ] + ); + assert_eq!(mgr.snapshot("m1"), Some(snap)); + } + + #[tokio::test] + async fn refresh_failure_keeps_snapshot() { + let Some(fx) = GitFixture::new() else { return }; + let c1 = fx.with_all_kinds(); + let url = fx.url(); + let data = tempfile::tempdir().unwrap(); + let mgr = MarketplaceManager::new(data.path().to_path_buf()); + let settings = settings_with(&url); + let first = refresh_marketplace(&mgr, &settings, "m1").await; + assert_eq!(first.fetch_error, None); + + drop(fx); // the source repository disappears (offline, deleted, …) + let second = refresh_marketplace(&mgr, &settings, "m1").await; + + assert!(second.fetch_error.is_some(), "expected a fetch error"); + assert_eq!(second.head_commit.as_deref(), Some(c1.as_str())); + assert_eq!(second.items, first.items); + assert_eq!(second.fetched_at, first.fetched_at); + } + + #[tokio::test] + async fn refresh_waits_for_the_repo_lock() { + let Some(fx) = GitFixture::new() else { return }; + let c1 = fx.with_all_kinds(); + let data = tempfile::tempdir().unwrap(); + let mgr = MarketplaceManager::new(data.path().to_path_buf()); + let settings = settings_with(&fx.url()); + + let guard = mgr.repo_lock().lock().await; + let blocked = tokio::time::timeout( + std::time::Duration::from_millis(300), + refresh_marketplace(&mgr, &settings, "m1"), + ) + .await; + assert!(blocked.is_err(), "refresh must not fetch while the repo lock is held"); + assert!( + !git::cache_path(data.path(), "m1").exists(), + "nothing may touch the cache while the lock is held" + ); + drop(guard); + + let snap = refresh_marketplace(&mgr, &settings, "m1").await; + assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str())); + } + + #[tokio::test] + async fn concurrent_refreshes_all_succeed() { + let Some(fx) = GitFixture::new() else { return }; + let c1 = fx.with_all_kinds(); + let data = tempfile::tempdir().unwrap(); + let mgr = MarketplaceManager::new(data.path().to_path_buf()); + let settings = settings_with(&fx.url()); + + let (a, b, c) = tokio::join!( + refresh_marketplace(&mgr, &settings, "m1"), + refresh_marketplace(&mgr, &settings, "m1"), + refresh_marketplace(&mgr, &settings, "m1"), + ); + for snap in [a, b, c] { + assert_eq!(snap.fetch_error, None); + assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str())); + } + } + + #[tokio::test] + async fn cached_snapshot_loads_without_network() { + let Some(fx) = GitFixture::new() else { return }; + let c1 = fx.with_all_kinds(); + let data = tempfile::tempdir().unwrap(); + let settings = settings_with(&fx.url()); + { + let mgr = MarketplaceManager::new(data.path().to_path_buf()); + refresh_marketplace(&mgr, &settings, "m1").await; + } + drop(fx); + let mgr = MarketplaceManager::new(data.path().to_path_buf()); + let snap = load_cached_snapshot(&mgr, &settings.marketplaces[0]); + assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str())); + assert_eq!(snap.items.len(), 5); + assert_eq!(snap.fetch_error, None); + } + + #[tokio::test] + async fn only_items_whose_own_files_changed_have_updates() { + let Some(fx) = GitFixture::new() else { return }; + let c1 = fx.with_all_kinds(); + fx.write( + "agents/code-reviewer.md", + "---\nname: code-reviewer\ndescription: Reviews code\n---\nReview harder.\n", + ); + let c2 = fx.commit("tweak agent"); + let data = tempfile::tempdir().unwrap(); + let mgr = MarketplaceManager::new(data.path().to_path_buf()); + let mut settings = settings_with(&fx.url()); + settings.global_marketplace_installs = vec![ + install(ItemKind::Agent, "code-reviewer", &c1), + install(ItemKind::Hook, "notify-on-stop", &c1), + ]; + let mut project = crate::models::Project::new("p".into(), vec![]); + project.marketplace_installs = vec![install(ItemKind::Skill, "example-skill", &c1)]; + refresh_marketplace(&mgr, &settings, "m1").await; + + let updates = compute_updates(&mgr, &settings, &[project]); + + assert_eq!(updates.len(), 1, "{updates:?}"); + assert_eq!(updates[0].item.key, "code-reviewer"); + assert_eq!(updates[0].pinned, c1); + assert_eq!(updates[0].head, c2); + } + + #[test] + fn pins_are_grouped_and_deduplicated_per_marketplace() { + let a = "a".repeat(40); + let b = "b".repeat(40); + let mut settings = settings_with("https://example.invalid/r.git"); + settings.global_marketplace_installs = vec![ + install(ItemKind::Agent, "x", &b), + install(ItemKind::Hook, "y", &a), + ]; + let mut project = crate::models::Project::new("p".into(), vec![]); + project.marketplace_installs = vec![install(ItemKind::Agent, "z", &a)]; + let pins = pins_by_marketplace(&settings, &[project]); + assert_eq!(pins.get("m1"), Some(&vec![a.clone(), b.clone()])); + } + + #[test] + fn reports_are_persisted_per_project() { + let data = tempfile::tempdir().unwrap(); + let report = SyncReport { + installed: vec!["agent:x".into()], + ..Default::default() + }; + MarketplaceManager::new(data.path().to_path_buf()).put_report("proj-1", report.clone()); + let fresh = MarketplaceManager::new(data.path().to_path_buf()); + assert_eq!(fresh.report("proj-1"), Some(report)); + assert_eq!(fresh.report("proj-2"), None); + } + + #[tokio::test] + async fn only_one_gh_login_may_hold_the_cancel_slot() { + let mgr = MarketplaceManager::new(std::env::temp_dir()); + let (tx1, rx1) = tokio::sync::oneshot::channel(); + let (tx2, _rx2) = tokio::sync::oneshot::channel(); + assert!(mgr.set_gh_login_cancel(Some(tx1)).await); + assert!(!mgr.set_gh_login_cancel(Some(tx2)).await); + mgr.cancel_gh_login().await; + assert!(rx1.await.is_ok(), "cancel must signal the running login"); + let (tx3, _rx3) = tokio::sync::oneshot::channel(); + assert!( + mgr.set_gh_login_cancel(Some(tx3)).await, + "slot is free after cancel" + ); + } +} diff --git a/app/src-tauri/src/marketplace/test_support.rs b/app/src-tauri/src/marketplace/test_support.rs new file mode 100644 index 0000000..eda62ac --- /dev/null +++ b/app/src-tauri/src/marketplace/test_support.rs @@ -0,0 +1,90 @@ +//! Test-only helpers: throwaway git repositories built with the `git` CLI, so +//! marketplace code is exercised against real git objects over `file://`. +//! The git plumbing itself lives in [`super::git::test_support`] (one copy). + +use std::fs; + +use super::git::test_support::{file_url, git, git_available}; + +pub struct GitFixture { + pub dir: tempfile::TempDir, +} + +impl GitFixture { + /// `None` (with a note on stderr) when `git` is not installed; callers skip. + pub fn new() -> Option { + if !git_available() { + eprintln!("skipping: git is not installed"); + return None; + } + let dir = tempfile::tempdir().expect("tempdir"); + git(dir.path(), &["init", "-q", "-b", "main"]); + Some(Self { dir }) + } + + pub fn url(&self) -> String { + file_url(self.dir.path()) + } + + pub fn write(&self, path: &str, contents: &str) -> &Self { + let p = self.dir.path().join(path); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(&p, contents).unwrap(); + self + } + + pub fn write_exec(&self, path: &str, contents: &str) -> &Self { + self.write(path, contents); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let p = self.dir.path().join(path); + fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); + } + self + } + + /// Commit everything and return the new commit id (40 hex). + pub fn commit(&self, message: &str) -> String { + git(self.dir.path(), &["add", "-A"]); + git( + self.dir.path(), + &["commit", "-q", "--allow-empty", "-m", message], + ); + git(self.dir.path(), &["rev-parse", "HEAD"]) + } + + /// A repo with one item of every kind, committed. Returns the commit. + pub fn with_all_kinds(&self) -> String { + self.write( + "agents/code-reviewer.md", + "---\nname: code-reviewer\ndescription: Reviews code\n---\nReview the diff.\n", + ) + .write( + "skills/example-skill/SKILL.md", + "---\nname: example-skill\ndescription: An example skill\n---\nDo the thing.\n", + ) + .write( + "commands/example-command.md", + "---\ndescription: An example command\n---\nRun the example.\n", + ) + .write( + "hooks/notify-on-stop/hook.json", + r#"{"name":"notify-on-stop","description":"Ping on stop","hooks":{"Stop":[{"hooks":[{"type":"command","command":"${HOOK_DIR}/notify.sh"}]}]}}"#, + ) + .write_exec("hooks/notify-on-stop/notify.sh", "#!/bin/sh\necho done\n") + .write( + "plugins/.claude-plugin/marketplace.json", + r#"{"name":"upstream","owner":{"name":"Test"},"plugins":[{"name":"example-plugin","source":"./example-plugin","description":"An example plugin"}]}"#, + ) + .write( + "plugins/example-plugin/.claude-plugin/plugin.json", + r#"{"name":"example-plugin","version":"0.1.0"}"#, + ) + .write( + "plugins/example-plugin/skills/hello/SKILL.md", + "---\nname: hello\ndescription: Says hello\n---\nSay hello.\n", + ); + self.commit("all kinds") + } +}