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
11pub 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
27pub 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
53fn 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
68pub fn join_issue(summary: &str, description: &str) -> String {
70 format!("{summary}\n\n{description}")
71}
72
73pub 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}