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(in crate::app) input: Option<String>,
63    pub(in crate::app) cursor: usize,
64    pub(in crate::app) list: Vec<String>,
65}
66
67impl TagState {
68    /// Open the inline tag-edit input on the host detail screen with the
69    /// given seed text. Cursor lands at the end of the text so users can
70    /// type extra tags without re-positioning.
71    pub(crate) fn open_tag_input(&mut self, text: String) {
72        self.cursor = text.chars().count();
73        self.input = Some(text);
74    }
75
76    /// Close the inline tag-edit input. Called on both Enter (after the
77    /// submit hits disk) and Esc (cancel) so the two fields cannot drift
78    /// out of sync.
79    pub(crate) fn close_tag_input(&mut self) {
80        self.input = None;
81        self.cursor = 0;
82    }
83
84    pub fn input(&self) -> Option<&str> {
85        self.input.as_deref()
86    }
87
88    pub fn cursor(&self) -> usize {
89        self.cursor
90    }
91
92    pub fn list(&self) -> &[String] {
93        &self.list
94    }
95
96    pub fn cursor_left(&mut self) {
97        if self.cursor > 0 {
98            self.cursor -= 1;
99        }
100    }
101
102    pub fn cursor_right(&mut self) {
103        if let Some(ref input) = self.input {
104            if self.cursor < input.chars().count() {
105                self.cursor += 1;
106            }
107        }
108    }
109
110    pub fn cursor_home(&mut self) {
111        self.cursor = 0;
112    }
113
114    pub fn cursor_end(&mut self) {
115        if let Some(ref input) = self.input {
116            self.cursor = input.chars().count();
117        }
118    }
119
120    /// Insert a char at the cursor position and advance the cursor.
121    /// No-op when the input is not active.
122    pub fn insert_char(&mut self, c: char) {
123        if let Some(ref mut input) = self.input {
124            let byte_pos = super::char_to_byte_pos(input, self.cursor);
125            input.insert(byte_pos, c);
126            self.cursor += 1;
127        }
128    }
129
130    /// Delete the char left of the cursor. No-op when cursor is 0 or input is inactive.
131    pub fn backspace(&mut self) {
132        if self.cursor == 0 {
133            return;
134        }
135        if let Some(ref mut input) = self.input {
136            let byte_pos = super::char_to_byte_pos(input, self.cursor);
137            let prev = super::char_to_byte_pos(input, self.cursor - 1);
138            input.drain(prev..byte_pos);
139            self.cursor -= 1;
140        }
141    }
142}
143
144/// User action per tag row in the bulk tag editor.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum BulkTagAction {
147    /// `[~]` Leave each host's state for this tag unchanged.
148    Leave,
149    /// `[x]` Ensure the tag is present on every selected host.
150    AddToAll,
151    /// `[ ]` Ensure the tag is absent from every selected host.
152    RemoveFromAll,
153}
154
155impl BulkTagAction {
156    /// 3-way cycle: `Leave` → `AddToAll` → `RemoveFromAll` → `Leave`.
157    pub fn cycle(self) -> Self {
158        match self {
159            BulkTagAction::Leave => BulkTagAction::AddToAll,
160            BulkTagAction::AddToAll => BulkTagAction::RemoveFromAll,
161            BulkTagAction::RemoveFromAll => BulkTagAction::Leave,
162        }
163    }
164
165    pub fn glyph(self) -> &'static str {
166        match self {
167            BulkTagAction::Leave => "[~]",
168            BulkTagAction::AddToAll => "[x]",
169            BulkTagAction::RemoveFromAll => "[ ]",
170        }
171    }
172}
173
174/// A single row in the bulk tag editor.
175#[derive(Debug, Clone)]
176pub struct BulkTagRow {
177    pub tag: String,
178    /// Number of selected hosts that had this tag at editor open time.
179    pub initial_count: usize,
180    pub action: BulkTagAction,
181}
182
183/// Snapshot state for the bulk tag editor overlay.
184#[derive(Debug, Default)]
185pub struct BulkTagEditorState {
186    pub rows: Vec<BulkTagRow>,
187    /// Aliases being edited, snapshot at open time so selection changes
188    /// during the flow do not affect the in-progress edit.
189    pub aliases: Vec<String>,
190    /// Aliases that live in an Include file and cannot be edited in place.
191    /// Surfaced in the header so the user sees the blast radius.
192    pub skipped_included: Vec<String>,
193    /// Draft name for a brand-new tag being typed by the user. `None` when
194    /// the input bar is inactive. Newly entered tags are appended to `rows`
195    /// with `action = AddToAll`.
196    pub new_tag_input: Option<String>,
197    pub new_tag_cursor: usize,
198    /// Snapshot of `rows[i].action` at editor open time. Used by `is_dirty`
199    /// to detect pending changes on Esc and prompt the user before
200    /// discarding. Captured by the opener (e.g. `App::open_bulk_tag_editor`)
201    /// after `rows` is populated.
202    ///
203    /// Length-mismatch semantics: any extra row beyond the baseline length
204    /// (i.e. a newly added tag via `+`) counts as dirty if its action is
205    /// non-Leave. This matches the user's intuition that "I typed a new tag,
206    /// closing now should warn me".
207    pub initial_actions: Vec<BulkTagAction>,
208}
209
210impl BulkTagEditorState {
211    /// Returns true if any row's action differs from the open-time baseline,
212    /// or if rows have been added since open.
213    ///
214    /// Single source of truth for the dirty check. The handler consults this
215    /// on Esc to decide between immediate exit and discard confirmation.
216    /// Every editable surface gets a dirty-check so Esc never drops unsaved
217    /// work.
218    ///
219    /// **Invariant**: rows is append-only after `open_bulk_tag_editor`
220    /// captures the baseline. The `+ new tag` flow only appends to `rows`;
221    /// no code path removes rows during the editor session. If a future
222    /// change introduces row removal, the length-mismatch branch below will
223    /// silently treat the missing baseline rows as clean (because `zip`
224    /// stops at the shorter slice). At that point this method needs an
225    /// explicit shrink branch; the assertion below guards the assumption.
226    pub fn is_dirty(&self) -> bool {
227        debug_assert!(
228            self.rows.len() >= self.initial_actions.len(),
229            "rows must be append-only after baseline capture; \
230             shorter rows breaks the dirty-check"
231        );
232        if self.rows.len() != self.initial_actions.len() {
233            // Tags added since open. New rows count as dirty unless still Leave.
234            return self
235                .rows
236                .iter()
237                .skip(self.initial_actions.len())
238                .any(|r| r.action != BulkTagAction::Leave)
239                || self
240                    .rows
241                    .iter()
242                    .zip(self.initial_actions.iter())
243                    .any(|(r, baseline)| r.action != *baseline);
244        }
245        self.rows
246            .iter()
247            .zip(self.initial_actions.iter())
248            .any(|(r, baseline)| r.action != *baseline)
249    }
250}
251
252/// Outcome of applying a bulk tag edit.
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
254pub struct BulkTagApplyResult {
255    /// Hosts whose tag list actually changed.
256    pub changed_hosts: usize,
257    /// Total (host, tag) additions.
258    pub added: usize,
259    /// Total (host, tag) removals.
260    pub removed: usize,
261    /// Hosts skipped because they live in an Include file.
262    pub skipped_included: usize,
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn open_tag_input_seeds_text_and_parks_cursor_at_end() {
271        let mut t = TagState::default();
272        t.open_tag_input("prod, web".to_string());
273        assert_eq!(t.input.as_deref(), Some("prod, web"));
274        assert_eq!(t.cursor, "prod, web".chars().count());
275    }
276
277    #[test]
278    fn open_tag_input_with_empty_text_lands_cursor_at_zero() {
279        let mut t = TagState::default();
280        t.open_tag_input(String::new());
281        assert_eq!(t.input.as_deref(), Some(""));
282        assert_eq!(t.cursor, 0);
283    }
284
285    #[test]
286    fn open_tag_input_counts_chars_not_bytes() {
287        // Cursor units are character positions; multi-byte text must not
288        // produce a byte-offset cursor (host_detail handler indexes by
289        // chars when converting to byte positions).
290        let mut t = TagState::default();
291        t.open_tag_input("café".to_string());
292        assert_eq!(t.cursor, 4);
293    }
294
295    #[test]
296    fn close_tag_input_clears_both_fields() {
297        let mut t = TagState::default();
298        t.open_tag_input("staging".to_string());
299        t.close_tag_input();
300        assert!(t.input.is_none());
301        assert_eq!(t.cursor, 0);
302    }
303
304    #[test]
305    fn close_tag_input_on_idle_state_is_noop() {
306        let mut t = TagState::default();
307        t.close_tag_input();
308        assert!(t.input.is_none());
309        assert_eq!(t.cursor, 0);
310    }
311
312    #[test]
313    fn close_tag_input_does_not_touch_picker_list() {
314        // The `list` field powers the tag picker overlay and lives
315        // independently of the inline tag-edit input.
316        let mut t = TagState {
317            list: vec!["prod".to_string(), "web".to_string()],
318            ..Default::default()
319        };
320        t.open_tag_input("staging".to_string());
321        t.close_tag_input();
322        assert_eq!(t.list, vec!["prod".to_string(), "web".to_string()]);
323    }
324}