Skip to main content

tmux_workspace/config/
emit.rs

1//! Rendering a [`Workspace`] back to tmuxp-style YAML. [`Workspace::to_yaml`]
2//! writes only the keys this crate acts on, so a round trip can come back
3//! shorter than the document that produced it.
4
5use std::fmt::Write as _;
6use std::path::Path;
7
8use super::{PaneConfig, ShellCommand, WindowConfig, Workspace};
9
10impl Workspace {
11    /// Render this workspace as tmuxp-style YAML.
12    ///
13    /// Emits the keys this crate acts on and nothing else, so a document that
14    /// came from [`Self::from_yaml`] and back may be shorter than it started:
15    /// what is dropped is what `unsupported_keys` already named.
16    ///
17    /// # Examples
18    ///
19    /// ```
20    /// use tmux_workspace::Workspace;
21    ///
22    /// let workspace = Workspace::from_yaml(
23    ///     "
24    /// session_name: demo
25    /// windows:
26    ///   - window_name: editor
27    ///     panes: [htop]
28    /// ",
29    /// )?;
30    ///
31    /// // What it writes, it can read.
32    /// assert_eq!(Workspace::from_yaml(&workspace.to_yaml())?, workspace);
33    /// # Ok::<(), tmux_workspace::ConfigError>(())
34    /// ```
35    #[must_use]
36    pub fn to_yaml(&self) -> String {
37        let mut out = String::new();
38        let _ = writeln!(out, "session_name: {}", quoted(&self.session_name));
39        if let Some(directory) = &self.start_directory {
40            let _ = writeln!(out, "start_directory: {}", path(directory));
41        }
42        if !self.suppress_history {
43            out.push_str("suppress_history: false\n");
44        }
45        write_pairs(&mut out, Some("environment"), &self.environment, 2);
46        write_pairs(&mut out, Some("options"), &self.options, 2);
47        write_pairs(&mut out, Some("global_options"), &self.global_options, 2);
48        write_commands(
49            &mut out,
50            Some("shell_command_before"),
51            &self.shell_command_before,
52            2,
53        );
54
55        if self.windows.is_empty() {
56            // A bare `windows:` reads back as null, which this crate
57            // refuses; an explicit empty list is what round-trips.
58            out.push_str("windows: []\n");
59        } else {
60            out.push_str("windows:\n");
61            for window in &self.windows {
62                window.write_yaml(&mut out);
63            }
64        }
65
66        out
67    }
68}
69
70/// Writes the keys of one sequence entry, indenting all but the first.
71///
72/// A YAML sequence entry marks only its first line with `-`, so which line
73/// that is has to be tracked rather than decided per key.
74struct Entry {
75    marker: &'static str,
76    indent: &'static str,
77    first: bool,
78}
79
80impl Entry {
81    const fn new(marker: &'static str, indent: &'static str) -> Self {
82        Self {
83            marker,
84            indent,
85            first: true,
86        }
87    }
88
89    fn key(&mut self, out: &mut String, line: &str) {
90        out.push_str(if self.first { self.marker } else { self.indent });
91        self.first = false;
92        out.push_str(line);
93        out.push('\n');
94    }
95}
96
97impl WindowConfig {
98    /// Write this window as one entry of a `windows:` sequence.
99    fn write_yaml(&self, out: &mut String) {
100        let mut entry = Entry::new("  - ", "    ");
101
102        if let Some(name) = &self.window_name {
103            entry.key(out, &format!("window_name: {}", quoted(name)));
104        }
105        if let Some(index) = self.window_index {
106            entry.key(out, &format!("window_index: {index}"));
107        }
108        if let Some(shell) = &self.window_shell {
109            entry.key(out, &format!("window_shell: {}", quoted(shell)));
110        }
111        if let Some(layout) = &self.layout {
112            entry.key(out, &format!("layout: {}", quoted(layout)));
113        }
114        if let Some(directory) = &self.start_directory {
115            entry.key(out, &format!("start_directory: {}", path(directory)));
116        }
117        if self.focus {
118            entry.key(out, "focus: true");
119        }
120        if let Some(suppress) = self.suppress_history {
121            entry.key(out, &format!("suppress_history: {suppress}"));
122        }
123        if !self.environment.is_empty() {
124            entry.key(out, "environment:");
125            write_pairs(out, None, &self.environment, 6);
126        }
127        if !self.options.is_empty() {
128            entry.key(out, "options:");
129            write_pairs(out, None, &self.options, 6);
130        }
131        if !self.shell_command_before.is_empty() {
132            entry.key(out, "shell_command_before:");
133            write_commands(out, None, &self.shell_command_before, 6);
134        }
135
136        // Always written, even when empty: an entry with no keys at all is
137        // not a mapping, and `panes` is the one key every window has.
138        entry.key(out, "panes:");
139        for pane in &self.panes {
140            pane.write_yaml(out);
141        }
142    }
143}
144
145impl PaneConfig {
146    /// Write this pane as one entry of a `panes:` sequence.
147    fn write_yaml(&self, out: &mut String) {
148        let mut entry = Entry::new("      - ", "        ");
149
150        if !self.shell_command_before.is_empty() {
151            entry.key(out, "shell_command_before:");
152            write_commands(out, None, &self.shell_command_before, 10);
153        }
154        if !self.shell_commands.is_empty() {
155            entry.key(out, "shell_command:");
156            write_commands(out, None, &self.shell_commands, 10);
157        }
158        if let Some(directory) = &self.start_directory {
159            entry.key(out, &format!("start_directory: {}", path(directory)));
160        }
161        if self.focus {
162            entry.key(out, "focus: true");
163        }
164        if let Some(shell) = &self.shell {
165            entry.key(out, &format!("shell: {}", quoted(shell)));
166        }
167        if !self.enter {
168            entry.key(out, "enter: false");
169        }
170        if let Some(sleep) = self.sleep_before {
171            entry.key(out, &format!("sleep_before: {}", sleep.as_secs_f64()));
172        }
173        if let Some(sleep) = self.sleep_after {
174            entry.key(out, &format!("sleep_after: {}", sleep.as_secs_f64()));
175        }
176        if let Some(suppress) = self.suppress_history {
177            entry.key(out, &format!("suppress_history: {suppress}"));
178        }
179        if let Some(environment) = &self.environment {
180            entry.key(out, "environment:");
181            write_pairs(out, None, environment, 10);
182        }
183        if entry.first {
184            // Nothing distinguished this pane, so it is the empty mapping a
185            // reader turns back into a default pane.
186            out.push_str("      - {}\n");
187        }
188    }
189}
190
191/// Write a mapping of name to value, indented.
192fn write_pairs(out: &mut String, name: Option<&str>, values: &[(String, String)], indent: usize) {
193    if values.is_empty() {
194        return;
195    }
196    if let Some(name) = name {
197        let _ = writeln!(out, "{name}:");
198    }
199    for (key, value) in values {
200        let _ = writeln!(out, "{:indent$}{}: {}", "", quoted(key), quoted(value));
201    }
202}
203
204/// Write a sequence of commands, indented.
205fn write_commands(out: &mut String, name: Option<&str>, commands: &[ShellCommand], indent: usize) {
206    if commands.is_empty() {
207        return;
208    }
209    if let Some(name) = name {
210        let _ = writeln!(out, "{name}:");
211    }
212    for command in commands {
213        let _ = writeln!(out, "{:indent$}- {}", "", command_yaml(command));
214    }
215}
216
217/// A command as a string, or as a flow mapping when it has settings of its own.
218fn command_yaml(command: &ShellCommand) -> String {
219    if command.is_plain() {
220        return quoted(&command.cmd);
221    }
222    let mut fields = vec![format!("cmd: {}", quoted(&command.cmd))];
223    if let Some(enter) = command.enter {
224        fields.push(format!("enter: {enter}"));
225    }
226    if let Some(sleep) = command.sleep_before {
227        fields.push(format!("sleep_before: {}", sleep.as_secs_f64()));
228    }
229    if let Some(sleep) = command.sleep_after {
230        fields.push(format!("sleep_after: {}", sleep.as_secs_f64()));
231    }
232    format!("{{{}}}", fields.join(", "))
233}
234
235/// Quote a path the way a scalar is quoted.
236fn path(value: &Path) -> String {
237    quoted(&value.display().to_string())
238}
239
240/// Quote a scalar so YAML reads it back as the string it started as.
241///
242/// Always quoted rather than only when necessary: a command is arbitrary
243/// shell, and deciding which of YAML's bare-scalar rules it trips is a larger
244/// job than quoting everything.
245fn quoted(value: &str) -> String {
246    let mut escaped = String::with_capacity(value.len() + 2);
247    escaped.push('"');
248    for character in value.chars() {
249        match character {
250            '"' => escaped.push_str(r#"\""#),
251            '\\' => escaped.push_str(r"\\"),
252            character if character.is_control() || matches!(character, '\u{2028}' | '\u{2029}') => {
253                let code = u32::from(character);
254                let _ = write!(escaped, r"\u{code:04x}");
255            }
256            _ => escaped.push(character),
257        }
258    }
259    escaped.push('"');
260    escaped
261}
262
263#[cfg(test)]
264mod tests {
265    use super::quoted;
266
267    #[test]
268    fn quoted_escapes_the_characters_yaml_or_a_shell_would_read_specially() {
269        assert_eq!(quoted("plain"), "\"plain\"");
270        assert_eq!(quoted(r#"a "quote""#), r#""a \"quote\"""#);
271        assert_eq!(quoted(r"back\slash"), r#""back\\slash""#);
272        assert_eq!(quoted("tab\there"), "\"tab\\u0009here\"");
273    }
274}