Skip to main content

amont_runtime/
setup.rs

1//! `amont setup` — choose what `commit-msg` enforces, once.
2//!
3//! The keys in [`crate::commit_style`] are the answer to "these defaults do not
4//! suit me". This is the answer to "I did not know there were any". An adoption
5//! feature is only half built if the dial exists and nobody finds it, so the
6//! same four settings are offered here, each with its current value as the
7//! default and one line saying what it is for.
8//!
9//! **Input is stdin, not `/dev/tty`.** `trust::confirm` opens `/dev/tty`
10//! because it prompts from inside a *hook*, where git owns stdin and reading it
11//! would consume a pre-push ref list. That reasoning does not reach here: this
12//! is a subcommand somebody typed, and its stdin IS the terminal. The general
13//! rule, worth stating once: `/dev/tty` for a prompt inside a hook, stdin for a
14//! prompt inside a subcommand. Reading stdin also works on Windows — where
15//! `confirm` is hard-coded to decline — and can be driven by a heredoc, which
16//! is how this is tested without a pty.
17//!
18//! Nothing is written until every question has been answered, and what does get
19//! written is printed as the `git config` commands that would produce it. A
20//! wizard that reports "saved" has told you nothing you can check, paste into
21//! your dotfiles, or hand to a teammate.
22
23use 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/// Where the answers are written.
31#[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
52/// One decided setting: the key, and the value to write — or `None` to unset,
53/// which is how a setting returns to the shipped default.
54struct 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    // `--local` needs a repository, and refusing beats falling back to `.` —
80    // the same reasoning `trust::command` records: a fallback would write a
81    // setting into whatever directory somebody happened to be standing in.
82    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
112/// Every question, in order. `None` means the reader quit and nothing should be
113/// written.
114///
115/// Takes its input as a `BufRead` rather than reaching for stdin itself, which
116/// is what lets the whole flow — defaults, re-asking on a bad answer, quitting
117/// — be tested without a pseudo-terminal.
118fn 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
179/// Write the answers, and print exactly what was written.
180fn 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                // Unsetting is how a setting goes back to the shipped default,
195                // which is a state `git config <key> <default>` cannot express:
196                // one is "I chose this", the other is "I have no opinion".
197                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                        // Exit 5 is "nothing to unset", which is success here.
206                        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
235/// Not a terminal: print the keys and their current values as commands, and
236/// exit **0**.
237///
238/// `amont setup > setup.sh` in a provisioning script should produce
239/// something usable rather than an error. The refusal goes to stderr so it does
240/// not end up in that file.
241fn 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
266/// Read one answer. `None` means quit — `q`, or end of input.
267fn 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; // EOF
277    }
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, &current.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
378/// A chosen value, as a write instruction.
379///
380/// Choosing the shipped default writes an **unset**, not the value: the two
381/// are different statements, and a config file full of keys set to what they
382/// already were is noise somebody else has to read.
383fn 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    /// Choosing the default means the key goes away, not that it gets written
401    /// with a value equal to the default.
402    #[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    /// Answering with what is already in effect is not a change, so the wizard
414    /// writes nothing and says so. That is what makes re-running it safe.
415    #[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    /// A quit at any prompt writes nothing — a half-applied wizard is the worst
428    /// outcome available.
429    #[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    /// The whole flow, answered.
451    #[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    /// PROPERTY: pressing Enter through the whole wizard writes nothing.
462    ///
463    /// That is what makes re-running it safe — the brackets show what is in
464    /// effect, so accepting them all is by definition a no-op.
465    #[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    /// The brackets show the CURRENT value, not the shipped one, so a reader
476    /// who already configured something is not quietly offered a reset.
477    #[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        // Keeping a non-default value keeps it WRITTEN, not unset.
487        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    /// Answering with the shipped default removes the key rather than pinning
492    /// it — "I have no opinion" is a different statement from "I chose this".
493    #[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    /// A bad answer re-asks rather than aborting or silently taking a default.
509    #[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    /// Quitting partway writes nothing at all.
521    #[test]
522    fn quitting_midway_discards_the_answers_already_given() {
523        assert!(answers("global\nsuffix\nq\n", &Style::default()).is_none());
524        // And end of input is the same thing.
525        assert!(answers("global\nsuffix\n", &Style::default()).is_none());
526    }
527}