Skip to main content

grit_lib/config/
typed.rs

1//! Typed config reads and canonical write values (`--type bool|int|path`).
2
3use crate::environment::Environment;
4use crate::error::{BadNumericSource, ConfigError, Error, Result};
5
6use super::{
7    canonical_key, parse_bool, parse_git_config_int_strict, parse_path, ConfigSet,
8    GitConfigIntStrictError,
9};
10
11/// Value type for typed config operations (Git `--type` subset).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ConfigValueType {
14    /// Boolean (`true` / `false`).
15    Bool,
16    /// Signed integer with optional `k`/`m`/`g` suffix on input.
17    Int,
18    /// Path (tilde and environment expansion).
19    Path,
20}
21
22/// A config value after typed parsing.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum TypedConfigValue {
25    /// Parsed boolean.
26    Bool(bool),
27    /// Parsed integer.
28    Int(i64),
29    /// Expanded path string.
30    Path(String),
31}
32
33impl TypedConfigValue {
34    /// Canonical string form for writing to a config file (matches Git `--type` output).
35    #[must_use]
36    pub fn to_canonical_string(&self) -> String {
37        match self {
38            Self::Bool(b) => if *b { "true" } else { "false" }.to_owned(),
39            Self::Int(n) => n.to_string(),
40            Self::Path(p) => p.clone(),
41        }
42    }
43}
44
45/// Parse and canonicalize a value for writing with `--type`.
46///
47/// # Errors
48///
49/// Returns [`Error::Config`] when the value is not valid for the requested type.
50pub fn canonicalize_config_write_value(
51    key: &str,
52    ty: ConfigValueType,
53    value: &str,
54    env: &Environment,
55) -> Result<String> {
56    let typed = parse_typed_value(key, ty, value, env)?;
57    Ok(typed.to_canonical_string())
58}
59
60/// Parse a raw config string according to `ty` (for reads and writes).
61///
62/// # Errors
63///
64/// Returns [`Error::Config`] when the value is not valid for the requested type.
65pub fn parse_typed_value(
66    key: &str,
67    ty: ConfigValueType,
68    value: &str,
69    env: &Environment,
70) -> Result<TypedConfigValue> {
71    match ty {
72        ConfigValueType::Bool => parse_bool(value).map(TypedConfigValue::Bool).map_err(|_| {
73            Error::Config(ConfigError::Other(format!(
74                "bad boolean config value '{value}' for '{key}'"
75            )))
76        }),
77        ConfigValueType::Int => parse_git_config_int_strict(value)
78            .map(TypedConfigValue::Int)
79            .map_err(|e| int_parse_error(key, value, e)),
80        ConfigValueType::Path => Ok(TypedConfigValue::Path(parse_path(env, value))),
81    }
82}
83
84fn int_parse_error(key: &str, value: &str, err: GitConfigIntStrictError) -> Error {
85    let reason = match err {
86        GitConfigIntStrictError::InvalidUnit => BadNumericSource::InvalidUnit,
87        GitConfigIntStrictError::OutOfRange => BadNumericSource::OutOfRange,
88    };
89    Error::Config(ConfigError::BadNumericValue {
90        key: key.to_owned(),
91        value: value.to_owned(),
92        reason,
93    })
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97enum ListPrefixMatch {
98    /// Match keys that start with this canonical prefix (section or section.subsection).
99    KeyPrefix(String),
100    /// Match one canonical key exactly (full `--list user.email` style prefix).
101    ExactKey(String),
102}
103
104impl ConfigSet {
105    /// Read the winning value for `key`, parsed as `ty`.
106    ///
107    /// Bare boolean keys are treated as `true`. Missing keys return `Ok(None)`.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`Error::Config`] when `key` is invalid or the stored value does not match `ty`.
112    pub fn get_typed(
113        &self,
114        key: &str,
115        ty: ConfigValueType,
116        env: &Environment,
117    ) -> Result<Option<TypedConfigValue>> {
118        let _canon = canonical_key(key)?;
119        let Some(entry) = self.get_last_entry(key) else {
120            return Ok(None);
121        };
122        let raw = entry.value.as_deref().unwrap_or("true");
123        parse_typed_value(key, ty, raw, env).map(Some)
124    }
125
126    /// Read the winning value for `key` as a Git integer (with optional `k`/`m`/`g` suffix).
127    ///
128    /// Missing keys return `Ok(None)`.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`ConfigError::BadNumericValue`] (as [`Error::Config`]) when the value is
133    /// not an integer or does not fit in an `i64`.
134    pub(crate) fn get_i64_strict(&self, key: &str) -> Result<Option<i64>> {
135        let Some(raw) = self.get(key) else {
136            return Ok(None);
137        };
138        parse_git_config_int_strict(&raw)
139            .map(Some)
140            .map_err(|e| int_parse_error(key, &raw, e))
141    }
142
143    /// Entries whose canonical key starts with `prefix`.
144    ///
145    /// `prefix` may be a full key, a section (`remote.origin`), or a section prefix ending in `.`.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`Error::Config`] when `prefix` is empty or invalid.
150    pub fn entries_with_prefix(&self, prefix: &str) -> Result<Vec<super::ConfigEntry>> {
151        let matcher = list_prefix_match(prefix)?;
152        Ok(self
153            .entries()
154            .iter()
155            .filter(|e| match &matcher {
156                ListPrefixMatch::ExactKey(key) => &e.key == key,
157                ListPrefixMatch::KeyPrefix(needle) => e.key.starts_with(needle.as_str()),
158            })
159            .cloned()
160            .collect())
161    }
162}
163
164fn list_prefix_match(prefix: &str) -> Result<ListPrefixMatch> {
165    let prefix = prefix.trim();
166    if prefix.is_empty() {
167        return Err(Error::Config(ConfigError::Other(
168            "list prefix must not be empty".into(),
169        )));
170    }
171    if let Ok(canon) = canonical_key(prefix) {
172        return Ok(ListPrefixMatch::ExactKey(canon));
173    }
174    let section_part = prefix.trim_end_matches('.');
175    if section_part.is_empty() {
176        return Err(Error::Config(ConfigError::Other(
177            "list prefix must not be empty".into(),
178        )));
179    }
180    let (sec, sub) = match section_part.find('.') {
181        Some(i) => (&section_part[..i], Some(&section_part[i + 1..])),
182        None => (section_part, None),
183    };
184    if sec.is_empty()
185        || !sec
186            .chars()
187            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
188    {
189        return Err(Error::Config(ConfigError::Other(format!(
190            "invalid list prefix '{prefix}'"
191        ))));
192    }
193    let sec_lower = sec.to_ascii_lowercase();
194    let needle = match sub {
195        None => format!("{sec_lower}."),
196        Some(sub) if !sub.is_empty() => format!("{sec_lower}.{sub}."),
197        Some(_) => {
198            return Err(Error::Config(ConfigError::Other(format!(
199                "invalid list prefix '{prefix}'"
200            ))));
201        }
202    };
203    Ok(ListPrefixMatch::KeyPrefix(needle))
204}