1use std::path::Path;
2use std::path::PathBuf;
3
4use anyhow::Context;
5use serde_json::{Value as JsonValue, json};
6
7use crate::demo::runner_host::{DemoRunnerHost, OperatorContext};
8use crate::discovery::{self, DiscoveryOptions};
9use crate::domains::{Domain, ProviderPack};
10use crate::secrets_gate;
11use greentic_types::cbor::canonical;
12use greentic_types::decode_pack_manifest;
13use greentic_types::schemas::component::v0_6_0::ComponentQaSpec;
14
15#[derive(Debug, Clone, Copy, Eq, PartialEq)]
16pub enum QaMode {
17 Default,
18 Setup,
19 Upgrade,
20 Remove,
21}
22
23impl QaMode {
24 pub fn as_str(self) -> &'static str {
25 match self {
26 QaMode::Default => "default",
27 QaMode::Setup => "setup",
28 QaMode::Upgrade => "upgrade",
29 QaMode::Remove => "remove",
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, Eq, PartialEq)]
35pub enum QaDiagnosticCode {
36 QaSpecFailed,
37 QaSpecInvalid,
38 I18nExportMissing,
39 I18nKeyMissing,
40 ApplyAnswersFailed,
41 ConfigSchemaMismatch,
42}
43
44impl QaDiagnosticCode {
45 pub fn as_str(self) -> &'static str {
46 match self {
47 QaDiagnosticCode::QaSpecFailed => "OP_QA_SPEC_FAILED",
48 QaDiagnosticCode::QaSpecInvalid => "OP_QA_SPEC_INVALID",
49 QaDiagnosticCode::I18nExportMissing => "OP_I18N_EXPORT_MISSING",
50 QaDiagnosticCode::I18nKeyMissing => "OP_I18N_KEY_MISSING",
51 QaDiagnosticCode::ApplyAnswersFailed => "OP_APPLY_ANSWERS_FAILED",
52 QaDiagnosticCode::ConfigSchemaMismatch => "OP_CONFIG_SCHEMA_MISMATCH",
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
58pub struct QaDiagnostic {
59 pub code: QaDiagnosticCode,
60 pub message: String,
61}
62
63impl std::fmt::Display for QaDiagnostic {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 write!(f, "{}: {}", self.code.as_str(), self.message)
66 }
67}
68
69impl std::error::Error for QaDiagnostic {}
70
71pub fn qa_mode_for_flow(flow_id: &str) -> Option<QaMode> {
72 let normalized = flow_id.to_ascii_lowercase();
73 if normalized.contains("remove") {
74 Some(QaMode::Remove)
75 } else if normalized.contains("upgrade") {
76 Some(QaMode::Upgrade)
77 } else if normalized.contains("default") {
78 Some(QaMode::Default)
79 } else if normalized.contains("setup") {
80 Some(QaMode::Setup)
81 } else {
82 None
83 }
84}
85
86#[allow(clippy::too_many_arguments)]
87pub fn apply_answers_via_component_qa(
88 root: &Path,
89 domain: Domain,
90 tenant: &str,
91 team: Option<&str>,
92 pack: &ProviderPack,
93 provider_id: &str,
94 mode: QaMode,
95 current_config: Option<&JsonValue>,
96 answers: &JsonValue,
97) -> Result<Option<JsonValue>, QaDiagnostic> {
98 if !supports_component_qa_contract(&pack.path).map_err(|err| {
99 diagnostic(
100 QaDiagnosticCode::QaSpecFailed,
101 format!("inspect qa contract support: {err}"),
102 )
103 })? {
104 return Ok(None);
105 }
106
107 let cbor_only = root.join("greentic.demo.yaml").exists();
108 let discovery = discovery::discover_with_options(root, DiscoveryOptions { cbor_only })
109 .map_err(|err| {
110 diagnostic(
111 QaDiagnosticCode::QaSpecFailed,
112 format!("discover providers: {err}"),
113 )
114 })?;
115 let secrets_handle =
116 secrets_gate::resolve_secrets_manager(root, tenant, team).map_err(|err| {
117 diagnostic(
118 QaDiagnosticCode::QaSpecFailed,
119 format!("resolve secrets manager: {err}"),
120 )
121 })?;
122 let host = DemoRunnerHost::new(root.to_path_buf(), &discovery, None, secrets_handle, false)
123 .map_err(|err| {
124 diagnostic(
125 QaDiagnosticCode::QaSpecFailed,
126 format!("build runner host: {err}"),
127 )
128 })?;
129 let ctx = OperatorContext {
130 tenant: tenant.to_string(),
131 team: team.map(|value| value.to_string()),
132 correlation_id: None,
133 };
134
135 let qa_payload = serde_json::to_vec(&json!({"mode": mode.as_str()})).map_err(|err| {
136 diagnostic(
137 QaDiagnosticCode::QaSpecFailed,
138 format!("encode qa-spec payload: {err}"),
139 )
140 })?;
141 let qa_out = host
142 .invoke_provider_component_op_direct(
143 domain,
144 pack,
145 provider_id,
146 "qa-spec",
147 &qa_payload,
148 &ctx,
149 )
150 .map_err(|err| {
151 diagnostic(
152 QaDiagnosticCode::QaSpecFailed,
153 format!("invoke qa-spec: {err}"),
154 )
155 })?;
156 if !qa_out.success {
157 let message = qa_out.error.unwrap_or_else(|| "unknown error".to_string());
158 if is_missing_op(&message) {
159 return Ok(None);
160 }
161 return Err(diagnostic(QaDiagnosticCode::QaSpecFailed, message));
162 }
163 let qa_json = qa_out.output.ok_or_else(|| {
164 diagnostic(
165 QaDiagnosticCode::QaSpecFailed,
166 "missing qa-spec output payload".to_string(),
167 )
168 })?;
169 let qa_spec: ComponentQaSpec = serde_json::from_value(qa_json).map_err(|err| {
170 diagnostic(
171 QaDiagnosticCode::QaSpecInvalid,
172 format!("decode qa-spec payload: {err}"),
173 )
174 })?;
175
176 let i18n_payload = serde_json::to_vec(&json!({})).map_err(|err| {
177 diagnostic(
178 QaDiagnosticCode::I18nExportMissing,
179 format!("encode i18n-keys payload: {err}"),
180 )
181 })?;
182 let i18n_out = host
183 .invoke_provider_component_op_direct(
184 domain,
185 pack,
186 provider_id,
187 "i18n-keys",
188 &i18n_payload,
189 &ctx,
190 )
191 .map_err(|err| {
192 diagnostic(
193 QaDiagnosticCode::I18nExportMissing,
194 format!("invoke i18n-keys: {err}"),
195 )
196 })?;
197 if !i18n_out.success {
198 let message = i18n_out
199 .error
200 .unwrap_or_else(|| "unknown error".to_string());
201 return Err(diagnostic(QaDiagnosticCode::I18nExportMissing, message));
202 }
203 let i18n_json = i18n_out.output.ok_or_else(|| {
204 diagnostic(
205 QaDiagnosticCode::I18nExportMissing,
206 "missing i18n-keys payload".to_string(),
207 )
208 })?;
209 let known_keys: Vec<String> = serde_json::from_value(i18n_json).map_err(|err| {
210 diagnostic(
211 QaDiagnosticCode::I18nExportMissing,
212 format!("i18n-keys payload is not a string array: {err}"),
213 )
214 })?;
215 validate_i18n_contract(&qa_spec, &known_keys)?;
216
217 let apply_payload = serde_json::to_vec(&json!({
218 "mode": mode.as_str(),
219 "current_config": current_config.cloned().unwrap_or_else(|| json!({})),
220 "answers": answers,
221 }))
222 .map_err(|err| {
223 diagnostic(
224 QaDiagnosticCode::ApplyAnswersFailed,
225 format!("encode apply-answers payload: {err}"),
226 )
227 })?;
228 let apply_out = host
229 .invoke_provider_component_op_direct(
230 domain,
231 pack,
232 provider_id,
233 "apply-answers",
234 &apply_payload,
235 &ctx,
236 )
237 .map_err(|err| {
238 diagnostic(
239 QaDiagnosticCode::ApplyAnswersFailed,
240 format!("invoke apply-answers: {err}"),
241 )
242 })?;
243 if !apply_out.success {
244 let message = apply_out
245 .error
246 .unwrap_or_else(|| "unknown error".to_string());
247 return Err(diagnostic(QaDiagnosticCode::ApplyAnswersFailed, message));
248 }
249 let apply_json = apply_out.output.ok_or_else(|| {
250 diagnostic(
251 QaDiagnosticCode::ApplyAnswersFailed,
252 "missing apply-answers payload".to_string(),
253 )
254 })?;
255 let config = extract_config_from_apply_output(apply_json);
256
257 if let Some(schema) = read_pack_config_schema(&pack.path).map_err(|err| {
258 diagnostic(
259 QaDiagnosticCode::ConfigSchemaMismatch,
260 format!("read config schema: {err}"),
261 )
262 })? && let Some(reason) = validate_config_strict(&config, &schema)
263 {
264 return Err(diagnostic(QaDiagnosticCode::ConfigSchemaMismatch, reason));
265 }
266
267 Ok(Some(config))
268}
269
270pub fn persist_answers_artifacts(
271 providers_root: &Path,
272 provider_id: &str,
273 mode: QaMode,
274 answers: &JsonValue,
275) -> anyhow::Result<(PathBuf, PathBuf)> {
276 let answers_dir = providers_root.join(provider_id).join("answers");
277 std::fs::create_dir_all(&answers_dir)?;
278 let json_path = answers_dir.join(format!("{}.answers.json", mode.as_str()));
279 let cbor_path = answers_dir.join(format!("{}.answers.cbor", mode.as_str()));
280 let json_bytes = serde_json::to_vec_pretty(answers)?;
281 let cbor_bytes =
282 canonical::to_canonical_cbor(answers).map_err(|err| anyhow::anyhow!("{err}"))?;
283 std::fs::write(&json_path, json_bytes)?;
284 std::fs::write(&cbor_path, cbor_bytes)?;
285 Ok((json_path, cbor_path))
286}
287
288fn validate_i18n_contract(
289 qa_spec: &ComponentQaSpec,
290 known_keys: &[String],
291) -> Result<(), QaDiagnostic> {
292 let known_key_set = known_keys
293 .iter()
294 .cloned()
295 .collect::<std::collections::BTreeSet<_>>();
296 let missing = qa_spec
297 .i18n_keys()
298 .into_iter()
299 .filter(|key| !known_key_set.contains(key))
300 .collect::<Vec<_>>();
301 if !missing.is_empty() {
302 return Err(diagnostic(
303 QaDiagnosticCode::I18nKeyMissing,
304 format!("unknown keys referenced by qa-spec: {}", missing.join(", ")),
305 ));
306 }
307 Ok(())
308}
309
310fn extract_config_from_apply_output(apply_json: JsonValue) -> JsonValue {
311 if let Some(value) = apply_json.get("config") {
312 value.clone()
313 } else {
314 apply_json
315 }
316}
317
318fn supports_component_qa_contract(pack_path: &Path) -> anyhow::Result<bool> {
319 let bytes = match read_manifest_cbor_bytes(pack_path) {
320 Ok(bytes) => bytes,
321 Err(_) => return Ok(false),
322 };
323 let decoded = match decode_pack_manifest(&bytes) {
324 Ok(value) => value,
325 Err(_) => return Ok(false),
326 };
327 let Some(provider_ext) = decoded.provider_extension_inline() else {
328 return Ok(false);
329 };
330 let supports = provider_ext.providers.iter().any(|provider| {
331 provider.ops.iter().any(|op| op == "qa-spec")
332 && provider.ops.iter().any(|op| op == "apply-answers")
333 && provider.ops.iter().any(|op| op == "i18n-keys")
334 });
335 Ok(supports)
336}
337
338fn read_pack_config_schema(pack_path: &Path) -> anyhow::Result<Option<JsonValue>> {
339 let bytes = read_manifest_cbor_bytes(pack_path)?;
340 let decoded = decode_pack_manifest(&bytes)
341 .with_context(|| format!("decode manifest.cbor {}", pack_path.display()))?;
342 let schema = decoded
343 .components
344 .first()
345 .and_then(|component| component.config_schema.clone());
346 Ok(schema)
347}
348
349fn read_manifest_cbor_bytes(pack_path: &Path) -> anyhow::Result<Vec<u8>> {
350 let file = std::fs::File::open(pack_path)?;
351 let mut archive = zip::ZipArchive::new(file)?;
352 let mut manifest = archive
353 .by_name("manifest.cbor")
354 .with_context(|| format!("manifest.cbor missing in {}", pack_path.display()))?;
355 let mut bytes = Vec::new();
356 std::io::Read::read_to_end(&mut manifest, &mut bytes)?;
357 Ok(bytes)
358}
359
360fn validate_config_strict(config: &JsonValue, schema: &JsonValue) -> Option<String> {
361 if schema.is_object()
362 && let Err(err) = jsonschema::validate(schema, config)
363 {
364 return Some(err.to_string());
365 }
366 validate_config_shallow(config, schema)
367}
368
369fn validate_config_shallow(config: &JsonValue, schema: &JsonValue) -> Option<String> {
370 let schema_obj = schema.as_object()?;
371
372 if let Some(expected) = schema_obj.get("type").and_then(JsonValue::as_str)
373 && !matches_json_type(config, expected)
374 {
375 return Some(format!(
376 "config type mismatch: expected `{expected}`, got `{}`",
377 json_type_name(config)
378 ));
379 }
380
381 if let Some(required) = schema_obj.get("required").and_then(JsonValue::as_array)
382 && let Some(map) = config.as_object()
383 {
384 for key in required.iter().filter_map(JsonValue::as_str) {
385 if !map.contains_key(key) {
386 return Some(format!("missing required config key `{key}`"));
387 }
388 }
389 }
390
391 if let (Some(properties), Some(map)) = (
392 schema_obj.get("properties").and_then(JsonValue::as_object),
393 config.as_object(),
394 ) {
395 for (key, value) in map {
396 if let Some(prop_schema) = properties.get(key)
397 && let Some(expected) = prop_schema.get("type").and_then(JsonValue::as_str)
398 && !matches_json_type(value, expected)
399 {
400 return Some(format!(
401 "config key `{key}` type mismatch: expected `{expected}`, got `{}`",
402 json_type_name(value)
403 ));
404 }
405 }
406 if schema_obj
407 .get("additionalProperties")
408 .is_some_and(|value| value == &JsonValue::Bool(false))
409 {
410 for key in map.keys() {
411 if !properties.contains_key(key) {
412 return Some(format!("unknown config key `{key}`"));
413 }
414 }
415 }
416 }
417
418 None
419}
420
421fn matches_json_type(value: &JsonValue, expected: &str) -> bool {
422 match expected {
423 "object" => value.is_object(),
424 "array" => value.is_array(),
425 "string" => value.is_string(),
426 "boolean" => value.is_boolean(),
427 "number" => value.is_number(),
428 "integer" => {
429 value.as_i64().is_some()
430 || value.as_u64().is_some()
431 || value.as_f64().is_some_and(|number| number.fract() == 0.0)
432 }
433 "null" => value.is_null(),
434 _ => true,
435 }
436}
437
438fn json_type_name(value: &JsonValue) -> &'static str {
439 if value.is_object() {
440 "object"
441 } else if value.is_array() {
442 "array"
443 } else if value.is_string() {
444 "string"
445 } else if value.is_boolean() {
446 "boolean"
447 } else if value.is_number() {
448 "number"
449 } else {
450 "null"
451 }
452}
453
454fn is_missing_op(message: &str) -> bool {
455 let lower = message.to_ascii_lowercase();
456 lower.contains("not found") || lower.contains("opnotfound") || lower.contains("op not found")
457}
458
459fn diagnostic(code: QaDiagnosticCode, message: String) -> QaDiagnostic {
460 QaDiagnostic { code, message }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use greentic_types::i18n_text::I18nText;
467 use greentic_types::schemas::component::v0_6_0::{
468 ComponentQaSpec, QaMode as SpecQaMode, Question, QuestionKind,
469 };
470 use std::collections::BTreeMap;
471
472 #[test]
473 fn shallow_schema_type_mismatch_is_reported() {
474 let config = json!({"enabled":"yes"});
475 let schema = json!({
476 "type": "object",
477 "properties": {
478 "enabled": {"type":"boolean"}
479 },
480 "required": ["enabled"]
481 });
482 let message = validate_config_shallow(&config, &schema).unwrap();
483 assert!(message.contains("enabled"));
484 assert!(message.contains("boolean"));
485 }
486
487 #[test]
488 fn strict_schema_reports_missing_required_property() {
489 let config = json!({});
490 let schema = json!({
491 "type": "object",
492 "properties": {
493 "token": {"type":"string"}
494 },
495 "required": ["token"]
496 });
497 let message = validate_config_strict(&config, &schema).unwrap();
498 assert!(message.to_ascii_lowercase().contains("token"));
499 }
500
501 #[test]
502 fn shallow_schema_accepts_valid_object() {
503 let config = json!({"enabled": true, "name": "demo"});
504 let schema = json!({
505 "type": "object",
506 "properties": {
507 "enabled": {"type":"boolean"},
508 "name": {"type":"string"}
509 },
510 "required": ["enabled", "name"]
511 });
512 assert!(validate_config_shallow(&config, &schema).is_none());
513 }
514
515 #[test]
516 fn missing_op_detection_matches_common_messages() {
517 assert!(is_missing_op("op not found"));
518 assert!(is_missing_op("OperatorErrorCode::OpNotFound"));
519 assert!(!is_missing_op("invalid input"));
520 }
521
522 #[test]
523 fn qa_mode_infers_from_flow_names() {
524 assert_eq!(qa_mode_for_flow("setup_default"), Some(QaMode::Default));
525 assert_eq!(qa_mode_for_flow("setup_upgrade"), Some(QaMode::Upgrade));
526 assert_eq!(qa_mode_for_flow("setup_remove"), Some(QaMode::Remove));
527 assert_eq!(qa_mode_for_flow("setup"), Some(QaMode::Setup));
528 assert_eq!(qa_mode_for_flow("verify_webhooks"), None);
529 }
530
531 #[test]
532 fn qa_contract_success_path_validates_i18n() {
533 let qa_spec = sample_qa_spec();
534 let known_keys = vec![
535 "qa.title".to_string(),
536 "qa.question.label".to_string(),
537 "qa.question.help".to_string(),
538 "qa.question.error".to_string(),
539 ];
540 assert!(validate_i18n_contract(&qa_spec, &known_keys).is_ok());
541 }
542
543 #[test]
544 fn qa_contract_reports_missing_i18n_keys() {
545 let qa_spec = sample_qa_spec();
546 let known_keys = vec!["qa.title".to_string()];
547 let err = validate_i18n_contract(&qa_spec, &known_keys).unwrap_err();
548 assert_eq!(err.code, QaDiagnosticCode::I18nKeyMissing);
549 assert!(err.message.contains("unknown keys"));
550 }
551
552 #[test]
553 fn extract_apply_output_prefers_config_field() {
554 let config = extract_config_from_apply_output(json!({"config": {"token":"x"}}));
555 assert_eq!(config, json!({"token":"x"}));
556 }
557
558 fn sample_qa_spec() -> ComponentQaSpec {
559 ComponentQaSpec {
560 mode: SpecQaMode::Setup,
561 title: I18nText {
562 key: "qa.title".to_string(),
563 fallback: None,
564 },
565 description: None,
566 questions: vec![Question {
567 id: "token".to_string(),
568 label: I18nText {
569 key: "qa.question.label".to_string(),
570 fallback: None,
571 },
572 help: Some(I18nText {
573 key: "qa.question.help".to_string(),
574 fallback: None,
575 }),
576 error: Some(I18nText {
577 key: "qa.question.error".to_string(),
578 fallback: None,
579 }),
580 kind: QuestionKind::Text,
581 required: true,
582 default: None,
583 skip_if: None,
584 }],
585 defaults: BTreeMap::new(),
586 }
587 }
588}