1#![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
12pub const RG_VERSION: &str = "14.1.0";
14
15#[derive(Debug, Clone, Default)]
17pub struct RipgrepOptions {
18 pub cwd: Option<PathBuf>,
20 pub pattern: String,
22 pub paths: Vec<PathBuf>,
24 pub glob: Option<String>,
26 pub file_type: Option<String>,
28 pub ignore_case: bool,
30 pub fixed_strings: bool,
32 pub max_count: Option<u32>,
34 pub context: Option<u32>,
36 pub before_context: Option<u32>,
38 pub after_context: Option<u32>,
40 pub files_with_matches: bool,
42 pub count: bool,
44 pub json: bool,
46 pub no_ignore: bool,
48 pub hidden: bool,
50 pub multiline: bool,
52 pub timeout_ms: Option<u64>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RipgrepMatch {
59 pub path: String,
61 pub line_number: u32,
63 pub line_content: String,
65 pub match_start: u32,
67 pub match_end: u32,
69}
70
71#[derive(Debug, Clone, Default)]
73pub struct RipgrepResult {
74 pub matches: Vec<RipgrepMatch>,
76 pub files_searched: usize,
78 pub match_count: usize,
80 pub truncated: bool,
82}
83
84pub fn get_system_rg_path() -> Option<PathBuf> {
86 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
101pub 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 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
115pub fn get_rg_path() -> Option<PathBuf> {
117 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 get_vendored_rg_path().or_else(get_system_rg_path)
130}
131
132pub fn is_ripgrep_available() -> bool {
134 get_rg_path().is_some()
135}
136
137pub 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 version_str
147 .lines()
148 .next()
149 .and_then(|line| line.split_whitespace().nth(1))
150 .map(|v| v.to_string())
151}
152
153fn build_rg_args(options: &RipgrepOptions) -> Vec<String> {
155 let mut args = Vec::new();
156
157 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 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 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 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 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 args.push("--".to_string());
229 args.push(options.pattern.clone());
230
231 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
243pub 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 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
274pub 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
297fn 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
357pub 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 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 #[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 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
759fn 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
776fn 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#[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 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 #[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 std::fs::write(&target_path, &bytes).map_err(|e| format!("写入文件失败: {}", e))?;
824 }
825
826 #[cfg(not(feature = "http"))]
827 {
828 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 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 let _ = std::fs::remove_file(&temp_file);
858
859 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 #[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
882pub async fn ensure_ripgrep_available() -> Result<PathBuf, String> {
884 if let Some(path) = get_rg_path() {
885 return Ok(path);
886 }
887
888 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}