tix-tracker 0.6.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Archive delegation (WP05).
//!
//! #190 owns archiving. This mission owns asking for it.
//!
//! That boundary is the whole design, and it is stated three times in the
//! contracts because getting it wrong is expensive:
//!
//! - `contracts/operation-outcomes.md`: "Archive operations use the issue-190
//!   outcome contract; bulk does not define a second archive state or event."
//! - `contracts/patch-plan.md` §6: "An archive patch is a typed request to the
//!   issue-190 archive service; it has no bulk-only archive semantics."
//! - C-005 in #190's own contract: archiving "does **not** move, rename, merge, or
//!   remove the file".
//!
//! So this module does three things and refuses a fourth.
//!
//! ## What it does
//!
//! 1. Carries a **request**: which direction, which issues, and — for a restore —
//!    the **explicit target status** #190 requires. A restore without one is refused
//!    *here*, before the user confirms anything, because the service will refuse it
//!    and a preview that hides that is a preview that lies.
//! 2. Maps the service's outcomes into the shared batch outcome vector, so there is
//!    one outcome type rather than two (R7 in #190's contract says the same).
//! 3. Reports a **History prediction** of zero, and says why.
//!
//! ## What it refuses
//!
//! Applying. The `issue-archive-restore-01M3C4JF` mission is `planned` — all five of
//! its work packages — so `bulk_archive`/`bulk_restore` do not exist.
//! [`apply_archive`] therefore returns a named refusal and touches nothing. A bulk
//! path that "worked" without the service would be the second archive implementation
//! the contract prohibits, and it would be the one with no tests behind it.

use std::collections::BTreeMap;

use crate::bulk::{ArchiveRequest, IssuePatch};
use crate::error::{Result, TixError};

/// The mission that owns archiving, named in the refusal so a user can find it.
///
/// A constant rather than a string in the message, because a refusal that says
/// "not available" and a comment that says "see #190" are two facts a reader has to
/// join by hand. The message carries the name.
pub const ARCHIVE_OWNER: &str = "issue-archive-restore-01M3C4JF";

/// One issue's request to the archive service.
///
/// Issue identity is carried as **both** the number and the selector the user typed,
/// because the service takes selectors (`bulk_archive` is "issue selectors") and a
/// bulk preview has numbers. Resolving a number back to a selector would be a
/// second resolution path, and the selector is what the user asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveItemRequest {
    /// The issue number, stable across an archive and a restore (C-002 in #190).
    pub number: u32,
    /// The selector as typed, or the number's own spelling when the source was a
    /// query rather than explicit selectors.
    pub selector: String,
    /// The direction, as the service names it.
    pub operation: ArchiveOperation,
    /// The explicit target status, for a restore.
    ///
    /// #190's C-003: "Restore requires an explicit `target_status`. A restore
    /// without one is rejected; there is no 'restore to the previous status'
    /// behavior." So this is not optional on a restore, and [`ArchiveRequest`]
    /// carries it for the same reason — see its own doc comment.
    pub target_status: Option<String>,
    /// The reason the user supplied, preserved verbatim.
    ///
    /// Not interpreted here. A reason is text the service records, and a bulk side
    /// that summarised it would be authoring a second version of the record.
    pub reason: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveOperation {
    Archive,
    Restore,
}

impl ArchiveOperation {
    /// The service's own verb, so a diagnostic names `bulk_archive` or
    /// `bulk_restore` rather than "an archive operation".
    pub fn service_fn(self) -> &'static str {
        match self {
            Self::Archive => "bulk_archive",
            Self::Restore => "bulk_restore",
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Archive => "archive",
            Self::Restore => "restore",
        }
    }
}

/// The whole batch's request, in the shape #190's `bulk_archive` takes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveBatchRequest {
    pub operation: ArchiveOperation,
    /// One entry per selected issue, in the plan's order. #190's bulk rules require
    /// "exactly one outcome per selected issue", so the request is one-per-issue and
    /// the order is the plan's.
    pub items: Vec<ArchiveItemRequest>,
}

impl ArchiveBatchRequest {
    pub fn len(&self) -> usize {
        self.items.len()
    }

    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    /// The one-line summary, with the count named even when zero.
    pub fn summary(&self) -> String {
        format!(
            "{} {} request for {} issue{}",
            self.operation.service_fn(),
            self.operation.as_str(),
            self.items.len(),
            if self.items.len() == 1 { "" } else { "s" }
        )
    }
}

/// Why an archive request cannot even be built.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveProblem {
    /// A restore with no target status. #190's C-003.
    MissingTargetStatus,
    /// The selection carried an issue the service could not be asked about —
    /// unreachable through `Selection`, so this exists for a caller that builds a
    /// request by hand.
    UnknownIssue,
}

impl ArchiveProblem {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::MissingTargetStatus => "missing_target_status",
            Self::UnknownIssue => "unknown_issue",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveDiagnostic {
    pub problem: ArchiveProblem,
    /// The issue, when the problem belongs to one.
    pub issue: Option<u32>,
    pub message: String,
}

impl std::fmt::Display for ArchiveDiagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.issue {
            Some(issue) => write!(f, "#{}: {}: {}", issue, self.problem.as_str(), self.message),
            None => write!(f, "{}: {}", self.problem.as_str(), self.message),
        }
    }
}

/// Build the service request from a confirmed plan's patch.
///
/// **Pure**: reads the patch and the plan's items, writes nothing, and needs no
/// store — so a preview can be rendered with no filesystem at all, which is the
/// same reason [`crate::bulk::plan::build_plan`] takes an `issues_dir` rather than a
/// `Store`.
///
/// The refusal is returned rather than raised, because a plan must be *reportable*:
/// `tix bulk archive --preview` on a restore with no target status should print the
/// diagnostic and exit non-zero, not panic, and the outcome vector is where that
/// diagnostic belongs.
pub fn adapt(
    patch: &IssuePatch,
    numbers: &[u32],
    selectors: &BTreeMap<u32, String>,
) -> std::result::Result<ArchiveBatchRequest, ArchiveDiagnostic> {
    let request = patch.archive.clone().ok_or_else(|| ArchiveDiagnostic {
        problem: ArchiveProblem::UnknownIssue,
        issue: None,
        message: "this patch carries no archive request".to_string(),
    })?;
    Ok(build(request, numbers, selectors))
}

/// The build half of [`adapt`], taking the direction and target directly.
///
/// Separate so the refusal lives in one place and the construction is total: a
/// caller that has already validated can build without re-deciding, and a caller
/// that has not can go through [`adapt`].
fn build(
    request: ArchiveRequest,
    numbers: &[u32],
    selectors: &BTreeMap<u32, String>,
) -> ArchiveBatchRequest {
    let operation = if request.archive {
        ArchiveOperation::Archive
    } else {
        ArchiveOperation::Restore
    };
    // A restore with no target status is refused **before** the items are built, so
    // a plan built from such a patch has no items to report a per-item problem on
    // — the problem is with the patch, not with an issue.
    let target_status = if request.archive {
        None
    } else {
        request.target_status.clone()
    };
    let items = numbers
        .iter()
        .map(|number| ArchiveItemRequest {
            number: *number,
            selector: selectors
                .get(number)
                .cloned()
                .unwrap_or_else(|| number.to_string()),
            operation,
            target_status: target_status.clone(),
            reason: request.reason.clone(),
        })
        .collect();
    ArchiveBatchRequest { operation, items }
}

/// The refusal a request carries, if any. Called by the preview and by the apply, so
/// the two cannot disagree about whether a request is well-formed.
pub fn refusal(request: &ArchiveBatchRequest) -> Option<ArchiveDiagnostic> {
    if request.operation != ArchiveOperation::Restore {
        return None;
    }
    if request
        .items
        .iter()
        .all(|item| item.target_status.is_some())
    {
        return None;
    }
    Some(ArchiveDiagnostic {
        problem: ArchiveProblem::MissingTargetStatus,
        issue: None,
        message: "restoring needs an explicit target status — there is no \\
                  \"restore to the previous status\", so name the column to restore \\
                  into"
            .to_string(),
    })
}

/// How many History entries applying this patch would append.
///
/// **Zero**, and the reason is checkable rather than asserted: `HistoryEventKind` has
/// no archive variant. Its eight spellings are relationship-added, -removed,
/// -retargeted, blocked-move-acknowledged, parent-set, -changed, -cleared and
/// transition-accepted — none of which is an archive, and #190's contract says the
/// event comes from the archive service's own `ArchiveRecord` plus "one History
/// entry" whose kind #190 will add when it lands.
///
/// So a bulk archive predicts **no** History event, exactly as a bulk field patch
/// does after WP03's correction, and for the same reason: the single-issue path is
/// the reference and it has no archive operation to be parallel to yet. The
/// prediction becomes one the day #190 adds the variant, and the case that pins this
/// is written against the model's **own** spellings so it will notice.
pub fn predicted_history_events(_patch: &IssuePatch) -> usize {
    0
}

/// The History kind an archive apply would append, if it appended one.
///
/// `None`, and `None` is the honest answer rather than a placeholder string: there is
/// no `HistoryEventKind` to name. A `Some("archived")` here would be a bulk-only
/// History vocabulary — the same defect WP03 found and removed for `field_updated`.
pub fn history_kind_for(_patch: &IssuePatch) -> Option<&'static str> {
    None
}

/// Apply a confirmed archive plan.
///
/// **Refuses.** The `issue-archive-restore-01M3C4JF` mission is `planned`, so
/// `bulk_archive`/`bulk_restore` do not exist, and this function is the place that
/// says so rather than the place that works around it.
///
/// The refusal is a named `Err`, not an empty outcome vector, because an empty
/// vector reads as "nothing to do" and a user who typed `tix bulk archive --apply`
/// deserves to be told the feature is not there. The message names the owning
/// mission, so the refusal is a route rather than a wall.
pub fn apply_archive(plan: &crate::bulk::BatchPlan) -> Result<crate::bulk::plan::ItemDiagnostic> {
    // The confirmation gate first, for the same reason WP03's and WP04's are: the
    // gate belongs to the apply, and a caller that reached the write path without
    // one should learn that before anything else.
    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 changed"
                .to_string(),
        ));
    }
    let request = plan
        .patch
        .archive
        .clone()
        .ok_or_else(|| TixError::InvalidInput("this plan carries no archive request".into()))?;

    // A malformed request is refused **before** the missing service, because it would
    // be refused anyway and the user can fix it now.
    if !request.archive && request.target_status.is_none() {
        return Err(TixError::InvalidInput(format!(
            "{}: restoring needs an explicit target status — there is no \\
             \"restore to the previous status\"",
            ArchiveProblem::MissingTargetStatus.as_str()
        )));
    }

    // The operation word comes from the **request already in hand**, not from a
    // second read of the plan. Two reads of the same value in one function is two
    // places to disagree, and the first version did exactly that.
    let operation = if request.archive {
        "archive"
    } else {
        "restore"
    };
    Err(TixError::InvalidInput(format!(
        "archive delegation is not available: the archive service is part of mission \
         {ARCHIVE_OWNER}, which has not been implemented — this path will delegate \
         to its `bulk_{operation}` once it lands, and it will not implement archiving \
         itself"
    )))
}

/// Whether this module can apply the patch. Always false until #190 lands, and
/// asserted as such by `tests/bulk_archive.rs` so the day it becomes true somebody
/// has to notice.
pub fn can_apply(_patch: &IssuePatch) -> bool {
    false
}

// The semantic audit — what #190's contract forbids a bulk archive from doing —
// lives in `tests/bulk_archive.rs`, **not** here.
//
// It was in this file first, as `semantic_audit()` returning the rules as data, so a
// caller would get the reasoning and the greps together. That does not work: the
// rules are string patterns, so a rule list that lives in the file it audits
// **contains its own patterns** and the grep fires on the audit. The case reported
// `fs::rename` as a violation of C-005, having found it in the audit's own row for
// C-005.
//
// The rules are test data, so they are in the test, next to the greps that enforce
// them and the reasoning a reviewer needs to disagree with one. This module carries
// only what production needs: the owning mission's name and the refusals.