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 { "\u{2514}\u{2500}\u{2500}" } else { "\u{251C}\u{2500}\u{2500}" };
136                let mut field_label = format!("  {} {:<22} {}", conn, display_name, val.dimmed());
137                if show_descriptions {
138                    let desc = crate::config::field_description(suffix);
139                    if !desc.is_empty() {
140                        field_label.push_str(&format!("\n      {}", desc.bright_black()));
141                    }
142                }
143                labels.push(field_label);
144                actions.push(MenuAction::EditField(suffix));
145            }
146
147            for (sg_idx, sg) in group.subgroups.iter().enumerate() {
148                let is_last_sg = sg_idx == group.subgroups.len() - 1;
149                let sg_open = expanded.contains(sg.name);
150                let sg_arrow = if sg_open { "\u{25BC}" } else { "\u{25B6}" };
151                let sg_conn = if is_last_sg { "\u{2514}\u{2500}\u{2500}" } else { "\u{251C}\u{2500}\u{2500}" };
152                labels.push(format!("  {} {} {}", sg_conn, sg_arrow, sg.name.bright_cyan().bold()));
153                actions.push(MenuAction::ToggleSubgroup(sg.name));
154
155                if !sg_open {
156                    continue;
157                }
158
159                let pipe = if is_last_sg { " " } else { "\u{2502}" };
160                for (f_idx, (display_name, suffix, val)) in sg.fields.iter().enumerate() {
161                    let is_last_field = f_idx == sg.fields.len() - 1;
162                    let f_conn = if is_last_field { "\u{2514}\u{2500}\u{2500}" } else { "\u{251C}\u{2500}\u{2500}" };
163                    let mut field_label = format!(
164                        "  {}   {} {:<22} {}",
165                        pipe, f_conn, display_name, val.dimmed()
166                    );
167                    if show_descriptions {
168                        let desc = crate::config::field_description(suffix);
169                        if !desc.is_empty() {
170                            field_label.push_str(&format!("\n          {}", desc.bright_black()));
171                        }
172                    }
173                    labels.push(field_label);
174                    actions.push(MenuAction::EditField(suffix));
175                }
176            }
177        }
178
179        // Preset/fallback menu entries
180        let current_fields = crate::preset::fields_from_config(&cfg);
181        let has_matching_preset = crate::preset::load_presets()
182            .ok()
183            .and_then(|f| crate::preset::find_duplicate(&f, &current_fields))
184            .is_some();
185
186        if !has_matching_preset {
187            labels.push("Save current as preset".cyan().to_string());
188            actions.push(MenuAction::SaveAsPreset);
189        }
190
191        labels.push("Load a preset".cyan().to_string());
192        actions.push(MenuAction::LoadPreset);
193
194        labels.push("Manage presets...".cyan().to_string());
195        actions.push(MenuAction::ManagePresets);
196
197        labels.push("Configure fallback order...".cyan().to_string());
198        actions.push(MenuAction::ManageFallbackOrder);
199
200        // Toggle descriptions menu item
201        let desc_label = if show_descriptions {
202            "Hide descriptions [?]".bright_yellow().to_string()
203        } else {
204            "Show descriptions [?]".bright_yellow().to_string()
205        };
206        labels.push(desc_label);
207        actions.push(MenuAction::ToggleDescriptions);
208
209        // Search menu item
210        labels.push("Search settings [/]".bright_yellow().to_string());
211        actions.push(MenuAction::Search);
212
213        // Resolve starting cursor position from previous toggle target
214        let starting_cursor = cursor_target
215            .and_then(|target| {
216                actions.iter().position(|a| matches!(
217                    a,
218                    MenuAction::ToggleGroup(n) | MenuAction::ToggleSubgroup(n) if *n == target
219                ))
220            })
221            .unwrap_or(0);
222        cursor_target = None;
223
224        let mut all_labels = labels.clone();
225        all_labels.push("Save & Exit".green().to_string());
226        all_labels.push("Exit without saving".red().to_string());
227
228        let selection = Select::new("Edit a setting:", all_labels)
229            .with_page_size(22)
230            .with_starting_cursor(starting_cursor)
231            .with_formatter(&|opt| ui::strip_tree_chars(opt.value))
232            .prompt();
233
234        let selection = match selection {
235            Ok(s) => s,
236            Err(_) => break,
237        };
238
239        if selection.contains("Save & Exit") {
240            // If preset was loaded and modified, offer to update it
241            if preset_modified {
242                if let Some(pid) = loaded_preset_id {
243                    let _ = crate::preset::prompt_update_preset(&cfg, pid);
244                }
245            }
246
247            if global {
248                cfg.save_global()?;
249                let path = crate::config::global_config_path()
250                    .map(|p| p.display().to_string())
251                    .unwrap_or_default();
252                println!("\n{} Saved to {}", "done!".green().bold(), path.dimmed());
253            } else {
254                cfg.save_local()?;
255                println!("\n{} Saved to {}", "done!".green().bold(), ".env".dimmed());
256            }
257            break;
258        }
259        if selection.contains("Exit without saving") {
260            println!("{}", "Cancelled.".dimmed());
261            break;
262        }
263
264        let idx = labels.iter().position(|l| selection.contains(l.as_str()));
265        let idx = match idx {
266            Some(i) => i,
267            None => continue,
268        };
269
270        match &actions[idx] {
271            MenuAction::ToggleGroup(name) => {
272                if expanded.contains(name) {
273                    expanded.remove(name);
274                    let groups = cfg.grouped_fields();
275                    if let Some(g) = groups.iter().find(|g| g.name == *name) {
276                        for sg in &g.subgroups {
277                            expanded.remove(sg.name);
278                        }
279                    }
280                } else {
281                    expanded.insert(name);
282                }
283                cursor_target = Some(name);
284            }
285            MenuAction::ToggleSubgroup(name) => {
286                if expanded.contains(name) {
287                    expanded.remove(name);
288                } else {
289                    expanded.insert(name);
290                }
291                cursor_target = Some(name);
292            }
293            MenuAction::EditField(suffix) => {
294                let new_value = edit_field(suffix, &cfg);
295                if let Some(val) = new_value {
296                    if let Err(err) = cfg.set_field(suffix, &val) {
297                        println!("  {} {}", "error:".red().bold(), err);
298                        continue;
299                    }
300                    if *suffix == "PROVIDER" {
301                        let default_model = crate::provider::default_model_for(&val);
302                        cfg.set_field("MODEL", default_model)?;
303                        if default_model.is_empty() {
304                            println!(
305                                "  {} Model cleared (set it manually)",
306                                "note:".yellow().bold()
307                            );
308                        } else {
309                            println!(
310                                "  {} Model set to {}",
311                                "note:".yellow().bold(),
312                                default_model.dimmed()
313                            );
314                        }
315                    }
316                }
317            }
318            MenuAction::SaveAsPreset => {
319                let _ = crate::preset::save_current_as_preset(&cfg);
320            }
321            MenuAction::LoadPreset => {
322                match crate::preset::select_and_load_preset(&mut cfg) {
323                    Ok(Some((id, snapshot))) => {
324                        loaded_preset_id = Some(id);
325                        loaded_preset_snapshot = Some(snapshot);
326                    }
327                    Ok(None) => {}
328                    Err(e) => println!("  {} {}", "error:".red().bold(), e),
329                }
330            }
331            MenuAction::ManagePresets => {
332                let _ = crate::preset::interactive_presets();
333            }
334            MenuAction::ManageFallbackOrder => {
335                let _ = crate::preset::interactive_fallback_order();
336            }
337            MenuAction::ToggleDescriptions => {
338                show_descriptions = !show_descriptions;
339            }
340            MenuAction::Search => {
341                if let Ok(query) = Text::new("Search:")
342                    .with_help_message("Enter text to search for in setting names")
343                    .prompt()
344                {
345                    let query_lower = query.to_lowercase();
346                    if !query_lower.is_empty() {
347                        // Search all fields across all groups and auto-expand matching groups
348                        let groups = cfg.grouped_fields();
349                        for group in &groups {
350                            let group_has_match = group.fields.iter().any(|(name, _, _)| {
351                                name.to_lowercase().contains(&query_lower)
352                            });
353                            if group_has_match {
354                                expanded.insert(group.name);
355                            }
356
357                            for sg in &group.subgroups {
358                                let sg_has_match = sg.fields.iter().any(|(name, _, _)| {
359                                    name.to_lowercase().contains(&query_lower)
360                                });
361                                if sg_has_match {
362                                    expanded.insert(group.name);
363                                    expanded.insert(sg.name);
364                                }
365                            }
366                        }
367                    }
368                }
369            }
370        }
371    }
372
373    Ok(())
374}
375
376fn edit_field(suffix: &str, cfg: &AppConfig) -> Option<String> {
377    match suffix {
378        "PROVIDER" => {
379            let choices = vec![
380                "gemini",
381                "openai",
382                "anthropic",
383                "groq",
384                "grok",
385                "deepseek",
386                "openrouter",
387                "mistral",
388                "together",
389                "fireworks",
390                "perplexity",
391                "(custom)",
392            ];
393            match Select::new("Provider:", choices).prompt() {
394                Ok("(custom)") => Text::new("Custom provider name:").prompt().ok(),
395                Ok(v) => Some(v.to_string()),
396                Err(_) => None,
397            }
398        }
399        "ONE_LINER" => {
400            let choices = vec!["enabled", "disabled"];
401            Select::new("One-liner commits:", choices)
402                .prompt()
403                .ok()
404                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
405        }
406        "USE_GITMOJI" => {
407            let choices = vec!["disabled", "enabled"];
408            Select::new("Use Gitmoji:", choices)
409                .prompt()
410                .ok()
411                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
412        }
413        "GITMOJI_FORMAT" => {
414            let choices = vec!["unicode", "shortcode"];
415            Select::new("Gitmoji format:", choices)
416                .prompt()
417                .ok()
418                .map(|v| v.to_string())
419        }
420        "REVIEW_COMMIT" => {
421            let choices = vec!["disabled", "enabled"];
422            Select::new("Review commit before confirming:", choices)
423                .prompt()
424                .ok()
425                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
426        }
427        "POST_COMMIT_PUSH" => {
428            let choices = vec!["ask", "always", "never"];
429            Select::new("Post-commit push behavior:", choices)
430                .prompt()
431                .ok()
432                .map(|v| v.to_string())
433        }
434        "SUPPRESS_TOOL_OUTPUT" => {
435            let choices = vec!["disabled", "enabled"];
436            Select::new("Suppress git command output:", choices)
437                .prompt()
438                .ok()
439                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
440        }
441        "WARN_STAGED_FILES_ENABLED" => {
442            let choices = vec!["enabled", "disabled"];
443            Select::new("Warn when staged files exceed threshold:", choices)
444                .prompt()
445                .ok()
446                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
447        }
448        "WARN_STAGED_FILES_THRESHOLD" => Text::new("Warn threshold (staged files count):")
449            .with_help_message(
450                "Integer value; warning shows when count is greater than this threshold",
451            )
452            .prompt()
453            .ok(),
454        "CONFIRM_NEW_VERSION" => {
455            let choices = vec!["enabled", "disabled"];
456            Select::new("Confirm new semantic version tag:", choices)
457                .prompt()
458                .ok()
459                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
460        }
461        "AUTO_UPDATE" => {
462            let choices = vec!["enabled", "disabled"];
463            Select::new("Enable automatic updates:", choices)
464                .prompt()
465                .ok()
466                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
467        }
468        "FALLBACK_ENABLED" => {
469            let choices = vec!["enabled", "disabled"];
470            Select::new("Enable LLM fallback on failure:", choices)
471                .prompt()
472                .ok()
473                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
474        }
475        "TRACK_GENERATED_COMMITS" => {
476            let choices = vec!["enabled", "disabled"];
477            Select::new("Track AI-generated commits:", choices)
478                .prompt()
479                .ok()
480                .map(|v| if v == "enabled" { "1" } else { "0" }.to_string())
481        }
482        "API_KEY" => Text::new("API Key:")
483            .with_help_message("Your LLM provider API key")
484            .prompt()
485            .ok(),
486        "DIFF_EXCLUDE_GLOBS" => Text::new("Diff Exclude Globs:")
487            .with_help_message("Comma-separated glob patterns (e.g., *.json,*.lock,*.png)")
488            .with_default(&cfg.diff_exclude_globs.join(","))
489            .prompt()
490            .ok(),
491        _ => {
492            let fields = cfg.fields_display();
493            let field = fields.iter().find(|(_, s, _)| *s == suffix);
494            match field {
495                Some((name, _, val)) => {
496                    let prompt_text = format!("{}:", name);
497                    Text::new(&prompt_text).with_default(val).prompt().ok()
498                }
499                None => None,
500            }
501        }
502    }
503}