Skip to main content

auto_commit_rs/
cli.rs

1use anyhow::Result;
2use clap::{Parser, Subcommand};
3use colored::Colorize;
4use inquire::{Select, Text};
5
6use crate::config::AppConfig;
7
8#[derive(Parser, Debug)]
9#[command(
10    name = "cgen",
11    about = "Generate git commit messages via LLMs",
12    version,
13    after_help = "Any arguments after `cgen` (without a subcommand) are forwarded to `git commit`."
14)]
15pub struct Cli {
16    #[command(subcommand)]
17    pub command: Option<Command>,
18
19    /// Generate and print commit message without creating a commit
20    #[arg(long)]
21    pub dry_run: bool,
22
23    /// Print the final system prompt sent to the LLM (without diff payload)
24    #[arg(long)]
25    pub verbose: bool,
26
27    /// Create a semantic version tag after a successful commit
28    #[arg(long)]
29    pub tag: bool,
30
31    /// Extra arguments forwarded to `git commit`
32    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
33    pub extra_args: Vec<String>,
34}
35
36#[derive(Subcommand, Debug)]
37pub enum Command {
38    /// Open interactive configuration editor
39    Config {
40        /// Edit global config instead of local .env
41        #[arg(long, short)]
42        global: bool,
43    },
44    /// Undo latest commit (soft reset)
45    Undo,
46    /// Generate message from existing commit diff and rewrite commit message
47    Alter {
48        /// One hash: rewrite that commit from its own diff. Two hashes: use older..newer diff and rewrite newer.
49        #[arg(value_name = "HASH", num_args = 1..=2)]
50        commits: Vec<String>,
51    },
52}
53
54pub fn parse() -> Cli {
55    Cli::parse()
56}
57
58pub fn interactive_config(global: bool) -> Result<()> {
59    let mut cfg = AppConfig::load()?;
60    let scope = if global { "global" } else { "local" };
61
62    println!("\n{}  {} configuration\n", "cgen".cyan().bold(), scope);
63
64    loop {
65        let fields = cfg.fields_display();
66        let options: Vec<String> = fields
67            .iter()
68            .map(|(name, _suffix, val)| format!("{:<18} {}", name, val.dimmed()))
69            .collect();
70
71        let mut all_options = options.clone();
72        all_options.push("Save & Exit".green().to_string());
73        all_options.push("Exit without saving".red().to_string());
74
75        let selection = Select::new("Edit a setting:", all_options)
76            .with_page_size(17)
77            .prompt();
78
79        let selection = match selection {
80            Ok(s) => s,
81            Err(_) => break, // Ctrl+C / ESC
82        };
83
84        // Check for exit options
85        if selection.contains("Save & Exit") {
86            if global {
87                cfg.save_global()?;
88                let path = crate::config::global_config_path()
89                    .map(|p| p.display().to_string())
90                    .unwrap_or_default();
91                println!("\n{} Saved to {}", "done!".green().bold(), path.dimmed());
92            } else {
93                cfg.save_local()?;
94                println!("\n{} Saved to {}", "done!".green().bold(), ".env".dimmed());
95            }
96            break;
97        }
98        if selection.contains("Exit without saving") {
99            println!("{}", "Cancelled.".dimmed());
100            break;
101        }
102
103        // Find which field was selected
104        let idx = options.iter().position(|o| selection.contains(o.as_str()));
105        let idx = match idx {
106            Some(i) => i,
107            None => continue,
108        };
109
110        let (_name, suffix, _val) = &fields[idx];
111
112        // Edit the field
113        let new_value = match *suffix {
114            "PROVIDER" => {
115                let choices = vec!["gemini", "openai", "anthropic", "groq", "(custom)"];
116                match Select::new("Provider:", choices).prompt() {
117                    Ok("(custom)") => Text::new("Custom provider name:").prompt().ok(),
118                    Ok(v) => Some(v.to_string()),
119                    Err(_) => None,
120                }
121            }
122            "ONE_LINER" => {
123                let choices = vec!["1 (yes)", "0 (no)"];
124                Select::new("One-liner commits:", choices)
125                    .prompt()
126                    .ok()
127                    .map(|v| v.chars().next().unwrap().to_string())
128            }
129            "USE_GITMOJI" => {
130                let choices = vec!["0 (no)", "1 (yes)"];
131                Select::new("Use Gitmoji:", choices)
132                    .prompt()
133                    .ok()
134                    .map(|v| v.chars().next().unwrap().to_string())
135            }
136            "GITMOJI_FORMAT" => {
137                let choices = vec!["unicode", "shortcode"];
138                Select::new("Gitmoji format:", choices)
139                    .prompt()
140                    .ok()
141                    .map(|v| v.to_string())
142            }
143            "REVIEW_COMMIT" => {
144                let choices = vec!["0 (no)", "1 (yes)"];
145                Select::new("Review commit before confirming:", choices)
146                    .prompt()
147                    .ok()
148                    .map(|v| v.chars().next().unwrap().to_string())
149            }
150            "POST_COMMIT_PUSH" => {
151                let choices = vec!["ask", "always", "never"];
152                Select::new("Post-commit push behavior:", choices)
153                    .prompt()
154                    .ok()
155                    .map(|v| v.to_string())
156            }
157            "SUPPRESS_TOOL_OUTPUT" => {
158                let choices = vec!["0 (no)", "1 (yes)"];
159                Select::new("Suppress git command output:", choices)
160                    .prompt()
161                    .ok()
162                    .map(|v| v.chars().next().unwrap().to_string())
163            }
164            "WARN_STAGED_FILES_ENABLED" => {
165                let choices = vec!["1 (yes)", "0 (no)"];
166                Select::new("Warn when staged files exceed threshold:", choices)
167                    .prompt()
168                    .ok()
169                    .map(|v| v.chars().next().unwrap().to_string())
170            }
171            "WARN_STAGED_FILES_THRESHOLD" => Text::new("Warn threshold (staged files count):")
172                .with_help_message(
173                    "Integer value; warning shows when count is greater than this threshold",
174                )
175                .prompt()
176                .ok(),
177            "CONFIRM_NEW_VERSION" => {
178                let choices = vec!["1 (yes)", "0 (no)"];
179                Select::new("Confirm new semantic version tag:", choices)
180                    .prompt()
181                    .ok()
182                    .map(|v| v.chars().next().unwrap().to_string())
183            }
184            "API_KEY" => Text::new("API Key:")
185                .with_help_message("Your LLM provider API key")
186                .prompt()
187                .ok(),
188            _ => {
189                let current = fields[idx].2.clone();
190                let prompt_text = format!("{}:", fields[idx].0);
191                Text::new(&prompt_text).with_default(&current).prompt().ok()
192            }
193        };
194
195        if let Some(val) = new_value {
196            if let Err(err) = cfg.set_field(suffix, &val) {
197                println!("  {} {}", "error:".red().bold(), err);
198                continue;
199            }
200
201            // When switching providers, auto-set the model to that provider's default
202            if *suffix == "PROVIDER" {
203                let default_model = crate::provider::default_model_for(&val);
204                cfg.set_field("MODEL", default_model)?;
205                if default_model.is_empty() {
206                    println!(
207                        "  {} Model cleared (set it manually)",
208                        "note:".yellow().bold()
209                    );
210                } else {
211                    println!(
212                        "  {} Model set to {}",
213                        "note:".yellow().bold(),
214                        default_model.dimmed()
215                    );
216                }
217            }
218        }
219    }
220
221    Ok(())
222}