Skip to main content

auto_commit_rs/
cli.rs

1use std::collections::HashSet;
2
3use anyhow::Result;
4use clap::{Parser, Subcommand};
5use colored::Colorize;
6use inquire::{Select, Text};
7
8use crate::config::AppConfig;
9
10#[derive(Parser, Debug)]
11#[command(
12    name = "cgen",
13    about = "Generate git commit messages via LLMs",
14    version,
15    after_help = "Any arguments after `cgen` (without a subcommand) are forwarded to `git commit`."
16)]
17pub struct Cli {
18    #[command(subcommand)]
19    pub command: Option<Command>,
20
21    /// Generate and print commit message without creating a commit
22    #[arg(long)]
23    pub dry_run: bool,
24
25    /// Print the final system prompt sent to the LLM (without diff payload)
26    #[arg(long)]
27    pub verbose: bool,
28
29    /// Create a semantic version tag after a successful commit
30    #[arg(long)]
31    pub tag: bool,
32
33    /// Extra arguments forwarded to `git commit`
34    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
35    pub extra_args: Vec<String>,
36}
37
38#[derive(Subcommand, Debug)]
39pub enum Command {
40    /// Open interactive configuration editor
41    Config,
42    /// Undo latest commit (soft reset)
43    Undo,
44    /// Generate message from existing commit diff and rewrite commit message
45    Alter {
46        /// One hash: rewrite that commit from its own diff. Two hashes: use older..newer diff and rewrite newer.
47        #[arg(value_name = "HASH", num_args = 1..=2)]
48        commits: Vec<String>,
49    },
50    /// Update cgen to the latest version
51    Update,
52    /// Print the LLM system prompt without running anything
53    Prompt,
54}
55
56pub fn parse() -> Cli {
57    Cli::parse()
58}
59
60/// Menu entry types for the grouped interactive config
61enum MenuEntry {
62    GroupHeader {
63        name: &'static str,
64        expanded: bool,
65    },
66    SubgroupHeader {
67        name: &'static str,
68        is_last_subgroup: bool,
69    },
70    Field {
71        display_name: &'static str,
72        suffix: &'static str,
73        value: String,
74        is_last_in_group: bool,
75        in_subgroup: bool,
76        parent_is_last_subgroup: bool,
77        is_last_in_subgroup: bool,
78    },
79}
80
81pub fn interactive_config(global: bool) -> Result<()> {
82    let mut cfg = AppConfig::load()?;
83    let scope = if global { "global" } else { "local" };
84
85    println!("\n{}  {} configuration\n", "cgen".cyan().bold(), scope);
86
87    let mut expanded: HashSet<&str> = HashSet::new();
88    expanded.insert("Basic");
89    let mut first_render = true;
90
91    loop {
92        // Clear screen and re-home cursor for in-place redraw
93        if first_render {
94            first_render = false;
95        } else {
96            print!("\x1B[2J\x1B[H");
97            println!("\n{}  {} configuration\n", "cgen".cyan().bold(), scope);
98        }
99
100        let groups = cfg.grouped_fields();
101        let mut entries: Vec<MenuEntry> = Vec::new();
102
103        for group in &groups {
104            let is_expanded = expanded.contains(group.name);
105            entries.push(MenuEntry::GroupHeader {
106                name: group.name,
107                expanded: is_expanded,
108            });
109
110            if is_expanded {
111                let has_subgroups = !group.subgroups.is_empty();
112
113                for (i, (display_name, suffix, val)) in group.fields.iter().enumerate() {
114                    let is_last = !has_subgroups && i == group.fields.len() - 1;
115                    entries.push(MenuEntry::Field {
116                        display_name,
117                        suffix,
118                        value: val.clone(),
119                        is_last_in_group: is_last,
120                        in_subgroup: false,
121                        parent_is_last_subgroup: false,
122                        is_last_in_subgroup: false,
123                    });
124                }
125
126                for (sg_idx, sg) in group.subgroups.iter().enumerate() {
127                    let is_last_sg = sg_idx == group.subgroups.len() - 1;
128                    entries.push(MenuEntry::SubgroupHeader {
129                        name: sg.name,
130                        is_last_subgroup: is_last_sg,
131                    });
132                    for (f_idx, (display_name, suffix, val)) in sg.fields.iter().enumerate() {
133                        let is_last_field = f_idx == sg.fields.len() - 1;
134                        entries.push(MenuEntry::Field {
135                            display_name,
136                            suffix,
137                            value: val.clone(),
138                            is_last_in_group: is_last_sg && is_last_field,
139                            in_subgroup: true,
140                            parent_is_last_subgroup: is_last_sg,
141                            is_last_in_subgroup: is_last_field,
142                        });
143                    }
144                }
145            }
146        }
147
148        let options: Vec<String> = entries
149            .iter()
150            .map(|entry| match entry {
151                MenuEntry::GroupHeader { name, expanded } => {
152                    let arrow = if *expanded { "\u{25BC}" } else { "\u{25B6}" };
153                    format!("{} {}", arrow, name.bold())
154                }
155                MenuEntry::SubgroupHeader {
156                    name,
157                    is_last_subgroup,
158                } => {
159                    let connector = if *is_last_subgroup {
160                        "\u{2514}\u{2500}\u{2500}"
161                    } else {
162                        "\u{251C}\u{2500}\u{2500}"
163                    };
164                    format!("  {} {}", connector, name.bold().dimmed())
165                }
166                MenuEntry::Field {
167                    display_name,
168                    value,
169                    in_subgroup,
170                    parent_is_last_subgroup,
171                    is_last_in_subgroup,
172                    is_last_in_group,
173                    ..
174                } => {
175                    if *in_subgroup {
176                        // Indent deeper under subgroup, with continuation line from parent
177                        let pipe = if *parent_is_last_subgroup {
178                            " "
179                        } else {
180                            "\u{2502}"
181                        };
182                        let connector = if *is_last_in_subgroup {
183                            "\u{2514}\u{2500}\u{2500}"
184                        } else {
185                            "\u{251C}\u{2500}\u{2500}"
186                        };
187                        format!(
188                            "  {}   {} {:<22} {}",
189                            pipe,
190                            connector,
191                            display_name,
192                            value.dimmed()
193                        )
194                    } else {
195                        let connector = if *is_last_in_group {
196                            "\u{2514}\u{2500}\u{2500}"
197                        } else {
198                            "\u{251C}\u{2500}\u{2500}"
199                        };
200                        format!(
201                            "  {} {:<22} {}",
202                            connector,
203                            display_name,
204                            value.dimmed()
205                        )
206                    }
207                }
208            })
209            .collect();
210
211        let mut all_options = options.clone();
212        all_options.push("Save & Exit".green().to_string());
213        all_options.push("Exit without saving".red().to_string());
214
215        let selection = Select::new("Edit a setting:", all_options)
216            .with_page_size(22)
217            .prompt();
218
219        let selection = match selection {
220            Ok(s) => s,
221            Err(_) => break,
222        };
223
224        if selection.contains("Save & Exit") {
225            if global {
226                cfg.save_global()?;
227                let path = crate::config::global_config_path()
228                    .map(|p| p.display().to_string())
229                    .unwrap_or_default();
230                println!("\n{} Saved to {}", "done!".green().bold(), path.dimmed());
231            } else {
232                cfg.save_local()?;
233                println!("\n{} Saved to {}", "done!".green().bold(), ".env".dimmed());
234            }
235            break;
236        }
237        if selection.contains("Exit without saving") {
238            println!("{}", "Cancelled.".dimmed());
239            break;
240        }
241
242        // Find which entry was selected
243        let idx = options.iter().position(|o| selection.contains(o.as_str()));
244        let idx = match idx {
245            Some(i) => i,
246            None => continue,
247        };
248
249        match &entries[idx] {
250            MenuEntry::GroupHeader { name, expanded: is_expanded } => {
251                if *is_expanded {
252                    expanded.remove(name);
253                } else {
254                    expanded.insert(name);
255                }
256                continue;
257            }
258            MenuEntry::SubgroupHeader { .. } => {
259                continue;
260            }
261            MenuEntry::Field { suffix, .. } => {
262                let new_value = edit_field(suffix, &cfg);
263                if let Some(val) = new_value {
264                    if let Err(err) = cfg.set_field(suffix, &val) {
265                        println!("  {} {}", "error:".red().bold(), err);
266                        continue;
267                    }
268                    if *suffix == "PROVIDER" {
269                        let default_model = crate::provider::default_model_for(&val);
270                        cfg.set_field("MODEL", default_model)?;
271                        if default_model.is_empty() {
272                            println!(
273                                "  {} Model cleared (set it manually)",
274                                "note:".yellow().bold()
275                            );
276                        } else {
277                            println!(
278                                "  {} Model set to {}",
279                                "note:".yellow().bold(),
280                                default_model.dimmed()
281                            );
282                        }
283                    }
284                }
285            }
286        }
287    }
288
289    Ok(())
290}
291
292fn edit_field(suffix: &str, cfg: &AppConfig) -> Option<String> {
293    match suffix {
294        "PROVIDER" => {
295            let choices = vec!["gemini", "openai", "anthropic", "groq", "(custom)"];
296            match Select::new("Provider:", choices).prompt() {
297                Ok("(custom)") => Text::new("Custom provider name:").prompt().ok(),
298                Ok(v) => Some(v.to_string()),
299                Err(_) => None,
300            }
301        }
302        "ONE_LINER" => {
303            let choices = vec!["enabled", "disabled"];
304            Select::new("One-liner commits:", choices)
305                .prompt()
306                .ok()
307                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
308        }
309        "USE_GITMOJI" => {
310            let choices = vec!["disabled", "enabled"];
311            Select::new("Use Gitmoji:", choices)
312                .prompt()
313                .ok()
314                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
315        }
316        "GITMOJI_FORMAT" => {
317            let choices = vec!["unicode", "shortcode"];
318            Select::new("Gitmoji format:", choices)
319                .prompt()
320                .ok()
321                .map(|v| v.to_string())
322        }
323        "REVIEW_COMMIT" => {
324            let choices = vec!["disabled", "enabled"];
325            Select::new("Review commit before confirming:", choices)
326                .prompt()
327                .ok()
328                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
329        }
330        "POST_COMMIT_PUSH" => {
331            let choices = vec!["ask", "always", "never"];
332            Select::new("Post-commit push behavior:", choices)
333                .prompt()
334                .ok()
335                .map(|v| v.to_string())
336        }
337        "SUPPRESS_TOOL_OUTPUT" => {
338            let choices = vec!["disabled", "enabled"];
339            Select::new("Suppress git command output:", choices)
340                .prompt()
341                .ok()
342                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
343        }
344        "WARN_STAGED_FILES_ENABLED" => {
345            let choices = vec!["enabled", "disabled"];
346            Select::new("Warn when staged files exceed threshold:", choices)
347                .prompt()
348                .ok()
349                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
350        }
351        "WARN_STAGED_FILES_THRESHOLD" => Text::new("Warn threshold (staged files count):")
352            .with_help_message(
353                "Integer value; warning shows when count is greater than this threshold",
354            )
355            .prompt()
356            .ok(),
357        "CONFIRM_NEW_VERSION" => {
358            let choices = vec!["enabled", "disabled"];
359            Select::new("Confirm new semantic version tag:", choices)
360                .prompt()
361                .ok()
362                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
363        }
364        "AUTO_UPDATE" => {
365            let choices = vec!["enabled", "disabled"];
366            Select::new("Enable automatic updates:", choices)
367                .prompt()
368                .ok()
369                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
370        }
371        "API_KEY" => Text::new("API Key:")
372            .with_help_message("Your LLM provider API key")
373            .prompt()
374            .ok(),
375        _ => {
376            let fields = cfg.fields_display();
377            let field = fields.iter().find(|(_, s, _)| *s == suffix);
378            match field {
379                Some((name, _, val)) => {
380                    let prompt_text = format!("{}:", name);
381                    Text::new(&prompt_text).with_default(val).prompt().ok()
382                }
383                None => None,
384            }
385        }
386    }
387}