onlyne_client/ops/local_cli/
config.rs1use anyhow::{Context, Result, anyhow};
6use onlyne_config::layout::RoleWorkspace;
7use std::path::Path;
8
9struct PluginsLine {
12 index: usize,
13 ids: Vec<String>,
14 tail: String,
15}
16
17struct PluginsEdit {
20 primary: PluginsLine,
21 duplicates: Vec<usize>,
22}
23
24fn 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
64fn 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
70fn 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
78fn 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
114fn render_array(ids: &[String]) -> String {
116 let items: Vec<String> = ids.iter().map(|id| format!("{id:?}")).collect();
117 format!("[{}]", items.join(", "))
118}
119
120pub(super) fn render_plugins(ids: &[String]) -> String {
122 format!("plugins = {}", render_array(ids))
123}
124
125fn 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 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
162fn 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
180fn 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
231fn 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
243fn 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
256pub(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
266pub(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
288pub(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 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
320fn 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
342pub 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
374pub 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}