1use crate::error::Result;
7use serde::Deserialize;
8use std::path::{Path, PathBuf};
9use tracing::{debug, warn};
10
11#[derive(Debug, Clone)]
13pub struct BrewReceipt {
14 pub installed_on_request: bool,
15 pub install_time: Option<u64>,
16 pub runtime_dependencies: Vec<BrewRuntimeDep>,
17 pub source_tap: Option<String>,
18 pub poured_from_bottle: Option<bool>,
19}
20
21#[derive(Debug, Clone)]
23pub struct BrewRuntimeDep {
24 pub full_name: String,
25 pub version: String,
26}
27
28#[derive(Debug, Clone)]
30pub struct CellarPackage {
31 pub name: String,
32 pub version: String,
33 pub path: PathBuf,
34 pub receipt: Option<BrewReceipt>,
35}
36
37#[derive(Deserialize)]
39struct RawReceipt {
40 #[serde(default)]
41 installed_on_request: Option<bool>,
42 #[serde(default)]
43 install_time: Option<serde_json::Value>,
44 #[serde(default)]
45 runtime_dependencies: Option<serde_json::Value>,
46 #[serde(default)]
47 source: Option<RawSource>,
48 #[serde(default)]
49 poured_from_bottle: Option<bool>,
50}
51
52#[derive(Deserialize)]
53struct RawSource {
54 #[serde(default)]
55 tap: Option<String>,
56}
57
58#[derive(Deserialize)]
59struct RawRuntimeDep {
60 #[serde(default)]
61 full_name: Option<String>,
62 #[serde(default)]
63 version: Option<String>,
64}
65
66pub fn parse_brew_receipt(path: &Path) -> Result<BrewReceipt> {
71 let json = std::fs::read_to_string(path)?;
72 let raw: RawReceipt = serde_json::from_str(&json)?;
73
74 let install_time = raw.install_time.and_then(|v| match v {
75 serde_json::Value::Number(n) => n.as_u64(),
76 _ => None,
77 });
78
79 let runtime_dependencies = match raw.runtime_dependencies {
80 Some(serde_json::Value::Array(arr)) => arr
81 .into_iter()
82 .filter_map(|v| {
83 let dep: RawRuntimeDep = serde_json::from_value(v).ok()?;
84 Some(BrewRuntimeDep {
85 full_name: dep.full_name?,
86 version: dep.version.unwrap_or_default(),
87 })
88 })
89 .collect(),
90 _ => Vec::new(),
91 };
92
93 Ok(BrewReceipt {
94 installed_on_request: raw.installed_on_request.unwrap_or(true),
95 install_time,
96 runtime_dependencies,
97 source_tap: raw.source.and_then(|s| s.tap),
98 poured_from_bottle: raw.poured_from_bottle,
99 })
100}
101
102pub fn scan_cellar(cellar: &Path) -> Result<Vec<CellarPackage>> {
106 if !cellar.exists() {
107 return Ok(Vec::new());
108 }
109
110 let mut packages = Vec::new();
111
112 let entries = std::fs::read_dir(cellar)?;
113 for entry in entries {
114 let entry = entry?;
115 let path = entry.path();
116
117 if path.symlink_metadata()?.file_type().is_symlink() {
119 debug!("Skipping symlink in Cellar: {}", path.display());
120 continue;
121 }
122
123 if !path.is_dir() {
124 continue;
125 }
126
127 let name = match entry.file_name().into_string() {
128 Ok(n) => n,
129 Err(_) => continue,
130 };
131
132 if name.starts_with('.') || !is_safe_package_name(&name) {
134 continue;
135 }
136
137 match scan_package_versions(&path, &name) {
138 Ok(Some(pkg)) => packages.push(pkg),
139 Ok(None) => {
140 debug!("No versions found for {}", name);
141 }
142 Err(e) => {
143 warn!("Failed to scan {}: {}", name, e);
144 }
145 }
146 }
147
148 packages.sort_by(|a, b| a.name.cmp(&b.name));
149 Ok(packages)
150}
151
152pub fn scan_cellar_package(cellar: &Path, name: &str) -> Result<Option<CellarPackage>> {
154 if !is_safe_package_name(name) {
155 return Ok(None);
156 }
157 let pkg_dir = cellar.join(name);
158 if !pkg_dir.is_dir() {
159 return Ok(None);
160 }
161 scan_package_versions(&pkg_dir, name)
162}
163
164fn is_safe_package_name(name: &str) -> bool {
166 !name.is_empty()
167 && !name.contains('/')
168 && !name.contains('\\')
169 && !name.contains('\0')
170 && name != ".."
171}
172
173fn scan_package_versions(pkg_dir: &Path, name: &str) -> Result<Option<CellarPackage>> {
175 let mut versions: Vec<String> = Vec::new();
176
177 let entries = std::fs::read_dir(pkg_dir)?;
178 for entry in entries {
179 let entry = entry?;
180 let path = entry.path();
181
182 if path.symlink_metadata()?.file_type().is_symlink() {
184 continue;
185 }
186
187 if !path.is_dir() {
188 continue;
189 }
190 if let Ok(v) = entry.file_name().into_string() {
191 if !v.starts_with('.') {
192 versions.push(v);
193 }
194 }
195 }
196
197 if versions.is_empty() {
198 return Ok(None);
199 }
200
201 versions.sort();
203 let version = versions.last().unwrap().clone();
204
205 let version_path = pkg_dir.join(&version);
206 let receipt_path = version_path.join("INSTALL_RECEIPT.json");
207
208 let receipt = if receipt_path.exists() {
209 match parse_brew_receipt(&receipt_path) {
210 Ok(r) => Some(r),
211 Err(e) => {
212 warn!("Failed to parse receipt for {}/{}: {}", name, version, e);
213 None
214 }
215 }
216 } else {
217 None
218 };
219
220 Ok(Some(CellarPackage {
221 name: name.to_string(),
222 version,
223 path: version_path,
224 receipt,
225 }))
226}
227
228pub fn count_cellar_packages(cellar: &Path) -> usize {
230 if !cellar.exists() {
231 return 0;
232 }
233
234 std::fs::read_dir(cellar)
235 .ok()
236 .map(|entries| {
237 entries
238 .filter_map(|e| e.ok())
239 .filter(|e| {
240 e.path().is_dir()
241 && e.file_name()
242 .to_str()
243 .map(|n| !n.starts_with('.'))
244 .unwrap_or(false)
245 })
246 .count()
247 })
248 .unwrap_or(0)
249}
250
251pub fn timestamp_to_iso(ts: u64) -> String {
253 use jiff::Timestamp;
254 Timestamp::from_second(ts as i64)
255 .unwrap_or(Timestamp::UNIX_EPOCH)
256 .strftime("%Y-%m-%dT%H:%M:%SZ")
257 .to_string()
258}