tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The per-field merge: what a patch does to one issue's values (WP03 T013).
//!
//! **No validation lives here.** That is the whole design. A single-issue
//! `tix edit` validates through `model::validate_issue_dates`,
//! `commands::parse_priority` and the workflow service, and WP02's plan calls the
//! same three. A merge that also checked values would be a **third** place, free to
//! drift from the other two, and the drift would show up as a bulk apply accepting
//! a value a single edit rejects — the exact second definition
//! `contracts/patch-plan.md` §5 prohibits.
//!
//! So [`merge_issue`] takes a patch that has already been judged and returns the
//! issue it would produce. It is total: it cannot fail, and the only way to reach a
//! refusal in the apply is through a core.
//!
//! ## `Unchanged` is a no-op here too, not an empty
//!
//! The second reason `PatchValue` exists. `Clear` empties a field; `Unchanged`
//! leaves whatever was there. A merge that treated `Unchanged` as "set to nothing"
//! would clear every field the user did not mention — the same failure the enum was
//! introduced to prevent, one layer down.

use crate::bulk::{IssuePatch, PatchValue};
use crate::model::{Issue, Priority};
use crate::storage::Store;

/// Whether a patch is **the field path's** to apply: no pair, no archive request.
///
/// "The field path's" and not "fields only", because a patch carrying a pair *is*
/// field-only in the sense that matters here — it has no field values. Asking the
/// question as "no pair and no archive" is what the field apply needs, and it is
/// what the pair path must ask the **negation** of; the first version of the pair
/// path asked this one directly and so refused every pure pair plan it was handed.
pub fn is_field_path(patch: &IssuePatch) -> bool {
    patch.relationship.is_none() && patch.archive.is_none()
}

/// Whether a patch is **the pair path's** to apply: a pair and nothing else.
///
/// Not the negation of [`is_field_path`], because a patch carrying a pair *and* a
/// field change belongs to neither — the patch's own structural check refuses the
/// combination, and an apply that reached one anyway must refuse it rather than
/// invent an order.
pub fn is_pair_path(patch: &IssuePatch) -> bool {
    patch.relationship.is_some() && !patch.has_any_field() && patch.archive.is_none()
}

/// The merged issue a patch would produce. Cannot fail — see the module docs.
pub fn merge_issue(issue: &Issue, patch: &IssuePatch) -> Issue {
    let mut merged = issue.clone();

    // `status` is deliberately **left alone**.
    //
    // The first draft of this function applied it, and that was a defect of exactly
    // the kind `contracts/patch-plan.md` §5 exists to prevent: it wrote the target
    // column through `Store::update` *before* the status seam ran, so the seam
    // re-read a file already in the target column, judged a **no-op**, and appended
    // no `transition_accepted`. The move happened and the decision was not
    // recorded — an issue that moved with no History, which is the one thing the
    // History section of the contract forbids.
    //
    // `tix edit --status` hit the same trap and `edit.rs` says so at the merge
    // site, in the words this comment is built on: *"The merge above deliberately
    // left `status` alone, so the seam sees the real starting column and judges a
    // real transition rather than a no-op."* A parallel implementation that does
    // not check what the original does is a second implementation of the rule, and
    // this is what it cost.
    //
    // So the status is the seam's to write, and this function's job is to leave the
    // starting column visible to it. A `Clear` is not a case at all: a column is
    // not a field a bulk operation may empty, and `Issue::status` is not optional.

    // `Priority` is not `Copy` — it carries `Other(String)` — so the value is
    // cloned. That is a real allocation on the apply path and it is not avoidable
    // without a model change, which is out of scope: one clone per bulk item is
    // not worth a `Copy` derive that would let a priority be duplicated by
    // accident everywhere else.
    match &patch.priority {
        PatchValue::Unchanged => {}
        PatchValue::Set(v) => merged.priority = v.clone(),
        PatchValue::Clear => merged.priority = Priority::default(),
    }

    match &patch.assignee {
        PatchValue::Unchanged => {}
        PatchValue::Set(v) => merged.assignee = Some(v.clone()),
        PatchValue::Clear => merged.assignee = None,
    }

    merge_text(&mut merged.estimate, &patch.estimate);
    merge_text(&mut merged.start_date, &patch.start_date);
    merge_text(&mut merged.due_date, &patch.due_date);

    match &patch.labels {
        PatchValue::Unchanged => {}
        PatchValue::Set(v) => merged.labels = v.clone(),
        PatchValue::Clear => merged.labels = Vec::new(),
    }
    merge_list(&mut merged.cycles, &patch.cycles);
    merge_list(&mut merged.milestones, &patch.milestones);

    merged
}

fn merge_text(slot: &mut Option<String>, patch: &PatchValue<String>) {
    match patch {
        PatchValue::Unchanged => {}
        PatchValue::Set(v) => *slot = Some(v.clone()),
        PatchValue::Clear => *slot = None,
    }
}

fn merge_list(slot: &mut Option<Vec<String>>, patch: &PatchValue<Vec<String>>) {
    match patch {
        PatchValue::Unchanged => {}
        PatchValue::Set(v) => *slot = Some(v.clone()),
        PatchValue::Clear => *slot = None,
    }
}

/// Whether applying this patch would move the issue's file to another segment.
///
/// The **destination** is computed by the same `derive_segment` a create uses, so
/// a preview line, this predicate and the file that lands all agree. Asking the
/// question here — rather than comparing assignee strings — is what makes
/// "re-setting the assignee the issue already has" a no-op rather than a pointless
/// `rename` of a file onto itself.
pub fn moves_segment(issue: &Issue, patch: &IssuePatch) -> bool {
    let PatchValue::Set(new) = &patch.assignee else {
        return false;
    };
    let target = crate::assignee::parse_assignee(new);
    // An unparseable assignee is refused by the plan's validator, so `None` here
    // means "leave the file where it is" rather than "move to `unassigned`" — a
    // refused patch must not also move the file.
    let Some(target) = target else {
        return false;
    };
    let from = crate::assignee::derive_segment(issue.assignee.as_deref());
    let to = crate::assignee::derive_segment(Some(&target));
    from != to
}

/// The destination path an apply would write to, for a caller that wants to report
/// it before applying.
///
/// Two parameters rather than one `&Store`, because the answer is a pure function
/// of the issues directory and the assignee — and a preview that needed a store to
/// compute a path would be a preview that could be wrong for want of a filesystem.
pub fn destination_path(store: &Store, issue: &Issue, patch: &IssuePatch) -> Option<String> {
    if !moves_segment(issue, patch) {
        return None;
    }
    let PatchValue::Set(new) = &patch.assignee else {
        return None;
    };
    let target = crate::assignee::parse_assignee(new)?;
    Some(
        store
            .issues_dir
            .join(crate::assignee::derive_segment(Some(&target)))
            .display()
            .to_string(),
    )
}

/// What a merge did to one field, for a caller that reports per-field results.
///
/// A thin view over the merged issue rather than a second comparison: it asks
/// "does this field differ from before", which is the same question `merge_issue`
/// answered, so the two cannot disagree about what changed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldOutcome {
    pub field: &'static str,
    pub from: String,
    pub to: String,
}

impl FieldOutcome {
    pub fn changed(&self) -> bool {
        self.from != self.to
    }
}

/// The per-field outcomes of a merge, in the contract's field order.
pub fn field_outcomes(issue: &Issue, patch: &IssuePatch) -> Vec<FieldOutcome> {
    let merged = merge_issue(issue, patch);
    let out = |field: &'static str, from: String, to: String| FieldOutcome { field, from, to };
    vec![
        // The status row is the **seam's** change, not the merge's — the merge
        // leaves `status` alone (see the note in `merge_issue`), so reporting
        // `merged.status` here would show "no change" for a patch that definitely
        // moves the issue. The row is derived from the patch, which is the same
        // thing the apply acts on.
        out(
            "status",
            issue.status.display().to_string(),
            match &patch.status {
                PatchValue::Set(target) => target.clone(),
                _ => issue.status.display().to_string(),
            },
        ),
        out(
            "priority",
            render_priority(&issue.priority),
            render_priority(&merged.priority),
        ),
        out(
            "assignee",
            issue.assignee.clone().unwrap_or_default(),
            merged.assignee.clone().unwrap_or_default(),
        ),
        out("labels", issue.labels.join(", "), merged.labels.join(", ")),
        out(
            "estimate",
            issue.estimate.clone().unwrap_or_default(),
            merged.estimate.clone().unwrap_or_default(),
        ),
        out(
            "start-date",
            issue.start_date.clone().unwrap_or_default(),
            merged.start_date.clone().unwrap_or_default(),
        ),
        out(
            "due-date",
            issue.due_date.clone().unwrap_or_default(),
            merged.due_date.clone().unwrap_or_default(),
        ),
        out(
            "cycles",
            issue.cycles.clone().unwrap_or_default().join(", "),
            merged.cycles.clone().unwrap_or_default().join(", "),
        ),
        out(
            "milestones",
            issue.milestones.clone().unwrap_or_default().join(", "),
            merged.milestones.clone().unwrap_or_default().join(", "),
        ),
    ]
}

/// `Priority` renders as `Other(s)`, so an unset priority would render as the empty
/// string only by accident. `is_undefined` is the model's own answer.
fn render_priority(p: &Priority) -> String {
    if p.is_undefined() {
        String::new()
    } else {
        p.to_string()
    }
}