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
14pub 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
26pub 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}