tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! WP06 acceptance tests: the `tix recurring` CLI surface (#175; mission
//! `recurring-issues-01M3C7G7`).
//!
//! Driven through the **compiled binary only**, like `tests/cli.rs` and
//! `tests/views_cli.rs`. The rules, identity, detection, generation and lifecycle
//! have their own targets (`tests/recurrence_rule.rs`, `tests/recurring_series.rs`,
//! `tests/recurring_detection.rs`, `tests/recurring_generation.rs`,
//! `tests/recurring_lifecycle.rs`); this file is the *surface* half: that the CLI
//! routes to those services, that a refusal is inert, that detection writes
//! nothing, and that the pre-existing commands are unaffected by a file that
//! carries the new `series:` key.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use assert_cmd::Command;
use tempfile::TempDir;

fn tix() -> Command {
    Command::cargo_bin("tix").unwrap()
}

/// An initialized repository in a fresh temp dir.
fn repo() -> TempDir {
    let tmp = TempDir::new().unwrap();
    tix().current_dir(tmp.path()).arg("init").assert().success();
    tmp
}

/// Run `tix <args>` and return stdout, asserting success.
fn stdout(dir: &Path, args: &[&str]) -> String {
    let out = tix()
        .current_dir(dir)
        .args(args)
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    String::from_utf8(out).unwrap()
}

/// Run `tix <args>` expecting failure, and return stderr.
fn stderr(dir: &Path, args: &[&str]) -> String {
    let out = tix()
        .current_dir(dir)
        .args(args)
        .assert()
        .failure()
        .get_output()
        .stderr
        .clone();
    String::from_utf8(out).unwrap()
}

/// Create a series and return its stable id, taken from the `list` output so the
/// test exercises the same surface a script would.
fn create_series(dir: &Path, extra: &[&str]) -> String {
    let mut args = vec![
        "recurring",
        "create",
        "--title",
        "Nightly close",
        "--rule",
        "daily",
        "--anchor",
        "2026-01-01",
    ];
    args.extend_from_slice(extra);
    let out = stdout(dir, &args);
    assert!(out.starts_with("Created series "), "{out}");
    list_ids(dir).first().cloned().expect("one series")
}

/// Every series id the board knows, in id order.
fn list_ids(dir: &Path) -> Vec<String> {
    stdout(dir, &["recurring", "list"])
        .lines()
        .skip(1)
        .filter(|line| !line.trim().is_empty())
        .map(|line| line.split('\t').next().unwrap().to_string())
        .collect()
}

/// Every file under `<repo>/docs/issues`, by name, as raw bytes.
fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
    let root = dir.join("docs").join("issues");
    let mut out = BTreeMap::new();
    let mut stack = vec![root.clone()];
    while let Some(current) = stack.pop() {
        let Ok(entries) = fs::read_dir(&current) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.extension().is_some_and(|ext| ext == "md") {
                let name = path
                    .strip_prefix(&root)
                    .unwrap()
                    .to_string_lossy()
                    .to_string();
                out.insert(name, fs::read(&path).unwrap());
            }
        }
    }
    out
}

#[test]
fn a_series_round_trips_create_list_show_and_rename() {
    let tmp = repo();
    let dir = tmp.path();

    let id = create_series(dir, &["--labels", "ops", "--priority", "high"]);
    assert!(id.starts_with("s-"), "a generated stable id: {id}");

    let list = stdout(dir, &["recurring", "list"]);
    assert!(
        list.starts_with("id\tname\tstate\tnext_cursor\tskipped"),
        "{list}"
    );
    assert!(list.contains(&id), "{list}");
    assert!(list.contains("Nightly close"), "{list}");

    let shown = stdout(dir, &["recurring", "show", &id]);
    assert!(shown.contains(&format!("id: {id}")), "{shown}");
    assert!(shown.contains("state: active"), "{shown}");
    assert!(shown.contains("rule: daily"), "{shown}");
    assert!(shown.contains("anchor: 2026-01-01"), "{shown}");
    assert!(shown.contains("issue: #1"), "{shown}");

    // The definition's own fields are the template an occurrence inherits.
    let created = stdout(dir, &["list", "--all"]);
    assert!(created.contains("Nightly close"), "{created}");

    // A rename keeps the identity.
    let renamed = stdout(dir, &["recurring", "edit", &id, "--name", "Nightly build"]);
    assert!(
        renamed.contains(&format!("Updated series {id}")),
        "{renamed}"
    );
    assert!(renamed.contains("name: Nightly build"), "{renamed}");
    let list = stdout(dir, &["recurring", "list"]);
    assert!(list.contains(&id), "the id survives a rename: {list}");
    assert!(list.contains("Nightly build"), "{list}");
}

#[test]
fn an_invalid_rule_is_refused_before_anything_is_written() {
    let tmp = repo();
    let dir = tmp.path();
    let before = snapshot(dir);

    let refused = stderr(
        dir,
        &[
            "recurring",
            "create",
            "--title",
            "Broken",
            "--rule",
            "hourly",
            "--anchor",
            "2026-01-01",
        ],
    );
    assert!(
        refused.contains("error[E-tix-series]") && refused.contains("invalid_series_rule"),
        "{refused}"
    );

    let missing_anchor = stderr(
        dir,
        &[
            "recurring",
            "create",
            "--title",
            "No anchor",
            "--rule",
            "daily",
        ],
    );
    assert!(
        missing_anchor.contains("invalid_series_rule"),
        "the rule's own field-aware diagnostic: {missing_anchor}"
    );

    assert_eq!(
        snapshot(dir),
        before,
        "a refused rule creates no issue at all"
    );
    assert!(list_ids(dir).is_empty());
}

#[test]
fn detection_is_read_only_and_says_what_is_due() {
    let tmp = repo();
    let dir = tmp.path();
    create_series(dir, &[]);
    let before = snapshot(dir);

    let due = stdout(dir, &["recurring", "detect", "--date", "2026-01-05"]);
    assert!(
        due.starts_with("series_id\toccurrence_key\tdue_date\trule"),
        "{due}"
    );
    assert!(due.contains("@2026-01-01"), "{due}");

    // Before the anchor nothing is due, and the answer is the same twice.
    let early = stdout(dir, &["recurring", "detect", "--date", "2025-12-31"]);
    assert!(
        early.contains("No occurrences due across 1 series."),
        "{early}"
    );
    let again = stdout(dir, &["recurring", "detect", "--date", "2026-01-05"]);
    assert_eq!(due, again, "repeated detection is deterministic");

    assert_eq!(
        snapshot(dir),
        before,
        "detection must not write a single byte"
    );
}

#[test]
fn generation_is_confirmed_and_idempotent() {
    let tmp = repo();
    let dir = tmp.path();
    let id = create_series(dir, &[]);
    let before = snapshot(dir);

    let unconfirmed = stderr(dir, &["recurring", "generate", &id, "--date", "2026-01-01"]);
    assert!(unconfirmed.contains("not_confirmed"), "{unconfirmed}");
    assert_eq!(
        snapshot(dir),
        before,
        "an unconfirmed generation writes nothing"
    );

    let created = stdout(
        dir,
        &[
            "recurring",
            "generate",
            &id,
            "--date",
            "2026-01-01",
            "--yes",
        ],
    );
    assert!(
        created.starts_with("Created occurrence #2 for "),
        "{created}"
    );

    // The occurrence is a normal issue: `tix view` prints it and `tix list` sees it.
    let view = stdout(dir, &["view", "2"]);
    assert!(view.contains("key:") && view.contains("series:"), "{view}");
    let listing = stdout(dir, &["list", "--all"]);
    assert!(listing.contains("Nightly close (2026-01-01)"), "{listing}");

    let after = snapshot(dir);
    let repeated = stdout(
        dir,
        &[
            "recurring",
            "generate",
            &id,
            "--date",
            "2026-01-01",
            "--yes",
        ],
    );
    assert!(repeated.starts_with("No-op for "), "{repeated}");
    assert!(repeated.contains("already exists"), "{repeated}");
    assert_eq!(snapshot(dir), after, "a no-op writes nothing");
}

#[test]
fn lifecycle_actions_need_the_flag_and_record_their_reason() {
    let tmp = repo();
    let dir = tmp.path();
    let id = create_series(dir, &[]);

    let unconfirmed = stderr(dir, &["recurring", "pause", &id]);
    assert!(unconfirmed.contains("not_confirmed"), "{unconfirmed}");

    let paused = stdout(
        dir,
        &["recurring", "pause", &id, "--yes", "--reason", "freeze"],
    );
    assert!(paused.contains("is now paused"), "{paused}");
    assert!(stdout(dir, &["recurring", "show", &id]).contains("state: paused"));

    let again = stdout(dir, &["recurring", "pause", &id, "--yes"]);
    assert!(again.contains("no-op"), "{again}");

    let resumed = stdout(dir, &["recurring", "resume", &id, "--yes"]);
    assert!(resumed.contains("is now active"), "{resumed}");

    let skipped = stdout(
        dir,
        &[
            "recurring",
            "skip",
            &id,
            "--date",
            "2026-01-02",
            "--yes",
            "--reason",
            "release week",
        ],
    );
    assert!(skipped.contains("@2026-01-02"), "{skipped}");
    let shown = stdout(dir, &["recurring", "show", &id]);
    assert!(shown.contains("skipped: 2026-01-02"), "{shown}");
    assert!(shown.contains("next_cursor: 2026-01-02"), "{shown}");

    // Skip is recorded, so detection moves on and generation refuses the date.
    let detection = stdout(dir, &["recurring", "detect", "--date", "2026-01-05"]);
    assert!(detection.contains("cursor_already_taken"), "{detection}");
    // Generating a skipped date is a **no-op**, not a refusal: the key is
    // recorded, so the request is satisfied by doing nothing — exit 0, and the
    // line says which of the two it found.
    let skipped_again = stdout(
        dir,
        &[
            "recurring",
            "generate",
            &id,
            "--date",
            "2026-01-02",
            "--yes",
        ],
    );
    assert!(
        skipped_again.starts_with("No-op for ") && skipped_again.contains("skipped"),
        "{skipped_again}"
    );

    // End is terminal, and a keyed action without a date is refused.
    let ended = stdout(dir, &["recurring", "end", &id, "--yes"]);
    assert!(ended.contains("is now ended"), "{ended}");
    let after_end = stderr(dir, &["recurring", "pause", &id, "--yes"]);
    assert!(after_end.contains("series_ended"), "{after_end}");
    let missing_date = stderr(dir, &["recurring", "skip", &id, "--yes"]);
    assert!(
        missing_date.contains("requires") || missing_date.contains("--date"),
        "{missing_date}"
    );
}

#[test]
fn the_existing_commands_are_unaffected_by_a_series_file() {
    let tmp = repo();
    let dir = tmp.path();
    let id = create_series(dir, &[]);
    stdout(
        dir,
        &[
            "recurring",
            "generate",
            &id,
            "--date",
            "2026-01-01",
            "--yes",
        ],
    );

    // The new frontmatter key is known, so lint does not flag it as unknown and
    // the ordinary surfaces keep working.
    let lint = tix()
        .current_dir(dir)
        .arg("lint")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let lint = String::from_utf8(lint).unwrap();
    assert!(
        !lint.contains("series"),
        "`series:` is a known key, not an unknown one: {lint}"
    );

    assert!(stdout(dir, &["status"]).contains("Total"));
    assert!(stdout(dir, &["list", "--all"]).contains("Nightly close (2026-01-01)"));
    assert!(stdout(dir, &["view", "1"]).contains("series:"));
    // A series may be selected by its id or by its unique name.
    assert!(stdout(dir, &["recurring", "show", "Nightly close"]).contains(&format!("id: {id}")));
    // An unknown selector is a not-found, not a panic.
    let missing = tix()
        .current_dir(dir)
        .args(["recurring", "show", "s-nope"])
        .assert()
        .failure();
    let _ = missing;
}