Skip to main content

loop_lib/
lib.rs

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
17/// Returns the shell program and command-line flag for executing commands.
18/// On Unix: uses $SHELL or /bin/sh with -c
19/// On Windows: uses cmd.exe with /c
20fn 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    /// Milliseconds to wait between spawning threads in parallel mode.
60    /// Default is 0 (no stagger). Set to e.g. 10 to spread out connections.
61    #[serde(default)]
62    pub spawn_stagger_ms: u64,
63    /// Environment variables to set for all command subprocesses.
64    /// Tool-specific env vars (e.g., GIT_PAGER) should be set by the caller.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub env: Option<HashMap<String, String>>,
67    /// Maximum number of commands to run in parallel (pool size limit).
68    /// When set, limits the rayon thread pool to this many threads.
69    /// Use for operations with shared resources (e.g., SSH ControlMaster
70    /// has a default session limit of 10).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub max_parallel: Option<usize>,
73    /// The root directory of the meta workspace. When set, this directory
74    /// is displayed as "." or ". (basename)" instead of its full path,
75    /// and is sorted first in output.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub root_dir: Option<PathBuf>,
78}
79
80/// A command to execute in a specific directory
81#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct DirCommand {
83    pub dir: String,
84    pub cmd: String,
85    /// Environment variables to set for this command's subprocess
86    #[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    // Show preview of changes
201    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    // Resolve aliases for display
251    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    // Dry run mode: print what would be executed without running it
259    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    // Apply plugin-specified environment variables (e.g., GIT_PAGER=cat)
304    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        // Check if this directory is the root_dir (should display as ".")
335        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                // Display root as ". (basename)"
350                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                // Fallback for literal "." paths
357                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(), // Sequential mode uses Stdio::inherit(), so no capture
381        stderr: String::new(),
382    }
383}
384
385/// Capturing version for parallel execution - captures stdout/stderr for display after completion
386pub 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    // Dry run mode: return what would be executed without running it
412    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    // Apply plugin-specified environment variables (e.g., GIT_PAGER=cat)
443    if let Some(extra) = extra_env {
444        cmd_builder.envs(extra);
445    }
446
447    // Force color output when parent stdout is a TTY
448    // This is needed because piped stdout makes child processes think they're not in a terminal
449    // Note: Tool-specific color vars (e.g., git's GIT_CONFIG_*) should be set by the
450    // plugin that knows about that tool, not here. loop_lib is tool-agnostic.
451    if io::stdout().is_terminal() {
452        cmd_builder
453            .env("FORCE_COLOR", "1") // Node.js ecosystem
454            .env("CLICOLOR_FORCE", "1"); // BSD/macOS convention
455                                         // Preserve TERM if set, otherwise provide a reasonable default
456        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
513/// Run the same command across multiple directories.
514/// This applies include/exclude filters and then delegates to the unified execution engine.
515pub fn run(orig_config: &LoopConfig, command: &str) -> Result<()> {
516    // Handle special case: add_aliases_to_global_looprc
517    if orig_config.add_aliases_to_global_looprc {
518        return add_aliases_to_global_looprc();
519    }
520
521    // Apply include/exclude filters to directories
522    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    // Build DirCommand list with same command for each directory
549    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    // Delegate to unified execution engine
559    execute_commands_internal(orig_config, &commands)
560}
561
562/// JSON output structure for command results
563#[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
590// ============================================================================
591// Unified Execution Engine
592// ============================================================================
593
594/// Internal execution engine that handles both parallel and sequential execution.
595/// This is the unified implementation used by both `run()` and `run_commands()`.
596fn 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        // Parallel execution using rayon thread pool with spinners
606        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        // Pre-create all spinners (in order) so they display in correct sequence
619        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        // Atomic counter for staggered spawning - prevents SSH socket saturation
648        let spawn_counter = Arc::new(AtomicUsize::new(0));
649        let stagger_ms = config.spawn_stagger_ms;
650
651        // Closure to execute commands in parallel
652        let execute_parallel = || {
653            commands
654                .par_iter()
655                .enumerate()
656                .map(|(i, dir_cmd)| {
657                    // Apply stagger delay to spread out connection attempts.
658                    // Each thread gets a slot number and sleeps proportionally.
659                    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                    // Update spinner to show running
677                    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                    // Update spinner with result (only if not JSON output)
690                    // Note: We don't print completion status here - detailed results shown after all complete
691                    if !config.json_output {
692                        if let Some(ref pb) = spinners[i] {
693                            // Clear spinner - detailed results shown later
694                            pb.finish_and_clear();
695                        }
696                        // Non-TTY: no per-command output, detailed results shown after all complete
697                    }
698
699                    result
700                })
701                .collect()
702        };
703
704        // Use custom thread pool if max_parallel is set, otherwise use global pool
705        let parallel_results: Vec<CommandResult> = if let Some(max) = config.max_parallel {
706            // Create a custom thread pool with limited threads
707            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        // Store results (already collected from rayon), sorted for deterministic output
717        // Sort by directory name: root_dir first (displayed as "."), then alphabetical
718        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        // Clear spinner lines before showing detailed output
750        if let Some(ref mp) = mp {
751            mp.clear().ok();
752        }
753
754        // Print captured output after all spinners complete (if not JSON)
755        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                // Check if this directory is the root_dir (should display as ".")
767                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                    // Display root as ". (basename)"
773                    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!(); // Blank line after each repo's output
802                }
803            }
804        }
805    } else {
806        // Sequential execution
807        for dir_cmd in commands {
808            let dir = PathBuf::from(&dir_cmd.dir);
809            let result = if config.json_output {
810                // Capture output for JSON mode
811                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    // Build results summary
835    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    // Output results
841    if config.json_output {
842        // JSON output mode
843        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        // Text output mode
869        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
905/// Execute a list of commands (each with its own directory)
906/// This is the unified execution engine for plugins.
907/// Applies include/exclude filters from config before executing.
908pub 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;