Skip to main content

tix/
editor.rs

1use std::io::IsTerminal;
2use std::path::Path;
3use std::process::Command;
4
5use crate::config::Config;
6use crate::error::{Result, TixError};
7use crate::model::Issue;
8
9/// Read a single line from TTY with basic readline-style editing.
10///
11/// This replaces `std::io::stdin().read_line` for interactive prompts so
12/// common editing shortcuts (Ctrl-A/E, arrows, delete/backspace) work.
13pub fn read_tty_line(prompt: &str) -> Result<String> {
14    if !std::io::stdin().is_terminal() {
15        return Err(TixError::InvalidInput(
16            "interactive input requires a TTY; pass an explicit argument instead".into(),
17        ));
18    }
19
20    use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal};
21
22    let mut line_editor = Reedline::create();
23    let prompt = DefaultPrompt::new(
24        DefaultPromptSegment::Basic(prompt.to_string()),
25        DefaultPromptSegment::Empty,
26    );
27
28    match line_editor.read_line(&prompt) {
29        Ok(Signal::Success(input)) => Ok(input.trim().to_string()),
30        Ok(Signal::CtrlC) | Ok(Signal::CtrlD) => {
31            Err(TixError::InvalidInput("input cancelled".into()))
32        }
33        Err(e) => Err(TixError::Editor(format!("failed to read tty line: {e}"))),
34    }
35}
36
37pub struct FieldEdits {
38    pub title: Option<String>,
39    pub status: Option<String>,
40    pub priority: Option<String>,
41    pub labels: Option<Vec<String>>,
42    pub files: Option<Vec<String>>,
43    pub assignee: Option<Option<String>>,
44    pub estimate: Option<Option<String>>,
45    pub commit: Option<Option<String>>,
46}
47
48pub fn resolve_editor(pref: Option<&str>) -> Result<String> {
49    resolve_editor_with_env(
50        pref,
51        std::env::var("EDITOR").ok(),
52        std::env::var("VISUAL").ok(),
53    )
54}
55
56pub fn resolve_agent(pref: Option<&str>) -> Result<String> {
57    resolve_editor_with_env(
58        pref,
59        std::env::var("TIX_AGENT")
60            .ok()
61            .or_else(|| std::env::var("AGENT").ok()),
62        std::env::var("VISUAL").ok(),
63    )
64}
65
66fn resolve_editor_with_env(
67    pref: Option<&str>,
68    editor: Option<String>,
69    visual: Option<String>,
70) -> Result<String> {
71    if let Some(e) = pref
72        && !e.is_empty()
73    {
74        return Ok(e.to_string());
75    }
76    if let Some(e) = editor.filter(|v| !v.is_empty()) {
77        return Ok(e);
78    }
79    if let Some(e) = visual.filter(|v| !v.is_empty()) {
80        return Ok(e);
81    }
82    let fallbacks: &[&str] = if cfg!(target_os = "windows") {
83        &["cmd", "nano", "vi"]
84    } else {
85        &["editor", "nano", "vi"]
86    };
87    for name in fallbacks {
88        if which(name) {
89            return Ok(name.to_string());
90        }
91    }
92    Err(TixError::Editor("no editor found".into()))
93}
94
95pub fn run_editor(editor: &str, path: &Path, _config: &Config) -> Result<()> {
96    let status = Command::new(editor)
97        .arg(path)
98        .status()
99        .map_err(|e| TixError::Editor(format!("failed to launch {editor}: {e}")))?;
100    if !status.success() {
101        return Err(TixError::Editor(format!(
102            "editor {editor} exited with {status}"
103        )));
104    }
105    let contents = std::fs::read_to_string(path)
106        .map_err(|e| TixError::Editor(format!("cannot read file after edit: {e}")))?;
107    if Issue::parse(&contents).is_err() {
108        return Err(TixError::Editor("file not valid after edit".into()));
109    }
110    Ok(())
111}
112
113pub fn merge_fields(issue: &Issue, edits: &FieldEdits) -> Issue {
114    let mut result = issue.clone();
115    if let Some(ref title) = edits.title {
116        result.title = title.clone();
117    }
118    if let Some(ref status) = edits.status {
119        result.status = crate::model::Status::new(status);
120    }
121    if let Some(ref priority) = edits.priority {
122        result.priority = priority.parse().unwrap_or_default();
123    }
124    if let Some(ref labels) = edits.labels {
125        result.labels = labels.clone();
126    }
127    if let Some(ref files) = edits.files {
128        result.files = files.clone();
129    }
130    if let Some(ref assignee) = edits.assignee {
131        result.assignee = assignee.clone();
132    }
133    if let Some(ref estimate) = edits.estimate {
134        result.estimate = estimate.clone();
135    }
136    if let Some(ref commit) = edits.commit {
137        result.commit = commit.clone();
138    }
139    result.bump_updated();
140    result
141}
142
143fn which(name: &str) -> bool {
144    let output = Command::new("which")
145        .arg(name)
146        .output()
147        .map(|o| o.status.success());
148    output.unwrap_or(false)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::model::{Priority, Status};
155    use std::fs;
156    use tempfile::TempDir;
157
158    #[test]
159    fn resolve_editor_pref_wins() {
160        let result = resolve_editor_with_env(Some("nvim"), None, None).unwrap();
161        assert_eq!(result, "nvim");
162    }
163
164    #[test]
165    fn resolve_editor_pref_empty_falls_through() {
166        let result = resolve_editor_with_env(Some(""), Some("vim".into()), None).unwrap();
167        assert_eq!(result, "vim");
168    }
169
170    #[test]
171    fn resolve_editor_editor_env() {
172        let result = resolve_editor_with_env(None, Some("emacs".into()), None).unwrap();
173        assert_eq!(result, "emacs");
174    }
175
176    #[test]
177    fn resolve_editor_visual_env() {
178        let result = resolve_editor_with_env(None, None, Some("helix".into())).unwrap();
179        assert_eq!(result, "helix");
180    }
181
182    #[test]
183    fn resolve_editor_editor_beats_visual() {
184        let result =
185            resolve_editor_with_env(None, Some("vim".into()), Some("helix".into())).unwrap();
186        assert_eq!(result, "vim");
187    }
188
189    #[test]
190    fn resolve_editor_empty_env_falls_through_to_fallback() {
191        let result = resolve_editor_with_env(None, Some("".into()), None);
192        // Either a fallback editor resolves, or a clear error is returned.
193        match result {
194            Ok(name) => assert!(!name.is_empty()),
195            Err(TixError::Editor(_)) => {}
196            Err(other) => panic!("unexpected error: {other:?}"),
197        }
198    }
199
200    #[test]
201    fn run_editor_success() {
202        let tmp = TempDir::new().unwrap();
203        let file = tmp.path().join("issue.md");
204        fs::write(
205            &file,
206            "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody",
207        )
208        .unwrap();
209        let script = tmp.path().join("edit.sh");
210        fs::write(&script, "#!/bin/sh\necho 'done' > /dev/null\n").unwrap();
211        Command::new("chmod")
212            .arg("+x")
213            .arg(&script)
214            .status()
215            .unwrap();
216        let config = Config::default();
217        run_editor(script.to_str().unwrap(), &file, &config).unwrap();
218    }
219
220    #[test]
221    fn run_editor_non_zero_exits_with_error() {
222        let tmp = TempDir::new().unwrap();
223        let file = tmp.path().join("issue.md");
224        fs::write(
225            &file,
226            "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody",
227        )
228        .unwrap();
229        let script = tmp.path().join("fail.sh");
230        fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
231        Command::new("chmod")
232            .arg("+x")
233            .arg(&script)
234            .status()
235            .unwrap();
236        let config = Config::default();
237        let err = run_editor(script.to_str().unwrap(), &file, &config).unwrap_err();
238        assert!(matches!(err, TixError::Editor(_)));
239    }
240
241    #[test]
242    fn merge_fields_flag_driven() {
243        let issue = Issue {
244            number: 1,
245            title: "Old".into(),
246            status: Status::new("todo"),
247            labels: vec![],
248            priority: Priority::Low,
249            assignee: Some("alice".into()),
250            estimate: Some("2".into()),
251            commit: None,
252            gitlab_id: None,
253            linear_id: None,
254            mission: None,
255            task: None,
256            links: vec![],
257            files: vec![],
258            created: "2026-01-01".into(),
259            updated: "2026-01-01".into(),
260            filename: String::new(),
261            body: "original body".into(),
262        };
263        let edits = FieldEdits {
264            title: Some("New".into()),
265            status: None,
266            priority: None,
267            labels: None,
268            files: None,
269            assignee: None,
270            estimate: None,
271            commit: None,
272        };
273        let merged = merge_fields(&issue, &edits);
274        assert_eq!(merged.title, "New");
275        assert_eq!(merged.status.display(), "todo");
276        assert_eq!(merged.body, "original body");
277        assert_eq!(merged.assignee.as_deref(), Some("alice"));
278    }
279
280    #[test]
281    fn merge_fields_commit_clear() {
282        let issue = Issue {
283            number: 1,
284            title: "T".into(),
285            status: Status::new("done"),
286            labels: vec![],
287            priority: Priority::default(),
288            assignee: None,
289            estimate: None,
290            commit: Some("abc".into()),
291            gitlab_id: None,
292            linear_id: None,
293            mission: None,
294            task: None,
295            links: vec![],
296            files: vec![],
297            created: "2026-01-01".into(),
298            updated: "2026-01-01".into(),
299            filename: String::new(),
300            body: String::new(),
301        };
302        let edits = FieldEdits {
303            title: None,
304            status: Some("in progress".into()),
305            priority: None,
306            labels: None,
307            files: None,
308            assignee: None,
309            estimate: None,
310            commit: Some(None),
311        };
312        let merged = merge_fields(&issue, &edits);
313        assert!(merged.commit.is_none());
314        assert_eq!(merged.status.display(), "in progress");
315    }
316
317    #[test]
318    fn merge_fields_body_never_altered() {
319        let issue = Issue {
320            number: 1,
321            title: "T".into(),
322            status: Status::new("todo"),
323            labels: vec![],
324            priority: Priority::default(),
325            assignee: None,
326            estimate: None,
327            commit: None,
328            gitlab_id: None,
329            linear_id: None,
330            mission: None,
331            task: None,
332            links: vec![],
333            files: vec![],
334            created: "2026-01-01".into(),
335            updated: "2026-01-01".into(),
336            filename: String::new(),
337            body: "important body".into(),
338        };
339        let edits = FieldEdits {
340            title: Some("Changed".into()),
341            status: Some("done".into()),
342            priority: Some("high".into()),
343            labels: Some(vec!["bug".into()]),
344            files: Some(vec!["specs/s.md".into()]),
345            assignee: Some(Some("bob".into())),
346            estimate: Some(Some("3".into())),
347            commit: Some(Some("def".into())),
348        };
349        let merged = merge_fields(&issue, &edits);
350        assert_eq!(merged.body, "important body");
351    }
352}