sps_common/model/
cask.rs

1// ===== sps-common/src/model/cask.rs ===== // Corrected path
2use std::collections::HashMap;
3use std::fs;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::Config;
8
9pub type Artifact = serde_json::Value;
10
11/// Represents the `url` field, which can be a simple string or a map with specs
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(untagged)]
14pub enum UrlField {
15    Simple(String),
16    WithSpec {
17        url: String,
18        #[serde(default)]
19        verified: Option<String>,
20        #[serde(flatten)]
21        other: HashMap<String, serde_json::Value>,
22    },
23}
24
25/// Represents the `sha256` field: hex, no_check, or per-architecture
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(untagged)]
28pub enum Sha256Field {
29    Hex(String),
30    #[serde(rename_all = "snake_case")]
31    NoCheck {
32        no_check: bool,
33    },
34    PerArch(HashMap<String, String>),
35}
36
37/// Appcast metadata
38#[derive(Debug, Clone, Serialize, Deserialize)] // Ensure Serialize/Deserialize are here
39pub struct Appcast {
40    pub url: String,
41    pub checkpoint: Option<String>,
42}
43
44/// Represents conflicts with other casks or formulae
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ConflictsWith {
47    #[serde(default)]
48    pub cask: Vec<String>,
49    #[serde(default)]
50    pub formula: Vec<String>,
51    #[serde(flatten)]
52    pub extra: HashMap<String, serde_json::Value>,
53}
54
55/// Represents the specific architecture details found in some cask definitions
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ArchSpec {
58    #[serde(rename = "type")] // Map the JSON "type" field
59    pub type_name: String, // e.g., "arm"
60    pub bits: u32, // e.g., 64
61}
62
63/// Helper for architecture requirements: single string, list of strings, or list of spec objects
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(untagged)]
66pub enum ArchReq {
67    One(String),       // e.g., "arm64"
68    Many(Vec<String>), // e.g., ["arm64", "x86_64"]
69    Specs(Vec<ArchSpec>),
70}
71
72/// Helper for macOS requirements: symbol, list, comparison, or map
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(untagged)]
75pub enum MacOSReq {
76    Symbol(String),       // ":big_sur"
77    Symbols(Vec<String>), // [":catalina", ":big_sur"]
78    Comparison(String),   // ">= :big_sur"
79    Map(HashMap<String, Vec<String>>),
80}
81
82/// Helper to coerce string-or-list into Vec<String>
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(untagged)]
85pub enum StringList {
86    One(String),
87    Many(Vec<String>),
88}
89
90impl From<StringList> for Vec<String> {
91    fn from(item: StringList) -> Self {
92        match item {
93            StringList::One(s) => vec![s],
94            StringList::Many(v) => v,
95        }
96    }
97}
98
99/// Represents `depends_on` block with multiple possible keys
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct DependsOn {
102    #[serde(default)]
103    pub cask: Vec<String>,
104    #[serde(default)]
105    pub formula: Vec<String>,
106    #[serde(default)]
107    pub arch: Option<ArchReq>,
108    #[serde(default)]
109    pub macos: Option<MacOSReq>,
110    #[serde(flatten)]
111    pub extra: HashMap<String, serde_json::Value>,
112}
113
114/// The main Cask model matching Homebrew JSON v2
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct Cask {
117    pub token: String,
118
119    #[serde(default)]
120    pub name: Option<Vec<String>>,
121    pub version: Option<String>,
122    pub desc: Option<String>,
123    pub homepage: Option<String>,
124
125    #[serde(default)]
126    pub artifacts: Option<Vec<Artifact>>,
127
128    #[serde(default)]
129    pub url: Option<UrlField>,
130    #[serde(default)]
131    pub url_specs: Option<HashMap<String, serde_json::Value>>,
132
133    #[serde(default)]
134    pub sha256: Option<Sha256Field>,
135
136    pub appcast: Option<Appcast>,
137    pub auto_updates: Option<bool>,
138
139    #[serde(default)]
140    pub depends_on: Option<DependsOn>,
141
142    #[serde(default)]
143    pub conflicts_with: Option<ConflictsWith>,
144
145    pub caveats: Option<String>,
146    pub stage_only: Option<bool>,
147
148    #[serde(default)]
149    pub uninstall: Option<HashMap<String, serde_json::Value>>,
150
151    #[serde(default)] // Only one default here
152    pub zap: Option<Vec<ZapStanza>>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct CaskList {
157    pub casks: Vec<Cask>,
158}
159
160// --- ZAP STANZA SUPPORT ---
161
162/// Helper for zap: string or array of strings
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum StringOrVec {
166    String(String),
167    Vec(Vec<String>),
168}
169impl StringOrVec {
170    pub fn into_vec(self) -> Vec<String> {
171        match self {
172            StringOrVec::String(s) => vec![s],
173            StringOrVec::Vec(v) => v,
174        }
175    }
176}
177
178/// Zap action details (trash, delete, rmdir, pkgutil, launchctl, script, signal, etc)
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(tag = "action", rename_all = "snake_case")]
181pub enum ZapActionDetail {
182    Trash(Vec<String>),
183    Delete(Vec<String>),
184    Rmdir(Vec<String>),
185    Pkgutil(StringOrVec),
186    Launchctl(StringOrVec),
187    Script {
188        executable: String,
189        args: Option<Vec<String>>,
190    },
191    Signal(Vec<String>),
192    // Add more as needed
193}
194
195/// A zap stanza is a map of action -> detail
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ZapStanza(pub std::collections::HashMap<String, ZapActionDetail>);
198
199// --- Cask Impl ---
200
201impl Cask {
202    /// Check if this cask is installed by looking for a manifest file
203    /// in any versioned directory within the Caskroom.
204    pub fn is_installed(&self, config: &Config) -> bool {
205        let cask_dir = config.cask_room_token_path(&self.token); // e.g., /opt/sps/cask_room/firefox
206        if !cask_dir.exists() || !cask_dir.is_dir() {
207            return false;
208        }
209
210        // Iterate through entries (version dirs) inside the cask_dir
211        match fs::read_dir(&cask_dir) {
212            Ok(entries) => {
213                // Clippy fix: Use flatten() to handle Result entries directly
214                for entry in entries.flatten() {
215                    // <-- Use flatten() here
216                    let version_path = entry.path();
217                    // Check if it's a directory (representing a version)
218                    if version_path.is_dir() {
219                        // Check for the existence of the manifest file
220                        let manifest_path = version_path.join("CASK_INSTALL_MANIFEST.json"); // <-- Correct filename
221                        if manifest_path.is_file() {
222                            // Check is_installed flag in manifest
223                            let mut include = true;
224                            if let Ok(manifest_str) = std::fs::read_to_string(&manifest_path) {
225                                if let Ok(manifest_json) =
226                                    serde_json::from_str::<serde_json::Value>(&manifest_str)
227                                {
228                                    if let Some(is_installed) =
229                                        manifest_json.get("is_installed").and_then(|v| v.as_bool())
230                                    {
231                                        include = is_installed;
232                                    }
233                                }
234                            }
235                            if include {
236                                // Found a manifest in at least one version directory, consider it
237                                // installed
238                                return true;
239                            }
240                        }
241                    }
242                }
243                // If loop completes without finding a manifest in any version dir
244                false
245            }
246            Err(e) => {
247                // Log error if reading the directory fails, but assume not installed
248                tracing::warn!(
249                    "Failed to read cask directory {} to check for installed versions: {}",
250                    cask_dir.display(),
251                    e
252                );
253                false
254            }
255        }
256    }
257
258    /// Get the installed version of this cask by reading the directory names
259    /// in the Caskroom. Returns the first version found (use cautiously if multiple
260    /// versions could exist, though current install logic prevents this).
261    pub fn installed_version(&self, config: &Config) -> Option<String> {
262        let cask_dir = config.cask_room_token_path(&self.token); //
263        if !cask_dir.exists() {
264            return None;
265        }
266        // Iterate through entries and return the first directory name found
267        match fs::read_dir(&cask_dir) {
268            Ok(entries) => {
269                // Clippy fix: Use flatten()
270                for entry in entries.flatten() {
271                    // <-- Use flatten() here
272                    let path = entry.path();
273                    // Check if it's a directory (representing a version)
274                    if path.is_dir() {
275                        if let Some(version_str) = path.file_name().and_then(|name| name.to_str()) {
276                            // Return the first version directory name found
277                            return Some(version_str.to_string());
278                        }
279                    }
280                }
281                // No version directories found
282                None
283            }
284            Err(_) => None, // Error reading directory
285        }
286    }
287
288    /// Get a friendly name for display purposes
289    pub fn display_name(&self) -> String {
290        self.name
291            .as_ref()
292            .and_then(|names| names.first().cloned())
293            .unwrap_or_else(|| self.token.clone())
294    }
295}