Skip to main content

purple_ssh/app/
tag_state.rs

1//! Tag domain state: per-host tag tracking and bulk-tag-editor model.
2
3use crate::app::host_state::GroupBy;
4use crate::ssh_config::model::HostEntry;
5
6/// A display tag with its source (user-defined or provider-synced).
7#[derive(Debug, Clone, PartialEq)]
8pub struct DisplayTag {
9    pub name: String,
10    pub is_user: bool,
11}
12
13/// Select up to 3 tags for display based on view mode and grouping.
14/// Returns a Vec of up to 3 DisplayTags (user tags first, then provider tags).
15///
16/// In grouped views the tag matching the group criterion is suppressed
17/// (it lives in the group header). Non-matching provider tags and the
18/// provider name itself stay visible.
19pub fn select_display_tags(
20    host: &HostEntry,
21    group_by: &GroupBy,
22    detail_mode: bool,
23) -> Vec<DisplayTag> {
24    let group_name = match group_by {
25        GroupBy::Provider => host.provider.clone(),
26        GroupBy::Tag(t) => Some(t.clone()),
27        GroupBy::None => None,
28    };
29
30    let not_group = |t: &str| {
31        group_name
32            .as_ref()
33            .is_none_or(|g| !t.eq_ignore_ascii_case(g))
34    };
35
36    let user_tags = host
37        .tags
38        .iter()
39        .filter(|t| not_group(t))
40        .map(|t| DisplayTag {
41            name: t.to_string(),
42            is_user: true,
43        });
44
45    let provider_tags = host
46        .provider_tags
47        .iter()
48        .filter(|t| not_group(t))
49        .chain(host.provider.iter().filter(|p| not_group(p)))
50        .map(|t| DisplayTag {
51            name: t.to_string(),
52            is_user: false,
53        });
54
55    let limit = if detail_mode { 1 } else { 3 };
56    user_tags.chain(provider_tags).take(limit).collect()
57}
58
59/// Tag editor state.
60#[derive(Default)]
61pub struct TagState {
62    pub input: Option<String>,
63    pub cursor: usize,
64    pub list: Vec<String>,
65}
66
67/// User action per tag row in the bulk tag editor.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum BulkTagAction {
70    /// `[~]` Leave each host's state for this tag unchanged.
71    Leave,
72    /// `[x]` Ensure the tag is present on every selected host.
73    AddToAll,
74    /// `[ ]` Ensure the tag is absent from every selected host.
75    RemoveFromAll,
76}
77
78impl BulkTagAction {
79    /// 3-way cycle: `Leave` → `AddToAll` → `RemoveFromAll` → `Leave`.
80    pub fn cycle(self) -> Self {
81        match self {
82            BulkTagAction::Leave => BulkTagAction::AddToAll,
83            BulkTagAction::AddToAll => BulkTagAction::RemoveFromAll,
84            BulkTagAction::RemoveFromAll => BulkTagAction::Leave,
85        }
86    }
87
88    pub fn glyph(self) -> &'static str {
89        match self {
90            BulkTagAction::Leave => "[~]",
91            BulkTagAction::AddToAll => "[x]",
92            BulkTagAction::RemoveFromAll => "[ ]",
93        }
94    }
95}
96
97/// A single row in the bulk tag editor.
98#[derive(Debug, Clone)]
99pub struct BulkTagRow {
100    pub tag: String,
101    /// Number of selected hosts that had this tag at editor open time.
102    pub initial_count: usize,
103    pub action: BulkTagAction,
104}
105
106/// Snapshot state for the bulk tag editor overlay.
107#[derive(Debug, Default)]
108pub struct BulkTagEditorState {
109    pub rows: Vec<BulkTagRow>,
110    /// Aliases being edited, snapshot at open time so selection changes
111    /// during the flow do not affect the in-progress edit.
112    pub aliases: Vec<String>,
113    /// Aliases that live in an Include file and cannot be edited in place.
114    /// Surfaced in the header so the user sees the blast radius.
115    pub skipped_included: Vec<String>,
116    /// Draft name for a brand-new tag being typed by the user. `None` when
117    /// the input bar is inactive. Newly entered tags are appended to `rows`
118    /// with `action = AddToAll`.
119    pub new_tag_input: Option<String>,
120    pub new_tag_cursor: usize,
121    /// Snapshot of `rows[i].action` at editor open time. Used by `is_dirty`
122    /// to detect pending changes on Esc and prompt the user before
123    /// discarding. Captured by the opener (e.g. `App::open_bulk_tag_editor`)
124    /// after `rows` is populated.
125    ///
126    /// Length-mismatch semantics: any extra row beyond the baseline length
127    /// (i.e. a newly added tag via `+`) counts as dirty if its action is
128    /// non-Leave. This matches the user's intuition that "I typed a new tag,
129    /// closing now should warn me".
130    pub initial_actions: Vec<BulkTagAction>,
131}
132
133impl BulkTagEditorState {
134    /// Returns true if any row's action differs from the open-time baseline,
135    /// or if rows have been added since open.
136    ///
137    /// Single source of truth for the dirty check. The handler consults this
138    /// on Esc to decide between immediate exit and discard confirmation.
139    /// Every editable surface gets a dirty-check so Esc never drops unsaved
140    /// work.
141    ///
142    /// **Invariant**: rows is append-only after `open_bulk_tag_editor`
143    /// captures the baseline. The `+ new tag` flow only appends to `rows`;
144    /// no code path removes rows during the editor session. If a future
145    /// change introduces row removal, the length-mismatch branch below will
146    /// silently treat the missing baseline rows as clean (because `zip`
147    /// stops at the shorter slice). At that point this method needs an
148    /// explicit shrink branch; the assertion below guards the assumption.
149    pub fn is_dirty(&self) -> bool {
150        debug_assert!(
151            self.rows.len() >= self.initial_actions.len(),
152            "rows must be append-only after baseline capture; \
153             shorter rows breaks the dirty-check"
154        );
155        if self.rows.len() != self.initial_actions.len() {
156            // Tags added since open. New rows count as dirty unless still Leave.
157            return self
158                .rows
159                .iter()
160                .skip(self.initial_actions.len())
161                .any(|r| r.action != BulkTagAction::Leave)
162                || self
163                    .rows
164                    .iter()
165                    .zip(self.initial_actions.iter())
166                    .any(|(r, baseline)| r.action != *baseline);
167        }
168        self.rows
169            .iter()
170            .zip(self.initial_actions.iter())
171            .any(|(r, baseline)| r.action != *baseline)
172    }
173}
174
175/// Outcome of applying a bulk tag edit.
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177pub struct BulkTagApplyResult {
178    /// Hosts whose tag list actually changed.
179    pub changed_hosts: usize,
180    /// Total (host, tag) additions.
181    pub added: usize,
182    /// Total (host, tag) removals.
183    pub removed: usize,
184    /// Hosts skipped because they live in an Include file.
185    pub skipped_included: usize,
186}