cargo_hatch/settings/repo/
mod.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fmt::Display,
4    fs,
5    hash::BuildHasher,
6    iter::FromIterator,
7    str::FromStr,
8};
9
10use anyhow::{bail, Context, Result};
11use camino::{Utf8Path, Utf8PathBuf};
12use git2::Config as GitConfig;
13use indexmap::{IndexMap, IndexSet};
14use num_traits::Num;
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use tera::{Context as TeraContext, Tera};
18
19use super::global::DefaultSetting;
20
21mod de;
22mod defaults;
23mod prompts;
24mod validators;
25
26#[derive(Deserialize)]
27pub struct RepoSettings {
28    crate_type: Option<CrateType>,
29    #[serde(default)]
30    pub ignore: Vec<IgnorePattern>,
31    #[serde(flatten)]
32    pub args: IndexMap<String, RepoSetting>,
33}
34
35#[derive(Deserialize)]
36pub struct IgnorePattern {
37    pub paths: Vec<Utf8PathBuf>,
38    pub condition: Option<String>,
39}
40
41#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum CrateType {
44    Bin,
45    Lib,
46}
47
48#[derive(Deserialize)]
49pub struct RepoSetting {
50    description: String,
51    condition: Option<String>,
52    #[serde(flatten)]
53    ty: SettingType,
54}
55
56#[derive(Deserialize)]
57#[serde(rename_all = "snake_case", tag = "type")]
58pub enum SettingType {
59    Bool(BoolSetting),
60    String(StringSetting),
61    Number(NumberSetting<i64>),
62    Float(NumberSetting<f64>),
63    List(ListSetting),
64    MultiList(MultiListSetting),
65}
66
67trait Setting<D> {
68    fn set_default(&mut self, default: D);
69    fn validate(&self) -> Option<&'static str> {
70        None
71    }
72}
73
74#[derive(Deserialize)]
75pub struct BoolSetting {
76    default: Option<bool>,
77}
78
79impl Setting<bool> for BoolSetting {
80    fn set_default(&mut self, default: bool) {
81        self.default = Some(default);
82    }
83}
84
85#[derive(Deserialize)]
86pub struct StringSetting {
87    default: Option<String>,
88    validator: Option<StringValidator>,
89}
90
91impl Setting<String> for StringSetting {
92    fn set_default(&mut self, default: String) {
93        self.default = Some(default);
94    }
95}
96
97#[derive(Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum StringValidator {
100    Crate,
101    Ident,
102    Semver,
103    SemverReq,
104    #[serde(deserialize_with = "de::from_str")]
105    Regex(Regex),
106}
107
108pub trait Number: Num + Copy + Display + FromStr + PartialOrd + Serialize {}
109
110impl<T: Num + Copy + Display + FromStr + PartialOrd + Serialize> Number for T {}
111
112#[derive(Deserialize)]
113pub struct NumberSetting<T: Number> {
114    min: T,
115    max: T,
116    default: Option<T>,
117}
118
119impl<T: Number> Setting<T> for NumberSetting<T> {
120    fn set_default(&mut self, default: T) {
121        self.default = Some(default);
122    }
123
124    fn validate(&self) -> Option<&'static str> {
125        let Self { min, max, default } = self;
126
127        (min >= max)
128            .then_some("minimum is greater or equal the maximum value")
129            .or_else(|| {
130                default
131                    .as_ref()
132                    .map(|d| !(*min..*max).contains(d))
133                    .and_then(|invalid| {
134                        invalid.then_some("default value is not within the min/max range")
135                    })
136            })
137    }
138}
139
140#[derive(Deserialize)]
141pub struct ListSetting {
142    values: IndexSet<String>,
143    default: Option<String>,
144}
145
146impl Setting<String> for ListSetting {
147    fn set_default(&mut self, default: String) {
148        self.default = Some(default);
149    }
150
151    fn validate(&self) -> Option<&'static str> {
152        let Self { values, default } = self;
153
154        default.as_ref().and_then(|default| {
155            (!values.contains(default)).then_some("default value isn't part of the possible values")
156        })
157    }
158}
159
160#[derive(Deserialize)]
161pub struct MultiListSetting {
162    values: IndexSet<String>,
163    default: Option<HashSet<String>>,
164}
165
166impl Setting<HashSet<String>> for MultiListSetting {
167    fn set_default(&mut self, default: HashSet<String>) {
168        self.default = Some(default);
169    }
170
171    fn validate(&self) -> Option<&'static str> {
172        let Self { values, default } = self;
173
174        default.as_ref().and_then(|default| {
175            default
176                .iter()
177                .any(|def| !values.contains(def))
178                .then_some("one of the default values isn't part of the possible values")
179        })
180    }
181}
182
183impl RepoSetting {
184    /// Check the setting for invalid values and return a error message describing the problem if
185    /// an invalid configuration was found.
186    #[must_use]
187    pub fn validate(&self) -> Option<&'static str> {
188        match &self.ty {
189            SettingType::Bool(_) | SettingType::String(_) => None,
190            SettingType::Number(setting) => Self::validate_number(setting),
191            SettingType::Float(setting) => Self::validate_number(setting),
192            SettingType::List(ListSetting { values, default }) => {
193                default.as_ref().and_then(|default| {
194                    (!values.contains(default))
195                        .then_some("default value isn't part of the possible values")
196                })
197            }
198            SettingType::MultiList(MultiListSetting { values, default }) => {
199                default.as_ref().and_then(|default| {
200                    default
201                        .iter()
202                        .any(|def| !values.contains(def))
203                        .then_some("one of the default values isn't part of the possible values")
204                })
205            }
206        }
207    }
208
209    fn validate_number<T: Number>(
210        NumberSetting { min, max, default }: &NumberSetting<T>,
211    ) -> Option<&'static str> {
212        (min >= max)
213            .then_some("minimum is greater or equal the maximum value")
214            .or_else(|| {
215                default
216                    .as_ref()
217                    .map(|d| !(*min..*max).contains(d))
218                    .and_then(|invalid| {
219                        invalid.then_some("default value is not within the min/max range")
220                    })
221            })
222    }
223}
224
225pub fn load(path: &Utf8Path) -> Result<RepoSettings> {
226    let buf = fs::read(path.join(".hatch.toml")).context("failed reading hatch config file")?;
227    let settings =
228        basic_toml::from_slice::<RepoSettings>(&buf).context("invalid hatch settings")?;
229
230    if let Some((name, error)) = settings
231        .args
232        .iter()
233        .find_map(|(name, setting)| setting.validate().map(|error| (name, error)))
234    {
235        bail!("invalid setting `{name}`: {error}");
236    }
237
238    Ok(settings)
239}
240
241pub fn new_context(settings: &RepoSettings, project_name: &str) -> Result<TeraContext> {
242    let mut ctx = TeraContext::new();
243
244    ctx.try_insert("project_name", &project_name)
245        .context("failed adding value to context")?;
246
247    let config = GitConfig::open_default()
248        .context("failed opening default git config")?
249        .snapshot()
250        .context("failed creating git config snapshot")?;
251
252    let name = config
253        .get_str("user.name")
254        .context("failed getting name from git config")?;
255    let email = config
256        .get_str("user.email")
257        .context("failed getting email from git config")?;
258
259    ctx.try_insert("git_author", &format!("{name} <{email}>"))
260        .context("failed adding value to context")?;
261    ctx.try_insert("git_name", &name)
262        .context("failed adding value to context")?;
263    ctx.try_insert("git_email", &email)
264        .context("failed adding value to context")?;
265
266    let crate_type = if let Some(ty) = settings.crate_type {
267        ty
268    } else {
269        let setting = ListSetting {
270            values: IndexSet::from_iter(["bin".to_owned(), "lib".to_owned()]),
271            default: None,
272        };
273        match prompts::prompt_list("what crate type would you like to create?", setting)?.as_ref() {
274            "bin" => CrateType::Bin,
275            "lib" => CrateType::Lib,
276            _ => unreachable!(),
277        }
278    };
279
280    ctx.try_insert("crate_type", &crate_type)
281        .context("failed adding value to context")?;
282    ctx.try_insert("crate_bin", &(crate_type == CrateType::Bin))
283        .context("failed adding value to context")?;
284    ctx.try_insert("crate_lib", &(crate_type == CrateType::Lib))
285        .context("failed adding value to context")?;
286
287    Ok(ctx)
288}
289
290pub fn fill_context<H>(
291    ctx: &mut TeraContext,
292    args: IndexMap<String, RepoSetting>,
293    mut defaults: HashMap<String, DefaultSetting, H>,
294) -> Result<()>
295where
296    H: BuildHasher,
297{
298    for (name, setting) in args {
299        if let Some(condition) = setting.condition {
300            let result = Tera::one_off(&condition, ctx, false)?;
301            let active = result.trim().parse::<bool>()?;
302
303            if !active {
304                continue;
305            }
306        }
307
308        match setting.ty {
309            SettingType::Bool(value) => {
310                let value = run(
311                    value,
312                    &setting.description,
313                    defaults.remove(&name),
314                    defaults::get_bool,
315                    prompts::prompt_bool,
316                )?;
317
318                ctx.try_insert(name, &value)
319                    .context("failed adding value to context")?;
320            }
321            SettingType::String(value) => {
322                let value = run(
323                    value,
324                    &setting.description,
325                    defaults.remove(&name),
326                    defaults::get_string,
327                    prompts::prompt_string,
328                )?;
329
330                ctx.try_insert(name, &value)
331                    .context("failed adding value to context")?;
332            }
333            SettingType::Number(value) => {
334                let value = run(
335                    value,
336                    &setting.description,
337                    defaults.remove(&name),
338                    defaults::get_number,
339                    prompts::prompt_number,
340                )?;
341
342                ctx.try_insert(name, &value)
343                    .context("failed adding value to context")?;
344            }
345            SettingType::Float(value) => {
346                let value = run(
347                    value,
348                    &setting.description,
349                    defaults.remove(&name),
350                    defaults::get_float,
351                    prompts::prompt_number,
352                )?;
353
354                ctx.try_insert(name, &value)
355                    .context("failed adding value to context")?;
356            }
357            SettingType::List(value) => {
358                let value = run(
359                    value,
360                    &setting.description,
361                    defaults.remove(&name),
362                    defaults::get_list,
363                    prompts::prompt_list,
364                )?;
365
366                ctx.try_insert(name, &value)
367                    .context("failed adding value to context")?;
368            }
369            SettingType::MultiList(value) => {
370                let value = run(
371                    value,
372                    &setting.description,
373                    defaults.remove(&name),
374                    defaults::get_multi_list,
375                    prompts::prompt_multi_list,
376                )?;
377
378                ctx.try_insert(name, &value)
379                    .context("failed adding value to context")?;
380            }
381        }
382    }
383
384    Ok(())
385}
386
387fn run<S: Setting<R>, R>(
388    mut setting: S,
389    description: &str,
390    default: Option<DefaultSetting>,
391    load: impl Fn(DefaultSetting) -> Result<R>,
392    prompt: impl Fn(&str, S) -> Result<R>,
393) -> Result<R> {
394    match default {
395        Some(default) if default.skip_prompt => load(default),
396        Some(default) => {
397            setting.set_default(load(default)?);
398            prompt(description, setting)
399        }
400        None => prompt(description, setting),
401    }
402}