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 #[arg(long)]
23 pub dry_run: bool,
24
25 #[arg(long)]
27 pub verbose: bool,
28
29 #[arg(long)]
31 pub tag: bool,
32
33 #[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 Config,
42 Undo,
44 Alter {
46 #[arg(value_name = "HASH", num_args = 1..=2)]
48 commits: Vec<String>,
49 },
50 Update,
52 Prompt,
54}
55
56pub fn parse() -> Cli {
57 Cli::parse()
58}
59
60enum 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 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 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 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}