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    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    /// The decomposition parent, as a **resolved issue number**.
52    ///
53    /// `Some(None)` clears it, mirroring `assignee` and every other optional field:
54    /// an absent flag leaves it untouched, `--parent ""` removes it.
55    ///
56    /// A number, never a selector, because a title is not stable and a stored title
57    /// would silently break every child on a retitle. Resolution happens in
58    /// [`crate::commands::edit`] against the store, which `merge_fields` has no
59    /// access to.
60    ///
61    /// **Out of map for WP03** (`src/editor.rs` is in no WP's `owned_files`; see tix
62    /// #1374). The field lives here, rather than as a separate parameter, because
63    /// that is exactly what `assignee` does: the value is merged so the merge stays
64    /// uniform, and the side effect `merge_fields` cannot perform — here validating
65    /// through the hierarchy service and appending the History event — happens
66    /// around it in the caller. The alternative, a second write path in `edit` that
67    /// bypasses the merge, is what `assignee`'s precedent argues against.
68    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        // Either a fallback editor resolves, or a clear error is returned.
233        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}