Skip to main content

tix/
editor.rs

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
9/// Read a single line from TTY with basic readline-style editing.
10///
11/// This replaces `std::io::stdin().read_line` for interactive prompts so
12/// common editing shortcuts (Ctrl-A/E, arrows, delete/backspace) work.
13pub 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    /// General cross-repository references, replacing the whole list when
44    /// present (`Some(vec![])` clears). Free-form strings, stored verbatim.
45    pub related_issues: Option<Vec<String>>,
46    /// Issue type. `Some(None)` clears it, `Some(Some(v))` sets it; absent leaves
47    /// it untouched.
48    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    /// The decomposition parent, as a **resolved canonical issue reference**.
58    ///
59    /// `Some(None)` clears it, mirroring `assignee` and every other optional field:
60    /// an absent flag leaves it untouched, `--parent ""` removes it.
61    ///
62    /// A canonical id, never a selector, because a title is not stable and a stored title
63    /// would silently break every child on a retitle. Resolution happens in
64    /// [`crate::commands::edit`] against the store, which `merge_fields` has no
65    /// access to.
66    ///
67    /// **Out of map for WP03** (`src/editor.rs` is in no WP's `owned_files`; see tix
68    /// #1374). The field lives here, rather than as a separate parameter, because
69    /// that is exactly what `assignee` does: the value is merged so the merge stays
70    /// uniform, and the side effect `merge_fields` cannot perform — here validating
71    /// through the hierarchy service and appending the History event — happens
72    /// around it in the caller. The alternative, a second write path in `edit` that
73    /// bypasses the merge, is what `assignee`'s precedent argues against.
74    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
124/// Run the editor on `path` and wait for it.
125///
126/// This runs the editor and nothing else (tix #1506). It deliberately does
127/// **not** validate the result: the old trailing `Issue::parse` threw the real
128/// diagnostic away and replaced it with `file not valid after edit`, so the
129/// caller could not tell a duplicate key from a missing fence. Callers parse the
130/// copy themselves and classify what came back with [`crate::edit_problems`],
131/// which is where a problem's fix lives.
132pub 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
145/// The auto viewer probe chain, in the order tix tries the programs (tix #1507).
146///
147/// `mdterm` first because it is the tool built for this — a read-only markdown
148/// pager. `batcat` is Debian's name for `bat`, and `most`/`less`/`more` are the
149/// pagers nearly every system ships one of.
150pub const VIEWER_CANDIDATES: &[&str] = &["mdterm", "batcat", "most", "less", "more"];
151
152/// Resolve the read-only viewer used by the TUI's `v` key (tix #1507).
153///
154/// An explicit, non-`auto` preference wins. `None`, `Some("auto")`, and a
155/// whitespace-only value are the probe chain; a whitespace-only value is an
156/// error rather than a silent fallback, because the way to ask for the chain is
157/// `auto` (or omitting the key).
158///
159/// The `auto` probe result is cached for the process, so a surface that renders
160/// the resolved name does not spawn `which` on every frame.
161pub 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
171/// [`resolve_viewer`] with the PATH probe injected, so the ordering, the
172/// override, and the none-found error are testable without the host's PATH.
173pub 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
184/// The trimmed explicit viewer, or `None` when the preference means "auto".
185///
186/// Errors on a whitespace-only preference: it is neither a usable program nor a
187/// request for the chain, so guessing at the user's intent would be worse than
188/// saying so.
189fn 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
215/// Run a read-only viewer on `path`, with inherited stdio and a wait.
216///
217/// Mirrors [`run_editor`]'s mechanics — same inherited terminal, same wait — but
218/// strictly read-only: no temp file, no read-back, no write. A **non-zero exit
219/// is not an error**: a pager exits non-zero on an ordinary quit, so reporting
220/// that would turn every close into a failure message. Only a spawn failure is
221/// an error.
222pub 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        // Either a fallback editor resolves, or a clear error is returned.
334        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    /// `run_editor` runs the editor; it does not own validation (tix #1506).
383    ///
384    /// The old trailing `Issue::parse` replaced the real diagnostic with the
385    /// generic `file not valid after edit`; an editor that leaves an unparsable
386    /// file must therefore come back `Ok`, so the caller can classify it.
387    #[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        // Whitespace is neither a program nor "use the chain"; guessing would be
419        // worse than saying so.
420        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        // A pager quits non-zero on an ordinary close; that is not a failure.
460        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}