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