Skip to main content

aster/search/
ripgrep.rs

1//! Ripgrep 集成
2//!
3//! 提供内置的 ripgrep 二进制文件支持
4
5#![allow(clippy::items_after_test_module)]
6
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use tokio::process::Command as AsyncCommand;
11
12/// Ripgrep 版本
13pub const RG_VERSION: &str = "14.1.0";
14
15/// Ripgrep 搜索选项
16#[derive(Debug, Clone, Default)]
17pub struct RipgrepOptions {
18    /// 工作目录
19    pub cwd: Option<PathBuf>,
20    /// 搜索模式
21    pub pattern: String,
22    /// 搜索路径
23    pub paths: Vec<PathBuf>,
24    /// Glob 模式
25    pub glob: Option<String>,
26    /// 文件类型
27    pub file_type: Option<String>,
28    /// 忽略大小写
29    pub ignore_case: bool,
30    /// 固定字符串搜索
31    pub fixed_strings: bool,
32    /// 最大匹配数
33    pub max_count: Option<u32>,
34    /// 上下文行数
35    pub context: Option<u32>,
36    /// 前置上下文行数
37    pub before_context: Option<u32>,
38    /// 后置上下文行数
39    pub after_context: Option<u32>,
40    /// 只返回匹配的文件名
41    pub files_with_matches: bool,
42    /// 只返回匹配数量
43    pub count: bool,
44    /// JSON 输出
45    pub json: bool,
46    /// 不使用 ignore 文件
47    pub no_ignore: bool,
48    /// 搜索隐藏文件
49    pub hidden: bool,
50    /// 多行模式
51    pub multiline: bool,
52    /// 超时(毫秒)
53    pub timeout_ms: Option<u64>,
54}
55
56/// Ripgrep 匹配结果
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RipgrepMatch {
59    /// 文件路径
60    pub path: String,
61    /// 行号
62    pub line_number: u32,
63    /// 行内容
64    pub line_content: String,
65    /// 匹配开始位置
66    pub match_start: u32,
67    /// 匹配结束位置
68    pub match_end: u32,
69}
70
71/// Ripgrep 搜索结果
72#[derive(Debug, Clone, Default)]
73pub struct RipgrepResult {
74    /// 匹配列表
75    pub matches: Vec<RipgrepMatch>,
76    /// 搜索的文件数
77    pub files_searched: usize,
78    /// 匹配数量
79    pub match_count: usize,
80    /// 是否被截断
81    pub truncated: bool,
82}
83
84/// 获取系统 ripgrep 路径
85pub fn get_system_rg_path() -> Option<PathBuf> {
86    // 尝试 which/where 命令
87    let output = if cfg!(windows) {
88        Command::new("where").arg("rg").output()
89    } else {
90        Command::new("which").arg("rg").output()
91    };
92
93    output
94        .ok()
95        .filter(|o| o.status.success())
96        .and_then(|o| String::from_utf8(o.stdout).ok())
97        .map(|s| PathBuf::from(s.trim().lines().next().unwrap_or("")))
98        .filter(|p| p.exists())
99}
100
101/// 获取 vendored ripgrep 路径
102pub fn get_vendored_rg_path() -> Option<PathBuf> {
103    let home = dirs::home_dir()?;
104    let binary_name = if cfg!(windows) { "rg.exe" } else { "rg" };
105
106    // 检查多个可能的位置
107    let possible_paths = [
108        home.join(".aster").join("bin").join(binary_name),
109        home.join(".local").join("bin").join(binary_name),
110    ];
111
112    possible_paths.into_iter().find(|p| p.exists())
113}
114
115/// 获取可用的 ripgrep 路径
116pub fn get_rg_path() -> Option<PathBuf> {
117    // 检查环境变量
118    if std::env::var("USE_BUILTIN_RIPGREP")
119        .map(|v| v == "1" || v == "true")
120        .unwrap_or(false)
121    {
122        if let Some(path) = get_system_rg_path() {
123            return Some(path);
124        }
125        return get_vendored_rg_path();
126    }
127
128    // 默认优先使用 vendored 版本
129    get_vendored_rg_path().or_else(get_system_rg_path)
130}
131
132/// 检查 ripgrep 是否可用
133pub fn is_ripgrep_available() -> bool {
134    get_rg_path().is_some()
135}
136
137/// 获取 ripgrep 版本
138pub fn get_ripgrep_version() -> Option<String> {
139    let rg_path = get_rg_path()?;
140
141    let output = Command::new(&rg_path).arg("--version").output().ok()?;
142
143    let version_str = String::from_utf8(output.stdout).ok()?;
144
145    // 解析版本号 "ripgrep X.Y.Z"
146    version_str
147        .lines()
148        .next()
149        .and_then(|line| line.split_whitespace().nth(1))
150        .map(|v| v.to_string())
151}
152
153/// 构建 ripgrep 命令参数
154fn build_rg_args(options: &RipgrepOptions) -> Vec<String> {
155    let mut args = Vec::new();
156
157    // 基本模式
158    if options.fixed_strings {
159        args.push("-F".to_string());
160    }
161
162    if options.ignore_case {
163        args.push("-i".to_string());
164    }
165
166    if options.multiline {
167        args.push("-U".to_string());
168        args.push("--multiline-dotall".to_string());
169    }
170
171    // 输出格式
172    if options.json {
173        args.push("--json".to_string());
174    } else {
175        args.push("--line-number".to_string());
176        args.push("--column".to_string());
177    }
178
179    // 过滤
180    if let Some(ref glob) = options.glob {
181        args.push("--glob".to_string());
182        args.push(glob.clone());
183    }
184
185    if let Some(ref file_type) = options.file_type {
186        args.push("--type".to_string());
187        args.push(file_type.clone());
188    }
189
190    if options.no_ignore {
191        args.push("--no-ignore".to_string());
192    }
193
194    if options.hidden {
195        args.push("--hidden".to_string());
196    }
197
198    // 输出限制
199    if let Some(max) = options.max_count {
200        args.push("--max-count".to_string());
201        args.push(max.to_string());
202    }
203
204    if options.files_with_matches {
205        args.push("--files-with-matches".to_string());
206    }
207
208    if options.count {
209        args.push("--count".to_string());
210    }
211
212    // 上下文
213    if let Some(ctx) = options.context {
214        args.push("-C".to_string());
215        args.push(ctx.to_string());
216    } else {
217        if let Some(before) = options.before_context {
218            args.push("-B".to_string());
219            args.push(before.to_string());
220        }
221        if let Some(after) = options.after_context {
222            args.push("-A".to_string());
223            args.push(after.to_string());
224        }
225    }
226
227    // 搜索模式
228    args.push("--".to_string());
229    args.push(options.pattern.clone());
230
231    // 搜索路径
232    if options.paths.is_empty() {
233        args.push(".".to_string());
234    } else {
235        for path in &options.paths {
236            args.push(path.display().to_string());
237        }
238    }
239
240    args
241}
242
243/// 异步执行 ripgrep 搜索
244pub async fn search(options: RipgrepOptions) -> Result<RipgrepResult, String> {
245    let rg_path = get_rg_path().ok_or("ripgrep 不可用")?;
246
247    let mut search_options = options.clone();
248    search_options.json = true;
249
250    let args = build_rg_args(&search_options);
251    let cwd = options
252        .cwd
253        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
254
255    let output = AsyncCommand::new(&rg_path)
256        .args(&args)
257        .current_dir(&cwd)
258        .stdout(Stdio::piped())
259        .stderr(Stdio::piped())
260        .output()
261        .await
262        .map_err(|e| format!("执行 ripgrep 失败: {}", e))?;
263
264    // ripgrep 返回 1 表示没有匹配,不是错误
265    if !output.status.success() && output.status.code() != Some(1) {
266        let stderr = String::from_utf8_lossy(&output.stderr);
267        return Err(format!("ripgrep 错误: {}", stderr));
268    }
269
270    let stdout = String::from_utf8_lossy(&output.stdout);
271    parse_json_output(&stdout)
272}
273
274/// 同步执行 ripgrep 搜索
275pub fn search_sync(options: RipgrepOptions) -> Result<String, String> {
276    let rg_path = get_rg_path().ok_or("ripgrep 不可用")?;
277
278    let args = build_rg_args(&options);
279    let cwd = options
280        .cwd
281        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
282
283    let output = Command::new(&rg_path)
284        .args(&args)
285        .current_dir(&cwd)
286        .output()
287        .map_err(|e| format!("执行 ripgrep 失败: {}", e))?;
288
289    if !output.status.success() && output.status.code() != Some(1) {
290        let stderr = String::from_utf8_lossy(&output.stderr);
291        return Err(format!("ripgrep 错误: {}", stderr));
292    }
293
294    Ok(String::from_utf8_lossy(&output.stdout).to_string())
295}
296
297/// 解析 JSON 输出
298fn parse_json_output(output: &str) -> Result<RipgrepResult, String> {
299    let mut matches = Vec::new();
300    let mut files = std::collections::HashSet::new();
301    let mut match_count = 0;
302
303    for line in output.lines().filter(|l| !l.is_empty()) {
304        if let Ok(obj) = serde_json::from_str::<serde_json::Value>(line) {
305            if obj.get("type").and_then(|t| t.as_str()) == Some("match") {
306                if let Some(data) = obj.get("data") {
307                    let path = data
308                        .get("path")
309                        .and_then(|p| p.get("text"))
310                        .and_then(|t| t.as_str())
311                        .unwrap_or("");
312
313                    files.insert(path.to_string());
314
315                    let line_number = data
316                        .get("line_number")
317                        .and_then(|n| n.as_u64())
318                        .unwrap_or(0) as u32;
319
320                    let line_content = data
321                        .get("lines")
322                        .and_then(|l| l.get("text"))
323                        .and_then(|t| t.as_str())
324                        .unwrap_or("")
325                        .trim_end_matches('\n');
326
327                    if let Some(submatches) = data.get("submatches").and_then(|s| s.as_array()) {
328                        for submatch in submatches {
329                            let start =
330                                submatch.get("start").and_then(|s| s.as_u64()).unwrap_or(0) as u32;
331                            let end =
332                                submatch.get("end").and_then(|e| e.as_u64()).unwrap_or(0) as u32;
333
334                            matches.push(RipgrepMatch {
335                                path: path.to_string(),
336                                line_number,
337                                line_content: line_content.to_string(),
338                                match_start: start,
339                                match_end: end,
340                            });
341                            match_count += 1;
342                        }
343                    }
344                }
345            }
346        }
347    }
348
349    Ok(RipgrepResult {
350        matches,
351        files_searched: files.len(),
352        match_count,
353        truncated: false,
354    })
355}
356
357/// 列出文件(使用 rg --files)
358pub async fn list_files(
359    cwd: Option<PathBuf>,
360    glob: Option<&str>,
361    file_type: Option<&str>,
362    hidden: bool,
363    no_ignore: bool,
364) -> Result<Vec<String>, String> {
365    let rg_path = get_rg_path().ok_or("ripgrep 不可用")?;
366
367    let mut args = vec!["--files".to_string()];
368
369    if let Some(g) = glob {
370        args.push("--glob".to_string());
371        args.push(g.to_string());
372    }
373
374    if let Some(t) = file_type {
375        args.push("--type".to_string());
376        args.push(t.to_string());
377    }
378
379    if hidden {
380        args.push("--hidden".to_string());
381    }
382
383    if no_ignore {
384        args.push("--no-ignore".to_string());
385    }
386
387    let working_dir = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
388
389    let output = AsyncCommand::new(&rg_path)
390        .args(&args)
391        .current_dir(&working_dir)
392        .output()
393        .await
394        .map_err(|e| format!("执行 ripgrep 失败: {}", e))?;
395
396    if !output.status.success() && output.status.code() != Some(1) {
397        let stderr = String::from_utf8_lossy(&output.stderr);
398        return Err(format!("ripgrep 错误: {}", stderr));
399    }
400
401    let stdout = String::from_utf8_lossy(&output.stdout);
402    Ok(stdout
403        .lines()
404        .filter(|l| !l.is_empty())
405        .map(|s| s.to_string())
406        .collect())
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn test_build_rg_args_basic() {
415        let options = RipgrepOptions {
416            pattern: "test".to_string(),
417            ..Default::default()
418        };
419
420        let args = build_rg_args(&options);
421        assert!(args.contains(&"--line-number".to_string()));
422        assert!(args.contains(&"test".to_string()));
423    }
424
425    #[test]
426    fn test_build_rg_args_with_options() {
427        let options = RipgrepOptions {
428            pattern: "test".to_string(),
429            ignore_case: true,
430            hidden: true,
431            glob: Some("*.rs".to_string()),
432            ..Default::default()
433        };
434
435        let args = build_rg_args(&options);
436        assert!(args.contains(&"-i".to_string()));
437        assert!(args.contains(&"--hidden".to_string()));
438        assert!(args.contains(&"--glob".to_string()));
439        assert!(args.contains(&"*.rs".to_string()));
440    }
441
442    #[test]
443    fn test_build_rg_args_fixed_strings() {
444        let options = RipgrepOptions {
445            pattern: "test.pattern".to_string(),
446            fixed_strings: true,
447            ..Default::default()
448        };
449
450        let args = build_rg_args(&options);
451        assert!(args.contains(&"-F".to_string()));
452    }
453
454    #[test]
455    fn test_build_rg_args_multiline() {
456        let options = RipgrepOptions {
457            pattern: "test".to_string(),
458            multiline: true,
459            ..Default::default()
460        };
461
462        let args = build_rg_args(&options);
463        assert!(args.contains(&"-U".to_string()));
464        assert!(args.contains(&"--multiline-dotall".to_string()));
465    }
466
467    #[test]
468    fn test_build_rg_args_context() {
469        let options = RipgrepOptions {
470            pattern: "test".to_string(),
471            context: Some(3),
472            ..Default::default()
473        };
474
475        let args = build_rg_args(&options);
476        assert!(args.contains(&"-C".to_string()));
477        assert!(args.contains(&"3".to_string()));
478    }
479
480    #[test]
481    fn test_build_rg_args_before_after_context() {
482        let options = RipgrepOptions {
483            pattern: "test".to_string(),
484            before_context: Some(2),
485            after_context: Some(4),
486            ..Default::default()
487        };
488
489        let args = build_rg_args(&options);
490        assert!(args.contains(&"-B".to_string()));
491        assert!(args.contains(&"2".to_string()));
492        assert!(args.contains(&"-A".to_string()));
493        assert!(args.contains(&"4".to_string()));
494    }
495
496    #[test]
497    fn test_build_rg_args_max_count() {
498        let options = RipgrepOptions {
499            pattern: "test".to_string(),
500            max_count: Some(10),
501            ..Default::default()
502        };
503
504        let args = build_rg_args(&options);
505        assert!(args.contains(&"--max-count".to_string()));
506        assert!(args.contains(&"10".to_string()));
507    }
508
509    #[test]
510    fn test_build_rg_args_files_with_matches() {
511        let options = RipgrepOptions {
512            pattern: "test".to_string(),
513            files_with_matches: true,
514            ..Default::default()
515        };
516
517        let args = build_rg_args(&options);
518        assert!(args.contains(&"--files-with-matches".to_string()));
519    }
520
521    #[test]
522    fn test_build_rg_args_count() {
523        let options = RipgrepOptions {
524            pattern: "test".to_string(),
525            count: true,
526            ..Default::default()
527        };
528
529        let args = build_rg_args(&options);
530        assert!(args.contains(&"--count".to_string()));
531    }
532
533    #[test]
534    fn test_build_rg_args_no_ignore() {
535        let options = RipgrepOptions {
536            pattern: "test".to_string(),
537            no_ignore: true,
538            ..Default::default()
539        };
540
541        let args = build_rg_args(&options);
542        assert!(args.contains(&"--no-ignore".to_string()));
543    }
544
545    #[test]
546    fn test_build_rg_args_file_type() {
547        let options = RipgrepOptions {
548            pattern: "test".to_string(),
549            file_type: Some("rust".to_string()),
550            ..Default::default()
551        };
552
553        let args = build_rg_args(&options);
554        assert!(args.contains(&"--type".to_string()));
555        assert!(args.contains(&"rust".to_string()));
556    }
557
558    #[test]
559    fn test_build_rg_args_json() {
560        let options = RipgrepOptions {
561            pattern: "test".to_string(),
562            json: true,
563            ..Default::default()
564        };
565
566        let args = build_rg_args(&options);
567        assert!(args.contains(&"--json".to_string()));
568        assert!(!args.contains(&"--line-number".to_string()));
569    }
570
571    #[test]
572    fn test_build_rg_args_with_paths() {
573        let options = RipgrepOptions {
574            pattern: "test".to_string(),
575            paths: vec![PathBuf::from("src"), PathBuf::from("tests")],
576            ..Default::default()
577        };
578
579        let args = build_rg_args(&options);
580        assert!(args.contains(&"src".to_string()));
581        assert!(args.contains(&"tests".to_string()));
582        assert!(!args.contains(&".".to_string()));
583    }
584
585    #[test]
586    fn test_is_ripgrep_available() {
587        // 这个测试依赖于系统是否安装了 ripgrep
588        let available = is_ripgrep_available();
589        println!("ripgrep available: {}", available);
590    }
591
592    #[test]
593    fn test_get_ripgrep_version() {
594        if is_ripgrep_available() {
595            let version = get_ripgrep_version();
596            assert!(version.is_some());
597            println!("ripgrep version: {:?}", version);
598        }
599    }
600
601    #[test]
602    fn test_parse_json_output() {
603        let json = r#"{"type":"match","data":{"path":{"text":"test.rs"},"lines":{"text":"fn test() {}\n"},"line_number":1,"submatches":[{"match":{"text":"test"},"start":3,"end":7}]}}"#;
604
605        let result = parse_json_output(json).unwrap();
606        assert_eq!(result.matches.len(), 1);
607        assert_eq!(result.matches[0].path, "test.rs");
608        assert_eq!(result.matches[0].line_number, 1);
609        assert_eq!(result.matches[0].match_start, 3);
610        assert_eq!(result.matches[0].match_end, 7);
611    }
612
613    #[test]
614    fn test_parse_json_output_multiple_matches() {
615        let json = r#"{"type":"match","data":{"path":{"text":"test.rs"},"lines":{"text":"test test test\n"},"line_number":1,"submatches":[{"match":{"text":"test"},"start":0,"end":4},{"match":{"text":"test"},"start":5,"end":9}]}}
616{"type":"match","data":{"path":{"text":"test.rs"},"lines":{"text":"another test\n"},"line_number":2,"submatches":[{"match":{"text":"test"},"start":8,"end":12}]}}"#;
617
618        let result = parse_json_output(json).unwrap();
619        assert_eq!(result.matches.len(), 3);
620        assert_eq!(result.match_count, 3);
621        assert_eq!(result.files_searched, 1);
622    }
623
624    #[test]
625    fn test_parse_json_output_multiple_files() {
626        let json = r#"{"type":"match","data":{"path":{"text":"file1.rs"},"lines":{"text":"test\n"},"line_number":1,"submatches":[{"match":{"text":"test"},"start":0,"end":4}]}}
627{"type":"match","data":{"path":{"text":"file2.rs"},"lines":{"text":"test\n"},"line_number":1,"submatches":[{"match":{"text":"test"},"start":0,"end":4}]}}"#;
628
629        let result = parse_json_output(json).unwrap();
630        assert_eq!(result.matches.len(), 2);
631        assert_eq!(result.files_searched, 2);
632    }
633
634    #[test]
635    fn test_parse_json_output_empty() {
636        let result = parse_json_output("").unwrap();
637        assert!(result.matches.is_empty());
638        assert_eq!(result.files_searched, 0);
639        assert_eq!(result.match_count, 0);
640    }
641
642    #[test]
643    fn test_parse_json_output_invalid_json() {
644        let result = parse_json_output("not json at all");
645        assert!(result.is_ok());
646        assert!(result.unwrap().matches.is_empty());
647    }
648
649    #[test]
650    fn test_parse_json_output_non_match_type() {
651        let json = r#"{"type":"begin","data":{"path":{"text":"test.rs"}}}
652{"type":"end","data":{"path":{"text":"test.rs"}}}"#;
653
654        let result = parse_json_output(json).unwrap();
655        assert!(result.matches.is_empty());
656    }
657
658    #[test]
659    fn test_ripgrep_options_default() {
660        let options = RipgrepOptions::default();
661        assert!(options.pattern.is_empty());
662        assert!(options.paths.is_empty());
663        assert!(!options.ignore_case);
664        assert!(!options.hidden);
665        assert!(!options.json);
666    }
667
668    #[test]
669    fn test_ripgrep_result_default() {
670        let result = RipgrepResult::default();
671        assert!(result.matches.is_empty());
672        assert_eq!(result.files_searched, 0);
673        assert_eq!(result.match_count, 0);
674        assert!(!result.truncated);
675    }
676
677    #[test]
678    fn test_get_platform_binary_name() {
679        let name = get_platform_binary_name();
680        // 应该在支持的平台上返回 Some
681        #[cfg(any(
682            all(target_os = "macos", target_arch = "x86_64"),
683            all(target_os = "macos", target_arch = "aarch64"),
684            all(target_os = "linux", target_arch = "x86_64"),
685            all(target_os = "linux", target_arch = "aarch64"),
686            all(target_os = "windows", target_arch = "x86_64"),
687        ))]
688        assert!(name.is_some());
689    }
690
691    #[test]
692    fn test_get_download_url() {
693        let url = get_download_url();
694        if let Some(u) = url {
695            assert!(u.contains("ripgrep"));
696            assert!(u.contains(RG_VERSION));
697        }
698    }
699
700    #[tokio::test]
701    async fn test_search_with_ripgrep() {
702        if !is_ripgrep_available() {
703            println!("跳过测试:ripgrep 不可用");
704            return;
705        }
706
707        let options = RipgrepOptions {
708            pattern: "fn ".to_string(),
709            cwd: Some(std::env::current_dir().unwrap()),
710            glob: Some("*.rs".to_string()),
711            max_count: Some(5),
712            ..Default::default()
713        };
714
715        let result = search(options).await;
716        // 应该能成功执行(可能有或没有匹配)
717        assert!(result.is_ok());
718    }
719
720    #[test]
721    fn test_search_sync_with_ripgrep() {
722        if !is_ripgrep_available() {
723            println!("跳过测试:ripgrep 不可用");
724            return;
725        }
726
727        let options = RipgrepOptions {
728            pattern: "fn ".to_string(),
729            cwd: Some(std::env::current_dir().unwrap()),
730            glob: Some("*.rs".to_string()),
731            max_count: Some(5),
732            ..Default::default()
733        };
734
735        let result = search_sync(options);
736        assert!(result.is_ok());
737    }
738
739    #[tokio::test]
740    async fn test_list_files_with_ripgrep() {
741        if !is_ripgrep_available() {
742            println!("跳过测试:ripgrep 不可用");
743            return;
744        }
745
746        let result = list_files(
747            Some(std::env::current_dir().unwrap()),
748            Some("*.rs"),
749            None,
750            false,
751            false,
752        )
753        .await;
754
755        assert!(result.is_ok());
756    }
757}
758
759// ============ Vendored Ripgrep 下载 ============
760
761/// 平台到二进制名称的映射
762fn get_platform_binary_name() -> Option<&'static str> {
763    let os = std::env::consts::OS;
764    let arch = std::env::consts::ARCH;
765
766    match (os, arch) {
767        ("macos", "x86_64") => Some("rg-darwin-x64"),
768        ("macos", "aarch64") => Some("rg-darwin-arm64"),
769        ("linux", "x86_64") => Some("rg-linux-x64"),
770        ("linux", "aarch64") => Some("rg-linux-arm64"),
771        ("windows", "x86_64") => Some("rg-win32-x64.exe"),
772        _ => None,
773    }
774}
775
776/// 获取下载 URL
777fn get_download_url() -> Option<String> {
778    let os = std::env::consts::OS;
779    let arch = std::env::consts::ARCH;
780
781    let archive_name = match (os, arch) {
782        ("windows", "x86_64") => format!("ripgrep-{}-x86_64-pc-windows-msvc.zip", RG_VERSION),
783        ("macos", "x86_64") => format!("ripgrep-{}-x86_64-apple-darwin.tar.gz", RG_VERSION),
784        ("macos", "aarch64") => format!("ripgrep-{}-aarch64-apple-darwin.tar.gz", RG_VERSION),
785        ("linux", "x86_64") => format!("ripgrep-{}-x86_64-unknown-linux-musl.tar.gz", RG_VERSION),
786        ("linux", "aarch64") => format!("ripgrep-{}-aarch64-unknown-linux-gnu.tar.gz", RG_VERSION),
787        _ => return None,
788    };
789
790    Some(format!(
791        "https://github.com/BurntSushi/ripgrep/releases/download/{}/{}",
792        RG_VERSION, archive_name
793    ))
794}
795
796/// 下载 vendored ripgrep
797#[allow(unexpected_cfgs)]
798pub async fn download_vendored_rg(target_dir: &Path) -> Result<PathBuf, String> {
799    let binary_name = get_platform_binary_name().ok_or("不支持的平台")?;
800    let download_url = get_download_url().ok_or("无法获取下载 URL")?;
801
802    // 确保目录存在
803    std::fs::create_dir_all(target_dir).map_err(|e| format!("创建目录失败: {}", e))?;
804
805    let target_path = target_dir.join(binary_name);
806
807    tracing::info!("下载 ripgrep: {} -> {:?}", download_url, target_path);
808
809    // 使用 reqwest 下载(如果可用)或回退到 curl
810    #[cfg(feature = "http")]
811    {
812        let response = reqwest::get(&download_url)
813            .await
814            .map_err(|e| format!("下载失败: {}", e))?;
815
816        let bytes = response
817            .bytes()
818            .await
819            .map_err(|e| format!("读取响应失败: {}", e))?;
820
821        // 解压并保存
822        // 简化实现:假设已经是二进制文件
823        std::fs::write(&target_path, &bytes).map_err(|e| format!("写入文件失败: {}", e))?;
824    }
825
826    #[cfg(not(feature = "http"))]
827    {
828        // 使用 curl 下载
829        let temp_file = std::env::temp_dir().join("rg_download.tar.gz");
830
831        let status = Command::new("curl")
832            .args(["-L", "-o"])
833            .arg(&temp_file)
834            .arg(&download_url)
835            .status()
836            .map_err(|e| format!("执行 curl 失败: {}", e))?;
837
838        if !status.success() {
839            return Err("curl 下载失败".to_string());
840        }
841
842        // 解压
843        let status = Command::new("tar")
844            .args(["-xzf"])
845            .arg(&temp_file)
846            .arg("-C")
847            .arg(target_dir)
848            .arg("--strip-components=1")
849            .status()
850            .map_err(|e| format!("解压失败: {}", e))?;
851
852        if !status.success() {
853            return Err("解压失败".to_string());
854        }
855
856        // 清理临时文件
857        let _ = std::fs::remove_file(&temp_file);
858
859        // 重命名
860        let extracted = target_dir.join("rg");
861        if extracted.exists() && extracted != target_path {
862            std::fs::rename(&extracted, &target_path).map_err(|e| format!("重命名失败: {}", e))?;
863        }
864
865        // 设置执行权限
866        #[cfg(unix)]
867        {
868            use std::os::unix::fs::PermissionsExt;
869            let mut perms = std::fs::metadata(&target_path)
870                .map_err(|e| format!("获取权限失败: {}", e))?
871                .permissions();
872            perms.set_mode(0o755);
873            std::fs::set_permissions(&target_path, perms)
874                .map_err(|e| format!("设置权限失败: {}", e))?;
875        }
876    }
877
878    tracing::info!("ripgrep 已安装到 {:?}", target_path);
879    Ok(target_path)
880}
881
882/// 确保 ripgrep 可用(如果不可用则下载)
883pub async fn ensure_ripgrep_available() -> Result<PathBuf, String> {
884    if let Some(path) = get_rg_path() {
885        return Ok(path);
886    }
887
888    // 下载到默认位置
889    let target_dir = dirs::home_dir()
890        .ok_or("无法获取 home 目录")?
891        .join(".aster")
892        .join("bin");
893
894    download_vendored_rg(&target_dir).await
895}