clap-tui 0.1.0

Auto-generate a TUI from clap commands
Documentation
use std::collections::BTreeMap;

use crate::input::{AppState, ArgInput, ArgInputState, InputSource};
use crate::pipeline::ValidationState;
use crate::spec::{ArgSpec, CommandModel, CommandPath};

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct FieldInstanceId {
    pub(crate) owner_path: CommandPath,
    pub(crate) arg_id: String,
}

impl FieldInstanceId {
    pub(crate) fn from_arg(arg: &ArgSpec) -> Self {
        Self {
            owner_path: arg.owner_path().clone(),
            arg_id: arg.id.clone(),
        }
    }
}

#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum FieldVisibility {
    #[default]
    Visible,
    Hidden,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum FieldActivity {
    #[default]
    Active,
    NeutralInherited,
    Disabled,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum FieldConflictState {
    #[default]
    None,
    PotentialPathConflict,
    ActualValidationConflict,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FieldSemantics {
    pub(crate) id: FieldInstanceId,
    pub(crate) arg_id: String,
    pub(crate) owner_path: CommandPath,
    pub(crate) visibility: FieldVisibility,
    pub(crate) activity: FieldActivity,
    pub(crate) conflict: FieldConflictState,
    pub(crate) required_badge: bool,
    pub(crate) can_edit: bool,
    pub(crate) reason: Option<String>,
}

pub(crate) fn derive_field_semantics(
    state: &AppState,
    validation: &ValidationState,
) -> BTreeMap<FieldInstanceId, FieldSemantics> {
    let selected_path = state.domain.selected_path();
    let current_form = state.domain.current_form().unwrap_or_default();
    state
        .domain
        .root
        .effective_args_for_path(selected_path)
        .into_iter()
        .flatten()
        .filter(|(_, arg)| !arg.is_external_subcommand_field() || arg.owner_path() == selected_path)
        .map(|(_, arg)| {
            let id = FieldInstanceId::from_arg(arg);
            let owner_path = arg.owner_path().clone();
            let inherited = arg.is_inherited_for(selected_path);
            let requirement_negated =
                inherited && owner_negates_requirements(&state.domain.root, selected_path, arg);
            let path_conflict = inherited
                && owner_args_conflict_with_selected_subcommand(
                    &state.domain.root,
                    selected_path,
                    arg,
                );
            let authored = current_form
                .input(&arg.id)
                .is_some_and(input_state_is_user_provided);
            let actual_validation_conflict =
                path_conflict && authored && validation.field_errors.contains_key(&arg.id);

            let conflict = if actual_validation_conflict {
                FieldConflictState::ActualValidationConflict
            } else if path_conflict {
                FieldConflictState::PotentialPathConflict
            } else {
                FieldConflictState::None
            };

            let activity = if path_conflict && !authored {
                FieldActivity::Disabled
            } else if inherited {
                FieldActivity::NeutralInherited
            } else {
                FieldActivity::Active
            };

            let reason = match (conflict, activity) {
                (FieldConflictState::ActualValidationConflict, _) => {
                    Some("Conflicts with the selected subcommand.".to_string())
                }
                (FieldConflictState::PotentialPathConflict, FieldActivity::Disabled) => {
                    Some("Disabled because it conflicts with the selected subcommand.".to_string())
                }
                (FieldConflictState::PotentialPathConflict, _) => {
                    Some("May conflict with the selected subcommand.".to_string())
                }
                _ => None,
            };

            (
                id.clone(),
                FieldSemantics {
                    id,
                    arg_id: arg.id.clone(),
                    owner_path,
                    visibility: FieldVisibility::Visible,
                    activity,
                    conflict,
                    required_badge: arg.required && !requirement_negated,
                    can_edit: activity != FieldActivity::Disabled,
                    reason,
                },
            )
        })
        .collect()
}

fn owner_negates_requirements(
    root: &CommandModel,
    selected_path: &CommandPath,
    arg: &ArgSpec,
) -> bool {
    selected_path_descends_from(selected_path, arg.owner_path())
        && root
            .resolve_path(arg.owner_path().as_slice())
            .is_some_and(|owner| owner.parser_rules.subcommand_negates_reqs)
}

fn owner_args_conflict_with_selected_subcommand(
    root: &CommandModel,
    selected_path: &CommandPath,
    arg: &ArgSpec,
) -> bool {
    selected_path_descends_from(selected_path, arg.owner_path())
        && root
            .resolve_path(arg.owner_path().as_slice())
            .is_some_and(|owner| owner.parser_rules.args_conflicts_with_subcommands)
}

fn selected_path_descends_from(selected_path: &CommandPath, owner_path: &CommandPath) -> bool {
    selected_path.as_slice().len() > owner_path.as_slice().len()
        && selected_path.as_slice().starts_with(owner_path.as_slice())
}

fn input_state_is_user_provided(input: &ArgInputState) -> bool {
    if input.touched {
        return true;
    }

    match &input.value {
        ArgInput::Flag { present, source } => *present && *source == InputSource::User,
        ArgInput::Count {
            occurrences,
            source,
        } => *occurrences > 0 && *source == InputSource::User,
        ArgInput::Values { occurrences } => occurrences.iter().any(|occurrence| {
            occurrence.source == InputSource::User
                && occurrence.values.iter().any(|value| !value.is_empty())
        }),
    }
}