Skip to main content

update_kit/checker/sources/
brew_api.rs

1use std::sync::Arc;
2
3use super::{FetchOptions, VersionInfo, VersionSource, VersionSourceResult};
4use crate::utils::process::{CommandRunner, TokioCommandRunner};
5
6/// A version source backed by Homebrew (brew info --json).
7pub struct BrewSource {
8    cask_name: String,
9    cmd: Arc<dyn CommandRunner>,
10}
11
12impl BrewSource {
13    pub fn new(cask_name: String) -> Self {
14        Self {
15            cask_name,
16            cmd: Arc::new(TokioCommandRunner),
17        }
18    }
19
20    pub fn with_cmd(cask_name: String, cmd: Arc<dyn CommandRunner>) -> Self {
21        Self { cask_name, cmd }
22    }
23}
24
25#[async_trait::async_trait]
26impl VersionSource for BrewSource {
27    fn name(&self) -> &str {
28        "brew"
29    }
30
31    async fn fetch_latest(&self, _options: FetchOptions) -> VersionSourceResult {
32        let output = match self
33            .cmd
34            .run("brew", &["info", "--json=v2", "--cask", &self.cask_name])
35            .await
36        {
37            Ok(o) => o,
38            Err(e) => {
39                return VersionSourceResult::Error {
40                    reason: format!("Failed to run brew: {}", e),
41                    status: None,
42                }
43            }
44        };
45
46        if !output.success() {
47            return VersionSourceResult::Error {
48                reason: format!("brew info failed: {}", output.stderr.trim()),
49                status: None,
50            };
51        }
52
53        let json: serde_json::Value = match serde_json::from_str(&output.stdout) {
54            Ok(j) => j,
55            Err(e) => {
56                return VersionSourceResult::Error {
57                    reason: format!("Failed to parse brew JSON: {}", e),
58                    status: None,
59                }
60            }
61        };
62
63        // brew info --json=v2 returns { "casks": [ { "version": "...", ... } ] }
64        let version = json
65            .get("casks")
66            .and_then(|v| v.as_array())
67            .and_then(|arr| arr.first())
68            .and_then(|cask| cask.get("version"))
69            .and_then(|v| v.as_str())
70            .map(String::from);
71
72        match version {
73            Some(version) => VersionSourceResult::Found {
74                info: VersionInfo {
75                    version,
76                    release_url: None,
77                    release_notes: None,
78                    assets: None,
79                    published_at: None,
80                },
81                etag: None,
82            },
83            None => VersionSourceResult::Error {
84                reason: "Could not find cask version in brew output".into(),
85                status: None,
86            },
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::sync::Arc;
95
96    use crate::test_utils::MockCommandRunner;
97
98    #[test]
99    fn test_source_name() {
100        let source = BrewSource::new("my-cask".into());
101        assert_eq!(source.name(), "brew");
102    }
103
104    #[tokio::test]
105    async fn fetch_latest_success() {
106        let cmd = MockCommandRunner::new();
107        cmd.on(
108            "brew info --json=v2 --cask my-cask",
109            Ok(MockCommandRunner::success_output(
110                r#"{"casks": [{"version": "4.1.0", "name": [{"name": "my-cask"}]}]}"#,
111            )),
112        );
113
114        let source = BrewSource::with_cmd("my-cask".into(), Arc::new(cmd));
115        let result = source.fetch_latest(FetchOptions::default()).await;
116
117        match result {
118            VersionSourceResult::Found { info, .. } => {
119                assert_eq!(info.version, "4.1.0");
120            }
121            other => panic!("Expected Found, got: {other:?}"),
122        }
123    }
124
125    #[tokio::test]
126    async fn fetch_latest_brew_command_fails() {
127        let cmd = MockCommandRunner::new();
128        cmd.on(
129            "brew info --json=v2 --cask my-cask",
130            Ok(MockCommandRunner::failure_output(
131                "Error: Cask 'my-cask' is unavailable",
132            )),
133        );
134
135        let source = BrewSource::with_cmd("my-cask".into(), Arc::new(cmd));
136        let result = source.fetch_latest(FetchOptions::default()).await;
137
138        match result {
139            VersionSourceResult::Error { reason, .. } => {
140                assert!(reason.contains("brew info failed"));
141            }
142            other => panic!("Expected Error, got: {other:?}"),
143        }
144    }
145
146    #[tokio::test]
147    async fn fetch_latest_brew_not_found() {
148        // Don't register any response — will get CommandSpawnFailed
149        let cmd = MockCommandRunner::new();
150
151        let source = BrewSource::with_cmd("my-cask".into(), Arc::new(cmd));
152        let result = source.fetch_latest(FetchOptions::default()).await;
153
154        match result {
155            VersionSourceResult::Error { reason, .. } => {
156                assert!(!reason.is_empty());
157            }
158            other => panic!("Expected Error, got: {other:?}"),
159        }
160    }
161
162    #[tokio::test]
163    async fn fetch_latest_invalid_json() {
164        let cmd = MockCommandRunner::new();
165        cmd.on(
166            "brew info --json=v2 --cask my-cask",
167            Ok(MockCommandRunner::success_output("not json")),
168        );
169
170        let source = BrewSource::with_cmd("my-cask".into(), Arc::new(cmd));
171        let result = source.fetch_latest(FetchOptions::default()).await;
172
173        match result {
174            VersionSourceResult::Error { reason, .. } => {
175                assert!(reason.contains("parse"));
176            }
177            other => panic!("Expected Error, got: {other:?}"),
178        }
179    }
180
181    #[tokio::test]
182    async fn fetch_latest_empty_casks_array() {
183        let cmd = MockCommandRunner::new();
184        cmd.on(
185            "brew info --json=v2 --cask my-cask",
186            Ok(MockCommandRunner::success_output(r#"{"casks": []}"#)),
187        );
188
189        let source = BrewSource::with_cmd("my-cask".into(), Arc::new(cmd));
190        let result = source.fetch_latest(FetchOptions::default()).await;
191
192        match result {
193            VersionSourceResult::Error { reason, .. } => {
194                assert!(reason.contains("version"));
195            }
196            other => panic!("Expected Error, got: {other:?}"),
197        }
198    }
199
200    #[tokio::test]
201    async fn fetch_latest_missing_version_field() {
202        let cmd = MockCommandRunner::new();
203        cmd.on(
204            "brew info --json=v2 --cask my-cask",
205            Ok(MockCommandRunner::success_output(
206                r#"{"casks": [{"name": "my-cask"}]}"#,
207            )),
208        );
209
210        let source = BrewSource::with_cmd("my-cask".into(), Arc::new(cmd));
211        let result = source.fetch_latest(FetchOptions::default()).await;
212
213        match result {
214            VersionSourceResult::Error { reason, .. } => {
215                assert!(reason.contains("version"));
216            }
217            other => panic!("Expected Error, got: {other:?}"),
218        }
219    }
220
221    #[tokio::test]
222    async fn fetch_versions_returns_unsupported() {
223        let cmd = MockCommandRunner::new();
224        let source = BrewSource::with_cmd("my-cask".into(), Arc::new(cmd));
225        let result = source
226            .fetch_versions(super::super::FetchVersionsOptions::default())
227            .await;
228        assert!(result.is_err());
229        assert_eq!(result.unwrap_err().code(), "UNSUPPORTED_OPERATION");
230    }
231}