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;
9use crate::preset::LlmPresetFields;
10use crate::ui;
11
12#[derive(Parser, Debug)]
13#[command(
14    name = "cgen",
15    about = "Generate git commit messages via LLMs",
16    version,
17    after_help = "Any arguments after `cgen` (without a subcommand) are forwarded to `git commit`."
18)]
19pub struct Cli {
20    #[command(subcommand)]
21    pub command: Option<Command>,
22
23    /// Generate and print commit message without creating a commit
24    #[arg(long)]
25    pub dry_run: bool,
26
27    /// Print the final system prompt sent to the LLM (without diff payload)
28    #[arg(long)]
29    pub verbose: bool,
30
31    /// Create a semantic version tag after a successful commit
32    #[arg(long)]
33    pub tag: bool,
34
35    /// Extra arguments forwarded to `git commit`
36    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
37    pub extra_args: Vec<String>,
38}
39
40#[derive(Subcommand, Debug)]
41pub enum Command {
42    /// Open interactive configuration editor
43    Config,
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    /// Update cgen to the latest version
53    Update,
54    /// Print the LLM system prompt without running anything
55    Prompt,
56    /// View commit history generated by cgen
57    History,
58    /// Manage LLM presets (save, load, rename, duplicate, delete, export/import)
59    Preset,
60    /// Configure LLM fallback order
61    Fallback,
62}
63
64pub fn parse() -> Cli {
65    Cli::parse()
66}
67
68/// What happens when a menu entry is selected
69enum MenuAction {
70    ToggleGroup(&'static str),
71    ToggleSubgroup(&'static str),
72    EditField(&'static str),
73    SaveAsPreset,
74    LoadPreset,
75    ManagePresets,
76    ManageFallbackOrder,
77    ToggleDescriptions,
78    Search,
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
90    let mut cursor_target: Option<&str> = None;
91    let mut first_render = true;
92
93    // Preset tracking state
94    let mut loaded_preset_id: Option<u32> = None;
95    let mut loaded_preset_snapshot: Option<LlmPresetFields> = None;
96
97    // Description toggle (resets each session)
98    let mut show_descriptions = false;
99
100    loop {
101        if first_render {
102            first_render = false;
103        } else {
104            print!("\x1B[2J\x1B[H");
105            println!("\n{}  {} configuration\n", "cgen".cyan().bold(), scope);
106        }
107
108        // Show preset modification warning
109        let preset_modified = if let Some(ref snapshot) = loaded_preset_snapshot {
110            crate::preset::preset_is_modified(&cfg, snapshot)
111        } else {
112            false
113        };
114        if preset_modified {
115            println!("  {}", "(preset modified)".dimmed());
116        }
117
118        let groups = cfg.grouped_fields();
119        let mut actions: Vec<MenuAction> = Vec::new();
120        let mut labels: Vec<String> = Vec::new();
121
122        for group in &groups {
123            let group_open = expanded.contains(group.name);
124            let arrow = if group_open { "\u{25BC}" } else { "\u{25B6}" };
125            labels.push(format!("{} {}", arrow, group.name.bright_white().bold()));
126            actions.push(MenuAction::ToggleGroup(group.name));
127
128            if !group_open {
129                continue;
130            }
131
132            let has_subgroups = !group.subgroups.is_empty();
133            for (i, (display_name, suffix, val)) in group.fields.iter().enumerate() {
134                let is_last = !has_subgroups && i == group.fields.len() - 1;
135                let conn = if is_last {
136                    "\u{2514}\u{2500}\u{2500}"
137                } else {
138                    "\u{251C}\u{2500}\u{2500}"
139                };
140                let mut field_label = format!("  {} {:<22} {}", conn, display_name, val.dimmed());
141                if show_descriptions {
142                    let desc = crate::config::field_description(suffix);
143                    if !desc.is_empty() {
144                        field_label.push_str(&format!("\n      {}", desc.bright_black()));
145                    }
146                }
147                labels.push(field_label);
148                actions.push(MenuAction::EditField(suffix));
149            }
150
151            for (sg_idx, sg) in group.subgroups.iter().enumerate() {
152                let is_last_sg = sg_idx == group.subgroups.len() - 1;
153                let sg_open = expanded.contains(sg.name);
154                let sg_arrow = if sg_open { "\u{25BC}" } else { "\u{25B6}" };
155                let sg_conn = if is_last_sg {
156                    "\u{2514}\u{2500}\u{2500}"
157                } else {
158                    "\u{251C}\u{2500}\u{2500}"
159                };
160                labels.push(format!(
161                    "  {} {} {}",
162                    sg_conn,
163                    sg_arrow,
164                    sg.name.bright_cyan().bold()
165                ));
166                actions.push(MenuAction::ToggleSubgroup(sg.name));
167
168                if !sg_open {
169                    continue;
170                }
171
172                let pipe = if is_last_sg { " " } else { "\u{2502}" };
173                for (f_idx, (display_name, suffix, val)) in sg.fields.iter().enumerate() {
174                    let is_last_field = f_idx == sg.fields.len() - 1;
175                    let f_conn = if is_last_field {
176                        "\u{2514}\u{2500}\u{2500}"
177                    } else {
178                        "\u{251C}\u{2500}\u{2500}"
179                    };
180                    let mut field_label = format!(
181                        "  {}   {} {:<22} {}",
182                        pipe,
183                        f_conn,
184                        display_name,
185                        val.dimmed()
186                    );
187                    if show_descriptions {
188                        let desc = crate::config::field_description(suffix);
189                        if !desc.is_empty() {
190                            field_label.push_str(&format!("\n          {}", desc.bright_black()));
191                        }
192                    }
193                    labels.push(field_label);
194                    actions.push(MenuAction::EditField(suffix));
195                }
196            }
197        }
198
199        // Preset/fallback menu entries
200        let current_fields = crate::preset::fields_from_config(&cfg);
201        let has_matching_preset = crate::preset::load_presets()
202            .ok()
203            .and_then(|f| crate::preset::find_duplicate(&f, &current_fields))
204            .is_some();
205
206        if !has_matching_preset {
207            labels.push("Save current as preset".cyan().to_string());
208            actions.push(MenuAction::SaveAsPreset);
209        }
210
211        labels.push("Load a preset".cyan().to_string());
212        actions.push(MenuAction::LoadPreset);
213
214        labels.push("Manage presets...".cyan().to_string());
215        actions.push(MenuAction::ManagePresets);
216
217        labels.push("Configure fallback order...".cyan().to_string());
218        actions.push(MenuAction::ManageFallbackOrder);
219
220        // Toggle descriptions menu item
221        let desc_label = if show_descriptions {
222            "Hide descriptions [?]".bright_yellow().to_string()
223        } else {
224            "Show descriptions [?]".bright_yellow().to_string()
225        };
226        labels.push(desc_label);
227        actions.push(MenuAction::ToggleDescriptions);
228
229        // Search menu item
230        labels.push("Search settings [/]".bright_yellow().to_string());
231        actions.push(MenuAction::Search);
232
233        // Resolve starting cursor position from previous toggle target
234        let starting_cursor =
235            cursor_target
236                .and_then(|target| {
237                    actions.iter().position(|a| matches!(
238                    a,
239                    MenuAction::ToggleGroup(n) | MenuAction::ToggleSubgroup(n) if *n == target
240                ))
241                })
242                .unwrap_or(0);
243        cursor_target = None;
244
245        let mut all_labels = labels.clone();
246        all_labels.push("Save & Exit".green().to_string());
247        all_labels.push("Exit without saving".red().to_string());
248
249        let selection = Select::new("Edit a setting:", all_labels)
250            .with_page_size(22)
251            .with_starting_cursor(starting_cursor)
252            .with_formatter(&|opt| ui::strip_tree_chars(opt.value))
253            .prompt();
254
255        let selection = match selection {
256            Ok(s) => s,
257            Err(_) => break,
258        };
259
260        if selection.contains("Save & Exit") {
261            // If preset was loaded and modified, offer to update it
262            if preset_modified {
263                if let Some(pid) = loaded_preset_id {
264                    let _ = crate::preset::prompt_update_preset(&cfg, pid);
265                }
266            }
267
268            if global {
269                cfg.save_global()?;
270                let path = crate::config::global_config_path()
271                    .map(|p| p.display().to_string())
272                    .unwrap_or_default();
273                println!("\n{} Saved to {}", "done!".green().bold(), path.dimmed());
274            } else {
275                cfg.save_local()?;
276                println!("\n{} Saved to {}", "done!".green().bold(), ".env".dimmed());
277            }
278            break;
279        }
280        if selection.contains("Exit without saving") {
281            println!("{}", "Cancelled.".dimmed());
282            break;
283        }
284
285        let idx = labels.iter().position(|l| selection.contains(l.as_str()));
286        let idx = match idx {
287            Some(i) => i,
288            None => continue,
289        };
290
291        match &actions[idx] {
292            MenuAction::ToggleGroup(name) => {
293                if expanded.contains(name) {
294                    expanded.remove(name);
295                    let groups = cfg.grouped_fields();
296                    if let Some(g) = groups.iter().find(|g| g.name == *name) {
297                        for sg in &g.subgroups {
298                            expanded.remove(sg.name);
299                        }
300                    }
301                } else {
302                    expanded.insert(name);
303                }
304                cursor_target = Some(name);
305            }
306            MenuAction::ToggleSubgroup(name) => {
307                if expanded.contains(name) {
308                    expanded.remove(name);
309                } else {
310                    expanded.insert(name);
311                }
312                cursor_target = Some(name);
313            }
314            MenuAction::EditField(suffix) => {
315                let new_value = edit_field(suffix, &cfg);
316                if let Some(val) = new_value {
317                    if let Err(err) = cfg.set_field(suffix, &val) {
318                        println!("  {} {}", "error:".red().bold(), err);
319                        continue;
320                    }
321                    if *suffix == "PROVIDER" {
322                        let default_model = crate::provider::default_model_for(&val);
323                        cfg.set_field("MODEL", default_model)?;
324                        if default_model.is_empty() {
325                            println!(
326                                "  {} Model cleared (set it manually)",
327                                "note:".yellow().bold()
328                            );
329                        } else {
330                            println!(
331                                "  {} Model set to {}",
332                                "note:".yellow().bold(),
333                                default_model.dimmed()
334                            );
335                        }
336                    }
337                }
338            }
339            MenuAction::SaveAsPreset => {
340                let _ = crate::preset::save_current_as_preset(&cfg);
341            }
342            MenuAction::LoadPreset => match crate::preset::select_and_load_preset(&mut cfg) {
343                Ok(Some((id, snapshot))) => {
344                    loaded_preset_id = Some(id);
345                    loaded_preset_snapshot = Some(snapshot);
346                }
347                Ok(None) => {}
348                Err(e) => println!("  {} {}", "error:".red().bold(), e),
349            },
350            MenuAction::ManagePresets => {
351                let _ = crate::preset::interactive_presets();
352            }
353            MenuAction::ManageFallbackOrder => {
354                let _ = crate::preset::interactive_fallback_order();
355            }
356            MenuAction::ToggleDescriptions => {
357                show_descriptions = !show_descriptions;
358            }
359            MenuAction::Search => {
360                if let Ok(query) = Text::new("Search:")
361                    .with_help_message("Enter text to search for in setting names")
362                    .prompt()
363                {
364                    let query_lower = query.to_lowercase();
365                    if !query_lower.is_empty() {
366                        // Search all fields across all groups and auto-expand matching groups
367                        let groups = cfg.grouped_fields();
368                        for group in &groups {
369                            let group_has_match = group
370                                .fields
371                                .iter()
372                                .any(|(name, _, _)| name.to_lowercase().contains(&query_lower));
373                            if group_has_match {
374                                expanded.insert(group.name);
375                            }
376
377                            for sg in &group.subgroups {
378                                let sg_has_match = sg
379                                    .fields
380                                    .iter()
381                                    .any(|(name, _, _)| name.to_lowercase().contains(&query_lower));
382                                if sg_has_match {
383                                    expanded.insert(group.name);
384                                    expanded.insert(sg.name);
385                                }
386                            }
387                        }
388                    }
389                }
390            }
391        }
392    }
393
394    Ok(())
395}
396
397fn edit_field(suffix: &str, cfg: &AppConfig) -> Option<String> {
398    match suffix {
399        "PROVIDER" => {
400            let choices = vec![
401                "gemini",
402                "openai",
403                "anthropic",
404                "groq",
405                "grok",
406                "deepseek",
407                "openrouter",
408                "mistral",
409                "together",
410                "fireworks",
411                "perplexity",
412                "lm_studio",
413                "(custom)",
414            ];
415            match Select::new("Provider:", choices).prompt() {
416                Ok("(custom)") => Text::new("Custom provider name:").prompt().ok(),
417                Ok(v) => Some(v.to_string()),
418                Err(_) => None,
419            }
420        }
421        "ONE_LINER" => {
422            let choices = vec!["enabled", "disabled"];
423            Select::new("One-liner commits:", choices)
424                .prompt()
425                .ok()
426                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
427        }
428        "USE_GITMOJI" => {
429            let choices = vec!["disabled", "enabled"];
430            Select::new("Use Gitmoji:", choices)
431                .prompt()
432                .ok()
433                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
434        }
435        "GITMOJI_FORMAT" => {
436            let choices = vec!["unicode", "shortcode"];
437            Select::new("Gitmoji format:", choices)
438                .prompt()
439                .ok()
440                .map(|v| v.to_string())
441        }
442        "REVIEW_COMMIT" => {
443            let choices = vec!["disabled", "enabled"];
444            Select::new("Review commit before confirming:", choices)
445                .prompt()
446                .ok()
447                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
448        }
449        "POST_COMMIT_PUSH" => {
450            let choices = vec!["ask", "always", "never"];
451            Select::new("Post-commit push behavior:", choices)
452                .prompt()
453                .ok()
454                .map(|v| v.to_string())
455        }
456        "SUPPRESS_TOOL_OUTPUT" => {
457            let choices = vec!["disabled", "enabled"];
458            Select::new("Suppress git command output:", choices)
459                .prompt()
460                .ok()
461                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
462        }
463        "WARN_STAGED_FILES_ENABLED" => {
464            let choices = vec!["enabled", "disabled"];
465            Select::new("Warn when staged files exceed threshold:", choices)
466                .prompt()
467                .ok()
468                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
469        }
470        "WARN_STAGED_FILES_THRESHOLD" => Text::new("Warn threshold (staged files count):")
471            .with_help_message(
472                "Integer value; warning shows when count is greater than this threshold",
473            )
474            .prompt()
475            .ok(),
476        "CONFIRM_NEW_VERSION" => {
477            let choices = vec!["enabled", "disabled"];
478            Select::new("Confirm new semantic version tag:", choices)
479                .prompt()
480                .ok()
481                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
482        }
483        "AUTO_UPDATE" => {
484            let choices = vec!["enabled", "disabled"];
485            Select::new("Enable automatic updates:", choices)
486                .prompt()
487                .ok()
488                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
489        }
490        "FALLBACK_ENABLED" => {
491            let choices = vec!["enabled", "disabled"];
492            Select::new("Enable LLM fallback on failure:", choices)
493                .prompt()
494                .ok()
495                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
496        }
497        "TRACK_GENERATED_COMMITS" => {
498            let choices = vec!["enabled", "disabled"];
499            Select::new("Track AI-generated commits:", choices)
500                .prompt()
501                .ok()
502                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
503        }
504        "API_KEY" => Text::new("API Key:")
505            .with_help_message("Your LLM provider API key")
506            .prompt()
507            .ok(),
508        "DIFF_EXCLUDE_GLOBS" => Text::new("Diff Exclude Globs:")
509            .with_help_message("Comma-separated glob patterns (e.g., *.json,*.lock,*.png)")
510            .with_default(&cfg.diff_exclude_globs.join(","))
511            .prompt()
512            .ok(),
513        _ => {
514            let fields = cfg.fields_display();
515            let field = fields.iter().find(|(_, s, _)| *s == suffix);
516            match field {
517                Some((name, _, val)) => {
518                    let prompt_text = format!("{}:", name);
519                    Text::new(&prompt_text).with_default(val).prompt().ok()
520                }
521                None => None,
522            }
523        }
524    }
525}