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 pub cycles: Option<Option<Vec<String>>>,
47 pub milestones: Option<Option<Vec<String>>>,
48 pub reviewer: Option<Option<String>>,
49 pub start_date: Option<Option<String>>,
50 pub due_date: Option<Option<String>>,
51 pub parent: Option<Option<u32>>,
69}
70
71pub fn resolve_editor(pref: Option<&str>) -> Result<String> {
72 resolve_editor_with_env(
73 pref,
74 std::env::var("EDITOR").ok(),
75 std::env::var("VISUAL").ok(),
76 )
77}
78
79pub fn resolve_agent(pref: Option<&str>) -> Result<String> {
80 resolve_editor_with_env(
81 pref,
82 std::env::var("TIX_AGENT")
83 .ok()
84 .or_else(|| std::env::var("AGENT").ok()),
85 std::env::var("VISUAL").ok(),
86 )
87}
88
89fn resolve_editor_with_env(
90 pref: Option<&str>,
91 editor: Option<String>,
92 visual: Option<String>,
93) -> Result<String> {
94 if let Some(e) = pref
95 && !e.is_empty()
96 {
97 return Ok(e.to_string());
98 }
99 if let Some(e) = editor.filter(|v| !v.is_empty()) {
100 return Ok(e);
101 }
102 if let Some(e) = visual.filter(|v| !v.is_empty()) {
103 return Ok(e);
104 }
105 let fallbacks: &[&str] = if cfg!(target_os = "windows") {
106 &["cmd", "nano", "vi"]
107 } else {
108 &["editor", "nano", "vi"]
109 };
110 for name in fallbacks {
111 if which(name) {
112 return Ok(name.to_string());
113 }
114 }
115 Err(TixError::Editor("no editor found".into()))
116}
117
118pub fn run_editor(editor: &str, path: &Path, _config: &Config) -> Result<()> {
119 let status = Command::new(editor)
120 .arg(path)
121 .status()
122 .map_err(|e| TixError::Editor(format!("failed to launch {editor}: {e}")))?;
123 if !status.success() {
124 return Err(TixError::Editor(format!(
125 "editor {editor} exited with {status}"
126 )));
127 }
128 let contents = std::fs::read_to_string(path)
129 .map_err(|e| TixError::Editor(format!("cannot read file after edit: {e}")))?;
130 if Issue::parse(&contents).is_err() {
131 return Err(TixError::Editor("file not valid after edit".into()));
132 }
133 Ok(())
134}
135
136pub fn merge_fields(issue: &Issue, edits: &FieldEdits) -> Issue {
137 let mut result = issue.clone();
138 if let Some(ref title) = edits.title {
139 result.title = title.clone();
140 }
141 if let Some(ref status) = edits.status {
142 result.status = crate::model::Status::new(status);
143 }
144 if let Some(ref priority) = edits.priority {
145 result.priority = priority.parse().unwrap_or_default();
146 }
147 if let Some(ref labels) = edits.labels {
148 result.labels = labels.clone();
149 }
150 if let Some(ref files) = edits.files {
151 result.files = files.clone();
152 }
153 if let Some(ref assignee) = edits.assignee {
154 result.assignee = assignee.clone();
155 }
156 if let Some(ref estimate) = edits.estimate {
157 result.estimate = estimate.clone();
158 }
159 if let Some(ref commit) = edits.commit {
160 result.commit = commit.clone();
161 }
162 if let Some(ref cycles) = edits.cycles {
163 result.cycles = cycles.clone();
164 }
165 if let Some(ref milestones) = edits.milestones {
166 result.milestones = milestones.clone();
167 }
168 if let Some(ref reviewer) = edits.reviewer {
169 result.reviewer = reviewer.clone();
170 }
171 if let Some(ref start_date) = edits.start_date {
172 result.start_date = start_date.clone();
173 }
174 if let Some(ref due_date) = edits.due_date {
175 result.due_date = due_date.clone();
176 }
177 if let Some(ref parent) = edits.parent {
178 result.parent = *parent;
179 }
180 result
181}
182
183fn which(name: &str) -> bool {
184 let output = Command::new("which")
185 .arg(name)
186 .output()
187 .map(|o| o.status.success());
188 output.unwrap_or(false)
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::model::{Priority, Status};
195 use std::fs;
196 use tempfile::TempDir;
197
198 #[test]
199 fn resolve_editor_pref_wins() {
200 let result = resolve_editor_with_env(Some("nvim"), None, None).unwrap();
201 assert_eq!(result, "nvim");
202 }
203
204 #[test]
205 fn resolve_editor_pref_empty_falls_through() {
206 let result = resolve_editor_with_env(Some(""), Some("vim".into()), None).unwrap();
207 assert_eq!(result, "vim");
208 }
209
210 #[test]
211 fn resolve_editor_editor_env() {
212 let result = resolve_editor_with_env(None, Some("emacs".into()), None).unwrap();
213 assert_eq!(result, "emacs");
214 }
215
216 #[test]
217 fn resolve_editor_visual_env() {
218 let result = resolve_editor_with_env(None, None, Some("helix".into())).unwrap();
219 assert_eq!(result, "helix");
220 }
221
222 #[test]
223 fn resolve_editor_editor_beats_visual() {
224 let result =
225 resolve_editor_with_env(None, Some("vim".into()), Some("helix".into())).unwrap();
226 assert_eq!(result, "vim");
227 }
228
229 #[test]
230 fn resolve_editor_empty_env_falls_through_to_fallback() {
231 let result = resolve_editor_with_env(None, Some("".into()), None);
232 match result {
234 Ok(name) => assert!(!name.is_empty()),
235 Err(TixError::Editor(_)) => {}
236 Err(other) => panic!("unexpected error: {other:?}"),
237 }
238 }
239
240 #[test]
241 fn run_editor_success() {
242 let tmp = TempDir::new().unwrap();
243 let file = tmp.path().join("issue.md");
244 fs::write(
245 &file,
246 "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody",
247 )
248 .unwrap();
249 let script = tmp.path().join("edit.sh");
250 fs::write(&script, "#!/bin/sh\necho 'done' > /dev/null\n").unwrap();
251 Command::new("chmod")
252 .arg("+x")
253 .arg(&script)
254 .status()
255 .unwrap();
256 let config = Config::default();
257 run_editor(script.to_str().unwrap(), &file, &config).unwrap();
258 }
259
260 #[test]
261 fn run_editor_non_zero_exits_with_error() {
262 let tmp = TempDir::new().unwrap();
263 let file = tmp.path().join("issue.md");
264 fs::write(
265 &file,
266 "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody",
267 )
268 .unwrap();
269 let script = tmp.path().join("fail.sh");
270 fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
271 Command::new("chmod")
272 .arg("+x")
273 .arg(&script)
274 .status()
275 .unwrap();
276 let config = Config::default();
277 let err = run_editor(script.to_str().unwrap(), &file, &config).unwrap_err();
278 assert!(matches!(err, TixError::Editor(_)));
279 }
280
281 #[test]
282 fn merge_fields_flag_driven() {
283 let issue = Issue {
284 number: 1,
285 title: "Old".into(),
286 status: Status::new("todo"),
287 labels: vec![],
288 priority: Priority::Low,
289 assignee: Some("alice".into()),
290 estimate: Some("2".into()),
291 commit: None,
292 gitlab_id: None,
293 linear_id: None,
294 mission: None,
295 task: None,
296 relationships: vec![],
297 files: vec![],
298 cycles: None,
299 milestones: None,
300 reviewer: None,
301 start_date: None,
302 due_date: None,
303 created: "2026-01-01".into(),
304 updated: "2026-01-01".into(),
305 filename: String::new(),
306 body: "original body".into(),
307 history: crate::model::History::new(),
308 has_retired_links_key: false,
309 parent: None,
310 extra_frontmatter: std::collections::BTreeMap::new(),
311 archived: false,
312 entered_at: None,
313 source: None,
314 };
315 let edits = FieldEdits {
316 title: Some("New".into()),
317 status: None,
318 priority: None,
319 labels: None,
320 files: None,
321 assignee: None,
322 estimate: None,
323 commit: None,
324 cycles: None,
325 milestones: None,
326 reviewer: None,
327 start_date: None,
328 due_date: None,
329 parent: None,
330 };
331 let merged = merge_fields(&issue, &edits);
332 assert_eq!(merged.title, "New");
333 assert_eq!(merged.status.display(), "todo");
334 assert_eq!(merged.body, "original body");
335 assert_eq!(merged.assignee.as_deref(), Some("alice"));
336 }
337
338 #[test]
339 fn merge_fields_commit_clear() {
340 let issue = Issue {
341 number: 1,
342 title: "T".into(),
343 status: Status::new("done"),
344 labels: vec![],
345 priority: Priority::default(),
346 assignee: None,
347 estimate: None,
348 commit: Some("abc".into()),
349 gitlab_id: None,
350 linear_id: None,
351 mission: None,
352 task: None,
353 relationships: vec![],
354 files: vec![],
355 cycles: None,
356 milestones: None,
357 reviewer: None,
358 start_date: None,
359 due_date: None,
360 created: "2026-01-01".into(),
361 updated: "2026-01-01".into(),
362 filename: String::new(),
363 body: String::new(),
364 history: crate::model::History::new(),
365 has_retired_links_key: false,
366 parent: None,
367 extra_frontmatter: std::collections::BTreeMap::new(),
368 archived: false,
369 entered_at: None,
370 source: None,
371 };
372 let edits = FieldEdits {
373 title: None,
374 status: Some("in progress".into()),
375 priority: None,
376 labels: None,
377 files: None,
378 assignee: None,
379 estimate: None,
380 commit: Some(None),
381 cycles: None,
382 milestones: None,
383 reviewer: None,
384 start_date: None,
385 due_date: None,
386 parent: None,
387 };
388 let merged = merge_fields(&issue, &edits);
389 assert!(merged.commit.is_none());
390 assert_eq!(merged.status.display(), "in progress");
391 }
392
393 #[test]
394 fn merge_fields_body_never_altered() {
395 let issue = Issue {
396 number: 1,
397 title: "T".into(),
398 status: Status::new("todo"),
399 labels: vec![],
400 priority: Priority::default(),
401 assignee: None,
402 estimate: None,
403 commit: None,
404 gitlab_id: None,
405 linear_id: None,
406 mission: None,
407 task: None,
408 relationships: vec![],
409 history: crate::model::History::new(),
410 has_retired_links_key: false,
411 parent: None,
412 files: vec![],
413 cycles: None,
414 milestones: None,
415 reviewer: None,
416 start_date: None,
417 due_date: None,
418 created: "2026-01-01".into(),
419 updated: "2026-01-01".into(),
420 filename: String::new(),
421 body: "important body".into(),
422 extra_frontmatter: std::collections::BTreeMap::new(),
423 archived: false,
424 entered_at: None,
425 source: None,
426 };
427 let edits = FieldEdits {
428 title: Some("Changed".into()),
429 status: Some("done".into()),
430 priority: Some("high".into()),
431 labels: Some(vec!["bug".into()]),
432 files: Some(vec!["specs/s.md".into()]),
433 assignee: Some(Some("bob".into())),
434 estimate: Some(Some("3".into())),
435 commit: Some(Some("def".into())),
436 cycles: Some(Some(vec!["c1".into()])),
437 milestones: None,
438 reviewer: Some(Some("renata".into())),
439 start_date: None,
440 due_date: None,
441 parent: None,
442 };
443 let merged = merge_fields(&issue, &edits);
444 assert_eq!(merged.body, "important body");
445 }
446}