Skip to main content

onlyne_client/ops/local_cli/
config.rs

1//! The workspace `config.toml` plugin array: locate the top-level `plugins`
2//! line, rewrite it in place, and fold the legacy `[[plugin]]` blocks the
3//! 1.2.1 installer appended.
4
5use anyhow::{Context, Result, anyhow};
6use onlyne_config::layout::RoleWorkspace;
7use std::path::Path;
8
9/// The top-level `plugins` array line, its parsed ids, and the text the file
10/// keeps after the closing bracket (a trailing comment or the line terminator).
11struct PluginsLine {
12    index: usize,
13    ids: Vec<String>,
14    tail: String,
15}
16
17/// The first top-level `plugins` assignment and any duplicate assignments whose
18/// ids must fold into it.
19struct PluginsEdit {
20    primary: PluginsLine,
21    duplicates: Vec<usize>,
22}
23
24/// Locate the top-level `plugins = [...]` assignment. TOML binds every key
25/// after a table header to that table, so only lines before the first header
26/// are the client's own. Duplicate top-level assignments merge their ids onto
27/// the first line; a `plugins` line whose value is not a single-line array of
28/// plain quoted strings is refused, never guessed at.
29fn find_plugins(text: &str) -> Result<Option<PluginsEdit>> {
30    let mut found = Vec::new();
31    for (index, line) in text.lines().enumerate() {
32        let key = line.trim_end_matches('\r').trim_start();
33        if key.is_empty() || key.starts_with('#') {
34            continue;
35        }
36        if key.starts_with('[') {
37            break;
38        }
39        let Some(value) = key_value(line, "plugins") else {
40            continue;
41        };
42        let (ids, tail) = parse_plugins_value(value, index)?;
43        found.push(PluginsLine { index, ids, tail });
44    }
45    let mut found = found.into_iter();
46    let Some(mut primary) = found.next() else {
47        return Ok(None);
48    };
49    let mut duplicates = Vec::new();
50    for duplicate in found {
51        for id in duplicate.ids {
52            if !primary.ids.contains(&id) {
53                primary.ids.push(id);
54            }
55        }
56        duplicates.push(duplicate.index);
57    }
58    Ok(Some(PluginsEdit {
59        primary,
60        duplicates,
61    }))
62}
63
64/// Parse one physical `plugins` value, naming the line whenever its shape is
65/// outside the closed, single-line form this module can safely rewrite.
66fn parse_plugins_value(value: &str, index: usize) -> Result<(Vec<String>, String)> {
67    parse_inline_array(value).map_err(|error| anyhow!("{error} (line {})", index + 1))
68}
69
70/// Split a `name = value` line into the value text when `name` matches.
71fn key_value<'a>(line: &'a str, name: &str) -> Option<&'a str> {
72    let key = line.trim_end_matches('\r').trim_start();
73    let rest = key.strip_prefix(name)?.trim_start();
74    let value = rest.strip_prefix('=')?.trim_start();
75    Some(value)
76}
77
78/// Parse a one-line inline array of quoted strings. Everything after the
79/// closing bracket must be empty or a comment; the returned tail keeps it.
80fn parse_inline_array(value: &str) -> Result<(Vec<String>, String)> {
81    let value = value.trim_end();
82    let open = value.strip_prefix('[').ok_or_else(|| {
83        anyhow!("onlyne: config.toml keeps `plugins` in a shape this verb cannot edit; write it as plugins = [\"id\"]")
84    })?;
85    let close = open.rfind(']').ok_or_else(|| {
86        anyhow!(
87            "onlyne: config.toml splits the `plugins` array across lines; put every id on one line"
88        )
89    })?;
90    let tail = open[close + 1..].to_string();
91    if !tail.trim().is_empty() && !tail.trim().starts_with('#') {
92        return Err(anyhow!(
93            "onlyne: config.toml has trailing text on the `plugins` line: {value}"
94        ));
95    }
96    let ids = open[..close]
97        .split(',')
98        .map(str::trim)
99        .filter(|item| !item.is_empty())
100        .map(|item| {
101            let bare = item.strip_prefix('"')?.strip_suffix('"')?;
102            if bare.contains(['\\', '"']) {
103                return None;
104            }
105            Some(bare.to_string())
106        })
107        .collect::<Option<Vec<String>>>()
108        .ok_or_else(|| {
109            anyhow!("onlyne: config.toml `plugins` array holds a value this verb cannot edit")
110        })?;
111    Ok((ids, tail))
112}
113
114/// The inline-array spelling this module always writes.
115fn render_array(ids: &[String]) -> String {
116    let items: Vec<String> = ids.iter().map(|id| format!("{id:?}")).collect();
117    format!("[{}]", items.join(", "))
118}
119
120/// The config line the plugin verbs report to the operator.
121pub(super) fn render_plugins(ids: &[String]) -> String {
122    format!("plugins = {}", render_array(ids))
123}
124
125/// Rewrite the top-level `plugins` array line in place, or insert a fresh one
126/// after `key_path` (falling back to just before the first table header, then
127/// to the end of the file). Every other line keeps its exact bytes.
128fn set_plugins(text: &str, ids: &[String]) -> Result<String> {
129    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
130    let body = render_plugins(ids);
131    match find_plugins(text)? {
132        Some(edit) => {
133            let old = &lines[edit.primary.index];
134            let indent = &old[..old.len() - old.trim_start().len()];
135            lines[edit.primary.index] = format!("{indent}{body}{}", edit.primary.tail);
136            for duplicate in edit.duplicates.iter().rev() {
137                lines.remove(*duplicate);
138            }
139        }
140        None => {
141            // Only lines before the first table header are top-level, so a
142            // table that happens to hold a `key_path` key cannot mislead the
143            // insertion point.
144            let first_header = lines
145                .iter()
146                .position(|line| line.trim_end_matches('\r').trim_start().starts_with('['))
147                .unwrap_or(lines.len());
148            let insert_at = lines[..first_header]
149                .iter()
150                .position(|line| key_value(line, "key_path").is_some())
151                .map_or(first_header, |index| index + 1);
152            lines.insert(insert_at, body);
153        }
154    }
155    let mut out = lines.join("\n");
156    if !out.is_empty() && !out.ends_with('\n') {
157        out.push('\n');
158    }
159    Ok(out)
160}
161
162/// Recognize a legacy plugin table header even when it uses spaces or carries
163/// a trailing comment. The header line itself contains no quoted value, so the
164/// first `#` is unambiguously the start of a comment.
165fn is_plugin_table_header(line: &str) -> bool {
166    let line = line.trim_end_matches('\r').trim_start();
167    let line = match line.split_once('#') {
168        Some((header, _)) => header.trim_end(),
169        None => line,
170    };
171    let Some(inner) = line
172        .strip_prefix("[[")
173        .and_then(|line| line.strip_suffix("]]"))
174    else {
175        return false;
176    };
177    inner.trim() == "plugin"
178}
179
180/// Fold `[[plugin]]` blocks (the table form the 1.2.1 installer wrote, which
181/// `ClientConfig` rejects) out of a config text. `only` restricts the fold to
182/// blocks naming that id; every other block stays byte-for-byte. A block that
183/// carries no parsable `id` line names no plugin, so it stays too and the
184/// operator keeps seeing the loader's refusal.
185fn fold_plugin_blocks(text: &str, only: Option<&str>) -> (String, Vec<String>, usize) {
186    let lines: Vec<&str> = text.lines().collect();
187    let ends_with_newline = text.ends_with('\n');
188    let mut kept: Vec<&str> = Vec::with_capacity(lines.len());
189    let mut ids: Vec<String> = Vec::new();
190    let mut folded = 0usize;
191    let mut cursor = 0;
192    while cursor < lines.len() {
193        if !is_plugin_table_header(lines[cursor]) {
194            kept.push(lines[cursor]);
195            cursor += 1;
196            continue;
197        }
198        let start = cursor;
199        cursor += 1;
200        while cursor < lines.len()
201            && !lines[cursor]
202                .trim_end_matches('\r')
203                .trim_start()
204                .starts_with('[')
205        {
206            cursor += 1;
207        }
208        let block_id = block_id_value(&lines[start + 1..cursor]);
209        let owned = match (only, &block_id) {
210            (_, None) => false,
211            (None, Some(_)) => true,
212            (Some(wanted), Some(id)) => id == wanted,
213        };
214        if owned {
215            folded += 1;
216            let id = block_id.unwrap();
217            if !ids.contains(&id) {
218                ids.push(id);
219            }
220        } else {
221            kept.extend_from_slice(&lines[start..cursor]);
222        }
223    }
224    let mut out = kept.join("\n");
225    if ends_with_newline && !out.is_empty() && !out.ends_with('\n') {
226        out.push('\n');
227    }
228    (out, ids, folded)
229}
230
231/// The `id = "..."` entry inside one `[[plugin]]` block.
232fn block_id_value(block: &[&str]) -> Option<String> {
233    block.iter().find_map(|line| {
234        let value = key_value(line, "id")?.trim_end();
235        let bare = value.strip_prefix('"')?.strip_suffix('"')?;
236        if bare.contains(['\\', '"']) {
237            return None;
238        }
239        Some(bare.to_string())
240    })
241}
242
243/// Replace a file through a sibling temp name so a half-written config never
244/// survives a crash.
245fn write_atomic(path: &Path, text: &str) -> Result<()> {
246    let name = path
247        .file_name()
248        .map(|name| format!("{}.tmp", name.to_string_lossy()))
249        .unwrap_or_else(|| "onlyne-config.tmp".to_string());
250    let tmp = path.with_file_name(name);
251    std::fs::write(&tmp, text).with_context(|| format!("write {}", tmp.display()))?;
252    std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
253    Ok(())
254}
255
256/// Whether the workspace config already registers this plugin id. A workspace
257/// with no config file registers nothing.
258pub(super) fn config_lists_plugin(workspace: &Path, plugin_id: &str) -> Result<bool> {
259    let config = RoleWorkspace::resolve(workspace).config_path();
260    let Ok(text) = std::fs::read_to_string(&config) else {
261        return Ok(false);
262    };
263    Ok(find_plugins(&text)?.is_some_and(|edit| edit.primary.ids.iter().any(|id| id == plugin_id)))
264}
265
266/// Register `plugin_id` in the workspace `plugins` array and return the ids
267/// the file lists afterwards. An id the merged array already holds changes the
268/// registered set; duplicate top-level lines still fold onto the first line.
269pub(super) fn append_plugin_entry(workspace: &Path, plugin_id: &str) -> Result<Vec<String>> {
270    let config = RoleWorkspace::resolve(workspace).config_path();
271    let text = std::fs::read_to_string(&config).unwrap_or_default();
272    let edit = find_plugins(&text)?;
273    let has_duplicates = edit
274        .as_ref()
275        .is_some_and(|edit| !edit.duplicates.is_empty());
276    let mut ids = edit.map(|edit| edit.primary.ids).unwrap_or_default();
277    let already_registered = ids.iter().any(|id| id == plugin_id);
278    if !already_registered {
279        ids.push(plugin_id.to_string());
280    }
281    if !already_registered || has_duplicates {
282        let updated = set_plugins(&text, &ids)?;
283        write_atomic(&config, &updated)?;
284    }
285    Ok(ids)
286}
287
288/// Deregister `plugin_id`: drop it from the `plugins` array and fold away any
289/// leftover `[[plugin]]` block naming it, so uninstalling a 1.2.1-era entry
290/// still recovers a workspace that never restarted the client.
291pub(super) fn remove_plugin_entry(workspace: &Path, plugin_id: &str) -> Result<()> {
292    let config = RoleWorkspace::resolve(workspace).config_path();
293    let text =
294        std::fs::read_to_string(&config).with_context(|| format!("read {}", config.display()))?;
295    let (body, _ids, _folded) = fold_plugin_blocks(&text, Some(plugin_id));
296    let Some(edit) = find_plugins(&body)? else {
297        if body != text {
298            write_atomic(&config, &body)?;
299        }
300        return Ok(());
301    };
302    if !edit.primary.ids.iter().any(|id| id == plugin_id) {
303        // The array holds nothing, but a legacy block naming this plugin may
304        // have been folded above; that removal still has to land.
305        if body != text {
306            write_atomic(&config, &body)?;
307        }
308        return Ok(());
309    }
310    let kept: Vec<String> = edit
311        .primary
312        .ids
313        .into_iter()
314        .filter(|id| id != plugin_id)
315        .collect();
316    let updated = set_plugins(&body, &kept)?;
317    write_atomic(&config, &updated)
318}
319
320/// Whether the top-level part of the config repeats `plugins`. This check only
321/// recognizes the key; value safety stays with the parser that will rewrite it.
322fn has_duplicate_plugins_lines(text: &str) -> bool {
323    let mut seen = false;
324    for line in text.lines() {
325        let key = line.trim_end_matches('\r').trim_start();
326        if key.is_empty() || key.starts_with('#') {
327            continue;
328        }
329        if key.starts_with('[') {
330            break;
331        }
332        if key_value(line, "plugins").is_some() {
333            if seen {
334                return true;
335            }
336            seen = true;
337        }
338    }
339    false
340}
341
342/// Self-heal a workspace whose `config.toml` still carries the `[[plugin]]`
343/// tables the 1.2.1 installer appended: merge their ids into the top-level
344/// `plugins` array, drop the blocks, and rewrite the file atomically. Duplicate
345/// top-level `plugins` lines merge onto the first line even when no legacy
346/// block remains. Returns the number of folded blocks; `0` means no block was
347/// folded, so an unrelated parse failure keeps reporting its own serde error.
348/// A `plugins` array this module refuses to edit aborts the fold without
349/// writing, and the loader's refusal then reaches the operator.
350pub fn migrate_plugin_blocks(workspace: &Path) -> Result<usize> {
351    let config = RoleWorkspace::resolve(workspace).config_path();
352    let Ok(text) = std::fs::read_to_string(&config) else {
353        return Ok(0);
354    };
355    let (body, block_ids, folded) = fold_plugin_blocks(&text, None);
356    if folded == 0 && !has_duplicate_plugins_lines(&body) {
357        return Ok(0);
358    }
359    let mut ids = find_plugins(&body)?
360        .map(|edit| edit.primary.ids)
361        .unwrap_or_default();
362    for id in block_ids {
363        if !ids.contains(&id) {
364            ids.push(id);
365        }
366    }
367    let updated = set_plugins(&body, &ids)?;
368    if updated != body {
369        write_atomic(&config, &updated)?;
370    }
371    Ok(folded)
372}
373
374/// Startup hook: fold the legacy blocks and print exactly one operator line
375/// when the workspace self-healed or the fold was refused.
376pub fn heal_workspace_config(workspace: &Path) {
377    match migrate_plugin_blocks(workspace) {
378        Ok(0) => {}
379        Ok(_) => {
380            eprintln!("onlyne: migrated [[plugin]] blocks into plugins = [...]");
381        }
382        Err(error) => eprintln!("{error}"),
383    }
384}