Skip to main content

sui_spec/
store_query.rs

1//! Typed query AST over the typed store substrate.
2//!
3//! Composable predicates that match against [`StoreEntry`] +
4//! [`RefIndex`] context.  Operators build queries in Rust or
5//! tatara-lisp; the executor walks the inventory once and
6//! returns matching entries.
7
8use serde::{Deserialize, Serialize};
9use tatara_lisp::DeriveTataraDomain;
10
11use crate::nar;
12use crate::store_inventory::{RefIndex, StoreEntry};
13use crate::SpecError;
14
15/// Composable predicate over a `StoreEntry` (with optional
16/// `RefIndex` context for reference-aware predicates).
17#[derive(Debug, Clone)]
18pub enum StorePredicate {
19    /// Entry's basename (e.g. `hello-2.12`) matches this regex.
20    NameMatches(String),
21    /// Entry size in bytes ≥ this value.
22    SizeAtLeast(u64),
23    /// Entry size in bytes ≤ this value.
24    SizeAtMost(u64),
25    /// Entry's file_count ≥ this value.
26    FileCountAtLeast(usize),
27    /// Entry's NAR contents contain this literal byte sequence
28    /// (case-sensitive, no regex).
29    ContainsBytes(Vec<u8>),
30    /// Entry's NAR file contents match this regex (UTF-8 lossy).
31    ContentsMatch(String),
32    /// Entry references at least one `/nix/store/<prefix>*` path
33    /// where `<prefix>` matches this substring.
34    HasReference(String),
35    /// Logical AND.
36    All(Vec<StorePredicate>),
37    /// Logical OR.
38    Any(Vec<StorePredicate>),
39    /// Logical NOT.
40    Not(Box<StorePredicate>),
41}
42
43/// Decide whether `entry` satisfies `predicate`.  `idx` is
44/// optional context — predicates that don't need it (NameMatches,
45/// SizeAtLeast, etc.) ignore it.
46pub fn matches(
47    entry: &StoreEntry,
48    predicate: &StorePredicate,
49    idx: Option<&RefIndex>,
50) -> bool {
51    match predicate {
52        StorePredicate::NameMatches(pattern) => {
53            regex::Regex::new(pattern).is_ok_and(|re| re.is_match(&entry.parsed.name))
54        }
55        StorePredicate::SizeAtLeast(n) => entry.size >= *n,
56        StorePredicate::SizeAtMost(n) => entry.size <= *n,
57        StorePredicate::FileCountAtLeast(n) => entry.file_count >= *n,
58        StorePredicate::ContainsBytes(needle) => {
59            let nar = match nar::encode(&entry.path) {
60                Ok(b) => b,
61                Err(_) => return false,
62            };
63            nar.windows(needle.len()).any(|w| w == needle)
64        }
65        StorePredicate::ContentsMatch(pattern) => {
66            let re = match regex::bytes::Regex::new(pattern) {
67                Ok(r) => r,
68                Err(_) => return false,
69            };
70            let nar = match nar::encode(&entry.path) {
71                Ok(b) => b,
72                Err(_) => return false,
73            };
74            re.is_match(&nar)
75        }
76        StorePredicate::HasReference(substr) => {
77            if let Some(idx) = idx {
78                idx.refs_from(&entry.path).iter().any(|p| {
79                    p.to_string_lossy().contains(substr)
80                })
81            } else {
82                false
83            }
84        }
85        StorePredicate::All(ps) => ps.iter().all(|p| matches(entry, p, idx)),
86        StorePredicate::Any(ps) => ps.iter().any(|p| matches(entry, p, idx)),
87        StorePredicate::Not(p) => !matches(entry, p, idx),
88    }
89}
90
91// ── Named query — Lisp-authorable declarative form ────────────
92//
93// The full `StorePredicate` AST is too rich for the Lisp
94// surface (recursive enums + closures don't translate cleanly
95// to TataraDomain).  Instead we expose a flat-field form that
96// captures the common cases: name regex / size bracket /
97// contents regex / reference substring.  These compose with AND.
98
99/// Typed Lisp-authorable named query.  Authored as
100/// `(defstore-query :name X :description Y :name-regex ...)`.
101#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
102#[tatara(keyword = "defstore-query")]
103pub struct StoreQuery {
104    /// Stable name (catalog key).
105    pub name: String,
106    /// Operator-facing description.
107    pub description: String,
108    /// Name regex (empty = any name).
109    #[serde(rename = "nameRegex", default)]
110    pub name_regex: String,
111    /// Minimum size in bytes (0 = no minimum).
112    #[serde(rename = "minSize", default)]
113    pub min_size: u64,
114    /// Maximum size in bytes (0 = no maximum).
115    #[serde(rename = "maxSize", default)]
116    pub max_size: u64,
117    /// Content regex (empty = any contents).
118    #[serde(rename = "contentsRegex", default)]
119    pub contents_regex: String,
120    /// Reference substring (empty = no reference filter).
121    #[serde(rename = "hasReference", default)]
122    pub has_reference: String,
123}
124
125impl StoreQuery {
126    /// Compile the named query to a `StorePredicate` AST that
127    /// the executor consumes.  Empty fields are skipped.
128    #[must_use]
129    pub fn to_predicate(&self) -> StorePredicate {
130        let mut clauses = Vec::new();
131        if !self.name_regex.is_empty() {
132            clauses.push(StorePredicate::NameMatches(self.name_regex.clone()));
133        }
134        if self.min_size > 0 {
135            clauses.push(StorePredicate::SizeAtLeast(self.min_size));
136        }
137        if self.max_size > 0 {
138            clauses.push(StorePredicate::SizeAtMost(self.max_size));
139        }
140        if !self.contents_regex.is_empty() {
141            clauses.push(StorePredicate::ContentsMatch(self.contents_regex.clone()));
142        }
143        if !self.has_reference.is_empty() {
144            clauses.push(StorePredicate::HasReference(self.has_reference.clone()));
145        }
146        if clauses.is_empty() {
147            // Empty query matches everything (operator authored a
148            // catch-all).
149            StorePredicate::All(vec![])
150        } else {
151            StorePredicate::All(clauses)
152        }
153    }
154}
155
156pub const CANONICAL_STORE_QUERIES_LISP: &str =
157    include_str!("../specs/store_queries.lisp");
158
159/// Compile every authored query.
160///
161/// # Errors
162///
163/// Returns an error if the Lisp source can't be parsed.
164pub fn load_canonical() -> Result<Vec<StoreQuery>, SpecError> {
165    crate::loader::load_all::<StoreQuery>(CANONICAL_STORE_QUERIES_LISP)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::store_inventory::StoreEntry;
172    use crate::store_layout::ParsedStorePath;
173
174    fn entry(name: &str, size: u64, file_count: usize) -> StoreEntry {
175        StoreEntry {
176            path: std::path::PathBuf::from(format!("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-{name}")),
177            parsed: ParsedStorePath {
178                algorithm: None,
179                hash: "a".repeat(32),
180                name: name.to_string(),
181                sub_path: None,
182            },
183            is_directory: false,
184            file_count,
185            size,
186        }
187    }
188
189    #[test]
190    fn name_matches_regex() {
191        let e = entry("hello-2.12", 0, 1);
192        assert!(matches(&e, &StorePredicate::NameMatches("^hello-.*".into()), None));
193        assert!(!matches(&e, &StorePredicate::NameMatches("^bye".into()), None));
194    }
195
196    #[test]
197    fn size_predicates_work() {
198        let e = entry("x", 100, 1);
199        assert!(matches(&e, &StorePredicate::SizeAtLeast(50), None));
200        assert!(!matches(&e, &StorePredicate::SizeAtLeast(150), None));
201        assert!(matches(&e, &StorePredicate::SizeAtMost(100), None));
202    }
203
204    #[test]
205    fn and_or_not_compose() {
206        let e = entry("hello-1.0", 100, 1);
207        let p = StorePredicate::All(vec![
208            StorePredicate::NameMatches("^hello".into()),
209            StorePredicate::SizeAtLeast(50),
210            StorePredicate::Not(Box::new(StorePredicate::SizeAtLeast(500))),
211        ]);
212        assert!(matches(&e, &p, None));
213    }
214
215    #[test]
216    fn any_short_circuits_on_true() {
217        let e = entry("hello", 100, 1);
218        let p = StorePredicate::Any(vec![
219            StorePredicate::NameMatches("^hello".into()),
220            StorePredicate::SizeAtLeast(99999),  // false
221        ]);
222        assert!(matches(&e, &p, None));
223    }
224}