1use anyhow::{Context, Result};
2use colored::*;
3use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
4use rayon::prelude::*;
5use rayon::ThreadPoolBuilder;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::env;
9use std::fs;
10use std::io::{self, IsTerminal, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::sync::atomic::{AtomicUsize, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16
17fn get_shell_and_flag() -> (String, &'static str) {
21 #[cfg(windows)]
22 {
23 (
24 env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()),
25 "/c",
26 )
27 }
28 #[cfg(not(windows))]
29 {
30 (
31 env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
32 "-c",
33 )
34 }
35}
36
37#[derive(Debug, Clone, Deserialize, Serialize)]
38pub struct LoopConfig {
39 #[serde(default)]
40 pub directories: Vec<String>,
41 #[serde(default)]
42 pub ignore: Vec<String>,
43 #[serde(default)]
44 pub verbose: bool,
45 #[serde(default)]
46 pub silent: bool,
47 #[serde(default)]
48 pub add_aliases_to_global_looprc: bool,
49 #[serde(default)]
50 pub include_filters: Option<Vec<String>>,
51 #[serde(default)]
52 pub exclude_filters: Option<Vec<String>>,
53 #[serde(default)]
54 pub parallel: bool,
55 #[serde(default)]
56 pub dry_run: bool,
57 #[serde(default)]
58 pub json_output: bool,
59 #[serde(default)]
62 pub spawn_stagger_ms: u64,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub env: Option<HashMap<String, String>>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub max_parallel: Option<usize>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub root_dir: Option<PathBuf>,
78}
79
80#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct DirCommand {
83 pub dir: String,
84 pub cmd: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub env: Option<HashMap<String, String>>,
88}
89
90impl Default for LoopConfig {
91 fn default() -> Self {
92 LoopConfig {
93 directories: vec![],
94 ignore: vec![".git".to_string()],
95 verbose: false,
96 silent: false,
97 add_aliases_to_global_looprc: false,
98 include_filters: None,
99 exclude_filters: None,
100 parallel: false,
101 dry_run: false,
102 json_output: false,
103 spawn_stagger_ms: 0,
104 env: None,
105 max_parallel: None,
106 root_dir: None,
107 }
108 }
109}
110
111#[derive(Default)]
112pub struct CommandResult {
113 pub success: bool,
114 pub exit_code: i32,
115 pub directory: PathBuf,
116 pub command: String,
117 pub stdout: String,
118 pub stderr: String,
119}
120
121pub fn load_aliases_from_file(path: &Path) -> Result<HashMap<String, String>> {
122 let content = fs::read_to_string(path)?;
123 let config: serde_json::Value = serde_json::from_str(&content)?;
124 let aliases = config["aliases"]
125 .as_object()
126 .ok_or_else(|| anyhow::anyhow!("No 'aliases' object found in config file"))?;
127 Ok(aliases
128 .iter()
129 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
130 .collect())
131}
132
133fn prompt_user(question: &str) -> Result<bool> {
134 print!("{question} [y/N]: ");
135 io::stdout().flush()?;
136 let mut input = String::new();
137 io::stdin().read_line(&mut input)?;
138 Ok(input.trim().to_lowercase() == "y")
139}
140
141pub fn add_aliases_to_global_looprc() -> Result<()> {
142 println!("Starting add_aliases_to_global_looprc function");
143
144 let home = env::var("HOME").context("Failed to get HOME directory")?;
145 let global_looprc = PathBuf::from(home).join(".looprc");
146 println!("Global .looprc path: {global_looprc:?}");
147
148 let mut aliases = HashMap::new();
149 let mut existing_content = String::new();
150
151 if global_looprc.exists() {
152 println!("Global .looprc exists, loading existing aliases");
153 existing_content = fs::read_to_string(&global_looprc)?;
154 aliases = load_aliases_from_file(&global_looprc)?;
155 } else {
156 println!("Global .looprc does not exist");
157 if !prompt_user("The global .looprc file does not exist. Do you want to create it?")? {
158 println!("Operation cancelled by user.");
159 return Ok(());
160 }
161 }
162
163 if !prompt_user("Do you want to set the value of the 'aliases' property?")? {
164 println!("Operation cancelled by user.");
165 return Ok(());
166 }
167
168 let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
169 println!("Using shell: {shell}");
170
171 println!("Executing 'alias' command");
172 let output = Command::new(&shell)
173 .arg("-i")
174 .arg("-c")
175 .arg("alias")
176 .output()?;
177
178 println!("Processing 'alias' command output");
179 let stdout = String::from_utf8_lossy(&output.stdout);
180 for line in stdout.lines() {
181 if let Some((alias, command)) = line.split_once('=') {
182 let alias = alias.trim().trim_start_matches("alias ").to_string();
183 let command = command
184 .trim()
185 .trim_matches('\'')
186 .trim_matches('"')
187 .to_string();
188 aliases.insert(alias, command);
189 }
190 }
191
192 println!("Creating config JSON");
193 let config = serde_json::json!({
194 "aliases": aliases
195 });
196
197 println!("Serializing config to string");
198 let new_content = serde_json::to_string_pretty(&config)?;
199
200 println!("\nPreview of changes:");
202 if !existing_content.is_empty() {
203 for diff in diff::lines(&existing_content, &new_content) {
204 match diff {
205 diff::Result::Left(l) => println!("{}", format!("-{l}").red()),
206 diff::Result::Both(l, _) => println!(" {l}"),
207 diff::Result::Right(r) => println!("{}", format!("+{r}").green()),
208 }
209 }
210 } else {
211 println!("{}", new_content.green());
212 }
213
214 if !prompt_user("Do you want to apply these changes?")? {
215 println!("Operation cancelled by user.");
216 return Ok(());
217 }
218
219 println!("Writing config to file");
220 fs::write(global_looprc, new_content)?;
221
222 println!("Aliases have been added to ~/.looprc");
223 Ok(())
224}
225
226pub fn execute_command_in_directory(
227 dir: &Path,
228 command: &str,
229 config: &LoopConfig,
230 aliases: &HashMap<String, String>,
231 extra_env: Option<&HashMap<String, String>>,
232) -> CommandResult {
233 if !dir.exists() {
234 println!("\nNo directory found for {}", dir.display());
235 let dir_name = dir.file_name().unwrap_or_default().to_str().unwrap();
236 println!(
237 "\x1b[31m\n✗ {}: No directory found. Command: {} (Exit code: {})\x1b[0m",
238 dir_name, command, 1
239 );
240 return CommandResult {
241 success: false,
242 exit_code: 1,
243 directory: dir.to_path_buf(),
244 command: command.to_string(),
245 stdout: String::new(),
246 stderr: String::new(),
247 };
248 }
249
250 let resolved_command = command
252 .split_whitespace()
253 .next()
254 .and_then(|cmd| aliases.get(cmd).map(|alias_cmd| (cmd, alias_cmd)))
255 .map(|(cmd, alias_cmd)| command.replacen(cmd, alias_cmd, 1))
256 .unwrap_or_else(|| command.to_string());
257
258 if config.dry_run {
260 let dir_display = if dir.as_os_str() == "." {
261 if let Ok(cwd) = std::env::current_dir() {
262 cwd.display().to_string()
263 } else {
264 ".".to_string()
265 }
266 } else {
267 dir.display().to_string()
268 };
269 println!(
270 "{} Would execute in {}:\n {}",
271 "[DRY RUN]".cyan(),
272 dir_display.yellow(),
273 resolved_command
274 );
275 return CommandResult {
276 success: true,
277 exit_code: 0,
278 directory: dir.to_path_buf(),
279 command: resolved_command,
280 stdout: String::new(),
281 stderr: String::new(),
282 };
283 }
284
285 if config.verbose {
286 println!("Executing in directory: {}", dir.display());
287 }
288
289 if !config.silent {
290 println!();
291 io::stdout().flush().unwrap();
292 }
293
294 let (shell, shell_flag) = get_shell_and_flag();
295
296 let mut cmd_builder = Command::new(&shell);
297 cmd_builder
298 .arg(shell_flag)
299 .arg(&resolved_command)
300 .current_dir(dir)
301 .envs(env::vars());
302
303 if let Some(extra) = extra_env {
305 cmd_builder.envs(extra);
306 }
307
308 let mut child = cmd_builder
309 .stdout(if config.silent {
310 Stdio::null()
311 } else {
312 Stdio::inherit()
313 })
314 .stderr(if config.silent {
315 Stdio::null()
316 } else {
317 Stdio::inherit()
318 })
319 .spawn()
320 .with_context(|| {
321 format!(
322 "Failed to execute command '{}' in directory '{}'",
323 resolved_command,
324 dir.display()
325 )
326 })
327 .expect("Failed to execute command");
328
329 let status = child.wait().expect("Failed to wait on child process");
330 let exit_code = status.code().unwrap_or(-1);
331 let success = status.success();
332
333 if !config.silent {
334 let is_root = config
336 .root_dir
337 .as_ref()
338 .is_some_and(|root| dir == root.as_path());
339 let dir_name = if is_root {
340 "."
341 } else {
342 dir.file_name()
343 .and_then(|name| name.to_str())
344 .filter(|&s| !s.is_empty())
345 .unwrap_or(".")
346 };
347 if success {
348 if is_root {
349 if let Some(base) = dir.file_name().and_then(|s| s.to_str()) {
351 println!("\x1b[32m\n✓ . ({base})\x1b[0m");
352 } else {
353 println!("\x1b[32m\n✓ .\x1b[0m");
354 }
355 } else if dir_name == "." {
356 if let Ok(cwd) = std::env::current_dir() {
358 if let Some(base) = cwd.file_name().and_then(|s| s.to_str()) {
359 println!("\x1b[32m\n✓ . ({base})\x1b[0m");
360 } else {
361 println!("\x1b[32m\n✓ .\x1b[0m");
362 }
363 } else {
364 println!("\x1b[32m\n✓ .\x1b[0m");
365 }
366 } else {
367 println!("\x1b[32m\n✓ {dir_name}\x1b[0m");
368 }
369 } else {
370 println!("\x1b[31m\n✗ {dir_name}: exited code {exit_code}\x1b[0m");
371 }
372 io::stdout().flush().unwrap();
373 }
374
375 CommandResult {
376 success,
377 exit_code,
378 directory: dir.to_path_buf(),
379 command: resolved_command,
380 stdout: String::new(), stderr: String::new(),
382 }
383}
384
385pub fn execute_command_in_directory_capturing(
387 dir: &Path,
388 command: &str,
389 config: &LoopConfig,
390 aliases: &HashMap<String, String>,
391 extra_env: Option<&HashMap<String, String>>,
392) -> CommandResult {
393 if !dir.exists() {
394 return CommandResult {
395 success: false,
396 exit_code: 1,
397 directory: dir.to_path_buf(),
398 command: command.to_string(),
399 stdout: String::new(),
400 stderr: format!("Directory does not exist: {}", dir.display()),
401 };
402 }
403
404 let resolved_command = command
405 .split_whitespace()
406 .next()
407 .and_then(|cmd| aliases.get(cmd).map(|alias_cmd| (cmd, alias_cmd)))
408 .map(|(cmd, alias_cmd)| command.replacen(cmd, alias_cmd, 1))
409 .unwrap_or_else(|| command.to_string());
410
411 if config.dry_run {
413 let dir_display = if dir.as_os_str() == "." {
414 if let Ok(cwd) = std::env::current_dir() {
415 cwd.display().to_string()
416 } else {
417 ".".to_string()
418 }
419 } else {
420 dir.display().to_string()
421 };
422 let stdout_msg = format!("[DRY RUN] Would execute in {dir_display}:\n {resolved_command}");
423 return CommandResult {
424 success: true,
425 exit_code: 0,
426 directory: dir.to_path_buf(),
427 command: resolved_command,
428 stdout: stdout_msg,
429 stderr: String::new(),
430 };
431 }
432
433 let (shell, shell_flag) = get_shell_and_flag();
434
435 let mut cmd_builder = Command::new(&shell);
436 cmd_builder
437 .arg(shell_flag)
438 .arg(&resolved_command)
439 .current_dir(dir)
440 .envs(env::vars());
441
442 if let Some(extra) = extra_env {
444 cmd_builder.envs(extra);
445 }
446
447 if io::stdout().is_terminal() {
452 cmd_builder
453 .env("FORCE_COLOR", "1") .env("CLICOLOR_FORCE", "1"); if env::var("TERM").is_err() {
457 cmd_builder.env("TERM", "xterm-256color");
458 }
459 }
460
461 let output = cmd_builder
462 .stdout(Stdio::piped())
463 .stderr(Stdio::piped())
464 .output();
465
466 match output {
467 Ok(output) => {
468 let success = output.status.success();
469 let exit_code = output.status.code().unwrap_or(-1);
470 CommandResult {
471 success,
472 exit_code,
473 directory: dir.to_path_buf(),
474 command: resolved_command,
475 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
476 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
477 }
478 }
479 Err(e) => CommandResult {
480 success: false,
481 exit_code: -1,
482 directory: dir.to_path_buf(),
483 command: resolved_command,
484 stdout: String::new(),
485 stderr: format!("Failed to execute: {e}"),
486 },
487 }
488}
489
490pub fn expand_directories(directories: &[String], ignore: &[String]) -> Result<Vec<String>> {
491 let mut expanded = Vec::new();
492
493 use std::fs;
494
495 for dir in directories {
496 let dir_path = PathBuf::from(dir);
497 if dir_path.is_dir() && !should_ignore(&dir_path, ignore) {
498 expanded.push(dir_path.to_string_lossy().into_owned());
499
500 for entry in fs::read_dir(&dir_path)? {
501 let entry = entry?;
502 let path = entry.path();
503 if path.is_dir() && !should_ignore(&path, ignore) {
504 expanded.push(path.to_string_lossy().into_owned());
505 }
506 }
507 }
508 }
509
510 Ok(expanded)
511}
512
513pub fn run(orig_config: &LoopConfig, command: &str) -> Result<()> {
516 if orig_config.add_aliases_to_global_looprc {
518 return add_aliases_to_global_looprc();
519 }
520
521 let mut dirs = orig_config.directories.clone();
523
524 if let Some(ref includes) = orig_config.include_filters {
525 if !includes.is_empty() {
526 dirs.retain(|p| includes.iter().any(|f| p.contains(f)));
527 }
528 }
529
530 if let Some(ref excludes) = orig_config.exclude_filters {
531 if !excludes.is_empty() {
532 if orig_config.verbose {
533 println!("Exclude filters: {excludes:?}");
534 }
535 dirs.retain(|p| {
536 let excluded = excludes.iter().any(|f| {
537 let f = f.trim_end_matches('/');
538 p.contains(f)
539 });
540 if orig_config.verbose {
541 println!("Dir: {p}, excluded: {excluded}");
542 }
543 !excluded
544 });
545 }
546 }
547
548 let commands: Vec<DirCommand> = dirs
550 .iter()
551 .map(|dir| DirCommand {
552 dir: dir.clone(),
553 cmd: command.to_string(),
554 env: orig_config.env.clone(),
555 })
556 .collect();
557
558 execute_commands_internal(orig_config, &commands)
560}
561
562#[derive(Debug, Serialize)]
564pub struct JsonOutput {
565 pub success: bool,
566 pub results: Vec<JsonCommandResult>,
567 pub summary: JsonSummary,
568}
569
570#[derive(Debug, Serialize)]
571pub struct JsonCommandResult {
572 pub directory: String,
573 pub command: String,
574 pub success: bool,
575 pub exit_code: i32,
576 #[serde(skip_serializing_if = "String::is_empty")]
577 pub stdout: String,
578 #[serde(skip_serializing_if = "String::is_empty")]
579 pub stderr: String,
580}
581
582#[derive(Debug, Serialize)]
583pub struct JsonSummary {
584 pub total: usize,
585 pub succeeded: usize,
586 pub failed: usize,
587 pub dry_run: bool,
588}
589
590fn execute_commands_internal(config: &LoopConfig, commands: &[DirCommand]) -> Result<()> {
597 if commands.is_empty() {
598 return Ok(());
599 }
600
601 let results = Arc::new(Mutex::new(Vec::new()));
602 let aliases = Arc::new(get_aliases());
603
604 if config.parallel {
605 let is_tty = std::io::stdout().is_terminal() && !config.json_output;
607 let mp = if is_tty {
608 Some(Arc::new(MultiProgress::new()))
609 } else {
610 None
611 };
612 let spinner_style = ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
613 .unwrap()
614 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
615
616 let total = commands.len();
617
618 let spinners: Vec<Option<ProgressBar>> = commands
620 .iter()
621 .enumerate()
622 .map(|(i, dir_cmd)| {
623 if let Some(ref mp) = mp {
624 let pb = mp.add(ProgressBar::new_spinner());
625 pb.set_style(spinner_style.clone());
626 pb.set_prefix(format!("[{}/{}]", i + 1, total));
627 let dir_path = PathBuf::from(&dir_cmd.dir);
628 let is_root = config.root_dir.as_ref().is_some_and(|r| dir_path == *r);
629 let dir_name = if is_root {
630 ".".to_string()
631 } else {
632 dir_path
633 .file_name()
634 .and_then(|n| n.to_str())
635 .unwrap_or(".")
636 .to_string()
637 };
638 pb.set_message(format!("{dir_name}: pending..."));
639 pb.enable_steady_tick(Duration::from_millis(100));
640 Some(pb)
641 } else {
642 None
643 }
644 })
645 .collect();
646
647 let spawn_counter = Arc::new(AtomicUsize::new(0));
649 let stagger_ms = config.spawn_stagger_ms;
650
651 let execute_parallel = || {
653 commands
654 .par_iter()
655 .enumerate()
656 .map(|(i, dir_cmd)| {
657 if stagger_ms > 0 {
660 let slot = spawn_counter.fetch_add(1, Ordering::SeqCst);
661 let delay = Duration::from_millis(stagger_ms * slot as u64);
662 std::thread::sleep(delay);
663 }
664
665 let dir = PathBuf::from(&dir_cmd.dir);
666 let is_root = config.root_dir.as_ref().is_some_and(|r| dir == *r);
667 let dir_name = if is_root {
668 ".".to_string()
669 } else {
670 dir.file_name()
671 .and_then(|n| n.to_str())
672 .unwrap_or(".")
673 .to_string()
674 };
675
676 if let Some(ref pb) = spinners[i] {
678 pb.set_message(format!("{dir_name}: running..."));
679 }
680
681 let result = execute_command_in_directory_capturing(
682 &dir,
683 &dir_cmd.cmd,
684 config,
685 &aliases,
686 dir_cmd.env.as_ref(),
687 );
688
689 if !config.json_output {
692 if let Some(ref pb) = spinners[i] {
693 pb.finish_and_clear();
695 }
696 }
698
699 result
700 })
701 .collect()
702 };
703
704 let parallel_results: Vec<CommandResult> = if let Some(max) = config.max_parallel {
706 let pool = ThreadPoolBuilder::new()
708 .num_threads(max)
709 .build()
710 .expect("Failed to create thread pool");
711 pool.install(execute_parallel)
712 } else {
713 execute_parallel()
714 };
715
716 let mut sorted_results = parallel_results;
719 sorted_results.sort_by(|a, b| {
720 let a_is_root = config.root_dir.as_ref().is_some_and(|r| a.directory == *r);
721 let b_is_root = config.root_dir.as_ref().is_some_and(|r| b.directory == *r);
722 let a_name = a
723 .directory
724 .file_name()
725 .and_then(|n| n.to_str())
726 .unwrap_or(".");
727 let b_name = b
728 .directory
729 .file_name()
730 .and_then(|n| n.to_str())
731 .unwrap_or(".");
732 match (a_is_root, b_is_root) {
733 (true, true) => std::cmp::Ordering::Equal,
734 (true, false) => std::cmp::Ordering::Less,
735 (false, true) => std::cmp::Ordering::Greater,
736 _ => match (a_name, b_name) {
737 (".", ".") => std::cmp::Ordering::Equal,
738 (".", _) => std::cmp::Ordering::Less,
739 (_, ".") => std::cmp::Ordering::Greater,
740 _ => a_name.cmp(b_name),
741 },
742 }
743 });
744 results
745 .lock()
746 .unwrap_or_else(|e| e.into_inner())
747 .extend(sorted_results);
748
749 if let Some(ref mp) = mp {
751 mp.clear().ok();
752 }
753
754 if !config.silent && !config.json_output {
756 let results = results.lock().unwrap_or_else(|e| e.into_inner());
757 let has_any_output = results
758 .iter()
759 .any(|r| !r.stdout.trim().is_empty() || !r.stderr.trim().is_empty());
760
761 if has_any_output {
762 println!();
763 }
764
765 for result in results.iter() {
766 let is_root = config
768 .root_dir
769 .as_ref()
770 .is_some_and(|r| result.directory == *r);
771 let dir_name = if is_root {
772 if let Some(base) = result.directory.file_name().and_then(|s| s.to_str()) {
774 format!(". ({base})")
775 } else {
776 ".".to_string()
777 }
778 } else {
779 result
780 .directory
781 .file_name()
782 .and_then(|n| n.to_str())
783 .unwrap_or(".")
784 .to_string()
785 };
786
787 let has_output =
788 !result.stdout.trim().is_empty() || !result.stderr.trim().is_empty();
789 if has_output {
790 if result.success {
791 println!("{} {}:", "✓".green(), dir_name.green());
792 } else {
793 println!("{} {}:", "✗".red(), dir_name.red());
794 }
795 if !result.stdout.trim().is_empty() {
796 print!("{}", result.stdout);
797 }
798 if !result.stderr.trim().is_empty() {
799 print!("{}", result.stderr);
800 }
801 println!(); }
803 }
804 }
805 } else {
806 for dir_cmd in commands {
808 let dir = PathBuf::from(&dir_cmd.dir);
809 let result = if config.json_output {
810 execute_command_in_directory_capturing(
812 &dir,
813 &dir_cmd.cmd,
814 config,
815 &aliases,
816 dir_cmd.env.as_ref(),
817 )
818 } else {
819 execute_command_in_directory(
820 &dir,
821 &dir_cmd.cmd,
822 config,
823 &aliases,
824 dir_cmd.env.as_ref(),
825 )
826 };
827 results
828 .lock()
829 .unwrap_or_else(|e| e.into_inner())
830 .push(result);
831 }
832 }
833
834 let results = results.lock().unwrap_or_else(|e| e.into_inner());
836 let total = results.len();
837 let failed: Vec<_> = results.iter().filter(|r| !r.success).collect();
838 let failed_count = failed.len();
839
840 if config.json_output {
842 let json_results: Vec<JsonCommandResult> = results
844 .iter()
845 .map(|r| JsonCommandResult {
846 directory: r.directory.display().to_string(),
847 command: r.command.clone(),
848 success: r.success,
849 exit_code: r.exit_code,
850 stdout: r.stdout.clone(),
851 stderr: r.stderr.clone(),
852 })
853 .collect();
854
855 let output = JsonOutput {
856 success: failed_count == 0,
857 results: json_results,
858 summary: JsonSummary {
859 total,
860 succeeded: total - failed_count,
861 failed: failed_count,
862 dry_run: config.dry_run,
863 },
864 };
865
866 println!("{}", serde_json::to_string_pretty(&output)?);
867 } else if !config.silent {
868 if config.dry_run {
870 println!(
871 "\n{} Would run {} command(s) across {} directories",
872 "[DRY RUN]".cyan(),
873 total.to_string().yellow(),
874 total.to_string().yellow()
875 );
876 } else if failed_count == 0 {
877 println!("{} commands complete", total.to_string().green());
878 } else {
879 println!(
880 "\nSummary: {} {} out of {} commands failed",
881 "✗".red(),
882 failed_count.to_string().red(),
883 total
884 );
885 for result in &failed {
886 println!(
887 "\n{} {}: {} (Exit code {}) ",
888 "✗".red(),
889 result.directory.display(),
890 result.command,
891 result.exit_code
892 );
893 }
894 println!();
895 }
896 }
897
898 if failed_count > 0 && !config.dry_run {
899 return Err(anyhow::anyhow!("At least one command failed"));
900 }
901
902 Ok(())
903}
904
905pub fn run_commands(config: &LoopConfig, commands: &[DirCommand]) -> Result<()> {
909 let mut filtered: Vec<DirCommand> = commands.to_vec();
910
911 if let Some(ref includes) = config.include_filters {
912 if !includes.is_empty() {
913 filtered.retain(|c| includes.iter().any(|f| c.dir.contains(f)));
914 }
915 }
916
917 if let Some(ref excludes) = config.exclude_filters {
918 if !excludes.is_empty() {
919 filtered.retain(|c| {
920 let excluded = excludes.iter().any(|f| {
921 let f = f.trim_end_matches('/');
922 c.dir.contains(f)
923 });
924 !excluded
925 });
926 }
927 }
928
929 execute_commands_internal(config, &filtered)
930}
931
932pub fn should_ignore(path: &Path, ignore: &[String]) -> bool {
933 ignore.iter().any(|i| path.to_string_lossy().contains(i))
934}
935
936pub fn parse_config(config_path: &Path) -> Result<LoopConfig> {
937 let config_str = fs::read_to_string(config_path)
938 .with_context(|| format!("Failed to read looprc config file: {config_path:?}"))?;
939 let config: LoopConfig = serde_json::from_str(&config_str)
940 .with_context(|| format!("Failed to parse looprc config file: {config_path:?}"))?;
941 Ok(config)
942}
943
944pub fn get_aliases() -> HashMap<String, String> {
945 let mut aliases = HashMap::new();
946
947 if let Some(home) = env::var_os("HOME") {
948 let global_looprc = PathBuf::from(home).join(".looprc");
949 if global_looprc.exists() {
950 if let Ok(global_aliases) = load_aliases_from_file(&global_looprc) {
951 aliases.extend(global_aliases);
952 }
953 }
954 }
955
956 if aliases.is_empty() {
957 if let Ok(output) = Command::new("sh").arg("-c").arg("alias").output() {
958 let stdout = String::from_utf8_lossy(&output.stdout);
959 for line in stdout.lines() {
960 if let Some((alias, command)) = line.split_once('=') {
961 let alias = alias.trim().trim_start_matches("alias ").to_string();
962 let command = command
963 .trim()
964 .trim_matches('\'')
965 .trim_matches('"')
966 .to_string();
967 aliases.insert(alias, command);
968 }
969 }
970 }
971 }
972
973 if let Ok(local_aliases) = load_aliases_from_file(Path::new(".looprc")) {
974 aliases.extend(local_aliases);
975 }
976
977 aliases
978}
979
980#[cfg(test)]
981#[path = "tests.rs"]
982mod tests;