Skip to main content

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}