use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use vta_sdk::provision_client::EphemeralSetupKey;
use vti_common::error::AppError;
use crate::config::{MessagingConfig, SecretsConfig};
use super::wizard::{
SetupOutcome, WebvhTarget, WizardInputs, WizardPlan, apply, refuse_if_already_set_up,
};
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct VtcWizardInputs {
pub config_path: PathBuf,
pub base_url: String,
pub vta_did: String,
#[serde(default = "default_context")]
pub context: String,
#[serde(default)]
pub webvh: WebvhTarget,
#[serde(default)]
pub messaging: Option<MessagingConfig>,
pub secrets: SecretsConfig,
pub setup_key_file: PathBuf,
}
fn default_context() -> String {
"default".to_string()
}
pub(crate) fn parse_from_toml(file_path: &Path) -> Result<WizardPlan, AppError> {
let raw = std::fs::read_to_string(file_path)
.map_err(|e| AppError::Config(format!("read setup file {}: {e}", file_path.display())))?;
let inputs: VtcWizardInputs = toml::from_str(&raw)
.map_err(|e| AppError::Config(format!("parse setup file {}: {e}", file_path.display())))?;
validate(&inputs)?;
refuse_if_already_set_up(&inputs.config_path)?;
let setup_key = EphemeralSetupKey::load_from(&inputs.setup_key_file).map_err(|e| {
AppError::Config(format!(
"load setup key {}: {e}. Generate + persist an ephemeral setup key and grant its \
did:key an admin ACL at the VTA before running `vtc setup --from` (see the \
non-interactive setup docs).",
inputs.setup_key_file.display()
))
})?;
Ok(WizardPlan {
config_path: inputs.config_path,
inputs: WizardInputs {
base_url: inputs.base_url.trim_end_matches('/').to_string(),
vta_did: inputs.vta_did,
context: inputs.context,
},
webvh: inputs.webvh,
secrets: inputs.secrets,
messaging: inputs.messaging,
setup_key,
})
}
fn validate(inputs: &VtcWizardInputs) -> Result<(), AppError> {
let mut errors: Vec<String> = Vec::new();
let base = inputs.base_url.trim_end_matches('/');
if base.is_empty() {
errors.push("base_url must not be empty".into());
} else {
if !(base.starts_with("http://") || base.starts_with("https://")) {
errors.push(format!(
"base_url must start with http:// or https:// (got {:?})",
inputs.base_url
));
}
if base.ends_with("/v1") {
errors.push(
"base_url must be the host base, without the /v1 API prefix (the template \
appends API paths itself)"
.into(),
);
}
}
if !inputs.vta_did.starts_with("did:") {
errors.push(format!(
"vta_did must be a DID starting with `did:` (got {:?})",
inputs.vta_did
));
}
if inputs.context.trim().is_empty() {
errors.push("context must not be empty".into());
}
if let Some(messaging) = inputs.messaging.as_ref()
&& messaging.mediator_did.trim().is_empty()
{
errors.push("messaging.mediator_did must not be empty when [messaging] is present".into());
}
if errors.is_empty() {
Ok(())
} else {
Err(AppError::Validation(format!(
"setup file has {} validation error(s):\n - {}",
errors.len(),
errors.join("\n - ")
)))
}
}
pub async fn run_setup_from_file(file_path: PathBuf) -> Result<(), AppError> {
eprintln!(
"Running non-interactive VTC setup from {} ...",
file_path.display()
);
let plan = parse_from_toml(&file_path)?;
let outcome = apply(plan).await?;
print_setup_summary_terse(&outcome);
Ok(())
}
fn print_setup_summary_terse(outcome: &SetupOutcome) {
println!("VTC setup complete.");
println!("vtc_did={}", outcome.vtc_did);
println!("admin_did={}", outcome.admin_did);
println!("config_path={}", outcome.config_path.display());
println!("data_dir={}", outcome.data_dir.display());
println!("install_url={}", outcome.install_url);
println!("claim_code={}", outcome.claim_code);
if outcome.admin_key_json.is_some() {
println!(
"admin_key=<not printed in non-interactive mode; re-run interactively if you need it>"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_toml(config_path: &str, setup_key_file: &str) -> String {
format!(
r#"
config_path = "{config_path}"
base_url = "https://vtc.example.com"
vta_did = "did:webvh:vta.example.com:abc"
setup_key_file = "{setup_key_file}"
[secrets]
keyring_service = "vtc-test"
"#
)
}
fn persist_setup_key(dir: &Path) -> (PathBuf, String) {
let key = EphemeralSetupKey::generate().expect("generate setup key");
let did = key.did.clone();
let path = dir.join("setup-key.json");
key.persist_to(&path).expect("persist setup key");
(path, did)
}
#[test]
fn minimal_inputs_parse_and_default() {
let toml = minimal_toml("/srv/vtc/config.toml", "/tmp/key.json");
let inputs: VtcWizardInputs = toml::from_str(&toml).expect("parse");
assert_eq!(inputs.context, "default", "context defaults to `default`");
assert!(inputs.messaging.is_none());
assert!(inputs.webvh.server_id.is_none());
assert_eq!(inputs.secrets.keyring_service, "vtc-test");
}
#[test]
fn unknown_field_rejected() {
let toml = r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
bogus_field = true
[secrets]
"#;
let err = toml::from_str::<VtcWizardInputs>(toml).unwrap_err();
assert!(
err.to_string().contains("bogus_field"),
"deny_unknown_fields should reject bogus_field, got: {err}"
);
}
#[test]
fn full_inputs_parse() {
let toml = r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com/"
vta_did = "did:webvh:vta.example.com:abc"
context = "acme"
setup_key_file = "/secrets/vtc-setup-key.json"
[webvh]
server_id = "host-1"
domain = "tenant.example.com"
path = "communities/acme"
[messaging]
mediator_did = "did:web:mediator.example.com"
[secrets]
keyring_service = "vtc-acme"
"#;
let inputs: VtcWizardInputs = toml::from_str(toml).expect("parse");
assert_eq!(inputs.context, "acme");
assert_eq!(inputs.webvh.server_id.as_deref(), Some("host-1"));
assert_eq!(inputs.webvh.domain.as_deref(), Some("tenant.example.com"));
assert_eq!(inputs.webvh.path.as_deref(), Some("communities/acme"));
assert_eq!(
inputs.messaging.as_ref().unwrap().mediator_did,
"did:web:mediator.example.com"
);
}
#[test]
fn validate_rejects_bad_base_url_and_vta_did() {
let inputs: VtcWizardInputs = toml::from_str(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "vtc.example.com/v1"
vta_did = "not-a-did"
setup_key_file = "/tmp/key.json"
[secrets]
"#,
)
.expect("parse");
let err = validate(&inputs).unwrap_err().to_string();
assert!(err.contains("base_url must start with http"), "{err}");
assert!(err.contains("/v1"), "{err}");
assert!(err.contains("vta_did must be a DID"), "{err}");
}
#[test]
fn validate_rejects_empty_mediator_did() {
let inputs: VtcWizardInputs = toml::from_str(
r#"
config_path = "/srv/vtc/config.toml"
base_url = "https://vtc.example.com"
vta_did = "did:web:vta.example.com"
setup_key_file = "/tmp/key.json"
[messaging]
mediator_did = ""
[secrets]
"#,
)
.expect("parse");
let err = validate(&inputs).unwrap_err().to_string();
assert!(err.contains("messaging.mediator_did"), "{err}");
}
#[test]
fn parse_from_toml_builds_plan_with_loaded_key() {
let dir = tempfile::tempdir().expect("tempdir");
let (key_path, key_did) = persist_setup_key(dir.path());
let toml = format!(
r#"
config_path = "{cfg}"
base_url = "https://vtc.example.com/"
vta_did = "did:webvh:vta.example.com:abc"
setup_key_file = "{key}"
[secrets]
keyring_service = "vtc-test"
"#,
cfg = dir.path().join("config.toml").display(),
key = key_path.display(),
);
let toml_path = dir.path().join("setup.toml");
std::fs::write(&toml_path, toml).expect("write toml");
let plan = parse_from_toml(&toml_path).expect("parse_from_toml");
assert_eq!(
plan.setup_key.did, key_did,
"the plan must carry the pre-authorised key loaded from disk"
);
assert_eq!(
plan.inputs.base_url, "https://vtc.example.com",
"trailing slash trimmed"
);
assert_eq!(plan.inputs.context, "default");
}
#[test]
fn parse_from_toml_reports_missing_setup_key() {
let dir = tempfile::tempdir().expect("tempdir");
let toml = minimal_toml(
dir.path().join("config.toml").to_str().unwrap(),
dir.path().join("does-not-exist.json").to_str().unwrap(),
);
let toml_path = dir.path().join("setup.toml");
std::fs::write(&toml_path, toml).expect("write toml");
let err = match parse_from_toml(&toml_path) {
Ok(_) => panic!("expected a missing-setup-key error"),
Err(e) => e.to_string(),
};
assert!(err.contains("load setup key"), "{err}");
assert!(err.contains("admin ACL"), "actionable hint present: {err}");
}
#[test]
fn shipped_example_parses() {
let example = include_str!("../../../docs/03-vtc/examples/vtc-setup.example.toml");
let inputs: VtcWizardInputs = toml::from_str(example).expect("shipped example must parse");
assert!(inputs.base_url.starts_with("http"));
assert!(inputs.vta_did.starts_with("did:"));
validate(&inputs).expect("shipped example must validate");
}
}