1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::{Args, ValueEnum};
10use greentic_distributor_client::{DistClient, DistOptions};
11use greentic_flow::schema_validate::{Severity, validate_value_against_schema};
12use greentic_flow::wizard_ops::{
13 WizardAbi, WizardMode as FlowWizardMode, apply_wizard_answers, decode_component_qa_spec,
14 fetch_wizard_spec,
15};
16use greentic_interfaces_host::component_v0_6::exports::greentic::component::node::{
17 ComponentDescriptor, SchemaSource,
18};
19use greentic_pack::pack_lock::read_pack_lock;
20use greentic_types::cbor::canonical;
21use greentic_types::i18n_text::I18nText;
22use greentic_types::qa::QaSpecSource;
23use greentic_types::schemas::common::schema_ir::SchemaIr;
24use greentic_types::schemas::component::v0_6_0::qa::{
25 ComponentQaSpec, QaMode as SpecQaMode, Question, QuestionKind,
26};
27use greentic_types::schemas::pack::v0_6_0::PackDescribe;
28use greentic_types::schemas::pack::v0_6_0::qa::{
29 PackQaSpec, QaMode as PackQaMode, Question as PackQuestion, QuestionKind as PackQuestionKind,
30};
31use hex;
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34use tokio::runtime::Handle;
35
36use crate::config::PackConfig;
37use crate::runtime::{NetworkPolicy, RuntimeContext};
38
39#[derive(Debug, Args)]
40pub struct QaArgs {
41 #[arg(long = "pack", value_name = "DIR", default_value = ".")]
43 pub pack_dir: PathBuf,
44
45 #[arg(long = "mode", value_enum, default_value = "default")]
47 pub mode: QaModeLabel,
48
49 #[arg(long = "answers", value_name = "FILE_OR_DIR")]
51 pub answers: Option<PathBuf>,
52
53 #[arg(long = "locale", default_value = "en")]
55 pub locale: String,
56
57 #[arg(long = "non-interactive", default_value_t = false)]
59 pub non_interactive: bool,
60
61 #[arg(long = "reask", default_value_t = false)]
63 pub reask: bool,
64
65 #[arg(long = "component", value_name = "ID", action = clap::ArgAction::Append)]
67 pub components: Vec<String>,
68
69 #[arg(long = "all-locked", default_value_t = false)]
71 pub all_locked: bool,
72
73 #[arg(long = "pack-only", default_value_t = false)]
75 pub pack_only: bool,
76}
77
78#[derive(Clone, Copy, Debug, ValueEnum)]
79pub enum QaModeLabel {
80 Default,
81 Setup,
82 Update,
83 #[value(hide = true)]
84 Upgrade,
85 Remove,
86}
87
88impl QaModeLabel {
89 fn as_str(&self) -> &'static str {
90 match self {
91 QaModeLabel::Default => "default",
92 QaModeLabel::Setup => "setup",
93 QaModeLabel::Update | QaModeLabel::Upgrade => "update",
94 QaModeLabel::Remove => "remove",
95 }
96 }
97
98 fn to_flow_mode(self) -> FlowWizardMode {
99 match self {
100 QaModeLabel::Default => FlowWizardMode::Default,
101 QaModeLabel::Setup => FlowWizardMode::Setup,
102 QaModeLabel::Update | QaModeLabel::Upgrade => FlowWizardMode::Update,
103 QaModeLabel::Remove => FlowWizardMode::Remove,
104 }
105 }
106
107 fn to_spec_mode(self) -> SpecQaMode {
108 match self {
109 QaModeLabel::Default => SpecQaMode::Default,
110 QaModeLabel::Setup => SpecQaMode::Setup,
111 QaModeLabel::Update | QaModeLabel::Upgrade => SpecQaMode::Update,
112 QaModeLabel::Remove => SpecQaMode::Remove,
113 }
114 }
115
116 fn to_pack_mode(self) -> PackQaMode {
117 match self {
118 QaModeLabel::Default => PackQaMode::Default,
119 QaModeLabel::Setup => PackQaMode::Setup,
120 QaModeLabel::Update | QaModeLabel::Upgrade => PackQaMode::Update,
121 QaModeLabel::Remove => PackQaMode::Remove,
122 }
123 }
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize)]
127struct AnswersDoc {
128 schema_version: u32,
129 mode: String,
130 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
131 pack: BTreeMap<String, serde_json::Value>,
132 components: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
133}
134
135impl AnswersDoc {
136 fn new(mode: &QaModeLabel) -> Self {
137 Self {
138 schema_version: 1,
139 mode: mode.as_str().to_string(),
140 pack: BTreeMap::new(),
141 components: BTreeMap::new(),
142 }
143 }
144}
145
146pub fn handle(args: QaArgs, runtime: &RuntimeContext) -> Result<()> {
147 if matches!(args.mode, QaModeLabel::Upgrade) {
148 eprintln!("{}", crate::cli_i18n::t("cli.qa.warn.upgrade_deprecated"));
149 }
150 let pack_dir = args
151 .pack_dir
152 .canonicalize()
153 .with_context(|| format!("failed to resolve pack dir {}", args.pack_dir.display()))?;
154 let pack_yaml = pack_dir.join("pack.yaml");
155 if args.pack_only && (args.all_locked || !args.components.is_empty()) {
156 bail!(
157 "{}",
158 crate::cli_i18n::t("cli.qa.error.pack_only_combination")
159 );
160 }
161
162 let config = read_pack_config(&pack_yaml)?;
163 let lock = if args.pack_only {
164 None
165 } else {
166 Some(
167 read_pack_lock(&pack_dir.join("pack.lock.cbor")).with_context(|| {
168 format!("failed to read pack.lock.cbor under {}", pack_dir.display())
169 })?,
170 )
171 };
172
173 let (answers_json_path, answers_cbor_path) =
174 resolve_answers_paths(&pack_dir, args.answers.as_deref(), args.mode.as_str())?;
175
176 let mut answers = load_answers(&answers_json_path, args.reask, &args.mode)?;
177
178 let i18n_bundle = load_i18n_bundle(&pack_dir, &args.locale)?;
179 let pack_qa_spec = load_pack_qa_spec(&pack_dir, args.mode.to_pack_mode(), args.pack_only)?;
180 let dist = DistClient::new(DistOptions {
181 cache_dir: runtime.cache_dir(),
182 allow_tags: true,
183 offline: runtime.network_policy() == NetworkPolicy::Offline,
184 allow_insecure_local_http: false,
185 ..DistOptions::default()
186 });
187
188 let wasm_paths = index_component_paths(&config, &pack_dir);
189 let targets = if args.pack_only {
190 Vec::new()
191 } else {
192 let lock = lock
193 .as_ref()
194 .ok_or_else(|| anyhow!("pack.lock.cbor is required unless --pack-only is set"))?;
195 select_target_components(&config, lock, &args)?
196 };
197
198 if let Some(spec) = pack_qa_spec.as_ref() {
199 let pack_answers = collect_answers_for_pack(
200 spec,
201 if args.reask {
202 None
203 } else {
204 Some(&answers.pack)
205 },
206 &i18n_bundle,
207 args.non_interactive,
208 args.reask,
209 )?;
210 answers.pack = pack_answers;
211 }
212
213 for component_id in targets {
214 let lock = lock
215 .as_ref()
216 .ok_or_else(|| anyhow!("pack.lock.cbor is required unless --pack-only is set"))?;
217 let locked = lock.components.get(&component_id).ok_or_else(|| {
218 anyhow!(
219 "component {} missing from pack.lock.cbor (run `greentic-pack resolve`)",
220 component_id
221 )
222 })?;
223 let reference = match locked.r#ref.as_deref() {
224 Some(reference) => reference.to_string(),
225 None => {
226 let path = wasm_paths.get(&component_id).ok_or_else(|| {
227 anyhow!(
228 "pack.lock entry {} has no ref and no pack.yaml wasm path",
229 component_id
230 )
231 })?;
232 format!("file://{}", path.display())
233 }
234 };
235 let resolved =
236 resolve_component_bytes(&dist, runtime, &reference, Some(&locked.resolved_digest))?;
237
238 let spec = load_component_qa_spec(&resolved.bytes, args.mode.to_flow_mode())
239 .with_context(|| format!("load QA spec for {}", component_id))?;
240
241 if spec.mode != args.mode.to_spec_mode() {
242 bail!(
243 "component {} returned QA spec for {:?}, expected {:?}",
244 component_id,
245 spec.mode,
246 args.mode.to_spec_mode()
247 );
248 }
249
250 let existing = answers.components.get(&component_id).cloned();
251 let updated = collect_answers_for_component(
252 &component_id,
253 &spec,
254 existing.as_ref(),
255 &i18n_bundle,
256 args.non_interactive,
257 args.reask,
258 )?;
259 answers.components.insert(component_id.clone(), updated);
260
261 let component_answers = answers
262 .components
263 .get(&component_id)
264 .cloned()
265 .unwrap_or_default();
266 let answers_cbor = canonical::to_canonical_cbor_allow_floats(&component_answers)
267 .with_context(|| format!("encode answers cbor for {}", component_id))?;
268 let current_config = canonical::to_canonical_cbor_allow_floats(&serde_json::json!({}))
269 .context("encode empty config cbor")?;
270 let config_cbor = apply_component_answers(
271 &resolved.bytes,
272 args.mode.to_flow_mode(),
273 ¤t_config,
274 &answers_cbor,
275 )
276 .with_context(|| format!("apply-answers for {}", component_id))?;
277 let wizard_spec = fetch_wizard_spec(&resolved.bytes, args.mode.to_flow_mode())
278 .with_context(|| format!("load setup descriptor for {}", component_id))?;
279 validate_component_config_output(
280 &component_id,
281 wizard_spec.descriptor.as_ref(),
282 &config_cbor,
283 )?;
284 }
285
286 write_answers(&answers_json_path, &answers_cbor_path, &answers)?;
287 eprintln!(
288 "{}",
289 crate::cli_i18n::tf(
290 "cli.common.wrote_path",
291 &[&answers_json_path.display().to_string()]
292 )
293 );
294 eprintln!(
295 "{}",
296 crate::cli_i18n::tf(
297 "cli.common.wrote_path",
298 &[&answers_cbor_path.display().to_string()]
299 )
300 );
301
302 Ok(())
303}
304
305fn read_pack_config(pack_yaml: &Path) -> Result<PackConfig> {
306 let contents = fs::read_to_string(pack_yaml)
307 .with_context(|| format!("failed to read {}", pack_yaml.display()))?;
308 let cfg: PackConfig = serde_yaml_bw::from_str(&contents)
309 .with_context(|| format!("{} is not a valid pack.yaml", pack_yaml.display()))?;
310 Ok(cfg)
311}
312
313fn load_pack_qa_spec(
314 pack_dir: &Path,
315 mode: PackQaMode,
316 require: bool,
317) -> Result<Option<PackQaSpec>> {
318 let pack_cbor = pack_dir.join("pack.cbor");
319 if !pack_cbor.exists() {
320 if require {
321 bail!("{}", crate::cli_i18n::t("cli.qa.error.pack_cbor_required"));
322 }
323 return Ok(None);
324 }
325 let bytes =
326 fs::read(&pack_cbor).with_context(|| format!("failed to read {}", pack_cbor.display()))?;
327 let describe: PackDescribe =
328 canonical::from_cbor(&bytes).with_context(|| format!("decode {}", pack_cbor.display()))?;
329
330 let Some(value) = describe.metadata.get("greentic.qa") else {
331 if require {
332 bail!("pack.cbor metadata missing greentic.qa");
333 }
334 return Ok(None);
335 };
336
337 let source: QaSpecSource =
338 decode_qa_spec_source(value).context("decode pack-level QA spec source")?;
339
340 let spec = match source {
341 QaSpecSource::InlineCbor(bytes) => decode_pack_qa_spec(bytes.as_slice())?,
342 QaSpecSource::RefPackPath(path) => {
343 let path = pack_dir.join(path);
344 let bytes =
345 fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
346 canonical::ensure_canonical(&bytes).context("pack QA spec must be canonical CBOR")?;
347 decode_pack_qa_spec(bytes.as_slice())?
348 }
349 QaSpecSource::RefUri(uri) => {
350 bail!("pack QA RefUri not supported yet: {}", uri);
351 }
352 };
353
354 if spec.mode != mode {
355 bail!(
356 "pack QA spec mode mismatch: expected {:?}, got {:?}",
357 mode,
358 spec.mode
359 );
360 }
361 Ok(Some(spec))
362}
363
364fn decode_qa_spec_source(value: &ciborium::value::Value) -> Result<QaSpecSource> {
365 let bytes = canonical::to_canonical_cbor_allow_floats(value)
366 .context("canonicalize QaSpecSource metadata")?;
367 let source: QaSpecSource =
368 canonical::from_cbor(&bytes).context("decode QaSpecSource metadata")?;
369 Ok(source)
370}
371
372fn decode_pack_qa_spec(bytes: &[u8]) -> Result<PackQaSpec> {
373 canonical::from_cbor(bytes).context("decode PackQaSpec")
374}
375
376fn index_component_paths(config: &PackConfig, pack_dir: &Path) -> BTreeMap<String, PathBuf> {
377 let mut map = BTreeMap::new();
378 for component in &config.components {
379 let path = if component.wasm.is_absolute() {
380 component.wasm.clone()
381 } else {
382 pack_dir.join(&component.wasm)
383 };
384 map.insert(component.id.clone(), path);
385 }
386 map
387}
388
389fn select_target_components(
390 config: &PackConfig,
391 lock: &greentic_pack::pack_lock::PackLockV1,
392 args: &QaArgs,
393) -> Result<Vec<String>> {
394 if args.all_locked && !args.components.is_empty() {
395 bail!("--component cannot be combined with --all-locked");
396 }
397
398 let mut targets = Vec::new();
399 let mut pack_component_ids: BTreeMap<String, ()> = BTreeMap::new();
400 for component in &config.components {
401 pack_component_ids.insert(component.id.clone(), ());
402 }
403
404 if !args.components.is_empty() {
405 for component_id in &args.components {
406 if !pack_component_ids.contains_key(component_id) {
407 bail!(
408 "component {} not found in pack.yaml (use --all-locked to target lock-only entries)",
409 component_id
410 );
411 }
412 targets.push(component_id.clone());
413 }
414 targets.sort();
415 targets.dedup();
416 return Ok(targets);
417 }
418
419 if args.all_locked {
420 targets.extend(lock.components.keys().cloned());
421 targets.sort();
422 return Ok(targets);
423 }
424
425 targets.extend(pack_component_ids.keys().cloned());
426 targets.sort();
427 Ok(targets)
428}
429
430fn resolve_answers_paths(
431 pack_dir: &Path,
432 answers: Option<&Path>,
433 mode: &str,
434) -> Result<(PathBuf, PathBuf)> {
435 let (json_path, cbor_path) = match answers {
436 None => {
437 let dir = pack_dir.join("answers");
438 (
439 dir.join(format!("{mode}.answers.json")),
440 dir.join(format!("{mode}.answers.cbor")),
441 )
442 }
443 Some(path) if path.is_dir() => (
444 path.join(format!("{mode}.answers.json")),
445 path.join(format!("{mode}.answers.cbor")),
446 ),
447 Some(path) => {
448 let ext = path
449 .extension()
450 .and_then(|e| e.to_str())
451 .unwrap_or_default();
452 if ext.eq_ignore_ascii_case("json") {
453 let cbor = path.with_extension("cbor");
454 (path.to_path_buf(), cbor)
455 } else if ext.eq_ignore_ascii_case("cbor") {
456 let json = path.with_extension("json");
457 (json, path.to_path_buf())
458 } else {
459 let json = path.with_extension("answers.json");
460 let cbor = path.with_extension("answers.cbor");
461 (json, cbor)
462 }
463 }
464 };
465
466 if let Some(parent) = json_path.parent() {
467 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
468 }
469
470 Ok((json_path, cbor_path))
471}
472
473fn load_answers(path: &Path, reask: bool, mode: &QaModeLabel) -> Result<AnswersDoc> {
474 if reask || !path.exists() {
475 return Ok(AnswersDoc::new(mode));
476 }
477 let contents =
478 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
479 let mut doc: AnswersDoc = serde_json::from_str(&contents)
480 .with_context(|| format!("{} is not valid answers JSON", path.display()))?;
481 if doc.mode != mode.as_str() {
482 doc.mode = mode.as_str().to_string();
483 }
484 Ok(doc)
485}
486
487fn write_answers(json_path: &Path, cbor_path: &Path, answers: &AnswersDoc) -> Result<()> {
488 let json_bytes = to_sorted_json_bytes(answers)?;
489 fs::write(json_path, json_bytes).with_context(|| format!("write {}", json_path.display()))?;
490
491 let cbor_bytes =
492 canonical::to_canonical_cbor_allow_floats(answers).context("encode answers CBOR")?;
493 fs::write(cbor_path, cbor_bytes).with_context(|| format!("write {}", cbor_path.display()))?;
494 Ok(())
495}
496
497fn to_sorted_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>> {
498 let value = serde_json::to_value(value).context("encode json")?;
499 let sorted = sort_json(value);
500 let bytes = serde_json::to_vec_pretty(&sorted).context("serialize json")?;
501 Ok(bytes)
502}
503
504fn sort_json(value: serde_json::Value) -> serde_json::Value {
505 match value {
506 serde_json::Value::Object(map) => {
507 let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
508 entries.sort_by(|a, b| a.0.cmp(&b.0));
509 let mut sorted = serde_json::Map::new();
510 for (key, value) in entries {
511 sorted.insert(key, sort_json(value));
512 }
513 serde_json::Value::Object(sorted)
514 }
515 serde_json::Value::Array(values) => {
516 serde_json::Value::Array(values.into_iter().map(sort_json).collect())
517 }
518 other => other,
519 }
520}
521
522fn load_i18n_bundle(pack_dir: &Path, locale: &str) -> Result<BTreeMap<String, String>> {
523 let path = pack_dir
524 .join("assets")
525 .join("i18n")
526 .join(format!("{locale}.json"));
527 if !path.exists() {
528 return Ok(BTreeMap::new());
529 }
530 let contents =
531 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
532 let raw: serde_json::Value = serde_json::from_str(&contents)
533 .with_context(|| format!("{} is not valid JSON", path.display()))?;
534 let mut map = BTreeMap::new();
535 if let serde_json::Value::Object(entries) = raw {
536 for (key, value) in entries {
537 if let Some(value) = value.as_str() {
538 map.insert(key, value.to_string());
539 }
540 }
541 }
542 Ok(map)
543}
544
545fn collect_answers_for_component(
546 component_id: &str,
547 spec: &ComponentQaSpec,
548 existing: Option<&BTreeMap<String, serde_json::Value>>,
549 i18n_bundle: &BTreeMap<String, String>,
550 non_interactive: bool,
551 reask: bool,
552) -> Result<BTreeMap<String, serde_json::Value>> {
553 let mut answers = existing.cloned().unwrap_or_default();
554
555 if !spec.questions.is_empty() {
556 println!();
557 println!("== {} ==", render_text(&spec.title, i18n_bundle));
558 if let Some(desc) = &spec.description {
559 println!("{}", render_text(desc, i18n_bundle));
560 }
561 }
562
563 for question in &spec.questions {
564 if !reask && answers.contains_key(&question.id) {
565 continue;
566 }
567
568 let default = question
569 .default
570 .as_ref()
571 .map(cbor_value_to_json)
572 .or_else(|| spec.defaults.get(&question.id).map(cbor_value_to_json));
573
574 let value = if non_interactive {
575 if let Some(existing) = answers.get(&question.id) {
576 Some(existing.clone())
577 } else if let Some(default) = default {
578 Some(default)
579 } else if question.required {
580 return Err(anyhow!(
581 "missing required answer {}.{} (non-interactive)",
582 component_id,
583 question.id
584 ));
585 } else {
586 None
587 }
588 } else {
589 prompt_question(question, i18n_bundle, default.as_ref())?
590 };
591
592 if let Some(value) = value {
593 answers.insert(question.id.clone(), value);
594 }
595 }
596
597 Ok(answers)
598}
599
600fn collect_answers_for_pack(
601 spec: &PackQaSpec,
602 existing: Option<&BTreeMap<String, serde_json::Value>>,
603 i18n_bundle: &BTreeMap<String, String>,
604 non_interactive: bool,
605 reask: bool,
606) -> Result<BTreeMap<String, serde_json::Value>> {
607 let mut answers = existing.cloned().unwrap_or_default();
608
609 if !spec.questions.is_empty() {
610 println!();
611 println!("== {} ==", render_text(&spec.title, i18n_bundle));
612 if let Some(desc) = &spec.description {
613 println!("{}", render_text(desc, i18n_bundle));
614 }
615 }
616
617 for question in &spec.questions {
618 if !reask && answers.contains_key(&question.id) {
619 continue;
620 }
621
622 let default = question
623 .default
624 .as_ref()
625 .map(cbor_value_to_json)
626 .or_else(|| spec.defaults.get(&question.id).map(cbor_value_to_json));
627
628 let value = if non_interactive {
629 if let Some(existing) = answers.get(&question.id) {
630 Some(existing.clone())
631 } else if let Some(default) = default {
632 Some(default)
633 } else if question.required {
634 return Err(anyhow!(
635 "missing required pack answer {} (non-interactive)",
636 question.id
637 ));
638 } else {
639 None
640 }
641 } else {
642 prompt_pack_question(question, i18n_bundle, default.as_ref())?
643 };
644
645 if let Some(value) = value {
646 answers.insert(question.id.clone(), value);
647 }
648 }
649
650 Ok(answers)
651}
652
653fn render_text(text: &I18nText, i18n_bundle: &BTreeMap<String, String>) -> String {
654 if let Some(value) = i18n_bundle.get(&text.key) {
655 return value.clone();
656 }
657 if let Some(fallback) = &text.fallback {
658 return fallback.clone();
659 }
660 text.key.clone()
661}
662
663fn prompt_question(
664 question: &Question,
665 i18n_bundle: &BTreeMap<String, String>,
666 default: Option<&serde_json::Value>,
667) -> Result<Option<serde_json::Value>> {
668 let label = render_text(&question.label, i18n_bundle);
669 let help = question
670 .help
671 .as_ref()
672 .map(|help| render_text(help, i18n_bundle));
673
674 loop {
675 print_prompt(&label, help.as_deref(), default);
676 let mut input = String::new();
677 io::stdin().read_line(&mut input)?;
678 let input = input.trim();
679
680 if input.is_empty() {
681 if let Some(default) = default {
682 return Ok(Some(default.clone()));
683 }
684 if question.required {
685 println!("{}", crate::cli_i18n::t("cli.qa.prompt.answer_required"));
686 continue;
687 }
688 return Ok(None);
689 }
690
691 let parsed = match &question.kind {
692 QuestionKind::Text => serde_json::Value::String(input.to_string()),
693 QuestionKind::Number => match input.parse::<f64>() {
694 Ok(value) => serde_json::Value::Number(
695 serde_json::Number::from_f64(value)
696 .ok_or_else(|| anyhow!("invalid numeric input"))?,
697 ),
698 Err(_) => {
699 println!("{}", crate::cli_i18n::t("cli.qa.prompt.expected_number"));
700 continue;
701 }
702 },
703 QuestionKind::Bool => match parse_bool(input) {
704 Some(value) => serde_json::Value::Bool(value),
705 None => {
706 println!("{}", crate::cli_i18n::t("cli.qa.prompt.expected_yes_no"));
707 continue;
708 }
709 },
710 QuestionKind::Choice { options } => match parse_choice(input, options, i18n_bundle) {
711 Some(value) => serde_json::Value::String(value),
712 None => {
713 println!("{}", crate::cli_i18n::t("cli.qa.prompt.select_option"));
714 continue;
715 }
716 },
717 QuestionKind::InlineJson { .. } => {
718 match serde_json::from_str::<serde_json::Value>(input) {
719 Ok(value) => value,
720 Err(_) => {
721 println!("{}", crate::cli_i18n::t("cli.qa.prompt.invalid_json"));
722 continue;
723 }
724 }
725 }
726 QuestionKind::AssetRef { .. } => serde_json::Value::String(input.to_string()),
727 };
728
729 return Ok(Some(parsed));
730 }
731}
732
733fn prompt_pack_question(
734 question: &PackQuestion,
735 i18n_bundle: &BTreeMap<String, String>,
736 default: Option<&serde_json::Value>,
737) -> Result<Option<serde_json::Value>> {
738 let label = render_text(&question.label, i18n_bundle);
739 let help = question
740 .help
741 .as_ref()
742 .map(|help| render_text(help, i18n_bundle));
743
744 loop {
745 print_prompt(&label, help.as_deref(), default);
746 let mut input = String::new();
747 io::stdin().read_line(&mut input)?;
748 let input = input.trim();
749
750 if input.is_empty() {
751 if let Some(default) = default {
752 return Ok(Some(default.clone()));
753 }
754 if question.required {
755 println!("{}", crate::cli_i18n::t("cli.qa.prompt.answer_required"));
756 continue;
757 }
758 return Ok(None);
759 }
760
761 let parsed = match &question.kind {
762 PackQuestionKind::Text => serde_json::Value::String(input.to_string()),
763 PackQuestionKind::Number => match input.parse::<f64>() {
764 Ok(value) => serde_json::Value::Number(
765 serde_json::Number::from_f64(value)
766 .ok_or_else(|| anyhow!("invalid numeric input"))?,
767 ),
768 Err(_) => {
769 println!("{}", crate::cli_i18n::t("cli.qa.prompt.expected_number"));
770 continue;
771 }
772 },
773 PackQuestionKind::Bool => match parse_bool(input) {
774 Some(value) => serde_json::Value::Bool(value),
775 None => {
776 println!("{}", crate::cli_i18n::t("cli.qa.prompt.expected_yes_no"));
777 continue;
778 }
779 },
780 PackQuestionKind::Choice { options } => {
781 match parse_pack_choice(input, options, i18n_bundle) {
782 Some(value) => serde_json::Value::String(value),
783 None => {
784 println!("{}", crate::cli_i18n::t("cli.qa.prompt.select_option"));
785 continue;
786 }
787 }
788 }
789 };
790
791 return Ok(Some(parsed));
792 }
793}
794
795fn print_prompt(label: &str, help: Option<&str>, default: Option<&serde_json::Value>) {
796 if let Some(help) = help {
797 println!("{label} ({help})");
798 } else {
799 println!("{label}");
800 }
801 if let Some(default) = default {
802 println!("default: {}", default);
803 }
804 print!("> ");
805 let _ = io::stdout().flush();
806}
807
808fn parse_bool(input: &str) -> Option<bool> {
809 match input.to_ascii_lowercase().as_str() {
810 "y" | "yes" | "true" | "1" => Some(true),
811 "n" | "no" | "false" | "0" => Some(false),
812 _ => None,
813 }
814}
815
816fn parse_choice(
817 input: &str,
818 options: &[greentic_types::schemas::component::v0_6_0::qa::ChoiceOption],
819 i18n_bundle: &BTreeMap<String, String>,
820) -> Option<String> {
821 let index = input.parse::<usize>().ok();
822 if let Some(idx) = index
823 && idx > 0
824 && idx <= options.len()
825 {
826 return Some(options[idx - 1].value.clone());
827 }
828 options
829 .iter()
830 .find(|option| option.value == input)
831 .map(|option| option.value.clone())
832 .or_else(|| {
833 options
834 .iter()
835 .find(|option| render_text(&option.label, i18n_bundle) == input)
836 .map(|option| option.value.clone())
837 })
838}
839
840fn parse_pack_choice(
841 input: &str,
842 options: &[greentic_types::schemas::pack::v0_6_0::qa::ChoiceOption],
843 i18n_bundle: &BTreeMap<String, String>,
844) -> Option<String> {
845 let index = input.parse::<usize>().ok();
846 if let Some(idx) = index
847 && idx > 0
848 && idx <= options.len()
849 {
850 return Some(options[idx - 1].value.clone());
851 }
852 options
853 .iter()
854 .find(|option| option.value == input)
855 .map(|option| option.value.clone())
856 .or_else(|| {
857 options
858 .iter()
859 .find(|option| render_text(&option.label, i18n_bundle) == input)
860 .map(|option| option.value.clone())
861 })
862}
863
864fn cbor_value_to_json(value: &ciborium::value::Value) -> serde_json::Value {
865 use ciborium::value::Value;
866 match value {
867 Value::Integer(int) => {
868 let raw: i128 = (*int).into();
869 if let Ok(value) = i64::try_from(raw) {
870 serde_json::Value::Number(value.into())
871 } else if let Ok(value) = u64::try_from(raw) {
872 serde_json::Value::Number(value.into())
873 } else {
874 serde_json::Value::Number(
875 serde_json::Number::from_f64(raw as f64)
876 .unwrap_or_else(|| serde_json::Number::from(0)),
877 )
878 }
879 }
880 Value::Bytes(bytes) => serde_json::Value::String(hex::encode(bytes)),
881 Value::Float(value) => serde_json::Value::Number(
882 serde_json::Number::from_f64(*value).unwrap_or_else(|| serde_json::Number::from(0)),
883 ),
884 Value::Text(value) => serde_json::Value::String(value.clone()),
885 Value::Bool(value) => serde_json::Value::Bool(*value),
886 Value::Null => serde_json::Value::Null,
887 Value::Tag(_, inner) => cbor_value_to_json(inner),
888 Value::Array(values) => {
889 serde_json::Value::Array(values.iter().map(cbor_value_to_json).collect())
890 }
891 Value::Map(entries) => {
892 let mut map = serde_json::Map::new();
893 for (key, value) in entries {
894 let key_string = match key {
895 Value::Text(value) => value.clone(),
896 Value::Integer(int) => {
897 let raw: i128 = (*int).into();
898 raw.to_string()
899 }
900 other => format!("{other:?}"),
901 };
902 map.insert(key_string, cbor_value_to_json(value));
903 }
904 serde_json::Value::Object(map)
905 }
906 _ => serde_json::Value::Null,
907 }
908}
909
910struct ResolvedBytes {
911 bytes: Vec<u8>,
912}
913
914fn resolve_component_bytes(
915 dist: &DistClient,
916 runtime: &RuntimeContext,
917 reference: &str,
918 expected_digest: Option<&str>,
919) -> Result<ResolvedBytes> {
920 if let Some(path) = reference.strip_prefix("file://") {
921 let bytes = fs::read(path).with_context(|| format!("read {}", path))?;
922 let digest = digest_for_bytes(&bytes);
923 if let Some(expected) = expected_digest
924 && expected != digest
925 {
926 bail!(
927 "digest mismatch for {} (expected {}, got {})",
928 reference,
929 expected,
930 digest
931 );
932 }
933 return Ok(ResolvedBytes { bytes });
934 }
935
936 let handle = Handle::try_current().context("component resolution requires a Tokio runtime")?;
937 let source = dist
938 .parse_source(reference)
939 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?;
940 let offline = runtime.network_policy() == NetworkPolicy::Offline;
941 let descriptor = if offline {
942 block_on(
943 &handle,
944 dist.resolve(source, greentic_distributor_client::ResolvePolicy),
945 )
946 .map_err(|err| anyhow!("offline cache miss for {}: {}", reference, err))?
947 } else {
948 block_on(
949 &handle,
950 dist.resolve(source, greentic_distributor_client::ResolvePolicy),
951 )
952 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?
953 };
954 let resolved = if offline {
955 block_on(
956 &handle,
957 dist.fetch(&descriptor, greentic_distributor_client::CachePolicy),
958 )
959 .map_err(|err| anyhow!("offline cache miss for {}: {}", reference, err))?
960 } else {
961 block_on(
962 &handle,
963 dist.fetch(&descriptor, greentic_distributor_client::CachePolicy),
964 )
965 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?
966 };
967 let path = resolved
968 .cache_path
969 .ok_or_else(|| anyhow!("resolved component missing path for {}", reference))?;
970 let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
971 let digest = digest_for_bytes(&bytes);
972 if digest != resolved.digest {
973 bail!(
974 "digest mismatch for {} (expected {}, got {})",
975 reference,
976 resolved.digest,
977 digest
978 );
979 }
980 if let Some(expected) = expected_digest
981 && expected != digest
982 {
983 bail!(
984 "digest mismatch for {} (expected {}, got {})",
985 reference,
986 expected,
987 digest
988 );
989 }
990 Ok(ResolvedBytes { bytes })
991}
992
993fn digest_for_bytes(bytes: &[u8]) -> String {
994 let mut hasher = Sha256::new();
995 hasher.update(bytes);
996 format!("sha256:{}", hex::encode(hasher.finalize()))
997}
998
999fn block_on<F, T, E>(handle: &Handle, fut: F) -> std::result::Result<T, E>
1000where
1001 F: std::future::Future<Output = std::result::Result<T, E>>,
1002{
1003 tokio::task::block_in_place(|| handle.block_on(fut))
1004}
1005
1006fn load_component_qa_spec(bytes: &[u8], mode: FlowWizardMode) -> Result<ComponentQaSpec> {
1007 let spec = fetch_wizard_spec(bytes, mode).context("fetch wizard spec from component")?;
1008 decode_component_qa_spec(&spec.qa_spec_cbor, mode).context("decode wizard qa-spec")
1009}
1010
1011fn apply_component_answers(
1012 bytes: &[u8],
1013 mode: FlowWizardMode,
1014 current_config: &[u8],
1015 answers: &[u8],
1016) -> Result<Vec<u8>> {
1017 apply_wizard_answers(bytes, WizardAbi::V6, mode, current_config, answers)
1018 .context("invoke setup.apply_answers")
1019}
1020
1021fn validate_component_config_output(
1022 component_id: &str,
1023 descriptor: Option<&ComponentDescriptor>,
1024 config_cbor: &[u8],
1025) -> Result<()> {
1026 canonical::ensure_canonical(config_cbor)
1027 .context("apply-answers output must be canonical CBOR")?;
1028
1029 let value: ciborium::value::Value =
1030 ciborium::de::from_reader(config_cbor).context("decode apply-answers output as CBOR")?;
1031
1032 let schema = match descriptor {
1033 Some(descriptor) => setup_apply_answers_output_schema(descriptor).with_context(|| {
1034 format!("decode setup.apply_answers output schema for {component_id}")
1035 })?,
1036 None => None,
1037 };
1038 let Some(schema) = schema else {
1039 return Ok(());
1040 };
1041
1042 let diags = validate_value_against_schema(&schema, &value);
1043 let errors: Vec<_> = diags
1044 .iter()
1045 .filter(|diag| diag.severity == Severity::Error)
1046 .collect();
1047 if errors.is_empty() {
1048 return Ok(());
1049 }
1050
1051 let summary = errors
1052 .iter()
1053 .map(|diag| format!("{}: {}", diag.path, diag.message))
1054 .collect::<Vec<_>>()
1055 .join("; ");
1056 bail!(
1057 "component {} apply-answers output failed schema validation ({} violations): {}",
1058 component_id,
1059 errors.len(),
1060 summary
1061 );
1062}
1063
1064fn setup_apply_answers_output_schema(descriptor: &ComponentDescriptor) -> Result<Option<SchemaIr>> {
1065 let Some(op) = descriptor
1066 .ops
1067 .iter()
1068 .find(|op| op.name == "setup.apply_answers")
1069 else {
1070 return Ok(None);
1071 };
1072 match &op.output.schema {
1073 SchemaSource::InlineCbor(bytes) => {
1074 let schema: SchemaIr =
1075 canonical::from_cbor(bytes).context("decode inline output schema")?;
1076 Ok(Some(schema))
1077 }
1078 _ => Ok(None),
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use crate::config::{ComponentConfig, FlowKindLabel};
1086 use clap::ValueEnum;
1087 use greentic_interfaces_host::component_v0_6::exports::greentic::component::node::{
1088 ComponentDescriptor, IoSchema, Op, SchemaSource,
1089 };
1090 use greentic_pack::pack_lock::PackLockV1;
1091 use greentic_types::cbor_bytes::CborBytes;
1092 use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
1093 use greentic_types::{ComponentCapabilities, ComponentProfiles};
1094 use tempfile::TempDir;
1095
1096 #[test]
1097 fn answers_paths_default_to_pack_answers_dir() {
1098 let temp = TempDir::new().expect("temp dir");
1099 let (json_path, cbor_path) =
1100 resolve_answers_paths(temp.path(), None, "default").expect("paths");
1101 assert!(json_path.ends_with("answers/default.answers.json"));
1102 assert!(cbor_path.ends_with("answers/default.answers.cbor"));
1103 }
1104
1105 #[test]
1106 fn answers_paths_custom_file() {
1107 let temp = TempDir::new().expect("temp dir");
1108 let custom = temp.path().join("qa.json");
1109 let (json_path, cbor_path) =
1110 resolve_answers_paths(temp.path(), Some(custom.as_path()), "setup").expect("paths");
1111 assert!(json_path.ends_with("qa.json"));
1112 assert!(cbor_path.ends_with("qa.cbor"));
1113 }
1114
1115 #[test]
1116 fn sorted_json_is_deterministic() {
1117 let mut map = serde_json::Map::new();
1118 map.insert("b".to_string(), serde_json::Value::Number(2.into()));
1119 map.insert("a".to_string(), serde_json::Value::Number(1.into()));
1120 let value = serde_json::Value::Object(map);
1121 let bytes = to_sorted_json_bytes(&value).expect("json bytes");
1122 let text = String::from_utf8(bytes).expect("utf8");
1123 let a_idx = text.find("\"a\"").expect("a key");
1124 let b_idx = text.find("\"b\"").expect("b key");
1125 assert!(a_idx < b_idx);
1126 }
1127
1128 #[test]
1129 fn select_targets_defaults_to_pack_components() {
1130 let cfg = PackConfig {
1131 pack_id: "demo".to_string(),
1132 version: "0.1.0".to_string(),
1133 kind: "application".to_string(),
1134 publisher: "Greentic".to_string(),
1135 name: None,
1136 bootstrap: None,
1137 components: vec![ComponentConfig {
1138 id: "demo.component".to_string(),
1139 version: "0.1.0".to_string(),
1140 world: "greentic:component/stub".to_string(),
1141 supports: vec![FlowKindLabel::Messaging],
1142 profiles: ComponentProfiles::default(),
1143 capabilities: ComponentCapabilities::default(),
1144 wasm: PathBuf::from("components/demo.wasm"),
1145 operations: Vec::new(),
1146 config_schema: None,
1147 resources: None,
1148 configurators: None,
1149 }],
1150 dependencies: Vec::new(),
1151 flows: Vec::new(),
1152 assets: Vec::new(),
1153 extensions: None,
1154 };
1155 let lock = PackLockV1::new(BTreeMap::new());
1156 let args = QaArgs {
1157 pack_dir: PathBuf::from("."),
1158 mode: QaModeLabel::Default,
1159 answers: None,
1160 locale: "en".to_string(),
1161 non_interactive: false,
1162 reask: false,
1163 components: Vec::new(),
1164 all_locked: false,
1165 pack_only: false,
1166 };
1167 let targets = select_target_components(&cfg, &lock, &args).expect("targets");
1168 assert_eq!(targets, vec!["demo.component".to_string()]);
1169 }
1170
1171 fn sample_pack_qa_spec(mode: PackQaMode) -> PackQaSpec {
1172 PackQaSpec {
1173 mode,
1174 title: I18nText::new("pack.qa.title", Some("Pack QA".to_string())),
1175 description: None,
1176 questions: vec![PackQuestion {
1177 id: "region".to_string(),
1178 label: I18nText::new("pack.qa.region", Some("Region".to_string())),
1179 help: None,
1180 error: None,
1181 kind: PackQuestionKind::Text,
1182 required: true,
1183 default: None,
1184 }],
1185 defaults: BTreeMap::new(),
1186 }
1187 }
1188
1189 fn write_pack_cbor_with_metadata(
1190 dir: &Path,
1191 metadata: BTreeMap<String, ciborium::value::Value>,
1192 ) -> Result<()> {
1193 let describe = PackDescribe {
1194 info: greentic_types::schemas::pack::v0_6_0::PackInfo {
1195 id: "demo.pack".to_string(),
1196 version: "0.1.0".to_string(),
1197 role: "application".to_string(),
1198 display_name: None,
1199 },
1200 provided_capabilities: Vec::new(),
1201 required_capabilities: Vec::new(),
1202 units_summary: BTreeMap::new(),
1203 metadata,
1204 };
1205 let bytes =
1206 canonical::to_canonical_cbor_allow_floats(&describe).context("encode pack.cbor")?;
1207 fs::write(dir.join("pack.cbor"), bytes).context("write pack.cbor")?;
1208 Ok(())
1209 }
1210
1211 #[test]
1212 fn pack_qa_inline_cbor_is_loaded() {
1213 let temp = TempDir::new().expect("temp dir");
1214 let spec = sample_pack_qa_spec(PackQaMode::Default);
1215 let spec_bytes = canonical::to_canonical_cbor_allow_floats(&spec).expect("spec bytes");
1216 let source = QaSpecSource::InlineCbor(CborBytes::new(spec_bytes));
1217 let source_bytes =
1218 canonical::to_canonical_cbor_allow_floats(&source).expect("source bytes");
1219 let source_value: ciborium::value::Value =
1220 canonical::from_cbor(&source_bytes).expect("source value");
1221
1222 let mut metadata = BTreeMap::new();
1223 metadata.insert("greentic.qa".to_string(), source_value);
1224 write_pack_cbor_with_metadata(temp.path(), metadata).expect("pack.cbor");
1225
1226 let loaded = load_pack_qa_spec(temp.path(), PackQaMode::Default, true)
1227 .expect("load")
1228 .expect("spec");
1229 assert_eq!(loaded.mode, PackQaMode::Default);
1230 }
1231
1232 #[test]
1233 fn pack_qa_ref_pack_path_is_loaded() {
1234 let temp = TempDir::new().expect("temp dir");
1235 let spec = sample_pack_qa_spec(PackQaMode::Default);
1236 let spec_bytes = canonical::to_canonical_cbor_allow_floats(&spec).expect("spec bytes");
1237 let qa_path = temp.path().join("qa/pack/default.cbor");
1238 fs::create_dir_all(qa_path.parent().unwrap()).expect("qa dir");
1239 fs::write(&qa_path, spec_bytes).expect("write qa spec");
1240
1241 let source = QaSpecSource::RefPackPath("qa/pack/default.cbor".to_string());
1242 let source_bytes =
1243 canonical::to_canonical_cbor_allow_floats(&source).expect("source bytes");
1244 let source_value: ciborium::value::Value =
1245 canonical::from_cbor(&source_bytes).expect("source value");
1246
1247 let mut metadata = BTreeMap::new();
1248 metadata.insert("greentic.qa".to_string(), source_value);
1249 write_pack_cbor_with_metadata(temp.path(), metadata).expect("pack.cbor");
1250
1251 let loaded = load_pack_qa_spec(temp.path(), PackQaMode::Default, true)
1252 .expect("load")
1253 .expect("spec");
1254 assert_eq!(loaded.mode, PackQaMode::Default);
1255 }
1256
1257 #[test]
1258 fn qa_mode_upgrade_alias_normalizes_to_update() {
1259 let update = QaModeLabel::from_str("update", false).expect("parse update");
1260 let upgrade = QaModeLabel::from_str("upgrade", false).expect("parse upgrade");
1261 assert_eq!(update.as_str(), "update");
1262 assert_eq!(upgrade.as_str(), "update");
1263 assert_eq!(update.to_spec_mode(), SpecQaMode::Update);
1264 assert_eq!(upgrade.to_spec_mode(), SpecQaMode::Update);
1265 }
1266
1267 #[test]
1268 fn validation_error_includes_paths_and_structured_violations() {
1269 let output_schema = SchemaIr::Object {
1270 properties: BTreeMap::from([("enabled".to_string(), SchemaIr::Bool)]),
1271 required: vec!["enabled".to_string()],
1272 additional: AdditionalProperties::Forbid,
1273 };
1274 let output_schema_cbor =
1275 canonical::to_canonical_cbor_allow_floats(&output_schema).expect("schema cbor");
1276 let describe = ComponentDescriptor {
1277 name: "demo.component".to_string(),
1278 version: "0.1.0".to_string(),
1279 summary: None,
1280 capabilities: Vec::new(),
1281 ops: vec![Op {
1282 name: "setup.apply_answers".to_string(),
1283 summary: None,
1284 input: IoSchema {
1285 schema: SchemaSource::InlineCbor(vec![0xa0]),
1286 content_type: "application/cbor".to_string(),
1287 schema_version: None,
1288 },
1289 output: IoSchema {
1290 schema: SchemaSource::InlineCbor(output_schema_cbor),
1291 content_type: "application/cbor".to_string(),
1292 schema_version: None,
1293 },
1294 examples: Vec::new(),
1295 }],
1296 schemas: Vec::new(),
1297 setup: None,
1298 };
1299 let config_cbor = canonical::to_canonical_cbor_allow_floats(&serde_json::json!({}))
1300 .expect("encode config");
1301 let err = validate_component_config_output("demo.component", Some(&describe), &config_cbor)
1302 .expect_err("missing required field must fail");
1303 let msg = format!("{err:#}");
1304 assert!(msg.contains("$.enabled"), "missing path in: {msg}");
1305 assert!(
1306 msg.contains("schema validation"),
1307 "missing validation text in: {msg}"
1308 );
1309 }
1310}