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