Skip to main content

grit_lib/
config.rs

1//! Git-compatible configuration file parser and accessor.
2//!
3//! Supports the standard Git config file format:
4//!
5//! ```text
6//! [section]
7//!     key = value
8//! [section "subsection"]
9//!     key = value
10//! ```
11//!
12//! # Multi-file layering
13//!
14//! Git reads configuration from several files in priority order:
15//!
16//! 1. System (`/etc/gitconfig`)
17//! 2. Global (`~/.gitconfig` or `$XDG_CONFIG_HOME/git/config`)
18//! 3. Local (`.git/config`)
19//! 4. Worktree (`.git/config.worktree`)
20//! 5. Command-line (`-c key=value` or `GIT_CONFIG_*`)
21//!
22//! [`ConfigSet`] merges all layers; last-wins for single-valued keys.
23//!
24//! # Include directives
25//!
26//! `[include] path = <path>` and `[includeIf "<condition>"] path = <path>`
27//! are supported. Conditions: `gitdir:`, `gitdir/i:`, `onbranch:`.
28
29use std::fmt;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use crate::error::{Error, Result};
34use crate::refs;
35use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
36
37/// The scope (origin) of a configuration value.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub enum ConfigScope {
40    /// System-wide configuration (`/etc/gitconfig`).
41    System,
42    /// Per-user global configuration (`~/.gitconfig` or XDG).
43    Global,
44    /// Repository-local configuration (`.git/config`).
45    Local,
46    /// Per-worktree configuration (`.git/config.worktree`).
47    Worktree,
48    /// Command-line overrides (`-c key=value`).
49    Command,
50}
51
52impl fmt::Display for ConfigScope {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::System => write!(f, "system"),
56            Self::Global => write!(f, "global"),
57            Self::Local => write!(f, "local"),
58            Self::Worktree => write!(f, "worktree"),
59            Self::Command => write!(f, "command"),
60        }
61    }
62}
63
64/// A single configuration entry with its origin metadata.
65#[derive(Debug, Clone)]
66pub struct ConfigEntry {
67    /// Fully-qualified key in canonical form: `section.subsection.name`
68    /// (section and name lowercased; subsection preserves case).
69    pub key: String,
70    /// The raw string value, or `None` for a boolean-true bare key.
71    pub value: Option<String>,
72    /// Which scope this entry came from.
73    pub scope: ConfigScope,
74    /// The file this entry was read from (if file-backed).
75    pub file: Option<PathBuf>,
76    /// One-based line number in the source file.
77    pub line: usize,
78}
79
80/// Where a [`ConfigFile`] was loaded from for Git include semantics.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ConfigIncludeOrigin {
83    /// Normal path on disk (`-f`, global/local config files, etc.).
84    Disk,
85    /// `--file -` (stdin).
86    Stdin,
87    /// Synthetic file built from `GIT_CONFIG_PARAMETERS` / `git -c`.
88    CommandLine,
89    /// `git config --blob=…`.
90    Blob,
91}
92
93/// A parsed configuration file that preserves the raw text for round-trip
94/// editing (set/unset/rename-section/remove-section).
95#[derive(Debug, Clone)]
96pub struct ConfigFile {
97    /// The path to this config file on disk.
98    pub path: PathBuf,
99    /// The scope this file represents.
100    pub scope: ConfigScope,
101    /// Parsed entries (in file order).
102    pub entries: Vec<ConfigEntry>,
103    /// Raw lines of the file (for round-trip editing).
104    raw_lines: Vec<String>,
105    /// Source kind for `[include]` resolution (Git `CONFIG_ORIGIN_*`).
106    pub include_origin: ConfigIncludeOrigin,
107}
108
109/// A merged view across all configuration scopes.
110///
111/// Entries are stored in file-order within each scope; scopes are layered
112/// in priority order (system < global < local < worktree < command).
113#[derive(Debug, Clone, Default)]
114pub struct ConfigSet {
115    /// All entries across all scopes, in load order.
116    entries: Vec<ConfigEntry>,
117}
118
119/// Context for evaluating `[includeIf]` conditions (`gitdir:`, `onbranch:`).
120#[derive(Debug, Clone, Default)]
121pub struct IncludeContext {
122    /// Git directory path used for `gitdir:` matching (may contain unresolved symlinks).
123    pub git_dir: Option<PathBuf>,
124    /// When true, `git -c include.path=relative` fails instead of ignoring the include.
125    pub command_line_relative_include_is_error: bool,
126}
127
128/// Options controlling how [`ConfigSet::load_with_options`] merges files and includes.
129#[derive(Debug, Clone)]
130pub struct LoadConfigOptions {
131    /// Load `/etc/gitconfig` (unless `GIT_CONFIG_NOSYSTEM` is set).
132    pub include_system: bool,
133    /// Expand `[include]` / `[includeIf]` while reading file-backed layers.
134    pub process_includes: bool,
135    /// Expand includes for synthetic command-line config built from `GIT_CONFIG_PARAMETERS`.
136    pub command_includes: bool,
137    pub include_ctx: IncludeContext,
138}
139
140impl Default for LoadConfigOptions {
141    fn default() -> Self {
142        Self {
143            include_system: true,
144            process_includes: true,
145            command_includes: true,
146            include_ctx: IncludeContext::default(),
147        }
148    }
149}
150
151// ── Canonical key helpers ────────────────────────────────────────────
152
153/// Normalise a config key to canonical form.
154///
155/// - Section name is lowercased.
156/// - Variable name (last dot-separated component) is lowercased.
157/// - Subsection (middle components) preserves original case.
158///
159/// Returns `Err` if the key has fewer than two dot-separated parts.
160///
161/// # Examples
162///
163/// - `core.bare` → `core.bare`
164/// - `Section.SubSection.Key` → `section.SubSection.key`
165/// - `CORE.BARE` → `core.bare`
166pub fn canonical_key(raw: &str) -> Result<String> {
167    // Reject keys containing newlines
168    if raw.contains('\n') || raw.contains('\r') {
169        return Err(Error::ConfigError(format!(
170            "invalid key: '{}'",
171            raw.replace('\n', "\\n")
172        )));
173    }
174
175    let first_dot = raw
176        .find('.')
177        .ok_or_else(|| Error::ConfigError(format!("key does not contain a section: '{raw}'")))?;
178    let last_dot = raw
179        .rfind('.')
180        .ok_or_else(|| Error::ConfigError(format!("key does not contain a section: '{raw}'")))?;
181
182    if last_dot == raw.len() - 1 {
183        return Err(Error::ConfigError(format!(
184            "key does not contain variable name: '{raw}'"
185        )));
186    }
187
188    let section = &raw[..first_dot];
189    let name = &raw[last_dot + 1..];
190
191    // Validate section name: must be alphanumeric or hyphen
192    if section.is_empty() || !section.chars().all(|c| c.is_alphanumeric() || c == '-') {
193        return Err(Error::ConfigError(format!(
194            "invalid key (bad section): '{raw}'"
195        )));
196    }
197
198    // Validate variable name: must start with alpha, rest alphanumeric or hyphen
199    if name.is_empty()
200        || !name.chars().next().unwrap().is_ascii_alphabetic()
201        || !name.chars().all(|c| c.is_alphanumeric() || c == '-')
202    {
203        return Err(Error::ConfigError(format!(
204            "invalid key (bad variable name): '{raw}'"
205        )));
206    }
207
208    if first_dot == last_dot {
209        // No subsection: section.name
210        Ok(format!(
211            "{}.{}",
212            section.to_lowercase(),
213            name.to_lowercase()
214        ))
215    } else {
216        // section.subsection.name
217        let subsection = &raw[first_dot + 1..last_dot];
218        Ok(format!(
219            "{}.{}.{}",
220            section.to_lowercase(),
221            subsection,
222            name.to_lowercase()
223        ))
224    }
225}
226
227// ── Parser ──────────────────────────────────────────────────────────
228
229fn config_error_path_display(path: &Path) -> String {
230    if path.file_name().and_then(|s| s.to_str()) == Some("config")
231        && path
232            .parent()
233            .and_then(|p| p.file_name())
234            .and_then(|s| s.to_str())
235            == Some(".git")
236    {
237        return ".git/config".to_owned();
238    }
239    path.display().to_string()
240}
241
242/// State tracked while parsing a config file line-by-line.
243struct Parser {
244    section: String,
245    subsection: Option<String>,
246}
247
248impl Parser {
249    fn new() -> Self {
250        Self {
251            section: String::new(),
252            subsection: None,
253        }
254    }
255
256    /// Build the canonical key for a variable name in the current section.
257    fn make_key(&self, name: &str) -> String {
258        let sec = self.section.to_lowercase();
259        let var = name.to_lowercase();
260        match &self.subsection {
261            Some(sub) => format!("{sec}.{sub}.{var}"),
262            None => format!("{sec}.{var}"),
263        }
264    }
265
266    /// Parse a section header line like `[section]` or `[section "subsection"]`.
267    ///
268    /// Returns `true` if the line was a section header.
269    /// If there is content after `]` (an inline key=value), it is returned
270    /// via the `inline_remainder` parameter.
271    fn try_parse_section_with_remainder<'a>(
272        &mut self,
273        line: &'a str,
274        inline_remainder: &mut Option<&'a str>,
275    ) -> bool {
276        let trimmed = line.trim();
277        if !trimmed.starts_with('[') {
278            return false;
279        }
280        // Find the closing `]` — but for subsection headers like
281        // [section "sub\"escaped"], we need to skip escaped chars
282        // inside quotes.
283        let end = {
284            let bytes = trimmed.as_bytes();
285            let mut i = 1; // skip opening '['
286            let mut in_quotes = false;
287            let mut found = None;
288            while i < bytes.len() {
289                if in_quotes {
290                    if bytes[i] == b'\\' {
291                        i += 2; // skip escaped char
292                        continue;
293                    }
294                    if bytes[i] == b'"' {
295                        in_quotes = false;
296                    }
297                } else {
298                    if bytes[i] == b'"' {
299                        in_quotes = true;
300                    }
301                    if bytes[i] == b']' {
302                        found = Some(i);
303                        break;
304                    }
305                }
306                i += 1;
307            }
308            match found {
309                Some(i) => i,
310                None => return false,
311            }
312        };
313        let inside = &trimmed[1..end];
314        // Check for subsection: [section "subsection"]
315        if let Some(quote_start) = inside.find('"') {
316            self.section = inside[..quote_start].trim().to_owned();
317            let rest = &inside[quote_start + 1..];
318            // Find unescaped closing quote
319            let mut sub = String::new();
320            let mut chars = rest.chars();
321            while let Some(ch) = chars.next() {
322                if ch == '\\' {
323                    if let Some(escaped) = chars.next() {
324                        sub.push(escaped);
325                    }
326                } else if ch == '"' {
327                    break;
328                } else {
329                    sub.push(ch);
330                }
331            }
332            self.subsection = Some(sub);
333        } else {
334            self.section = inside.trim().to_owned();
335            self.subsection = None;
336        }
337        // Check for inline content after the closing `]`
338        let after = trimmed[end + 1..].trim();
339        if !after.is_empty() && !after.starts_with('#') && !after.starts_with(';') {
340            *inline_remainder = Some(after);
341        } else {
342            *inline_remainder = None;
343        }
344        true
345    }
346
347    /// Parse a section header line (without inline remainder tracking).
348    fn try_parse_section(&mut self, line: &str) -> bool {
349        let mut _remainder = None;
350        self.try_parse_section_with_remainder(line, &mut _remainder)
351    }
352
353    /// Parse a `key = value` or bare `key` line.
354    ///
355    /// Returns `Some((canonical_key, value))` if this is a variable line.
356    fn try_parse_entry(&self, line: &str) -> Option<(String, Option<String>)> {
357        let trimmed = line.trim();
358        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
359            return None;
360        }
361        if trimmed.starts_with('[') {
362            return None;
363        }
364        if self.section.is_empty() {
365            return None;
366        }
367
368        if let Some(eq_pos) = trimmed.find('=') {
369            let raw_name = trimmed[..eq_pos].trim();
370            let raw_value = trimmed[eq_pos + 1..].trim();
371            // Strip inline comment (not inside quotes)
372            let value = strip_inline_comment(raw_value);
373            let value = unescape_value(&value);
374            let key = self.make_key(raw_name);
375            Some((key, Some(value)))
376        } else {
377            // Bare key (boolean true)
378            let raw_name = strip_inline_comment(trimmed);
379            let key = self.make_key(raw_name.trim());
380            Some((key, None))
381        }
382    }
383}
384
385/// Check if a value line ends with a continuation backslash.
386///
387/// This checks the value portion (after `=`) for a trailing `\` that is
388/// outside quotes and outside an inline comment. If the `\` is after
389/// a `#` or `;` that starts a comment, it does NOT count as continuation.
390fn value_line_continues(line: &str) -> bool {
391    let trimmed = line.trim();
392    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
393        return false;
394    }
395    // Find the value portion (after '=')
396    // If no '=', this is a bare key — no continuation
397    let value_part = match trimmed.find('=') {
398        Some(pos) => &trimmed[pos + 1..],
399        None => return false,
400    };
401    // Walk the value portion tracking quotes and comments
402    let mut in_quote = false;
403    let mut last_was_backslash = false;
404    let mut in_comment = false;
405    for ch in value_part.chars() {
406        if in_comment {
407            // Inside comment, backslash doesn't matter
408            last_was_backslash = false;
409            continue;
410        }
411        match ch {
412            '"' if !last_was_backslash => {
413                in_quote = !in_quote;
414                last_was_backslash = false;
415            }
416            '\\' if !last_was_backslash => {
417                last_was_backslash = true;
418                continue;
419            }
420            '#' | ';' if !in_quote && !last_was_backslash => {
421                in_comment = true;
422                last_was_backslash = false;
423            }
424            _ => {
425                last_was_backslash = false;
426            }
427        }
428    }
429    // The line continues if it ends with an unescaped backslash outside comments
430    last_was_backslash && !in_comment
431}
432
433/// Strip an inline comment (`#` or `;`) that is not inside quotes.
434fn strip_inline_comment(s: &str) -> String {
435    let mut in_quote = false;
436    let mut result = String::with_capacity(s.len());
437    let mut chars = s.chars().peekable();
438    while let Some(ch) = chars.next() {
439        match ch {
440            '"' => {
441                in_quote = !in_quote;
442                result.push(ch);
443            }
444            '\\' if in_quote => {
445                result.push(ch);
446                if let Some(&next) = chars.peek() {
447                    result.push(next);
448                    chars.next();
449                }
450            }
451            '#' | ';' if !in_quote => break,
452            _ => result.push(ch),
453        }
454    }
455    // Trim trailing whitespace that was before the comment
456    let trimmed = result.trim_end();
457    trimmed.to_owned()
458}
459
460/// Unescape a config value: handle `\"`, `\\`, `\n`, `\t`, and strip
461/// surrounding quotes.
462fn unescape_value(s: &str) -> String {
463    let mut result = String::with_capacity(s.len());
464    let mut chars = s.chars();
465    while let Some(ch) = chars.next() {
466        match ch {
467            '"' => { /* strip quotes */ }
468            '\\' => match chars.next() {
469                Some('n') => result.push('\n'),
470                Some('t') => result.push('\t'),
471                Some('\\') => result.push('\\'),
472                Some('"') => result.push('"'),
473                Some(other) => {
474                    result.push('\\');
475                    result.push(other);
476                }
477                None => result.push('\\'),
478            },
479            _ => result.push(ch),
480        }
481    }
482    result
483}
484
485/// Escape a config value for writing back to a file.
486///
487/// Wraps in double quotes if the value contains leading/trailing whitespace,
488/// internal quotes, backslashes, or special characters.
489/// Escape a subsection name for writing in a config section header.
490/// In subsection names, `"` and `\` must be escaped.
491fn escape_subsection(s: &str) -> String {
492    let mut out = String::with_capacity(s.len());
493    for ch in s.chars() {
494        match ch {
495            '"' => out.push_str("\\\""),
496            '\\' => out.push_str("\\\\"),
497            other => out.push(other),
498        }
499    }
500    out
501}
502
503fn escape_value(s: &str) -> String {
504    // Quote leading `-` so values are not mistaken for config options (Git does this for
505    // submodule paths like `-sub` in `.gitmodules`).
506    let needs_quoting = s.starts_with('-')
507        || s.starts_with(' ')
508        || s.starts_with('\t')
509        || s.ends_with(' ')
510        || s.ends_with('\t')
511        || s.contains('"')
512        || s.contains('\\')
513        || s.contains('\n')
514        || s.contains('#')
515        || s.contains(';');
516
517    if !needs_quoting {
518        return s.to_owned();
519    }
520
521    let mut out = String::with_capacity(s.len() + 4);
522    out.push('"');
523    for ch in s.chars() {
524        match ch {
525            '"' => out.push_str("\\\""),
526            '\\' => out.push_str("\\\\"),
527            '\n' => out.push_str("\\n"),
528            '\t' => out.push_str("\\t"),
529            other => out.push(other),
530        }
531    }
532    out.push('"');
533    out
534}
535
536/// Format a comment suffix for appending to a config value line.
537///
538/// Git's `--comment` flag normalises the comment:
539/// - If the comment already starts with `#` (possibly preceded by whitespace/tab),
540///   it is used as-is.
541/// - Otherwise, ` # ` is prepended.
542fn format_comment_suffix(comment: Option<&str>) -> String {
543    match comment {
544        None => String::new(),
545        Some(c) => {
546            if c.starts_with(' ') || c.starts_with('\t') {
547                // Comment has its own leading whitespace separator
548                c.to_owned()
549            } else if c.starts_with('#') {
550                // Comment starts with #, just prepend a space separator
551                format!(" {c}")
552            } else {
553                // Plain text comment, prepend " # "
554                format!(" # {c}")
555            }
556        }
557    }
558}
559
560impl ConfigFile {
561    /// Parse a config file from its raw text content.
562    ///
563    /// # Parameters
564    ///
565    /// - `path` — the file path (stored for diagnostics and round-trip writes).
566    /// - `content` — the raw text of the file.
567    /// - `scope` — the [`ConfigScope`] this file represents.
568    ///
569    /// # Errors
570    ///
571    /// Returns [`Error::ConfigError`] on malformed input.
572    pub fn parse(path: &Path, content: &str, scope: ConfigScope) -> Result<Self> {
573        let raw_lines: Vec<String> = content
574            .lines()
575            .map(|l| l.strip_suffix('\r').unwrap_or(l))
576            .map(String::from)
577            .collect();
578        let mut entries = Vec::new();
579        let mut parser = Parser::new();
580
581        let mut idx = 0;
582        while idx < raw_lines.len() {
583            let start_idx = idx;
584            let line = &raw_lines[idx];
585            idx += 1;
586
587            // Pure comment lines don't continue even with trailing \
588            let trimmed = line.trim();
589            if trimmed.starts_with('#') || trimmed.starts_with(';') {
590                continue;
591            }
592
593            let mut inline_remainder = None;
594            if parser.try_parse_section_with_remainder(line, &mut inline_remainder) {
595                // Check if there's an inline key=value after the section header
596                if let Some(remainder) = inline_remainder {
597                    if let Some((key, value)) = parser.try_parse_entry(remainder) {
598                        if key == "fetch.negotiationalgorithm" && value.is_none() {
599                            let file_disp = config_error_path_display(path);
600                            return Err(Error::Message(format!(
601                                "error: missing value for 'fetch.negotiationalgorithm'\n\
602fatal: bad config variable 'fetch.negotiationalgorithm' in file '{file_disp}' at line {}",
603                                start_idx + 1
604                            )));
605                        }
606                        entries.push(ConfigEntry {
607                            key,
608                            value,
609                            scope,
610                            file: Some(path.to_path_buf()),
611                            line: start_idx + 1,
612                        });
613                    }
614                }
615                continue;
616            }
617
618            // For entry lines, we need to check continuation.
619            // Build a logical line by joining continuations.
620            let mut logical_line = line.clone();
621            while value_line_continues(&logical_line) && idx < raw_lines.len() {
622                // Remove the trailing backslash
623                let t = logical_line.trim_end();
624                logical_line = t[..t.len() - 1].to_string();
625                // Append next line (trimmed of leading whitespace)
626                let next = raw_lines[idx].trim_start();
627                logical_line.push_str(next);
628                idx += 1;
629            }
630
631            if let Some((key, value)) = parser.try_parse_entry(&logical_line) {
632                if key == "fetch.negotiationalgorithm" && value.is_none() {
633                    let file_disp = config_error_path_display(path);
634                    return Err(Error::Message(format!(
635                        "error: missing value for 'fetch.negotiationalgorithm'\n\
636fatal: bad config variable 'fetch.negotiationalgorithm' in file '{file_disp}' at line {}",
637                        start_idx + 1
638                    )));
639                }
640                entries.push(ConfigEntry {
641                    key,
642                    value,
643                    scope,
644                    file: Some(path.to_path_buf()),
645                    line: start_idx + 1,
646                });
647            }
648        }
649
650        Ok(Self {
651            path: path.to_path_buf(),
652            scope,
653            entries,
654            raw_lines,
655            include_origin: ConfigIncludeOrigin::Disk,
656        })
657    }
658
659    /// Parse like [`Self::parse`] but record a non-disk include origin (blob, stdin, command line).
660    pub fn parse_with_origin(
661        path: &Path,
662        content: &str,
663        scope: ConfigScope,
664        include_origin: ConfigIncludeOrigin,
665    ) -> Result<Self> {
666        let mut f = Self::parse(path, content, scope)?;
667        f.include_origin = include_origin;
668        Ok(f)
669    }
670
671    /// Build a synthetic [`ConfigFile`] from `GIT_CONFIG_PARAMETERS` / `git -c` payloads.
672    ///
673    /// Unlike [`Self::parse`], this accepts flat `key=value` assignments without `[section]`
674    /// headers, matching how Git injects command-line configuration.
675    pub fn from_git_config_parameters(path: &Path, raw: &str) -> Result<Self> {
676        let mut entries = Vec::new();
677        for entry in parse_config_parameters(raw) {
678            if let Some((key, val)) = entry.split_once('=') {
679                let canon = canonical_key(key.trim())?;
680                entries.push(ConfigEntry {
681                    key: canon,
682                    value: Some(val.to_owned()),
683                    scope: ConfigScope::Command,
684                    file: None,
685                    line: 0,
686                });
687            } else {
688                let canon = canonical_key(entry.trim())?;
689                entries.push(ConfigEntry {
690                    key: canon,
691                    value: None,
692                    scope: ConfigScope::Command,
693                    file: None,
694                    line: 0,
695                });
696            }
697        }
698        Ok(Self {
699            path: path.to_path_buf(),
700            scope: ConfigScope::Command,
701            entries,
702            raw_lines: Vec::new(),
703            include_origin: ConfigIncludeOrigin::CommandLine,
704        })
705    }
706
707    /// Read and parse a config file from disk.
708    ///
709    /// Returns `Ok(None)` if the file does not exist.
710    ///
711    /// # Errors
712    ///
713    /// Returns [`Error::Io`] on read failure (other than not-found) or
714    /// [`Error::ConfigError`] on parse failure.
715    pub fn from_path(path: &Path, scope: ConfigScope) -> Result<Option<Self>> {
716        match fs::read_to_string(path) {
717            Ok(content) => Ok(Some(Self::parse(path, &content, scope)?)),
718            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
719            Err(e) => Err(Error::Io(e)),
720        }
721    }
722
723    /// Set a value in this config file, creating the section if needed.
724    ///
725    /// If the key already exists, its last occurrence is updated in-place.
726    /// Otherwise a new entry is appended (creating the section header if
727    /// necessary).
728    ///
729    /// # Parameters
730    ///
731    /// - `key` — canonical key (e.g. `core.bare`).
732    /// - `value` — the value to set.
733    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
734        self.set_with_comment(key, value, None)
735    }
736
737    /// Set a value in this config file, optionally appending an inline comment.
738    pub fn set_with_comment(
739        &mut self,
740        key: &str,
741        value: &str,
742        comment: Option<&str>,
743    ) -> Result<()> {
744        let canon = canonical_key(key)?;
745        let raw_var = raw_variable_name(key);
746        let comment_suffix = format_comment_suffix(comment);
747
748        // Find the last entry with this key to replace in-place.
749        let existing_idx = self.entries.iter().rposition(|e| e.key == canon);
750
751        if let Some(idx) = existing_idx {
752            let line_idx = self.entries[idx].line - 1;
753            let raw_line = &self.raw_lines[line_idx];
754            if is_section_header_with_inline_entry(raw_line) {
755                // Entry is on the same line as a section header — split it
756                let header_only = extract_section_header(raw_line);
757                self.raw_lines[line_idx] = header_only;
758                let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
759                self.raw_lines.insert(line_idx + 1, new_line);
760                // Re-parse to fix up entries and line numbers
761                let content = self.raw_lines.join("\n");
762                let reparsed = Self::parse(&self.path, &content, self.scope)?;
763                self.entries = reparsed.entries;
764                self.raw_lines = reparsed.raw_lines;
765            } else {
766                self.raw_lines[line_idx] =
767                    format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
768                self.entries[idx].value = Some(value.to_owned());
769            }
770        } else {
771            // Need to add: find or create the section
772            let (section, subsection, _var) = split_key(&canon)?;
773            let (raw_sec, raw_sub) = raw_section_parts(key);
774            let section_line = self.find_or_create_section_preserving_case(
775                &section,
776                subsection.as_deref(),
777                &raw_sec,
778                raw_sub.as_deref(),
779            );
780            let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
781
782            // Insert after the section header (or last entry in section)
783            let insert_at = self.last_line_in_section(section_line) + 1;
784            self.raw_lines.insert(insert_at, new_line);
785
786            // Re-parse to fix up line numbers
787            let content = self.raw_lines.join("\n");
788            let reparsed = Self::parse(&self.path, &content, self.scope)?;
789            self.entries = reparsed.entries;
790            self.raw_lines = reparsed.raw_lines;
791        }
792
793        Ok(())
794    }
795
796    /// Replace ALL occurrences of a key with a new value.
797    ///
798    /// Removes all but the last occurrence from the file, then updates
799    /// the last occurrence with the new value (matching Git behaviour).
800    pub fn replace_all(
801        &mut self,
802        key: &str,
803        value: &str,
804        value_pattern: Option<&str>,
805    ) -> Result<()> {
806        self.replace_all_with_comment(key, value, value_pattern, None)
807    }
808
809    /// Replace all occurrences, optionally appending an inline comment.
810    ///
811    /// Value patterns starting with `!` are treated as negated regex
812    /// (matching values that do NOT match the pattern).
813    pub fn replace_all_with_comment(
814        &mut self,
815        key: &str,
816        value: &str,
817        value_pattern: Option<&str>,
818        comment: Option<&str>,
819    ) -> Result<()> {
820        let canon = canonical_key(key)?;
821        let comment_suffix = format_comment_suffix(comment);
822
823        // Parse optional regex pattern, handling `!` negation
824        let (re, negated) = match value_pattern {
825            Some(pat) => {
826                let (neg, actual_pat) = if let Some(rest) = pat.strip_prefix('!') {
827                    (true, rest)
828                } else {
829                    (false, pat)
830                };
831                let compiled = regex::Regex::new(actual_pat)
832                    .map_err(|e| Error::ConfigError(format!("invalid value-pattern regex: {e}")))?;
833                (Some(compiled), neg)
834            }
835            None => (None, false),
836        };
837
838        // Find all matching entries (by key, and optionally by value pattern)
839        let matching_indices: Vec<usize> = self
840            .entries
841            .iter()
842            .enumerate()
843            .filter(|(_, e)| {
844                if e.key != canon {
845                    return false;
846                }
847                if let Some(ref re) = re {
848                    let v = e.value.as_deref().unwrap_or("");
849                    let matched = re.is_match(v);
850                    if negated {
851                        !matched
852                    } else {
853                        matched
854                    }
855                } else {
856                    true
857                }
858            })
859            .map(|(i, _)| i)
860            .collect();
861
862        if matching_indices.is_empty() {
863            // No matching entries — add a new one at the end of the section
864            return self.add_value_with_comment(key, value, comment);
865        }
866
867        let raw_var = raw_variable_name(key);
868
869        if matching_indices.len() == 1 {
870            // Single match: update in-place (preserves position)
871            let match_idx = matching_indices[0];
872            let line_idx = self.entries[match_idx].line - 1;
873            let raw_line = &self.raw_lines[line_idx];
874            if is_section_header_with_inline_entry(raw_line) {
875                let header = extract_section_header(raw_line);
876                self.raw_lines[line_idx] = header;
877                let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
878                self.raw_lines.insert(line_idx + 1, new_line);
879            } else {
880                self.raw_lines[line_idx] =
881                    format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
882            }
883        } else {
884            // Multiple matches: remove ALL, then add one new entry at end of section
885            for &idx in matching_indices.iter().rev() {
886                let line_idx = self.entries[idx].line - 1;
887                self.remove_entry_line(line_idx);
888            }
889
890            // Re-parse after removals
891            let content = self.raw_lines.join("\n");
892            let reparsed = Self::parse(&self.path, &content, self.scope)?;
893            self.entries = reparsed.entries;
894            self.raw_lines = reparsed.raw_lines;
895
896            // Add the new entry at the end of the section
897            let (section, subsection, _var) = split_key(&canon)?;
898            let (raw_sec, raw_sub) = raw_section_parts(key);
899            let section_line = self.find_or_create_section_preserving_case(
900                &section,
901                subsection.as_deref(),
902                &raw_sec,
903                raw_sub.as_deref(),
904            );
905            let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
906            let insert_at = self.last_line_in_section(section_line) + 1;
907            self.raw_lines.insert(insert_at, new_line);
908        }
909
910        // Re-parse
911        let content = self.raw_lines.join("\n");
912        let reparsed = Self::parse(&self.path, &content, self.scope)?;
913        self.entries = reparsed.entries;
914        self.raw_lines = reparsed.raw_lines;
915
916        Ok(())
917    }
918
919    /// Count how many entries exist for a key.
920    pub fn count(&self, key: &str) -> Result<usize> {
921        let canon = canonical_key(key)?;
922        Ok(self.entries.iter().filter(|e| e.key == canon).count())
923    }
924
925    /// Remove an entry at the given raw line index.
926    ///
927    /// If the line is a section header with an inline entry, only the inline
928    /// portion is removed (the header is kept). Otherwise the entire line is
929    /// removed. Also removes continuation lines following the entry.
930    /// Remove an entry at the given raw line index.
931    ///
932    /// If the line is a section header with an inline entry, only the inline
933    /// portion is removed (the header is kept). Otherwise the entire line
934    /// (and any continuation lines) is removed.
935    fn remove_entry_line(&mut self, line_idx: usize) {
936        if is_section_header_with_inline_entry(&self.raw_lines[line_idx]) {
937            // Keep the section header, strip the inline entry
938            let header = extract_section_header(&self.raw_lines[line_idx]);
939            self.raw_lines[line_idx] = header;
940        } else {
941            // Check if this line has continuation lines and remove them too
942            let mut lines_to_remove = 1;
943            let mut check_line = self.raw_lines[line_idx].clone();
944            while value_line_continues(&check_line)
945                && (line_idx + lines_to_remove) < self.raw_lines.len()
946            {
947                check_line = self.raw_lines[line_idx + lines_to_remove].clone();
948                lines_to_remove += 1;
949            }
950            for _ in 0..lines_to_remove {
951                self.raw_lines.remove(line_idx);
952            }
953        }
954    }
955
956    /// Unset (remove) only the last occurrence of a key.
957    ///
958    /// Returns the number of entries removed (0 or 1).
959    pub fn unset_last(&mut self, key: &str) -> Result<usize> {
960        let canon = canonical_key(key)?;
961        let last_idx = self.entries.iter().rposition(|e| e.key == canon);
962
963        if let Some(idx) = last_idx {
964            let line_idx = self.entries[idx].line - 1;
965            self.remove_entry_line(line_idx);
966            let content = self.raw_lines.join("\n");
967            let reparsed = Self::parse(&self.path, &content, self.scope)?;
968            self.entries = reparsed.entries;
969            self.raw_lines = reparsed.raw_lines;
970            Ok(1)
971        } else {
972            Ok(0)
973        }
974    }
975
976    /// Unset (remove) all occurrences of a key.
977    ///
978    /// # Parameters
979    ///
980    /// - `key` — canonical key (e.g. `core.bare`).
981    ///
982    /// # Returns
983    ///
984    /// The number of entries removed.
985    pub fn unset(&mut self, key: &str) -> Result<usize> {
986        let canon = canonical_key(key)?;
987        let line_indices: Vec<usize> = self
988            .entries
989            .iter()
990            .filter(|e| e.key == canon)
991            .map(|e| e.line - 1)
992            .collect();
993
994        let count = line_indices.len();
995        // Remove from bottom to top to keep indices valid
996        for &idx in line_indices.iter().rev() {
997            self.remove_entry_line(idx);
998        }
999
1000        if count > 0 {
1001            let content = self.raw_lines.join("\n");
1002            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1003            self.entries = reparsed.entries;
1004            self.raw_lines = reparsed.raw_lines;
1005        }
1006
1007        Ok(count)
1008    }
1009
1010    /// Unset entries matching a key and optional value-pattern regex.
1011    ///
1012    /// If `value_pattern` is `None`, removes all entries with the given key.
1013    /// If `value_pattern` is `Some(pat)`, only removes entries whose value matches the regex.
1014    ///
1015    /// When `preserve_empty_section_header` is `true`, a section header is kept even if the
1016    /// section has no remaining keys (Git's `config unset --all`). When `false`, empty sections
1017    /// are stripped (`config --unset`, `config --unset-all`, and value-pattern unsets).
1018    pub fn unset_matching(
1019        &mut self,
1020        key: &str,
1021        value_pattern: Option<&str>,
1022        preserve_empty_section_header: bool,
1023    ) -> Result<usize> {
1024        let canon = canonical_key(key)?;
1025        let re = match value_pattern {
1026            Some(pat) => Some(
1027                regex::Regex::new(pat)
1028                    .map_err(|e| Error::ConfigError(format!("invalid value-pattern regex: {e}")))?,
1029            ),
1030            None => None,
1031        };
1032
1033        let line_indices: Vec<usize> = self
1034            .entries
1035            .iter()
1036            .filter(|e| {
1037                if e.key != canon {
1038                    return false;
1039                }
1040                if let Some(ref re) = re {
1041                    let v = e.value.as_deref().unwrap_or("");
1042                    re.is_match(v)
1043                } else {
1044                    true
1045                }
1046            })
1047            .map(|e| e.line - 1)
1048            .collect();
1049
1050        let count = line_indices.len();
1051        for &idx in line_indices.iter().rev() {
1052            self.remove_entry_line(idx);
1053        }
1054
1055        if count > 0 {
1056            if !preserve_empty_section_header {
1057                // Remove empty section headers (sections with no remaining entries and no comments)
1058                self.remove_empty_section_headers();
1059            }
1060
1061            let content = self.raw_lines.join("\n");
1062            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1063            self.entries = reparsed.entries;
1064            self.raw_lines = reparsed.raw_lines;
1065        }
1066
1067        Ok(count)
1068    }
1069
1070    /// Remove an entire section (and all its entries).
1071    ///
1072    /// # Parameters
1073    ///
1074    /// - `section` — section name (e.g. `"core"`, `"remote.origin"`).
1075    pub fn remove_section(&mut self, section: &str) -> Result<bool> {
1076        let (sec_name, sub_name) = parse_section_name(section);
1077        let sec_lower = sec_name.to_lowercase();
1078
1079        // Find section header line and all lines that belong to it
1080        let mut start = None;
1081        let mut end = 0;
1082        let mut parser = Parser::new();
1083
1084        for (idx, line) in self.raw_lines.iter().enumerate() {
1085            if parser.try_parse_section(line) {
1086                if parser.section.to_lowercase() == sec_lower
1087                    && parser.subsection.as_deref() == sub_name
1088                {
1089                    start = Some(idx);
1090                    end = idx;
1091                } else if start.is_some() {
1092                    break;
1093                }
1094            } else if start.is_some() {
1095                end = idx;
1096            }
1097        }
1098
1099        if let Some(s) = start {
1100            self.raw_lines.drain(s..=end);
1101            let content = self.raw_lines.join("\n");
1102            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1103            self.entries = reparsed.entries;
1104            self.raw_lines = reparsed.raw_lines;
1105            Ok(true)
1106        } else {
1107            Ok(false)
1108        }
1109    }
1110
1111    /// Rename a section.
1112    ///
1113    /// # Parameters
1114    ///
1115    /// - `old_name` — current section name (e.g. `"branch.main"`).
1116    /// - `new_name` — new section name (e.g. `"branch.develop"`).
1117    pub fn rename_section(&mut self, old_name: &str, new_name: &str) -> Result<bool> {
1118        let (old_sec, old_sub) = parse_section_name(old_name);
1119        let (new_sec, new_sub) = parse_section_name(new_name);
1120        let old_lower = old_sec.to_lowercase();
1121
1122        let mut found = false;
1123        let mut parser = Parser::new();
1124
1125        for idx in 0..self.raw_lines.len() {
1126            let line = &self.raw_lines[idx];
1127            if parser.try_parse_section(line)
1128                && parser.section.to_lowercase() == old_lower
1129                && parser.subsection.as_deref() == old_sub
1130            {
1131                // Rewrite the section header
1132                let header = match new_sub {
1133                    Some(sub) => format!("[{} \"{}\"]", new_sec, sub),
1134                    None => format!("[{}]", new_sec),
1135                };
1136                self.raw_lines[idx] = header;
1137                found = true;
1138            }
1139        }
1140
1141        if found {
1142            let content = self.raw_lines.join("\n");
1143            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1144            self.entries = reparsed.entries;
1145            self.raw_lines = reparsed.raw_lines;
1146        }
1147
1148        Ok(found)
1149    }
1150
1151    /// Append a new value for a key without removing existing entries.
1152    ///
1153    /// This is the behaviour of `git config --add section.key value`.
1154    /// If the section doesn't exist, it is created.
1155    pub fn add_value(&mut self, key: &str, value: &str) -> Result<()> {
1156        self.add_value_with_comment(key, value, None)
1157    }
1158
1159    /// Append a new value with an optional inline comment.
1160    pub fn add_value_with_comment(
1161        &mut self,
1162        key: &str,
1163        value: &str,
1164        comment: Option<&str>,
1165    ) -> Result<()> {
1166        let canon = canonical_key(key)?;
1167        let raw_var = raw_variable_name(key);
1168        let comment_suffix = format_comment_suffix(comment);
1169        let (section, subsection, _var) = split_key(&canon)?;
1170        let (raw_sec, raw_sub) = raw_section_parts(key);
1171
1172        let section_line = self.find_or_create_section_preserving_case(
1173            &section,
1174            subsection.as_deref(),
1175            &raw_sec,
1176            raw_sub.as_deref(),
1177        );
1178        let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1179        let insert_at = self.last_line_in_section(section_line) + 1;
1180        self.raw_lines.insert(insert_at, new_line);
1181
1182        // Re-parse to fix up entries and line numbers
1183        let content = self.raw_lines.join("\n");
1184        let reparsed = Self::parse(&self.path, &content, self.scope)?;
1185        self.entries = reparsed.entries;
1186        self.raw_lines = reparsed.raw_lines;
1187
1188        Ok(())
1189    }
1190
1191    /// Write the (possibly modified) config back to disk.
1192    /// Remove section headers that have no remaining entries or comments.
1193    fn remove_empty_section_headers(&mut self) {
1194        let section_re = regex::Regex::new(r"^\s*\[").unwrap();
1195        let comment_re = regex::Regex::new(r"^\s*(#|;)").unwrap();
1196
1197        let mut to_remove: Vec<usize> = Vec::new();
1198        let len = self.raw_lines.len();
1199
1200        for i in 0..len {
1201            let line = &self.raw_lines[i];
1202            if !section_re.is_match(line) {
1203                continue;
1204            }
1205            // Don't remove section headers that have inline key=value entries
1206            if is_section_header_with_inline_entry(line) {
1207                continue;
1208            }
1209            // Check if this section header is followed only by blank lines,
1210            // comments, or another section header (or end of file).
1211            let mut has_entries = false;
1212            for j in (i + 1)..len {
1213                let next = self.raw_lines[j].trim();
1214                if next.is_empty() {
1215                    continue;
1216                }
1217                if section_re.is_match(&self.raw_lines[j]) {
1218                    break;
1219                }
1220                if comment_re.is_match(&self.raw_lines[j]) {
1221                    // Has comments — keep the section
1222                    has_entries = true;
1223                    break;
1224                }
1225                // Has a key-value entry
1226                has_entries = true;
1227                break;
1228            }
1229            if !has_entries {
1230                to_remove.push(i);
1231            }
1232        }
1233
1234        // Remove in reverse to preserve indices
1235        for &idx in to_remove.iter().rev() {
1236            self.raw_lines.remove(idx);
1237        }
1238
1239        // Also remove trailing blank lines
1240        while self.raw_lines.last().is_some_and(|l| l.trim().is_empty()) {
1241            self.raw_lines.pop();
1242        }
1243    }
1244
1245    ///
1246    /// # Errors
1247    ///
1248    /// Returns [`Error::Io`] on write failure.
1249    pub fn write(&self) -> Result<()> {
1250        let content = self.raw_lines.join("\n");
1251        let trimmed = content.trim();
1252        if trimmed.is_empty() {
1253            // Write empty file if no content
1254            fs::write(&self.path, "")?;
1255        } else {
1256            // Ensure trailing newline
1257            let content = if content.ends_with('\n') {
1258                content
1259            } else {
1260                format!("{content}\n")
1261            };
1262            fs::write(&self.path, content)?;
1263        }
1264        Ok(())
1265    }
1266
1267    /// Find the line index of a section header, or create one.
1268    #[allow(dead_code)]
1269    fn find_or_create_section(&mut self, section: &str, subsection: Option<&str>) -> usize {
1270        let sec_lower = section.to_lowercase();
1271        let mut parser = Parser::new();
1272
1273        for (idx, line) in self.raw_lines.iter().enumerate() {
1274            if parser.try_parse_section(line)
1275                && parser.section.to_lowercase() == sec_lower
1276                && parser.subsection.as_deref() == subsection
1277            {
1278                return idx;
1279            }
1280        }
1281
1282        // Create new section at end of file
1283        let header = match subsection {
1284            Some(sub) => {
1285                let escaped = escape_subsection(sub);
1286                format!("[{} \"{}\"]", section, escaped)
1287            }
1288            None => format!("[{}]", section),
1289        };
1290        self.raw_lines.push(header);
1291        self.raw_lines.len() - 1
1292    }
1293
1294    /// Find the line index of a section header (case-insensitive match),
1295    /// or create one using the original-case names from user input.
1296    fn find_or_create_section_preserving_case(
1297        &mut self,
1298        section: &str,
1299        subsection: Option<&str>,
1300        raw_section: &str,
1301        raw_subsection: Option<&str>,
1302    ) -> usize {
1303        let sec_lower = section.to_lowercase();
1304        let mut parser = Parser::new();
1305
1306        for (idx, line) in self.raw_lines.iter().enumerate() {
1307            if parser.try_parse_section(line)
1308                && parser.section.to_lowercase() == sec_lower
1309                && parser.subsection.as_deref() == subsection
1310            {
1311                return idx;
1312            }
1313        }
1314
1315        // Create new section at end of file, using original case
1316        let header = match raw_subsection {
1317            Some(sub) => {
1318                let escaped = escape_subsection(sub);
1319                format!("[{} \"{}\"]", raw_section, escaped)
1320            }
1321            None => format!("[{}]", raw_section),
1322        };
1323        self.raw_lines.push(header);
1324        self.raw_lines.len() - 1
1325    }
1326
1327    /// Find the last line that belongs to the section starting at `section_line`.
1328    fn last_line_in_section(&self, section_line: usize) -> usize {
1329        let mut last = section_line;
1330        for idx in (section_line + 1)..self.raw_lines.len() {
1331            let trimmed = self.raw_lines[idx].trim();
1332            if trimmed.starts_with('[') {
1333                break;
1334            }
1335            last = idx;
1336        }
1337        last
1338    }
1339}
1340
1341// ── ConfigSet ───────────────────────────────────────────────────────
1342
1343impl ConfigSet {
1344    /// Create an empty config set.
1345    #[must_use]
1346    pub fn new() -> Self {
1347        Self {
1348            entries: Vec::new(),
1349        }
1350    }
1351
1352    /// All merged entries in load order (for listing keys such as `alias.*`).
1353    #[must_use]
1354    pub fn entries(&self) -> &[ConfigEntry] {
1355        &self.entries
1356    }
1357
1358    /// Merge entries from a [`ConfigFile`] into this set.
1359    ///
1360    /// Entries are appended; later values override earlier ones for
1361    /// single-value lookups.
1362    pub fn merge(&mut self, file: &ConfigFile) {
1363        self.entries.extend(file.entries.iter().cloned());
1364    }
1365
1366    /// Add a command-line override (`-c key=value`).
1367    pub fn add_command_override(&mut self, key: &str, value: &str) -> Result<()> {
1368        let canon = canonical_key(key)?;
1369        self.entries.push(ConfigEntry {
1370            key: canon,
1371            value: Some(value.to_owned()),
1372            scope: ConfigScope::Command,
1373            file: None,
1374            line: 0,
1375        });
1376        Ok(())
1377    }
1378
1379    /// Get the last (highest-priority) value for a key.
1380    ///
1381    /// # Parameters
1382    ///
1383    /// - `key` — the key to look up (will be canonicalized).
1384    ///
1385    /// # Returns
1386    ///
1387    /// `Some(value)` for the last matching entry, or `None` if not found.
1388    /// Bare boolean keys return `Some("true")`.
1389    #[must_use]
1390    pub fn get(&self, key: &str) -> Option<String> {
1391        let canon = canonical_key(key).ok()?;
1392        self.entries
1393            .iter()
1394            .rev()
1395            .find(|e| e.key == canon)
1396            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
1397    }
1398
1399    /// Last (highest-priority) [`ConfigEntry`] for a key, including origin metadata.
1400    ///
1401    /// Bare boolean keys are returned with [`ConfigEntry::value`] set to `None` (same as `get`,
1402    /// which maps them to `"true"` for string lookups).
1403    #[must_use]
1404    pub fn get_last_entry(&self, key: &str) -> Option<ConfigEntry> {
1405        let canon = canonical_key(key).ok()?;
1406        self.entries.iter().rev().find(|e| e.key == canon).cloned()
1407    }
1408
1409    /// Get all values for a key (multi-valued; in load order).
1410    #[must_use]
1411    pub fn get_all(&self, key: &str) -> Vec<String> {
1412        let canon = match canonical_key(key) {
1413            Ok(c) => c,
1414            Err(_) => return Vec::new(),
1415        };
1416        self.entries
1417            .iter()
1418            .filter(|e| e.key == canon)
1419            .map(|e| e.value.clone().unwrap_or_default())
1420            .collect()
1421    }
1422
1423    /// Get a boolean value, interpreting `true`/`yes`/`on`/`1` as true and
1424    /// `false`/`no`/`off`/`0` as false.
1425    pub fn get_bool(&self, key: &str) -> Option<std::result::Result<bool, String>> {
1426        self.get(key).map(|v| parse_bool(&v))
1427    }
1428
1429    /// Whether pathnames in human-readable output should fully C-quote non-ASCII bytes as octal.
1430    ///
1431    /// Maps to Git's `quote_path_fully` (`core.quotepath`, default true). When false, UTF-8 and
1432    /// other high bytes are emitted literally; only ASCII specials are escaped. Also honors
1433    /// `core.quotePath` as an alternate spelling.
1434    #[must_use]
1435    pub fn quote_path_fully(&self) -> bool {
1436        let from_key = |key: &str| self.get_bool(key).and_then(|r| r.ok());
1437        from_key("core.quotepath")
1438            .or_else(|| from_key("core.quotePath"))
1439            .unwrap_or(true)
1440    }
1441
1442    /// Resolved `core.logAllRefUpdates` using this merged set (includes `git -c` / env), then Git's
1443    /// bare-repo default when the key is unset everywhere.
1444    #[must_use]
1445    pub fn effective_log_refs_config(&self, git_dir: &Path) -> refs::LogRefsConfig {
1446        if let Some(v) = self.get("core.logAllRefUpdates") {
1447            let lower = v.trim().to_ascii_lowercase();
1448            let parsed = match lower.as_str() {
1449                "always" => Some(refs::LogRefsConfig::Always),
1450                "1" | "true" | "yes" | "on" => Some(refs::LogRefsConfig::Normal),
1451                "0" | "false" | "no" | "off" | "never" => Some(refs::LogRefsConfig::None),
1452                _ => None,
1453            };
1454            if let Some(c) = parsed {
1455                return c;
1456            }
1457        }
1458        refs::effective_log_refs_config(git_dir)
1459    }
1460
1461    /// Get an integer value, supporting Git's `k`/`m`/`g` suffixes.
1462    pub fn get_i64(&self, key: &str) -> Option<std::result::Result<i64, String>> {
1463        self.get(key).map(|v| parse_i64(&v))
1464    }
1465
1466    /// Get all entries matching a key pattern (regex).
1467    ///
1468    /// Used by `git config --get-regexp`. Returns an error if the pattern
1469    /// is not a valid regex.
1470    pub fn get_regexp(&self, pattern: &str) -> std::result::Result<Vec<&ConfigEntry>, String> {
1471        let re = regex::Regex::new(pattern).map_err(|e| format!("invalid key pattern: {e}"))?;
1472        Ok(self
1473            .entries
1474            .iter()
1475            .filter(|e| re.is_match(&e.key))
1476            .collect())
1477    }
1478
1479    /// Load the standard Git configuration file cascade for a repository.
1480    ///
1481    /// # Parameters
1482    ///
1483    /// - `git_dir` — path to the `.git` directory (for local/worktree config).
1484    /// - `include_system` — whether to load system config.
1485    ///
1486    /// # Errors
1487    ///
1488    /// Returns errors from file I/O or parsing.
1489    pub fn load(git_dir: Option<&Path>, include_system: bool) -> Result<Self> {
1490        let mut opts = LoadConfigOptions::default();
1491        opts.include_system = include_system;
1492        opts.include_ctx.git_dir = git_dir.map(PathBuf::from);
1493        Self::load_with_options(git_dir, &opts)
1494    }
1495
1496    /// Load the standard configuration cascade with explicit include and scope control.
1497    ///
1498    /// See [`LoadConfigOptions`] for `GIT_CONFIG_PARAMETERS` / `-c` include behaviour.
1499    pub fn load_with_options(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Result<Self> {
1500        let mut set = Self::new();
1501        let proc = opts.process_includes;
1502        let ctx = opts.include_ctx.clone();
1503
1504        // System config
1505        if opts.include_system && std::env::var("GIT_CONFIG_NOSYSTEM").is_err() {
1506            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
1507                .map(std::path::PathBuf::from)
1508                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
1509            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
1510                Self::merge_with_includes(&mut set, &f, proc, 0, &ctx)?;
1511            }
1512        }
1513
1514        // Global config (Git merges every existing file: XDG then ~/.gitconfig).
1515        for path in global_config_paths() {
1516            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
1517                Self::merge_with_includes(&mut set, &f, proc, 0, &ctx)?;
1518            }
1519        }
1520
1521        // Local config
1522        if let Some(gd) = git_dir {
1523            let local_path = gd.join("config");
1524            if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
1525                Self::merge_with_includes(&mut set, &f, proc, 0, &ctx)?;
1526            }
1527
1528            // Worktree config
1529            let wt_path = gd.join("config.worktree");
1530            if let Ok(Some(f)) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
1531                Self::merge_with_includes(&mut set, &f, proc, 0, &ctx)?;
1532            }
1533        }
1534
1535        // Environment overrides: optional file
1536        if let Ok(path) = std::env::var("GIT_CONFIG") {
1537            if let Ok(Some(f)) = ConfigFile::from_path(Path::new(&path), ConfigScope::Command) {
1538                if proc {
1539                    Self::merge_with_includes(&mut set, &f, proc, 0, &ctx)?;
1540                } else {
1541                    set.merge(&f);
1542                }
1543            }
1544        }
1545
1546        // GIT_CONFIG_COUNT / GIT_CONFIG_KEY_N / GIT_CONFIG_VALUE_N
1547        if let Ok(count_str) = std::env::var("GIT_CONFIG_COUNT") {
1548            if let Ok(count) = count_str.parse::<usize>() {
1549                for i in 0..count {
1550                    let key_var = format!("GIT_CONFIG_KEY_{i}");
1551                    let val_var = format!("GIT_CONFIG_VALUE_{i}");
1552                    if let (Ok(key), Ok(val)) = (std::env::var(&key_var), std::env::var(&val_var)) {
1553                        let _ = set.add_command_override(&key, &val);
1554                    }
1555                }
1556            }
1557        }
1558
1559        // GIT_CONFIG_PARAMETERS — used by `git -c key=value`.
1560        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
1561            if proc && opts.command_includes && !params.trim().is_empty() {
1562                let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
1563                let cmd_file = ConfigFile::from_git_config_parameters(pseudo, &params)?;
1564                Self::merge_with_includes(&mut set, &cmd_file, proc, 0, &ctx)?;
1565            } else if !params.trim().is_empty() {
1566                for entry in parse_config_parameters(&params) {
1567                    if let Some((key, val)) = entry.split_once('=') {
1568                        let _ = set.add_command_override(key.trim(), val);
1569                    } else {
1570                        let _ = set.add_command_override(entry.trim(), "true");
1571                    }
1572                }
1573            }
1574        }
1575
1576        Ok(set)
1577    }
1578
1579    /// Read configuration the way Git's `read_early_config` / `do_git_config_sequence` does:
1580    /// system (unless disabled), global files in Git order, optional repository `config` /
1581    /// `config.worktree`, then `GIT_CONFIG_PARAMETERS`.
1582    ///
1583    /// When `git_dir` is `None` (no discovered repository, e.g. `GIT_CEILING_DIRECTORIES`), only
1584    /// non-repo layers are read — matching Git when discovery returns no gitdir (t1309 ceiling #2).
1585    ///
1586    /// Returns all values for `key` in load order (Git's `read_early_config` callback runs once per
1587    /// occurrence).
1588    ///
1589    /// This matches upstream ordering for `test-tool config read_early_config` (t1309, t1305).
1590    pub fn read_early_config(git_dir: Option<&Path>, key: &str) -> Result<Vec<String>> {
1591        let mut set = Self::new();
1592        let ctx = IncludeContext {
1593            git_dir: git_dir.map(PathBuf::from),
1594            command_line_relative_include_is_error: false,
1595        };
1596
1597        // System
1598        if std::env::var("GIT_CONFIG_NOSYSTEM").is_err() {
1599            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
1600                .map(std::path::PathBuf::from)
1601                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
1602            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
1603                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1604            }
1605        }
1606
1607        // Global: all existing candidates (Git merges every readable file).
1608        for path in global_config_paths() {
1609            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
1610                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1611            }
1612        }
1613
1614        if let Some(gd) = git_dir {
1615            let common_dir = crate::repo::common_git_dir_for_config(gd);
1616            // Local (commondir) — skip when format is newer than supported (t1309).
1617            let local_path = common_dir.join("config");
1618            if let Some(msg) = crate::repo::early_config_ignore_repo_reason(&common_dir) {
1619                eprintln!("warning: ignoring git dir '{}': {}", gd.display(), msg);
1620            } else if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
1621                set.merge_file_with_includes(&f, true, &ctx)?;
1622            }
1623
1624            // Worktree-specific config (when enabled for this repo).
1625            let wt_path = gd.join("config.worktree");
1626            if crate::repo::worktree_config_enabled(&common_dir) {
1627                if let Ok(Some(f)) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
1628                    Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1629                }
1630            }
1631        }
1632
1633        // GIT_CONFIG_PARAMETERS — same as full load (`load_with_options` default).
1634        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
1635            if !params.trim().is_empty() {
1636                let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
1637                let cmd_file = ConfigFile::from_git_config_parameters(pseudo, &params)?;
1638                Self::merge_with_includes(&mut set, &cmd_file, true, 0, &ctx)?;
1639            }
1640        }
1641
1642        Ok(set.get_all(key))
1643    }
1644
1645    /// Merge a single config file, optionally expanding `[include]` / `[includeIf]`.
1646    ///
1647    /// Used by `grit config -f` and scoped reads; [`ConfigSet::load_with_options`] uses the same
1648    /// internal routine for the standard cascade.
1649    pub fn merge_file_with_includes(
1650        &mut self,
1651        file: &ConfigFile,
1652        process_includes: bool,
1653        ctx: &IncludeContext,
1654    ) -> Result<()> {
1655        Self::merge_with_includes(self, file, process_includes, 0, ctx)
1656    }
1657
1658    /// Load only the repository's own `config` file (plus any `[include]` targets).
1659    ///
1660    /// Unlike [`Self::load`], this ignores system/global config and environment
1661    /// overrides. Used for receive-side options (e.g. `transfer.fsckObjects`) so a
1662    /// pusher's global configuration cannot weaken the remote repository's policy.
1663    pub fn load_repo_local_only(git_dir: &Path) -> Result<Self> {
1664        let mut set = Self::new();
1665        let local_path = git_dir.join("config");
1666        let ctx = IncludeContext {
1667            git_dir: Some(git_dir.to_path_buf()),
1668            command_line_relative_include_is_error: false,
1669        };
1670        if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
1671            Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1672        }
1673        Ok(set)
1674    }
1675
1676    /// Load configuration the way Git loads **protected** config (e.g. `uploadpack.packObjectsHook`).
1677    ///
1678    /// This matches Git's `read_protected_config`: system (optional), global files only (no
1679    /// repository or worktree `config`), then command-line overrides from `GIT_CONFIG_COUNT` /
1680    /// `GIT_CONFIG_PARAMETERS`. It does **not** read `$GIT_CONFIG` (Git omits that for protected
1681    /// config).
1682    ///
1683    /// Global file order matches Git: XDG `git/config` first (when present), then `~/.gitconfig`,
1684    /// unless `GIT_CONFIG_GLOBAL` is set (single file). When both global files exist, both are
1685    /// merged so later entries win for duplicate keys.
1686    pub fn load_protected(include_system: bool) -> Result<Self> {
1687        let mut set = Self::new();
1688        let ctx = IncludeContext {
1689            git_dir: None,
1690            command_line_relative_include_is_error: false,
1691        };
1692
1693        if include_system && std::env::var("GIT_CONFIG_NOSYSTEM").is_err() {
1694            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
1695                .map(std::path::PathBuf::from)
1696                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
1697            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
1698                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1699            }
1700        }
1701
1702        if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
1703            let path = PathBuf::from(p);
1704            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
1705                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1706            }
1707        } else {
1708            let mut global_paths = Vec::new();
1709            if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
1710                global_paths.push(PathBuf::from(xdg).join("git/config"));
1711            } else if let Some(home) = home_dir() {
1712                global_paths.push(home.join(".config/git/config"));
1713            }
1714            if let Some(home) = home_dir() {
1715                global_paths.push(home.join(".gitconfig"));
1716            }
1717            for path in global_paths {
1718                if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
1719                    Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1720                }
1721            }
1722        }
1723
1724        if let Ok(count_str) = std::env::var("GIT_CONFIG_COUNT") {
1725            if let Ok(count) = count_str.parse::<usize>() {
1726                for i in 0..count {
1727                    let key_var = format!("GIT_CONFIG_KEY_{i}");
1728                    let val_var = format!("GIT_CONFIG_VALUE_{i}");
1729                    if let (Ok(key), Ok(val)) = (std::env::var(&key_var), std::env::var(&val_var)) {
1730                        let _ = set.add_command_override(&key, &val);
1731                    }
1732                }
1733            }
1734        }
1735
1736        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
1737            for entry in parse_config_parameters(&params) {
1738                if let Some((key, val)) = entry.split_once('=') {
1739                    let _ = set.add_command_override(key.trim(), val);
1740                } else {
1741                    let _ = set.add_command_override(entry.trim(), "true");
1742                }
1743            }
1744        }
1745
1746        Ok(set)
1747    }
1748
1749    /// Merge a file, processing `[include]` and `[includeIf]` directives.
1750    fn merge_with_includes(
1751        set: &mut Self,
1752        file: &ConfigFile,
1753        process_includes: bool,
1754        depth: usize,
1755        ctx: &IncludeContext,
1756    ) -> Result<()> {
1757        // Mirror Git behavior and stop runaway include recursion.
1758        // t0017 expects the diagnostic to contain this exact phrase.
1759        const MAX_INCLUDE_DEPTH: usize = 10;
1760        if depth > MAX_INCLUDE_DEPTH {
1761            return Err(Error::ConfigError(
1762                "exceeded maximum include depth".to_owned(),
1763            ));
1764        }
1765        // First pass: find include paths
1766        let mut includes: Vec<(String, Option<String>)> = Vec::new();
1767
1768        for entry in &file.entries {
1769            if entry.key == "include.path" {
1770                if let Some(ref val) = entry.value {
1771                    includes.push((val.clone(), None));
1772                }
1773            } else if entry.key.starts_with("includeif.") && entry.key.ends_with(".path") {
1774                // Extract condition from key: includeif.<condition>.path
1775                let mid = &entry.key["includeif.".len()..entry.key.len() - ".path".len()];
1776                if let Some(ref val) = entry.value {
1777                    includes.push((val.clone(), Some(mid.to_owned())));
1778                }
1779            }
1780        }
1781
1782        // Merge the file's own entries
1783        set.merge(file);
1784
1785        // Process includes
1786        if process_includes {
1787            for (inc_path, condition) in includes {
1788                if let Some(ref cond) = condition {
1789                    if !evaluate_include_condition(cond, file, ctx) {
1790                        continue;
1791                    }
1792                }
1793
1794                let resolved = match resolve_include_file_path(&inc_path, file, ctx) {
1795                    Ok(p) => p,
1796                    Err(Error::ConfigError(msg)) if msg.is_empty() => continue,
1797                    Err(e) => return Err(e),
1798                };
1799                if let Ok(Some(inc_file)) = ConfigFile::from_path(&resolved, file.scope) {
1800                    Self::merge_with_includes(set, &inc_file, process_includes, depth + 1, ctx)?;
1801                }
1802            }
1803        }
1804
1805        Ok(())
1806    }
1807}
1808
1809// ── Type coercion helpers ───────────────────────────────────────────
1810
1811/// Parse a Git boolean value.
1812///
1813/// Accepts: `true`, `yes`, `on`, `1` as true.
1814/// Accepts: `false`, `no`, `off`, `0` (and explicit empty value) as false.
1815///
1816/// Note: bare config keys are represented as `None` in [`ConfigEntry`] and
1817/// are normalized to `"true"` by higher-level readers (`ConfigSet::get`).
1818/// This parser only sees string values and therefore treats `""` as an
1819/// explicitly empty assignment (`key =`), which Git interprets as false.
1820pub fn parse_bool(s: &str) -> std::result::Result<bool, String> {
1821    match s.to_lowercase().as_str() {
1822        "true" | "yes" | "on" => Ok(true),
1823        "" => Ok(false),
1824        "false" | "no" | "off" => Ok(false),
1825        _ => {
1826            // Try parsing as integer: 0 → false, non-zero → true
1827            if let Ok(n) = s.parse::<i64>() {
1828                return Ok(n != 0);
1829            }
1830            Err(format!("bad boolean config value '{s}'"))
1831        }
1832    }
1833}
1834
1835/// Parse a Git integer value with optional `k`/`m`/`g` suffix.
1836pub fn parse_i64(s: &str) -> std::result::Result<i64, String> {
1837    let s = s.trim();
1838    if s.is_empty() {
1839        return Err("empty integer value".to_owned());
1840    }
1841
1842    let (num_str, multiplier) = match s.as_bytes().last() {
1843        Some(b'k' | b'K') => (&s[..s.len() - 1], 1024_i64),
1844        Some(b'm' | b'M') => (&s[..s.len() - 1], 1024 * 1024),
1845        Some(b'g' | b'G') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
1846        _ => (s, 1_i64),
1847    };
1848
1849    let base: i64 = num_str
1850        .parse()
1851        .map_err(|_| format!("invalid integer: '{s}'"))?;
1852    base.checked_mul(multiplier)
1853        .ok_or_else(|| format!("integer overflow: '{s}'"))
1854}
1855
1856/// Why [`parse_git_config_int_strict`] failed (mirrors Git `errno` after `git_parse_signed`).
1857#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1858pub enum GitConfigIntStrictError {
1859    /// `EINVAL` — trailing junk, unknown unit suffix, or not a number.
1860    InvalidUnit,
1861    /// `ERANGE` — value does not fit in `i64` after scaling.
1862    OutOfRange,
1863}
1864
1865/// Parse a signed decimal integer with optional `k`/`m`/`g` multiplier suffix, requiring the
1866/// entire input (trimmed) to be consumed — same constraints as Git's `git_parse_signed` used by
1867/// `git_config_int` (so `no` and `1foo` are rejected, unlike [`parse_i64`]).
1868pub fn parse_git_config_int_strict(raw: &str) -> std::result::Result<i64, GitConfigIntStrictError> {
1869    let s = raw.trim();
1870    if s.is_empty() {
1871        return Err(GitConfigIntStrictError::InvalidUnit);
1872    }
1873
1874    let bytes = s.as_bytes();
1875    let mut idx = 0usize;
1876    if matches!(bytes.first(), Some(b'+') | Some(b'-')) {
1877        idx = 1;
1878    }
1879    if idx >= bytes.len() {
1880        return Err(GitConfigIntStrictError::InvalidUnit);
1881    }
1882    let digit_start = idx;
1883    while idx < bytes.len() && bytes[idx].is_ascii_digit() {
1884        idx += 1;
1885    }
1886    if idx == digit_start {
1887        return Err(GitConfigIntStrictError::InvalidUnit);
1888    }
1889
1890    let num_part =
1891        std::str::from_utf8(&bytes[..idx]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
1892    let suffix =
1893        std::str::from_utf8(&bytes[idx..]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
1894    let mult: i64 = match suffix {
1895        "" => 1,
1896        "k" | "K" => 1024,
1897        "m" | "M" => 1024 * 1024,
1898        "g" | "G" => 1024_i64
1899            .checked_mul(1024)
1900            .and_then(|x| x.checked_mul(1024))
1901            .ok_or(GitConfigIntStrictError::OutOfRange)?,
1902        _ => return Err(GitConfigIntStrictError::InvalidUnit),
1903    };
1904
1905    let val: i64 = num_part
1906        .parse()
1907        .map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
1908    val.checked_mul(mult)
1909        .ok_or(GitConfigIntStrictError::OutOfRange)
1910}
1911
1912const DIFF_CONTEXT_KEY: &str = "diff.context";
1913
1914fn format_bad_numeric_diff_context(
1915    value: &str,
1916    err: GitConfigIntStrictError,
1917    entry: &ConfigEntry,
1918) -> String {
1919    let detail = match err {
1920        GitConfigIntStrictError::InvalidUnit => "invalid unit",
1921        GitConfigIntStrictError::OutOfRange => "out of range",
1922    };
1923    if entry.scope == ConfigScope::Command || entry.file.is_none() {
1924        return format!(
1925            "fatal: bad numeric config value '{value}' for '{DIFF_CONTEXT_KEY}': {detail}"
1926        );
1927    }
1928    let path = entry
1929        .file
1930        .as_deref()
1931        .map(config_error_path_display)
1932        .unwrap_or_default();
1933    format!("fatal: bad numeric config value '{value}' for '{DIFF_CONTEXT_KEY}' in file {path}: {detail}")
1934}
1935
1936fn format_bad_diff_context_variable(entry: &ConfigEntry) -> String {
1937    if entry.scope == ConfigScope::Command || entry.file.is_none() {
1938        return format!("fatal: unable to parse '{DIFF_CONTEXT_KEY}' from command-line config");
1939    }
1940    let path = entry
1941        .file
1942        .as_deref()
1943        .map(config_error_path_display)
1944        .unwrap_or_default();
1945    format!(
1946        "fatal: bad config variable '{DIFF_CONTEXT_KEY}' in file '{path}' at line {}",
1947        entry.line
1948    )
1949}
1950
1951/// Read `diff.context` from a loaded [`ConfigSet`] with Git-compatible validation.
1952///
1953/// Returns `Ok(None)` when the key is unset. When set, the value must be a non-negative integer
1954/// acceptable to Git's diff machinery (same rules as `git diff` / `git log -p`).
1955pub fn resolve_diff_context_lines(cfg: &ConfigSet) -> std::result::Result<Option<usize>, String> {
1956    let Some(entry) = cfg.get_last_entry(DIFF_CONTEXT_KEY) else {
1957        return Ok(None);
1958    };
1959    let value_src = entry.value.as_deref().unwrap_or("").trim();
1960    match parse_git_config_int_strict(value_src) {
1961        Ok(n) if n < 0 => Err(format_bad_diff_context_variable(&entry)),
1962        Ok(n) => Ok(Some(usize::try_from(n).map_err(|_| {
1963            format_bad_numeric_diff_context(value_src, GitConfigIntStrictError::OutOfRange, &entry)
1964        })?)),
1965        Err(e) => Err(format_bad_numeric_diff_context(value_src, e, &entry)),
1966    }
1967}
1968
1969/// Parse a Git color value and return the ANSI escape sequence.
1970///
1971/// Matches Git's `color_parse_mem` (`git/color.c`): whitespace-separated words,
1972/// optional leading `reset`, up to two color tokens (foreground then background),
1973/// then graphic rendition attributes. Attribute codes are accumulated as a
1974/// bitmask keyed by SGR number (so `bold` sets bit 1, `nobold` sets bit 22).
1975pub fn parse_color(s: &str) -> std::result::Result<String, String> {
1976    const COLOR_BACKGROUND_OFFSET: i32 = 10;
1977    const COLOR_FOREGROUND_ANSI: i32 = 30;
1978    const COLOR_FOREGROUND_RGB: i32 = 38;
1979    const COLOR_FOREGROUND_256: i32 = 38;
1980    const COLOR_FOREGROUND_BRIGHT_ANSI: i32 = 90;
1981
1982    #[derive(Clone, Copy, Default)]
1983    struct Color {
1984        kind: u8,
1985        value: u8,
1986        red: u8,
1987        green: u8,
1988        blue: u8,
1989    }
1990
1991    const COLOR_UNSPECIFIED: u8 = 0;
1992    const COLOR_NORMAL: u8 = 1;
1993    const COLOR_ANSI: u8 = 2;
1994    const COLOR_256: u8 = 3;
1995    const COLOR_RGB: u8 = 4;
1996
1997    fn color_empty(c: &Color) -> bool {
1998        c.kind == COLOR_UNSPECIFIED || c.kind == COLOR_NORMAL
1999    }
2000
2001    fn parse_ansi_color(name: &str) -> Option<Color> {
2002        let color_names = [
2003            "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
2004        ];
2005        let color_offset = COLOR_FOREGROUND_ANSI;
2006
2007        if name.eq_ignore_ascii_case("default") {
2008            return Some(Color {
2009                kind: COLOR_ANSI,
2010                value: (9 + color_offset) as u8,
2011                ..Default::default()
2012            });
2013        }
2014
2015        let (name, color_offset) = if name.len() >= 6 && name[..6].eq_ignore_ascii_case("bright") {
2016            (&name[6..], COLOR_FOREGROUND_BRIGHT_ANSI)
2017        } else {
2018            (name, COLOR_FOREGROUND_ANSI)
2019        };
2020
2021        for (i, cn) in color_names.iter().enumerate() {
2022            if name.eq_ignore_ascii_case(cn) {
2023                return Some(Color {
2024                    kind: COLOR_ANSI,
2025                    value: (i as i32 + color_offset) as u8,
2026                    ..Default::default()
2027                });
2028            }
2029        }
2030        None
2031    }
2032
2033    fn hex_val(b: u8) -> Option<u8> {
2034        match b {
2035            b'0'..=b'9' => Some(b - b'0'),
2036            b'a'..=b'f' => Some(b - b'a' + 10),
2037            b'A'..=b'F' => Some(b - b'A' + 10),
2038            _ => None,
2039        }
2040    }
2041
2042    fn get_hex_color(chars: &[u8], width: usize) -> Option<(u8, usize)> {
2043        assert!(width == 1 || width == 2);
2044        if chars.len() < width {
2045            return None;
2046        }
2047        let v = if width == 2 {
2048            let hi = hex_val(chars[0])?;
2049            let lo = hex_val(chars[1])?;
2050            (hi << 4) | lo
2051        } else {
2052            let n = hex_val(chars[0])?;
2053            (n << 4) | n
2054        };
2055        Some((v, width))
2056    }
2057
2058    fn parse_single_color(word: &str) -> Option<Color> {
2059        if word.eq_ignore_ascii_case("normal") {
2060            return Some(Color {
2061                kind: COLOR_NORMAL,
2062                ..Default::default()
2063            });
2064        }
2065
2066        let bytes = word.as_bytes();
2067        if (bytes.len() == 7 || bytes.len() == 4) && bytes.first() == Some(&b'#') {
2068            let width = if bytes.len() == 7 { 2 } else { 1 };
2069            let mut idx = 1;
2070            let (r, n1) = get_hex_color(&bytes[idx..], width)?;
2071            idx += n1;
2072            let (g, n2) = get_hex_color(&bytes[idx..], width)?;
2073            idx += n2;
2074            let (b, n3) = get_hex_color(&bytes[idx..], width)?;
2075            idx += n3;
2076            if idx != bytes.len() {
2077                return None;
2078            }
2079            return Some(Color {
2080                kind: COLOR_RGB,
2081                red: r,
2082                green: g,
2083                blue: b,
2084                ..Default::default()
2085            });
2086        }
2087
2088        if let Some(c) = parse_ansi_color(word) {
2089            return Some(c);
2090        }
2091
2092        let Ok(val) = word.parse::<i64>() else {
2093            return None;
2094        };
2095        if val < -1 {
2096            return None;
2097        }
2098        if val < 0 {
2099            return Some(Color {
2100                kind: COLOR_NORMAL,
2101                ..Default::default()
2102            });
2103        }
2104        if val < 8 {
2105            return Some(Color {
2106                kind: COLOR_ANSI,
2107                value: (val as i32 + COLOR_FOREGROUND_ANSI) as u8,
2108                ..Default::default()
2109            });
2110        }
2111        if val < 16 {
2112            return Some(Color {
2113                kind: COLOR_ANSI,
2114                value: (val as i32 - 8 + COLOR_FOREGROUND_BRIGHT_ANSI) as u8,
2115                ..Default::default()
2116            });
2117        }
2118        if val < 256 {
2119            return Some(Color {
2120                kind: COLOR_256,
2121                value: val as u8,
2122                ..Default::default()
2123            });
2124        }
2125        None
2126    }
2127
2128    fn parse_attr(word: &str) -> Option<u8> {
2129        const ATTRS: [(&str, u8, u8); 8] = [
2130            ("bold", 1, 22),
2131            ("dim", 2, 22),
2132            ("italic", 3, 23),
2133            ("ul", 4, 24),
2134            ("underline", 4, 24),
2135            ("blink", 5, 25),
2136            ("reverse", 7, 27),
2137            ("strike", 9, 29),
2138        ];
2139
2140        let mut negate = false;
2141        let mut rest = word;
2142        if let Some(stripped) = rest.strip_prefix("no") {
2143            negate = true;
2144            rest = stripped;
2145            if let Some(s) = rest.strip_prefix('-') {
2146                rest = s;
2147            }
2148        }
2149
2150        for (name, val, neg) in ATTRS {
2151            if rest == name {
2152                return Some(if negate { neg } else { val });
2153            }
2154        }
2155        None
2156    }
2157
2158    fn append_color_output(out: &mut String, c: &Color, background: bool) {
2159        let offset = if background {
2160            COLOR_BACKGROUND_OFFSET
2161        } else {
2162            0
2163        };
2164        match c.kind {
2165            COLOR_UNSPECIFIED | COLOR_NORMAL => {}
2166            COLOR_ANSI => {
2167                use std::fmt::Write;
2168                let _ = write!(out, "{}", i32::from(c.value) + offset);
2169            }
2170            COLOR_256 => {
2171                use std::fmt::Write;
2172                let _ = write!(out, "{};5;{}", COLOR_FOREGROUND_256 + offset, c.value);
2173            }
2174            COLOR_RGB => {
2175                use std::fmt::Write;
2176                let _ = write!(
2177                    out,
2178                    "{};2;{};{};{}",
2179                    COLOR_FOREGROUND_RGB + offset,
2180                    c.red,
2181                    c.green,
2182                    c.blue
2183                );
2184            }
2185            _ => {}
2186        }
2187    }
2188
2189    let s = s.trim();
2190    if s.is_empty() {
2191        return Ok(String::new());
2192    }
2193
2194    let mut has_reset = false;
2195    let mut attr: u64 = 0;
2196    let mut fg = Color::default();
2197    let mut bg = Color::default();
2198    fg.kind = COLOR_UNSPECIFIED;
2199    bg.kind = COLOR_UNSPECIFIED;
2200
2201    for word in s.split_whitespace() {
2202        if word.eq_ignore_ascii_case("reset") {
2203            has_reset = true;
2204            continue;
2205        }
2206
2207        if let Some(c) = parse_single_color(word) {
2208            if fg.kind == COLOR_UNSPECIFIED {
2209                fg = c;
2210                continue;
2211            }
2212            if bg.kind == COLOR_UNSPECIFIED {
2213                bg = c;
2214                continue;
2215            }
2216            return Err(format!("bad color value '{s}'"));
2217        }
2218
2219        if let Some(code) = parse_attr(word) {
2220            attr |= 1u64 << u64::from(code);
2221            continue;
2222        }
2223
2224        return Err(format!("bad color value '{s}'"));
2225    }
2226
2227    if !has_reset && attr == 0 && color_empty(&fg) && color_empty(&bg) {
2228        return Err(format!("bad color value '{s}'"));
2229    }
2230
2231    let mut out = String::from("\x1b[");
2232    let mut sep = if has_reset { 1u32 } else { 0u32 };
2233
2234    let mut attr_bits = attr;
2235    let mut i = 0u32;
2236    while attr_bits != 0 {
2237        let bit = 1u64 << i;
2238        if attr_bits & bit == 0 {
2239            i += 1;
2240            continue;
2241        }
2242        attr_bits &= !bit;
2243        if sep > 0 {
2244            out.push(';');
2245        }
2246        sep += 1;
2247        use std::fmt::Write;
2248        let _ = write!(out, "{i}");
2249        i += 1;
2250    }
2251
2252    if !color_empty(&fg) {
2253        if sep > 0 {
2254            out.push(';');
2255        }
2256        sep += 1;
2257        append_color_output(&mut out, &fg, false);
2258    }
2259    if !color_empty(&bg) {
2260        if sep > 0 {
2261            out.push(';');
2262        }
2263        append_color_output(&mut out, &bg, true);
2264    }
2265    out.push('m');
2266    Ok(out)
2267}
2268
2269/// Match a URL against a URL pattern from config.
2270pub fn url_matches(pattern_url: &str, target_url: &str) -> bool {
2271    let pattern = pattern_url.trim_end_matches('/');
2272    let target = target_url.trim_end_matches('/');
2273    if target == pattern {
2274        return true;
2275    }
2276    if let Some(rest) = target.strip_prefix(pattern) {
2277        return rest.starts_with('/') || rest.is_empty();
2278    }
2279    let pattern_slash = format!("{}/", pattern);
2280    target.starts_with(&pattern_slash)
2281}
2282
2283/// Get the best URL match for a specific key.
2284pub fn get_urlmatch_entries<'a>(
2285    entries: &'a [ConfigEntry],
2286    section: &str,
2287    variable: &str,
2288    url: &str,
2289) -> Vec<&'a ConfigEntry> {
2290    let section_lower = section.to_lowercase();
2291    let variable_lower = variable.to_lowercase();
2292    let mut matches: Vec<(usize, &'a ConfigEntry)> = Vec::new();
2293
2294    for entry in entries {
2295        let key = &entry.key;
2296        let first_dot = match key.find('.') {
2297            Some(i) => i,
2298            None => continue,
2299        };
2300        let last_dot = match key.rfind('.') {
2301            Some(i) => i,
2302            None => continue,
2303        };
2304        let entry_section = &key[..first_dot];
2305        let entry_variable = &key[last_dot + 1..];
2306        if entry_section.to_lowercase() != section_lower
2307            || entry_variable.to_lowercase() != variable_lower
2308        {
2309            continue;
2310        }
2311        if first_dot == last_dot {
2312            matches.push((0, entry));
2313        } else {
2314            let subsection = &key[first_dot + 1..last_dot];
2315            if url_matches(subsection, url) {
2316                matches.push((subsection.len(), entry));
2317            }
2318        }
2319    }
2320    matches.sort_by(|a, b| a.0.cmp(&b.0));
2321    matches.into_iter().map(|(_, e)| e).collect()
2322}
2323
2324/// Get all matching variables in a section for a given URL.
2325pub fn get_urlmatch_all_in_section(
2326    entries: &[ConfigEntry],
2327    section: &str,
2328    url: &str,
2329) -> Vec<(String, String, ConfigScope)> {
2330    let section_lower = section.to_lowercase();
2331    let mut matches: Vec<(String, usize, String, String, ConfigScope)> = Vec::new();
2332
2333    for entry in entries {
2334        let key = &entry.key;
2335        let first_dot = match key.find('.') {
2336            Some(i) => i,
2337            None => continue,
2338        };
2339        let last_dot = match key.rfind('.') {
2340            Some(i) => i,
2341            None => continue,
2342        };
2343        let entry_section = &key[..first_dot];
2344        if entry_section.to_lowercase() != section_lower {
2345            continue;
2346        }
2347        let entry_variable = &key[last_dot + 1..];
2348        let val = entry.value.as_deref().unwrap_or("true");
2349        if first_dot == last_dot {
2350            let canonical = format!("{}.{}", section_lower, entry_variable);
2351            matches.push((
2352                entry_variable.to_lowercase(),
2353                0,
2354                val.to_owned(),
2355                canonical,
2356                entry.scope,
2357            ));
2358        } else {
2359            let subsection = &key[first_dot + 1..last_dot];
2360            if url_matches(subsection, url) {
2361                let canonical = format!("{}.{}", section_lower, entry_variable);
2362                matches.push((
2363                    entry_variable.to_lowercase(),
2364                    subsection.len(),
2365                    val.to_owned(),
2366                    canonical,
2367                    entry.scope,
2368                ));
2369            }
2370        }
2371    }
2372
2373    let mut best: std::collections::BTreeMap<String, (usize, String, String, ConfigScope)> =
2374        std::collections::BTreeMap::new();
2375    for (var, specificity, val, canonical, scope) in matches {
2376        let entry = best
2377            .entry(var)
2378            .or_insert((0, String::new(), String::new(), scope));
2379        if specificity >= entry.0 {
2380            *entry = (specificity, val, canonical, scope);
2381        }
2382    }
2383    best.into_values()
2384        .map(|(_, val, canonical, scope)| (canonical, val, scope))
2385        .collect()
2386}
2387
2388/// Parse a Git path value (expand `~/` to home directory).
2389/// Parse a path value. Returns the resolved path string.
2390/// Does NOT handle :(optional) prefix — use `parse_path_optional` for that.
2391pub fn parse_path(s: &str) -> String {
2392    if let Some(rest) = s.strip_prefix("~/") {
2393        if let Some(home) = home_dir() {
2394            return home.join(rest).to_string_lossy().to_string();
2395        }
2396    }
2397    s.to_owned()
2398}
2399
2400/// Parse a path value that may have an `:(optional)` prefix.
2401///
2402/// Returns `Some(path)` if the path should be used, `None` if the path
2403/// is optional and does not exist (meaning the entry should be skipped).
2404pub fn parse_path_optional(s: &str) -> Option<String> {
2405    if let Some(rest) = s.strip_prefix(":(optional)") {
2406        let resolved = parse_path(rest);
2407        if std::path::Path::new(&resolved).exists() {
2408            Some(resolved)
2409        } else {
2410            None // optional and missing → skip
2411        }
2412    } else {
2413        Some(parse_path(s))
2414    }
2415}
2416
2417// ── Helpers ─────────────────────────────────────────────────────────
2418
2419/// Parse `GIT_CONFIG_PARAMETERS` payloads.
2420///
2421/// We support the common formats seen in tests and wrappers:
2422/// - single-quoted entries: `'key=value'`
2423/// - double-quoted entries: `"key=value"`
2424/// - unquoted `key=value` tokens separated by whitespace
2425///
2426/// Backslash escapes are interpreted minimally inside double quotes.
2427fn parse_config_parameters(raw: &str) -> Vec<String> {
2428    let mut out: Vec<String> = Vec::new();
2429    let mut buf = String::new();
2430    let mut in_single = false;
2431    let mut in_double = false;
2432
2433    let mut chars = raw.chars().peekable();
2434    while let Some(ch) = chars.next() {
2435        if in_single {
2436            if ch == '\'' {
2437                in_single = false;
2438            } else {
2439                buf.push(ch);
2440            }
2441            continue;
2442        }
2443        if in_double {
2444            if ch == '"' {
2445                in_double = false;
2446                continue;
2447            }
2448            if ch == '\\' {
2449                if let Some(next) = chars.next() {
2450                    let mapped = match next {
2451                        'n' => '\n',
2452                        't' => '\t',
2453                        'r' => '\r',
2454                        '"' => '"',
2455                        '\\' => '\\',
2456                        other => other,
2457                    };
2458                    buf.push(mapped);
2459                }
2460                continue;
2461            }
2462            buf.push(ch);
2463            continue;
2464        }
2465
2466        if ch == '\'' {
2467            in_single = true;
2468            continue;
2469        }
2470        if ch == '"' {
2471            in_double = true;
2472            continue;
2473        }
2474
2475        if ch.is_whitespace() {
2476            if !buf.is_empty() {
2477                out.push(std::mem::take(&mut buf));
2478            }
2479            continue;
2480        }
2481
2482        buf.push(ch);
2483    }
2484
2485    if !buf.is_empty() {
2486        out.push(buf);
2487    }
2488
2489    out
2490}
2491
2492/// Return candidate paths for the global config file, in priority order.
2493/// Public accessor for the ordered list of global config file paths.
2494pub fn global_config_paths_pub() -> Vec<PathBuf> {
2495    global_config_paths()
2496}
2497
2498fn global_config_paths() -> Vec<PathBuf> {
2499    let mut paths = Vec::new();
2500
2501    // $GIT_CONFIG_GLOBAL overrides
2502    if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
2503        paths.push(PathBuf::from(p));
2504        return paths;
2505    }
2506
2507    // Git order: XDG `git/config` first, then `~/.gitconfig` (see `git_global_config_paths`).
2508    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
2509        paths.push(PathBuf::from(xdg).join("git/config"));
2510    } else if let Some(home) = home_dir() {
2511        paths.push(home.join(".config/git/config"));
2512    }
2513    if let Some(home) = home_dir() {
2514        paths.push(home.join(".gitconfig"));
2515    }
2516
2517    paths
2518}
2519
2520/// Return the user's home directory.
2521fn home_dir() -> Option<PathBuf> {
2522    std::env::var("HOME").ok().map(PathBuf::from)
2523}
2524
2525/// True when Git would treat the config source as `CONFIG_ORIGIN_FILE` for includes.
2526fn include_source_is_disk_file(file: &ConfigFile) -> bool {
2527    file.include_origin == ConfigIncludeOrigin::Disk
2528}
2529
2530/// Resolve an include file path (Git `handle_path_include` semantics).
2531///
2532/// Relative paths are only allowed when the including config came from a real on-disk file.
2533fn resolve_include_file_path(
2534    path: &str,
2535    file: &ConfigFile,
2536    ctx: &IncludeContext,
2537) -> Result<PathBuf> {
2538    let expanded = parse_path(path);
2539    let p = Path::new(&expanded);
2540    if p.is_absolute() {
2541        return Ok(p.to_path_buf());
2542    }
2543    if !include_source_is_disk_file(file) {
2544        if file.include_origin == ConfigIncludeOrigin::CommandLine {
2545            if ctx.command_line_relative_include_is_error {
2546                return Err(Error::ConfigError(
2547                    "relative config includes must come from files".to_owned(),
2548                ));
2549            }
2550            return Err(Error::ConfigError(String::new()));
2551        }
2552        return Err(Error::ConfigError(
2553            "relative config includes must come from files".to_owned(),
2554        ));
2555    }
2556    let base = match file.path.parent() {
2557        Some(p) if !p.as_os_str().is_empty() => p,
2558        Some(_) | None => Path::new("."),
2559    };
2560    Ok(base.join(p))
2561}
2562
2563fn is_dir_sep(b: u8) -> bool {
2564    b == b'/' || b == b'\\'
2565}
2566
2567fn add_trailing_starstar_for_dir(pat: &mut String) {
2568    let bytes = pat.as_bytes();
2569    if !bytes.is_empty() && is_dir_sep(*bytes.last().unwrap()) {
2570        pat.push_str("**");
2571    }
2572}
2573
2574/// Prepare a `gitdir:` / `gitdir/i:` pattern (Git `prepare_include_condition_pattern`).
2575fn prepare_gitdir_pattern(condition: &str, file: &ConfigFile) -> Result<(String, usize)> {
2576    // Git `interpolate_path`: expand `~/` in the condition before pattern rules.
2577    let mut pat = parse_path(condition);
2578    if pat.starts_with("./") || pat.starts_with(".\\") {
2579        if !include_source_is_disk_file(file) {
2580            return Err(Error::ConfigError(
2581                "relative config include conditionals must come from files".to_owned(),
2582            ));
2583        }
2584        let parent = file.path.parent().ok_or_else(|| {
2585            Error::ConfigError(
2586                "relative config include conditionals must come from files".to_owned(),
2587            )
2588        })?;
2589        let real = parent.canonicalize().map_err(Error::Io)?;
2590        let mut dir = real.to_string_lossy().into_owned();
2591        if !dir.ends_with('/') && !dir.ends_with('\\') {
2592            dir.push('/');
2593        }
2594        let rest = &pat[2..];
2595        pat = format!("{dir}{rest}");
2596        let prefix_len = dir.len();
2597        add_trailing_starstar_for_dir(&mut pat);
2598        return Ok((pat, prefix_len));
2599    }
2600    let p = Path::new(&pat);
2601    if !p.is_absolute() {
2602        pat.insert_str(0, "**/");
2603    }
2604    add_trailing_starstar_for_dir(&mut pat);
2605    Ok((pat, 0))
2606}
2607
2608/// Git `include_by_gitdir` tries `strbuf_realpath` first, then `strbuf_add_absolute_path` if no match.
2609fn git_dir_match_texts(git_dir: &Path) -> (String, String) {
2610    let real = git_dir
2611        .canonicalize()
2612        .map(|p| p.to_string_lossy().into_owned())
2613        .unwrap_or_else(|_| git_dir.to_string_lossy().into_owned());
2614    let abs = if git_dir.is_absolute() {
2615        git_dir.to_string_lossy().into_owned()
2616    } else if let Ok(cwd) = std::env::current_dir() {
2617        cwd.join(git_dir).to_string_lossy().into_owned()
2618    } else {
2619        git_dir.to_string_lossy().into_owned()
2620    };
2621    (real, abs)
2622}
2623
2624fn include_by_gitdir(
2625    condition: &str,
2626    file: &ConfigFile,
2627    ctx: &IncludeContext,
2628    icase: bool,
2629) -> bool {
2630    let Some(git_dir) = ctx.git_dir.as_ref() else {
2631        return false;
2632    };
2633    let (pattern, prefix) = match prepare_gitdir_pattern(condition, file) {
2634        Ok(x) => x,
2635        Err(_) => return false,
2636    };
2637    let flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
2638    let (text_real, text_abs) = git_dir_match_texts(git_dir);
2639    let try_match = |text: &str| -> bool {
2640        let t = text.as_bytes();
2641        let p = pattern.as_bytes();
2642        if prefix > 0 {
2643            if t.len() < prefix {
2644                return false;
2645            }
2646            let pre = &p[..prefix];
2647            let te = &t[..prefix];
2648            let ok = if icase {
2649                pre.eq_ignore_ascii_case(te)
2650            } else {
2651                pre == te
2652            };
2653            if !ok {
2654                return false;
2655            }
2656            return wildmatch(&p[prefix..], &t[prefix..], flags);
2657        }
2658        wildmatch(p, t, flags)
2659    };
2660    if try_match(&text_real) {
2661        return true;
2662    }
2663    text_real != text_abs && try_match(&text_abs)
2664}
2665
2666fn current_branch_short_name(git_dir: Option<&Path>) -> Option<String> {
2667    let gd = git_dir?;
2668    let target = refs::read_symbolic_ref(gd, "HEAD").ok()??;
2669    let rest = target.strip_prefix("refs/heads/")?;
2670    Some(rest.to_owned())
2671}
2672
2673fn include_by_onbranch(condition: &str, ctx: &IncludeContext) -> bool {
2674    let Some(short) = current_branch_short_name(ctx.git_dir.as_deref()) else {
2675        return false;
2676    };
2677    let mut pattern = condition.to_owned();
2678    add_trailing_starstar_for_dir(&mut pattern);
2679    wildmatch(pattern.as_bytes(), short.as_bytes(), WM_PATHNAME)
2680}
2681
2682/// Evaluate an `[includeIf]` condition.
2683///
2684/// Supports `gitdir:`, `gitdir/i:`, and `onbranch:` like Git. Unknown prefixes are false.
2685fn evaluate_include_condition(condition: &str, file: &ConfigFile, ctx: &IncludeContext) -> bool {
2686    if let Some(rest) = condition.strip_prefix("gitdir/i:") {
2687        return include_by_gitdir(rest, file, ctx, true);
2688    }
2689    if let Some(rest) = condition.strip_prefix("gitdir:") {
2690        return include_by_gitdir(rest, file, ctx, false);
2691    }
2692    if let Some(rest) = condition.strip_prefix("onbranch:") {
2693        return include_by_onbranch(rest, ctx);
2694    }
2695    false
2696}
2697
2698/// Split a canonical key into (section, subsection, variable).
2699fn split_key(key: &str) -> Result<(String, Option<String>, String)> {
2700    let first_dot = key
2701        .find('.')
2702        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
2703    let last_dot = key
2704        .rfind('.')
2705        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
2706
2707    let section = key[..first_dot].to_owned();
2708    let variable = key[last_dot + 1..].to_owned();
2709
2710    let subsection = if first_dot == last_dot {
2711        None
2712    } else {
2713        Some(key[first_dot + 1..last_dot].to_owned())
2714    };
2715
2716    Ok((section, subsection, variable))
2717}
2718
2719/// Extract the variable name from a canonical key.
2720#[allow(dead_code)]
2721fn variable_name_from_key(key: &str) -> &str {
2722    match key.rfind('.') {
2723        Some(i) => &key[i + 1..],
2724        None => key,
2725    }
2726}
2727
2728/// Parse a section name that may contain a subsection (e.g. `"remote.origin"`).
2729///
2730/// Returns (section, subsection).
2731fn parse_section_name(name: &str) -> (&str, Option<&str>) {
2732    match name.find('.') {
2733        Some(i) => (&name[..i], Some(&name[i + 1..])),
2734        None => (name, None),
2735    }
2736}
2737
2738/// Extract the original-case variable name from a raw (user-typed) key.
2739///
2740/// E.g. `"Section.Movie"` → `"Movie"`, `"a.b.CamelCase"` → `"CamelCase"`.
2741fn raw_variable_name(raw_key: &str) -> &str {
2742    match raw_key.rfind('.') {
2743        Some(i) => &raw_key[i + 1..],
2744        None => raw_key,
2745    }
2746}
2747
2748/// Extract the original-case section and subsection from a raw (user-typed) key.
2749///
2750/// E.g. `"Section.key"` → `("Section", None)`,
2751///      `"Remote.origin.url"` → `("Remote", Some("origin"))`.
2752fn raw_section_parts(raw_key: &str) -> (String, Option<String>) {
2753    let first_dot = match raw_key.find('.') {
2754        Some(i) => i,
2755        None => return (raw_key.to_owned(), None),
2756    };
2757    // rfind always succeeds here since we already found at least one dot above.
2758    let last_dot = match raw_key.rfind('.') {
2759        Some(i) => i,
2760        None => return (raw_key[..first_dot].to_owned(), None),
2761    };
2762    let section = raw_key[..first_dot].to_owned();
2763    if first_dot == last_dot {
2764        (section, None)
2765    } else {
2766        let subsection = raw_key[first_dot + 1..last_dot].to_owned();
2767        (section, Some(subsection))
2768    }
2769}
2770
2771/// Check if a raw line is a section header that also contains an inline key=value.
2772fn is_section_header_with_inline_entry(line: &str) -> bool {
2773    let trimmed = line.trim();
2774    if !trimmed.starts_with('[') {
2775        return false;
2776    }
2777    let end = match trimmed.find(']') {
2778        Some(i) => i,
2779        None => return false,
2780    };
2781    let after = trimmed[end + 1..].trim();
2782    // Has non-comment content after the ]
2783    !after.is_empty() && !after.starts_with('#') && !after.starts_with(';')
2784}
2785
2786/// Extract just the section header portion (up to and including `]` and any
2787/// comment after it, but not any inline key=value) from a raw line.
2788fn extract_section_header(line: &str) -> String {
2789    let trimmed = line.trim();
2790    let end = match trimmed.find(']') {
2791        Some(i) => i,
2792        None => return line.to_owned(),
2793    };
2794    // Preserve any comment on the section header itself (between ] and key),
2795    // but git doesn't really do this. Just return up to ].
2796    trimmed[..=end].to_owned()
2797}
2798
2799#[cfg(test)]
2800mod get_regexp_tests {
2801    use super::{ConfigFile, ConfigScope, ConfigSet};
2802    use std::path::Path;
2803
2804    fn set_from_snippet(text: &str) -> ConfigSet {
2805        let path = Path::new(".git/config");
2806        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
2807        let mut set = ConfigSet::new();
2808        set.merge(&file);
2809        set
2810    }
2811
2812    #[test]
2813    fn get_regexp_matches_section_prefix_like_git_config() {
2814        let text = r#"
2815[user]
2816    email = alice@example.com
2817    name = Alice
2818[core]
2819    bare = false
2820"#;
2821        let set = set_from_snippet(text);
2822        let keys: Vec<_> = set
2823            .get_regexp("user")
2824            .expect("valid pattern")
2825            .into_iter()
2826            .map(|e| e.key.as_str())
2827            .collect();
2828        assert!(keys.contains(&"user.email"));
2829        assert!(keys.contains(&"user.name"));
2830        assert!(!keys.iter().any(|k| k.starts_with("core.")));
2831    }
2832
2833    #[test]
2834    fn get_regexp_returns_all_multi_value_entries_in_order() {
2835        let text = r#"
2836[remote "origin"]
2837    url = https://example.com/repo.git
2838    fetch = +refs/heads/*:refs/remotes/origin/*
2839    push = +refs/heads/main:refs/heads/main
2840    push = +refs/heads/develop:refs/heads/develop
2841"#;
2842        let set = set_from_snippet(text);
2843        let matches = set.get_regexp("remote.origin").expect("valid pattern");
2844        let push_vals: Vec<_> = matches
2845            .iter()
2846            .filter(|e| e.key == "remote.origin.push")
2847            .map(|e| e.value.as_deref().unwrap_or(""))
2848            .collect();
2849        assert_eq!(push_vals.len(), 2);
2850        assert_eq!(push_vals[0], "+refs/heads/main:refs/heads/main");
2851        assert_eq!(push_vals[1], "+refs/heads/develop:refs/heads/develop");
2852    }
2853
2854    #[test]
2855    fn get_regexp_dot_matches_any_key() {
2856        let text = r#"
2857[a]
2858    x = 1
2859[b]
2860    y = 2
2861"#;
2862        let set = set_from_snippet(text);
2863        let m = set.get_regexp(".").expect("valid pattern");
2864        assert_eq!(m.len(), 2);
2865    }
2866
2867    #[test]
2868    fn get_regexp_no_match_returns_empty_vec() {
2869        let set = set_from_snippet("[user]\n\tname = x\n");
2870        let m = set.get_regexp("zzz").expect("valid pattern");
2871        assert!(m.is_empty());
2872    }
2873
2874    #[test]
2875    fn get_regexp_invalid_pattern_is_error() {
2876        let set = set_from_snippet("[user]\n\tname = x\n");
2877        let err = set.get_regexp("(").expect_err("unclosed group");
2878        assert!(err.contains("invalid key pattern"), "got: {err}");
2879    }
2880}