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.find(skill_name).ok_or_else(|| {
165        anyhow::anyhow!(
166            "Skill '{}' not found. Run 'rco skills list' to see available skills.",
167            skill_name
168        )
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.remote.as_deref().unwrap_or("origin");
344
345    match git::git_push(remote, &current_branch) {
346        Ok(_) => {
347            ctx.success(&format!("Pushed '{}' to '{}'", current_branch, remote));
348        }
349        Err(e) => {
350            ctx.warning(&format!(
351                "Push failed: {}. Try running 'git push' manually.",
352                e
353            ));
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 std::io::Write;
531    use std::process::Command;
532    use tempfile::NamedTempFile;
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
555        .flush()
556        .context("Failed to flush temporary file")?;
557
558    let temp_path = temp_file.path().to_path_buf();
559
560    // Keep the temp file from being deleted when dropped
561    let _temp_file = temp_file.into_temp_path();
562
563    // Open the editor
564    let status = Command::new(&editor)
565        .arg(&temp_path)
566        .status()
567        .with_context(|| {
568            format!(
569                "Failed to open editor '{}'. Make sure $EDITOR is set correctly.",
570                editor
571            )
572        })?;
573
574    if !status.success() {
575        anyhow::bail!("Editor exited with error status");
576    }
577
578    // Read the edited message back
579    let edited = std::fs::read_to_string(&temp_path)
580        .context("Failed to read edited commit message from temporary file")?;
581
582    Ok(edited)
583}
584
585fn perform_commit(message: &str) -> Result<()> {
586    let output = Command::new("git")
587        .args(["commit", "-m", message])
588        .output()
589        .context("Failed to execute git commit")?;
590
591    if !output.status.success() {
592        let stderr = String::from_utf8_lossy(&output.stderr);
593        anyhow::bail!("Git commit failed: {}", stderr);
594    }
595
596    Ok(())
597}
598
599async fn run_post_commit_hooks(config: &Config, message: &str) -> Result<()> {
600    if let Some(hooks) = config.post_commit_hook.clone() {
601        let envs = vec![
602            ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
603            ("RCO_COMMIT_MESSAGE", message.to_string()),
604            (
605                "RCO_PROVIDER",
606                config.ai_provider.clone().unwrap_or_default(),
607            ),
608            ("RCO_MODEL", config.model.clone().unwrap_or_default()),
609        ];
610        run_hooks(HookOptions {
611            name: "post-commit",
612            commands: hooks,
613            strict: config.hook_strict.unwrap_or(true),
614            timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
615            envs,
616        })?;
617    }
618    Ok(())
619}
620
621/// Run pre-commit hooks on a commit message, returning the possibly modified message
622fn run_pre_commit_hooks(config: &Config, message: &str) -> Result<String> {
623    if let Some(hooks) = config.pre_commit_hook.clone() {
624        let commit_file = write_temp_commit_file(message)?;
625        let envs = vec![
626            ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
627            ("RCO_COMMIT_MESSAGE", message.to_string()),
628            ("RCO_COMMIT_FILE", commit_file.to_string_lossy().to_string()),
629            (
630                "RCO_PROVIDER",
631                config.ai_provider.clone().unwrap_or_default(),
632            ),
633            ("RCO_MODEL", config.model.clone().unwrap_or_default()),
634        ];
635        run_hooks(HookOptions {
636            name: "pre-commit",
637            commands: hooks,
638            strict: config.hook_strict.unwrap_or(true),
639            timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
640            envs,
641        })?;
642        // Read back possibly modified commit file
643        if let Ok(updated) = std::fs::read_to_string(&commit_file) {
644            if !updated.trim().is_empty() {
645                return Ok(updated);
646            }
647        }
648    }
649    Ok(message.to_string())
650}
651
652async fn generate_commit_messages(
653    config: &Config,
654    diff: &str,
655    context: Option<&str>,
656    full_gitmoji: bool,
657    count: u8,
658    strip_thinking: bool,
659    ctx: &ExecContext,
660) -> Result<Vec<String>> {
661    let pb = progress::spinner(&format!(
662        "Generating {} commit message{}...",
663        count,
664        if count > 1 { "s" } else { "" }
665    ));
666
667    // Try to use an active account first
668    let provider: Box<dyn providers::AIProvider> =
669        if let Some(account) = config.get_active_account()? {
670            tracing::info!("Using account: {}", account.alias);
671            ctx.key_value("Using account", &account.alias);
672            providers::create_provider_for_account(&account, config)?
673        } else {
674            providers::create_provider(config)?
675        };
676
677    let mut messages = provider
678        .generate_commit_messages(diff, context, full_gitmoji, config, count)
679        .await?;
680
681    // Strip thinking tags if requested
682    if strip_thinking {
683        for message in &mut messages {
684            *message = utils::strip_thinking(message);
685        }
686    }
687
688    pb.finish_with_message("Commit message(s) generated!");
689    Ok(messages)
690}
691
692/// Load and parse .rcoignore file
693fn load_rcoignore() -> Result<Vec<String>> {
694    let repo_root = git::get_repo_root()?;
695    let rcoignore_path = Path::new(&repo_root).join(".rcoignore");
696
697    if !rcoignore_path.exists() {
698        return Ok(vec![]);
699    }
700
701    let content = std::fs::read_to_string(&rcoignore_path)?;
702    Ok(content
703        .lines()
704        .map(|s| s.trim().to_string())
705        .filter(|s| !s.is_empty() && !s.starts_with('#'))
706        .collect())
707}
708
709/// Filter diff to exclude files matching .rcoignore patterns
710fn filter_diff_by_rcoignore(diff: &str) -> Result<String> {
711    let patterns = load_rcoignore();
712    let patterns = match patterns {
713        Ok(p) => p,
714        Err(e) => {
715            eprintln!("Warning: Failed to read .rcoignore: {}", e);
716            return Ok(diff.to_string());
717        }
718    };
719
720    if patterns.is_empty() {
721        return Ok(diff.to_string());
722    }
723
724    // Pre-allocate with reasonable capacity estimate
725    let mut filtered = String::with_capacity(diff.len().min(1024));
726    let mut include_current_file = true;
727
728    for line in diff.lines() {
729        if line.starts_with("+++ b/") || line.starts_with("--- a/") {
730            let file_path = line
731                .strip_prefix("+++ b/")
732                .unwrap_or_else(|| line.strip_prefix("--- a/").unwrap_or(&line[6..]));
733
734            include_current_file = !patterns.iter().any(|pattern| {
735                if pattern.starts_with('/') {
736                    // Exact match from root
737                    file_path.trim_start_matches('/') == pattern.trim_start_matches('/')
738                } else {
739                    // Match anywhere in path
740                    file_path.contains(pattern)
741                }
742            });
743        }
744
745        if include_current_file {
746            filtered.push_str(line);
747            filtered.push('\n');
748        }
749    }
750
751    Ok(filtered)
752}
753
754/// Chunk a large diff into smaller pieces that fit within token limit
755fn chunk_diff(diff: &str, max_tokens: usize) -> Result<String> {
756    // Use the enhanced multi-level chunking from utils
757    let effective_max = max_tokens.saturating_sub(PROMPT_OVERHEAD_TOKENS);
758    let chunked = utils::chunk_diff(diff, effective_max);
759
760    // Log if chunking occurred
761    if chunked.contains("---CHUNK") {
762        tracing::info!("Diff was chunked for token limit");
763    }
764
765    Ok(chunked)
766}
767
768/// Copy text to clipboard with proper error handling
769fn copy_to_clipboard(text: &str) -> Result<()> {
770    #[cfg(target_os = "macos")]
771    {
772        use std::io::Write;
773        use std::process::{Command, Stdio};
774
775        // Use pbcopy with properly piped stdin
776        let mut process = Command::new("pbcopy")
777            .stdin(Stdio::piped())
778            .spawn()
779            .context("Failed to spawn pbcopy process")?;
780
781        // Write to stdin, handling the Result properly
782        {
783            let stdin = process
784                .stdin
785                .as_mut()
786                .context("pbcopy stdin not available")?;
787            stdin
788                .write_all(text.as_bytes())
789                .context("Failed to write to clipboard")?;
790        }
791
792        let status = process
793            .wait()
794            .context("Failed to wait for pbcopy process")?;
795
796        if !status.success() {
797            anyhow::bail!("pbcopy exited with error: {:?}", status);
798        }
799    }
800
801    #[cfg(target_os = "linux")]
802    {
803        use std::io::Write;
804        use std::process::{Command, Stdio};
805
806        // Check if xclip is available, otherwise try xsel as fallback
807        let use_xclip = !Command::new("which")
808            .arg("xclip")
809            .output()?
810            .stdout
811            .is_empty();
812
813        let (cmd_name, args) = if use_xclip {
814            ("xclip", vec!["-selection", "clipboard"])
815        } else {
816            ("xsel", vec!["--clipboard", "--input"])
817        };
818
819        let mut process = Command::new(cmd_name)
820            .args(&args)
821            .stdin(Stdio::piped())
822            .spawn()
823            .context(format!("Failed to spawn {} process", cmd_name))?;
824
825        {
826            let stdin = process
827                .stdin
828                .as_mut()
829                .context(format!("{} stdin not available", cmd_name))?;
830            stdin
831                .write_all(text.as_bytes())
832                .context("Failed to write to clipboard")?;
833        }
834
835        let status = process
836            .wait()
837            .context(format!("Failed to wait for {} process", cmd_name))?;
838
839        if !status.success() {
840            anyhow::bail!("{} exited with error: {:?}", cmd_name, status);
841        }
842    }
843
844    #[cfg(target_os = "windows")]
845    {
846        let mut ctx = arboard::Clipboard::new()
847            .map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
848        ctx.set_text(text.to_string())
849            .map_err(|e| anyhow::anyhow!("Failed to set clipboard contents: {}", e))?;
850    }
851
852    Ok(())
853}