Skip to main content

pacenotes_installer\install/
ini.rs

1/// What `replace_key` found: the key replaced, or where it has to go.
2enum Search {
3    Replaced,
4    /// The section ends before the line at this index.
5    SectionEndsAt(usize),
6    SectionRunsToEnd,
7    NoSection,
8}
9
10/// Sets one key in one section and leaves every other byte as it was, since
11/// the file is the package author's and parsing it into a map would rewrite it.
12///
13/// Comment lines never match, the spacing around `=` and any inline comment are
14/// kept, line endings stay the file's, and a missing key joins its own section
15/// rather than the end of the file.
16pub fn set_ini_key(text: &str, section: &str, key: &str, value: &str) -> String {
17    let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
18    let ends_with_newline = text.ends_with('\n') || text.is_empty();
19
20    let mut lines: Vec<String> = text
21        .split('\n')
22        .map(|line| line.strip_suffix('\r').unwrap_or(line).to_string())
23        .collect();
24    // The empty piece after a final newline is put back when joining.
25    if ends_with_newline && lines.last().is_some_and(|line| line.is_empty()) {
26        lines.pop();
27    }
28
29    let entry = format!("{key}={value}");
30    match replace_key(&mut lines, section, key, value) {
31        Search::Replaced => {}
32        Search::SectionEndsAt(at) => lines.insert(at, entry),
33        Search::SectionRunsToEnd => {
34            let after_last_content = lines
35                .iter()
36                .rposition(|line| !line.trim().is_empty())
37                .map_or(lines.len(), |at| at + 1);
38            lines.insert(after_last_content, entry);
39        }
40        Search::NoSection => {
41            if lines.last().is_some_and(|line| !line.trim().is_empty()) {
42                lines.push(String::new());
43            }
44            lines.push(format!("[{section}]"));
45            lines.push(entry);
46        }
47    }
48
49    let mut out = lines.join(newline);
50    if ends_with_newline && !out.is_empty() {
51        out.push_str(newline);
52    }
53    out
54}
55
56fn replace_key(lines: &mut [String], section: &str, key: &str, value: &str) -> Search {
57    let mut in_section = false;
58    let mut section_seen = false;
59    let mut section_end = None;
60    for (index, line) in lines.iter_mut().enumerate() {
61        let trimmed = line.trim();
62        if let Some(name) = trimmed
63            .strip_prefix('[')
64            .and_then(|rest| rest.strip_suffix(']'))
65        {
66            if in_section {
67                section_end = Some(index);
68            }
69            in_section = name.eq_ignore_ascii_case(section);
70            section_seen |= in_section;
71            continue;
72        }
73        if !in_section || trimmed.starts_with(';') || trimmed.starts_with('#') {
74            continue;
75        }
76        let Some((left, right)) = line.split_once('=') else {
77            continue;
78        };
79        if left.trim().eq_ignore_ascii_case(key) {
80            let indent: String = right.chars().take_while(|c| c.is_whitespace()).collect();
81            let tail = inline_comment(right).unwrap_or("");
82            *line = format!("{left}={indent}{value}{tail}");
83            return Search::Replaced;
84        }
85    }
86    match (section_seen, section_end) {
87        (true, Some(at)) => Search::SectionEndsAt(at),
88        (true, None) => Search::SectionRunsToEnd,
89        (false, _) => Search::NoSection,
90    }
91}
92
93/// The whitespace-led `;` or `#` comment at the end of a value, with its
94/// alignment; a `;` inside a value, as in `a;b;c`, is not one.
95fn inline_comment(value: &str) -> Option<&str> {
96    value.char_indices().find_map(|(at, c)| {
97        let before = &value[..at];
98        let starts_comment = (c == ';' || c == '#') && before.ends_with(char::is_whitespace);
99        starts_comment.then(|| &value[before.trim_end().len()..])
100    })
101}
102
103#[cfg(test)]
104mod tests;