greentic-x 0.4.10

Greentic-X CLI for catalog-driven composition, scaffolding, validation, and simulation.
Documentation
mod answers;
mod bundle;
mod catalog;
mod compose;
mod handoff;
mod plan;
mod qa;
mod remote;

use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::Path;

use crate::i18n::{normalize_locale, resolve_locale};
use crate::{
    GX_WIZARD_ID, GX_WIZARD_SCHEMA_ID, GX_WIZARD_SCHEMA_VERSION, WizardAction,
    WizardAnswerDocument, WizardCommonArgs, WizardExecutionMode, WizardNormalizedAnswers,
    WizardPlanEnvelope, WizardPlanMetadata,
};
use serde_json::Value;

use answers::{load_wizard_answers, normalize_schema_version, normalize_wizard_answers};
use catalog::{DistributorCatalogFetcher, load_catalogs};
use compose::{generate_artifacts, write_generated_artifacts};
use handoff::{resolve_wizard_path, run_bundle_handoff};
use plan::{
    should_delegate_bundle_handoff, wizard_action_name, wizard_expected_writes,
    wizard_normalized_summary, wizard_plan_steps, wizard_warnings,
};
use qa::collect_interactive_answers;

#[allow(unused_imports)]
pub(crate) use handoff::bundle_handoff_invocation;

struct WizardSpec {
    ordered_step_list: Vec<crate::WizardPlanStep>,
}

struct WizardApplyResult {
    normalized_input_summary: BTreeMap<String, Value>,
    expected_file_writes: Vec<String>,
    warnings: Vec<String>,
}

pub(crate) fn run_wizard(
    cwd: &Path,
    action: WizardAction,
    args: WizardCommonArgs,
) -> Result<String, String> {
    let preferred_locale = normalize_locale(args.locale.as_deref().unwrap_or("en"));
    let target_schema_version = normalize_schema_version(
        args.schema_version
            .as_deref()
            .unwrap_or(GX_WIZARD_SCHEMA_VERSION),
        &preferred_locale,
    )?;
    let execution = match action {
        WizardAction::Validate => WizardExecutionMode::DryRun,
        WizardAction::Run | WizardAction::Apply => {
            if args.dry_run {
                WizardExecutionMode::DryRun
            } else {
                WizardExecutionMode::Execute
            }
        }
    };
    let mut document = load_wizard_answers(cwd, &args, &target_schema_version, &preferred_locale)?;
    if !args.catalog.is_empty() {
        let mut refs = document
            .answers
            .get("catalog_oci_refs")
            .and_then(Value::as_array)
            .map(|items| {
                items
                    .iter()
                    .filter_map(Value::as_str)
                    .map(ToOwned::to_owned)
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default();
        for catalog in &args.catalog {
            if !refs.iter().any(|existing| existing == catalog) {
                refs.push(catalog.clone());
            }
        }
        document.answers.insert(
            "catalog_oci_refs".to_owned(),
            Value::Array(refs.into_iter().map(Value::String).collect()),
        );
    }
    let locale = resolve_locale(
        args.locale.as_deref(),
        args.answers.as_ref().map(|_| document.locale.as_str()),
    );
    document.locale = locale.clone();
    document.schema_version = target_schema_version;
    document.answers.insert(
        "gx_action".to_owned(),
        Value::String(wizard_action_name(action).to_owned()),
    );
    let fetcher = DistributorCatalogFetcher;
    if should_collect_interactive_answers(action, execution, &args)
        && !collect_interactive_answers(cwd, &mut document, &fetcher)?
    {
        return Ok(String::new());
    }

    let normalized_answers = normalize_wizard_answers(
        cwd,
        &mut document,
        args.mode.as_deref(),
        &locale,
        matches!(execution, WizardExecutionMode::Execute),
    )?;

    let emit_answers_path = args
        .emit_answers
        .as_ref()
        .map(|path| resolve_wizard_path(cwd, path));
    if let Some(path) = emit_answers_path.as_ref() {
        write_answer_document(path, &document)?;
    }

    let applied = wizard_apply(
        cwd,
        action,
        execution,
        &args,
        &document,
        &normalized_answers,
        &locale,
    );
    wizard_execute_plan(
        cwd,
        action,
        execution,
        &args,
        &document,
        &normalized_answers,
        emit_answers_path.as_deref(),
    )?;

    let spec = WizardSpec {
        ordered_step_list: wizard_plan_steps(action, execution, &locale),
    };
    let plan = WizardPlanEnvelope {
        metadata: WizardPlanMetadata {
            wizard_id: GX_WIZARD_ID.to_owned(),
            schema_id: GX_WIZARD_SCHEMA_ID.to_owned(),
            schema_version: document.schema_version.clone(),
            locale,
            execution,
        },
        requested_action: wizard_action_name(action).to_owned(),
        target_root: cwd.display().to_string(),
        normalized_input_summary: applied.normalized_input_summary,
        ordered_step_list: spec.ordered_step_list,
        expected_file_writes: applied.expected_file_writes,
        warnings: applied.warnings,
    };
    serde_json::to_string_pretty(&plan)
        .map_err(|err| format!("failed to serialize wizard plan: {err}"))
}

pub(crate) fn run_default_wizard(cwd: &Path, args: WizardCommonArgs) -> Result<String, String> {
    if should_run_interactive_session(&args) {
        run_interactive_session(cwd, args)?;
        return Ok(String::new());
    }
    run_wizard(cwd, WizardAction::Run, args)
}

fn should_collect_interactive_answers(
    action: WizardAction,
    execution: WizardExecutionMode,
    args: &WizardCommonArgs,
) -> bool {
    args.answers.is_none()
        && matches!(action, WizardAction::Run | WizardAction::Apply)
        && matches!(execution, WizardExecutionMode::Execute)
        && !is_automated_context()
        && std::io::stdin().is_terminal()
        && std::io::stdout().is_terminal()
}

fn should_run_interactive_session(args: &WizardCommonArgs) -> bool {
    args.answers.is_none()
        && args.emit_answers.is_none()
        && !args.dry_run
        && !is_automated_context()
        && std::io::stdin().is_terminal()
        && std::io::stdout().is_terminal()
}

fn is_automated_context() -> bool {
    cfg!(test)
        || std::env::var_os("RUST_TEST_THREADS").is_some()
        || std::env::var_os("CI").is_some()
        || std::env::var_os("GX_WIZARD_NON_INTERACTIVE").is_some()
}

fn wizard_apply(
    cwd: &Path,
    action: WizardAction,
    execution: WizardExecutionMode,
    args: &WizardCommonArgs,
    document: &WizardAnswerDocument,
    normalized_answers: &WizardNormalizedAnswers,
    locale: &str,
) -> WizardApplyResult {
    WizardApplyResult {
        normalized_input_summary: wizard_normalized_summary(action, document, normalized_answers),
        expected_file_writes: wizard_expected_writes(
            cwd,
            action,
            execution,
            args,
            normalized_answers,
        ),
        warnings: wizard_warnings(normalized_answers, locale),
    }
}

fn wizard_execute_plan(
    cwd: &Path,
    action: WizardAction,
    execution: WizardExecutionMode,
    args: &WizardCommonArgs,
    _document: &WizardAnswerDocument,
    normalized_answers: &WizardNormalizedAnswers,
    emit_answers_path: Option<&Path>,
) -> Result<(), String> {
    if !matches!(execution, WizardExecutionMode::Execute) {
        return Ok(());
    }
    let fetcher = DistributorCatalogFetcher;
    let WizardNormalizedAnswers::Composition(request) = normalized_answers;
    let catalogs = load_catalogs(cwd, &request.catalog_oci_refs, &fetcher)?;
    let generated = generate_artifacts(cwd, request, &catalogs, "en", true, &fetcher)?;
    write_generated_artifacts(cwd, request, &generated)?;

    if should_delegate_bundle_handoff(action, execution, args, normalized_answers) {
        let bundle_answers_path = emit_answers_path
            .map(Path::to_path_buf)
            .unwrap_or_else(|| resolve_wizard_path(cwd, Path::new(&request.bundle_answers_path)));
        if emit_answers_path.is_none() {
            write_answer_document(&bundle_answers_path, &generated.bundle_answers)?;
        }
        run_bundle_handoff(cwd, &bundle_answers_path)?;
    }
    Ok(())
}

fn run_interactive_session(cwd: &Path, args: WizardCommonArgs) -> Result<(), String> {
    loop {
        let mut session_args = args.clone();
        session_args.bundle_handoff = true;
        let plan_json = run_wizard(cwd, WizardAction::Apply, session_args)?;
        if plan_json.trim().is_empty() {
            return Ok(());
        }
        let plan: WizardPlanEnvelope = serde_json::from_str(&plan_json)
            .map_err(|err| format!("failed to parse interactive wizard result: {err}"))?;
        print_completion_message(cwd, &plan)?;
    }
}

fn print_completion_message(cwd: &Path, plan: &WizardPlanEnvelope) -> Result<(), String> {
    let path = plan
        .normalized_input_summary
        .get("bundle_output_path")
        .and_then(Value::as_str)
        .ok_or_else(|| "wizard result missing bundle_output_path".to_owned())?;
    let resolved = resolve_wizard_path(cwd, Path::new(path));
    if !resolved.exists() {
        return Err(format!(
            "wizard reported bundle output {}, but the file was not created",
            resolved.display()
        ));
    }
    println!("Solution created successfully.");
    println!();
    println!("Generated bundle: {}", resolved.display());
    println!();
    println!("M) Main menu");
    println!("0) Exit");
    println!();
    Ok(())
}

fn write_answer_document(path: &Path, document: &WizardAnswerDocument) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
    }
    let rendered = serde_json::to_string_pretty(document)
        .map_err(|err| format!("failed to serialize answer document: {err}"))?;
    std::fs::write(path, format!("{rendered}\n"))
        .map_err(|err| format!("failed to write {}: {err}", path.display()))
}