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 std::path::PathBuf;

use crate::cli::MilestoneCommand;
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: &MilestoneCommand) -> Result<()> {
    let planner = Planner::from_config(config.clone(), root_from_store(store));
    match cmd {
        MilestoneCommand::Create {
            name,
            target_date,
            description,
        } => {
            let path = planner.create_milestone_file(
                name,
                target_date.as_deref(),
                description.as_deref(),
            )?;
            println!("{}", path.display());
        }
        MilestoneCommand::List => {
            for milestone in planner.list_milestones()? {
                let issues = store
                    .list()?
                    .into_iter()
                    .filter(|issue| {
                        issue.milestones.as_ref().is_some_and(|milestones| {
                            milestones.iter().any(|m| m == &milestone.name)
                        })
                    })
                    .count();
                println!(
                    "{}\t{}\t{}\t{}",
                    milestone.name, milestone.target_date, milestone.description, issues
                );
            }
        }
        MilestoneCommand::Edit {
            name,
            new_name,
            target_date,
            description,
        } => {
            let mut active_name = name.clone();
            if let Some(new_name) = new_name {
                let old = planner.milestone(name)?;
                let impact = planner.rename_milestone_cascade(store, &old, new_name)?;
                println!(
                    "renamed milestone `{name}` to `{new_name}`; updated {} issue(s)",
                    impact.changed_issue_count
                );
                active_name = new_name.clone();
            }
            if target_date.is_some() || description.is_some() {
                planner.update_milestone(
                    &active_name,
                    target_date.as_deref(),
                    description.as_deref(),
                )?;
            }
        }
        MilestoneCommand::Delete { name, yes } => {
            let impact = planner.milestone_delete_impact(store, name)?;
            if !impact.referencing_issues.is_empty() {
                eprintln!(
                    "milestone `{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 milestone '{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(());
            }

            let filename = planner.delete_milestone(name)?;
            println!("deleted milestone `{name}` ({filename})");
            if !impact.referencing_issues.is_empty() {
                println!("note: issue milestone references were left unchanged");
            }
        }
    }
    Ok(())
}