tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! One-shot graph migrations.

use std::collections::BTreeSet;

use crate::cli::MigrateAction;
use crate::error::{Result, TixError};
use crate::id::{AliasIndex, IssueRef, generate_issue_id, is_issue_id};
use crate::model::Issue;
use crate::storage::Store;

/// Dispatch `tix migrate`.
pub fn run(store: &Store, action: &MigrateAction) -> Result<()> {
    match action {
        MigrateAction::IssueGraph { apply } => issue_graph(store, *apply),
    }
}

#[derive(Debug, Clone)]
struct MigrationDiagnostic {
    filename: String,
    field: String,
    reference: IssueRef,
    reason: String,
    remedy: String,
}

#[derive(Debug, Default)]
struct MigrationPlan {
    changes: Vec<Issue>,
    diagnostics: Vec<MigrationDiagnostic>,
    assigned_ids: usize,
    rewritten_parents: usize,
    rewritten_relationships: usize,
}

impl MigrationPlan {
    fn changed(&self) -> bool {
        !self.changes.is_empty()
    }
}

fn issue_graph(store: &Store, apply: bool) -> Result<()> {
    let plan = build_plan(store)?;
    print_plan(&plan, apply);
    if !plan.diagnostics.is_empty() {
        return Err(TixError::InvalidInput(format!(
            "issue-graph migration refused: {} unresolved or ambiguous reference(s); repair them and rerun",
            plan.diagnostics.len()
        )));
    }
    if !apply {
        return Ok(());
    }
    if !plan.changed() {
        println!("issue-graph migration: no changes");
        return Ok(());
    }

    store.transaction(|| {
        for issue in plan.changes {
            store.update(&issue.filename, &issue)?;
        }
        Ok(())
    })?;
    println!("issue-graph migration applied.");
    Ok(())
}

fn build_plan(store: &Store) -> Result<MigrationPlan> {
    let original = store.list_all()?;
    let mut issues = original.clone();
    let mut assigned_ids = 0usize;
    let mut used: BTreeSet<String> = issues
        .iter()
        .filter_map(|issue| issue.id.as_deref())
        .filter(|id| is_issue_id(id))
        .map(str::to_ascii_uppercase)
        .collect();

    for issue in &mut issues {
        let valid = issue.id.as_deref().and_then(IssueRef::id).is_some();
        if valid {
            continue;
        }
        let mut id = generate_issue_id();
        while !used.insert(id.clone()) {
            id = generate_issue_id();
        }
        issue.id = Some(id);
        assigned_ids += 1;
    }

    let aliases = AliasIndex::build(&issues);
    let mut diagnostics = aliases
        .duplicate_ids()
        .iter()
        .map(|(id, numbers)| MigrationDiagnostic {
            filename: aliases
                .duplicate_id_files()
                .get(id)
                .map(|files| files.join(", "))
                .unwrap_or_else(|| {
                    numbers
                        .iter()
                        .map(u32::to_string)
                        .collect::<Vec<_>>()
                        .join(", ")
                }),
            field: "id".to_string(),
            reference: IssueRef::id(id).unwrap_or(IssueRef::Number(0)),
            reason: format!(
                "canonical id `{id}` is claimed by issue aliases {numbers:?} in the files named above"
            ),
            remedy: "give the colliding issues distinct canonical ids by hand".to_string(),
        })
        .collect::<Vec<_>>();

    let mut rewritten_parents = 0usize;
    let mut rewritten_relationships = 0usize;
    let mut changes = Vec::new();
    for (index, issue) in issues.iter_mut().enumerate() {
        let mut changed = issue.id != original[index].id;
        if let Some(parent) = issue.parent {
            match aliases.resolve(&parent) {
                Ok(canonical) => {
                    if canonical != parent {
                        issue.parent = Some(canonical);
                        changed = true;
                        rewritten_parents += 1;
                    }
                }
                Err(error) => diagnostics.push(MigrationDiagnostic {
                    filename: issue.filename.clone(),
                    field: "parent".to_string(),
                    reference: parent,
                    reason: error_message(&error),
                    remedy: "point `parent:` at one canonical issue id".to_string(),
                }),
            }
        }

        for relationship in &mut issue.relationships {
            for (field, endpoint) in [
                ("relationships.from", &mut relationship.from),
                ("relationships.to", &mut relationship.to),
            ] {
                let original_endpoint = *endpoint;
                match aliases.resolve(&original_endpoint) {
                    Ok(canonical) => {
                        if canonical != original_endpoint {
                            *endpoint = canonical;
                            changed = true;
                            rewritten_relationships += 1;
                        }
                    }
                    Err(error) => diagnostics.push(MigrationDiagnostic {
                        filename: issue.filename.clone(),
                        field: field.to_string(),
                        reference: original_endpoint,
                        reason: error_message(&error),
                        remedy: "replace the endpoint with one canonical issue id".to_string(),
                    }),
                }
            }
        }

        if changed {
            changes.push(issue.clone());
        }
    }

    Ok(MigrationPlan {
        changes,
        diagnostics,
        assigned_ids,
        rewritten_parents,
        rewritten_relationships,
    })
}

fn error_message(error: &crate::id::AliasError) -> String {
    match error {
        crate::id::AliasError::Missing(value) => format!("`{value}` resolves to no issue"),
        crate::id::AliasError::Ambiguous { alias, candidates } => format!(
            "`{alias}` is ambiguous: {}",
            candidates
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(", ")
        ),
        crate::id::AliasError::DuplicateId { id, numbers } => {
            format!("id `{id}` is claimed by issues {numbers:?}")
        }
    }
}

fn print_plan(plan: &MigrationPlan, apply: bool) {
    println!(
        "issue-graph migration: {} file(s) change; {assigned} assigned id(s); {parents} parent endpoint(s); {relationships} relationship endpoint(s)",
        plan.changes.len(),
        assigned = plan.assigned_ids,
        parents = plan.rewritten_parents,
        relationships = plan.rewritten_relationships,
    );
    for issue in &plan.changes {
        println!(
            "  #{} {} -> id {}",
            issue.number,
            issue.filename,
            issue.id.as_deref().unwrap_or("-")
        );
    }
    for diagnostic in &plan.diagnostics {
        println!(
            "  refused {}: {} {}: {}; remedy: {}",
            diagnostic.filename,
            diagnostic.field,
            diagnostic.reference,
            diagnostic.reason,
            diagnostic.remedy
        );
    }
    if !apply {
        println!("dry run; pass --apply to write the plan");
    }
}