1use crate::commit_style::{
24 self, Gitmoji, Style, KEY_BODY_WRAP, KEY_DESCRIPTION_MAX, KEY_GITMOJI, KEY_SUBJECT_MAX,
25};
26use crate::git;
27use crate::ui::highlight;
28use std::io::{BufRead, IsTerminal, Write};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Where {
33 Local,
34 Global,
35}
36
37impl Where {
38 fn flag(self) -> &'static str {
39 match self {
40 Where::Local => "--local",
41 Where::Global => "--global",
42 }
43 }
44 fn word(self) -> &'static str {
45 match self {
46 Where::Local => "local",
47 Where::Global => "global",
48 }
49 }
50}
51
52struct Answer {
55 key: &'static str,
56 value: Option<String>,
57 changed: bool,
58}
59
60pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
61 let dry_run = args.iter().any(|a| a == "--dry-run");
62 let asked_local = args.iter().any(|a| a == "--local");
63 let asked_global = args.iter().any(|a| a == "--global");
64 if asked_local && asked_global {
65 return Err("amont setup: --local and --global contradict each other".to_string());
66 }
67 if let Some(bad) = args.iter().find(|a| {
68 !matches!(
69 a.to_str(),
70 Some("--dry-run") | Some("--local") | Some("--global")
71 )
72 }) {
73 return Err(format!(
74 "amont setup: unknown argument {:?}\nusage: amont setup [--local|--global] [--dry-run]",
75 bad.to_string_lossy()
76 ));
77 }
78
79 if asked_local && git::stdout(&["rev-parse", "--show-toplevel"]).is_none() {
83 return Err("amont setup --local: not inside a git repository".to_string());
84 }
85
86 let (style, _) = commit_style::describe();
87
88 if !std::io::stdin().is_terminal() {
89 return offer_the_commands(
90 &style,
91 if asked_local {
92 Where::Local
93 } else {
94 Where::Global
95 },
96 );
97 }
98
99 let stdin = std::io::stdin();
100 let mut input = stdin.lock();
101 let forced = match (asked_local, asked_global) {
102 (true, _) => Some(Where::Local),
103 (_, true) => Some(Where::Global),
104 _ => None,
105 };
106 match ask_all(&mut input, &style, forced)? {
107 Some((scope, answers)) => apply(&answers, scope, dry_run),
108 None => quit(),
109 }
110}
111
112fn ask_all(
119 input: &mut impl BufRead,
120 style: &Style,
121 forced_scope: Option<Where>,
122) -> Result<Option<(Where, Vec<Answer>)>, String> {
123 println!("amont setup — what `commit-msg` enforces, and how it decorates.");
124 println!("Nothing here disables a check; it changes what the check asks for.");
125 println!("Enter keeps the current value. `q` quits without writing anything.");
126
127 let scope = match forced_scope {
128 Some(s) => s,
129 None => match ask_scope(input)? {
130 Some(s) => s,
131 None => return Ok(None),
132 },
133 };
134
135 let mut answers = Vec::new();
136 match ask_gitmoji(input, style.gitmoji)? {
137 Some(a) => answers.push(a),
138 None => return Ok(None),
139 }
140 for (key, label, why, current, default) in [
141 (
142 KEY_SUBJECT_MAX,
143 "Maximum length of the whole subject line",
144 "72 is git's own convention, and what `git log --oneline` fits",
145 style.subject_max,
146 commit_style::DEFAULT_SUBJECT_MAX,
147 ),
148 (
149 KEY_DESCRIPTION_MAX,
150 "Maximum length of the description, after the `type: `",
151 "50 is the strict end of the convention; 68 still fits a 72-column \
152 subject with a short type and no scope",
153 style.description_max,
154 commit_style::DEFAULT_DESCRIPTION_MAX,
155 ),
156 (
157 KEY_BODY_WRAP,
158 "Hard-wrap the body at how many columns",
159 "0 leaves the body exactly as written — what keeps a pasted stack \
160 trace or a fenced code block intact",
161 style.body_wrap,
162 commit_style::DEFAULT_BODY_WRAP,
163 ),
164 ] {
165 match ask_number(input, key, label, why, current, default)? {
166 Some(a) => answers.push(a),
167 None => return Ok(None),
168 }
169 }
170
171 Ok(Some((scope, answers)))
172}
173
174fn quit() -> Result<(), String> {
175 println!("\nnothing written.");
176 Ok(())
177}
178
179fn apply(answers: &[Answer], scope: Where, dry_run: bool) -> Result<(), String> {
181 let changed: Vec<&Answer> = answers.iter().filter(|a| a.changed).collect();
182 println!();
183 if changed.is_empty() {
184 println!("nothing to change — every setting is already what you chose.");
185 } else {
186 println!(
187 "{} ({}):",
188 if dry_run { "would write" } else { "wrote" },
189 scope.word()
190 );
191 for a in &changed {
192 match &a.value {
193 Some(v) => println!(" git config {} {} {v}", scope.flag(), a.key),
194 None => println!(" git config {} --unset {}", scope.flag(), a.key),
198 }
199 }
200 if !dry_run {
201 for a in &changed {
202 let ok = match &a.value {
203 Some(v) => git::succeeds(&["config", scope.flag(), a.key, v]),
204 None => {
205 git::succeeds(&["config", scope.flag(), "--unset", a.key])
207 || matches!(
208 git::output(&["config", scope.flag(), "--get", a.key]),
209 Some(o) if o.code == 1
210 )
211 }
212 };
213 if !ok {
214 return Err(format!("amont setup: could not write {}", a.key));
215 }
216 }
217 }
218 }
219
220 let unchanged: Vec<&Answer> = answers.iter().filter(|a| !a.changed).collect();
221 if !unchanged.is_empty() {
222 println!("\nunchanged:");
223 for a in unchanged {
224 println!(" {}", a.key);
225 }
226 }
227
228 println!("\nTwo more, per repository rather than per person:");
229 println!(" git config amont.fix true # let a check fix what it finds");
230 println!(" git config amont.testPushedTree true # test what you push, not your tree");
231 println!("\nRead it all back with: amont list");
232 Ok(())
233}
234
235fn offer_the_commands(style: &Style, scope: Where) -> Result<(), String> {
242 eprintln!("amont setup: not a terminal — nothing to ask. The keys, with their current values:");
243 println!(
244 "git config {} {KEY_GITMOJI} {}",
245 scope.flag(),
246 style.gitmoji.as_str()
247 );
248 println!(
249 "git config {} {KEY_SUBJECT_MAX} {}",
250 scope.flag(),
251 style.subject_max
252 );
253 println!(
254 "git config {} {KEY_DESCRIPTION_MAX} {}",
255 scope.flag(),
256 style.description_max
257 );
258 println!(
259 "git config {} {KEY_BODY_WRAP} {}",
260 scope.flag(),
261 style.body_wrap
262 );
263 Ok(())
264}
265
266fn prompt(input: &mut impl BufRead, question: &str, why: &str, current: &str) -> Option<String> {
268 println!("\n{question}");
269 if !why.is_empty() {
270 println!(" {why}");
271 }
272 print!(" [{}] > ", highlight(current));
273 let _ = std::io::stdout().flush();
274 let mut line = String::new();
275 if input.read_line(&mut line).ok()? == 0 {
276 return None; }
278 let answer = line.trim().to_string();
279 if answer.eq_ignore_ascii_case("q") {
280 return None;
281 }
282 Some(answer)
283}
284
285fn ask_scope(input: &mut impl BufRead) -> Result<Option<Where>, String> {
286 loop {
287 let Some(answer) = prompt(
288 input,
289 "Where should these settings go?",
290 "global is usually right — how you write commit messages is the same \
291 statement in every repository you have",
292 "global",
293 ) else {
294 return Ok(None);
295 };
296 match answer.as_str() {
297 "" | "global" => return Ok(Some(Where::Global)),
298 "local" => {
299 if git::stdout(&["rev-parse", "--show-toplevel"]).is_none() {
300 println!(" not inside a git repository — `local` has nowhere to go.");
301 continue;
302 }
303 return Ok(Some(Where::Local));
304 }
305 other => println!(" {other:?} is neither `global` nor `local`."),
306 }
307 }
308}
309
310fn ask_gitmoji(input: &mut impl BufRead, current: Gitmoji) -> Result<Option<Answer>, String> {
311 println!("\nWhere should the type's gitmoji go?");
312 for g in Gitmoji::ALL {
313 println!(" {:<9} {:<22} {}", g.as_str(), g.example(), g.explain());
314 }
315 loop {
316 print!(" [{}] > ", highlight(current.as_str()));
317 let _ = std::io::stdout().flush();
318 let mut line = String::new();
319 if input.read_line(&mut line).map_err(|e| e.to_string())? == 0 {
320 return Ok(None);
321 }
322 let answer = line.trim();
323 if answer.eq_ignore_ascii_case("q") {
324 return Ok(None);
325 }
326 let chosen = if answer.is_empty() {
327 current
328 } else {
329 match Gitmoji::parse(&answer.to_ascii_lowercase()) {
330 Some(g) => g,
331 None => {
332 println!(" {answer:?} is not one of the four above.");
333 continue;
334 }
335 }
336 };
337 return Ok(Some(answer_for(
338 KEY_GITMOJI,
339 chosen.as_str().to_string(),
340 commit_style::DEFAULT_GITMOJI.as_str().to_string(),
341 current.as_str().to_string(),
342 )));
343 }
344}
345
346fn ask_number(
347 input: &mut impl BufRead,
348 key: &'static str,
349 label: &str,
350 why: &str,
351 current: usize,
352 default: usize,
353) -> Result<Option<Answer>, String> {
354 loop {
355 let Some(answer) = prompt(input, label, why, ¤t.to_string()) else {
356 return Ok(None);
357 };
358 let chosen = if answer.is_empty() {
359 current
360 } else {
361 match answer.parse::<usize>() {
362 Ok(n) => n,
363 Err(_) => {
364 println!(" {answer:?} is not a number.");
365 continue;
366 }
367 }
368 };
369 return Ok(Some(answer_for(
370 key,
371 chosen.to_string(),
372 default.to_string(),
373 current.to_string(),
374 )));
375 }
376}
377
378fn answer_for(key: &'static str, chosen: String, default: String, current: String) -> Answer {
384 let changed = chosen != current;
385 Answer {
386 key,
387 value: if chosen == default {
388 None
389 } else {
390 Some(chosen)
391 },
392 changed,
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
403 fn choosing_the_default_unsets_the_key() {
404 let a = answer_for(KEY_SUBJECT_MAX, "72".into(), "72".into(), "100".into());
405 assert!(a.value.is_none(), "should unset");
406 assert!(a.changed);
407
408 let b = answer_for(KEY_SUBJECT_MAX, "100".into(), "72".into(), "72".into());
409 assert_eq!(b.value.as_deref(), Some("100"));
410 assert!(b.changed);
411 }
412
413 #[test]
416 fn keeping_the_current_value_changes_nothing() {
417 let a = answer_for(KEY_SUBJECT_MAX, "100".into(), "72".into(), "100".into());
418 assert!(!a.changed);
419 }
420
421 #[test]
422 fn the_two_scopes_spell_themselves_for_git() {
423 assert_eq!(Where::Local.flag(), "--local");
424 assert_eq!(Where::Global.flag(), "--global");
425 }
426
427 #[test]
430 fn q_and_eof_both_mean_quit() {
431 let mut q = std::io::Cursor::new(b"q\n".to_vec());
432 assert!(prompt(&mut q, "x", "", "d").is_none());
433 let mut eof = std::io::Cursor::new(Vec::new());
434 assert!(prompt(&mut eof, "x", "", "d").is_none());
435 let mut enter = std::io::Cursor::new(b"\n".to_vec());
436 assert_eq!(prompt(&mut enter, "x", "", "d").as_deref(), Some(""));
437 }
438
439 fn answers(input: &str, style: &Style) -> Option<(Where, Vec<Answer>)> {
440 let mut cursor = std::io::Cursor::new(input.as_bytes().to_vec());
441 ask_all(&mut cursor, style, None).expect("the flow does not fail")
442 }
443
444 fn value(list: &[Answer], key: &str) -> Option<String> {
445 list.iter()
446 .find(|a| a.key == key)
447 .and_then(|a| a.value.clone())
448 }
449
450 #[test]
452 fn every_answer_reaches_its_key() {
453 let (scope, list) = answers("global\nsuffix\n100\n68\n0\n", &Style::default()).unwrap();
454 assert_eq!(scope, Where::Global);
455 assert_eq!(value(&list, KEY_GITMOJI).as_deref(), Some("suffix"));
456 assert_eq!(value(&list, KEY_SUBJECT_MAX).as_deref(), Some("100"));
457 assert_eq!(value(&list, KEY_DESCRIPTION_MAX).as_deref(), Some("68"));
458 assert_eq!(value(&list, KEY_BODY_WRAP).as_deref(), Some("0"));
459 }
460
461 #[test]
466 fn accepting_every_default_changes_nothing() {
467 let (_, list) = answers("\n\n\n\n\n", &Style::default()).unwrap();
468 assert!(
469 list.iter().all(|a| !a.changed),
470 "something was marked changed: {:?}",
471 list.iter().map(|a| a.key).collect::<Vec<_>>()
472 );
473 }
474
475 #[test]
478 fn the_offered_default_is_what_is_in_effect() {
479 let configured = Style {
480 gitmoji: Gitmoji::Prefix,
481 subject_max: 100,
482 ..Style::default()
483 };
484 let (_, list) = answers("\n\n\n\n\n", &configured).unwrap();
485 assert!(list.iter().all(|a| !a.changed));
486 assert_eq!(value(&list, KEY_GITMOJI).as_deref(), Some("prefix"));
488 assert_eq!(value(&list, KEY_SUBJECT_MAX).as_deref(), Some("100"));
489 }
490
491 #[test]
494 fn returning_to_the_default_unsets_rather_than_pins() {
495 let configured = Style {
496 subject_max: 100,
497 ..Style::default()
498 };
499 let (_, list) = answers("\nnone\n72\n\n\n", &configured).unwrap();
500 let subject = list
501 .iter()
502 .find(|a| a.key == KEY_SUBJECT_MAX)
503 .expect("asked");
504 assert!(subject.changed);
505 assert!(subject.value.is_none(), "should unset, not write 72");
506 }
507
508 #[test]
510 fn an_unusable_answer_is_asked_again() {
511 let (_, list) = answers(
512 "sideways\nglobal\nnope\nsuffix\nlots\n80\n\n\n",
513 &Style::default(),
514 )
515 .unwrap();
516 assert_eq!(value(&list, KEY_GITMOJI).as_deref(), Some("suffix"));
517 assert_eq!(value(&list, KEY_SUBJECT_MAX).as_deref(), Some("80"));
518 }
519
520 #[test]
522 fn quitting_midway_discards_the_answers_already_given() {
523 assert!(answers("global\nsuffix\nq\n", &Style::default()).is_none());
524 assert!(answers("global\nsuffix\n", &Style::default()).is_none());
526 }
527}