Skip to main content

yt/
editor.rs

1use std::io::Write as _;
2use std::os::unix::fs::OpenOptionsExt as _;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use miette::IntoDiagnostic as _;
7
8use crate::env::Env;
9use crate::error::Result;
10
11/// The editor to run: `$VISUAL`, else `$EDITOR`, else `vi`, split on whitespace (`code --wait`).
12pub fn command(env: &Env) -> Vec<String> {
13    let words: Vec<String> = env
14        .get("VISUAL")
15        .or_else(|| env.get("EDITOR"))
16        .unwrap_or("vi")
17        .split_whitespace()
18        .map(str::to_string)
19        .collect();
20    if words.is_empty() {
21        vec!["vi".to_string()]
22    } else {
23        words
24    }
25}
26
27/// Opens `initial` in the editor and returns the saved text. The buffer is a temporary Markdown file in `dir`.
28pub async fn edit(env: &Env, dir: &Path, initial: &str) -> Result<String> {
29    let argv = command(env);
30    let dir = dir.to_path_buf();
31    let initial = initial.to_string();
32    tokio::task::spawn_blocking(move || -> Result<String> {
33        std::fs::create_dir_all(&dir).into_diagnostic()?;
34        let path = buffer(&dir, &initial).into_diagnostic()?;
35        let status = Command::new(&argv[0]).args(&argv[1..]).arg(&path).status();
36        let text = std::fs::read_to_string(&path);
37        let _ = std::fs::remove_file(&path);
38        match status {
39            Ok(status) if status.success() => text.into_diagnostic(),
40            Ok(status) => Err(miette::miette!(
41                "the editor exited with {status}; nothing was changed"
42            )),
43            Err(error) => Err(miette::miette!(
44                "could not start the editor '{}': {error}",
45                argv[0]
46            )),
47        }
48    })
49    .await
50    .into_diagnostic()?
51}
52
53/// A new file only the user can read, under a name nobody can guess or plant a link at beforehand.
54fn buffer(dir: &Path, initial: &str) -> std::io::Result<PathBuf> {
55    let mut random = [0u8; 8];
56    rand::fill(&mut random);
57    let suffix: String = random.iter().map(|byte| format!("{byte:02x}")).collect();
58    let path = dir.join(format!("yt-edit-{}-{suffix}.md", std::process::id()));
59    std::fs::OpenOptions::new()
60        .write(true)
61        .create_new(true)
62        .mode(0o600)
63        .open(&path)?
64        .write_all(initial.as_bytes())?;
65    Ok(path)
66}
67
68/// An issue as an editor buffer: the summary, a blank line, the description.
69pub fn join_issue(summary: &str, description: &str) -> String {
70    format!("{summary}\n\n{description}")
71}
72
73/// The summary (first line) and description (the rest, without surrounding blank lines) of a buffer.
74pub fn split_issue(buffer: &str) -> (String, String) {
75    let (first, rest) = buffer.split_once('\n').unwrap_or((buffer, ""));
76    (
77        first.trim().to_string(),
78        rest.trim_start_matches(['\n', '\r']).trim_end().to_string(),
79    )
80}
81
82#[cfg(test)]
83mod tests {
84    use std::os::unix::fs::PermissionsExt as _;
85
86    use super::*;
87
88    fn script(dir: &Path, body: &str) -> String {
89        let path = dir.join("editor.sh");
90        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
91        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
92        path.display().to_string()
93    }
94
95    #[tokio::test]
96    async fn the_saved_buffer_is_returned_and_removed() {
97        let dir = tempfile::tempdir().unwrap();
98        let editor = script(dir.path(), r#"printf 'edited\n' >> "$1""#);
99        let env = Env::from_vars([("VISUAL", editor.as_str())]);
100        assert_eq!(
101            edit(&env, dir.path(), "first\n").await.unwrap(),
102            "first\nedited\n"
103        );
104        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
105            .unwrap()
106            .filter_map(|entry| entry.ok())
107            .filter(|entry| entry.file_name().to_string_lossy().starts_with("yt-edit-"))
108            .collect();
109        assert!(leftovers.is_empty());
110    }
111
112    #[tokio::test]
113    async fn the_buffer_is_private_to_the_user() {
114        let dir = tempfile::tempdir().unwrap();
115        let editor = script(
116            dir.path(),
117            r#"mode=$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"); printf %s "$mode" > "$1""#,
118        );
119        let env = Env::from_vars([("VISUAL", editor.as_str())]);
120        assert_eq!(edit(&env, dir.path(), "draft").await.unwrap(), "600");
121    }
122
123    #[tokio::test]
124    async fn a_failing_editor_changes_nothing() {
125        let dir = tempfile::tempdir().unwrap();
126        let editor = script(dir.path(), "exit 3");
127        let env = Env::from_vars([("EDITOR", editor.as_str())]);
128        let error = edit(&env, dir.path(), "x").await.unwrap_err();
129        assert!(error.to_string().contains("nothing was changed"), "{error}");
130    }
131
132    #[test]
133    fn visual_wins_and_arguments_are_split() {
134        assert_eq!(
135            command(&Env::from_vars([
136                ("VISUAL", "code --wait"),
137                ("EDITOR", "nano")
138            ])),
139            ["code", "--wait"]
140        );
141        assert_eq!(command(&Env::from_vars([("EDITOR", "nano")])), ["nano"]);
142        assert_eq!(command(&Env::from_vars([("EDITOR", "   ")])), ["vi"]);
143        assert_eq!(command(&Env::default()), ["vi"]);
144    }
145
146    #[test]
147    fn issue_buffers_round_trip() {
148        let buffer = join_issue("Login fails", "Steps:\n1. Open\n");
149        assert_eq!(buffer, "Login fails\n\nSteps:\n1. Open\n");
150        assert_eq!(
151            split_issue(&buffer),
152            ("Login fails".into(), "Steps:\n1. Open".into())
153        );
154        assert_eq!(
155            split_issue("  Only a summary  \n"),
156            ("Only a summary".into(), String::new())
157        );
158        assert_eq!(
159            split_issue("Title\nno blank line\n"),
160            ("Title".into(), "no blank line".into())
161        );
162    }
163}