Skip to main content

rmux_core/options/
mutation.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    colour_to_string, command_parser::parse_command_string, key_string_lookup_key,
5    key_string_lookup_string, parse_colour, Style, KEYC_UNKNOWN,
6};
7use rmux_proto::types::OptionScopeSelector;
8use rmux_proto::{OptionName, RmuxError, ScopeSelector, SetOptionMode};
9
10use super::registry::{
11    self, option_metadata, resolve_option_name, DefaultValue, GlobalRoot, OptionValueType,
12};
13use super::storage::{ArrayItem, OptionEntry, StoredOptionValue};
14use super::{OptionMutationOutcome, OptionNotification, OptionQuery};
15
16const TMUX_NUMBER_MAX: i64 = i32::MAX as i64;
17
18/// Validates an option mutation against the legacy known-option registry.
19pub fn validate_option_mutation(
20    option: OptionName,
21    scope: &ScopeSelector,
22    mode: SetOptionMode,
23    value: &str,
24) -> Result<(), RmuxError> {
25    let query = OptionQuery::known(option);
26    let explicit_scope = legacy_scope_for_option(option, scope);
27    validate_query_mutation(&query, &explicit_scope, mode, Some(value), false)
28}
29
30/// Validates an option mutation against the open string-keyed registry.
31pub fn validate_option_name_mutation(
32    name: &str,
33    scope: &OptionScopeSelector,
34    mode: SetOptionMode,
35    value: Option<&str>,
36    unset: bool,
37) -> Result<OptionQuery, RmuxError> {
38    let query = resolve_option_name(name)?;
39    validate_query_mutation(&query, scope, mode, value, unset)?;
40    Ok(query)
41}
42
43fn validate_query_mutation(
44    query: &OptionQuery,
45    _scope: &OptionScopeSelector,
46    _mode: SetOptionMode,
47    value: Option<&str>,
48    unset: bool,
49) -> Result<(), RmuxError> {
50    if !unset && !query.is_array() {
51        match query.value_type() {
52            OptionValueType::Flag | OptionValueType::Choice(_) => {}
53            _ if query.is_user() && value.is_none() => {
54                return Err(RmuxError::InvalidSetOption("empty value".to_owned()))
55            }
56            OptionValueType::String
57            | OptionValueType::Number { .. }
58            | OptionValueType::Key
59            | OptionValueType::Colour
60            | OptionValueType::Command
61                if value.is_none() =>
62            {
63                return Err(RmuxError::InvalidSetOption("empty value".to_owned()))
64            }
65            _ => {
66                let _ = normalize_scalar_value(query, value, None)?;
67            }
68        }
69    }
70
71    Ok(())
72}
73
74pub(super) fn normalize_scalar_value(
75    query: &OptionQuery,
76    value: Option<&str>,
77    current: Option<&str>,
78) -> Result<StoredOptionValue, RmuxError> {
79    match query.value_type() {
80        OptionValueType::String => {
81            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
82            let next = match current {
83                Some(current) => format!("{current}{}{raw}", query.separator()),
84                None => raw.to_owned(),
85            };
86            if query.canonical_name() == "default-size" && !matches_default_size_pattern(&next) {
87                return Err(RmuxError::InvalidSetOption(format!(
88                    "value is invalid: {next}"
89                )));
90            }
91            if query.effects().contains(registry::EFFECT_STYLE_PARSE)
92                && !next.contains("#{")
93                && normalize_style_string(&next).is_err()
94            {
95                return Err(RmuxError::InvalidSetOption(format!(
96                    "invalid style: {next}"
97                )));
98            }
99            Ok(StoredOptionValue::String(next))
100        }
101        OptionValueType::Number { minimum } => {
102            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
103            let parsed = raw
104                .parse::<i64>()
105                .map_err(|_| RmuxError::InvalidSetOption(format!("value is invalid: {raw}")))?;
106            if parsed < i64::from(minimum) {
107                return Err(RmuxError::InvalidSetOption(format!(
108                    "value is too small: {raw}"
109                )));
110            }
111            if parsed > TMUX_NUMBER_MAX {
112                return Err(RmuxError::InvalidSetOption(format!(
113                    "value is too large: {raw}"
114                )));
115            }
116            let parsed = u32::try_from(parsed)
117                .map_err(|_| RmuxError::InvalidSetOption(format!("value is too large: {raw}")))?;
118            Ok(StoredOptionValue::Number(parsed))
119        }
120        OptionValueType::Key => {
121            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
122            Ok(StoredOptionValue::Key(normalize_key(raw).ok_or_else(
123                || invalid_integer(query.canonical_name(), "key code"),
124            )?))
125        }
126        OptionValueType::Colour => {
127            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
128            Ok(StoredOptionValue::Colour(normalize_colour(raw).map_err(
129                |_| invalid_integer(query.canonical_name(), "colour value"),
130            )?))
131        }
132        OptionValueType::Flag => {
133            let toggled = match value.map(str::trim) {
134                None | Some("") => !matches!(current, Some("on")),
135                Some(raw) if matches_flag_true(raw) => true,
136                Some(raw) if matches_flag_false(raw) => false,
137                Some(raw) => return Err(RmuxError::InvalidSetOption(format!("bad value: {raw}"))),
138            };
139            Ok(StoredOptionValue::Flag(toggled))
140        }
141        OptionValueType::Choice(choices) => {
142            let Some(raw) = value else {
143                let current = current.unwrap_or(choices[0]);
144                let Some(index) = choices.iter().position(|choice| *choice == current) else {
145                    return Ok(StoredOptionValue::Choice(current.to_owned()));
146                };
147                let next = if index < 2 {
148                    choices[1 - index]
149                } else {
150                    choices[index]
151                };
152                return Ok(StoredOptionValue::Choice(next.to_owned()));
153            };
154            if choices.contains(&raw) {
155                Ok(StoredOptionValue::Choice(raw.to_owned()))
156            } else {
157                Err(RmuxError::InvalidSetOption(format!("unknown value: {raw}")))
158            }
159        }
160        OptionValueType::Command => {
161            let raw = value.ok_or_else(|| RmuxError::InvalidSetOption("empty value".to_owned()))?;
162            let commands = parse_command_string(raw).map_err(|error| {
163                RmuxError::InvalidSetOption(format!(
164                    "{} expects a command list: {error}",
165                    query.canonical_name()
166                ))
167            })?;
168            Ok(StoredOptionValue::Command(commands))
169        }
170    }
171}
172
173pub(super) fn apply_array_mutation(
174    entry: &mut OptionEntry,
175    query: &OptionQuery,
176    value: &str,
177    mode: SetOptionMode,
178    current: Option<&str>,
179) -> Result<(), RmuxError> {
180    let separator = query.separator();
181    let indexes = split_array_assignment(value, separator);
182    match (query.index(), mode) {
183        (Some(index), SetOptionMode::Replace) => {
184            let item = array_item_from_value(query, Some(value), None)?;
185            entry.set_array_item(index, item, separator);
186        }
187        (Some(index), SetOptionMode::Append) => {
188            let item = array_item_from_value(query, Some(value), current)?;
189            entry.set_array_item(index, item, separator);
190        }
191        (None, SetOptionMode::Replace) => {
192            entry.clear_array();
193            for item_value in indexes {
194                let next_index = entry.next_array_index();
195                let item = array_item_from_value(query, Some(&item_value), None)?;
196                entry.set_array_item(next_index, item, separator);
197            }
198        }
199        (None, SetOptionMode::Append) => {
200            for item_value in indexes {
201                let next_index = entry.next_array_index();
202                let item = array_item_from_value(query, Some(&item_value), None)?;
203                entry.set_array_item(next_index, item, separator);
204            }
205        }
206    }
207    Ok(())
208}
209
210fn array_item_from_value(
211    query: &OptionQuery,
212    value: Option<&str>,
213    current: Option<&str>,
214) -> Result<ArrayItem, RmuxError> {
215    let normalized = match current {
216        Some(current)
217            if query.index().is_some() && matches!(query.value_type(), OptionValueType::String) =>
218        {
219            let joined = format!("{current}{}", value.unwrap_or_default());
220            normalize_scalar_value(query, Some(&joined), None)?
221        }
222        _ => normalize_scalar_value(query, value, None)?,
223    };
224    Ok(ArrayItem::new(normalized))
225}
226
227pub(super) fn default_array_items(
228    query: &OptionQuery,
229    default: DefaultValue,
230) -> Result<BTreeMap<u32, ArrayItem>, RmuxError> {
231    let mut items = BTreeMap::new();
232    match default {
233        DefaultValue::Scalar(value) => {
234            for (index, item) in split_array_assignment(value, query.separator())
235                .into_iter()
236                .enumerate()
237            {
238                items.insert(
239                    index as u32,
240                    array_item_from_value(query, Some(&item), None)?,
241                );
242            }
243        }
244        DefaultValue::Array(values) => {
245            for (index, item) in values.iter().enumerate() {
246                items.insert(
247                    index as u32,
248                    array_item_from_value(query, Some(item), None)?,
249                );
250            }
251        }
252    }
253    Ok(items)
254}
255
256pub(super) fn split_array_assignment(value: &str, separator: &str) -> Vec<String> {
257    if separator.is_empty() {
258        return vec![value.to_owned()];
259    }
260    if separator.contains(',') {
261        return value
262            .split(',')
263            .map(str::trim)
264            .filter(|segment| !segment.is_empty())
265            .map(str::to_owned)
266            .collect();
267    }
268    value
269        .split(separator)
270        .map(str::trim)
271        .filter(|segment| !segment.is_empty())
272        .map(str::to_owned)
273        .collect()
274}
275
276pub(super) fn default_scalar_text(default: DefaultValue) -> &'static str {
277    match default {
278        DefaultValue::Scalar(value) => value,
279        DefaultValue::Array(_) => "",
280    }
281}
282
283pub(super) fn build_mutation_outcome(
284    query: &OptionQuery,
285    scope: OptionScopeSelector,
286    old_explicit: Option<String>,
287    new_explicit: Option<String>,
288) -> OptionMutationOutcome {
289    let notification = OptionNotification {
290        name: query.canonical_name().to_owned(),
291        scope: scope.clone(),
292        effects: query.effects(),
293    };
294    let changed = old_explicit != new_explicit;
295    OptionMutationOutcome {
296        name: query.canonical_name().to_owned(),
297        scope,
298        known_option: query.known_option(),
299        old_explicit,
300        new_explicit,
301        changed,
302        notifications: if notification.effects.is_empty() {
303            Vec::new()
304        } else {
305            vec![notification]
306        },
307        related: Vec::new(),
308    }
309}
310
311pub(super) fn legacy_scope_for_option(
312    option: OptionName,
313    scope: &ScopeSelector,
314) -> OptionScopeSelector {
315    match scope {
316        ScopeSelector::Global => match option_metadata(option).global_root() {
317            GlobalRoot::Server => OptionScopeSelector::ServerGlobal,
318            GlobalRoot::Session => OptionScopeSelector::SessionGlobal,
319            GlobalRoot::Window => OptionScopeSelector::WindowGlobal,
320        },
321        ScopeSelector::Session(session_name) => OptionScopeSelector::Session(session_name.clone()),
322        ScopeSelector::Window(target) => OptionScopeSelector::Window(target.clone()),
323        ScopeSelector::Pane(target) => OptionScopeSelector::Pane(target.clone()),
324    }
325}
326
327pub(super) fn is_global_scope(scope: &OptionScopeSelector) -> bool {
328    matches!(
329        scope,
330        OptionScopeSelector::ServerGlobal
331            | OptionScopeSelector::SessionGlobal
332            | OptionScopeSelector::WindowGlobal
333    )
334}
335
336fn invalid_integer(name: &str, label: &str) -> RmuxError {
337    RmuxError::InvalidSetOption(format!("{name} expects a {label}"))
338}
339
340fn matches_default_size_pattern(value: &str) -> bool {
341    let Some((width, height)) = value.split_once('x') else {
342        return false;
343    };
344    !width.is_empty()
345        && !height.is_empty()
346        && width.chars().all(|character| character.is_ascii_digit())
347        && height.chars().all(|character| character.is_ascii_digit())
348}
349
350fn normalize_key(value: &str) -> Option<String> {
351    let key = key_string_lookup_string(value.trim())?;
352    if key == KEYC_UNKNOWN {
353        return None;
354    }
355    Some(key_string_lookup_key(key, false))
356}
357
358fn normalize_colour(value: &str) -> Result<String, ()> {
359    let trimmed = value.trim();
360    if trimmed.is_empty() {
361        return Err(());
362    }
363    Ok(colour_to_string(parse_colour(trimmed).map_err(|_| ())?))
364}
365
366fn normalize_style_string(value: &str) -> Result<(), ()> {
367    Style::parse(value).map(|_| ()).map_err(|_| ())
368}
369
370fn matches_flag_true(value: &str) -> bool {
371    matches!(value.to_ascii_lowercase().as_str(), "on" | "1" | "yes")
372}
373
374fn matches_flag_false(value: &str) -> bool {
375    matches!(value.to_ascii_lowercase().as_str(), "off" | "0" | "no")
376}