cargo_hatch/settings/repo/
mod.rs1use 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 #[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}