tix-tracker 0.6.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The confirmed apply path for a field patch (WP03).
//!
//! Three things make this different from every other write in the codebase, and
//! each is a rule rather than a preference:
//!
//! ## An absent confirmation is refused before any filesystem operation
//!
//! Not "before the first write" — before the first **read**, before `Store` is
//! touched at all. A refusal that has already enumerated the board has already
//! leaked what the caller could see, and a caller that got the plan wrong should
//! learn that from the refusal alone. [`crate::bulk::plan::BatchPlan::confirmation`]
//! being `None` is therefore checked first, and the check is the first statement
//! in [`apply_fields`].
//!
//! ## An item is all-or-nothing, a batch is not
//!
//! `contracts/operation-outcomes.md`: a confirmation "authorizes independent item
//! processing; it is not an all-or-nothing transaction". So a failure on item 7 of
//! 200 is reported and the batch continues — and there is deliberately **no
//! rollback**, because a rollback is a second write policy the contract does not
//! describe and that would have to be correct in the presence of the failures it
//! was written to undo. There is also no silent retry: [`write_failures`] is the
//! report, and the count of items that failed is printed rather than absorbed.
//!
//! Within one item the order is the single-issue order, and the reason is
//! documented at the write site: a field write and a status move are two different
//! records, and a `--status` move re-reads the file it writes.
//!
//! ## A no-op writes nothing and appends nothing
//!
//! Not "appends nothing important" — writes **no bytes at all**, so `updated` is
//! not bumped either, matching [`Store::set_status`]. The comparison is against the
//! **final** state, computed before the first write, because comparing field by
//! field as each is applied would make a half-applied item look like a no-op after
//! its first write.

use crate::bulk::plan::{BatchPlan, ItemDiagnostic};
use crate::bulk::{IssuePatch, PatchValue};
use crate::error::{Result, TixError};
use crate::model::HistoryEventKind;
use crate::storage::Store;

/// What happened to one item.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ItemOutcome {
    /// The requested final state was written.
    Applied {
        /// The path the file is at now, which differs from the old one after a
        /// reassign.
        filename: String,
    },
    /// The requested final state was already present. Nothing was written and
    /// nothing was appended.
    ///
    /// A distinct outcome rather than a variant of `Applied`, because
    /// `contracts/operation-outcomes.md` says a retry "is explicit and reports the
    /// no-op; it is never silent" — so a caller has to be able to *count* these
    /// separately from the writes.
    NoOp,
    /// Refused at plan time. Nothing was attempted.
    Rejected(String),
    /// The write failed. Whatever succeeded before it stands; nothing is undone.
    Failed { item: ItemDiagnostic, error: String },
}

impl ItemOutcome {
    /// The one-line form a surface prints.
    pub fn as_line(&self, number: u32, title: &str) -> String {
        match self {
            Self::Applied { filename } => {
                format!("#{number} {title} -> applied ({filename})")
            }
            Self::NoOp => format!("#{number} {title} -> already in that state"),
            Self::Rejected(why) => format!("#{number} {title} -> refused: {why}"),
            Self::Failed { item, error } => {
                format!("#{number} {title} -> failed ({}): {error}", item.field)
            }
        }
    }

    pub fn is_applied(&self) -> bool {
        matches!(self, Self::Applied { .. })
    }

    /// The shared status word (`NFR-003`).
    ///
    /// **A method, not a match in each surface.** `NFR-003` requires CLI, TUI and MCP
    /// to expose equivalent per-item statuses, and three `match` blocks over this enum
    /// is three places for a fourth surface — or a rename — to disagree. The words are
    /// the ones `bulk::metadata::OUTCOME_STATUSES` lists, and a case asserts the two
    /// agree, so the vocabulary has exactly one definition.
    pub fn status(&self) -> &'static str {
        match self {
            Self::Applied { .. } => "applied",
            Self::NoOp => "no-op",
            Self::Rejected(_) => "rejected",
            Self::Failed { .. } => "failed",
        }
    }
}

/// The result of a whole apply.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApplyReport {
    pub outcomes: Vec<(u32, ItemOutcome)>,
    /// Items whose write failed, so a caller can report them without scanning.
    pub write_failures: Vec<ItemDiagnostic>,
}

impl ApplyReport {
    pub fn applied(&self) -> usize {
        self.outcomes.iter().filter(|(_, o)| o.is_applied()).count()
    }

    pub fn noop(&self) -> usize {
        self.outcomes
            .iter()
            .filter(|(_, o)| matches!(o, ItemOutcome::NoOp))
            .count()
    }

    pub fn rejected(&self) -> usize {
        self.outcomes
            .iter()
            .filter(|(_, o)| matches!(o, ItemOutcome::Rejected(_)))
            .count()
    }

    pub fn failed(&self) -> usize {
        self.write_failures.len()
    }

    /// The one-line summary, with every count named.
    ///
    /// Every number is stated even when it is zero, because a bulk apply that
    /// silently skipped 200 items would otherwise look identical to one that wrote
    /// them.
    pub fn summary(&self) -> String {
        let total = self.outcomes.len();
        let mut parts = vec![
            format!("{} of {total} applied", self.applied()),
            format!("{} already in that state", self.noop()),
            format!("{} refused", self.rejected()),
        ];
        if self.failed() > 0 {
            parts.push(format!("{} write failures", self.failed()));
        }
        parts.join("; ")
    }
}

/// Apply a confirmed plan's field patch.
///
/// T012: refuses an absent confirmation **before any filesystem operation**. T013:
/// every field goes through the same core a single-issue operation uses. T014: the
/// History event is appended only on success, and only the one the seam writes.
/// T015: per-file atomic writes, per-item failure classification, no rollback, no
/// silent retry.
pub fn apply_fields(
    store: &Store,
    plan: &BatchPlan,
    actor: &str,
    override_reason: Option<&str>,
) -> Result<ApplyReport> {
    // ---- The gate. First statement, before `store` is read at all. ----
    if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
        return Err(TixError::InvalidInput(
            "a bulk apply needs a confirmation for exactly this plan — preview it \
             first, and preview again if the selection or the patch changed"
                .to_string(),
        ));
    }
    if plan.patch.relationship.is_some() {
        return Err(TixError::InvalidInput(
            "this plan carries a relationship operation, which WP04 applies — not \
             the field path"
                .to_string(),
        ));
    }
    if plan.patch.archive.is_some() {
        return Err(TixError::InvalidInput(
            "this plan carries an archive request, which WP05 applies — not the \
             field path"
                .to_string(),
        ));
    }

    let patch = &plan.patch;
    let mut outcomes = Vec::with_capacity(plan.items.len());
    let mut write_failures = Vec::new();

    for item in &plan.items {
        // A plan-time refusal is **not retried**. Re-running the validator here
        // would be a second decision on the same inputs, and a bulk apply that
        // re-judges a plan it was handed is a bulk apply with its own policy.
        if let Some(diagnostic) = &item.diagnostic {
            outcomes.push((
                item.number,
                ItemOutcome::Rejected(diagnostic.message.clone()),
            ));
            continue;
        }

        let number = item.number;
        match apply_one(store, patch, number, actor, override_reason) {
            Ok(Outcome::NoOp) => outcomes.push((item.number, ItemOutcome::NoOp)),
            Ok(Outcome::Applied { filename }) => {
                outcomes.push((item.number, ItemOutcome::Applied { filename }))
            }
            // A refusal that only the seam can make — a terminal exit discovered
            // between preview and apply, because the board changed. Reported as a
            // failure, not silently skipped.
            Err((diagnostic, error)) => {
                write_failures.push(diagnostic.clone());
                outcomes.push((
                    number,
                    ItemOutcome::Failed {
                        item: diagnostic,
                        error: error.to_string(),
                    },
                ));
            }
        }
    }

    Ok(ApplyReport {
        outcomes,
        write_failures,
    })
}

/// One item's result, before it is classified.
enum Outcome {
    Applied { filename: String },
    NoOp,
}

/// Apply one item. The `Err` half carries the item's diagnostic, so the caller can
/// report *which* item failed and on what, rather than only what.
#[allow(clippy::type_complexity)]
fn apply_one(
    store: &Store,
    patch: &IssuePatch,
    number: u32,
    actor: &str,
    override_reason: Option<&str>,
) -> std::result::Result<Outcome, (ItemDiagnostic, TixError)> {
    let issue = store
        .get(crate::storage::IssueSelector::Number(number))
        .map_err(|e| (failed_item(number, "read", e.to_string()), e))?;
    let fail = |field: &'static str, e: TixError| (failed_item(number, field, e.to_string()), e);

    // ---- The final state, computed before the first write. ----
    let merged = crate::bulk::field_apply::merge_issue(&issue, patch);
    let changes_status = match (&patch.status, &issue.status) {
        (PatchValue::Set(target), current) => current.display() != target,
        _ => false,
    };
    let changes_fields = merged != issue;
    let moves_file = crate::bulk::field_apply::moves_segment(&issue, patch);

    // T014: the no-op test is "nothing would change", and it is made **once**, up
    // front. A per-field comparison as each field is applied would make an item
    // that changed its status and then failed look like a no-op, and would decide
    // the question after bytes had already been written.
    if !changes_status && !changes_fields && !moves_file {
        return Ok(Outcome::NoOp);
    }

    let mut filename = issue.filename.clone();

    // ---- The write, in the single-issue order. ----
    //
    // Reassign first, then the field write at the (possibly new) path, then the
    // status seam. `tix edit` does the same in the same order, and the reason is
    // in `edit.rs`: a field write and a status move are **two different records**,
    // and `set_status` re-reads the file it writes — so writing the fields first
    // means the decision is appended to an issue whose other changes are already
    // saved.
    if moves_file {
        let moved = store
            .reassign(&issue.filename, merged.assignee.as_deref())
            .map_err(|e| fail("assignee", e))?;
        filename = moved.to_string_lossy().to_string();
    }
    if changes_fields {
        let mut written = merged.clone();
        written.filename = filename.clone();
        // `updated` bumps only when the merge actually changed something, the way
        // `tix edit` does it: a reassign on its own moves the file and is not a
        // content edit, so it must not look like one.
        //
        // There is a second `written != issue` guard here, and a first draft
        // wrapped the bump in a *third* check — `if changes_fields` — which is
        // always true inside this block. It was harmless and it was also
        // **unobservable**: a revert of redundant code is a revert of nothing, so
        // the harness case written for it came back green while proving nothing.
        // That is the eighth no-op revert this programme has produced, and the
        // lesson is the same each time — a case needs a revert that changes
        // behaviour, or it is decoration. The guard is gone because it carried no
        // meaning; the case is gone because it could not be made to carry any.
        if written != issue || filename != issue.filename {
            written.bump_updated();
        }
        filename = store
            .update(&filename, &written)
            .map_err(|e| fail("write", e))?;
    }
    if changes_status {
        let target = match &patch.status {
            PatchValue::Set(t) => t.clone(),
            _ => unreachable!("changes_status is only true for a Set"),
        };
        // The seam: the transition is judged again, the completion coupling is
        // applied, and exactly one `transition_accepted` is appended. A bulk apply
        // does not re-implement any of that, and a refusal here is a *failure* of
        // this item, not a silent skip.
        let outcome = store
            .set_status(
                &filename,
                crate::storage::StatusChange::to(&target).with_override(override_reason),
            )
            .map_err(|e| fail("status", e))?;
        filename = outcome.issue.filename.clone();
        let _ = actor;
    }

    Ok(Outcome::Applied { filename })
}

fn failed_item(number: u32, field: &'static str, message: String) -> ItemDiagnostic {
    ItemDiagnostic {
        issue: number,
        field,
        message,
    }
}

/// The single History kind an apply can append, if it appends one.
///
/// A pure function of the patch, so a surface can render "this apply will append 1
/// History event" with no plan and no store — and so a test can assert the claim
/// for **every** patch shape rather than one fixture.
///
/// It is `Option` rather than a count because the answer is a vocabulary, and a
/// count would let a second kind be added without anything noticing. There is one:
/// the transition seam's. A field write goes through `Store::update` and appends
/// nothing, which is what the single-issue path does too.
pub fn history_kind_for(patch: &IssuePatch) -> Option<HistoryEventKind> {
    match patch.status {
        PatchValue::Set(_) => Some(HistoryEventKind::TransitionAccepted),
        _ => None,
    }
}

/// How many History entries an apply of this patch would append.
pub fn predicted_history_events(patch: &IssuePatch) -> usize {
    usize::from(history_kind_for(patch).is_some())
}

/// Whether this module knows how to apply the patch, and says so rather than
/// doing half of it.
///
/// WP03 owns the field path, WP04 the pair, WP05 the archive. A plan carrying a
/// pair must be **refused** here, not partially applied — which is why the check
/// lives in [`apply_fields`] and is not left to the caller. Exposed as a predicate
/// so a surface can route a plan to the right work package before it starts.
pub fn owns(patch: &IssuePatch) -> bool {
    crate::bulk::field_apply::is_field_path(patch)
}

// ---------------------------------------------------------------------------
// Re-exported so a caller writes `bulk::apply::merge_issue` rather than reaching
// into `field_apply`, which is where the merge lives.
// ---------------------------------------------------------------------------

pub use crate::bulk::field_apply::{
    FieldOutcome, destination_path, field_outcomes, merge_issue, moves_segment,
};