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!("invalid key: '{}'" , raw.replace('\n', "\\n"))));
121    }
122
123    let first_dot = raw
124        .find('.')
125        .ok_or_else(|| Error::ConfigError(format!("key does not contain a section: '{raw}'")))?;
126    let last_dot = raw
127        .rfind('.')
128        .ok_or_else(|| Error::ConfigError(format!("key does not contain a section: '{raw}'")))?;
129
130    if last_dot == raw.len() - 1 {
131        return Err(Error::ConfigError(format!(
132            "key does not contain variable name: '{raw}'"
133        )));
134    }
135
136    let section = &raw[..first_dot];
137    let name = &raw[last_dot + 1..];
138
139    // Validate section name: must be alphanumeric or hyphen
140    if section.is_empty() || !section.chars().all(|c| c.is_alphanumeric() || c == '-') {
141        return Err(Error::ConfigError(format!("invalid key (bad section): '{raw}'")));
142    }
143
144    // Validate variable name: must start with alpha, rest alphanumeric or hyphen
145    if name.is_empty()
146        || !name.chars().next().unwrap().is_ascii_alphabetic()
147        || !name.chars().all(|c| c.is_alphanumeric() || c == '-')
148    {
149        return Err(Error::ConfigError(format!("invalid key (bad variable name): '{raw}'")));
150    }
151
152    if first_dot == last_dot {
153        // No subsection: section.name
154        Ok(format!(
155            "{}.{}",
156            section.to_lowercase(),
157            name.to_lowercase()
158        ))
159    } else {
160        // section.subsection.name
161        let subsection = &raw[first_dot + 1..last_dot];
162        Ok(format!(
163            "{}.{}.{}",
164            section.to_lowercase(),
165            subsection,
166            name.to_lowercase()
167        ))
168    }
169}
170
171// ── Parser ──────────────────────────────────────────────────────────
172
173/// State tracked while parsing a config file line-by-line.
174struct Parser {
175    section: String,
176    subsection: Option<String>,
177}
178
179impl Parser {
180    fn new() -> Self {
181        Self {
182            section: String::new(),
183            subsection: None,
184        }
185    }
186
187    /// Build the canonical key for a variable name in the current section.
188    fn make_key(&self, name: &str) -> String {
189        let sec = self.section.to_lowercase();
190        let var = name.to_lowercase();
191        match &self.subsection {
192            Some(sub) => format!("{sec}.{sub}.{var}"),
193            None => format!("{sec}.{var}"),
194        }
195    }
196
197    /// Parse a section header line like `[section]` or `[section "subsection"]`.
198    ///
199    /// Returns `true` if the line was a section header.
200    fn try_parse_section(&mut self, line: &str) -> bool {
201        let trimmed = line.trim();
202        if !trimmed.starts_with('[') {
203            return false;
204        }
205        let end = match trimmed.find(']') {
206            Some(i) => i,
207            None => return false,
208        };
209        let inside = &trimmed[1..end];
210        // Check for subsection: [section "subsection"]
211        if let Some(quote_start) = inside.find('"') {
212            self.section = inside[..quote_start].trim().to_owned();
213            let rest = &inside[quote_start + 1..];
214            if let Some(quote_end) = rest.find('"') {
215                self.subsection = Some(rest[..quote_end].to_owned());
216            } else {
217                self.subsection = Some(rest.to_owned());
218            }
219        } else {
220            self.section = inside.trim().to_owned();
221            self.subsection = None;
222        }
223        true
224    }
225
226    /// Parse a `key = value` or bare `key` line.
227    ///
228    /// Returns `Some((canonical_key, value))` if this is a variable line.
229    fn try_parse_entry(&self, line: &str) -> Option<(String, Option<String>)> {
230        let trimmed = line.trim();
231        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
232            return None;
233        }
234        if trimmed.starts_with('[') {
235            return None;
236        }
237        if self.section.is_empty() {
238            return None;
239        }
240
241        if let Some(eq_pos) = trimmed.find('=') {
242            let raw_name = trimmed[..eq_pos].trim();
243            let raw_value = trimmed[eq_pos + 1..].trim();
244            // Strip inline comment (not inside quotes)
245            let value = strip_inline_comment(raw_value);
246            let value = unescape_value(&value);
247            let key = self.make_key(raw_name);
248            Some((key, Some(value)))
249        } else {
250            // Bare key (boolean true)
251            let raw_name = strip_inline_comment(trimmed);
252            let key = self.make_key(raw_name.trim());
253            Some((key, None))
254        }
255    }
256}
257
258/// Check if a value line ends with a continuation backslash.
259///
260/// This checks the value portion (after `=`) for a trailing `\` that is
261/// outside quotes and outside an inline comment. If the `\` is after
262/// a `#` or `;` that starts a comment, it does NOT count as continuation.
263fn value_line_continues(line: &str) -> bool {
264    let trimmed = line.trim();
265    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
266        return false;
267    }
268    // Find the value portion (after '=')
269    // If no '=', this is a bare key — no continuation
270    let value_part = match trimmed.find('=') {
271        Some(pos) => &trimmed[pos + 1..],
272        None => return false,
273    };
274    // Walk the value portion tracking quotes and comments
275    let mut in_quote = false;
276    let mut last_was_backslash = false;
277    let mut in_comment = false;
278    for ch in value_part.chars() {
279        if in_comment {
280            // Inside comment, backslash doesn't matter
281            last_was_backslash = false;
282            continue;
283        }
284        match ch {
285            '"' if !last_was_backslash => {
286                in_quote = !in_quote;
287                last_was_backslash = false;
288            }
289            '\\' if !last_was_backslash => {
290                last_was_backslash = true;
291                continue;
292            }
293            '#' | ';' if !in_quote && !last_was_backslash => {
294                in_comment = true;
295                last_was_backslash = false;
296            }
297            _ => {
298                last_was_backslash = false;
299            }
300        }
301    }
302    // The line continues if it ends with an unescaped backslash outside comments
303    last_was_backslash && !in_comment
304}
305
306/// Strip an inline comment (`#` or `;`) that is not inside quotes.
307fn strip_inline_comment(s: &str) -> String {
308    let mut in_quote = false;
309    let mut result = String::with_capacity(s.len());
310    let mut chars = s.chars().peekable();
311    while let Some(ch) = chars.next() {
312        match ch {
313            '"' => {
314                in_quote = !in_quote;
315                result.push(ch);
316            }
317            '\\' if in_quote => {
318                result.push(ch);
319                if let Some(&next) = chars.peek() {
320                    result.push(next);
321                    chars.next();
322                }
323            }
324            '#' | ';' if !in_quote => break,
325            _ => result.push(ch),
326        }
327    }
328    // Trim trailing whitespace that was before the comment
329    let trimmed = result.trim_end();
330    trimmed.to_owned()
331}
332
333/// Unescape a config value: handle `\"`, `\\`, `\n`, `\t`, and strip
334/// surrounding quotes.
335fn unescape_value(s: &str) -> String {
336    let mut result = String::with_capacity(s.len());
337    let mut chars = s.chars();
338    while let Some(ch) = chars.next() {
339        match ch {
340            '"' => { /* strip quotes */ }
341            '\\' => match chars.next() {
342                Some('n') => result.push('\n'),
343                Some('t') => result.push('\t'),
344                Some('\\') => result.push('\\'),
345                Some('"') => result.push('"'),
346                Some(other) => {
347                    result.push('\\');
348                    result.push(other);
349                }
350                None => result.push('\\'),
351            },
352            _ => result.push(ch),
353        }
354    }
355    result
356}
357
358/// Escape a config value for writing back to a file.
359///
360/// Wraps in double quotes if the value contains leading/trailing whitespace,
361/// internal quotes, backslashes, or special characters.
362fn escape_value(s: &str) -> String {
363    let needs_quoting = s.starts_with(' ')
364        || s.starts_with('\t')
365        || s.ends_with(' ')
366        || s.ends_with('\t')
367        || s.contains('"')
368        || s.contains('\\')
369        || s.contains('\n')
370        || s.contains('#')
371        || s.contains(';');
372
373    if !needs_quoting {
374        return s.to_owned();
375    }
376
377    let mut out = String::with_capacity(s.len() + 4);
378    out.push('"');
379    for ch in s.chars() {
380        match ch {
381            '"' => out.push_str("\\\""),
382            '\\' => out.push_str("\\\\"),
383            '\n' => out.push_str("\\n"),
384            '\t' => out.push_str("\\t"),
385            other => out.push(other),
386        }
387    }
388    out.push('"');
389    out
390}
391
392// ── ConfigFile ──────────────────────────────────────────────────────
393
394impl ConfigFile {
395    /// Parse a config file from its raw text content.
396    ///
397    /// # Parameters
398    ///
399    /// - `path` — the file path (stored for diagnostics and round-trip writes).
400    /// - `content` — the raw text of the file.
401    /// - `scope` — the [`ConfigScope`] this file represents.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`Error::ConfigError`] on malformed input.
406    pub fn parse(path: &Path, content: &str, scope: ConfigScope) -> Result<Self> {
407        let raw_lines: Vec<String> = content.lines().map(String::from).collect();
408        let mut entries = Vec::new();
409        let mut parser = Parser::new();
410
411        let mut idx = 0;
412        while idx < raw_lines.len() {
413            let start_idx = idx;
414            let line = &raw_lines[idx];
415            idx += 1;
416
417            // Pure comment lines don't continue even with trailing \
418            let trimmed = line.trim();
419            if trimmed.starts_with('#') || trimmed.starts_with(';') {
420                continue;
421            }
422
423            if parser.try_parse_section(line) {
424                continue;
425            }
426
427            // For entry lines, we need to check continuation.
428            // Build a logical line by joining continuations.
429            let mut logical_line = line.clone();
430            while value_line_continues(&logical_line) && idx < raw_lines.len() {
431                // Remove the trailing backslash
432                let t = logical_line.trim_end();
433                logical_line = t[..t.len() - 1].to_string();
434                // Append next line (trimmed of leading whitespace)
435                let next = raw_lines[idx].trim_start();
436                logical_line.push_str(next);
437                idx += 1;
438            }
439
440            if let Some((key, value)) = parser.try_parse_entry(&logical_line) {
441                entries.push(ConfigEntry {
442                    key,
443                    value,
444                    scope,
445                    file: Some(path.to_path_buf()),
446                    line: start_idx + 1,
447                });
448            }
449        }
450
451        Ok(Self {
452            path: path.to_path_buf(),
453            scope,
454            entries,
455            raw_lines,
456        })
457    }
458
459    /// Read and parse a config file from disk.
460    ///
461    /// Returns `Ok(None)` if the file does not exist.
462    ///
463    /// # Errors
464    ///
465    /// Returns [`Error::Io`] on read failure (other than not-found) or
466    /// [`Error::ConfigError`] on parse failure.
467    pub fn from_path(path: &Path, scope: ConfigScope) -> Result<Option<Self>> {
468        match fs::read_to_string(path) {
469            Ok(content) => Ok(Some(Self::parse(path, &content, scope)?)),
470            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
471            Err(e) => Err(Error::Io(e)),
472        }
473    }
474
475    /// Set a value in this config file, creating the section if needed.
476    ///
477    /// If the key already exists, its last occurrence is updated in-place.
478    /// Otherwise a new entry is appended (creating the section header if
479    /// necessary).
480    ///
481    /// # Parameters
482    ///
483    /// - `key` — canonical key (e.g. `core.bare`).
484    /// - `value` — the value to set.
485    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
486        let canon = canonical_key(key)?;
487        // Git lowercases variable names when writing
488        let var_lower = raw_variable_name(key).to_lowercase();
489
490        // Find the last entry with this key to replace in-place.
491        let existing_idx = self.entries.iter().rposition(|e| e.key == canon);
492
493        if let Some(idx) = existing_idx {
494            let line_idx = self.entries[idx].line - 1;
495            self.raw_lines[line_idx] = format!("\t{} = {}", var_lower, escape_value(value));
496            self.entries[idx].value = Some(value.to_owned());
497        } else {
498            // Need to add: find or create the section
499            let (section, subsection, _var) = split_key(&canon)?;
500            // Git lowercases section names; subsection preserves case
501            let (_raw_sec, raw_sub) = raw_section_parts(key);
502            let section_line = self.find_or_create_section_preserving_case(
503                &section, subsection.as_deref(),
504                &section, raw_sub.as_deref(),
505            );
506            let new_line = format!("\t{} = {}", var_lower, escape_value(value));
507
508            // Insert after the section header (or last entry in section)
509            let insert_at = self.last_line_in_section(section_line) + 1;
510            self.raw_lines.insert(insert_at, new_line);
511
512            // Re-parse to fix up line numbers
513            let content = self.raw_lines.join("\n");
514            let reparsed = Self::parse(&self.path, &content, self.scope)?;
515            self.entries = reparsed.entries;
516            self.raw_lines = reparsed.raw_lines;
517        }
518
519        Ok(())
520    }
521
522    /// Replace ALL occurrences of a key with a new value.
523    ///
524    /// Removes all but the last occurrence from the file, then updates
525    /// the last occurrence with the new value (matching Git behaviour).
526    pub fn replace_all(&mut self, key: &str, value: &str, value_pattern: Option<&str>) -> Result<()> {
527        let canon = canonical_key(key)?;
528        let var_lower = raw_variable_name(key).to_lowercase();
529
530        // Compile optional regex pattern
531        let re = match value_pattern {
532            Some(pat) => Some(
533                regex::Regex::new(pat)
534                    .map_err(|e| Error::ConfigError(format!("invalid value-pattern regex: {e}")))?),
535            None => None,
536        };
537
538        // Find all matching entries (by key, and optionally by value pattern)
539        let matching_indices: Vec<usize> = self
540            .entries
541            .iter()
542            .enumerate()
543            .filter(|(_, e)| {
544                if e.key != canon {
545                    return false;
546                }
547                if let Some(ref re) = re {
548                    let v = e.value.as_deref().unwrap_or("");
549                    re.is_match(v)
550                } else {
551                    true
552                }
553            })
554            .map(|(i, _)| i)
555            .collect();
556
557        if matching_indices.is_empty() {
558            // No matching entries — add a new one (same as set)
559            return self.set(key, value);
560        }
561
562        // Keep the first matching entry, remove the rest
563        let first_match = matching_indices[0];
564        let lines_to_remove: Vec<usize> = matching_indices
565            .iter()
566            .skip(1)
567            .map(|&i| self.entries[i].line - 1)
568            .collect();
569
570        // Update the first matching entry's line with the new value
571        let first_line_idx = self.entries[first_match].line - 1;
572        self.raw_lines[first_line_idx] = format!("\t{} = {}", var_lower, escape_value(value));
573        self.entries[first_match].value = Some(value.to_owned());
574
575        // Remove remaining matching lines from bottom to top
576        for &line_idx in lines_to_remove.iter().rev() {
577            self.raw_lines.remove(line_idx);
578        }
579
580        // Re-parse after modifications
581        let content = self.raw_lines.join("\n");
582        let reparsed = Self::parse(&self.path, &content, self.scope)?;
583        self.entries = reparsed.entries;
584        self.raw_lines = reparsed.raw_lines;
585
586        Ok(())
587    }
588
589    /// Count how many entries exist for a key.
590    pub fn count(&self, key: &str) -> Result<usize> {
591        let canon = canonical_key(key)?;
592        Ok(self.entries.iter().filter(|e| e.key == canon).count())
593    }
594
595    /// Unset (remove) only the last occurrence of a key.
596    ///
597    /// Returns the number of entries removed (0 or 1).
598    pub fn unset_last(&mut self, key: &str) -> Result<usize> {
599        let canon = canonical_key(key)?;
600        let last_idx = self.entries.iter().rposition(|e| e.key == canon);
601
602        if let Some(idx) = last_idx {
603            let line_idx = self.entries[idx].line - 1;
604            self.raw_lines.remove(line_idx);
605            let content = self.raw_lines.join("\n");
606            let reparsed = Self::parse(&self.path, &content, self.scope)?;
607            self.entries = reparsed.entries;
608            self.raw_lines = reparsed.raw_lines;
609            Ok(1)
610        } else {
611            Ok(0)
612        }
613    }
614
615    /// Unset (remove) all occurrences of a key.
616    ///
617    /// # Parameters
618    ///
619    /// - `key` — canonical key (e.g. `core.bare`).
620    ///
621    /// # Returns
622    ///
623    /// The number of entries removed.
624    pub fn unset(&mut self, key: &str) -> Result<usize> {
625        let canon = canonical_key(key)?;
626        let line_indices: Vec<usize> = self
627            .entries
628            .iter()
629            .filter(|e| e.key == canon)
630            .map(|e| e.line - 1)
631            .collect();
632
633        let count = line_indices.len();
634        // Remove from bottom to top to keep indices valid
635        for &idx in line_indices.iter().rev() {
636            self.raw_lines.remove(idx);
637        }
638
639        if count > 0 {
640            let content = self.raw_lines.join("\n");
641            let reparsed = Self::parse(&self.path, &content, self.scope)?;
642            self.entries = reparsed.entries;
643            self.raw_lines = reparsed.raw_lines;
644        }
645
646        Ok(count)
647    }
648
649    /// Unset entries matching a key and optional value-pattern regex.
650    ///
651    /// If `value_pattern` is `None`, removes all entries with the given key.
652    /// If `value_pattern` is `Some(pat)`, only removes entries whose value matches the regex.
653    pub fn unset_matching(&mut self, key: &str, value_pattern: Option<&str>) -> Result<usize> {
654        let canon = canonical_key(key)?;
655        let re = match value_pattern {
656            Some(pat) => Some(
657                regex::Regex::new(pat)
658                    .map_err(|e| Error::ConfigError(format!("invalid value-pattern regex: {e}")))?),
659            None => None,
660        };
661
662        let line_indices: Vec<usize> = self
663            .entries
664            .iter()
665            .filter(|e| {
666                if e.key != canon {
667                    return false;
668                }
669                if let Some(ref re) = re {
670                    let v = e.value.as_deref().unwrap_or("");
671                    re.is_match(v)
672                } else {
673                    true
674                }
675            })
676            .map(|e| e.line - 1)
677            .collect();
678
679        let count = line_indices.len();
680        for &idx in line_indices.iter().rev() {
681            self.raw_lines.remove(idx);
682        }
683
684        if count > 0 {
685            let content = self.raw_lines.join("\n");
686            let reparsed = Self::parse(&self.path, &content, self.scope)?;
687            self.entries = reparsed.entries;
688            self.raw_lines = reparsed.raw_lines;
689        }
690
691        Ok(count)
692    }
693
694    /// Remove an entire section (and all its entries).
695    ///
696    /// # Parameters
697    ///
698    /// - `section` — section name (e.g. `"core"`, `"remote.origin"`).
699    pub fn remove_section(&mut self, section: &str) -> Result<bool> {
700        let (sec_name, sub_name) = parse_section_name(section);
701        let sec_lower = sec_name.to_lowercase();
702
703        // Find section header line and all lines that belong to it
704        let mut start = None;
705        let mut end = 0;
706        let mut parser = Parser::new();
707
708        for (idx, line) in self.raw_lines.iter().enumerate() {
709            if parser.try_parse_section(line) {
710                if parser.section.to_lowercase() == sec_lower
711                    && parser.subsection.as_deref() == sub_name
712                {
713                    start = Some(idx);
714                    end = idx;
715                } else if start.is_some() {
716                    break;
717                }
718            } else if start.is_some() {
719                end = idx;
720            }
721        }
722
723        if let Some(s) = start {
724            self.raw_lines.drain(s..=end);
725            let content = self.raw_lines.join("\n");
726            let reparsed = Self::parse(&self.path, &content, self.scope)?;
727            self.entries = reparsed.entries;
728            self.raw_lines = reparsed.raw_lines;
729            Ok(true)
730        } else {
731            Ok(false)
732        }
733    }
734
735    /// Rename a section.
736    ///
737    /// # Parameters
738    ///
739    /// - `old_name` — current section name (e.g. `"branch.main"`).
740    /// - `new_name` — new section name (e.g. `"branch.develop"`).
741    pub fn rename_section(&mut self, old_name: &str, new_name: &str) -> Result<bool> {
742        let (old_sec, old_sub) = parse_section_name(old_name);
743        let (new_sec, new_sub) = parse_section_name(new_name);
744        let old_lower = old_sec.to_lowercase();
745
746        let mut found = false;
747        let mut parser = Parser::new();
748
749        for idx in 0..self.raw_lines.len() {
750            let line = &self.raw_lines[idx];
751            if parser.try_parse_section(line)
752                && parser.section.to_lowercase() == old_lower
753                && parser.subsection.as_deref() == old_sub
754            {
755                // Rewrite the section header
756                let header = match new_sub {
757                    Some(sub) => format!("[{} \"{}\"]", new_sec, sub),
758                    None => format!("[{}]", new_sec),
759                };
760                self.raw_lines[idx] = header;
761                found = true;
762            }
763        }
764
765        if found {
766            let content = self.raw_lines.join("\n");
767            let reparsed = Self::parse(&self.path, &content, self.scope)?;
768            self.entries = reparsed.entries;
769            self.raw_lines = reparsed.raw_lines;
770        }
771
772        Ok(found)
773    }
774
775    /// Append a new value for a key without removing existing entries.
776    ///
777    /// This is the behaviour of `git config --add section.key value`.
778    /// If the section doesn't exist, it is created.
779    pub fn add_value(&mut self, key: &str, value: &str) -> Result<()> {
780        let canon = canonical_key(key)?;
781        let raw_var = raw_variable_name(key);
782        let (section, subsection, _var) = split_key(&canon)?;
783        let (raw_sec, raw_sub) = raw_section_parts(key);
784
785        let section_line = self.find_or_create_section_preserving_case(
786            &section, subsection.as_deref(),
787            &raw_sec, raw_sub.as_deref(),
788        );
789        let new_line = format!("\t{} = {}", raw_var, escape_value(value));
790        let insert_at = self.last_line_in_section(section_line) + 1;
791        self.raw_lines.insert(insert_at, new_line);
792
793        // Re-parse to fix up entries and line numbers
794        let content = self.raw_lines.join("\n");
795        let reparsed = Self::parse(&self.path, &content, self.scope)?;
796        self.entries = reparsed.entries;
797        self.raw_lines = reparsed.raw_lines;
798
799        Ok(())
800    }
801
802    /// Write the (possibly modified) config back to disk.
803    ///
804    /// # Errors
805    ///
806    /// Returns [`Error::Io`] on write failure.
807    pub fn write(&self) -> Result<()> {
808        let content = self.raw_lines.join("\n");
809        // Ensure trailing newline
810        let content = if content.ends_with('\n') {
811            content
812        } else {
813            format!("{content}\n")
814        };
815        fs::write(&self.path, content)?;
816        Ok(())
817    }
818
819    /// Find the line index of a section header, or create one.
820    #[allow(dead_code)]
821    fn find_or_create_section(&mut self, section: &str, subsection: Option<&str>) -> usize {
822        let sec_lower = section.to_lowercase();
823        let mut parser = Parser::new();
824
825        for (idx, line) in self.raw_lines.iter().enumerate() {
826            if parser.try_parse_section(line)
827                && parser.section.to_lowercase() == sec_lower
828                && parser.subsection.as_deref() == subsection
829            {
830                return idx;
831            }
832        }
833
834        // Create new section at end of file
835        let header = match subsection {
836            Some(sub) => format!("[{} \"{}\"]", section, sub),
837            None => format!("[{}]", section),
838        };
839        self.raw_lines.push(header);
840        self.raw_lines.len() - 1
841    }
842
843    /// Find the line index of a section header (case-insensitive match),
844    /// or create one using the original-case names from user input.
845    fn find_or_create_section_preserving_case(
846        &mut self,
847        section: &str,
848        subsection: Option<&str>,
849        raw_section: &str,
850        raw_subsection: Option<&str>,
851    ) -> usize {
852        let sec_lower = section.to_lowercase();
853        let mut parser = Parser::new();
854
855        for (idx, line) in self.raw_lines.iter().enumerate() {
856            if parser.try_parse_section(line)
857                && parser.section.to_lowercase() == sec_lower
858                && parser.subsection.as_deref() == subsection
859            {
860                return idx;
861            }
862        }
863
864        // Create new section at end of file, using original case
865        let header = match raw_subsection {
866            Some(sub) => format!("[{} \"{}\"]", raw_section, sub),
867            None => format!("[{}]", raw_section),
868        };
869        self.raw_lines.push(header);
870        self.raw_lines.len() - 1
871    }
872
873    /// Find the last line that belongs to the section starting at `section_line`.
874    fn last_line_in_section(&self, section_line: usize) -> usize {
875        let mut last = section_line;
876        for idx in (section_line + 1)..self.raw_lines.len() {
877            let trimmed = self.raw_lines[idx].trim();
878            if trimmed.starts_with('[') {
879                break;
880            }
881            last = idx;
882        }
883        last
884    }
885}
886
887// ── ConfigSet ───────────────────────────────────────────────────────
888
889impl ConfigSet {
890    /// Create an empty config set.
891    #[must_use]
892    pub fn new() -> Self {
893        Self {
894            entries: Vec::new(),
895        }
896    }
897
898    /// Merge entries from a [`ConfigFile`] into this set.
899    ///
900    /// Entries are appended; later values override earlier ones for
901    /// single-value lookups.
902    pub fn merge(&mut self, file: &ConfigFile) {
903        self.entries.extend(file.entries.iter().cloned());
904    }
905
906    /// Add a command-line override (`-c key=value`).
907    pub fn add_command_override(&mut self, key: &str, value: &str) -> Result<()> {
908        let canon = canonical_key(key)?;
909        self.entries.push(ConfigEntry {
910            key: canon,
911            value: Some(value.to_owned()),
912            scope: ConfigScope::Command,
913            file: None,
914            line: 0,
915        });
916        Ok(())
917    }
918
919    /// Get the last (highest-priority) value for a key.
920    ///
921    /// # Parameters
922    ///
923    /// - `key` — the key to look up (will be canonicalized).
924    ///
925    /// # Returns
926    ///
927    /// `Some(value)` for the last matching entry, or `None` if not found.
928    /// Bare boolean keys return `Some("true")`.
929    #[must_use]
930    pub fn get(&self, key: &str) -> Option<String> {
931        let canon = canonical_key(key).ok()?;
932        self.entries
933            .iter()
934            .rev()
935            .find(|e| e.key == canon)
936            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
937    }
938
939    /// Get all values for a key (multi-valued; in load order).
940    #[must_use]
941    pub fn get_all(&self, key: &str) -> Vec<String> {
942        let canon = match canonical_key(key) {
943            Ok(c) => c,
944            Err(_) => return Vec::new(),
945        };
946        self.entries
947            .iter()
948            .filter(|e| e.key == canon)
949            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
950            .collect()
951    }
952
953    /// Get a boolean value, interpreting `true`/`yes`/`on`/`1` as true and
954    /// `false`/`no`/`off`/`0` as false.
955    pub fn get_bool(&self, key: &str) -> Option<std::result::Result<bool, String>> {
956        self.get(key).map(|v| parse_bool(&v))
957    }
958
959    /// Get an integer value, supporting Git's `k`/`m`/`g` suffixes.
960    pub fn get_i64(&self, key: &str) -> Option<std::result::Result<i64, String>> {
961        self.get(key).map(|v| parse_i64(&v))
962    }
963
964    /// Get all entries matching a key pattern (regex).
965    ///
966    /// Used by `git config --get-regexp`. Returns an error if the pattern
967    /// is not a valid regex.
968    pub fn get_regexp(&self, pattern: &str) -> std::result::Result<Vec<&ConfigEntry>, String> {
969        let re = regex::Regex::new(pattern)
970            .map_err(|e| format!("invalid key pattern: {e}"))?;
971        Ok(self.entries
972            .iter()
973            .filter(|e| re.is_match(&e.key))
974            .collect())
975    }
976
977    /// List all entries in load order.
978    #[must_use]
979    pub fn entries(&self) -> &[ConfigEntry] {
980        &self.entries
981    }
982
983    /// Load the standard Git configuration file cascade for a repository.
984    ///
985    /// # Parameters
986    ///
987    /// - `git_dir` — path to the `.git` directory (for local/worktree config).
988    /// - `include_system` — whether to load system config.
989    ///
990    /// # Errors
991    ///
992    /// Returns errors from file I/O or parsing.
993    pub fn load(git_dir: Option<&Path>, include_system: bool) -> Result<Self> {
994        let mut set = Self::new();
995
996        // System config
997        if include_system {
998            if let Ok(Some(f)) =
999                ConfigFile::from_path(Path::new("/etc/gitconfig"), ConfigScope::System)
1000            {
1001                Self::merge_with_includes(&mut set, &f, true)?;
1002            }
1003        }
1004
1005        // Global config
1006        for path in global_config_paths() {
1007            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
1008                Self::merge_with_includes(&mut set, &f, true)?;
1009                break; // Only use the first found
1010            }
1011        }
1012
1013        // Local config
1014        if let Some(gd) = git_dir {
1015            let local_path = gd.join("config");
1016            if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
1017                Self::merge_with_includes(&mut set, &f, true)?;
1018            }
1019
1020            // Worktree config
1021            let wt_path = gd.join("config.worktree");
1022            if let Ok(Some(f)) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
1023                Self::merge_with_includes(&mut set, &f, true)?;
1024            }
1025        }
1026
1027        // Environment overrides
1028        if let Ok(path) = std::env::var("GIT_CONFIG") {
1029            if let Ok(Some(f)) = ConfigFile::from_path(Path::new(&path), ConfigScope::Command) {
1030                set.merge(&f);
1031            }
1032        }
1033
1034        // GIT_CONFIG_COUNT / GIT_CONFIG_KEY_N / GIT_CONFIG_VALUE_N
1035        if let Ok(count_str) = std::env::var("GIT_CONFIG_COUNT") {
1036            if let Ok(count) = count_str.parse::<usize>() {
1037                for i in 0..count {
1038                    let key_var = format!("GIT_CONFIG_KEY_{i}");
1039                    let val_var = format!("GIT_CONFIG_VALUE_{i}");
1040                    if let (Ok(key), Ok(val)) = (std::env::var(&key_var), std::env::var(&val_var)) {
1041                        let _ = set.add_command_override(&key, &val);
1042                    }
1043                }
1044            }
1045        }
1046
1047        // GIT_CONFIG_PARAMETERS — single-quoted 'key=value' entries separated by spaces.
1048        // This is the format used by `git -c key=value`.
1049        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
1050            for entry in parse_config_parameters(&params) {
1051                if let Some((key, val)) = entry.split_once('=') {
1052                    let _ = set.add_command_override(key.trim(), val.trim());
1053                } else {
1054                    // Bare key (boolean true)
1055                    let _ = set.add_command_override(entry.trim(), "true");
1056                }
1057            }
1058        }
1059
1060        Ok(set)
1061    }
1062
1063    /// Merge a file, processing `[include]` and `[includeIf]` directives.
1064    fn merge_with_includes(
1065        set: &mut Self,
1066        file: &ConfigFile,
1067        process_includes: bool,
1068    ) -> Result<()> {
1069        // First pass: find include paths
1070        let mut includes: Vec<(String, Option<String>)> = Vec::new();
1071
1072        for entry in &file.entries {
1073            if entry.key == "include.path" {
1074                if let Some(ref val) = entry.value {
1075                    includes.push((val.clone(), None));
1076                }
1077            } else if entry.key.starts_with("includeif.") && entry.key.ends_with(".path") {
1078                // Extract condition from key: includeif.<condition>.path
1079                let mid = &entry.key["includeif.".len()..entry.key.len() - ".path".len()];
1080                if let Some(ref val) = entry.value {
1081                    includes.push((val.clone(), Some(mid.to_owned())));
1082                }
1083            }
1084        }
1085
1086        // Merge the file's own entries
1087        set.merge(file);
1088
1089        // Process includes
1090        if process_includes {
1091            for (inc_path, condition) in includes {
1092                if let Some(ref cond) = condition {
1093                    if !evaluate_include_condition(cond, file) {
1094                        continue;
1095                    }
1096                }
1097
1098                let resolved = resolve_include_path(&inc_path, file.path.parent());
1099                if let Ok(Some(inc_file)) = ConfigFile::from_path(&resolved, file.scope) {
1100                    Self::merge_with_includes(set, &inc_file, true)?;
1101                }
1102            }
1103        }
1104
1105        Ok(())
1106    }
1107}
1108
1109// ── Type coercion helpers ───────────────────────────────────────────
1110
1111/// Parse a Git boolean value.
1112///
1113/// Accepts: `true`, `yes`, `on`, `1` (and bare key / empty) as true.
1114/// Accepts: `false`, `no`, `off`, `0` as false.
1115pub fn parse_bool(s: &str) -> std::result::Result<bool, String> {
1116    match s.to_lowercase().as_str() {
1117        "true" | "yes" | "on" | "" => Ok(true),
1118        "false" | "no" | "off" => Ok(false),
1119        _ => {
1120            // Try parsing as integer: 0 → false, non-zero → true
1121            if let Ok(n) = s.parse::<i64>() {
1122                return Ok(n != 0);
1123            }
1124            Err(format!("bad boolean config value '{s}'"))
1125        }
1126    }
1127}
1128
1129/// Parse a Git integer value with optional `k`/`m`/`g` suffix.
1130pub fn parse_i64(s: &str) -> std::result::Result<i64, String> {
1131    let s = s.trim();
1132    if s.is_empty() {
1133        return Err("empty integer value".to_owned());
1134    }
1135
1136    let (num_str, multiplier) = match s.as_bytes().last() {
1137        Some(b'k' | b'K') => (&s[..s.len() - 1], 1024_i64),
1138        Some(b'm' | b'M') => (&s[..s.len() - 1], 1024 * 1024),
1139        Some(b'g' | b'G') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
1140        _ => (s, 1_i64),
1141    };
1142
1143    let base: i64 = num_str
1144        .parse()
1145        .map_err(|_| format!("invalid integer: '{s}'"))?;
1146    base.checked_mul(multiplier)
1147        .ok_or_else(|| format!("integer overflow: '{s}'"))
1148}
1149
1150/// Parse a Git path value (expand `~/` to home directory).
1151pub fn parse_path(s: &str) -> String {
1152    if let Some(rest) = s.strip_prefix("~/") {
1153        if let Some(home) = home_dir() {
1154            return home.join(rest).to_string_lossy().to_string();
1155        }
1156    }
1157    s.to_owned()
1158}
1159
1160// ── Helpers ─────────────────────────────────────────────────────────
1161
1162/// Parse `GIT_CONFIG_PARAMETERS` — single-quoted `'key=value'` entries
1163/// separated by whitespace.
1164fn parse_config_parameters(raw: &str) -> Vec<String> {
1165    let mut out: Vec<String> = Vec::new();
1166    let mut iter = raw.chars().peekable();
1167    while let Some(&c) = iter.peek() {
1168        if c == '\'' {
1169            iter.next();
1170            let mut s = String::new();
1171            loop {
1172                match iter.next() {
1173                    Some('\'') | None => break,
1174                    Some(x) => s.push(x),
1175                }
1176            }
1177            if !s.is_empty() {
1178                out.push(s);
1179            }
1180        } else {
1181            iter.next();
1182        }
1183    }
1184    out
1185}
1186
1187/// Return candidate paths for the global config file, in priority order.
1188fn global_config_paths() -> Vec<PathBuf> {
1189    let mut paths = Vec::new();
1190
1191    // $GIT_CONFIG_GLOBAL overrides
1192    if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
1193        paths.push(PathBuf::from(p));
1194        return paths;
1195    }
1196
1197    // $HOME/.gitconfig
1198    if let Some(home) = home_dir() {
1199        paths.push(home.join(".gitconfig"));
1200    }
1201
1202    // $XDG_CONFIG_HOME/git/config
1203    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
1204        paths.push(PathBuf::from(xdg).join("git/config"));
1205    } else if let Some(home) = home_dir() {
1206        paths.push(home.join(".config/git/config"));
1207    }
1208
1209    paths
1210}
1211
1212/// Return the user's home directory.
1213fn home_dir() -> Option<PathBuf> {
1214    std::env::var("HOME").ok().map(PathBuf::from)
1215}
1216
1217/// Resolve an include path relative to the including file's directory.
1218fn resolve_include_path(path: &str, base: Option<&Path>) -> PathBuf {
1219    let expanded = parse_path(path);
1220    let p = Path::new(&expanded);
1221    if p.is_absolute() {
1222        p.to_path_buf()
1223    } else if let Some(base) = base {
1224        base.join(p)
1225    } else {
1226        p.to_path_buf()
1227    }
1228}
1229
1230/// Evaluate an `[includeIf]` condition.
1231///
1232/// Currently supports:
1233/// - `gitdir:<pattern>` / `gitdir/i:<pattern>` — match against the git dir.
1234/// - `onbranch:<pattern>` — match against the current branch.
1235fn evaluate_include_condition(condition: &str, _file: &ConfigFile) -> bool {
1236    // TODO: Implement gitdir: and onbranch: matching.
1237    // For now, we skip conditional includes (safe default: don't include).
1238    let _ = condition;
1239    false
1240}
1241
1242/// Split a canonical key into (section, subsection, variable).
1243fn split_key(key: &str) -> Result<(String, Option<String>, String)> {
1244    let first_dot = key
1245        .find('.')
1246        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
1247    let last_dot = key
1248        .rfind('.')
1249        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
1250
1251    let section = key[..first_dot].to_owned();
1252    let variable = key[last_dot + 1..].to_owned();
1253
1254    let subsection = if first_dot == last_dot {
1255        None
1256    } else {
1257        Some(key[first_dot + 1..last_dot].to_owned())
1258    };
1259
1260    Ok((section, subsection, variable))
1261}
1262
1263/// Extract the variable name from a canonical key.
1264#[allow(dead_code)]
1265fn variable_name_from_key(key: &str) -> &str {
1266    match key.rfind('.') {
1267        Some(i) => &key[i + 1..],
1268        None => key,
1269    }
1270}
1271
1272/// Parse a section name that may contain a subsection (e.g. `"remote.origin"`).
1273///
1274/// Returns (section, subsection).
1275fn parse_section_name(name: &str) -> (&str, Option<&str>) {
1276    match name.find('.') {
1277        Some(i) => (&name[..i], Some(&name[i + 1..])),
1278        None => (name, None),
1279    }
1280}
1281
1282/// Extract the original-case variable name from a raw (user-typed) key.
1283///
1284/// E.g. `"Section.Movie"` → `"Movie"`, `"a.b.CamelCase"` → `"CamelCase"`.
1285fn raw_variable_name(raw_key: &str) -> &str {
1286    match raw_key.rfind('.') {
1287        Some(i) => &raw_key[i + 1..],
1288        None => raw_key,
1289    }
1290}
1291
1292/// Extract the original-case section and subsection from a raw (user-typed) key.
1293///
1294/// E.g. `"Section.key"` → `("Section", None)`,
1295///      `"Remote.origin.url"` → `("Remote", Some("origin"))`.
1296fn raw_section_parts(raw_key: &str) -> (String, Option<String>) {
1297    let first_dot = match raw_key.find('.') {
1298        Some(i) => i,
1299        None => return (raw_key.to_owned(), None),
1300    };
1301    // rfind always succeeds here since we already found at least one dot above.
1302    let last_dot = match raw_key.rfind('.') {
1303        Some(i) => i,
1304        None => return (raw_key[..first_dot].to_owned(), None),
1305    };
1306    let section = raw_key[..first_dot].to_owned();
1307    if first_dot == last_dot {
1308        (section, None)
1309    } else {
1310        let subsection = raw_key[first_dot + 1..last_dot].to_owned();
1311        (section, Some(subsection))
1312    }
1313}