Skip to main content

workshop_rs/settings/schema/
operations.rs

1use std::ops::Range;
2
3use super::super::table::{self, KeyKind};
4use super::*;
5use crate::gameplay::{AbilityVariant, GameplayDataError, HeroId, LogicalSlot, data};
6
7impl SettingSourceEdit {
8    pub fn range(&self) -> Range<usize> {
9        self.edit.range()
10    }
11
12    pub fn replacement(&self) -> &str {
13        self.edit.replacement()
14    }
15
16    /// Apply this edit when the targeted source bytes are unchanged.
17    pub fn apply(&self, source: &str) -> Result<String, SettingOperationError> {
18        self.edit
19            .apply(source)
20            .map_err(|_| SettingOperationError::SourceMismatch)
21    }
22}
23
24impl SettingPresentation {
25    pub fn localized_name(&self, locale: &str) -> Option<&'static str> {
26        if locale.eq_ignore_ascii_case("en-US") {
27            Some(self.english_name)
28        } else {
29            table::localized_name(locale, self.locale_section, self.english_name)
30        }
31    }
32}
33
34impl SettingDefinition {
35    pub fn identity(&self) -> &SettingIdentity {
36        &self.identity
37    }
38
39    pub fn id(&self) -> Option<&SettingId> {
40        match &self.identity {
41            SettingIdentity::Known(id) => Some(id),
42            SettingIdentity::Unknown => None,
43        }
44    }
45
46    pub fn scope(&self) -> SettingScope {
47        self.scope
48    }
49
50    pub fn path(&self) -> &str {
51        &self.path
52    }
53
54    pub fn domain(&self) -> &SettingValueDomain {
55        &self.domain
56    }
57
58    /// Enumerate the accepted members when this setting uses an enum value.
59    /// Boolean settings backed by an enum token (such as `Enabled`) expose
60    /// that token here as well.
61    pub fn enum_members(&self) -> impl Iterator<Item = SettingEnumMember> + '_ {
62        self.enum_domain
63            .into_iter()
64            .flat_map(table::enum_members)
65            .map(|member| SettingEnumMember {
66                domain: member.domain,
67                id: member.member,
68                english_name: member.name,
69            })
70    }
71
72    pub fn target_kind(&self) -> SettingTargetKind {
73        match &self.target {
74            TargetPattern::Global => SettingTargetKind::Global,
75            TargetPattern::Mode(_) => SettingTargetKind::Mode,
76            TargetPattern::Team(_) => SettingTargetKind::Team,
77            TargetPattern::TeamAbility { slot, variant, .. } => SettingTargetKind::TeamAbility {
78                slot: slot.clone(),
79                variant: variant.clone(),
80            },
81            TargetPattern::Hero { .. } => SettingTargetKind::Hero,
82            TargetPattern::HeroAbility { slot, variant, .. } => SettingTargetKind::HeroAbility {
83                slot: slot.clone(),
84                variant: variant.clone(),
85            },
86            TargetPattern::Unknown => SettingTargetKind::Unknown,
87        }
88    }
89
90    pub fn presentation(&self) -> &SettingPresentation {
91        &self.presentation
92    }
93
94    pub fn localized_name(
95        &self,
96        locale: &str,
97        target: &SettingTarget,
98    ) -> Result<Option<&'static str>, GameplayDataError> {
99        match target {
100            SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
101                if self.applicability(target)? == Applicability::NotApplicable {
102                    Ok(None)
103                } else {
104                    Ok(table::hero_setting_name(hero.as_str(), self.key, locale)
105                        .or_else(|| self.presentation.localized_name(locale)))
106                }
107            }
108            _ => Ok(self.presentation.localized_name(locale)),
109        }
110    }
111
112    pub fn source(&self) -> SettingSource {
113        self.source
114    }
115
116    /// Query effective applicability without exposing table deduplication.
117    pub fn applicability(
118        &self,
119        target: &SettingTarget,
120    ) -> Result<Applicability, GameplayDataError> {
121        Ok(match (&self.target, target) {
122            (TargetPattern::Global, SettingTarget::Global) => Applicability::Applicable,
123            (TargetPattern::Mode(expected), SettingTarget::Mode(actual)) => {
124                if expected
125                    .as_deref()
126                    .is_none_or(|expected| expected == actual)
127                {
128                    Applicability::Applicable
129                } else {
130                    Applicability::NotApplicable
131                }
132            }
133            (TargetPattern::Team(expected), SettingTarget::Team(actual)) => {
134                if expected
135                    .as_deref()
136                    .is_none_or(|expected| expected == actual.as_str())
137                {
138                    Applicability::Applicable
139                } else {
140                    Applicability::NotApplicable
141                }
142            }
143            (TargetPattern::Team(expected), SettingTarget::Hero { team, .. }) => {
144                if team_matches(expected.as_deref(), team.as_ref()) {
145                    Applicability::Unknown
146                } else {
147                    Applicability::NotApplicable
148                }
149            }
150            (
151                TargetPattern::TeamAbility {
152                    team,
153                    slot,
154                    variant: expected_variant,
155                },
156                SettingTarget::TeamAbility {
157                    team: actual_team,
158                    slot: actual_slot,
159                    variant: actual_variant,
160                },
161            ) => {
162                if !team_matches(team.as_deref(), actual_team.as_ref())
163                    || slot != actual_slot
164                    || expected_variant
165                        .as_ref()
166                        .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
167                {
168                    Applicability::NotApplicable
169                } else {
170                    Applicability::Applicable
171                }
172            }
173            (
174                TargetPattern::TeamAbility {
175                    team,
176                    slot,
177                    variant: expected_variant,
178                },
179                SettingTarget::HeroAbility {
180                    team: actual_team,
181                    hero: actual_hero,
182                    slot: actual_slot,
183                    variant: actual_variant,
184                },
185            ) => {
186                if !team_matches(team.as_deref(), actual_team.as_ref())
187                    || slot != actual_slot
188                    || expected_variant
189                        .as_ref()
190                        .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
191                {
192                    Applicability::NotApplicable
193                } else {
194                    match hero_ability_exists(actual_hero, actual_slot, actual_variant.as_ref())? {
195                        Some(true) => Applicability::Unknown,
196                        Some(false) => Applicability::NotApplicable,
197                        None => Applicability::Unknown,
198                    }
199                }
200            }
201            (
202                TargetPattern::Hero { team, hero },
203                SettingTarget::Hero {
204                    team: actual_team,
205                    hero: actual_hero,
206                },
207            ) => {
208                if !team_matches(team.as_deref(), actual_team.as_ref())
209                    || hero
210                        .as_deref()
211                        .is_some_and(|expected| expected != actual_hero.as_str())
212                {
213                    Applicability::NotApplicable
214                } else {
215                    Applicability::Unknown
216                }
217            }
218            (
219                TargetPattern::HeroAbility {
220                    team,
221                    hero,
222                    slot,
223                    variant: expected_variant,
224                },
225                SettingTarget::HeroAbility {
226                    team: actual_team,
227                    hero: actual_hero,
228                    slot: actual_slot,
229                    ..
230                },
231            ) => {
232                if !team_matches(team.as_deref(), actual_team.as_ref())
233                    || hero
234                        .as_deref()
235                        .is_some_and(|expected| expected != actual_hero.as_str())
236                    || slot.as_str() != actual_slot.as_str()
237                    || expected_variant
238                        .as_ref()
239                        .is_some_and(|expected| Some(expected) != target_variant(target))
240                {
241                    return Ok(Applicability::NotApplicable);
242                }
243                match hero_ability_exists(actual_hero, actual_slot, target_variant(target))? {
244                    None => Applicability::Unknown,
245                    Some(false) => Applicability::NotApplicable,
246                    Some(true) => Applicability::Unknown,
247                }
248            }
249            (TargetPattern::Unknown, _) => Applicability::Unknown,
250            _ => Applicability::NotApplicable,
251        })
252    }
253
254    pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
255        self.domain.effective_number(authored)
256    }
257
258    /// Read an existing source-preserving occurrence with its authored value
259    /// and, when source-backed, its effective numeric value.
260    pub fn read(
261        &self,
262        settings: &Settings,
263        target: &SettingTarget,
264    ) -> Result<SettingOccurrence, SettingOperationError> {
265        let id = self.operation_id()?;
266        self.ensure_read_target(target)?;
267        let path = self.concrete_path(target);
268        let node = find_node(&settings.children, &path).ok_or_else(|| {
269            SettingOperationError::NotFound {
270                setting: id.clone(),
271                target: target.clone(),
272            }
273        })?;
274        let authored = value_from_node(node, &self.domain, &id)?;
275        let effective = match authored {
276            SettingValue::Number(value) | SettingValue::Percent(value) => {
277                self.effective_number(value)
278            }
279            _ => None,
280        };
281        Ok(SettingOccurrence {
282            authored,
283            effective,
284        })
285    }
286
287    /// Update one existing occurrence without rebuilding the surrounding
288    /// settings tree. Unknown and unrelated source structure is untouched.
289    pub fn write(
290        &self,
291        settings: &mut Settings,
292        target: &SettingTarget,
293        value: SettingValue,
294    ) -> Result<(), SettingOperationError> {
295        let id = self.operation_id()?;
296        self.ensure_write_target(target)?;
297        let path = self.concrete_path(target);
298        let node = find_node_mut(&mut settings.children, &path).ok_or_else(|| {
299            SettingOperationError::NotFound {
300                setting: id.clone(),
301                target: target.clone(),
302            }
303        })?;
304        let span = node.span();
305        validate_value(&self.domain, &id, &value, span)?;
306        apply_value(node, &id, value)
307    }
308
309    /// Build a source-text edit for one existing scalar occurrence.
310    ///
311    /// The caller retains the original source and applies the returned edit
312    /// while its targeted bytes are unchanged. Comments, whitespace, and every
313    /// other byte remain outside the edit range and are therefore preserved
314    /// without assigning them comment/trivia ownership semantics.
315    pub fn source_edit(
316        &self,
317        source: &str,
318        settings: &Settings,
319        locale: &str,
320        target: &SettingTarget,
321        value: SettingValue,
322    ) -> Result<SettingSourceEdit, SettingOperationError> {
323        let id = self.operation_id()?;
324        self.ensure_write_target(target)?;
325        let path = self.concrete_path(target);
326        let node = find_node(&settings.children, &path).ok_or_else(|| {
327            SettingOperationError::NotFound {
328                setting: id.clone(),
329                target: target.clone(),
330            }
331        })?;
332        validate_value(&self.domain, &id, &value, node.span())?;
333        let span = node
334            .span()
335            .ok_or_else(|| SettingOperationError::SourceUnavailable {
336                setting: id.clone(),
337            })?;
338        let range = source_value_range(source, span, &id)?;
339        let kind = table::lookup(self.path_parts)
340            .expect("settings definition must retain its table entry")
341            .kind;
342        let replacement = source_value_spelling(&self.domain, kind, locale, &id, value)?;
343        let edit = crate::core::source::SourceEdit::from_source(source, range, replacement)
344            .map_err(|_| SettingOperationError::SourceUnavailable {
345                setting: id.clone(),
346            })?;
347        Ok(SettingSourceEdit { edit })
348    }
349
350    fn ensure_read_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
351        let id = self.operation_id()?;
352        match self
353            .applicability(target)
354            .map_err(|error| SettingOperationError::InvalidValue {
355                setting: id.clone(),
356                message: error.to_string(),
357                span: None,
358            })? {
359            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
360                setting: id,
361                target: target.clone(),
362            }),
363            Applicability::Applicable | Applicability::Unknown => Ok(()),
364        }
365    }
366
367    fn ensure_write_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
368        let id = self.operation_id()?;
369        match self
370            .applicability(target)
371            .map_err(|error| SettingOperationError::InvalidValue {
372                setting: id.clone(),
373                message: error.to_string(),
374                span: None,
375            })? {
376            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
377                setting: id,
378                target: target.clone(),
379            }),
380            Applicability::Unknown => Err(SettingOperationError::ApplicabilityUnknown {
381                setting: id,
382                target: Box::new(target.clone()),
383            }),
384            Applicability::Applicable => Ok(()),
385        }
386    }
387
388    fn operation_id(&self) -> Result<SettingId, SettingOperationError> {
389        self.id()
390            .cloned()
391            .ok_or_else(|| SettingOperationError::InvalidValue {
392                setting: SettingId::new("unknown"),
393                message: "setting has no reviewed canonical identity".to_string(),
394                span: None,
395            })
396    }
397
398    fn concrete_path(&self, target: &SettingTarget) -> Vec<String> {
399        self.path_parts
400            .iter()
401            .map(|part| match part {
402                PathPart::Part(name) => (*name).to_string(),
403                PathPart::Team => target_team(target),
404                PathPart::Hero => target_hero(target),
405            })
406            .collect()
407    }
408}
409
410fn target_team(target: &SettingTarget) -> String {
411    match target {
412        SettingTarget::Team(team)
413        | SettingTarget::Hero {
414            team: Some(team), ..
415        }
416        | SettingTarget::TeamAbility {
417            team: Some(team), ..
418        }
419        | SettingTarget::HeroAbility {
420            team: Some(team), ..
421        } => team.as_str().to_string(),
422        _ => "allTeams".to_string(),
423    }
424}
425
426fn target_hero(target: &SettingTarget) -> String {
427    match target {
428        SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
429            hero.as_str().to_string()
430        }
431        _ => String::new(),
432    }
433}
434
435fn source_value_range(
436    source: &str,
437    span: crate::core::source::Span,
438    setting: &SettingId,
439) -> Result<Range<usize>, SettingOperationError> {
440    let start = crate::core::source::byte_offset(source, span.start).ok_or_else(|| {
441        SettingOperationError::SourceUnavailable {
442            setting: setting.clone(),
443        }
444    })?;
445    let end = crate::core::source::byte_offset(source, span.end).ok_or_else(|| {
446        SettingOperationError::SourceUnavailable {
447            setting: setting.clone(),
448        }
449    })?;
450    let member =
451        source
452            .get(start..end)
453            .ok_or_else(|| SettingOperationError::SourceUnavailable {
454                setting: setting.clone(),
455            })?;
456    let Some(colon) = member.find(':') else {
457        return Err(SettingOperationError::SourceUnavailable {
458            setting: setting.clone(),
459        });
460    };
461    let value_start = start + colon + 1;
462    let leading = source[value_start..end].len()
463        - source[value_start..end]
464            .trim_start_matches(char::is_whitespace)
465            .len();
466    let range = value_start + leading..end;
467    if range.is_empty() || source.get(range.clone()).is_none() {
468        return Err(SettingOperationError::SourceUnavailable {
469            setting: setting.clone(),
470        });
471    }
472    Ok(range)
473}
474
475fn source_value_spelling(
476    domain: &SettingValueDomain,
477    kind: KeyKind,
478    locale: &str,
479    setting: &SettingId,
480    value: SettingValue,
481) -> Result<String, SettingOperationError> {
482    let localized = |section: &str, english: &str| {
483        if locale.eq_ignore_ascii_case("en-US") {
484            Some(english)
485        } else {
486            table::localized_name(locale, section, english)
487        }
488        .map(str::to_string)
489        .ok_or_else(|| SettingOperationError::InvalidValue {
490            setting: setting.clone(),
491            message: format!("missing {section} locale mapping for '{english}' in {locale}"),
492            span: None,
493        })
494    };
495    match (domain, kind, value) {
496        (SettingValueDomain::Boolean, KeyKind::Bool, SettingValue::Boolean(value)) => {
497            localized("tokens", if value { "On" } else { "Off" })
498        }
499        (SettingValueDomain::Boolean, KeyKind::YesNo, SettingValue::Boolean(value)) => {
500            localized("tokens", if value { "Yes" } else { "No" })
501        }
502        (SettingValueDomain::Boolean, KeyKind::BoolEnum(domain), SettingValue::Boolean(true)) => {
503            let english = table::enum_name(domain, "enabled").ok_or_else(|| {
504                SettingOperationError::InvalidValue {
505                    setting: setting.clone(),
506                    message: format!("unknown enabled member for enum domain '{domain}'"),
507                    span: None,
508                }
509            })?;
510            localized("enums", english)
511        }
512        (SettingValueDomain::Boolean, KeyKind::BoolEnum(_), SettingValue::Boolean(false)) => {
513            Err(SettingOperationError::InvalidValue {
514                setting: setting.clone(),
515                message: "false is unsupported by this Workshop boolean-enum setting".to_string(),
516                span: None,
517            })
518        }
519        (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => {
520            Ok(crate::format::format_setting_number(value))
521        }
522        (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => {
523            Ok(format!("{}%", crate::format::format_setting_number(value)))
524        }
525        (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!(
526            "\"{}\"",
527            crate::output::emitter::escape_settings_string(&value)
528        )),
529        (SettingValueDomain::Enum { domain }, KeyKind::Enum(_), SettingValue::Enum(member)) => {
530            let english = table::enum_name(domain, &member).ok_or_else(|| {
531                SettingOperationError::InvalidValue {
532                    setting: setting.clone(),
533                    message: format!("unknown member '{member}' for enum domain '{domain}'"),
534                    span: None,
535                }
536            })?;
537            localized("enums", english)
538        }
539        _ => Err(SettingOperationError::SourceUnavailable {
540            setting: setting.clone(),
541        }),
542    }
543}
544
545fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> {
546    let (name, rest) = path.split_first()?;
547    let node = children.iter().find(|node| node.name() == name)?;
548    if rest.is_empty() {
549        Some(node)
550    } else {
551        match node {
552            SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
553                find_node(children, rest)
554            }
555            _ => None,
556        }
557    }
558}
559
560fn find_node_mut<'a>(
561    children: &'a mut [SettingsNode],
562    path: &[String],
563) -> Option<&'a mut SettingsNode> {
564    let (name, rest) = path.split_first()?;
565    let node = children.iter_mut().find(|node| node.name() == name)?;
566    if rest.is_empty() {
567        Some(node)
568    } else {
569        match node {
570            SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
571                find_node_mut(children, rest)
572            }
573            _ => None,
574        }
575    }
576}
577
578fn validate_value(
579    domain: &SettingValueDomain,
580    id: &SettingId,
581    value: &SettingValue,
582    span: Option<crate::core::source::Span>,
583) -> Result<(), SettingOperationError> {
584    let expected = domain.kind();
585    if value.kind() != expected {
586        return Err(SettingOperationError::WrongValueKind {
587            setting: id.clone(),
588            expected,
589            actual: value.kind(),
590            span,
591        });
592    }
593    match (domain, value) {
594        (
595            SettingValueDomain::Number(_) | SettingValueDomain::Percent(_),
596            SettingValue::Number(value) | SettingValue::Percent(value),
597        ) if !value.is_finite() => Err(SettingOperationError::InvalidValue {
598            setting: id.clone(),
599            message: "numeric settings values must be finite".to_string(),
600            span,
601        }),
602        (SettingValueDomain::Enum { domain }, SettingValue::Enum(member))
603            if table::enum_name(domain, member).is_none() =>
604        {
605            Err(SettingOperationError::InvalidValue {
606                setting: id.clone(),
607                message: format!("unknown member '{member}' for enum domain '{domain}'"),
608                span,
609            })
610        }
611        (SettingValueDomain::HeroList, SettingValue::HeroList(values))
612            if values.iter().any(|value| table::hero_name(value).is_none()) =>
613        {
614            Err(SettingOperationError::InvalidValue {
615                setting: id.clone(),
616                message: "hero list contains an unknown hero".to_string(),
617                span,
618            })
619        }
620        (SettingValueDomain::MapList, SettingValue::MapList(values))
621            if values.iter().any(|value| table::map_name(value).is_none()) =>
622        {
623            Err(SettingOperationError::InvalidValue {
624                setting: id.clone(),
625                message: "map list contains an unknown map".to_string(),
626                span,
627            })
628        }
629        _ => Ok(()),
630    }
631}
632
633fn value_from_node(
634    node: &SettingsNode,
635    domain: &SettingValueDomain,
636    id: &SettingId,
637) -> Result<SettingValue, SettingOperationError> {
638    let value = match node {
639        SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value),
640        SettingsNode::Number { value, .. } => match domain {
641            SettingValueDomain::Percent(_) => SettingValue::Percent(*value),
642            _ => SettingValue::Number(*value),
643        },
644        SettingsNode::String { value, .. } => match domain {
645            SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()),
646            _ => SettingValue::String(value.clone()),
647        },
648        SettingsNode::Flag { .. } => SettingValue::PresenceOnly,
649        SettingsNode::List { elements, .. } => {
650            let values = elements
651                .iter()
652                .map(|element| element.value.clone())
653                .collect();
654            match domain {
655                SettingValueDomain::HeroList => SettingValue::HeroList(values),
656                _ => SettingValue::MapList(values),
657            }
658        }
659        _ => {
660            return Err(SettingOperationError::InvalidValue {
661                setting: id.clone(),
662                message: "settings occurrence is not a typed leaf".to_string(),
663                span: node.span(),
664            });
665        }
666    };
667    validate_value(domain, id, &value, node.span())?;
668    Ok(value)
669}
670
671fn apply_value(
672    node: &mut SettingsNode,
673    id: &SettingId,
674    value: SettingValue,
675) -> Result<(), SettingOperationError> {
676    match (node, value) {
677        (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => {
678            *current = value
679        }
680        (
681            SettingsNode::Number { value: current, .. },
682            SettingValue::Number(value) | SettingValue::Percent(value),
683        ) => *current = value,
684        (
685            SettingsNode::String { value: current, .. },
686            SettingValue::String(value) | SettingValue::Enum(value),
687        ) => *current = value,
688        (
689            SettingsNode::List { elements, span, .. },
690            SettingValue::HeroList(values) | SettingValue::MapList(values),
691        ) => {
692            if elements.len() != values.len() {
693                return Err(SettingOperationError::InvalidValue {
694                    setting: id.clone(),
695                    message: "source-preserving list edits cannot change list length".to_string(),
696                    span: *span,
697                });
698            }
699            elements
700                .iter_mut()
701                .zip(values)
702                .for_each(|(element, value)| element.value = value);
703        }
704        (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {}
705        (node, value) => {
706            return Err(SettingOperationError::WrongValueKind {
707                setting: id.clone(),
708                expected: "existing typed value",
709                actual: value.kind(),
710                span: node.span(),
711            });
712        }
713    }
714    Ok(())
715}
716
717fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
718    expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
719}
720
721fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
722    match target {
723        SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
724        _ => None,
725    }
726}
727
728fn hero_ability_exists(
729    hero: &HeroId,
730    slot: &LogicalSlot,
731    variant: Option<&AbilityVariant>,
732) -> Result<Option<bool>, GameplayDataError> {
733    data::builtin_ref().map_err(Clone::clone).map(|catalog| {
734        catalog.hero(hero).map(|hero| match variant {
735            Some(variant) => hero.ability_variant(slot, variant).is_ok(),
736            None => !hero.abilities_in_slot(slot).is_empty(),
737        })
738    })
739}