use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{Context, Result, bail};
use greentic_deploy_spec::SecretRef;
use serde_json::Value;
use super::backend::SetupBackend;
use super::legacy_formspec::normalize_answer_value;
use super::{
FormSpec, PersistedSetupState, QuestionSpec, SETUP_STATE_DIR, SETUP_STATE_SCHEMA_VERSION,
SetupSpecInput,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetupInstruction {
pub provider_id: String,
pub spec_input: SetupSpecInput,
pub answers: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetupPersistenceResult {
pub states: Vec<PersistedSetupState>,
pub writes: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct SetupScope<'a> {
pub env_id: &'a str,
pub bundle_id: &'a str,
}
pub fn collect_setup_instructions(
specs: &BTreeMap<String, Value>,
answers: &BTreeMap<String, Value>,
) -> Result<Vec<SetupInstruction>> {
let mut provider_ids = specs.keys().cloned().collect::<Vec<_>>();
provider_ids.sort();
provider_ids.dedup();
let mut instructions = Vec::new();
for provider_id in provider_ids {
let spec_input = parse_spec_input(
specs
.get(&provider_id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("missing setup spec for {provider_id}"))?,
)?;
let provider_answers = answers
.get(&provider_id)
.cloned()
.unwrap_or_else(|| Value::Object(Default::default()));
instructions.push(SetupInstruction {
provider_id,
spec_input,
answers: provider_answers,
});
}
Ok(instructions)
}
pub fn persist_setup(
root: &Path,
instructions: &[SetupInstruction],
backend: &dyn SetupBackend,
scope: &SetupScope<'_>,
) -> Result<SetupPersistenceResult> {
let mut states = Vec::new();
let mut writes = Vec::new();
for instruction in instructions {
let state = build_state(instruction, scope)?;
reject_env_id_remint(root, &state)?;
if let Some(path) = backend.persist(&state)? {
writes.push(relative_display(root, &path));
} else {
writes.push(format!(
"{SETUP_STATE_DIR}/{}.json",
instruction.provider_id
));
}
states.push(state);
}
writes.sort();
writes.dedup();
Ok(SetupPersistenceResult { states, writes })
}
fn reject_env_id_remint(root: &Path, state: &PersistedSetupState) -> Result<()> {
let path = root
.join(SETUP_STATE_DIR)
.join(format!("{}.json", state.provider_id));
let Ok(bytes) = std::fs::read(&path) else {
return Ok(());
};
let existing: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => bail!(
"persisted setup state at `{}` is corrupt JSON ({e}), refusing to overwrite (delete the file and re-run the wizard)",
path.display()
),
};
let existing_env = existing.get("env_id").and_then(|v| v.as_str());
match existing_env {
Some(env) if env == state.env_id => Ok(()),
Some(env) => bail!(
"persisted setup state at `{}` was minted under env `{env}`, refusing to overwrite under env `{}` (use a different bundle_id to keep both envs)",
path.display(),
state.env_id
),
None => bail!(
"persisted setup state at `{}` has no env_id (pre-C7 schema), refusing to overwrite under env `{}` (delete the file and re-run the wizard to mint fresh state)",
path.display(),
state.env_id
),
}
}
fn build_state(
instruction: &SetupInstruction,
scope: &SetupScope<'_>,
) -> Result<PersistedSetupState> {
let (source_kind, form) =
super::form_spec_from_input(&instruction.spec_input, &instruction.provider_id)?;
validate_ref_segment("env_id", scope.env_id)?;
validate_ref_segment("bundle_id", scope.bundle_id)?;
validate_ref_segment("provider_id", &instruction.provider_id)?;
let mut normalized_answers = normalize_answers(&form, &instruction.answers)?;
let mut non_secret_config = BTreeMap::new();
let mut secret_refs = BTreeMap::new();
let mut seen_ids = BTreeSet::new();
for question in &form.questions {
if !seen_ids.insert(question.id.clone()) {
bail!(
"duplicate question id `{}` in setup spec for provider `{}`",
question.id,
instruction.provider_id
);
}
let Some(value) = normalized_answers.get(&question.id).cloned() else {
continue;
};
if question.secret {
validate_ref_segment("question.id", &question.id)?;
normalized_answers.remove(&question.id);
let uri = format!(
"secret://{}/{}/{}/{}",
scope.env_id, scope.bundle_id, instruction.provider_id, question.id
);
let secret_ref = SecretRef::try_new(uri)
.with_context(|| format!("mint secret ref for answer {}", question.id))?;
secret_refs.insert(question.id.clone(), secret_ref);
} else {
non_secret_config.insert(question.id.clone(), value);
}
}
if !secret_refs.is_empty() {
tracing::debug!(
provider = %instruction.provider_id,
count = secret_refs.len(),
"recorded secret refs in setup state — backend population is the consuming wizard pipeline's responsibility (qa_persist → secrets backend)",
);
}
Ok(PersistedSetupState {
schema_version: SETUP_STATE_SCHEMA_VERSION,
env_id: scope.env_id.to_string(),
provider_id: instruction.provider_id.clone(),
source_kind,
form,
normalized_answers,
non_secret_config,
secret_refs,
})
}
fn validate_ref_segment(label: &str, value: &str) -> Result<()> {
if value.is_empty() {
bail!("{label} must not be empty when minting a secret ref");
}
if value.contains('/') {
bail!("{label} `{value}` contains '/' which would corrupt the secret-ref URI path");
}
Ok(())
}
fn normalize_answers(form: &FormSpec, answers: &Value) -> Result<BTreeMap<String, Value>> {
let map = answers
.as_object()
.ok_or_else(|| anyhow::anyhow!("setup answers must be a JSON object"))?;
let mut normalized = BTreeMap::new();
for question in &form.questions {
match map.get(&question.id) {
Some(value) => {
normalized.insert(
question.id.clone(),
normalize_question_answer(question, value)?,
);
}
None if question.required => bail!("missing required setup answer for {}", question.id),
None => {
if let Some(default) = &question.default_value {
normalized.insert(question.id.clone(), default.clone());
}
}
}
}
Ok(normalized)
}
fn normalize_question_answer(question: &QuestionSpec, value: &Value) -> Result<Value> {
normalize_answer_value(question, value)
}
fn parse_spec_input(value: Value) -> Result<SetupSpecInput> {
serde_json::from_value(value).map_err(Into::into)
}
fn relative_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}