sps_common/model/
cask.rs

1// ===== sps-core/src/model/cask.rs =====
2use std::collections::HashMap;
3use std::fs;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::Config; // <-- Added import
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)]
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/// Helper for architecture requirements: single string, list of strings, or list of spec objects
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(untagged)]
58pub enum ArchReq {
59    One(String),          // e.g., "arm64"
60    Many(Vec<String>),    // e.g., ["arm64", "x86_64"]
61    Specs(Vec<ArchSpec>), // Add this variant to handle [{"type": "arm", "bits": 64}]
62}
63
64/// Helper for macOS requirements: symbol, list, comparison, or map
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(untagged)]
67pub enum MacOSReq {
68    Symbol(String),       // ":big_sur"
69    Symbols(Vec<String>), // [":catalina", ":big_sur"]
70    Comparison(String),   // ">= :big_sur"
71    Map(HashMap<String, Vec<String>>),
72}
73
74/// Helper to coerce string-or-list into Vec<String>
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(untagged)]
77pub enum StringList {
78    One(String),
79    Many(Vec<String>),
80}
81
82impl From<StringList> for Vec<String> {
83    fn from(item: StringList) -> Self {
84        match item {
85            StringList::One(s) => vec![s],
86            StringList::Many(v) => v,
87        }
88    }
89}
90
91/// Represents the specific architecture details found in some cask definitions
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct ArchSpec {
94    #[serde(rename = "type")] // Map the JSON "type" field
95    pub type_name: String, // e.g., "arm"
96    pub bits: u32, // e.g., 64
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    #[serde(default)]
151    pub zap: Option<HashMap<String, serde_json::Value>>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct CaskList {
156    pub casks: Vec<Cask>,
157}
158
159impl Cask {
160    /// Check if this cask is installed by looking for a manifest file
161    /// in any versioned directory within the Caskroom.
162    pub fn is_installed(&self, config: &Config) -> bool {
163        let cask_dir = config.cask_dir(&self.token); // e.g., /opt/homebrew/Caskroom/firefox
164        if !cask_dir.exists() || !cask_dir.is_dir() {
165            return false;
166        }
167
168        // Iterate through entries (version dirs) inside the cask_dir
169        match fs::read_dir(&cask_dir) {
170            Ok(entries) => {
171                // Clippy fix: Use flatten() to handle Result entries directly
172                for entry in entries.flatten() {
173                    // <-- Use flatten() here
174                    let version_path = entry.path();
175                    // Check if it's a directory (representing a version)
176                    if version_path.is_dir() {
177                        // Check for the existence of the manifest file
178                        let manifest_path = version_path.join("CASK_INSTALL_MANIFEST.json"); // <-- Correct filename
179                        if manifest_path.is_file() {
180                            // Found a manifest in at least one version directory, consider it
181                            // installed
182                            return true;
183                        }
184                    }
185                }
186                // If loop completes without finding a manifest in any version dir
187                false
188            }
189            Err(e) => {
190                // Log error if reading the directory fails, but assume not installed
191                tracing::warn!(
192                    "Failed to read cask directory {} to check for installed versions: {}",
193                    cask_dir.display(),
194                    e
195                );
196                false
197            }
198        }
199    }
200
201    /// Get the installed version of this cask by reading the directory names
202    /// in the Caskroom. Returns the first version found (use cautiously if multiple
203    /// versions could exist, though current install logic prevents this).
204    pub fn installed_version(&self, config: &Config) -> Option<String> {
205        let cask_dir = config.cask_dir(&self.token); //
206        if !cask_dir.exists() {
207            return None;
208        }
209        // Iterate through entries and return the first directory name found
210        match fs::read_dir(&cask_dir) {
211            Ok(entries) => {
212                // Clippy fix: Use flatten()
213                for entry in entries.flatten() {
214                    // <-- Use flatten() here
215                    let path = entry.path();
216                    // Check if it's a directory (representing a version)
217                    if path.is_dir() {
218                        if let Some(version_str) = path.file_name().and_then(|name| name.to_str()) {
219                            // Return the first version directory name found
220                            return Some(version_str.to_string());
221                        }
222                    }
223                }
224                // No version directories found
225                None
226            }
227            Err(_) => None, // Error reading directory
228        }
229    }
230
231    /// Get a friendly name for display purposes
232    pub fn display_name(&self) -> String {
233        self.name
234            .as_ref()
235            .and_then(|names| names.first().cloned())
236            .unwrap_or_else(|| self.token.clone())
237    }
238}