tix-tracker 0.6.0

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

use crate::cli::CycleCommand;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::planning::Planner;
use crate::storage::Store;

fn root_from_store(store: &Store) -> PathBuf {
    Config::project_root(&store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
}

pub fn run(config: &Config, store: &Store, cmd: &CycleCommand) -> Result<()> {
    let mut planner = Planner::from_config(config.clone(), root_from_store(store));
    match cmd {
        CycleCommand::Create {
            name,
            description,
            start_date,
            due_date,
        } => create(
            &mut planner,
            name.as_deref(),
            description.as_deref(),
            start_date.as_deref(),
            due_date.as_deref(),
        ),
        CycleCommand::List => list(&planner, store),
        CycleCommand::Edit {
            name,
            new_name,
            description,
            start_date,
            due_date,
        } => edit(
            &mut planner,
            store,
            name,
            new_name.as_deref(),
            description.as_deref(),
            start_date.as_deref(),
            due_date.as_deref(),
        ),
        CycleCommand::Delete { name, yes } => delete(&mut planner, store, name, *yes),
    }
}

fn create(
    planner: &mut Planner,
    name: Option<&str>,
    description: Option<&str>,
    start_date: Option<&str>,
    due_date: Option<&str>,
) -> Result<()> {
    let name = planner.add_cycle(name.map(str::to_string))?;
    planner.set_cycle_fields(&name, description, start_date, due_date)?;
    let overlaps = planner.overlapping_cycles(&name);
    println!("{name}");
    // The cycle is created either way; an overlapping window is a warning, not
    // a refusal. A cycle with no start date has no window and warns about
    // nothing (tix #1509).
    if !overlaps.is_empty() {
        eprintln!(
            "warning: cycle `{name}` overlaps existing cycle(s): {}",
            overlaps.join(", ")
        );
    }
    Ok(())
}

fn list(planner: &Planner, store: &Store) -> Result<()> {
    for name in planner.cycle_names() {
        let Some(cycle) = planner.cycle(&name) else {
            continue;
        };
        let issues = store
            .list()?
            .into_iter()
            .filter(|issue| {
                issue
                    .cycles
                    .as_ref()
                    .is_some_and(|cycles| cycles.iter().any(|cycle_name| cycle_name == &name))
            })
            .count();
        let description = cycle.description.as_deref().unwrap_or("");
        let start = cycle.start_date.as_deref().unwrap_or("");
        let due = cycle.due_date.as_deref().unwrap_or("");
        println!("{name}\t{description}\t{start}\t{due}\t{issues}");
    }
    Ok(())
}

fn edit(
    planner: &mut Planner,
    store: &Store,
    name: &str,
    new_name: Option<&str>,
    description: Option<&str>,
    start_date: Option<&str>,
    due_date: Option<&str>,
) -> Result<()> {
    let final_name = if let Some(new_name) = new_name {
        let impact = planner.rename_cycle_cascade(store, name, new_name)?;
        println!(
            "renamed cycle `{name}` to `{new_name}`; updated {} issue(s)",
            impact.changed_issue_count
        );
        new_name
    } else {
        name
    };

    if description.is_some() || start_date.is_some() || due_date.is_some() {
        planner.set_cycle_fields(final_name, description, start_date, due_date)?;
    }
    Ok(())
}

fn delete(planner: &mut Planner, store: &Store, name: &str, yes: bool) -> Result<()> {
    let impact = planner.delete_impact(store, name)?;
    if !impact.referencing_issues.is_empty() {
        eprintln!(
            "cycle `{name}` is referenced by {} issue(s): {}",
            impact.referencing_issues.len(),
            impact.referencing_issues.join(", ")
        );
    }

    let mut confirmed = yes || !std::io::stdin().is_terminal();
    if !confirmed {
        eprint!("Delete cycle '{name}'? [y/N] ");
        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");
        return Ok(());
    }

    planner.delete_cycle(name)?;
    println!("deleted cycle `{name}`");
    if !impact.referencing_issues.is_empty() {
        println!("note: issue cycle references were left unchanged");
    }
    Ok(())
}