tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Audit: no production write bypasses the journaled primitives (#1579).
//!
//! `Store::transaction` can only roll back what the write journal saw, and the
//! journal is recorded by three primitives in [`crate::storage`] — `write_file`,
//! the renames in `move_issue_file`, and `delete`. Any other production
//! `std::fs::write` / `File::create` / `fs::rename` / `fs::remove_file` is
//! invisible to a rollback and silently breaks the all-or-nothing claim, so this
//! test fails when one appears that is not on the list below.
//!
//! ## The audit
//!
//! Every production mutation site in `src/` is one of two things:
//!
//! * a **journaled primitive** — the write calls inside [`crate::storage`] that
//!   record their own pre-image, plus the rollback that replays it; or
//! * an **exempt** site — a write that is not a mutation of the issue corpus:
//!   editor temp files, the centralized `.tix-backups/` copies, repository
//!   bootstrap files, the `tix.toml` config, a user-named `.ics` export, and the
//!   separate named-record store.
//!
//! Each entry carries the reason it is exempt. A new site — or a new count at an
//! existing one — is a test failure, which is the point: the check is here so a
//! future contributor reaching for `fs::write` is stopped and must classify, not
//! so that today's tree happens to be clean.
//!
//! ## What this cannot see
//!
//! It is a source grep, so it enforces *call sites*, not data flow: an exempt
//! helper that is later used on the corpus would pass. That is the known limit of
//! a grep audit and the reason every entry names its surface.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// The mutation calls this audit tracks.
///
/// Deliberately the four the issue names (`fs::write`, `File::create`,
/// `fs::rename`, `fs::remove_file`) plus the other file-level mutators the tree
/// actually uses (`fs::copy`, `fs::hard_link`, `fs::remove_dir_all`,
/// `OpenOptions`). Directory creation is out of scope: it makes no content
/// durable, so it cannot break a rollback.
const PATTERNS: &[&str] = &[
    "fs::write",
    "fs::rename",
    "fs::remove_file",
    "fs::remove_dir_all",
    "fs::copy",
    "File::create",
    "fs::hard_link",
    "OpenOptions",
];

/// One classified mutation site: `count` occurrences of `pattern` in `path`.
struct Site {
    /// Repository-relative source path, e.g. `src/storage.rs`.
    path: &'static str,
    /// The tracked call text.
    pattern: &'static str,
    /// How many occurrences the production code contains.
    count: usize,
    /// Why the site is journaled or exempt.
    reason: &'static str,
}

/// Every production mutation site, with its classification.
///
/// Ordering is irrelevant (the test folds into a map); the grouping is by file
/// so a reader can check one surface at a time.
#[rustfmt::skip]
const ALLOWED: &[Site] = &[
    // ---- The journaled primitives themselves --------------------------------
    Site { path: "src/storage.rs", pattern: "fs::write",         count: 1, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`), their rollback, the atomic asset copy they call, and the out-of-corpus number-claim ledger" },
    Site { path: "src/storage.rs", pattern: "fs::rename",        count: 4, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`), their rollback, the atomic asset copy they call, and the out-of-corpus number-claim ledger" },
    Site { path: "src/storage.rs", pattern: "fs::remove_file",   count: 8, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`), their rollback, the atomic asset copy they call, and the out-of-corpus number-claim ledger" },
    Site { path: "src/storage/lifecycle.rs", pattern: "fs::rename", count: 5, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`) and their rollback, after the storage module split (tix #1723)" },
    Site { path: "src/storage/lifecycle.rs", pattern: "fs::remove_file", count: 1, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`) and their rollback, after the storage module split (tix #1723)" },
    Site { path: "src/storage/lifecycle.rs", pattern: "fs::remove_dir_all", count: 1, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`) and their rollback, after the storage module split (tix #1723)" },
    Site { path: "src/storage.rs", pattern: "fs::copy",          count: 1, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`), their rollback, the atomic asset copy they call, and the out-of-corpus number-claim ledger" },
    Site { path: "src/storage.rs", pattern: "File::create",      count: 1, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`), their rollback, the atomic asset copy they call, and the out-of-corpus number-claim ledger" },
    Site { path: "src/storage.rs", pattern: "fs::hard_link",     count: 1, reason: "the journaled write primitives (`write_file`, `move_issue_file`, `delete`), their rollback, the atomic asset copy they call, and the out-of-corpus number-claim ledger" },
    Site { path: "src/storage.rs", pattern: "OpenOptions",       count: 1, reason: "the out-of-corpus number-claim ledger, which is deliberately outside the issue journal" },

    // ---- Exempt: not the issue corpus ---------------------------------------
    Site { path: "src/app/editor_input.rs", pattern: "fs::write", count: 1, reason: "the TUI's editor temp file; the edited issue is applied through `Store`" },
    Site { path: "src/app/repair.rs", pattern: "fs::write", count: 1, reason: "the TUI's retained editor copy and pending-edit temp; the corpus write is applied through `Store`" },
    Site { path: "src/app/repair.rs", pattern: "fs::remove_file", count: 5, reason: "the TUI's retained editor copy and pending-edit temp; the corpus write is applied through `Store`" },
    Site { path: "src/app/session.rs", pattern: "fs::write", count: 1, reason: "the TUI's editor-session temp file; the corpus write is applied through `Store` and the backup through `crate::backup`" },
    Site { path: "src/app/session.rs", pattern: "fs::remove_file", count: 1, reason: "the TUI's editor-session temp file; the corpus write is applied through `Store` and the backup through `crate::backup`" },
    Site { path: "src/backup.rs", pattern: "fs::write", count: 1, reason: "the centralized `.tix-backups/` copy (#1581) — deliberately out-of-corpus, never the issue file itself" },
    Site { path: "src/backup.rs", pattern: "fs::remove_file", count: 1, reason: "`tix clean --keep`'s prune of old centralized backups — deliberately out-of-corpus" },
    Site { path: "src/commands/clean.rs", pattern: "fs::remove_file", count: 1, reason: "`tix clean`'s migration sweep of legacy stray `.tixbak`/`.md.bak` files — the command's whole purpose; the centralized `.tix-backups/` prune lives in `src/backup.rs`" },
    Site { path: "src/commands/edit.rs", pattern: "fs::write", count: 1, reason: "the `$EDITOR` temp file; the edited issue is applied through `Store` afterwards" },
    Site { path: "src/commands/edit.rs", pattern: "fs::remove_file", count: 1, reason: "the `$EDITOR` temp file; the edited issue is applied through `Store` afterwards" },
    Site { path: "src/commands/init.rs", pattern: "File::create", count: 1, reason: "repository bootstrap (`tix.toml`, `.gitignore`) — not the issue corpus" },
    Site { path: "src/commands/init.rs", pattern: "fs::remove_file", count: 2, reason: "repository bootstrap (`tix.toml`, `.gitignore`) — not the issue corpus" },
    Site { path: "src/commands/init.rs", pattern: "fs::rename", count: 1, reason: "repository bootstrap (`tix.toml`, `.gitignore`) — not the issue corpus" },
    Site { path: "src/commands/mod.rs", pattern: "fs::write", count: 1, reason: "`atomic_write` for config/metadata files — not the issue corpus" },
    Site { path: "src/commands/mod.rs", pattern: "fs::rename", count: 1, reason: "`atomic_write` for config/metadata files — not the issue corpus" },
    Site { path: "src/config/io.rs", pattern: "File::create", count: 1, reason: "saving `tix.toml` — not the issue corpus" },
    Site { path: "src/config/io.rs", pattern: "fs::remove_file", count: 2, reason: "saving `tix.toml` — not the issue corpus" },
    Site { path: "src/config/io.rs", pattern: "fs::rename", count: 1, reason: "saving `tix.toml` — not the issue corpus" },
    Site { path: "src/ics.rs", pattern: "fs::write", count: 1, reason: "the user-named `.ics` output — an export artifact, not the corpus" },
    Site { path: "src/ics.rs", pattern: "fs::rename", count: 1, reason: "the user-named `.ics` output — an export artifact, not the corpus" },
    Site { path: "src/records.rs", pattern: "fs::write", count: 1, reason: "the distinct named-record store (milestones/cycles/…) — separate from the issue corpus and single-file per operation" },
    Site { path: "src/records.rs", pattern: "fs::rename", count: 1, reason: "the distinct named-record store (milestones/cycles/…) — separate from the issue corpus and single-file per operation" },
    Site { path: "src/records.rs", pattern: "fs::remove_file", count: 1, reason: "the distinct named-record store (milestones/cycles/…) — separate from the issue corpus and single-file per operation" },
    Site { path: "src/mentions/store.rs", pattern: "fs::remove_file", count: 1, reason: "removing an empty per-target mention file. It is **not** an un-journaled write: the removal records its pre-image with `storage::journal_record_write` first, and the ordinary mention write goes through `storage::atomic_write`, so a rolled-back transaction restores it" },
    Site { path: "src/sync/beads.rs", pattern: "fs::write", count: 2, reason: "one stages the `bd import` JSONL under `std::env::temp_dir()` (removed after the call), the other is `save_state`'s temp half of the atomically-renamed `.tix-beads-state.json` — the beads identity ledger, not the issue corpus" },
    Site { path: "src/sync/beads.rs", pattern: "fs::remove_file", count: 1, reason: "removes the staged `bd import` temp file after `bd` has read it — never the issue corpus" },
    Site { path: "src/sync/beads.rs", pattern: "fs::rename", count: 1, reason: "`save_state`'s rename of `.tix-beads-state.json` into place — the beads identity ledger, not the issue corpus" },
];

/// The production text of `path`: everything before the first `#[cfg(test)]`
/// module, with full-line `//` comments removed.
///
/// Tests are excluded because their fixture writes are not production writes;
/// comments are excluded because the rules are about what the code *does*, and a
/// comment explaining *why* an audit exists must not itself trip it. Only
/// whole-line comments are stripped — a `//` inside a string literal would
/// otherwise be mis-parsed, and a trailing comment never hides a call.
fn production_source(path: &Path) -> String {
    let text = std::fs::read_to_string(path)
        .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
    let mut out = String::new();
    for line in text.lines() {
        let trimmed = line.trim_start();
        if trimmed.starts_with("#[cfg(test)]") {
            break;
        }
        if trimmed.starts_with("//") {
            continue;
        }
        out.push_str(line);
        out.push('\n');
    }
    out
}

/// Collect every `.rs` file under `dir`, recursively.
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
    for entry in
        std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()))
    {
        let path = entry.expect("dir entry").path();
        if path.is_dir() {
            // `src/**/tests/` is test code even when it lives under `src/`:
            // module-split refactors moved inline `#[cfg(test)]` blocks into
            // separate files, and their fixtures are not production writes
            // (tix #1723/#1745).
            if path.file_name().is_some_and(|name| name == "tests") {
                continue;
            }
            rs_files(&path, out);
        } else if path.extension().is_some_and(|e| e == "rs") {
            out.push(path);
        }
    }
}

#[test]
fn no_unlisted_production_write() {
    let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
    let mut files = Vec::new();
    rs_files(&manifest.join("src"), &mut files);
    assert!(!files.is_empty(), "the audit found no source files to scan");

    let mut observed: BTreeMap<(String, String), usize> = BTreeMap::new();
    for file in &files {
        let rel = file
            .strip_prefix(manifest)
            .expect("source under the manifest")
            .to_string_lossy()
            .replace('\\', "/");
        let source = production_source(file);
        for pattern in PATTERNS {
            let count = source.matches(pattern).count();
            if count > 0 {
                *observed
                    .entry((rel.clone(), (*pattern).to_string()))
                    .or_default() += count;
            }
        }
    }

    let mut allowed: BTreeMap<(String, String), usize> = BTreeMap::new();
    for site in ALLOWED {
        assert!(
            !site.reason.is_empty(),
            "every site states why it is journaled or exempt: {} {}",
            site.path,
            site.pattern
        );
        assert!(site.count > 0, "a site records a real count: {}", site.path);
        *allowed
            .entry((site.path.to_string(), site.pattern.to_string()))
            .or_default() += site.count;
    }

    assert_eq!(
        observed, allowed,
        "a production write is unclassified, or a classified count changed. \
         Route the write through a journaled primitive in `src/storage.rs`, or add \
         it to ALLOWED with the surface it does not touch"
    );
}

#[test]
fn the_audit_covers_the_named_mutators() {
    // A guard on the guard: if the pattern list were emptied or narrowed, the
    // audit would pass vacuously. The four mutators the issue names must be in it.
    for required in ["fs::write", "File::create", "fs::rename", "fs::remove_file"] {
        assert!(
            PATTERNS.contains(&required),
            "the audit must track `{required}` (#1579)"
        );
    }
    assert!(
        ALLOWED.iter().any(|s| s.path == "src/storage.rs"),
        "the journaled primitives must be a classified site"
    );
}