Skip to main content

rusty_commit/commands/
setup.rs

1use anyhow::Result;
2use colored::Colorize;
3use dialoguer::{Confirm, Input, Select};
4
5use crate::cli::SetupCommand;
6use crate::config::Config;
7
8/// Provider option for the setup wizard
9struct ProviderOption {
10    name: &'static str,
11    display: &'static str,
12    default_model: &'static str,
13    requires_key: bool,
14    category: ProviderCategory,
15}
16
17#[derive(Clone, Copy, PartialEq)]
18enum ProviderCategory {
19    Popular,
20    Local,
21    Cloud,
22    Enterprise,
23}
24
25impl ProviderOption {
26    fn all() -> Vec<Self> {
27        vec![
28            // Popular providers
29            ProviderOption {
30                name: "openai",
31                display: "OpenAI (GPT-4o, GPT-4o-mini)",
32                default_model: "gpt-4o-mini",
33                requires_key: true,
34                category: ProviderCategory::Popular,
35            },
36            ProviderOption {
37                name: "anthropic",
38                display: "Anthropic (Claude 3.5 Sonnet, Haiku)",
39                default_model: "claude-3-5-haiku-20241022",
40                requires_key: true,
41                category: ProviderCategory::Popular,
42            },
43            ProviderOption {
44                name: "gemini",
45                display: "Google Gemini (Flash, Pro)",
46                default_model: "gemini-1.5-flash",
47                requires_key: true,
48                category: ProviderCategory::Popular,
49            },
50            // Local/Self-hosted
51            ProviderOption {
52                name: "ollama",
53                display: "Ollama (Local models - free, private)",
54                default_model: "llama3.2",
55                requires_key: false,
56                category: ProviderCategory::Local,
57            },
58            // Cloud providers
59            ProviderOption {
60                name: "xai",
61                display: "xAI (Grok)",
62                default_model: "grok-2",
63                requires_key: true,
64                category: ProviderCategory::Cloud,
65            },
66            ProviderOption {
67                name: "deepseek",
68                display: "DeepSeek (V3, Coder)",
69                default_model: "deepseek-chat",
70                requires_key: true,
71                category: ProviderCategory::Cloud,
72            },
73            ProviderOption {
74                name: "groq",
75                display: "Groq (Fast inference)",
76                default_model: "llama-3.1-8b-instant",
77                requires_key: true,
78                category: ProviderCategory::Cloud,
79            },
80            ProviderOption {
81                name: "openrouter",
82                display: "OpenRouter (Access many models)",
83                default_model: "anthropic/claude-3.5-haiku",
84                requires_key: true,
85                category: ProviderCategory::Cloud,
86            },
87            ProviderOption {
88                name: "mistral",
89                display: "Mistral AI",
90                default_model: "mistral-small-latest",
91                requires_key: true,
92                category: ProviderCategory::Cloud,
93            },
94            ProviderOption {
95                name: "perplexity",
96                display: "Perplexity AI",
97                default_model: "sonar",
98                requires_key: true,
99                category: ProviderCategory::Cloud,
100            },
101            // Enterprise
102            ProviderOption {
103                name: "azure",
104                display: "Azure OpenAI",
105                default_model: "gpt-4o",
106                requires_key: true,
107                category: ProviderCategory::Enterprise,
108            },
109            ProviderOption {
110                name: "bedrock",
111                display: "AWS Bedrock",
112                default_model: "anthropic.claude-3-haiku-20240307-v1:0",
113                requires_key: true,
114                category: ProviderCategory::Enterprise,
115            },
116            ProviderOption {
117                name: "vertex",
118                display: "Google Vertex AI",
119                default_model: "gemini-1.5-flash-001",
120                requires_key: true,
121                category: ProviderCategory::Enterprise,
122            },
123        ]
124    }
125
126    fn by_name(name: &str) -> Option<Self> {
127        Self::all().into_iter().find(|p| p.name == name)
128    }
129}
130
131/// Commit format options
132#[derive(Clone, Copy)]
133enum CommitFormat {
134    Conventional,
135    Gitmoji,
136    Simple,
137}
138
139impl CommitFormat {
140    fn display(&self) -> &'static str {
141        match self {
142            CommitFormat::Conventional => "Conventional Commits (feat:, fix:, docs:, etc.)",
143            CommitFormat::Gitmoji => "GitMoji (✨ feat:, πŸ› fix:, πŸ“ docs:, etc.)",
144            CommitFormat::Simple => "Simple (no prefix)",
145        }
146    }
147
148    fn as_str(&self) -> &'static str {
149        match self {
150            CommitFormat::Conventional => "conventional",
151            CommitFormat::Gitmoji => "gitmoji",
152            CommitFormat::Simple => "simple",
153        }
154    }
155
156    fn all() -> Vec<Self> {
157        vec![
158            CommitFormat::Conventional,
159            CommitFormat::Gitmoji,
160            CommitFormat::Simple,
161        ]
162    }
163}
164
165/// Language options
166struct LanguageOption {
167    code: &'static str,
168    display: &'static str,
169}
170
171impl LanguageOption {
172    fn all() -> Vec<Self> {
173        vec![
174            LanguageOption {
175                code: "en",
176                display: "English",
177            },
178            LanguageOption {
179                code: "zh",
180                display: "Chinese (δΈ­ζ–‡)",
181            },
182            LanguageOption {
183                code: "es",
184                display: "Spanish (EspaΓ±ol)",
185            },
186            LanguageOption {
187                code: "fr",
188                display: "French (FranΓ§ais)",
189            },
190            LanguageOption {
191                code: "de",
192                display: "German (Deutsch)",
193            },
194            LanguageOption {
195                code: "ja",
196                display: "Japanese (ζ—₯本θͺž)",
197            },
198            LanguageOption {
199                code: "ko",
200                display: "Korean (ν•œκ΅­μ–΄)",
201            },
202            LanguageOption {
203                code: "ru",
204                display: "Russian (Русский)",
205            },
206            LanguageOption {
207                code: "pt",
208                display: "Portuguese (PortuguΓͺs)",
209            },
210            LanguageOption {
211                code: "it",
212                display: "Italian (Italiano)",
213            },
214            LanguageOption {
215                code: "other",
216                display: "Other (specify)",
217            },
218        ]
219    }
220}
221
222pub async fn execute(cmd: SetupCommand) -> Result<()> {
223    print_welcome_header();
224
225    // Determine if we're doing quick or advanced setup
226    let is_advanced = if cmd.defaults {
227        // Non-interactive defaults mode
228        return apply_defaults().await;
229    } else if cmd.advanced {
230        true
231    } else {
232        // Ask user which mode they prefer
233        println!();
234        println!("{}", "Choose your setup mode:".bold());
235        println!();
236
237        let modes = vec![
238            "πŸš€ Quick Setup - Just the essentials (recommended)",
239            "βš™οΈ  Advanced Setup - Full configuration options",
240        ];
241
242        let selection = Select::new()
243            .with_prompt("Select mode")
244            .items(&modes)
245            .default(0)
246            .interact()?;
247
248        selection == 1
249    };
250
251    if is_advanced {
252        run_advanced_setup().await
253    } else {
254        run_quick_setup().await
255    }
256}
257
258fn print_welcome_header() {
259    println!();
260    println!(
261        "{} {}",
262        "πŸš€".green(),
263        "Welcome to Rusty Commit Setup!".bold().white()
264    );
265    println!();
266    println!(
267        "{}",
268        "   Let's get you set up with AI-powered commit messages.".dimmed()
269    );
270    println!();
271}
272
273async fn run_quick_setup() -> Result<()> {
274    let mut config = Config::load()?;
275
276    // Step 1: Provider selection
277    let provider = select_provider_quick()?;
278    config.ai_provider = Some(provider.name.to_string());
279    config.model = Some(provider.default_model.to_string());
280
281    // Step 2: API key (if needed)
282    if provider.requires_key {
283        let api_key = prompt_for_api_key(provider.name)?;
284        if !api_key.is_empty() {
285            config.api_key = Some(api_key);
286        }
287    } else {
288        println!();
289        println!(
290            "{} {} doesn't require an API key - great for privacy!",
291            "ℹ️".blue(),
292            provider.name.bright_cyan()
293        );
294    }
295
296    // Step 3: Commit format
297    let format = select_commit_format()?;
298    config.commit_type = Some(format.as_str().to_string());
299    config.emoji = Some(matches!(format, CommitFormat::Gitmoji));
300
301    // Save configuration
302    config.save()?;
303
304    print_completion_message(&config, false);
305    Ok(())
306}
307
308async fn run_advanced_setup() -> Result<()> {
309    let mut config = Config::load()?;
310
311    // Section 1: AI Provider Configuration
312    print_section_header("πŸ€– AI Provider Configuration");
313
314    let provider = select_provider_advanced()?;
315    config.ai_provider = Some(provider.name.to_string());
316
317    // Custom model selection
318    let default_model = provider.default_model;
319    let use_custom_model = Confirm::new()
320        .with_prompt(format!(
321            "Use default model ({}), or specify a custom one?",
322            default_model.bright_cyan()
323        ))
324        .default(true)
325        .interact()?;
326
327    if use_custom_model {
328        config.model = Some(default_model.to_string());
329    } else {
330        let custom_model: String = Input::new()
331            .with_prompt("Enter model name")
332            .default(default_model.to_string())
333            .interact()?;
334        config.model = Some(custom_model);
335    }
336
337    // API key or custom endpoint
338    if provider.requires_key {
339        let api_key = prompt_for_api_key(provider.name)?;
340        if !api_key.is_empty() {
341            config.api_key = Some(api_key);
342        }
343
344        // Custom API URL option
345        let use_custom_url = Confirm::new()
346            .with_prompt("Use a custom API endpoint URL?")
347            .default(false)
348            .interact()?;
349
350        if use_custom_url {
351            let custom_url: String = Input::new()
352                .with_prompt("Enter custom API URL")
353                .default(format!("https://api.{}.com/v1", provider.name))
354                .interact()?;
355            config.api_url = Some(custom_url);
356        }
357    }
358
359    // Section 2: Commit Message Style
360    print_section_header("πŸ“ Commit Message Style");
361
362    let format = select_commit_format()?;
363    config.commit_type = Some(format.as_str().to_string());
364    config.emoji = Some(matches!(format, CommitFormat::Gitmoji));
365
366    // Capitalization
367    config.description_capitalize = Some(
368        Confirm::new()
369            .with_prompt("Capitalize the first letter of commit messages?")
370            .default(true)
371            .interact()?,
372    );
373
374    // Period at end
375    config.description_add_period = Some(
376        Confirm::new()
377            .with_prompt("Add period at the end of commit messages?")
378            .default(false)
379            .interact()?,
380    );
381
382    // Max length
383    let max_length: usize = Input::new()
384        .with_prompt("Maximum commit message length")
385        .default(100)
386        .validate_with(|input: &usize| -> Result<(), &str> {
387            if *input >= 50 && *input <= 200 {
388                Ok(())
389            } else {
390                Err("Please enter a value between 50 and 200")
391            }
392        })
393        .interact()?;
394    config.description_max_length = Some(max_length);
395
396    // Language selection
397    let language = select_language()?;
398    config.language = Some(language.to_string());
399
400    // Section 3: Behavior Settings
401    print_section_header("βš™οΈ  Behavior Settings");
402
403    // Generate count
404    let generate_count: u8 = Input::new()
405        .with_prompt("Number of commit variations to generate (1-5)")
406        .default(1)
407        .validate_with(|input: &u8| -> Result<(), &str> {
408            if *input >= 1 && *input <= 5 {
409                Ok(())
410            } else {
411                Err("Please enter a value between 1 and 5")
412            }
413        })
414        .interact()?;
415    config.generate_count = Some(generate_count);
416
417    // Git push option
418    config.gitpush = Some(
419        Confirm::new()
420            .with_prompt("Automatically push commits to remote?")
421            .default(false)
422            .interact()?,
423    );
424
425    // One-line commits
426    config.one_line_commit = Some(
427        Confirm::new()
428            .with_prompt("Always use one-line commits (no body)?")
429            .default(false)
430            .interact()?,
431    );
432
433    // Enable commit body
434    config.enable_commit_body = Some(
435        Confirm::new()
436            .with_prompt("Allow multi-line commit messages with body?")
437            .default(false)
438            .interact()?,
439    );
440
441    // Section 4: Advanced Features
442    print_section_header("πŸ”§ Advanced Features");
443
444    // Learn from history
445    config.learn_from_history = Some(
446        Confirm::new()
447            .with_prompt("Learn commit style from repository history?")
448            .default(false)
449            .interact()?,
450    );
451
452    if config.learn_from_history == Some(true) {
453        let history_count: usize = Input::new()
454            .with_prompt("Number of commits to analyze for style")
455            .default(50)
456            .validate_with(|input: &usize| -> Result<(), &str> {
457                if *input >= 10 && *input <= 200 {
458                    Ok(())
459                } else {
460                    Err("Please enter a value between 10 and 200")
461                }
462            })
463            .interact()?;
464        config.history_commits_count = Some(history_count);
465    }
466
467    // Clipboard on timeout
468    config.clipboard_on_timeout = Some(
469        Confirm::new()
470            .with_prompt("Copy commit message to clipboard on timeout/error?")
471            .default(true)
472            .interact()?,
473    );
474
475    // Hook settings
476    config.hook_strict = Some(
477        Confirm::new()
478            .with_prompt("Strict hook mode (fail on hook errors)?")
479            .default(true)
480            .interact()?,
481    );
482
483    let hook_timeout: u64 = Input::new()
484        .with_prompt("Hook timeout (milliseconds)")
485        .default(30000)
486        .validate_with(|input: &u64| -> Result<(), &str> {
487            if *input >= 1000 && *input <= 300000 {
488                Ok(())
489            } else {
490                Err("Please enter a value between 1000 and 300000")
491            }
492        })
493        .interact()?;
494    config.hook_timeout_ms = Some(hook_timeout);
495
496    // Section 5: Token Limits (for advanced users)
497    print_section_header("🎯 Token Limits (Optional)");
498
499    let configure_tokens = Confirm::new()
500        .with_prompt("Configure token limits? (Most users can skip this)")
501        .default(false)
502        .interact()?;
503
504    if configure_tokens {
505        let max_input: usize = Input::new()
506            .with_prompt("Maximum input tokens")
507            .default(4096)
508            .interact()?;
509        config.tokens_max_input = Some(max_input);
510
511        let max_output: u32 = Input::new()
512            .with_prompt("Maximum output tokens")
513            .default(500)
514            .interact()?;
515        config.tokens_max_output = Some(max_output);
516    }
517
518    // Save configuration
519    config.save()?;
520
521    print_completion_message(&config, true);
522    Ok(())
523}
524
525fn select_provider_quick() -> Result<ProviderOption> {
526    println!();
527    println!("{}", "Select your AI provider:".bold());
528    println!(
529        "{}",
530        "   This determines which AI will generate your commit messages.".dimmed()
531    );
532    println!();
533
534    let providers = ProviderOption::all();
535    let popular: Vec<_> = providers
536        .iter()
537        .filter(|p| p.category == ProviderCategory::Popular)
538        .map(|p| p.display)
539        .collect();
540
541    let local: Vec<_> = providers
542        .iter()
543        .filter(|p| p.category == ProviderCategory::Local)
544        .map(|p| p.display)
545        .collect();
546
547    let others: Vec<_> = providers
548        .iter()
549        .filter(|p| {
550            p.category == ProviderCategory::Cloud || p.category == ProviderCategory::Enterprise
551        })
552        .map(|p| p.display)
553        .collect();
554
555    let mut all_displays = Vec::new();
556    all_displays.push("─── Popular Providers ───".dimmed().to_string());
557    all_displays.extend(popular.iter().map(|s| s.to_string()));
558    all_displays.push("─── Local/Private ───".dimmed().to_string());
559    all_displays.extend(local.iter().map(|s| s.to_string()));
560    all_displays.push("─── More Cloud Providers ───".dimmed().to_string());
561    all_displays.extend(others.iter().map(|s| s.to_string()));
562
563    let selection = Select::new()
564        .with_prompt("AI Provider")
565        .items(&all_displays)
566        .default(1) // First real item (after header)
567        .interact()?;
568
569    // Map selection back to provider (accounting for headers)
570    let provider_name = match selection {
571        1 => "openai",
572        2 => "anthropic",
573        3 => "gemini",
574        5 => "ollama",
575        7 => "xai",
576        8 => "deepseek",
577        9 => "groq",
578        10 => "openrouter",
579        11 => "mistral",
580        12 => "perplexity",
581        13 => "azure",
582        14 => "bedrock",
583        15 => "vertex",
584        _ => "openai",
585    };
586
587    let provider = ProviderOption::by_name(provider_name).unwrap();
588
589    println!();
590    println!(
591        "{} Selected: {} {}",
592        "βœ“".green(),
593        provider.name.bright_cyan(),
594        format!("(model: {})", provider.default_model).dimmed()
595    );
596
597    Ok(provider)
598}
599
600fn select_provider_advanced() -> Result<ProviderOption> {
601    println!();
602    println!("{}", "Select your AI provider:".bold());
603    println!();
604
605    let providers = ProviderOption::all();
606    let items: Vec<_> = providers.iter().map(|p| p.display).collect();
607
608    let selection = Select::new()
609        .with_prompt("AI Provider")
610        .items(&items)
611        .default(0)
612        .interact()?;
613
614    let provider = providers.into_iter().nth(selection).unwrap();
615
616    println!();
617    println!("{} Selected: {}", "βœ“".green(), provider.name.bright_cyan());
618
619    Ok(provider)
620}
621
622fn prompt_for_api_key(provider_name: &str) -> Result<String> {
623    println!();
624    println!("{}", "API Key Configuration".bold());
625    println!(
626        "{}",
627        format!(
628            "   Get your API key from the {} dashboard",
629            provider_name.bright_cyan()
630        )
631        .dimmed()
632    );
633    println!(
634        "{}",
635        "   Your key will be stored securely in your system's keychain.".dimmed()
636    );
637    println!();
638
639    let api_key: String = Input::new()
640        .with_prompt(format!(
641            "Enter your {} API key",
642            provider_name.bright_cyan()
643        ))
644        .allow_empty(true)
645        .interact()?;
646
647    let trimmed = api_key.trim();
648
649    if trimmed.is_empty() {
650        println!();
651        println!(
652            "{} No API key provided. You can set it later with: {}",
653            "⚠️".yellow(),
654            format!("rco config set RCO_API_KEY=<your_key>").bright_cyan()
655        );
656    } else {
657        // Show last 4 characters for confirmation
658        let masked = if trimmed.len() > 4 {
659            format!("{}****", &trimmed[trimmed.len() - 4..])
660        } else {
661            "****".to_string()
662        };
663        println!();
664        println!("{} API key saved: {}", "βœ“".green(), masked.dimmed());
665    }
666
667    Ok(trimmed.to_string())
668}
669
670fn select_commit_format() -> Result<CommitFormat> {
671    println!();
672    println!("{}", "Commit Message Format".bold());
673    println!(
674        "{}",
675        "   Choose how your commit messages should be formatted.".dimmed()
676    );
677    println!();
678
679    let formats = CommitFormat::all();
680    let items: Vec<_> = formats.iter().map(|f| f.display()).collect();
681
682    let selection = Select::new()
683        .with_prompt("Commit format")
684        .items(&items)
685        .default(0)
686        .interact()?;
687
688    let format = formats.into_iter().nth(selection).unwrap();
689
690    println!();
691    println!(
692        "{} Selected: {}",
693        "βœ“".green(),
694        format.as_str().bright_cyan()
695    );
696
697    // Show example
698    let example = match format {
699        CommitFormat::Conventional => "feat(auth): Add login functionality",
700        CommitFormat::Gitmoji => "✨ feat(auth): Add login functionality",
701        CommitFormat::Simple => "Add login functionality",
702    };
703    println!("  Example: {}", example.dimmed());
704
705    Ok(format)
706}
707
708fn select_language() -> Result<String> {
709    println!();
710    println!("{}", "Output Language".bold());
711    println!(
712        "{}",
713        "   What language should commit messages be generated in?".dimmed()
714    );
715    println!();
716
717    let languages = LanguageOption::all();
718    let items: Vec<_> = languages.iter().map(|l| l.display).collect();
719
720    let selection = Select::new()
721        .with_prompt("Language")
722        .items(&items)
723        .default(0)
724        .interact()?;
725
726    let lang = &languages[selection];
727
728    if lang.code == "other" {
729        let custom: String = Input::new()
730            .with_prompt("Enter language code (e.g., 'nl' for Dutch)")
731            .interact()?;
732        Ok(custom)
733    } else {
734        Ok(lang.code.to_string())
735    }
736}
737
738fn print_section_header(title: &str) {
739    println!();
740    println!(
741        "{}",
742        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
743    );
744    println!("{}", title.bold());
745    println!(
746        "{}",
747        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
748    );
749}
750
751fn print_completion_message(config: &Config, is_advanced: bool) {
752    println!();
753    println!(
754        "{}",
755        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
756    );
757    println!();
758    println!("{} Setup complete! πŸŽ‰", "βœ“".green().bold());
759    println!();
760
761    // Show summary
762    println!("{}", "Configuration Summary:".bold());
763    println!();
764
765    if let Some(ref provider) = config.ai_provider {
766        println!("  {} Provider: {}", "β€’".cyan(), provider.bright_white());
767    }
768    if let Some(ref model) = config.model {
769        println!("  {} Model: {}", "β€’".cyan(), model.bright_white());
770    }
771    if let Some(ref commit_type) = config.commit_type {
772        println!(
773            "  {} Commit format: {}",
774            "β€’".cyan(),
775            commit_type.bright_white()
776        );
777    }
778    if let Some(ref language) = config.language {
779        if language != "en" {
780            println!("  {} Language: {}", "β€’".cyan(), language.bright_white());
781        }
782    }
783
784    println!();
785    println!("{} You're ready to go!", "β†’".cyan());
786    println!();
787    println!("   Try it now:  {}", "rco".bold().bright_cyan().underline());
788    println!();
789
790    if is_advanced {
791        println!("   Make a commit:  {}", "git add . && rco".dimmed());
792        println!();
793        println!(
794            "{} Modify settings anytime: {}",
795            "β†’".cyan(),
796            "rco setup --advanced".bright_cyan()
797        );
798        println!(
799            "{} Or use: {}",
800            "β†’".cyan(),
801            "rco config set <key>=<value>".bright_cyan()
802        );
803    } else {
804        println!(
805            "   Want more options? Run: {}",
806            "rco setup --advanced".bright_cyan()
807        );
808    }
809
810    println!();
811    println!(
812        "{}",
813        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
814    );
815}
816
817async fn apply_defaults() -> Result<()> {
818    let mut config = Config::load()?;
819
820    // Apply sensible defaults without prompting
821    config.ai_provider = Some("openai".to_string());
822    config.model = Some("gpt-4o-mini".to_string());
823    config.commit_type = Some("conventional".to_string());
824    config.description_capitalize = Some(true);
825    config.description_add_period = Some(false);
826    config.description_max_length = Some(100);
827    config.language = Some("en".to_string());
828    config.generate_count = Some(1);
829    config.emoji = Some(false);
830    config.gitpush = Some(false);
831    config.one_line_commit = Some(false);
832    config.enable_commit_body = Some(false);
833    config.learn_from_history = Some(false);
834    config.clipboard_on_timeout = Some(true);
835    config.hook_strict = Some(true);
836    config.hook_timeout_ms = Some(30000);
837    config.tokens_max_input = Some(4096);
838    config.tokens_max_output = Some(500);
839
840    config.save()?;
841
842    println!();
843    println!("{} Default configuration applied!", "βœ“".green().bold());
844    println!();
845    println!("   Provider: openai (gpt-4o-mini)");
846    println!("   Format: conventional commits");
847    println!();
848    println!(
849        "   Set your API key: {}",
850        "rco config set RCO_API_KEY=<your_key>".bright_cyan()
851    );
852    println!();
853
854    Ok(())
855}