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};
34
35/// The scope (origin) of a configuration value.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum ConfigScope {
38    /// System-wide configuration (`/etc/gitconfig`).
39    System,
40    /// Per-user global configuration (`~/.gitconfig` or XDG).
41    Global,
42    /// Repository-local configuration (`.git/config`).
43    Local,
44    /// Per-worktree configuration (`.git/config.worktree`).
45    Worktree,
46    /// Command-line overrides (`-c key=value`).
47    Command,
48}
49
50impl fmt::Display for ConfigScope {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::System => write!(f, "system"),
54            Self::Global => write!(f, "global"),
55            Self::Local => write!(f, "local"),
56            Self::Worktree => write!(f, "worktree"),
57            Self::Command => write!(f, "command"),
58        }
59    }
60}
61
62/// A single configuration entry with its origin metadata.
63#[derive(Debug, Clone)]
64pub struct ConfigEntry {
65    /// Fully-qualified key in canonical form: `section.subsection.name`
66    /// (section and name lowercased; subsection preserves case).
67    pub key: String,
68    /// The raw string value, or `None` for a boolean-true bare key.
69    pub value: Option<String>,
70    /// Which scope this entry came from.
71    pub scope: ConfigScope,
72    /// The file this entry was read from (if file-backed).
73    pub file: Option<PathBuf>,
74    /// One-based line number in the source file.
75    pub line: usize,
76}
77
78/// A parsed configuration file that preserves the raw text for round-trip
79/// editing (set/unset/rename-section/remove-section).
80#[derive(Debug, Clone)]
81pub struct ConfigFile {
82    /// The path to this config file on disk.
83    pub path: PathBuf,
84    /// The scope this file represents.
85    pub scope: ConfigScope,
86    /// Parsed entries (in file order).
87    pub entries: Vec<ConfigEntry>,
88    /// Raw lines of the file (for round-trip editing).
89    raw_lines: Vec<String>,
90}
91
92/// A merged view across all configuration scopes.
93///
94/// Entries are stored in file-order within each scope; scopes are layered
95/// in priority order (system < global < local < worktree < command).
96#[derive(Debug, Clone, Default)]
97pub struct ConfigSet {
98    /// All entries across all scopes, in load order.
99    entries: Vec<ConfigEntry>,
100}
101
102// ── Canonical key helpers ────────────────────────────────────────────
103
104/// Normalise a config key to canonical form.
105///
106/// - Section name is lowercased.
107/// - Variable name (last dot-separated component) is lowercased.
108/// - Subsection (middle components) preserves original case.
109///
110/// Returns `Err` if the key has fewer than two dot-separated parts.
111///
112/// # Examples
113///
114/// - `core.bare` → `core.bare`
115/// - `Section.SubSection.Key` → `section.SubSection.key`
116/// - `CORE.BARE` → `core.bare`
117pub fn canonical_key(raw: &str) -> Result<String> {
118    // Reject keys containing newlines
119    if raw.contains('\n') || raw.contains('\r') {
120        return Err(Error::ConfigError(format!(
121            "invalid key: '{}'",
122            raw.replace('\n', "\\n")
123        )));
124    }
125
126    let first_dot = raw
127        .find('.')
128        .ok_or_else(|| Error::ConfigError(format!("key does not contain a section: '{raw}'")))?;
129    let last_dot = raw
130        .rfind('.')
131        .ok_or_else(|| Error::ConfigError(format!("key does not contain a section: '{raw}'")))?;
132
133    if last_dot == raw.len() - 1 {
134        return Err(Error::ConfigError(format!(
135            "key does not contain variable name: '{raw}'"
136        )));
137    }
138
139    let section = &raw[..first_dot];
140    let name = &raw[last_dot + 1..];
141
142    // Validate section name: must be alphanumeric or hyphen
143    if section.is_empty() || !section.chars().all(|c| c.is_alphanumeric() || c == '-') {
144        return Err(Error::ConfigError(format!(
145            "invalid key (bad section): '{raw}'"
146        )));
147    }
148
149    // Validate variable name: must start with alpha, rest alphanumeric or hyphen
150    if name.is_empty()
151        || !name.chars().next().unwrap().is_ascii_alphabetic()
152        || !name.chars().all(|c| c.is_alphanumeric() || c == '-')
153    {
154        return Err(Error::ConfigError(format!(
155            "invalid key (bad variable name): '{raw}'"
156        )));
157    }
158
159    if first_dot == last_dot {
160        // No subsection: section.name
161        Ok(format!(
162            "{}.{}",
163            section.to_lowercase(),
164            name.to_lowercase()
165        ))
166    } else {
167        // section.subsection.name
168        let subsection = &raw[first_dot + 1..last_dot];
169        Ok(format!(
170            "{}.{}.{}",
171            section.to_lowercase(),
172            subsection,
173            name.to_lowercase()
174        ))
175    }
176}
177
178// ── Parser ──────────────────────────────────────────────────────────
179
180/// State tracked while parsing a config file line-by-line.
181struct Parser {
182    section: String,
183    subsection: Option<String>,
184}
185
186impl Parser {
187    fn new() -> Self {
188        Self {
189            section: String::new(),
190            subsection: None,
191        }
192    }
193
194    /// Build the canonical key for a variable name in the current section.
195    fn make_key(&self, name: &str) -> String {
196        let sec = self.section.to_lowercase();
197        let var = name.to_lowercase();
198        match &self.subsection {
199            Some(sub) => format!("{sec}.{sub}.{var}"),
200            None => format!("{sec}.{var}"),
201        }
202    }
203
204    /// Parse a section header line like `[section]` or `[section "subsection"]`.
205    ///
206    /// Returns `true` if the line was a section header.
207    /// If there is content after `]` (an inline key=value), it is returned
208    /// via the `inline_remainder` parameter.
209    fn try_parse_section_with_remainder<'a>(
210        &mut self,
211        line: &'a str,
212        inline_remainder: &mut Option<&'a str>,
213    ) -> bool {
214        let trimmed = line.trim();
215        if !trimmed.starts_with('[') {
216            return false;
217        }
218        // Find the closing `]` — but for subsection headers like
219        // [section "sub\"escaped"], we need to skip escaped chars
220        // inside quotes.
221        let end = {
222            let bytes = trimmed.as_bytes();
223            let mut i = 1; // skip opening '['
224            let mut in_quotes = false;
225            let mut found = None;
226            while i < bytes.len() {
227                if in_quotes {
228                    if bytes[i] == b'\\' {
229                        i += 2; // skip escaped char
230                        continue;
231                    }
232                    if bytes[i] == b'"' {
233                        in_quotes = false;
234                    }
235                } else {
236                    if bytes[i] == b'"' {
237                        in_quotes = true;
238                    }
239                    if bytes[i] == b']' {
240                        found = Some(i);
241                        break;
242                    }
243                }
244                i += 1;
245            }
246            match found {
247                Some(i) => i,
248                None => return false,
249            }
250        };
251        let inside = &trimmed[1..end];
252        // Check for subsection: [section "subsection"]
253        if let Some(quote_start) = inside.find('"') {
254            self.section = inside[..quote_start].trim().to_owned();
255            let rest = &inside[quote_start + 1..];
256            // Find unescaped closing quote
257            let mut sub = String::new();
258            let mut chars = rest.chars();
259            while let Some(ch) = chars.next() {
260                if ch == '\\' {
261                    if let Some(escaped) = chars.next() {
262                        sub.push(escaped);
263                    }
264                } else if ch == '"' {
265                    break;
266                } else {
267                    sub.push(ch);
268                }
269            }
270            self.subsection = Some(sub);
271        } else {
272            self.section = inside.trim().to_owned();
273            self.subsection = None;
274        }
275        // Check for inline content after the closing `]`
276        let after = trimmed[end + 1..].trim();
277        if !after.is_empty() && !after.starts_with('#') && !after.starts_with(';') {
278            *inline_remainder = Some(after);
279        } else {
280            *inline_remainder = None;
281        }
282        true
283    }
284
285    /// Parse a section header line (without inline remainder tracking).
286    fn try_parse_section(&mut self, line: &str) -> bool {
287        let mut _remainder = None;
288        self.try_parse_section_with_remainder(line, &mut _remainder)
289    }
290
291    /// Parse a `key = value` or bare `key` line.
292    ///
293    /// Returns `Some((canonical_key, value))` if this is a variable line.
294    fn try_parse_entry(&self, line: &str) -> Option<(String, Option<String>)> {
295        let trimmed = line.trim();
296        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
297            return None;
298        }
299        if trimmed.starts_with('[') {
300            return None;
301        }
302        if self.section.is_empty() {
303            return None;
304        }
305
306        if let Some(eq_pos) = trimmed.find('=') {
307            let raw_name = trimmed[..eq_pos].trim();
308            let raw_value = trimmed[eq_pos + 1..].trim();
309            // Strip inline comment (not inside quotes)
310            let value = strip_inline_comment(raw_value);
311            let value = unescape_value(&value);
312            let key = self.make_key(raw_name);
313            Some((key, Some(value)))
314        } else {
315            // Bare key (boolean true)
316            let raw_name = strip_inline_comment(trimmed);
317            let key = self.make_key(raw_name.trim());
318            Some((key, None))
319        }
320    }
321}
322
323/// Check if a value line ends with a continuation backslash.
324///
325/// This checks the value portion (after `=`) for a trailing `\` that is
326/// outside quotes and outside an inline comment. If the `\` is after
327/// a `#` or `;` that starts a comment, it does NOT count as continuation.
328fn value_line_continues(line: &str) -> bool {
329    let trimmed = line.trim();
330    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
331        return false;
332    }
333    // Find the value portion (after '=')
334    // If no '=', this is a bare key — no continuation
335    let value_part = match trimmed.find('=') {
336        Some(pos) => &trimmed[pos + 1..],
337        None => return false,
338    };
339    // Walk the value portion tracking quotes and comments
340    let mut in_quote = false;
341    let mut last_was_backslash = false;
342    let mut in_comment = false;
343    for ch in value_part.chars() {
344        if in_comment {
345            // Inside comment, backslash doesn't matter
346            last_was_backslash = false;
347            continue;
348        }
349        match ch {
350            '"' if !last_was_backslash => {
351                in_quote = !in_quote;
352                last_was_backslash = false;
353            }
354            '\\' if !last_was_backslash => {
355                last_was_backslash = true;
356                continue;
357            }
358            '#' | ';' if !in_quote && !last_was_backslash => {
359                in_comment = true;
360                last_was_backslash = false;
361            }
362            _ => {
363                last_was_backslash = false;
364            }
365        }
366    }
367    // The line continues if it ends with an unescaped backslash outside comments
368    last_was_backslash && !in_comment
369}
370
371/// Strip an inline comment (`#` or `;`) that is not inside quotes.
372fn strip_inline_comment(s: &str) -> String {
373    let mut in_quote = false;
374    let mut result = String::with_capacity(s.len());
375    let mut chars = s.chars().peekable();
376    while let Some(ch) = chars.next() {
377        match ch {
378            '"' => {
379                in_quote = !in_quote;
380                result.push(ch);
381            }
382            '\\' if in_quote => {
383                result.push(ch);
384                if let Some(&next) = chars.peek() {
385                    result.push(next);
386                    chars.next();
387                }
388            }
389            '#' | ';' if !in_quote => break,
390            _ => result.push(ch),
391        }
392    }
393    // Trim trailing whitespace that was before the comment
394    let trimmed = result.trim_end();
395    trimmed.to_owned()
396}
397
398/// Unescape a config value: handle `\"`, `\\`, `\n`, `\t`, and strip
399/// surrounding quotes.
400fn unescape_value(s: &str) -> String {
401    let mut result = String::with_capacity(s.len());
402    let mut chars = s.chars();
403    while let Some(ch) = chars.next() {
404        match ch {
405            '"' => { /* strip quotes */ }
406            '\\' => match chars.next() {
407                Some('n') => result.push('\n'),
408                Some('t') => result.push('\t'),
409                Some('\\') => result.push('\\'),
410                Some('"') => result.push('"'),
411                Some(other) => {
412                    result.push('\\');
413                    result.push(other);
414                }
415                None => result.push('\\'),
416            },
417            _ => result.push(ch),
418        }
419    }
420    result
421}
422
423/// Escape a config value for writing back to a file.
424///
425/// Wraps in double quotes if the value contains leading/trailing whitespace,
426/// internal quotes, backslashes, or special characters.
427/// Escape a subsection name for writing in a config section header.
428/// In subsection names, `"` and `\` must be escaped.
429fn escape_subsection(s: &str) -> String {
430    let mut out = String::with_capacity(s.len());
431    for ch in s.chars() {
432        match ch {
433            '"' => out.push_str("\\\""),
434            '\\' => out.push_str("\\\\"),
435            other => out.push(other),
436        }
437    }
438    out
439}
440
441fn escape_value(s: &str) -> String {
442    let needs_quoting = s.starts_with(' ')
443        || s.starts_with('\t')
444        || s.ends_with(' ')
445        || s.ends_with('\t')
446        || s.contains('"')
447        || s.contains('\\')
448        || s.contains('\n')
449        || s.contains('#')
450        || s.contains(';');
451
452    if !needs_quoting {
453        return s.to_owned();
454    }
455
456    let mut out = String::with_capacity(s.len() + 4);
457    out.push('"');
458    for ch in s.chars() {
459        match ch {
460            '"' => out.push_str("\\\""),
461            '\\' => out.push_str("\\\\"),
462            '\n' => out.push_str("\\n"),
463            '\t' => out.push_str("\\t"),
464            other => out.push(other),
465        }
466    }
467    out.push('"');
468    out
469}
470
471/// Format a comment suffix for appending to a config value line.
472///
473/// Git's `--comment` flag normalises the comment:
474/// - If the comment already starts with `#` (possibly preceded by whitespace/tab),
475///   it is used as-is.
476/// - Otherwise, ` # ` is prepended.
477fn format_comment_suffix(comment: Option<&str>) -> String {
478    match comment {
479        None => String::new(),
480        Some(c) => {
481            if c.starts_with(' ') || c.starts_with('\t') {
482                // Comment has its own leading whitespace separator
483                c.to_owned()
484            } else if c.starts_with('#') {
485                // Comment starts with #, just prepend a space separator
486                format!(" {c}")
487            } else {
488                // Plain text comment, prepend " # "
489                format!(" # {c}")
490            }
491        }
492    }
493}
494
495impl ConfigFile {
496    /// Parse a config file from its raw text content.
497    ///
498    /// # Parameters
499    ///
500    /// - `path` — the file path (stored for diagnostics and round-trip writes).
501    /// - `content` — the raw text of the file.
502    /// - `scope` — the [`ConfigScope`] this file represents.
503    ///
504    /// # Errors
505    ///
506    /// Returns [`Error::ConfigError`] on malformed input.
507    pub fn parse(path: &Path, content: &str, scope: ConfigScope) -> Result<Self> {
508        let raw_lines: Vec<String> = content
509            .lines()
510            .map(|l| l.strip_suffix('\r').unwrap_or(l))
511            .map(String::from)
512            .collect();
513        let mut entries = Vec::new();
514        let mut parser = Parser::new();
515
516        let mut idx = 0;
517        while idx < raw_lines.len() {
518            let start_idx = idx;
519            let line = &raw_lines[idx];
520            idx += 1;
521
522            // Pure comment lines don't continue even with trailing \
523            let trimmed = line.trim();
524            if trimmed.starts_with('#') || trimmed.starts_with(';') {
525                continue;
526            }
527
528            let mut inline_remainder = None;
529            if parser.try_parse_section_with_remainder(line, &mut inline_remainder) {
530                // Check if there's an inline key=value after the section header
531                if let Some(remainder) = inline_remainder {
532                    if let Some((key, value)) = parser.try_parse_entry(remainder) {
533                        entries.push(ConfigEntry {
534                            key,
535                            value,
536                            scope,
537                            file: Some(path.to_path_buf()),
538                            line: start_idx + 1,
539                        });
540                    }
541                }
542                continue;
543            }
544
545            // For entry lines, we need to check continuation.
546            // Build a logical line by joining continuations.
547            let mut logical_line = line.clone();
548            while value_line_continues(&logical_line) && idx < raw_lines.len() {
549                // Remove the trailing backslash
550                let t = logical_line.trim_end();
551                logical_line = t[..t.len() - 1].to_string();
552                // Append next line (trimmed of leading whitespace)
553                let next = raw_lines[idx].trim_start();
554                logical_line.push_str(next);
555                idx += 1;
556            }
557
558            if let Some((key, value)) = parser.try_parse_entry(&logical_line) {
559                entries.push(ConfigEntry {
560                    key,
561                    value,
562                    scope,
563                    file: Some(path.to_path_buf()),
564                    line: start_idx + 1,
565                });
566            }
567        }
568
569        Ok(Self {
570            path: path.to_path_buf(),
571            scope,
572            entries,
573            raw_lines,
574        })
575    }
576
577    /// Read and parse a config file from disk.
578    ///
579    /// Returns `Ok(None)` if the file does not exist.
580    ///
581    /// # Errors
582    ///
583    /// Returns [`Error::Io`] on read failure (other than not-found) or
584    /// [`Error::ConfigError`] on parse failure.
585    pub fn from_path(path: &Path, scope: ConfigScope) -> Result<Option<Self>> {
586        match fs::read_to_string(path) {
587            Ok(content) => Ok(Some(Self::parse(path, &content, scope)?)),
588            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
589            Err(e) => Err(Error::Io(e)),
590        }
591    }
592
593    /// Set a value in this config file, creating the section if needed.
594    ///
595    /// If the key already exists, its last occurrence is updated in-place.
596    /// Otherwise a new entry is appended (creating the section header if
597    /// necessary).
598    ///
599    /// # Parameters
600    ///
601    /// - `key` — canonical key (e.g. `core.bare`).
602    /// - `value` — the value to set.
603    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
604        self.set_with_comment(key, value, None)
605    }
606
607    /// Set a value in this config file, optionally appending an inline comment.
608    pub fn set_with_comment(
609        &mut self,
610        key: &str,
611        value: &str,
612        comment: Option<&str>,
613    ) -> Result<()> {
614        let canon = canonical_key(key)?;
615        let raw_var = raw_variable_name(key);
616        let comment_suffix = format_comment_suffix(comment);
617
618        // Find the last entry with this key to replace in-place.
619        let existing_idx = self.entries.iter().rposition(|e| e.key == canon);
620
621        if let Some(idx) = existing_idx {
622            let line_idx = self.entries[idx].line - 1;
623            let raw_line = &self.raw_lines[line_idx];
624            if is_section_header_with_inline_entry(raw_line) {
625                // Entry is on the same line as a section header — split it
626                let header_only = extract_section_header(raw_line);
627                self.raw_lines[line_idx] = header_only;
628                let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
629                self.raw_lines.insert(line_idx + 1, new_line);
630                // Re-parse to fix up entries and line numbers
631                let content = self.raw_lines.join("\n");
632                let reparsed = Self::parse(&self.path, &content, self.scope)?;
633                self.entries = reparsed.entries;
634                self.raw_lines = reparsed.raw_lines;
635            } else {
636                self.raw_lines[line_idx] =
637                    format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
638                self.entries[idx].value = Some(value.to_owned());
639            }
640        } else {
641            // Need to add: find or create the section
642            let (section, subsection, _var) = split_key(&canon)?;
643            let (raw_sec, raw_sub) = raw_section_parts(key);
644            let section_line = self.find_or_create_section_preserving_case(
645                &section,
646                subsection.as_deref(),
647                &raw_sec,
648                raw_sub.as_deref(),
649            );
650            let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
651
652            // Insert after the section header (or last entry in section)
653            let insert_at = self.last_line_in_section(section_line) + 1;
654            self.raw_lines.insert(insert_at, new_line);
655
656            // Re-parse to fix up line numbers
657            let content = self.raw_lines.join("\n");
658            let reparsed = Self::parse(&self.path, &content, self.scope)?;
659            self.entries = reparsed.entries;
660            self.raw_lines = reparsed.raw_lines;
661        }
662
663        Ok(())
664    }
665
666    /// Replace ALL occurrences of a key with a new value.
667    ///
668    /// Removes all but the last occurrence from the file, then updates
669    /// the last occurrence with the new value (matching Git behaviour).
670    pub fn replace_all(
671        &mut self,
672        key: &str,
673        value: &str,
674        value_pattern: Option<&str>,
675    ) -> Result<()> {
676        self.replace_all_with_comment(key, value, value_pattern, None)
677    }
678
679    /// Replace all occurrences, optionally appending an inline comment.
680    ///
681    /// Value patterns starting with `!` are treated as negated regex
682    /// (matching values that do NOT match the pattern).
683    pub fn replace_all_with_comment(
684        &mut self,
685        key: &str,
686        value: &str,
687        value_pattern: Option<&str>,
688        comment: Option<&str>,
689    ) -> Result<()> {
690        let canon = canonical_key(key)?;
691        let comment_suffix = format_comment_suffix(comment);
692
693        // Parse optional regex pattern, handling `!` negation
694        let (re, negated) = match value_pattern {
695            Some(pat) => {
696                let (neg, actual_pat) = if let Some(rest) = pat.strip_prefix('!') {
697                    (true, rest)
698                } else {
699                    (false, pat)
700                };
701                let compiled = regex::Regex::new(actual_pat)
702                    .map_err(|e| Error::ConfigError(format!("invalid value-pattern regex: {e}")))?;
703                (Some(compiled), neg)
704            }
705            None => (None, false),
706        };
707
708        // Find all matching entries (by key, and optionally by value pattern)
709        let matching_indices: Vec<usize> = self
710            .entries
711            .iter()
712            .enumerate()
713            .filter(|(_, e)| {
714                if e.key != canon {
715                    return false;
716                }
717                if let Some(ref re) = re {
718                    let v = e.value.as_deref().unwrap_or("");
719                    let matched = re.is_match(v);
720                    if negated {
721                        !matched
722                    } else {
723                        matched
724                    }
725                } else {
726                    true
727                }
728            })
729            .map(|(i, _)| i)
730            .collect();
731
732        if matching_indices.is_empty() {
733            // No matching entries — add a new one at the end of the section
734            return self.add_value_with_comment(key, value, comment);
735        }
736
737        let raw_var = raw_variable_name(key);
738
739        if matching_indices.len() == 1 {
740            // Single match: update in-place (preserves position)
741            let match_idx = matching_indices[0];
742            let line_idx = self.entries[match_idx].line - 1;
743            let raw_line = &self.raw_lines[line_idx];
744            if is_section_header_with_inline_entry(raw_line) {
745                let header = extract_section_header(raw_line);
746                self.raw_lines[line_idx] = header;
747                let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
748                self.raw_lines.insert(line_idx + 1, new_line);
749            } else {
750                self.raw_lines[line_idx] =
751                    format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
752            }
753        } else {
754            // Multiple matches: remove ALL, then add one new entry at end of section
755            for &idx in matching_indices.iter().rev() {
756                let line_idx = self.entries[idx].line - 1;
757                self.remove_entry_line(line_idx);
758            }
759
760            // Re-parse after removals
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
766            // Add the new entry at the end of the section
767            let (section, subsection, _var) = split_key(&canon)?;
768            let (raw_sec, raw_sub) = raw_section_parts(key);
769            let section_line = self.find_or_create_section_preserving_case(
770                &section,
771                subsection.as_deref(),
772                &raw_sec,
773                raw_sub.as_deref(),
774            );
775            let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
776            let insert_at = self.last_line_in_section(section_line) + 1;
777            self.raw_lines.insert(insert_at, new_line);
778        }
779
780        // Re-parse
781        let content = self.raw_lines.join("\n");
782        let reparsed = Self::parse(&self.path, &content, self.scope)?;
783        self.entries = reparsed.entries;
784        self.raw_lines = reparsed.raw_lines;
785
786        Ok(())
787    }
788
789    /// Count how many entries exist for a key.
790    pub fn count(&self, key: &str) -> Result<usize> {
791        let canon = canonical_key(key)?;
792        Ok(self.entries.iter().filter(|e| e.key == canon).count())
793    }
794
795    /// Remove an entry at the given raw line index.
796    ///
797    /// If the line is a section header with an inline entry, only the inline
798    /// portion is removed (the header is kept). Otherwise the entire line is
799    /// removed. Also removes continuation lines following the entry.
800    /// Remove an entry at the given raw line index.
801    ///
802    /// If the line is a section header with an inline entry, only the inline
803    /// portion is removed (the header is kept). Otherwise the entire line
804    /// (and any continuation lines) is removed.
805    fn remove_entry_line(&mut self, line_idx: usize) {
806        if is_section_header_with_inline_entry(&self.raw_lines[line_idx]) {
807            // Keep the section header, strip the inline entry
808            let header = extract_section_header(&self.raw_lines[line_idx]);
809            self.raw_lines[line_idx] = header;
810        } else {
811            // Check if this line has continuation lines and remove them too
812            let mut lines_to_remove = 1;
813            let mut check_line = self.raw_lines[line_idx].clone();
814            while value_line_continues(&check_line)
815                && (line_idx + lines_to_remove) < self.raw_lines.len()
816            {
817                check_line = self.raw_lines[line_idx + lines_to_remove].clone();
818                lines_to_remove += 1;
819            }
820            for _ in 0..lines_to_remove {
821                self.raw_lines.remove(line_idx);
822            }
823        }
824    }
825
826    /// Unset (remove) only the last occurrence of a key.
827    ///
828    /// Returns the number of entries removed (0 or 1).
829    pub fn unset_last(&mut self, key: &str) -> Result<usize> {
830        let canon = canonical_key(key)?;
831        let last_idx = self.entries.iter().rposition(|e| e.key == canon);
832
833        if let Some(idx) = last_idx {
834            let line_idx = self.entries[idx].line - 1;
835            self.remove_entry_line(line_idx);
836            let content = self.raw_lines.join("\n");
837            let reparsed = Self::parse(&self.path, &content, self.scope)?;
838            self.entries = reparsed.entries;
839            self.raw_lines = reparsed.raw_lines;
840            Ok(1)
841        } else {
842            Ok(0)
843        }
844    }
845
846    /// Unset (remove) all occurrences of a key.
847    ///
848    /// # Parameters
849    ///
850    /// - `key` — canonical key (e.g. `core.bare`).
851    ///
852    /// # Returns
853    ///
854    /// The number of entries removed.
855    pub fn unset(&mut self, key: &str) -> Result<usize> {
856        let canon = canonical_key(key)?;
857        let line_indices: Vec<usize> = self
858            .entries
859            .iter()
860            .filter(|e| e.key == canon)
861            .map(|e| e.line - 1)
862            .collect();
863
864        let count = line_indices.len();
865        // Remove from bottom to top to keep indices valid
866        for &idx in line_indices.iter().rev() {
867            self.remove_entry_line(idx);
868        }
869
870        if count > 0 {
871            let content = self.raw_lines.join("\n");
872            let reparsed = Self::parse(&self.path, &content, self.scope)?;
873            self.entries = reparsed.entries;
874            self.raw_lines = reparsed.raw_lines;
875        }
876
877        Ok(count)
878    }
879
880    /// Unset entries matching a key and optional value-pattern regex.
881    ///
882    /// If `value_pattern` is `None`, removes all entries with the given key.
883    /// If `value_pattern` is `Some(pat)`, only removes entries whose value matches the regex.
884    pub fn unset_matching(&mut self, key: &str, value_pattern: Option<&str>) -> Result<usize> {
885        let canon = canonical_key(key)?;
886        let re = match value_pattern {
887            Some(pat) => Some(
888                regex::Regex::new(pat)
889                    .map_err(|e| Error::ConfigError(format!("invalid value-pattern regex: {e}")))?,
890            ),
891            None => None,
892        };
893
894        let line_indices: Vec<usize> = self
895            .entries
896            .iter()
897            .filter(|e| {
898                if e.key != canon {
899                    return false;
900                }
901                if let Some(ref re) = re {
902                    let v = e.value.as_deref().unwrap_or("");
903                    re.is_match(v)
904                } else {
905                    true
906                }
907            })
908            .map(|e| e.line - 1)
909            .collect();
910
911        let count = line_indices.len();
912        for &idx in line_indices.iter().rev() {
913            self.remove_entry_line(idx);
914        }
915
916        if count > 0 {
917            // Remove empty section headers (sections with no remaining entries and no comments)
918            self.remove_empty_section_headers();
919
920            let content = self.raw_lines.join("\n");
921            let reparsed = Self::parse(&self.path, &content, self.scope)?;
922            self.entries = reparsed.entries;
923            self.raw_lines = reparsed.raw_lines;
924        }
925
926        Ok(count)
927    }
928
929    /// Remove an entire section (and all its entries).
930    ///
931    /// # Parameters
932    ///
933    /// - `section` — section name (e.g. `"core"`, `"remote.origin"`).
934    pub fn remove_section(&mut self, section: &str) -> Result<bool> {
935        let (sec_name, sub_name) = parse_section_name(section);
936        let sec_lower = sec_name.to_lowercase();
937
938        // Find section header line and all lines that belong to it
939        let mut start = None;
940        let mut end = 0;
941        let mut parser = Parser::new();
942
943        for (idx, line) in self.raw_lines.iter().enumerate() {
944            if parser.try_parse_section(line) {
945                if parser.section.to_lowercase() == sec_lower
946                    && parser.subsection.as_deref() == sub_name
947                {
948                    start = Some(idx);
949                    end = idx;
950                } else if start.is_some() {
951                    break;
952                }
953            } else if start.is_some() {
954                end = idx;
955            }
956        }
957
958        if let Some(s) = start {
959            self.raw_lines.drain(s..=end);
960            let content = self.raw_lines.join("\n");
961            let reparsed = Self::parse(&self.path, &content, self.scope)?;
962            self.entries = reparsed.entries;
963            self.raw_lines = reparsed.raw_lines;
964            Ok(true)
965        } else {
966            Ok(false)
967        }
968    }
969
970    /// Rename a section.
971    ///
972    /// # Parameters
973    ///
974    /// - `old_name` — current section name (e.g. `"branch.main"`).
975    /// - `new_name` — new section name (e.g. `"branch.develop"`).
976    pub fn rename_section(&mut self, old_name: &str, new_name: &str) -> Result<bool> {
977        let (old_sec, old_sub) = parse_section_name(old_name);
978        let (new_sec, new_sub) = parse_section_name(new_name);
979        let old_lower = old_sec.to_lowercase();
980
981        let mut found = false;
982        let mut parser = Parser::new();
983
984        for idx in 0..self.raw_lines.len() {
985            let line = &self.raw_lines[idx];
986            if parser.try_parse_section(line)
987                && parser.section.to_lowercase() == old_lower
988                && parser.subsection.as_deref() == old_sub
989            {
990                // Rewrite the section header
991                let header = match new_sub {
992                    Some(sub) => format!("[{} \"{}\"]", new_sec, sub),
993                    None => format!("[{}]", new_sec),
994                };
995                self.raw_lines[idx] = header;
996                found = true;
997            }
998        }
999
1000        if found {
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(found)
1008    }
1009
1010    /// Append a new value for a key without removing existing entries.
1011    ///
1012    /// This is the behaviour of `git config --add section.key value`.
1013    /// If the section doesn't exist, it is created.
1014    pub fn add_value(&mut self, key: &str, value: &str) -> Result<()> {
1015        self.add_value_with_comment(key, value, None)
1016    }
1017
1018    /// Append a new value with an optional inline comment.
1019    pub fn add_value_with_comment(
1020        &mut self,
1021        key: &str,
1022        value: &str,
1023        comment: Option<&str>,
1024    ) -> Result<()> {
1025        let canon = canonical_key(key)?;
1026        let raw_var = raw_variable_name(key);
1027        let comment_suffix = format_comment_suffix(comment);
1028        let (section, subsection, _var) = split_key(&canon)?;
1029        let (raw_sec, raw_sub) = raw_section_parts(key);
1030
1031        let section_line = self.find_or_create_section_preserving_case(
1032            &section,
1033            subsection.as_deref(),
1034            &raw_sec,
1035            raw_sub.as_deref(),
1036        );
1037        let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1038        let insert_at = self.last_line_in_section(section_line) + 1;
1039        self.raw_lines.insert(insert_at, new_line);
1040
1041        // Re-parse to fix up entries and line numbers
1042        let content = self.raw_lines.join("\n");
1043        let reparsed = Self::parse(&self.path, &content, self.scope)?;
1044        self.entries = reparsed.entries;
1045        self.raw_lines = reparsed.raw_lines;
1046
1047        Ok(())
1048    }
1049
1050    /// Write the (possibly modified) config back to disk.
1051    /// Remove section headers that have no remaining entries or comments.
1052    fn remove_empty_section_headers(&mut self) {
1053        let section_re = regex::Regex::new(r"^\s*\[").unwrap();
1054        let comment_re = regex::Regex::new(r"^\s*(#|;)").unwrap();
1055
1056        let mut to_remove: Vec<usize> = Vec::new();
1057        let len = self.raw_lines.len();
1058
1059        for i in 0..len {
1060            let line = &self.raw_lines[i];
1061            if !section_re.is_match(line) {
1062                continue;
1063            }
1064            // Don't remove section headers that have inline key=value entries
1065            if is_section_header_with_inline_entry(line) {
1066                continue;
1067            }
1068            // Check if this section header is followed only by blank lines,
1069            // comments, or another section header (or end of file).
1070            let mut has_entries = false;
1071            for j in (i + 1)..len {
1072                let next = self.raw_lines[j].trim();
1073                if next.is_empty() {
1074                    continue;
1075                }
1076                if section_re.is_match(&self.raw_lines[j]) {
1077                    break;
1078                }
1079                if comment_re.is_match(&self.raw_lines[j]) {
1080                    // Has comments — keep the section
1081                    has_entries = true;
1082                    break;
1083                }
1084                // Has a key-value entry
1085                has_entries = true;
1086                break;
1087            }
1088            if !has_entries {
1089                to_remove.push(i);
1090            }
1091        }
1092
1093        // Remove in reverse to preserve indices
1094        for &idx in to_remove.iter().rev() {
1095            self.raw_lines.remove(idx);
1096        }
1097
1098        // Also remove trailing blank lines
1099        while self.raw_lines.last().is_some_and(|l| l.trim().is_empty()) {
1100            self.raw_lines.pop();
1101        }
1102    }
1103
1104    ///
1105    /// # Errors
1106    ///
1107    /// Returns [`Error::Io`] on write failure.
1108    pub fn write(&self) -> Result<()> {
1109        let content = self.raw_lines.join("\n");
1110        let trimmed = content.trim();
1111        if trimmed.is_empty() {
1112            // Write empty file if no content
1113            fs::write(&self.path, "")?;
1114        } else {
1115            // Ensure trailing newline
1116            let content = if content.ends_with('\n') {
1117                content
1118            } else {
1119                format!("{content}\n")
1120            };
1121            fs::write(&self.path, content)?;
1122        }
1123        Ok(())
1124    }
1125
1126    /// Find the line index of a section header, or create one.
1127    #[allow(dead_code)]
1128    fn find_or_create_section(&mut self, section: &str, subsection: Option<&str>) -> usize {
1129        let sec_lower = section.to_lowercase();
1130        let mut parser = Parser::new();
1131
1132        for (idx, line) in self.raw_lines.iter().enumerate() {
1133            if parser.try_parse_section(line)
1134                && parser.section.to_lowercase() == sec_lower
1135                && parser.subsection.as_deref() == subsection
1136            {
1137                return idx;
1138            }
1139        }
1140
1141        // Create new section at end of file
1142        let header = match subsection {
1143            Some(sub) => {
1144                let escaped = escape_subsection(sub);
1145                format!("[{} \"{}\"]", section, escaped)
1146            }
1147            None => format!("[{}]", section),
1148        };
1149        self.raw_lines.push(header);
1150        self.raw_lines.len() - 1
1151    }
1152
1153    /// Find the line index of a section header (case-insensitive match),
1154    /// or create one using the original-case names from user input.
1155    fn find_or_create_section_preserving_case(
1156        &mut self,
1157        section: &str,
1158        subsection: Option<&str>,
1159        raw_section: &str,
1160        raw_subsection: Option<&str>,
1161    ) -> usize {
1162        let sec_lower = section.to_lowercase();
1163        let mut parser = Parser::new();
1164
1165        for (idx, line) in self.raw_lines.iter().enumerate() {
1166            if parser.try_parse_section(line)
1167                && parser.section.to_lowercase() == sec_lower
1168                && parser.subsection.as_deref() == subsection
1169            {
1170                return idx;
1171            }
1172        }
1173
1174        // Create new section at end of file, using original case
1175        let header = match raw_subsection {
1176            Some(sub) => {
1177                let escaped = escape_subsection(sub);
1178                format!("[{} \"{}\"]", raw_section, escaped)
1179            }
1180            None => format!("[{}]", raw_section),
1181        };
1182        self.raw_lines.push(header);
1183        self.raw_lines.len() - 1
1184    }
1185
1186    /// Find the last line that belongs to the section starting at `section_line`.
1187    fn last_line_in_section(&self, section_line: usize) -> usize {
1188        let mut last = section_line;
1189        for idx in (section_line + 1)..self.raw_lines.len() {
1190            let trimmed = self.raw_lines[idx].trim();
1191            if trimmed.starts_with('[') {
1192                break;
1193            }
1194            last = idx;
1195        }
1196        last
1197    }
1198}
1199
1200// ── ConfigSet ───────────────────────────────────────────────────────
1201
1202impl ConfigSet {
1203    /// Create an empty config set.
1204    #[must_use]
1205    pub fn new() -> Self {
1206        Self {
1207            entries: Vec::new(),
1208        }
1209    }
1210
1211    /// All merged entries in load order (for listing keys such as `alias.*`).
1212    #[must_use]
1213    pub fn entries(&self) -> &[ConfigEntry] {
1214        &self.entries
1215    }
1216
1217    /// Merge entries from a [`ConfigFile`] into this set.
1218    ///
1219    /// Entries are appended; later values override earlier ones for
1220    /// single-value lookups.
1221    pub fn merge(&mut self, file: &ConfigFile) {
1222        self.entries.extend(file.entries.iter().cloned());
1223    }
1224
1225    /// Add a command-line override (`-c key=value`).
1226    pub fn add_command_override(&mut self, key: &str, value: &str) -> Result<()> {
1227        let canon = canonical_key(key)?;
1228        self.entries.push(ConfigEntry {
1229            key: canon,
1230            value: Some(value.to_owned()),
1231            scope: ConfigScope::Command,
1232            file: None,
1233            line: 0,
1234        });
1235        Ok(())
1236    }
1237
1238    /// Get the last (highest-priority) value for a key.
1239    ///
1240    /// # Parameters
1241    ///
1242    /// - `key` — the key to look up (will be canonicalized).
1243    ///
1244    /// # Returns
1245    ///
1246    /// `Some(value)` for the last matching entry, or `None` if not found.
1247    /// Bare boolean keys return `Some("true")`.
1248    #[must_use]
1249    pub fn get(&self, key: &str) -> Option<String> {
1250        let canon = canonical_key(key).ok()?;
1251        self.entries
1252            .iter()
1253            .rev()
1254            .find(|e| e.key == canon)
1255            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
1256    }
1257
1258    /// Get all values for a key (multi-valued; in load order).
1259    #[must_use]
1260    pub fn get_all(&self, key: &str) -> Vec<String> {
1261        let canon = match canonical_key(key) {
1262            Ok(c) => c,
1263            Err(_) => return Vec::new(),
1264        };
1265        self.entries
1266            .iter()
1267            .filter(|e| e.key == canon)
1268            .map(|e| e.value.clone().unwrap_or_default())
1269            .collect()
1270    }
1271
1272    /// Get a boolean value, interpreting `true`/`yes`/`on`/`1` as true and
1273    /// `false`/`no`/`off`/`0` as false.
1274    pub fn get_bool(&self, key: &str) -> Option<std::result::Result<bool, String>> {
1275        self.get(key).map(|v| parse_bool(&v))
1276    }
1277
1278    /// Get an integer value, supporting Git's `k`/`m`/`g` suffixes.
1279    pub fn get_i64(&self, key: &str) -> Option<std::result::Result<i64, String>> {
1280        self.get(key).map(|v| parse_i64(&v))
1281    }
1282
1283    /// Get all entries matching a key pattern (regex).
1284    ///
1285    /// Used by `git config --get-regexp`. Returns an error if the pattern
1286    /// is not a valid regex.
1287    pub fn get_regexp(&self, pattern: &str) -> std::result::Result<Vec<&ConfigEntry>, String> {
1288        let re = regex::Regex::new(pattern).map_err(|e| format!("invalid key pattern: {e}"))?;
1289        Ok(self
1290            .entries
1291            .iter()
1292            .filter(|e| re.is_match(&e.key))
1293            .collect())
1294    }
1295
1296    /// Load the standard Git configuration file cascade for a repository.
1297    ///
1298    /// # Parameters
1299    ///
1300    /// - `git_dir` — path to the `.git` directory (for local/worktree config).
1301    /// - `include_system` — whether to load system config.
1302    ///
1303    /// # Errors
1304    ///
1305    /// Returns errors from file I/O or parsing.
1306    pub fn load(git_dir: Option<&Path>, include_system: bool) -> Result<Self> {
1307        let mut set = Self::new();
1308
1309        // System config
1310        if include_system && std::env::var("GIT_CONFIG_NOSYSTEM").is_err() {
1311            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
1312                .map(std::path::PathBuf::from)
1313                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
1314            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
1315                Self::merge_with_includes(&mut set, &f, true, 0)?;
1316            }
1317        }
1318
1319        // Global config
1320        for path in global_config_paths() {
1321            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
1322                Self::merge_with_includes(&mut set, &f, true, 0)?;
1323                break; // Only use the first found
1324            }
1325        }
1326
1327        // Local config
1328        if let Some(gd) = git_dir {
1329            let local_path = gd.join("config");
1330            if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
1331                Self::merge_with_includes(&mut set, &f, true, 0)?;
1332            }
1333
1334            // Worktree config
1335            let wt_path = gd.join("config.worktree");
1336            if let Ok(Some(f)) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
1337                Self::merge_with_includes(&mut set, &f, true, 0)?;
1338            }
1339        }
1340
1341        // Environment overrides
1342        if let Ok(path) = std::env::var("GIT_CONFIG") {
1343            if let Ok(Some(f)) = ConfigFile::from_path(Path::new(&path), ConfigScope::Command) {
1344                set.merge(&f);
1345            }
1346        }
1347
1348        // GIT_CONFIG_COUNT / GIT_CONFIG_KEY_N / GIT_CONFIG_VALUE_N
1349        if let Ok(count_str) = std::env::var("GIT_CONFIG_COUNT") {
1350            if let Ok(count) = count_str.parse::<usize>() {
1351                for i in 0..count {
1352                    let key_var = format!("GIT_CONFIG_KEY_{i}");
1353                    let val_var = format!("GIT_CONFIG_VALUE_{i}");
1354                    if let (Ok(key), Ok(val)) = (std::env::var(&key_var), std::env::var(&val_var)) {
1355                        let _ = set.add_command_override(&key, &val);
1356                    }
1357                }
1358            }
1359        }
1360
1361        // GIT_CONFIG_PARAMETERS — single-quoted 'key=value' entries separated by spaces.
1362        // This is the format used by `git -c key=value`.
1363        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
1364            for entry in parse_config_parameters(&params) {
1365                if let Some((key, val)) = entry.split_once('=') {
1366                    let _ = set.add_command_override(key.trim(), val);
1367                } else {
1368                    // Bare key (boolean true)
1369                    let _ = set.add_command_override(entry.trim(), "true");
1370                }
1371            }
1372        }
1373
1374        Ok(set)
1375    }
1376
1377    /// Merge a file, processing `[include]` and `[includeIf]` directives.
1378    fn merge_with_includes(
1379        set: &mut Self,
1380        file: &ConfigFile,
1381        process_includes: bool,
1382        depth: usize,
1383    ) -> Result<()> {
1384        // Mirror Git behavior and stop runaway include recursion.
1385        // t0017 expects the diagnostic to contain this exact phrase.
1386        const MAX_INCLUDE_DEPTH: usize = 10;
1387        if depth > MAX_INCLUDE_DEPTH {
1388            return Err(Error::ConfigError(
1389                "exceeded maximum include depth".to_owned(),
1390            ));
1391        }
1392        // First pass: find include paths
1393        let mut includes: Vec<(String, Option<String>)> = Vec::new();
1394
1395        for entry in &file.entries {
1396            if entry.key == "include.path" {
1397                if let Some(ref val) = entry.value {
1398                    includes.push((val.clone(), None));
1399                }
1400            } else if entry.key.starts_with("includeif.") && entry.key.ends_with(".path") {
1401                // Extract condition from key: includeif.<condition>.path
1402                let mid = &entry.key["includeif.".len()..entry.key.len() - ".path".len()];
1403                if let Some(ref val) = entry.value {
1404                    includes.push((val.clone(), Some(mid.to_owned())));
1405                }
1406            }
1407        }
1408
1409        // Merge the file's own entries
1410        set.merge(file);
1411
1412        // Process includes
1413        if process_includes {
1414            for (inc_path, condition) in includes {
1415                if let Some(ref cond) = condition {
1416                    if !evaluate_include_condition(cond, file) {
1417                        continue;
1418                    }
1419                }
1420
1421                let resolved = resolve_include_path(&inc_path, file.path.parent());
1422                if let Ok(Some(inc_file)) = ConfigFile::from_path(&resolved, file.scope) {
1423                    Self::merge_with_includes(set, &inc_file, true, depth + 1)?;
1424                }
1425            }
1426        }
1427
1428        Ok(())
1429    }
1430}
1431
1432// ── Type coercion helpers ───────────────────────────────────────────
1433
1434/// Parse a Git boolean value.
1435///
1436/// Accepts: `true`, `yes`, `on`, `1` as true.
1437/// Accepts: `false`, `no`, `off`, `0` (and explicit empty value) as false.
1438///
1439/// Note: bare config keys are represented as `None` in [`ConfigEntry`] and
1440/// are normalized to `"true"` by higher-level readers (`ConfigSet::get`).
1441/// This parser only sees string values and therefore treats `""` as an
1442/// explicitly empty assignment (`key =`), which Git interprets as false.
1443pub fn parse_bool(s: &str) -> std::result::Result<bool, String> {
1444    match s.to_lowercase().as_str() {
1445        "true" | "yes" | "on" => Ok(true),
1446        "" => Ok(false),
1447        "false" | "no" | "off" => Ok(false),
1448        _ => {
1449            // Try parsing as integer: 0 → false, non-zero → true
1450            if let Ok(n) = s.parse::<i64>() {
1451                return Ok(n != 0);
1452            }
1453            Err(format!("bad boolean config value '{s}'"))
1454        }
1455    }
1456}
1457
1458/// Parse a Git integer value with optional `k`/`m`/`g` suffix.
1459pub fn parse_i64(s: &str) -> std::result::Result<i64, String> {
1460    let s = s.trim();
1461    if s.is_empty() {
1462        return Err("empty integer value".to_owned());
1463    }
1464
1465    let (num_str, multiplier) = match s.as_bytes().last() {
1466        Some(b'k' | b'K') => (&s[..s.len() - 1], 1024_i64),
1467        Some(b'm' | b'M') => (&s[..s.len() - 1], 1024 * 1024),
1468        Some(b'g' | b'G') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
1469        _ => (s, 1_i64),
1470    };
1471
1472    let base: i64 = num_str
1473        .parse()
1474        .map_err(|_| format!("invalid integer: '{s}'"))?;
1475    base.checked_mul(multiplier)
1476        .ok_or_else(|| format!("integer overflow: '{s}'"))
1477}
1478
1479/// Parse a Git color value and return the ANSI escape sequence.
1480pub fn parse_color(s: &str) -> std::result::Result<String, String> {
1481    let s = s.trim();
1482    if s.is_empty() || s == "reset" || s == "normal" {
1483        return Ok("\x1b[m".to_owned());
1484    }
1485
1486    let mut codes: Vec<String> = Vec::new();
1487    let mut fg_set = false;
1488    let mut bg_set = false;
1489
1490    for token in s.split_whitespace() {
1491        match token.to_lowercase().as_str() {
1492            "bold" => codes.push("1".to_owned()),
1493            "dim" => codes.push("2".to_owned()),
1494            "italic" => codes.push("3".to_owned()),
1495            "ul" | "underline" => codes.push("4".to_owned()),
1496            "blink" => codes.push("5".to_owned()),
1497            "reverse" => codes.push("7".to_owned()),
1498            "strike" => codes.push("9".to_owned()),
1499            "nobold" | "nodim" => codes.push("22".to_owned()),
1500            "noitalic" => codes.push("23".to_owned()),
1501            "noul" | "nounderline" => codes.push("24".to_owned()),
1502            "noblink" => codes.push("25".to_owned()),
1503            "noreverse" => codes.push("27".to_owned()),
1504            "nostrike" => codes.push("29".to_owned()),
1505            name => {
1506                if let Some(code) = color_name_to_ansi(name) {
1507                    if !fg_set {
1508                        codes.push(format!("3{code}"));
1509                        fg_set = true;
1510                    } else if !bg_set {
1511                        codes.push(format!("4{code}"));
1512                        bg_set = true;
1513                    } else {
1514                        return Err(format!("bad color value '{s}'"));
1515                    }
1516                } else if let Ok(n) = token.parse::<u8>() {
1517                    if !fg_set {
1518                        codes.push(format!("38;5;{n}"));
1519                        fg_set = true;
1520                    } else if !bg_set {
1521                        codes.push(format!("48;5;{n}"));
1522                        bg_set = true;
1523                    } else {
1524                        return Err(format!("bad color value '{s}'"));
1525                    }
1526                } else {
1527                    return Err(format!("bad color value '{s}'"));
1528                }
1529            }
1530        }
1531    }
1532
1533    if codes.is_empty() {
1534        return Err(format!("bad color value '{s}'"));
1535    }
1536
1537    Ok(format!("\x1b[{}m", codes.join(";")))
1538}
1539
1540fn color_name_to_ansi(name: &str) -> Option<&'static str> {
1541    match name.to_lowercase().as_str() {
1542        "normal" | "default" => Some("9"),
1543        "black" => Some("0"),
1544        "red" => Some("1"),
1545        "green" => Some("2"),
1546        "yellow" => Some("3"),
1547        "blue" => Some("4"),
1548        "magenta" => Some("5"),
1549        "cyan" => Some("6"),
1550        "white" => Some("7"),
1551        _ => None,
1552    }
1553}
1554
1555/// Match a URL against a URL pattern from config.
1556pub fn url_matches(pattern_url: &str, target_url: &str) -> bool {
1557    let pattern = pattern_url.trim_end_matches('/');
1558    let target = target_url.trim_end_matches('/');
1559    if target == pattern {
1560        return true;
1561    }
1562    if let Some(rest) = target.strip_prefix(pattern) {
1563        return rest.starts_with('/') || rest.is_empty();
1564    }
1565    let pattern_slash = format!("{}/", pattern);
1566    target.starts_with(&pattern_slash)
1567}
1568
1569/// Get the best URL match for a specific key.
1570pub fn get_urlmatch_entries<'a>(
1571    entries: &'a [ConfigEntry],
1572    section: &str,
1573    variable: &str,
1574    url: &str,
1575) -> Vec<&'a ConfigEntry> {
1576    let section_lower = section.to_lowercase();
1577    let variable_lower = variable.to_lowercase();
1578    let mut matches: Vec<(usize, &'a ConfigEntry)> = Vec::new();
1579
1580    for entry in entries {
1581        let key = &entry.key;
1582        let first_dot = match key.find('.') {
1583            Some(i) => i,
1584            None => continue,
1585        };
1586        let last_dot = match key.rfind('.') {
1587            Some(i) => i,
1588            None => continue,
1589        };
1590        let entry_section = &key[..first_dot];
1591        let entry_variable = &key[last_dot + 1..];
1592        if entry_section.to_lowercase() != section_lower
1593            || entry_variable.to_lowercase() != variable_lower
1594        {
1595            continue;
1596        }
1597        if first_dot == last_dot {
1598            matches.push((0, entry));
1599        } else {
1600            let subsection = &key[first_dot + 1..last_dot];
1601            if url_matches(subsection, url) {
1602                matches.push((subsection.len(), entry));
1603            }
1604        }
1605    }
1606    matches.sort_by(|a, b| a.0.cmp(&b.0));
1607    matches.into_iter().map(|(_, e)| e).collect()
1608}
1609
1610/// Get all matching variables in a section for a given URL.
1611pub fn get_urlmatch_all_in_section(
1612    entries: &[ConfigEntry],
1613    section: &str,
1614    url: &str,
1615) -> Vec<(String, String, ConfigScope)> {
1616    let section_lower = section.to_lowercase();
1617    let mut matches: Vec<(String, usize, String, String, ConfigScope)> = Vec::new();
1618
1619    for entry in entries {
1620        let key = &entry.key;
1621        let first_dot = match key.find('.') {
1622            Some(i) => i,
1623            None => continue,
1624        };
1625        let last_dot = match key.rfind('.') {
1626            Some(i) => i,
1627            None => continue,
1628        };
1629        let entry_section = &key[..first_dot];
1630        if entry_section.to_lowercase() != section_lower {
1631            continue;
1632        }
1633        let entry_variable = &key[last_dot + 1..];
1634        let val = entry.value.as_deref().unwrap_or("true");
1635        if first_dot == last_dot {
1636            let canonical = format!("{}.{}", section_lower, entry_variable);
1637            matches.push((
1638                entry_variable.to_lowercase(),
1639                0,
1640                val.to_owned(),
1641                canonical,
1642                entry.scope,
1643            ));
1644        } else {
1645            let subsection = &key[first_dot + 1..last_dot];
1646            if url_matches(subsection, url) {
1647                let canonical = format!("{}.{}", section_lower, entry_variable);
1648                matches.push((
1649                    entry_variable.to_lowercase(),
1650                    subsection.len(),
1651                    val.to_owned(),
1652                    canonical,
1653                    entry.scope,
1654                ));
1655            }
1656        }
1657    }
1658
1659    let mut best: std::collections::BTreeMap<String, (usize, String, String, ConfigScope)> =
1660        std::collections::BTreeMap::new();
1661    for (var, specificity, val, canonical, scope) in matches {
1662        let entry = best
1663            .entry(var)
1664            .or_insert((0, String::new(), String::new(), scope));
1665        if specificity >= entry.0 {
1666            *entry = (specificity, val, canonical, scope);
1667        }
1668    }
1669    best.into_values()
1670        .map(|(_, val, canonical, scope)| (canonical, val, scope))
1671        .collect()
1672}
1673
1674/// Parse a Git path value (expand `~/` to home directory).
1675/// Parse a path value. Returns the resolved path string.
1676/// Does NOT handle :(optional) prefix — use `parse_path_optional` for that.
1677pub fn parse_path(s: &str) -> String {
1678    if let Some(rest) = s.strip_prefix("~/") {
1679        if let Some(home) = home_dir() {
1680            return home.join(rest).to_string_lossy().to_string();
1681        }
1682    }
1683    s.to_owned()
1684}
1685
1686/// Parse a path value that may have an `:(optional)` prefix.
1687///
1688/// Returns `Some(path)` if the path should be used, `None` if the path
1689/// is optional and does not exist (meaning the entry should be skipped).
1690pub fn parse_path_optional(s: &str) -> Option<String> {
1691    if let Some(rest) = s.strip_prefix(":(optional)") {
1692        let resolved = parse_path(rest);
1693        if std::path::Path::new(&resolved).exists() {
1694            Some(resolved)
1695        } else {
1696            None // optional and missing → skip
1697        }
1698    } else {
1699        Some(parse_path(s))
1700    }
1701}
1702
1703// ── Helpers ─────────────────────────────────────────────────────────
1704
1705/// Parse `GIT_CONFIG_PARAMETERS` payloads.
1706///
1707/// We support the common formats seen in tests and wrappers:
1708/// - single-quoted entries: `'key=value'`
1709/// - double-quoted entries: `"key=value"`
1710/// - unquoted `key=value` tokens separated by whitespace
1711///
1712/// Backslash escapes are interpreted minimally inside double quotes.
1713fn parse_config_parameters(raw: &str) -> Vec<String> {
1714    let mut out: Vec<String> = Vec::new();
1715    let mut buf = String::new();
1716    let mut in_single = false;
1717    let mut in_double = false;
1718
1719    let mut chars = raw.chars().peekable();
1720    while let Some(ch) = chars.next() {
1721        if in_single {
1722            if ch == '\'' {
1723                in_single = false;
1724            } else {
1725                buf.push(ch);
1726            }
1727            continue;
1728        }
1729        if in_double {
1730            if ch == '"' {
1731                in_double = false;
1732                continue;
1733            }
1734            if ch == '\\' {
1735                if let Some(next) = chars.next() {
1736                    let mapped = match next {
1737                        'n' => '\n',
1738                        't' => '\t',
1739                        'r' => '\r',
1740                        '"' => '"',
1741                        '\\' => '\\',
1742                        other => other,
1743                    };
1744                    buf.push(mapped);
1745                }
1746                continue;
1747            }
1748            buf.push(ch);
1749            continue;
1750        }
1751
1752        if ch == '\'' {
1753            in_single = true;
1754            continue;
1755        }
1756        if ch == '"' {
1757            in_double = true;
1758            continue;
1759        }
1760
1761        if ch.is_whitespace() {
1762            if !buf.is_empty() {
1763                out.push(std::mem::take(&mut buf));
1764            }
1765            continue;
1766        }
1767
1768        buf.push(ch);
1769    }
1770
1771    if !buf.is_empty() {
1772        out.push(buf);
1773    }
1774
1775    out
1776}
1777
1778/// Return candidate paths for the global config file, in priority order.
1779/// Public accessor for the ordered list of global config file paths.
1780pub fn global_config_paths_pub() -> Vec<PathBuf> {
1781    global_config_paths()
1782}
1783
1784fn global_config_paths() -> Vec<PathBuf> {
1785    let mut paths = Vec::new();
1786
1787    // $GIT_CONFIG_GLOBAL overrides
1788    if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
1789        paths.push(PathBuf::from(p));
1790        return paths;
1791    }
1792
1793    // $HOME/.gitconfig
1794    if let Some(home) = home_dir() {
1795        paths.push(home.join(".gitconfig"));
1796    }
1797
1798    // $XDG_CONFIG_HOME/git/config
1799    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
1800        paths.push(PathBuf::from(xdg).join("git/config"));
1801    } else if let Some(home) = home_dir() {
1802        paths.push(home.join(".config/git/config"));
1803    }
1804
1805    paths
1806}
1807
1808/// Return the user's home directory.
1809fn home_dir() -> Option<PathBuf> {
1810    std::env::var("HOME").ok().map(PathBuf::from)
1811}
1812
1813/// Resolve an include path relative to the including file's directory.
1814fn resolve_include_path(path: &str, base: Option<&Path>) -> PathBuf {
1815    let expanded = parse_path(path);
1816    let p = Path::new(&expanded);
1817    if p.is_absolute() {
1818        p.to_path_buf()
1819    } else if let Some(base) = base {
1820        base.join(p)
1821    } else {
1822        p.to_path_buf()
1823    }
1824}
1825
1826/// Evaluate an `[includeIf]` condition.
1827///
1828/// Currently supports:
1829/// - `gitdir:<pattern>` / `gitdir/i:<pattern>` — match against the git dir.
1830/// - `onbranch:<pattern>` — match against the current branch.
1831fn evaluate_include_condition(condition: &str, _file: &ConfigFile) -> bool {
1832    // TODO: Implement gitdir: and onbranch: matching.
1833    // For now, we skip conditional includes (safe default: don't include).
1834    let _ = condition;
1835    false
1836}
1837
1838/// Split a canonical key into (section, subsection, variable).
1839fn split_key(key: &str) -> Result<(String, Option<String>, String)> {
1840    let first_dot = key
1841        .find('.')
1842        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
1843    let last_dot = key
1844        .rfind('.')
1845        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
1846
1847    let section = key[..first_dot].to_owned();
1848    let variable = key[last_dot + 1..].to_owned();
1849
1850    let subsection = if first_dot == last_dot {
1851        None
1852    } else {
1853        Some(key[first_dot + 1..last_dot].to_owned())
1854    };
1855
1856    Ok((section, subsection, variable))
1857}
1858
1859/// Extract the variable name from a canonical key.
1860#[allow(dead_code)]
1861fn variable_name_from_key(key: &str) -> &str {
1862    match key.rfind('.') {
1863        Some(i) => &key[i + 1..],
1864        None => key,
1865    }
1866}
1867
1868/// Parse a section name that may contain a subsection (e.g. `"remote.origin"`).
1869///
1870/// Returns (section, subsection).
1871fn parse_section_name(name: &str) -> (&str, Option<&str>) {
1872    match name.find('.') {
1873        Some(i) => (&name[..i], Some(&name[i + 1..])),
1874        None => (name, None),
1875    }
1876}
1877
1878/// Extract the original-case variable name from a raw (user-typed) key.
1879///
1880/// E.g. `"Section.Movie"` → `"Movie"`, `"a.b.CamelCase"` → `"CamelCase"`.
1881fn raw_variable_name(raw_key: &str) -> &str {
1882    match raw_key.rfind('.') {
1883        Some(i) => &raw_key[i + 1..],
1884        None => raw_key,
1885    }
1886}
1887
1888/// Extract the original-case section and subsection from a raw (user-typed) key.
1889///
1890/// E.g. `"Section.key"` → `("Section", None)`,
1891///      `"Remote.origin.url"` → `("Remote", Some("origin"))`.
1892fn raw_section_parts(raw_key: &str) -> (String, Option<String>) {
1893    let first_dot = match raw_key.find('.') {
1894        Some(i) => i,
1895        None => return (raw_key.to_owned(), None),
1896    };
1897    // rfind always succeeds here since we already found at least one dot above.
1898    let last_dot = match raw_key.rfind('.') {
1899        Some(i) => i,
1900        None => return (raw_key[..first_dot].to_owned(), None),
1901    };
1902    let section = raw_key[..first_dot].to_owned();
1903    if first_dot == last_dot {
1904        (section, None)
1905    } else {
1906        let subsection = raw_key[first_dot + 1..last_dot].to_owned();
1907        (section, Some(subsection))
1908    }
1909}
1910
1911/// Check if a raw line is a section header that also contains an inline key=value.
1912fn is_section_header_with_inline_entry(line: &str) -> bool {
1913    let trimmed = line.trim();
1914    if !trimmed.starts_with('[') {
1915        return false;
1916    }
1917    let end = match trimmed.find(']') {
1918        Some(i) => i,
1919        None => return false,
1920    };
1921    let after = trimmed[end + 1..].trim();
1922    // Has non-comment content after the ]
1923    !after.is_empty() && !after.starts_with('#') && !after.starts_with(';')
1924}
1925
1926/// Extract just the section header portion (up to and including `]` and any
1927/// comment after it, but not any inline key=value) from a raw line.
1928fn extract_section_header(line: &str) -> String {
1929    let trimmed = line.trim();
1930    let end = match trimmed.find(']') {
1931        Some(i) => i,
1932        None => return line.to_owned(),
1933    };
1934    // Preserve any comment on the section header itself (between ] and key),
1935    // but git doesn't really do this. Just return up to ].
1936    trimmed[..=end].to_owned()
1937}