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
18pub 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
30pub 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}