use std::io::IsTerminal;
use std::path::Path;
use std::process::Command;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::Issue;
pub fn read_tty_line(prompt: &str) -> Result<String> {
if !std::io::stdin().is_terminal() {
return Err(TixError::InvalidInput(
"interactive input requires a TTY; pass an explicit argument instead".into(),
));
}
use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal};
let mut line_editor = Reedline::create();
let prompt = DefaultPrompt::new(
DefaultPromptSegment::Basic(prompt.to_string()),
DefaultPromptSegment::Empty,
);
match line_editor.read_line(&prompt) {
Ok(Signal::Success(input)) => Ok(input.trim().to_string()),
Ok(Signal::CtrlC) | Ok(Signal::CtrlD) => {
Err(TixError::InvalidInput("input cancelled".into()))
}
Err(e) => Err(TixError::Editor(format!("failed to read tty line: {e}"))),
}
}
pub struct FieldEdits {
pub title: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub labels: Option<Vec<String>>,
pub files: Option<Vec<String>>,
pub related_issues: Option<Vec<String>>,
pub type_: Option<Option<String>>,
pub assignee: Option<Option<String>>,
pub estimate: Option<Option<String>>,
pub commit: Option<Option<String>>,
pub cycles: Option<Option<Vec<String>>>,
pub milestones: Option<Option<Vec<String>>>,
pub reviewer: Option<Option<String>>,
pub start_date: Option<Option<String>>,
pub due_date: Option<Option<String>>,
pub parent: Option<Option<u32>>,
}
pub fn resolve_editor(pref: Option<&str>) -> Result<String> {
resolve_editor_with_env(
pref,
std::env::var("EDITOR").ok(),
std::env::var("VISUAL").ok(),
)
}
pub fn resolve_agent(pref: Option<&str>) -> Result<String> {
resolve_editor_with_env(
pref,
std::env::var("TIX_AGENT")
.ok()
.or_else(|| std::env::var("AGENT").ok()),
std::env::var("VISUAL").ok(),
)
}
fn resolve_editor_with_env(
pref: Option<&str>,
editor: Option<String>,
visual: Option<String>,
) -> Result<String> {
if let Some(e) = pref
&& !e.is_empty()
{
return Ok(e.to_string());
}
if let Some(e) = editor.filter(|v| !v.is_empty()) {
return Ok(e);
}
if let Some(e) = visual.filter(|v| !v.is_empty()) {
return Ok(e);
}
let fallbacks: &[&str] = if cfg!(target_os = "windows") {
&["cmd", "nano", "vi"]
} else {
&["editor", "nano", "vi"]
};
for name in fallbacks {
if which(name) {
return Ok(name.to_string());
}
}
Err(TixError::Editor("no editor found".into()))
}
pub fn run_editor(editor: &str, path: &Path, _config: &Config) -> Result<()> {
let status = Command::new(editor)
.arg(path)
.status()
.map_err(|e| TixError::Editor(format!("failed to launch {editor}: {e}")))?;
if !status.success() {
return Err(TixError::Editor(format!(
"editor {editor} exited with {status}"
)));
}
Ok(())
}
pub const VIEWER_CANDIDATES: &[&str] = &["mdterm", "batcat", "most", "less", "more"];
pub fn resolve_viewer(pref: Option<&str>) -> Result<String> {
static AUTO_VIEWER: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
VIEWER_CANDIDATES
.iter()
.find(|name| which(name))
.map(|name| (*name).to_string())
});
resolve_viewer_with(pref, |name| AUTO_VIEWER.as_deref() == Some(name))
}
pub fn resolve_viewer_with(pref: Option<&str>, probe: impl Fn(&str) -> bool) -> Result<String> {
if let Some(name) = explicit_viewer(pref)? {
return Ok(name);
}
VIEWER_CANDIDATES
.iter()
.find(|name| probe(name))
.map(|name| (*name).to_string())
.ok_or_else(no_viewer_error)
}
fn explicit_viewer(pref: Option<&str>) -> Result<Option<String>> {
match pref {
None => Ok(None),
Some(raw) => {
let name = raw.trim();
if name.is_empty() {
return Err(TixError::Editor(
"`viewer` must not be blank; use `auto` for the default chain".into(),
));
}
if name == "auto" {
Ok(None)
} else {
Ok(Some(name.to_string()))
}
}
}
}
fn no_viewer_error() -> TixError {
TixError::Editor(format!(
"no viewer found: install one of {} (or set `viewer` in tix.toml)",
VIEWER_CANDIDATES.join(", ")
))
}
pub fn run_viewer(viewer: &str, path: &Path) -> Result<()> {
Command::new(viewer)
.arg(path)
.status()
.map_err(|e| TixError::Editor(format!("failed to launch {viewer}: {e}")))?;
Ok(())
}
pub fn merge_fields(issue: &Issue, edits: &FieldEdits) -> Issue {
let mut result = issue.clone();
if let Some(ref title) = edits.title {
result.title = title.clone();
}
if let Some(ref status) = edits.status {
result.status = crate::model::Status::new(status);
}
if let Some(ref priority) = edits.priority {
result.priority = priority.parse().unwrap_or_default();
}
if let Some(ref labels) = edits.labels {
result.labels = labels.clone();
}
if let Some(ref files) = edits.files {
result.files = files.clone();
}
if let Some(related_issues) = &edits.related_issues {
result.related_issues = related_issues.clone();
}
if let Some(t) = &edits.type_ {
result.type_ = t.clone();
}
if let Some(ref assignee) = edits.assignee {
result.assignee = assignee.clone();
}
if let Some(ref estimate) = edits.estimate {
result.estimate = estimate.clone();
}
if let Some(ref commit) = edits.commit {
result.commit = commit.clone();
}
if let Some(ref cycles) = edits.cycles {
result.cycles = cycles.clone();
}
if let Some(ref milestones) = edits.milestones {
result.milestones = milestones.clone();
}
if let Some(ref reviewer) = edits.reviewer {
result.reviewer = reviewer.clone();
}
if let Some(ref start_date) = edits.start_date {
result.start_date = start_date.clone();
}
if let Some(ref due_date) = edits.due_date {
result.due_date = due_date.clone();
}
if let Some(ref parent) = edits.parent {
result.parent = *parent;
}
result
}
fn which(name: &str) -> bool {
let output = Command::new("which")
.arg(name)
.output()
.map(|o| o.status.success());
output.unwrap_or(false)
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::{Priority, Status};
use std::fs;
use tempfile::TempDir;
#[test]
fn resolve_editor_pref_wins() {
let result = resolve_editor_with_env(Some("nvim"), None, None).unwrap();
assert_eq!(result, "nvim");
}
#[test]
fn resolve_editor_pref_empty_falls_through() {
let result = resolve_editor_with_env(Some(""), Some("vim".into()), None).unwrap();
assert_eq!(result, "vim");
}
#[test]
fn resolve_editor_editor_env() {
let result = resolve_editor_with_env(None, Some("emacs".into()), None).unwrap();
assert_eq!(result, "emacs");
}
#[test]
fn resolve_editor_visual_env() {
let result = resolve_editor_with_env(None, None, Some("helix".into())).unwrap();
assert_eq!(result, "helix");
}
#[test]
fn resolve_editor_editor_beats_visual() {
let result =
resolve_editor_with_env(None, Some("vim".into()), Some("helix".into())).unwrap();
assert_eq!(result, "vim");
}
#[test]
fn resolve_editor_empty_env_falls_through_to_fallback() {
let result = resolve_editor_with_env(None, Some("".into()), None);
match result {
Ok(name) => assert_ne!(name, ""),
Err(TixError::Editor(_)) => {}
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn run_editor_success() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("issue.md");
fs::write(
&file,
"---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody",
)
.unwrap();
let script = tmp.path().join("edit.sh");
fs::write(&script, "#!/bin/sh\necho 'done' > /dev/null\n").unwrap();
Command::new("chmod")
.arg("+x")
.arg(&script)
.status()
.unwrap();
let config = Config::default();
run_editor(script.to_str().unwrap(), &file, &config).unwrap();
}
#[test]
fn run_editor_non_zero_exits_with_error() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("issue.md");
fs::write(
&file,
"---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody",
)
.unwrap();
let script = tmp.path().join("fail.sh");
fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
Command::new("chmod")
.arg("+x")
.arg(&script)
.status()
.unwrap();
let config = Config::default();
let err = run_editor(script.to_str().unwrap(), &file, &config).unwrap_err();
assert!(matches!(err, TixError::Editor(_)));
}
#[test]
fn run_editor_does_not_validate_the_file() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("issue.md");
fs::write(&file, "not an issue at all").unwrap();
let script = tmp.path().join("noop.sh");
fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
Command::new("chmod")
.arg("+x")
.arg(&script)
.status()
.unwrap();
let config = Config::default();
run_editor(script.to_str().unwrap(), &file, &config).unwrap();
assert_eq!(fs::read_to_string(&file).unwrap(), "not an issue at all");
}
#[test]
fn resolve_viewer_explicit_override_wins() {
let result = resolve_viewer_with(Some("mdview"), |_| false).unwrap();
assert_eq!(result, "mdview");
}
#[test]
fn resolve_viewer_trims_the_override() {
let result = resolve_viewer_with(Some(" mdview "), |_| true).unwrap();
assert_eq!(result, "mdview");
}
#[test]
fn resolve_viewer_blank_override_is_an_error() {
let err = resolve_viewer_with(Some(" "), |_| true).unwrap_err();
assert!(matches!(err, TixError::Editor(_)));
}
#[test]
fn resolve_viewer_follows_the_documented_probe_order() {
let order = |found: &[&str]| {
let found = found.to_vec();
resolve_viewer_with(None, move |name| found.contains(&name)).unwrap()
};
assert_eq!(
order(&["mdterm", "batcat", "most", "less", "more"]),
"mdterm"
);
assert_eq!(order(&["batcat", "most", "less", "more"]), "batcat");
assert_eq!(order(&["most", "less", "more"]), "most");
assert_eq!(order(&["less", "more"]), "less");
assert_eq!(order(&["more"]), "more");
}
#[test]
fn resolve_viewer_auto_uses_the_probe_chain() {
let found = ["less"];
let result = resolve_viewer_with(Some("auto"), |name| found.contains(&name)).unwrap();
assert_eq!(result, "less");
}
#[test]
fn resolve_viewer_none_found_names_every_candidate() {
let err = resolve_viewer_with(None, |_| false)
.unwrap_err()
.to_string();
for name in VIEWER_CANDIDATES {
assert!(err.contains(name), "the error must name `{name}`: {err}");
}
}
#[test]
fn run_viewer_ignores_a_non_zero_exit_and_leaves_the_file_alone() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("issue.md");
fs::write(&file, "body").unwrap();
let script = tmp.path().join("quit.sh");
fs::write(&script, "#!/bin/sh\nexit 3\n").unwrap();
Command::new("chmod")
.arg("+x")
.arg(&script)
.status()
.unwrap();
run_viewer(script.to_str().unwrap(), &file).unwrap();
assert_eq!(
fs::read_to_string(&file).unwrap(),
"body",
"the viewer is read-only"
);
}
#[test]
fn run_viewer_spawn_failure_is_an_error() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("issue.md");
fs::write(&file, "body").unwrap();
let err = run_viewer("tix-no-such-viewer-1507", &file).unwrap_err();
assert!(matches!(err, TixError::Editor(_)));
}
#[test]
fn merge_fields_flag_driven() {
let issue = Issue {
projects: None,
id: None,
number: 1,
title: "Old".into(),
status: Status::new("todo"),
labels: vec![],
priority: Priority::Low,
assignee: Some("alice".into()),
estimate: Some("2".into()),
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: "original body".into(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let edits = FieldEdits {
title: Some("New".into()),
status: None,
priority: None,
labels: None,
files: None,
assignee: None,
estimate: None,
commit: None,
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
parent: None,
related_issues: None,
type_: None,
};
let merged = merge_fields(&issue, &edits);
assert_eq!(merged.title, "New");
assert_eq!(merged.status.display(), "todo");
assert_eq!(merged.body, "original body");
assert_eq!(merged.assignee.as_deref(), Some("alice"));
}
#[test]
fn merge_fields_commit_clear() {
let issue = Issue {
projects: None,
id: None,
number: 1,
title: "T".into(),
status: Status::new("done"),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: Some("abc".into()),
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let edits = FieldEdits {
title: None,
status: Some("in progress".into()),
priority: None,
labels: None,
files: None,
assignee: None,
estimate: None,
commit: Some(None),
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
parent: None,
related_issues: None,
type_: None,
};
let merged = merge_fields(&issue, &edits);
assert!(merged.commit.is_none());
assert_eq!(merged.status.display(), "in progress");
}
#[test]
fn merge_fields_body_never_altered() {
let issue = Issue {
projects: None,
id: None,
number: 1,
title: "T".into(),
status: Status::new("todo"),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: "important body".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let edits = FieldEdits {
title: Some("Changed".into()),
status: Some("done".into()),
priority: Some("high".into()),
labels: Some(vec!["bug".into()]),
files: Some(vec!["specs/s.md".into()]),
assignee: Some(Some("bob".into())),
estimate: Some(Some("3".into())),
commit: Some(Some("def".into())),
cycles: Some(Some(vec!["c1".into()])),
milestones: None,
reviewer: Some(Some("renata".into())),
start_date: None,
due_date: None,
parent: None,
related_issues: None,
type_: None,
};
let merged = merge_fields(&issue, &edits);
assert_eq!(merged.body, "important body");
}
}