Skip to main content

tix/
editor.rs

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