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 { "\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 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, ¤t_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 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 labels.push("Search settings [/]".bright_yellow().to_string());
211 actions.push(MenuAction::Search);
212
213 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_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 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}