tix-tracker 0.11.0

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

/// A `TixError` from a service refusal, rendered through the one boundary
/// renderer so the text is byte-identical to the CLI's stderr.
pub(super) fn project_refusal(error: crate::projects::ProjectError) -> Value {
    error_result(crate::error::render_error(&crate::error::TixError::from(
        error,
    )))
}

// ---------------------------------------------------------------------------
// WP07 / T034: the epic handlers
// ---------------------------------------------------------------------------
//
// Every handler reaches behaviour through `crate::epics` (and, for the reference
// writes, the store's service-backed `reference_epic`/`dereference_epic`/
// `bulk_reference`): identity, resolution, hierarchy, membership, progress,
// blocked state and reference diagnostics each have exactly one owner, and a
// rule or a figure appearing here would be the second implementation C-008
// prohibited. `tix epic …` reads every issue file (`list_all`), so these reads do
// too, and the blocked state comes from the relationship service's derived read
// (`graph_source`, the active set) exactly as `commands::epic::show` does.

/// The epic service's error, rendered through the one boundary renderer so the
/// text is byte-identical to the CLI's stderr.
pub(super) fn epic_refusal(error: crate::epics::EpicError) -> Value {
    error_result(crate::error::render_error(&crate::error::TixError::from(
        error,
    )))
}

/// The refusal `tix epic edit` prints when no field flag was given, in the CLI's
/// own words.
///
/// Duplicated from `commands::epic` because it is private there; a parity row
/// (`t036_an_epic_edit_with_no_fields_is_refused_identically`) pins the two
/// wordings together, which is what makes the duplication safe.
pub(super) fn epic_edit_requires_a_field() -> String {
    crate::error::render_error(&crate::error::TixError::InvalidInput(
        "no fields to change; pass at least one of --name, --description, --status, \
         --owner, --start-date, --due-date, --weight, --parent, --projects, \
         --milestone or --cycle"
            .to_string(),
    ))
}

/// The one wording for a non-integer epic weight, naming the service's kind.
///
/// The **positivity** rule stays the service's; this is only the argument-level
/// parse refusal, worded with the same `invalid_weight` kind so one problem has
/// one name and MCP's text equals the CLI's.
pub(super) fn epic_weight_parse_error(text: &str) -> String {
    crate::error::render_error(&crate::error::TixError::InvalidInput(format!(
        "{}: epic weight must be an integer; got `{text}`",
        crate::epics::EpicDiagnosticKind::InvalidWeight
    )))
}

/// A create-time epic weight: an integer, a decimal string, or nothing.
///
/// `""` is unset, matching `tix epic create --weight ""`. A malformed string is
/// the CLI's own refusal; no value is coerced.
pub(super) fn epic_create_weight(args: &Value, key: &str) -> ToolOutcome2<Option<i64>> {
    match args.get(key) {
        None | Some(Value::Null) => Ok(None),
        Some(Value::Number(number)) => match number.as_i64() {
            Some(value) => Ok(Some(value)),
            None => Err(refuse(epic_weight_parse_error(&number.to_string()))),
        },
        Some(Value::String(text)) if text.trim().is_empty() => Ok(None),
        Some(Value::String(text)) => match text.trim().parse::<i64>() {
            Ok(value) => Ok(Some(value)),
            Err(_) => Err(refuse(epic_weight_parse_error(text))),
        },
        Some(_) => Err(proto()),
    }
}

/// An epic-weight patch: absent keeps it, a blank value clears it, a written
/// value is parsed. Mirrors `commands::epic::weight_patch`.
pub(super) fn epic_weight_patch(args: &Value, key: &str) -> ToolOutcome2<crate::epics::Patch<i64>> {
    use crate::epics::Patch;
    match args.get(key) {
        None | Some(Value::Null) => Ok(Patch::Unchanged),
        Some(Value::Number(number)) => number
            .as_i64()
            .map(Patch::Set)
            .ok_or_else(|| refuse(epic_weight_parse_error(&number.to_string()))),
        Some(Value::String(text)) if text.trim().is_empty() => Ok(Patch::Clear),
        Some(Value::String(text)) => text
            .trim()
            .parse::<i64>()
            .map(Patch::Set)
            .map_err(|_| refuse(epic_weight_parse_error(text))),
        Some(_) => Err(proto()),
    }
}

/// One derived member as JSON: the issue number and the shared display label.
pub(super) fn epic_member_value(member: &crate::epics::MemberRef) -> Value {
    json!({ "issue": member.issue.number(), "display": member.display })
}

/// One derived epic reference as JSON: the stable id, the name and the display.
pub(super) fn epic_ref_value(reference: &crate::epics::EpicRef) -> Value {
    json!({
        "id": reference.id.as_str(),
        "name": reference.name,
        "display": reference.display,
    })
}

/// One unresolvable reference as JSON, exactly as the service reports it (C-005).
///
/// `kind` is the reference kind and `diagnostic_kind` is the `tix lint` name for
/// it, so MCP, lint and the CLI name one condition with one spelling.
pub(super) fn epic_missing_value(missing: &crate::epics::EpicReferenceResolution) -> Value {
    json!({
        "holder": missing.holder.to_string(),
        "kind": missing.target_kind.as_str(),
        "diagnostic_kind": missing.target_kind.diagnostic_kind(),
        "target": missing.target,
        "reason": missing.reason,
    })
}

/// An epic's registry record plus its derived children and members, as JSON.
///
/// Raw values only: no formatted figure and no computed percentage beyond what
/// the service already carries.
pub(super) fn epic_record_value(epic: &crate::epics::Epic) -> Value {
    json!({
        "id": epic.id.as_str(),
        "name": epic.name,
        "description": epic.description,
        "status": epic.status,
        "owner": epic.owner,
        "start_date": epic.start_date,
        "due_date": epic.due_date,
        "weight": epic.weight,
        "parent": epic.parent,
        "projects": epic.projects,
        "milestone": epic.milestone,
        "cycle": epic.cycle,
        "member_count": epic.member_count,
        "child_count": epic.child_count,
        "children": epic.children.iter().map(epic_ref_value).collect::<Vec<Value>>(),
        "members": epic.members.iter().map(epic_member_value).collect::<Vec<Value>>(),
    })
}

/// The typed weight-aware figure, flattened to the service's raw fields.
pub(super) fn weighted_progress_value(weighted: &crate::epics::WeightedProgress) -> Value {
    json!({
        "own_weight": weighted.own_weight,
        "weighted_children": weighted.weighted_children,
        "unweighted_children": weighted.unweighted_children,
        "done_weight": weighted.done_weight,
        "total_weight": weighted.total_weight,
        "percent": weighted.percent,
    })
}

/// The typed rollup **values**, flattened to the raw figures the contract names.
///
/// No formatted summary: the response carries exactly what the service computed,
/// so a client — and the parity matrix — can compare it by value (NFR-003).
pub(super) fn epic_rollup_value(rollup: &crate::epics::EpicRollup) -> Value {
    json!({
        "epic_id": rollup.epic_id.as_str(),
        "name": rollup.name,
        "done": rollup.progress.done,
        "total": rollup.progress.total,
        "progress": { "done": rollup.progress.done, "total": rollup.progress.total },
        "child_count": rollup.child_count,
        "weighted_progress": weighted_progress_value(&rollup.weighted_progress),
        "blocked": rollup.blocked,
        "blocked_members": rollup
            .blocked_members
            .iter()
            .map(|member| json!({
                "issue": member.issue.number(),
                "display": member.display,
                "reason": member.reason,
            }))
            .collect::<Vec<Value>>(),
        "missing_references": rollup
            .missing_references
            .iter()
            .map(epic_missing_value)
            .collect::<Vec<Value>>(),
    })
}

/// The derived hierarchy as JSON: the raw parent/children/ancestors/depth and
/// the explicit unresolved record, in the service's order.
pub(super) fn epic_hierarchy_value(hierarchy: &crate::epics::EpicHierarchy) -> Value {
    json!({
        "epic": hierarchy.epic.as_str(),
        "recorded_parent": hierarchy.recorded_parent,
        "parent": hierarchy.parent.as_ref().map(epic_ref_value),
        "children": hierarchy.children.iter().map(epic_ref_value).collect::<Vec<Value>>(),
        "child_count": hierarchy.child_count,
        "ancestors": hierarchy.ancestors.iter().map(epic_ref_value).collect::<Vec<Value>>(),
        "depth": hierarchy.depth,
        "unresolved": hierarchy.unresolved.as_ref().map(|unresolved| json!({
            "epic": unresolved.epic.as_str(),
            "recorded_parent": unresolved.recorded_parent,
            "reason": unresolved.reason,
        })),
    })
}

/// One epic's figures on one line, for the text mirror of `epic_health`.
pub(super) fn epic_health_text(rollup: &crate::epics::EpicRollup) -> String {
    format!(
        "{} {}/{} done, blocked {}, missing-references {}",
        crate::epics::epic_display(&rollup.epic_id, &rollup.name),
        rollup.progress.done,
        rollup.progress.total,
        rollup.blocked,
        rollup.missing_references.len(),
    )
}

/// The `ReferenceTargets` for one snapshot, consumed from their owners.
///
/// Mirrors `commands::epic::reference_targets` (private there): the milestone and
/// cycle names come from the existing planner lookups and the project ids from
/// the project registry — no second milestone, cycle, or project store.
pub(super) fn epic_reference_targets(
    config: &Config,
    store: &Store,
) -> TixResult<crate::epics::ReferenceTargets> {
    let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| store.issues_dir.clone());
    let planner = crate::planning::Planner::from_config(config.clone(), root);
    crate::epics::ReferenceTargets::from_planner(&planner)
}

/// One bulk reference/dereference outcome as JSON.
pub(super) fn epic_reference_outcome_value(
    outcome: &crate::storage::BulkReferenceOutcome,
) -> Value {
    match &outcome.outcome {
        Ok(applied) => json!({
            "selector": outcome.selector,
            "changed": applied.changed,
            "epic_ids": applied.epic_ids,
            "written": applied.written.as_ref().map(|issue| issue.filename.clone()),
        }),
        Err(error) => json!({
            "selector": outcome.selector,
            "error": crate::error::render_error(error),
        }),
    }
}

/// The shared policy's decision as structured data (WP06 T029).
///
/// The same fields the CLI renders, so a client can branch on `kind` rather than
/// parse the sentence — and `history` names the one event an applied override wrote.
pub(super) fn wip_value(decision: &crate::wip::WipDecision) -> Value {
    json!({
        "kind": decision.as_str(),
        "from": decision.from,
        "to": decision.to,
        "before": decision.state_before.as_str(),
        "after": decision.state_after.as_str(),
        "limit": decision.limit.as_ref().map(|limit| json!({
            "column": decision.to,
            "max_items": limit.max_items,
            "max_estimate": limit.max_estimate,
            "unit": limit.unit,
            "policy": limit.policy.map(|policy| policy.as_str()),
            "enabled": limit.enabled,
        })),
        "usage": decision.usage.as_ref().map(|usage| json!({
            "cell": usage.cell(),
            "items": usage.items,
            "estimate": usage.estimate,
        })),
        "diagnostics": decision
            .diagnostics
            .iter()
            .map(|diagnostic| json!({
                "kind": diagnostic.kind.as_str(),
                "message": diagnostic.message,
            }))
            .collect::<Vec<_>>(),
        "history": decision.history.as_ref().map(|intent| json!({
            "kind": intent.kind.as_str(),
            "actor": intent.actor,
            "timestamp": intent.timestamp,
            "detail": intent.detail,
        })),
    })
}

/// A WIP refusal: `isError: true` **and** the structured decision beside it, so a
/// client can branch on `wip.kind` rather than parse the sentence (the shape the
/// template and view refusals already use).
pub(super) fn wip_result(
    text: String,
    decision: &crate::wip::WipDecision,
    is_error: bool,
) -> Value {
    json!({
        "content": [{ "type": "text", "text": text }],
        "structuredContent": { "wip": wip_value(decision) },
        "isError": is_error,
    })
}