1use std::fmt;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use crate::error::{Error, Result};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum ConfigScope {
38 System,
40 Global,
42 Local,
44 Worktree,
46 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#[derive(Debug, Clone)]
64pub struct ConfigEntry {
65 pub key: String,
68 pub value: Option<String>,
70 pub scope: ConfigScope,
72 pub file: Option<PathBuf>,
74 pub line: usize,
76}
77
78#[derive(Debug, Clone)]
81pub struct ConfigFile {
82 pub path: PathBuf,
84 pub scope: ConfigScope,
86 pub entries: Vec<ConfigEntry>,
88 raw_lines: Vec<String>,
90}
91
92#[derive(Debug, Clone, Default)]
97pub struct ConfigSet {
98 entries: Vec<ConfigEntry>,
100}
101
102pub fn canonical_key(raw: &str) -> Result<String> {
118 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 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 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 Ok(format!(
162 "{}.{}",
163 section.to_lowercase(),
164 name.to_lowercase()
165 ))
166 } else {
167 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
178struct 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 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 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 let end = {
222 let bytes = trimmed.as_bytes();
223 let mut i = 1; 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; 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 if let Some(quote_start) = inside.find('"') {
254 self.section = inside[..quote_start].trim().to_owned();
255 let rest = &inside[quote_start + 1..];
256 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 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 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 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 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 let raw_name = strip_inline_comment(trimmed);
317 let key = self.make_key(raw_name.trim());
318 Some((key, None))
319 }
320 }
321}
322
323fn 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 let value_part = match trimmed.find('=') {
336 Some(pos) => &trimmed[pos + 1..],
337 None => return false,
338 };
339 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 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 last_was_backslash && !in_comment
369}
370
371fn 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 let trimmed = result.trim_end();
395 trimmed.to_owned()
396}
397
398fn 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 '"' => { }
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
423fn 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
471fn 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 c.to_owned()
484 } else if c.starts_with('#') {
485 format!(" {c}")
487 } else {
488 format!(" # {c}")
490 }
491 }
492 }
493}
494
495impl ConfigFile {
496 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 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 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 let mut logical_line = line.clone();
548 while value_line_continues(&logical_line) && idx < raw_lines.len() {
549 let t = logical_line.trim_end();
551 logical_line = t[..t.len() - 1].to_string();
552 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 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 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
604 self.set_with_comment(key, value, None)
605 }
606
607 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 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 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 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 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 §ion,
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 let insert_at = self.last_line_in_section(section_line) + 1;
654 self.raw_lines.insert(insert_at, new_line);
655
656 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 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 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 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 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 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 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 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 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 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 §ion,
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 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 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 fn remove_entry_line(&mut self, line_idx: usize) {
806 if is_section_header_with_inline_entry(&self.raw_lines[line_idx]) {
807 let header = extract_section_header(&self.raw_lines[line_idx]);
809 self.raw_lines[line_idx] = header;
810 } else {
811 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 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 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 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 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 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 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 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 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 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 pub fn add_value(&mut self, key: &str, value: &str) -> Result<()> {
1015 self.add_value_with_comment(key, value, None)
1016 }
1017
1018 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 §ion,
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 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 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 if is_section_header_with_inline_entry(line) {
1066 continue;
1067 }
1068 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_entries = true;
1082 break;
1083 }
1084 has_entries = true;
1086 break;
1087 }
1088 if !has_entries {
1089 to_remove.push(i);
1090 }
1091 }
1092
1093 for &idx in to_remove.iter().rev() {
1095 self.raw_lines.remove(idx);
1096 }
1097
1098 while self.raw_lines.last().is_some_and(|l| l.trim().is_empty()) {
1100 self.raw_lines.pop();
1101 }
1102 }
1103
1104 pub fn write(&self) -> Result<()> {
1109 let content = self.raw_lines.join("\n");
1110 let trimmed = content.trim();
1111 if trimmed.is_empty() {
1112 fs::write(&self.path, "")?;
1114 } else {
1115 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 #[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 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 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 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 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
1200impl ConfigSet {
1203 #[must_use]
1205 pub fn new() -> Self {
1206 Self {
1207 entries: Vec::new(),
1208 }
1209 }
1210
1211 #[must_use]
1213 pub fn entries(&self) -> &[ConfigEntry] {
1214 &self.entries
1215 }
1216
1217 pub fn merge(&mut self, file: &ConfigFile) {
1222 self.entries.extend(file.entries.iter().cloned());
1223 }
1224
1225 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 #[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 #[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 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 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 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 pub fn load(git_dir: Option<&Path>, include_system: bool) -> Result<Self> {
1307 let mut set = Self::new();
1308
1309 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 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; }
1325 }
1326
1327 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 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 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 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 if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
1364 for entry in parse_config_parameters(¶ms) {
1365 if let Some((key, val)) = entry.split_once('=') {
1366 let _ = set.add_command_override(key.trim(), val);
1367 } else {
1368 let _ = set.add_command_override(entry.trim(), "true");
1370 }
1371 }
1372 }
1373
1374 Ok(set)
1375 }
1376
1377 fn merge_with_includes(
1379 set: &mut Self,
1380 file: &ConfigFile,
1381 process_includes: bool,
1382 depth: usize,
1383 ) -> Result<()> {
1384 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 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 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 set.merge(file);
1411
1412 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
1432pub 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 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
1458pub 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
1479pub 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
1555pub 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
1569pub 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
1610pub 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
1674pub 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
1686pub 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 }
1698 } else {
1699 Some(parse_path(s))
1700 }
1701}
1702
1703fn 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
1778pub 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 if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
1789 paths.push(PathBuf::from(p));
1790 return paths;
1791 }
1792
1793 if let Some(home) = home_dir() {
1795 paths.push(home.join(".gitconfig"));
1796 }
1797
1798 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
1808fn home_dir() -> Option<PathBuf> {
1810 std::env::var("HOME").ok().map(PathBuf::from)
1811}
1812
1813fn 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
1826fn evaluate_include_condition(condition: &str, _file: &ConfigFile) -> bool {
1832 let _ = condition;
1835 false
1836}
1837
1838fn 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#[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
1868fn 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
1878fn 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
1888fn 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 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
1911fn 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 !after.is_empty() && !after.starts_with('#') && !after.starts_with(';')
1924}
1925
1926fn 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 trimmed[..=end].to_owned()
1937}