tryme_core/emit.rs
1//! Script emission — the ONLY module that writes to stdout.
2//!
3//! Port of upstream's shell-script helpers (`try.rb:1388-1409`). The emitted
4//! script is the product: the wrapper function captures stdout and `eval`s it
5//! on exit 0. Byte format is pinned by `test_05_script_format.sh`.
6
7use std::io::Write;
8
9/// First line of every emitted script (`try.rb:1389`), byte-exact.
10pub const SCRIPT_WARNING: &str =
11 "# if you can read this, you didn't launch try from an alias. run try --help.";
12
13/// Single-quote shell quoting, port of `q()` (`try.rb:1391-1393`):
14/// wrap in `'…'`, escaping embedded `'` as `'"'"'`.
15#[must_use]
16pub fn q(s: &str) -> String {
17 format!("'{}'", s.replace('\'', r#"'"'"'"#))
18}
19
20/// Newtype over the real stdout handle. Constructed once in `main` and handed
21/// only to emission call sites, so writing script bytes to the wrong stream is
22/// unrepresentable.
23pub struct ScriptOut<W: Write>(W);
24
25impl<W: Write> ScriptOut<W> {
26 /// Wrap the process stdout (or a test buffer).
27 pub fn new(w: W) -> Self {
28 Self(w)
29 }
30
31 /// Port of `emit_script` (`try.rb:1395-1409`): warning comment first,
32 /// commands chained `&& \` with 2-space continuation indent, final
33 /// newline, no trailing `&&`.
34 ///
35 /// # Errors
36 /// Propagates I/O errors from the underlying writer.
37 pub fn emit_script(&mut self, cmds: &[String]) -> std::io::Result<()> {
38 writeln!(self.0, "{SCRIPT_WARNING}")?;
39 let last = cmds.len().saturating_sub(1);
40 for (i, cmd) in cmds.iter().enumerate() {
41 if i == 0 {
42 write!(self.0, "{cmd}")?;
43 } else {
44 write!(self.0, " {cmd}")?;
45 }
46 if i < last {
47 writeln!(self.0, " && \\")?;
48 } else {
49 writeln!(self.0)?;
50 }
51 }
52 Ok(())
53 }
54
55 /// Upstream's `puts "Cancelled."` on cancel goes to STDOUT
56 /// (`try.rb:1557,1566,1584`) — a sanctioned non-script stdout emission.
57 ///
58 /// # Errors
59 /// Propagates I/O errors from the underlying writer.
60 pub fn cancelled(&mut self) -> std::io::Result<()> {
61 writeln!(self.0, "Cancelled.")
62 }
63
64 /// `init` output (the wrapper function) also goes to stdout — it is what
65 /// the user's `eval "$(tryme init …)"` consumes (`try.rb:1181`).
66 ///
67 /// # Errors
68 /// Propagates I/O errors from the underlying writer.
69 pub fn raw(&mut self, text: &str) -> std::io::Result<()> {
70 write!(self.0, "{text}")
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 fn emit(cmds: &[&str]) -> String {
79 let mut buf = Vec::new();
80 ScriptOut::new(&mut buf)
81 .emit_script(&cmds.iter().map(ToString::to_string).collect::<Vec<_>>())
82 .unwrap();
83 String::from_utf8(buf).unwrap()
84 }
85
86 #[test]
87 fn quotes_plain_and_embedded_single_quotes() {
88 assert_eq!(q("abc"), "'abc'");
89 assert_eq!(q("a'b"), r#"'a'"'"'b'"#);
90 }
91
92 #[test]
93 fn script_format_matches_upstream() {
94 // Pinned by test_05_script_format.sh: warning first, `&& \` chaining,
95 // two-space continuation indent, no trailing chain on the last line.
96 let out = emit(&["touch '/a'", "echo '/a'", "cd '/a'"]);
97 assert_eq!(
98 out,
99 format!("{SCRIPT_WARNING}\ntouch '/a' && \\\n echo '/a' && \\\n cd '/a'\n")
100 );
101 }
102
103 #[test]
104 fn single_command_has_no_chain() {
105 let out = emit(&["cd '/x'"]);
106 assert_eq!(out, format!("{SCRIPT_WARNING}\ncd '/x'\n"));
107 }
108}