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
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
85/// User action per tag row in the bulk tag editor.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum BulkTagAction {
88 /// `[~]` Leave each host's state for this tag unchanged.
89 Leave,
90 /// `[x]` Ensure the tag is present on every selected host.
91 AddToAll,
92 /// `[ ]` Ensure the tag is absent from every selected host.
93 RemoveFromAll,
94}
95
96impl BulkTagAction {
97 /// 3-way cycle: `Leave` → `AddToAll` → `RemoveFromAll` → `Leave`.
98 pub fn cycle(self) -> Self {
99 match self {
100 BulkTagAction::Leave => BulkTagAction::AddToAll,
101 BulkTagAction::AddToAll => BulkTagAction::RemoveFromAll,
102 BulkTagAction::RemoveFromAll => BulkTagAction::Leave,
103 }
104 }
105
106 pub fn glyph(self) -> &'static str {
107 match self {
108 BulkTagAction::Leave => "[~]",
109 BulkTagAction::AddToAll => "[x]",
110 BulkTagAction::RemoveFromAll => "[ ]",
111 }
112 }
113}
114
115/// A single row in the bulk tag editor.
116#[derive(Debug, Clone)]
117pub struct BulkTagRow {
118 pub tag: String,
119 /// Number of selected hosts that had this tag at editor open time.
120 pub initial_count: usize,
121 pub action: BulkTagAction,
122}
123
124/// Snapshot state for the bulk tag editor overlay.
125#[derive(Debug, Default)]
126pub struct BulkTagEditorState {
127 pub rows: Vec<BulkTagRow>,
128 /// Aliases being edited, snapshot at open time so selection changes
129 /// during the flow do not affect the in-progress edit.
130 pub aliases: Vec<String>,
131 /// Aliases that live in an Include file and cannot be edited in place.
132 /// Surfaced in the header so the user sees the blast radius.
133 pub skipped_included: Vec<String>,
134 /// Draft name for a brand-new tag being typed by the user. `None` when
135 /// the input bar is inactive. Newly entered tags are appended to `rows`
136 /// with `action = AddToAll`.
137 pub new_tag_input: Option<String>,
138 pub new_tag_cursor: usize,
139 /// Snapshot of `rows[i].action` at editor open time. Used by `is_dirty`
140 /// to detect pending changes on Esc and prompt the user before
141 /// discarding. Captured by the opener (e.g. `App::open_bulk_tag_editor`)
142 /// after `rows` is populated.
143 ///
144 /// Length-mismatch semantics: any extra row beyond the baseline length
145 /// (i.e. a newly added tag via `+`) counts as dirty if its action is
146 /// non-Leave. This matches the user's intuition that "I typed a new tag,
147 /// closing now should warn me".
148 pub initial_actions: Vec<BulkTagAction>,
149}
150
151impl BulkTagEditorState {
152 /// Returns true if any row's action differs from the open-time baseline,
153 /// or if rows have been added since open.
154 ///
155 /// Single source of truth for the dirty check. The handler consults this
156 /// on Esc to decide between immediate exit and discard confirmation.
157 /// Every editable surface gets a dirty-check so Esc never drops unsaved
158 /// work.
159 ///
160 /// **Invariant**: rows is append-only after `open_bulk_tag_editor`
161 /// captures the baseline. The `+ new tag` flow only appends to `rows`;
162 /// no code path removes rows during the editor session. If a future
163 /// change introduces row removal, the length-mismatch branch below will
164 /// silently treat the missing baseline rows as clean (because `zip`
165 /// stops at the shorter slice). At that point this method needs an
166 /// explicit shrink branch; the assertion below guards the assumption.
167 pub fn is_dirty(&self) -> bool {
168 debug_assert!(
169 self.rows.len() >= self.initial_actions.len(),
170 "rows must be append-only after baseline capture; \
171 shorter rows breaks the dirty-check"
172 );
173 if self.rows.len() != self.initial_actions.len() {
174 // Tags added since open. New rows count as dirty unless still Leave.
175 return self
176 .rows
177 .iter()
178 .skip(self.initial_actions.len())
179 .any(|r| r.action != BulkTagAction::Leave)
180 || self
181 .rows
182 .iter()
183 .zip(self.initial_actions.iter())
184 .any(|(r, baseline)| r.action != *baseline);
185 }
186 self.rows
187 .iter()
188 .zip(self.initial_actions.iter())
189 .any(|(r, baseline)| r.action != *baseline)
190 }
191}
192
193/// Outcome of applying a bulk tag edit.
194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
195pub struct BulkTagApplyResult {
196 /// Hosts whose tag list actually changed.
197 pub changed_hosts: usize,
198 /// Total (host, tag) additions.
199 pub added: usize,
200 /// Total (host, tag) removals.
201 pub removed: usize,
202 /// Hosts skipped because they live in an Include file.
203 pub skipped_included: usize,
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn open_tag_input_seeds_text_and_parks_cursor_at_end() {
212 let mut t = TagState::default();
213 t.open_tag_input("prod, web".to_string());
214 assert_eq!(t.input.as_deref(), Some("prod, web"));
215 assert_eq!(t.cursor, "prod, web".chars().count());
216 }
217
218 #[test]
219 fn open_tag_input_with_empty_text_lands_cursor_at_zero() {
220 let mut t = TagState::default();
221 t.open_tag_input(String::new());
222 assert_eq!(t.input.as_deref(), Some(""));
223 assert_eq!(t.cursor, 0);
224 }
225
226 #[test]
227 fn open_tag_input_counts_chars_not_bytes() {
228 // Cursor units are character positions; multi-byte text must not
229 // produce a byte-offset cursor (host_detail handler indexes by
230 // chars when converting to byte positions).
231 let mut t = TagState::default();
232 t.open_tag_input("café".to_string());
233 assert_eq!(t.cursor, 4);
234 }
235
236 #[test]
237 fn close_tag_input_clears_both_fields() {
238 let mut t = TagState::default();
239 t.open_tag_input("staging".to_string());
240 t.close_tag_input();
241 assert!(t.input.is_none());
242 assert_eq!(t.cursor, 0);
243 }
244
245 #[test]
246 fn close_tag_input_on_idle_state_is_noop() {
247 let mut t = TagState::default();
248 t.close_tag_input();
249 assert!(t.input.is_none());
250 assert_eq!(t.cursor, 0);
251 }
252
253 #[test]
254 fn close_tag_input_does_not_touch_picker_list() {
255 // The `list` field powers the tag picker overlay and lives
256 // independently of the inline tag-edit input.
257 let mut t = TagState {
258 list: vec!["prod".to_string(), "web".to_string()],
259 ..Default::default()
260 };
261 t.open_tag_input("staging".to_string());
262 t.close_tag_input();
263 assert_eq!(t.list, vec!["prod".to_string(), "web".to_string()]);
264 }
265}