Skip to main content

packc/cli/
qa.rs

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    /// Pack root directory containing pack.yaml.
42    #[arg(long = "pack", value_name = "DIR", default_value = ".")]
43    pub pack_dir: PathBuf,
44
45    /// QA mode to run.
46    #[arg(long = "mode", value_enum, default_value = "default")]
47    pub mode: QaModeLabel,
48
49    /// Answers file or directory (defaults to <pack>/answers/<mode>.answers.json).
50    #[arg(long = "answers", value_name = "FILE_OR_DIR")]
51    pub answers: Option<PathBuf>,
52
53    /// Locale tag for i18n lookup.
54    #[arg(long = "locale", default_value = "en")]
55    pub locale: String,
56
57    /// Disable interactive prompting (fail if required answers missing).
58    #[arg(long = "non-interactive", default_value_t = false)]
59    pub non_interactive: bool,
60
61    /// Re-ask questions even if answers exist.
62    #[arg(long = "reask", default_value_t = false)]
63    pub reask: bool,
64
65    /// Run QA for a specific component id (repeatable).
66    #[arg(long = "component", value_name = "ID", action = clap::ArgAction::Append)]
67    pub components: Vec<String>,
68
69    /// Run QA for every entry in pack.lock.cbor.
70    #[arg(long = "all-locked", default_value_t = false)]
71    pub all_locked: bool,
72
73    /// Run pack-level QA only (requires PackDescribe.metadata["greentic.qa"]).
74    #[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            &current_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}