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