editor.rs

  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}