Skip to main content

sui_store/
traits.rs

1//! Core store trait — the interface all store backends implement.
2
3use sui_compat::store_path::StorePath;
4
5/// Result type for store operations.
6pub type StoreResult<T> = Result<T, StoreError>;
7
8/// Store operation errors.
9#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum StoreError {
12    /// The requested store path does not exist.
13    #[error("path not found: {0}")]
14    PathNotFound(String),
15    /// A database or backend operation failed.
16    #[error("database error: {0}")]
17    Database(String),
18    /// An HTTP request to a binary cache failed.
19    #[error("http error: {0}")]
20    Http(String),
21    /// A NarInfo response could not be parsed.
22    #[error("narinfo parse error: {0}")]
23    NarInfo(String),
24    /// An I/O operation failed.
25    #[error("io error: {0}")]
26    Io(#[from] std::io::Error),
27    /// The operation is not supported by this store backend.
28    #[error("not supported: {0}")]
29    NotSupported(String),
30    /// An internal invariant was violated.
31    #[error("internal error: {0}")]
32    Internal(String),
33}
34
35/// Information about a store path.
36#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37#[must_use]
38pub struct PathInfo {
39    /// Full absolute store path (e.g., `/nix/store/abc...-hello-2.12.1`).
40    pub path: String,
41    /// Hash of the NAR archive in `sha256:<base16>` format.
42    pub nar_hash: String,
43    /// Size of the NAR archive in bytes.
44    pub nar_size: i64,
45    /// Runtime dependency store paths.
46    pub references: Vec<String>,
47    /// Store path of the `.drv` that produced this path (if known).
48    pub deriver: Option<String>,
49    /// Ed25519 signatures (`keyname:base64sig`).
50    pub signatures: Vec<String>,
51    /// Unix timestamp of when this path was registered.
52    pub registration_time: i64,
53    /// Content-address assertion (e.g., `fixed:out:r:sha256:...`).
54    pub content_address: Option<String>,
55}
56
57impl StoreError {
58    /// Returns `true` if this is a `PathNotFound` error.
59    #[must_use]
60    pub fn is_path_not_found(&self) -> bool {
61        matches!(self, Self::PathNotFound(_))
62    }
63
64    /// Returns `true` if this is a `NotSupported` error.
65    #[must_use]
66    pub fn is_not_supported(&self) -> bool {
67        matches!(self, Self::NotSupported(_))
68    }
69}
70
71impl From<crate::http::HttpError> for StoreError {
72    fn from(e: crate::http::HttpError) -> Self {
73        Self::Http(e.to_string())
74    }
75}
76
77impl PathInfo {
78    /// Create a new `PathInfo` with the given path and NAR hash.
79    ///
80    /// All other fields default to zero/empty. Use the struct update syntax
81    /// or setter calls to fill in remaining fields.
82    pub fn new(path: impl Into<String>, nar_hash: impl Into<String>) -> Self {
83        Self {
84            path: path.into(),
85            nar_hash: nar_hash.into(),
86            ..Self::default()
87        }
88    }
89}
90
91impl std::fmt::Display for PathInfo {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(f, "{} (nar_size={})", self.path, self.nar_size)
94    }
95}
96
97impl From<&sui_compat::narinfo::NarInfo> for PathInfo {
98    fn from(info: &sui_compat::narinfo::NarInfo) -> Self {
99        // NarInfo's `References` field stores bare basenames
100        // (e.g. `abc...-glibc-2.37`). PathInfo.references must contain absolute
101        // store paths (e.g. `/nix/store/abc...-glibc-2.37`) so the default
102        // `Store::query_references` impl can parse them via
103        // `StorePath::from_absolute_path`. Anything that already looks
104        // absolute is passed through unchanged for robustness.
105        let store_dir = sui_compat::store_path::DEFAULT_STORE_DIR;
106        let references = info
107            .references
108            .iter()
109            .map(|r| {
110                if r.starts_with('/') {
111                    r.clone()
112                } else {
113                    format!("{store_dir}/{r}")
114                }
115            })
116            .collect();
117        Self {
118            path: info.store_path.clone(),
119            nar_hash: info.nar_hash.clone(),
120            nar_size: info.nar_size as i64,
121            references,
122            deriver: info.deriver.clone(),
123            signatures: info.signatures.clone(),
124            registration_time: 0,
125            content_address: info.ca.clone(),
126        }
127    }
128}
129
130/// Garbage collection options.
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct GcOptions {
133    /// Maximum bytes to free (0 = unlimited).
134    pub max_freed: u64,
135    /// Delete paths older than this many seconds.
136    pub delete_older_than: Option<u64>,
137}
138
139impl GcOptions {
140    /// Set the maximum number of bytes to free.
141    #[must_use]
142    pub fn with_max_freed(mut self, bytes: u64) -> Self {
143        self.max_freed = bytes;
144        self
145    }
146
147    /// Delete store paths older than the given number of seconds.
148    #[must_use]
149    pub fn with_delete_older_than(mut self, seconds: u64) -> Self {
150        self.delete_older_than = Some(seconds);
151        self
152    }
153}
154
155/// Garbage collection result.
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157#[must_use]
158pub struct GcResult {
159    /// Number of store paths deleted.
160    pub paths_deleted: usize,
161    /// Total bytes freed.
162    pub bytes_freed: u64,
163}
164
165impl std::fmt::Display for GcResult {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        write!(
168            f,
169            "GC: {} paths deleted, {} bytes freed",
170            self.paths_deleted, self.bytes_freed
171        )
172    }
173}
174
175/// Result of store optimisation (hard-link deduplication).
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177#[must_use]
178pub struct OptimiseResult {
179    /// Number of files that were hard-linked to deduplicate.
180    pub files_linked: u64,
181    /// Total bytes saved by deduplication.
182    pub bytes_saved: u64,
183}
184
185impl std::fmt::Display for OptimiseResult {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        write!(
188            f,
189            "Optimise: {} files linked, {} bytes saved",
190            self.files_linked, self.bytes_saved
191        )
192    }
193}
194
195/// Information about a corrupt store path detected during verification.
196#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
197#[must_use]
198pub struct CorruptPath {
199    /// The store path that failed verification.
200    pub path: String,
201    /// The hash recorded in the database.
202    pub expected_hash: String,
203    /// The hash computed from the actual files on disk.
204    pub actual_hash: String,
205}
206
207/// Result of store integrity verification.
208#[derive(Debug, Clone, Default, PartialEq, Eq)]
209#[must_use]
210pub struct VerifyResult {
211    /// Total number of paths checked.
212    pub total_checked: usize,
213    /// Number of paths that passed verification.
214    pub valid_count: usize,
215    /// Paths that failed hash verification.
216    pub corrupt: Vec<CorruptPath>,
217}
218
219impl std::fmt::Display for VerifyResult {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        write!(
222            f,
223            "Verify: {} checked, {} valid, {} corrupt",
224            self.total_checked,
225            self.valid_count,
226            self.corrupt.len()
227        )
228    }
229}
230
231/// The core store interface.
232///
233/// All store backends (local, remote, binary cache) implement this trait.
234/// Uses `#[async_trait]` for object safety — enables `dyn Store`.
235#[async_trait::async_trait]
236pub trait Store: Send + Sync {
237    /// Query information about a store path.
238    async fn query_path_info(
239        &self,
240        path: &StorePath,
241    ) -> StoreResult<Option<PathInfo>>;
242
243    /// Check whether a store path is valid (registered in the store).
244    async fn is_valid_path(
245        &self,
246        path: &StorePath,
247    ) -> StoreResult<bool>;
248
249    /// List all valid store paths.
250    async fn query_all_valid_paths(
251        &self,
252    ) -> StoreResult<Vec<StorePath>>;
253
254    /// Query the runtime references (dependencies) of a store path.
255    async fn query_references(
256        &self,
257        path: &StorePath,
258    ) -> StoreResult<Vec<StorePath>> {
259        let info = self
260            .query_path_info(path)
261            .await?
262            .ok_or_else(|| StoreError::PathNotFound(path.to_absolute_path()))?;
263
264        Ok(info
265            .references
266            .iter()
267            .filter_map(|r| StorePath::from_absolute_path(r).ok())
268            .collect())
269    }
270
271    /// Compute the transitive closure of a set of store paths.
272    ///
273    /// Uses `BTreeSet` for deterministic traversal order.
274    async fn compute_closure(
275        &self,
276        roots: &[StorePath],
277    ) -> StoreResult<Vec<StorePath>> {
278        let mut closure = Vec::new();
279        let mut stack: Vec<StorePath> = roots.to_vec();
280        let mut seen = std::collections::BTreeSet::new();
281
282        while let Some(path) = stack.pop() {
283            let key = path.to_absolute_path();
284            if !seen.insert(key) {
285                continue;
286            }
287
288            let refs = self.query_references(&path).await?;
289            stack.extend(refs);
290            closure.push(path);
291        }
292        Ok(closure)
293    }
294
295    /// Run garbage collection on the store.
296    async fn collect_garbage(
297        &self,
298        _options: &GcOptions,
299    ) -> StoreResult<GcResult> {
300        Err(StoreError::NotSupported(
301            "garbage collection not implemented for this backend".to_string(),
302        ))
303    }
304
305    /// Add a store path with its NAR content. Returns the registered PathInfo.
306    async fn add_to_store(
307        &self,
308        _name: &str,
309        _nar_data: &[u8],
310        _references: &[String],
311    ) -> StoreResult<PathInfo> {
312        Err(StoreError::NotSupported(
313            "add_to_store not implemented for this backend".to_string(),
314        ))
315    }
316
317    /// Register a pre-built path in the store database.
318    async fn register_path(
319        &self,
320        _info: &PathInfo,
321    ) -> StoreResult<()> {
322        Err(StoreError::NotSupported(
323            "register_path not implemented for this backend".to_string(),
324        ))
325    }
326
327    /// Add signatures to an existing store path.
328    async fn add_signatures(
329        &self,
330        _path: &StorePath,
331        _signatures: &[String],
332    ) -> StoreResult<()> {
333        Err(StoreError::NotSupported(
334            "add_signatures not implemented for this backend".to_string(),
335        ))
336    }
337
338    /// Query paths that refer to the given path (reverse dependencies).
339    async fn query_referrers(
340        &self,
341        _path: &StorePath,
342    ) -> StoreResult<Vec<StorePath>> {
343        Err(StoreError::NotSupported(
344            "query_referrers not implemented for this backend".to_string(),
345        ))
346    }
347
348    /// Verify store integrity by checking NAR hashes of all valid paths.
349    async fn verify_store(&self) -> StoreResult<VerifyResult> {
350        Err(StoreError::NotSupported(
351            "verify_store not implemented for this backend".to_string(),
352        ))
353    }
354
355    /// Delete a store path from disk and the database.
356    ///
357    /// Returns the number of bytes freed.
358    async fn delete_path(&self, _path: &StorePath) -> StoreResult<u64> {
359        Err(StoreError::NotSupported(
360            "delete_path not implemented for this backend".to_string(),
361        ))
362    }
363
364    /// Optimise the store by hard-linking identical files.
365    ///
366    /// Walks all files under `/nix/store`, hashing each unique file.
367    /// When two files share the same content hash and are not already
368    /// hard-linked, replaces duplicates with hard links to save disk space.
369    async fn optimise_store(&self, _dry_run: bool) -> StoreResult<OptimiseResult> {
370        Err(StoreError::NotSupported(
371            "optimise_store not implemented for this backend".to_string(),
372        ))
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn path_info_serialization_roundtrip() {
382        let info = PathInfo {
383            path: "/nix/store/abc-hello".to_string(),
384            nar_hash: "sha256:deadbeef".to_string(),
385            nar_size: 1024,
386            references: vec!["/nix/store/dep1".to_string()],
387            deriver: Some("/nix/store/abc.drv".to_string()),
388            signatures: vec!["key:sig".to_string()],
389            registration_time: 1234567890,
390            content_address: None,
391        };
392        let json = serde_json::to_string(&info).unwrap();
393        let parsed: PathInfo = serde_json::from_str(&json).unwrap();
394        assert_eq!(parsed.path, info.path);
395        assert_eq!(parsed.nar_hash, info.nar_hash);
396        assert_eq!(parsed.nar_size, info.nar_size);
397        assert_eq!(parsed.references, info.references);
398        assert_eq!(parsed.deriver, info.deriver);
399        assert_eq!(parsed.signatures, info.signatures);
400        assert_eq!(parsed.registration_time, info.registration_time);
401    }
402
403    #[test]
404    fn path_info_serialization_all_fields_present() {
405        let info = PathInfo {
406            path: "/nix/store/abc-hello".to_string(),
407            nar_hash: "sha256:deadbeef".to_string(),
408            nar_size: 1024,
409            references: vec!["/nix/store/dep1".to_string(), "/nix/store/dep2".to_string()],
410            deriver: Some("/nix/store/abc.drv".to_string()),
411            signatures: vec!["key1:sig1".to_string(), "key2:sig2".to_string()],
412            registration_time: 1234567890,
413            content_address: Some("fixed:out:r:sha256:cafe".to_string()),
414        };
415        let json = serde_json::to_string(&info).unwrap();
416        assert!(json.contains("\"content_address\""));
417        assert!(json.contains("fixed:out:r:sha256:cafe"));
418        let parsed: PathInfo = serde_json::from_str(&json).unwrap();
419        assert_eq!(parsed.content_address, info.content_address);
420        assert_eq!(parsed.references.len(), 2);
421        assert_eq!(parsed.signatures.len(), 2);
422    }
423
424    #[test]
425    fn path_info_serialization_none_fields() {
426        let info = PathInfo {
427            path: "/nix/store/abc-minimal".to_string(),
428            nar_hash: "sha256:000".to_string(),
429            nar_size: 0,
430            references: vec![],
431            deriver: None,
432            signatures: vec![],
433            registration_time: 0,
434            content_address: None,
435        };
436        let json = serde_json::to_string(&info).unwrap();
437        let parsed: PathInfo = serde_json::from_str(&json).unwrap();
438        assert!(parsed.deriver.is_none());
439        assert!(parsed.content_address.is_none());
440        assert!(parsed.references.is_empty());
441        assert!(parsed.signatures.is_empty());
442        assert_eq!(parsed.nar_size, 0);
443    }
444
445    #[test]
446    fn path_info_json_pretty_roundtrip() {
447        let info = PathInfo {
448            path: "/nix/store/abc-hello".to_string(),
449            nar_hash: "sha256:deadbeef".to_string(),
450            nar_size: 42,
451            references: vec![],
452            deriver: None,
453            signatures: vec![],
454            registration_time: 999,
455            content_address: None,
456        };
457        let pretty = serde_json::to_string_pretty(&info).unwrap();
458        let parsed: PathInfo = serde_json::from_str(&pretty).unwrap();
459        assert_eq!(parsed.path, info.path);
460        assert_eq!(parsed.nar_size, 42);
461    }
462
463    #[test]
464    fn path_info_deserialization_from_json_object() {
465        let json = r#"{
466            "path": "/nix/store/xyz-test",
467            "nar_hash": "sha256:abc123",
468            "nar_size": 9999,
469            "references": ["/nix/store/dep-a"],
470            "deriver": null,
471            "signatures": [],
472            "registration_time": 0,
473            "content_address": null
474        }"#;
475        let info: PathInfo = serde_json::from_str(json).unwrap();
476        assert_eq!(info.path, "/nix/store/xyz-test");
477        assert_eq!(info.nar_size, 9999);
478        assert_eq!(info.references, vec!["/nix/store/dep-a"]);
479    }
480
481    #[test]
482    fn path_info_clone_independence() {
483        let info = PathInfo {
484            path: "/nix/store/abc-hello".to_string(),
485            nar_hash: "sha256:deadbeef".to_string(),
486            nar_size: 1024,
487            references: vec!["/nix/store/dep1".to_string()],
488            deriver: Some("/nix/store/abc.drv".to_string()),
489            signatures: vec!["key:sig".to_string()],
490            registration_time: 1234567890,
491            content_address: None,
492        };
493        let mut cloned = info.clone();
494        cloned.nar_size = 9999;
495        cloned.path = "/nix/store/other".to_string();
496        assert_eq!(info.nar_size, 1024);
497        assert_eq!(info.path, "/nix/store/abc-hello");
498    }
499
500    #[test]
501    fn gc_options_default() {
502        let opts = GcOptions::default();
503        assert_eq!(opts.max_freed, 0);
504        assert!(opts.delete_older_than.is_none());
505    }
506
507    #[test]
508    fn store_error_display() {
509        let e = StoreError::PathNotFound("/nix/store/abc".to_string());
510        assert!(e.to_string().contains("/nix/store/abc"));
511
512        let e = StoreError::NotSupported("gc".to_string());
513        assert!(e.to_string().contains("gc"));
514    }
515
516    // ── TestStore: implements only required methods ───────────
517
518    /// Minimal store for exercising default trait methods.
519    /// Uses BTreeMap for deterministic iteration order.
520    struct TestStore {
521        infos: std::collections::BTreeMap<String, PathInfo>,
522    }
523
524    impl TestStore {
525        fn new() -> Self {
526            Self {
527                infos: std::collections::BTreeMap::new(),
528            }
529        }
530
531        fn with_path(mut self, info: PathInfo) -> Self {
532            self.infos.insert(info.path.clone(), info);
533            self
534        }
535    }
536
537    #[async_trait::async_trait]
538    impl Store for TestStore {
539        async fn query_path_info(
540            &self,
541            path: &StorePath,
542        ) -> StoreResult<Option<PathInfo>> {
543            Ok(self.infos.get(&path.to_absolute_path()).cloned())
544        }
545
546        async fn is_valid_path(
547            &self,
548            path: &StorePath,
549        ) -> StoreResult<bool> {
550            Ok(self.infos.contains_key(&path.to_absolute_path()))
551        }
552
553        async fn query_all_valid_paths(&self) -> StoreResult<Vec<StorePath>> {
554            self.infos
555                .keys()
556                .map(|p| {
557                    StorePath::from_absolute_path(p)
558                        .map_err(|e| StoreError::Database(e.to_string()))
559                })
560                .collect()
561        }
562    }
563
564    // Helper to create a real StorePath from the well-known hello hash.
565    fn hello_path() -> StorePath {
566        StorePath::from_absolute_path(
567            "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1",
568        )
569        .unwrap()
570    }
571
572    fn glibc_path() -> StorePath {
573        StorePath::from_absolute_path(
574            "/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37",
575        )
576        .unwrap()
577    }
578
579    fn bash_path() -> StorePath {
580        StorePath::from_absolute_path(
581            "/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2",
582        )
583        .unwrap()
584    }
585
586    fn hello_info() -> PathInfo {
587        PathInfo {
588            path: hello_path().to_absolute_path(),
589            nar_hash: "sha256:aaa".to_string(),
590            nar_size: 5000,
591            references: vec![glibc_path().to_absolute_path()],
592            deriver: Some("/nix/store/abc.drv".to_string()),
593            signatures: vec!["key:sig".to_string()],
594            registration_time: 1000,
595            content_address: None,
596        }
597    }
598
599    fn glibc_info() -> PathInfo {
600        PathInfo {
601            path: glibc_path().to_absolute_path(),
602            nar_hash: "sha256:bbb".to_string(),
603            nar_size: 30000,
604            references: vec![bash_path().to_absolute_path()],
605            deriver: None,
606            signatures: vec![],
607            registration_time: 900,
608            content_address: None,
609        }
610    }
611
612    fn bash_info() -> PathInfo {
613        PathInfo {
614            path: bash_path().to_absolute_path(),
615            nar_hash: "sha256:ccc".to_string(),
616            nar_size: 8000,
617            references: vec![], // leaf — no deps
618            deriver: None,
619            signatures: vec![],
620            registration_time: 800,
621            content_address: None,
622        }
623    }
624
625    // ── Default method: query_references ─────────────────────
626
627    #[tokio::test]
628    async fn query_references_returns_refs_from_path_info() {
629        let store = TestStore::new()
630            .with_path(hello_info())
631            .with_path(glibc_info());
632
633        let refs = store.query_references(&hello_path()).await.unwrap();
634        assert_eq!(refs.len(), 1);
635        assert_eq!(refs[0].to_absolute_path(), glibc_path().to_absolute_path());
636    }
637
638    #[tokio::test]
639    async fn query_references_returns_empty_for_leaf() {
640        let store = TestStore::new().with_path(bash_info());
641
642        let refs = store.query_references(&bash_path()).await.unwrap();
643        assert!(refs.is_empty());
644    }
645
646    #[tokio::test]
647    async fn query_references_errors_for_missing_path() {
648        let store = TestStore::new();
649
650        let result = store.query_references(&hello_path()).await;
651        assert!(result.is_err());
652        match result.unwrap_err() {
653            StoreError::PathNotFound(p) => {
654                assert!(p.contains("hello-2.12.1"));
655            }
656            other => panic!("expected PathNotFound, got {other:?}"),
657        }
658    }
659
660    // ── Default method: compute_closure ──────────────────────
661
662    #[tokio::test]
663    async fn compute_closure_walks_transitive_deps() {
664        let store = TestStore::new()
665            .with_path(hello_info())
666            .with_path(glibc_info())
667            .with_path(bash_info());
668
669        let closure = store.compute_closure(&[hello_path()]).await.unwrap();
670        // Should contain hello, glibc, and bash (transitive)
671        assert_eq!(closure.len(), 3);
672        let paths: Vec<String> = closure.iter().map(|p| p.to_absolute_path()).collect();
673        assert!(paths.contains(&hello_path().to_absolute_path()));
674        assert!(paths.contains(&glibc_path().to_absolute_path()));
675        assert!(paths.contains(&bash_path().to_absolute_path()));
676    }
677
678    #[tokio::test]
679    async fn compute_closure_deduplicates() {
680        // Both hello and glibc depend on bash; bash should appear once
681        let store = TestStore::new()
682            .with_path(hello_info())
683            .with_path(glibc_info())
684            .with_path(bash_info());
685
686        let closure = store
687            .compute_closure(&[hello_path(), glibc_path()])
688            .await
689            .unwrap();
690        let bash_count = closure
691            .iter()
692            .filter(|p| p.to_absolute_path() == bash_path().to_absolute_path())
693            .count();
694        assert_eq!(bash_count, 1);
695    }
696
697    #[tokio::test]
698    async fn compute_closure_empty_roots() {
699        let store = TestStore::new();
700        let closure = store.compute_closure(&[]).await.unwrap();
701        assert!(closure.is_empty());
702    }
703
704    #[tokio::test]
705    async fn compute_closure_single_leaf() {
706        let store = TestStore::new().with_path(bash_info());
707
708        let closure = store.compute_closure(&[bash_path()]).await.unwrap();
709        assert_eq!(closure.len(), 1);
710        assert_eq!(closure[0].to_absolute_path(), bash_path().to_absolute_path());
711    }
712
713    // ── Default method: collect_garbage ──────────────────────
714
715    #[tokio::test]
716    async fn collect_garbage_returns_not_supported() {
717        let store = TestStore::new();
718        let result = store.collect_garbage(&GcOptions::default()).await;
719        assert!(result.is_err());
720        match result.unwrap_err() {
721            StoreError::NotSupported(msg) => {
722                assert!(msg.contains("garbage collection"));
723            }
724            other => panic!("expected NotSupported, got {other:?}"),
725        }
726    }
727
728    // ── Default method: add_to_store ─────────────────────────
729
730    #[tokio::test]
731    async fn add_to_store_returns_not_supported() {
732        let store = TestStore::new();
733        let result = store.add_to_store("test", b"data", &[]).await;
734        assert!(result.is_err());
735        match result.unwrap_err() {
736            StoreError::NotSupported(msg) => {
737                assert!(msg.contains("add_to_store"));
738            }
739            other => panic!("expected NotSupported, got {other:?}"),
740        }
741    }
742
743    // ── Default method: register_path ────────────────────────
744
745    #[tokio::test]
746    async fn register_path_returns_not_supported() {
747        let store = TestStore::new();
748        let info = hello_info();
749        let result = store.register_path(&info).await;
750        assert!(result.is_err());
751        match result.unwrap_err() {
752            StoreError::NotSupported(msg) => {
753                assert!(msg.contains("register_path"));
754            }
755            other => panic!("expected NotSupported, got {other:?}"),
756        }
757    }
758
759    // ── Default method: add_signatures ───────────────────────
760
761    #[tokio::test]
762    async fn add_signatures_returns_not_supported() {
763        let store = TestStore::new();
764        let result = store
765            .add_signatures(&hello_path(), &["sig1".to_string()])
766            .await;
767        assert!(result.is_err());
768        match result.unwrap_err() {
769            StoreError::NotSupported(msg) => {
770                assert!(msg.contains("add_signatures"));
771            }
772            other => panic!("expected NotSupported, got {other:?}"),
773        }
774    }
775
776    // ── Default method: query_referrers ──────────────────────
777
778    #[tokio::test]
779    async fn query_referrers_returns_not_supported() {
780        let store = TestStore::new();
781        let result = store.query_referrers(&hello_path()).await;
782        assert!(result.is_err());
783        match result.unwrap_err() {
784            StoreError::NotSupported(msg) => {
785                assert!(msg.contains("query_referrers"));
786            }
787            other => panic!("expected NotSupported, got {other:?}"),
788        }
789    }
790
791    // ── Store trait: object safety ───────────────────────────
792
793    #[test]
794    fn store_trait_is_object_safe() {
795        fn assert_obj_safe(_: &dyn Store) {}
796        let store = TestStore::new();
797        assert_obj_safe(&store);
798    }
799
800    // ── StoreError: Io variant ──────────────────────────────
801
802    #[test]
803    fn store_error_from_io() {
804        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
805        let store_err: StoreError = io_err.into();
806        assert!(store_err.to_string().contains("denied"));
807    }
808
809    #[test]
810    fn store_error_database_display() {
811        let e = StoreError::Database("connection lost".to_string());
812        assert!(e.to_string().contains("connection lost"));
813    }
814
815    #[test]
816    fn store_error_http_display() {
817        let e = StoreError::Http("timeout".to_string());
818        assert!(e.to_string().contains("timeout"));
819        assert!(e.to_string().contains("http"));
820    }
821
822    #[test]
823    fn store_error_narinfo_display() {
824        let e = StoreError::NarInfo("missing field: StorePath".to_string());
825        assert!(e.to_string().contains("missing field"));
826        assert!(e.to_string().contains("narinfo"));
827    }
828
829    // ── Arc<dyn Store> dispatch (the AppState pattern) ──────
830
831    #[tokio::test]
832    async fn arc_dyn_store_query_path_info() {
833        let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
834            TestStore::new().with_path(hello_info()),
835        );
836        let info = store.query_path_info(&hello_path()).await.unwrap();
837        assert!(info.is_some());
838        assert_eq!(info.unwrap().nar_hash, "sha256:aaa");
839    }
840
841    #[tokio::test]
842    async fn arc_dyn_store_is_valid_path() {
843        let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
844            TestStore::new().with_path(hello_info()),
845        );
846        assert!(store.is_valid_path(&hello_path()).await.unwrap());
847        assert!(!store.is_valid_path(&bash_path()).await.unwrap());
848    }
849
850    #[tokio::test]
851    async fn arc_dyn_store_query_all_valid_paths() {
852        let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
853            TestStore::new()
854                .with_path(hello_info())
855                .with_path(glibc_info()),
856        );
857        let paths = store.query_all_valid_paths().await.unwrap();
858        assert_eq!(paths.len(), 2);
859    }
860
861    #[tokio::test]
862    async fn arc_dyn_store_query_references() {
863        let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
864            TestStore::new()
865                .with_path(hello_info())
866                .with_path(glibc_info()),
867        );
868        let refs = store.query_references(&hello_path()).await.unwrap();
869        assert_eq!(refs.len(), 1);
870        assert_eq!(refs[0].to_absolute_path(), glibc_path().to_absolute_path());
871    }
872
873    #[tokio::test]
874    async fn arc_dyn_store_compute_closure() {
875        let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
876            TestStore::new()
877                .with_path(hello_info())
878                .with_path(glibc_info())
879                .with_path(bash_info()),
880        );
881        let closure = store.compute_closure(&[hello_path()]).await.unwrap();
882        assert_eq!(closure.len(), 3);
883    }
884
885    #[tokio::test]
886    async fn arc_dyn_store_default_methods_not_supported() {
887        let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
888            TestStore::new(),
889        );
890        assert!(store.collect_garbage(&GcOptions::default()).await.is_err());
891        assert!(store.add_to_store("x", b"data", &[]).await.is_err());
892        assert!(store.register_path(&hello_info()).await.is_err());
893        assert!(store.add_signatures(&hello_path(), &["sig".to_string()]).await.is_err());
894        assert!(store.query_referrers(&hello_path()).await.is_err());
895    }
896
897    // ── Box<dyn Store> dispatch ────────────────────────────
898
899    #[tokio::test]
900    async fn box_dyn_store_query_path_info() {
901        let store: Box<dyn Store> = Box::new(
902            TestStore::new().with_path(hello_info()),
903        );
904        let info = store.query_path_info(&hello_path()).await.unwrap();
905        assert!(info.is_some());
906    }
907
908    #[tokio::test]
909    async fn box_dyn_store_is_valid_path() {
910        let store: Box<dyn Store> = Box::new(
911            TestStore::new().with_path(hello_info()),
912        );
913        assert!(store.is_valid_path(&hello_path()).await.unwrap());
914        assert!(!store.is_valid_path(&glibc_path()).await.unwrap());
915    }
916
917    // ── GcResult and GcOptions ─────────────────────────────
918
919    #[test]
920    fn gc_result_fields() {
921        let result = GcResult {
922            paths_deleted: 42,
923            bytes_freed: 1_000_000,
924        };
925        assert_eq!(result.paths_deleted, 42);
926        assert_eq!(result.bytes_freed, 1_000_000);
927    }
928
929    #[test]
930    fn gc_options_with_values() {
931        let opts = GcOptions {
932            max_freed: 500_000,
933            delete_older_than: Some(3600),
934        };
935        assert_eq!(opts.max_freed, 500_000);
936        assert_eq!(opts.delete_older_than, Some(3600));
937    }
938
939    #[test]
940    fn gc_result_clone() {
941        let result = GcResult {
942            paths_deleted: 10,
943            bytes_freed: 5000,
944        };
945        let cloned = result.clone();
946        assert_eq!(cloned.paths_deleted, result.paths_deleted);
947        assert_eq!(cloned.bytes_freed, result.bytes_freed);
948    }
949
950    #[test]
951    fn gc_options_clone() {
952        let opts = GcOptions {
953            max_freed: 100,
954            delete_older_than: Some(60),
955        };
956        let cloned = opts.clone();
957        assert_eq!(cloned.max_freed, opts.max_freed);
958        assert_eq!(cloned.delete_older_than, opts.delete_older_than);
959    }
960
961    // ── StoreError debug ───────────────────────────────────
962
963    #[test]
964    fn store_error_debug_format() {
965        let e = StoreError::PathNotFound("/nix/store/abc".to_string());
966        let debug = format!("{e:?}");
967        assert!(debug.contains("PathNotFound"));
968    }
969
970    // ── query_path_info missing returns None ────────────────
971
972    #[tokio::test]
973    async fn query_path_info_missing_returns_none() {
974        let store = TestStore::new();
975        let result = store.query_path_info(&hello_path()).await.unwrap();
976        assert!(result.is_none());
977    }
978
979    // ── is_valid_path false for missing ────────────────────
980
981    #[tokio::test]
982    async fn is_valid_path_false_when_missing() {
983        let store = TestStore::new();
984        assert!(!store.is_valid_path(&hello_path()).await.unwrap());
985    }
986
987    // ── query_all_valid_paths empty store ──────────────────
988
989    #[tokio::test]
990    async fn query_all_valid_paths_empty_store() {
991        let store = TestStore::new();
992        let paths = store.query_all_valid_paths().await.unwrap();
993        assert!(paths.is_empty());
994    }
995
996    // ── PathInfo debug ─────────────────────────────────────
997
998    #[test]
999    fn path_info_debug_format() {
1000        let info = hello_info();
1001        let debug = format!("{info:?}");
1002        assert!(debug.contains("hello"));
1003        assert!(debug.contains("sha256:aaa"));
1004    }
1005
1006    // ── compute_closure with cycle-like duplicates ─────────
1007
1008    #[tokio::test]
1009    async fn compute_closure_handles_self_reference() {
1010        let mut info = bash_info();
1011        info.references = vec![bash_path().to_absolute_path()];
1012        let store = TestStore::new().with_path(info);
1013
1014        let closure = store.compute_closure(&[bash_path()]).await.unwrap();
1015        assert_eq!(closure.len(), 1);
1016    }
1017
1018    // ── PathInfo::new constructor ──────────────────────────
1019
1020    #[test]
1021    fn path_info_new_sets_path_and_hash() {
1022        let info = PathInfo::new("/nix/store/abc-x", "sha256:aaa");
1023        assert_eq!(info.path, "/nix/store/abc-x");
1024        assert_eq!(info.nar_hash, "sha256:aaa");
1025        assert_eq!(info.nar_size, 0);
1026        assert!(info.references.is_empty());
1027        assert!(info.deriver.is_none());
1028        assert!(info.signatures.is_empty());
1029        assert_eq!(info.registration_time, 0);
1030        assert!(info.content_address.is_none());
1031    }
1032
1033    #[test]
1034    fn path_info_new_accepts_string_owned() {
1035        let info = PathInfo::new(String::from("/nix/store/abc-x"), String::from("sha256:aaa"));
1036        assert_eq!(info.path, "/nix/store/abc-x");
1037    }
1038
1039    #[test]
1040    fn path_info_default_is_zero() {
1041        let info = PathInfo::default();
1042        assert!(info.path.is_empty());
1043        assert_eq!(info.nar_size, 0);
1044    }
1045
1046    // ── PathInfo Display ───────────────────────────────────
1047
1048    #[test]
1049    fn path_info_display_includes_path_and_size() {
1050        let info = PathInfo {
1051            path: "/nix/store/abc-hello".to_string(),
1052            nar_hash: "sha256:aaa".to_string(),
1053            nar_size: 1024,
1054            references: vec![],
1055            deriver: None,
1056            signatures: vec![],
1057            registration_time: 0,
1058            content_address: None,
1059        };
1060        let s = info.to_string();
1061        assert!(s.contains("/nix/store/abc-hello"));
1062        assert!(s.contains("1024"));
1063    }
1064
1065    // ── PathInfo From<&NarInfo> conversion ─────────────────
1066
1067    #[test]
1068    fn path_info_from_narinfo_full() {
1069        let narinfo = sui_compat::narinfo::NarInfo {
1070            store_path: "/nix/store/abc-hello".to_string(),
1071            url: "nar/abc.nar.xz".to_string(),
1072            compression: "xz".to_string(),
1073            file_hash: "sha256:fhash".to_string(),
1074            file_size: 500,
1075            nar_hash: "sha256:nhash".to_string(),
1076            nar_size: 1024,
1077            references: vec!["dep1".to_string(), "dep2".to_string()],
1078            deriver: Some("abc.drv".to_string()),
1079            signatures: vec!["k:s".to_string()],
1080            ca: Some("fixed:out:r:sha256:cafe".to_string()),
1081        };
1082        let info = PathInfo::from(&narinfo);
1083        assert_eq!(info.path, "/nix/store/abc-hello");
1084        assert_eq!(info.nar_hash, "sha256:nhash");
1085        assert_eq!(info.nar_size, 1024);
1086        assert_eq!(info.references.len(), 2);
1087        assert_eq!(info.deriver.as_deref(), Some("abc.drv"));
1088        assert_eq!(info.signatures, vec!["k:s"]);
1089        assert_eq!(info.content_address.as_deref(), Some("fixed:out:r:sha256:cafe"));
1090        // registration_time is not in NarInfo, defaults to 0
1091        assert_eq!(info.registration_time, 0);
1092    }
1093
1094    #[test]
1095    fn path_info_from_narinfo_minimal() {
1096        let narinfo = sui_compat::narinfo::NarInfo {
1097            store_path: "/nix/store/abc-leaf".to_string(),
1098            url: "nar/abc.nar".to_string(),
1099            compression: "none".to_string(),
1100            file_hash: "sha256:f".to_string(),
1101            file_size: 0,
1102            nar_hash: "sha256:n".to_string(),
1103            nar_size: 0,
1104            references: vec![],
1105            deriver: None,
1106            signatures: vec![],
1107            ca: None,
1108        };
1109        let info = PathInfo::from(&narinfo);
1110        assert!(info.references.is_empty());
1111        assert!(info.deriver.is_none());
1112        assert!(info.content_address.is_none());
1113        assert_eq!(info.nar_size, 0);
1114    }
1115
1116    // ── StoreError discriminants ───────────────────────────
1117
1118    #[test]
1119    fn store_error_is_path_not_found_true() {
1120        let e = StoreError::PathNotFound("/nix/store/x".to_string());
1121        assert!(e.is_path_not_found());
1122        assert!(!e.is_not_supported());
1123    }
1124
1125    #[test]
1126    fn store_error_is_path_not_found_false() {
1127        let e = StoreError::Database("x".to_string());
1128        assert!(!e.is_path_not_found());
1129    }
1130
1131    #[test]
1132    fn store_error_is_not_supported_true() {
1133        let e = StoreError::NotSupported("gc".to_string());
1134        assert!(e.is_not_supported());
1135        assert!(!e.is_path_not_found());
1136    }
1137
1138    #[test]
1139    fn store_error_is_not_supported_false_for_io() {
1140        let e = StoreError::Io(std::io::Error::new(
1141            std::io::ErrorKind::Other,
1142            "boom",
1143        ));
1144        assert!(!e.is_not_supported());
1145        assert!(!e.is_path_not_found());
1146    }
1147
1148    #[test]
1149    fn store_error_io_display_contains_message() {
1150        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
1151        let e: StoreError = io_err.into();
1152        assert!(e.to_string().contains("missing file"));
1153        assert!(e.to_string().contains("io error"));
1154    }
1155
1156    #[test]
1157    fn store_error_not_supported_display() {
1158        let e = StoreError::NotSupported("gc not implemented".to_string());
1159        let s = e.to_string();
1160        assert!(s.contains("not supported"));
1161        assert!(s.contains("gc"));
1162    }
1163
1164    // ── StoreError From<HttpError> ─────────────────────────
1165
1166    #[test]
1167    fn store_error_from_http_error_request() {
1168        use crate::http::HttpError;
1169        let http_err = HttpError::Request("dns failed".to_string());
1170        let store_err: StoreError = http_err.into();
1171        assert!(matches!(store_err, StoreError::Http(_)));
1172        assert!(store_err.to_string().contains("dns failed"));
1173    }
1174
1175    #[test]
1176    fn store_error_from_http_error_decode() {
1177        use crate::http::HttpError;
1178        let http_err = HttpError::Decode("bad utf-8".to_string());
1179        let store_err: StoreError = http_err.into();
1180        assert!(matches!(store_err, StoreError::Http(_)));
1181        assert!(store_err.to_string().contains("bad utf-8"));
1182    }
1183
1184    // ── GcOptions builder methods ──────────────────────────
1185
1186    #[test]
1187    fn gc_options_with_max_freed() {
1188        let opts = GcOptions::default().with_max_freed(1024);
1189        assert_eq!(opts.max_freed, 1024);
1190        assert!(opts.delete_older_than.is_none());
1191    }
1192
1193    #[test]
1194    fn gc_options_with_delete_older_than() {
1195        let opts = GcOptions::default().with_delete_older_than(3600);
1196        assert_eq!(opts.delete_older_than, Some(3600));
1197        assert_eq!(opts.max_freed, 0);
1198    }
1199
1200    #[test]
1201    fn gc_options_chain_builder() {
1202        let opts = GcOptions::default()
1203            .with_max_freed(1_000_000)
1204            .with_delete_older_than(7200);
1205        assert_eq!(opts.max_freed, 1_000_000);
1206        assert_eq!(opts.delete_older_than, Some(7200));
1207    }
1208
1209    #[test]
1210    fn gc_options_eq() {
1211        let a = GcOptions::default().with_max_freed(100);
1212        let b = GcOptions {
1213            max_freed: 100,
1214            delete_older_than: None,
1215        };
1216        assert_eq!(a, b);
1217    }
1218
1219    // ── GcResult Display ───────────────────────────────────
1220
1221    #[test]
1222    fn gc_result_display_format() {
1223        let r = GcResult {
1224            paths_deleted: 5,
1225            bytes_freed: 1024,
1226        };
1227        let s = r.to_string();
1228        assert!(s.contains("5"));
1229        assert!(s.contains("1024"));
1230        assert!(s.contains("paths"));
1231    }
1232
1233    #[test]
1234    fn gc_result_default() {
1235        let r = GcResult::default();
1236        assert_eq!(r.paths_deleted, 0);
1237        assert_eq!(r.bytes_freed, 0);
1238    }
1239
1240    #[test]
1241    fn gc_result_eq() {
1242        let a = GcResult {
1243            paths_deleted: 1,
1244            bytes_freed: 100,
1245        };
1246        let b = GcResult {
1247            paths_deleted: 1,
1248            bytes_freed: 100,
1249        };
1250        let c = GcResult {
1251            paths_deleted: 1,
1252            bytes_freed: 200,
1253        };
1254        assert_eq!(a, b);
1255        assert_ne!(a, c);
1256    }
1257
1258    // ── compute_closure ordering & determinism ─────────────
1259
1260    #[tokio::test]
1261    async fn compute_closure_dedup_with_diamond_deps() {
1262        // diamond: a -> b, a -> c, b -> d, c -> d
1263        let a_path =
1264            StorePath::from_absolute_path("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-a")
1265                .unwrap();
1266        let b_path =
1267            StorePath::from_absolute_path("/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-b")
1268                .unwrap();
1269        let c_path =
1270            StorePath::from_absolute_path("/nix/store/cccccccccccccccccccccccccccccccc-c")
1271                .unwrap();
1272        let d_path =
1273            StorePath::from_absolute_path("/nix/store/dddddddddddddddddddddddddddddddd-d")
1274                .unwrap();
1275
1276        let store = TestStore::new()
1277            .with_path(PathInfo {
1278                path: a_path.to_absolute_path(),
1279                nar_hash: "sha256:a".to_string(),
1280                nar_size: 1,
1281                references: vec![b_path.to_absolute_path(), c_path.to_absolute_path()],
1282                deriver: None,
1283                signatures: vec![],
1284                registration_time: 0,
1285                content_address: None,
1286            })
1287            .with_path(PathInfo {
1288                path: b_path.to_absolute_path(),
1289                nar_hash: "sha256:b".to_string(),
1290                nar_size: 1,
1291                references: vec![d_path.to_absolute_path()],
1292                deriver: None,
1293                signatures: vec![],
1294                registration_time: 0,
1295                content_address: None,
1296            })
1297            .with_path(PathInfo {
1298                path: c_path.to_absolute_path(),
1299                nar_hash: "sha256:c".to_string(),
1300                nar_size: 1,
1301                references: vec![d_path.to_absolute_path()],
1302                deriver: None,
1303                signatures: vec![],
1304                registration_time: 0,
1305                content_address: None,
1306            })
1307            .with_path(PathInfo {
1308                path: d_path.to_absolute_path(),
1309                nar_hash: "sha256:d".to_string(),
1310                nar_size: 1,
1311                references: vec![],
1312                deriver: None,
1313                signatures: vec![],
1314                registration_time: 0,
1315                content_address: None,
1316            });
1317
1318        let closure = store.compute_closure(&[a_path.clone()]).await.unwrap();
1319        // a, b, c, d each appear exactly once
1320        assert_eq!(closure.len(), 4);
1321        let paths: Vec<String> = closure.iter().map(|p| p.to_absolute_path()).collect();
1322        assert!(paths.contains(&a_path.to_absolute_path()));
1323        assert!(paths.contains(&b_path.to_absolute_path()));
1324        assert!(paths.contains(&c_path.to_absolute_path()));
1325        assert!(paths.contains(&d_path.to_absolute_path()));
1326
1327        // d should appear once even though both b and c reference it
1328        let d_count = paths
1329            .iter()
1330            .filter(|p| **p == d_path.to_absolute_path())
1331            .count();
1332        assert_eq!(d_count, 1);
1333    }
1334
1335    #[tokio::test]
1336    async fn compute_closure_propagates_query_error() {
1337        // The store has hello but hello references glibc which is missing.
1338        // query_references for hello succeeds (info present), then while
1339        // walking, glibc isn't in the store -> query_references for glibc errors.
1340        let store = TestStore::new().with_path(hello_info());
1341        let result = store.compute_closure(&[hello_path()]).await;
1342        assert!(result.is_err());
1343    }
1344
1345    // ── PartialEq on PathInfo ──────────────────────────────
1346
1347    #[test]
1348    fn path_info_eq_full_match() {
1349        let a = hello_info();
1350        let b = hello_info();
1351        assert_eq!(a, b);
1352    }
1353
1354    #[test]
1355    fn path_info_neq_when_size_differs() {
1356        let a = hello_info();
1357        let mut b = hello_info();
1358        b.nar_size = 9999;
1359        assert_ne!(a, b);
1360    }
1361
1362    #[test]
1363    fn path_info_neq_when_signatures_differ() {
1364        let a = hello_info();
1365        let mut b = hello_info();
1366        b.signatures = vec!["other:sig".to_string()];
1367        assert_ne!(a, b);
1368    }
1369
1370    #[test]
1371    fn path_info_neq_when_deriver_differs() {
1372        let a = hello_info();
1373        let mut b = hello_info();
1374        b.deriver = None;
1375        assert_ne!(a, b);
1376    }
1377
1378    // ── MockStore (alternate Store impl) ──────────────────
1379    // Verifies that multiple Store implementations can coexist via dyn dispatch.
1380
1381    /// Counts every Store-trait call. Useful for verifying call dispatch.
1382    struct MockStore {
1383        info: Option<PathInfo>,
1384        query_count: std::sync::atomic::AtomicUsize,
1385        valid_count: std::sync::atomic::AtomicUsize,
1386    }
1387
1388    impl MockStore {
1389        fn new(info: Option<PathInfo>) -> Self {
1390            Self {
1391                info,
1392                query_count: std::sync::atomic::AtomicUsize::new(0),
1393                valid_count: std::sync::atomic::AtomicUsize::new(0),
1394            }
1395        }
1396        fn query_count(&self) -> usize {
1397            self.query_count.load(std::sync::atomic::Ordering::Relaxed)
1398        }
1399        fn valid_count(&self) -> usize {
1400            self.valid_count.load(std::sync::atomic::Ordering::Relaxed)
1401        }
1402    }
1403
1404    #[async_trait::async_trait]
1405    impl Store for MockStore {
1406        async fn query_path_info(
1407            &self,
1408            _path: &StorePath,
1409        ) -> StoreResult<Option<PathInfo>> {
1410            self.query_count
1411                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1412            Ok(self.info.clone())
1413        }
1414        async fn is_valid_path(
1415            &self,
1416            _path: &StorePath,
1417        ) -> StoreResult<bool> {
1418            self.valid_count
1419                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1420            Ok(self.info.is_some())
1421        }
1422        async fn query_all_valid_paths(&self) -> StoreResult<Vec<StorePath>> {
1423            Ok(vec![])
1424        }
1425    }
1426
1427    #[tokio::test]
1428    async fn mock_store_counts_query_calls() {
1429        let store = MockStore::new(Some(hello_info()));
1430        let _ = store.query_path_info(&hello_path()).await.unwrap();
1431        let _ = store.query_path_info(&hello_path()).await.unwrap();
1432        let _ = store.query_path_info(&hello_path()).await.unwrap();
1433        assert_eq!(store.query_count(), 3);
1434    }
1435
1436    #[tokio::test]
1437    async fn mock_store_counts_valid_calls() {
1438        let store = MockStore::new(Some(hello_info()));
1439        let _ = store.is_valid_path(&hello_path()).await.unwrap();
1440        let _ = store.is_valid_path(&hello_path()).await.unwrap();
1441        assert_eq!(store.valid_count(), 2);
1442    }
1443
1444    #[tokio::test]
1445    async fn mock_store_via_dyn_dispatch() {
1446        let store: Box<dyn Store> = Box::new(MockStore::new(Some(hello_info())));
1447        let info = store.query_path_info(&hello_path()).await.unwrap();
1448        assert!(info.is_some());
1449    }
1450
1451    #[tokio::test]
1452    async fn mock_store_returns_none_when_empty() {
1453        let store = MockStore::new(None);
1454        let info = store.query_path_info(&hello_path()).await.unwrap();
1455        assert!(info.is_none());
1456    }
1457
1458    #[tokio::test]
1459    async fn mock_store_returns_false_when_empty() {
1460        let store = MockStore::new(None);
1461        assert!(!store.is_valid_path(&hello_path()).await.unwrap());
1462    }
1463
1464    #[tokio::test]
1465    async fn mock_store_query_all_returns_empty() {
1466        let store = MockStore::new(Some(hello_info()));
1467        let paths = store.query_all_valid_paths().await.unwrap();
1468        assert!(paths.is_empty());
1469    }
1470
1471    // ── Multiple Store impls coexist via Vec<Box<dyn Store>> ──
1472
1473    #[tokio::test]
1474    async fn vec_of_dyn_store_dispatch() {
1475        let stores: Vec<Box<dyn Store>> = vec![
1476            Box::new(TestStore::new().with_path(hello_info())),
1477            Box::new(MockStore::new(Some(hello_info()))),
1478        ];
1479        for store in &stores {
1480            let info = store.query_path_info(&hello_path()).await.unwrap();
1481            assert!(info.is_some());
1482        }
1483    }
1484
1485    // ── compute_closure with multiple roots ────────────────
1486
1487    #[tokio::test]
1488    async fn compute_closure_multiple_roots_no_overlap() {
1489        let store = TestStore::new()
1490            .with_path(hello_info())
1491            .with_path(glibc_info())
1492            .with_path(bash_info());
1493
1494        let closure = store
1495            .compute_closure(&[glibc_path(), bash_path()])
1496            .await
1497            .unwrap();
1498        // glibc + bash + bash (already in glibc closure) = 2 unique
1499        assert_eq!(closure.len(), 2);
1500    }
1501
1502    // ── add_to_store / register_path / add_signatures error messages ──
1503
1504    #[tokio::test]
1505    async fn add_to_store_error_includes_method_name() {
1506        let store = TestStore::new();
1507        let err = store.add_to_store("x", b"d", &[]).await.unwrap_err();
1508        assert!(err.to_string().contains("add_to_store"));
1509    }
1510
1511    #[tokio::test]
1512    async fn register_path_error_includes_method_name() {
1513        let store = TestStore::new();
1514        let err = store.register_path(&hello_info()).await.unwrap_err();
1515        assert!(err.to_string().contains("register_path"));
1516    }
1517
1518    #[tokio::test]
1519    async fn add_signatures_error_includes_method_name() {
1520        let store = TestStore::new();
1521        let err = store
1522            .add_signatures(&hello_path(), &[])
1523            .await
1524            .unwrap_err();
1525        assert!(err.to_string().contains("add_signatures"));
1526    }
1527
1528    #[tokio::test]
1529    async fn query_referrers_error_includes_method_name() {
1530        let store = TestStore::new();
1531        let err = store.query_referrers(&hello_path()).await.unwrap_err();
1532        assert!(err.to_string().contains("query_referrers"));
1533    }
1534
1535    #[tokio::test]
1536    async fn collect_garbage_error_includes_method_name() {
1537        let store = TestStore::new();
1538        let err = store
1539            .collect_garbage(&GcOptions::default())
1540            .await
1541            .unwrap_err();
1542        assert!(err.to_string().contains("garbage collection"));
1543    }
1544
1545    // ── Send + Sync constraints ────────────────────────────
1546
1547    #[test]
1548    fn store_trait_is_send_and_sync() {
1549        fn assert_send_sync<T: Send + Sync + ?Sized>() {}
1550        assert_send_sync::<dyn Store>();
1551    }
1552
1553    #[test]
1554    fn path_info_send_and_sync() {
1555        fn assert_send_sync<T: Send + Sync>() {}
1556        assert_send_sync::<PathInfo>();
1557    }
1558
1559    #[test]
1560    fn store_error_send_and_sync() {
1561        fn assert_send_sync<T: Send + Sync>() {}
1562        assert_send_sync::<StoreError>();
1563    }
1564
1565    #[test]
1566    fn store_error_internal_display() {
1567        let e = StoreError::Internal("something unexpected".to_string());
1568        let msg = e.to_string();
1569        assert!(msg.contains("internal error"));
1570        assert!(msg.contains("something unexpected"));
1571    }
1572
1573    // ── New type tests ─────────────────────────────────────────
1574
1575    #[test]
1576    fn corrupt_path_serialization_roundtrip() {
1577        let cp = CorruptPath {
1578            path: "/nix/store/abc-hello".to_string(),
1579            expected_hash: "sha256:aaa".to_string(),
1580            actual_hash: "sha256:bbb".to_string(),
1581        };
1582        let json = serde_json::to_string(&cp).unwrap();
1583        let cp2: CorruptPath = serde_json::from_str(&json).unwrap();
1584        assert_eq!(cp, cp2);
1585    }
1586
1587    #[test]
1588    fn verify_result_display() {
1589        let r = VerifyResult {
1590            total_checked: 10,
1591            valid_count: 8,
1592            corrupt: vec![],
1593        };
1594        assert!(r.to_string().contains("10 checked"));
1595        assert!(r.to_string().contains("8 valid"));
1596    }
1597
1598    #[test]
1599    fn gc_options_builder() {
1600        let opts = GcOptions::default()
1601            .with_max_freed(1024)
1602            .with_delete_older_than(3600);
1603        assert_eq!(opts.max_freed, 1024);
1604        assert_eq!(opts.delete_older_than, Some(3600));
1605    }
1606
1607    #[test]
1608    fn gc_result_display() {
1609        let r = GcResult {
1610            paths_deleted: 42,
1611            bytes_freed: 1_000_000,
1612        };
1613        assert!(r.to_string().contains("42 paths"));
1614        assert!(r.to_string().contains("1000000 bytes"));
1615    }
1616
1617    #[tokio::test]
1618    async fn verify_store_returns_not_supported() {
1619        let store = TestStore::new();
1620        let result = store.verify_store().await;
1621        assert!(result.is_err());
1622        match result.unwrap_err() {
1623            StoreError::NotSupported(msg) => {
1624                assert!(msg.contains("verify_store"));
1625            }
1626            other => panic!("expected NotSupported, got {other:?}"),
1627        }
1628    }
1629
1630    #[tokio::test]
1631    async fn delete_path_returns_not_supported() {
1632        let store = TestStore::new();
1633        let result = store.delete_path(&hello_path()).await;
1634        assert!(result.is_err());
1635        match result.unwrap_err() {
1636            StoreError::NotSupported(msg) => {
1637                assert!(msg.contains("delete_path"));
1638            }
1639            other => panic!("expected NotSupported, got {other:?}"),
1640        }
1641    }
1642
1643    // ── OptimiseResult tests ────────────────────────────────────
1644
1645    #[test]
1646    fn optimise_result_default() {
1647        let result = OptimiseResult::default();
1648        assert_eq!(result.files_linked, 0);
1649        assert_eq!(result.bytes_saved, 0);
1650    }
1651
1652    #[test]
1653    fn optimise_result_display() {
1654        let result = OptimiseResult {
1655            files_linked: 42,
1656            bytes_saved: 1_048_576,
1657        };
1658        assert_eq!(
1659            result.to_string(),
1660            "Optimise: 42 files linked, 1048576 bytes saved"
1661        );
1662    }
1663
1664    #[test]
1665    fn optimise_result_display_zero() {
1666        let result = OptimiseResult::default();
1667        assert_eq!(
1668            result.to_string(),
1669            "Optimise: 0 files linked, 0 bytes saved"
1670        );
1671    }
1672
1673    #[test]
1674    fn optimise_result_clone_eq() {
1675        let a = OptimiseResult {
1676            files_linked: 10,
1677            bytes_saved: 5000,
1678        };
1679        let b = a.clone();
1680        assert_eq!(a, b);
1681    }
1682
1683    #[test]
1684    fn optimise_result_ne() {
1685        let a = OptimiseResult {
1686            files_linked: 10,
1687            bytes_saved: 5000,
1688        };
1689        let b = OptimiseResult {
1690            files_linked: 11,
1691            bytes_saved: 5000,
1692        };
1693        assert_ne!(a, b);
1694    }
1695
1696    #[test]
1697    fn optimise_result_debug() {
1698        let result = OptimiseResult {
1699            files_linked: 3,
1700            bytes_saved: 100,
1701        };
1702        let debug = format!("{result:?}");
1703        assert!(debug.contains("files_linked"));
1704        assert!(debug.contains("bytes_saved"));
1705    }
1706
1707    #[tokio::test]
1708    async fn optimise_store_returns_not_supported() {
1709        let store = TestStore::new();
1710        let result = store.optimise_store(false).await;
1711        assert!(result.is_err());
1712        match result.unwrap_err() {
1713            StoreError::NotSupported(msg) => {
1714                assert!(msg.contains("optimise_store"));
1715            }
1716            other => panic!("expected NotSupported, got {other:?}"),
1717        }
1718    }
1719
1720    #[tokio::test]
1721    async fn optimise_store_dry_run_returns_not_supported() {
1722        let store = TestStore::new();
1723        let result = store.optimise_store(true).await;
1724        assert!(result.is_err());
1725        match result.unwrap_err() {
1726            StoreError::NotSupported(msg) => {
1727                assert!(msg.contains("optimise_store"));
1728            }
1729            other => panic!("expected NotSupported, got {other:?}"),
1730        }
1731    }
1732}