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

/// FR-014 success shape: `content` mirror + `structuredContent`, `isError: false`.
pub(super) fn success_result(text: String, structured: Value) -> Value {
    json!({
        "content": [{ "type": "text", "text": text }],
        "structuredContent": structured,
        "isError": false,
    })
}

/// FR-014 failure shape: `isError: true`, the CLI's exact error string, and
/// **no** `structuredContent` (`contracts/mcp-tools.md` §4).
pub(super) fn error_result(text: String) -> Value {
    json!({
        "content": [{ "type": "text", "text": text }],
        "isError": true,
    })
}

/// The issue object used by `create_issue`/`get_issue`/`update_issue`/
/// `move_issue`/`comment_on_issue`: the frontmatter fields plus `filename` and
/// `body` (which the derived serializer skips).
/// WP07: `issue_value` now takes the board's [`CategoryResolution`](crate::workflow::CategoryResolution).
///
/// Every call site already has a `Config` in scope, so threading it is mechanical, and
/// doing it this way means a response can **never** carry a category derived from a
/// different board than the one it was resolved against — the failure mode of a
/// helper that fetched the resolution itself.
pub(super) fn issue_value(
    issue: &Issue,
    resolution: &crate::workflow::CategoryResolution,
) -> Value {
    let mut obj = serde_json::to_value(issue).expect("Issue serialization is infallible");
    obj["filename"] = Value::String(issue.filename.clone());
    obj["body"] = Value::String(issue.body.clone());
    // Always present, even when empty (`skip_serializing_if` omits it from the
    // serialized issue; a client reading the payload should not have to tell
    // "absent" from "none").
    obj["related_issues"] = json!(issue.related_issues);
    // The issue's resolved category, from the service (T030 step 4, FR-004). The
    // **key** as well as the label: a client filtering or grouping needs the stable
    // name, and the label is a display string that may be reworded.
    //
    // `null` for a column the board does not configure — a configuration error the
    // client should be able to see, not a silent default. `tix list --show category`
    // spells the same case `(unmapped)`, and a JSON field has no room for a marker
    // string, so `null` plus the CLI's spelling is the honest pairing.
    match crate::workflow::category_of(issue, resolution) {
        Some(c) => {
            obj["category"] = json!(c.key());
            obj["category_label"] = json!(c.label());
        }
        None => {
            obj["category"] = Value::Null;
            obj["category_label"] = json!("(unmapped)");
        }
    }
    obj
}

// ---------------------------------------------------------------------------
// T022/T023 – handlers
// ---------------------------------------------------------------------------

/// `create_issue` with a template selected (T030).
///
/// The resolver is the CLI's own (`commands::templates::selection_for` +
/// `crate::templates::resolve`): it merges the template's defaults and prompts
/// with these fields, validates required prompts and typed references, and
/// returns one normal create request — or **every** diagnostic and no request.
/// A failed resolution writes nothing: no issue, no template change. The request
/// then goes through `commands::templates::apply_request`, the exact call
/// `tix create --template` makes, so both the created issue and the failure text
/// are the CLI's (C-007/NFR-002).
pub(super) fn create_issue_from_template(
    config: &Config,
    store: &Store,
    template_id: &str,
    scope: Option<&str>,
    fields: &cmd_templates::CreateFields<'_>,
    resolution: &crate::workflow::CategoryResolution,
) -> ToolOutcome {
    // Resolve against a freshly loaded config, not the one captured at server
    // start: a template written by `create_template` earlier in the same MCP
    // session must be visible here. The management handlers already re-load per
    // call; without this, `create_issue` would contradict `list_templates`.
    // Falls back to the session config if the reload fails.
    let fresh = template_management_config().ok().map(|(config, _)| config);
    let config: &Config = fresh.as_ref().unwrap_or(config);

    let selection = match cmd_templates::selection_for(template_id, scope) {
        Ok(selection) => selection,
        Err(error) => {
            return Ok(template_error_result(
                crate::error::render_error(&error),
                template_outcome_value(
                    "create",
                    "rejected",
                    None,
                    vec![json!({
                        "kind": "invalid-scope",
                        "field": "scope",
                        "message": error.to_string(),
                    })],
                ),
            ));
        }
    };

    let resolved = resolve(config, &selection, &fields.to_input());
    if !resolved.is_ok() {
        let diagnostics: Vec<Value> = resolved
            .diagnostics()
            .iter()
            .map(|diagnostic| {
                json!({
                    "kind": diagnostic.kind().as_str(),
                    "field": diagnostic.field(),
                    "message": diagnostic.message(),
                })
            })
            .collect();
        return Ok(template_error_result(
            crate::error::render_error(&cmd_templates::resolution_error(&resolved)),
            template_outcome_value("create", "rejected", None, diagnostics),
        ));
    }

    let template_id = resolved.template_id().to_string();
    let scope = resolved.scope().to_string();
    let request = resolved
        .into_request()
        .expect("a successful resolution always carries a request");
    match cmd_templates::apply_request(config, store, &request) {
        Ok(issue) => {
            let mut value = issue_value(&issue, resolution);
            // Provenance, not a second issue schema: the stable ID and the
            // explicit scope the caller's creation used (data-model §7).
            value["template"] = json!({ "id": template_id, "scope": scope });
            Ok(success_result(format!("Created {}", issue.filename), value))
        }
        Err(e) => Ok(error_result(crate::error::render_error(&e))),
    }
}

// ---------------------------------------------------------------------------
// WP07 / T031–T034: the project tools, and WP07 / T033–T036: the epic tools
// ---------------------------------------------------------------------------
//
// Every handler below reaches behaviour through `crate::projects` or
// `crate::epics` (or the store's service-backed association/reference write):
// identity, resolution, uniqueness, membership, hierarchy, progress and health
// all have exactly one owner, and a rule or a figure appearing in this layer
// would be the second implementation C-008 prohibits. The registry is re-read
// from disk per call, exactly as `template_management_config` does it, so a
// project or epic this session created is visible to the next read.

/// The registry and the `tix.toml` path a project or epic mutation writes.
///
/// Re-read per call: the server holds the `Config` it bootstrapped with, so a
/// read that used that snapshot would not see a project or epic this session
/// just created — and the CLI, a fresh process each time, always does. The path
/// is found through the store's issue directory (the same upward walk every
/// other command uses) rather than the process cwd, so the file is the one the
/// rest of the session reads.
pub(super) fn registry_config(store: &Store) -> TixResult<(Config, std::path::PathBuf)> {
    let path = crate::commands::views::config_path(store);
    let root = path.parent().map_or_else(
        || std::path::PathBuf::from("."),
        std::path::Path::to_path_buf,
    );
    let config = Config::load(&root)?;
    Ok((config, path))
}

/// A [`Store`] over the same issue directory but a freshly loaded [`Config`].
///
/// The store's service-backed association and reference writes resolve their
/// project/epic reference through `store.config`, so a mutation has to be applied
/// through a store that already holds the registry on disk — otherwise a project
/// or epic created earlier in the same MCP session would read as unknown to the
/// write.
pub(super) fn store_with_config(store: &Store, config: Config) -> Store {
    Store {
        issues_dir: store.issues_dir.clone(),
        config,
    }
}

/// Persist a registry change through the one atomic-save path.
///
/// `validate` runs first, so a change that would make `tix.toml` unloadable is
/// refused before it is written, and the wording is the CLI's
/// (`commands::project::save` / `commands::epic::save` perform the same two
/// steps).
pub(super) fn save_registry_config(config: &mut Config, path: &Path) -> TixResult<()> {
    config.validate()?;
    config.save(path).map_err(|error| {
        crate::error::TixError::Config(format!("could not save `{}`: {error}", path.display()))
    })
}

/// A trimmed, non-blank optional string, or `None` — the CLI's `trim_optional`.
pub(super) fn trimmed_optional(value: Option<String>) -> Option<String> {
    value
        .map(|text| text.trim().to_string())
        .filter(|text| !text.is_empty())
}

/// A blank value clears an optional text field; a written one sets it.
pub(super) fn 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),
    }
}

/// A blank value clears a date field; a written one is passed through trimmed.
pub(super) fn date_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()),
    }
}

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

/// One dangling association as JSON, exactly as the service reports it (C-005).
pub(super) fn missing_reference_value(missing: &crate::projects::MissingProjectReference) -> Value {
    json!({
        "issue": missing.issue.number(),
        "issue_display": missing.issue_display,
        "project_id": missing.project_id,
        "reason": missing.reason,
    })
}

/// A project's registry record as JSON — raw values, no derived figure.
pub(super) fn project_record_value(project: &crate::projects::Project) -> Value {
    json!({
        "id": project.id.as_str(),
        "name": project.name,
        "description": project.description,
        "status": project.status,
        "lead": project.lead,
        "start_date": project.start_date,
        "due_date": project.due_date,
        "member_count": project.member_count,
    })
}

/// A project's record plus its ordered members, as JSON.
pub(super) fn project_value(project: &crate::projects::Project) -> Value {
    let mut value = project_record_value(project);
    value["members"] = json!(
        project
            .members
            .iter()
            .map(member_value)
            .collect::<Vec<Value>>()
    );
    value
}

/// The typed progress and health **values**, flattened to the raw figures
/// (`done`, `total`, `overdue`, `blocked`, `unassigned`, `missing_references`).
///
/// No formatted summary and no computed percentage: 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 project_health_value(report: &crate::projects::ProjectHealthReport) -> Value {
    json!({
        "project_id": report.project_id.as_str(),
        "name": report.name,
        "done": report.progress.done,
        "total": report.progress.total,
        "overdue": report.health.overdue,
        "blocked": report.health.blocked,
        "unassigned": report.health.unassigned,
        "missing_references": report
            .health
            .missing_references
            .iter()
            .map(missing_reference_value)
            .collect::<Vec<Value>>(),
    })
}

/// The `tix project list` rows: `id<TAB>name<TAB>member count`, one per project.
pub(super) fn project_rows_text(projects: &[crate::projects::Project]) -> String {
    projects
        .iter()
        .map(|project| format!("{}\t{}\t{}", project.id, project.name, project.member_count))
        .collect::<Vec<String>>()
        .join("\n")
}

/// The `tix project show` record, figure and member lines, in the CLI's shape.
///
/// Produced by the CLI's own [`crate::commands::project::record_lines`], so the two
/// surfaces cannot describe one record two ways — the same reason every other
/// shared renderer exists. `report` is the service's typed value for the project,
/// read once by the caller (C-006).
pub(super) fn project_record_text(
    project: &crate::projects::Project,
    report: Option<&crate::projects::ProjectHealthReport>,
) -> String {
    crate::commands::project::record_lines(project, report).join("\n")
}

/// One project's figures, on one line, for the text mirror.
///
/// `{display} {done}/{total}{suffix}` — the **same** string the CLI's detail block
/// and its `--show progress` column render, through the same
/// [`crate::commands::view::health_suffix`] renderer. One renderer means the two
/// surfaces cannot word the same value differently, and the parity matrix can
/// compare the two channels as text.
pub(super) fn project_health_text(report: &crate::projects::ProjectHealthReport) -> String {
    format!(
        "{} {}/{}{}",
        crate::projects::project_display(&report.project_id, &report.name),
        report.progress.done,
        report.progress.total,
        crate::commands::view::health_suffix(report),
    )
}

/// The delete-time impact notice, in the CLI's own words.
///
/// One line per affected issue; empty when nothing references the project. The
/// notice is what lets a caller warn a user instead of silently orphaning work,
/// and the affected list is returned structurally beside it.
pub(super) fn deletion_notice(deletion: &crate::projects::ProjectDeletion) -> String {
    if deletion.affected.is_empty() {
        return String::new();
    }
    let plural = if deletion.affected.len() == 1 {
        "issue"
    } else {
        "issues"
    };
    let mut out = format!(
        "Note: {} {plural} still reference this project and keep the reference as a \
         missing project reference.\nThey are not deleted or modified; `tix lint` reports \
         the dangling reference until it is repaired.\n",
        deletion.affected.len()
    );
    for member in &deletion.affected {
        out.push_str(&format!("  {}\n", member.display));
    }
    out
}

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