tix-tracker 0.10.0

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

use crate::assignee::git_identity;
use crate::board_store::BoardStore;
use crate::config::{Config, PlanningConfig, PlanningCycle, Team, starter_board};
use crate::error::{Result, TixError};
use crate::planning::Planner;

/// `tix init`: write the default `tix.toml` at `cwd`, seed the planning
/// registry, write the default board file, then create the first `Backlog`
/// milestone file. Refuses to overwrite an existing `tix.toml` (per
/// `contracts/tix-toml.md` §init).
pub fn run(cwd: &Path) -> Result<()> {
    run_with(cwd, None)
}

/// `tix init` with an explicit beads mode.
///
/// `beads` is the `--beads` value. `None` means "decide it here": when `bd` and a
/// beads workspace are both reachable **and** there is a terminal to ask on, the
/// user is offered the modes; anything else — no `bd`, no workspace, or no
/// terminal — leaves `off` without a prompt, so a non-interactive `tix init`
/// never blocks and never writes a section nobody asked for.
pub fn run_with(cwd: &Path, beads: Option<&str>) -> Result<()> {
    let path = cwd.join("tix.toml");
    if path.exists() {
        return Err(TixError::Config(format!(
            "{} already exists — refusing to overwrite (delete it first if you want to re-init)",
            path.display()
        )));
    }
    let mut config = Config::starter();
    seed_planning(&mut config);
    let mode = resolve_beads_mode(&config, beads)?;
    config.beads.mode = mode;
    // The board is written to its file store first; once `docs/boards/` exists
    // it is authoritative, so `tix.toml` must not carry a second copy.
    seed_board(cwd)?;
    write_init_config(&config, &path)?;
    seed_milestone(cwd)?;
    seed_tags(cwd, &config)?;
    seed_gitignore(cwd)?;
    println!("Created {}", path.display());
    apply_beads(cwd, &config, mode)?;
    Ok(())
}

/// Decide the beads mode `tix init` records.
///
/// Detection is read-only and runs only `bd version`/`bd where`/`bd config get`.
/// A `bd` that is missing, or a repository with no workspace, is **not** an error
/// here: it is the common case, and `tix init` says so once and keeps `off`.
fn resolve_beads_mode(config: &Config, beads: Option<&str>) -> Result<crate::config::BeadsMode> {
    use crate::config::BeadsMode;
    use std::io::IsTerminal as _;

    if let Some(key) = beads {
        return BeadsMode::from_key(key).ok_or_else(|| {
            TixError::InvalidInput(format!(
                "unknown --beads `{key}` — use off, mirror, or sync"
            ))
        });
    }
    if !std::io::stdin().is_terminal() {
        return Ok(BeadsMode::Off);
    }
    let detection = crate::sync::beads::detect(&crate::sync::ProcessRunner, config);
    if !detection.workspace {
        if detection.available {
            eprintln!("tix: {}", detection.detail);
        }
        return Ok(BeadsMode::Off);
    }
    eprintln!("tix: {}", detection.detail);
    eprint!("tix: keep this board in step with beads? [off/mirror/sync] (off) ");
    let mut line = String::new();
    std::io::stdin()
        .read_line(&mut line)
        .map_err(TixError::Io)?;
    match line.trim() {
        "" => Ok(BeadsMode::Off),
        key => BeadsMode::from_key(key).ok_or_else(|| {
            TixError::InvalidInput(format!(
                "unknown beads mode `{key}` — use off, mirror, or sync"
            ))
        }),
    }
}

/// Act on the recorded mode: nothing for `off`, a line for `mirror`, and the
/// `beads → tix` pipe for `sync`.
///
/// `sync` without a beads workspace under this root is refused rather than
/// half-installed: the hooks live in `.beads/hooks/`, and writing them somewhere
/// beads will never look would be a silent no-op.
fn apply_beads(cwd: &Path, config: &Config, mode: crate::config::BeadsMode) -> Result<()> {
    use crate::config::BeadsMode;
    match mode {
        BeadsMode::Off => Ok(()),
        BeadsMode::Mirror => {
            println!("beads: mirror recorded (read-only) — `tix beads status` reads the workspace");
            Ok(())
        }
        BeadsMode::Sync => {
            let beads_dir = cwd.join(".beads");
            if !beads_dir.is_dir() {
                return Err(TixError::Config(format!(
                    "beads sync needs a workspace here: {} does not exist — run `bd init` first",
                    beads_dir.display()
                )));
            }
            let written = crate::sync::beads::install_hooks(cwd, &beads_dir, &config.beads.binary)?;
            println!(
                "beads: sync recorded; installed {} hook(s) in {}",
                written.len(),
                beads_dir.join("hooks").display()
            );
            Ok(())
        }
    }
}

/// The `.gitignore` marker pair. The `AGENTS.md` block uses HTML comments
/// (`<!-- tix:start -->`), but `.gitignore` is a shell-comment file, so the same
/// conventions in `commands::upsert_block` are used with `#` markers, and the rest
/// of the file is never touched.
const GITIGNORE_START: &str = "# tix:start";
const GITIGNORE_END: &str = "# tix:end";

/// Ensure `.gitignore` ignores the backups `tix` writes — the centralized
/// `.tix-backups/` directory beside the issues directory (#1581), plus the
/// legacy `<name>.md.tixbak` (the old TUI editor/repair/conflict path) and
/// `<name>.md.bak` (the old import overwrite path) that an older release left
/// beside an issue — inside a marker-delimited block so a re-run replaces the
/// block in place and leaves the project's own rules alone.
fn seed_gitignore(cwd: &Path) -> Result<()> {
    let path = cwd.join(".gitignore");
    let block = format!("{GITIGNORE_START}\n.tix-backups/\n*.tixbak\n*.md.bak\n{GITIGNORE_END}");
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    let updated = crate::commands::upsert_block(&existing, &block, GITIGNORE_START, GITIGNORE_END);
    if updated == existing {
        return Ok(());
    }
    crate::commands::atomic_write(&path, &updated)?;
    println!("Updated {}", path.display());
    Ok(())
}

/// Write `docs/boards/default.md` with the starter board and a short body
/// documenting the sparse `card_order`, idempotently: a pre-existing file is
/// never overwritten. The `Default` cycle is implicit and needs no
/// `docs/cycles/` directory.
fn seed_board(cwd: &Path) -> Result<()> {
    let store = BoardStore::new(cwd);
    if store.dir().join("default.md").exists() {
        return Ok(());
    }
    store.save(
        "default",
        &starter_board(),
        Some("Only cards whose ordering differs from the default are listed in `card_order`."),
    )
}

/// Create the tag directory (tix #1530) if absent, so `docs/tags/` exists and is
/// discoverable. An empty store is a valid store — no records are written, and a
/// re-run leaves existing files alone.
fn seed_tags(cwd: &Path, config: &Config) -> Result<()> {
    let dir = cwd.join(config.tags_dir());
    if !dir.exists() {
        std::fs::create_dir_all(&dir)?;
        println!("Created {}", dir.display());
    }
    Ok(())
}

/// Write the initial `tix.toml`. Board and cycle records live in their
/// `docs/` stores, so the serialized file omits the `[kanban]` section and
/// carries only the settings that remain authoritative here.
fn write_init_config(config: &Config, path: &Path) -> Result<()> {
    let dir = match path.parent() {
        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
        _ => Path::new(".").to_path_buf(),
    };
    std::fs::create_dir_all(&dir)?;

    let mut disk = config.clone();
    disk.kanban = crate::config::KanbanSection::default();
    let mut contents = toml::to_string_pretty(&disk)
        .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
    // tix #1632: the TUI's startup tips are on by default, and the key that turns
    // them off is written explicitly so the setting is discoverable where the rest
    // of the configuration lives. The serializer omits a `true` value (so a legacy
    // file still round-trips byte-identically), which is why this is spliced in
    // rather than left to the serializer: a new repository is the one file that
    // must name it. It goes before the first table header, since a bare key after
    // one would be read as a member of that table.
    match contents.find("\n[") {
        Some(index) => contents.insert_str(index + 1, "tips_on_startup = true\n\n"),
        None => contents.push_str("\ntips_on_startup = true\n"),
    }
    contents.push_str(
        "\n# Optional TUI overrides; all other actions keep their defaults.\n# See docs/key-bindings.md for the complete reference.\n# [keybindings.board]\n# new_issue = [\"ctrl+n\"]\n",
    );
    contents.push_str(
        "\n# Optional new-issue templates for the F4 New Issue mode; selecting one\n\
         # pre-fills the form. Every field is optional; the title stays required.\n\
         # See docs/for-users/new-issue.md.\n\
         # [templates.bug]\n\
         # labels = [\"bug\"]\n\
         # body = \"## Steps to reproduce\\n\\n## Expected\\n\"\n",
    );
    contents.push_str(
        "\n# Frontmatter keys this project uses on purpose. An undeclared key is\n\
         # preserved on write but `tix lint` warns about it (usually a typo);\n\
         # declaring it here once stops the warning. Top-level names only.\n\
         # [custom_attributes]\n\
         # keys = [\"build_id\", \"reviewed_by\"]\n",
    );
    contents.push_str(
        "\n# Configurable on-disk issue layout, relative to issues_dir. Directory\n\
         # tokens: {assignee} {type} {status} {priority} {cycle} {milestone}.\n\
         # Filename tokens: {id} {date} {slug} {number} {title}. Defaults shown.\n\
         # `move_on_change` relocates a file when a templated value changes.\n\
         # Inspect with `tix layout show`; relocate with `tix layout migrate`.\n\
         # [layout]\n\
         # directory = \"{assignee}/{type}/{status}\"\n\
         # filename = \"{date}-{id}-{slug}.md\"\n\
         # move_on_change = true\n",
    );
    contents.push_str(
        "\n# Child repositories this project coordinates (tix #1529). A parent-only\n\
         # list, read one level deep: each name is how the repository appears in\n\
         # the F6 Repos mode, and each value is that child's project root (the\n\
         # directory holding its tix.toml), relative to this file. Activating one\n\
         # runs tix inside it; exiting returns here.\n\
         # [repos]\n\
         # tix-tracker = \"../tix\"\n\
         # website = \"../website\"\n\
         #\n\
         # When this repository is itself a managed child, name the parent so the\n\
         # F6 Repos mode can jump back to it.\n\
         # parent_repo = \"../parent\"\n",
    );
    contents.push_str(
        "\n# Per-type accent colors (tix #1530). Each shipped type already has a\n\
         # default color; override any of them here. A type chip renders in its\n\
         # color wherever it appears.\n\
         # [type_colors]\n\
         # bug = \"red\"\n\
         # feature = \"green\"\n\
         #\n\
         # Tag colors live in `docs/tags/`, one `*.md` per tag with a `name:` and\n\
         # an optional `color:`. See docs/key-bindings.md and the tags directory.\n",
    );

    let file_name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("tix.toml");
    let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));

    let write_result = (|| -> std::io::Result<()> {
        let mut f = std::fs::File::create(&tmp)?;
        use std::io::Write;
        f.write_all(contents.as_bytes())?;
        f.sync_all()?;
        Ok(())
    })();
    if let Err(e) = write_result {
        let _ = std::fs::remove_file(&tmp);
        return Err(TixError::Io(e));
    }

    if let Err(e) = std::fs::rename(&tmp, path) {
        let _ = std::fs::remove_file(&tmp);
        return Err(TixError::Io(e));
    }
    Ok(())
}

/// Fill the default config's planning surface: the milestones dir, the
/// auto-cycle counter at `1`, the implicit `Default` cycle (no end date, so
/// it is always current), the picker presets, and — when a git identity can
/// be read — a `default` team containing that identity (FR-002).
///
/// A user who never configures cycles keeps everything in `Default`; the
/// `docs/cycles/` directory is created only when a second cycle is added.
fn seed_planning(config: &mut Config) {
    config.milestones_dir = Some("docs/milestones".into());
    config.planning = PlanningConfig {
        default_cycle: Some(crate::cycle_store::DEFAULT_CYCLE_NAME.into()),
        default_milestone: Some("Backlog".into()),
        default_team: None,
        ..PlanningConfig::default()
    };
    config.cycles.insert(
        crate::cycle_store::DEFAULT_CYCLE_NAME.into(),
        PlanningCycle::default(),
    );
    if let Some(identity) = git_identity() {
        config.teams.insert(
            "default".into(),
            Team {
                members: vec![identity.to_string()],
            },
        );
        config.planning.default_team = Some("default".into());
    }
}

/// Create the initial `Backlog` milestone file
/// (`contracts/milestone-file-format.md`), idempotently: a pre-existing
/// file is never overwritten (`create_milestone_file` refuses, and produces
/// an [`TixError::InvalidInput`], which we absorb).
fn seed_milestone(cwd: &Path) -> Result<()> {
    let planner = Planner::from_config(Config::default(), cwd.to_path_buf());
    match planner.create_milestone_file(
        "Backlog",
        None,
        Some("Default milestone for unscheduled work"),
    ) {
        Ok(_) => Ok(()),
        Err(TixError::InvalidInput(_)) => Ok(()),
        Err(e) => Err(e),
    }
}

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

    #[test]
    fn seed_gitignore_creates_the_block_and_keeps_other_rules() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join(".gitignore");
        fs::write(&path, "/target\nnode_modules\n").unwrap();

        seed_gitignore(tmp.path()).unwrap();

        let first = fs::read_to_string(&path).unwrap();
        assert!(
            first.contains("# tix:start\n.tix-backups/\n*.tixbak\n*.md.bak\n# tix:end"),
            "the marked block is written: {first}"
        );
        assert!(
            first.contains("/target\nnode_modules"),
            "unrelated rules are kept: {first}"
        );

        // A second run is a no-op: the block already matches byte for byte.
        seed_gitignore(tmp.path()).unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), first);
    }

    #[test]
    fn seed_gitignore_replaces_a_stale_block_in_place() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join(".gitignore");
        fs::write(&path, "keep\n# tix:start\nold-rule\n# tix:end\ntail\n").unwrap();

        seed_gitignore(tmp.path()).unwrap();

        let got = fs::read_to_string(&path).unwrap();
        assert!(
            got.contains("# tix:start\n.tix-backups/\n*.tixbak\n*.md.bak\n# tix:end"),
            "the block is refreshed: {got}"
        );
        assert!(!got.contains("old-rule"), "the stale rule is gone: {got}");
        assert!(
            got.contains("keep") && got.contains("tail"),
            "text around the block survives: {got}"
        );
    }

    #[test]
    fn init_needs_no_labels_key_because_decision_pending_is_builtin() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("tix.toml");
        write_init_config(&Config::starter(), &path).unwrap();

        let got = fs::read_to_string(&path).unwrap();
        // No `labels` key: the builtin resolves without one, and writing a
        // brand-new key would make the file unreadable to a released binary whose
        // `Config` denies unknown fields.
        assert!(
            !got.lines()
                .any(|line| line.trim_start().starts_with("labels =")),
            "`tix init` writes no labels key: {got}"
        );
        assert!(
            Config::starter()
                .labels()
                .contains(&"decision-pending".to_string()),
            "the builtin label resolves without any key"
        );
    }
}