purple_ssh/app/
tag_state.rs1use crate::app::host_state::GroupBy;
4use crate::ssh_config::model::HostEntry;
5
6#[derive(Debug, Clone, PartialEq)]
8pub struct DisplayTag {
9 pub name: String,
10 pub is_user: bool,
11}
12
13pub 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#[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 pub(crate) fn open_tag_input(&mut self, text: String) {
72 self.cursor = text.chars().count();
73 self.input = Some(text);
74 }
75
76 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum BulkTagAction {
147 Leave,
149 AddToAll,
151 RemoveFromAll,
153}
154
155impl BulkTagAction {
156 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#[derive(Debug, Clone)]
176pub struct BulkTagRow {
177 pub tag: String,
178 pub initial_count: usize,
180 pub action: BulkTagAction,
181}
182
183#[derive(Debug, Default)]
185pub struct BulkTagEditorState {
186 pub rows: Vec<BulkTagRow>,
187 pub aliases: Vec<String>,
190 pub skipped_included: Vec<String>,
193 pub new_tag_input: Option<String>,
197 pub new_tag_cursor: usize,
198 pub initial_actions: Vec<BulkTagAction>,
208}
209
210impl BulkTagEditorState {
211 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
254pub struct BulkTagApplyResult {
255 pub changed_hosts: usize,
257 pub added: usize,
259 pub removed: usize,
261 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 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 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}