tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
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;

/// Read a single line from TTY with basic readline-style editing.
///
/// This replaces `std::io::stdin().read_line` for interactive prompts so
/// common editing shortcuts (Ctrl-A/E, arrows, delete/backspace) work.
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);
        // Either a fallback editor resolves, or a clear error is returned.
        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");
    }
}