tix-tracker 0.12.0

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

use crate::commands::parse_selector;
use crate::error::{Result, TixError};
use crate::storage::Store;

/// `tix delete <issue> [--yes]` (FR-006): remove an issue file. `--yes`
/// skips the confirmation prompt; a non-TTY stdin deletes immediately.
pub fn run(store: &Store, raw: &str, yes: bool) -> Result<()> {
    let selector = parse_selector(store, raw)?;
    let issue = store.get(selector)?;

    let mut confirmed = yes || !std::io::stdin().is_terminal();
    if !confirmed {
        eprint!("Delete '{}'? [y/N] ", issue.filename);
        let mut line = String::new();
        std::io::stdin()
            .read_line(&mut line)
            .map_err(TixError::Io)?;
        confirmed = line.trim().eq_ignore_ascii_case("y");
        if !confirmed {
            eprintln!("aborted");
        }
    }

    // The impact notice, printed **before** the delete so it is on screen
    // alongside the confirmation and not scrolled away by the result line.
    //
    // Informational and non-blocking: the children keep an unresolved reference,
    // which is a diagnosed, repairable state (data-model.md ยง5) and not a reason to
    // refuse. Refusing here would be a destructive-cascade policy by another name.
    if confirmed {
        eprint!("{}", impact_notice(store, issue.number));
    }

    remove(store, raw, confirmed)?;
    if confirmed {
        println!("Deleted {}", issue.filename);
    }
    Ok(())
}

/// The delete-time hierarchy impact, or the empty string when there is none.
///
/// One `tix delete` removes **exactly one file**. The children of the deleted issue
/// are left with a reference that no longer resolves, which `tix lint` then reports
/// and `tix edit --parent` repairs. That is the whole design: no cascade, and no
/// `--cascade` flag to grow one from.
///
/// Rendered through the service's [`crate::hierarchy::affected_by_deletion`], so the
/// child list is derived once and the same derivation backs the notice, the lint
/// diagnostic and `tix parent children`.
///
/// `eprint!`, not `println!`: the notice is a warning about what just happened, and
/// a consumer redirecting stdout to a file should not find it in the data.
pub fn impact_notice(store: &Store, number: u32) -> String {
    let issues = match store.list() {
        Ok(issues) => issues,
        // A store that cannot be listed is about to be deleted from anyway. The
        // notice is informational, so it must not become a second failure.
        Err(_) => return String::new(),
    };
    let affected = crate::hierarchy::affected_by_deletion(&issues, number);
    if affected.is_empty() {
        return String::new();
    }
    let plural = if affected.len() == 1 {
        "child"
    } else {
        "children"
    };
    let mut out = format!(
        "Note: {} {plural} will keep an unresolved parent reference.\n\
         They are not deleted; `tix lint` reports the dangling reference until it is \
         repaired with `tix edit <issue> --parent <issue>`.\n",
        affected.len()
    );
    for child in &affected {
        // The child's label and the reference it is keeping. The reference is the
        // deleted issue's number โ€” the one this function was asked about โ€” and
        // **not** `child.number`: the first version printed the child's own number
        // in the `parent:` slot, so a child of `#0` was reported as
        // "keeps `parent: 1`", naming a parent it never had.
        out.push_str(&format!(
            "  {} - keeps `parent: {}`\n",
            child.display, number
        ));
    }
    out
}

/// The delete core (WP02): remove the issue file selected by `raw`.
///
/// `yes` records the caller's confirmation decision: `true` short-circuits
/// the confirmation so the issue is deleted immediately; `false` means the
/// caller did **not** confirm, so nothing is touched (mirroring the CLI's
/// "aborted" decline). The interactive prompt is CLI glue in [`run`]
/// (C-005) โ€” this core never reads `stdin` or writes `stdout`, and MCP's
/// `delete_issue` tool (WP04) calls it directly with `confirm == true`.
pub fn remove(store: &Store, raw: &str, yes: bool) -> Result<()> {
    if !yes {
        return Ok(());
    }
    let selector = parse_selector(store, raw)?;
    let issue = store.get(selector)?;
    store.delete(&issue.filename)?;
    Ok(())
}

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

    fn seed(store: &Store) -> Issue {
        create(
            &store.config,
            store,
            "Delete me",
            Some("body"),
            None,
            None,
            Some(""),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        )
        .unwrap()
    }

    #[test]
    fn remove_with_yes_deletes_the_file() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store);
        remove(&store, &seeded.number.to_string(), true).unwrap();
        assert!(
            !store.issues_dir.join(&seeded.filename).exists(),
            "confirmed removal must delete the issue"
        );
        assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
    }

    #[test]
    fn remove_without_yes_is_a_no_op() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store);
        remove(&store, &seeded.number.to_string(), false).unwrap();
        assert!(
            store.issues_dir.join(&seeded.filename).is_file(),
            "unconfirmed removal must not touch the issue"
        );
    }

    #[test]
    fn remove_selector_miss_is_not_found() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let _ = seed(&store);
        let err = remove(&store, "999", true).unwrap_err();
        assert!(matches!(err, TixError::NotFound(_)));
    }
}