tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The typed patch: what a bulk operation is being asked to change (WP01 T003–T004).
//!
//! A patch is a **whitelist**, and that is the whole point. A raw `key=value` map
//! would let `tix bulk --set priority=high --set number=99` through, and `number:`
//! is not a field a bulk operation may touch. So every value is an enum over a
//! named field, and the set of fields is closed by construction — there is no
//! syntax by which a caller can name a key that is not in the contract
//! (data-model §2, contract §1 and §7).
//!
//! ## `unchanged` is not `None`
//!
//! `PatchValue<T>` is `Unchanged | Set(T) | Clear`. An `Option` cannot
//! distinguish "the user did not mention this field" from "the user asked to empty
//! it", and those are different requests: the first must not touch the file at
//! all, the second must. Collapsing them is how a bulk `--clear-dates` ends up
//! clearing the assignee as well.

use serde::{Deserialize, Serialize};

use crate::error::{Result, TixError};

/// One field's requested change.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PatchValue<T> {
    /// The user did not name this field. **Never** mutates it.
    #[default]
    Unchanged,
    /// Set to this value.
    Set(T),
    /// Empty the field, where the field supports clearing.
    Clear,
}

impl<T> PatchValue<T> {
    /// Whether this value would change anything. The cheap form of the
    /// "already the requested state is a no-op" rule (data-model §2): a plan can
    /// skip the field without comparing it to the current value.
    pub fn is_unchanged(&self) -> bool {
        matches!(self, Self::Unchanged)
    }

    pub fn is_clear(&self) -> bool {
        matches!(self, Self::Clear)
    }

    /// The value, or `None` for both `Unchanged` and `Clear`.
    pub fn as_set(&self) -> Option<&T> {
        match self {
            Self::Set(v) => Some(v),
            _ => None,
        }
    }

    /// Map the payload, leaving the variant alone.
    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> PatchValue<U> {
        match self {
            Self::Unchanged => PatchValue::Unchanged,
            Self::Set(v) => PatchValue::Set(f(v)),
            Self::Clear => PatchValue::Clear,
        }
    }
}

impl<T> From<Option<T>> for PatchValue<T> {
    /// `None` becomes `Unchanged`, **not** `Clear`.
    ///
    /// This conversion is the one place the distinction could be lost, so it is
    /// deliberately one-way: `Option` cannot express `Clear` on its own, and a
    /// caller that means to clear has to write `PatchValue::Clear` by hand.
    fn from(value: Option<T>) -> Self {
        match value {
            Some(v) => Self::Set(v),
            None => Self::Unchanged,
        }
    }
}

/// The relationship half of a patch: one explicit pair operation.
///
/// A **pair**, not a list of endpoints, because an operation with two ends and no
/// stated order cannot be applied without one of them being invented (contract §6).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelationshipOp {
    /// `true` adds the edge, `false` removes it.
    pub link: bool,
    /// The `from` selector, as typed. Resolved at plan time (WP02 T008) so a
    /// missing or self-referential pair is a **diagnostic**, not a write.
    pub from: String,
    /// The `to` selector, as typed.
    pub to: String,
    /// The typed kind: `blocks`, `blocked_by`, `duplicates`, or `related`.
    ///
    /// The vocabulary is the relationship service's, not a copy — an unknown kind
    /// is rejected there with its own message, and duplicating the list here is
    /// how the two drift.
    pub kind: crate::relationships::RelationKind,
}

impl RelationshipOp {
    /// A `tix link`-shaped operation.
    pub fn link(
        from: impl Into<String>,
        to: impl Into<String>,
        kind: crate::relationships::RelationKind,
    ) -> Self {
        Self {
            link: true,
            from: from.into(),
            to: to.into(),
            kind,
        }
    }

    /// A `tix unlink`-shaped operation.
    pub fn unlink(
        from: impl Into<String>,
        to: impl Into<String>,
        kind: crate::relationships::RelationKind,
    ) -> Self {
        Self {
            link: false,
            from: from.into(),
            to: to.into(),
            kind,
        }
    }

    /// The word the diagnostics use, so a message says "link" or "unlink" rather
    /// than "a relationship operation".
    pub fn verb(&self) -> &'static str {
        if self.link { "link" } else { "unlink" }
    }
}

/// A request to delegate archiving to the #190 service.
///
/// A **request**, with no semantics of its own (contract §6): there is no
/// bulk-specific archive behaviour, and inventing one here would be the exact
/// "temporary archive behavior" the contract prohibits. WP05 builds the service
/// request; `archive::apply_archive` is where the delegation will happen.
///
/// ## A restore carries an explicit target status
///
/// `target_status` and `reason` were **absent** from the first draft, and #190's own
/// contract is what shows the gap:
///
/// > **Restore requires an explicit `target_status`.** A restore without one is
/// > rejected; there is no "restore to the previous status" behavior (C-003).
///
/// So a `false`-direction request with no target cannot be expressed at all. That is
/// the right shape: a request the service will refuse should not be constructible,
/// and a restore whose target is inferred from a stored previous status is exactly
/// the "restore to the previous status" behaviour C-003 rules out.
///
/// The fields are `Option` rather than a required-value-per-variant because a
/// request is also what a **preview** carries, and a preview must be able to say
/// "this restore is missing its target" rather than being unable to exist.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchiveRequest {
    /// `true` archives, `false` restores.
    pub archive: bool,
    /// The column to restore into. Required when `archive` is `false`; ignored when
    /// it is `true`, because archiving does not change status (C-001 in #190).
    pub target_status: Option<String>,
    /// The user's reason, preserved verbatim for the service's record.
    pub reason: Option<String>,
}

impl ArchiveRequest {
    /// An archive request, which needs no target status.
    pub fn archive() -> Self {
        Self {
            archive: true,
            target_status: None,
            reason: None,
        }
    }

    /// A restore request. A blank target is stored as `None` rather than `Some("")`,
    /// so a missing target and an empty one are the same refusal — the same
    /// "absent is not a value" rule the rest of the patch follows.
    pub fn restore(target_status: impl Into<String>, reason: Option<String>) -> Self {
        let target = target_status.into();
        Self {
            archive: false,
            target_status: (!target.trim().is_empty()).then_some(target),
            reason,
        }
    }

    /// The refusal this request carries on its own, if any.
    ///
    /// In the patch rather than in `archive.rs`, because it is a property of the
    /// request and both the preview and the apply have to see it — two places
    /// deciding it is two places to disagree.
    pub fn problem(&self) -> Option<&'static str> {
        if self.archive || self.target_status.is_some() {
            None
        } else {
            Some("missing_target_status")
        }
    }
}

/// The complete set of bulk field operations.
///
/// Closed by construction: adding a field means adding it here, and the
/// `patch_fields` iterator is what the plan and the CLI both read, so neither can
/// name a field the other does not know.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssuePatch {
    /// The new column. Validated through the workflow service at apply time, like
    /// every other status write.
    pub status: PatchValue<String>,
    pub priority: PatchValue<crate::model::Priority>,
    pub assignee: PatchValue<String>,
    pub labels: PatchValue<Vec<String>>,
    pub start_date: PatchValue<String>,
    pub due_date: PatchValue<String>,
    pub estimate: PatchValue<String>,
    pub cycles: PatchValue<Vec<String>>,
    pub milestones: PatchValue<Vec<String>>,
    /// The relationship operation, if this patch names one. A patch is
    /// field-change **or** pair, not both: combining them would make the plan's
    /// "one proposed change per item" ambiguous.
    pub relationship: Option<RelationshipOp>,
    /// The archive delegation, if this patch names one. Same one-of rule.
    pub archive: Option<ArchiveRequest>,
}

/// The outcome of a patch's own structural validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchProblem {
    /// Which field or operation, as the contract spells it.
    pub field: &'static str,
    pub message: String,
}

impl IssuePatch {
    /// Whether this patch asks for nothing at all.
    ///
    /// Reads **every** field, not just the string-shaped ones. The first version
    /// consulted [`Self::fields`], which yields only the `PatchValue<String>`
    /// projection — so a patch that set only `priority` reported itself empty, and
    /// `require_wellformed` refused it with "no fields to change" while the user
    /// had named one. Caught by
    /// `tests/bulk_selection.rs::t003_a_patch_is_a_whitelist_with_no_escape_hatch`,
    /// which is the case this function's own doc comment describes.
    pub fn is_empty(&self) -> bool {
        self.changed_fields().all(|(_, changed)| !changed)
            && self.relationship.is_none()
            && self.archive.is_none()
    }

    /// Every field value, whether or not it is a `String`.
    ///
    /// The single source of "is this field set", so `is_empty` and the
    /// plan's per-field walk cannot disagree about which fields exist. The
    /// string-shaped [`Self::fields`] view is a convenience for rendering; it is
    /// **not** the definition of which fields a patch carries.
    pub fn all_field_values(&self) -> [(&'static str, bool); 9] {
        [
            ("status", !self.status.is_unchanged()),
            ("priority", !self.priority.is_unchanged()),
            ("assignee", !self.assignee.is_unchanged()),
            ("labels", !self.labels.is_unchanged()),
            ("start-date", !self.start_date.is_unchanged()),
            ("due-date", !self.due_date.is_unchanged()),
            ("estimate", !self.estimate.is_unchanged()),
            ("cycles", !self.cycles.is_unchanged()),
            ("milestones", !self.milestones.is_unchanged()),
        ]
    }

    /// The field values that would change something, as `(name, changed)`.
    pub fn changed_fields(&self) -> impl Iterator<Item = (&'static str, bool)> {
        self.all_field_values()
            .into_iter()
            .filter(|(_, changed)| *changed)
    }

    /// Whether any **field** is set, ignoring the pair and archive variants. The
    /// question `structural_problems` needs when it is deciding whether a patch
    /// mixes two kinds of operation.
    pub fn has_any_field(&self) -> bool {
        self.all_field_values().iter().any(|(_, changed)| *changed)
    }

    /// The patch's own field values, in the contract's order.
    ///
    /// The single enumeration the plan, the CLI renderer and the tests all read.
    /// A second list would be a place for the three to disagree about which
    /// fields exist — and the WP04 MCP parity bug in the previous mission was
    /// exactly a duplicated list drifting from the original.
    pub fn fields(&self) -> impl Iterator<Item = (&'static str, &PatchValue<String>)> {
        // `Priority` and the `Vec<String>` fields are rendered through `label`
        // below, so the iterator yields the string-shaped projection and the
        // typed values are read by `apply`, which has the issue in hand.
        [
            ("status", &self.status),
            ("assignee", &self.assignee),
            ("estimate", &self.estimate),
            ("start_date", &self.start_date),
            ("due_date", &self.due_date),
        ]
        .into_iter()
    }

    /// Every field name the patch can carry, in the contract's order. Used by the
    /// CLI's help and by the "you named no fields" diagnostic.
    pub fn field_names() -> &'static [&'static str] {
        &[
            "status",
            "priority",
            "assignee",
            "labels",
            "start-date",
            "due-date",
            "estimate",
            "cycles",
            "milestones",
        ]
    }

    /// Structural problems that are **not** field-value validation.
    ///
    /// Field values are validated by the same cores as single-issue operations
    /// (contract §5) — at plan time, per item, with the item's context added. What
    /// is checked here is only what can be known without an issue: that the patch
    /// is not empty, and that it is not two kinds of operation at once.
    ///
    /// A patch is one of *field change*, *relationship pair*, or *archive
    /// delegation*. Combining them is refused rather than resolved, because
    /// "what is the order" has no answer that is right for every pair of
    /// operations, and an order invented here would be a policy nobody chose.
    pub fn structural_problems(&self) -> Vec<PatchProblem> {
        let mut out = Vec::new();
        if self.is_empty() {
            out.push(PatchProblem {
                field: "patch",
                message: format!(
                    "no fields to change — name at least one of: {}",
                    Self::field_names().join(", ")
                ),
            });
            return out;
        }
        let kinds = [
            self.relationship.is_some(),
            self.archive.is_some(),
            self.has_any_field(),
        ];
        if kinds.iter().filter(|k| **k).count() > 1 {
            let named: Vec<&str> = [
                self.relationship
                    .as_ref()
                    .map(|_| "a relationship operation"),
                self.archive.as_ref().map(|_| "an archive request"),
                (!self.has_any_field())
                    .then_some("")
                    .filter(|s| s.is_empty())
                    .map(|_| "field changes"),
            ]
            .into_iter()
            .flatten()
            .collect();
            out.push(PatchProblem {
                field: "patch",
                message: format!(
                    "a patch is one of field changes, a relationship operation, or an \\
                     archive request — this one names {}",
                    named.join(" and ")
                ),
            });
        }
        out
    }

    /// Refuse the patch if it has structural problems, with the first message.
    pub fn require_wellformed(&self) -> Result<()> {
        match self.structural_problems().first() {
            Some(problem) => Err(TixError::InvalidInput(problem.message.clone())),
            None => Ok(()),
        }
    }
}

impl std::fmt::Display for PatchProblem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.field, self.message)
    }
}