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