tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

impl App {
    /// Run read-only due detection and put the result in front of the user (T033).
    ///
    /// `replace` decides what happens to an overlay that is already open: `false`
    /// (start, refresh) leaves it alone, so a warning cannot vanish under the cursor
    /// that is answering it; `true` (after an action) re-derives the rows, because
    /// the answered one is resolved.
    ///
    /// The overlay is only raised from the board, and closing it returns there: a
    /// due warning is not allowed to interrupt a form, a prompt or another modal.
    pub(in crate::app) fn refresh_recurring_warnings(&mut self, replace: bool) {
        if self.recurring_warning.is_some() && !replace {
            return;
        }
        let Some(today) = Self::today_as_date() else {
            return;
        };
        let Ok(index) = self.series_index() else {
            return;
        };
        let context = crate::recurrence::rule::RuleContext::on(today);
        let warnings = crate::recurrence::detect::detect(&index, &context)
            .warnings()
            .to_vec();
        if warnings.is_empty() {
            if replace {
                self.recurring_warning = None;
                if matches!(self.mode, Mode::RecurringWarning) {
                    self.mode = Mode::Board;
                }
            }
            return;
        }
        match self.recurring_warning.as_mut() {
            Some(overlay) if replace => {
                let selected = overlay.selected;
                *overlay = crate::ui::recurring_warning::RecurringWarning::new(warnings);
                overlay.selected = selected.min(overlay.len().saturating_sub(1));
            }
            Some(_) => {}
            None => {
                self.recurring_warning = Some(crate::ui::recurring_warning::RecurringWarning::new(
                    warnings,
                ));
                if matches!(self.mode, Mode::Board) {
                    self.mode = Mode::RecurringWarning;
                }
            }
        }
    }

    /// The due-warning overlay\'s keys (T035).
    ///
    /// Raw keys, because the board underneath binds the same letters to its own
    /// actions (see `handle_key`). `c`/`Esc` close and answer nothing: the next
    /// refresh asks again, which is what makes closing non-mutating.
    /// The detail pane's WIP line for one issue, or `None` for a column with no
    /// limit (WP05 T025) — a limit-free board's pane is unchanged.
    pub(in crate::app) fn wip_line(&self, issue: &Issue) -> Option<String> {
        let usage = self.wip.column(issue.status.display())?;
        let limit = usage.limit.as_ref()?;
        Some(format!(
            "{} ({})",
            usage.cell(),
            limit.policy.map_or("no policy", |policy| policy.as_str())
        ))
    }

    /// The shared policy's decision for `number` moving to `target` (WP05
    /// T023/T024).
    ///
    /// `None` when the issue is not in the corpus: the write path refuses a selector
    /// it cannot resolve, so the caller proceeds exactly as it did before this
    /// mission rather than inventing a decision for an issue nobody has.
    pub(in crate::app) fn wip_decision(
        &self,
        number: u32,
        target: &str,
        transition_reason: Option<&str>,
        over: Option<&crate::wip::OverrideRequest>,
    ) -> Option<crate::wip::WipDecision> {
        let issue = self
            .corpus
            .issues
            .iter()
            .find(|issue| issue.number == number)?;
        let context = crate::wip::MoveContext::new(&self.store.config, &self.corpus.issues);
        // The reason the user gave a **transition** prompt is passed through: #183's
        // gate is the policy's first question, and dropping the reason here refuses a
        // move the user had just authorized — the defect the TUI's own reason-prompt
        // test caught.
        let mut request = crate::wip::MoveRequest::new(issue, target);
        if let Some(reason) = transition_reason {
            request = request.because(reason);
        }
        Some(match over {
            Some(over) => crate::wip::evaluate(&context, &request.overriding(over)),
            None => crate::wip::evaluate(&context, &request),
        })
    }

    /// The override modal's keys (WP05 T026): a typed reason, `enter` to authorize,
    /// `esc` to leave the move where it was.
    pub(in crate::app) fn handle_wip_override_key(&mut self, key: KeyEvent) -> Result<Action> {
        match key.code {
            KeyCode::Esc => {
                self.wip_override = None;
                self.finish_move();
                self.mode = Mode::Board;
                self.status_msg = Some("override cancelled; nothing was written".to_string());
                Ok(Action::None)
            }
            KeyCode::Backspace => {
                if let Mode::WipOverride { buffer } = &mut self.mode {
                    buffer.pop();
                }
                Ok(Action::None)
            }
            KeyCode::Char(c) => {
                if let Mode::WipOverride { buffer } = &mut self.mode {
                    buffer.push(c);
                }
                Ok(Action::None)
            }
            KeyCode::Enter => self.confirm_wip_override(),
            _ => Ok(Action::None),
        }
    }

    /// Authorize the refused move, re-running the same policy with the reason the
    /// user typed (WP05 T026).
    ///
    /// The request binds the limit the modal **showed**, so a configuration that
    /// changed while the modal was open is refused as `stale_override_limit` rather
    /// than authorized silently — the policy's rule, not a second one here.
    pub(in crate::app) fn confirm_wip_override(&mut self) -> Result<Action> {
        let Some(prompt) = self.wip_override.clone() else {
            self.mode = Mode::Board;
            return Ok(Action::None);
        };
        let reason = match &self.mode {
            Mode::WipOverride { buffer } => buffer.trim().to_string(),
            _ => String::new(),
        };
        if reason.is_empty() {
            // The modal stays open: the reason is the authorization, and an empty
            // one is exactly what the policy refuses.
            self.status_msg = Some("an override needs a reason; nothing was written".to_string());
            return Ok(Action::None);
        }
        let Some(limit) = prompt.decision.bound_limit() else {
            self.wip_override = None;
            self.finish_move();
            self.mode = Mode::Board;
            self.status_msg =
                Some("the limit this override was for is gone; nothing was written".to_string());
            return Ok(Action::None);
        };
        let over = crate::wip::OverrideRequest::new(
            crate::storage::actor_identity(),
            crate::model::utc_now_rfc3339(),
            reason,
            limit,
        );
        self.wip_override = None;
        self.pending_wip_override = Some(over);
        self.mode = Mode::Board;
        self.advance_move()
    }

    pub(in crate::app) fn handle_recurring_warning_key(&mut self, key: KeyEvent) -> Result<Action> {
        if self.recurring_warning.is_none() {
            self.mode = Mode::Board;
            return Ok(Action::None);
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') | KeyCode::Char('Q') => {
                self.recurring_warning = None;
                self.mode = Mode::Board;
            }
            KeyCode::Up | KeyCode::Char('k') => {
                if let Some(overlay) = self.recurring_warning.as_mut() {
                    overlay.move_selected(-1);
                }
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if let Some(overlay) = self.recurring_warning.as_mut() {
                    overlay.move_selected(1);
                }
            }
            KeyCode::Char('g') => {
                self.answer_warning(crate::recurrence::detect::WarningAction::Generate)?
            }
            KeyCode::Char('d') => {
                self.answer_warning(crate::recurrence::detect::WarningAction::Defer)?
            }
            KeyCode::Char('i') => {
                self.answer_warning(crate::recurrence::detect::WarningAction::Ignore)?
            }
            KeyCode::Char('?') => self.open_help(Mode::RecurringWarning),
            _ => {}
        }
        Ok(Action::None)
    }

    /// Answer the warning under the cursor through the shared service (T035).
    ///
    /// The press *is* the confirmation: the user saw the warning and chose. The
    /// outcome is reported in the status line, the rows are re-derived (the
    /// answered one is resolved or the action was refused), and the board is
    /// rebuilt so a created occurrence is visible immediately.
    pub(in crate::app) fn answer_warning(
        &mut self,
        action: crate::recurrence::detect::WarningAction,
    ) -> Result<()> {
        use crate::recurrence::detect::WarningAction;
        if action == WarningAction::Close {
            self.recurring_warning = None;
            self.mode = Mode::Board;
            return Ok(());
        }
        let Some(warning) = self
            .recurring_warning
            .as_ref()
            .and_then(|overlay| overlay.current().cloned())
        else {
            return Ok(());
        };
        let candidate = match action {
            WarningAction::Generate => CandidateAction::Generate,
            WarningAction::Defer => CandidateAction::Defer,
            WarningAction::Ignore => CandidateAction::Ignore,
            WarningAction::Close => unreachable!("handled above"),
        };
        self.status_msg = Some(self.apply_candidate(&warning, candidate)?);
        // The answer changed the files: re-read, then re-derive the rows.
        self.refresh_corpus()?;
        self.refresh_recurring_warnings(true);
        if self
            .recurring_warning
            .as_ref()
            .is_some_and(|overlay| overlay.is_empty())
        {
            self.recurring_warning = None;
            self.mode = Mode::Board;
        }
        Ok(())
    }

    /// Put one answer to one candidate through the shared service, and return the
    /// line a surface shows.
    ///
    /// One implementation for both doors: the warning overlay and the Schedule
    /// page answer the *same* candidate with the same four actions, so a
    /// divergence between them is impossible to write.
    pub(in crate::app) fn apply_candidate(
        &mut self,
        warning: &crate::recurrence::detect::DueWarning,
        action: CandidateAction,
    ) -> Result<String> {
        let actor = crate::commands::recurring::actor();
        Ok(match action {
            CandidateAction::Generate => {
                let index = self.series_index()?;
                let request = crate::recurrence::generate::GenerationRequest::new(
                    warning.series_id.clone(),
                    warning.occurrence_key.clone(),
                    actor,
                )
                .confirm();
                match crate::recurrence::generate::generate(
                    &self.store.config,
                    &self.store,
                    &index,
                    &request,
                )? {
                    crate::recurrence::generate::GenerationOutcome::Created {
                        issue_number,
                        ..
                    } => format!("generated #{issue_number} for `{}`", warning.occurrence_key),
                    crate::recurrence::generate::GenerationOutcome::NoOp { message, .. } => {
                        format!("no-op: {message}")
                    }
                    outcome @ crate::recurrence::generate::GenerationOutcome::Rejected {
                        ..
                    } => outcome.render(),
                }
            }
            CandidateAction::Defer | CandidateAction::Skip | CandidateAction::Ignore => {
                let lifecycle = match action {
                    CandidateAction::Defer => crate::recurrence::lifecycle::LifecycleAction::Defer,
                    CandidateAction::Skip => crate::recurrence::lifecycle::LifecycleAction::Skip,
                    _ => crate::recurrence::lifecycle::LifecycleAction::Ignore,
                };
                let request = crate::recurrence::lifecycle::LifecycleRequest::new(
                    warning.series_id.clone(),
                    lifecycle,
                    actor,
                )
                .about(warning.occurrence_key.clone())
                .confirm();
                let outcome = crate::recurrence::lifecycle::apply(&self.store, &request)?;
                format!("{}: {}", lifecycle.as_str(), outcome.render())
            }
        })
    }
}