1use anyhow::Result;
2use clap::{Parser, Subcommand};
3use colored::Colorize;
4use inquire::{Select, Text};
5
6use crate::config::AppConfig;
7
8#[derive(Parser, Debug)]
9#[command(
10 name = "cgen",
11 about = "Generate git commit messages via LLMs",
12 version,
13 after_help = "Any arguments after `cgen` (without a subcommand) are forwarded to `git commit`."
14)]
15pub struct Cli {
16 #[command(subcommand)]
17 pub command: Option<Command>,
18
19 #[arg(long)]
21 pub dry_run: bool,
22
23 #[arg(long)]
25 pub verbose: bool,
26
27 #[arg(long)]
29 pub tag: bool,
30
31 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
33 pub extra_args: Vec<String>,
34}
35
36#[derive(Subcommand, Debug)]
37pub enum Command {
38 Config {
40 #[arg(long, short)]
42 global: bool,
43 },
44 Undo,
46 Alter {
48 #[arg(value_name = "HASH", num_args = 1..=2)]
50 commits: Vec<String>,
51 },
52}
53
54pub fn parse() -> Cli {
55 Cli::parse()
56}
57
58pub fn interactive_config(global: bool) -> Result<()> {
59 let mut cfg = AppConfig::load()?;
60 let scope = if global { "global" } else { "local" };
61
62 println!("\n{} {} configuration\n", "cgen".cyan().bold(), scope);
63
64 loop {
65 let fields = cfg.fields_display();
66 let options: Vec<String> = fields
67 .iter()
68 .map(|(name, _suffix, val)| format!("{:<18} {}", name, val.dimmed()))
69 .collect();
70
71 let mut all_options = options.clone();
72 all_options.push("Save & Exit".green().to_string());
73 all_options.push("Exit without saving".red().to_string());
74
75 let selection = Select::new("Edit a setting:", all_options)
76 .with_page_size(17)
77 .prompt();
78
79 let selection = match selection {
80 Ok(s) => s,
81 Err(_) => break, };
83
84 if selection.contains("Save & Exit") {
86 if global {
87 cfg.save_global()?;
88 let path = crate::config::global_config_path()
89 .map(|p| p.display().to_string())
90 .unwrap_or_default();
91 println!("\n{} Saved to {}", "done!".green().bold(), path.dimmed());
92 } else {
93 cfg.save_local()?;
94 println!("\n{} Saved to {}", "done!".green().bold(), ".env".dimmed());
95 }
96 break;
97 }
98 if selection.contains("Exit without saving") {
99 println!("{}", "Cancelled.".dimmed());
100 break;
101 }
102
103 let idx = options.iter().position(|o| selection.contains(o.as_str()));
105 let idx = match idx {
106 Some(i) => i,
107 None => continue,
108 };
109
110 let (_name, suffix, _val) = &fields[idx];
111
112 let new_value = match *suffix {
114 "PROVIDER" => {
115 let choices = vec!["gemini", "openai", "anthropic", "groq", "(custom)"];
116 match Select::new("Provider:", choices).prompt() {
117 Ok("(custom)") => Text::new("Custom provider name:").prompt().ok(),
118 Ok(v) => Some(v.to_string()),
119 Err(_) => None,
120 }
121 }
122 "ONE_LINER" => {
123 let choices = vec!["1 (yes)", "0 (no)"];
124 Select::new("One-liner commits:", choices)
125 .prompt()
126 .ok()
127 .map(|v| v.chars().next().unwrap().to_string())
128 }
129 "USE_GITMOJI" => {
130 let choices = vec!["0 (no)", "1 (yes)"];
131 Select::new("Use Gitmoji:", choices)
132 .prompt()
133 .ok()
134 .map(|v| v.chars().next().unwrap().to_string())
135 }
136 "GITMOJI_FORMAT" => {
137 let choices = vec!["unicode", "shortcode"];
138 Select::new("Gitmoji format:", choices)
139 .prompt()
140 .ok()
141 .map(|v| v.to_string())
142 }
143 "REVIEW_COMMIT" => {
144 let choices = vec!["0 (no)", "1 (yes)"];
145 Select::new("Review commit before confirming:", choices)
146 .prompt()
147 .ok()
148 .map(|v| v.chars().next().unwrap().to_string())
149 }
150 "POST_COMMIT_PUSH" => {
151 let choices = vec!["ask", "always", "never"];
152 Select::new("Post-commit push behavior:", choices)
153 .prompt()
154 .ok()
155 .map(|v| v.to_string())
156 }
157 "SUPPRESS_TOOL_OUTPUT" => {
158 let choices = vec!["0 (no)", "1 (yes)"];
159 Select::new("Suppress git command output:", choices)
160 .prompt()
161 .ok()
162 .map(|v| v.chars().next().unwrap().to_string())
163 }
164 "WARN_STAGED_FILES_ENABLED" => {
165 let choices = vec!["1 (yes)", "0 (no)"];
166 Select::new("Warn when staged files exceed threshold:", choices)
167 .prompt()
168 .ok()
169 .map(|v| v.chars().next().unwrap().to_string())
170 }
171 "WARN_STAGED_FILES_THRESHOLD" => Text::new("Warn threshold (staged files count):")
172 .with_help_message(
173 "Integer value; warning shows when count is greater than this threshold",
174 )
175 .prompt()
176 .ok(),
177 "CONFIRM_NEW_VERSION" => {
178 let choices = vec!["1 (yes)", "0 (no)"];
179 Select::new("Confirm new semantic version tag:", choices)
180 .prompt()
181 .ok()
182 .map(|v| v.chars().next().unwrap().to_string())
183 }
184 "API_KEY" => Text::new("API Key:")
185 .with_help_message("Your LLM provider API key")
186 .prompt()
187 .ok(),
188 _ => {
189 let current = fields[idx].2.clone();
190 let prompt_text = format!("{}:", fields[idx].0);
191 Text::new(&prompt_text).with_default(¤t).prompt().ok()
192 }
193 };
194
195 if let Some(val) = new_value {
196 if let Err(err) = cfg.set_field(suffix, &val) {
197 println!(" {} {}", "error:".red().bold(), err);
198 continue;
199 }
200
201 if *suffix == "PROVIDER" {
203 let default_model = crate::provider::default_model_for(&val);
204 cfg.set_field("MODEL", default_model)?;
205 if default_model.is_empty() {
206 println!(
207 " {} Model cleared (set it manually)",
208 "note:".yellow().bold()
209 );
210 } else {
211 println!(
212 " {} Model set to {}",
213 "note:".yellow().bold(),
214 default_model.dimmed()
215 );
216 }
217 }
218 }
219 }
220
221 Ok(())
222}