Skip to main content

rmux_core/options/
mutation.rs

1use std::collections::BTreeMap;
2
3use crate::{colour_to_string, command_parser::parse_command_string, parse_colour, Style};
4use rmux_proto::types::OptionScopeSelector;
5use rmux_proto::{OptionName, RmuxError, ScopeSelector, SetOptionMode};
6
7use super::registry::{
8    self, option_metadata, resolve_option_name, DefaultValue, GlobalRoot, OptionMetadata,
9    OptionValueType,
10};
11use super::storage::{ArrayItem, OptionEntry, StoredOptionValue};
12use super::{OptionMutationOutcome, OptionNotification, OptionQuery};
13
14/// Validates an option mutation against the legacy known-option registry.
15pub fn validate_option_mutation(
16    option: OptionName,
17    scope: &ScopeSelector,
18    mode: SetOptionMode,
19    value: &str,
20) -> Result<(), RmuxError> {
21    let query = OptionQuery::known(option);
22    let explicit_scope = legacy_scope_for_option(option, scope);
23    validate_query_mutation(&query, &explicit_scope, mode, Some(value), false)
24}
25
26/// Validates an option mutation against the open string-keyed registry.
27pub fn validate_option_name_mutation(
28    name: &str,
29    scope: &OptionScopeSelector,
30    mode: SetOptionMode,
31    value: Option<&str>,
32    unset: bool,
33) -> Result<OptionQuery, RmuxError> {
34    let query = resolve_option_name(name)?;
35    validate_query_mutation(&query, scope, mode, value, unset)?;
36    Ok(query)
37}
38
39fn validate_query_mutation(
40    query: &OptionQuery,
41    scope: &OptionScopeSelector,
42    mode: SetOptionMode,
43    value: Option<&str>,
44    unset: bool,
45) -> Result<(), RmuxError> {
46    if let Some(metadata) = query.metadata() {
47        if !metadata.supports_scope(scope) {
48            return Err(RmuxError::InvalidSetOption(format!(
49                "{} is only supported at {} scope",
50                query.canonical_name(),
51                allowed_scope_message(metadata),
52            )));
53        }
54    }
55
56    if mode == SetOptionMode::Append
57        && !query.is_array()
58        && !matches!(query.value_type(), OptionValueType::String)
59    {
60        return Err(RmuxError::InvalidSetOption(format!(
61            "{} is not an array option",
62            query.canonical_name()
63        )));
64    }
65
66    if !unset && !query.is_array() {
67        match query.value_type() {
68            OptionValueType::Flag | OptionValueType::Choice(_) => {}
69            _ if query.is_user() && value.is_none() => {
70                return Err(RmuxError::InvalidSetOption("empty value".to_owned()))
71            }
72            OptionValueType::String
73            | OptionValueType::Number { .. }
74            | OptionValueType::Key
75            | OptionValueType::Colour
76            | OptionValueType::Command
77                if value.is_none() =>
78            {
79                return Err(RmuxError::InvalidSetOption("empty value".to_owned()))
80            }
81            _ => {
82                let _ = normalize_scalar_value(query, value, None)?;
83            }
84        }
85    }
86
87    Ok(())
88}
89
90pub(super) fn normalize_scalar_value(
91    query: &OptionQuery,
92    value: Option<&str>,
93    current: Option<&str>,
94) -> Result<StoredOptionValue, RmuxError> {
95    match query.value_type() {
96        OptionValueType::String => {
97            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
98            let next = match current {
99                Some(current) => format!("{current}{}{raw}", query.separator()),
100                None => raw.to_owned(),
101            };
102            if query.canonical_name() == "default-size" && !matches_default_size_pattern(&next) {
103                return Err(RmuxError::InvalidSetOption(format!(
104                    "value is invalid: {next}"
105                )));
106            }
107            if query.effects().contains(registry::EFFECT_STYLE_PARSE)
108                && !next.contains("#{")
109                && normalize_style_string(&next).is_err()
110            {
111                return Err(RmuxError::InvalidSetOption(format!(
112                    "invalid style: {next}"
113                )));
114            }
115            Ok(StoredOptionValue::String(next))
116        }
117        OptionValueType::Number { minimum } => {
118            let parsed = value
119                .ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?
120                .parse::<u32>()
121                .map_err(|_| invalid_number(query.canonical_name(), minimum))?;
122            if parsed < minimum {
123                return Err(invalid_number(query.canonical_name(), minimum));
124            }
125            Ok(StoredOptionValue::Number(parsed))
126        }
127        OptionValueType::Key => {
128            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
129            Ok(StoredOptionValue::Key(normalize_key(raw).ok_or_else(
130                || invalid_integer(query.canonical_name(), "key code"),
131            )?))
132        }
133        OptionValueType::Colour => {
134            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
135            Ok(StoredOptionValue::Colour(normalize_colour(raw).map_err(
136                |_| invalid_integer(query.canonical_name(), "colour value"),
137            )?))
138        }
139        OptionValueType::Flag => {
140            let toggled = match value.map(str::trim) {
141                None | Some("") => !matches!(current, Some("on")),
142                Some(raw) if matches_flag_true(raw) => true,
143                Some(raw) if matches_flag_false(raw) => false,
144                Some(_) => {
145                    return Err(RmuxError::InvalidSetOption(format!(
146                        "{} expects on or off",
147                        query.canonical_name()
148                    )))
149                }
150            };
151            Ok(StoredOptionValue::Flag(toggled))
152        }
153        OptionValueType::Choice(choices) => {
154            let raw = value.unwrap_or_default();
155            if raw.is_empty() {
156                let current = current.unwrap_or(choices[0]);
157                let next = if choices.len() == 2 {
158                    if current == choices[0] {
159                        choices[1]
160                    } else {
161                        choices[0]
162                    }
163                } else {
164                    current
165                };
166                return Ok(StoredOptionValue::Choice(next.to_owned()));
167            }
168            if choices.contains(&raw) {
169                Ok(StoredOptionValue::Choice(raw.to_owned()))
170            } else {
171                Err(RmuxError::InvalidSetOption(format!(
172                    "{} expects one of: {}",
173                    query.canonical_name(),
174                    choices.join(", ")
175                )))
176            }
177        }
178        OptionValueType::Command => {
179            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
180            let commands = parse_command_string(raw).map_err(|error| {
181                RmuxError::InvalidSetOption(format!(
182                    "{} expects a command list: {error}",
183                    query.canonical_name()
184                ))
185            })?;
186            Ok(StoredOptionValue::Command(commands))
187        }
188    }
189}
190
191pub(super) fn apply_array_mutation(
192    entry: &mut OptionEntry,
193    query: &OptionQuery,
194    value: &str,
195    mode: SetOptionMode,
196    current: Option<&str>,
197) -> Result<(), RmuxError> {
198    let separator = query.separator();
199    let indexes = split_array_assignment(value, separator);
200    match (query.index(), mode) {
201        (Some(index), SetOptionMode::Replace) => {
202            let item = array_item_from_value(query, Some(value), None)?;
203            entry.set_array_item(index, item, separator);
204        }
205        (Some(index), SetOptionMode::Append) => {
206            let item = array_item_from_value(query, Some(value), current)?;
207            entry.set_array_item(index, item, separator);
208        }
209        (None, SetOptionMode::Replace) => {
210            entry.clear_array();
211            for item_value in indexes {
212                let next_index = entry.next_array_index();
213                let item = array_item_from_value(query, Some(&item_value), None)?;
214                entry.set_array_item(next_index, item, separator);
215            }
216        }
217        (None, SetOptionMode::Append) => {
218            for item_value in indexes {
219                let next_index = entry.next_array_index();
220                let item = array_item_from_value(query, Some(&item_value), None)?;
221                entry.set_array_item(next_index, item, separator);
222            }
223        }
224    }
225    Ok(())
226}
227
228fn array_item_from_value(
229    query: &OptionQuery,
230    value: Option<&str>,
231    current: Option<&str>,
232) -> Result<ArrayItem, RmuxError> {
233    let normalized = match current {
234        Some(current)
235            if query.index().is_some() && matches!(query.value_type(), OptionValueType::String) =>
236        {
237            let joined = format!("{current}{}", value.unwrap_or_default());
238            normalize_scalar_value(query, Some(&joined), None)?
239        }
240        _ => normalize_scalar_value(query, value, None)?,
241    };
242    Ok(ArrayItem::new(normalized))
243}
244
245pub(super) fn default_array_items(
246    query: &OptionQuery,
247    default: DefaultValue,
248) -> Result<BTreeMap<u32, ArrayItem>, RmuxError> {
249    let mut items = BTreeMap::new();
250    match default {
251        DefaultValue::Scalar(value) => {
252            for (index, item) in split_array_assignment(value, query.separator())
253                .into_iter()
254                .enumerate()
255            {
256                items.insert(
257                    index as u32,
258                    array_item_from_value(query, Some(&item), None)?,
259                );
260            }
261        }
262        DefaultValue::Array(values) => {
263            for (index, item) in values.iter().enumerate() {
264                items.insert(
265                    index as u32,
266                    array_item_from_value(query, Some(item), None)?,
267                );
268            }
269        }
270    }
271    Ok(items)
272}
273
274pub(super) fn split_array_assignment(value: &str, separator: &str) -> Vec<String> {
275    if separator.is_empty() {
276        return vec![value.to_owned()];
277    }
278    if separator.contains(',') {
279        return value
280            .split(',')
281            .map(str::trim)
282            .filter(|segment| !segment.is_empty())
283            .map(str::to_owned)
284            .collect();
285    }
286    value
287        .split(separator)
288        .map(str::trim)
289        .filter(|segment| !segment.is_empty())
290        .map(str::to_owned)
291        .collect()
292}
293
294pub(super) fn default_scalar_text(default: DefaultValue) -> &'static str {
295    match default {
296        DefaultValue::Scalar(value) => value,
297        DefaultValue::Array(_) => "",
298    }
299}
300
301pub(super) fn build_mutation_outcome(
302    query: &OptionQuery,
303    scope: OptionScopeSelector,
304) -> OptionMutationOutcome {
305    let notification = OptionNotification {
306        name: query.canonical_name().to_owned(),
307        scope: scope.clone(),
308        effects: query.effects(),
309    };
310    OptionMutationOutcome {
311        name: query.canonical_name().to_owned(),
312        known_option: query.known_option(),
313        notifications: if notification.effects.is_empty() {
314            Vec::new()
315        } else {
316            vec![notification]
317        },
318    }
319}
320
321pub(super) fn legacy_scope_for_option(
322    option: OptionName,
323    scope: &ScopeSelector,
324) -> OptionScopeSelector {
325    match scope {
326        ScopeSelector::Global => match option_metadata(option).global_root() {
327            GlobalRoot::Server => OptionScopeSelector::ServerGlobal,
328            GlobalRoot::Session => OptionScopeSelector::SessionGlobal,
329            GlobalRoot::Window => OptionScopeSelector::WindowGlobal,
330        },
331        ScopeSelector::Session(session_name) => OptionScopeSelector::Session(session_name.clone()),
332        ScopeSelector::Window(target) => OptionScopeSelector::Window(target.clone()),
333        ScopeSelector::Pane(target) => OptionScopeSelector::Pane(target.clone()),
334    }
335}
336
337pub(super) fn is_global_scope(scope: &OptionScopeSelector) -> bool {
338    matches!(
339        scope,
340        OptionScopeSelector::ServerGlobal
341            | OptionScopeSelector::SessionGlobal
342            | OptionScopeSelector::WindowGlobal
343    )
344}
345
346fn allowed_scope_message(metadata: &OptionMetadata) -> String {
347    let mut scopes = Vec::new();
348    if metadata.scope_mask() & registry::SCOPE_SERVER != 0 {
349        scopes.push("global");
350    }
351    if metadata.scope_mask() & registry::SCOPE_SESSION != 0 {
352        scopes.push("session");
353    }
354    if metadata.scope_mask() & registry::SCOPE_WINDOW != 0 {
355        scopes.push("window");
356    }
357    if metadata.scope_mask() & registry::SCOPE_PANE != 0 {
358        scopes.push("pane");
359    }
360    scopes.join(" or ")
361}
362
363fn invalid_number(name: &str, minimum: u32) -> RmuxError {
364    RmuxError::InvalidSetOption(format!(
365        "{name} expects a number greater than or equal to {minimum}"
366    ))
367}
368
369fn invalid_integer(name: &str, label: &str) -> RmuxError {
370    RmuxError::InvalidSetOption(format!("{name} expects a {label}"))
371}
372
373fn matches_default_size_pattern(value: &str) -> bool {
374    let Some((width, height)) = value.split_once('x') else {
375        return false;
376    };
377    !width.is_empty()
378        && !height.is_empty()
379        && width.chars().all(|character| character.is_ascii_digit())
380        && height.chars().all(|character| character.is_ascii_digit())
381}
382
383fn normalize_key(value: &str) -> Option<String> {
384    let mut rest = value.trim();
385    if rest.is_empty() {
386        return None;
387    }
388
389    let mut ctrl = false;
390    let mut meta = false;
391    let mut shift = false;
392    while let Some((prefix, tail)) = rest.split_once('-') {
393        match prefix.to_ascii_lowercase().as_str() {
394            "c" => ctrl = true,
395            "m" => meta = true,
396            "s" => shift = true,
397            _ => break,
398        }
399        rest = tail;
400    }
401
402    if rest.is_empty() {
403        return None;
404    }
405
406    let tail = match rest.to_ascii_lowercase().as_str() {
407        "none" => "None".to_owned(),
408        "bspace" => "BSpace".to_owned(),
409        "enter" => "Enter".to_owned(),
410        "space" => "Space".to_owned(),
411        "tab" => "Tab".to_owned(),
412        "up" => "Up".to_owned(),
413        "down" => "Down".to_owned(),
414        "left" => "Left".to_owned(),
415        "right" => "Right".to_owned(),
416        "home" => "Home".to_owned(),
417        "end" => "End".to_owned(),
418        "escape" | "esc" => "Escape".to_owned(),
419        _ if rest.starts_with('F')
420            && rest[1..]
421                .chars()
422                .all(|character| character.is_ascii_digit()) =>
423        {
424            rest.to_owned()
425        }
426        _ if rest.starts_with('f')
427            && rest[1..]
428                .chars()
429                .all(|character| character.is_ascii_digit()) =>
430        {
431            format!("F{}", &rest[1..])
432        }
433        _ if rest.chars().count() == 1 => {
434            let character = rest.chars().next().expect("single-char tail");
435            if ctrl && character.is_ascii_alphabetic() {
436                character.to_ascii_lowercase().to_string()
437            } else {
438                character.to_string()
439            }
440        }
441        _ => return None,
442    };
443
444    let mut normalized = String::new();
445    if ctrl {
446        normalized.push_str("C-");
447    }
448    if meta {
449        normalized.push_str("M-");
450    }
451    if shift {
452        normalized.push_str("S-");
453    }
454    normalized.push_str(&tail);
455    Some(normalized)
456}
457
458fn normalize_colour(value: &str) -> Result<String, ()> {
459    let trimmed = value.trim();
460    if trimmed.is_empty() {
461        return Err(());
462    }
463    Ok(colour_to_string(parse_colour(trimmed).map_err(|_| ())?))
464}
465
466fn normalize_style_string(value: &str) -> Result<(), ()> {
467    Style::parse(value).map(|_| ()).map_err(|_| ())
468}
469
470fn matches_flag_true(value: &str) -> bool {
471    matches!(value.to_ascii_lowercase().as_str(), "on" | "1" | "yes")
472}
473
474fn matches_flag_false(value: &str) -> bool {
475    matches!(value.to_ascii_lowercase().as_str(), "off" | "0" | "no")
476}