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 assignee: Option<Option<String>>,
pub estimate: Option<Option<String>>,
pub commit: Option<Option<String>>,
}
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}"
)));
}
let contents = std::fs::read_to_string(path)
.map_err(|e| TixError::Editor(format!("cannot read file after edit: {e}")))?;
if Issue::parse(&contents).is_err() {
return Err(TixError::Editor("file not valid after edit".into()));
}
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(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();
}
result.bump_updated();
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 {
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!(!name.is_empty()),
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 merge_fields_flag_driven() {
let issue = Issue {
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,
links: vec![],
files: vec![],
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: "original body".into(),
};
let edits = FieldEdits {
title: Some("New".into()),
status: None,
priority: None,
labels: None,
files: None,
assignee: None,
estimate: None,
commit: 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 {
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,
links: vec![],
files: vec![],
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let edits = FieldEdits {
title: None,
status: Some("in progress".into()),
priority: None,
labels: None,
files: None,
assignee: None,
estimate: None,
commit: Some(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 {
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,
links: vec![],
files: vec![],
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: "important body".into(),
};
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())),
};
let merged = merge_fields(&issue, &edits);
assert_eq!(merged.body, "important body");
}
}