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