1use std::path::PathBuf;
6
7use chrono::{DateTime, Utc};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct PackageManifest {
14 pub name: String,
15 pub version: String,
16 pub author: String,
17 pub description: String,
18 #[serde(default)]
19 pub tags: Vec<String>,
20 #[serde(default)]
21 pub downloads: u64,
22 pub created_at: Option<DateTime<Utc>>,
23 pub updated_at: Option<DateTime<Utc>>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28pub struct PackageReview {
29 pub package_name: String,
30 pub reviewer: String,
31 pub rating: u8,
32 pub comment: String,
33 pub created_at: DateTime<Utc>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
38#[serde(rename_all = "snake_case")]
39pub enum PackageSort {
40 #[default]
41 Downloads,
42 Recent,
43 Rating,
44 Name,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
49pub struct PackageFilter {
50 #[serde(default)]
51 pub query: Option<String>,
52 #[serde(default)]
53 pub tags: Vec<String>,
54 #[serde(default)]
55 pub author: Option<String>,
56 #[serde(default)]
57 pub sort: PackageSort,
58 #[serde(default = "default_limit")]
59 pub limit: usize,
60 #[serde(default)]
61 pub offset: usize,
62}
63
64fn default_limit() -> usize {
65 50
66}
67
68impl Default for PackageFilter {
69 fn default() -> Self {
70 Self {
71 query: None,
72 tags: Vec::new(),
73 author: None,
74 sort: PackageSort::default(),
75 limit: default_limit(),
76 offset: 0,
77 }
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
83pub struct PackageListResult {
84 pub packages: Vec<PackageManifest>,
85 pub total: usize,
86 pub offset: usize,
87 pub limit: usize,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
92pub struct InstallResult {
93 pub package: PackageManifest,
94 pub install_path: PathBuf,
95 pub installed_at: DateTime<Utc>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
100pub struct PublishResult {
101 pub package: PackageManifest,
102 pub published_at: DateTime<Utc>,
103 pub url: String,
104}
105
106#[derive(Debug, Clone)]
108pub struct MarketplaceClient {
109 api_base: String,
110 api_token: Option<String>,
111 workflows_dir: PathBuf,
112}
113
114impl MarketplaceClient {
115 pub fn new(api_token: Option<String>) -> Self {
117 let workflows_dir = directories::BaseDirs::new()
118 .map(|dirs| dirs.home_dir().join(".mur/workflows"))
119 .unwrap_or_else(|| PathBuf::from(".mur/workflows"));
120
121 Self {
122 api_base: "https://api.mur.run/v1/marketplace".to_string(),
123 api_token,
124 workflows_dir,
125 }
126 }
127
128 pub fn with_base_url(api_base: String, api_token: Option<String>) -> Self {
130 let workflows_dir = directories::BaseDirs::new()
131 .map(|dirs| dirs.home_dir().join(".mur/workflows"))
132 .unwrap_or_else(|| PathBuf::from(".mur/workflows"));
133
134 Self {
135 api_base,
136 api_token,
137 workflows_dir,
138 }
139 }
140
141 pub fn workflows_dir(&self) -> &PathBuf {
143 &self.workflows_dir
144 }
145
146 pub async fn list_packages(
148 &self,
149 filter: &PackageFilter,
150 ) -> anyhow::Result<PackageListResult> {
151 let client = reqwest::Client::new();
152 let mut url = format!("{}/packages", self.api_base);
153
154 let mut params = Vec::new();
155 if let Some(q) = &filter.query {
156 params.push(format!("q={}", urlencoded(q)));
157 }
158 if let Some(author) = &filter.author {
159 params.push(format!("author={}", urlencoded(author)));
160 }
161 if !filter.tags.is_empty() {
162 params.push(format!("tags={}", filter.tags.join(",")));
163 }
164 params.push(format!("sort={}", serde_json::to_string(&filter.sort).unwrap_or_default().trim_matches('"')));
165 params.push(format!("limit={}", filter.limit));
166 params.push(format!("offset={}", filter.offset));
167
168 if !params.is_empty() {
169 url = format!("{}?{}", url, params.join("&"));
170 }
171
172 let mut req = client.get(&url);
173 if let Some(token) = &self.api_token {
174 req = req.header("Authorization", format!("Bearer {token}"));
175 }
176
177 let resp = req
178 .send()
179 .await
180 .map_err(|e| anyhow::anyhow!("marketplace request failed: {e}"))?;
181
182 if !resp.status().is_success() {
183 anyhow::bail!(
184 "marketplace API returned status {}",
185 resp.status()
186 );
187 }
188
189 let result: PackageListResult = resp
190 .json()
191 .await
192 .map_err(|e| anyhow::anyhow!("failed to parse marketplace response: {e}"))?;
193
194 Ok(result)
195 }
196
197 pub async fn install_package(&self, package_name: &str) -> anyhow::Result<InstallResult> {
199 let client = reqwest::Client::new();
200 let url = format!("{}/packages/{}/download", self.api_base, urlencoded(package_name));
201
202 let mut req = client.get(&url);
203 if let Some(token) = &self.api_token {
204 req = req.header("Authorization", format!("Bearer {token}"));
205 }
206
207 let resp = req
208 .send()
209 .await
210 .map_err(|e| anyhow::anyhow!("download request failed: {e}"))?;
211
212 if !resp.status().is_success() {
213 anyhow::bail!(
214 "failed to download package '{}': status {}",
215 package_name,
216 resp.status()
217 );
218 }
219
220 let manifest: PackageManifest = resp
221 .json()
222 .await
223 .map_err(|e| anyhow::anyhow!("failed to parse package data: {e}"))?;
224
225 let install_path = self.workflows_dir.join(&manifest.name);
226 std::fs::create_dir_all(&install_path)
227 .map_err(|e| anyhow::anyhow!("failed to create install directory: {e}"))?;
228
229 Ok(InstallResult {
230 package: manifest,
231 install_path,
232 installed_at: Utc::now(),
233 })
234 }
235
236 pub async fn publish_package(
238 &self,
239 manifest: &PackageManifest,
240 workflow_yaml: &str,
241 ) -> anyhow::Result<PublishResult> {
242 let token = self
243 .api_token
244 .as_ref()
245 .ok_or_else(|| anyhow::anyhow!("API token required for publishing"))?;
246
247 let client = reqwest::Client::new();
248 let url = format!("{}/packages", self.api_base);
249
250 let body = serde_json::json!({
251 "manifest": manifest,
252 "workflow": workflow_yaml,
253 });
254
255 let resp = client
256 .post(&url)
257 .header("Authorization", format!("Bearer {token}"))
258 .json(&body)
259 .send()
260 .await
261 .map_err(|e| anyhow::anyhow!("publish request failed: {e}"))?;
262
263 if !resp.status().is_success() {
264 anyhow::bail!(
265 "failed to publish package: status {}",
266 resp.status()
267 );
268 }
269
270 let result: PublishResult = resp
271 .json()
272 .await
273 .map_err(|e| anyhow::anyhow!("failed to parse publish response: {e}"))?;
274
275 Ok(result)
276 }
277
278 pub async fn submit_review(
280 &self,
281 package_name: &str,
282 rating: u8,
283 comment: &str,
284 ) -> anyhow::Result<PackageReview> {
285 let token = self
286 .api_token
287 .as_ref()
288 .ok_or_else(|| anyhow::anyhow!("API token required for reviews"))?;
289
290 if !(1..=5).contains(&rating) {
291 anyhow::bail!("rating must be between 1 and 5");
292 }
293
294 let client = reqwest::Client::new();
295 let url = format!(
296 "{}/packages/{}/reviews",
297 self.api_base,
298 urlencoded(package_name)
299 );
300
301 let body = serde_json::json!({
302 "rating": rating,
303 "comment": comment,
304 });
305
306 let resp = client
307 .post(&url)
308 .header("Authorization", format!("Bearer {token}"))
309 .json(&body)
310 .send()
311 .await
312 .map_err(|e| anyhow::anyhow!("review request failed: {e}"))?;
313
314 if !resp.status().is_success() {
315 anyhow::bail!(
316 "failed to submit review: status {}",
317 resp.status()
318 );
319 }
320
321 let review: PackageReview = resp
322 .json()
323 .await
324 .map_err(|e| anyhow::anyhow!("failed to parse review response: {e}"))?;
325
326 Ok(review)
327 }
328
329 pub async fn get_reviews(&self, package_name: &str) -> anyhow::Result<Vec<PackageReview>> {
331 let client = reqwest::Client::new();
332 let url = format!(
333 "{}/packages/{}/reviews",
334 self.api_base,
335 urlencoded(package_name)
336 );
337
338 let mut req = client.get(&url);
339 if let Some(token) = &self.api_token {
340 req = req.header("Authorization", format!("Bearer {token}"));
341 }
342
343 let resp = req
344 .send()
345 .await
346 .map_err(|e| anyhow::anyhow!("reviews request failed: {e}"))?;
347
348 if !resp.status().is_success() {
349 anyhow::bail!(
350 "failed to get reviews: status {}",
351 resp.status()
352 );
353 }
354
355 let reviews: Vec<PackageReview> = resp
356 .json()
357 .await
358 .map_err(|e| anyhow::anyhow!("failed to parse reviews response: {e}"))?;
359
360 Ok(reviews)
361 }
362}
363
364fn urlencoded(s: &str) -> String {
366 s.replace('%', "%25")
367 .replace(' ', "%20")
368 .replace('/', "%2F")
369 .replace('?', "%3F")
370 .replace('#', "%23")
371 .replace('&', "%26")
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_marketplace_client_new() {
380 let client = MarketplaceClient::new(Some("test-token".into()));
381 assert!(client.workflows_dir().to_str().unwrap().contains(".mur/workflows"));
382 }
383
384 #[test]
385 fn test_marketplace_client_custom_url() {
386 let client =
387 MarketplaceClient::with_base_url("http://localhost:8080".into(), None);
388 assert_eq!(client.api_base, "http://localhost:8080");
389 }
390
391 #[test]
392 fn test_package_manifest_serialization() {
393 let manifest = PackageManifest {
394 name: "deploy-aws".into(),
395 version: "1.0.0".into(),
396 author: "alice".into(),
397 description: "AWS deployment workflow".into(),
398 tags: vec!["aws".into(), "deploy".into()],
399 downloads: 1234,
400 created_at: Some(Utc::now()),
401 updated_at: None,
402 };
403
404 let json = serde_json::to_string(&manifest).unwrap();
405 let back: PackageManifest = serde_json::from_str(&json).unwrap();
406 assert_eq!(manifest.name, back.name);
407 assert_eq!(manifest.version, back.version);
408 assert_eq!(manifest.downloads, back.downloads);
409 }
410
411 #[test]
412 fn test_package_review_serialization() {
413 let review = PackageReview {
414 package_name: "deploy-aws".into(),
415 reviewer: "bob".into(),
416 rating: 5,
417 comment: "Great workflow!".into(),
418 created_at: Utc::now(),
419 };
420
421 let json = serde_json::to_string(&review).unwrap();
422 let back: PackageReview = serde_json::from_str(&json).unwrap();
423 assert_eq!(review.package_name, back.package_name);
424 assert_eq!(review.rating, back.rating);
425 }
426
427 #[test]
428 fn test_package_filter_default() {
429 let filter = PackageFilter::default();
430 assert!(filter.query.is_none());
431 assert!(filter.tags.is_empty());
432 assert!(filter.author.is_none());
433 assert_eq!(filter.limit, 50);
434 assert_eq!(filter.offset, 0);
435 }
436
437 #[test]
438 fn test_package_sort_default() {
439 let sort = PackageSort::default();
440 let json = serde_json::to_string(&sort).unwrap();
441 assert!(json.contains("downloads"));
442 }
443
444 #[test]
445 fn test_urlencoded() {
446 assert_eq!(urlencoded("hello world"), "hello%20world");
447 assert_eq!(urlencoded("a/b"), "a%2Fb");
448 assert_eq!(urlencoded("q?x=1&y=2"), "q%3Fx=1%26y=2");
449 }
450
451 #[test]
452 fn test_install_result_serialization() {
453 let result = InstallResult {
454 package: PackageManifest {
455 name: "test".into(),
456 version: "0.1.0".into(),
457 author: "tester".into(),
458 description: "test pkg".into(),
459 tags: vec![],
460 downloads: 0,
461 created_at: None,
462 updated_at: None,
463 },
464 install_path: PathBuf::from("/home/user/.mur/workflows/test"),
465 installed_at: Utc::now(),
466 };
467
468 let json = serde_json::to_string(&result).unwrap();
469 let back: InstallResult = serde_json::from_str(&json).unwrap();
470 assert_eq!(result.package.name, back.package.name);
471 }
472
473 #[test]
474 fn test_publish_result_serialization() {
475 let result = PublishResult {
476 package: PackageManifest {
477 name: "my-workflow".into(),
478 version: "1.0.0".into(),
479 author: "alice".into(),
480 description: "A workflow".into(),
481 tags: vec!["ci".into()],
482 downloads: 0,
483 created_at: None,
484 updated_at: None,
485 },
486 published_at: Utc::now(),
487 url: "https://mur.run/packages/my-workflow".into(),
488 };
489
490 let json = serde_json::to_string(&result).unwrap();
491 let back: PublishResult = serde_json::from_str(&json).unwrap();
492 assert_eq!(result.url, back.url);
493 }
494
495 #[test]
496 fn test_package_list_result_serialization() {
497 let result = PackageListResult {
498 packages: vec![],
499 total: 0,
500 offset: 0,
501 limit: 50,
502 };
503
504 let json = serde_json::to_string(&result).unwrap();
505 let back: PackageListResult = serde_json::from_str(&json).unwrap();
506 assert_eq!(result.total, back.total);
507 }
508}