diff --git a/crates/toride-apps/README.md b/crates/toride-apps/README.md index c98efa9..0b0e263 100644 --- a/crates/toride-apps/README.md +++ b/crates/toride-apps/README.md @@ -9,9 +9,13 @@ manifest. manager argv from a registry `App` plus a host `Target` — no I/O, fixture-testable. - The `Backend` trait with executed implementations for Homebrew, - Flatpak, distro managers (apt, dnf, pacman, apk), npm, cargo, pipx, - uv, mise-delegated tools, and direct downloads — install, uninstall, - update, status, version queries, and pinning. + Flatpak, snap, distro managers (apt, dnf, pacman, apk), npm, cargo, + pipx, uv, mise-delegated tools, and direct downloads: install, + uninstall, update, status, and version queries. Pinning is + Homebrew-only; the trait default refuses it for every other backend. + Snap has no registry `InstallMethod` routing to it yet: its + install/remove/refresh plans are built at the operation seam, and + records carry the snap name for uninstall and update replay. - The `Apps` facade: ensure-installed (optionally at a version) and uninstall, each with a per-item batch verb (`ensure_installed_many` / `uninstall_many`), update with dry-run, diff --git a/crates/toride-apps/src/apps.rs b/crates/toride-apps/src/apps.rs index 1555775..3418876 100644 --- a/crates/toride-apps/src/apps.rs +++ b/crates/toride-apps/src/apps.rs @@ -108,7 +108,7 @@ use crate::backends::flatpak::FlatpakListScope; use crate::backends::homebrew::{BrewKind, OutdatedScope}; use crate::backends::{ CargoBackend, DistroBackend, FlatpakBackend, HomebrewBackend, NpmBackend, PipxBackend, - UvBackend, + SnapBackend, UvBackend, }; use crate::error::Error as BackendError; use crate::manifest::{ @@ -590,6 +590,8 @@ struct AttachedBackends { homebrew: Option, /// Flatpak backend, when flatpak is usable on this host. flatpak: Option, + /// Snap backend, when snapd's CLI is usable on this host. + snap: Option, /// Distro backend, when a family manager is usable on this host. distro: Option, /// Direct-download backend, when attached (the `direct` feature). @@ -1198,7 +1200,8 @@ impl Apps { NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. } => Ok(self + | NativeIds::Uv { .. } + | NativeIds::Snap { .. } => Ok(self .backend_for(ids.backend())? .available_versions(native_id(ids)) .await?), @@ -1400,6 +1403,9 @@ impl Apps { if let Some(backend) = &self.backends.flatpak { set = set.flatpak(backend); } + if let Some(backend) = &self.backends.snap { + set = set.snap(backend); + } if let Some(backend) = &self.backends.distro { set = set.distro(backend); } @@ -1431,6 +1437,7 @@ impl Apps { let routed: Option<&dyn Backend> = match backend { BackendId::Homebrew => self.backends.homebrew.as_ref().map(|b| b as &dyn Backend), BackendId::Flatpak => self.backends.flatpak.as_ref().map(|b| b as &dyn Backend), + BackendId::Snap => self.backends.snap.as_ref().map(|b| b as &dyn Backend), // The family is carried by the attached backend itself; a // family mismatch surfaces at execution (the distro backend's // executor check), not here. @@ -1544,7 +1551,8 @@ impl Apps { NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. } => { + | NativeIds::Uv { .. } + | NativeIds::Snap { .. } => { let backend = self.backend_for(ids.backend())?; Ok(presence_from_status( backend.status(StatusQuery::new(native_id(ids))).await?, @@ -1720,7 +1728,8 @@ impl Apps { NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. } => Ok(self + | NativeIds::Uv { .. } + | NativeIds::Snap { .. } => Ok(self .backend_for(ids.backend())? .available_versions_sync(native_id(ids))?), #[cfg(feature = "mise")] @@ -1833,7 +1842,8 @@ impl Apps { NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. } => { + | NativeIds::Uv { .. } + | NativeIds::Snap { .. } => { let backend = self.backend_for(ids.backend())?; Ok(presence_from_status( backend.status_sync(StatusQuery::new(native_id(ids)))?, @@ -1908,6 +1918,8 @@ pub struct AppsBuilder { homebrew: Option, /// The flatpak backend, when flatpak is usable on this host. flatpak: Option, + /// The snap backend, when snapd's CLI is usable on this host. + snap: Option, /// The distro backend, when a family manager is usable on this host. distro: Option, /// The direct-download backend, when attached (the `direct` feature). @@ -1969,6 +1981,15 @@ impl AppsBuilder { self } + /// Attach the snap backend — consume-and-return; unattached, snap + /// installs answer [`AppsError::BackendUnavailable`] and status never + /// probes them. + #[must_use] + pub fn snap(mut self, backend: SnapBackend) -> Self { + self.snap = Some(backend); + self + } + /// Attach the distro backend — consume-and-return. #[must_use] pub fn distro(mut self, backend: DistroBackend) -> Self { @@ -2073,16 +2094,16 @@ impl AppsBuilder { self } - /// Attach every backend this host supports: homebrew and flatpak via - /// their PATH checks, distro via os-release(5) detection plus its PATH - /// checks, and the language backends — npm, cargo, pipx, uv via each - /// manager's own PATH check, mise (under the `mise` feature) via its - /// offline discovery cascade (`$PATH` plus the managed, system-wide, - /// and app-bundled fallbacks, file checks only) — the same auto-attach - /// Direct gets under the `direct` feature. A backend whose binary is - /// simply absent is skipped (no brew on a Linux box is normal, not an - /// error); a distro host with no known family is skipped the same - /// way. No command executes. + /// Attach every backend this host supports: homebrew, flatpak, and + /// snap via their PATH checks, distro via os-release(5) detection + /// plus its PATH checks, and the language backends — npm, cargo, + /// pipx, uv via each manager's own PATH check, mise (under the `mise` + /// feature) via its offline discovery cascade (`$PATH` plus the + /// managed, system-wide, and app-bundled fallbacks, file checks + /// only) — the same auto-attach Direct gets under the `direct` + /// feature. A backend whose binary is simply absent is skipped (no + /// brew on a Linux box is normal, not an error); a distro host with + /// no known family is skipped the same way. No command executes. /// /// The seam the detection shares is kept as the builder's runner, so /// the built facade and its backends ride one seam. @@ -2108,6 +2129,12 @@ impl AppsBuilder { Err(BackendError::Command(toride_runner::Error::BinaryNotFound(_))) => {} Err(error) => return Err(error.into()), } + match SnapBackend::detect(runner.clone()) { + Ok(backend) => builder = builder.snap(backend), + // Absent binary: this host simply has no snapd CLI — skip. + Err(BackendError::Command(toride_runner::Error::BinaryNotFound(_))) => {} + Err(error) => return Err(error.into()), + } match DistroBackend::detect(runner.clone()) { Ok(backend) => builder = builder.distro(backend), // Absent binaries or no known family on this host — every mode @@ -2173,6 +2200,7 @@ impl AppsBuilder { backends: AttachedBackends { homebrew: self.homebrew, flatpak: self.flatpak, + snap: self.snap, distro: self.distro, #[cfg(feature = "direct")] direct: self.direct, @@ -2756,6 +2784,7 @@ impl Apps { installation: *installation, }) } + Operation::SnapInstall { name, .. } => Ok(NativeIds::Snap { name: name.clone() }), Operation::DistroInstall { package, .. } => match plan.backend { BackendId::Distro(family) => Ok(NativeIds::Distro { package: package.clone(), @@ -2834,6 +2863,7 @@ fn uninstall_plan_from_record( app_id: app_id.clone(), installation: *installation, }, + NativeIds::Snap { name } => Operation::SnapUninstall { name: name.clone() }, NativeIds::Distro { package, family } => { let manager = PackageManager::for_family(*family).ok_or_else(|| { AppsError::UnrecordableOperation { @@ -2896,6 +2926,7 @@ fn update_plan_from_record(id: &TorideId, record: &InstallRecord) -> AppsResult< app_id: app_id.clone(), installation: *installation, }, + NativeIds::Snap { name } => Operation::SnapUpdate { name: name.clone() }, NativeIds::Distro { package, family } => { let manager = PackageManager::for_family(*family).ok_or_else(|| { AppsError::UnrecordableOperation { @@ -2955,6 +2986,7 @@ fn native_id(ids: &NativeIds) -> &str { NativeIds::Npm { package } | NativeIds::Pipx { package } | NativeIds::Uv { package } => { package } + NativeIds::Snap { name } => name, NativeIds::Cargo { crate_ } => crate_, #[cfg(feature = "mise")] NativeIds::Mise { tool } => tool, @@ -2995,7 +3027,8 @@ fn requested_version_satisfied( | NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. } => match recorded { + | NativeIds::Uv { .. } + | NativeIds::Snap { .. } => match recorded { AppStatus::Installed { version, .. } => version.as_deref() == Some(requested.as_str()), AppStatus::Foreign { .. } | AppStatus::NotInstalled => false, }, @@ -3041,6 +3074,7 @@ fn native_subject(ids: &NativeIds) -> String { token, cask: false, .. } => format!("formula `{token}`"), NativeIds::Flatpak { app_id, .. } => format!("flatpak `{app_id}`"), + NativeIds::Snap { name } => format!("snap `{name}`"), NativeIds::Distro { package, .. } => format!("package `{package}`"), #[cfg(feature = "direct")] NativeIds::Direct { bin_path, .. } => format!("direct binary `{bin_path}`"), @@ -3436,6 +3470,72 @@ mod tests { ); } + #[test] + fn native_ids_from_executed_read_the_snap_operation() { + let apps = empty_apps(); + let plan = InstallPlan { + app: TorideId::slugify("firefox"), + backend: BackendId::Snap, + operation: Operation::SnapInstall { + name: "firefox".to_owned(), + channel: Some("esr/stable".to_owned()), + }, + dry_run: false, + requires_elevation: false, + }; + assert_eq!( + apps.native_ids_from_executed(&plan).unwrap(), + NativeIds::Snap { + name: "firefox".to_owned(), + }, + "the record carries the snap name, never the channel" + ); + } + + #[test] + fn record_sourced_plans_replay_snap_ids_verbatim() { + let record = record_for( + Operation::SnapInstall { + name: "firefox".to_owned(), + channel: None, + }, + NativeIds::Snap { + name: "firefox".to_owned(), + }, + ); + assert_eq!( + uninstall_plan_from_record(&TorideId::slugify("firefox"), &record, false) + .unwrap() + .operation + .argv(), + ["snap", "remove", "firefox"] + ); + let update = update_plan_from_record(&TorideId::slugify("firefox"), &record).unwrap(); + assert_eq!(update.operation.argv(), ["snap", "refresh", "firefox"]); + assert!(!update.requires_elevation, "snap refresh never needs root"); + } + + #[test] + fn snap_records_satisfy_a_version_by_the_probed_one() { + let ids = NativeIds::Snap { + name: "firefox".to_owned(), + }; + let probed = AppStatus::Installed { + backend: BackendId::Snap, + version: Some("139.0.4-1".to_owned()), + }; + assert!(requested_version_satisfied( + &ids, + &probed, + Some(&Version::new("139.0.4-1")) + )); + assert!(!requested_version_satisfied( + &ids, + &probed, + Some(&Version::new("140.0-1")) + )); + } + #[test] fn language_records_satisfy_a_version_by_the_probed_one() { let ids = NativeIds::Npm { @@ -3494,6 +3594,12 @@ mod tests { }), "uv tool `ruff`" ); + assert_eq!( + native_subject(&NativeIds::Snap { + name: "firefox".to_owned() + }), + "snap `firefox`" + ); } #[cfg(feature = "mise")] @@ -3935,6 +4041,12 @@ mod tests { }), "brave-browser" ); + assert_eq!( + native_id(&NativeIds::Snap { + name: "firefox".to_owned() + }), + "firefox" + ); } // --- small helpers ------------------------------------------------------------- @@ -4200,6 +4312,175 @@ mod tests { assert_eq!(provenance.ids.backend(), BackendId::Homebrew); } + // --- snap facade flow ----------------------------------------------------- + + use std::sync::Arc; + use toride_runner::CommandOutput; + use toride_runner::fake::FakeRunner; + + use crate::runner::command; + + /// A representative per-snap `snap list ` table (the same + /// tabwriter shape the snap backend's fixtures carry: every column + /// separator two or more spaces). + fn snap_listing(name: &str, version: &str) -> String { + format!( + "Name Version Rev Tracking Publisher Notes\n\ + {name} {version} 4842 latest/stable Mozilla✓ -\n" + ) + } + + /// A facade over a snap backend riding the fake seam. + fn snap_apps(fake: &FakeRunner, label: &str) -> AppsBlocking { + let path = std::env::temp_dir().join(format!( + "toride-apps-unit-snap-{label}-{}.json", + std::process::id() + )); + let path = Utf8PathBuf::from_path_buf(path).expect("system temp dir is valid UTF-8"); + Apps::builder() + .manifest_path(&path) + .snap(SnapBackend::new(CommandRunner::new(Arc::new(fake.clone())))) + .build() + .expect("facade builds with the snap backend attached") + .blocking() + } + + fn snap_provenance() -> AdoptProvenance { + AdoptProvenance::new(NativeIds::Snap { + name: "firefox".to_owned(), + }) + } + + #[test] + fn adopt_records_a_snap_install_through_the_per_snap_listing() { + let spec = command("snap", ["list", "firefox"]); + let listing = CommandOutput::from_stdout(snap_listing("firefox", "139.0.4-1")); + let fake = FakeRunner::new() + .strict() + .respond(spec.clone(), listing.clone()) + .respond(spec.clone(), listing); + let mut apps = snap_apps(&fake, "adopt"); + let record = apps + .adopt(&TorideId::slugify("firefox"), snap_provenance()) + .expect("the per-snap listing confirms presence"); + assert_eq!(record.backend, BackendId::Snap); + assert_eq!(record.version.as_deref(), Some("139.0.4-1")); + assert_eq!( + apps.status(&TorideId::slugify("firefox")).unwrap(), + AppStatus::Installed { + backend: BackendId::Snap, + version: Some("139.0.4-1".to_owned()), + } + ); + fake.assert_called_with(&spec); + } + + #[test] + fn adopt_refuses_a_snap_record_the_listing_does_not_carry() { + let fake = FakeRunner::new().strict().respond( + command("snap", ["list", "ghost"]), + CommandOutput::from_stderr("error: no matching snaps installed\n", 1), + ); + let mut apps = snap_apps(&fake, "adopt-absent"); + let error = apps + .adopt( + &TorideId::slugify("ghost"), + AdoptProvenance::new(NativeIds::Snap { + name: "ghost".to_owned(), + }), + ) + .unwrap_err(); + assert!( + matches!(error, AppsError::AdoptionAbsent { .. }), + "{error:?}" + ); + assert!( + apps.records().is_empty(), + "a refused adoption records nothing" + ); + } + + #[test] + fn update_refreshes_a_snap_record_and_rewrites_the_version() { + let list = command("snap", ["list", "firefox"]); + let refresh = command("snap", ["refresh", "firefox"]); + let fake = FakeRunner::new() + .strict() + .respond( + list.clone(), + CommandOutput::from_stdout(snap_listing("firefox", "139.0.4-1")), + ) + .respond( + list.clone(), + CommandOutput::from_stdout(snap_listing("firefox", "139.0.4-1")), + ) + .respond( + list.clone(), + CommandOutput::from_stdout(snap_listing("firefox", "140.0-1")), + ) + .respond(refresh.clone(), CommandOutput::from_stdout("")); + let mut apps = snap_apps(&fake, "update"); + apps.adopt(&TorideId::slugify("firefox"), snap_provenance()) + .unwrap(); + let outcome = apps + .update(&TorideId::slugify("firefox"), &AppUpdateOptions::new()) + .unwrap(); + assert_eq!( + outcome, + UpdateOutcome::Updated { + from: Some(Version::new("139.0.4-1")), + to: Some(Version::new("140.0-1")), + } + ); + fake.assert_called_with(&refresh); + assert_eq!( + apps.records()[0].1.version.as_deref(), + Some("140.0-1"), + "the record carries the re-probed version, never the adopted one" + ); + } + + #[test] + fn uninstall_removes_a_snap_record_through_snap_remove() { + let list = command("snap", ["list", "firefox"]); + let remove = command("snap", ["remove", "firefox"]); + let fake = FakeRunner::new() + .strict() + .respond( + list.clone(), + CommandOutput::from_stdout(snap_listing("firefox", "139.0.4-1")), + ) + .respond( + list.clone(), + CommandOutput::from_stderr("error: no matching snaps installed\n", 1), + ) + .respond( + remove.clone(), + CommandOutput::from_stdout("firefox removed\n"), + ); + let mut apps = snap_apps(&fake, "uninstall"); + apps.adopt(&TorideId::slugify("firefox"), snap_provenance()) + .unwrap(); + let outcome = apps + .uninstall(&TorideId::slugify("firefox"), AppUninstallOptions::new()) + .unwrap(); + assert_eq!( + outcome, + UninstallAppOutcome::Removed { + backend: BackendId::Snap, + ids: NativeIds::Snap { + name: "firefox".to_owned(), + }, + warning: None, + } + ); + fake.assert_called_with(&remove); + assert!( + apps.records().is_empty(), + "the record left with the removal" + ); + } + struct MapStore { snapshot: std::sync::Mutex, } diff --git a/crates/toride-apps/src/backend.rs b/crates/toride-apps/src/backend.rs index 454cc1c..c18afd6 100644 --- a/crates/toride-apps/src/backend.rs +++ b/crates/toride-apps/src/backend.rs @@ -46,6 +46,8 @@ pub enum BackendId { Homebrew, /// Linux Flatpak — remotes and refs (`flatpak`). Flatpak, + /// Linux Snap packages, through snapd (`snap`). + Snap, /// A distro's native package manager, selected by family (`apt`, `dnf`, /// `pacman`, `apk`). Distro(DistroFamily), @@ -72,6 +74,7 @@ impl std::fmt::Display for BackendId { match self { Self::Homebrew => f.write_str("homebrew"), Self::Flatpak => f.write_str("flatpak"), + Self::Snap => f.write_str("snap"), // Families render lowercase (`distro-debian`); the wildcard keeps // future families displayable without touching this crate. Self::Distro(family) => { diff --git a/crates/toride-apps/src/backends/mod.rs b/crates/toride-apps/src/backends/mod.rs index 589c929..f1b636e 100644 --- a/crates/toride-apps/src/backends/mod.rs +++ b/crates/toride-apps/src/backends/mod.rs @@ -16,6 +16,7 @@ pub mod homebrew; pub mod mise; pub mod npm; pub mod pipx; +pub mod snap; pub mod uv; pub use cargo::CargoBackend; @@ -28,4 +29,5 @@ pub use homebrew::HomebrewBackend; pub use mise::MiseBackend; pub use npm::NpmBackend; pub use pipx::PipxBackend; +pub use snap::SnapBackend; pub use uv::UvBackend; diff --git a/crates/toride-apps/src/backends/snap.rs b/crates/toride-apps/src/backends/snap.rs new file mode 100644 index 0000000..b245dad --- /dev/null +++ b/crates/toride-apps/src/backends/snap.rs @@ -0,0 +1,1655 @@ +//! # Snap backend (`snap`) +//! +//! [`SnapBackend`] is the [`Backend`] implementation for Snap on Linux: it +//! executes the planner's [`Operation::SnapInstall`] / +//! [`Operation::SnapUninstall`] / [`Operation::SnapUpdate`] operations +//! through the shared [`CommandRunner`] seam and answers list/status +//! queries from snapd's space-aligned tables. +//! +//! ## Real-CLI semantics (verified against snapd 2.66.1 source) +//! +//! - **Version** — `snap version` prints one space-aligned `key value` +//! row each for `snap` (the client), `snapd`, `series`, the OS, and the +//! kernel; the client `snap` row is the version this backend reports. +//! - **Install** — `snap install [--channel=/[/branch]]`. +//! A snap already installed is *not* an error: snapd's +//! snap-already-installed notice prints bare (no `error:` prefix) and the +//! command exits 0 (cmd/snap/error.go prints it without the error +//! prefix), so a repeat install is a successful no-op for the +//! ensure-installed semantics the facade wants. An unknown snap fails +//! with exit 1 and `error: snap "" not found`. +//! - **Uninstall** — `snap remove `: snap's own verb. Removing a +//! snap that is not installed is likewise an exit-0 success carrying +//! snapd's bare `snap "" is not installed` notice (the CLI maps +//! the daemon's snap-not-installed kind to a non-error for remove), so +//! manifest-driven uninstalls stay idempotent. +//! - **Update** — `snap refresh `: an up-to-date snap refreshes to +//! an exit-0 success with a bare `snap "" has no updates +//! available` notice. +//! - **Listing** — `snap list` renders through Go's tabwriter (minwidth 5, +//! padding 2) as a **header row plus one row per snap**, space-aligned: +//! every column separator is two or more spaces, and single spaces stay +//! inside cells (publisher display names carry them). The six columns +//! are Name, Version, Rev, Tracking, Publisher, Notes; an empty version +//! or tracking channel renders as `-`, and Notes renders `-` when +//! nothing applies. A host with zero snaps prints the notice to stderr +//! and nothing at all to stdout, still exiting 0. +//! - **Per-snap listing** — `snap list `: the same table for one +//! snap; a name nothing matches fails with exit 1 and +//! `error: no matching snaps installed` (the CLI's `ErrNoMatchingSnaps`). +//! - **Outdated** — `snap refresh --list`: the same tabwriter shape with +//! columns Name, Version, Rev, Size, Publisher, Notes, where Version is +//! the revision snapd would move *to* (the rows come from the store's +//! refresh listing, so the installed version is not in the table). With +//! no refreshes pending, `All snaps up to date.` rides stderr with an +//! empty stdout and exit 0. +//! - **Errors** — every real failure prints `error: ` to stderr +//! and exits 1; snapd being down surfaces as +//! `error: cannot communicate with server: ` on every command. +//! +//! ## Split +//! +//! - **Detection surface** — [`SnapBackend::detect`] (PATH check via +//! `toride-runner`'s discovery helpers, no command executed) and +//! [`SnapBackend::version`] (`snap version`). +//! - **Trait operations** — install/uninstall/update/list/status per the +//! [`Backend`] contract (guard-first, seam-only execution), plus the +//! status override riding the cheaper per-snap listing and the outdated +//! override parsing `snap refresh --list`. +//! - **Table parsing** — the space-aligned listings parsed into typed +//! rows. Per-row tolerance: a malformed row (wrong cell count) is +//! skipped, never fatal to the whole listing; a non-empty document where +//! nothing parses is treated as unparseable output, not as an empty +//! host. +//! +//! ## Conventions honored +//! +//! - [`ensure_install_allowed`] / [`ensure_uninstall_allowed`] / +//! [`ensure_update_allowed`] are the first statement of the trait's +//! mutating operations. +//! - Interactivity: unlike flatpak, snap never prompts, so the executed +//! argv is **exactly** the plan's canonical argv — no runtime flags are +//! layered on (there is nothing to suppress). +//! - Error mapping: non-zero exits surface as [`Error::Command`] carrying +//! snapd's stderr. Two classifications: a `snap remove` whose stderr +//! carries snapd's not-installed wording (exit 0 bare notice, or exit 1 +//! under an `error:` prefix) becomes a success noting the snap was +//! already absent; an install whose stderr carries the already-installed +//! notice is annotated in the outcome detail. Everything else — snapd +//! unreachable, unknown snap, permission failures — stays a real error. +//! See `NOT_INSTALLED_MARKER` / `ALREADY_INSTALLED_MARKER`. +//! +//! [`Operation::SnapInstall`]: crate::Operation::SnapInstall +//! [`Operation::SnapUninstall`]: crate::Operation::SnapUninstall +//! [`Operation::SnapUpdate`]: crate::Operation::SnapUpdate +//! [`ensure_install_allowed`]: crate::backend::ensure_install_allowed +//! [`ensure_uninstall_allowed`]: crate::backend::ensure_uninstall_allowed +//! [`ensure_update_allowed`]: crate::backend::ensure_update_allowed + +use async_trait::async_trait; +use toride_registry::Os; +use toride_runner::CommandOutput; + +use crate::backend::{ + Backend, BackendId, BackendStatus, InstallOutcome, InstallRequest, InstalledApp, ListQuery, + OutdatedEntry, StatusQuery, UninstallOutcome, UninstallRequest, UpdateRequest, + ensure_install_allowed, ensure_uninstall_allowed, ensure_update_allowed, +}; +use crate::error::{Error, Result}; +use crate::plan::{Operation, Target}; +use crate::runner::{CommandRunner, command}; + +/// The snap CLI binary every command in this module targets. +const SNAP: &str = "snap"; + +/// How many space-aligned cells a well-formed table row carries (the fixed +/// six columns both listings render: name, version, revision, tracking or +/// size, publisher, notes). +const TABLE_COLUMN_COUNT: usize = 6; + +/// Marker whose presence in a `snap remove`'s stderr classifies the outcome +/// as "the snap is simply not installed" rather than a real error. +/// snapd's wording (snap/errors.go, `NotInstalledError`) is +/// `snap "" is not installed`, printed bare (no `error:` prefix) with +/// exit 0 on the remove path — cmd/snap/error.go downgrades the +/// snap-not-installed kind to a notice for remove — so the marker cannot +/// require an `error:` prefix the way flatpak's does. Matched +/// case-insensitively against any stderr line. +const NOT_INSTALLED_MARKER: &str = "is not installed"; + +/// Marker classifying a failed `snap list ` as "no such snap on this +/// host": the CLI's own `ErrNoMatchingSnaps` wording, printed under an +/// `error:` prefix with exit 1. +const NO_MATCHING_SNAPS_MARKER: &str = "no matching snaps installed"; + +/// Marker for snapd's already-installed notice on an exit-0 install — +/// `snap "" is already installed, see 'snap help refresh'` — which +/// annotates the install outcome rather than failing it. +const ALREADY_INSTALLED_MARKER: &str = "already installed"; +// --------------------------------------------------------------------------- +// Backend +// --------------------------------------------------------------------------- + +/// Snap [`Backend`]: executes snap install/remove/refresh plans and answers +/// list/status queries from snapd's space-aligned tables. +/// +/// # Example +/// +/// ```rust,ignore +/// use toride_apps::CommandRunner; +/// use toride_apps::backends::snap::SnapBackend; +/// +/// # async fn demo() -> toride_apps::Result<()> { +/// let runner = CommandRunner::builder().build(); +/// let backend = SnapBackend::detect(runner)?; // snap must be on PATH +/// let entries = backend.list_entries().await?; +/// # let _ = entries; +/// # Ok(()) +/// # } +/// ``` +pub struct SnapBackend { + /// The seam every snap command flows through. + runner: CommandRunner, +} + +impl SnapBackend { + /// Create the backend over an explicit seam, with no host assumptions. + /// + /// This is the test-friendly constructor: the seam's runner is + /// injectable, so every command the backend issues is fake-able. + #[must_use] + pub fn new(runner: CommandRunner) -> Self { + Self { runner } + } + + /// Create the backend after verifying `snap` is on the host `$PATH` + /// (via `toride-runner`'s discovery helpers — no command is executed). + /// + /// Use this in production entry points; use [`SnapBackend::new`] under + /// a fake runner, where the real PATH is irrelevant. + /// + /// # Errors + /// + /// [`Error::Command`] wrapping `BinaryNotFound` when `snap` is not on + /// the PATH. + pub fn detect(runner: CommandRunner) -> Result { + let _path = toride_runner::discovery::find_binary(SNAP)?; + Ok(Self::new(runner)) + } + + /// The installed snap client's version, from `snap version`. + /// + /// Parses the space-aligned `snap ` row (the client row; the + /// daemon's own version rides the `snapd` row); any extra version + /// suffix is kept verbatim. + /// + /// # Errors + /// + /// [`Error::Command`] when the probe fails or its output carries no + /// `snap ` row. + pub async fn version(&self) -> Result { + let output = self.runner.run_checked(command(SNAP, ["version"])).await?; + parse_version_output(&output.stdout) + } + + /// The full typed listing of installed snaps: name, version, + /// revision, and tracking channel, straight from `snap list`. + /// + /// Richer than the trait's [`Backend::list_installed`] (which maps + /// these entries to plain [`InstalledApp`]s) so callers that need the + /// revision or the tracked channel can take them directly. + /// + /// # Errors + /// + /// [`Error::Command`] when the listing command fails or its output is + /// non-empty yet contains no parseable row (malformed *rows* are + /// skipped, not fatal). + pub async fn list_entries(&self) -> Result> { + let output = self.runner.run_checked(command(SNAP, ["list"])).await?; + parse_list_output(&output.stdout) + } + + /// The sync twin of [`SnapBackend::list_entries`] — same listing, same + /// parsing, executed on the calling thread. + /// + /// # Errors + /// + /// Same contract as [`SnapBackend::list_entries`]. + pub fn list_entries_sync(&self) -> Result> { + let output = self.runner.run_checked_sync(command(SNAP, ["list"]))?; + parse_list_output(&output.stdout) + } + + /// The per-snap listing probe (`snap list `): the typed row for + /// `name`, or `Ok(None)` when snapd answers that nothing matches. The + /// probe backs both the status override and the post-install verify. + /// + /// # Errors + /// + /// [`Error::Command`] when the listing fails for any reason other than + /// no-matching-snaps, or its output is unparseable. + async fn snap_row(&self, name: &str) -> Result> { + match self.runner.run_checked(command(SNAP, ["list", name])).await { + Ok(output) => Ok(parse_list_output(&output.stdout)? + .into_iter() + .find(|entry| entry.name == name)), + Err(Error::Command(toride_runner::Error::CommandFailed { + stderr, + exit_code: Some(1), + .. + })) if stderr_mentions(&stderr, NO_MATCHING_SNAPS_MARKER) => Ok(None), + Err(error) => Err(error), + } + } + + /// The sync twin of [`SnapBackend::snap_row`]. + /// + /// # Errors + /// + /// Same contract as [`SnapBackend::snap_row`]. + fn snap_row_sync(&self, name: &str) -> Result> { + match self.runner.run_checked_sync(command(SNAP, ["list", name])) { + Ok(output) => Ok(parse_list_output(&output.stdout)? + .into_iter() + .find(|entry| entry.name == name)), + Err(Error::Command(toride_runner::Error::CommandFailed { + stderr, + exit_code: Some(1), + .. + })) if stderr_mentions(&stderr, NO_MATCHING_SNAPS_MARKER) => Ok(None), + Err(error) => Err(error), + } + } + + /// Post-verify: ask the per-snap listing what version landed, for the + /// outcome record. The install already succeeded, so a failing probe + /// degrades to "no version reported" instead of failing the outcome. + async fn post_install_version(&self, name: &str) -> Option { + self.snap_row(name) + .await + .ok() + .flatten() + .and_then(|row| row.version) + } + + /// The sync twin of [`SnapBackend::post_install_version`]. + fn post_install_version_sync(&self, name: &str) -> Option { + self.snap_row_sync(name) + .ok() + .flatten() + .and_then(|row| row.version) + } +} + +#[async_trait] +impl Backend for SnapBackend { + fn id(&self) -> BackendId { + BackendId::Snap + } + + fn supports(&self, target: &Target) -> bool { + target.os == Os::Linux + } + + async fn install(&self, request: InstallRequest<'_>) -> Result { + ensure_install_allowed(&request)?; + let Operation::SnapInstall { name, channel } = &request.plan.operation else { + return Err(misrouted_operation(&request.plan.operation)); + }; + let spec = snap_install_spec(name, channel.as_deref()); + let output = self.runner.run_checked(spec).await?; + let version = self.post_install_version(name).await; + let description = request.plan.operation.description(); + let detail = if stderr_mentions(&output.stderr, ALREADY_INSTALLED_MARKER) { + format!("{description} (already installed)") + } else { + description + }; + Ok(InstallOutcome { version, detail }) + } + + async fn uninstall(&self, request: UninstallRequest<'_>) -> Result { + ensure_uninstall_allowed(&request)?; + let Operation::SnapUninstall { name } = &request.plan.operation else { + return Err(misrouted_operation(&request.plan.operation)); + }; + let spec = snap_remove_spec(name); + classify_uninstall( + self.runner.run_checked(spec).await, + request.plan.operation.description(), + ) + } + + async fn update(&self, request: UpdateRequest<'_>) -> Result<()> { + ensure_update_allowed(&request)?; + let Operation::SnapUpdate { name } = &request.plan.operation else { + return Err(misrouted_operation(&request.plan.operation)); + }; + self.runner.run_checked(snap_refresh_spec(name)).await?; + Ok(()) + } + + async fn list_installed(&self, query: ListQuery) -> Result> { + let entries = self.list_entries().await?; + Ok(entries + .into_iter() + .filter(|entry| query.ids.is_empty() || query.ids.contains(&entry.name)) + .map(|entry| InstalledApp { + id: entry.name, + version: entry.version, + }) + .collect()) + } + + async fn status(&self, query: StatusQuery<'_>) -> Result { + Ok(match self.snap_row(query.id).await? { + Some(entry) => BackendStatus::Installed { + version: entry.version, + }, + None => BackendStatus::NotInstalled, + }) + } + + async fn outdated(&self) -> Result> { + let output = self + .runner + .run_checked(command(SNAP, ["refresh", "--list"])) + .await?; + parse_refresh_list(&output.stdout) + } + + fn install_sync(&self, request: InstallRequest<'_>) -> Result { + ensure_install_allowed(&request)?; + let Operation::SnapInstall { name, channel } = &request.plan.operation else { + return Err(misrouted_operation(&request.plan.operation)); + }; + let spec = snap_install_spec(name, channel.as_deref()); + let output = self.runner.run_checked_sync(spec)?; + let version = self.post_install_version_sync(name); + let description = request.plan.operation.description(); + let detail = if stderr_mentions(&output.stderr, ALREADY_INSTALLED_MARKER) { + format!("{description} (already installed)") + } else { + description + }; + Ok(InstallOutcome { version, detail }) + } + + fn uninstall_sync(&self, request: UninstallRequest<'_>) -> Result { + ensure_uninstall_allowed(&request)?; + let Operation::SnapUninstall { name } = &request.plan.operation else { + return Err(misrouted_operation(&request.plan.operation)); + }; + let spec = snap_remove_spec(name); + classify_uninstall( + self.runner.run_checked_sync(spec), + request.plan.operation.description(), + ) + } + + fn update_sync(&self, request: UpdateRequest<'_>) -> Result<()> { + ensure_update_allowed(&request)?; + let Operation::SnapUpdate { name } = &request.plan.operation else { + return Err(misrouted_operation(&request.plan.operation)); + }; + self.runner.run_checked_sync(snap_refresh_spec(name))?; + Ok(()) + } + + fn list_installed_sync(&self, query: ListQuery) -> Result> { + let entries = self.list_entries_sync()?; + Ok(entries + .into_iter() + .filter(|entry| query.ids.is_empty() || query.ids.contains(&entry.name)) + .map(|entry| InstalledApp { + id: entry.name, + version: entry.version, + }) + .collect()) + } + + fn status_sync(&self, query: StatusQuery<'_>) -> Result { + Ok(match self.snap_row_sync(query.id)? { + Some(entry) => BackendStatus::Installed { + version: entry.version, + }, + None => BackendStatus::NotInstalled, + }) + } + + fn outdated_sync(&self) -> Result> { + let output = self + .runner + .run_checked_sync(command(SNAP, ["refresh", "--list"]))?; + parse_refresh_list(&output.stdout) + } +} +// --------------------------------------------------------------------------- +// Typed query results +// --------------------------------------------------------------------------- + +/// One row of `snap list`: the typed, backend-native view behind the +/// trait's plain [`InstalledApp`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapEntry { + /// Snap name (`firefox`) — the backend-native id and the join key + /// against plan operations and manifest records. + pub name: String, + /// Installed version when the listing reports one (a snap without + /// version metadata reports `-`, typically locally-installed ones). + pub version: Option, + /// Store revision of the installed snap (`4842`; `x1`, `x2`, … for + /// locally-installed revisions). + pub rev: String, + /// Channel the snap tracks (`latest/stable`); `-` when the snap tracks + /// nothing (local install). + pub tracking: String, +} +// --------------------------------------------------------------------------- +// Output parsing +// --------------------------------------------------------------------------- + +/// The canonical `snap install` spec the plan's operation executes — +/// identical to the plan's argv: snap never prompts, so there are no +/// runtime flags to layer on. +fn snap_install_spec(name: &str, channel: Option<&str>) -> toride_runner::CommandSpec { + match channel { + Some(channel) => command(SNAP, ["install", &format!("--channel={channel}"), name]), + None => command(SNAP, ["install", name]), + } +} + +/// The canonical `snap remove` spec the plan's operation executes. +fn snap_remove_spec(name: &str) -> toride_runner::CommandSpec { + command(SNAP, ["remove", name]) +} + +/// The canonical `snap refresh` spec the plan's operation executes. +fn snap_refresh_spec(name: &str) -> toride_runner::CommandSpec { + command(SNAP, ["refresh", name]) +} + +/// Classify a dispatched `snap remove`: success carries the removal +/// detail; snapd's own not-installed answer — the bare exit-0 notice, or +/// the `error:`-prefixed exit 1 — is a success noting the absence, since +/// the desired end state already holds. The exit-code gate on the error +/// path keeps signal kills and other failures as real errors. +fn classify_uninstall( + result: Result, + description: String, +) -> Result { + match result { + Ok(output) => Ok(UninstallOutcome { + detail: if stderr_mentions(&output.stderr, NOT_INSTALLED_MARKER) { + format!("{description} (already absent)") + } else { + description + }, + }), + Err(Error::Command(toride_runner::Error::CommandFailed { + stderr, + exit_code: Some(1), + .. + })) if stderr_mentions(&stderr, NOT_INSTALLED_MARKER) => Ok(UninstallOutcome { + detail: format!("{description} (already absent)"), + }), + Err(error) => Err(error), + } +} + +/// Split one space-aligned table row into its cells: snapd's tabwriter +/// (minwidth 5, padding 2) pads every column with at least two spaces, so +/// runs of two or more spaces are column separators while single spaces +/// stay inside cells (publisher display names carry them). +fn split_table_row(line: &str) -> Vec<&str> { + line.split(" ") + .map(str::trim) + .filter(|cell| !cell.is_empty()) + .collect() +} + +/// Whether the cells are the table's header row (the fourth column is the +/// discriminator: `Tracking` for `snap list`, `Size` for +/// `snap refresh --list`). +fn is_table_header(cells: &[&str], fourth_column: &str) -> bool { + cells.first() == Some(&"Name") + && cells.get(1) == Some(&"Version") + && cells.get(3) == Some(&fourth_column) +} + +/// Parse the space-aligned `snap list` table into typed entries, skipping +/// the header row and malformed rows (anything that is not exactly the six +/// columns). +/// +/// A non-empty document where no row parses is an error, not an empty +/// host: every row being malformed means the output shape is not what snap +/// renders, which must surface loudly rather than read as "nothing +/// installed". +/// +/// # Errors +/// +/// [`Error::Command`] wrapping `OutputParse` for the no-parseable-rows +/// case above. +fn parse_list_output(stdout: &str) -> Result> { + let mut entries = Vec::new(); + for line in stdout.lines() { + let line = line.trim_end(); + if line.is_empty() { + continue; + } + let cells = split_table_row(line); + if is_table_header(&cells, "Tracking") { + continue; + } + if cells.len() != TABLE_COLUMN_COUNT { + continue; + } + entries.push(SnapEntry { + name: cells[0].to_owned(), + version: version_cell(cells[1]), + rev: cells[2].to_owned(), + tracking: cells[3].to_owned(), + }); + } + if !stdout.trim().is_empty() && entries.is_empty() { + return Err(output_parse_error( + "snap list", + format!("no parseable six-column rows in {stdout:?}"), + )); + } + Ok(entries) +} + +/// Parse the space-aligned `snap refresh --list` table into stale-item +/// records. The table's Version column is the revision snapd would move +/// *to*, so it fills `current_version` and `installed_versions` stays +/// empty — the refresh listing does not carry the installed revision. +/// Same tolerance shape as [`parse_list_output`]. +/// +/// # Errors +/// +/// [`Error::Command`] wrapping `OutputParse` for a non-empty document with +/// no parseable rows. +fn parse_refresh_list(stdout: &str) -> Result> { + let mut entries = Vec::new(); + for line in stdout.lines() { + let line = line.trim_end(); + if line.is_empty() { + continue; + } + let cells = split_table_row(line); + if is_table_header(&cells, "Size") { + continue; + } + if cells.len() != TABLE_COLUMN_COUNT { + continue; + } + entries.push(OutdatedEntry { + id: cells[0].to_owned(), + installed_versions: Vec::new(), + current_version: version_cell(cells[1]), + pinned: false, + }); + } + if !stdout.trim().is_empty() && entries.is_empty() { + return Err(output_parse_error( + "snap refresh --list", + format!("no parseable six-column rows in {stdout:?}"), + )); + } + Ok(entries) +} + +/// Parse `snap version` output: the first whitespace-split row whose key +/// is exactly `snap` (the client — `snapd` is the daemon's row) contributes +/// its value. +/// +/// # Errors +/// +/// [`Error::Command`] wrapping `OutputParse` when no `snap ` row +/// exists or the value is empty. +fn parse_version_output(stdout: &str) -> Result { + let version = stdout.lines().find_map(|line| { + let mut cells = line.split_whitespace(); + if cells.next() == Some("snap") { + cells.next().filter(|version| !version.is_empty()) + } else { + None + } + }); + version.map_or_else( + || { + Err(output_parse_error( + "snap version", + format!("no `snap ` row in {stdout:?}"), + )) + }, + |version| Ok(version.to_owned()), + ) +} + +/// Whether stderr mentions `marker` on any line, case-insensitively. +/// Unlike flatpak's sniffer this cannot gate on an `error:` prefix: +/// snapd's not-installed and already-installed notices print bare on their +/// exit-0 paths (cmd/snap/error.go prints them without the prefix). +fn stderr_mentions(stderr: &str, marker: &str) -> bool { + stderr + .lines() + .any(|line| line.to_ascii_lowercase().contains(marker)) +} + +/// A version cell's value: `None` when snapd renders its `-` placeholder +/// (fmtVersion maps an empty version to `-`) or the cell is empty, the +/// verbatim string otherwise. +fn version_cell(cell: &str) -> Option { + Some(cell.to_owned()).filter(|value| value != "-" && !value.is_empty()) +} + +/// The error for a plan operation this backend cannot execute (a non-snap +/// operation routed to the snap backend). +fn misrouted_operation(operation: &Operation) -> Error { + Error::Command(toride_runner::Error::Other(format!( + "snap backend cannot execute non-snap operation: {operation:?}" + ))) +} + +/// Build the unparseable-output error, naming the snap command and the +/// cause. +fn output_parse_error(snap_command: &str, cause: impl std::fmt::Display) -> Error { + Error::Command(toride_runner::Error::OutputParse(format!( + "{snap_command}: {cause}" + ))) +} +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::plan::{InstallPlan, UninstallPlan, UpdatePlan}; + use std::sync::Arc; + use toride_registry::{Arch, DistroFamily, Os, TorideId}; + use toride_runner::fake::FakeRunner; + + // --- test helpers ---------------------------------------------------------- + + /// A representative `snap list` table exactly as snapd's tabwriter + /// emits it: header first, every column separator two or more spaces, + /// `-` for an empty Notes cell. The rows are real store snaps (a base + /// snap, the core runtime, an app, a GNOME platform snap with the wide + /// cells that prove the name/version split). + fn listing_output() -> String { + [ + "Name Version Rev Tracking Publisher Notes", + "bare 1.0 5 latest/stable Canonical✓ base", + "core24 20240710-1 488 latest/stable Canonical✓ base", + "firefox 139.0.4-1 4842 latest/stable Mozilla✓ -", + "gnome-42-2204 0+git.ffb0376f 602 latest/stable Canonical✓ -", + ] + .join("\n") + } + + /// A representative `snap refresh --list` table (the Size column + /// replaces Tracking; Version is the revision being moved to). + fn refresh_output() -> String { + [ + "Name Version Rev Size Publisher Notes", + "firefox 140.0-1 5000 231MB Mozilla✓ -", + "core24 20240801-1 490 26MB Canonical✓ -", + ] + .join("\n") + } + + /// A representative `snap version` document: one space-aligned key / + /// value row each for the client, the daemon, the series, the OS, and + /// the kernel. + fn version_output() -> String { + [ + "snap 2.66.1", + "snapd 2.66.1", + "series 16", + "ubuntu 24.04", + "kernel 6.8.0-45-generic", + ] + .join("\n") + } + + fn linux_target() -> Target { + Target::linux(Arch::X86_64, DistroFamily::Debian) + } + + /// A backend over a strict fake runner (unmatched dispatches fail). + fn backend(fake: &FakeRunner) -> SnapBackend { + SnapBackend::new(CommandRunner::new(Arc::new(fake.clone()))) + } + + /// A snap-backend install plan carrying a hand-picked operation (the + /// registry has no snap install method yet, so plans are built at the + /// operation seam). + fn install_plan(operation: Operation) -> InstallPlan { + InstallPlan { + app: TorideId::slugify("firefox"), + backend: BackendId::Snap, + operation, + dry_run: false, + requires_elevation: false, + } + } + + fn uninstall_plan(operation: Operation) -> UninstallPlan { + UninstallPlan { + app: TorideId::slugify("firefox"), + backend: BackendId::Snap, + operation, + dry_run: false, + requires_elevation: false, + } + } + + fn update_plan(operation: Operation) -> UpdatePlan { + UpdatePlan { + app: TorideId::slugify("firefox"), + backend: BackendId::Snap, + operation, + dry_run: false, + requires_elevation: false, + } + } + + fn default_install_plan() -> InstallPlan { + install_plan(Operation::SnapInstall { + name: "firefox".to_owned(), + channel: None, + }) + } + + fn default_uninstall_plan() -> UninstallPlan { + uninstall_plan(Operation::SnapUninstall { + name: "firefox".to_owned(), + }) + } + + fn default_update_plan() -> UpdatePlan { + update_plan(Operation::SnapUpdate { + name: "firefox".to_owned(), + }) + } + + /// The exact spec the install runs for a name/channel pair. + fn install_spec(name: &str, channel: Option<&str>) -> toride_runner::CommandSpec { + snap_install_spec(name, channel) + } + + // --- version probe parsing --------------------------------------------------- + + #[test] + fn parse_version_output_takes_the_snap_client_row() { + let version = parse_version_output(&version_output()).unwrap(); + assert_eq!(version, "2.66.1"); + } + + #[test] + fn parse_version_output_rejects_output_without_a_snap_client_row() { + // Only the daemon's row: the client row is the contract. + let error = parse_version_output("snapd 2.66.1\nseries 16\n").unwrap_err(); + assert!( + matches!(error, Error::Command(toride_runner::Error::OutputParse(_))), + "{error:?}" + ); + } + + #[test] + fn parse_version_output_rejects_empty_output() { + assert!(parse_version_output("").is_err()); + assert!(parse_version_output(" \n\n").is_err()); + } + + // --- listing parsing ----------------------------------------------------------- + + #[test] + fn parse_list_output_reads_entries_from_a_representative_table() { + let entries = parse_list_output(&listing_output()).unwrap(); + assert_eq!(entries.len(), 4, "{entries:?}"); + assert_eq!( + entries[0], + SnapEntry { + name: "bare".to_owned(), + version: Some("1.0".to_owned()), + rev: "5".to_owned(), + tracking: "latest/stable".to_owned(), + } + ); + let firefox = entries + .iter() + .find(|entry| entry.name == "firefox") + .unwrap(); + assert_eq!(firefox.version.as_deref(), Some("139.0.4-1")); + assert_eq!(firefox.rev, "4842"); + } + + #[test] + fn parse_list_output_maps_a_dash_version_cell_to_none() { + // A locally-installed snap renders `-` for its empty version and + // tracking cells (snapd's fmtVersion/fmtChannel placeholders). + let raw = "Name Version Rev Tracking Publisher Notes\n\ + local-snap - x1 - - -\n"; + let entries = parse_list_output(raw).unwrap(); + assert_eq!( + entries, + vec![SnapEntry { + name: "local-snap".to_owned(), + version: None, + rev: "x1".to_owned(), + tracking: "-".to_owned(), + }] + ); + } + + #[test] + fn parse_list_output_skips_malformed_rows_without_failing_the_listing() { + // One row per failure mode (no double-space separators at all, an + // extra cell) plus two valid rows. + let raw = "Name Version Rev Tracking Publisher Notes\n\ + no double separators at all here\n\ + bare 1.0 5 latest/stable Canonical✓ base extra\n\ + core24 20240710-1 488 latest/stable Canonical✓ base\n\ + firefox 139.0.4-1 4842 latest/stable Mozilla✓ -\n"; + let entries = parse_list_output(raw).unwrap(); + let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["core24", "firefox"], "{entries:?}"); + } + + #[test] + fn parse_list_output_returns_an_empty_listing_for_empty_output() { + // Zero snaps installed: snapd prints its notice to stderr and + // nothing at all to stdout, still exiting 0. + assert_eq!(parse_list_output("").unwrap(), Vec::new()); + assert_eq!(parse_list_output(" \n\n").unwrap(), Vec::new()); + } + + #[test] + fn parse_list_output_errors_when_nothing_in_a_non_empty_document_parses() { + // Every row malformed means the output shape is wrong — that must + // not read as "nothing installed". + let error = parse_list_output("totally garbage\nnot a listing\n").unwrap_err(); + assert!( + matches!(error, Error::Command(toride_runner::Error::OutputParse(_))), + "{error:?}" + ); + } + + // --- refresh-list parsing ------------------------------------------------------ + + #[test] + fn parse_refresh_list_reads_the_available_updates_from_the_table() { + let entries = parse_refresh_list(&refresh_output()).unwrap(); + assert_eq!( + entries, + vec![ + OutdatedEntry { + id: "firefox".to_owned(), + installed_versions: Vec::new(), + current_version: Some("140.0-1".to_owned()), + pinned: false, + }, + OutdatedEntry { + id: "core24".to_owned(), + installed_versions: Vec::new(), + current_version: Some("20240801-1".to_owned()), + pinned: false, + }, + ], + "the table's Version column is the revision snapd moves to" + ); + } + + #[test] + fn parse_refresh_list_returns_no_entries_for_empty_output() { + // "All snaps up to date." rides stderr with an empty stdout. + assert_eq!(parse_refresh_list("").unwrap(), Vec::new()); + } + + #[test] + fn parse_refresh_list_errors_when_nothing_in_a_non_empty_document_parses() { + let error = parse_refresh_list("garbage without columns\n").unwrap_err(); + assert!( + matches!(error, Error::Command(toride_runner::Error::OutputParse(_))), + "{error:?}" + ); + } + + // --- stderr classification ------------------------------------------------------ + + #[test] + fn stderr_classification_matches_the_not_installed_notice_bare_and_prefixed() { + // The exit-0 path prints the notice bare; an error-path rendering + // carries the `error:` prefix — both classify. + for stderr in [ + "snap \"firefox\" is not installed\n", + "error: snap \"firefox\" is not installed\n", + ] { + assert!(stderr_mentions(stderr, NOT_INSTALLED_MARKER), "{stderr}"); + } + } + + #[test] + fn stderr_classification_rejects_unrelated_failures() { + for stderr in [ + "error: cannot communicate with server: timeout exceeded while waiting for response", + "error: snap \"nope\" not found", + "error: access denied", + ] { + assert!(!stderr_mentions(stderr, NOT_INSTALLED_MARKER), "{stderr}"); + assert!( + !stderr_mentions(stderr, NO_MATCHING_SNAPS_MARKER), + "{stderr}" + ); + } + } + + #[test] + fn stderr_classification_matches_the_already_installed_notice() { + assert!(stderr_mentions( + "snap \"firefox\" is already installed, see 'snap help refresh'\n", + ALREADY_INSTALLED_MARKER + )); + assert!(!stderr_mentions( + "error: snap \"nope\" not found", + ALREADY_INSTALLED_MARKER + )); + } + + // --- detection probes through the seam -------------------------------------------- + + #[tokio::test] + async fn version_probes_snap_version_with_exact_argv() { + let spec = command(SNAP, ["version"]); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + toride_runner::CommandOutput::from_stdout(version_output()), + ); + let backend = backend(&fake); + assert_eq!(backend.version().await.unwrap(), "2.66.1"); + fake.assert_called_with(&spec); + } + + // --- install ---------------------------------------------------------------- + + #[tokio::test] + async fn install_refuses_dry_run_plans_without_touching_the_runner() { + let fake = FakeRunner::new().strict(); + let backend = backend(&fake); + let plan = default_install_plan().dry_run(true); + let target = linux_target(); + let error = backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!(matches!(error, Error::DryRun { .. }), "{error:?}"); + assert!(fake.calls().is_empty(), "no snap command may run"); + } + + #[tokio::test] + async fn install_refuses_elevation_requiring_plans_without_a_grant() { + let fake = FakeRunner::new().strict(); + let backend = backend(&fake); + let mut plan = default_install_plan(); + plan.requires_elevation = true; + let target = linux_target(); + let error = backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!( + matches!(error, Error::ElevationRequired { .. }), + "{error:?}" + ); + assert!(fake.calls().is_empty(), "no snap command may run"); + } + + #[tokio::test] + async fn install_rejects_non_snap_operations() { + let fake = FakeRunner::new().strict(); + let backend = backend(&fake); + let plan = install_plan(Operation::DistroInstall { + manager: crate::plan::PackageManager::Apt, + package: "firefox".to_owned(), + }); + let target = linux_target(); + let error = backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!( + matches!(error, Error::Command(toride_runner::Error::Other(_))), + "{error:?}" + ); + } + + #[tokio::test] + async fn install_executes_the_plans_canonical_argv_exactly() { + let plan = default_install_plan(); + // Snap never prompts, so the executed argv IS the plan's canonical + // argv — nothing is layered on at execution. + assert_eq!(plan.operation.argv(), ["snap", "install", "firefox"]); + let fake = FakeRunner::new() + .strict() + .respond( + install_spec("firefox", None), + toride_runner::CommandOutput::from_stdout("firefox 139.0.4-1 installed\n"), + ) + .respond( + command(SNAP, ["list", "firefox"]), + toride_runner::CommandOutput::from_stdout( + "Name Version Rev Tracking Publisher Notes\n\ + firefox 139.0.4-1 4842 latest/stable Mozilla✓ -\n", + ), + ); + let backend = backend(&fake); + let target = linux_target(); + backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap(); + fake.assert_called_with(&install_spec("firefox", None)); + fake.assert_no_unmatched_calls(); + } + + #[tokio::test] + async fn install_passes_the_planned_channel_flag() { + let plan = install_plan(Operation::SnapInstall { + name: "firefox".to_owned(), + channel: Some("esr/stable".to_owned()), + }); + assert_eq!( + plan.operation.argv(), + ["snap", "install", "--channel=esr/stable", "firefox"] + ); + let fake = FakeRunner::new() + .strict() + .respond( + install_spec("firefox", Some("esr/stable")), + toride_runner::CommandOutput::from_stdout(""), + ) + .respond( + command(SNAP, ["list", "firefox"]), + toride_runner::CommandOutput::from_stdout(""), + ); + let backend = backend(&fake); + let target = linux_target(); + backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap(); + fake.assert_called_with(&install_spec("firefox", Some("esr/stable"))); + } + + #[tokio::test] + async fn install_reports_the_version_from_the_post_install_listing() { + let fake = FakeRunner::new() + .strict() + .respond( + install_spec("firefox", None), + toride_runner::CommandOutput::from_stdout(""), + ) + .respond( + command(SNAP, ["list", "firefox"]), + toride_runner::CommandOutput::from_stdout( + "Name Version Rev Tracking Publisher Notes\n\ + firefox 139.0.4-1 4842 latest/stable Mozilla✓ -\n", + ), + ); + let backend = backend(&fake); + let plan = default_install_plan(); + let target = linux_target(); + let outcome = backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap(); + assert_eq!(outcome.version.as_deref(), Some("139.0.4-1")); + assert!( + outcome.detail.contains("install snap `firefox`"), + "{}", + outcome.detail + ); + } + + #[tokio::test] + async fn install_notes_when_snapd_already_had_the_snap_installed() { + // The already-installed notice rides an exit-0 run: the install is + // a successful no-op, and the outcome says so. + let fake = FakeRunner::new() + .strict() + .respond( + install_spec("firefox", None), + toride_runner::CommandOutput::from_stderr( + "snap \"firefox\" is already installed, see 'snap help refresh'\n", + 0, + ), + ) + .respond( + command(SNAP, ["list", "firefox"]), + toride_runner::CommandOutput::from_stdout( + "Name Version Rev Tracking Publisher Notes\n\ + firefox 139.0.4-1 4842 latest/stable Mozilla✓ -\n", + ), + ); + let backend = backend(&fake); + let plan = default_install_plan(); + let target = linux_target(); + let outcome = backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap(); + assert!( + outcome.detail.contains("(already installed)"), + "{}", + outcome.detail + ); + assert_eq!(outcome.version.as_deref(), Some("139.0.4-1")); + } + + #[tokio::test] + async fn install_degrades_to_no_version_when_the_post_verify_listing_fails() { + let fake = FakeRunner::new() + .strict() + .respond( + install_spec("firefox", None), + toride_runner::CommandOutput::from_stdout(""), + ) + .respond( + command(SNAP, ["list", "firefox"]), + toride_runner::CommandOutput::from_stderr( + "error: cannot communicate with server: timeout exceeded", + 1, + ), + ); + let backend = backend(&fake); + let plan = default_install_plan(); + let target = linux_target(); + let outcome = backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap(); + assert_eq!(outcome.version, None); + } + + #[tokio::test] + async fn install_maps_unknown_snaps_to_command_error() { + let fake = FakeRunner::new().strict().respond( + install_spec("nope", None), + toride_runner::CommandOutput::from_stderr("error: snap \"nope\" not found\n", 1), + ); + let backend = backend(&fake); + let plan = install_plan(Operation::SnapInstall { + name: "nope".to_owned(), + channel: None, + }); + let target = linux_target(); + let error = backend + .install(InstallRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!( + matches!( + error, + Error::Command(toride_runner::Error::CommandFailed { .. }) + ), + "{error:?}" + ); + } + + // --- uninstall ------------------------------------------------------------------ + + #[tokio::test] + async fn uninstall_refuses_dry_run_plans_without_touching_the_runner() { + let fake = FakeRunner::new().strict(); + let backend = backend(&fake); + let plan = default_uninstall_plan().dry_run(true); + let target = linux_target(); + let error = backend + .uninstall(UninstallRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!(matches!(error, Error::DryRun { .. }), "{error:?}"); + assert!(fake.calls().is_empty(), "no snap command may run"); + } + + #[tokio::test] + async fn uninstall_executes_snap_remove() { + let spec = command(SNAP, ["remove", "firefox"]); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + toride_runner::CommandOutput::from_stdout("firefox removed\n"), + ); + let backend = backend(&fake); + let plan = default_uninstall_plan(); + let target = linux_target(); + backend + .uninstall(UninstallRequest::new(&plan, &target)) + .await + .unwrap(); + fake.assert_called_with(&spec); + fake.assert_no_unmatched_calls(); + } + + #[tokio::test] + async fn uninstall_reports_already_absent_when_the_exit_zero_notice_says_not_installed() { + let spec = command(SNAP, ["remove", "firefox"]); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + toride_runner::CommandOutput::from_stderr("snap \"firefox\" is not installed\n", 0), + ); + let backend = backend(&fake); + let plan = default_uninstall_plan(); + let target = linux_target(); + let outcome = backend + .uninstall(UninstallRequest::new(&plan, &target)) + .await + .unwrap(); + assert!( + outcome.detail.contains("(already absent)"), + "{}", + outcome.detail + ); + } + + #[tokio::test] + async fn uninstall_classifies_the_error_prefixed_not_installed_exit_as_already_absent() { + let spec = command(SNAP, ["remove", "firefox"]); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + toride_runner::CommandOutput::from_stderr( + "error: snap \"firefox\" is not installed\n", + 1, + ), + ); + let backend = backend(&fake); + let plan = default_uninstall_plan(); + let target = linux_target(); + let outcome = backend + .uninstall(UninstallRequest::new(&plan, &target)) + .await + .unwrap(); + assert!( + outcome.detail.contains("(already absent)"), + "{}", + outcome.detail + ); + } + + #[tokio::test] + async fn uninstall_keeps_snapd_unreachable_as_a_real_error() { + let spec = command(SNAP, ["remove", "firefox"]); + let fake = FakeRunner::new().strict().respond( + spec, + toride_runner::CommandOutput::from_stderr( + "error: cannot communicate with server: dial unix /run/snapd.socket: connect: no such file or directory", + 1, + ), + ); + let backend = backend(&fake); + let plan = default_uninstall_plan(); + let target = linux_target(); + let error = backend + .uninstall(UninstallRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!( + matches!( + error, + Error::Command(toride_runner::Error::CommandFailed { .. }) + ), + "{error:?}" + ); + } + + // --- update -------------------------------------------------------------------- + + #[tokio::test] + async fn update_refuses_dry_run_plans_without_touching_the_runner() { + let fake = FakeRunner::new().strict(); + let backend = backend(&fake); + let plan = default_update_plan().dry_run(true); + let target = linux_target(); + let error = backend + .update(UpdateRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!(matches!(error, Error::DryRun { .. }), "{error:?}"); + assert!(fake.calls().is_empty(), "no snap command may run"); + } + + #[tokio::test] + async fn update_rejects_non_snap_operations() { + let fake = FakeRunner::new().strict(); + let backend = backend(&fake); + let plan = update_plan(Operation::FlatpakUpdate { + app_id: "com.brave.Browser".to_owned(), + installation: crate::plan::FlatpakInstallation::User, + }); + let target = linux_target(); + let error = backend + .update(UpdateRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!( + matches!(error, Error::Command(toride_runner::Error::Other(_))), + "{error:?}" + ); + } + + #[tokio::test] + async fn update_executes_snap_refresh_and_treats_up_to_date_as_success() { + let spec = command(SNAP, ["refresh", "firefox"]); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + toride_runner::CommandOutput::from_stderr( + "snap \"firefox\" has no updates available\n", + 0, + ), + ); + let backend = backend(&fake); + let plan = default_update_plan(); + let target = linux_target(); + backend + .update(UpdateRequest::new(&plan, &target)) + .await + .unwrap(); + fake.assert_called_with(&spec); + } + + #[tokio::test] + async fn update_maps_a_failing_refresh_to_command_error() { + let spec = command(SNAP, ["refresh", "firefox"]); + let fake = FakeRunner::new().strict().respond( + spec, + toride_runner::CommandOutput::from_stderr( + "error: snap \"firefox\" is not installed\n", + 1, + ), + ); + let backend = backend(&fake); + let plan = default_update_plan(); + let target = linux_target(); + let error = backend + .update(UpdateRequest::new(&plan, &target)) + .await + .unwrap_err(); + assert!( + matches!( + error, + Error::Command(toride_runner::Error::CommandFailed { .. }) + ), + "{error:?}" + ); + } + + // --- listing / status / outdated ------------------------------------------------- + + #[tokio::test] + async fn list_installed_lists_every_snap_and_filters_by_query() { + let spec = command(SNAP, ["list"]); + let fake = FakeRunner::new() + .strict() + .respond( + spec.clone(), + toride_runner::CommandOutput::from_stdout(listing_output()), + ) + .respond( + spec.clone(), + toride_runner::CommandOutput::from_stdout(listing_output()), + ); + let backend = backend(&fake); + let all = backend.list_installed(ListQuery::all()).await.unwrap(); + assert_eq!(all.len(), 4, "{all:?}"); + let one = backend + .list_installed(ListQuery::id("firefox")) + .await + .unwrap(); + assert_eq!( + one, + vec![InstalledApp { + id: "firefox".to_owned(), + version: Some("139.0.4-1".to_owned()), + }] + ); + fake.assert_called_with(&spec); + } + + #[tokio::test] + async fn status_reports_the_version_from_the_per_snap_listing() { + let spec = command(SNAP, ["list", "firefox"]); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + toride_runner::CommandOutput::from_stdout( + "Name Version Rev Tracking Publisher Notes\n\ + firefox 139.0.4-1 4842 latest/stable Mozilla✓ -\n", + ), + ); + let backend = backend(&fake); + let status = backend.status(StatusQuery::new("firefox")).await.unwrap(); + assert_eq!( + status, + BackendStatus::Installed { + version: Some("139.0.4-1".to_owned()) + } + ); + fake.assert_called_with(&spec); + } + + #[tokio::test] + async fn status_reports_not_installed_when_no_snaps_match() { + let spec = command(SNAP, ["list", "firefox"]); + let fake = FakeRunner::new().strict().respond( + spec, + toride_runner::CommandOutput::from_stderr("error: no matching snaps installed\n", 1), + ); + let backend = backend(&fake); + let status = backend.status(StatusQuery::new("firefox")).await.unwrap(); + assert_eq!(status, BackendStatus::NotInstalled); + } + + #[tokio::test] + async fn status_keeps_other_failures_as_errors() { + let fake = FakeRunner::new().strict().respond( + command(SNAP, ["list", "firefox"]), + toride_runner::CommandOutput::from_stderr( + "error: cannot communicate with server: timeout exceeded", + 1, + ), + ); + let backend = backend(&fake); + let error = backend + .status(StatusQuery::new("firefox")) + .await + .unwrap_err(); + assert!( + matches!( + error, + Error::Command(toride_runner::Error::CommandFailed { .. }) + ), + "{error:?}" + ); + } + + #[tokio::test] + async fn outdated_lists_the_available_refreshes() { + let spec = command(SNAP, ["refresh", "--list"]); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + toride_runner::CommandOutput::from_stdout(refresh_output()), + ); + let backend = backend(&fake); + let entries = backend.outdated().await.unwrap(); + assert_eq!(entries.len(), 2, "{entries:?}"); + assert_eq!(entries[0].id, "firefox"); + assert_eq!(entries[0].current_version.as_deref(), Some("140.0-1")); + fake.assert_called_with(&spec); + } + + #[tokio::test] + async fn outdated_reports_nothing_when_all_snaps_are_up_to_date() { + // The notice rides stderr with an empty stdout on an exit-0 run. + let spec = command(SNAP, ["refresh", "--list"]); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + toride_runner::CommandOutput::from_stderr("All snaps up to date.\n", 0), + ); + let backend = backend(&fake); + assert_eq!(backend.outdated().await.unwrap(), Vec::new()); + fake.assert_called_with(&spec); + } + + // --- identity ---------------------------------------------------------------- + + #[test] + fn id_is_snap_and_supports_linux_targets_only() { + let backend = backend(&FakeRunner::new().strict()); + assert_eq!(backend.id(), BackendId::Snap); + assert_eq!(backend.id().to_string(), "snap"); + assert!(backend.supports(&linux_target())); + assert!(!backend.supports(&Target::macos(Arch::X86_64))); + assert!(!backend.supports(&Target::new(Os::Windows, Arch::X86_64))); + } + + // --- sync twins ---------------------------------------------------------------- + + #[test] + fn install_sync_refuses_dry_run_and_elevation_without_dispatching() { + let fake = FakeRunner::new().strict(); + let backend = backend(&fake); + let target = linux_target(); + let error = backend + .install_sync(InstallRequest::new( + &default_install_plan().dry_run(true), + &target, + )) + .unwrap_err(); + assert!(matches!(error, Error::DryRun { .. }), "{error:?}"); + let mut plan = default_install_plan(); + plan.requires_elevation = true; + let error = backend + .install_sync(InstallRequest::new(&plan, &target)) + .unwrap_err(); + assert!( + matches!(error, Error::ElevationRequired { .. }), + "{error:?}" + ); + assert!(fake.calls().is_empty(), "no snap command may run"); + } + + #[test] + fn install_sync_executes_the_plan_and_reports_the_version() { + let fake = FakeRunner::new() + .strict() + .respond( + install_spec("firefox", None), + toride_runner::CommandOutput::from_stdout(""), + ) + .respond( + command(SNAP, ["list", "firefox"]), + toride_runner::CommandOutput::from_stdout( + "Name Version Rev Tracking Publisher Notes\n\ + firefox 139.0.4-1 4842 latest/stable Mozilla✓ -\n", + ), + ); + let backend = backend(&fake); + let plan = default_install_plan(); + let target = linux_target(); + let outcome = backend + .install_sync(InstallRequest::new(&plan, &target)) + .unwrap(); + assert_eq!(outcome.version.as_deref(), Some("139.0.4-1")); + fake.assert_called_with(&install_spec("firefox", None)); + } + + #[test] + fn uninstall_sync_runs_remove_and_reclassifies_already_absent() { + let remove = command(SNAP, ["remove", "firefox"]); + let absent = command(SNAP, ["remove", "nope"]); + let fake = FakeRunner::new() + .strict() + .respond( + remove.clone(), + toride_runner::CommandOutput::from_stdout("firefox removed\n"), + ) + .respond( + absent.clone(), + toride_runner::CommandOutput::from_stderr("snap \"nope\" is not installed\n", 0), + ); + let backend = backend(&fake); + let target = linux_target(); + backend + .uninstall_sync(UninstallRequest::new(&default_uninstall_plan(), &target)) + .unwrap(); + let outcome = backend + .uninstall_sync(UninstallRequest::new( + &uninstall_plan(Operation::SnapUninstall { + name: "nope".to_owned(), + }), + &target, + )) + .unwrap(); + assert!( + outcome.detail.contains("(already absent)"), + "{}", + outcome.detail + ); + fake.assert_called_with(&remove); + fake.assert_called_with(&absent); + } + + #[test] + fn update_sync_runs_refresh_exactly() { + let spec = command(SNAP, ["refresh", "firefox"]); + let fake = FakeRunner::new() + .strict() + .respond(spec.clone(), toride_runner::CommandOutput::from_stdout("")); + let backend = backend(&fake); + let target = linux_target(); + backend + .update_sync(UpdateRequest::new(&default_update_plan(), &target)) + .unwrap(); + fake.assert_called_with(&spec); + fake.assert_no_unmatched_calls(); + } + + #[test] + fn list_entries_sync_and_status_sync_read_the_listings() { + let fake = FakeRunner::new() + .strict() + .respond( + command(SNAP, ["list"]), + toride_runner::CommandOutput::from_stdout(listing_output()), + ) + .respond( + command(SNAP, ["list", "firefox"]), + toride_runner::CommandOutput::from_stdout( + "Name Version Rev Tracking Publisher Notes\n\ + firefox 139.0.4-1 4842 latest/stable Mozilla✓ -\n", + ), + ); + let backend = backend(&fake); + let entries = backend.list_entries_sync().unwrap(); + assert_eq!(entries.len(), 4, "{entries:?}"); + assert_eq!( + backend.status_sync(StatusQuery::new("firefox")).unwrap(), + BackendStatus::Installed { + version: Some("139.0.4-1".to_owned()) + } + ); + } + + #[test] + fn outdated_sync_reads_the_refresh_list() { + let fake = FakeRunner::new().strict().respond( + command(SNAP, ["refresh", "--list"]), + toride_runner::CommandOutput::from_stdout(refresh_output()), + ); + let backend = backend(&fake); + let entries = backend.outdated_sync().unwrap(); + assert_eq!(entries.len(), 2, "{entries:?}"); + } +} diff --git a/crates/toride-apps/src/detect.rs b/crates/toride-apps/src/detect.rs index 2fa9481..d349e5b 100644 --- a/crates/toride-apps/src/detect.rs +++ b/crates/toride-apps/src/detect.rs @@ -13,7 +13,7 @@ use crate::backend::{Backend, ListQuery}; use crate::backends::MiseBackend; use crate::backends::{ CargoBackend, DistroBackend, FlatpakBackend, HomebrewBackend, NpmBackend, PipxBackend, - UvBackend, + SnapBackend, UvBackend, }; use crate::error::{Error, Result}; use crate::plan::Target; @@ -31,6 +31,8 @@ pub enum DetectionSource { Homebrew, /// The flatpak backend's installed listing carries the app id. Flatpak, + /// The snap backend's installed listing carries the snap name. + Snap, /// The distro backend's installed listing carries the package. Distro(DistroFamily), /// The npm backend's global listing carries the package. @@ -52,6 +54,7 @@ impl std::fmt::Display for DetectionSource { Self::MiseShim => f.write_str("mise_shim"), Self::Homebrew => f.write_str("homebrew"), Self::Flatpak => f.write_str("flatpak"), + Self::Snap => f.write_str("snap"), Self::Distro(family) => { let slug = format!("distro-{family:?}").to_ascii_lowercase(); f.write_str(&slug) @@ -233,6 +236,7 @@ pub struct MultiSourceDetector { mise_shim_dir: Option, homebrew: Option, flatpak: Option, + snap: Option, distro: Option, npm: Option, cargo: Option, @@ -390,6 +394,9 @@ impl Detector for MultiSourceDetector { if let Some(backend) = &self.flatpak { hits.extend(backend_hits(backend, &DetectionSource::Flatpak, target)); } + if let Some(backend) = &self.snap { + hits.extend(backend_hits(backend, &DetectionSource::Snap, target)); + } if let Some(backend) = &self.distro { let source = DetectionSource::Distro(backend.family()); hits.extend(backend_hits(backend, &source, target)); @@ -561,6 +568,7 @@ pub struct MultiSourceDetectorBuilder { mise_shim_dir: Option, homebrew: Option, flatpak: Option, + snap: Option, distro: Option, npm: Option, cargo: Option, @@ -634,6 +642,13 @@ impl MultiSourceDetectorBuilder { self } + /// Attach the snap backend — consume-and-return. + #[must_use] + pub fn snap(mut self, backend: SnapBackend) -> Self { + self.snap = Some(backend); + self + } + /// Attach the distro backend — consume-and-return. #[must_use] pub fn distro(mut self, backend: DistroBackend) -> Self { @@ -698,6 +713,7 @@ impl MultiSourceDetectorBuilder { HomebrewBackend::detect(runner.clone()), )?; attach(&mut builder.flatpak, FlatpakBackend::detect(runner.clone()))?; + attach(&mut builder.snap, SnapBackend::detect(runner.clone()))?; match DistroBackend::detect(runner.clone()) { Ok(backend) => builder.distro = Some(backend), Err(Error::Command( @@ -728,6 +744,7 @@ impl MultiSourceDetectorBuilder { mise_shim_dir: self.mise_shim_dir, homebrew: self.homebrew, flatpak: self.flatpak, + snap: self.snap, distro: self.distro, npm: self.npm, cargo: self.cargo, @@ -784,6 +801,7 @@ mod tests { assert_eq!(DetectionSource::Path.to_string(), "path"); assert_eq!(DetectionSource::MiseShim.to_string(), "mise_shim"); assert_eq!(DetectionSource::Npm.to_string(), "npm"); + assert_eq!(DetectionSource::Snap.to_string(), "snap"); assert_eq!( DetectionSource::Distro(DistroFamily::Debian).to_string(), "distro-debian" @@ -1429,6 +1447,60 @@ mod tests { assert_eq!(hits[0].confidence, DetectionConfidence::Low); } + #[test] + fn snap_backend_probes_contribute_managed_hits_and_misses_contribute_none() { + let list_spec = command("snap", ["list"]); + let installed = "Name Version Rev Tracking Publisher Notes\n\ + firefox 139.0.4-1 4842 latest/stable Mozilla✓ -\n"; + let without_the_row = "Name Version Rev Tracking Publisher Notes\nbare 1.0 5 latest/stable Canonical✓ base\n"; + let fake = FakeRunner::new() + .strict() + .respond(list_spec.clone(), CommandOutput::from_stdout(installed)) + .respond( + list_spec.clone(), + CommandOutput::from_stdout(without_the_row), + ); + let detector = MultiSourceDetector::builder() + .runner(seam(&fake)) + .path_dirs(Vec::new()) + .snap(crate::backends::SnapBackend::new(seam(&fake))) + .build(); + let hits = detector.detect("firefox"); + assert_eq!(hits.len(), 1, "{hits:?}"); + assert_eq!(hits[0].source, DetectionSource::Snap); + assert_eq!(hits[0].version.as_deref(), Some("139.0.4-1")); + assert_eq!(hits[0].confidence, DetectionConfidence::High); + assert_eq!(hits[0].path, None); + assert!( + detector.detect("core24").is_empty(), + "a listing without the row answers no hits" + ); + fake.assert_called_with(&list_spec); + } + + #[test] + fn merge_dedups_pathless_snap_hits_by_source_and_name() { + let first = Detection::new( + "firefox", + DetectionSource::Snap, + DetectionConfidence::Medium, + ); + let second = Detection::new("firefox", DetectionSource::Snap, DetectionConfidence::High) + .with_version("139.0.4-1"); + let path_hit = Detection::new( + "firefox", + DetectionSource::Path, + DetectionConfidence::Medium, + ) + .with_path("/snap/bin/firefox"); + let merged = merge_by_canonical_path(vec![first, second, path_hit]); + assert_eq!(merged.len(), 2, "{merged:?}"); + assert_eq!(merged[0].source, DetectionSource::Snap); + assert_eq!(merged[0].version.as_deref(), Some("139.0.4-1")); + assert_eq!(merged[0].confidence, DetectionConfidence::High); + assert_eq!(merged[1].source, DetectionSource::Path); + } + #[test] fn a_bare_builder_answers_no_hits_and_the_trait_is_object_safe() { let detector: Box = Box::new( diff --git a/crates/toride-apps/src/lib.rs b/crates/toride-apps/src/lib.rs index cc98a40..967ea5d 100644 --- a/crates/toride-apps/src/lib.rs +++ b/crates/toride-apps/src/lib.rs @@ -16,7 +16,7 @@ //! manager argv from a registry `App` plus a host [`Target`] — no I/O, no //! clock, no processes, fully fixture-testable; //! - the [`Backend`] trait — the contract one install technology (homebrew, -//! flatpak, a distro manager) implements: install, uninstall, +//! flatpak, snap, a distro manager) implements: install, uninstall, //! `list_installed`, status, plus an applicability probe; //! - a thin **runner seam** ([`CommandRunner`]) over //! `toride_runner::Runner` so every backend command is constructed and @@ -127,6 +127,20 @@ //! [`Operation::DistroUpdate`] complete the lifecycle: update plans are //! derived from the manifest record's own identifiers //! ([`Apps::update`]), never re-resolved through the registry +//! - Snap rides the same surface with no registry method of its own: +//! there is no `InstallMethod::Snap` to route yet (snapcraft.io +//! source data is a later registry round), so +//! [`Operation::SnapInstall`] / [`Operation::SnapUninstall`] / +//! [`Operation::SnapUpdate`] are constructed at the operation seam +//! directly. [`backends::SnapBackend`] executes them (`snap install` +//! with the planned `--channel`, `snap remove`, `snap refresh`), +//! auto-attaches through +//! [`detect_backends`](AppsBuilder::detect_backends) when the snap +//! CLI is on the PATH or through the builder's `snap` slot, and +//! answers list / status / outdated from snapd's tables. Records +//! carry the snap name ([`NativeIds::Snap`]), so uninstall and +//! update replay the recorded identity exactly like brew and flatpak +//! records. //! - Language ecosystems — [`InstallMethod::Npm`] / [`InstallMethod::Cargo`] / //! [`InstallMethod::Pipx`] / [`InstallMethod::Uv`] (and //! [`InstallMethod::Mise`] under the non-default `mise` feature) route diff --git a/crates/toride-apps/src/manifest.rs b/crates/toride-apps/src/manifest.rs index 9a26fd4..657af12 100644 --- a/crates/toride-apps/src/manifest.rs +++ b/crates/toride-apps/src/manifest.rs @@ -169,6 +169,13 @@ pub enum NativeIds { /// Installation the app lives in — scopes every probe. installation: FlatpakInstallation, }, + /// A snap package, by snap name. + Snap { + /// Snap name as installed (`firefox`) — the join key against plan + /// operations and the listing rows; the channel a plan pinned lives + /// in the record's own plan, like npm's version does. + name: String, + }, /// A distro package, by package name on a manager family. Distro { /// Package name the manager knows. @@ -229,6 +236,7 @@ impl NativeIds { match self { Self::Homebrew { .. } => BackendId::Homebrew, Self::Flatpak { .. } => BackendId::Flatpak, + Self::Snap { .. } => BackendId::Snap, Self::Distro { family, .. } => BackendId::Distro(*family), #[cfg(feature = "direct")] Self::Direct { .. } => BackendId::Direct, @@ -1475,6 +1483,34 @@ mod tests { } } + #[test] + fn snap_records_round_trip_and_map_to_the_snap_backend() { + let path = temp_manifest_path("snap-round-trip"); + let mut manifest = InstallManifest::at(&path); + let record = InstallRecord::new( + plan_for( + "firefox", + Operation::SnapInstall { + name: "firefox".to_owned(), + channel: Some("esr/stable".to_owned()), + }, + ), + NativeIds::Snap { + name: "firefox".to_owned(), + }, + Some("139.0.4-1".to_owned()), + ) + .with_installed_at(1_700_000_000); + manifest.record(&app_id("firefox"), record.clone()); + manifest.save().unwrap(); + let reloaded = InstallManifest::load(&path).unwrap(); + assert_eq!(reloaded.get(&app_id("firefox")), Some(&record)); + assert_eq!(record.backend, BackendId::Snap); + assert_eq!(record.ids.backend(), BackendId::Snap); + let json = serde_json::to_value(&record.ids).unwrap(); + assert_eq!(json["Snap"]["name"], serde_json::json!("firefox")); + } + #[cfg(feature = "mise")] #[test] fn mise_records_round_trip_and_map_to_the_mise_backend() { diff --git a/crates/toride-apps/src/plan.rs b/crates/toride-apps/src/plan.rs index 691d7ad..17fc056 100644 --- a/crates/toride-apps/src/plan.rs +++ b/crates/toride-apps/src/plan.rs @@ -323,6 +323,25 @@ pub enum Operation { /// User vs system installation. installation: FlatpakInstallation, }, + /// `snap install [--channel=/[/]]`. There is + /// no registry `InstallMethod::Snap` to route yet (snapcraft.io source + /// data is a later registry round), so snap operations are constructed + /// at this seam directly — the argv contract is the same as every + /// routed method's. + SnapInstall { + /// Snap name (`firefox`). + name: String, + /// Channel the install pins (`esr/stable`, `latest/beta`); + /// `None` rides the snap's default channel — the publisher's + /// default track at stable risk, `latest/stable` unless the + /// publisher pinned a different default track. + channel: Option, + }, + /// `snap remove ` — snap's own uninstall verb. + SnapUninstall { + /// Snap name. + name: String, + }, /// ` ` (e.g. `pacman --sync firefox`). /// Requires elevation — see the `requires_elevation` slot on the plan. DistroInstall { @@ -354,6 +373,11 @@ pub enum Operation { /// User vs system installation. installation: FlatpakInstallation, }, + /// `snap refresh ` — snap's own per-snap upgrade verb. + SnapUpdate { + /// Snap name. + name: String, + }, /// ` ` — apt `install /// --only-upgrade`, dnf `upgrade`, pacman `--sync --refresh`, apk /// `upgrade`. Requires elevation. @@ -532,6 +556,11 @@ impl Operation { app_id, installation, } => argv(&["flatpak", "uninstall", installation.flag(), app_id]), + Self::SnapInstall { name, channel } => match channel.as_deref() { + Some(channel) => argv(&["snap", "install", &format!("--channel={channel}"), name]), + None => argv(&["snap", "install", name]), + }, + Self::SnapUninstall { name } => argv(&["snap", "remove", name]), Self::DistroInstall { manager, package } => { argv(&[manager.program(), manager.install_verb(), package]) } @@ -550,6 +579,7 @@ impl Operation { app_id, installation, } => argv(&["flatpak", "update", installation.flag(), app_id]), + Self::SnapUpdate { name } => argv(&["snap", "refresh", name]), Self::DistroUpdate { manager, package } => { let mut parts = vec![manager.program()]; parts.extend(manager.update_verb().iter().copied()); @@ -647,6 +677,11 @@ impl Operation { remote, app_ref, .. } => format!("install flatpak `{app_ref}` from remote `{remote}`"), Self::FlatpakUninstall { app_id, .. } => format!("uninstall flatpak `{app_id}`"), + Self::SnapInstall { name, channel } => match channel { + Some(channel) => format!("install snap `{name}` from channel `{channel}`"), + None => format!("install snap `{name}`"), + }, + Self::SnapUninstall { name } => format!("remove snap `{name}`"), Self::DistroInstall { manager, package } => { format!("install {} package `{package}`", manager.program()) } @@ -658,6 +693,7 @@ impl Operation { if *cask { "cask" } else { "formula" } ), Self::FlatpakUpdate { app_id, .. } => format!("update flatpak `{app_id}`"), + Self::SnapUpdate { name } => format!("refresh snap `{name}`"), Self::DistroUpdate { manager, package } => { format!("upgrade {} package `{package}`", manager.program()) } @@ -2828,6 +2864,90 @@ mod tests { ); } + #[test] + fn snap_operations_render_the_plan_argv() { + assert_eq!( + Operation::SnapInstall { + name: "firefox".to_owned(), + channel: None, + } + .argv(), + ["snap", "install", "firefox"], + "`None` rides the snap's default channel (latest/stable)" + ); + assert_eq!( + Operation::SnapInstall { + name: "firefox".to_owned(), + channel: Some("esr/stable".to_owned()), + } + .argv(), + ["snap", "install", "--channel=esr/stable", "firefox"] + ); + assert_eq!( + Operation::SnapUninstall { + name: "firefox".to_owned() + } + .argv(), + ["snap", "remove", "firefox"] + ); + assert_eq!( + Operation::SnapUpdate { + name: "firefox".to_owned() + } + .argv(), + ["snap", "refresh", "firefox"] + ); + assert_eq!( + Operation::SnapInstall { + name: "firefox".to_owned(), + channel: Some("latest/beta".to_owned()), + } + .description(), + "install snap `firefox` from channel `latest/beta`" + ); + assert_eq!( + Operation::SnapUninstall { + name: "firefox".to_owned() + } + .description(), + "remove snap `firefox`" + ); + assert_eq!( + Operation::SnapUpdate { + name: "firefox".to_owned() + } + .description(), + "refresh snap `firefox`" + ); + } + + #[test] + fn snap_operations_round_trip_through_json() { + for operation in [ + Operation::SnapInstall { + name: "firefox".to_owned(), + channel: Some("esr/stable".to_owned()), + }, + Operation::SnapInstall { + name: "hello-world".to_owned(), + channel: None, + }, + Operation::SnapUninstall { + name: "firefox".to_owned(), + }, + Operation::SnapUpdate { + name: "firefox".to_owned(), + }, + ] { + let json = serde_json::to_string(&operation).unwrap(); + assert_eq!( + operation, + serde_json::from_str::(&json).unwrap(), + "round-trips: {json}" + ); + } + } + #[cfg(feature = "mise")] #[test] fn mise_operations_render_the_plan_argv() { diff --git a/crates/toride-apps/src/status.rs b/crates/toride-apps/src/status.rs index 26c3c92..b0bd0c3 100644 --- a/crates/toride-apps/src/status.rs +++ b/crates/toride-apps/src/status.rs @@ -69,7 +69,7 @@ use crate::backends::flatpak::FlatpakListScope; use crate::backends::homebrew::BrewKind; use crate::backends::{ CargoBackend, DistroBackend, FlatpakBackend, HomebrewBackend, NpmBackend, PipxBackend, - UvBackend, + SnapBackend, UvBackend, }; use crate::error::Result; use crate::manifest::{InstallRecord, NativeIds}; @@ -121,6 +121,8 @@ pub struct BackendSet<'a> { homebrew: Option<&'a HomebrewBackend>, /// Flatpak backend, when flatpak is usable on this host. flatpak: Option<&'a FlatpakBackend>, + /// Snap backend, when snapd's CLI is usable on this host. + snap: Option<&'a SnapBackend>, /// Distro backend, when a family manager is usable on this host. distro: Option<&'a DistroBackend>, /// Direct-download backend, when attached (the `direct` feature). @@ -147,6 +149,7 @@ impl std::fmt::Debug for BackendSet<'_> { builder .field("homebrew", &self.homebrew.is_some()) .field("flatpak", &self.flatpak.is_some()) + .field("snap", &self.snap.is_some()) .field("distro", &self.distro.is_some()) .field("npm", &self.npm.is_some()) .field("cargo", &self.cargo.is_some()) @@ -181,6 +184,13 @@ impl<'a> BackendSet<'a> { self } + /// Attach the snap backend — consume-and-return. + #[must_use] + pub const fn snap(mut self, backend: &'a SnapBackend) -> Self { + self.snap = Some(backend); + self + } + /// Attach the distro backend — consume-and-return. #[must_use] pub const fn distro(mut self, backend: &'a DistroBackend) -> Self { @@ -234,8 +244,8 @@ impl<'a> BackendSet<'a> { } } -/// The language-ecosystem slot a set of ids routes to — the one seam all -/// five share (their trait `status` presence probe over the listing); +/// The slot a set of ids routes to — the one seam the language ecosystems +/// and snap share (their trait `status` presence probe over a listing); /// `None` when that backend is not attached. fn language_backend<'a>(set: &BackendSet<'a>, ids: &NativeIds) -> Option<&'a dyn Backend> { let backend: Option<&'a dyn Backend> = match ids { @@ -243,6 +253,7 @@ fn language_backend<'a>(set: &BackendSet<'a>, ids: &NativeIds) -> Option<&'a dyn NativeIds::Cargo { .. } => set.cargo.map(|backend| backend as &dyn Backend), NativeIds::Pipx { .. } => set.pipx.map(|backend| backend as &dyn Backend), NativeIds::Uv { .. } => set.uv.map(|backend| backend as &dyn Backend), + NativeIds::Snap { .. } => set.snap.map(|backend| backend as &dyn Backend), #[cfg(feature = "mise")] NativeIds::Mise { .. } => set.mise.map(|backend| backend as &dyn Backend), NativeIds::Homebrew { .. } | NativeIds::Flatpak { .. } | NativeIds::Distro { .. } => { @@ -254,12 +265,13 @@ fn language_backend<'a>(set: &BackendSet<'a>, ids: &NativeIds) -> Option<&'a dyn backend } -/// The language-ecosystem native id a presence probe keys on. +/// The native id a presence probe keys on. fn language_native_id(ids: &NativeIds) -> &str { match ids { NativeIds::Npm { package } | NativeIds::Pipx { package } | NativeIds::Uv { package } => { package } + NativeIds::Snap { name } => name, NativeIds::Cargo { crate_ } => crate_, #[cfg(feature = "mise")] NativeIds::Mise { tool } => tool, @@ -386,7 +398,8 @@ async fn toride_recorded_status( ids @ (NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. }) => { + | NativeIds::Uv { .. } + | NativeIds::Snap { .. }) => { let Some(backend) = language_backend(backends, ids) else { return Ok(AppStatus::NotInstalled); }; @@ -484,7 +497,8 @@ async fn foreign_status(native: &NativeIds, backends: &BackendSet<'_>) -> Result ids @ (NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. }) => { + | NativeIds::Uv { .. } + | NativeIds::Snap { .. }) => { let Some(backend) = language_backend(backends, ids) else { return Ok(AppStatus::NotInstalled); }; @@ -513,6 +527,7 @@ fn language_subject(ids: &NativeIds) -> String { NativeIds::Cargo { crate_ } => format!("cargo crate `{crate_}`"), NativeIds::Pipx { package } => format!("pipx package `{package}`"), NativeIds::Uv { package } => format!("uv tool `{package}`"), + NativeIds::Snap { name } => format!("snap `{name}`"), #[cfg(feature = "mise")] NativeIds::Mise { tool } => format!("mise tool `{tool}`"), NativeIds::Homebrew { .. } | NativeIds::Flatpak { .. } | NativeIds::Distro { .. } => { @@ -635,7 +650,8 @@ fn toride_recorded_status_sync( ids @ (NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. }) => { + | NativeIds::Uv { .. } + | NativeIds::Snap { .. }) => { let Some(backend) = language_backend(backends, ids) else { return Ok(AppStatus::NotInstalled); }; @@ -720,7 +736,8 @@ fn foreign_status_sync(native: &NativeIds, backends: &BackendSet<'_>) -> Result< ids @ (NativeIds::Npm { .. } | NativeIds::Cargo { .. } | NativeIds::Pipx { .. } - | NativeIds::Uv { .. }) => { + | NativeIds::Uv { .. } + | NativeIds::Snap { .. }) => { let Some(backend) = language_backend(backends, ids) else { return Ok(AppStatus::NotInstalled); }; @@ -1619,6 +1636,191 @@ mod tests { fake.assert_no_unmatched_calls(); } + fn snap_backend(fake: &FakeRunner) -> SnapBackend { + SnapBackend::new(CommandRunner::new(Arc::new(fake.clone()))) + } + + fn snap_list_spec(name: &str) -> toride_runner::CommandSpec { + command("snap", ["list", name]) + } + + /// A representative per-snap `snap list ` table exactly as snapd's + /// tabwriter emits it (the same shape the snap backend's own fixtures + /// carry): two-or-more-space column separators, `-` for empty cells. + fn snap_listing(name: &str, version: &str) -> String { + format!( + "Name Version Rev Tracking Publisher Notes\n\ + {name} {version} 4842 latest/stable Mozilla✓ -\n" + ) + } + + fn snap_record(slug: &str, name: &str) -> InstallRecord { + InstallRecord::new( + crate::plan::InstallPlan { + app: app_id(slug), + backend: BackendId::Snap, + operation: Operation::SnapInstall { + name: name.to_owned(), + channel: None, + }, + dry_run: false, + requires_elevation: false, + }, + NativeIds::Snap { + name: name.to_owned(), + }, + None, + ) + .with_installed_at(1_700_000_000) + } + + #[tokio::test] + async fn manifest_hit_snap_record_reports_installed_from_the_per_snap_listing() { + let spec = snap_list_spec("firefox"); + let fake = FakeRunner::new().strict().respond( + spec.clone(), + CommandOutput::from_stdout(snap_listing("firefox", "139.0.4-1")), + ); + let snap = snap_backend(&fake); + let backends = BackendSet::new().snap(&snap); + let status = app_status(Some(&snap_record("firefox", "firefox")), None, &backends) + .await + .unwrap(); + assert_eq!( + status, + AppStatus::Installed { + backend: BackendId::Snap, + version: Some("139.0.4-1".to_owned()), + } + ); + fake.assert_called_with(&spec); + fake.assert_no_unmatched_calls(); + } + + #[tokio::test] + async fn manifest_hit_snap_record_without_a_version_still_reports_installed() { + // A locally-installed snap renders `-` for its empty version cell, + // so presence must be read from the row, not from the version. + let fake = FakeRunner::new().strict().respond( + snap_list_spec("local-snap"), + CommandOutput::from_stdout(snap_listing("local-snap", "-")), + ); + let snap = snap_backend(&fake); + let backends = BackendSet::new().snap(&snap); + let status = app_status( + Some(&snap_record("local-snap", "local-snap")), + None, + &backends, + ) + .await + .unwrap(); + assert_eq!( + status, + AppStatus::Installed { + backend: BackendId::Snap, + version: None, + } + ); + fake.assert_no_unmatched_calls(); + } + + #[tokio::test] + async fn manifest_hit_snap_record_when_no_snaps_match_reports_not_installed() { + // The CLI's own ErrNoMatchingSnaps wording, exit 1. + let fake = FakeRunner::new().strict().respond( + snap_list_spec("firefox"), + CommandOutput::from_stderr("error: no matching snaps installed\n", 1), + ); + let snap = snap_backend(&fake); + let backends = BackendSet::new().snap(&snap); + let status = app_status(Some(&snap_record("firefox", "firefox")), None, &backends) + .await + .unwrap(); + assert_eq!(status, AppStatus::NotInstalled); + fake.assert_no_unmatched_calls(); + } + + #[tokio::test] + async fn manifest_miss_with_snap_listing_the_name_reports_foreign() { + let fake = FakeRunner::new().strict().respond( + snap_list_spec("firefox"), + CommandOutput::from_stdout(snap_listing("firefox", "139.0.4-1")), + ); + let snap = snap_backend(&fake); + let backends = BackendSet::new().snap(&snap); + let native = NativeIds::Snap { + name: "firefox".to_owned(), + }; + let status = app_status(None, Some(&native), &backends).await.unwrap(); + match status { + AppStatus::Foreign { backend, detail } => { + assert_eq!(backend, BackendId::Snap); + assert!(detail.contains("snap `firefox`"), "{detail}"); + assert!(detail.contains("139.0.4-1"), "{detail}"); + assert!(detail.contains("not by toride"), "{detail}"); + } + other => panic!("expected Foreign, got {other:?}"), + } + fake.assert_no_unmatched_calls(); + } + + #[tokio::test] + async fn an_absent_snap_slot_skips_the_probe_entirely() { + let fake = FakeRunner::new().strict(); + let backends = BackendSet::new(); + assert_eq!( + app_status(Some(&snap_record("firefox", "firefox")), None, &backends) + .await + .unwrap(), + AppStatus::NotInstalled + ); + let native = NativeIds::Snap { + name: "firefox".to_owned(), + }; + assert_eq!( + app_status(None, Some(&native), &backends).await.unwrap(), + AppStatus::NotInstalled + ); + fake.assert_no_unmatched_calls(); + } + + #[test] + fn sync_snap_record_and_foreign_mirror_the_async_probes() { + let fake = FakeRunner::new() + .strict() + .respond( + snap_list_spec("firefox"), + CommandOutput::from_stdout(snap_listing("firefox", "139.0.4-1")), + ) + .respond( + snap_list_spec("firefox"), + CommandOutput::from_stdout(snap_listing("firefox", "139.0.4-1")), + ); + let snap = snap_backend(&fake); + let backends = BackendSet::new().snap(&snap); + assert_eq!( + app_status_sync(Some(&snap_record("firefox", "firefox")), None, &backends).unwrap(), + AppStatus::Installed { + backend: BackendId::Snap, + version: Some("139.0.4-1".to_owned()), + } + ); + let native = NativeIds::Snap { + name: "firefox".to_owned(), + }; + assert!( + matches!( + app_status_sync(None, Some(&native), &backends).unwrap(), + AppStatus::Foreign { + backend: BackendId::Snap, + .. + } + ), + "the sync snap probes mirror the async ones" + ); + fake.assert_no_unmatched_calls(); + } + #[cfg(feature = "direct")] mod direct { use super::*; diff --git a/crates/toride-apps/tests/live.rs b/crates/toride-apps/tests/live.rs index fcb02f6..c9a968d 100644 --- a/crates/toride-apps/tests/live.rs +++ b/crates/toride-apps/tests/live.rs @@ -13,10 +13,11 @@ //! //! What runs when the gate is open (and what deliberately does not): //! -//! - the `detect()` seams of all three backends against the REAL host PATH -//! and `/etc/os-release` — brew and flatpak are expected to be absent on -//! this Linux host, dpkg present, but every assertion branches on what -//! the host actually has, so nothing is assumed either way; +//! - the `detect()` seams of the backends probed here (brew, flatpak, +//! snap, distro) against the REAL host PATH and `/etc/os-release`: +//! brew, flatpak, and snap are expected to be absent on this Linux +//! host, dpkg present, but every assertion branches on what the host +//! actually has, so nothing is assumed either way; //! - the real toride-registry fetch layers through this crate's public //! surface: the Homebrew and Flathub adapters resolving live apps into //! planner input (pure `plan_install` afterward — no execution), the @@ -32,7 +33,7 @@ use std::sync::Arc; use toride_apps::backends::distro::detect_host_family; -use toride_apps::backends::{DistroBackend, FlatpakBackend, HomebrewBackend}; +use toride_apps::backends::{DistroBackend, FlatpakBackend, HomebrewBackend, SnapBackend}; use toride_apps::runner::CommandRunner; use toride_apps::{ AppStatus, Apps, Backend, BackendId, BackendStatus, InstallOptions, StatusQuery, Target, @@ -164,6 +165,65 @@ async fn distro_detect_probes_this_host_through_its_real_package_manager() { } } +/// The snap seam: `detect()` is a real PATH check, and on a host carrying +/// snapd's CLI the read-only probes answer live snapd (`snap version`, +/// `snap list`, the per-snap `snap list ` the status override +/// rides). The status probe asserts consistency with the listing rather +/// than any hard-coded host state: status must confirm the `snapd` row +/// the listing shows, with the same version, or agree that no such row +/// exists. No mutation anywhere (install/remove/refresh are what the +/// offline suites pin with fakes). +#[tokio::test] +async fn snap_detect_and_read_only_probes_track_the_real_host() { + if !integration_enabled() { + eprintln!("TORIDE_APPS_INTEGRATION not set; skipping live test"); + return; + } + match find_binary("snap") { + Ok(_) => { + let backend = SnapBackend::detect(CommandRunner::builder().build()) + .expect("snap is on PATH; detect must succeed"); + backend + .version() + .await + .expect("a snap CLI answering at all carries a client version row"); + + let entries = backend + .list_entries() + .await + .expect("`snap list` answers on a host running snapd"); + let listed = entries.iter().find(|entry| entry.name == "snapd"); + let status = backend + .status(StatusQuery::new("snapd")) + .await + .expect("the per-snap listing is an answer, never an error"); + match listed { + Some(row) => assert_eq!( + status, + BackendStatus::Installed { + version: row.version.clone() + }, + "status must confirm what the listing showed for the snapd snap" + ), + None => assert_eq!( + status, + BackendStatus::NotInstalled, + "no snapd row in the listing; status must agree" + ), + } + } + Err(_) => match SnapBackend::detect(CommandRunner::builder().build()) { + Err(toride_apps::Error::Command(toride_runner::Error::BinaryNotFound(name))) => { + assert_eq!(name, "snap"); + } + Ok(_) => panic!("snap absent from PATH; detect must refuse"), + Err(other) => panic!( + "snap absent from PATH; detect must refuse with BinaryNotFound, got {other:?}" + ), + }, + } +} + // --------------------------------------------------------------------------- // Real registry adapters — the live fetch layers behind the facade's resolve // ---------------------------------------------------------------------------