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 create_project(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let name = req_string(args, "name")?;
    let description = opt_string(args, "description")?;
    let status = opt_string(args, "status")?;
    let lead = opt_string(args, "lead")?;
    // Dates are trimmed and a blank one is absent, exactly as `tix project create`
    // normalizes them; the other optional fields are kept verbatim.
    let start_date = trimmed_optional(opt_string(args, "start_date")?);
    let due_date = trimmed_optional(opt_string(args, "due_date")?);
    let (mut config, path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let new = crate::projects::NewProject {
        name,
        description,
        status,
        lead,
        start_date,
        due_date,
    };
    // The service allocates the stable id and validates name and dates, so an
    // invalid request refuses here with the registry untouched.
    match crate::projects::create_project(&mut config, &new) {
        Ok(project) => {
            if let Err(e) = save_registry_config(&mut config, &path) {
                return Ok(error_result(crate::error::render_error(&e)));
            }
            Ok(success_result(
                format!(
                    "Created {}",
                    crate::projects::project_display(&project.id, &project.name)
                ),
                project_value(&project),
            ))
        }
        Err(error) => Ok(project_refusal(error)),
    }
}

pub(super) fn delete_project(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "project")?;
    // The schema declares `confirm` required, but a schema is documentation a
    // client may ignore; checked before anything is resolved, so a forgotten
    // confirmation writes nothing (the convention `delete_template` follows).
    if opt_bool(args, "confirm")? != Some(true) {
        return Ok(error_result(
            "invalid input: `confirm` must be true to delete a project — only the registry \
             entry is removed and no issue is deleted"
                .to_string(),
        ));
    }
    let (mut config, path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let issues = match store.list_all() {
        Ok(issues) => issues,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // Removes the registry entry **only**; the service reports every issue that
    // still references it, so this response carries the CLI's impact report and
    // no issue is ever deleted (C-004, C-005).
    match crate::projects::delete_project(&mut config, &reference, &issues) {
        Ok(deletion) => {
            if let Err(e) = save_registry_config(&mut config, &path) {
                return Ok(error_result(crate::error::render_error(&e)));
            }
            let affected: Vec<Value> = deletion.affected.iter().map(member_value).collect();
            let text = format!(
                "Deleted project {}\n{}",
                crate::projects::project_display(&deletion.id, &deletion.name),
                deletion_notice(&deletion),
            );
            Ok(success_result(
                text,
                json!({
                    "id": deletion.id.as_str(),
                    "name": deletion.name,
                    "affected": affected,
                }),
            ))
        }
        Err(error) => Ok(project_refusal(error)),
    }
}

pub(super) fn get_project(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "project")?;
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let issues = match store.list_all() {
        Ok(issues) => issues,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // The figures are the service's typed value, over the **same snapshot** this
    // read enumerates (`list_all`, as `tix project show` and `tix project list`
    // do), so `get_project` mirrors `tix project show` including its progress and
    // health lines rather than dropping them (C-006, `contracts/cli-surface.md` §1).
    let registry = store_with_config(store, config);
    let reports = match crate::commands::view::project_reports(&registry, &issues) {
        Ok((_, reports)) => reports,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    match crate::projects::resolve(&registry.config, &issues, &reference) {
        Ok(project) => Ok(success_result(
            project_record_text(&project, reports.get(&project.id)),
            project_value(&project),
        )),
        Err(error) => Ok(project_refusal(error)),
    }
}

pub(super) fn list_projects(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let issues = match store.list_all() {
        Ok(issues) => issues,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // The service's own listing: ids, names and derived member counts, in
    // declaration order. No count is computed here.
    let projects = crate::projects::list_projects(&config, &issues);
    let structured: Vec<Value> = projects.iter().map(project_record_value).collect();
    Ok(success_result(
        project_rows_text(&projects),
        json!({ "projects": structured }),
    ))
}

pub(super) fn project_associate_issues(
    _config: &Config,
    store: &Store,
    args: &Value,
) -> ToolOutcome {
    let reference = req_string(args, "project")?;
    let selectors = crate::commands::parse_csv_list(&req_string(args, "issues")?);
    if selectors.is_empty() {
        return Ok(error_result(
            "invalid input: `issues` must name at least one issue selector".to_string(),
        ));
    }
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let registry = store_with_config(store, config);
    // Resolved **once**, up front, so an unknown project refuses before any write
    // (mirroring `tix project add`). The id, not the reference, is what the
    // service-backed bulk write receives.
    let id = match crate::projects::resolve_project_id(&registry.config, &reference) {
        Ok(id) => id,
        Err(error) => return Ok(project_refusal(error)),
    };
    match registry.bulk_associate(id.as_str(), &selectors) {
        Ok(outcomes) => {
            let changed = outcomes
                .iter()
                .filter(|outcome| outcome.outcome.as_ref().is_ok_and(|a| a.changed))
                .count();
            let failed = outcomes
                .iter()
                .filter(|outcome| outcome.outcome.is_err())
                .count();
            let rows: Vec<Value> = outcomes.iter().map(association_outcome_value).collect();
            let changes: Vec<String> = outcomes
                .iter()
                .map(|outcome| match &outcome.outcome {
                    Ok(applied) if applied.changed => {
                        format!("{}: associated", outcome.selector)
                    }
                    Ok(_) => format!("{}: already associated", outcome.selector),
                    Err(error) => {
                        format!(
                            "{}: {}",
                            outcome.selector,
                            crate::error::render_error(error)
                        )
                    }
                })
                .collect();
            let text = format!(
                "associated {changed} of {} issue(s) with {id}\n{}",
                outcomes.len(),
                changes.join("\n")
            );
            Ok(success_result(
                text,
                json!({
                    "project_id": id.as_str(),
                    "changed": changed,
                    "failed": failed,
                    "total": outcomes.len(),
                    "outcomes": rows,
                }),
            ))
        }
        Err(error) => Ok(error_result(crate::error::render_error(&error))),
    }
}

pub(super) fn project_dissociate_issues(
    _config: &Config,
    store: &Store,
    args: &Value,
) -> ToolOutcome {
    let reference = req_string(args, "project")?;
    let selectors = crate::commands::parse_csv_list(&req_string(args, "issues")?);
    if selectors.is_empty() {
        return Ok(error_result(
            "invalid input: `issues` must name at least one issue selector".to_string(),
        ));
    }
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let registry = store_with_config(store, config);
    // Resolve when the reference names a live project; otherwise use it
    // **verbatim**, which is the repair path C-005 promises and exactly what
    // `tix project remove` does. The rule that decides is the service's resolver,
    // not a second classification here.
    let needle = crate::projects::resolve_project_id(&registry.config, &reference)
        .map_or_else(|_| reference.clone(), |id| id.to_string());
    let mut rows: Vec<Value> = Vec::with_capacity(selectors.len());
    let mut changes: Vec<String> = Vec::with_capacity(selectors.len());
    let mut changed = 0usize;
    let mut failed = 0usize;
    for selector in &selectors {
        match registry.dissociate(selector, &needle) {
            Ok(applied) => {
                if applied.changed {
                    changed += 1;
                    changes.push(format!("{selector}: dissociated"));
                } else {
                    changes.push(format!("{selector}: was not associated"));
                }
                rows.push(json!({
                    "selector": selector,
                    "changed": applied.changed,
                    "project_ids": applied.project_ids,
                }));
            }
            Err(error) => {
                failed += 1;
                changes.push(format!(
                    "{selector}: {}",
                    crate::error::render_error(&error)
                ));
                rows.push(
                    json!({ "selector": selector, "error": crate::error::render_error(&error) }),
                );
            }
        }
    }
    let text = format!(
        "dissociated {changed} of {} issue(s) from {needle}\n{}",
        selectors.len(),
        changes.join("\n")
    );
    Ok(success_result(
        text,
        json!({
            "project_id": needle,
            "changed": changed,
            "failed": failed,
            "total": selectors.len(),
            "outcomes": rows,
        }),
    ))
}

pub(super) fn project_membership(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "project")?;
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // The **active** set (`list`), which is the snapshot every figure-bearing read
    // uses: `tix show`'s project block, `tix list --show progress`, and the
    // `list_issues {project}` filter all read the active set, so membership must
    // too or the filter and this read would disagree on an archived member. The
    // registry verbs (`tix project list` / `show`) are the ones that see every
    // issue file, and `list_projects` / `get_project` mirror them.
    let issues = match store.list() {
        Ok(issues) => issues,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // The service's derived membership — the same value the CLI reads and the same
    // set `list_issues {project}` filters to. Read-only: nothing is written.
    match crate::projects::membership(&config, &issues, &reference) {
        Ok(membership) => {
            let members: Vec<Value> = membership.members.iter().map(member_value).collect();
            let text = format!(
                "{}: {} member(s)",
                membership.project_id, membership.member_count
            );
            Ok(success_result(
                text,
                json!({
                    "project_id": membership.project_id.as_str(),
                    "member_count": membership.member_count,
                    "members": members,
                }),
            ))
        }
        Err(error) => Ok(project_refusal(error)),
    }
}

pub(super) fn update_project(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "project")?;
    let name = opt_string(args, "name")?;
    let description = opt_string(args, "description")?;
    let status = opt_string(args, "status")?;
    let lead = opt_string(args, "lead")?;
    let start_date = opt_string(args, "start_date")?;
    let due_date = opt_string(args, "due_date")?;
    // The patch shape `tix project edit` builds: `--name` always sets (a name is
    // never cleared, which the service refuses as a duplicate-name diagnostic),
    // while the other fields follow the blank-clears convention.
    let edit = crate::projects::ProjectEdit {
        name: name.map_or(
            crate::projects::Patch::Unchanged,
            crate::projects::Patch::Set,
        ),
        description: text_patch(description),
        status: text_patch(status),
        lead: text_patch(lead),
        start_date: date_patch(start_date),
        due_date: date_patch(due_date),
    };
    if edit.name.is_unchanged()
        && edit.description.is_unchanged()
        && edit.status.is_unchanged()
        && edit.lead.is_unchanged()
        && edit.start_date.is_unchanged()
        && edit.due_date.is_unchanged()
    {
        // The CLI's wording for the same refusal, from the one shared helper.
        return Ok(error_result(crate::error::render_error(
            &crate::commands::project_edit_requires_a_field(),
        )));
    }
    let (mut config, path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    match crate::projects::edit_project(&mut config, &reference, &edit) {
        Ok(project) => {
            if let Err(e) = save_registry_config(&mut config, &path) {
                return Ok(error_result(crate::error::render_error(&e)));
            }
            // Re-resolve through an index so the response carries the members: a
            // registry-only edit has no issue set to derive from and would report
            // `member_count: 0` for a project that has members.
            let issues = match store.list_all() {
                Ok(issues) => issues,
                Err(e) => return Ok(error_result(crate::error::render_error(&e))),
            };
            match crate::projects::resolve(&config, &issues, project.id.as_str()) {
                Ok(project) => Ok(success_result(
                    format!(
                        "Updated {}",
                        crate::projects::project_display(&project.id, &project.name)
                    ),
                    project_value(&project),
                )),
                Err(error) => Ok(project_refusal(error)),
            }
        }
        Err(error) => Ok(project_refusal(error)),
    }
}

pub(super) fn dispatch(
    config: &Config,
    store: &Store,
    name: &str,
    arguments: &Value,
) -> Option<ToolOutcome> {
    match name {
        "list_projects" => Some(list_projects(config, store, arguments)),
        "get_project" => Some(get_project(config, store, arguments)),
        "create_project" => Some(create_project(config, store, arguments)),
        "update_project" => Some(update_project(config, store, arguments)),
        "delete_project" => Some(delete_project(config, store, arguments)),
        "project_membership" => Some(project_membership(config, store, arguments)),
        "project_associate_issues" => Some(project_associate_issues(config, store, arguments)),
        "project_dissociate_issues" => Some(project_dissociate_issues(config, store, arguments)),
        _ => None,
    }
}