1use std::io;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use crate::fs_util;
6
7#[derive(Debug, Clone, PartialEq)]
9pub struct Snippet {
10 pub name: String,
11 pub command: String,
12 pub description: String,
13}
14
15pub struct SnippetResult {
17 pub status: ExitStatus,
18 pub stdout: String,
19 pub stderr: String,
20}
21
22#[derive(Debug, Clone, Default)]
24pub struct SnippetStore {
25 pub snippets: Vec<Snippet>,
26 pub path_override: Option<PathBuf>,
28}
29
30fn config_path() -> Option<PathBuf> {
31 dirs::home_dir().map(|h| h.join(".purple/snippets"))
32}
33
34impl SnippetStore {
35 pub fn load() -> Self {
38 let path = match config_path() {
39 Some(p) => p,
40 None => return Self::default(),
41 };
42 let content = match std::fs::read_to_string(&path) {
43 Ok(c) => c,
44 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
45 Err(e) => {
46 eprintln!("! Could not read {}: {}", path.display(), e);
47 return Self::default();
48 }
49 };
50 Self::parse(&content)
51 }
52
53 pub fn parse(content: &str) -> Self {
55 let mut snippets = Vec::new();
56 let mut current: Option<Snippet> = None;
57
58 for line in content.lines() {
59 let trimmed = line.trim();
60 if trimmed.is_empty() || trimmed.starts_with('#') {
61 continue;
62 }
63 if trimmed.starts_with('[') && trimmed.ends_with(']') {
64 if let Some(snippet) = current.take() {
65 if !snippet.command.is_empty()
66 && !snippets.iter().any(|s: &Snippet| s.name == snippet.name)
67 {
68 snippets.push(snippet);
69 }
70 }
71 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
72 if snippets.iter().any(|s| s.name == name) {
73 current = None;
74 continue;
75 }
76 current = Some(Snippet {
77 name,
78 command: String::new(),
79 description: String::new(),
80 });
81 } else if let Some(ref mut snippet) = current {
82 if let Some((key, value)) = trimmed.split_once('=') {
83 let key = key.trim();
84 let value = value.trim_start().to_string();
87 match key {
88 "command" => snippet.command = value,
89 "description" => snippet.description = value,
90 _ => {}
91 }
92 }
93 }
94 }
95 if let Some(snippet) = current {
96 if !snippet.command.is_empty() && !snippets.iter().any(|s| s.name == snippet.name) {
97 snippets.push(snippet);
98 }
99 }
100 Self {
101 snippets,
102 path_override: None,
103 }
104 }
105
106 pub fn save(&self) -> io::Result<()> {
108 if crate::demo_flag::is_demo() {
109 return Ok(());
110 }
111 let path = match &self.path_override {
112 Some(p) => p.clone(),
113 None => match config_path() {
114 Some(p) => p,
115 None => {
116 return Err(io::Error::new(
117 io::ErrorKind::NotFound,
118 "Could not determine home directory",
119 ));
120 }
121 },
122 };
123
124 let mut content = String::new();
125 for (i, snippet) in self.snippets.iter().enumerate() {
126 if i > 0 {
127 content.push('\n');
128 }
129 content.push_str(&format!("[{}]\n", snippet.name));
130 content.push_str(&format!("command={}\n", snippet.command));
131 if !snippet.description.is_empty() {
132 content.push_str(&format!("description={}\n", snippet.description));
133 }
134 }
135
136 fs_util::atomic_write(&path, content.as_bytes())
137 }
138
139 pub fn get(&self, name: &str) -> Option<&Snippet> {
141 self.snippets.iter().find(|s| s.name == name)
142 }
143
144 pub fn set(&mut self, snippet: Snippet) {
146 if let Some(existing) = self.snippets.iter_mut().find(|s| s.name == snippet.name) {
147 *existing = snippet;
148 } else {
149 self.snippets.push(snippet);
150 }
151 }
152
153 pub fn remove(&mut self, name: &str) {
155 self.snippets.retain(|s| s.name != name);
156 }
157}
158
159pub fn validate_name(name: &str) -> Result<(), String> {
162 if name.trim().is_empty() {
163 return Err("Snippet name cannot be empty.".to_string());
164 }
165 if name != name.trim() {
166 return Err("Snippet name cannot have leading or trailing whitespace.".to_string());
167 }
168 if name.contains('#') || name.contains('[') || name.contains(']') {
169 return Err("Snippet name cannot contain #, [ or ].".to_string());
170 }
171 if name.contains(|c: char| c.is_control()) {
172 return Err("Snippet name cannot contain control characters.".to_string());
173 }
174 Ok(())
175}
176
177pub fn validate_command(command: &str) -> Result<(), String> {
179 if command.trim().is_empty() {
180 return Err("Command cannot be empty.".to_string());
181 }
182 if command.contains(|c: char| c.is_control() && c != '\t') {
183 return Err("Command cannot contain control characters.".to_string());
184 }
185 Ok(())
186}
187
188#[derive(Debug, Clone, PartialEq)]
194pub struct SnippetParam {
195 pub name: String,
196 pub default: Option<String>,
197}
198
199pub fn shell_escape(s: &str) -> String {
202 format!("'{}'", s.replace('\'', "'\\''"))
203}
204
205pub fn parse_params(command: &str) -> Vec<SnippetParam> {
208 let mut params = Vec::new();
209 let mut seen = std::collections::HashSet::new();
210 let bytes = command.as_bytes();
211 let len = bytes.len();
212 let mut i = 0;
213 while i + 3 < len {
214 if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') {
215 if let Some(end) = command[i + 2..].find("}}") {
216 let inner = &command[i + 2..i + 2 + end];
217 let (name, default) = if let Some((n, d)) = inner.split_once(':') {
218 (n.to_string(), Some(d.to_string()))
219 } else {
220 (inner.to_string(), None)
221 };
222 if validate_param_name(&name).is_ok() && !seen.contains(&name) && params.len() < 20
223 {
224 seen.insert(name.clone());
225 params.push(SnippetParam { name, default });
226 }
227 i = i + 2 + end + 2;
228 continue;
229 }
230 }
231 i += 1;
232 }
233 params
234}
235
236pub fn validate_param_name(name: &str) -> Result<(), String> {
239 if name.is_empty() {
240 return Err("Parameter name cannot be empty.".to_string());
241 }
242 if !name
243 .chars()
244 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
245 {
246 return Err(format!(
247 "Parameter name '{}' contains invalid characters.",
248 name
249 ));
250 }
251 Ok(())
252}
253
254pub fn substitute_params(
257 command: &str,
258 values: &std::collections::HashMap<String, String>,
259) -> String {
260 let mut result = String::with_capacity(command.len());
261 let bytes = command.as_bytes();
262 let len = bytes.len();
263 let mut i = 0;
264 while i < len {
265 if i + 3 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
266 if let Some(end) = command[i + 2..].find("}}") {
267 let inner = &command[i + 2..i + 2 + end];
268 let (name, default) = if let Some((n, d)) = inner.split_once(':') {
269 (n, Some(d))
270 } else {
271 (inner, None)
272 };
273 let value = values
274 .get(name)
275 .filter(|v| !v.is_empty())
276 .map(|v| v.as_str())
277 .or(default)
278 .unwrap_or("");
279 result.push_str(&shell_escape(value));
280 i = i + 2 + end + 2;
281 continue;
282 }
283 }
284 let ch = command[i..].chars().next().unwrap();
286 result.push(ch);
287 i += ch.len_utf8();
288 }
289 result
290}
291
292pub fn sanitize_output(input: &str) -> String {
299 let mut out = String::with_capacity(input.len());
300 let mut chars = input.chars().peekable();
301 while let Some(c) = chars.next() {
302 match c {
303 '\x1b' => {
304 match chars.peek() {
305 Some('[') => {
306 chars.next();
307 while let Some(&ch) = chars.peek() {
309 chars.next();
310 if ('\x40'..='\x7e').contains(&ch) {
311 break;
312 }
313 }
314 }
315 Some(']') | Some('P') | Some('X') | Some('^') | Some('_') => {
316 chars.next();
317 consume_until_st(&mut chars);
319 }
320 _ => {
321 chars.next();
323 }
324 }
325 }
326 c if ('\u{0080}'..='\u{009F}').contains(&c) => {
327 }
329 c if c.is_control() && c != '\n' && c != '\t' => {
330 }
332 _ => out.push(c),
333 }
334 }
335 out
336}
337
338fn consume_until_st(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
340 while let Some(&ch) = chars.peek() {
341 if ch == '\x07' {
342 chars.next();
343 break;
344 }
345 if ch == '\x1b' {
346 chars.next();
347 if chars.peek() == Some(&'\\') {
348 chars.next();
349 }
350 break;
351 }
352 chars.next();
353 }
354}
355
356const MAX_OUTPUT_LINES: usize = 10_000;
363
364pub enum SnippetEvent {
367 HostDone {
368 run_id: u64,
369 alias: String,
370 stdout: String,
371 stderr: String,
372 exit_code: Option<i32>,
373 },
374 Progress {
375 run_id: u64,
376 completed: usize,
377 total: usize,
378 },
379 AllDone {
380 run_id: u64,
381 },
382}
383
384pub struct ChildGuard {
387 inner: std::sync::Mutex<Option<std::process::Child>>,
388 pgid: i32,
389}
390
391impl ChildGuard {
392 fn new(child: std::process::Child) -> Self {
393 let pgid = i32::try_from(child.id()).unwrap_or(-1);
397 Self {
398 inner: std::sync::Mutex::new(Some(child)),
399 pgid,
400 }
401 }
402}
403
404impl Drop for ChildGuard {
405 fn drop(&mut self) {
406 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
407 if let Some(ref mut child) = *lock {
408 if let Ok(Some(_)) = child.try_wait() {
410 return;
411 }
412 #[cfg(unix)]
414 unsafe {
415 libc::kill(-self.pgid, libc::SIGTERM);
416 }
417 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
419 loop {
420 if let Ok(Some(_)) = child.try_wait() {
421 return;
422 }
423 if std::time::Instant::now() >= deadline {
424 break;
425 }
426 std::thread::sleep(std::time::Duration::from_millis(50));
427 }
428 #[cfg(unix)]
430 unsafe {
431 libc::kill(-self.pgid, libc::SIGKILL);
432 }
433 let _ = child.kill();
435 let _ = child.wait();
436 }
437 }
438}
439
440fn read_pipe_capped<R: io::Read>(reader: R) -> String {
443 use io::BufRead;
444 let mut reader = io::BufReader::new(reader);
445 let mut output = String::new();
446 let mut line_count = 0;
447 let mut capped = false;
448 let mut buf = Vec::new();
449 loop {
450 buf.clear();
451 match reader.read_until(b'\n', &mut buf) {
452 Ok(0) => break, Ok(_) => {
454 if !capped {
455 if line_count < MAX_OUTPUT_LINES {
456 if line_count > 0 {
457 output.push('\n');
458 }
459 if buf.last() == Some(&b'\n') {
461 buf.pop();
462 if buf.last() == Some(&b'\r') {
463 buf.pop();
464 }
465 }
466 output.push_str(&String::from_utf8_lossy(&buf));
468 line_count += 1;
469 } else {
470 output.push_str("\n[Output truncated at 10,000 lines]");
471 capped = true;
472 }
473 }
474 }
476 Err(_) => break,
477 }
478 }
479 output
480}
481
482fn base_ssh_command(
486 alias: &str,
487 config_path: &Path,
488 command: &str,
489 askpass: Option<&str>,
490 bw_session: Option<&str>,
491 has_active_tunnel: bool,
492) -> Command {
493 let mut cmd = Command::new("ssh");
494 cmd.arg("-F")
495 .arg(config_path)
496 .arg("-o")
497 .arg("ConnectTimeout=10")
498 .arg("-o")
499 .arg("ControlMaster=no")
500 .arg("-o")
501 .arg("ControlPath=none");
502
503 if has_active_tunnel {
504 cmd.arg("-o").arg("ClearAllForwardings=yes");
505 }
506
507 cmd.arg("--").arg(alias).arg(command);
508
509 if askpass.is_some() {
510 let exe = std::env::current_exe()
511 .ok()
512 .map(|p| p.to_string_lossy().to_string())
513 .or_else(|| std::env::args().next())
514 .unwrap_or_else(|| "purple".to_string());
515 cmd.env("SSH_ASKPASS", &exe)
516 .env("SSH_ASKPASS_REQUIRE", "prefer")
517 .env("PURPLE_ASKPASS_MODE", "1")
518 .env("PURPLE_HOST_ALIAS", alias)
519 .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
520 }
521
522 if let Some(token) = bw_session {
523 cmd.env("BW_SESSION", token);
524 }
525
526 cmd
527}
528
529fn build_snippet_command(
531 alias: &str,
532 config_path: &Path,
533 command: &str,
534 askpass: Option<&str>,
535 bw_session: Option<&str>,
536 has_active_tunnel: bool,
537) -> Command {
538 let mut cmd = base_ssh_command(
539 alias,
540 config_path,
541 command,
542 askpass,
543 bw_session,
544 has_active_tunnel,
545 );
546 cmd.stdin(Stdio::null())
547 .stdout(Stdio::piped())
548 .stderr(Stdio::piped());
549
550 #[cfg(unix)]
553 unsafe {
554 use std::os::unix::process::CommandExt;
555 cmd.pre_exec(|| {
556 libc::setpgid(0, 0);
557 Ok(())
558 });
559 }
560
561 cmd
562}
563
564#[allow(clippy::too_many_arguments)]
566fn execute_host(
567 run_id: u64,
568 alias: &str,
569 config_path: &Path,
570 command: &str,
571 askpass: Option<&str>,
572 bw_session: Option<&str>,
573 has_active_tunnel: bool,
574 tx: &std::sync::mpsc::Sender<SnippetEvent>,
575) -> Option<std::sync::Arc<ChildGuard>> {
576 let mut cmd = build_snippet_command(
577 alias,
578 config_path,
579 command,
580 askpass,
581 bw_session,
582 has_active_tunnel,
583 );
584
585 match cmd.spawn() {
586 Ok(child) => {
587 let guard = std::sync::Arc::new(ChildGuard::new(child));
588
589 let stdout_pipe = {
591 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
592 lock.as_mut().and_then(|c| c.stdout.take())
593 };
594 let stderr_pipe = {
595 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
596 lock.as_mut().and_then(|c| c.stderr.take())
597 };
598
599 let stdout_handle = std::thread::spawn(move || match stdout_pipe {
601 Some(pipe) => read_pipe_capped(pipe),
602 None => String::new(),
603 });
604 let stderr_handle = std::thread::spawn(move || match stderr_pipe {
605 Some(pipe) => read_pipe_capped(pipe),
606 None => String::new(),
607 });
608
609 let stdout_text = stdout_handle.join().unwrap_or_default();
611 let stderr_text = stderr_handle.join().unwrap_or_default();
612
613 let exit_code = {
616 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
617 let status = lock.as_mut().and_then(|c| c.wait().ok());
618 let _ = lock.take(); status.and_then(|s| {
620 #[cfg(unix)]
621 {
622 use std::os::unix::process::ExitStatusExt;
623 s.code().or_else(|| s.signal().map(|sig| 128 + sig))
624 }
625 #[cfg(not(unix))]
626 {
627 s.code()
628 }
629 })
630 };
631
632 let _ = tx.send(SnippetEvent::HostDone {
633 run_id,
634 alias: alias.to_string(),
635 stdout: sanitize_output(&stdout_text),
636 stderr: sanitize_output(&stderr_text),
637 exit_code,
638 });
639
640 Some(guard)
641 }
642 Err(e) => {
643 let _ = tx.send(SnippetEvent::HostDone {
644 run_id,
645 alias: alias.to_string(),
646 stdout: String::new(),
647 stderr: format!("Failed to launch ssh: {}", e),
648 exit_code: None,
649 });
650 None
651 }
652 }
653}
654
655#[allow(clippy::too_many_arguments)]
658pub fn spawn_snippet_execution(
659 run_id: u64,
660 askpass_map: Vec<(String, Option<String>)>,
661 config_path: PathBuf,
662 command: String,
663 bw_session: Option<String>,
664 tunnel_aliases: std::collections::HashSet<String>,
665 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
666 tx: std::sync::mpsc::Sender<SnippetEvent>,
667 parallel: bool,
668) {
669 let total = askpass_map.len();
670 let max_concurrent: usize = 20;
671
672 std::thread::Builder::new()
673 .name("snippet-coordinator".into())
674 .spawn(move || {
675 let guards: std::sync::Arc<std::sync::Mutex<Vec<std::sync::Arc<ChildGuard>>>> =
676 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
677
678 if parallel && total > 1 {
679 let (slot_tx, slot_rx) = std::sync::mpsc::channel::<()>();
681 for _ in 0..max_concurrent.min(total) {
682 let _ = slot_tx.send(());
683 }
684
685 let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
686 let mut worker_handles = Vec::new();
687
688 for (alias, askpass) in askpass_map {
689 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
690 break;
691 }
692
693 loop {
695 match slot_rx.recv_timeout(std::time::Duration::from_millis(100)) {
696 Ok(()) => break,
697 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
698 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
699 break;
700 }
701 }
702 Err(_) => break, }
704 }
705
706 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
707 break;
708 }
709
710 let config_path = config_path.clone();
711 let command = command.clone();
712 let bw_session = bw_session.clone();
713 let has_tunnel = tunnel_aliases.contains(&alias);
714 let tx = tx.clone();
715 let slot_tx = slot_tx.clone();
716 let guards = guards.clone();
717 let completed = completed.clone();
718 let total = total;
719
720 let handle = std::thread::spawn(move || {
721 struct SlotRelease(Option<std::sync::mpsc::Sender<()>>);
723 impl Drop for SlotRelease {
724 fn drop(&mut self) {
725 if let Some(tx) = self.0.take() {
726 let _ = tx.send(());
727 }
728 }
729 }
730 let _slot = SlotRelease(Some(slot_tx));
731
732 let guard = execute_host(
733 run_id,
734 &alias,
735 &config_path,
736 &command,
737 askpass.as_deref(),
738 bw_session.as_deref(),
739 has_tunnel,
740 &tx,
741 );
742
743 if let Some(g) = guard {
745 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
746 }
747
748 let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
749 let _ = tx.send(SnippetEvent::Progress {
750 run_id,
751 completed: c,
752 total,
753 });
754 });
756 worker_handles.push(handle);
757 }
758
759 for handle in worker_handles {
761 let _ = handle.join();
762 }
763 } else {
764 for (i, (alias, askpass)) in askpass_map.into_iter().enumerate() {
766 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
767 break;
768 }
769
770 let has_tunnel = tunnel_aliases.contains(&alias);
771 let guard = execute_host(
772 run_id,
773 &alias,
774 &config_path,
775 &command,
776 askpass.as_deref(),
777 bw_session.as_deref(),
778 has_tunnel,
779 &tx,
780 );
781
782 if let Some(g) = guard {
783 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
784 }
785
786 let _ = tx.send(SnippetEvent::Progress {
787 run_id,
788 completed: i + 1,
789 total,
790 });
791 }
792 }
793
794 let _ = tx.send(SnippetEvent::AllDone { run_id });
795 })
797 .expect("failed to spawn snippet coordinator");
798}
799
800pub fn run_snippet(
805 alias: &str,
806 config_path: &Path,
807 command: &str,
808 askpass: Option<&str>,
809 bw_session: Option<&str>,
810 capture: bool,
811 has_active_tunnel: bool,
812) -> anyhow::Result<SnippetResult> {
813 let mut cmd = base_ssh_command(
814 alias,
815 config_path,
816 command,
817 askpass,
818 bw_session,
819 has_active_tunnel,
820 );
821 cmd.stdin(Stdio::inherit());
822
823 if capture {
824 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
825 } else {
826 cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
827 }
828
829 if capture {
830 let output = cmd
831 .output()
832 .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
833
834 Ok(SnippetResult {
835 status: output.status,
836 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
837 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
838 })
839 } else {
840 let status = cmd
841 .status()
842 .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
843
844 Ok(SnippetResult {
845 status,
846 stdout: String::new(),
847 stderr: String::new(),
848 })
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 #[test]
861 fn test_parse_empty() {
862 let store = SnippetStore::parse("");
863 assert!(store.snippets.is_empty());
864 }
865
866 #[test]
867 fn test_parse_single_snippet() {
868 let content = "\
869[check-disk]
870command=df -h
871description=Check disk usage
872";
873 let store = SnippetStore::parse(content);
874 assert_eq!(store.snippets.len(), 1);
875 let s = &store.snippets[0];
876 assert_eq!(s.name, "check-disk");
877 assert_eq!(s.command, "df -h");
878 assert_eq!(s.description, "Check disk usage");
879 }
880
881 #[test]
882 fn test_parse_multiple_snippets() {
883 let content = "\
884[check-disk]
885command=df -h
886
887[uptime]
888command=uptime
889description=Check server uptime
890";
891 let store = SnippetStore::parse(content);
892 assert_eq!(store.snippets.len(), 2);
893 assert_eq!(store.snippets[0].name, "check-disk");
894 assert_eq!(store.snippets[1].name, "uptime");
895 }
896
897 #[test]
898 fn test_parse_comments_and_blanks() {
899 let content = "\
900# Snippet config
901
902[check-disk]
903# Main command
904command=df -h
905";
906 let store = SnippetStore::parse(content);
907 assert_eq!(store.snippets.len(), 1);
908 assert_eq!(store.snippets[0].command, "df -h");
909 }
910
911 #[test]
912 fn test_parse_duplicate_sections_first_wins() {
913 let content = "\
914[check-disk]
915command=df -h
916
917[check-disk]
918command=du -sh *
919";
920 let store = SnippetStore::parse(content);
921 assert_eq!(store.snippets.len(), 1);
922 assert_eq!(store.snippets[0].command, "df -h");
923 }
924
925 #[test]
926 fn test_parse_snippet_without_command_skipped() {
927 let content = "\
928[empty]
929description=No command here
930
931[valid]
932command=ls -la
933";
934 let store = SnippetStore::parse(content);
935 assert_eq!(store.snippets.len(), 1);
936 assert_eq!(store.snippets[0].name, "valid");
937 }
938
939 #[test]
940 fn test_parse_unknown_keys_ignored() {
941 let content = "\
942[check-disk]
943command=df -h
944unknown=value
945foo=bar
946";
947 let store = SnippetStore::parse(content);
948 assert_eq!(store.snippets.len(), 1);
949 assert_eq!(store.snippets[0].command, "df -h");
950 }
951
952 #[test]
953 fn test_parse_whitespace_in_section_name() {
954 let content = "[ check-disk ]\ncommand=df -h\n";
955 let store = SnippetStore::parse(content);
956 assert_eq!(store.snippets[0].name, "check-disk");
957 }
958
959 #[test]
960 fn test_parse_whitespace_around_key_value() {
961 let content = "[check-disk]\n command = df -h \n";
962 let store = SnippetStore::parse(content);
963 assert_eq!(store.snippets[0].command, "df -h");
964 }
965
966 #[test]
967 fn test_parse_command_with_equals() {
968 let content = "[env-check]\ncommand=env | grep HOME=\n";
969 let store = SnippetStore::parse(content);
970 assert_eq!(store.snippets[0].command, "env | grep HOME=");
971 }
972
973 #[test]
974 fn test_parse_line_without_equals_ignored() {
975 let content = "[check]\ncommand=ls\ngarbage_line\n";
976 let store = SnippetStore::parse(content);
977 assert_eq!(store.snippets[0].command, "ls");
978 }
979
980 #[test]
985 fn test_get_found() {
986 let store = SnippetStore::parse("[check]\ncommand=ls\n");
987 assert!(store.get("check").is_some());
988 }
989
990 #[test]
991 fn test_get_not_found() {
992 let store = SnippetStore::parse("");
993 assert!(store.get("nope").is_none());
994 }
995
996 #[test]
997 fn test_set_adds_new() {
998 let mut store = SnippetStore::default();
999 store.set(Snippet {
1000 name: "check".to_string(),
1001 command: "ls".to_string(),
1002 description: String::new(),
1003 });
1004 assert_eq!(store.snippets.len(), 1);
1005 }
1006
1007 #[test]
1008 fn test_set_replaces_existing() {
1009 let mut store = SnippetStore::parse("[check]\ncommand=ls\n");
1010 store.set(Snippet {
1011 name: "check".to_string(),
1012 command: "df -h".to_string(),
1013 description: String::new(),
1014 });
1015 assert_eq!(store.snippets.len(), 1);
1016 assert_eq!(store.snippets[0].command, "df -h");
1017 }
1018
1019 #[test]
1020 fn test_remove() {
1021 let mut store = SnippetStore::parse("[check]\ncommand=ls\n[uptime]\ncommand=uptime\n");
1022 store.remove("check");
1023 assert_eq!(store.snippets.len(), 1);
1024 assert_eq!(store.snippets[0].name, "uptime");
1025 }
1026
1027 #[test]
1028 fn test_remove_nonexistent_noop() {
1029 let mut store = SnippetStore::parse("[check]\ncommand=ls\n");
1030 store.remove("nope");
1031 assert_eq!(store.snippets.len(), 1);
1032 }
1033
1034 #[test]
1039 fn test_validate_name_valid() {
1040 assert!(validate_name("check-disk").is_ok());
1041 assert!(validate_name("restart_nginx").is_ok());
1042 assert!(validate_name("a").is_ok());
1043 }
1044
1045 #[test]
1046 fn test_validate_name_empty() {
1047 assert!(validate_name("").is_err());
1048 }
1049
1050 #[test]
1051 fn test_validate_name_whitespace() {
1052 assert!(validate_name("check disk").is_ok());
1053 assert!(validate_name("check\tdisk").is_err()); assert!(validate_name(" ").is_err()); assert!(validate_name(" leading").is_err()); assert!(validate_name("trailing ").is_err()); }
1058
1059 #[test]
1060 fn test_validate_name_special_chars() {
1061 assert!(validate_name("check#disk").is_err());
1062 assert!(validate_name("[check]").is_err());
1063 }
1064
1065 #[test]
1066 fn test_validate_name_control_chars() {
1067 assert!(validate_name("check\x00disk").is_err());
1068 }
1069
1070 #[test]
1075 fn test_validate_command_valid() {
1076 assert!(validate_command("df -h").is_ok());
1077 assert!(validate_command("cat /etc/hosts | grep localhost").is_ok());
1078 assert!(validate_command("echo 'hello\tworld'").is_ok()); }
1080
1081 #[test]
1082 fn test_validate_command_empty() {
1083 assert!(validate_command("").is_err());
1084 }
1085
1086 #[test]
1087 fn test_validate_command_whitespace_only() {
1088 assert!(validate_command(" ").is_err());
1089 assert!(validate_command(" \t ").is_err());
1090 }
1091
1092 #[test]
1093 fn test_validate_command_control_chars() {
1094 assert!(validate_command("ls\x00-la").is_err());
1095 }
1096
1097 #[test]
1102 fn test_save_roundtrip() {
1103 let mut store = SnippetStore::default();
1104 store.set(Snippet {
1105 name: "check-disk".to_string(),
1106 command: "df -h".to_string(),
1107 description: "Check disk usage".to_string(),
1108 });
1109 store.set(Snippet {
1110 name: "uptime".to_string(),
1111 command: "uptime".to_string(),
1112 description: String::new(),
1113 });
1114
1115 let mut content = String::new();
1117 for (i, snippet) in store.snippets.iter().enumerate() {
1118 if i > 0 {
1119 content.push('\n');
1120 }
1121 content.push_str(&format!("[{}]\n", snippet.name));
1122 content.push_str(&format!("command={}\n", snippet.command));
1123 if !snippet.description.is_empty() {
1124 content.push_str(&format!("description={}\n", snippet.description));
1125 }
1126 }
1127
1128 let reparsed = SnippetStore::parse(&content);
1130 assert_eq!(reparsed.snippets.len(), 2);
1131 assert_eq!(reparsed.snippets[0].name, "check-disk");
1132 assert_eq!(reparsed.snippets[0].command, "df -h");
1133 assert_eq!(reparsed.snippets[0].description, "Check disk usage");
1134 assert_eq!(reparsed.snippets[1].name, "uptime");
1135 assert_eq!(reparsed.snippets[1].command, "uptime");
1136 assert!(reparsed.snippets[1].description.is_empty());
1137 }
1138
1139 #[test]
1140 fn test_save_to_temp_file() {
1141 let dir = std::env::temp_dir().join(format!("purple_snippet_test_{}", std::process::id()));
1142 let _ = std::fs::create_dir_all(&dir);
1143 let path = dir.join("snippets");
1144
1145 let mut store = SnippetStore {
1146 path_override: Some(path.clone()),
1147 ..Default::default()
1148 };
1149 store.set(Snippet {
1150 name: "test".to_string(),
1151 command: "echo hello".to_string(),
1152 description: "Test snippet".to_string(),
1153 });
1154 store.save().unwrap();
1155
1156 let content = std::fs::read_to_string(&path).unwrap();
1158 let reloaded = SnippetStore::parse(&content);
1159 assert_eq!(reloaded.snippets.len(), 1);
1160 assert_eq!(reloaded.snippets[0].name, "test");
1161 assert_eq!(reloaded.snippets[0].command, "echo hello");
1162
1163 let _ = std::fs::remove_dir_all(&dir);
1165 }
1166
1167 #[test]
1172 fn test_set_multiple_then_remove_all() {
1173 let mut store = SnippetStore::default();
1174 for name in ["a", "b", "c"] {
1175 store.set(Snippet {
1176 name: name.to_string(),
1177 command: "cmd".to_string(),
1178 description: String::new(),
1179 });
1180 }
1181 assert_eq!(store.snippets.len(), 3);
1182 store.remove("a");
1183 store.remove("b");
1184 store.remove("c");
1185 assert!(store.snippets.is_empty());
1186 }
1187
1188 #[test]
1189 fn test_snippet_with_complex_command() {
1190 let content = "[complex]\ncommand=for i in $(seq 1 5); do echo $i; done\n";
1191 let store = SnippetStore::parse(content);
1192 assert_eq!(
1193 store.snippets[0].command,
1194 "for i in $(seq 1 5); do echo $i; done"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_snippet_command_with_pipes_and_redirects() {
1200 let content = "[logs]\ncommand=tail -100 /var/log/syslog | grep error | head -20\n";
1201 let store = SnippetStore::parse(content);
1202 assert_eq!(
1203 store.snippets[0].command,
1204 "tail -100 /var/log/syslog | grep error | head -20"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_description_optional() {
1210 let content = "[check]\ncommand=ls\n";
1211 let store = SnippetStore::parse(content);
1212 assert!(store.snippets[0].description.is_empty());
1213 }
1214
1215 #[test]
1216 fn test_description_with_equals() {
1217 let content = "[env]\ncommand=env\ndescription=Check HOME= and PATH= vars\n";
1218 let store = SnippetStore::parse(content);
1219 assert_eq!(store.snippets[0].description, "Check HOME= and PATH= vars");
1220 }
1221
1222 #[test]
1223 fn test_name_with_equals_roundtrip() {
1224 let mut store = SnippetStore::default();
1225 store.set(Snippet {
1226 name: "check=disk".to_string(),
1227 command: "df -h".to_string(),
1228 description: String::new(),
1229 });
1230
1231 let mut content = String::new();
1232 for (i, snippet) in store.snippets.iter().enumerate() {
1233 if i > 0 {
1234 content.push('\n');
1235 }
1236 content.push_str(&format!("[{}]\n", snippet.name));
1237 content.push_str(&format!("command={}\n", snippet.command));
1238 if !snippet.description.is_empty() {
1239 content.push_str(&format!("description={}\n", snippet.description));
1240 }
1241 }
1242
1243 let reparsed = SnippetStore::parse(&content);
1244 assert_eq!(reparsed.snippets.len(), 1);
1245 assert_eq!(reparsed.snippets[0].name, "check=disk");
1246 }
1247
1248 #[test]
1249 fn test_validate_name_with_equals() {
1250 assert!(validate_name("check=disk").is_ok());
1251 }
1252
1253 #[test]
1254 fn test_parse_only_comments_and_blanks() {
1255 let content = "# comment\n\n# another\n";
1256 let store = SnippetStore::parse(content);
1257 assert!(store.snippets.is_empty());
1258 }
1259
1260 #[test]
1261 fn test_parse_section_without_close_bracket() {
1262 let content = "[incomplete\ncommand=ls\n";
1263 let store = SnippetStore::parse(content);
1264 assert!(store.snippets.is_empty());
1265 }
1266
1267 #[test]
1268 fn test_parse_trailing_content_after_last_section() {
1269 let content = "[check]\ncommand=ls\n";
1270 let store = SnippetStore::parse(content);
1271 assert_eq!(store.snippets.len(), 1);
1272 assert_eq!(store.snippets[0].command, "ls");
1273 }
1274
1275 #[test]
1276 fn test_set_overwrite_preserves_order() {
1277 let mut store = SnippetStore::default();
1278 store.set(Snippet {
1279 name: "a".into(),
1280 command: "1".into(),
1281 description: String::new(),
1282 });
1283 store.set(Snippet {
1284 name: "b".into(),
1285 command: "2".into(),
1286 description: String::new(),
1287 });
1288 store.set(Snippet {
1289 name: "c".into(),
1290 command: "3".into(),
1291 description: String::new(),
1292 });
1293 store.set(Snippet {
1294 name: "b".into(),
1295 command: "updated".into(),
1296 description: String::new(),
1297 });
1298 assert_eq!(store.snippets.len(), 3);
1299 assert_eq!(store.snippets[0].name, "a");
1300 assert_eq!(store.snippets[1].name, "b");
1301 assert_eq!(store.snippets[1].command, "updated");
1302 assert_eq!(store.snippets[2].name, "c");
1303 }
1304
1305 #[test]
1306 fn test_validate_command_with_tab() {
1307 assert!(validate_command("echo\thello").is_ok());
1308 }
1309
1310 #[test]
1311 fn test_validate_command_with_newline() {
1312 assert!(validate_command("echo\nhello").is_err());
1313 }
1314
1315 #[test]
1316 fn test_validate_name_newline() {
1317 assert!(validate_name("check\ndisk").is_err());
1318 }
1319
1320 #[test]
1325 fn test_shell_escape_simple() {
1326 assert_eq!(shell_escape("hello"), "'hello'");
1327 }
1328
1329 #[test]
1330 fn test_shell_escape_with_single_quote() {
1331 assert_eq!(shell_escape("it's"), "'it'\\''s'");
1332 }
1333
1334 #[test]
1335 fn test_shell_escape_with_spaces() {
1336 assert_eq!(shell_escape("hello world"), "'hello world'");
1337 }
1338
1339 #[test]
1340 fn test_shell_escape_with_semicolon() {
1341 assert_eq!(shell_escape("; rm -rf /"), "'; rm -rf /'");
1342 }
1343
1344 #[test]
1345 fn test_shell_escape_with_dollar() {
1346 assert_eq!(shell_escape("$(whoami)"), "'$(whoami)'");
1347 }
1348
1349 #[test]
1350 fn test_shell_escape_empty() {
1351 assert_eq!(shell_escape(""), "''");
1352 }
1353
1354 #[test]
1359 fn test_parse_params_none() {
1360 assert!(parse_params("df -h").is_empty());
1361 }
1362
1363 #[test]
1364 fn test_parse_params_single() {
1365 let params = parse_params("df -h {{path}}");
1366 assert_eq!(params.len(), 1);
1367 assert_eq!(params[0].name, "path");
1368 assert_eq!(params[0].default, None);
1369 }
1370
1371 #[test]
1372 fn test_parse_params_with_default() {
1373 let params = parse_params("df -h {{path:/var/log}}");
1374 assert_eq!(params.len(), 1);
1375 assert_eq!(params[0].name, "path");
1376 assert_eq!(params[0].default, Some("/var/log".to_string()));
1377 }
1378
1379 #[test]
1380 fn test_parse_params_multiple() {
1381 let params = parse_params("grep {{pattern}} {{file}}");
1382 assert_eq!(params.len(), 2);
1383 assert_eq!(params[0].name, "pattern");
1384 assert_eq!(params[1].name, "file");
1385 }
1386
1387 #[test]
1388 fn test_parse_params_deduplicate() {
1389 let params = parse_params("echo {{name}} {{name}}");
1390 assert_eq!(params.len(), 1);
1391 }
1392
1393 #[test]
1394 fn test_parse_params_invalid_name_skipped() {
1395 let params = parse_params("echo {{valid}} {{bad name}} {{ok}}");
1396 assert_eq!(params.len(), 2);
1397 assert_eq!(params[0].name, "valid");
1398 assert_eq!(params[1].name, "ok");
1399 }
1400
1401 #[test]
1402 fn test_parse_params_unclosed_brace() {
1403 let params = parse_params("echo {{unclosed");
1404 assert!(params.is_empty());
1405 }
1406
1407 #[test]
1408 fn test_parse_params_max_20() {
1409 let cmd: String = (0..25)
1410 .map(|i| format!("{{{{p{}}}}}", i))
1411 .collect::<Vec<_>>()
1412 .join(" ");
1413 let params = parse_params(&cmd);
1414 assert_eq!(params.len(), 20);
1415 }
1416
1417 #[test]
1422 fn test_validate_param_name_valid() {
1423 assert!(validate_param_name("path").is_ok());
1424 assert!(validate_param_name("my-param").is_ok());
1425 assert!(validate_param_name("my_param").is_ok());
1426 assert!(validate_param_name("param1").is_ok());
1427 }
1428
1429 #[test]
1430 fn test_validate_param_name_empty() {
1431 assert!(validate_param_name("").is_err());
1432 }
1433
1434 #[test]
1435 fn test_validate_param_name_rejects_braces() {
1436 assert!(validate_param_name("a{b").is_err());
1437 assert!(validate_param_name("a}b").is_err());
1438 }
1439
1440 #[test]
1441 fn test_validate_param_name_rejects_quote() {
1442 assert!(validate_param_name("it's").is_err());
1443 }
1444
1445 #[test]
1446 fn test_validate_param_name_rejects_whitespace() {
1447 assert!(validate_param_name("a b").is_err());
1448 }
1449
1450 #[test]
1455 fn test_substitute_simple() {
1456 let mut values = std::collections::HashMap::new();
1457 values.insert("path".to_string(), "/var/log".to_string());
1458 let result = substitute_params("df -h {{path}}", &values);
1459 assert_eq!(result, "df -h '/var/log'");
1460 }
1461
1462 #[test]
1463 fn test_substitute_with_default() {
1464 let values = std::collections::HashMap::new();
1465 let result = substitute_params("df -h {{path:/tmp}}", &values);
1466 assert_eq!(result, "df -h '/tmp'");
1467 }
1468
1469 #[test]
1470 fn test_substitute_overrides_default() {
1471 let mut values = std::collections::HashMap::new();
1472 values.insert("path".to_string(), "/home".to_string());
1473 let result = substitute_params("df -h {{path:/tmp}}", &values);
1474 assert_eq!(result, "df -h '/home'");
1475 }
1476
1477 #[test]
1478 fn test_substitute_escapes_injection() {
1479 let mut values = std::collections::HashMap::new();
1480 values.insert("name".to_string(), "; rm -rf /".to_string());
1481 let result = substitute_params("echo {{name}}", &values);
1482 assert_eq!(result, "echo '; rm -rf /'");
1483 }
1484
1485 #[test]
1486 fn test_substitute_no_recursive_expansion() {
1487 let mut values = std::collections::HashMap::new();
1488 values.insert("a".to_string(), "{{b}}".to_string());
1489 values.insert("b".to_string(), "gotcha".to_string());
1490 let result = substitute_params("echo {{a}}", &values);
1491 assert_eq!(result, "echo '{{b}}'");
1492 }
1493
1494 #[test]
1495 fn test_substitute_default_also_escaped() {
1496 let values = std::collections::HashMap::new();
1497 let result = substitute_params("echo {{x:$(whoami)}}", &values);
1498 assert_eq!(result, "echo '$(whoami)'");
1499 }
1500
1501 #[test]
1506 fn test_sanitize_plain_text() {
1507 assert_eq!(sanitize_output("hello world"), "hello world");
1508 }
1509
1510 #[test]
1511 fn test_sanitize_preserves_newlines_tabs() {
1512 assert_eq!(sanitize_output("line1\nline2\tok"), "line1\nline2\tok");
1513 }
1514
1515 #[test]
1516 fn test_sanitize_strips_csi() {
1517 assert_eq!(sanitize_output("\x1b[31mred\x1b[0m"), "red");
1518 }
1519
1520 #[test]
1521 fn test_sanitize_strips_osc_bel() {
1522 assert_eq!(sanitize_output("\x1b]0;title\x07text"), "text");
1523 }
1524
1525 #[test]
1526 fn test_sanitize_strips_osc_st() {
1527 assert_eq!(sanitize_output("\x1b]52;c;dGVzdA==\x1b\\text"), "text");
1528 }
1529
1530 #[test]
1531 fn test_sanitize_strips_c1_range() {
1532 assert_eq!(sanitize_output("a\u{0090}b\u{009C}c"), "abc");
1533 }
1534
1535 #[test]
1536 fn test_sanitize_strips_control_chars() {
1537 assert_eq!(sanitize_output("a\x01b\x07c"), "abc");
1538 }
1539
1540 #[test]
1541 fn test_sanitize_strips_dcs() {
1542 assert_eq!(sanitize_output("\x1bPdata\x1b\\text"), "text");
1543 }
1544
1545 #[test]
1550 fn test_shell_escape_only_single_quotes() {
1551 assert_eq!(shell_escape("'''"), "''\\'''\\'''\\'''");
1552 }
1553
1554 #[test]
1555 fn test_shell_escape_consecutive_single_quotes() {
1556 assert_eq!(shell_escape("a''b"), "'a'\\'''\\''b'");
1557 }
1558
1559 #[test]
1564 fn test_parse_params_adjacent() {
1565 let params = parse_params("{{a}}{{b}}");
1566 assert_eq!(params.len(), 2);
1567 assert_eq!(params[0].name, "a");
1568 assert_eq!(params[1].name, "b");
1569 }
1570
1571 #[test]
1572 fn test_parse_params_command_is_only_param() {
1573 let params = parse_params("{{cmd}}");
1574 assert_eq!(params.len(), 1);
1575 assert_eq!(params[0].name, "cmd");
1576 }
1577
1578 #[test]
1579 fn test_parse_params_nested_braces_rejected() {
1580 let params = parse_params("{{{a}}}");
1582 assert!(params.is_empty());
1583 }
1584
1585 #[test]
1586 fn test_parse_params_colon_empty_default() {
1587 let params = parse_params("echo {{name:}}");
1588 assert_eq!(params.len(), 1);
1589 assert_eq!(params[0].name, "name");
1590 assert_eq!(params[0].default, Some("".to_string()));
1591 }
1592
1593 #[test]
1594 fn test_parse_params_empty_inner() {
1595 let params = parse_params("echo {{}}");
1596 assert!(params.is_empty());
1597 }
1598
1599 #[test]
1600 fn test_parse_params_single_braces_ignored() {
1601 let params = parse_params("echo {notaparam}");
1602 assert!(params.is_empty());
1603 }
1604
1605 #[test]
1606 fn test_parse_params_default_with_colons() {
1607 let params = parse_params("{{url:http://localhost:8080}}");
1608 assert_eq!(params.len(), 1);
1609 assert_eq!(params[0].name, "url");
1610 assert_eq!(params[0].default, Some("http://localhost:8080".to_string()));
1611 }
1612
1613 #[test]
1618 fn test_validate_param_name_unicode() {
1619 assert!(validate_param_name("caf\u{00e9}").is_ok());
1620 }
1621
1622 #[test]
1623 fn test_validate_param_name_hyphen_only() {
1624 assert!(validate_param_name("-").is_ok());
1625 }
1626
1627 #[test]
1628 fn test_validate_param_name_underscore_only() {
1629 assert!(validate_param_name("_").is_ok());
1630 }
1631
1632 #[test]
1633 fn test_validate_param_name_rejects_dot() {
1634 assert!(validate_param_name("a.b").is_err());
1635 }
1636
1637 #[test]
1642 fn test_substitute_no_params_passthrough() {
1643 let values = std::collections::HashMap::new();
1644 let result = substitute_params("df -h /tmp", &values);
1645 assert_eq!(result, "df -h /tmp");
1646 }
1647
1648 #[test]
1649 fn test_substitute_missing_param_no_default() {
1650 let values = std::collections::HashMap::new();
1651 let result = substitute_params("echo {{name}}", &values);
1652 assert_eq!(result, "echo ''");
1653 }
1654
1655 #[test]
1656 fn test_substitute_empty_value_falls_to_default() {
1657 let mut values = std::collections::HashMap::new();
1658 values.insert("name".to_string(), "".to_string());
1659 let result = substitute_params("echo {{name:fallback}}", &values);
1660 assert_eq!(result, "echo 'fallback'");
1661 }
1662
1663 #[test]
1664 fn test_substitute_non_ascii_around_params() {
1665 let mut values = std::collections::HashMap::new();
1666 values.insert("x".to_string(), "val".to_string());
1667 let result = substitute_params("\u{00e9}cho {{x}} \u{2603}", &values);
1668 assert_eq!(result, "\u{00e9}cho 'val' \u{2603}");
1669 }
1670
1671 #[test]
1672 fn test_substitute_adjacent_params() {
1673 let mut values = std::collections::HashMap::new();
1674 values.insert("a".to_string(), "x".to_string());
1675 values.insert("b".to_string(), "y".to_string());
1676 let result = substitute_params("{{a}}{{b}}", &values);
1677 assert_eq!(result, "'x''y'");
1678 }
1679
1680 #[test]
1685 fn test_sanitize_empty() {
1686 assert_eq!(sanitize_output(""), "");
1687 }
1688
1689 #[test]
1690 fn test_sanitize_only_escapes() {
1691 assert_eq!(sanitize_output("\x1b[31m\x1b[0m\x1b[1m"), "");
1692 }
1693
1694 #[test]
1695 fn test_sanitize_lone_esc_at_end() {
1696 assert_eq!(sanitize_output("hello\x1b"), "hello");
1697 }
1698
1699 #[test]
1700 fn test_sanitize_truncated_csi_no_terminator() {
1701 assert_eq!(sanitize_output("hello\x1b[123"), "hello");
1702 }
1703
1704 #[test]
1705 fn test_sanitize_apc_sequence() {
1706 assert_eq!(sanitize_output("\x1b_payload\x1b\\visible"), "visible");
1707 }
1708
1709 #[test]
1710 fn test_sanitize_pm_sequence() {
1711 assert_eq!(sanitize_output("\x1b^payload\x1b\\visible"), "visible");
1712 }
1713
1714 #[test]
1715 fn test_sanitize_dcs_terminated_by_bel() {
1716 assert_eq!(sanitize_output("\x1bPdata\x07text"), "text");
1717 }
1718
1719 #[test]
1720 fn test_sanitize_lone_esc_plus_letter() {
1721 assert_eq!(sanitize_output("a\x1bMb"), "ab");
1722 }
1723
1724 #[test]
1725 fn test_sanitize_multiple_mixed_sequences() {
1726 let input = "\x1b[1mbold\x1b[0m \x1b]0;title\x07normal \x01gone";
1728 assert_eq!(sanitize_output(input), "bold normal gone");
1729 }
1730}