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 #[arg(long)]
25 pub dry_run: bool,
26
27 #[arg(long)]
29 pub verbose: bool,
30
31 #[arg(long)]
33 pub tag: bool,
34
35 #[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 Config,
44 Undo,
46 Alter {
48 #[arg(value_name = "HASH", num_args = 1..=2)]
50 commits: Vec<String>,
51 },
52 Update,
54 Prompt,
56 History,
58 Preset,
60 Fallback,
62}
63
64pub fn parse() -> Cli {
65 Cli::parse()
66}
67
68enum 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 let mut loaded_preset_id: Option<u32> = None;
95 let mut loaded_preset_snapshot: Option<LlmPresetFields> = None;
96
97 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 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 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, ¤t_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 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 labels.push("Search settings [/]".bright_yellow().to_string());
231 actions.push(MenuAction::Search);
232
233 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_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 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}