tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! WP02 integration target: project identity, rename stability, membership, and
//! missing-reference retention over a **real** `tix.toml` and a **real** issue
//! tree.
//!
//! The pure rules live beside the code (`src/projects.rs`, whose inline suite is
//! filesystem-free). This file is the *contract* half: what a `tix.toml` and an
//! issue file look like before and after an operation, that the service writes
//! nothing itself, and that a deleted project leaves its references diagnosable
//! rather than dropping them.
//!
//! `tests/projects.rs` reads the real [`tix::model::Issue`] with the `projects`
//! field WP03 added, through the same real parser WP02 used, so the issue tree
//! below is genuinely parsed Markdown, not hand-built values.

use std::fs;
use std::path::Path;

use tempfile::TempDir;
use tix::config::{Config, ProjectConfiguration, ProjectId};
use tix::model::Issue;
use tix::projects::{
    IssueKey, NewProject, Patch, ProjectDiagnosticKind, ProjectEdit, associate, create_project,
    delete_project, edit_project, membership, membership_index, resolve_project_id,
    validate_references,
};

const PROJECTS_TOML: &str = "issues_dir = \"docs/issues\"\n\n\
    [projects.\"p-platform\"]\nname = \"Platform hardening\"\n\n\
    [projects.\"p-docs\"]\nname = \"Docs refresh\"\n";

/// A repository whose `tix.toml` declares two projects.
fn repo() -> TempDir {
    let dir = TempDir::new().unwrap();
    write_toml(&dir, PROJECTS_TOML);
    dir
}

/// A repository with no `[projects]` section yet.
fn repo_without_projects() -> TempDir {
    let dir = TempDir::new().unwrap();
    write_toml(&dir, "issues_dir = \"docs/issues\"\n");
    dir
}

fn write_toml(dir: &TempDir, raw: &str) {
    fs::write(dir.path().join("tix.toml"), raw).unwrap();
    fs::create_dir_all(dir.path().join("docs/issues")).unwrap();
}

fn toml_path(dir: &TempDir) -> std::path::PathBuf {
    dir.path().join("tix.toml")
}

fn issue_path(dir: &TempDir, number: u32) -> std::path::PathBuf {
    dir.path().join("docs/issues").join(format!("{number}.md"))
}

/// Write a real issue file, with a `projects` block only when ids are present.
fn write_issue(dir: &TempDir, number: u32, title: &str, ids: &[&str]) {
    let mut raw = format!("---\nnumber: {number}\ntitle: {title}\nstatus: todo\n");
    if !ids.is_empty() {
        raw.push_str("projects:\n");
        for id in ids {
            raw.push_str(&format!("- {id}\n"));
        }
    }
    raw.push_str("---\n\nBody.\n");
    fs::write(issue_path(dir, number), raw).unwrap();
}

/// Read every issue file in the repository, ordered by issue number.
fn read_issues(root: &Path) -> Vec<Issue> {
    let mut out: Vec<Issue> = fs::read_dir(root.join("docs/issues"))
        .unwrap()
        .map(|entry| entry.unwrap().path())
        .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
        .map(|path| Issue::parse(&fs::read_to_string(&path).unwrap()).unwrap())
        .collect();
    out.sort_by_key(|issue| issue.number);
    out
}

#[test]
fn identity_survives_a_rename_through_a_real_config() {
    let dir = repo();
    write_issue(&dir, 2, "Two", &["p-platform"]);
    let path = toml_path(&dir);

    let mut config = Config::load(dir.path()).unwrap();
    let before = read_issues(dir.path());
    assert_eq!(
        membership(&config, &before, "p-platform")
            .unwrap()
            .member_count,
        1
    );

    // Rename: a label change only. The id is the registry key and must survive.
    let edited = edit_project(
        &mut config,
        "p-platform",
        &ProjectEdit {
            name: Patch::Set("Core platform".into()),
            ..ProjectEdit::default()
        },
    )
    .unwrap();
    assert_eq!(edited.id.as_str(), "p-platform");
    config.save(&path).unwrap();

    let reloaded = Config::load(dir.path()).unwrap();
    let after = read_issues(dir.path());
    assert!(
        reloaded.projects.contains_key("p-platform"),
        "the id is the key and survives the rename"
    );
    // The association still resolves, to the same project, under the old id.
    let membership = membership(&reloaded, &after, "p-platform").unwrap();
    assert_eq!(membership.member_count, 1);
    assert_eq!(membership.members[0].issue.number(), 2);
    // And the new name is a convenience that resolves to the same id.
    assert_eq!(
        resolve_project_id(&reloaded, "Core platform")
            .unwrap()
            .as_str(),
        "p-platform"
    );
    assert_eq!(
        after[0].projects.as_deref(),
        Some(["p-platform".to_string()].as_slice())
    );
}

#[test]
fn associate_returns_a_change_and_writes_nothing() {
    let dir = repo();
    write_issue(&dir, 1, "One", &[]);
    let config = Config::load(dir.path()).unwrap();
    let issues = read_issues(dir.path());
    let before_toml = fs::read_to_string(toml_path(&dir)).unwrap();
    let before_issue = fs::read_to_string(issue_path(&dir, 1)).unwrap();

    let change = associate(&config, &issues, 1, "p-platform").unwrap();
    assert_eq!(change.project_ids, vec!["p-platform".to_string()]);
    let event = change.event.expect("an accepted change is audited");
    assert_eq!(event.detail, "project `p-platform` associated");

    // Storage owns the write: the service changed neither file.
    assert_eq!(fs::read_to_string(toml_path(&dir)).unwrap(), before_toml);
    assert_eq!(
        fs::read_to_string(issue_path(&dir, 1)).unwrap(),
        before_issue
    );
}

#[test]
fn multi_membership_resolves_through_a_real_issue_tree() {
    let dir = repo();
    write_issue(&dir, 5, "Five", &["p-platform", "p-docs"]);
    write_issue(&dir, 2, "Two", &["p-platform"]);
    let config = Config::load(dir.path()).unwrap();
    let issues = read_issues(dir.path());

    let platform = membership(&config, &issues, "p-platform").unwrap();
    assert_eq!(
        platform
            .members
            .iter()
            .map(|member| member.issue.number())
            .collect::<Vec<_>>(),
        vec![2, 5],
        "members are ordered by issue number"
    );
    assert_eq!(
        membership(&config, &issues, "p-docs").unwrap().member_count,
        1
    );

    // The one-pass index agrees with the per-project read.
    let buckets = membership_index(&config, &issues);
    let id = ProjectId::new("p-platform").unwrap();
    assert_eq!(
        buckets[&id],
        vec![IssueKey::new(2), IssueKey::new(5)],
        "a filter is a bucket lookup"
    );
    // An issue carrying several associations appears in each bucket.
    let docs = ProjectId::new("p-docs").unwrap();
    assert_eq!(buckets[&docs], vec![IssueKey::new(5)]);
}

#[test]
fn a_missing_reference_is_reported_end_to_end_and_retained() {
    let dir = repo();
    write_issue(&dir, 3, "Three", &["p-platform"]);
    let mut config = Config::load(dir.path()).unwrap();
    let issues = read_issues(dir.path());

    let impact = delete_project(&mut config, "p-platform", &issues).unwrap();
    assert_eq!(impact.id.as_str(), "p-platform");
    assert_eq!(
        impact
            .affected
            .iter()
            .map(|member| member.issue.number())
            .collect::<Vec<_>>(),
        vec![3]
    );
    config.save(&toml_path(&dir)).unwrap();

    let reloaded = Config::load(dir.path()).unwrap();
    let after = read_issues(dir.path());
    let missing = validate_references(&reloaded, &after);
    assert_eq!(missing.len(), 1);
    assert_eq!(missing[0].issue.number(), 3);
    assert_eq!(missing[0].project_id, "p-platform");
    assert!(missing[0].reason.contains("not in the registry"));

    // The issue file still carries the id: the reference is retained, not dropped.
    let raw = fs::read_to_string(issue_path(&dir, 3)).unwrap();
    assert!(
        raw.contains("p-platform"),
        "the reference must survive: {raw}"
    );
}

#[test]
fn successive_create_draws_after_a_delete_are_distinct_end_to_end() {
    // Honest scope: this pins that two create operations separated by a delete
    // and a save/load round-trip yield distinct ids, and that the registry keeps
    // exactly the fresh one. Deleted-id non-reuse itself is WP01's guarantee
    // (id shape plus stored-id redraw); a test here cannot observe it.
    let dir = repo_without_projects();
    let path = toml_path(&dir);

    let mut config = Config::load(dir.path()).unwrap();
    let first = create_project(
        &mut config,
        &NewProject {
            name: "One".into(),
            ..NewProject::default()
        },
    )
    .unwrap();
    assert!(!config.projects.is_empty());
    config.save(&path).unwrap();

    let mut config = Config::load(dir.path()).unwrap();
    delete_project(&mut config, first.id.as_str(), &read_issues(dir.path())).unwrap();
    config.save(&path).unwrap();
    assert!(Config::load(dir.path()).unwrap().projects.is_empty());

    let mut config = Config::load(dir.path()).unwrap();
    let second = create_project(
        &mut config,
        &NewProject {
            name: "One".into(),
            ..NewProject::default()
        },
    )
    .unwrap();
    config.save(&path).unwrap();

    assert_ne!(first.id, second.id, "two created ids must be distinct");
    let reloaded = Config::load(dir.path()).unwrap();
    assert!(reloaded.projects.contains_key(second.id.as_str()));
    assert!(!reloaded.projects.contains_key(first.id.as_str()));
}

#[test]
fn service_and_loader_agree_on_project_name_rules() {
    // Pin the service's write-path name rule against WP01's loader rule, so the
    // two copies cannot diverge silently. The service refuses a name; the loader
    // refuses the same name written straight into a `tix.toml`.
    let dir = repo_without_projects();

    // An accepted name is written by the service and re-validated by the loader.
    let mut config = Config::load(dir.path()).unwrap();
    let created = create_project(
        &mut config,
        &NewProject {
            name: "Platform hardening".into(),
            ..NewProject::default()
        },
    )
    .unwrap();
    config.save(&toml_path(&dir)).unwrap();
    assert!(
        Config::load(dir.path()).is_ok(),
        "a name the service accepted must load"
    );
    assert_eq!(
        Config::load(dir.path())
            .unwrap()
            .projects
            .get(created.id.as_str())
            .unwrap()
            .name,
        "Platform hardening"
    );

    // The loader refuses a padded name, exactly as the service does.
    let padded = "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" One \"\n";
    fs::write(toml_path(&dir), padded).unwrap();
    assert!(
        Config::load(dir.path()).is_err(),
        "the loader refuses padding"
    );
    let err = create_project(
        &mut Config::default(),
        &NewProject {
            name: " One ".into(),
            ..NewProject::default()
        },
    )
    .unwrap_err();
    assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);

    // And the loader refuses a case-folded duplicate, exactly as the service does.
    let duplicate = "issues_dir = \"docs/issues\"\n\n\
        [projects.\"p-1\"]\nname = \"Platform\"\n\n\
        [projects.\"p-2\"]\nname = \"platform\"\n";
    fs::write(toml_path(&dir), duplicate).unwrap();
    assert!(
        Config::load(dir.path()).is_err(),
        "the loader refuses a case-folded duplicate"
    );
    let mut config = Config::default();
    config.projects.insert(
        "p-1".to_string(),
        ProjectConfiguration {
            name: "Platform".into(),
            ..ProjectConfiguration::default()
        },
    );
    let err = create_project(
        &mut config,
        &NewProject {
            name: "platform".into(),
            ..NewProject::default()
        },
    )
    .unwrap_err();
    assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);
}