Skip to main content

start_command/
sequence_parser.rs

1//! Sequence Parser for Isolation Stacking
2//!
3//! Parses space-separated sequences with underscore placeholders for
4//! distributing options across isolation levels.
5//!
6//! Based on Links Notation conventions and mirrors the JavaScript implementation
7//! in js/src/lib/sequence-parser.js.
8
9/// Parse a space-separated sequence with underscore placeholders.
10///
11/// Returns a Vec of Option<String>, with None for underscore placeholders.
12///
13/// # Examples
14///
15/// ```
16/// use start_command::sequence_parser::parse_sequence;
17/// assert_eq!(parse_sequence("docker"), vec![Some("docker".to_string())]);
18/// assert_eq!(parse_sequence("screen ssh docker"), vec![
19///     Some("screen".to_string()),
20///     Some("ssh".to_string()),
21///     Some("docker".to_string()),
22/// ]);
23/// assert_eq!(parse_sequence("_ ssh _"), vec![None, Some("ssh".to_string()), None]);
24/// ```
25pub fn parse_sequence(value: &str) -> Vec<Option<String>> {
26    // split_whitespace handles leading/trailing whitespace and returns empty iter for empty/whitespace-only strings
27    let parts: Vec<Option<String>> = value
28        .split_whitespace()
29        .map(|v| if v == "_" { None } else { Some(v.to_string()) })
30        .collect();
31    parts
32}
33
34/// Format a sequence array back to a space-separated string.
35///
36/// None values are represented as underscores.
37///
38/// # Examples
39///
40/// ```
41/// use start_command::sequence_parser::format_sequence;
42/// assert_eq!(format_sequence(&[Some("screen".to_string()), None, Some("docker".to_string())]),
43///     "screen _ docker");
44/// assert_eq!(format_sequence(&[]), "");
45/// ```
46pub fn format_sequence(sequence: &[Option<String>]) -> String {
47    if sequence.is_empty() {
48        return String::new();
49    }
50    sequence
51        .iter()
52        .map(|v| v.as_deref().unwrap_or("_"))
53        .collect::<Vec<_>>()
54        .join(" ")
55}
56
57/// Shift a sequence by removing the first element.
58///
59/// # Examples
60///
61/// ```
62/// use start_command::sequence_parser::shift_sequence;
63/// let seq = vec![Some("screen".to_string()), Some("ssh".to_string())];
64/// assert_eq!(shift_sequence(&seq), vec![Some("ssh".to_string())]);
65/// ```
66pub fn shift_sequence(sequence: &[Option<String>]) -> Vec<Option<String>> {
67    if sequence.is_empty() {
68        return vec![];
69    }
70    sequence[1..].to_vec()
71}
72
73/// Check if a string represents a multi-value sequence (contains spaces).
74///
75/// # Examples
76///
77/// ```
78/// use start_command::sequence_parser::is_sequence;
79/// assert!(is_sequence("screen ssh docker"));
80/// assert!(!is_sequence("docker"));
81/// assert!(!is_sequence(""));
82/// ```
83pub fn is_sequence(value: &str) -> bool {
84    value.contains(' ')
85}
86
87/// Distribute a single option value across all isolation levels.
88///
89/// If the value is a sequence, validates length matches stack depth.
90/// If the value is a single value, replicates it for all levels.
91///
92/// Returns an error string if sequence length doesn't match stack depth.
93///
94/// # Examples
95///
96/// ```
97/// use start_command::sequence_parser::distribute_option;
98/// // Single value replicated
99/// assert_eq!(distribute_option("node:20", 3, "image"), Ok(vec![
100///     Some("node:20".to_string()),
101///     Some("node:20".to_string()),
102///     Some("node:20".to_string()),
103/// ]));
104/// // Sequence distributed
105/// assert_eq!(distribute_option("_ user@host _", 3, "endpoint"), Ok(vec![
106///     None, Some("user@host".to_string()), None,
107/// ]));
108/// ```
109pub fn distribute_option(
110    option_value: &str,
111    stack_depth: usize,
112    option_name: &str,
113) -> Result<Vec<Option<String>>, String> {
114    if option_value.is_empty() {
115        return Ok(vec![None; stack_depth]);
116    }
117
118    let parsed = parse_sequence(option_value);
119
120    // Single value: replicate for all levels
121    if parsed.len() == 1 && stack_depth > 1 {
122        return Ok(vec![parsed[0].clone(); stack_depth]);
123    }
124
125    // Sequence: validate length matches
126    if parsed.len() != stack_depth {
127        return Err(format!(
128            "{} has {} value(s) but isolation stack has {} level(s). \
129             Use underscores (_) as placeholders for levels that don't need this option.",
130            option_name,
131            parsed.len(),
132            stack_depth
133        ));
134    }
135
136    Ok(parsed)
137}
138
139/// Get the value at a specific level from a distributed option.
140///
141/// Returns None if the index is out of bounds or the value at that level is None.
142pub fn get_value_at_level(distributed: &[Option<String>], level: usize) -> Option<&str> {
143    distributed.get(level)?.as_deref()
144}
145
146/// Format isolation chain for display.
147///
148/// Returns a human-readable description like "screen → ssh@host → docker:ubuntu".
149///
150/// # Examples
151///
152/// ```
153/// use start_command::sequence_parser::{format_isolation_chain, IsolationChainOptions};
154/// let stack = vec![Some("screen".to_string()), Some("docker".to_string())];
155/// let opts = IsolationChainOptions::default();
156/// assert_eq!(format_isolation_chain(&stack, &opts), "screen → docker");
157/// ```
158pub fn format_isolation_chain(stack: &[Option<String>], options: &IsolationChainOptions) -> String {
159    if stack.is_empty() {
160        return String::new();
161    }
162    stack
163        .iter()
164        .enumerate()
165        .map(|(i, backend)| match backend.as_deref() {
166            None => "_".to_string(),
167            Some("ssh") => {
168                if let Some(ep) = get_value_at_level(&options.endpoint_stack, i) {
169                    format!("ssh@{}", ep)
170                } else {
171                    "ssh".to_string()
172                }
173            }
174            Some("docker") => {
175                if let Some(image) = get_value_at_level(&options.image_stack, i) {
176                    let short_name = image.split(':').next().unwrap_or(image);
177                    let short_name = short_name.rsplit('/').next().unwrap_or(short_name);
178                    format!("docker:{}", short_name)
179                } else {
180                    "docker".to_string()
181                }
182            }
183            Some(b) => b.to_string(),
184        })
185        .collect::<Vec<_>>()
186        .join(" \u{2192} ")
187}
188
189/// Options for format_isolation_chain
190#[derive(Default)]
191pub struct IsolationChainOptions {
192    /// Distributed endpoints for SSH levels
193    pub endpoint_stack: Vec<Option<String>>,
194    /// Distributed images for Docker levels
195    pub image_stack: Vec<Option<String>>,
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_parse_sequence_single() {
204        assert_eq!(parse_sequence("docker"), vec![Some("docker".to_string())]);
205    }
206
207    #[test]
208    fn test_parse_sequence_multiple() {
209        assert_eq!(
210            parse_sequence("screen ssh docker"),
211            vec![
212                Some("screen".to_string()),
213                Some("ssh".to_string()),
214                Some("docker".to_string()),
215            ]
216        );
217    }
218
219    #[test]
220    fn test_parse_sequence_with_underscores() {
221        assert_eq!(
222            parse_sequence("_ ssh _ docker"),
223            vec![
224                None,
225                Some("ssh".to_string()),
226                None,
227                Some("docker".to_string()),
228            ]
229        );
230    }
231
232    #[test]
233    fn test_parse_sequence_all_underscores() {
234        assert_eq!(parse_sequence("_ _ _"), vec![None, None, None]);
235    }
236
237    #[test]
238    fn test_parse_sequence_empty_string() {
239        assert_eq!(parse_sequence(""), vec![]);
240    }
241
242    #[test]
243    fn test_parse_sequence_trims_whitespace() {
244        assert_eq!(
245            parse_sequence("  docker  "),
246            vec![Some("docker".to_string())]
247        );
248    }
249
250    #[test]
251    fn test_parse_sequence_multiple_spaces() {
252        assert_eq!(
253            parse_sequence("screen  ssh"),
254            vec![Some("screen".to_string()), Some("ssh".to_string())]
255        );
256    }
257
258    #[test]
259    fn test_format_sequence_with_values() {
260        let seq = vec![
261            Some("screen".to_string()),
262            Some("ssh".to_string()),
263            Some("docker".to_string()),
264        ];
265        assert_eq!(format_sequence(&seq), "screen ssh docker");
266    }
267
268    #[test]
269    fn test_format_sequence_with_nulls() {
270        let seq = vec![None, Some("ssh".to_string()), None];
271        assert_eq!(format_sequence(&seq), "_ ssh _");
272    }
273
274    #[test]
275    fn test_format_sequence_empty() {
276        assert_eq!(format_sequence(&[]), "");
277    }
278
279    #[test]
280    fn test_shift_sequence_removes_first() {
281        let seq = vec![
282            Some("screen".to_string()),
283            Some("ssh".to_string()),
284            Some("docker".to_string()),
285        ];
286        assert_eq!(
287            shift_sequence(&seq),
288            vec![Some("ssh".to_string()), Some("docker".to_string())]
289        );
290    }
291
292    #[test]
293    fn test_shift_sequence_with_nulls() {
294        let seq = vec![None, Some("ssh".to_string())];
295        assert_eq!(shift_sequence(&seq), vec![Some("ssh".to_string())]);
296    }
297
298    #[test]
299    fn test_shift_sequence_single_element() {
300        let seq = vec![Some("docker".to_string())];
301        assert_eq!(shift_sequence(&seq), vec![]);
302    }
303
304    #[test]
305    fn test_shift_sequence_empty() {
306        assert_eq!(shift_sequence(&[]), vec![]);
307    }
308
309    #[test]
310    fn test_is_sequence_true_for_space_separated() {
311        assert!(is_sequence("screen ssh docker"));
312        assert!(is_sequence("_ ssh _"));
313    }
314
315    #[test]
316    fn test_is_sequence_false_for_single_value() {
317        assert!(!is_sequence("docker"));
318        assert!(!is_sequence("screen"));
319    }
320
321    #[test]
322    fn test_is_sequence_false_for_empty() {
323        assert!(!is_sequence(""));
324    }
325
326    #[test]
327    fn test_distribute_option_single_value_replicates() {
328        let result = distribute_option("node:20", 3, "image").unwrap();
329        assert_eq!(
330            result,
331            vec![
332                Some("node:20".to_string()),
333                Some("node:20".to_string()),
334                Some("node:20".to_string()),
335            ]
336        );
337    }
338
339    #[test]
340    fn test_distribute_option_sequence_with_matching_length() {
341        let result = distribute_option("_ user@host _", 3, "endpoint").unwrap();
342        assert_eq!(result, vec![None, Some("user@host".to_string()), None]);
343    }
344
345    #[test]
346    fn test_distribute_option_throws_on_length_mismatch() {
347        let result = distribute_option("_ user@host", 3, "endpoint");
348        assert!(result.is_err());
349        assert!(result.unwrap_err().contains("endpoint"));
350    }
351
352    #[test]
353    fn test_distribute_option_empty_returns_nulls() {
354        let result = distribute_option("", 3, "endpoint").unwrap();
355        assert_eq!(result, vec![None, None, None]);
356    }
357
358    #[test]
359    fn test_get_value_at_level_valid_index() {
360        let dist = vec![Some("screen".to_string()), None, Some("docker".to_string())];
361        assert_eq!(get_value_at_level(&dist, 0), Some("screen"));
362        assert_eq!(get_value_at_level(&dist, 2), Some("docker"));
363    }
364
365    #[test]
366    fn test_get_value_at_level_null_value() {
367        let dist = vec![None, Some("ssh".to_string())];
368        assert_eq!(get_value_at_level(&dist, 0), None);
369    }
370
371    #[test]
372    fn test_get_value_at_level_out_of_bounds() {
373        let dist = vec![Some("docker".to_string())];
374        assert_eq!(get_value_at_level(&dist, 5), None);
375    }
376
377    #[test]
378    fn test_format_isolation_chain_simple() {
379        let stack = vec![Some("screen".to_string()), Some("docker".to_string())];
380        let opts = IsolationChainOptions::default();
381        assert_eq!(
382            format_isolation_chain(&stack, &opts),
383            "screen \u{2192} docker"
384        );
385    }
386
387    #[test]
388    fn test_format_isolation_chain_with_ssh_endpoint() {
389        let stack = vec![Some("screen".to_string()), Some("ssh".to_string())];
390        let opts = IsolationChainOptions {
391            endpoint_stack: vec![None, Some("user@host.com".to_string())],
392            image_stack: vec![],
393        };
394        assert_eq!(
395            format_isolation_chain(&stack, &opts),
396            "screen \u{2192} ssh@user@host.com"
397        );
398    }
399
400    #[test]
401    fn test_format_isolation_chain_with_docker_image_short_name() {
402        let stack = vec![Some("docker".to_string())];
403        let opts = IsolationChainOptions {
404            image_stack: vec![Some("myregistry.io/team/ubuntu:22.04".to_string())],
405            endpoint_stack: vec![],
406        };
407        assert_eq!(format_isolation_chain(&stack, &opts), "docker:ubuntu");
408    }
409
410    #[test]
411    fn test_format_isolation_chain_with_placeholders() {
412        let stack = vec![None, Some("ssh".to_string()), None];
413        let opts = IsolationChainOptions::default();
414        assert_eq!(
415            format_isolation_chain(&stack, &opts),
416            "_ \u{2192} ssh \u{2192} _"
417        );
418    }
419
420    #[test]
421    fn test_format_isolation_chain_empty() {
422        let opts = IsolationChainOptions::default();
423        assert_eq!(format_isolation_chain(&[], &opts), "");
424    }
425}