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 related_issues: Option<Vec<String>>,
46 pub type_: Option<Option<String>>,
49 pub assignee: Option<Option<String>>,
50 pub estimate: Option<Option<String>>,
51 pub commit: Option<Option<String>>,
52 pub cycles: Option<Option<Vec<String>>>,
53 pub milestones: Option<Option<Vec<String>>>,
54 pub reviewer: Option<Option<String>>,
55 pub start_date: Option<Option<String>>,
56 pub due_date: Option<Option<String>>,
57 pub parent: Option<Option<crate::id::IssueRef>>,
75}
76
77pub fn resolve_editor(pref: Option<&str>) -> Result<String> {
78 resolve_editor_with_env(
79 pref,
80 std::env::var("EDITOR").ok(),
81 std::env::var("VISUAL").ok(),
82 )
83}
84
85pub fn resolve_agent(pref: Option<&str>) -> Result<String> {
86 resolve_editor_with_env(
87 pref,
88 std::env::var("TIX_AGENT")
89 .ok()
90 .or_else(|| std::env::var("AGENT").ok()),
91 std::env::var("VISUAL").ok(),
92 )
93}
94
95fn resolve_editor_with_env(
96 pref: Option<&str>,
97 editor: Option<String>,
98 visual: Option<String>,
99) -> Result<String> {
100 if let Some(e) = pref
101 && !e.is_empty()
102 {
103 return Ok(e.to_string());
104 }
105 if let Some(e) = editor.filter(|v| !v.is_empty()) {
106 return Ok(e);
107 }
108 if let Some(e) = visual.filter(|v| !v.is_empty()) {
109 return Ok(e);
110 }
111 let fallbacks: &[&str] = if cfg!(target_os = "windows") {
112 &["cmd", "nano", "vi"]
113 } else {
114 &["editor", "nano", "vi"]
115 };
116 for name in fallbacks {
117 if which(name) {
118 return Ok(name.to_string());
119 }
120 }
121 Err(TixError::Editor("no editor found".into()))
122}
123
124pub fn run_editor(editor: &str, path: &Path, _config: &Config) -> Result<()> {
133 let status = Command::new(editor)
134 .arg(path)
135 .status()
136 .map_err(|e| TixError::Editor(format!("failed to launch {editor}: {e}")))?;
137 if !status.success() {
138 return Err(TixError::Editor(format!(
139 "editor {editor} exited with {status}"
140 )));
141 }
142 Ok(())
143}
144
145pub const VIEWER_CANDIDATES: &[&str] = &["mdterm", "batcat", "most", "less", "more"];
151
152pub fn resolve_viewer(pref: Option<&str>) -> Result<String> {
162 static AUTO_VIEWER: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
163 VIEWER_CANDIDATES
164 .iter()
165 .find(|name| which(name))
166 .map(|name| (*name).to_string())
167 });
168 resolve_viewer_with(pref, |name| AUTO_VIEWER.as_deref() == Some(name))
169}
170
171pub fn resolve_viewer_with(pref: Option<&str>, probe: impl Fn(&str) -> bool) -> Result<String> {
174 if let Some(name) = explicit_viewer(pref)? {
175 return Ok(name);
176 }
177 VIEWER_CANDIDATES
178 .iter()
179 .find(|name| probe(name))
180 .map(|name| (*name).to_string())
181 .ok_or_else(no_viewer_error)
182}
183
184fn explicit_viewer(pref: Option<&str>) -> Result<Option<String>> {
190 match pref {
191 None => Ok(None),
192 Some(raw) => {
193 let name = raw.trim();
194 if name.is_empty() {
195 return Err(TixError::Editor(
196 "`viewer` must not be blank; use `auto` for the default chain".into(),
197 ));
198 }
199 if name == "auto" {
200 Ok(None)
201 } else {
202 Ok(Some(name.to_string()))
203 }
204 }
205 }
206}
207
208fn no_viewer_error() -> TixError {
209 TixError::Editor(format!(
210 "no viewer found: install one of {} (or set `viewer` in tix.toml)",
211 VIEWER_CANDIDATES.join(", ")
212 ))
213}
214
215pub fn run_viewer(viewer: &str, path: &Path) -> Result<()> {
223 Command::new(viewer)
224 .arg(path)
225 .status()
226 .map_err(|e| TixError::Editor(format!("failed to launch {viewer}: {e}")))?;
227 Ok(())
228}
229
230pub fn merge_fields(issue: &Issue, edits: &FieldEdits) -> Issue {
231 let mut result = issue.clone();
232 if let Some(ref title) = edits.title {
233 result.title = title.clone();
234 }
235 if let Some(ref status) = edits.status {
236 result.status = crate::model::Status::new(status);
237 }
238 if let Some(ref priority) = edits.priority {
239 result.priority = priority.parse().unwrap_or_default();
240 }
241 if let Some(ref labels) = edits.labels {
242 result.labels = labels.clone();
243 }
244 if let Some(ref files) = edits.files {
245 result.files = files.clone();
246 }
247 if let Some(related_issues) = &edits.related_issues {
248 result.related_issues = related_issues.clone();
249 }
250 if let Some(t) = &edits.type_ {
251 result.type_ = t.clone();
252 }
253 if let Some(ref assignee) = edits.assignee {
254 result.assignee = assignee.clone();
255 }
256 if let Some(ref estimate) = edits.estimate {
257 result.estimate = estimate.clone();
258 }
259 if let Some(ref commit) = edits.commit {
260 result.commit = commit.clone();
261 }
262 if let Some(ref cycles) = edits.cycles {
263 result.cycles = cycles.clone();
264 }
265 if let Some(ref milestones) = edits.milestones {
266 result.milestones = milestones.clone();
267 }
268 if let Some(ref reviewer) = edits.reviewer {
269 result.reviewer = reviewer.clone();
270 }
271 if let Some(ref start_date) = edits.start_date {
272 result.start_date = start_date.clone();
273 }
274 if let Some(ref due_date) = edits.due_date {
275 result.due_date = due_date.clone();
276 }
277 if let Some(ref parent) = edits.parent {
278 result.parent = *parent;
279 }
280 result
281}
282
283fn which(name: &str) -> bool {
284 let output = Command::new("which")
285 .arg(name)
286 .output()
287 .map(|o| o.status.success());
288 output.unwrap_or(false)
289}
290
291#[cfg(test)]
292mod tests {
293 #[allow(clippy::wildcard_imports)]
294 use super::*;
295 use crate::model::{Priority, Status};
296 use std::fs;
297 use tempfile::TempDir;
298
299 #[test]
300 fn resolve_editor_pref_wins() {
301 let result = resolve_editor_with_env(Some("nvim"), None, None).unwrap();
302 assert_eq!(result, "nvim");
303 }
304
305 #[test]
306 fn resolve_editor_pref_empty_falls_through() {
307 let result = resolve_editor_with_env(Some(""), Some("vim".into()), None).unwrap();
308 assert_eq!(result, "vim");
309 }
310
311 #[test]
312 fn resolve_editor_editor_env() {
313 let result = resolve_editor_with_env(None, Some("emacs".into()), None).unwrap();
314 assert_eq!(result, "emacs");
315 }
316
317 #[test]
318 fn resolve_editor_visual_env() {
319 let result = resolve_editor_with_env(None, None, Some("helix".into())).unwrap();
320 assert_eq!(result, "helix");
321 }
322
323 #[test]
324 fn resolve_editor_editor_beats_visual() {
325 let result =
326 resolve_editor_with_env(None, Some("vim".into()), Some("helix".into())).unwrap();
327 assert_eq!(result, "vim");
328 }
329
330 #[test]
331 fn resolve_editor_empty_env_falls_through_to_fallback() {
332 let result = resolve_editor_with_env(None, Some("".into()), None);
333 match result {
335 Ok(name) => assert_ne!(name, ""),
336 Err(TixError::Editor(_)) => {}
337 Err(other) => panic!("unexpected error: {other:?}"),
338 }
339 }
340
341 #[test]
342 fn run_editor_success() {
343 let tmp = TempDir::new().unwrap();
344 let file = tmp.path().join("issue.md");
345 fs::write(
346 &file,
347 "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody",
348 )
349 .unwrap();
350 let script = tmp.path().join("edit.sh");
351 fs::write(&script, "#!/bin/sh\necho 'done' > /dev/null\n").unwrap();
352 Command::new("chmod")
353 .arg("+x")
354 .arg(&script)
355 .status()
356 .unwrap();
357 let config = Config::default();
358 run_editor(script.to_str().unwrap(), &file, &config).unwrap();
359 }
360
361 #[test]
362 fn run_editor_non_zero_exits_with_error() {
363 let tmp = TempDir::new().unwrap();
364 let file = tmp.path().join("issue.md");
365 fs::write(
366 &file,
367 "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody",
368 )
369 .unwrap();
370 let script = tmp.path().join("fail.sh");
371 fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
372 Command::new("chmod")
373 .arg("+x")
374 .arg(&script)
375 .status()
376 .unwrap();
377 let config = Config::default();
378 let err = run_editor(script.to_str().unwrap(), &file, &config).unwrap_err();
379 assert!(matches!(err, TixError::Editor(_)));
380 }
381
382 #[test]
388 fn run_editor_does_not_validate_the_file() {
389 let tmp = TempDir::new().unwrap();
390 let file = tmp.path().join("issue.md");
391 fs::write(&file, "not an issue at all").unwrap();
392 let script = tmp.path().join("noop.sh");
393 fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
394 Command::new("chmod")
395 .arg("+x")
396 .arg(&script)
397 .status()
398 .unwrap();
399 let config = Config::default();
400 run_editor(script.to_str().unwrap(), &file, &config).unwrap();
401 assert_eq!(fs::read_to_string(&file).unwrap(), "not an issue at all");
402 }
403
404 #[test]
405 fn resolve_viewer_explicit_override_wins() {
406 let result = resolve_viewer_with(Some("mdview"), |_| false).unwrap();
407 assert_eq!(result, "mdview");
408 }
409
410 #[test]
411 fn resolve_viewer_trims_the_override() {
412 let result = resolve_viewer_with(Some(" mdview "), |_| true).unwrap();
413 assert_eq!(result, "mdview");
414 }
415
416 #[test]
417 fn resolve_viewer_blank_override_is_an_error() {
418 let err = resolve_viewer_with(Some(" "), |_| true).unwrap_err();
421 assert!(matches!(err, TixError::Editor(_)));
422 }
423
424 #[test]
425 fn resolve_viewer_follows_the_documented_probe_order() {
426 let order = |found: &[&str]| {
427 let found = found.to_vec();
428 resolve_viewer_with(None, move |name| found.contains(&name)).unwrap()
429 };
430 assert_eq!(
431 order(&["mdterm", "batcat", "most", "less", "more"]),
432 "mdterm"
433 );
434 assert_eq!(order(&["batcat", "most", "less", "more"]), "batcat");
435 assert_eq!(order(&["most", "less", "more"]), "most");
436 assert_eq!(order(&["less", "more"]), "less");
437 assert_eq!(order(&["more"]), "more");
438 }
439
440 #[test]
441 fn resolve_viewer_auto_uses_the_probe_chain() {
442 let found = ["less"];
443 let result = resolve_viewer_with(Some("auto"), |name| found.contains(&name)).unwrap();
444 assert_eq!(result, "less");
445 }
446
447 #[test]
448 fn resolve_viewer_none_found_names_every_candidate() {
449 let err = resolve_viewer_with(None, |_| false)
450 .unwrap_err()
451 .to_string();
452 for name in VIEWER_CANDIDATES {
453 assert!(err.contains(name), "the error must name `{name}`: {err}");
454 }
455 }
456
457 #[test]
458 fn run_viewer_ignores_a_non_zero_exit_and_leaves_the_file_alone() {
459 let tmp = TempDir::new().unwrap();
461 let file = tmp.path().join("issue.md");
462 fs::write(&file, "body").unwrap();
463 let script = tmp.path().join("quit.sh");
464 fs::write(&script, "#!/bin/sh\nexit 3\n").unwrap();
465 Command::new("chmod")
466 .arg("+x")
467 .arg(&script)
468 .status()
469 .unwrap();
470 run_viewer(script.to_str().unwrap(), &file).unwrap();
471 assert_eq!(
472 fs::read_to_string(&file).unwrap(),
473 "body",
474 "the viewer is read-only"
475 );
476 }
477
478 #[test]
479 fn run_viewer_spawn_failure_is_an_error() {
480 let tmp = TempDir::new().unwrap();
481 let file = tmp.path().join("issue.md");
482 fs::write(&file, "body").unwrap();
483 let err = run_viewer("tix-no-such-viewer-1507", &file).unwrap_err();
484 assert!(matches!(err, TixError::Editor(_)));
485 }
486
487 #[test]
488 fn merge_fields_flag_driven() {
489 let issue = Issue {
490 projects: None,
491 id: None,
492 number: 1,
493 title: "Old".into(),
494 status: Status::new("todo"),
495 labels: vec![],
496 priority: Priority::Low,
497 assignee: Some("alice".into()),
498 estimate: Some("2".into()),
499 commit: None,
500 gitlab_id: None,
501 linear_id: None,
502 mission: None,
503 task: None,
504 relationships: vec![],
505 related_issues: vec![],
506 files: vec![],
507 cycles: None,
508 milestones: None,
509 reviewer: None,
510 start_date: None,
511 due_date: None,
512 created: "2026-01-01".into(),
513 type_: None,
514 updated: "2026-01-01".into(),
515 filename: String::new(),
516 body: "original body".into(),
517 history: crate::model::History::new(),
518 has_retired_links_key: false,
519 time_entries: vec![],
520 parent: None,
521 series: None,
522 health: None,
523 epics: None,
524 extra_frontmatter: std::collections::BTreeMap::new(),
525 archived: false,
526 entered_at: None,
527 source: None,
528 };
529 let edits = FieldEdits {
530 title: Some("New".into()),
531 status: None,
532 priority: None,
533 labels: None,
534 files: None,
535 assignee: None,
536 estimate: None,
537 commit: None,
538 cycles: None,
539 milestones: None,
540 reviewer: None,
541 start_date: None,
542 due_date: None,
543 parent: None,
544 related_issues: None,
545 type_: None,
546 };
547 let merged = merge_fields(&issue, &edits);
548 assert_eq!(merged.title, "New");
549 assert_eq!(merged.status.display(), "todo");
550 assert_eq!(merged.body, "original body");
551 assert_eq!(merged.assignee.as_deref(), Some("alice"));
552 }
553
554 #[test]
555 fn merge_fields_commit_clear() {
556 let issue = Issue {
557 projects: None,
558 id: None,
559 number: 1,
560 title: "T".into(),
561 status: Status::new("done"),
562 labels: vec![],
563 priority: Priority::default(),
564 assignee: None,
565 estimate: None,
566 commit: Some("abc".into()),
567 gitlab_id: None,
568 linear_id: None,
569 mission: None,
570 task: None,
571 relationships: vec![],
572 related_issues: vec![],
573 files: vec![],
574 cycles: None,
575 milestones: None,
576 reviewer: None,
577 start_date: None,
578 due_date: None,
579 created: "2026-01-01".into(),
580 type_: None,
581 updated: "2026-01-01".into(),
582 filename: String::new(),
583 body: String::new(),
584 history: crate::model::History::new(),
585 has_retired_links_key: false,
586 time_entries: vec![],
587 parent: None,
588 series: None,
589 health: None,
590 epics: None,
591 extra_frontmatter: std::collections::BTreeMap::new(),
592 archived: false,
593 entered_at: None,
594 source: None,
595 };
596 let edits = FieldEdits {
597 title: None,
598 status: Some("in progress".into()),
599 priority: None,
600 labels: None,
601 files: None,
602 assignee: None,
603 estimate: None,
604 commit: Some(None),
605 cycles: None,
606 milestones: None,
607 reviewer: None,
608 start_date: None,
609 due_date: None,
610 parent: None,
611 related_issues: None,
612 type_: None,
613 };
614 let merged = merge_fields(&issue, &edits);
615 assert!(merged.commit.is_none());
616 assert_eq!(merged.status.display(), "in progress");
617 }
618
619 #[test]
620 fn merge_fields_body_never_altered() {
621 let issue = Issue {
622 projects: None,
623 id: None,
624 number: 1,
625 title: "T".into(),
626 status: Status::new("todo"),
627 labels: vec![],
628 priority: Priority::default(),
629 assignee: None,
630 estimate: None,
631 commit: None,
632 gitlab_id: None,
633 linear_id: None,
634 mission: None,
635 task: None,
636 relationships: vec![],
637 related_issues: vec![],
638 history: crate::model::History::new(),
639 has_retired_links_key: false,
640 time_entries: vec![],
641 parent: None,
642 series: None,
643 health: None,
644 files: vec![],
645 cycles: None,
646 milestones: None,
647 reviewer: None,
648 start_date: None,
649 due_date: None,
650 created: "2026-01-01".into(),
651 type_: None,
652 updated: "2026-01-01".into(),
653 filename: String::new(),
654 body: "important body".into(),
655 epics: None,
656 extra_frontmatter: std::collections::BTreeMap::new(),
657 archived: false,
658 entered_at: None,
659 source: None,
660 };
661 let edits = FieldEdits {
662 title: Some("Changed".into()),
663 status: Some("done".into()),
664 priority: Some("high".into()),
665 labels: Some(vec!["bug".into()]),
666 files: Some(vec!["specs/s.md".into()]),
667 assignee: Some(Some("bob".into())),
668 estimate: Some(Some("3".into())),
669 commit: Some(Some("def".into())),
670 cycles: Some(Some(vec!["c1".into()])),
671 milestones: None,
672 reviewer: Some(Some("renata".into())),
673 start_date: None,
674 due_date: None,
675 parent: None,
676 related_issues: None,
677 type_: None,
678 };
679 let merged = merge_fields(&issue, &edits);
680 assert_eq!(merged.body, "important body");
681 }
682}