1/// Open the user's preferred editor to compose or revise text.
2///
3/// Editor discovery follows the conventional priority order: `$VISUAL`,
4/// then `$EDITOR`, then a platform default (`nano` on Unix, `notepad` on
5/// Windows). The value is split into a program and optional arguments
6/// using POSIX shell-word rules (via the `shell-words` crate), so values
7/// like `vim -u NONE` or `code --wait` work correctly.
8///
9/// In tests, set `TD_FORCE_EDITOR=<cmd>` to bypass the real editor and
10/// supply a fake one (e.g. a shell one-liner that writes known content).
11use anyhow::{anyhow, Result};
12
13/// Locate the editor binary (and any leading flags) from the environment.
14///
15/// Priority: `TD_FORCE_EDITOR` (tests) → `$VISUAL` → `$EDITOR` →
16/// platform default. Returns the argv list ready to pass to
17/// `std::process::Command`.
18pub fn find_editor() -> Result<Vec<String>> {
19 // TD_FORCE_EDITOR lets tests inject a fake editor without touching the
20 // real environment variables.
21 let raw = std::env::var("TD_FORCE_EDITOR")
22 .or_else(|_| std::env::var("VISUAL"))
23 .or_else(|_| std::env::var("EDITOR"))
24 .unwrap_or_else(|_| {
25 if cfg!(windows) {
26 "notepad".to_owned()
27 } else {
28 "nano".to_owned()
29 }
30 });
31
32 let argv = shell_words::split(&raw)
33 .map_err(|e| anyhow!("could not parse editor command {:?}: {}", raw, e))?;
34
35 if argv.is_empty() {
36 return Err(anyhow!(
37 "editor command is empty; set $VISUAL or $EDITOR to a valid command"
38 ));
39 }
40
41 Ok(argv)
42}
43
44/// Strip lines beginning with `TD: `, then extract the first non-blank line
45/// as the title and everything that follows as the description.
46///
47/// Lines starting with `#` are intentionally preserved — they are markdown
48/// headings that belong to the task content, not directives.
49///
50/// Returns `Err` when the result would be an empty title.
51pub fn parse_result(content: &str) -> Result<(String, String)> {
52 let meaningful: Vec<&str> = content.lines().filter(|l| !l.starts_with("TD: ")).collect();
53
54 // Find the first non-blank line — that becomes the title.
55 let title_pos = meaningful
56 .iter()
57 .position(|l| !l.trim().is_empty())
58 .ok_or_else(|| anyhow!("editor returned an empty message; task creation aborted"))?;
59
60 let title = meaningful[title_pos].trim().to_owned();
61
62 // Everything after the title line is the description; trim surrounding
63 // blank lines but preserve internal ones.
64 let desc = meaningful[title_pos + 1..].join("\n").trim().to_owned();
65
66 Ok((title, desc))
67}
68
69/// Write `template` to a temporary file, open the editor, read the
70/// result back, clean up the file, and return `parse_result`.
71pub fn open(template: &str) -> Result<(String, String)> {
72 use std::io::Write;
73
74 // Create a uniquely-named temp file so concurrent td invocations don't
75 // collide. The `.md` extension is a hint to editors to enable markdown
76 // highlighting.
77 let id = ulid::Ulid::new();
78 let path = std::env::temp_dir().join(format!("td-edit-{id}.md"));
79
80 {
81 let mut f = std::fs::File::create(&path)?;
82 f.write_all(template.as_bytes())?;
83 }
84
85 // Best-effort cleanup: delete the temp file even if the editor or parse
86 // step fails.
87 let result = (|| {
88 let argv = find_editor()?;
89 let (program, args) = argv
90 .split_first()
91 .ok_or_else(|| anyhow!("editor command resolved to an empty list"))?;
92
93 let status = std::process::Command::new(program)
94 .args(args)
95 .arg(&path)
96 .status()
97 .map_err(|e| anyhow!("failed to launch editor {:?}: {}", program, e))?;
98
99 if !status.success() {
100 return Err(anyhow!("editor exited with status {}", status));
101 }
102
103 let content = std::fs::read_to_string(&path)?;
104 parse_result(&content)
105 })();
106
107 // Always remove the temp file, regardless of success or failure.
108 let _ = std::fs::remove_file(&path);
109
110 result
111}
112
113// ── tests ─────────────────────────────────────────────────────────────────────
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 // ── find_editor ───────────────────────────────────────────────────────────
120
121 #[test]
122 fn find_editor_prefers_td_force_editor() {
123 // TD_FORCE_EDITOR should take precedence over VISUAL and EDITOR.
124 std::env::remove_var("VISUAL");
125 std::env::remove_var("EDITOR");
126 std::env::set_var("TD_FORCE_EDITOR", "myfakeeditor");
127 let argv = find_editor().unwrap();
128 assert_eq!(argv, vec!["myfakeeditor"]);
129 std::env::remove_var("TD_FORCE_EDITOR");
130 }
131
132 #[test]
133 fn find_editor_prefers_visual_over_editor() {
134 std::env::remove_var("TD_FORCE_EDITOR");
135 std::env::set_var("VISUAL", "vis");
136 std::env::set_var("EDITOR", "ed");
137 let argv = find_editor().unwrap();
138 assert_eq!(argv[0], "vis");
139 std::env::remove_var("VISUAL");
140 std::env::remove_var("EDITOR");
141 }
142
143 #[test]
144 fn find_editor_falls_back_to_editor() {
145 std::env::remove_var("TD_FORCE_EDITOR");
146 std::env::remove_var("VISUAL");
147 std::env::set_var("EDITOR", "myeditor");
148 let argv = find_editor().unwrap();
149 assert_eq!(argv[0], "myeditor");
150 std::env::remove_var("EDITOR");
151 }
152
153 #[test]
154 fn find_editor_splits_args() {
155 // Values like "vim -u NONE" must be split into separate argv entries.
156 std::env::remove_var("TD_FORCE_EDITOR");
157 std::env::remove_var("VISUAL");
158 std::env::set_var("EDITOR", "vim -u NONE");
159 let argv = find_editor().unwrap();
160 assert_eq!(argv, vec!["vim", "-u", "NONE"]);
161 std::env::remove_var("EDITOR");
162 }
163
164 #[test]
165 fn find_editor_splits_quoted_path() {
166 // Paths with spaces must survive shell-word quoting.
167 std::env::remove_var("TD_FORCE_EDITOR");
168 std::env::set_var("VISUAL", "\"/usr/local/bin/my editor\"");
169 std::env::remove_var("EDITOR");
170 let argv = find_editor().unwrap();
171 assert_eq!(argv, vec!["/usr/local/bin/my editor"]);
172 std::env::remove_var("VISUAL");
173 }
174
175 #[test]
176 fn find_editor_errors_on_unmatched_quote() {
177 std::env::remove_var("TD_FORCE_EDITOR");
178 std::env::remove_var("VISUAL");
179 std::env::set_var("EDITOR", "vim 'unterminated");
180 let result = find_editor();
181 assert!(result.is_err());
182 std::env::remove_var("EDITOR");
183 }
184
185 // ── parse_result ──────────────────────────────────────────────────────────
186
187 #[test]
188 fn parse_strips_td_comment_lines() {
189 let content = "TD: ignore me\nMy title\nsome description";
190 let (title, desc) = parse_result(content).unwrap();
191 assert_eq!(title, "My title");
192 assert_eq!(desc, "some description");
193 }
194
195 #[test]
196 fn parse_preserves_markdown_headings() {
197 // Lines starting with '#' are NOT comments — they're markdown headings
198 // and must be preserved as description content.
199 let content = "TD: ignore me\nMy title\n## Details\nsome description";
200 let (title, desc) = parse_result(content).unwrap();
201 assert_eq!(title, "My title");
202 assert_eq!(desc, "## Details\nsome description");
203 }
204
205 #[test]
206 fn parse_returns_empty_desc_when_only_title() {
207 let content = "TD: comment\nJust a title";
208 let (title, desc) = parse_result(content).unwrap();
209 assert_eq!(title, "Just a title");
210 assert_eq!(desc, "");
211 }
212
213 #[test]
214 fn parse_trims_surrounding_blank_lines_from_desc() {
215 let content = "Title\n\nParagraph one\n\nParagraph two\n";
216 let (title, desc) = parse_result(content).unwrap();
217 assert_eq!(title, "Title");
218 // Leading/trailing blank lines stripped from description.
219 assert_eq!(desc, "Paragraph one\n\nParagraph two");
220 }
221
222 #[test]
223 fn parse_errors_on_only_comments() {
224 let content = "TD: first comment\nTD: second comment\n";
225 let result = parse_result(content);
226 assert!(result.is_err());
227 }
228
229 #[test]
230 fn parse_errors_on_empty_string() {
231 let result = parse_result("");
232 assert!(result.is_err());
233 }
234
235 #[test]
236 fn parse_errors_on_blank_lines_only() {
237 let result = parse_result(" \n\n \n");
238 assert!(result.is_err());
239 }
240}