Skip to main content

sz_rust_cli/cmd/
plugin.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-2026 SZ-Rust Team
3//
4//! 插件市场 CLI 子命令组
5//!
6//! 提供 `sz-rust plugin search/install/publish/uninstall/update/list/login` 七个子命令。
7
8use clap::{Args, Subcommand};
9use indicatif::{ProgressBar, ProgressStyle};
10use tabled::{Table, Tabled};
11
12use crate::error::CliError;
13
14/// 默认市场 URL
15const DEFAULT_MARKET_URL: &str = "http://localhost:8080";
16
17/// 插件命令组
18#[derive(Subcommand, Debug)]
19pub enum PluginCommand {
20    /// 搜索插件
21    Search(SearchArgs),
22    /// 安装插件
23    Install(InstallArgs),
24    /// 发布插件
25    Publish(PublishArgs),
26    /// 卸载插件
27    Uninstall(UninstallArgs),
28    /// 更新插件
29    Update(UpdateArgs),
30    /// 列出已安装插件
31    List,
32    /// 登录市场
33    Login(LoginArgs),
34}
35
36/// 搜索参数
37#[derive(Args, Debug)]
38pub struct SearchArgs {
39    /// 搜索关键词
40    pub keyword: String,
41    /// 标签过滤(逗号分隔)
42    #[arg(long, value_delimiter = ',')]
43    pub tags: Vec<String>,
44    /// 来源过滤
45    #[arg(long)]
46    pub source: Option<String>,
47    /// 排序方式
48    #[arg(long, default_value = "relevance")]
49    pub sort: String,
50    /// 页码
51    #[arg(long, default_value_t = 1)]
52    pub page: u32,
53    /// 每页数量
54    #[arg(long, default_value_t = 20)]
55    pub page_size: u32,
56}
57
58/// 安装参数
59#[derive(Args, Debug)]
60pub struct InstallArgs {
61    /// 插件标识符(可含 @version)
62    pub identifier: String,
63}
64
65/// 发布参数
66#[derive(Args, Debug)]
67pub struct PublishArgs {
68    /// 插件归档路径
69    #[arg(short = 'p', long)]
70    pub path: String,
71    /// 签名私钥文件路径
72    #[arg(short = 's', long)]
73    pub sign: String,
74    /// changelog
75    #[arg(short = 'c', long)]
76    pub changelog: Option<String>,
77}
78
79/// 卸载参数
80#[derive(Args, Debug)]
81pub struct UninstallArgs {
82    /// 插件标识符
83    pub identifier: String,
84}
85
86/// 更新参数
87#[derive(Args, Debug)]
88pub struct UpdateArgs {
89    /// 插件标识符
90    pub identifier: String,
91    /// 目标版本(可选,默认最新)
92    #[arg(long)]
93    pub version: Option<String>,
94}
95
96/// 登录参数
97#[derive(Args, Debug)]
98pub struct LoginArgs {
99    /// 直接提供 token
100    #[arg(long)]
101    pub token: Option<String>,
102    /// 市场服务 URL
103    #[arg(long)]
104    pub url: Option<String>,
105}
106
107/// 执行插件命令
108pub async fn execute(cmd: &PluginCommand) -> Result<i32, CliError> {
109    match cmd {
110        PluginCommand::Search(args) => execute_search(args).await,
111        PluginCommand::Install(args) => execute_install(args).await,
112        PluginCommand::Publish(args) => execute_publish(args).await,
113        PluginCommand::Uninstall(args) => execute_uninstall(args).await,
114        PluginCommand::Update(args) => execute_update(args).await,
115        PluginCommand::List => execute_list().await,
116        PluginCommand::Login(args) => execute_login(args).await,
117    }
118}
119
120/// 解析标识符(name@version → (name, version))
121fn parse_identifier(id: &str) -> (&str, &str) {
122    if let Some((name, version)) = id.split_once('@') {
123        (name, version)
124    } else {
125        (id, "latest")
126    }
127}
128
129/// 创建市场客户端(从凭证文件或默认 URL)
130async fn create_client() -> Result<sz_rust_marketplace::client::MarketplaceClient, CliError> {
131    match sz_rust_marketplace::client::MarketplaceClient::from_credentials().await {
132        Ok(client) => Ok(client),
133        Err(_) => Ok(sz_rust_marketplace::client::MarketplaceClient::new(
134            DEFAULT_MARKET_URL,
135            None,
136        )),
137    }
138}
139
140/// 搜索结果表格行
141#[derive(Tabled)]
142struct PluginRow {
143    name: String,
144    title: String,
145    author: String,
146    tags: String,
147    price: String,
148}
149
150async fn execute_search(args: &SearchArgs) -> Result<i32, CliError> {
151    let client = create_client().await?;
152
153    let tag = args.tags.first().map(|s| s.as_str());
154    let offset = ((args.page - 1) * args.page_size) as i64;
155
156    let response = client
157        .search(&args.keyword, tag, args.page_size as i64, offset)
158        .await
159        .map_err(|e| CliError::Marketplace(e.to_string()))?;
160
161    if response.plugins.is_empty() {
162        println!("未找到匹配插件");
163        return Ok(0);
164    }
165
166    let rows: Vec<PluginRow> = response
167        .plugins
168        .iter()
169        .map(|p| PluginRow {
170            name: p.name.clone(),
171            title: p.title.clone(),
172            author: p.author.clone(),
173            tags: p.tags.join(", "),
174            price: if p.price == 0.0 {
175                "免费".to_string()
176            } else {
177                format!("¥{:.2}", p.price)
178            },
179        })
180        .collect();
181
182    let table = Table::new(rows);
183    println!("{table}");
184    println!("\n共 {} 个插件", response.total);
185
186    Ok(0)
187}
188
189async fn execute_install(args: &InstallArgs) -> Result<i32, CliError> {
190    let (name, version) = parse_identifier(&args.identifier);
191    let client = create_client().await?;
192
193    let pb = ProgressBar::new_spinner();
194    pb.set_style(
195        ProgressStyle::with_template("{spinner} [{elapsed}] {msg}")
196            .unwrap_or_else(|_| ProgressStyle::default_spinner()),
197    );
198    pb.set_message(format!("下载 {name}@{version}..."));
199
200    let archive = client
201        .install(name, version)
202        .await
203        .map_err(|e| CliError::Marketplace(e.to_string()))?;
204
205    pb.finish_with_message(format!("已下载 {name}@{version} ({} bytes)", archive.len()));
206    println!("插件 {name}@{version} 安装成功");
207
208    Ok(0)
209}
210
211async fn execute_publish(args: &PublishArgs) -> Result<i32, CliError> {
212    let client = create_client().await?;
213
214    let pb = ProgressBar::new_spinner();
215    pb.set_style(
216        ProgressStyle::with_template("{spinner} [{elapsed}] {msg}")
217            .unwrap_or_else(|_| ProgressStyle::default_spinner()),
218    );
219    pb.set_message("发布中...".to_string());
220
221    client
222        .publish(&args.path)
223        .await
224        .map_err(|e| CliError::Marketplace(e.to_string()))?;
225
226    pb.finish_with_message("发布请求已提交");
227    println!("插件已发布,等待审核");
228
229    Ok(0)
230}
231
232async fn execute_uninstall(args: &UninstallArgs) -> Result<i32, CliError> {
233    let (name, _) = parse_identifier(&args.identifier);
234    let client = create_client().await?;
235
236    client
237        .uninstall(name)
238        .await
239        .map_err(|e| CliError::Marketplace(e.to_string()))?;
240
241    println!("插件 {name} 已卸载");
242    Ok(0)
243}
244
245async fn execute_update(args: &UpdateArgs) -> Result<i32, CliError> {
246    let (name, _) = parse_identifier(&args.identifier);
247    let client = create_client().await?;
248
249    let pb = ProgressBar::new_spinner();
250    pb.set_style(
251        ProgressStyle::with_template("{spinner} [{elapsed}] {msg}")
252            .unwrap_or_else(|_| ProgressStyle::default_spinner()),
253    );
254    pb.set_message(format!("更新 {name}..."));
255
256    let archive = client
257        .update(name)
258        .await
259        .map_err(|e| CliError::Marketplace(e.to_string()))?;
260
261    pb.finish_with_message(format!("已更新 {name} ({} bytes)", archive.len()));
262    println!("插件 {name} 更新成功");
263
264    Ok(0)
265}
266
267async fn execute_list() -> Result<i32, CliError> {
268    let client = create_client().await?;
269
270    let entries = client
271        .list()
272        .await
273        .map_err(|e| CliError::Marketplace(e.to_string()))?;
274
275    if entries.is_empty() {
276        println!("未安装任何插件");
277        return Ok(0);
278    }
279
280    #[derive(Tabled)]
281    struct InstalledRow {
282        name: String,
283        version: String,
284        sha256: String,
285        installed_at: String,
286    }
287
288    let rows: Vec<InstalledRow> = entries
289        .iter()
290        .map(|e| InstalledRow {
291            name: e.name.clone(),
292            version: e.version.clone(),
293            sha256: e.sha256.chars().take(16).collect(),
294            installed_at: e.installed_at.format("%Y-%m-%d %H:%M").to_string(),
295        })
296        .collect();
297
298    let table = Table::new(rows);
299    println!("{table}");
300    println!("\n共 {} 个已安装插件", entries.len());
301
302    Ok(0)
303}
304
305async fn execute_login(args: &LoginArgs) -> Result<i32, CliError> {
306    let url = args.url.as_deref().unwrap_or(DEFAULT_MARKET_URL);
307
308    if let Some(token) = &args.token {
309        let client = sz_rust_marketplace::client::MarketplaceClient::new(url, None);
310        client
311            .save_token(token)
312            .await
313            .map_err(|e| CliError::Marketplace(e.to_string()))?;
314        println!("登录成功(token 已保存)");
315        Ok(0)
316    } else {
317        println!("请提供 --token 参数");
318        Ok(1)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[tokio::test]
327    async fn test_execute_list_no_lock() {
328        let result = execute_list().await;
329        assert!(result.is_ok());
330    }
331
332    #[tokio::test]
333    async fn test_execute_login_with_token() {
334        let args = LoginArgs {
335            token: Some("test".into()),
336            url: Some("http://localhost:9999".into()),
337        };
338        let result = execute_login(&args).await;
339        assert!(result.is_ok());
340    }
341
342    #[tokio::test]
343    async fn test_execute_login_without_token() {
344        let args = LoginArgs {
345            token: None,
346            url: None,
347        };
348        let result = execute_login(&args).await;
349        assert!(result.is_ok());
350        assert_eq!(result.unwrap(), 1);
351    }
352
353    #[test]
354    fn test_parse_identifier_with_version() {
355        let (name, version) = parse_identifier("orm@1.0.0");
356        assert_eq!(name, "orm");
357        assert_eq!(version, "1.0.0");
358    }
359
360    #[test]
361    fn test_parse_identifier_without_version() {
362        let (name, version) = parse_identifier("orm");
363        assert_eq!(name, "orm");
364        assert_eq!(version, "latest");
365    }
366
367    #[test]
368    fn test_plugin_row_tabled() {
369        let row = PluginRow {
370            name: "crm".to_string(),
371            title: "CRM".to_string(),
372            author: "alice".to_string(),
373            tags: "business".to_string(),
374            price: "免费".to_string(),
375        };
376        let table = Table::new(vec![row]);
377        let s = table.to_string();
378        assert!(s.contains("crm"));
379        assert!(s.contains("CRM"));
380    }
381}