Skip to main content

mur_core/
marketplace.rs

1//! Workflow marketplace client for browsing, installing, and publishing workflow packages.
2//!
3//! Connects to the mur.run API to access the community workflow marketplace.
4
5use std::path::PathBuf;
6
7use chrono::{DateTime, Utc};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Metadata for a workflow package in the marketplace.
12#[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/// A user review of a marketplace package.
27#[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/// Sorting options for package listings.
37#[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/// Filter criteria for browsing packages.
48#[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/// Result of a package listing query.
82#[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/// Result of installing a package.
91#[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/// Result of publishing a package.
99#[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/// Client for the mur.run workflow marketplace API.
107#[derive(Debug, Clone)]
108pub struct MarketplaceClient {
109    api_base: String,
110    api_token: Option<String>,
111    workflows_dir: PathBuf,
112}
113
114impl MarketplaceClient {
115    /// Create a new marketplace client with the default mur.run API endpoint.
116    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    /// Create a client with a custom API base URL (for testing).
129    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    /// Get the configured workflows directory.
142    pub fn workflows_dir(&self) -> &PathBuf {
143        &self.workflows_dir
144    }
145
146    /// Browse available workflow packages.
147    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    /// Download and install a workflow package to ~/.mur/workflows/.
198    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    /// Publish a workflow package to the marketplace.
237    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    /// Submit a review for a package.
279    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    /// Get reviews for a package.
330    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
364/// Simple percent-encoding for URL path segments.
365fn 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}