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 = child.id() as i32;
391 Self {
392 inner: std::sync::Mutex::new(Some(child)),
393 pgid,
394 }
395 }
396}
397
398impl Drop for ChildGuard {
399 fn drop(&mut self) {
400 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
401 if let Some(ref mut child) = *lock {
402 if let Ok(Some(_)) = child.try_wait() {
404 return;
405 }
406 #[cfg(unix)]
408 unsafe {
409 libc::kill(-self.pgid, libc::SIGTERM);
410 }
411 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
413 loop {
414 if let Ok(Some(_)) = child.try_wait() {
415 return;
416 }
417 if std::time::Instant::now() >= deadline {
418 break;
419 }
420 std::thread::sleep(std::time::Duration::from_millis(50));
421 }
422 #[cfg(unix)]
424 unsafe {
425 libc::kill(-self.pgid, libc::SIGKILL);
426 }
427 let _ = child.kill();
429 let _ = child.wait();
430 }
431 }
432}
433
434fn read_pipe_capped<R: io::Read>(reader: R) -> String {
437 use io::BufRead;
438 let mut reader = io::BufReader::new(reader);
439 let mut output = String::new();
440 let mut line_count = 0;
441 let mut capped = false;
442 let mut buf = Vec::new();
443 loop {
444 buf.clear();
445 match reader.read_until(b'\n', &mut buf) {
446 Ok(0) => break, Ok(_) => {
448 if !capped {
449 if line_count < MAX_OUTPUT_LINES {
450 if line_count > 0 {
451 output.push('\n');
452 }
453 if buf.last() == Some(&b'\n') {
455 buf.pop();
456 if buf.last() == Some(&b'\r') {
457 buf.pop();
458 }
459 }
460 output.push_str(&String::from_utf8_lossy(&buf));
462 line_count += 1;
463 } else {
464 output.push_str("\n[Output truncated at 10,000 lines]");
465 capped = true;
466 }
467 }
468 }
470 Err(_) => break,
471 }
472 }
473 output
474}
475
476fn base_ssh_command(
480 alias: &str,
481 config_path: &Path,
482 command: &str,
483 askpass: Option<&str>,
484 bw_session: Option<&str>,
485 has_active_tunnel: bool,
486) -> Command {
487 let mut cmd = Command::new("ssh");
488 cmd.arg("-F")
489 .arg(config_path)
490 .arg("-o")
491 .arg("ConnectTimeout=10")
492 .arg("-o")
493 .arg("ControlMaster=no")
494 .arg("-o")
495 .arg("ControlPath=none");
496
497 if has_active_tunnel {
498 cmd.arg("-o").arg("ClearAllForwardings=yes");
499 }
500
501 cmd.arg("--").arg(alias).arg(command);
502
503 if askpass.is_some() {
504 let exe = std::env::current_exe()
505 .ok()
506 .map(|p| p.to_string_lossy().to_string())
507 .or_else(|| std::env::args().next())
508 .unwrap_or_else(|| "purple".to_string());
509 cmd.env("SSH_ASKPASS", &exe)
510 .env("SSH_ASKPASS_REQUIRE", "prefer")
511 .env("PURPLE_ASKPASS_MODE", "1")
512 .env("PURPLE_HOST_ALIAS", alias)
513 .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
514 }
515
516 if let Some(token) = bw_session {
517 cmd.env("BW_SESSION", token);
518 }
519
520 cmd
521}
522
523fn build_snippet_command(
525 alias: &str,
526 config_path: &Path,
527 command: &str,
528 askpass: Option<&str>,
529 bw_session: Option<&str>,
530 has_active_tunnel: bool,
531) -> Command {
532 let mut cmd = base_ssh_command(
533 alias,
534 config_path,
535 command,
536 askpass,
537 bw_session,
538 has_active_tunnel,
539 );
540 cmd.stdin(Stdio::null())
541 .stdout(Stdio::piped())
542 .stderr(Stdio::piped());
543
544 #[cfg(unix)]
547 unsafe {
548 use std::os::unix::process::CommandExt;
549 cmd.pre_exec(|| {
550 libc::setpgid(0, 0);
551 Ok(())
552 });
553 }
554
555 cmd
556}
557
558#[allow(clippy::too_many_arguments)]
560fn execute_host(
561 run_id: u64,
562 alias: &str,
563 config_path: &Path,
564 command: &str,
565 askpass: Option<&str>,
566 bw_session: Option<&str>,
567 has_active_tunnel: bool,
568 tx: &std::sync::mpsc::Sender<SnippetEvent>,
569) -> Option<std::sync::Arc<ChildGuard>> {
570 let mut cmd = build_snippet_command(
571 alias,
572 config_path,
573 command,
574 askpass,
575 bw_session,
576 has_active_tunnel,
577 );
578
579 match cmd.spawn() {
580 Ok(child) => {
581 let guard = std::sync::Arc::new(ChildGuard::new(child));
582
583 let stdout_pipe = {
585 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
586 lock.as_mut().and_then(|c| c.stdout.take())
587 };
588 let stderr_pipe = {
589 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
590 lock.as_mut().and_then(|c| c.stderr.take())
591 };
592
593 let stdout_handle = std::thread::spawn(move || match stdout_pipe {
595 Some(pipe) => read_pipe_capped(pipe),
596 None => String::new(),
597 });
598 let stderr_handle = std::thread::spawn(move || match stderr_pipe {
599 Some(pipe) => read_pipe_capped(pipe),
600 None => String::new(),
601 });
602
603 let stdout_text = stdout_handle.join().unwrap_or_default();
605 let stderr_text = stderr_handle.join().unwrap_or_default();
606
607 let exit_code = {
610 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
611 let status = lock.as_mut().and_then(|c| c.wait().ok());
612 let _ = lock.take(); status.and_then(|s| {
614 #[cfg(unix)]
615 {
616 use std::os::unix::process::ExitStatusExt;
617 s.code().or_else(|| s.signal().map(|sig| 128 + sig))
618 }
619 #[cfg(not(unix))]
620 {
621 s.code()
622 }
623 })
624 };
625
626 let _ = tx.send(SnippetEvent::HostDone {
627 run_id,
628 alias: alias.to_string(),
629 stdout: sanitize_output(&stdout_text),
630 stderr: sanitize_output(&stderr_text),
631 exit_code,
632 });
633
634 Some(guard)
635 }
636 Err(e) => {
637 let _ = tx.send(SnippetEvent::HostDone {
638 run_id,
639 alias: alias.to_string(),
640 stdout: String::new(),
641 stderr: format!("Failed to launch ssh: {}", e),
642 exit_code: None,
643 });
644 None
645 }
646 }
647}
648
649#[allow(clippy::too_many_arguments)]
652pub fn spawn_snippet_execution(
653 run_id: u64,
654 askpass_map: Vec<(String, Option<String>)>,
655 config_path: PathBuf,
656 command: String,
657 bw_session: Option<String>,
658 tunnel_aliases: std::collections::HashSet<String>,
659 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
660 tx: std::sync::mpsc::Sender<SnippetEvent>,
661 parallel: bool,
662) {
663 let total = askpass_map.len();
664 let max_concurrent: usize = 20;
665
666 std::thread::Builder::new()
667 .name("snippet-coordinator".into())
668 .spawn(move || {
669 let guards: std::sync::Arc<std::sync::Mutex<Vec<std::sync::Arc<ChildGuard>>>> =
670 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
671
672 if parallel && total > 1 {
673 let (slot_tx, slot_rx) = std::sync::mpsc::channel::<()>();
675 for _ in 0..max_concurrent.min(total) {
676 let _ = slot_tx.send(());
677 }
678
679 let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
680 let mut worker_handles = Vec::new();
681
682 for (alias, askpass) in askpass_map {
683 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
684 break;
685 }
686
687 loop {
689 match slot_rx.recv_timeout(std::time::Duration::from_millis(100)) {
690 Ok(()) => break,
691 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
692 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
693 break;
694 }
695 }
696 Err(_) => break, }
698 }
699
700 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
701 break;
702 }
703
704 let config_path = config_path.clone();
705 let command = command.clone();
706 let bw_session = bw_session.clone();
707 let has_tunnel = tunnel_aliases.contains(&alias);
708 let tx = tx.clone();
709 let slot_tx = slot_tx.clone();
710 let guards = guards.clone();
711 let completed = completed.clone();
712 let total = total;
713
714 let handle = std::thread::spawn(move || {
715 struct SlotRelease(Option<std::sync::mpsc::Sender<()>>);
717 impl Drop for SlotRelease {
718 fn drop(&mut self) {
719 if let Some(tx) = self.0.take() {
720 let _ = tx.send(());
721 }
722 }
723 }
724 let _slot = SlotRelease(Some(slot_tx));
725
726 let guard = execute_host(
727 run_id,
728 &alias,
729 &config_path,
730 &command,
731 askpass.as_deref(),
732 bw_session.as_deref(),
733 has_tunnel,
734 &tx,
735 );
736
737 if let Some(g) = guard {
739 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
740 }
741
742 let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
743 let _ = tx.send(SnippetEvent::Progress {
744 run_id,
745 completed: c,
746 total,
747 });
748 });
750 worker_handles.push(handle);
751 }
752
753 for handle in worker_handles {
755 let _ = handle.join();
756 }
757 } else {
758 for (i, (alias, askpass)) in askpass_map.into_iter().enumerate() {
760 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
761 break;
762 }
763
764 let has_tunnel = tunnel_aliases.contains(&alias);
765 let guard = execute_host(
766 run_id,
767 &alias,
768 &config_path,
769 &command,
770 askpass.as_deref(),
771 bw_session.as_deref(),
772 has_tunnel,
773 &tx,
774 );
775
776 if let Some(g) = guard {
777 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
778 }
779
780 let _ = tx.send(SnippetEvent::Progress {
781 run_id,
782 completed: i + 1,
783 total,
784 });
785 }
786 }
787
788 let _ = tx.send(SnippetEvent::AllDone { run_id });
789 })
791 .expect("failed to spawn snippet coordinator");
792}
793
794pub fn run_snippet(
799 alias: &str,
800 config_path: &Path,
801 command: &str,
802 askpass: Option<&str>,
803 bw_session: Option<&str>,
804 capture: bool,
805 has_active_tunnel: bool,
806) -> anyhow::Result<SnippetResult> {
807 let mut cmd = base_ssh_command(
808 alias,
809 config_path,
810 command,
811 askpass,
812 bw_session,
813 has_active_tunnel,
814 );
815 cmd.stdin(Stdio::inherit());
816
817 if capture {
818 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
819 } else {
820 cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
821 }
822
823 if capture {
824 let output = cmd
825 .output()
826 .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
827
828 Ok(SnippetResult {
829 status: output.status,
830 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
831 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
832 })
833 } else {
834 let status = cmd
835 .status()
836 .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
837
838 Ok(SnippetResult {
839 status,
840 stdout: String::new(),
841 stderr: String::new(),
842 })
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849
850 #[test]
855 fn test_parse_empty() {
856 let store = SnippetStore::parse("");
857 assert!(store.snippets.is_empty());
858 }
859
860 #[test]
861 fn test_parse_single_snippet() {
862 let content = "\
863[check-disk]
864command=df -h
865description=Check disk usage
866";
867 let store = SnippetStore::parse(content);
868 assert_eq!(store.snippets.len(), 1);
869 let s = &store.snippets[0];
870 assert_eq!(s.name, "check-disk");
871 assert_eq!(s.command, "df -h");
872 assert_eq!(s.description, "Check disk usage");
873 }
874
875 #[test]
876 fn test_parse_multiple_snippets() {
877 let content = "\
878[check-disk]
879command=df -h
880
881[uptime]
882command=uptime
883description=Check server uptime
884";
885 let store = SnippetStore::parse(content);
886 assert_eq!(store.snippets.len(), 2);
887 assert_eq!(store.snippets[0].name, "check-disk");
888 assert_eq!(store.snippets[1].name, "uptime");
889 }
890
891 #[test]
892 fn test_parse_comments_and_blanks() {
893 let content = "\
894# Snippet config
895
896[check-disk]
897# Main command
898command=df -h
899";
900 let store = SnippetStore::parse(content);
901 assert_eq!(store.snippets.len(), 1);
902 assert_eq!(store.snippets[0].command, "df -h");
903 }
904
905 #[test]
906 fn test_parse_duplicate_sections_first_wins() {
907 let content = "\
908[check-disk]
909command=df -h
910
911[check-disk]
912command=du -sh *
913";
914 let store = SnippetStore::parse(content);
915 assert_eq!(store.snippets.len(), 1);
916 assert_eq!(store.snippets[0].command, "df -h");
917 }
918
919 #[test]
920 fn test_parse_snippet_without_command_skipped() {
921 let content = "\
922[empty]
923description=No command here
924
925[valid]
926command=ls -la
927";
928 let store = SnippetStore::parse(content);
929 assert_eq!(store.snippets.len(), 1);
930 assert_eq!(store.snippets[0].name, "valid");
931 }
932
933 #[test]
934 fn test_parse_unknown_keys_ignored() {
935 let content = "\
936[check-disk]
937command=df -h
938unknown=value
939foo=bar
940";
941 let store = SnippetStore::parse(content);
942 assert_eq!(store.snippets.len(), 1);
943 assert_eq!(store.snippets[0].command, "df -h");
944 }
945
946 #[test]
947 fn test_parse_whitespace_in_section_name() {
948 let content = "[ check-disk ]\ncommand=df -h\n";
949 let store = SnippetStore::parse(content);
950 assert_eq!(store.snippets[0].name, "check-disk");
951 }
952
953 #[test]
954 fn test_parse_whitespace_around_key_value() {
955 let content = "[check-disk]\n command = df -h \n";
956 let store = SnippetStore::parse(content);
957 assert_eq!(store.snippets[0].command, "df -h");
958 }
959
960 #[test]
961 fn test_parse_command_with_equals() {
962 let content = "[env-check]\ncommand=env | grep HOME=\n";
963 let store = SnippetStore::parse(content);
964 assert_eq!(store.snippets[0].command, "env | grep HOME=");
965 }
966
967 #[test]
968 fn test_parse_line_without_equals_ignored() {
969 let content = "[check]\ncommand=ls\ngarbage_line\n";
970 let store = SnippetStore::parse(content);
971 assert_eq!(store.snippets[0].command, "ls");
972 }
973
974 #[test]
979 fn test_get_found() {
980 let store = SnippetStore::parse("[check]\ncommand=ls\n");
981 assert!(store.get("check").is_some());
982 }
983
984 #[test]
985 fn test_get_not_found() {
986 let store = SnippetStore::parse("");
987 assert!(store.get("nope").is_none());
988 }
989
990 #[test]
991 fn test_set_adds_new() {
992 let mut store = SnippetStore::default();
993 store.set(Snippet {
994 name: "check".to_string(),
995 command: "ls".to_string(),
996 description: String::new(),
997 });
998 assert_eq!(store.snippets.len(), 1);
999 }
1000
1001 #[test]
1002 fn test_set_replaces_existing() {
1003 let mut store = SnippetStore::parse("[check]\ncommand=ls\n");
1004 store.set(Snippet {
1005 name: "check".to_string(),
1006 command: "df -h".to_string(),
1007 description: String::new(),
1008 });
1009 assert_eq!(store.snippets.len(), 1);
1010 assert_eq!(store.snippets[0].command, "df -h");
1011 }
1012
1013 #[test]
1014 fn test_remove() {
1015 let mut store = SnippetStore::parse("[check]\ncommand=ls\n[uptime]\ncommand=uptime\n");
1016 store.remove("check");
1017 assert_eq!(store.snippets.len(), 1);
1018 assert_eq!(store.snippets[0].name, "uptime");
1019 }
1020
1021 #[test]
1022 fn test_remove_nonexistent_noop() {
1023 let mut store = SnippetStore::parse("[check]\ncommand=ls\n");
1024 store.remove("nope");
1025 assert_eq!(store.snippets.len(), 1);
1026 }
1027
1028 #[test]
1033 fn test_validate_name_valid() {
1034 assert!(validate_name("check-disk").is_ok());
1035 assert!(validate_name("restart_nginx").is_ok());
1036 assert!(validate_name("a").is_ok());
1037 }
1038
1039 #[test]
1040 fn test_validate_name_empty() {
1041 assert!(validate_name("").is_err());
1042 }
1043
1044 #[test]
1045 fn test_validate_name_whitespace() {
1046 assert!(validate_name("check disk").is_ok());
1047 assert!(validate_name("check\tdisk").is_err()); assert!(validate_name(" ").is_err()); assert!(validate_name(" leading").is_err()); assert!(validate_name("trailing ").is_err()); }
1052
1053 #[test]
1054 fn test_validate_name_special_chars() {
1055 assert!(validate_name("check#disk").is_err());
1056 assert!(validate_name("[check]").is_err());
1057 }
1058
1059 #[test]
1060 fn test_validate_name_control_chars() {
1061 assert!(validate_name("check\x00disk").is_err());
1062 }
1063
1064 #[test]
1069 fn test_validate_command_valid() {
1070 assert!(validate_command("df -h").is_ok());
1071 assert!(validate_command("cat /etc/hosts | grep localhost").is_ok());
1072 assert!(validate_command("echo 'hello\tworld'").is_ok()); }
1074
1075 #[test]
1076 fn test_validate_command_empty() {
1077 assert!(validate_command("").is_err());
1078 }
1079
1080 #[test]
1081 fn test_validate_command_whitespace_only() {
1082 assert!(validate_command(" ").is_err());
1083 assert!(validate_command(" \t ").is_err());
1084 }
1085
1086 #[test]
1087 fn test_validate_command_control_chars() {
1088 assert!(validate_command("ls\x00-la").is_err());
1089 }
1090
1091 #[test]
1096 fn test_save_roundtrip() {
1097 let mut store = SnippetStore::default();
1098 store.set(Snippet {
1099 name: "check-disk".to_string(),
1100 command: "df -h".to_string(),
1101 description: "Check disk usage".to_string(),
1102 });
1103 store.set(Snippet {
1104 name: "uptime".to_string(),
1105 command: "uptime".to_string(),
1106 description: String::new(),
1107 });
1108
1109 let mut content = String::new();
1111 for (i, snippet) in store.snippets.iter().enumerate() {
1112 if i > 0 {
1113 content.push('\n');
1114 }
1115 content.push_str(&format!("[{}]\n", snippet.name));
1116 content.push_str(&format!("command={}\n", snippet.command));
1117 if !snippet.description.is_empty() {
1118 content.push_str(&format!("description={}\n", snippet.description));
1119 }
1120 }
1121
1122 let reparsed = SnippetStore::parse(&content);
1124 assert_eq!(reparsed.snippets.len(), 2);
1125 assert_eq!(reparsed.snippets[0].name, "check-disk");
1126 assert_eq!(reparsed.snippets[0].command, "df -h");
1127 assert_eq!(reparsed.snippets[0].description, "Check disk usage");
1128 assert_eq!(reparsed.snippets[1].name, "uptime");
1129 assert_eq!(reparsed.snippets[1].command, "uptime");
1130 assert!(reparsed.snippets[1].description.is_empty());
1131 }
1132
1133 #[test]
1134 fn test_save_to_temp_file() {
1135 let dir = std::env::temp_dir().join(format!("purple_snippet_test_{}", std::process::id()));
1136 let _ = std::fs::create_dir_all(&dir);
1137 let path = dir.join("snippets");
1138
1139 let mut store = SnippetStore {
1140 path_override: Some(path.clone()),
1141 ..Default::default()
1142 };
1143 store.set(Snippet {
1144 name: "test".to_string(),
1145 command: "echo hello".to_string(),
1146 description: "Test snippet".to_string(),
1147 });
1148 store.save().unwrap();
1149
1150 let content = std::fs::read_to_string(&path).unwrap();
1152 let reloaded = SnippetStore::parse(&content);
1153 assert_eq!(reloaded.snippets.len(), 1);
1154 assert_eq!(reloaded.snippets[0].name, "test");
1155 assert_eq!(reloaded.snippets[0].command, "echo hello");
1156
1157 let _ = std::fs::remove_dir_all(&dir);
1159 }
1160
1161 #[test]
1166 fn test_set_multiple_then_remove_all() {
1167 let mut store = SnippetStore::default();
1168 for name in ["a", "b", "c"] {
1169 store.set(Snippet {
1170 name: name.to_string(),
1171 command: "cmd".to_string(),
1172 description: String::new(),
1173 });
1174 }
1175 assert_eq!(store.snippets.len(), 3);
1176 store.remove("a");
1177 store.remove("b");
1178 store.remove("c");
1179 assert!(store.snippets.is_empty());
1180 }
1181
1182 #[test]
1183 fn test_snippet_with_complex_command() {
1184 let content = "[complex]\ncommand=for i in $(seq 1 5); do echo $i; done\n";
1185 let store = SnippetStore::parse(content);
1186 assert_eq!(
1187 store.snippets[0].command,
1188 "for i in $(seq 1 5); do echo $i; done"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_snippet_command_with_pipes_and_redirects() {
1194 let content = "[logs]\ncommand=tail -100 /var/log/syslog | grep error | head -20\n";
1195 let store = SnippetStore::parse(content);
1196 assert_eq!(
1197 store.snippets[0].command,
1198 "tail -100 /var/log/syslog | grep error | head -20"
1199 );
1200 }
1201
1202 #[test]
1203 fn test_description_optional() {
1204 let content = "[check]\ncommand=ls\n";
1205 let store = SnippetStore::parse(content);
1206 assert!(store.snippets[0].description.is_empty());
1207 }
1208
1209 #[test]
1210 fn test_description_with_equals() {
1211 let content = "[env]\ncommand=env\ndescription=Check HOME= and PATH= vars\n";
1212 let store = SnippetStore::parse(content);
1213 assert_eq!(store.snippets[0].description, "Check HOME= and PATH= vars");
1214 }
1215
1216 #[test]
1217 fn test_name_with_equals_roundtrip() {
1218 let mut store = SnippetStore::default();
1219 store.set(Snippet {
1220 name: "check=disk".to_string(),
1221 command: "df -h".to_string(),
1222 description: String::new(),
1223 });
1224
1225 let mut content = String::new();
1226 for (i, snippet) in store.snippets.iter().enumerate() {
1227 if i > 0 {
1228 content.push('\n');
1229 }
1230 content.push_str(&format!("[{}]\n", snippet.name));
1231 content.push_str(&format!("command={}\n", snippet.command));
1232 if !snippet.description.is_empty() {
1233 content.push_str(&format!("description={}\n", snippet.description));
1234 }
1235 }
1236
1237 let reparsed = SnippetStore::parse(&content);
1238 assert_eq!(reparsed.snippets.len(), 1);
1239 assert_eq!(reparsed.snippets[0].name, "check=disk");
1240 }
1241
1242 #[test]
1243 fn test_validate_name_with_equals() {
1244 assert!(validate_name("check=disk").is_ok());
1245 }
1246
1247 #[test]
1248 fn test_parse_only_comments_and_blanks() {
1249 let content = "# comment\n\n# another\n";
1250 let store = SnippetStore::parse(content);
1251 assert!(store.snippets.is_empty());
1252 }
1253
1254 #[test]
1255 fn test_parse_section_without_close_bracket() {
1256 let content = "[incomplete\ncommand=ls\n";
1257 let store = SnippetStore::parse(content);
1258 assert!(store.snippets.is_empty());
1259 }
1260
1261 #[test]
1262 fn test_parse_trailing_content_after_last_section() {
1263 let content = "[check]\ncommand=ls\n";
1264 let store = SnippetStore::parse(content);
1265 assert_eq!(store.snippets.len(), 1);
1266 assert_eq!(store.snippets[0].command, "ls");
1267 }
1268
1269 #[test]
1270 fn test_set_overwrite_preserves_order() {
1271 let mut store = SnippetStore::default();
1272 store.set(Snippet {
1273 name: "a".into(),
1274 command: "1".into(),
1275 description: String::new(),
1276 });
1277 store.set(Snippet {
1278 name: "b".into(),
1279 command: "2".into(),
1280 description: String::new(),
1281 });
1282 store.set(Snippet {
1283 name: "c".into(),
1284 command: "3".into(),
1285 description: String::new(),
1286 });
1287 store.set(Snippet {
1288 name: "b".into(),
1289 command: "updated".into(),
1290 description: String::new(),
1291 });
1292 assert_eq!(store.snippets.len(), 3);
1293 assert_eq!(store.snippets[0].name, "a");
1294 assert_eq!(store.snippets[1].name, "b");
1295 assert_eq!(store.snippets[1].command, "updated");
1296 assert_eq!(store.snippets[2].name, "c");
1297 }
1298
1299 #[test]
1300 fn test_validate_command_with_tab() {
1301 assert!(validate_command("echo\thello").is_ok());
1302 }
1303
1304 #[test]
1305 fn test_validate_command_with_newline() {
1306 assert!(validate_command("echo\nhello").is_err());
1307 }
1308
1309 #[test]
1310 fn test_validate_name_newline() {
1311 assert!(validate_name("check\ndisk").is_err());
1312 }
1313
1314 #[test]
1319 fn test_shell_escape_simple() {
1320 assert_eq!(shell_escape("hello"), "'hello'");
1321 }
1322
1323 #[test]
1324 fn test_shell_escape_with_single_quote() {
1325 assert_eq!(shell_escape("it's"), "'it'\\''s'");
1326 }
1327
1328 #[test]
1329 fn test_shell_escape_with_spaces() {
1330 assert_eq!(shell_escape("hello world"), "'hello world'");
1331 }
1332
1333 #[test]
1334 fn test_shell_escape_with_semicolon() {
1335 assert_eq!(shell_escape("; rm -rf /"), "'; rm -rf /'");
1336 }
1337
1338 #[test]
1339 fn test_shell_escape_with_dollar() {
1340 assert_eq!(shell_escape("$(whoami)"), "'$(whoami)'");
1341 }
1342
1343 #[test]
1344 fn test_shell_escape_empty() {
1345 assert_eq!(shell_escape(""), "''");
1346 }
1347
1348 #[test]
1353 fn test_parse_params_none() {
1354 assert!(parse_params("df -h").is_empty());
1355 }
1356
1357 #[test]
1358 fn test_parse_params_single() {
1359 let params = parse_params("df -h {{path}}");
1360 assert_eq!(params.len(), 1);
1361 assert_eq!(params[0].name, "path");
1362 assert_eq!(params[0].default, None);
1363 }
1364
1365 #[test]
1366 fn test_parse_params_with_default() {
1367 let params = parse_params("df -h {{path:/var/log}}");
1368 assert_eq!(params.len(), 1);
1369 assert_eq!(params[0].name, "path");
1370 assert_eq!(params[0].default, Some("/var/log".to_string()));
1371 }
1372
1373 #[test]
1374 fn test_parse_params_multiple() {
1375 let params = parse_params("grep {{pattern}} {{file}}");
1376 assert_eq!(params.len(), 2);
1377 assert_eq!(params[0].name, "pattern");
1378 assert_eq!(params[1].name, "file");
1379 }
1380
1381 #[test]
1382 fn test_parse_params_deduplicate() {
1383 let params = parse_params("echo {{name}} {{name}}");
1384 assert_eq!(params.len(), 1);
1385 }
1386
1387 #[test]
1388 fn test_parse_params_invalid_name_skipped() {
1389 let params = parse_params("echo {{valid}} {{bad name}} {{ok}}");
1390 assert_eq!(params.len(), 2);
1391 assert_eq!(params[0].name, "valid");
1392 assert_eq!(params[1].name, "ok");
1393 }
1394
1395 #[test]
1396 fn test_parse_params_unclosed_brace() {
1397 let params = parse_params("echo {{unclosed");
1398 assert!(params.is_empty());
1399 }
1400
1401 #[test]
1402 fn test_parse_params_max_20() {
1403 let cmd: String = (0..25)
1404 .map(|i| format!("{{{{p{}}}}}", i))
1405 .collect::<Vec<_>>()
1406 .join(" ");
1407 let params = parse_params(&cmd);
1408 assert_eq!(params.len(), 20);
1409 }
1410
1411 #[test]
1416 fn test_validate_param_name_valid() {
1417 assert!(validate_param_name("path").is_ok());
1418 assert!(validate_param_name("my-param").is_ok());
1419 assert!(validate_param_name("my_param").is_ok());
1420 assert!(validate_param_name("param1").is_ok());
1421 }
1422
1423 #[test]
1424 fn test_validate_param_name_empty() {
1425 assert!(validate_param_name("").is_err());
1426 }
1427
1428 #[test]
1429 fn test_validate_param_name_rejects_braces() {
1430 assert!(validate_param_name("a{b").is_err());
1431 assert!(validate_param_name("a}b").is_err());
1432 }
1433
1434 #[test]
1435 fn test_validate_param_name_rejects_quote() {
1436 assert!(validate_param_name("it's").is_err());
1437 }
1438
1439 #[test]
1440 fn test_validate_param_name_rejects_whitespace() {
1441 assert!(validate_param_name("a b").is_err());
1442 }
1443
1444 #[test]
1449 fn test_substitute_simple() {
1450 let mut values = std::collections::HashMap::new();
1451 values.insert("path".to_string(), "/var/log".to_string());
1452 let result = substitute_params("df -h {{path}}", &values);
1453 assert_eq!(result, "df -h '/var/log'");
1454 }
1455
1456 #[test]
1457 fn test_substitute_with_default() {
1458 let values = std::collections::HashMap::new();
1459 let result = substitute_params("df -h {{path:/tmp}}", &values);
1460 assert_eq!(result, "df -h '/tmp'");
1461 }
1462
1463 #[test]
1464 fn test_substitute_overrides_default() {
1465 let mut values = std::collections::HashMap::new();
1466 values.insert("path".to_string(), "/home".to_string());
1467 let result = substitute_params("df -h {{path:/tmp}}", &values);
1468 assert_eq!(result, "df -h '/home'");
1469 }
1470
1471 #[test]
1472 fn test_substitute_escapes_injection() {
1473 let mut values = std::collections::HashMap::new();
1474 values.insert("name".to_string(), "; rm -rf /".to_string());
1475 let result = substitute_params("echo {{name}}", &values);
1476 assert_eq!(result, "echo '; rm -rf /'");
1477 }
1478
1479 #[test]
1480 fn test_substitute_no_recursive_expansion() {
1481 let mut values = std::collections::HashMap::new();
1482 values.insert("a".to_string(), "{{b}}".to_string());
1483 values.insert("b".to_string(), "gotcha".to_string());
1484 let result = substitute_params("echo {{a}}", &values);
1485 assert_eq!(result, "echo '{{b}}'");
1486 }
1487
1488 #[test]
1489 fn test_substitute_default_also_escaped() {
1490 let values = std::collections::HashMap::new();
1491 let result = substitute_params("echo {{x:$(whoami)}}", &values);
1492 assert_eq!(result, "echo '$(whoami)'");
1493 }
1494
1495 #[test]
1500 fn test_sanitize_plain_text() {
1501 assert_eq!(sanitize_output("hello world"), "hello world");
1502 }
1503
1504 #[test]
1505 fn test_sanitize_preserves_newlines_tabs() {
1506 assert_eq!(sanitize_output("line1\nline2\tok"), "line1\nline2\tok");
1507 }
1508
1509 #[test]
1510 fn test_sanitize_strips_csi() {
1511 assert_eq!(sanitize_output("\x1b[31mred\x1b[0m"), "red");
1512 }
1513
1514 #[test]
1515 fn test_sanitize_strips_osc_bel() {
1516 assert_eq!(sanitize_output("\x1b]0;title\x07text"), "text");
1517 }
1518
1519 #[test]
1520 fn test_sanitize_strips_osc_st() {
1521 assert_eq!(sanitize_output("\x1b]52;c;dGVzdA==\x1b\\text"), "text");
1522 }
1523
1524 #[test]
1525 fn test_sanitize_strips_c1_range() {
1526 assert_eq!(sanitize_output("a\u{0090}b\u{009C}c"), "abc");
1527 }
1528
1529 #[test]
1530 fn test_sanitize_strips_control_chars() {
1531 assert_eq!(sanitize_output("a\x01b\x07c"), "abc");
1532 }
1533
1534 #[test]
1535 fn test_sanitize_strips_dcs() {
1536 assert_eq!(sanitize_output("\x1bPdata\x1b\\text"), "text");
1537 }
1538
1539 #[test]
1544 fn test_shell_escape_only_single_quotes() {
1545 assert_eq!(shell_escape("'''"), "''\\'''\\'''\\'''");
1546 }
1547
1548 #[test]
1549 fn test_shell_escape_consecutive_single_quotes() {
1550 assert_eq!(shell_escape("a''b"), "'a'\\'''\\''b'");
1551 }
1552
1553 #[test]
1558 fn test_parse_params_adjacent() {
1559 let params = parse_params("{{a}}{{b}}");
1560 assert_eq!(params.len(), 2);
1561 assert_eq!(params[0].name, "a");
1562 assert_eq!(params[1].name, "b");
1563 }
1564
1565 #[test]
1566 fn test_parse_params_command_is_only_param() {
1567 let params = parse_params("{{cmd}}");
1568 assert_eq!(params.len(), 1);
1569 assert_eq!(params[0].name, "cmd");
1570 }
1571
1572 #[test]
1573 fn test_parse_params_nested_braces_rejected() {
1574 let params = parse_params("{{{a}}}");
1576 assert!(params.is_empty());
1577 }
1578
1579 #[test]
1580 fn test_parse_params_colon_empty_default() {
1581 let params = parse_params("echo {{name:}}");
1582 assert_eq!(params.len(), 1);
1583 assert_eq!(params[0].name, "name");
1584 assert_eq!(params[0].default, Some("".to_string()));
1585 }
1586
1587 #[test]
1588 fn test_parse_params_empty_inner() {
1589 let params = parse_params("echo {{}}");
1590 assert!(params.is_empty());
1591 }
1592
1593 #[test]
1594 fn test_parse_params_single_braces_ignored() {
1595 let params = parse_params("echo {notaparam}");
1596 assert!(params.is_empty());
1597 }
1598
1599 #[test]
1600 fn test_parse_params_default_with_colons() {
1601 let params = parse_params("{{url:http://localhost:8080}}");
1602 assert_eq!(params.len(), 1);
1603 assert_eq!(params[0].name, "url");
1604 assert_eq!(params[0].default, Some("http://localhost:8080".to_string()));
1605 }
1606
1607 #[test]
1612 fn test_validate_param_name_unicode() {
1613 assert!(validate_param_name("caf\u{00e9}").is_ok());
1614 }
1615
1616 #[test]
1617 fn test_validate_param_name_hyphen_only() {
1618 assert!(validate_param_name("-").is_ok());
1619 }
1620
1621 #[test]
1622 fn test_validate_param_name_underscore_only() {
1623 assert!(validate_param_name("_").is_ok());
1624 }
1625
1626 #[test]
1627 fn test_validate_param_name_rejects_dot() {
1628 assert!(validate_param_name("a.b").is_err());
1629 }
1630
1631 #[test]
1636 fn test_substitute_no_params_passthrough() {
1637 let values = std::collections::HashMap::new();
1638 let result = substitute_params("df -h /tmp", &values);
1639 assert_eq!(result, "df -h /tmp");
1640 }
1641
1642 #[test]
1643 fn test_substitute_missing_param_no_default() {
1644 let values = std::collections::HashMap::new();
1645 let result = substitute_params("echo {{name}}", &values);
1646 assert_eq!(result, "echo ''");
1647 }
1648
1649 #[test]
1650 fn test_substitute_empty_value_falls_to_default() {
1651 let mut values = std::collections::HashMap::new();
1652 values.insert("name".to_string(), "".to_string());
1653 let result = substitute_params("echo {{name:fallback}}", &values);
1654 assert_eq!(result, "echo 'fallback'");
1655 }
1656
1657 #[test]
1658 fn test_substitute_non_ascii_around_params() {
1659 let mut values = std::collections::HashMap::new();
1660 values.insert("x".to_string(), "val".to_string());
1661 let result = substitute_params("\u{00e9}cho {{x}} \u{2603}", &values);
1662 assert_eq!(result, "\u{00e9}cho 'val' \u{2603}");
1663 }
1664
1665 #[test]
1666 fn test_substitute_adjacent_params() {
1667 let mut values = std::collections::HashMap::new();
1668 values.insert("a".to_string(), "x".to_string());
1669 values.insert("b".to_string(), "y".to_string());
1670 let result = substitute_params("{{a}}{{b}}", &values);
1671 assert_eq!(result, "'x''y'");
1672 }
1673
1674 #[test]
1679 fn test_sanitize_empty() {
1680 assert_eq!(sanitize_output(""), "");
1681 }
1682
1683 #[test]
1684 fn test_sanitize_only_escapes() {
1685 assert_eq!(sanitize_output("\x1b[31m\x1b[0m\x1b[1m"), "");
1686 }
1687
1688 #[test]
1689 fn test_sanitize_lone_esc_at_end() {
1690 assert_eq!(sanitize_output("hello\x1b"), "hello");
1691 }
1692
1693 #[test]
1694 fn test_sanitize_truncated_csi_no_terminator() {
1695 assert_eq!(sanitize_output("hello\x1b[123"), "hello");
1696 }
1697
1698 #[test]
1699 fn test_sanitize_apc_sequence() {
1700 assert_eq!(sanitize_output("\x1b_payload\x1b\\visible"), "visible");
1701 }
1702
1703 #[test]
1704 fn test_sanitize_pm_sequence() {
1705 assert_eq!(sanitize_output("\x1b^payload\x1b\\visible"), "visible");
1706 }
1707
1708 #[test]
1709 fn test_sanitize_dcs_terminated_by_bel() {
1710 assert_eq!(sanitize_output("\x1bPdata\x07text"), "text");
1711 }
1712
1713 #[test]
1714 fn test_sanitize_lone_esc_plus_letter() {
1715 assert_eq!(sanitize_output("a\x1bMb"), "ab");
1716 }
1717
1718 #[test]
1719 fn test_sanitize_multiple_mixed_sequences() {
1720 let input = "\x1b[1mbold\x1b[0m \x1b]0;title\x07normal \x01gone";
1722 assert_eq!(sanitize_output(input), "bold normal gone");
1723 }
1724}