tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use std::time::{SystemTime, UNIX_EPOCH};

use crate::assignee::parse_assignee;
use crate::commands::{parse_priority, parse_selector};
use crate::config::Config;
use crate::editor::{FieldEdits, merge_fields, resolve_editor, run_editor};
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::storage::Store;

/// `tix edit <issue>`: with flags, merge them into the frontmatter via
/// `merge_fields` and rewrite the file; with no flags, open `$EDITOR` on a
/// temporary copy, validate the result and commit it back.
///
/// `--assignee` is special (FR-031): the merged field is run through
/// `store.reassign`, which *moves* the file into the new assignee segment
/// (or `unassigned/` for `--assignee ""`). The success line always prints the
/// relative path the issue lives at afterwards.
#[allow(clippy::too_many_arguments)]
pub fn run(
    config: &Config,
    store: &Store,
    raw: &str,
    title: Option<&str>,
    body: Option<&str>,
    status: Option<&str>,
    priority: Option<&str>,
    assignee: Option<&str>,
    labels: Option<&str>,
    estimate: Option<&str>,
    commit: Option<&str>,
) -> Result<()> {
    if title.is_some()
        || body.is_some()
        || status.is_some()
        || priority.is_some()
        || assignee.is_some()
        || labels.is_some()
        || estimate.is_some()
        || commit.is_some()
    {
        let issue = apply(
            config, store, raw, title, body, status, priority, assignee, labels, estimate, commit,
        )?;
        println!("Updated {}", issue.filename);
    } else {
        let selector = parse_selector(store, raw)?;
        let issue = store.get(selector)?;
        edit_with_editor(config, store, &issue)?;
    }
    Ok(())
}

/// The edit core (WP02), holding the **flag path**: apply `merge_fields`
/// to the issue selected by `raw`, rewriting the file and returning the
/// updated [`Issue`] (with `filename` set to the relative path it lives at
/// afterwards, including after a reassign move).
///
/// This is what MCP's `update_issue` tool (WP04) calls — never the
/// `$EDITOR` flow, which is CLI-only (C-005). Side-effect policy: this core
/// never reads `stdin`/writes `stdout` and never launches an editor.
/// Validation order is byte-identical to the pre-extraction flag path:
/// selector resolution first, then priority validation (Directive 024), then
/// the reassign rule and `commit` clearing interplay.
///
/// `_config` is present to match the write-path core signature family; the
/// flag path has no config-dependent rule.
#[allow(clippy::too_many_arguments)]
pub fn apply(
    _config: &Config,
    store: &Store,
    raw: &str,
    title: Option<&str>,
    body: Option<&str>,
    status: Option<&str>,
    priority: Option<&str>,
    assignee: Option<&str>,
    labels: Option<&str>,
    estimate: Option<&str>,
    commit: Option<&str>,
) -> Result<Issue> {
    let selector = parse_selector(store, raw)?;
    let issue = store.get(selector)?;

    if let Some(p) = priority {
        parse_priority(p)?;
    }
    let edits = FieldEdits {
        title: title.map(String::from),
        status: status.map(String::from),
        priority: priority.map(String::from),
        labels: labels.map(|l| {
            l.split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect()
        }),
        assignee: assignee.map(parse_assignee),
        estimate: estimate.map(|e| Some(e.to_string())),
        commit: commit.map(|c| {
            if c == "clear" {
                None
            } else {
                Some(c.to_string())
            }
        }),
    };
    let mut merged = merge_fields(&issue, &edits);
    if let Some(b) = body {
        merged.body = b.to_string();
    }
    // Reassign first when the assignee segment may have changed, then
    // write the merged content at the (possibly new) relative path.
    let mut filename: String = issue.filename.clone();
    if assignee.is_some() {
        let moved = store.reassign(&issue.filename, merged.assignee.as_deref())?;
        filename = moved.to_string_lossy().to_string();
    }
    store.update(&filename, &merged)?;
    merged.filename = filename;
    Ok(merged)
}

/// Open the configured editor on a temporary copy of the issue file, then
/// apply whatever the editor produced (validated by `run_editor`).
fn edit_with_editor(config: &Config, store: &Store, issue: &Issue) -> Result<()> {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let temp = std::env::temp_dir().join(format!("tix-edit-{}-{nanos}.md", std::process::id()));
    std::fs::write(&temp, issue.to_file_contents()).map_err(TixError::Io)?;

    let result = (|| -> Result<()> {
        let editor = resolve_editor(config.editor.as_deref())?;
        run_editor(&editor, &temp, config)?;
        let edited = std::fs::read_to_string(&temp).map_err(TixError::Io)?;
        let mut parsed = Issue::parse(&edited)?;
        parsed.bump_updated();
        store.update(&issue.filename, &parsed)?;
        Ok(())
    })();

    let _ = std::fs::remove_file(&temp);
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::create::create;
    use crate::model::Priority;
    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,
            },
        )
    }

    /// Create a real committed issue to edit (via the create core, WP02).
    fn seed(store: &Store, assignee: &str) -> Issue {
        create(
            &store.config,
            store,
            "Seed issue",
            Some("body"),
            None,
            None,
            Some(assignee),
            None,
            None,
        )
        .unwrap()
    }

    #[test]
    fn apply_updates_fields_and_returns_updated_issue() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store, "@alice");
        let updated = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            Some("Renamed"),
            Some("new body"),
            Some("in progress"),
            Some("high"),
            None,
            Some("bug,urgent"),
            Some("2"),
            Some("abc123"),
        )
        .unwrap();
        assert_eq!(updated.title, "Renamed");
        assert_eq!(updated.body, "new body");
        assert_eq!(updated.status.display(), "in progress");
        assert_eq!(updated.priority, Priority::High);
        assert_eq!(
            updated.labels,
            vec!["bug".to_string(), "urgent".to_string()]
        );
        assert_eq!(updated.estimate.as_deref(), Some("2"));
        assert_eq!(updated.commit.as_deref(), Some("abc123"));
        assert_eq!(updated.filename, seeded.filename);
        let reloaded = store
            .get(crate::storage::IssueSelector::Filename(
                updated.filename.clone(),
            ))
            .unwrap();
        assert_eq!(reloaded.title, "Renamed");
        assert_eq!(reloaded.body, "new body");
    }

    #[test]
    fn apply_reassign_moves_file_between_segments() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store, "alice");
        let updated = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            None,
            None,
            None,
            None,
            Some("@bob"),
            None,
            None,
            None,
        )
        .unwrap();
        assert!(
            updated.filename.starts_with("bob/"),
            "file must move into the new segment, got {}",
            updated.filename
        );
        assert_eq!(updated.number, seeded.number, "number is stable");
        assert!(!store.issues_dir.join(&seeded.filename).exists());
        assert!(store.issues_dir.join(&updated.filename).is_file());
    }

    #[test]
    fn apply_commit_clear_resets_commit_field() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store, "alice");
        let with_commit = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            Some("abc123"),
        )
        .unwrap();
        assert_eq!(with_commit.commit.as_deref(), Some("abc123"));
        let cleared = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            Some("clear"),
        )
        .unwrap();
        assert_eq!(cleared.commit, None);
    }

    #[test]
    fn apply_rejects_invalid_priority_with_frozen_message() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store, "alice");
        let err = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            None,
            None,
            None,
            Some("bogus"),
            None,
            None,
            None,
            None,
        )
        .unwrap_err();
        assert_eq!(
            err.to_string(),
            "invalid input: invalid priority 'bogus' — use low, medium or high"
        );
    }
}