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

pub(super) fn roadmap_list(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let filter = match roadmap_filter(args) {
        Ok(filter) => filter,
        Err(()) => return Ok(proto_error()),
    };
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let projection = match project_roadmap(&config, store, &filter) {
        Ok(projection) => projection,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let mut lines: Vec<String> = Vec::new();
    if let Some(line) = cmd_roadmap::roadmap_filter_line(&filter) {
        lines.push(line);
    }
    lines.extend(cmd_roadmap::roadmap_list_lines(&projection));
    let structured = json!({
        "filters": serde_json::to_value(&filter).unwrap_or(Value::Null),
        "total": projection.total,
        "initiatives": projection
            .initiatives
            .iter()
            .map(roadmap_row_value)
            .collect::<Vec<Value>>(),
        "diagnostics": projection
            .diagnostics
            .iter()
            .map(roadmap_diagnostic_value)
            .collect::<Vec<Value>>(),
    });
    Ok(success_result(lines.join("\n"), structured))
}

pub(super) fn roadmap_show(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "initiative")?;
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let row = match roadmap_row_outcome(&config, store, &reference) {
        Ok(row) => row,
        Err(outcome) => return outcome,
    };
    // The CLI's own row rendering, verbatim (C-007/NFR-003).
    let text = cmd_roadmap::roadmap_row_lines(&row).join("\n");
    Ok(success_result(text, roadmap_row_value(&row)))
}

pub(super) fn roadmap_summary(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "initiative")?;
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let row = match roadmap_row_outcome(&config, store, &reference) {
        Ok(row) => row,
        Err(outcome) => return outcome,
    };
    // The text channel is the CLI's own row rendering (which *is* the summary:
    // one contribution line per source entity plus the diagnostics), so the two
    // surfaces cannot word one summary two ways.
    let text = cmd_roadmap::roadmap_row_lines(&row).join("\n");
    Ok(success_result(text, initiative_summary_value(&row.summary)))
}

pub(super) fn dispatch(
    config: &Config,
    store: &Store,
    name: &str,
    arguments: &Value,
) -> Option<ToolOutcome> {
    match name {
        "roadmap_list" => Some(roadmap_list(config, store, arguments)),
        "roadmap_show" => Some(roadmap_show(config, store, arguments)),
        "roadmap_summary" => Some(roadmap_summary(config, store, arguments)),
        _ => None,
    }
}

/// Project the roadmap over the loaded registry and the store's issue set.
///
/// The filter's syntax is validated **first**, before any source snapshot is
/// built or any row joined — the CLI's own precedence (`tix roadmap list` calls
/// `filter.validate()` before it reads the store), so a malformed value is
/// refused with the filter's named diagnostic even when the store read would
/// also fail (C-007). The source snapshot comes from the CLI's own
/// [`cmd_roadmap::build_sources`], so MCP, the CLI and the TUI share **one**
/// composition of the owner reads rather than three copies (C-008). Read-only.
pub(super) fn project_roadmap(
    config: &Config,
    store: &Store,
    filter: &RoadmapFilter,
) -> TixResult<RoadmapProjection> {
    filter.validate()?;
    let issues = store.list_all()?;
    let sources = cmd_roadmap::build_sources(config, store, &issues)?;
    crate::roadmap::project(config, &sources, filter)
}

/// One initiative's roadmap row under the empty filter.
pub(super) fn roadmap_row_by_id(
    config: &Config,
    store: &Store,
    id: &str,
) -> TixResult<Option<RoadmapRow>> {
    let projection = project_roadmap(config, store, &RoadmapFilter::default())?;
    Ok(projection.initiatives.into_iter().find(|row| row.id == id))
}

/// The stable singular spelling of an association kind (data-model §2), so the
/// structured `kind` is the same word the provenance prefix uses.
pub(super) fn association_kind_name(kind: AssociationKind) -> &'static str {
    match kind {
        AssociationKind::Project => "project",
        AssociationKind::Epic => "epic",
        AssociationKind::Milestone => "milestone",
    }
}

/// Parse an association kind. `None` for anything outside the closed vocabulary;
/// the caller turns that into a named tool refusal rather than a protocol error.
pub(super) fn association_kind(raw: &str) -> Option<AssociationKind> {
    match raw.trim() {
        "project" => Some(AssociationKind::Project),
        "epic" => Some(AssociationKind::Epic),
        "milestone" => Some(AssociationKind::Milestone),
        _ => None,
    }
}

/// A trimmed priority keeping the forward-compatible unknown spelling. The
/// initiative record keeps an unknown priority verbatim (the shared `Priority`
/// shape), so this mirrors the CLI's loose parse rather than the strict one.
pub(super) fn loose_priority(value: &str) -> crate::model::Priority {
    value
        .parse()
        .unwrap_or_else(|never: std::convert::Infallible| match never {})
}

/// An optional priority argument: absent or blank is absent; a written value is
/// trimmed and parsed loosely, exactly as `tix initiative create` does.
pub(super) fn initiative_priority(value: Option<String>) -> Option<crate::model::Priority> {
    let trimmed = value?;
    let trimmed = trimmed.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(loose_priority(trimmed))
    }
}

/// A blank value clears an optional text field; a written one sets it trimmed
/// (the CLI's `text_patch`).
pub(super) fn initiative_text_patch(value: Option<String>) -> crate::projects::Patch<String> {
    use crate::projects::Patch;
    match value {
        None => Patch::Unchanged,
        Some(text) if text.trim().is_empty() => Patch::Clear,
        Some(text) => Patch::Set(text.trim().to_string()),
    }
}

/// A blank value clears a reference list; a written one replaces it (the CLI's
/// `reference_patch`).
pub(super) fn initiative_reference_patch(
    value: Option<String>,
) -> crate::projects::Patch<Vec<String>> {
    use crate::projects::Patch;
    match value {
        None => Patch::Unchanged,
        Some(text) if text.trim().is_empty() => Patch::Clear,
        Some(text) => Patch::Set(crate::commands::parse_csv_list(&text)),
    }
}

/// An initiative's registry record as JSON — raw values, no derived figure.
pub(super) fn initiative_record_value(id: &str, record: &InitiativeConfiguration) -> Value {
    json!({
        "id": id,
        "name": record.name,
        "outcome": record.outcome,
        "owner": record.owner,
        "status": record.status,
        "priority": record.priority,
        "labels": record.labels,
        "target_date": record.target_date,
        "projects": record.projects,
        "epics": record.epics,
        "milestones": record.milestones,
    })
}

/// One roadmap diagnostic as JSON: the kind, the location it names and the
/// remedy. Every field is optional except `kind` and `message`, as the service
/// models it.
pub(super) fn roadmap_diagnostic_value(diagnostic: &RoadmapDiagnostic) -> Value {
    json!({
        "kind": diagnostic.kind.as_str(),
        "initiative": diagnostic.initiative,
        "field": diagnostic.field,
        "reference": diagnostic.reference,
        "source": diagnostic.source,
        "message": diagnostic.message,
        "remedy": diagnostic.remedy,
    })
}

/// One association as the row exposes it: identity, label and resolution.
pub(super) fn row_reference_value(reference: &RowReference) -> Value {
    json!({
        "kind": association_kind_name(reference.kind),
        "target": reference.target,
        "label": reference.label,
        "resolution": reference.resolution.as_str(),
    })
}

/// A health rollup as JSON — the typed risk value a surface compares by value.
pub(super) fn health_rollup_value(rollup: &crate::health::HealthRollup) -> Value {
    json!({
        "scope": rollup.scope.to_string(),
        "state": rollup.state.as_str(),
        "source": rollup.source.as_str(),
        "policy_version": rollup.policy_version,
        "contributions": rollup
            .contributions
            .iter()
            .map(|contribution| json!({
                "issue": contribution.number,
                "state": contribution.state.as_str(),
            }))
            .collect::<Vec<Value>>(),
    })
}

/// The owning service's own typed value for one resolved source entity, tagged
/// with which service produced it — never a blended figure (C-004).
pub(super) fn progress_value(progress: &ProgressValue) -> Value {
    match progress {
        ProgressValue::Project(report) => json!({
            "source": "project",
            "value": project_health_value(report),
        }),
        ProgressValue::Epic(rollup) => json!({
            "source": "epic",
            "value": epic_rollup_value(rollup),
        }),
    }
}

/// One summary contribution as JSON: provenance, label, resolution, the owning
/// service's own value, its risk and the shared policy version.
pub(super) fn contribution_value(contribution: &SummaryContribution) -> Value {
    json!({
        "source_entity": contribution.source_entity(),
        "kind": association_kind_name(contribution.kind),
        "target": contribution.target,
        "label": contribution.label,
        "resolution": contribution.resolution.as_str(),
        "progress": contribution.progress.as_ref().map(progress_value),
        "risk": contribution.risk.as_ref().map(health_rollup_value),
        "policy_version": contribution.policy_version,
    })
}

/// The source-attributed summary as JSON: the explicit state, one contribution
/// per referenced entity, the ordered source entities and policy versions, and
/// the diagnostics. No initiative-local aggregate (C-004).
pub(super) fn initiative_summary_value(summary: &InitiativeSummary) -> Value {
    json!({
        "initiative_id": summary.initiative_id,
        "state": summary.state.as_str(),
        "knowable_contributions": summary.knowable_contributions(),
        "source_entities": summary.source_entities,
        "policy_versions": summary.policy_versions,
        "contributions": summary
            .contributions
            .iter()
            .map(contribution_value)
            .collect::<Vec<Value>>(),
        "diagnostics": summary
            .diagnostics
            .iter()
            .map(roadmap_diagnostic_value)
            .collect::<Vec<Value>>(),
    })
}

/// One roadmap row as JSON — raw values, no formatted summary.
pub(super) fn roadmap_row_value(row: &RoadmapRow) -> Value {
    json!({
        "id": row.id,
        "name": row.name,
        "outcome": row.outcome,
        "owner": row.owner,
        "status": row.status,
        "priority": row.priority,
        "labels": row.labels,
        "target_date": row.target_date,
        "references": row.references.iter().map(row_reference_value).collect::<Vec<Value>>(),
        "summary": initiative_summary_value(&row.summary),
        "diagnostics": row.diagnostics.iter().map(roadmap_diagnostic_value).collect::<Vec<Value>>(),
    })
}

/// The normalized filter from the MCP arguments — the same DTO the CLI builds,
/// with the same field names, so the two surfaces cannot drift into two query
/// languages (C-007).
pub(super) fn roadmap_filter(args: &Value) -> std::result::Result<RoadmapFilter, ()> {
    Ok(RoadmapFilter {
        period_from: opt_string(args, "period_from")?,
        period_to: opt_string(args, "period_to")?,
        owner: opt_string(args, "owner")?,
        status: opt_string(args, "status")?,
        labels: opt_string_array(args, "labels")?.unwrap_or_default(),
        query: opt_string(args, "query")?,
    })
}

/// `initiative_associate` / `initiative_dereference`, one registry call each.
pub(super) fn initiative_association(store: &Store, args: &Value, associate: bool) -> ToolOutcome {
    let reference = req_string(args, "initiative")?;
    let raw_kind = req_string(args, "kind")?;
    let Some(kind) = association_kind(&raw_kind) else {
        return Ok(error_result(format!(
            "invalid input: `{raw_kind}` is not an association kind; expected project, epic or \
             milestone"
        )));
    };
    let target = req_string(args, "target")?;
    let (mut config, path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // Resolved once, up front, so an unknown initiative refuses before any write;
    // the id, not the reference, is what the service records against (mirroring
    // the CLI, which resolves first too).
    let id = match crate::roadmap::registry::resolve_initiative_id(&config, &reference) {
        Ok(id) => id,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let outcome = if associate {
        crate::roadmap::registry::associate(&mut config, &path, id.as_str(), kind, &target)
    } else {
        crate::roadmap::registry::dissociate(&mut config, &path, id.as_str(), kind, &target)
    };
    match outcome {
        Ok(changed) => {
            let text = if associate {
                if changed {
                    format!("Associated {kind} {target} with initiative {id}")
                } else {
                    format!("Already associated: {kind} {target} with initiative {id}")
                }
            } else if changed {
                format!("Dereferenced {kind} {target} from initiative {id}")
            } else {
                format!("Not associated: {kind} {target} with initiative {id}")
            };
            Ok(success_result(
                text,
                json!({
                    "initiative": id.as_str(),
                    "kind": association_kind_name(kind),
                    "target": target,
                    "changed": changed,
                }),
            ))
        }
        Err(e) => Ok(error_result(crate::error::render_error(&e))),
    }
}

/// The one row a single-row roadmap read addresses, or the refusal to return.
pub(super) fn roadmap_row_outcome(
    config: &Config,
    store: &Store,
    reference: &str,
) -> ToolOutcome2<RoadmapRow> {
    let id = match crate::roadmap::registry::resolve_initiative_id(config, reference) {
        Ok(id) => id,
        Err(e) => return Err(Ok(error_result(crate::error::render_error(&e)))),
    };
    match roadmap_row_by_id(config, store, id.as_str()) {
        Ok(Some(row)) => Ok(row),
        Ok(None) => Err(Ok(error_result(format!(
            "internal error: the roadmap projection returned no row for `{id}`"
        )))),
        Err(e) => Err(Ok(error_result(crate::error::render_error(&e)))),
    }
}

// ---------------------------------------------------------------------------
// WP07 (mission `cycle-capacity-planning-01M3C85C`) — the capacity tools
// ---------------------------------------------------------------------------