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::*;

#[derive(Debug, Clone, PartialEq, Eq)]
/// The health form's state (mission `project-issue-health-status-01M3C8HX`, WP06).
///
/// The audit fields are the same three the CLI requires — state, actor, rationale —
/// plus the layer, so one form records both a `current` value and an `override`
/// instead of teaching two key paths that differ in one field.
///
/// Nothing here writes. On submit the form builds the same
/// [`crate::health::HealthUpdate`] the CLI builds, through
/// [`crate::commands::health::record`], so a value a user records from the TUI is
/// indistinguishable from one recorded from the command line (C-007).
pub struct HealthForm {
    /// The issue the value is about.
    pub issue: u32,
    /// The state being recorded, typed.
    pub state: String,
    /// Who is making the change.
    pub actor: String,
    /// Why — required, and refused when blank.
    pub rationale: String,
    /// Which field the cursor is in: 0 state, 1 actor, 2 rationale, 3 layer.
    pub field: usize,
    /// The layer: 0 `current`, 1 `override`.
    pub layer: u8,
}

impl HealthForm {
    /// A form for `issue`, with the actor defaulted to the configured identity.
    pub fn new(issue: u32) -> Self {
        Self {
            issue,
            state: String::new(),
            actor: crate::storage::actor_identity(),
            rationale: String::new(),
            field: 0,
            layer: 0,
        }
    }

    /// The layer's name, as the shared vocabulary spells it.
    pub fn layer_name(&self) -> &'static str {
        if self.layer == 0 {
            "current"
        } else {
            "override"
        }
    }

    /// The field the cursor is in, by name.
    pub fn field_name(&self) -> &'static str {
        match self.field {
            0 => "state",
            1 => "actor",
            2 => "rationale",
            _ => "layer",
        }
    }
}

impl App {
    /// The Health tab's keys (WP06): walk the scope rows, open a row's issue.
    pub(in crate::app) fn handle_health_tab_key(&mut self, key: KeyEvent) -> Result<Action> {
        let rows = self.stats_rows();
        if rows.is_empty() {
            self.stats_cursor = 0;
        } else {
            self.stats_cursor = self.stats_cursor.min(rows.len() - 1);
        }
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => {
                self.stats_cursor = self.stats_cursor.saturating_sub(1);
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if !rows.is_empty() && self.stats_cursor + 1 < rows.len() {
                    self.stats_cursor += 1;
                }
            }
            KeyCode::Enter => {
                if let Some(row) = rows.get(self.stats_cursor) {
                    if let crate::health::HealthScope::Issue(number) = row.scope {
                        // A derived scope has no pane to open; the row is the answer.
                        match self
                            .store
                            .get(crate::storage::IssueSelector::Number(number))
                        {
                            Ok(issue) => self.open_detail_root(issue)?,
                            Err(error) => self.status_msg = Some(error.to_string()),
                        }
                    } else {
                        self.status_msg = Some(format!(
                            "{} is a derived scope: `tix health rollup --scope {} --id {}` \
                             shows its contributors",
                            row.scope.as_str(),
                            row.scope.as_str().split(':').next().unwrap_or("issue"),
                            row.scope.as_str().split_once(':').map_or("", |(_, id)| id),
                        ));
                    }
                }
            }
            _ => {}
        }
        Ok(Action::None)
    }

    /// Open the health form for the issue in the detail pane (WP06 T032).
    pub(in crate::app) fn open_health_form(&mut self) -> Result<Action> {
        let Some(detail) = self.detail.as_ref() else {
            self.status_msg = Some("open an issue's detail first: the form edits one issue".into());
            return Ok(Action::None);
        };
        if detail.read_only {
            self.status_msg = Some(
                "this issue is outside the store, so it has no file to record health in".into(),
            );
            return Ok(Action::None);
        }
        self.health_form = Some(HealthForm::new(detail.issue.number));
        self.mode = Mode::HealthForm;
        Ok(Action::None)
    }

    /// The form's keys: `Tab` walks the fields, `Enter` submits, `Esc` leaves.
    pub(in crate::app) fn handle_health_form_key(&mut self, key: KeyEvent) -> Result<Action> {
        if !matches!(self.mode, Mode::HealthForm) {
            return Ok(Action::None);
        }
        match key.code {
            KeyCode::Esc => {
                self.health_form = None;
                self.bulk_action_targets = None;
                self.mode = Mode::Detail;
            }
            KeyCode::Tab => {
                if let Some(form) = self.health_form.as_mut() {
                    form.field = (form.field + 1) % 4;
                }
            }
            KeyCode::BackTab => {
                if let Some(form) = self.health_form.as_mut() {
                    form.field = (form.field + 3) % 4;
                }
            }
            // The layer is chosen, not typed: `space` toggles it **when the layer
            // field is focused**. In every other field a space is a space — swallowing
            // it there made "blocked by upstream" record as "blockedbyupstream".
            KeyCode::Char(' ') => {
                if let Some(form) = self.health_form.as_mut() {
                    if form.field == 3 {
                        form.layer = 1 - form.layer;
                    } else {
                        match form.field {
                            0 => form.state.push(' '),
                            1 => form.actor.push(' '),
                            _ => form.rationale.push(' '),
                        }
                    }
                }
            }
            KeyCode::Backspace => {
                if let Some(form) = self.health_form.as_mut() {
                    match form.field {
                        0 => {
                            form.state.pop();
                        }
                        1 => {
                            form.actor.pop();
                        }
                        2 => {
                            form.rationale.pop();
                        }
                        _ => {}
                    }
                }
            }
            KeyCode::Enter => return self.confirm_health_form(),
            KeyCode::Char(c) => {
                if let Some(form) = self.health_form.as_mut() {
                    match form.field {
                        0 => form.state.push(c),
                        1 => form.actor.push(c),
                        2 => form.rationale.push(c),
                        _ => {}
                    }
                }
            }
            _ => {}
        }
        Ok(Action::None)
    }

    /// Submit the form through the shared service, and report what happened.
    ///
    /// The refusal path is the important one: a blank rationale, an unknown state, or
    /// a scope with no record is refused **having written nothing**, and the reason is
    /// shown in the status line rather than swallowed.
    pub(in crate::app) fn confirm_health_form(&mut self) -> Result<Action> {
        let Some(form) = self.health_form.clone() else {
            self.mode = Mode::Detail;
            return Ok(Action::None);
        };
        let source = if form.layer == 0 {
            crate::health::HealthSource::Current
        } else {
            crate::health::HealthSource::Override
        };
        // The actor defaults to the configured identity, so the common case types one
        // field fewer; the rationale does not, because a blank one is not a reason.
        let actor = if form.actor.trim().is_empty() {
            crate::storage::actor_identity()
        } else {
            form.actor.clone()
        };
        if let Some(targets) = self.bulk_action_targets.clone() {
            let mut applied = 0usize;
            let mut errors = Vec::new();
            for number in targets {
                let result = crate::commands::health::record(
                    &self.store,
                    crate::health::HealthScope::of_issue(number),
                    form.state.trim(),
                    actor.trim(),
                    &crate::model::today(),
                    form.rationale.trim(),
                    source,
                );
                match result {
                    Ok(_) => applied += 1,
                    Err(error) => errors.push((number, error.to_string())),
                }
            }
            self.health_form = None;
            self.bulk_action_targets = None;
            self.mode = Mode::Detail;
            self.refresh_health();
            self.refresh_detail()?;
            let suffix = if applied == 1 { "" } else { "s" };
            let mut message = format!("health: recorded on {applied} issue{suffix}");
            if let Some((number, error)) = errors.first() {
                message.push_str(&format!("; {} refused (#{number}: {error})", errors.len()));
            }
            self.status_msg = Some(message);
            return Ok(Action::None);
        }
        let result = crate::commands::health::record(
            &self.store,
            crate::health::HealthScope::of_issue(form.issue),
            form.state.trim(),
            actor.trim(),
            &crate::model::today(),
            form.rationale.trim(),
            source,
        );
        match result {
            Ok(applied) => {
                self.health_form = None;
                self.mode = Mode::Detail;
                self.refresh_health();
                self.refresh_detail()?;
                self.status_msg = Some(format!(
                    "health: issue #{} {} ({})",
                    applied.issue.number,
                    applied
                        .issue
                        .health
                        .as_ref()
                        .and_then(|health| health.state.clone())
                        .unwrap_or_default(),
                    source.as_str()
                ));
            }
            Err(error) => {
                // Stay in the form: the fields are the user's work, and the refusal
                // names what to fix.
                self.status_msg = Some(error.to_string());
            }
        }
        Ok(Action::None)
    }
}