tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use std::io::IsTerminal;

use crate::assignee::{AuthorIdentity, git_identity, resolve_me};
use crate::commands::parse_priority;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::{Issue, Priority, Status, today};
use crate::storage::Store;

/// `tix create`: write a new issue file. When `--title` is omitted on a
/// TTY the user is prompted interactively; on a pipe the absence is a hard
/// usage error (exit 1, never blocks on stdin).
#[allow(clippy::too_many_arguments)]
pub fn run(
    config: &Config,
    store: &Store,
    title: Option<&str>,
    body: Option<&str>,
    status: Option<&str>,
    priority: Option<&str>,
    assignee: Option<&str>,
    labels: Option<&str>,
    estimate: Option<&str>,
) -> Result<()> {
    let title = match title {
        Some(t) => {
            let trimmed = t.trim().to_string();
            if trimmed.is_empty() {
                return Err(TixError::InvalidInput("--title must not be empty".into()));
            }
            trimmed
        }
        None => {
            if !std::io::stdin().is_terminal() {
                return Err(TixError::InvalidInput(
                    "title is required (stdin is not a TTY); pass --title".into(),
                ));
            }
            eprint!("Title: ");
            let mut line = String::new();
            std::io::stdin()
                .read_line(&mut line)
                .map_err(TixError::Io)?;
            let t = line.trim().to_string();
            if t.is_empty() {
                return Err(TixError::InvalidInput("title must not be empty".into()));
            }
            t
        }
    };
    let status = match status {
        None => None,
        Some(s) if s.trim().is_empty() => {
            return Err(TixError::InvalidInput("--status must not be empty".into()));
        }
        Some(s) => Some(Status::new(s.to_string())),
    };
    let priority = match priority {
        Some(p) => Some(parse_priority(p)?),
        None => None,
    };
    let issue = create(
        config, store, &title, body, status, priority, assignee, labels, estimate,
    )?;
    println!("Created {}", issue.filename);
    Ok(())
}

/// The create core (WP02): build and persist a new issue from explicit
/// arguments, returning the created [`Issue`] (its `filename` is the
/// relative path, already set by [`Store::create`]).
///
/// This is what MCP's `create_issue` tool (WP04) calls with normalized
/// arguments, so defaults and validation live here once and the CLI and the
/// protocol share them (NFR-005). Side-effect policy: the interactive
/// `--title` prompt stays in [`run`] (C-005); this core never reads
/// `stdin`/writes `stdout` and only touches the store. A trimmed-but-empty
/// title is rejected here exactly as the CLI rejects it.
#[allow(clippy::too_many_arguments)]
pub fn create(
    config: &Config,
    store: &Store,
    title: &str,
    body: Option<&str>,
    status: Option<Status>,
    priority: Option<Priority>,
    assignee: Option<&str>,
    labels: Option<&str>,
    estimate: Option<&str>,
) -> Result<Issue> {
    let title = title.trim().to_string();
    if title.is_empty() {
        return Err(TixError::InvalidInput("--title must not be empty".into()));
    }
    let status = match status {
        None => Status::new(
            config
                .default_config()
                .columns
                .first()
                .cloned()
                .unwrap_or_else(|| "todo".to_string()),
        ),
        Some(s) if s.0.trim().is_empty() => {
            return Err(TixError::InvalidInput("--status must not be empty".into()));
        }
        Some(s) => s,
    };
    let priority = priority.unwrap_or_default();
    let assignee = resolve_assignee(assignee, git_identity().as_ref());
    let labels: Vec<String> = match labels {
        None => vec![],
        Some(l) => l
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect(),
    };
    let mut issue = Issue {
        number: 0,
        title,
        status,
        labels,
        priority,
        assignee,
        estimate: estimate.map(String::from),
        commit: None,
        gitlab_id: None,
        linear_id: None,
        mission: None,
        task: None,
        created: today(),
        updated: today(),
        filename: String::new(),
        body: String::new(),
    };
    store.create(&mut issue, body.unwrap_or(""), None)?;
    Ok(issue)
}

/// Resolve the effective assignee for a new issue (FR-028):
/// - an explicit `--assignee` value always wins (`parse_assignee` normalizes
///   `@alice` to `alice` for storage and lets full identities pass through
///   verbatim; `@me` resolves to the git identity; an empty value means
///   unassigned),
/// - otherwise the git author identity is used when one can be resolved,
/// - with neither, the issue stays unassigned (`unassigned/` segment) — a
///   missing/unconfigured git is never a hard error (spec §unassigned).
fn resolve_assignee(explicit: Option<&str>, identity: Option<&AuthorIdentity>) -> Option<String> {
    match explicit {
        Some(value) => resolve_me(value, identity),
        None => identity.map(|id| id.to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn make_store() -> (TempDir, Store) {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path().join("issues");
        fs::create_dir_all(&dir).unwrap();
        let config_path = tmp.path().join("tix.toml");
        let config = crate::config::Config {
            issues_dir: dir.clone(),
            ..crate::config::Config::default()
        };
        config.save(&config_path).unwrap();
        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
        (
            tmp,
            Store {
                issues_dir: dir,
                config,
            },
        )
    }

    #[test]
    fn explicit_assignee_wins_over_identity() {
        let identity = AuthorIdentity {
            name: "Herb".into(),
            email: Some("herb@example.com".into()),
        };
        assert_eq!(
            resolve_assignee(Some("@alice"), Some(&identity)),
            Some("alice".into())
        );
        assert_eq!(
            resolve_assignee(Some(""), Some(&identity)),
            None,
            "an explicit empty assignee means unassigned"
        );
    }

    #[test]
    fn me_assignee_resolves_to_git_identity() {
        let identity = AuthorIdentity {
            name: "Herb".into(),
            email: Some("herb@example.com".into()),
        };
        assert_eq!(
            resolve_assignee(Some("@me"), Some(&identity)),
            Some("Herb <herb@example.com>".into())
        );
        assert_eq!(
            resolve_assignee(Some("me"), Some(&identity)),
            Some("Herb <herb@example.com>".into())
        );
    }

    #[test]
    fn no_explicit_assignee_uses_full_identity_verbatim() {
        let identity = AuthorIdentity {
            name: "Herb".into(),
            email: Some("herb@example.com".into()),
        };
        assert_eq!(
            resolve_assignee(None, Some(&identity)),
            Some("Herb <herb@example.com>".into())
        );
        let name_only = AuthorIdentity {
            name: "Herb".into(),
            email: None,
        };
        assert_eq!(
            resolve_assignee(None, Some(&name_only)),
            Some("Herb".into())
        );
    }

    #[test]
    fn no_explicit_and_no_identity_means_unassigned() {
        assert_eq!(resolve_assignee(None, None), None);
    }

    #[test]
    fn create_defaults_status_priority_labels_and_assignee() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let issue = create(
            &store.config,
            &store,
            "  Hello World  ",
            None,
            None,
            None,
            Some(""),
            None,
            None,
        )
        .unwrap();
        assert_eq!(issue.title, "Hello World", "title is trimmed");
        assert_eq!(issue.status.display(), "todo", "default = first column");
        assert_eq!(issue.priority, Priority::Medium, "default priority");
        assert!(issue.labels.is_empty());
        assert_eq!(issue.assignee, None, "empty assignee → unassigned");
        assert_eq!(issue.estimate, None);
        assert!(
            issue.filename.starts_with("unassigned/"),
            "no assignee segment"
        );
        assert!(issue.filename.ends_with(".md"));
        // Persisted: number assigned from [state] counter, readable back.
        let reloaded = store
            .get(crate::storage::IssueSelector::Number(issue.number))
            .unwrap();
        assert_eq!(reloaded.title, "Hello World");
    }

    #[test]
    fn create_passes_explicit_arguments_through() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let issue = create(
            &store.config,
            &store,
            "Explicit",
            Some("the body"),
            Some(Status::new("in progress")),
            Some(Priority::High),
            Some("@alice"),
            Some("bug, docs, "),
            Some("3d"),
        )
        .unwrap();
        assert_eq!(issue.title, "Explicit");
        assert_eq!(issue.body, "the body");
        assert_eq!(issue.status.display(), "in progress");
        assert_eq!(issue.priority, Priority::High);
        assert_eq!(issue.assignee.as_deref(), Some("alice"));
        assert_eq!(issue.estimate.as_deref(), Some("3d"));
        assert_eq!(
            issue.labels,
            vec!["bug".to_string(), "docs".to_string()],
            "label list is split on commas and empty entries dropped"
        );
    }

    #[test]
    fn create_rejects_blank_title_and_blank_status() {
        let (tmp, store) = make_store();
        let _ = tmp;
        assert!(
            create(
                &store.config,
                &store,
                "   ",
                None,
                None,
                None,
                None,
                None,
                None
            )
            .is_err()
        );
        assert!(
            create(
                &store.config,
                &store,
                "T",
                None,
                Some(Status::new("")),
                None,
                None,
                None,
                None
            )
            .is_err(),
            "a caller-supplied blank status is rejected like --status ''"
        );
    }
}