soar_db/repository/
core.rs

1//! Core database repository for installed packages.
2
3use diesel::prelude::*;
4
5use crate::{
6    models::{
7        core::{NewPackage, NewPortablePackage, Package, PortablePackage},
8        types::PackageProvide,
9    },
10    schema::core::{packages, portable_package},
11};
12
13/// Sort direction for queries.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SortDirection {
16    Asc,
17    Desc,
18}
19
20/// Type alias for installed package (for clarity).
21pub type InstalledPackage = Package;
22/// Type alias for new installed package (for clarity).
23pub type NewInstalledPackage<'a> = NewPackage<'a>;
24
25/// Installed package with portable configuration joined.
26#[derive(Debug, Clone)]
27pub struct InstalledPackageWithPortable {
28    pub id: i32,
29    pub repo_name: String,
30    pub pkg_id: String,
31    pub pkg_name: String,
32    pub pkg_type: Option<String>,
33    pub version: String,
34    pub size: i64,
35    pub checksum: Option<String>,
36    pub installed_path: String,
37    pub installed_date: String,
38    pub profile: String,
39    pub pinned: bool,
40    pub is_installed: bool,
41    pub with_pkg_id: bool,
42    pub detached: bool,
43    pub unlinked: bool,
44    pub provides: Option<Vec<PackageProvide>>,
45    pub install_patterns: Option<Vec<String>>,
46    pub portable_path: Option<String>,
47    pub portable_home: Option<String>,
48    pub portable_config: Option<String>,
49    pub portable_share: Option<String>,
50    pub portable_cache: Option<String>,
51}
52
53impl From<(Package, Option<PortablePackage>)> for InstalledPackageWithPortable {
54    fn from((pkg, portable): (Package, Option<PortablePackage>)) -> Self {
55        Self {
56            id: pkg.id,
57            repo_name: pkg.repo_name,
58            pkg_id: pkg.pkg_id,
59            pkg_name: pkg.pkg_name,
60            pkg_type: pkg.pkg_type,
61            version: pkg.version,
62            size: pkg.size,
63            checksum: pkg.checksum,
64            installed_path: pkg.installed_path,
65            installed_date: pkg.installed_date,
66            profile: pkg.profile,
67            pinned: pkg.pinned,
68            is_installed: pkg.is_installed,
69            with_pkg_id: pkg.with_pkg_id,
70            detached: pkg.detached,
71            unlinked: pkg.unlinked,
72            provides: pkg.provides,
73            install_patterns: pkg.install_patterns,
74            portable_path: portable.as_ref().and_then(|p| p.portable_path.clone()),
75            portable_home: portable.as_ref().and_then(|p| p.portable_home.clone()),
76            portable_config: portable.as_ref().and_then(|p| p.portable_config.clone()),
77            portable_share: portable.as_ref().and_then(|p| p.portable_share.clone()),
78            portable_cache: portable.as_ref().and_then(|p| p.portable_cache.clone()),
79        }
80    }
81}
82
83/// Repository for installed package operations.
84pub struct CoreRepository;
85
86impl CoreRepository {
87    /// Lists all installed packages.
88    pub fn list_all(conn: &mut SqliteConnection) -> QueryResult<Vec<Package>> {
89        packages::table.select(Package::as_select()).load(conn)
90    }
91
92    /// Lists installed packages with flexible filtering.
93    #[allow(clippy::too_many_arguments)]
94    pub fn list_filtered(
95        conn: &mut SqliteConnection,
96        repo_name: Option<&str>,
97        pkg_name: Option<&str>,
98        pkg_id: Option<&str>,
99        version: Option<&str>,
100        is_installed: Option<bool>,
101        pinned: Option<bool>,
102        limit: Option<i64>,
103        sort_by_id: Option<SortDirection>,
104    ) -> QueryResult<Vec<InstalledPackageWithPortable>> {
105        let mut query = packages::table
106            .left_join(portable_package::table)
107            .into_boxed();
108
109        if let Some(repo) = repo_name {
110            query = query.filter(packages::repo_name.eq(repo));
111        }
112        if let Some(name) = pkg_name {
113            query = query.filter(packages::pkg_name.eq(name));
114        }
115        if let Some(id) = pkg_id {
116            query = query.filter(packages::pkg_id.eq(id));
117        }
118        if let Some(ver) = version {
119            query = query.filter(packages::version.eq(ver));
120        }
121        if let Some(installed) = is_installed {
122            query = query.filter(packages::is_installed.eq(installed));
123        }
124        if let Some(pin) = pinned {
125            query = query.filter(packages::pinned.eq(pin));
126        }
127
128        if let Some(direction) = sort_by_id {
129            query = match direction {
130                SortDirection::Asc => query.order(packages::id.asc()),
131                SortDirection::Desc => query.order(packages::id.desc()),
132            };
133        }
134
135        if let Some(lim) = limit {
136            query = query.limit(lim);
137        }
138
139        let results: Vec<(Package, Option<PortablePackage>)> = query
140            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
141            .load(conn)?;
142
143        Ok(results.into_iter().map(Into::into).collect())
144    }
145
146    /// Lists broken packages (is_installed = false).
147    pub fn list_broken(
148        conn: &mut SqliteConnection,
149    ) -> QueryResult<Vec<InstalledPackageWithPortable>> {
150        let results: Vec<(Package, Option<PortablePackage>)> = packages::table
151            .left_join(portable_package::table)
152            .filter(packages::is_installed.eq(false))
153            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
154            .load(conn)?;
155
156        Ok(results.into_iter().map(Into::into).collect())
157    }
158
159    /// Lists installed packages that are not pinned (for updates).
160    pub fn list_updatable(
161        conn: &mut SqliteConnection,
162    ) -> QueryResult<Vec<InstalledPackageWithPortable>> {
163        let results: Vec<(Package, Option<PortablePackage>)> = packages::table
164            .left_join(portable_package::table)
165            .filter(packages::is_installed.eq(true))
166            .filter(packages::pinned.eq(false))
167            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
168            .load(conn)?;
169
170        Ok(results.into_iter().map(Into::into).collect())
171    }
172
173    /// Finds an installed package by exact match on repo_name, pkg_name, pkg_id, and version.
174    pub fn find_exact(
175        conn: &mut SqliteConnection,
176        repo_name: &str,
177        pkg_name: &str,
178        pkg_id: &str,
179        version: &str,
180    ) -> QueryResult<Option<InstalledPackageWithPortable>> {
181        let result: Option<(Package, Option<PortablePackage>)> = packages::table
182            .left_join(portable_package::table)
183            .filter(packages::repo_name.eq(repo_name))
184            .filter(packages::pkg_name.eq(pkg_name))
185            .filter(packages::pkg_id.eq(pkg_id))
186            .filter(packages::version.eq(version))
187            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
188            .first(conn)
189            .optional()?;
190
191        Ok(result.map(Into::into))
192    }
193
194    /// Lists all installed packages with portable configuration.
195    pub fn list_all_with_portable(
196        conn: &mut SqliteConnection,
197    ) -> QueryResult<Vec<InstalledPackageWithPortable>> {
198        let results: Vec<(Package, Option<PortablePackage>)> = packages::table
199            .left_join(portable_package::table)
200            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
201            .load(conn)?;
202
203        Ok(results.into_iter().map(Into::into).collect())
204    }
205
206    /// Lists installed packages filtered by repo_name.
207    pub fn list_by_repo(conn: &mut SqliteConnection, repo_name: &str) -> QueryResult<Vec<Package>> {
208        packages::table
209            .filter(packages::repo_name.eq(repo_name))
210            .select(Package::as_select())
211            .load(conn)
212    }
213
214    /// Lists installed packages filtered by repo_name with portable configuration.
215    pub fn list_by_repo_with_portable(
216        conn: &mut SqliteConnection,
217        repo_name: &str,
218    ) -> QueryResult<Vec<InstalledPackageWithPortable>> {
219        let results: Vec<(Package, Option<PortablePackage>)> = packages::table
220            .left_join(portable_package::table)
221            .filter(packages::repo_name.eq(repo_name))
222            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
223            .load(conn)?;
224
225        Ok(results.into_iter().map(Into::into).collect())
226    }
227
228    /// Counts installed packages.
229    pub fn count(conn: &mut SqliteConnection) -> QueryResult<i64> {
230        packages::table.count().get_result(conn)
231    }
232
233    /// Counts distinct installed packages.
234    pub fn count_distinct_installed(
235        conn: &mut SqliteConnection,
236        repo_name: Option<&str>,
237    ) -> QueryResult<i64> {
238        use diesel::dsl::sql;
239
240        let mut query = packages::table
241            .filter(packages::is_installed.eq(true))
242            .into_boxed();
243
244        if let Some(repo) = repo_name {
245            query = query.filter(packages::repo_name.eq(repo));
246        }
247
248        query
249            .select(sql::<diesel::sql_types::BigInt>(
250                "COUNT(DISTINCT pkg_id || '\x00' || pkg_name)",
251            ))
252            .first(conn)
253    }
254
255    /// Finds an installed package by ID.
256    pub fn find_by_id(conn: &mut SqliteConnection, id: i32) -> QueryResult<Option<Package>> {
257        packages::table
258            .filter(packages::id.eq(id))
259            .select(Package::as_select())
260            .first(conn)
261            .optional()
262    }
263
264    /// Finds an installed package by ID with portable configuration.
265    pub fn find_by_id_with_portable(
266        conn: &mut SqliteConnection,
267        id: i32,
268    ) -> QueryResult<Option<InstalledPackageWithPortable>> {
269        let result: Option<(Package, Option<PortablePackage>)> = packages::table
270            .left_join(portable_package::table)
271            .filter(packages::id.eq(id))
272            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
273            .first(conn)
274            .optional()?;
275
276        Ok(result.map(Into::into))
277    }
278
279    /// Finds installed packages by name.
280    pub fn find_by_name(conn: &mut SqliteConnection, name: &str) -> QueryResult<Vec<Package>> {
281        packages::table
282            .filter(packages::pkg_name.eq(name))
283            .select(Package::as_select())
284            .load(conn)
285    }
286
287    /// Finds installed packages by name with portable configuration.
288    pub fn find_by_name_with_portable(
289        conn: &mut SqliteConnection,
290        name: &str,
291    ) -> QueryResult<Vec<InstalledPackageWithPortable>> {
292        let results: Vec<(Package, Option<PortablePackage>)> = packages::table
293            .left_join(portable_package::table)
294            .filter(packages::pkg_name.eq(name))
295            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
296            .load(conn)?;
297
298        Ok(results.into_iter().map(Into::into).collect())
299    }
300
301    /// Finds installed packages by name, excluding specific pkg_id and version.
302    pub fn find_alternates(
303        conn: &mut SqliteConnection,
304        pkg_name: &str,
305        exclude_pkg_id: &str,
306        exclude_version: &str,
307    ) -> QueryResult<Vec<InstalledPackageWithPortable>> {
308        let results: Vec<(Package, Option<PortablePackage>)> = packages::table
309            .left_join(portable_package::table)
310            .filter(packages::pkg_name.eq(pkg_name))
311            .filter(packages::pkg_id.ne(exclude_pkg_id))
312            .filter(packages::version.ne(exclude_version))
313            .select((Package::as_select(), Option::<PortablePackage>::as_select()))
314            .load(conn)?;
315
316        Ok(results.into_iter().map(Into::into).collect())
317    }
318
319    /// Finds an installed package by pkg_id and repo_name.
320    pub fn find_by_pkg_id_and_repo(
321        conn: &mut SqliteConnection,
322        pkg_id: &str,
323        repo_name: &str,
324    ) -> QueryResult<Option<Package>> {
325        packages::table
326            .filter(packages::pkg_id.eq(pkg_id))
327            .filter(packages::repo_name.eq(repo_name))
328            .select(Package::as_select())
329            .first(conn)
330            .optional()
331    }
332
333    /// Finds an installed package by pkg_id, pkg_name, and repo_name.
334    pub fn find_by_pkg_id_name_and_repo(
335        conn: &mut SqliteConnection,
336        pkg_id: &str,
337        pkg_name: &str,
338        repo_name: &str,
339    ) -> QueryResult<Option<Package>> {
340        packages::table
341            .filter(packages::pkg_id.eq(pkg_id))
342            .filter(packages::pkg_name.eq(pkg_name))
343            .filter(packages::repo_name.eq(repo_name))
344            .select(Package::as_select())
345            .first(conn)
346            .optional()
347    }
348
349    /// Inserts a new installed package and returns the inserted ID.
350    pub fn insert(conn: &mut SqliteConnection, package: &NewPackage) -> QueryResult<i32> {
351        diesel::insert_into(packages::table)
352            .values(package)
353            .returning(packages::id)
354            .get_result(conn)
355    }
356
357    /// Updates an installed package's version.
358    pub fn update_version(
359        conn: &mut SqliteConnection,
360        id: i32,
361        new_version: &str,
362    ) -> QueryResult<usize> {
363        diesel::update(packages::table.filter(packages::id.eq(id)))
364            .set(packages::version.eq(new_version))
365            .execute(conn)
366    }
367
368    /// Updates an installed package after successful installation.
369    #[allow(clippy::too_many_arguments)]
370    pub fn record_installation(
371        conn: &mut SqliteConnection,
372        repo_name: &str,
373        pkg_name: &str,
374        pkg_id: &str,
375        version: &str,
376        size: i64,
377        provides: Option<Vec<PackageProvide>>,
378        with_pkg_id: bool,
379        checksum: Option<&str>,
380        installed_date: &str,
381    ) -> QueryResult<Option<i32>> {
382        let provides = provides.map(|v| serde_json::to_value(v).unwrap_or_default());
383        diesel::update(
384            packages::table
385                .filter(packages::repo_name.eq(repo_name))
386                .filter(packages::pkg_name.eq(pkg_name))
387                .filter(packages::pkg_id.eq(pkg_id))
388                .filter(packages::version.eq(version)),
389        )
390        .set((
391            packages::size.eq(size),
392            packages::installed_date.eq(installed_date),
393            packages::is_installed.eq(true),
394            packages::provides.eq(provides),
395            packages::with_pkg_id.eq(with_pkg_id),
396            packages::checksum.eq(checksum),
397        ))
398        .returning(packages::id)
399        .get_result(conn)
400        .optional()
401    }
402
403    /// Sets the pinned status of a package.
404    pub fn set_pinned(conn: &mut SqliteConnection, id: i32, pinned: bool) -> QueryResult<usize> {
405        diesel::update(packages::table.filter(packages::id.eq(id)))
406            .set(packages::pinned.eq(pinned))
407            .execute(conn)
408    }
409
410    /// Sets the unlinked status of a package.
411    pub fn set_unlinked(
412        conn: &mut SqliteConnection,
413        id: i32,
414        unlinked: bool,
415    ) -> QueryResult<usize> {
416        diesel::update(packages::table.filter(packages::id.eq(id)))
417            .set(packages::unlinked.eq(unlinked))
418            .execute(conn)
419    }
420
421    /// Unlinks all packages with a given name except those matching pkg_id and version.
422    pub fn unlink_others(
423        conn: &mut SqliteConnection,
424        pkg_name: &str,
425        keep_pkg_id: &str,
426        keep_version: &str,
427    ) -> QueryResult<usize> {
428        diesel::update(
429            packages::table
430                .filter(packages::pkg_name.eq(pkg_name))
431                .filter(
432                    packages::pkg_id
433                        .ne(keep_pkg_id)
434                        .or(packages::version.ne(keep_version)),
435                ),
436        )
437        .set(packages::unlinked.eq(true))
438        .execute(conn)
439    }
440
441    /// Updates the pkg_id for packages matching repo_name and old pkg_id.
442    pub fn update_pkg_id(
443        conn: &mut SqliteConnection,
444        repo_name: &str,
445        old_pkg_id: &str,
446        new_pkg_id: &str,
447    ) -> QueryResult<usize> {
448        diesel::update(
449            packages::table
450                .filter(packages::repo_name.eq(repo_name))
451                .filter(packages::pkg_id.eq(old_pkg_id)),
452        )
453        .set(packages::pkg_id.eq(new_pkg_id))
454        .execute(conn)
455    }
456
457    /// Deletes an installed package by ID.
458    pub fn delete(conn: &mut SqliteConnection, id: i32) -> QueryResult<usize> {
459        diesel::delete(packages::table.filter(packages::id.eq(id))).execute(conn)
460    }
461
462    /// Gets the portable package configuration for a package.
463    pub fn get_portable(
464        conn: &mut SqliteConnection,
465        package_id: i32,
466    ) -> QueryResult<Option<PortablePackage>> {
467        portable_package::table
468            .filter(portable_package::package_id.eq(package_id))
469            .select(PortablePackage::as_select())
470            .first(conn)
471            .optional()
472    }
473
474    /// Inserts portable package configuration.
475    pub fn insert_portable(
476        conn: &mut SqliteConnection,
477        portable: &NewPortablePackage,
478    ) -> QueryResult<usize> {
479        diesel::insert_into(portable_package::table)
480            .values(portable)
481            .execute(conn)
482    }
483
484    /// Updates or inserts portable package configuration.
485    pub fn upsert_portable(
486        conn: &mut SqliteConnection,
487        package_id: i32,
488        portable_path: Option<&str>,
489        portable_home: Option<&str>,
490        portable_config: Option<&str>,
491        portable_share: Option<&str>,
492        portable_cache: Option<&str>,
493    ) -> QueryResult<usize> {
494        let updated = diesel::update(
495            portable_package::table.filter(portable_package::package_id.eq(package_id)),
496        )
497        .set((
498            portable_package::portable_path.eq(portable_path),
499            portable_package::portable_home.eq(portable_home),
500            portable_package::portable_config.eq(portable_config),
501            portable_package::portable_share.eq(portable_share),
502            portable_package::portable_cache.eq(portable_cache),
503        ))
504        .execute(conn)?;
505
506        if updated == 0 {
507            diesel::insert_into(portable_package::table)
508                .values(&NewPortablePackage {
509                    package_id,
510                    portable_path,
511                    portable_home,
512                    portable_config,
513                    portable_share,
514                    portable_cache,
515                })
516                .execute(conn)
517        } else {
518            Ok(updated)
519        }
520    }
521
522    /// Deletes portable package configuration.
523    pub fn delete_portable(conn: &mut SqliteConnection, package_id: i32) -> QueryResult<usize> {
524        diesel::delete(portable_package::table.filter(portable_package::package_id.eq(package_id)))
525            .execute(conn)
526    }
527
528    /// Gets old package versions (all except the newest unpinned one) for cleanup.
529    /// Returns the installed paths of packages to remove.
530    pub fn get_old_package_paths(
531        conn: &mut SqliteConnection,
532        pkg_id: &str,
533        pkg_name: &str,
534        repo_name: &str,
535    ) -> QueryResult<Vec<(i32, String)>> {
536        let latest: Option<(i32, String)> = packages::table
537            .filter(packages::pkg_id.eq(pkg_id))
538            .filter(packages::pkg_name.eq(pkg_name))
539            .filter(packages::repo_name.eq(repo_name))
540            .order(packages::id.desc())
541            .select((packages::id, packages::installed_path))
542            .first(conn)
543            .optional()?;
544
545        let Some((latest_id, latest_path)) = latest else {
546            return Ok(Vec::new());
547        };
548
549        packages::table
550            .filter(packages::pkg_id.eq(pkg_id))
551            .filter(packages::pkg_name.eq(pkg_name))
552            .filter(packages::repo_name.eq(repo_name))
553            .filter(packages::pinned.eq(false))
554            .filter(packages::id.ne(latest_id))
555            .filter(packages::installed_path.ne(&latest_path))
556            .select((packages::id, packages::installed_path))
557            .load(conn)
558    }
559
560    /// Deletes old package versions (all except the newest unpinned one).
561    pub fn delete_old_packages(
562        conn: &mut SqliteConnection,
563        pkg_id: &str,
564        pkg_name: &str,
565        repo_name: &str,
566    ) -> QueryResult<usize> {
567        let latest_id: Option<i32> = packages::table
568            .filter(packages::pkg_id.eq(pkg_id))
569            .filter(packages::pkg_name.eq(pkg_name))
570            .filter(packages::repo_name.eq(repo_name))
571            .order(packages::id.desc())
572            .select(packages::id)
573            .first(conn)
574            .optional()?;
575
576        let Some(latest_id) = latest_id else {
577            return Ok(0);
578        };
579
580        diesel::delete(
581            packages::table
582                .filter(packages::pkg_id.eq(pkg_id))
583                .filter(packages::pkg_name.eq(pkg_name))
584                .filter(packages::repo_name.eq(repo_name))
585                .filter(packages::pinned.eq(false))
586                .filter(packages::id.ne(latest_id)),
587        )
588        .execute(conn)
589    }
590
591    /// Unlinks all packages with a given name except those matching pkg_id and checksum.
592    /// Used when switching between alternate package versions.
593    pub fn unlink_others_by_checksum(
594        conn: &mut SqliteConnection,
595        pkg_name: &str,
596        keep_pkg_id: &str,
597        keep_checksum: Option<&str>,
598    ) -> QueryResult<usize> {
599        if let Some(checksum) = keep_checksum {
600            diesel::update(
601                packages::table
602                    .filter(packages::pkg_name.eq(pkg_name))
603                    .filter(packages::pkg_id.ne(keep_pkg_id))
604                    .filter(packages::checksum.ne(checksum)),
605            )
606            .set(packages::unlinked.eq(true))
607            .execute(conn)
608        } else {
609            diesel::update(
610                packages::table
611                    .filter(packages::pkg_name.eq(pkg_name))
612                    .filter(packages::pkg_id.ne(keep_pkg_id)),
613            )
614            .set(packages::unlinked.eq(true))
615            .execute(conn)
616        }
617    }
618
619    /// Links a package by pkg_name, pkg_id, and checksum.
620    /// Used when switching to an alternate package version.
621    pub fn link_by_checksum(
622        conn: &mut SqliteConnection,
623        pkg_name: &str,
624        pkg_id: &str,
625        checksum: Option<&str>,
626    ) -> QueryResult<usize> {
627        if let Some(checksum) = checksum {
628            diesel::update(
629                packages::table
630                    .filter(packages::pkg_name.eq(pkg_name))
631                    .filter(packages::pkg_id.eq(pkg_id))
632                    .filter(packages::checksum.eq(checksum)),
633            )
634            .set(packages::unlinked.eq(false))
635            .execute(conn)
636        } else {
637            diesel::update(
638                packages::table
639                    .filter(packages::pkg_name.eq(pkg_name))
640                    .filter(packages::pkg_id.eq(pkg_id)),
641            )
642            .set(packages::unlinked.eq(false))
643            .execute(conn)
644        }
645    }
646}