diff --git a/.gitignore b/.gitignore
index fcafaf2c..898e24dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,5 +15,6 @@ target/
*.ui.in~
*.ui~
-# Dynamically-generated file
+# Dynamically-generated files
src/config.rs
+hooks/checks-bin
diff --git a/.gitlab-ci/check.yml b/.gitlab-ci/check.yml
index fff7cc7c..4c44e416 100644
--- a/.gitlab-ci/check.yml
+++ b/.gitlab-ci/check.yml
@@ -8,7 +8,7 @@ pre-commit-checks:
image: "rustlang/rust:nightly-slim"
interruptible: true
script:
- - hooks/checks.sh --verbose --force-install
+ - RUST_BACKTRACE=1 cargo run --manifest-path hooks/checks/Cargo.toml -- --verbose --force-install
# Checks needing dependencies in the Flatpak runtime
flatpak-checks:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 946cc6b9..975542cf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -129,7 +129,7 @@ sudo ninja -C _build install
## Pre-commit
We expect all code contributions to be correctly formatted. To help with that, a pre-commit hook
-should get installed as part of the building process. It runs the `hooks/checks.sh` script. It's a
+should get installed as part of the building process. It runs the `hooks/checks` crate. It's a
quick script that makes sure that the code is correctly formatted with `rustfmt`, among other
things. Make sure that this script is effectively run before submitting your merge request,
otherwise CI will probably fail right away.
diff --git a/hooks/checks.sh b/hooks/checks.sh
deleted file mode 100755
index 450c0138..00000000
--- a/hooks/checks.sh
+++ /dev/null
@@ -1,798 +0,0 @@
-#!/usr/bin/env bash
-
-export LC_ALL=C
-
-# Usage info
-show_help() {
-cat << EOF
-Run conformity checks on the current Rust project.
-
-If a dependency is not found, helps the user to install it.
-
-USAGE: ${0##*/} [OPTIONS]
-
-OPTIONS:
- -s, --git-staged Only check files staged to be committed
- -f, --force-install Install missing dependencies without asking
- -v, --verbose Use verbose output
- -h, --help Display this help and exit
-
-ERROR CODES:
- 1 Check failed
- 2 Missing dependency
-EOF
-}
-
-# Style helpers
-act="\e[1;32m"
-err="\e[1;31m"
-pos="\e[32m"
-neg="\e[31m"
-res="\e[0m"
-
-# Common styled strings
-Installing="${act}Installing${res}"
-Checking=" ${act}Checking${res}"
-Could_not=" ${err}Could not${res}"
-error="${err}error:${res}"
-invalid="${neg}Invalid input${res}"
-ok="${pos}ok${res}"
-fail="${neg}fail${res}"
-
-# Initialize variables
-git_staged=0
-force_install=0
-verbose=0
-
-# Helper functions
-# Sort to_sort in natural order.
-sort() {
- local size=${#to_sort[@]}
- local swapped=0;
-
- for (( i = 0; i < $size-1; i++ ))
- do
- swapped=0
- for ((j = 0; j < $size-1-$i; j++ ))
- do
- if [[ "${to_sort[$j]}" > "${to_sort[$j+1]}" ]]
- then
- temp="${to_sort[$j]}";
- to_sort[$j]="${to_sort[$j+1]}";
- to_sort[$j+1]="$temp";
- swapped=1;
- fi
- done
-
- if [[ $swapped -eq 0 ]]; then
- break;
- fi
- done
-}
-
-# Remove common entries in to_diff1 and to_diff2.
-diff() {
- for i in ${!to_diff1[@]}; do
- for j in ${!to_diff2[@]}; do
- if [[ "${to_diff1[$i]}" == "${to_diff2[$j]}" ]]; then
- unset to_diff1[$i]
- unset to_diff2[$j]
- break
- fi
- done
- done
-}
-
-# Check if rustup is available.
-# Argument:
-# '-i' to install if missing.
-check_rustup() {
- if ! which rustup &> /dev/null; then
- if [[ "$1" == '-i' ]]; then
- echo -e "$Installing rustup…"
- curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly
- export PATH=$PATH:$HOME/.cargo/bin
- if ! which rustup &> /dev/null; then
- echo -e "$Could_not install rustup"
- exit 2
- fi
- else
- exit 2
- fi
- fi
-}
-
-# Install cargo via rustup.
-install_cargo() {
- check_rustup -i
- if ! which cargo >/dev/null 2>&1; then
- echo -e "$Could_not install cargo"
- exit 2
- fi
-}
-
-# Check if cargo is available. If not, ask to install it.
-check_cargo() {
- if ! which cargo >/dev/null 2>&1; then
- echo "Could not find cargo for pre-commit checks"
-
- if [[ $force_install -eq 1 ]]; then
- install_cargo
- elif [ ! -t 1 ]; then
- exit 2
- elif check_rustup; then
- echo -e "$error rustup is installed but the cargo command isn’t available"
- exit 2
- else
- echo ""
- echo "y: Install cargo via rustup"
- echo "N: Don’t install cargo and abort checks"
- echo ""
- while true; do
- echo -n "Install cargo? [y/N]: "; read yn < /dev/tty
- case $yn in
- [Yy]* )
- install_cargo
- break
- ;;
- [Nn]* | "" )
- exit 2
- ;;
- * )
- echo $invalid
- ;;
- esac
- done
- fi
- fi
-
- if [[ $verbose -eq 1 ]]; then
- echo ""
- rustc -Vv && cargo +nightly -Vv
- fi
-}
-
-# Install rustfmt with rustup.
-install_rustfmt() {
- check_rustup -i
-
- echo -e "$Installing rustfmt…"
- rustup component add --toolchain nightly rustfmt
- if ! cargo +nightly fmt --version >/dev/null 2>&1; then
- echo -e "$Could_not install rustfmt"
- exit 2
- fi
-}
-
-# Run rustfmt to enforce code style.
-run_rustfmt() {
- if ! cargo +nightly fmt --version >/dev/null 2>&1; then
- if [[ $force_install -eq 1 ]]; then
- install_rustfmt
- elif [ ! -t 1 ]; then
- echo "Could not check Fractal’s code style, because rustfmt could not be run"
- exit 2
- else
- echo "Rustfmt is needed to check Fractal’s code style, but it isn’t available"
- echo ""
- echo "y: Install rustfmt via rustup"
- echo "N: Don’t install rustfmt and abort checks"
- echo ""
- while true; do
- echo -n "Install rustfmt? [y/N]: "; read yn < /dev/tty
- case $yn in
- [Yy]* )
- install_rustfmt
- break
- ;;
- [Nn]* | "" )
- exit 2
- ;;
- * )
- echo $invalid
- ;;
- esac
- done
- fi
- fi
-
- echo -e "$Checking code style…"
-
- if [[ $verbose -eq 1 ]]; then
- echo ""
- cargo +nightly fmt --version
- echo ""
- fi
-
-
- if [[ $git_staged -eq 1 ]]; then
- staged_files=`git diff --name-only --cached | xargs ls -d 2>/dev/null | grep '.rs$'`
- result=0
- for file in ${staged_files[@]}; do
- if ! cargo +nightly fmt -- --unstable-features --skip-children --check $file; then
- result=1
- fi
- done
-
- if [[ $result -eq 1 ]]; then
- echo -e " Checking code style result: $fail"
- echo "Please fix the above issues, either manually or by running: cargo fmt --all"
- exit 1
- else
- echo -e " Checking code style result: $ok"
- fi
- else
- if ! cargo +nightly fmt --all -- --check; then
- echo -e " Checking code style result: $fail"
- echo "Please fix the above issues, either manually or by running: cargo fmt --all"
- exit 1
- else
- echo -e " Checking code style result: $ok"
- fi
- fi
-}
-
-
-# Install typos with cargo.
-install_typos() {
- echo -e "$Installing typos…"
- cargo install typos-cli
- if ! typos --version >/dev/null 2>&1; then
- echo -e "$Could_not install typos"
- exit 2
- fi
-}
-
-# Run typos to check for spelling mistakes.
-run_typos() {
- if ! typos --version >/dev/null 2>&1; then
- if [[ $force_install -eq 1 ]]; then
- install_typos
- elif [ ! -t 1 ]; then
- echo "Could not check spelling mistakes, because typos could not be run"
- exit 2
- else
- echo "Typos is needed to check spelling mistakes, but it isn’t available"
- echo ""
- echo "y: Install typos via cargo"
- echo "N: Don’t install typos and abort checks"
- echo ""
- while true; do
- echo -n "Install typos? [y/N]: "; read yn < /dev/tty
- case $yn in
- [Yy]* )
- install_typos
- break
- ;;
- [Nn]* | "" )
- exit 2
- ;;
- * )
- echo $invalid
- ;;
- esac
- done
- fi
- fi
-
- echo -e "$Checking spelling mistakes…"
-
- if [[ $verbose -eq 1 ]]; then
- echo ""
- typos --version
- echo ""
- fi
-
- staged_files=`git diff --name-only --cached | xargs ls -d 2>/dev/null`
-
- if ! typos --color always ${staged_files}; then
- echo -e " Checking spelling mistakes result: $fail"
- echo "Please fix the above issues, either manually or by running: typos -w"
- exit 1
- else
- echo -e " Checking spelling mistakes result: $ok"
- fi
-}
-
-# Install machete with cargo.
-install_machete() {
- echo -e "$Installing cargo-machete…"
- cargo install cargo-machete
- if ! cargo machete --version>/dev/null 2>&1; then
- echo -e "$Could_not install cargo-machete"
- exit 2
- fi
-}
-
-# Run machete to check for unused dependencies.
-run_machete() {
- if ! cargo machete --version >/dev/null 2>&1; then
- if [[ $force_install -eq 1 ]]; then
- install_machete
- elif [ ! -t 1 ]; then
- echo "Could not check for unused dependencies, because cargo-machete could not be run"
- exit 2
- else
- echo "cargo-machete is needed to check for unused dependencies, but it isn’t available"
- echo ""
- echo "y: Install cargo-machete via cargo"
- echo "N: Don’t install cargo-machete and abort checks"
- echo ""
- while true; do
- echo -n "Install cargo-machete? [y/N]: "; read yn < /dev/tty
- case $yn in
- [Yy]* )
- install_machete
- break
- ;;
- [Nn]* | "" )
- exit 2
- ;;
- * )
- echo $invalid
- ;;
- esac
- done
- fi
- fi
-
- echo -e "$Checking for unused dependencies…"
-
- if [[ $verbose -eq 1 ]]; then
- echo ""
- cargo machete --version
- echo ""
- fi
-
- if ! cargo machete --with-metadata; then
- echo -e " Checking for unused dependencies result: $fail"
- echo "Please fix the above issues, either by removing the dependencies, or by adding the necessary configuration option in Cargo.toml (see cargo-machete documentation)"
- exit 1
- else
- echo -e " Checking for unused dependencies result: $ok"
- fi
-}
-
-# Install cargo-deny with cargo.
-install_cargo_deny() {
- echo -e "$Installing cargo-deny…"
- cargo install cargo-deny
- if ! cargo deny --version>/dev/null 2>&1; then
- echo -e "$Could_not install cargo-deny"
- exit 2
- fi
-}
-
-# Run cargo-deny to check Rust dependencies.
-run_cargo_deny() {
- if ! cargo deny --version >/dev/null 2>&1; then
- if [[ $force_install -eq 1 ]]; then
- install_cargo_deny
- elif [ ! -t 1 ]; then
- echo "Could not check Rust dependencies, because cargo-deny could not be run"
- exit 2
- else
- echo "cargo-deny is needed to check the Rust dependencies, but it isn’t available"
- echo ""
- echo "y: Install cargo-deny via cargo"
- echo "N: Don’t install cargo-deny and abort checks"
- echo ""
- while true; do
- echo -n "Install cargo-deny? [y/N]: "; read yn < /dev/tty
- case $yn in
- [Yy]* )
- install_cargo_deny
- break
- ;;
- [Nn]* | "" )
- exit 2
- ;;
- * )
- echo $invalid
- ;;
- esac
- done
- fi
- fi
-
- echo -e "$Checking Rust dependencies…"
-
- if [[ $verbose -eq 1 ]]; then
- echo ""
- cargo deny --version
- echo ""
- fi
-
- if ! cargo deny check; then
- echo -e " Checking Rust dependencies result: $fail"
- echo "Please fix the above issues, either by removing the dependencies, or by adding the necessary configuration option in deny.toml (see cargo-deny documentation)"
- exit 1
- else
- echo -e " Checking Rust dependencies result: $ok"
- fi
-}
-
-# Check if files in POTFILES.in are correct.
-#
-# This checks, in that order:
-# - All files exist
-# - All files with translatable strings are present and only those
-# - Files are sorted alphabetically
-#
-# This assumes the following:
-# - POTFILES is located at 'po/POTFILES.in'
-# - UI (Glade) files are located in 'src' and use 'translatable="yes"'
-# - Blueprint files are located in 'src' and use '_('
-# - Rust files are located in 'src' and use '*gettext' methods or macros
-check_potfiles() {
- echo -e "$Checking po/POTFILES.in…"
-
- local ret=0
-
- # Check that files in POTFILES.in exist.
- while read -r line; do
- if [[ -n $line && ${line::1} != '#' ]]; then
- if [[ ! -f $line ]]; then
- echo -e "$error File '$line' in POTFILES.in does not exist"
- ret=1
- fi
- if [[ ${line:(-3):3} == '.ui' ]]; then
- ui_potfiles+=($line)
- elif [[ ${line:(-4):4} == '.blp' ]]; then
- blp_potfiles+=($line)
- elif [[ ${line:(-3):3} == '.rs' ]]; then
- rs_potfiles+=($line)
- fi
- fi
- done < po/POTFILES.in
-
- if [[ ret -eq 1 ]]; then
- echo -e " Checking po/POTFILES.in result: $fail"
- exit 1
- fi
-
- # Check that files in POTFILES.skip exist.
- while read -r line; do
- if [[ -n $line && ${line::1} != '#' ]]; then
- if [[ ! -f $line ]]; then
- echo -e "$error File '$line' in POTFILES.skip does not exist"
- ret=1
- fi
- if [[ ${line:(-3):3} == '.ui' ]]; then
- ui_skip+=($line)
- elif [[ ${line:(-4):4} == '.blp' ]]; then
- blp_skip+=($line)
- elif [[ ${line:(-3):3} == '.rs' ]]; then
- rs_skip+=($line)
- fi
- fi
- done < po/POTFILES.skip
-
- if [[ ret -eq 1 ]]; then
- echo -e " Checking po/POTFILES.skip result: $fail"
- exit 1
- fi
-
- # Get UI files with 'translatable="yes"'.
- ui_files=(`grep -lIr --include=*.ui 'translatable="yes"' src`)
-
- # Get blueprint files with '_('.
- blp_files=(`grep -lIr --include=*.blp '_(' src`)
-
- # Get Rust files with regex 'gettext(_f)?\('.
- rs_files=(`grep -lIrE --include=*.rs 'gettext(_f)?\(' src`)
-
- # Get Rust files with macros, regex 'gettext!\('.
- rs_macro_files=(`grep -lIrE --include=*.rs 'gettext!\(' src`)
-
- # Remove common files
- to_diff1=("${ui_skip[@]}")
- to_diff2=("${ui_files[@]}")
- diff
- ui_skip=("${to_diff1[@]}")
- ui_files=("${to_diff2[@]}")
-
- to_diff1=("${ui_potfiles[@]}")
- to_diff2=("${ui_files[@]}")
- diff
- ui_potfiles=("${to_diff1[@]}")
- ui_files=("${to_diff2[@]}")
-
- to_diff1=("${blp_skip[@]}")
- to_diff2=("${blp_files[@]}")
- diff
- blp_skip=("${to_diff1[@]}")
- blp_files=("${to_diff2[@]}")
-
- to_diff1=("${blp_potfiles[@]}")
- to_diff2=("${blp_files[@]}")
- diff
- blp_potfiles=("${to_diff1[@]}")
- blp_files=("${to_diff2[@]}")
-
- to_diff1=("${rs_skip[@]}")
- to_diff2=("${rs_files[@]}")
- diff
- rs_skip=("${to_diff1[@]}")
- rs_files=("${to_diff2[@]}")
-
- to_diff1=("${rs_potfiles[@]}")
- to_diff2=("${rs_files[@]}")
- diff
- rs_potfiles=("${to_diff1[@]}")
- rs_files=("${to_diff2[@]}")
-
- potfiles_count=$((${#ui_potfiles[@]} + ${#blp_potfiles[@]} + ${#rs_potfiles[@]}))
- if [[ $potfiles_count -eq 1 ]]; then
- echo ""
- echo -e "$error Found 1 file in POTFILES.in without translatable strings:"
- ret=1
- elif [[ $potfiles_count -ne 0 ]]; then
- echo ""
- echo -e "$error Found $potfiles_count files in POTFILES.in without translatable strings:"
- ret=1
- fi
- for file in ${ui_potfiles[@]}; do
- echo $file
- done
- for file in ${blp_potfiles[@]}; do
- echo $file
- done
- for file in ${rs_potfiles[@]}; do
- echo $file
- done
-
- let files_count=$((${#ui_files[@]} + ${#blp_files[@]} + ${#rs_files[@]}))
- if [[ $files_count -eq 1 ]]; then
- echo ""
- echo -e "$error Found 1 file with translatable strings not present in POTFILES.in:"
- ret=1
- elif [[ $files_count -ne 0 ]]; then
- echo ""
- echo -e "$error Found $files_count files with translatable strings not present in POTFILES.in:"
- ret=1
- fi
- for file in ${ui_files[@]}; do
- echo $file
- done
- for file in ${blp_files[@]}; do
- echo $file
- done
- for file in ${rs_files[@]}; do
- echo $file
- done
-
- let rs_macro_count=$((${#rs_macro_files[@]}))
- if [[ $rs_macro_count -eq 1 ]]; then
- echo ""
- echo -e "$error Found 1 Rust file that uses a gettext-rs macro, use the corresponding i18n method instead:"
- ret=1
- elif [[ $rs_macro_count -ne 0 ]]; then
- echo ""
- echo -e "$error Found $rs_macro_count Rust files that use a gettext-rs macro, use the corresponding i18n method instead:"
- ret=1
- fi
- for file in ${rs_macro_files[@]}; do
- echo $file
- done
-
- if [[ ret -eq 1 ]]; then
- echo ""
- echo -e " Checking po/POTFILES.in result: $fail"
- exit 1
- fi
-
- # Check sorted alphabetically
- to_sort=("${potfiles[@]}")
- sort
- for i in ${!potfiles[@]}; do
- if [[ "${potfiles[$i]}" != "${to_sort[$i]}" ]]; then
- echo -e "$error Found file '${potfiles[$i]}' before '${to_sort[$i]}' in POTFILES.in"
- ret=1
- break
- fi
- done
-
- if [[ ret -eq 1 ]]; then
- echo ""
- echo -e " Checking po/POTFILES.in result: $fail"
- exit 1
- else
- echo -e " Checking po/POTFILES.in result: $ok"
- fi
-}
-
-# Check if files in resource files are sorted alphabetically.
-check_resources() {
- echo -e "$Checking $1…"
-
- local ret=0
- local files=()
-
- # Get files.
- regex="(.*)"
- while read -r line; do
- if [[ $line =~ $regex ]]; then
- files+=("${BASH_REMATCH[1]}")
- fi
- done < $1
-
- # Check sorted alphabetically
- local to_sort=("${files[@]}")
- sort
- for i in ${!files[@]}; do
- if [[ "${files[$i]}" != "${to_sort[$i]}" ]]; then
- echo -e "$error Found file '${files[$i]#src/}' before '${to_sort[$i]#src/}' in $1"
- ret=1
- break
- fi
- done
-
- if [[ ret -eq 1 ]]; then
- echo ""
- echo -e " Checking $1 result: $fail"
- exit 1
- else
- echo -e " Checking $1 result: $ok"
- fi
-}
-
-# Check if files in blp-resources.in are sorted alphabetically.
-check_blueprint_resources() {
- input_file="src/ui-blueprint-resources.in"
- echo -e "$Checking $input_file…"
-
- local ret=0
- local files=()
-
- # Get files.
- while read -r line; do
- if [[ -n $line && ${line::1} != '#' ]]; then
- if [[ ! -f "src/${line}" ]]; then
- echo -e "$error File '$line' in $input_file does not exist"
- ret=1
- fi
- files+=($line)
- fi
- done < $input_file
-
- # Check sorted alphabetically
- local to_sort=("${files[@]}")
- sort
- for i in ${!files[@]}; do
- if [[ "${files[$i]}" != "${to_sort[$i]}" ]]; then
- echo -e "$error Found file '${files[$i]#src/}' before '${to_sort[$i]#src/}' in $input_file"
- ret=1
- break
- fi
- done
-
- if [[ ret -eq 1 ]]; then
- echo ""
- echo -e " Checking $input_file result: $fail"
- exit 1
- else
- echo -e " Checking $input_file result: $ok"
- fi
-}
-
-# Install cargo-sort with cargo.
-install_cargo_sort() {
- echo -e "$Installing cargo-sort…"
- cargo install cargo-sort
- if ! cargo-sort --version >/dev/null 2>&1; then
- echo -e "$Could_not install cargo-sort"
- exit 2
- fi
-}
-
-# Run cargo-sort to check if Cargo.toml is sorted.
-run_cargo_sort() {
- if ! cargo-sort --version >/dev/null 2>&1; then
- if [[ $force_install -eq 1 ]]; then
- install_cargo_sort
- elif [ ! -t 1 ]; then
- echo "Could not check Cargo.toml sorting, because cargo-sort could not be run"
- exit 2
- else
- echo "Cargo-sort is needed to check the sorting in Cargo.toml, but it isn’t available"
- echo ""
- echo "y: Install cargo-sort via cargo"
- echo "N: Don’t install cargo-sort and abort checks"
- echo ""
- while true; do
- echo -n "Install cargo-sort? [y/N]: "; read yn < /dev/tty
- case $yn in
- [Yy]* )
- install_cargo_sort
- break
- ;;
- [Nn]* | "" )
- exit 2
- ;;
- * )
- echo $invalid
- ;;
- esac
- done
- fi
- fi
-
- echo -e "$Checking Cargo.toml sorting…"
-
- if [[ $verbose -eq 1 ]]; then
- echo ""
- cargo-sort --version
- echo ""
- fi
-
- if ! cargo-sort --check --grouped --order package,lib,profile,features,dependencies,target,dev-dependencies,build-dependencies; then
- echo -e " Cargo.toml sorting result: $fail"
- echo "Please fix the Cargo.toml file, either manually or by running: cargo-sort --grouped --order package,lib,profile,features,dependencies,target,dev-dependencies,build-dependencies"
- exit 1
- else
- echo -e " Cargo.toml sorting result: $ok"
- fi
-}
-
-# Check arguments
-while [[ "$1" ]]; do case $1 in
- -s | --git-staged )
- git_staged=1
- ;;
- -f | --force-install )
- force_install=1
- ;;
- -v | --verbose )
- verbose=1
- ;;
- -h | --help )
- show_help
- exit 0
- ;;
- *)
- show_help >&2
- exit 1
-esac; shift; done
-
-if [[ $git_staged -eq 1 ]]; then
- staged_files=`git diff --name-only --cached`
- if [[ -z $staged_files ]]; then
- echo -e "$Could_not check files because none where staged"
- exit 2
- fi
-else
- staged_files=""
-fi
-
-
-# Run
-check_cargo
-echo ""
-run_rustfmt
-echo ""
-run_typos
-echo ""
-run_machete
-echo ""
-run_cargo_deny
-echo ""
-check_potfiles
-echo ""
-if [[ -n $staged_files ]]; then
- if [[ $staged_files = *src/ui-blueprint-resources.in* ]]; then
- check_blueprint_resources
- echo ""
- fi
- if [[ $staged_files = *data/resources/resources.gresource.xml* ]]; then
- check_resources "data/resources/resources.gresource.xml"
- echo ""
- fi
-else
- check_blueprint_resources
- echo ""
- check_resources "data/resources/resources.gresource.xml"
- echo ""
-fi
-run_cargo_sort
-echo ""
diff --git a/hooks/checks/Cargo.lock b/hooks/checks/Cargo.lock
new file mode 100644
index 00000000..4eae7674
--- /dev/null
+++ b/hooks/checks/Cargo.lock
@@ -0,0 +1,7 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "checks"
+version = "0.1.0"
diff --git a/hooks/checks/Cargo.toml b/hooks/checks/Cargo.toml
new file mode 100644
index 00000000..3b9b3696
--- /dev/null
+++ b/hooks/checks/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "checks"
+version = "0.1.0"
+license = "GPL-3.0-or-later"
+edition = "2024"
+rust-version = "1.85"
+publish = false
+
+[dependencies]
diff --git a/hooks/checks/src/main.rs b/hooks/checks/src/main.rs
new file mode 100644
index 00000000..d3fb7caf
--- /dev/null
+++ b/hooks/checks/src/main.rs
@@ -0,0 +1,724 @@
+use std::{
+ collections::BTreeSet,
+ fs::File,
+ io::{BufRead, BufReader},
+ path::Path,
+ process::{ExitCode, Output},
+ sync::atomic::{AtomicBool, Ordering},
+};
+
+mod utils;
+
+use crate::utils::{
+ CargoManifest, CheckDependency, CommandData, GitStagedFiles, InstallationCommand,
+ check_files_sorted, file_contains, load_files, print_error, visit_dir,
+};
+
+/// The path to the directory containing the workspace.
+const WORKSPACE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../");
+
+/// Whether to use verbose output.
+///
+/// Prefer to use [`is_verbose()`], which hides the complexity of the
+/// [`AtomicBool`] API.
+static VERBOSE: AtomicBool = AtomicBool::new(false);
+
+/// Whether to use verbose output.
+///
+/// This is helper around the [`VERBOSE`] variable to simplify the API.
+fn is_verbose() -> bool {
+ VERBOSE.load(Ordering::Relaxed)
+}
+
+fn main() -> ExitCode {
+ let result = ScriptCommand::parse_args().and_then(|cmd| cmd.run());
+ println!();
+
+ match result {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => error.exit_code(),
+ }
+}
+
+/// The possible commands to run in this script.
+enum ScriptCommand {
+ /// A command that prints the help of this script.
+ PrintHelp(PrintHelpCmd),
+ /// A command that prints the version of this script.
+ PrintVersion(PrintVersionCmd),
+ /// A command that runs conformity checks on the current Rust and GTK ///
+ /// project.
+ Check(CheckCmd),
+}
+
+impl ScriptCommand {
+ /// Parse the arguments passed to this script.
+ fn parse_args() -> Result {
+ let mut args = std::env::args();
+ let _script_path = args
+ .next()
+ .expect("the first argument to the script should be its path");
+
+ let mut check_cmd = CheckCmd::default();
+ let mut git_staged = false;
+
+ for arg in args {
+ if let Some(long_flag) = arg.strip_prefix("--") {
+ match long_flag {
+ "git-staged" => {
+ git_staged = true;
+ }
+ "force-install" => {
+ check_cmd.force_install = true;
+ }
+ "verbose" => {
+ VERBOSE.store(true, Ordering::Relaxed);
+ }
+ "version" => {
+ return Ok(Self::PrintVersion(PrintVersionCmd));
+ }
+ "help" => {
+ return Ok(Self::PrintHelp(PrintHelpCmd));
+ }
+ _ => {
+ print_error(&format!("unsupported flag `--{long_flag}`"));
+ PrintHelpCmd.run();
+ return Err(ScriptError::Check);
+ }
+ }
+ } else if let Some(short_flags) = arg.strip_prefix('-') {
+ // We allow to combine short flags.
+ for short_flag in short_flags.chars() {
+ match short_flag {
+ 's' => {
+ git_staged = true;
+ }
+ 'f' => {
+ check_cmd.force_install = true;
+ }
+ 'v' => {
+ VERBOSE.store(true, Ordering::Relaxed);
+ }
+ 'h' => {
+ return Ok(Self::PrintHelp(PrintHelpCmd));
+ }
+ _ => {
+ print_error(&format!("unsupported flag `-{short_flag}`"));
+ PrintHelpCmd.run();
+ return Err(ScriptError::Check);
+ }
+ }
+ }
+ } else {
+ print_error(&format!("unsupported argument `{arg}`"));
+ PrintHelpCmd.run();
+ return Err(ScriptError::Check);
+ }
+ }
+
+ if git_staged {
+ check_cmd.staged_files = Some(GitStagedFiles::load()?);
+ }
+
+ Ok(Self::Check(check_cmd))
+ }
+
+ /// Run the current command.
+ fn run(self) -> Result<(), ScriptError> {
+ match self {
+ Self::PrintHelp(cmd) => cmd.run(),
+ Self::PrintVersion(cmd) => cmd.run(),
+ Self::Check(cmd) => cmd.run()?,
+ }
+
+ Ok(())
+ }
+}
+
+/// A command that prints the help of this script.
+struct PrintHelpCmd;
+
+impl PrintHelpCmd {
+ /// Run this command.
+ fn run(self) {
+ let CargoManifest { name, .. } = CargoManifest::load();
+ println!(
+ "\
+Run conformity checks on the current Rust project.
+
+If a dependency is not found, helps the user to install it.
+
+USAGE: {name} [OPTIONS]
+
+OPTIONS:
+ -s, --git-staged Only check files staged to be committed
+ -f, --force-install Install missing dependencies without asking
+ -v, --verbose Use verbose output
+ --version Print the version of this script
+ -h, --help Print this help and exit
+
+ERROR CODES:
+ 1 Check failed
+ 2 Setup failed
+",
+ );
+ }
+}
+
+/// A command that prints the version of this script.
+struct PrintVersionCmd;
+
+impl PrintVersionCmd {
+ /// Run this command.
+ fn run(self) {
+ let CargoManifest { name, version } = CargoManifest::load();
+ println!("{name} {version}");
+ }
+}
+
+/// A command that runs conformity checks on the current Rust and GTK project.
+#[derive(Default)]
+struct CheckCmd {
+ /// The staged files to check.
+ ///
+ /// If this is `None`, all files are checked.
+ staged_files: Option,
+ /// Whether to install missing dependencies without asking.
+ force_install: bool,
+}
+
+impl CheckCmd {
+ /// Run this command.
+ fn run(self) -> Result<(), ScriptError> {
+ self.code_style()?;
+ self.lint_script()?;
+ self.spelling()?;
+ self.unused_dependencies()?;
+ self.allowed_dependencies()?;
+ self.potfiles()?;
+ self.blueprints()?;
+ self.data_gresources()?;
+ self.cargo_manifest()?;
+
+ Ok(())
+ }
+
+ /// Check code style with rustfmt nightly.
+ fn code_style(&self) -> Result<(), ScriptError> {
+ let mut check = Check::start("code style")
+ .with_fix("either manually or by running: cargo +nightly fmt --all");
+
+ CheckDependency {
+ name: "rustfmt",
+ version: CommandData::new("cargo", &["+nightly", "fmt", "--version"]),
+ install: InstallationCommand::Custom(CommandData::new(
+ "rustup",
+ &["component", "add", "--toolchain", "nightly", "rustfmt"],
+ )),
+ }
+ .check(self.force_install)?;
+
+ if let Some(staged_files) = &self.staged_files {
+ let cmd = CommandData::new(
+ "cargo",
+ &[
+ "+nightly",
+ "fmt",
+ "--check",
+ "--",
+ "--unstable-features",
+ "--skip-children",
+ ],
+ )
+ .print_output();
+
+ for rust_file in staged_files.filter(|file| file.ends_with(".rs")) {
+ let output = cmd.run_with_args(&[rust_file])?;
+ check.merge_output(output);
+ }
+ } else {
+ let output = CommandData::new("cargo", &["+nightly", "fmt", "--check", "--all"])
+ .print_output()
+ .run()?;
+ check.merge_output(output);
+ }
+
+ check.end()
+ }
+
+ /// Lint this crate with clippy and rustfmt.
+ fn lint_script(&self) -> Result<(), ScriptError> {
+ if self
+ .staged_files
+ .as_ref()
+ .is_some_and(|staged_files| !staged_files.any(|file| file.starts_with("hooks/checks")))
+ {
+ // No check necessary.
+ return Ok(());
+ }
+
+ let mut check = Check::start("hooks/checks");
+
+ CheckDependency {
+ name: "clippy",
+ version: CommandData::new("cargo", &["clippy", "--version"]),
+ install: InstallationCommand::Custom(CommandData::new(
+ "rustup",
+ &["component", "add", "clippy"],
+ )),
+ }
+ .check(self.force_install)?;
+
+ let manifest_path = format!("{WORKSPACE_DIR}/hooks/checks/Cargo.toml");
+
+ let output = CommandData::new("cargo", &["clippy", "--all-targets", "--manifest-path"])
+ .print_output()
+ .run_with_args(&[&manifest_path])?;
+ check.merge_output(output);
+
+ // We should have already checked that rustfmt is installed.
+ let output = CommandData::new("cargo", &["+nightly", "fmt", "--check", "--manifest-path"])
+ .print_output()
+ .run_with_args(&[&manifest_path])?;
+ check.merge_output(output);
+
+ check.end()
+ }
+
+ /// Check spelling with typos.
+ fn spelling(&self) -> Result<(), ScriptError> {
+ let mut check =
+ Check::start("spelling mistakes").with_fix("either manually or by running: typos -w");
+
+ CheckDependency {
+ name: "typos",
+ version: CommandData::new("typos", &["--version"]),
+ install: InstallationCommand::Cargo("typos-cli"),
+ }
+ .check(self.force_install)?;
+
+ let cmd = CommandData::new("typos", &["--color", "always"]).print_output();
+
+ let output = if let Some(staged_files) = &self.staged_files {
+ cmd.run_with_args(staged_files.as_slice())?
+ } else {
+ cmd.run()?
+ };
+
+ check.merge_output(output);
+ check.end()
+ }
+
+ /// Check unused dependencies with cargo-machete.
+ fn unused_dependencies(&self) -> Result<(), ScriptError> {
+ let mut check = Check::start("unused dependencies").with_fix(
+ "either by removing the dependencies, or by adding \
+ the necessary configuration option in Cargo.toml \
+ (see cargo-machete documentation)",
+ );
+
+ CheckDependency {
+ name: "cargo-machete",
+ version: CommandData::new("cargo-machete", &["--version"]),
+ install: InstallationCommand::Cargo("cargo-machete"),
+ }
+ .check(self.force_install)?;
+
+ let output = CommandData::new("cargo-machete", &["--with-metadata"])
+ .print_output()
+ .run()?;
+
+ check.merge_output(output);
+ check.end()
+ }
+
+ /// Check allowed dependencies with cargo-deny.
+ fn allowed_dependencies(&self) -> Result<(), ScriptError> {
+ let mut check = Check::start("allowed dependencies").with_fix(
+ "either by removing the dependencies, or by adding \
+ the necessary configuration option in deny.toml \
+ (see cargo-deny documentation)",
+ );
+
+ CheckDependency {
+ name: "cargo-deny",
+ version: CommandData::new("cargo", &["deny", "--version"]),
+ install: InstallationCommand::Cargo("cargo-deny"),
+ }
+ .check(self.force_install)?;
+
+ let output = CommandData::new("cargo", &["deny", "check"])
+ .print_output()
+ .run()?;
+
+ check.merge_output(output);
+ check.end()
+ }
+
+ /// Check that files in `POTFILES.in` and `POTFILES.skip` are correct.
+ ///
+ /// This applies the following checks, in that order:
+ ///
+ /// - All listed files exist
+ /// - All files with translatable strings are listed and only those
+ /// - Listed files are sorted alphabetically
+ /// - No Rust files use the gettext-rs macros
+ ///
+ /// This assumes the following:
+ ///
+ /// - The POTFILES are located at `po/POTFILES.(in/skip)`.
+ /// - UI (GtkBuilder) files are located under `src` and use
+ /// `translatable="yes"`.
+ /// - Blueprint files are located under `src` and use `_(`.
+ /// - Rust files are located under `src` and use `*gettext(_f)` methods.
+ fn potfiles(&self) -> Result<(), ScriptError> {
+ let base_dir = Path::new(WORKSPACE_DIR);
+
+ let potfiles_in_exist_check = Check::start("files exist in po/POTFILES.in");
+ let Ok(potfiles_in) = load_files(base_dir, Path::new("po/POTFILES.in")) else {
+ return potfiles_in_exist_check.fail();
+ };
+ potfiles_in_exist_check.end()?;
+
+ let potfiles_in_order_check =
+ Check::start("files are ordered alphabetically in po/POTFILES.in");
+ if check_files_sorted(base_dir, &potfiles_in).is_err() {
+ return potfiles_in_order_check.fail();
+ }
+ potfiles_in_order_check.end()?;
+
+ let potfiles_skip_exist_check = Check::start("files exist in po/POTFILES.skip");
+ let Ok(potfiles_skip) = load_files(base_dir, Path::new("po/POTFILES.skip")) else {
+ return potfiles_skip_exist_check.fail();
+ };
+ potfiles_skip_exist_check.end()?;
+
+ let potfiles_skip_order_check =
+ Check::start("files are ordered alphabetically in po/POTFILES.skip");
+ if check_files_sorted(base_dir, &potfiles_skip).is_err() {
+ return potfiles_skip_order_check.fail();
+ }
+ potfiles_skip_order_check.end()?;
+
+ let mut translatable_files_check = Check::start(
+ "all files with translatable strings are present in po/POTFILES.in or po/POTFILES.skip",
+ );
+ let mut translatable_ui = BTreeSet::new();
+ let mut translatable_blp = BTreeSet::new();
+ let mut translatable_rs = BTreeSet::new();
+
+ visit_dir(&base_dir.join("src"), &mut |path| {
+ if potfiles_skip.contains(&path) {
+ return;
+ }
+ let Some(extension) = path.extension() else {
+ return;
+ };
+
+ if extension == "ui" {
+ if file_contains(&path, &[r#"translatable="yes""#]) {
+ translatable_ui.insert(path);
+ }
+ } else if extension == "blp" {
+ if file_contains(&path, &["_("]) {
+ translatable_blp.insert(path);
+ }
+ } else if extension == "rs" {
+ if file_contains(&path, &["gettext!("]) {
+ let relative_path = path
+ .strip_prefix(base_dir)
+ .expect("all visited files should be in the workspace")
+ .to_owned();
+
+ print_error(&format!(
+ "file '{}' uses a gettext-rs macro, use the corresponding i18n method instead",
+ relative_path.to_string_lossy(),
+ ));
+ translatable_files_check.record_failure();
+ }
+ if file_contains(&path, &["gettext(", "gettext_f("]) {
+ translatable_rs.insert(path);
+ }
+ }
+ });
+
+ let mut potfiles_in_ui = BTreeSet::new();
+ let mut potfiles_in_blp = BTreeSet::new();
+ let mut potfiles_in_rs = BTreeSet::new();
+
+ for path in potfiles_in {
+ let Some(extension) = path.extension() else {
+ continue;
+ };
+
+ if extension == "ui" {
+ potfiles_in_ui.insert(path);
+ } else if extension == "blp" {
+ potfiles_in_blp.insert(path);
+ } else if extension == "rs" {
+ potfiles_in_rs.insert(path);
+ }
+ }
+
+ let not_translatable = potfiles_in_ui
+ .difference(&translatable_ui)
+ .chain(potfiles_in_blp.difference(&translatable_blp))
+ .chain(potfiles_in_rs.difference(&translatable_rs))
+ .collect::>();
+ if !not_translatable.is_empty() {
+ translatable_files_check.record_failure();
+ let count = not_translatable.len();
+
+ if count == 1 {
+ print_error("Found 1 file with translatable strings not present in POTFILES.in:");
+ } else {
+ print_error(&format!(
+ "Found {count} files with translatable strings not present in POTFILES.in:"
+ ));
+ }
+ }
+ for path in not_translatable {
+ let relative_path = path
+ .strip_prefix(base_dir)
+ .expect("all visited files should be in the workspace")
+ .to_owned();
+
+ println!("{}", relative_path.to_string_lossy());
+ }
+
+ let missing_translatable = translatable_ui
+ .difference(&potfiles_in_ui)
+ .chain(translatable_blp.difference(&potfiles_in_blp))
+ .chain(translatable_rs.difference(&potfiles_in_rs))
+ .collect::>();
+ if !missing_translatable.is_empty() {
+ translatable_files_check.record_failure();
+ let count = missing_translatable.len();
+
+ if count == 1 {
+ print_error("Found 1 file in POTFILES.in without translatable strings:");
+ } else {
+ print_error(&format!(
+ "Found {count} files in POTFILES.in without translatable strings:"
+ ));
+ }
+ }
+ for path in missing_translatable {
+ let relative_path = path
+ .strip_prefix(base_dir)
+ .expect("all visited files should be in the workspace")
+ .to_owned();
+
+ println!("{}", relative_path.to_string_lossy());
+ }
+
+ translatable_files_check.end()
+ }
+
+ /// Check `src/ui-blueprint-resources.in`.
+ ///
+ /// Checks that the files exist and are sorted alphabetically.
+ fn blueprints(&self) -> Result<(), ScriptError> {
+ let base_dir = Path::new(WORKSPACE_DIR).join("src");
+
+ if self.staged_files.as_ref().is_some_and(|staged_files| {
+ !staged_files.any(|file| file == "src/ui-blueprint-resources.in")
+ }) {
+ // No check necessary.
+ return Ok(());
+ }
+
+ let files_exist_check = Check::start("files exist in src/ui-blueprint-resources.in");
+ let Ok(blueprint_files) = load_files(&base_dir, Path::new("ui-blueprint-resources.in"))
+ else {
+ return files_exist_check.fail();
+ };
+ files_exist_check.end()?;
+
+ let files_order_check =
+ Check::start("files are ordered alphabetically in src/ui-blueprint-resources.in");
+ if check_files_sorted(&base_dir, &blueprint_files).is_err() {
+ return files_order_check.fail();
+ }
+ files_order_check.end()
+ }
+
+ /// Check that files listed in `data/resources/resources.gresource.xml` are
+ /// sorted alphabetically.
+ fn data_gresources(&self) -> Result<(), ScriptError> {
+ const GRESOURCES_PATH: &str = "data/resources/resources.gresource.xml";
+
+ if self
+ .staged_files
+ .as_ref()
+ .is_some_and(|staged_files| !staged_files.any(|file| file == GRESOURCES_PATH))
+ {
+ // No check necessary.
+ return Ok(());
+ }
+
+ let check = Check::start(
+ "files are ordered alphabetically in data/resources/resources.gresource.xml",
+ );
+
+ let reader = match File::open(Path::new(WORKSPACE_DIR).join(GRESOURCES_PATH)) {
+ Ok(file) => BufReader::new(file),
+ Err(error) => {
+ print_error(&format!("could not open file `{GRESOURCES_PATH}`: {error}"));
+ return check.fail();
+ }
+ };
+
+ let mut previous_file_path: Option = None;
+
+ for line in reader.lines() {
+ let line = match line {
+ Ok(line) => line,
+ Err(error) => {
+ print_error(&format!(
+ "could not read line of file `{GRESOURCES_PATH}`: {error}"
+ ));
+ return check.fail();
+ }
+ };
+
+ // The file path is between `` and ``.
+ let Some((file_path, _)) = line
+ .split_once("'))
+ .and_then(|(_, line_end)| line_end.split_once(""))
+ else {
+ continue;
+ };
+
+ if let Some(previous_file_path) = previous_file_path.as_deref() {
+ if previous_file_path > file_path {
+ print_error(&format!("file `{previous_file_path}` before `{file_path}`"));
+ return check.fail();
+ }
+ }
+
+ previous_file_path = Some(file_path.to_owned());
+ }
+
+ check.end()
+ }
+
+ /// Check `Cargo.toml` with cargo-sort.
+ fn cargo_manifest(&self) -> Result<(), ScriptError> {
+ if self
+ .staged_files
+ .as_ref()
+ .is_some_and(|staged_files| !staged_files.any(|file| file == "Cargo.toml"))
+ {
+ // No check necessary.
+ return Ok(());
+ }
+
+ let mut check = Check::start("Cargo.toml sorting").with_fix(
+ "either manually or by running: cargo-sort --grouped --order \
+ package,lib,profile,features,dependencies,target,dev-dependencies,build-dependencies",
+ );
+
+ CheckDependency {
+ name: "cargo-sort",
+ version: CommandData::new("cargo", &["sort", "--version"]),
+ install: InstallationCommand::Cargo("cargo-sort"),
+ }
+ .check(self.force_install)?;
+
+ let output = CommandData::new(
+ "cargo",
+ &["sort", "--check", "--grouped", "--order", "workspace,package,lib,profile,features,dependencies,target,dev-dependencies,build-dependencies"]
+ ).print_output().run()?;
+
+ check.merge_output(output);
+ check.end()
+ }
+}
+
+/// A check in this script.
+struct Check {
+ /// The name of this check.
+ name: &'static str,
+ /// The way to fix a failure of this check.
+ fix: Option<&'static str>,
+ /// Whether this check was successful.
+ success: bool,
+}
+
+impl Check {
+ /// Start the check with the given name.
+ fn start(name: &'static str) -> Self {
+ println!("\n\x1B[1;92mChecking\x1B[0m {name}");
+
+ Self {
+ name,
+ fix: None,
+ success: true,
+ }
+ }
+
+ /// Set the way to fix a failure of this check.
+ fn with_fix(mut self, fix: &'static str) -> Self {
+ self.fix = Some(fix);
+ self
+ }
+
+ /// Record the failure of this check.
+ fn record_failure(&mut self) {
+ self.success = false;
+ }
+
+ /// Merge the given output for the result of this check.
+ fn merge_output(&mut self, output: Output) {
+ self.success &= output.status.success();
+ }
+
+ /// Finish this check.
+ ///
+ /// Print the result and convert it to a Rust result.
+ fn end(self) -> Result<(), ScriptError> {
+ let Self { name, fix, success } = self;
+
+ if success {
+ println!("Checking {name} result: \x1B[1;92mok\x1B[0m",);
+ Ok(())
+ } else {
+ println!("Checking {name} result: \x1B[1;91mfail\x1B[0m",);
+
+ if let Some(fix) = fix {
+ println!("Please fix the above issues, {fix}");
+ } else {
+ println!("Please fix the above issues");
+ }
+
+ Err(ScriptError::Check)
+ }
+ }
+
+ /// Fail this check immediately.
+ fn fail(mut self) -> Result<(), ScriptError> {
+ self.record_failure();
+ self.end()
+ }
+}
+
+/// The possible errors returned by this script.
+enum ScriptError {
+ /// A check failed.
+ Check,
+ /// The setup for a check failed.
+ Setup,
+}
+
+impl ScriptError {
+ /// The exit code to return for this error.
+ fn exit_code(&self) -> ExitCode {
+ match self {
+ Self::Check => 1,
+ Self::Setup => 2,
+ }
+ .into()
+ }
+}
diff --git a/hooks/checks/src/utils.rs b/hooks/checks/src/utils.rs
new file mode 100644
index 00000000..40061a84
--- /dev/null
+++ b/hooks/checks/src/utils.rs
@@ -0,0 +1,467 @@
+use std::{
+ fs::{self, File},
+ io::{BufRead, BufReader, IsTerminal, Write, stdin, stdout},
+ path::{Path, PathBuf},
+ process::{Command, Output, Stdio},
+};
+
+use crate::{ScriptError, is_verbose};
+
+/// The data from this script's cargo manifest.
+pub(crate) struct CargoManifest {
+ /// The name of this script.
+ pub(crate) name: String,
+ /// The version of this script.
+ pub(crate) version: String,
+}
+
+impl CargoManifest {
+ /// Load the script's cargo manifest data.
+ pub(crate) fn load() -> Self {
+ let manifest = include_str!("../Cargo.toml");
+ let mut name = None;
+ let mut version = None;
+
+ for line in manifest.lines().map(str::trim) {
+ if let Some(value) = line.strip_prefix("name = ") {
+ name = Some(value.trim_matches('"').to_owned())
+ } else if let Some(value) = line.strip_prefix("version = ") {
+ version = Some(value.trim_matches('"').to_owned())
+ }
+ }
+
+ Self {
+ name: name.expect("name should be in cargo manifest"),
+ version: version.expect("version should be in cargo manifest"),
+ }
+ }
+}
+
+/// Files staged for git.
+pub(crate) struct GitStagedFiles(Vec);
+
+impl GitStagedFiles {
+ /// Load the staged files from git.
+ pub(crate) fn load() -> Result {
+ let output = CommandData::new(
+ "git",
+ &["diff", "--name-only", "--cached", "--diff-filter=d"],
+ )
+ .run()?;
+
+ if !output.status.success() {
+ print_error(&format!(
+ "could not get the list of staged files: {}",
+ String::from_utf8(output.stderr).expect("git output should be valid UTF-8"),
+ ));
+ return Err(ScriptError::Check);
+ }
+
+ let files = String::from_utf8(output.stdout)
+ .expect("git output should be valid UTF-8")
+ .trim()
+ .lines()
+ .map(|line| line.trim())
+ .filter(|line| !line.is_empty())
+ .map(ToOwned::to_owned)
+ .collect::>();
+
+ if files.is_empty() {
+ print_error("could not check staged files: no files are staged");
+ return Err(ScriptError::Setup);
+ }
+
+ Ok(Self(files))
+ }
+
+ /// Access the inner slice of this list.
+ pub(crate) fn as_slice(&self) -> &[String] {
+ &self.0
+ }
+
+ /// Whether any of the files in this list match the given predicate.
+ pub(crate) fn any(&self, f: F) -> bool
+ where
+ F: Fn(&str) -> bool,
+ {
+ self.0.iter().any(|file| f(file.as_str()))
+ }
+
+ /// Filter this list with the given predicate.
+ pub(crate) fn filter(&self, f: F) -> impl Iterator-
+ where
+ F: Fn(&str) -> bool,
+ {
+ self.0.iter().filter_map(move |file| {
+ let file = file.as_str();
+ f(file).then_some(file)
+ })
+ }
+}
+
+/// A check for the presence of a dependency.
+#[derive(Clone, Copy)]
+pub(crate) struct CheckDependency {
+ /// The name of the dependency.
+ pub(crate) name: &'static str,
+ /// The command to print the version of the dependency.
+ ///
+ /// It will be used to check whether the dependency is available.
+ pub(crate) version: CommandData,
+ /// The command to run to install the dependency.
+ pub(crate) install: InstallationCommand,
+}
+
+impl CheckDependency {
+ /// Check whether the dependency is available.
+ ///
+ /// Returns `Ok` if the dependency was available or successfully installed.
+ pub(crate) fn check(self, force_install: bool) -> Result<(), ScriptError> {
+ let Self {
+ name,
+ version,
+ install,
+ } = self;
+
+ let version = if is_verbose() {
+ version.print_output()
+ } else {
+ version.ignore_output()
+ };
+
+ // Do not forward errors here as it might just be the program that is missing.
+ if version.run().is_ok_and(|output| output.status.success()) {
+ // The dependency is available.
+ return Ok(());
+ }
+
+ if !force_install {
+ self.ask_install()?;
+ }
+
+ println!("\x1B[1;92mInstalling\x1B[0m {name}…");
+
+ if install.run()?.status.success() && version.run()?.status.success() {
+ // The dependency was installed successfully.
+ Ok(())
+ } else {
+ print_error(&format!("could not install {name}",));
+ Err(ScriptError::Setup)
+ }
+ }
+
+ /// Ask the user whether we should try to install the dependency, if we are
+ /// in a terminal.
+ fn ask_install(self) -> Result<(), ScriptError> {
+ let name = self.name;
+
+ let stdin = stdin();
+
+ if !stdin.is_terminal() {
+ print_error(&format!("could not run {name}"));
+ return Err(ScriptError::Setup);
+ }
+
+ println!("{name} is needed for this check, but it isn’t available\n");
+ println!("y: Install {name} via {}", self.install.via());
+ println!("N: Don’t install {name} and abort checks\n");
+
+ let mut input = String::new();
+ let mut stdout = stdout();
+
+ // Repeat the question until the user selects a proper response.
+ loop {
+ print!("Install {name}? [y/N]: ");
+ stdout.flush().expect("should succeed to flush stdout");
+
+ let mut handle = stdin.lock();
+ handle
+ .read_line(&mut input)
+ .expect("should succeed to read from stdin");
+
+ input = input.trim().to_ascii_lowercase();
+
+ match input.as_str() {
+ "y" | "yes" => return Ok(()),
+ "n" | "no" | "" => return Err(ScriptError::Setup),
+ _ => {
+ println!();
+ print_error("invalid input");
+ }
+ }
+
+ input.clear();
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct CommandData {
+ /// The program to execute.
+ program: &'static str,
+ /// The arguments of the program.
+ args: &'static [&'static str],
+ /// The behavior for the output of this command.
+ output: OutputBehavior,
+}
+
+impl CommandData {
+ /// Create a new `CommandData` for the given program and arguments.
+ ///
+ /// By default stdout and stderr will be available in the output of the
+ /// command.
+ #[must_use]
+ pub(crate) fn new(program: &'static str, args: &'static [&'static str]) -> Self {
+ Self {
+ program,
+ args,
+ output: OutputBehavior::Read,
+ }
+ }
+
+ /// Print the output of the command in the shell.
+ #[must_use]
+ pub(crate) fn print_output(mut self) -> Self {
+ self.output = OutputBehavior::Print;
+ self
+ }
+
+ /// Ignore the output of the command.
+ ///
+ /// It will neither be printed or read.
+ #[must_use]
+ pub(crate) fn ignore_output(mut self) -> Self {
+ self.output = OutputBehavior::Ignore;
+ self
+ }
+
+ /// Get the string representation of this command with the given extra
+ /// arguments.
+ fn to_string_with_args(self, extra_args: &[impl AsRef]) -> String {
+ let mut string = self.program.to_owned();
+
+ for arg in self
+ .args
+ .iter()
+ .copied()
+ .chain(extra_args.iter().map(AsRef::as_ref))
+ {
+ string.push(' ');
+ string.push_str(arg);
+ }
+
+ string
+ }
+
+ /// Run this command.
+ pub(crate) fn run(self) -> Result