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/// 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}