update_kit/checker/sources/
brew_api.rs1use std::sync::Arc;
2
3use super::{FetchOptions, VersionInfo, VersionSource, VersionSourceResult};
4use crate::utils::process::{CommandRunner, TokioCommandRunner};
5
6pub 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 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 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}