Skip to main content

greentic_setup/
engine.rs

1//! Setup engine — orchestrates plan building and execution for
2//! create/update/remove workflows.
3//!
4//! This is the main entry point that consumers (e.g. greentic-operator)
5//! use to drive bundle setup.
6
7use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, anyhow};
11use serde_json::{Map as JsonMap, Value};
12
13use crate::answers_crypto;
14use crate::bundle;
15use crate::discovery;
16use crate::plan::*;
17use crate::platform_setup::{
18    PlatformSetupAnswers, StaticRoutesPolicy, load_effective_static_routes_defaults,
19    persist_static_routes_artifact,
20};
21use crate::setup_input;
22
23#[derive(Clone, Debug, Default)]
24pub struct LoadedAnswers {
25    pub platform_setup: PlatformSetupAnswers,
26    pub setup_answers: JsonMap<String, Value>,
27}
28
29/// The request object that drives plan building.
30#[derive(Clone, Debug, Default)]
31pub struct SetupRequest {
32    pub bundle: PathBuf,
33    pub bundle_name: Option<String>,
34    pub pack_refs: Vec<String>,
35    pub tenants: Vec<TenantSelection>,
36    pub default_assignments: Vec<PackDefaultSelection>,
37    pub providers: Vec<String>,
38    pub update_ops: BTreeSet<UpdateOp>,
39    pub remove_targets: BTreeSet<RemoveTarget>,
40    pub packs_remove: Vec<PackRemoveSelection>,
41    pub providers_remove: Vec<String>,
42    pub tenants_remove: Vec<TenantSelection>,
43    pub access_changes: Vec<AccessChangeSelection>,
44    pub static_routes: StaticRoutesPolicy,
45    pub deployment_targets: Vec<crate::deployment_targets::DeploymentTargetRecord>,
46    pub setup_answers: serde_json::Map<String, serde_json::Value>,
47    /// Filter by provider domain (messaging, events, secrets, oauth).
48    pub domain_filter: Option<String>,
49    /// Number of parallel setup operations.
50    pub parallel: usize,
51    /// Backup existing config before setup.
52    pub backup: bool,
53    /// Skip secrets initialization.
54    pub skip_secrets_init: bool,
55    /// Continue on error (best effort).
56    pub best_effort: bool,
57}
58
59/// Configuration for the setup engine.
60pub struct SetupConfig {
61    pub tenant: String,
62    pub team: Option<String>,
63    pub env: String,
64    pub offline: bool,
65    pub verbose: bool,
66}
67
68/// The setup engine orchestrates plan → execute for bundle lifecycle.
69pub struct SetupEngine {
70    config: SetupConfig,
71}
72
73impl SetupEngine {
74    pub fn new(config: SetupConfig) -> Self {
75        Self { config }
76    }
77
78    /// Build a plan for the given mode and request.
79    pub fn plan(
80        &self,
81        mode: SetupMode,
82        request: &SetupRequest,
83        dry_run: bool,
84    ) -> anyhow::Result<SetupPlan> {
85        match mode {
86            SetupMode::Create => apply_create(request, dry_run),
87            SetupMode::Update => apply_update(request, dry_run),
88            SetupMode::Remove => apply_remove(request, dry_run),
89        }
90    }
91
92    /// Print a human-readable plan summary to stdout.
93    pub fn print_plan(&self, plan: &SetupPlan) {
94        print_plan_summary(plan);
95    }
96
97    /// Access the engine configuration.
98    pub fn config(&self) -> &SetupConfig {
99        &self.config
100    }
101
102    /// Execute a setup plan.
103    ///
104    /// Runs each step in the plan, performing the actual bundle setup operations.
105    /// Returns an execution report with details about what was done.
106    pub fn execute(&self, plan: &SetupPlan) -> anyhow::Result<SetupExecutionReport> {
107        if plan.dry_run {
108            return Err(anyhow!("cannot execute a dry-run plan"));
109        }
110
111        let bundle = &plan.bundle;
112        let mut report = SetupExecutionReport {
113            bundle: bundle.clone(),
114            resolved_packs: Vec::new(),
115            resolved_manifests: Vec::new(),
116            provider_updates: 0,
117            warnings: Vec::new(),
118        };
119
120        for step in &plan.steps {
121            match step.kind {
122                SetupStepKind::NoOp => {
123                    if self.config.verbose {
124                        println!("  [skip] {}", step.description);
125                    }
126                }
127                SetupStepKind::CreateBundle => {
128                    self.execute_create_bundle(bundle, &plan.metadata)?;
129                    if self.config.verbose {
130                        println!("  [done] {}", step.description);
131                    }
132                }
133                SetupStepKind::ResolvePacks => {
134                    let resolved = self.execute_resolve_packs(bundle, &plan.metadata)?;
135                    report.resolved_packs.extend(resolved);
136                    if self.config.verbose {
137                        println!("  [done] {}", step.description);
138                    }
139                }
140                SetupStepKind::AddPacksToBundle => {
141                    self.execute_add_packs_to_bundle(bundle, &report.resolved_packs)?;
142                    let _ = crate::deployment_targets::persist_explicit_deployment_targets(
143                        bundle,
144                        &plan.metadata.deployment_targets,
145                    );
146                    if self.config.verbose {
147                        println!("  [done] {}", step.description);
148                    }
149                }
150                SetupStepKind::ValidateCapabilities => {
151                    let cap_report = crate::capabilities::validate_and_upgrade_packs(bundle)?;
152                    for warn in &cap_report.warnings {
153                        report.warnings.push(warn.message.clone());
154                    }
155                    if self.config.verbose {
156                        println!(
157                            "  [done] {} (checked={}, upgraded={})",
158                            step.description,
159                            cap_report.checked,
160                            cap_report.upgraded.len()
161                        );
162                    }
163                }
164                SetupStepKind::ApplyPackSetup => {
165                    let count = self.execute_apply_pack_setup(bundle, &plan.metadata)?;
166                    report.provider_updates += count;
167                    if self.config.verbose {
168                        println!("  [done] {}", step.description);
169                    }
170                }
171                SetupStepKind::WriteGmapRules => {
172                    self.execute_write_gmap_rules(bundle, &plan.metadata)?;
173                    if self.config.verbose {
174                        println!("  [done] {}", step.description);
175                    }
176                }
177                SetupStepKind::RunResolver => {
178                    // Resolver is typically run by the runtime, not setup
179                    if self.config.verbose {
180                        println!("  [skip] {} (deferred to runtime)", step.description);
181                    }
182                }
183                SetupStepKind::CopyResolvedManifest => {
184                    let manifests = self.execute_copy_resolved_manifests(bundle, &plan.metadata)?;
185                    report.resolved_manifests.extend(manifests);
186                    if self.config.verbose {
187                        println!("  [done] {}", step.description);
188                    }
189                }
190                SetupStepKind::ValidateBundle => {
191                    self.execute_validate_bundle(bundle)?;
192                    if self.config.verbose {
193                        println!("  [done] {}", step.description);
194                    }
195                }
196            }
197        }
198
199        Ok(report)
200    }
201
202    /// Emit an answers template JSON file.
203    ///
204    /// Discovers all packs in the bundle and generates a template with all
205    /// setup questions. Users fill this in and pass it via `--answers`.
206    pub fn emit_answers(
207        &self,
208        plan: &SetupPlan,
209        output_path: &Path,
210        key: Option<&str>,
211        interactive: bool,
212    ) -> anyhow::Result<()> {
213        let bundle = &plan.bundle;
214
215        // Build the answers document structure
216        let mut answers_doc = serde_json::json!({
217            "greentic_setup_version": "1.0.0",
218            "bundle_source": bundle.display().to_string(),
219            "tenant": self.config.tenant,
220            "team": self.config.team,
221            "env": self.config.env,
222            "platform_setup": {
223                "static_routes": plan.metadata.static_routes.to_answers(),
224                "deployment_targets": plan.metadata.deployment_targets
225            },
226            "setup_answers": {}
227        });
228
229        if !plan.metadata.static_routes.public_web_enabled
230            && plan.metadata.static_routes.public_base_url.is_none()
231            && let Some(existing) = load_effective_static_routes_defaults(
232                bundle,
233                &self.config.tenant,
234                self.config.team.as_deref(),
235            )?
236        {
237            answers_doc["platform_setup"]["static_routes"] =
238                serde_json::to_value(existing.to_answers())?;
239        }
240
241        // Discover packs and extract their QA specs
242        let setup_answers = answers_doc
243            .get_mut("setup_answers")
244            .and_then(|v| v.as_object_mut())
245            .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
246
247        // Add existing answers from the plan metadata
248        for (provider_id, answers) in &plan.metadata.setup_answers {
249            setup_answers.insert(provider_id.clone(), answers.clone());
250        }
251
252        // Discover packs and populate question templates for all providers.
253        // If a provider entry already exists but is empty, merge in the
254        // questions from setup.yaml so the user sees what needs to be filled.
255        if bundle.exists() {
256            let discovered = discovery::discover(bundle)?;
257            for provider in discovered.providers {
258                let provider_id = provider.provider_id.clone();
259                let existing_is_empty = setup_answers
260                    .get(&provider_id)
261                    .and_then(|v| v.as_object())
262                    .is_some_and(|m| m.is_empty());
263                if !setup_answers.contains_key(&provider_id) || existing_is_empty {
264                    // Load the setup spec from the pack and create template
265                    let template =
266                        if let Some(spec) = setup_input::load_setup_spec(&provider.pack_path)? {
267                            // Pack has setup.yaml - extract questions
268                            let mut entries = JsonMap::new();
269                            for question in &spec.questions {
270                                let default_value = question
271                                    .default
272                                    .clone()
273                                    .unwrap_or_else(|| Value::String(String::new()));
274                                entries.insert(question.name.clone(), default_value);
275                            }
276                            entries
277                        } else {
278                            // Pack uses flow-based setup or has no questions
279                            // Add empty entry so user knows pack exists
280                            JsonMap::new()
281                        };
282                    setup_answers.insert(provider_id, Value::Object(template));
283                }
284            }
285        }
286
287        self.encrypt_secret_answers(bundle, &mut answers_doc, key, interactive)?;
288
289        // Write the answers document to the output path
290        let output_content = serde_json::to_string_pretty(&answers_doc)
291            .context("failed to serialize answers document")?;
292
293        if let Some(parent) = output_path.parent() {
294            std::fs::create_dir_all(parent)
295                .with_context(|| format!("failed to create directory: {}", parent.display()))?;
296        }
297
298        std::fs::write(output_path, output_content)
299            .with_context(|| format!("failed to write answers to: {}", output_path.display()))?;
300
301        println!("Answers template written to: {}", output_path.display());
302        Ok(())
303    }
304
305    /// Load answers from a JSON/YAML file.
306    pub fn load_answers(
307        &self,
308        answers_path: &Path,
309        key: Option<&str>,
310        interactive: bool,
311    ) -> anyhow::Result<LoadedAnswers> {
312        let raw = setup_input::load_setup_input(answers_path)?;
313        let raw = if answers_crypto::has_encrypted_values(&raw) {
314            let resolved_key = match key {
315                Some(value) => value.to_string(),
316                None if interactive => answers_crypto::prompt_for_key("decrypting answers")?,
317                None => {
318                    return Err(anyhow!(
319                        "answers file contains encrypted secret values; rerun with --key or interactive input"
320                    ));
321                }
322            };
323            answers_crypto::decrypt_tree(&raw, &resolved_key)?
324        } else {
325            raw
326        };
327        match raw {
328            Value::Object(map) => {
329                let platform_setup = map
330                    .get("platform_setup")
331                    .cloned()
332                    .map(serde_json::from_value)
333                    .transpose()
334                    .context("parse platform_setup answers")?
335                    .unwrap_or_default();
336
337                if let Some(Value::Object(setup_answers)) = map.get("setup_answers") {
338                    Ok(LoadedAnswers {
339                        platform_setup,
340                        setup_answers: setup_answers.clone(),
341                    })
342                } else if map.contains_key("bundle_source")
343                    || map.contains_key("tenant")
344                    || map.contains_key("team")
345                    || map.contains_key("env")
346                    || map.contains_key("platform_setup")
347                {
348                    Ok(LoadedAnswers {
349                        platform_setup,
350                        setup_answers: JsonMap::new(),
351                    })
352                } else {
353                    Ok(LoadedAnswers {
354                        platform_setup,
355                        setup_answers: map,
356                    })
357                }
358            }
359            _ => Err(anyhow!("answers file must be a JSON/YAML object")),
360        }
361    }
362
363    fn encrypt_secret_answers(
364        &self,
365        bundle: &Path,
366        answers_doc: &mut Value,
367        key: Option<&str>,
368        interactive: bool,
369    ) -> anyhow::Result<()> {
370        let setup_answers = answers_doc
371            .get_mut("setup_answers")
372            .and_then(Value::as_object_mut)
373            .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
374        let discovered = if bundle.exists() {
375            discovery::discover(bundle)?
376        } else {
377            return Ok(());
378        };
379
380        let mut secret_paths = Vec::new();
381        for provider in discovered.providers {
382            let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(
383                &provider.pack_path,
384                &provider.provider_id,
385            ) else {
386                continue;
387            };
388            let Some(provider_answers) = setup_answers
389                .get_mut(&provider.provider_id)
390                .and_then(Value::as_object_mut)
391            else {
392                continue;
393            };
394            for question in form_spec.questions {
395                if !question.secret {
396                    continue;
397                }
398                let Some(value) = provider_answers.get(&question.id).cloned() else {
399                    continue;
400                };
401                if value.is_null() || value == Value::String(String::new()) {
402                    continue;
403                }
404                secret_paths.push((provider.provider_id.clone(), question.id.clone(), value));
405            }
406        }
407
408        if secret_paths.is_empty() {
409            return Ok(());
410        }
411
412        let resolved_key = match key {
413            Some(value) => value.to_string(),
414            None if interactive => answers_crypto::prompt_for_key("encrypting answers")?,
415            None => {
416                return Err(anyhow!(
417                    "answer document includes secret values; rerun with --key or interactive input"
418                ));
419            }
420        };
421
422        for (provider_id, field_id, value) in secret_paths {
423            let encrypted = answers_crypto::encrypt_value(&value, &resolved_key)?;
424            if let Some(provider_answers) = setup_answers
425                .get_mut(&provider_id)
426                .and_then(Value::as_object_mut)
427            {
428                provider_answers.insert(field_id, encrypted);
429            }
430        }
431
432        Ok(())
433    }
434
435    // ── Step executors ─────────────────────────────────────────────────────
436
437    fn execute_create_bundle(
438        &self,
439        bundle_path: &Path,
440        metadata: &SetupPlanMetadata,
441    ) -> anyhow::Result<()> {
442        bundle::create_demo_bundle_structure(bundle_path, metadata.bundle_name.as_deref())
443            .context("failed to create bundle structure")
444    }
445
446    fn execute_resolve_packs(
447        &self,
448        _bundle_path: &Path,
449        metadata: &SetupPlanMetadata,
450    ) -> anyhow::Result<Vec<ResolvedPackInfo>> {
451        let mut resolved = Vec::new();
452
453        for pack_ref in &metadata.pack_refs {
454            // For now, we only support local pack refs (file paths)
455            // OCI resolution requires async and the distributor client
456            let path = PathBuf::from(pack_ref);
457
458            // Try to canonicalize the path to handle relative paths correctly
459            let resolved_path = if path.is_absolute() {
460                path.clone()
461            } else {
462                std::env::current_dir()
463                    .ok()
464                    .map(|cwd| cwd.join(&path))
465                    .unwrap_or_else(|| path.clone())
466            };
467
468            if resolved_path.exists() {
469                let canonical = resolved_path
470                    .canonicalize()
471                    .unwrap_or(resolved_path.clone());
472                resolved.push(ResolvedPackInfo {
473                    source_ref: pack_ref.clone(),
474                    mapped_ref: canonical.display().to_string(),
475                    resolved_digest: format!("sha256:{}", compute_simple_hash(pack_ref)),
476                    pack_id: canonical
477                        .file_stem()
478                        .and_then(|s| s.to_str())
479                        .unwrap_or("unknown")
480                        .to_string(),
481                    entry_flows: Vec::new(),
482                    cached_path: canonical.clone(),
483                    output_path: canonical,
484                });
485            } else if pack_ref.starts_with("oci://")
486                || pack_ref.starts_with("repo://")
487                || pack_ref.starts_with("store://")
488            {
489                // Remote packs need async resolution via distributor-client
490                // For now, we'll skip and let the caller handle this
491                tracing::warn!("remote pack ref requires async resolution: {}", pack_ref);
492            } else {
493                // Log warning for unresolved local paths
494                tracing::warn!(
495                    "pack ref not found: {} (resolved to: {})",
496                    pack_ref,
497                    resolved_path.display()
498                );
499            }
500        }
501
502        Ok(resolved)
503    }
504
505    fn execute_add_packs_to_bundle(
506        &self,
507        bundle_path: &Path,
508        resolved_packs: &[ResolvedPackInfo],
509    ) -> anyhow::Result<()> {
510        for pack in resolved_packs {
511            // Determine target directory based on pack ID domain prefix
512            let target_dir = Self::get_pack_target_dir(bundle_path, &pack.pack_id);
513            std::fs::create_dir_all(&target_dir)?;
514
515            let target_path = target_dir.join(format!("{}.gtpack", pack.pack_id));
516            if pack.cached_path.exists() && !target_path.exists() {
517                std::fs::copy(&pack.cached_path, &target_path).with_context(|| {
518                    format!(
519                        "failed to copy pack {} to {}",
520                        pack.cached_path.display(),
521                        target_path.display()
522                    )
523                })?;
524            }
525        }
526        Ok(())
527    }
528
529    /// Determine the target directory for a pack based on its ID.
530    ///
531    /// Packs with domain prefixes (e.g., `messaging-telegram`, `events-webhook`)
532    /// go to `providers/<domain>/`. Other packs go to `packs/`.
533    fn get_pack_target_dir(bundle_path: &Path, pack_id: &str) -> PathBuf {
534        const DOMAIN_PREFIXES: &[&str] = &[
535            "messaging-",
536            "events-",
537            "oauth-",
538            "secrets-",
539            "mcp-",
540            "state-",
541        ];
542
543        for prefix in DOMAIN_PREFIXES {
544            if pack_id.starts_with(prefix) {
545                let domain = prefix.trim_end_matches('-');
546                return bundle_path.join("providers").join(domain);
547            }
548        }
549
550        // Default to packs/ for non-provider packs
551        bundle_path.join("packs")
552    }
553
554    fn execute_apply_pack_setup(
555        &self,
556        bundle_path: &Path,
557        metadata: &SetupPlanMetadata,
558    ) -> anyhow::Result<usize> {
559        let mut count = 0;
560
561        if !metadata.providers_remove.is_empty() {
562            count +=
563                self.execute_remove_provider_artifacts(bundle_path, &metadata.providers_remove)?;
564        }
565
566        // Auto-install provider packs that are referenced in setup_answers
567        // but not yet present in the bundle.
568        self.auto_install_provider_packs(bundle_path, metadata);
569
570        // Discover packs so we can find pack_path for secret alias seeding
571        let discovered = if bundle_path.exists() {
572            discovery::discover(bundle_path).ok()
573        } else {
574            None
575        };
576
577        // Persist setup answers to local config files and dev secrets store
578        for (provider_id, answers) in &metadata.setup_answers {
579            // Write answers to provider config directory
580            let config_dir = bundle_path.join("state").join("config").join(provider_id);
581            std::fs::create_dir_all(&config_dir)?;
582
583            let config_path = config_dir.join("setup-answers.json");
584            let content = serde_json::to_string_pretty(answers)
585                .context("failed to serialize setup answers")?;
586            std::fs::write(&config_path, content).with_context(|| {
587                format!(
588                    "failed to write setup answers to: {}",
589                    config_path.display()
590                )
591            })?;
592
593            // Persist all answer values to the dev secrets store so that
594            // WASM components can read them via the secrets API at runtime.
595            let pack_path = discovered.as_ref().and_then(|d| {
596                d.providers
597                    .iter()
598                    .find(|p| p.provider_id == *provider_id)
599                    .map(|p| p.pack_path.as_path())
600            });
601            let env = crate::resolve_env(Some(&self.config.env));
602            let rt = tokio::runtime::Runtime::new()
603                .context("failed to create tokio runtime for secrets persistence")?;
604            let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
605                bundle_path,
606                &env,
607                &self.config.tenant,
608                self.config.team.as_deref(),
609                provider_id,
610                answers,
611                pack_path,
612            ))?;
613            if self.config.verbose && !persisted.is_empty() {
614                println!(
615                    "  [secrets] persisted {} key(s) for {provider_id}",
616                    persisted.len()
617                );
618            }
619
620            // Register webhooks if the provider needs one (e.g. Telegram, Slack, Webex)
621            if let Some(result) = crate::webhook::register_webhook(
622                provider_id,
623                answers,
624                &self.config.tenant,
625                self.config.team.as_deref(),
626            ) {
627                let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
628                if ok {
629                    println!("  [webhook] registered for {provider_id}");
630                } else {
631                    let err = result
632                        .get("error")
633                        .and_then(Value::as_str)
634                        .unwrap_or("unknown");
635                    println!("  [webhook] WARNING: registration failed for {provider_id}: {err}");
636                }
637            }
638
639            count += 1;
640        }
641
642        persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
643        let _ = crate::deployment_targets::persist_explicit_deployment_targets(
644            bundle_path,
645            &metadata.deployment_targets,
646        );
647
648        // Print post-setup instructions for providers needing manual steps
649        let provider_configs: Vec<(String, Value)> = metadata
650            .setup_answers
651            .iter()
652            .map(|(id, val)| (id.clone(), val.clone()))
653            .collect();
654        let team = self.config.team.as_deref().unwrap_or("default");
655        crate::webhook::print_post_setup_instructions(&provider_configs, &self.config.tenant, team);
656
657        Ok(count)
658    }
659
660    fn execute_remove_provider_artifacts(
661        &self,
662        bundle_path: &Path,
663        providers_remove: &[String],
664    ) -> anyhow::Result<usize> {
665        let mut removed = 0usize;
666        let discovered = discovery::discover(bundle_path).ok();
667        for provider_id in providers_remove {
668            if let Some(discovered) = discovered.as_ref()
669                && let Some(provider) = discovered
670                    .providers
671                    .iter()
672                    .find(|provider| provider.provider_id == *provider_id)
673            {
674                if provider.pack_path.exists() {
675                    std::fs::remove_file(&provider.pack_path).with_context(|| {
676                        format!(
677                            "failed to remove provider pack {}",
678                            provider.pack_path.display()
679                        )
680                    })?;
681                }
682                removed += 1;
683            } else {
684                let target_dir = Self::get_pack_target_dir(bundle_path, provider_id);
685                let target_path = target_dir.join(format!("{provider_id}.gtpack"));
686                if target_path.exists() {
687                    std::fs::remove_file(&target_path).with_context(|| {
688                        format!("failed to remove provider pack {}", target_path.display())
689                    })?;
690                    removed += 1;
691                }
692            }
693
694            let config_dir = bundle_path.join("state").join("config").join(provider_id);
695            if config_dir.exists() {
696                std::fs::remove_dir_all(&config_dir).with_context(|| {
697                    format!(
698                        "failed to remove provider config dir {}",
699                        config_dir.display()
700                    )
701                })?;
702            }
703        }
704        Ok(removed)
705    }
706
707    /// Search sibling bundles for provider packs referenced in setup_answers
708    /// and install them into this bundle if missing.
709    fn auto_install_provider_packs(&self, bundle_path: &Path, metadata: &SetupPlanMetadata) {
710        let bundle_abs =
711            std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
712
713        for provider_id in metadata.setup_answers.keys() {
714            let target_dir = Self::get_pack_target_dir(bundle_path, provider_id);
715            let target_path = target_dir.join(format!("{provider_id}.gtpack"));
716            if target_path.exists() {
717                continue;
718            }
719
720            // Determine the provider domain from the ID
721            let domain = Self::domain_from_provider_id(provider_id);
722
723            // Search for the pack in sibling bundles and build output
724            if let Some(source) = Self::find_provider_pack_source(provider_id, domain, &bundle_abs)
725            {
726                if let Err(err) = std::fs::create_dir_all(&target_dir) {
727                    eprintln!(
728                        "  [provider] WARNING: failed to create {}: {err}",
729                        target_dir.display()
730                    );
731                    continue;
732                }
733                match std::fs::copy(&source, &target_path) {
734                    Ok(_) => println!(
735                        "  [provider] installed {provider_id}.gtpack from {}",
736                        source.display()
737                    ),
738                    Err(err) => eprintln!(
739                        "  [provider] WARNING: failed to copy {}: {err}",
740                        source.display()
741                    ),
742                }
743            } else {
744                eprintln!(
745                    "  [provider] WARNING: {provider_id}.gtpack not found in sibling bundles"
746                );
747            }
748        }
749    }
750
751    /// Extract domain from a provider ID (e.g. "messaging-telegram" → "messaging").
752    fn domain_from_provider_id(provider_id: &str) -> &str {
753        const DOMAIN_PREFIXES: &[&str] = &[
754            "messaging-",
755            "events-",
756            "oauth-",
757            "secrets-",
758            "mcp-",
759            "state-",
760            "telemetry-",
761        ];
762        for prefix in DOMAIN_PREFIXES {
763            if provider_id.starts_with(prefix) {
764                return prefix.trim_end_matches('-');
765            }
766        }
767        "messaging" // default
768    }
769
770    /// Search known locations for a provider pack file.
771    ///
772    /// Search order:
773    /// 1. Sibling bundle directories: `../<bundle>/providers/<domain>/<id>.gtpack`
774    /// 2. Build output: `../greentic-messaging-providers/target/packs/<id>.gtpack`
775    fn find_provider_pack_source(
776        provider_id: &str,
777        domain: &str,
778        bundle_abs: &Path,
779    ) -> Option<PathBuf> {
780        let parent = bundle_abs.parent()?;
781        let filename = format!("{provider_id}.gtpack");
782
783        // 1. Sibling bundles
784        if let Ok(entries) = std::fs::read_dir(parent) {
785            for entry in entries.flatten() {
786                let sibling = entry.path();
787                if sibling == *bundle_abs || !sibling.is_dir() {
788                    continue;
789                }
790                let candidate = sibling.join("providers").join(domain).join(&filename);
791                if candidate.is_file() {
792                    return Some(candidate);
793                }
794            }
795        }
796
797        // 2. Build output from greentic-messaging-providers
798        for ancestor in parent.ancestors().take(4) {
799            let candidate = ancestor
800                .join("greentic-messaging-providers")
801                .join("target")
802                .join("packs")
803                .join(&filename);
804            if candidate.is_file() {
805                return Some(candidate);
806            }
807        }
808
809        None
810    }
811
812    fn execute_write_gmap_rules(
813        &self,
814        bundle_path: &Path,
815        metadata: &SetupPlanMetadata,
816    ) -> anyhow::Result<()> {
817        for tenant_sel in &metadata.tenants {
818            let gmap_path =
819                bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
820
821            if let Some(parent) = gmap_path.parent() {
822                std::fs::create_dir_all(parent)?;
823            }
824
825            // Build gmap content from allow_paths
826            let mut content = String::new();
827            if tenant_sel.allow_paths.is_empty() {
828                content.push_str("_ = forbidden\n");
829            } else {
830                for path in &tenant_sel.allow_paths {
831                    content.push_str(&format!("{} = allowed\n", path));
832                }
833                content.push_str("_ = forbidden\n");
834            }
835
836            std::fs::write(&gmap_path, content)
837                .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
838        }
839        Ok(())
840    }
841
842    fn execute_copy_resolved_manifests(
843        &self,
844        bundle_path: &Path,
845        metadata: &SetupPlanMetadata,
846    ) -> anyhow::Result<Vec<PathBuf>> {
847        let mut manifests = Vec::new();
848        let resolved_dir = bundle_path.join("resolved");
849        std::fs::create_dir_all(&resolved_dir)?;
850
851        for tenant_sel in &metadata.tenants {
852            let filename =
853                bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
854            let manifest_path = resolved_dir.join(&filename);
855
856            // Create an empty manifest placeholder if it doesn't exist
857            if !manifest_path.exists() {
858                std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
859            }
860            manifests.push(manifest_path);
861        }
862
863        Ok(manifests)
864    }
865
866    fn execute_validate_bundle(&self, bundle_path: &Path) -> anyhow::Result<()> {
867        bundle::validate_bundle_exists(bundle_path)
868    }
869}
870
871// ── Plan builders ───────────────────────────────────────────────────────────
872
873pub fn apply_create(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
874    if request.tenants.is_empty() {
875        return Err(anyhow!("at least one tenant selection is required"));
876    }
877
878    let pack_refs = dedup_sorted(&request.pack_refs);
879    let tenants = normalize_tenants(&request.tenants);
880
881    let mut steps = Vec::new();
882    if !pack_refs.is_empty() {
883        steps.push(step(
884            SetupStepKind::ResolvePacks,
885            "Resolve selected pack refs via distributor client",
886            [("count", pack_refs.len().to_string())],
887        ));
888    } else {
889        steps.push(step(
890            SetupStepKind::NoOp,
891            "No pack refs selected; skipping pack resolution",
892            [("reason", "empty_pack_refs".to_string())],
893        ));
894    }
895    steps.push(step(
896        SetupStepKind::CreateBundle,
897        "Create demo bundle scaffold using existing conventions",
898        [("bundle", request.bundle.display().to_string())],
899    ));
900    if !pack_refs.is_empty() {
901        steps.push(step(
902            SetupStepKind::AddPacksToBundle,
903            "Copy fetched packs into bundle/packs",
904            [("count", pack_refs.len().to_string())],
905        ));
906        steps.push(step(
907            SetupStepKind::ValidateCapabilities,
908            "Validate provider packs have capabilities extension",
909            [("check", "greentic.ext.capabilities.v1".to_string())],
910        ));
911        steps.push(step(
912            SetupStepKind::ApplyPackSetup,
913            "Apply pack-declared setup outputs through internal setup hooks",
914            [("status", "planned".to_string())],
915        ));
916    } else if !request.setup_answers.is_empty() {
917        // No new packs to fetch, but answers were provided for existing packs
918        steps.push(step(
919            SetupStepKind::ValidateCapabilities,
920            "Validate provider packs have capabilities extension",
921            [("check", "greentic.ext.capabilities.v1".to_string())],
922        ));
923        steps.push(step(
924            SetupStepKind::ApplyPackSetup,
925            "Apply setup answers to existing bundle packs",
926            [("providers", request.setup_answers.len().to_string())],
927        ));
928    } else {
929        steps.push(step(
930            SetupStepKind::NoOp,
931            "No fetched packs to add or setup",
932            [("reason", "empty_pack_refs".to_string())],
933        ));
934    }
935    steps.push(step(
936        SetupStepKind::WriteGmapRules,
937        "Write tenant/team allow rules to gmap",
938        [("targets", tenants.len().to_string())],
939    ));
940    steps.push(step(
941        SetupStepKind::RunResolver,
942        "Run resolver pipeline (same as demo allow)",
943        [("resolver", "project::sync_project".to_string())],
944    ));
945    steps.push(step(
946        SetupStepKind::CopyResolvedManifest,
947        "Copy state/resolved manifests into resolved/ for demo start",
948        [("targets", tenants.len().to_string())],
949    ));
950    steps.push(step(
951        SetupStepKind::ValidateBundle,
952        "Validate bundle is loadable by internal demo pipeline",
953        [("check", "resolved manifests present".to_string())],
954    ));
955
956    Ok(SetupPlan {
957        mode: "create".to_string(),
958        dry_run,
959        bundle: request.bundle.clone(),
960        steps,
961        metadata: build_metadata(request, pack_refs, tenants),
962    })
963}
964
965pub fn apply_update(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
966    let pack_refs = dedup_sorted(&request.pack_refs);
967    let tenants = normalize_tenants(&request.tenants);
968
969    let mut ops = request.update_ops.clone();
970    if ops.is_empty() {
971        infer_update_ops(&mut ops, &pack_refs, request, &tenants);
972    }
973
974    let mut steps = vec![step(
975        SetupStepKind::ValidateBundle,
976        "Validate target bundle exists before update",
977        [("mode", "update".to_string())],
978    )];
979
980    if ops.is_empty() {
981        steps.push(step(
982            SetupStepKind::NoOp,
983            "No update operations selected",
984            [("reason", "empty_update_ops".to_string())],
985        ));
986    }
987    if ops.contains(&UpdateOp::PacksAdd) {
988        if pack_refs.is_empty() {
989            steps.push(step(
990                SetupStepKind::NoOp,
991                "packs_add selected without pack refs",
992                [("reason", "empty_pack_refs".to_string())],
993            ));
994        } else {
995            steps.push(step(
996                SetupStepKind::ResolvePacks,
997                "Resolve selected pack refs via distributor client",
998                [("count", pack_refs.len().to_string())],
999            ));
1000            steps.push(step(
1001                SetupStepKind::AddPacksToBundle,
1002                "Copy fetched packs into bundle/packs",
1003                [("count", pack_refs.len().to_string())],
1004            ));
1005        }
1006    }
1007    if ops.contains(&UpdateOp::PacksRemove) {
1008        if request.packs_remove.is_empty() {
1009            steps.push(step(
1010                SetupStepKind::NoOp,
1011                "packs_remove selected without targets",
1012                [("reason", "empty_packs_remove".to_string())],
1013            ));
1014        } else {
1015            steps.push(step(
1016                SetupStepKind::AddPacksToBundle,
1017                "Remove pack artifacts/default links from bundle",
1018                [("count", request.packs_remove.len().to_string())],
1019            ));
1020        }
1021    }
1022    if ops.contains(&UpdateOp::ProvidersAdd) {
1023        if request.providers.is_empty() && pack_refs.is_empty() {
1024            steps.push(step(
1025                SetupStepKind::NoOp,
1026                "providers_add selected without providers or new packs",
1027                [("reason", "empty_providers_add".to_string())],
1028            ));
1029        } else {
1030            steps.push(step(
1031                SetupStepKind::ApplyPackSetup,
1032                "Enable providers in providers/providers.json",
1033                [("count", request.providers.len().to_string())],
1034            ));
1035        }
1036    }
1037    if ops.contains(&UpdateOp::ProvidersRemove) {
1038        if request.providers_remove.is_empty() {
1039            steps.push(step(
1040                SetupStepKind::NoOp,
1041                "providers_remove selected without providers",
1042                [("reason", "empty_providers_remove".to_string())],
1043            ));
1044        } else {
1045            steps.push(step(
1046                SetupStepKind::ApplyPackSetup,
1047                "Disable/remove providers in providers/providers.json",
1048                [("count", request.providers_remove.len().to_string())],
1049            ));
1050        }
1051    }
1052    if ops.contains(&UpdateOp::TenantsAdd) {
1053        if tenants.is_empty() {
1054            steps.push(step(
1055                SetupStepKind::NoOp,
1056                "tenants_add selected without tenant targets",
1057                [("reason", "empty_tenants_add".to_string())],
1058            ));
1059        } else {
1060            steps.push(step(
1061                SetupStepKind::WriteGmapRules,
1062                "Ensure tenant/team directories and allow rules",
1063                [("targets", tenants.len().to_string())],
1064            ));
1065        }
1066    }
1067    if ops.contains(&UpdateOp::TenantsRemove) {
1068        if request.tenants_remove.is_empty() {
1069            steps.push(step(
1070                SetupStepKind::NoOp,
1071                "tenants_remove selected without tenant targets",
1072                [("reason", "empty_tenants_remove".to_string())],
1073            ));
1074        } else {
1075            steps.push(step(
1076                SetupStepKind::WriteGmapRules,
1077                "Remove tenant/team directories and related rules",
1078                [("targets", request.tenants_remove.len().to_string())],
1079            ));
1080        }
1081    }
1082    if ops.contains(&UpdateOp::AccessChange) {
1083        let access_count = request.access_changes.len()
1084            + tenants.iter().filter(|t| !t.allow_paths.is_empty()).count();
1085        if access_count == 0 {
1086            steps.push(step(
1087                SetupStepKind::NoOp,
1088                "access_change selected without mutations",
1089                [("reason", "empty_access_changes".to_string())],
1090            ));
1091        } else {
1092            steps.push(step(
1093                SetupStepKind::WriteGmapRules,
1094                "Apply access rule updates",
1095                [("changes", access_count.to_string())],
1096            ));
1097            steps.push(step(
1098                SetupStepKind::RunResolver,
1099                "Run resolver pipeline (same as demo allow/forbid)",
1100                [("resolver", "project::sync_project".to_string())],
1101            ));
1102            steps.push(step(
1103                SetupStepKind::CopyResolvedManifest,
1104                "Copy state/resolved manifests into resolved/ for demo start",
1105                [("targets", tenants.len().to_string())],
1106            ));
1107        }
1108    }
1109    steps.push(step(
1110        SetupStepKind::ValidateBundle,
1111        "Validate bundle is loadable by internal demo pipeline",
1112        [("check", "resolved manifests present".to_string())],
1113    ));
1114
1115    Ok(SetupPlan {
1116        mode: SetupMode::Update.as_str().to_string(),
1117        dry_run,
1118        bundle: request.bundle.clone(),
1119        steps,
1120        metadata: build_metadata_with_ops(request, pack_refs, tenants, ops),
1121    })
1122}
1123
1124pub fn apply_remove(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
1125    let tenants = normalize_tenants(&request.tenants);
1126
1127    let mut targets = request.remove_targets.clone();
1128    if targets.is_empty() {
1129        if !request.packs_remove.is_empty() {
1130            targets.insert(RemoveTarget::Packs);
1131        }
1132        if !request.providers_remove.is_empty() {
1133            targets.insert(RemoveTarget::Providers);
1134        }
1135        if !request.tenants_remove.is_empty() {
1136            targets.insert(RemoveTarget::TenantsTeams);
1137        }
1138    }
1139
1140    let mut steps = vec![step(
1141        SetupStepKind::ValidateBundle,
1142        "Validate target bundle exists before remove",
1143        [("mode", "remove".to_string())],
1144    )];
1145
1146    if targets.is_empty() {
1147        steps.push(step(
1148            SetupStepKind::NoOp,
1149            "No remove targets selected",
1150            [("reason", "empty_remove_targets".to_string())],
1151        ));
1152    }
1153    if targets.contains(&RemoveTarget::Packs) {
1154        if request.packs_remove.is_empty() {
1155            steps.push(step(
1156                SetupStepKind::NoOp,
1157                "packs target selected without pack identifiers",
1158                [("reason", "empty_packs_remove".to_string())],
1159            ));
1160        } else {
1161            steps.push(step(
1162                SetupStepKind::AddPacksToBundle,
1163                "Delete pack files/default links from bundle",
1164                [("count", request.packs_remove.len().to_string())],
1165            ));
1166        }
1167    }
1168    if targets.contains(&RemoveTarget::Providers) {
1169        if request.providers_remove.is_empty() {
1170            steps.push(step(
1171                SetupStepKind::NoOp,
1172                "providers target selected without provider ids",
1173                [("reason", "empty_providers_remove".to_string())],
1174            ));
1175        } else {
1176            steps.push(step(
1177                SetupStepKind::ApplyPackSetup,
1178                "Remove provider entries from providers/providers.json",
1179                [("count", request.providers_remove.len().to_string())],
1180            ));
1181        }
1182    }
1183    if targets.contains(&RemoveTarget::TenantsTeams) {
1184        if request.tenants_remove.is_empty() {
1185            steps.push(step(
1186                SetupStepKind::NoOp,
1187                "tenants_teams target selected without tenant/team ids",
1188                [("reason", "empty_tenants_remove".to_string())],
1189            ));
1190        } else {
1191            steps.push(step(
1192                SetupStepKind::WriteGmapRules,
1193                "Delete tenant/team directories and access rules",
1194                [("count", request.tenants_remove.len().to_string())],
1195            ));
1196            steps.push(step(
1197                SetupStepKind::RunResolver,
1198                "Run resolver pipeline after tenant/team removals",
1199                [("resolver", "project::sync_project".to_string())],
1200            ));
1201            steps.push(step(
1202                SetupStepKind::CopyResolvedManifest,
1203                "Copy state/resolved manifests into resolved/ for demo start",
1204                [("targets", tenants.len().to_string())],
1205            ));
1206        }
1207    }
1208    steps.push(step(
1209        SetupStepKind::ValidateBundle,
1210        "Validate bundle is loadable by internal demo pipeline",
1211        [("check", "resolved manifests present".to_string())],
1212    ));
1213
1214    Ok(SetupPlan {
1215        mode: SetupMode::Remove.as_str().to_string(),
1216        dry_run,
1217        bundle: request.bundle.clone(),
1218        steps,
1219        metadata: SetupPlanMetadata {
1220            bundle_name: request.bundle_name.clone(),
1221            pack_refs: Vec::new(),
1222            tenants,
1223            default_assignments: request.default_assignments.clone(),
1224            providers: request.providers.clone(),
1225            update_ops: request.update_ops.clone(),
1226            remove_targets: targets,
1227            packs_remove: request.packs_remove.clone(),
1228            providers_remove: request.providers_remove.clone(),
1229            tenants_remove: request.tenants_remove.clone(),
1230            access_changes: request.access_changes.clone(),
1231            static_routes: request.static_routes.clone(),
1232            deployment_targets: request.deployment_targets.clone(),
1233            setup_answers: request.setup_answers.clone(),
1234        },
1235    })
1236}
1237
1238/// Print a human-readable plan summary.
1239pub fn print_plan_summary(plan: &SetupPlan) {
1240    println!("wizard plan: mode={} dry_run={}", plan.mode, plan.dry_run);
1241    println!("bundle: {}", plan.bundle.display());
1242    let noop_count = plan
1243        .steps
1244        .iter()
1245        .filter(|s| s.kind == SetupStepKind::NoOp)
1246        .count();
1247    if noop_count > 0 {
1248        println!("no-op steps: {noop_count}");
1249    }
1250    for (index, s) in plan.steps.iter().enumerate() {
1251        println!("{}. {}", index + 1, s.description);
1252    }
1253}
1254
1255// ── Helpers ─────────────────────────────────────────────────────────────────
1256
1257fn dedup_sorted(refs: &[String]) -> Vec<String> {
1258    let mut v: Vec<String> = refs
1259        .iter()
1260        .map(|r| r.trim().to_string())
1261        .filter(|r| !r.is_empty())
1262        .collect();
1263    v.sort();
1264    v.dedup();
1265    v
1266}
1267
1268fn normalize_tenants(tenants: &[TenantSelection]) -> Vec<TenantSelection> {
1269    let mut result: Vec<TenantSelection> = tenants
1270        .iter()
1271        .map(|t| {
1272            let mut t = t.clone();
1273            t.allow_paths.sort();
1274            t.allow_paths.dedup();
1275            t
1276        })
1277        .collect();
1278    result.sort_by(|a, b| {
1279        a.tenant
1280            .cmp(&b.tenant)
1281            .then_with(|| a.team.cmp(&b.team))
1282            .then_with(|| a.allow_paths.cmp(&b.allow_paths))
1283    });
1284    result
1285}
1286
1287fn infer_update_ops(
1288    ops: &mut BTreeSet<UpdateOp>,
1289    pack_refs: &[String],
1290    request: &SetupRequest,
1291    tenants: &[TenantSelection],
1292) {
1293    if !pack_refs.is_empty() {
1294        ops.insert(UpdateOp::PacksAdd);
1295    }
1296    if !request.providers.is_empty() {
1297        ops.insert(UpdateOp::ProvidersAdd);
1298    }
1299    if !request.providers_remove.is_empty() {
1300        ops.insert(UpdateOp::ProvidersRemove);
1301    }
1302    if !request.packs_remove.is_empty() {
1303        ops.insert(UpdateOp::PacksRemove);
1304    }
1305    if !tenants.is_empty() {
1306        ops.insert(UpdateOp::TenantsAdd);
1307    }
1308    if !request.tenants_remove.is_empty() {
1309        ops.insert(UpdateOp::TenantsRemove);
1310    }
1311    if !request.access_changes.is_empty() || tenants.iter().any(|t| !t.allow_paths.is_empty()) {
1312        ops.insert(UpdateOp::AccessChange);
1313    }
1314}
1315
1316fn build_metadata(
1317    request: &SetupRequest,
1318    pack_refs: Vec<String>,
1319    tenants: Vec<TenantSelection>,
1320) -> SetupPlanMetadata {
1321    SetupPlanMetadata {
1322        bundle_name: request.bundle_name.clone(),
1323        pack_refs,
1324        tenants,
1325        default_assignments: request.default_assignments.clone(),
1326        providers: request.providers.clone(),
1327        update_ops: request.update_ops.clone(),
1328        remove_targets: request.remove_targets.clone(),
1329        packs_remove: request.packs_remove.clone(),
1330        providers_remove: request.providers_remove.clone(),
1331        tenants_remove: request.tenants_remove.clone(),
1332        access_changes: request.access_changes.clone(),
1333        static_routes: request.static_routes.clone(),
1334        deployment_targets: request.deployment_targets.clone(),
1335        setup_answers: request.setup_answers.clone(),
1336    }
1337}
1338
1339fn build_metadata_with_ops(
1340    request: &SetupRequest,
1341    pack_refs: Vec<String>,
1342    tenants: Vec<TenantSelection>,
1343    ops: BTreeSet<UpdateOp>,
1344) -> SetupPlanMetadata {
1345    let mut meta = build_metadata(request, pack_refs, tenants);
1346    meta.update_ops = ops;
1347    meta
1348}
1349
1350/// Compute a simple hash for a string (used for digest placeholders).
1351fn compute_simple_hash(input: &str) -> String {
1352    use std::collections::hash_map::DefaultHasher;
1353    use std::hash::{Hash, Hasher};
1354
1355    let mut hasher = DefaultHasher::new();
1356    input.hash(&mut hasher);
1357    format!("{:016x}", hasher.finish())
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362    use super::*;
1363    use crate::bundle;
1364    use crate::platform_setup::static_routes_artifact_path;
1365    use serde_json::json;
1366
1367    fn empty_request(bundle: PathBuf) -> SetupRequest {
1368        SetupRequest {
1369            bundle,
1370            bundle_name: None,
1371            pack_refs: Vec::new(),
1372            tenants: vec![TenantSelection {
1373                tenant: "demo".to_string(),
1374                team: Some("default".to_string()),
1375                allow_paths: vec!["packs/default".to_string()],
1376            }],
1377            default_assignments: Vec::new(),
1378            providers: Vec::new(),
1379            update_ops: BTreeSet::new(),
1380            remove_targets: BTreeSet::new(),
1381            packs_remove: Vec::new(),
1382            providers_remove: Vec::new(),
1383            tenants_remove: Vec::new(),
1384            access_changes: Vec::new(),
1385            static_routes: StaticRoutesPolicy::default(),
1386            setup_answers: serde_json::Map::new(),
1387            ..Default::default()
1388        }
1389    }
1390
1391    #[test]
1392    fn create_plan_is_deterministic() {
1393        let req = SetupRequest {
1394            bundle: PathBuf::from("bundle"),
1395            bundle_name: None,
1396            pack_refs: vec![
1397                "repo://zeta/pack@1".to_string(),
1398                "repo://alpha/pack@1".to_string(),
1399                "repo://alpha/pack@1".to_string(),
1400            ],
1401            tenants: vec![
1402                TenantSelection {
1403                    tenant: "demo".to_string(),
1404                    team: Some("default".to_string()),
1405                    allow_paths: vec!["pack/b".to_string(), "pack/a".to_string()],
1406                },
1407                TenantSelection {
1408                    tenant: "alpha".to_string(),
1409                    team: None,
1410                    allow_paths: vec!["x".to_string()],
1411                },
1412            ],
1413            default_assignments: Vec::new(),
1414            providers: Vec::new(),
1415            update_ops: BTreeSet::new(),
1416            remove_targets: BTreeSet::new(),
1417            packs_remove: Vec::new(),
1418            providers_remove: Vec::new(),
1419            tenants_remove: Vec::new(),
1420            access_changes: Vec::new(),
1421            static_routes: StaticRoutesPolicy::default(),
1422            setup_answers: serde_json::Map::new(),
1423            ..Default::default()
1424        };
1425        let plan = apply_create(&req, true).unwrap();
1426        assert_eq!(
1427            plan.metadata.pack_refs,
1428            vec![
1429                "repo://alpha/pack@1".to_string(),
1430                "repo://zeta/pack@1".to_string()
1431            ]
1432        );
1433        assert_eq!(plan.metadata.tenants[0].tenant, "alpha");
1434        assert_eq!(
1435            plan.metadata.tenants[1].allow_paths,
1436            vec!["pack/a".to_string(), "pack/b".to_string()]
1437        );
1438    }
1439
1440    #[test]
1441    fn dry_run_does_not_create_files() {
1442        let bundle = PathBuf::from("/tmp/nonexistent-bundle");
1443        let req = empty_request(bundle.clone());
1444        let _plan = apply_create(&req, true).unwrap();
1445        assert!(!bundle.exists());
1446    }
1447
1448    #[test]
1449    fn create_requires_tenants() {
1450        let req = SetupRequest {
1451            tenants: vec![],
1452            ..empty_request(PathBuf::from("x"))
1453        };
1454        assert!(apply_create(&req, true).is_err());
1455    }
1456
1457    #[test]
1458    fn load_answers_reads_platform_setup_and_provider_answers() {
1459        let temp = tempfile::tempdir().unwrap();
1460        let answers_path = temp.path().join("answers.yaml");
1461        std::fs::write(
1462            &answers_path,
1463            r#"
1464bundle_source: ./bundle
1465env: prod
1466platform_setup:
1467  static_routes:
1468    public_web_enabled: true
1469    public_base_url: https://example.com/base/
1470  deployment_targets:
1471    - target: aws
1472      provider_pack: packs/aws.gtpack
1473      default: true
1474setup_answers:
1475  messaging-webchat:
1476    jwt_signing_key: abc
1477"#,
1478        )
1479        .unwrap();
1480
1481        let engine = SetupEngine::new(SetupConfig {
1482            tenant: "demo".into(),
1483            team: None,
1484            env: "prod".into(),
1485            offline: false,
1486            verbose: false,
1487        });
1488        let loaded = engine.load_answers(&answers_path, None, false).unwrap();
1489        assert_eq!(
1490            loaded
1491                .platform_setup
1492                .static_routes
1493                .as_ref()
1494                .and_then(|v| v.public_base_url.as_deref()),
1495            Some("https://example.com/base/")
1496        );
1497        assert_eq!(
1498            loaded
1499                .setup_answers
1500                .get("messaging-webchat")
1501                .and_then(|v| v.get("jwt_signing_key"))
1502                .and_then(Value::as_str),
1503            Some("abc")
1504        );
1505        assert_eq!(loaded.platform_setup.deployment_targets.len(), 1);
1506        assert_eq!(loaded.platform_setup.deployment_targets[0].target, "aws");
1507    }
1508
1509    #[test]
1510    fn emit_answers_includes_platform_setup() {
1511        let temp = tempfile::tempdir().unwrap();
1512        let bundle_root = temp.path().join("bundle");
1513        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1514
1515        let engine = SetupEngine::new(SetupConfig {
1516            tenant: "demo".into(),
1517            team: None,
1518            env: "prod".into(),
1519            offline: false,
1520            verbose: false,
1521        });
1522        let request = SetupRequest {
1523            bundle: bundle_root.clone(),
1524            tenants: vec![TenantSelection {
1525                tenant: "demo".into(),
1526                team: None,
1527                allow_paths: Vec::new(),
1528            }],
1529            static_routes: StaticRoutesPolicy {
1530                public_web_enabled: true,
1531                public_base_url: Some("https://example.com".into()),
1532                public_surface_policy: "enabled".into(),
1533                default_route_prefix_policy: "pack_declared".into(),
1534                tenant_path_policy: "pack_declared".into(),
1535                ..StaticRoutesPolicy::default()
1536            },
1537            ..Default::default()
1538        };
1539        let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
1540        let output = temp.path().join("answers.json");
1541        engine.emit_answers(&plan, &output, None, false).unwrap();
1542        let emitted: Value =
1543            serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
1544        assert_eq!(
1545            emitted["platform_setup"]["static_routes"]["public_base_url"],
1546            json!("https://example.com")
1547        );
1548        assert_eq!(emitted["platform_setup"]["deployment_targets"], json!([]));
1549    }
1550
1551    #[test]
1552    fn emit_answers_falls_back_to_runtime_public_endpoint() {
1553        let temp = tempfile::tempdir().unwrap();
1554        let bundle_root = temp.path().join("bundle");
1555        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1556        let runtime_dir = bundle_root
1557            .join("state")
1558            .join("runtime")
1559            .join("demo.default");
1560        std::fs::create_dir_all(&runtime_dir).unwrap();
1561        std::fs::write(
1562            runtime_dir.join("endpoints.json"),
1563            r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
1564        )
1565        .unwrap();
1566
1567        let engine = SetupEngine::new(SetupConfig {
1568            tenant: "demo".into(),
1569            team: Some("default".into()),
1570            env: "prod".into(),
1571            offline: false,
1572            verbose: false,
1573        });
1574        let request = SetupRequest {
1575            bundle: bundle_root.clone(),
1576            tenants: vec![TenantSelection {
1577                tenant: "demo".into(),
1578                team: Some("default".into()),
1579                allow_paths: Vec::new(),
1580            }],
1581            ..Default::default()
1582        };
1583        let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
1584        let output = temp.path().join("answers-runtime.json");
1585        engine.emit_answers(&plan, &output, None, false).unwrap();
1586        let emitted: Value =
1587            serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
1588        assert_eq!(
1589            emitted["platform_setup"]["static_routes"]["public_base_url"],
1590            json!("https://runtime.example.com")
1591        );
1592    }
1593
1594    #[test]
1595    fn execute_persists_static_routes_artifact() {
1596        let temp = tempfile::tempdir().unwrap();
1597        let bundle_root = temp.path().join("bundle");
1598        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1599
1600        let engine = SetupEngine::new(SetupConfig {
1601            tenant: "demo".into(),
1602            team: None,
1603            env: "prod".into(),
1604            offline: false,
1605            verbose: false,
1606        });
1607        let mut metadata = build_metadata(&empty_request(bundle_root.clone()), Vec::new(), vec![]);
1608        metadata.static_routes = StaticRoutesPolicy {
1609            public_web_enabled: true,
1610            public_base_url: Some("https://example.com".into()),
1611            public_surface_policy: "enabled".into(),
1612            default_route_prefix_policy: "pack_declared".into(),
1613            tenant_path_policy: "pack_declared".into(),
1614            ..StaticRoutesPolicy::default()
1615        };
1616
1617        engine
1618            .execute_apply_pack_setup(&bundle_root, &metadata)
1619            .unwrap();
1620        let artifact = static_routes_artifact_path(&bundle_root);
1621        assert!(artifact.exists());
1622        let stored: Value =
1623            serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap();
1624        assert_eq!(stored["public_web_enabled"], json!(true));
1625    }
1626
1627    #[test]
1628    fn remove_execute_deletes_provider_artifact_and_config_dir() {
1629        let temp = tempfile::tempdir().unwrap();
1630        let bundle_root = temp.path().join("bundle");
1631        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1632        let provider_dir = bundle_root.join("providers").join("messaging");
1633        std::fs::create_dir_all(&provider_dir).unwrap();
1634        let provider_pack = provider_dir.join("messaging-webchat.gtpack");
1635        std::fs::copy(
1636            bundle_root.join("packs").join("default.gtpack"),
1637            &provider_pack,
1638        )
1639        .unwrap();
1640        let config_dir = bundle_root
1641            .join("state")
1642            .join("config")
1643            .join("messaging-webchat");
1644        std::fs::create_dir_all(&config_dir).unwrap();
1645        std::fs::write(config_dir.join("setup-answers.json"), "{}").unwrap();
1646
1647        let engine = SetupEngine::new(SetupConfig {
1648            tenant: "demo".into(),
1649            team: None,
1650            env: "prod".into(),
1651            offline: false,
1652            verbose: false,
1653        });
1654        let request = SetupRequest {
1655            bundle: bundle_root.clone(),
1656            providers_remove: vec!["messaging-webchat".into()],
1657            ..Default::default()
1658        };
1659        let plan = engine.plan(SetupMode::Remove, &request, false).unwrap();
1660        engine.execute(&plan).unwrap();
1661
1662        assert!(!provider_pack.exists());
1663        assert!(!config_dir.exists());
1664    }
1665
1666    #[test]
1667    fn update_plan_preserves_static_routes_policy() {
1668        let req = SetupRequest {
1669            bundle: PathBuf::from("bundle"),
1670            tenants: vec![TenantSelection {
1671                tenant: "demo".into(),
1672                team: None,
1673                allow_paths: Vec::new(),
1674            }],
1675            static_routes: StaticRoutesPolicy {
1676                public_web_enabled: true,
1677                public_base_url: Some("https://example.com/new".into()),
1678                public_surface_policy: "enabled".into(),
1679                default_route_prefix_policy: "pack_declared".into(),
1680                tenant_path_policy: "pack_declared".into(),
1681                ..StaticRoutesPolicy::default()
1682            },
1683            ..Default::default()
1684        };
1685        let plan = apply_update(&req, true).unwrap();
1686        assert_eq!(
1687            plan.metadata.static_routes.public_base_url.as_deref(),
1688            Some("https://example.com/new")
1689        );
1690    }
1691}