Skip to main content

spam_db/
packages.rs

1use std::path::Path;
2
3use crate::{
4    Error, Result,
5    format::{DbFile, DbKind},
6};
7
8/// The type of a file entry in the store.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FileKind {
11    /// A regular file.
12    Regular,
13    /// A directory.
14    Directory,
15    /// A symbolic link.
16    Symlink,
17}
18
19impl Default for FileKind {
20    fn default() -> Self {
21        FileKind::Regular
22    }
23}
24
25/// A file-to-package mapping from a packages database or autonomous index.
26///
27/// Extended databases (produced by `spam index`) include full metadata;
28/// legacy databases (from `spam db build`) only populate `path` and `packages`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct FileRecord {
31    /// Relative path within a Nix store output, e.g. `"/bin/hello"`.
32    pub path: String,
33    /// Package names that ship this file.
34    pub packages: Vec<String>,
35    /// File size in bytes (0 for directories and symlinks, and for legacy records).
36    pub size: u64,
37    /// File type.
38    pub kind: FileKind,
39    /// Whether the file has the executable bit set (meaningful for [`FileKind::Regular`] only).
40    pub executable: bool,
41    /// Symlink target; empty unless `kind == FileKind::Symlink`.
42    pub target: String,
43}
44
45/// Handle to an open spam packages database or autonomous index.
46///
47/// Only the fixed-size bucket index is loaded into memory on construction.
48#[derive(Debug)]
49pub struct PackagesDb {
50    db: DbFile,
51}
52
53impl PackagesDb {
54    pub(crate) fn from_file(db: DbFile) -> Self {
55        Self { db }
56    }
57
58    /// Open the packages database or autonomous index at `path`.
59    ///
60    /// Returns [`Error::InvalidDatabase`] if the file is not queryable by `spam pkg`.
61    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
62        let db = DbFile::open(path)?;
63        if db.kind != DbKind::Packages && db.kind != DbKind::Index {
64            return Err(Error::InvalidDatabase(
65                "expected a packages or index database".into(),
66            ));
67        }
68        Ok(Self { db })
69    }
70
71    /// Return all records whose path contains `query` as a substring.
72    pub fn query(&self, query: &str) -> Result<Vec<FileRecord>> {
73        match self.db.kind {
74            DbKind::Packages => self.query_bucketed(query),
75            DbKind::Index => self.query_stream(query),
76            DbKind::Options => Err(Error::InvalidDatabase(
77                "expected a packages or index database".into(),
78            )),
79        }
80    }
81
82    fn query_bucketed(&self, query: &str) -> Result<Vec<FileRecord>> {
83        let bucket = DbFile::query_bucket(query);
84        let lines = self.db.bucket_lines(bucket)?;
85
86        let mut records = Vec::new();
87        for line in &lines {
88            let parts: Vec<&str> = line.splitn(7, '\t').collect();
89            if parts.is_empty() {
90                continue;
91            }
92            let path = parts[0];
93            if !path.contains(query) {
94                continue;
95            }
96
97            let record = if parts.len() >= 6 {
98                // Extended format: path\tkind\tsize\texec\ttarget\tpkg1,pkg2,...
99                let kind = match parts[1] {
100                    "d" => FileKind::Directory,
101                    "s" => FileKind::Symlink,
102                    _ => FileKind::Regular,
103                };
104                let size: u64 = parts[2].parse().unwrap_or(0);
105                let executable = parts[3] == "1";
106                let target = parts[4].to_owned();
107                let packages = parts[5]
108                    .split(',')
109                    .filter(|s| !s.is_empty())
110                    .map(str::to_owned)
111                    .collect();
112                FileRecord {
113                    path: path.to_owned(),
114                    packages,
115                    size,
116                    kind,
117                    executable,
118                    target,
119                }
120            } else if parts.len() >= 2 {
121                // Legacy format: path\tpkg1,pkg2,...
122                let packages = parts[1]
123                    .split(',')
124                    .filter(|s| !s.is_empty())
125                    .map(str::to_owned)
126                    .collect();
127                FileRecord {
128                    path: path.to_owned(),
129                    packages,
130                    size: 0,
131                    kind: FileKind::Regular,
132                    executable: false,
133                    target: String::new(),
134                }
135            } else {
136                continue;
137            };
138
139            records.push(record);
140        }
141        Ok(records)
142    }
143
144    fn query_stream(&self, query: &str) -> Result<Vec<FileRecord>> {
145        let lines = self.db.stream_lines()?;
146
147        let mut records = Vec::new();
148        let mut package = String::new();
149        let mut previous_path = String::new();
150        for line in &lines {
151            if let Some(package_name) = line.strip_prefix("P\t") {
152                package = package_name.to_owned();
153                previous_path.clear();
154                continue;
155            }
156
157            let Some(record) = parse_stream_record(line, &package, &mut previous_path) else {
158                continue;
159            };
160            if record.path.contains(query) {
161                records.push(record);
162            }
163        }
164        Ok(records)
165    }
166}
167
168fn parse_stream_record(
169    line: &str,
170    package: &str,
171    previous_path: &mut String,
172) -> Option<FileRecord> {
173    let parts: Vec<&str> = line.splitn(7, '\t').collect();
174    if parts.len() < 7 || parts[0] != "F" {
175        return None;
176    }
177
178    let shared = parts[5].parse::<usize>().ok()?;
179    if shared > previous_path.len() {
180        return None;
181    }
182    let path = format!("{}{}", &previous_path[..shared], parts[6]);
183    previous_path.clear();
184    previous_path.push_str(&path);
185
186    let kind = match parts[1] {
187        "d" => FileKind::Directory,
188        "s" => FileKind::Symlink,
189        _ => FileKind::Regular,
190    };
191
192    Some(FileRecord {
193        path,
194        packages: vec![package.to_owned()],
195        size: parts[2].parse().unwrap_or(0),
196        kind,
197        executable: parts[3] == "1",
198        target: parts[4].to_owned(),
199    })
200}