Skip to main content

rusty_commit/commands/
commit.rs

1use anyhow::{Context, Result};
2use colored::Colorize;
3use dialoguer::{theme::ColorfulTheme, Input, MultiSelect, Select};
4use std::path::Path;
5use std::process::Command;
6
7use crate::cli::GlobalOptions;
8use crate::config::Config;
9use crate::git;
10use crate::output::progress;
11use crate::output::styling::Styling;
12use crate::providers;
13use crate::utils;
14use crate::utils::hooks::{run_hooks, write_temp_commit_file, HookOptions};
15
16/// Tokens reserved for prompt overhead when chunking diffs.
17/// This accounts for system prompts, user instructions, and response tokens
18/// that are sent alongside the diff content.
19const PROMPT_OVERHEAD_TOKENS: usize = 500;
20
21/// Execution context for commit message output.
22struct ExecContext;
23
24impl ExecContext {
25    fn new(_options: &GlobalOptions) -> Self {
26        Self
27    }
28
29    /// Print a success message.
30    fn success(&self, message: &str) {
31        println!("{} {}", "✓".green(), message);
32    }
33
34    /// Print a warning message.
35    fn warning(&self, message: &str) {
36        eprintln!("{} {}", "!".yellow().bold(), message);
37    }
38
39    /// Print an error message.
40    fn error(&self, message: &str) {
41        eprintln!("{} {}", "✗".red(), message);
42    }
43
44    /// Print a header.
45    fn header(&self, text: &str) {
46        println!("\n{}", text.bold());
47    }
48
49    /// Print a subheader.
50    fn subheader(&self, text: &str) {
51        println!("{}", text.dimmed());
52    }
53
54    /// Print a divider.
55    fn divider(&self, length: Option<usize>) {
56        let len = length.unwrap_or(50);
57        println!("{}", Styling::divider(len));
58    }
59
60    /// Print a key-value pair.
61    fn key_value(&self, key: &str, value: &str) {
62        println!("{}: {}", key.dimmed(), value);
63    }
64}
65
66pub async fn execute(options: GlobalOptions) -> Result<()> {
67    let ctx = ExecContext::new(&options);
68
69    // Ensure we're in a git repository
70    git::assert_git_repo()?;
71
72    // Load and validate configuration
73    let config = load_and_validate_config(&options)?;
74
75    // Determine effective generate count (CLI > config > default), clamped to 1-5
76    let generate_count = options
77        .generate_count
78        .max(config.generate_count.unwrap_or(1))
79        .clamp(1, 5);
80
81    // Prepare the diff for processing
82    let (final_diff, token_count) = prepare_diff(&config, &ctx)?;
83
84    // If --show-prompt flag is set, just show the prompt and exit
85    if options.show_prompt {
86        display_prompt(&config, &final_diff, options.context.as_deref(), &ctx);
87        return Ok(());
88    }
89
90    // Run pre-generation hooks
91    if !options.no_pre_hooks {
92        run_pre_gen_hooks(&config, token_count, options.context.as_deref())?;
93    }
94
95    // Generate commit message(s)
96    let messages = generate_commit_messages(
97        &config,
98        &final_diff,
99        options.context.as_deref(),
100        options.full_gitmoji,
101        generate_count,
102        options.strip_thinking,
103        &ctx,
104    )
105    .await?;
106
107    if messages.is_empty() {
108        anyhow::bail!("Failed to generate any commit messages");
109    }
110
111    // Handle clipboard mode
112    if options.clipboard {
113        return handle_clipboard_mode(&messages, &ctx);
114    }
115
116    // Handle print mode (for hooks compatibility)
117    if options.print_message {
118        print!("{}", messages[0]);
119        return Ok(());
120    }
121
122    // Handle dry-run mode - preview without committing
123    if options.dry_run {
124        return handle_dry_run_mode(&messages, &ctx);
125    }
126
127    // Run pre-commit hooks on first message
128    let mut final_message = messages[0].clone();
129    if !options.no_pre_hooks {
130        final_message = run_pre_commit_hooks(&config, &final_message)?;
131    }
132
133    // Display messages and handle commit action
134    display_commit_messages(&messages, &ctx);
135    handle_commit_action(&options, &config, &messages, &mut final_message, &ctx).await
136}
137
138/// Load configuration and apply commitlint rules
139fn load_and_validate_config(options: &GlobalOptions) -> Result<Config> {
140    let mut config = Config::load()?;
141
142    // Apply CLI prompt-file override if provided
143    if let Some(ref prompt_file) = options.prompt_file {
144        config.set_prompt_file(Some(prompt_file.clone()));
145    }
146
147    // Apply skill if specified
148    if let Some(ref skill_name) = options.skill {
149        apply_skill_to_config(&mut config, skill_name)?;
150    }
151
152    config.load_with_commitlint()?;
153    config.apply_commitlint_rules()?;
154    Ok(config)
155}
156
157/// Apply a skill's configuration to the config
158fn apply_skill_to_config(config: &mut Config, skill_name: &str) -> Result<()> {
159    use crate::skills::SkillsManager;
160
161    let mut manager = SkillsManager::new()?;
162    manager.discover()?;
163
164    let skill = manager
165        .find(skill_name)
166        .ok_or_else(|| anyhow::anyhow!(
167            "Skill '{}' not found. Run 'rco skills list' to see available skills.",
168            skill_name
169        ))?;
170
171    // Load prompt template from skill if available
172    if let Some(prompt_template) = skill.load_prompt_template()? {
173        config.custom_prompt = Some(prompt_template);
174        tracing::info!("Loaded prompt template from skill: {}", skill_name);
175    }
176
177    println!("{} Using skill: {}", "→".cyan(), skill_name.green());
178    Ok(())
179}
180
181/// Prepare the diff for processing: get staged changes, apply filters, chunk if needed
182fn prepare_diff(config: &Config, ctx: &ExecContext) -> Result<(String, usize)> {
183    // Check for staged files or changes
184    let staged_files = git::get_staged_files()?;
185    let changed_files = if staged_files.is_empty() {
186        git::get_changed_files()?
187    } else {
188        staged_files
189    };
190
191    if changed_files.is_empty() {
192        ctx.error("No changes to commit");
193        ctx.subheader("Stage some changes with 'git add' or use 'git add -A' to stage all changes");
194        anyhow::bail!("No changes to commit");
195    }
196
197    // If no staged files, ask user which files to stage
198    let files_to_stage = if git::get_staged_files()?.is_empty() {
199        select_files_to_stage(&changed_files)?
200    } else {
201        vec![]
202    };
203
204    // Stage selected files
205    if !files_to_stage.is_empty() {
206        git::stage_files(&files_to_stage)?;
207    }
208
209    // Get the diff of staged changes
210    let diff = git::get_staged_diff()?;
211    if diff.is_empty() {
212        ctx.error("No staged changes to commit");
213        anyhow::bail!("No staged changes to commit");
214    }
215
216    // Apply .rcoignore if it exists
217    let diff = filter_diff_by_rcoignore(&diff)?;
218
219    // Check if diff became empty after filtering
220    if diff.trim().is_empty() {
221        ctx.error("No changes to commit after applying .rcoignore filters");
222        anyhow::bail!("No changes to commit after applying .rcoignore filters");
223    }
224
225    // Check if diff is too large - implement chunking if needed
226    let max_tokens = config.tokens_max_input.unwrap_or(4096);
227    let token_count = utils::token::estimate_tokens(&diff)?;
228
229    // If diff is too large, chunk it
230    let final_diff = if token_count > max_tokens {
231        ctx.warning(&format!(
232            "The diff is too large ({} tokens). Splitting into chunks...",
233            token_count
234        ));
235        chunk_diff(&diff, max_tokens)?
236    } else {
237        diff
238    };
239
240    // Check if diff is empty after chunking
241    if final_diff.trim().is_empty() {
242        anyhow::bail!(
243            "Diff is empty after processing. This may indicate all files were excluded by .rcoignore."
244        );
245    }
246
247    Ok((final_diff, token_count))
248}
249
250/// Display the prompt that would be sent to AI
251fn display_prompt(config: &Config, diff: &str, context: Option<&str>, ctx: &ExecContext) {
252    let prompt = config.get_effective_prompt(diff, context, false);
253    ctx.header("Prompt that would be sent to AI");
254    ctx.divider(None);
255    println!("{}", prompt);
256    ctx.divider(None);
257}
258
259/// Run pre-generation hooks
260fn run_pre_gen_hooks(config: &Config, token_count: usize, context: Option<&str>) -> Result<()> {
261    if let Some(hooks) = config.pre_gen_hook.clone() {
262        let envs = vec![
263            ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
264            (
265                "RCO_MAX_TOKENS",
266                (config.tokens_max_input.unwrap_or(4096)).to_string(),
267            ),
268            ("RCO_DIFF_TOKENS", token_count.to_string()),
269            ("RCO_CONTEXT", context.unwrap_or_default().to_string()),
270            (
271                "RCO_PROVIDER",
272                config.ai_provider.clone().unwrap_or_default(),
273            ),
274            ("RCO_MODEL", config.model.clone().unwrap_or_default()),
275        ];
276        run_hooks(HookOptions {
277            name: "pre-gen",
278            commands: hooks,
279            strict: config.hook_strict.unwrap_or(true),
280            timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
281            envs,
282        })?;
283    }
284    Ok(())
285}
286
287/// Handle clipboard mode - copy message to clipboard and exit
288fn handle_clipboard_mode(messages: &[String], ctx: &ExecContext) -> Result<()> {
289    let selected = if messages.len() == 1 {
290        0
291    } else {
292        select_message_variant(messages)?
293    };
294    copy_to_clipboard(&messages[selected])?;
295    ctx.success("Commit message copied to clipboard!");
296    Ok(())
297}
298
299/// Handle dry-run mode - preview message without committing
300fn handle_dry_run_mode(messages: &[String], ctx: &ExecContext) -> Result<()> {
301    ctx.header("Dry Run Mode - Preview");
302    ctx.divider(None);
303    ctx.subheader("The following commit message would be generated:");
304    println!();
305    
306    if messages.len() == 1 {
307        println!("{}", messages[0].green());
308    } else {
309        ctx.subheader("Multiple variations available:");
310        for (i, msg) in messages.iter().enumerate() {
311            println!("\n{}. {}", i + 1, format!("Option {}", i + 1).cyan().bold());
312            println!("{}", msg.green());
313        }
314    }
315    
316    ctx.divider(None);
317    ctx.subheader("No commit was made. Remove --dry-run to commit.");
318    Ok(())
319}
320
321/// Display the generated commit message(s)
322fn display_commit_messages(messages: &[String], ctx: &ExecContext) {
323    if messages.len() == 1 {
324        ctx.header("Generated Commit Message");
325        ctx.divider(None);
326        println!("{}", messages[0]);
327        ctx.divider(None);
328    } else {
329        ctx.header("Generated Commit Message Variations");
330        ctx.divider(None);
331        for (i, msg) in messages.iter().enumerate() {
332            println!("{}. {}", i + 1, msg);
333        }
334        ctx.divider(None);
335    }
336}
337
338/// Push the current branch to remote after commit
339fn push_after_commit(config: &Config, ctx: &ExecContext) -> Result<()> {
340    ctx.subheader("Pushing to remote...");
341
342    let current_branch = git::get_current_branch()?;
343    let remote = config
344        .remote
345        .as_deref()
346        .unwrap_or("origin");
347
348    match git::git_push(remote, &current_branch) {
349        Ok(_) => {
350            ctx.success(&format!("Pushed '{}' to '{}'", current_branch, remote));
351        }
352        Err(e) => {
353            ctx.warning(&format!("Push failed: {}. Try running 'git push' manually.", e));
354        }
355    }
356
357    Ok(())
358}
359
360/// Handle the commit action (commit, edit, select, cancel, regenerate)
361async fn handle_commit_action(
362    options: &GlobalOptions,
363    config: &Config,
364    messages: &[String],
365    final_message: &mut str,
366    ctx: &ExecContext,
367) -> Result<()> {
368    let action = if options.skip_confirmation {
369        CommitAction::Commit
370    } else if options.edit {
371        // --edit flag: go straight to editor with the first message
372        CommitAction::EditExternal
373    } else if messages.len() > 1 {
374        select_commit_action_with_variants(messages.len())?
375    } else {
376        select_commit_action()?
377    };
378
379    match action {
380        CommitAction::Commit => {
381            perform_commit(final_message)?;
382            run_post_commit_hooks(config, final_message).await?;
383            ctx.success("Changes committed successfully!");
384
385            // Push to remote if gitpush is enabled
386            if config.gitpush.unwrap_or(false) {
387                push_after_commit(config, ctx)?;
388            }
389        }
390        CommitAction::Edit => {
391            let edited_message = edit_commit_message(final_message)?;
392            perform_commit(&edited_message)?;
393            run_post_commit_hooks(config, &edited_message).await?;
394            ctx.success("Changes committed successfully!");
395
396            // Push to remote if gitpush is enabled
397            if config.gitpush.unwrap_or(false) {
398                push_after_commit(config, ctx)?;
399            }
400        }
401        CommitAction::EditExternal => {
402            // Open in $EDITOR (e.g., vim, nano, code, etc.)
403            let edited_message = edit_in_external_editor(final_message)?;
404            if edited_message.trim().is_empty() {
405                ctx.warning("Commit cancelled - empty message.");
406                return Ok(());
407            }
408            perform_commit(&edited_message)?;
409            run_post_commit_hooks(config, &edited_message).await?;
410            ctx.success("Changes committed successfully!");
411
412            // Push to remote if gitpush is enabled
413            if config.gitpush.unwrap_or(false) {
414                push_after_commit(config, ctx)?;
415            }
416        }
417        CommitAction::Select { index } => {
418            let selected_message = messages[index].clone();
419            let final_msg = if !options.no_pre_hooks {
420                run_pre_commit_hooks(config, &selected_message)?
421            } else {
422                selected_message
423            };
424            perform_commit(&final_msg)?;
425            run_post_commit_hooks(config, &final_msg).await?;
426            ctx.success("Changes committed successfully!");
427
428            // Push to remote if gitpush is enabled
429            if config.gitpush.unwrap_or(false) {
430                push_after_commit(config, ctx)?;
431            }
432        }
433        CommitAction::Cancel => {
434            ctx.warning("Commit cancelled.");
435        }
436        CommitAction::Regenerate => {
437            // Recursive call to regenerate
438            Box::pin(execute(options.clone())).await?;
439        }
440    }
441
442    Ok(())
443}
444
445fn select_files_to_stage(files: &[String]) -> Result<Vec<String>> {
446    let theme = ColorfulTheme::default();
447    let selections = MultiSelect::with_theme(&theme)
448        .with_prompt("Select files to stage")
449        .items(files)
450        .interact()?;
451
452    Ok(selections.into_iter().map(|i| files[i].clone()).collect())
453}
454
455enum CommitAction {
456    Commit,
457    Edit,
458    EditExternal, // Open in $EDITOR
459    Cancel,
460    Regenerate,
461    Select { index: usize },
462}
463
464fn select_commit_action() -> Result<CommitAction> {
465    let choices = vec!["Commit", "Edit message", "Cancel", "Regenerate"];
466    let selection = Select::with_theme(&ColorfulTheme::default())
467        .with_prompt("What would you like to do?")
468        .items(&choices)
469        .default(0)
470        .interact()?;
471
472    Ok(match selection {
473        0 => CommitAction::Commit,
474        1 => CommitAction::Edit,
475        2 => CommitAction::Cancel,
476        3 => CommitAction::Regenerate,
477        _ => unreachable!(),
478    })
479}
480
481fn select_commit_action_with_variants(num_variants: usize) -> Result<CommitAction> {
482    let mut choices: Vec<String> = (1..=num_variants)
483        .map(|i| format!("Use option {}", i))
484        .collect();
485    choices.extend(vec![
486        "Edit message".to_string(),
487        "Cancel".to_string(),
488        "Regenerate".to_string(),
489    ]);
490
491    let selection = Select::with_theme(&ColorfulTheme::default())
492        .with_prompt("What would you like to do?")
493        .items(&choices)
494        .default(0)
495        .interact()?;
496
497    Ok(if selection < num_variants {
498        CommitAction::Select { index: selection }
499    } else {
500        match selection - num_variants {
501            0 => CommitAction::Edit,
502            1 => CommitAction::Cancel,
503            2 => CommitAction::Regenerate,
504            _ => unreachable!(),
505        }
506    })
507}
508
509fn select_message_variant(messages: &[String]) -> Result<usize> {
510    let selection = Select::with_theme(&ColorfulTheme::default())
511        .with_prompt("Select a commit message")
512        .items(messages)
513        .default(0)
514        .interact()?;
515
516    Ok(selection)
517}
518
519fn edit_commit_message(original: &str) -> Result<String> {
520    Input::with_theme(&ColorfulTheme::default())
521        .with_prompt("Edit commit message")
522        .with_initial_text(original)
523        .interact_text()
524        .context("Failed to read edited commit message")
525}
526
527/// Open commit message in $EDITOR for editing
528fn edit_in_external_editor(original: &str) -> Result<String> {
529    use std::env;
530    use tempfile::NamedTempFile;
531    use std::io::Write;
532    use std::process::Command;
533
534    // Get the editor from environment
535    let editor = env::var("EDITOR")
536        .or_else(|_| env::var("VISUAL"))
537        .unwrap_or_else(|_| {
538            // Default editors by platform
539            if cfg!(target_os = "windows") {
540                "notepad".to_string()
541            } else {
542                "vi".to_string()
543            }
544        });
545
546    // Create a temporary file with the commit message
547    let mut temp_file = NamedTempFile::with_suffix(".txt")
548        .context("Failed to create temporary file for editing")?;
549    
550    // Write the original message to the temp file
551    temp_file
552        .write_all(original.as_bytes())
553        .context("Failed to write to temporary file")?;
554    temp_file.flush().context("Failed to flush temporary file")?;
555    
556    let temp_path = temp_file.path().to_path_buf();
557    
558    // Keep the temp file from being deleted when dropped
559    let _temp_file = temp_file.into_temp_path();
560    
561    // Open the editor
562    let status = Command::new(&editor)
563        .arg(&temp_path)
564        .status()
565        .with_context(|| format!("Failed to open editor '{}'. Make sure $EDITOR is set correctly.", editor))?;
566    
567    if !status.success() {
568        anyhow::bail!("Editor exited with error status");
569    }
570    
571    // Read the edited message back
572    let edited = std::fs::read_to_string(&temp_path)
573        .context("Failed to read edited commit message from temporary file")?;
574    
575    Ok(edited)
576}
577
578fn perform_commit(message: &str) -> Result<()> {
579    let output = Command::new("git")
580        .args(["commit", "-m", message])
581        .output()
582        .context("Failed to execute git commit")?;
583
584    if !output.status.success() {
585        let stderr = String::from_utf8_lossy(&output.stderr);
586        anyhow::bail!("Git commit failed: {}", stderr);
587    }
588
589    Ok(())
590}
591
592async fn run_post_commit_hooks(config: &Config, message: &str) -> Result<()> {
593    if let Some(hooks) = config.post_commit_hook.clone() {
594        let envs = vec![
595            ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
596            ("RCO_COMMIT_MESSAGE", message.to_string()),
597            (
598                "RCO_PROVIDER",
599                config.ai_provider.clone().unwrap_or_default(),
600            ),
601            ("RCO_MODEL", config.model.clone().unwrap_or_default()),
602        ];
603        run_hooks(HookOptions {
604            name: "post-commit",
605            commands: hooks,
606            strict: config.hook_strict.unwrap_or(true),
607            timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
608            envs,
609        })?;
610    }
611    Ok(())
612}
613
614/// Run pre-commit hooks on a commit message, returning the possibly modified message
615fn run_pre_commit_hooks(config: &Config, message: &str) -> Result<String> {
616    if let Some(hooks) = config.pre_commit_hook.clone() {
617        let commit_file = write_temp_commit_file(message)?;
618        let envs = vec![
619            ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
620            ("RCO_COMMIT_MESSAGE", message.to_string()),
621            ("RCO_COMMIT_FILE", commit_file.to_string_lossy().to_string()),
622            (
623                "RCO_PROVIDER",
624                config.ai_provider.clone().unwrap_or_default(),
625            ),
626            ("RCO_MODEL", config.model.clone().unwrap_or_default()),
627        ];
628        run_hooks(HookOptions {
629            name: "pre-commit",
630            commands: hooks,
631            strict: config.hook_strict.unwrap_or(true),
632            timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
633            envs,
634        })?;
635        // Read back possibly modified commit file
636        if let Ok(updated) = std::fs::read_to_string(&commit_file) {
637            if !updated.trim().is_empty() {
638                return Ok(updated);
639            }
640        }
641    }
642    Ok(message.to_string())
643}
644
645async fn generate_commit_messages(
646    config: &Config,
647    diff: &str,
648    context: Option<&str>,
649    full_gitmoji: bool,
650    count: u8,
651    strip_thinking: bool,
652    ctx: &ExecContext,
653) -> Result<Vec<String>> {
654    let pb = progress::spinner(&format!(
655        "Generating {} commit message{}...",
656        count,
657        if count > 1 { "s" } else { "" }
658    ));
659
660    // Try to use an active account first
661    let provider: Box<dyn providers::AIProvider> =
662        if let Some(account) = config.get_active_account()? {
663            tracing::info!("Using account: {}", account.alias);
664            ctx.key_value("Using account", &account.alias);
665            providers::create_provider_for_account(&account, config)?
666        } else {
667            providers::create_provider(config)?
668        };
669
670    let mut messages = provider
671        .generate_commit_messages(diff, context, full_gitmoji, config, count)
672        .await?;
673
674    // Strip thinking tags if requested
675    if strip_thinking {
676        for message in &mut messages {
677            *message = utils::strip_thinking(message);
678        }
679    }
680
681    pb.finish_with_message("Commit message(s) generated!");
682    Ok(messages)
683}
684
685/// Load and parse .rcoignore file
686fn load_rcoignore() -> Result<Vec<String>> {
687    let repo_root = git::get_repo_root()?;
688    let rcoignore_path = Path::new(&repo_root).join(".rcoignore");
689
690    if !rcoignore_path.exists() {
691        return Ok(vec![]);
692    }
693
694    let content = std::fs::read_to_string(&rcoignore_path)?;
695    Ok(content
696        .lines()
697        .map(|s| s.trim().to_string())
698        .filter(|s| !s.is_empty() && !s.starts_with('#'))
699        .collect())
700}
701
702/// Filter diff to exclude files matching .rcoignore patterns
703fn filter_diff_by_rcoignore(diff: &str) -> Result<String> {
704    let patterns = load_rcoignore();
705    let patterns = match patterns {
706        Ok(p) => p,
707        Err(e) => {
708            eprintln!("Warning: Failed to read .rcoignore: {}", e);
709            return Ok(diff.to_string());
710        }
711    };
712
713    if patterns.is_empty() {
714        return Ok(diff.to_string());
715    }
716
717    // Pre-allocate with reasonable capacity estimate
718    let mut filtered = String::with_capacity(diff.len().min(1024));
719    let mut include_current_file = true;
720
721    for line in diff.lines() {
722        if line.starts_with("+++ b/") || line.starts_with("--- a/") {
723            let file_path = line
724                .strip_prefix("+++ b/")
725                .unwrap_or_else(|| line.strip_prefix("--- a/").unwrap_or(&line[6..]));
726
727            include_current_file = !patterns.iter().any(|pattern| {
728                if pattern.starts_with('/') {
729                    // Exact match from root
730                    file_path.trim_start_matches('/') == pattern.trim_start_matches('/')
731                } else {
732                    // Match anywhere in path
733                    file_path.contains(pattern)
734                }
735            });
736        }
737
738        if include_current_file {
739            filtered.push_str(line);
740            filtered.push('\n');
741        }
742    }
743
744    Ok(filtered)
745}
746
747/// Chunk a large diff into smaller pieces that fit within token limit
748fn chunk_diff(diff: &str, max_tokens: usize) -> Result<String> {
749    // Use the enhanced multi-level chunking from utils
750    let effective_max = max_tokens.saturating_sub(PROMPT_OVERHEAD_TOKENS);
751    let chunked = utils::chunk_diff(diff, effective_max);
752
753    // Log if chunking occurred
754    if chunked.contains("---CHUNK") {
755        tracing::info!("Diff was chunked for token limit");
756    }
757
758    Ok(chunked)
759}
760
761/// Copy text to clipboard with proper error handling
762fn copy_to_clipboard(text: &str) -> Result<()> {
763    #[cfg(target_os = "macos")]
764    {
765        use std::io::Write;
766        use std::process::{Command, Stdio};
767
768        // Use pbcopy with properly piped stdin
769        let mut process = Command::new("pbcopy")
770            .stdin(Stdio::piped())
771            .spawn()
772            .context("Failed to spawn pbcopy process")?;
773
774        // Write to stdin, handling the Result properly
775        {
776            let stdin = process
777                .stdin
778                .as_mut()
779                .context("pbcopy stdin not available")?;
780            stdin
781                .write_all(text.as_bytes())
782                .context("Failed to write to clipboard")?;
783        }
784
785        let status = process
786            .wait()
787            .context("Failed to wait for pbcopy process")?;
788
789        if !status.success() {
790            anyhow::bail!("pbcopy exited with error: {:?}", status);
791        }
792    }
793
794    #[cfg(target_os = "linux")]
795    {
796        use std::io::Write;
797        use std::process::{Command, Stdio};
798
799        // Check if xclip is available, otherwise try xsel as fallback
800        let use_xclip = !Command::new("which")
801            .arg("xclip")
802            .output()?
803            .stdout
804            .is_empty();
805
806        let (cmd_name, args) = if use_xclip {
807            ("xclip", vec!["-selection", "clipboard"])
808        } else {
809            ("xsel", vec!["--clipboard", "--input"])
810        };
811
812        let mut process = Command::new(cmd_name)
813            .args(&args)
814            .stdin(Stdio::piped())
815            .spawn()
816            .context(format!("Failed to spawn {} process", cmd_name))?;
817
818        {
819            let stdin = process
820                .stdin
821                .as_mut()
822                .context(format!("{} stdin not available", cmd_name))?;
823            stdin
824                .write_all(text.as_bytes())
825                .context("Failed to write to clipboard")?;
826        }
827
828        let status = process
829            .wait()
830            .context(format!("Failed to wait for {} process", cmd_name))?;
831
832        if !status.success() {
833            anyhow::bail!("{} exited with error: {:?}", cmd_name, status);
834        }
835    }
836
837    #[cfg(target_os = "windows")]
838    {
839        let mut ctx = arboard::Clipboard::new()
840            .map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
841        ctx.set_text(text.to_string())
842            .map_err(|e| anyhow::anyhow!("Failed to set clipboard contents: {}", e))?;
843    }
844
845    Ok(())
846}