Skip to main content

esp_generate/
config.rs

1use crate::Chip;
2
3use crate::template::{GeneratorOption, GeneratorOptionItem};
4
5#[derive(Debug)]
6pub struct ActiveConfiguration {
7    /// The chip that is configured for
8    pub chip: Chip,
9    /// The names of the selected options
10    pub selected: Vec<usize>,
11    /// The tree of all available options
12    pub options: Vec<GeneratorOptionItem>,
13    /// All available option items (categories are not included), flattened to avoid the need for recursion.
14    pub flat_options: Vec<GeneratorOption>,
15}
16
17pub fn flatten_options(options: &[GeneratorOptionItem]) -> Vec<GeneratorOption> {
18    options
19        .iter()
20        .flat_map(|item| match item {
21            GeneratorOptionItem::Category(category) => flatten_options(&category.options),
22            GeneratorOptionItem::Option(option) => vec![option.clone()],
23        })
24        .collect()
25}
26
27impl ActiveConfiguration {
28    pub fn is_group_selected(&self, group: &str) -> bool {
29        self.selected
30            .iter()
31            .any(|s| self.flat_options[*s].selection_group == group)
32    }
33
34    pub fn is_selected(&self, option: &str) -> bool {
35        self.selected_index(option).is_some()
36    }
37
38    pub fn selected_index(&self, option: &str) -> Option<usize> {
39        self.selected
40            .iter()
41            .position(|s| self.flat_options[*s].name == option)
42    }
43
44    /// Tries to deselect all options in a selection group. Returns false if it's prevented by some
45    /// requirement.
46    fn deselect_group(selected: &mut Vec<usize>, options: &[GeneratorOption], group: &str) -> bool {
47        // No group, nothing to deselect
48        if group.is_empty() {
49            return true;
50        }
51
52        // Avoid deselecting some options then failing.
53        if !selected.iter().copied().all(|s| {
54            let o = &options[s];
55            if o.selection_group == group {
56                // We allow deselecting group options because we are changing the options in the
57                // group, so after this operation the group have a selected item still.
58                Self::can_be_disabled_impl(selected, options, s, true)
59            } else {
60                true
61            }
62        }) {
63            return false;
64        }
65
66        selected.retain(|s| options[*s].selection_group != group);
67
68        true
69    }
70
71    pub fn select(&mut self, option: &str) {
72        let (index, _o) = find_option(option, &self.flat_options, self.chip).unwrap();
73        self.select_idx(index);
74    }
75
76    /// Selects the option at `idx`.
77    ///
78    /// Negative requirements (`!X`) on this option force-deselect `X` if it's currently
79    /// selected. The deselection cascades: anything whose requirements are no longer
80    /// met is dropped as well (there is no save/restore mechanism; swapped-out options
81    /// simply disappear until the user re-enables them).
82    ///
83    /// Positive requirements remain hard gates — if any of them is unmet the call is
84    /// a no-op.
85    pub fn select_idx(&mut self, idx: usize) {
86        let o = self.flat_options[idx].clone();
87
88        // Positive requirements can't be materialised, so they still gate selection.
89        for req in &o.requires {
90            if req.starts_with('!') {
91                continue;
92            }
93            if self.is_selected(req) {
94                continue;
95            }
96            if Self::group_exists(req, &self.flat_options) && self.is_group_selected(req) {
97                continue;
98            }
99            return;
100        }
101
102        // If the option is already selected, leave state as-is.
103        if self.selected.contains(&idx) {
104            return;
105        }
106
107        // Swap out same-group siblings (the existing "radio-button" behaviour).
108        if !Self::deselect_group(&mut self.selected, &self.flat_options, &o.selection_group) {
109            return;
110        }
111
112        // Force-deselect anything directly forbidden by a `!X` requirement.
113        for req in &o.requires {
114            let Some(disables) = req.strip_prefix('!') else {
115                continue;
116            };
117            if let Some(pos) = self.selected_index(disables) {
118                self.selected.swap_remove(pos);
119            }
120        }
121
122        self.selected.push(idx);
123
124        // Cascade: drop anything whose requirements broke as a side effect.
125        self.drop_unsatisfied();
126    }
127
128    /// Deselects the option at `idx`, then cascades: any remaining selection whose
129    /// requirements were propped up by the one we just removed is dropped too. This is
130    /// the counterpart to [`Self::select_idx`]'s cascade, and gives the user a
131    /// predictable "clear, don't save" experience when toggling off an option that
132    /// others depend on (e.g. toggling probe-rs off clears panic-rtt-target).
133    pub fn deselect_idx(&mut self, idx: usize) {
134        let Some(pos) = self.selected.iter().position(|&s| s == idx) else {
135            return;
136        };
137        self.selected.swap_remove(pos);
138        self.drop_unsatisfied();
139    }
140
141    /// Fixpoint loop that evicts selected options whose requirements are no longer
142    /// met. Used after cascading deselection.
143    fn drop_unsatisfied(&mut self) {
144        loop {
145            let victim = self.selected.iter().position(|&idx| {
146                let opt = &self.flat_options[idx];
147                !self.requirements_met(&opt.requires)
148            });
149            match victim {
150                Some(pos) => {
151                    self.selected.swap_remove(pos);
152                }
153                None => return,
154            }
155        }
156    }
157
158    /// Returns the names of currently-selected options that would be force-deselected
159    /// (directly or via cascade) if the user toggled the given option. Empty when
160    /// toggling would be non-destructive.
161    ///
162    /// Symmetric: works whether the option is currently selected (simulates deselect,
163    /// reports cascade) or not (simulates select, reports same-group siblings,
164    /// negative-requirement targets, and cascade). The option being toggled is never
165    /// itself reported — only collateral damage.
166    ///
167    /// Defensive short-circuit: if the option is currently unselected and cannot
168    /// actually be toggled on (chip-mismatch or an unmet *positive* requirement),
169    /// the toggle would be a no-op, so we return an empty list. We gate on
170    /// [`Self::is_option_toggleable`] rather than the strict [`Self::is_option_active`]
171    /// on purpose — a row whose only conflict is a negative requirement *is*
172    /// toggleable (selecting it cascades the conflict out), and its cascade is
173    /// exactly what callers want reported.
174    pub fn would_force_deselect(&self, option: &GeneratorOption) -> Vec<String> {
175        let option_idx = find_option(&option.name, &self.flat_options, self.chip).map(|(i, _)| i);
176
177        let currently_selected = option_idx
178            .map(|idx| self.selected.contains(&idx))
179            .unwrap_or(false);
180
181        if !currently_selected && !self.is_option_toggleable(option) {
182            return Vec::new();
183        }
184
185        let mut simulated = self.selected.clone();
186        if currently_selected {
187            // Simulated deselect: remove the option, then let cascade evict dependents.
188            if let Some(idx) = option_idx {
189                if let Some(pos) = simulated.iter().position(|&i| i == idx) {
190                    simulated.swap_remove(pos);
191                }
192            }
193        } else {
194            // Simulated select: kick same-group siblings…
195            if !option.selection_group.is_empty() {
196                simulated.retain(|idx| {
197                    let o = &self.flat_options[*idx];
198                    o.selection_group != option.selection_group
199                });
200            }
201
202            // …and anything directly named by a `!X` requirement.
203            for req in &option.requires {
204                let Some(disables) = req.strip_prefix('!') else {
205                    continue;
206                };
207                if let Some(pos) = simulated
208                    .iter()
209                    .position(|&i| self.flat_options[i].name == disables)
210                {
211                    simulated.swap_remove(pos);
212                }
213            }
214
215            // Put the option into the simulated set so cascade evaluates it in context.
216            if let Some(idx) = option_idx {
217                if !simulated.contains(&idx) {
218                    simulated.push(idx);
219                }
220            }
221        }
222
223        // Shared cascade: drop anything whose requirements are no longer met.
224        loop {
225            let victim = simulated.iter().position(|&idx| {
226                let opt = &self.flat_options[idx];
227                !Self::requirements_met_against(opt, &simulated, &self.flat_options)
228            });
229            match victim {
230                Some(pos) => {
231                    simulated.swap_remove(pos);
232                }
233                None => break,
234            }
235        }
236
237        // Collateral = things that were selected but aren't in the simulated set
238        // (excluding the option itself, which is the user's direct action).
239        self.selected
240            .iter()
241            .copied()
242            .filter(|idx| !simulated.contains(idx) && Some(*idx) != option_idx)
243            .map(|idx| self.flat_options[idx].name.clone())
244            .collect()
245    }
246
247    /// Static helper: evaluate `option.requires` against an arbitrary selected set.
248    fn requirements_met_against(
249        option: &GeneratorOption,
250        selected: &[usize],
251        flat_options: &[GeneratorOption],
252    ) -> bool {
253        for requirement in &option.requires {
254            let (key, expected) = if let Some(rest) = requirement.strip_prefix('!') {
255                (rest, false)
256            } else {
257                (requirement.as_str(), true)
258            };
259
260            let is_selected = selected.iter().any(|s| flat_options[*s].name == key);
261            if is_selected == expected {
262                continue;
263            }
264
265            let is_group = flat_options.iter().any(|o| o.selection_group == key);
266            if is_group {
267                let group_selected = selected
268                    .iter()
269                    .any(|s| flat_options[*s].selection_group == key);
270                if group_selected == expected {
271                    continue;
272                }
273            }
274
275            return false;
276        }
277        true
278    }
279
280    /// Returns whether an item is toggleable in the UI.
281    ///
282    /// For a category, the category's own requirements must be met (strict) *and* at
283    /// least one descendant must itself be toggleable; for a leaf option this uses
284    /// [`Self::is_option_toggleable`], i.e. an option with an unmet `!X` is still
285    /// considered reachable because selecting it would cascade `X` out.
286    ///
287    /// This is the TUI-facing predicate. For strict validation (e.g. the CLI
288    /// checking whether a user's selection set is self-consistent), use
289    /// [`Self::is_option_active`] directly.
290    pub fn is_active(&self, item: &GeneratorOptionItem) -> bool {
291        match item {
292            GeneratorOptionItem::Category(category) => {
293                if !self.requirements_met(&category.requires) {
294                    return false;
295                }
296                for sub in category.options.iter() {
297                    if self.is_active(sub) {
298                        return true;
299                    }
300                }
301                false
302            }
303            GeneratorOptionItem::Option(option) => self.is_option_toggleable(option),
304        }
305    }
306
307    /// Returns whether all requirements are met.
308    ///
309    /// A requirement may be:
310    /// - an `option`
311    /// - the absence of an `!option`
312    /// - a `selection_group`, which means one option in that selection group must be selected
313    /// - the absence of a `!selection_group`, which means no option in that selection group must
314    ///   be selected
315    ///
316    /// A selection group must not have the same name as an option.
317    fn requirements_met(&self, requires: &[String]) -> bool {
318        for requirement in requires {
319            let (key, expected) = if let Some(requirement) = requirement.strip_prefix('!') {
320                (requirement, false)
321            } else {
322                (requirement.as_str(), true)
323            };
324
325            // Requirement is an option that must be selected?
326            if self.is_selected(key) == expected {
327                continue;
328            }
329
330            // Requirement is a group that must have a selected option?
331            let is_group = Self::group_exists(key, &self.flat_options);
332            if is_group && self.is_group_selected(key) == expected {
333                continue;
334            }
335
336            return false;
337        }
338
339        true
340    }
341
342    /// Strict "is this option consistent with the current selection?" predicate.
343    ///
344    /// Returns `true` only when:
345    ///   * the option is available for the current chip,
346    ///   * every one of its requirements is satisfied (including negative ones), and
347    ///   * no other currently-selected option has `!{option.name}` in its `requires`.
348    ///
349    /// This is the right check for validation: the CLI (`esp-generate --headless -o …`)
350    /// and the xtask regression harness rely on it to reject inconsistent selection
351    /// sets. It is *not* the right check for "can the user click this row in the TUI"
352    /// — that's [`Self::is_option_toggleable`], which permits negative-requirement
353    /// conflicts on the understanding that selecting the row will cascade them out
354    /// via [`Self::select_idx`].
355    pub fn is_option_active(&self, option: &GeneratorOption) -> bool {
356        if !option.chips.is_empty() && !option.chips.contains(&self.chip) {
357            return false;
358        }
359
360        if !self.requirements_met(&option.requires) {
361            return false;
362        }
363
364        for selected in self.selected.iter().copied() {
365            let Some(selected_option) = self.flat_options.get(selected) else {
366                ratatui::restore();
367                panic!("selected option not found: {selected}");
368            };
369
370            for requirement in selected_option.requires.iter() {
371                if let Some(requirement) = requirement.strip_prefix('!') {
372                    if requirement == option.name {
373                        return false;
374                    }
375                }
376            }
377        }
378
379        true
380    }
381
382    /// Permissive "can the user toggle this row in the TUI?" predicate.
383    ///
384    /// Returns `true` when the option is available for the current chip and every
385    /// *positive* requirement is met. Negative requirements (`!X`) are intentionally
386    /// ignored here: the TUI treats them as force-deselect hints, not gates.
387    /// Selecting a row in this state routes through [`Self::select_idx`], which
388    /// cascades the negative-requirement targets (and their dependents) out; use
389    /// [`Self::would_force_deselect`] to preview that cascade.
390    ///
391    /// Callers that care about full self-consistency (CLI validation, xtask
392    /// coverage) must use [`Self::is_option_active`] instead.
393    pub fn is_option_toggleable(&self, option: &GeneratorOption) -> bool {
394        if !option.chips.is_empty() && !option.chips.contains(&self.chip) {
395            return false;
396        }
397
398        for req in &option.requires {
399            if req.starts_with('!') {
400                continue;
401            }
402            if self.is_selected(req) {
403                continue;
404            }
405            if Self::group_exists(req, &self.flat_options) && self.is_group_selected(req) {
406                continue;
407            }
408            return false;
409        }
410
411        true
412    }
413
414    // An option can only be disabled if it's not required by any other selected option.
415    pub fn can_be_disabled(&self, option: &str) -> bool {
416        let (option, _) = find_option(option, &self.flat_options, self.chip).unwrap();
417        Self::can_be_disabled_impl(&self.selected, &self.flat_options, option, false)
418    }
419
420    fn can_be_disabled_impl(
421        selected: &[usize],
422        options: &[GeneratorOption],
423        option: usize,
424        allow_deselecting_group: bool,
425    ) -> bool {
426        let op = &options[option];
427        for selected in selected.iter().copied() {
428            let selected_option = &options[selected];
429            if selected_option
430                .requires
431                .iter()
432                .any(|o| o == &op.name || (o == &op.selection_group && !allow_deselecting_group))
433            {
434                return false;
435            }
436        }
437        true
438    }
439
440    pub fn collect_relationships<'a>(
441        &'a self,
442        option: &'a GeneratorOptionItem,
443    ) -> Relationships<'a> {
444        let mut requires = Vec::new();
445        let mut required_by = Vec::new();
446        let mut disabled_by = Vec::new();
447
448        self.selected.iter().for_each(|opt| {
449            let opt = &self.flat_options[*opt];
450            for o in opt.requires.iter() {
451                if let Some(disables) = o.strip_prefix("!") {
452                    if disables == option.name() {
453                        disabled_by.push(opt.name.as_str());
454                    }
455                } else if o == option.name() {
456                    required_by.push(opt.name.as_str());
457                }
458            }
459        });
460        for req in option.requires() {
461            if let Some(disables) = req.strip_prefix("!") {
462                if self.is_selected(disables) {
463                    disabled_by.push(disables);
464                }
465            } else {
466                requires.push(req.as_str());
467            }
468        }
469
470        Relationships {
471            requires,
472            required_by,
473            disabled_by,
474        }
475    }
476
477    fn group_exists(key: &str, options: &[GeneratorOption]) -> bool {
478        options.iter().any(|o| o.selection_group == key)
479    }
480}
481
482pub struct Relationships<'a> {
483    pub requires: Vec<&'a str>,
484    pub required_by: Vec<&'a str>,
485    pub disabled_by: Vec<&'a str>,
486}
487
488pub fn find_option<'c>(
489    option: &str,
490    options: &'c [GeneratorOption],
491    chip: Chip,
492) -> Option<(usize, &'c GeneratorOption)> {
493    options
494        .iter()
495        .enumerate()
496        .find(|(_, opt)| opt.name == option && (opt.chips.is_empty() || opt.chips.contains(&chip)))
497}
498
499#[cfg(test)]
500mod test {
501    use crate::Chip;
502
503    use crate::{
504        config::*,
505        template::{GeneratorOption, GeneratorOptionCategory, GeneratorOptionItem},
506    };
507
508    #[test]
509    fn required_by_and_requires_pick_the_right_options() {
510        let options = vec![
511            GeneratorOptionItem::Option(GeneratorOption {
512                name: "option1".to_string(),
513                display_name: "Foobar".to_string(),
514                selection_group: "".to_string(),
515                help: "".to_string(),
516                chips: vec![Chip::Esp32],
517                requires: vec!["option2".to_string()],
518            }),
519            GeneratorOptionItem::Option(GeneratorOption {
520                name: "option2".to_string(),
521                display_name: "Barfoo".to_string(),
522                selection_group: "".to_string(),
523                help: "".to_string(),
524                chips: vec![Chip::Esp32],
525                requires: vec![],
526            }),
527        ];
528        let active = ActiveConfiguration {
529            chip: Chip::Esp32,
530            selected: vec![0],
531            flat_options: flatten_options(&options),
532            options,
533        };
534
535        let rels = active.collect_relationships(&active.options[0]);
536        assert_eq!(rels.requires, &["option2"]);
537        assert_eq!(rels.required_by, <&[&str]>::default());
538
539        let rels = active.collect_relationships(&active.options[1]);
540        assert_eq!(rels.requires, <&[&str]>::default());
541        assert_eq!(rels.required_by, &["option1"]);
542    }
543
544    #[test]
545    fn selecting_one_in_group_deselects_other() {
546        let options = vec![
547            GeneratorOptionItem::Option(GeneratorOption {
548                name: "option1".to_string(),
549                display_name: "Foobar".to_string(),
550                selection_group: "group".to_string(),
551                help: "".to_string(),
552                chips: vec![Chip::Esp32],
553                requires: vec![],
554            }),
555            GeneratorOptionItem::Option(GeneratorOption {
556                name: "option2".to_string(),
557                display_name: "Barfoo".to_string(),
558                selection_group: "group".to_string(),
559                help: "".to_string(),
560                chips: vec![Chip::Esp32],
561                requires: vec![],
562            }),
563            GeneratorOptionItem::Option(GeneratorOption {
564                name: "option3".to_string(),
565                display_name: "Prevents deselecting option2".to_string(),
566                selection_group: "".to_string(),
567                help: "".to_string(),
568                chips: vec![Chip::Esp32],
569                requires: vec!["option2".to_string()],
570            }),
571        ];
572        let mut active = ActiveConfiguration {
573            chip: Chip::Esp32,
574            selected: vec![],
575            flat_options: flatten_options(&options),
576            options,
577        };
578
579        active.select("option1");
580        assert_eq!(active.selected, &[0]);
581
582        active.select("option2");
583        assert_eq!(active.selected, &[1]);
584
585        // Enable option3, which prevents deselecting option2, which disallows selecting option1
586        active.select("option3");
587        assert_eq!(active.selected, &[1, 2]);
588
589        active.select("option1");
590        assert_eq!(active.selected, &[1, 2]);
591    }
592
593    #[test]
594    fn depending_on_group_allows_changing_group_option() {
595        let options = vec![
596            GeneratorOptionItem::Category(GeneratorOptionCategory {
597                name: "group-options".to_string(),
598                display_name: "Group options".to_string(),
599                help: "".to_string(),
600                requires: vec![],
601                options: vec![
602                    GeneratorOptionItem::Option(GeneratorOption {
603                        name: "option1".to_string(),
604                        display_name: "Foobar".to_string(),
605                        selection_group: "group".to_string(),
606                        help: "".to_string(),
607                        chips: vec![Chip::Esp32],
608                        requires: vec![],
609                    }),
610                    GeneratorOptionItem::Option(GeneratorOption {
611                        name: "option2".to_string(),
612                        display_name: "Barfoo".to_string(),
613                        selection_group: "group".to_string(),
614                        help: "".to_string(),
615                        chips: vec![Chip::Esp32],
616                        requires: vec![],
617                    }),
618                ],
619            }),
620            GeneratorOptionItem::Option(GeneratorOption {
621                name: "option3".to_string(),
622                display_name: "Requires any in group to be selected".to_string(),
623                selection_group: "".to_string(),
624                help: "".to_string(),
625                chips: vec![Chip::Esp32],
626                requires: vec!["group".to_string()],
627            }),
628            GeneratorOptionItem::Option(GeneratorOption {
629                name: "option4".to_string(),
630                display_name: "Extra option that depends on something".to_string(),
631                selection_group: "".to_string(),
632                help: "".to_string(),
633                chips: vec![Chip::Esp32],
634                requires: vec!["option3".to_string()],
635            }),
636        ];
637        let mut active = ActiveConfiguration {
638            chip: Chip::Esp32,
639            selected: vec![],
640            flat_options: flatten_options(&options),
641            options,
642        };
643
644        // Nothing is selected in group, so option3 can't be selected
645        active.select("option3");
646        assert_eq!(active.selected, empty());
647
648        active.select("option1");
649        assert_eq!(active.selected, &[0]);
650
651        active.select("option3");
652        assert_eq!(active.selected, &[0, 2]);
653
654        // The rejection algorithm must not trigger on unrelated options. This option is
655        // meant to test the group filtering. It prevents disabling "option3" but it does not
656        // belong to `group`, so it should not prevent selecting between "option1" or "option2".
657        active.select("option4");
658        assert_eq!(active.selected, &[0, 2, 3]);
659
660        active.select("option2");
661        assert_eq!(active.selected, &[2, 3, 1]);
662    }
663
664    #[test]
665    fn depending_on_group_prevents_deselecting() {
666        let options = vec![
667            GeneratorOptionItem::Option(GeneratorOption {
668                name: "option1".to_string(),
669                display_name: "Foobar".to_string(),
670                selection_group: "group".to_string(),
671                help: "".to_string(),
672                chips: vec![Chip::Esp32],
673                requires: vec![],
674            }),
675            GeneratorOptionItem::Option(GeneratorOption {
676                name: "option2".to_string(),
677                display_name: "Barfoo".to_string(),
678                selection_group: "".to_string(),
679                help: "".to_string(),
680                chips: vec![Chip::Esp32],
681                requires: vec!["group".to_string()],
682            }),
683        ];
684        let mut active = ActiveConfiguration {
685            chip: Chip::Esp32,
686            selected: vec![],
687            flat_options: flatten_options(&options),
688            options,
689        };
690
691        active.select("option1");
692        active.select("option2");
693
694        // Option1 can't be deselected because option2 requires that a `group` option is selected
695        assert!(!active.can_be_disabled("option1"));
696    }
697
698    #[test]
699    fn negative_requirement_force_deselects_instead_of_blocking() {
700        // option2 requires `!option1`. option1 is selected. option2 must be *active*
701        // (negative requirements no longer block selection) and selecting it must
702        // clear option1 rather than save it somewhere for later.
703        let options = vec![
704            GeneratorOptionItem::Option(GeneratorOption {
705                name: "option1".to_string(),
706                display_name: "Foobar".to_string(),
707                selection_group: "".to_string(),
708                help: "".to_string(),
709                chips: vec![Chip::Esp32],
710                requires: vec![],
711            }),
712            GeneratorOptionItem::Option(GeneratorOption {
713                name: "option2".to_string(),
714                display_name: "Barfoo".to_string(),
715                selection_group: "".to_string(),
716                help: "".to_string(),
717                chips: vec![Chip::Esp32],
718                requires: vec!["!option1".to_string()],
719            }),
720        ];
721        let mut active = ActiveConfiguration {
722            chip: Chip::Esp32,
723            selected: vec![],
724            flat_options: flatten_options(&options),
725            options,
726        };
727
728        active.select("option1");
729        let opt2 = active.flat_options[1].clone();
730        // Strict validation (CLI/xtask): selection set is inconsistent → false.
731        assert!(!active.is_option_active(&opt2));
732        // TUI predicate: row is toggleable, selecting it will cascade `option1` out.
733        assert!(active.is_option_toggleable(&opt2));
734        assert_eq!(
735            active.would_force_deselect(&opt2),
736            vec!["option1".to_string()]
737        );
738
739        active.select("option2");
740        assert!(active.is_selected("option2"));
741        assert!(!active.is_selected("option1"));
742    }
743
744    #[test]
745    fn select_cascade_evicts_dependents_of_deselected_option() {
746        // Real-world analogue: selecting `probe-rs` must clear `log` (which has
747        // `!probe-rs`) and `embedded-test` (which requires `log`). Nothing is
748        // remembered — if the user toggles probe-rs back off they start from
749        // scratch. Symmetrically, once probe-rs *is* selected, deselecting it must
750        // cascade out anything requiring it (here, `panic-rtt-target`) and the
751        // warning predicate must list those dependents.
752        let options = vec![
753            GeneratorOptionItem::Option(GeneratorOption {
754                name: "probe-rs".to_string(),
755                display_name: "probe-rs".to_string(),
756                selection_group: "".to_string(),
757                help: "".to_string(),
758                chips: vec![Chip::Esp32],
759                requires: vec![],
760            }),
761            GeneratorOptionItem::Option(GeneratorOption {
762                name: "log".to_string(),
763                display_name: "log".to_string(),
764                selection_group: "".to_string(),
765                help: "".to_string(),
766                chips: vec![Chip::Esp32],
767                requires: vec!["!probe-rs".to_string()],
768            }),
769            GeneratorOptionItem::Option(GeneratorOption {
770                name: "embedded-test".to_string(),
771                display_name: "embedded-test".to_string(),
772                selection_group: "".to_string(),
773                help: "".to_string(),
774                chips: vec![Chip::Esp32],
775                requires: vec!["log".to_string()],
776            }),
777            GeneratorOptionItem::Option(GeneratorOption {
778                name: "panic-rtt-target".to_string(),
779                display_name: "panic-rtt-target".to_string(),
780                selection_group: "".to_string(),
781                help: "".to_string(),
782                chips: vec![Chip::Esp32],
783                requires: vec!["probe-rs".to_string()],
784            }),
785            GeneratorOptionItem::Option(GeneratorOption {
786                name: "wifi".to_string(),
787                display_name: "wifi".to_string(),
788                selection_group: "".to_string(),
789                help: "".to_string(),
790                chips: vec![Chip::Esp32],
791                requires: vec![],
792            }),
793        ];
794        let mut active = ActiveConfiguration {
795            chip: Chip::Esp32,
796            selected: vec![],
797            flat_options: flatten_options(&options),
798            options,
799        };
800
801        active.select("log");
802        active.select("embedded-test");
803        active.select("wifi");
804
805        // Select side: hovering `probe-rs` while `log`/`embedded-test` are on
806        // reports both as going away; actually selecting it does the cascade.
807        let probe_rs = active.flat_options[0].clone();
808        let mut evicted = active.would_force_deselect(&probe_rs);
809        evicted.sort();
810        assert_eq!(
811            evicted,
812            vec!["embedded-test".to_string(), "log".to_string()]
813        );
814
815        active.select("probe-rs");
816        assert!(active.is_selected("probe-rs"));
817        assert!(!active.is_selected("log"));
818        assert!(!active.is_selected("embedded-test"));
819        assert!(active.is_selected("wifi"));
820
821        // Deselect side: with probe-rs on, add `panic-rtt-target` (requires
822        // probe-rs). Hovering probe-rs must now list panic-rtt-target in the
823        // warning; `deselect_idx` does the same cascade.
824        active.select("panic-rtt-target");
825        let probe_rs = active.flat_options[0].clone();
826        let mut evicted = active.would_force_deselect(&probe_rs);
827        evicted.sort();
828        assert_eq!(evicted, vec!["panic-rtt-target".to_string()]);
829        assert!(!evicted.contains(&"probe-rs".to_string())); // never itself
830        assert!(!evicted.contains(&"wifi".to_string())); // unrelated stays
831
832        let (probe_rs_flat_idx, _) =
833            find_option("probe-rs", &active.flat_options, Chip::Esp32).unwrap();
834        active.deselect_idx(probe_rs_flat_idx);
835        assert!(!active.is_selected("probe-rs"));
836        assert!(!active.is_selected("panic-rtt-target"));
837        // Previously-cleared `!probe-rs` options are NOT resurrected.
838        assert!(!active.is_selected("log"));
839        assert!(!active.is_selected("embedded-test"));
840        assert!(active.is_selected("wifi"));
841
842        // Short-circuit: an unselected option that isn't actually toggleable must
843        // report an empty cascade — toggling it is a no-op, so advertising
844        // collateral damage would be a lie.
845        //
846        // Fresh config (no prior selections) with three would-be-destructive
847        // rows whose toggle is *not* actionable:
848        //   * chip-mismatch (`chips = [Esp32c6]` on an Esp32 config),
849        //   * unmet positive requirement (`requires: ["missing"]`),
850        //   * both of the above.
851        // Each of them also carries `!victim`, which — without the guard —
852        // `would_force_deselect` would still happily report.
853        let options = vec![
854            GeneratorOptionItem::Option(GeneratorOption {
855                name: "victim".to_string(),
856                display_name: "victim".to_string(),
857                selection_group: "".to_string(),
858                help: "".to_string(),
859                chips: vec![Chip::Esp32],
860                requires: vec![],
861            }),
862            GeneratorOptionItem::Option(GeneratorOption {
863                name: "wrong-chip".to_string(),
864                display_name: "wrong-chip".to_string(),
865                selection_group: "".to_string(),
866                help: "".to_string(),
867                chips: vec![Chip::Esp32c6],
868                requires: vec!["!victim".to_string()],
869            }),
870            GeneratorOptionItem::Option(GeneratorOption {
871                name: "unmet-pos".to_string(),
872                display_name: "unmet-pos".to_string(),
873                selection_group: "".to_string(),
874                help: "".to_string(),
875                chips: vec![Chip::Esp32],
876                requires: vec!["missing".to_string(), "!victim".to_string()],
877            }),
878            GeneratorOptionItem::Option(GeneratorOption {
879                name: "neg-conflict".to_string(),
880                display_name: "neg-conflict".to_string(),
881                selection_group: "".to_string(),
882                help: "".to_string(),
883                chips: vec![Chip::Esp32],
884                requires: vec!["!victim".to_string()],
885            }),
886        ];
887        let mut active = ActiveConfiguration {
888            chip: Chip::Esp32,
889            selected: vec![],
890            flat_options: flatten_options(&options),
891            options,
892        };
893        active.select("victim");
894
895        let wrong_chip = active
896            .flat_options
897            .iter()
898            .find(|o| o.name == "wrong-chip")
899            .cloned()
900            .unwrap();
901        let unmet_pos = active
902            .flat_options
903            .iter()
904            .find(|o| o.name == "unmet-pos")
905            .cloned()
906            .unwrap();
907        let neg_conflict = active
908            .flat_options
909            .iter()
910            .find(|o| o.name == "neg-conflict")
911            .cloned()
912            .unwrap();
913
914        assert!(active.would_force_deselect(&wrong_chip).is_empty());
915        assert!(active.would_force_deselect(&unmet_pos).is_empty());
916        // Negative-only conflict is still actionable — the cascade must be reported.
917        assert_eq!(
918            active.would_force_deselect(&neg_conflict),
919            vec!["victim".to_string()]
920        );
921    }
922
923    fn empty() -> &'static [usize] {
924        &[]
925    }
926}