Skip to main content

greentic_operator/
wizard.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4
5use anyhow::{Context, anyhow};
6use serde::{Deserialize, Serialize};
7
8use crate::gmap::{self, Policy};
9use crate::project;
10
11#[derive(Clone, Debug, Serialize)]
12pub struct QaQuestion {
13    pub id: String,
14    pub title: String,
15    pub required: bool,
16}
17
18#[derive(Clone, Debug, Serialize)]
19pub struct QaSpec {
20    pub mode: String,
21    pub questions: Vec<QaQuestion>,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum WizardMode {
26    Create,
27    Update,
28    Remove,
29}
30
31impl WizardMode {
32    pub fn as_str(self) -> &'static str {
33        match self {
34            WizardMode::Create => "create",
35            WizardMode::Update => "update",
36            WizardMode::Remove => "remove",
37        }
38    }
39}
40
41#[derive(Clone, Debug, Serialize)]
42pub struct WizardPlan {
43    pub mode: String,
44    pub dry_run: bool,
45    pub bundle: PathBuf,
46    pub steps: Vec<WizardPlanStep>,
47    pub metadata: WizardPlanMetadata,
48}
49
50#[derive(Clone, Debug, Serialize)]
51pub struct WizardPlanMetadata {
52    pub bundle_name: Option<String>,
53    pub pack_refs: Vec<String>,
54    pub tenants: Vec<TenantSelection>,
55    pub default_assignments: Vec<PackDefaultSelection>,
56    pub providers: Vec<String>,
57    pub update_ops: BTreeSet<WizardUpdateOp>,
58    pub remove_targets: BTreeSet<WizardRemoveTarget>,
59    pub packs_remove: Vec<PackRemoveSelection>,
60    pub providers_remove: Vec<String>,
61    pub tenants_remove: Vec<TenantSelection>,
62    pub access_changes: Vec<AccessChangeSelection>,
63    pub setup_answers: serde_json::Map<String, serde_json::Value>,
64}
65
66#[derive(Clone, Debug, Serialize)]
67pub struct WizardPlanStep {
68    pub kind: WizardStepKind,
69    pub description: String,
70    pub details: BTreeMap<String, String>,
71}
72
73#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum WizardStepKind {
76    NoOp,
77    ResolvePacks,
78    CreateBundle,
79    AddPacksToBundle,
80    ApplyPackSetup,
81    WriteGmapRules,
82    RunResolver,
83    CopyResolvedManifest,
84    ValidateBundle,
85}
86
87#[derive(Clone, Debug, Serialize, Deserialize)]
88pub struct PackListing {
89    pub id: String,
90    pub label: String,
91    pub reference: String,
92}
93
94pub trait CatalogSource {
95    fn list(&self) -> Vec<PackListing>;
96}
97
98#[derive(Clone, Debug, Default)]
99pub struct StaticCatalogSource;
100
101impl CatalogSource for StaticCatalogSource {
102    fn list(&self) -> Vec<PackListing> {
103        // Listing only; fetching is delegated to distributor client in execution.
104        vec![
105            PackListing {
106                id: "messaging-telegram".to_string(),
107                label: "Messaging Telegram".to_string(),
108                reference: "repo://messaging/providers/messaging-telegram@latest".to_string(),
109            },
110            PackListing {
111                id: "messaging-slack".to_string(),
112                label: "Messaging Slack".to_string(),
113                reference: "repo://messaging/providers/messaging-slack@latest".to_string(),
114            },
115        ]
116    }
117}
118
119pub fn load_catalog_from_file(path: &Path) -> anyhow::Result<Vec<PackListing>> {
120    let raw = std::fs::read_to_string(path)
121        .with_context(|| format!("read catalog file {}", path.display()))?;
122    if let Ok(parsed) = serde_json::from_str::<Vec<PackListing>>(&raw)
123        .or_else(|_| serde_yaml_bw::from_str::<Vec<PackListing>>(&raw))
124    {
125        return Ok(parsed);
126    }
127    let registry: ProviderRegistryFile = serde_json::from_str(&raw)
128        .or_else(|_| serde_yaml_bw::from_str(&raw))
129        .with_context(|| format!("parse catalog/provider registry file {}", path.display()))?;
130    Ok(registry
131        .items
132        .into_iter()
133        .map(|item| PackListing {
134            id: item.id,
135            label: item.label.fallback,
136            reference: item.reference,
137        })
138        .collect())
139}
140
141#[derive(Clone, Debug, Serialize, Deserialize)]
142struct ProviderRegistryFile {
143    #[serde(default)]
144    registry_version: Option<String>,
145    #[serde(default)]
146    items: Vec<ProviderRegistryItem>,
147}
148
149#[derive(Clone, Debug, Serialize, Deserialize)]
150struct ProviderRegistryItem {
151    id: String,
152    label: ProviderRegistryLabel,
153    #[serde(alias = "ref")]
154    reference: String,
155}
156
157#[derive(Clone, Debug, Serialize, Deserialize)]
158struct ProviderRegistryLabel {
159    #[serde(default)]
160    i18n_key: Option<String>,
161    fallback: String,
162}
163
164#[derive(Clone, Debug, Serialize)]
165pub struct TenantSelection {
166    pub tenant: String,
167    pub team: Option<String>,
168    pub allow_paths: Vec<String>,
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum WizardUpdateOp {
174    PacksAdd,
175    PacksRemove,
176    ProvidersAdd,
177    ProvidersRemove,
178    TenantsAdd,
179    TenantsRemove,
180    AccessChange,
181}
182
183impl WizardUpdateOp {
184    pub fn parse(value: &str) -> Option<Self> {
185        match value {
186            "packs_add" => Some(Self::PacksAdd),
187            "packs_remove" => Some(Self::PacksRemove),
188            "providers_add" => Some(Self::ProvidersAdd),
189            "providers_remove" => Some(Self::ProvidersRemove),
190            "tenants_add" => Some(Self::TenantsAdd),
191            "tenants_remove" => Some(Self::TenantsRemove),
192            "access_change" => Some(Self::AccessChange),
193            _ => None,
194        }
195    }
196}
197
198impl FromStr for WizardUpdateOp {
199    type Err = ();
200
201    fn from_str(value: &str) -> Result<Self, Self::Err> {
202        Self::parse(value).ok_or(())
203    }
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum WizardRemoveTarget {
209    Packs,
210    Providers,
211    TenantsTeams,
212}
213
214impl WizardRemoveTarget {
215    pub fn parse(value: &str) -> Option<Self> {
216        match value {
217            "packs" => Some(Self::Packs),
218            "providers" => Some(Self::Providers),
219            "tenants_teams" => Some(Self::TenantsTeams),
220            _ => None,
221        }
222    }
223}
224
225impl FromStr for WizardRemoveTarget {
226    type Err = ();
227
228    fn from_str(value: &str) -> Result<Self, Self::Err> {
229        Self::parse(value).ok_or(())
230    }
231}
232
233#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum PackScope {
236    Bundle,
237    Global,
238    Tenant { tenant_id: String },
239    Team { tenant_id: String, team_id: String },
240}
241
242#[derive(Clone, Debug, Serialize, Deserialize)]
243pub struct PackRemoveSelection {
244    pub pack_identifier: String,
245    #[serde(default)]
246    pub scope: Option<PackScope>,
247}
248
249#[derive(Clone, Debug, Serialize, Deserialize)]
250pub struct PackDefaultSelection {
251    pub pack_identifier: String,
252    pub scope: PackScope,
253}
254
255#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum AccessOperation {
258    AllowAdd,
259    AllowRemove,
260}
261
262impl AccessOperation {
263    pub fn policy(self) -> Policy {
264        match self {
265            AccessOperation::AllowAdd => Policy::Public,
266            AccessOperation::AllowRemove => Policy::Forbidden,
267        }
268    }
269}
270
271#[derive(Clone, Debug, Serialize, Deserialize)]
272pub struct AccessChangeSelection {
273    pub pack_id: String,
274    pub operation: AccessOperation,
275    pub tenant_id: String,
276    #[serde(default)]
277    pub team_id: Option<String>,
278}
279
280#[derive(Clone, Debug)]
281pub struct WizardCreateRequest {
282    pub bundle: PathBuf,
283    pub bundle_name: Option<String>,
284    pub pack_refs: Vec<String>,
285    pub tenants: Vec<TenantSelection>,
286    pub default_assignments: Vec<PackDefaultSelection>,
287    pub providers: Vec<String>,
288    pub update_ops: BTreeSet<WizardUpdateOp>,
289    pub remove_targets: BTreeSet<WizardRemoveTarget>,
290    pub packs_remove: Vec<PackRemoveSelection>,
291    pub providers_remove: Vec<String>,
292    pub tenants_remove: Vec<TenantSelection>,
293    pub access_changes: Vec<AccessChangeSelection>,
294    /// Per-provider setup answers to seed as secrets during bundle creation.
295    pub setup_answers: serde_json::Map<String, serde_json::Value>,
296}
297
298#[derive(Clone, Debug, Serialize)]
299pub struct ResolvedPackInfo {
300    pub source_ref: String,
301    pub mapped_ref: String,
302    pub resolved_digest: String,
303    pub pack_id: String,
304    pub entry_flows: Vec<String>,
305    pub cached_path: PathBuf,
306    pub output_path: PathBuf,
307}
308
309#[derive(Clone, Debug, Serialize)]
310pub struct WizardExecutionReport {
311    pub bundle: PathBuf,
312    pub resolved_packs: Vec<ResolvedPackInfo>,
313    pub resolved_manifests: Vec<PathBuf>,
314    pub provider_updates: usize,
315    pub warnings: Vec<String>,
316}
317
318#[derive(Clone, Debug, Serialize, Deserialize, Default)]
319struct PacksMetadata {
320    #[serde(default)]
321    packs: Vec<PackMappingRecord>,
322}
323
324#[derive(Clone, Debug, Serialize, Deserialize)]
325struct PackMappingRecord {
326    pack_id: String,
327    original_ref: String,
328    local_path_in_bundle: String,
329    #[serde(default)]
330    digest: Option<String>,
331}
332
333pub fn spec(mode: WizardMode) -> QaSpec {
334    QaSpec {
335        mode: mode.as_str().to_string(),
336        questions: vec![
337            QaQuestion {
338                id: "operator.bundle.path".to_string(),
339                title: "Bundle output path".to_string(),
340                required: true,
341            },
342            QaQuestion {
343                id: "operator.packs.refs".to_string(),
344                title: "Pack refs (catalog + custom)".to_string(),
345                required: false,
346            },
347            QaQuestion {
348                id: "operator.tenants".to_string(),
349                title: "Tenants and optional teams".to_string(),
350                required: true,
351            },
352            QaQuestion {
353                id: "operator.allow.paths".to_string(),
354                title: "Allow rules as PACK[/FLOW[/NODE]]".to_string(),
355                required: false,
356            },
357        ],
358    }
359}
360
361pub fn apply_create(request: &WizardCreateRequest, dry_run: bool) -> anyhow::Result<WizardPlan> {
362    if request.tenants.is_empty() {
363        return Err(anyhow!("at least one tenant selection is required"));
364    }
365
366    let mut pack_refs = request
367        .pack_refs
368        .iter()
369        .map(|value| value.trim().to_string())
370        .filter(|value| !value.is_empty())
371        .collect::<Vec<_>>();
372    pack_refs.sort();
373    pack_refs.dedup();
374
375    let mut tenants = request.tenants.clone();
376    for tenant in &mut tenants {
377        tenant.allow_paths.sort();
378        tenant.allow_paths.dedup();
379    }
380    tenants.sort_by(|a, b| {
381        a.tenant
382            .cmp(&b.tenant)
383            .then_with(|| a.team.cmp(&b.team))
384            .then_with(|| a.allow_paths.cmp(&b.allow_paths))
385    });
386
387    let mut steps = Vec::new();
388    if !pack_refs.is_empty() {
389        steps.push(step(
390            WizardStepKind::ResolvePacks,
391            "Resolve selected pack refs via distributor client",
392            [("count", pack_refs.len().to_string())],
393        ));
394    } else {
395        steps.push(step(
396            WizardStepKind::NoOp,
397            "No pack refs selected; skipping pack resolution",
398            [("reason", "empty_pack_refs".to_string())],
399        ));
400    }
401    steps.push(step(
402        WizardStepKind::CreateBundle,
403        "Create demo bundle scaffold using existing conventions",
404        [("bundle", request.bundle.display().to_string())],
405    ));
406    if !pack_refs.is_empty() {
407        steps.push(step(
408            WizardStepKind::AddPacksToBundle,
409            "Copy fetched packs into bundle/packs",
410            [("count", pack_refs.len().to_string())],
411        ));
412        steps.push(step(
413            WizardStepKind::ApplyPackSetup,
414            "Apply pack-declared setup outputs through internal setup hooks",
415            [("status", "planned".to_string())],
416        ));
417    } else {
418        steps.push(step(
419            WizardStepKind::NoOp,
420            "No fetched packs to add or setup",
421            [("reason", "empty_pack_refs".to_string())],
422        ));
423    }
424    steps.push(step(
425        WizardStepKind::WriteGmapRules,
426        "Write tenant/team allow rules to gmap",
427        [("targets", tenants.len().to_string())],
428    ));
429    steps.push(step(
430        WizardStepKind::RunResolver,
431        "Run resolver pipeline (same as demo allow)",
432        [("resolver", "project::sync_project".to_string())],
433    ));
434    steps.push(step(
435        WizardStepKind::CopyResolvedManifest,
436        "Copy state/resolved manifests into resolved/ for demo start",
437        [("targets", tenants.len().to_string())],
438    ));
439    steps.push(step(
440        WizardStepKind::ValidateBundle,
441        "Validate bundle is loadable by internal demo pipeline",
442        [("check", "resolved manifests present".to_string())],
443    ));
444
445    Ok(WizardPlan {
446        mode: "create".to_string(),
447        dry_run,
448        bundle: request.bundle.clone(),
449        steps,
450        metadata: WizardPlanMetadata {
451            bundle_name: request.bundle_name.clone(),
452            pack_refs,
453            tenants,
454            default_assignments: request.default_assignments.clone(),
455            providers: request.providers.clone(),
456            update_ops: request.update_ops.clone(),
457            remove_targets: request.remove_targets.clone(),
458            packs_remove: request.packs_remove.clone(),
459            providers_remove: request.providers_remove.clone(),
460            tenants_remove: request.tenants_remove.clone(),
461            access_changes: request.access_changes.clone(),
462            setup_answers: request.setup_answers.clone(),
463        },
464    })
465}
466
467pub fn apply_update(request: &WizardCreateRequest, dry_run: bool) -> anyhow::Result<WizardPlan> {
468    let mut pack_refs = request
469        .pack_refs
470        .iter()
471        .map(|value| value.trim().to_string())
472        .filter(|value| !value.is_empty())
473        .collect::<Vec<_>>();
474    pack_refs.sort();
475    pack_refs.dedup();
476
477    let mut tenants = request.tenants.clone();
478    for tenant in &mut tenants {
479        tenant.allow_paths.sort();
480        tenant.allow_paths.dedup();
481    }
482    tenants.sort_by(|a, b| {
483        a.tenant
484            .cmp(&b.tenant)
485            .then_with(|| a.team.cmp(&b.team))
486            .then_with(|| a.allow_paths.cmp(&b.allow_paths))
487    });
488
489    let mut ops = request.update_ops.clone();
490    if ops.is_empty() {
491        if !pack_refs.is_empty() {
492            ops.insert(WizardUpdateOp::PacksAdd);
493        }
494        if !request.providers.is_empty() {
495            ops.insert(WizardUpdateOp::ProvidersAdd);
496        }
497        if !request.providers_remove.is_empty() {
498            ops.insert(WizardUpdateOp::ProvidersRemove);
499        }
500        if !request.packs_remove.is_empty() {
501            ops.insert(WizardUpdateOp::PacksRemove);
502        }
503        if !tenants.is_empty() {
504            ops.insert(WizardUpdateOp::TenantsAdd);
505        }
506        if !request.tenants_remove.is_empty() {
507            ops.insert(WizardUpdateOp::TenantsRemove);
508        }
509        if !request.access_changes.is_empty()
510            || tenants.iter().any(|tenant| !tenant.allow_paths.is_empty())
511        {
512            ops.insert(WizardUpdateOp::AccessChange);
513        }
514    }
515
516    let mut steps = vec![step(
517        WizardStepKind::ValidateBundle,
518        "Validate target bundle exists before update",
519        [("mode", "update".to_string())],
520    )];
521    if ops.is_empty() {
522        steps.push(step(
523            WizardStepKind::NoOp,
524            "No update operations selected",
525            [("reason", "empty_update_ops".to_string())],
526        ));
527    }
528    if ops.contains(&WizardUpdateOp::PacksAdd) {
529        if pack_refs.is_empty() {
530            steps.push(step(
531                WizardStepKind::NoOp,
532                "packs_add selected without pack refs",
533                [("reason", "empty_pack_refs".to_string())],
534            ));
535        } else {
536            steps.push(step(
537                WizardStepKind::ResolvePacks,
538                "Resolve selected pack refs via distributor client",
539                [("count", pack_refs.len().to_string())],
540            ));
541            steps.push(step(
542                WizardStepKind::AddPacksToBundle,
543                "Copy fetched packs into bundle/packs",
544                [("count", pack_refs.len().to_string())],
545            ));
546        }
547    }
548    if ops.contains(&WizardUpdateOp::PacksRemove) {
549        if request.packs_remove.is_empty() {
550            steps.push(step(
551                WizardStepKind::NoOp,
552                "packs_remove selected without targets",
553                [("reason", "empty_packs_remove".to_string())],
554            ));
555        } else {
556            steps.push(step(
557                WizardStepKind::AddPacksToBundle,
558                "Remove pack artifacts/default links from bundle",
559                [("count", request.packs_remove.len().to_string())],
560            ));
561        }
562    }
563    if ops.contains(&WizardUpdateOp::ProvidersAdd) {
564        if request.providers.is_empty() && pack_refs.is_empty() {
565            steps.push(step(
566                WizardStepKind::NoOp,
567                "providers_add selected without providers or new packs",
568                [("reason", "empty_providers_add".to_string())],
569            ));
570        } else {
571            steps.push(step(
572                WizardStepKind::ApplyPackSetup,
573                "Enable providers in providers/providers.json",
574                [("count", request.providers.len().to_string())],
575            ));
576        }
577    }
578    if ops.contains(&WizardUpdateOp::ProvidersRemove) {
579        if request.providers_remove.is_empty() {
580            steps.push(step(
581                WizardStepKind::NoOp,
582                "providers_remove selected without providers",
583                [("reason", "empty_providers_remove".to_string())],
584            ));
585        } else {
586            steps.push(step(
587                WizardStepKind::ApplyPackSetup,
588                "Disable/remove providers in providers/providers.json",
589                [("count", request.providers_remove.len().to_string())],
590            ));
591        }
592    }
593    if ops.contains(&WizardUpdateOp::TenantsAdd) {
594        if tenants.is_empty() {
595            steps.push(step(
596                WizardStepKind::NoOp,
597                "tenants_add selected without tenant targets",
598                [("reason", "empty_tenants_add".to_string())],
599            ));
600        } else {
601            steps.push(step(
602                WizardStepKind::WriteGmapRules,
603                "Ensure tenant/team directories and allow rules",
604                [("targets", tenants.len().to_string())],
605            ));
606        }
607    }
608    if ops.contains(&WizardUpdateOp::TenantsRemove) {
609        if request.tenants_remove.is_empty() {
610            steps.push(step(
611                WizardStepKind::NoOp,
612                "tenants_remove selected without tenant targets",
613                [("reason", "empty_tenants_remove".to_string())],
614            ));
615        } else {
616            steps.push(step(
617                WizardStepKind::WriteGmapRules,
618                "Remove tenant/team directories and related rules",
619                [("targets", request.tenants_remove.len().to_string())],
620            ));
621        }
622    }
623    if ops.contains(&WizardUpdateOp::AccessChange) {
624        let access_count = request.access_changes.len()
625            + tenants
626                .iter()
627                .filter(|tenant| !tenant.allow_paths.is_empty())
628                .count();
629        if access_count == 0 {
630            steps.push(step(
631                WizardStepKind::NoOp,
632                "access_change selected without mutations",
633                [("reason", "empty_access_changes".to_string())],
634            ));
635        } else {
636            steps.push(step(
637                WizardStepKind::WriteGmapRules,
638                "Apply access rule updates",
639                [("changes", access_count.to_string())],
640            ));
641            steps.push(step(
642                WizardStepKind::RunResolver,
643                "Run resolver pipeline (same as demo allow/forbid)",
644                [("resolver", "project::sync_project".to_string())],
645            ));
646            steps.push(step(
647                WizardStepKind::CopyResolvedManifest,
648                "Copy state/resolved manifests into resolved/ for demo start",
649                [("targets", tenants.len().to_string())],
650            ));
651        }
652    }
653    steps.push(step(
654        WizardStepKind::ValidateBundle,
655        "Validate bundle is loadable by internal demo pipeline",
656        [("check", "resolved manifests present".to_string())],
657    ));
658
659    Ok(WizardPlan {
660        mode: WizardMode::Update.as_str().to_string(),
661        dry_run,
662        bundle: request.bundle.clone(),
663        steps,
664        metadata: WizardPlanMetadata {
665            bundle_name: request.bundle_name.clone(),
666            pack_refs,
667            tenants,
668            default_assignments: request.default_assignments.clone(),
669            providers: request.providers.clone(),
670            update_ops: ops,
671            remove_targets: request.remove_targets.clone(),
672            packs_remove: request.packs_remove.clone(),
673            providers_remove: request.providers_remove.clone(),
674            tenants_remove: request.tenants_remove.clone(),
675            access_changes: request.access_changes.clone(),
676            setup_answers: request.setup_answers.clone(),
677        },
678    })
679}
680
681pub fn apply_remove(request: &WizardCreateRequest, dry_run: bool) -> anyhow::Result<WizardPlan> {
682    let mut tenants = request.tenants.clone();
683    for tenant in &mut tenants {
684        tenant.allow_paths.sort();
685        tenant.allow_paths.dedup();
686    }
687    tenants.sort_by(|a, b| {
688        a.tenant
689            .cmp(&b.tenant)
690            .then_with(|| a.team.cmp(&b.team))
691            .then_with(|| a.allow_paths.cmp(&b.allow_paths))
692    });
693
694    let mut targets = request.remove_targets.clone();
695    if targets.is_empty() {
696        if !request.packs_remove.is_empty() {
697            targets.insert(WizardRemoveTarget::Packs);
698        }
699        if !request.providers_remove.is_empty() {
700            targets.insert(WizardRemoveTarget::Providers);
701        }
702        if !request.tenants_remove.is_empty() {
703            targets.insert(WizardRemoveTarget::TenantsTeams);
704        }
705    }
706
707    let mut steps = vec![step(
708        WizardStepKind::ValidateBundle,
709        "Validate target bundle exists before remove",
710        [("mode", "remove".to_string())],
711    )];
712    if targets.is_empty() {
713        steps.push(step(
714            WizardStepKind::NoOp,
715            "No remove targets selected",
716            [("reason", "empty_remove_targets".to_string())],
717        ));
718    }
719    if targets.contains(&WizardRemoveTarget::Packs) {
720        if request.packs_remove.is_empty() {
721            steps.push(step(
722                WizardStepKind::NoOp,
723                "packs target selected without pack identifiers",
724                [("reason", "empty_packs_remove".to_string())],
725            ));
726        } else {
727            steps.push(step(
728                WizardStepKind::AddPacksToBundle,
729                "Delete pack files/default links from bundle",
730                [("count", request.packs_remove.len().to_string())],
731            ));
732        }
733    }
734    if targets.contains(&WizardRemoveTarget::Providers) {
735        if request.providers_remove.is_empty() {
736            steps.push(step(
737                WizardStepKind::NoOp,
738                "providers target selected without provider ids",
739                [("reason", "empty_providers_remove".to_string())],
740            ));
741        } else {
742            steps.push(step(
743                WizardStepKind::ApplyPackSetup,
744                "Remove provider entries from providers/providers.json",
745                [("count", request.providers_remove.len().to_string())],
746            ));
747        }
748    }
749    if targets.contains(&WizardRemoveTarget::TenantsTeams) {
750        if request.tenants_remove.is_empty() {
751            steps.push(step(
752                WizardStepKind::NoOp,
753                "tenants_teams target selected without tenant/team ids",
754                [("reason", "empty_tenants_remove".to_string())],
755            ));
756        } else {
757            steps.push(step(
758                WizardStepKind::WriteGmapRules,
759                "Delete tenant/team directories and access rules",
760                [("count", request.tenants_remove.len().to_string())],
761            ));
762            steps.push(step(
763                WizardStepKind::RunResolver,
764                "Run resolver pipeline after tenant/team removals",
765                [("resolver", "project::sync_project".to_string())],
766            ));
767            steps.push(step(
768                WizardStepKind::CopyResolvedManifest,
769                "Copy state/resolved manifests into resolved/ for demo start",
770                [("targets", tenants.len().to_string())],
771            ));
772        }
773    }
774    steps.push(step(
775        WizardStepKind::ValidateBundle,
776        "Validate bundle is loadable by internal demo pipeline",
777        [("check", "resolved manifests present".to_string())],
778    ));
779
780    Ok(WizardPlan {
781        mode: WizardMode::Remove.as_str().to_string(),
782        dry_run,
783        bundle: request.bundle.clone(),
784        steps,
785        metadata: WizardPlanMetadata {
786            bundle_name: request.bundle_name.clone(),
787            pack_refs: Vec::new(),
788            tenants,
789            default_assignments: request.default_assignments.clone(),
790            providers: request.providers.clone(),
791            update_ops: request.update_ops.clone(),
792            remove_targets: targets,
793            packs_remove: request.packs_remove.clone(),
794            providers_remove: request.providers_remove.clone(),
795            tenants_remove: request.tenants_remove.clone(),
796            access_changes: request.access_changes.clone(),
797            setup_answers: request.setup_answers.clone(),
798        },
799    })
800}
801
802pub fn apply(
803    mode: WizardMode,
804    request: &WizardCreateRequest,
805    dry_run: bool,
806) -> anyhow::Result<WizardPlan> {
807    match mode {
808        WizardMode::Create => apply_create(request, dry_run),
809        WizardMode::Update => apply_update(request, dry_run),
810        WizardMode::Remove => apply_remove(request, dry_run),
811    }
812}
813
814pub fn normalize_request_for_plan(
815    request: &WizardCreateRequest,
816) -> anyhow::Result<WizardCreateRequest> {
817    let mut normalized = request.clone();
818    for selection in &mut normalized.default_assignments {
819        selection.pack_identifier =
820            canonical_pack_identifier(&normalized.bundle, &selection.pack_identifier)?;
821    }
822    for selection in &mut normalized.packs_remove {
823        selection.pack_identifier =
824            canonical_pack_identifier(&normalized.bundle, &selection.pack_identifier)?;
825    }
826    for change in &mut normalized.access_changes {
827        change.pack_id = canonical_pack_identifier(&normalized.bundle, &change.pack_id)?;
828    }
829    Ok(normalized)
830}
831
832pub fn execute_plan(
833    mode: WizardMode,
834    plan: &WizardPlan,
835    offline: bool,
836) -> anyhow::Result<WizardExecutionReport> {
837    match mode {
838        WizardMode::Create => execute_create_plan(plan, offline),
839        WizardMode::Update => execute_update_plan(plan, offline),
840        WizardMode::Remove => execute_remove_plan(plan),
841    }
842}
843
844fn validate_bundle_exists(bundle: &Path) -> anyhow::Result<()> {
845    if !bundle.exists() {
846        return Err(anyhow!("bundle path {} does not exist", bundle.display()));
847    }
848    if !bundle.join("greentic.demo.yaml").exists() {
849        return Err(anyhow!(
850            "bundle {} missing greentic.demo.yaml",
851            bundle.display()
852        ));
853    }
854    Ok(())
855}
856
857pub fn print_plan_summary(plan: &WizardPlan) {
858    println!(
859        "{} mode={} dry_run={}",
860        crate::operator_i18n::tr("cli.wizard.plan_header", "wizard plan:"),
861        plan.mode,
862        plan.dry_run
863    );
864    println!(
865        "{} {}",
866        crate::operator_i18n::tr("cli.wizard.bundle", "bundle:"),
867        plan.bundle.display()
868    );
869    let noop_count = plan
870        .steps
871        .iter()
872        .filter(|step| step.kind == WizardStepKind::NoOp)
873        .count();
874    if noop_count > 0 {
875        println!(
876            "{} {}",
877            crate::operator_i18n::tr("cli.wizard.noop_steps", "no-op steps:"),
878            noop_count
879        );
880    }
881    for (index, step) in plan.steps.iter().enumerate() {
882        println!(
883            "{}. {}",
884            index + 1,
885            localized_step_description(&step.description)
886        );
887    }
888}
889
890fn localized_step_description(description: &str) -> String {
891    match description {
892        "Resolve selected pack refs via distributor client" => crate::operator_i18n::tr(
893            "cli.wizard.step.resolve_packs",
894            "Resolve selected pack refs via distributor client",
895        ),
896        "Create demo bundle scaffold using existing conventions" => crate::operator_i18n::tr(
897            "cli.wizard.step.create_bundle",
898            "Create demo bundle scaffold using existing conventions",
899        ),
900        "Copy fetched packs into bundle/packs" => crate::operator_i18n::tr(
901            "cli.wizard.step.copy_packs",
902            "Copy fetched packs into bundle/packs",
903        ),
904        "Apply pack-declared setup outputs through internal setup hooks" => {
905            crate::operator_i18n::tr(
906                "cli.wizard.step.apply_pack_setup",
907                "Apply pack-declared setup outputs through internal setup hooks",
908            )
909        }
910        "Write tenant/team allow rules to gmap" => crate::operator_i18n::tr(
911            "cli.wizard.step.write_gmap",
912            "Write tenant/team allow rules to gmap",
913        ),
914        "Run resolver pipeline (same as demo allow)" => crate::operator_i18n::tr(
915            "cli.wizard.step.run_resolver_create",
916            "Run resolver pipeline (same as demo allow)",
917        ),
918        "Copy state/resolved manifests into resolved/ for demo start" => crate::operator_i18n::tr(
919            "cli.wizard.step.copy_resolved",
920            "Copy state/resolved manifests into resolved/ for demo start",
921        ),
922        "Validate bundle is loadable by internal demo pipeline" => crate::operator_i18n::tr(
923            "cli.wizard.step.validate_bundle",
924            "Validate bundle is loadable by internal demo pipeline",
925        ),
926        _ => description.to_string(),
927    }
928}
929
930pub fn execute_create_plan(
931    plan: &WizardPlan,
932    offline: bool,
933) -> anyhow::Result<WizardExecutionReport> {
934    if plan.mode != WizardMode::Create.as_str() {
935        return Err(anyhow!("unsupported wizard mode: {}", plan.mode));
936    }
937
938    if plan.bundle.exists() {
939        return Err(anyhow!(
940            "bundle path {} already exists",
941            plan.bundle.display()
942        ));
943    }
944
945    create_demo_bundle_structure(&plan.bundle, plan.metadata.bundle_name.as_deref())?;
946
947    let mut resolved_packs = Vec::new();
948    if !plan.metadata.pack_refs.is_empty() {
949        let mut resolved = resolve_pack_refs(&plan.metadata.pack_refs, offline)
950            .context("resolve pack refs via distributor-client")?;
951        assign_pack_ids_and_persist_metadata(&plan.bundle, &mut resolved)?;
952        for item in resolved {
953            copy_pack_into_bundle(&plan.bundle, &item)?;
954            resolved_packs.push(item);
955        }
956        link_packs_to_provider_dirs(&plan.bundle, &resolved_packs)?;
957    }
958    let mut warnings = Vec::new();
959    let mut provider_updates = upsert_provider_registry(&plan.bundle, &resolved_packs)?;
960    if !plan.metadata.default_assignments.is_empty() {
961        apply_default_assignments(
962            &plan.bundle,
963            &plan.metadata.default_assignments,
964            &mut warnings,
965        )?;
966    }
967    if !plan.metadata.providers.is_empty() {
968        provider_updates +=
969            upsert_provider_ids(&plan.bundle, &plan.metadata.providers, &mut warnings)?;
970    }
971
972    // Seed secrets from setup_answers for each tenant.
973    if !plan.metadata.setup_answers.is_empty() {
974        seed_setup_answers(
975            &plan.bundle,
976            &plan.metadata.tenants,
977            &plan.metadata.setup_answers,
978            &mut warnings,
979        )?;
980
981        // Auto-register webhooks using answers (Telegram, Slack, Webex).
982        run_webhook_setup_from_answers(
983            &plan.bundle,
984            &plan.metadata.tenants,
985            &plan.metadata.setup_answers,
986        );
987    }
988    let copied = apply_access_and_sync(
989        &plan.bundle,
990        &plan.metadata.tenants,
991        &plan.metadata.access_changes,
992        &mut warnings,
993    )?;
994
995    Ok(WizardExecutionReport {
996        bundle: plan.bundle.clone(),
997        resolved_packs,
998        resolved_manifests: copied,
999        provider_updates,
1000        warnings,
1001    })
1002}
1003
1004pub fn execute_update_plan(
1005    plan: &WizardPlan,
1006    offline: bool,
1007) -> anyhow::Result<WizardExecutionReport> {
1008    if plan.mode != WizardMode::Update.as_str() {
1009        return Err(anyhow!("unsupported wizard mode: {}", plan.mode));
1010    }
1011    validate_bundle_exists(&plan.bundle)?;
1012    let mut warnings = Vec::new();
1013
1014    let mut resolved_packs = Vec::new();
1015    let mut ops = plan.metadata.update_ops.clone();
1016    if ops.is_empty() {
1017        if !plan.metadata.pack_refs.is_empty() {
1018            ops.insert(WizardUpdateOp::PacksAdd);
1019        }
1020        if !plan.metadata.packs_remove.is_empty() {
1021            ops.insert(WizardUpdateOp::PacksRemove);
1022        }
1023        if !plan.metadata.providers.is_empty() {
1024            ops.insert(WizardUpdateOp::ProvidersAdd);
1025        }
1026        if !plan.metadata.providers_remove.is_empty() {
1027            ops.insert(WizardUpdateOp::ProvidersRemove);
1028        }
1029        if !plan.metadata.tenants.is_empty() {
1030            ops.insert(WizardUpdateOp::TenantsAdd);
1031        }
1032        if !plan.metadata.tenants_remove.is_empty() {
1033            ops.insert(WizardUpdateOp::TenantsRemove);
1034        }
1035        if !plan.metadata.access_changes.is_empty()
1036            || plan
1037                .metadata
1038                .tenants
1039                .iter()
1040                .any(|tenant| !tenant.allow_paths.is_empty())
1041        {
1042            ops.insert(WizardUpdateOp::AccessChange);
1043        }
1044    }
1045
1046    if ops.contains(&WizardUpdateOp::PacksAdd) && !plan.metadata.pack_refs.is_empty() {
1047        let mut resolved = resolve_pack_refs(&plan.metadata.pack_refs, offline)
1048            .context("resolve pack refs via distributor-client")?;
1049        assign_pack_ids_and_persist_metadata(&plan.bundle, &mut resolved)?;
1050        for item in resolved {
1051            copy_pack_into_bundle(&plan.bundle, &item)?;
1052            resolved_packs.push(item);
1053        }
1054        link_packs_to_provider_dirs(&plan.bundle, &resolved_packs)?;
1055    }
1056    if !plan.metadata.default_assignments.is_empty() {
1057        apply_default_assignments(
1058            &plan.bundle,
1059            &plan.metadata.default_assignments,
1060            &mut warnings,
1061        )?;
1062    }
1063    if ops.contains(&WizardUpdateOp::PacksRemove) {
1064        for selection in &plan.metadata.packs_remove {
1065            apply_pack_remove(&plan.bundle, selection, &mut warnings)?;
1066        }
1067    }
1068    let mut provider_updates = upsert_provider_registry(&plan.bundle, &resolved_packs)?;
1069    if ops.contains(&WizardUpdateOp::ProvidersAdd) && !plan.metadata.providers.is_empty() {
1070        provider_updates +=
1071            upsert_provider_ids(&plan.bundle, &plan.metadata.providers, &mut warnings)?;
1072    }
1073    if ops.contains(&WizardUpdateOp::ProvidersRemove) && !plan.metadata.providers_remove.is_empty()
1074    {
1075        provider_updates +=
1076            remove_provider_ids(&plan.bundle, &plan.metadata.providers_remove, &mut warnings)?;
1077    }
1078    if ops.contains(&WizardUpdateOp::TenantsAdd) {
1079        for tenant in &plan.metadata.tenants {
1080            ensure_tenant_and_team(&plan.bundle, tenant)?;
1081        }
1082    }
1083    if ops.contains(&WizardUpdateOp::TenantsRemove) {
1084        for tenant in &plan.metadata.tenants_remove {
1085            remove_tenant_or_team(&plan.bundle, tenant, &mut warnings)?;
1086        }
1087    }
1088    let mut copied = Vec::new();
1089    if ops.contains(&WizardUpdateOp::AccessChange) {
1090        copied.extend(apply_access_and_sync(
1091            &plan.bundle,
1092            &plan.metadata.tenants,
1093            &plan.metadata.access_changes,
1094            &mut warnings,
1095        )?);
1096    }
1097    Ok(WizardExecutionReport {
1098        bundle: plan.bundle.clone(),
1099        resolved_packs,
1100        resolved_manifests: copied,
1101        provider_updates,
1102        warnings,
1103    })
1104}
1105
1106pub fn execute_remove_plan(plan: &WizardPlan) -> anyhow::Result<WizardExecutionReport> {
1107    if plan.mode != WizardMode::Remove.as_str() {
1108        return Err(anyhow!("unsupported wizard mode: {}", plan.mode));
1109    }
1110    validate_bundle_exists(&plan.bundle)?;
1111    let mut warnings = Vec::new();
1112
1113    let mut targets = plan.metadata.remove_targets.clone();
1114    if targets.is_empty() {
1115        if !plan.metadata.packs_remove.is_empty() {
1116            targets.insert(WizardRemoveTarget::Packs);
1117        }
1118        if !plan.metadata.providers_remove.is_empty() {
1119            targets.insert(WizardRemoveTarget::Providers);
1120        }
1121        if !plan.metadata.tenants_remove.is_empty() {
1122            targets.insert(WizardRemoveTarget::TenantsTeams);
1123        }
1124    }
1125
1126    if targets.contains(&WizardRemoveTarget::Packs) {
1127        for selection in &plan.metadata.packs_remove {
1128            apply_pack_remove(&plan.bundle, selection, &mut warnings)?;
1129        }
1130    }
1131    let mut provider_updates = 0usize;
1132    if targets.contains(&WizardRemoveTarget::Providers) {
1133        provider_updates +=
1134            remove_provider_ids(&plan.bundle, &plan.metadata.providers_remove, &mut warnings)?;
1135    }
1136    if targets.contains(&WizardRemoveTarget::TenantsTeams) {
1137        for tenant in &plan.metadata.tenants_remove {
1138            remove_tenant_or_team(&plan.bundle, tenant, &mut warnings)?;
1139        }
1140    }
1141    Ok(WizardExecutionReport {
1142        bundle: plan.bundle.clone(),
1143        resolved_packs: Vec::new(),
1144        resolved_manifests: Vec::new(),
1145        provider_updates,
1146        warnings,
1147    })
1148}
1149
1150fn step<const N: usize>(
1151    kind: WizardStepKind,
1152    description: &str,
1153    details: [(&str, String); N],
1154) -> WizardPlanStep {
1155    let mut map = BTreeMap::new();
1156    for (key, value) in details {
1157        map.insert(key.to_string(), value);
1158    }
1159    WizardPlanStep {
1160        kind,
1161        description: description.to_string(),
1162        details: map,
1163    }
1164}
1165
1166fn create_demo_bundle_structure(root: &Path, bundle_name: Option<&str>) -> anyhow::Result<()> {
1167    let directories = [
1168        "",
1169        "providers",
1170        "providers/messaging",
1171        "providers/events",
1172        "providers/secrets",
1173        "providers/oauth",
1174        "packs",
1175        "resolved",
1176        "state",
1177        "state/resolved",
1178        "state/runs",
1179        "state/pids",
1180        "state/logs",
1181        "state/runtime",
1182        "state/doctor",
1183        "tenants",
1184        "tenants/default",
1185        "tenants/default/teams",
1186        "tenants/demo",
1187        "tenants/demo/teams",
1188        "tenants/demo/teams/default",
1189        "logs",
1190    ];
1191    for directory in directories {
1192        std::fs::create_dir_all(root.join(directory))?;
1193    }
1194    let mut demo_yaml = "version: \"1\"\nproject_root: \"./\"\n".to_string();
1195    if let Some(name) = bundle_name.filter(|value| !value.trim().is_empty()) {
1196        demo_yaml.push_str(&format!("bundle_name: \"{}\"\n", name.replace('"', "")));
1197    }
1198    write_if_missing(&root.join("greentic.demo.yaml"), &demo_yaml)?;
1199    write_if_missing(
1200        &root.join("tenants").join("default").join("tenant.gmap"),
1201        "_ = forbidden\n",
1202    )?;
1203    write_if_missing(
1204        &root.join("tenants").join("demo").join("tenant.gmap"),
1205        "_ = forbidden\n",
1206    )?;
1207    write_if_missing(
1208        &root
1209            .join("tenants")
1210            .join("demo")
1211            .join("teams")
1212            .join("default")
1213            .join("team.gmap"),
1214        "_ = forbidden\n",
1215    )?;
1216    Ok(())
1217}
1218
1219fn write_if_missing(path: &Path, contents: &str) -> anyhow::Result<()> {
1220    if path.exists() {
1221        return Ok(());
1222    }
1223    if let Some(parent) = path.parent() {
1224        std::fs::create_dir_all(parent)?;
1225    }
1226    std::fs::write(path, contents)?;
1227    Ok(())
1228}
1229
1230fn ensure_tenant_and_team(bundle: &Path, selection: &TenantSelection) -> anyhow::Result<()> {
1231    project::add_tenant(bundle, &selection.tenant)?;
1232    if let Some(team) = selection.team.as_deref()
1233        && !team.is_empty()
1234    {
1235        project::add_team(bundle, &selection.tenant, team)?;
1236    }
1237    Ok(())
1238}
1239
1240fn demo_bundle_gmap_path(bundle: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
1241    let mut path = bundle.join("tenants").join(tenant);
1242    if let Some(team) = team {
1243        path = path.join("teams").join(team).join("team.gmap");
1244    } else {
1245        path = path.join("tenant.gmap");
1246    }
1247    path
1248}
1249
1250fn resolved_manifest_filename(tenant: &str, team: Option<&str>) -> String {
1251    match team {
1252        Some(team) => format!("{tenant}.{team}.yaml"),
1253        None => format!("{tenant}.yaml"),
1254    }
1255}
1256
1257fn resolve_pack_refs(pack_refs: &[String], offline: bool) -> anyhow::Result<Vec<ResolvedPackInfo>> {
1258    use greentic_distributor_client::{
1259        OciPackFetcher, PackFetchOptions, oci_packs::DefaultRegistryClient,
1260    };
1261
1262    let rt = tokio::runtime::Builder::new_current_thread()
1263        .enable_all()
1264        .build()
1265        .context("build tokio runtime for pack resolution")?;
1266
1267    let mut opts = PackFetchOptions {
1268        allow_tags: true,
1269        offline,
1270        ..PackFetchOptions::default()
1271    };
1272    if let Ok(cache_dir) = std::env::var("GREENTIC_PACK_CACHE_DIR") {
1273        opts.cache_dir = PathBuf::from(cache_dir);
1274    }
1275    let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(opts);
1276
1277    let mut resolved = Vec::new();
1278    for reference in pack_refs {
1279        if let Some(local_path) = parse_local_pack_ref(reference) {
1280            let meta = crate::domains::read_pack_meta(&local_path)
1281                .with_context(|| format!("read pack meta from {}", local_path.display()))?;
1282            let digest = local_pack_digest(&local_path)?;
1283            let file_name = deterministic_pack_file_name(reference, &digest);
1284            resolved.push(ResolvedPackInfo {
1285                source_ref: reference.clone(),
1286                mapped_ref: local_path.display().to_string(),
1287                resolved_digest: digest,
1288                pack_id: meta.pack_id,
1289                entry_flows: meta.entry_flows,
1290                cached_path: local_path,
1291                output_path: PathBuf::from("packs").join(file_name),
1292            });
1293            continue;
1294        }
1295        let mapped_ref = map_pack_reference(reference)?;
1296        let fetched = rt
1297            .block_on(fetcher.fetch_pack_to_cache(&mapped_ref))
1298            .with_context(|| format!("fetch pack reference {reference}"))?;
1299        let meta = crate::domains::read_pack_meta(&fetched.path)
1300            .with_context(|| format!("read pack meta from {}", fetched.path.display()))?;
1301        let file_name = deterministic_pack_file_name(reference, &fetched.resolved_digest);
1302        resolved.push(ResolvedPackInfo {
1303            source_ref: reference.clone(),
1304            mapped_ref,
1305            resolved_digest: fetched.resolved_digest,
1306            pack_id: meta.pack_id,
1307            entry_flows: meta.entry_flows,
1308            cached_path: fetched.path,
1309            output_path: PathBuf::from("packs").join(file_name),
1310        });
1311    }
1312    resolved.sort_by(|a, b| a.source_ref.cmp(&b.source_ref));
1313    Ok(resolved)
1314}
1315
1316fn parse_local_pack_ref(reference: &str) -> Option<PathBuf> {
1317    let trimmed = reference.trim();
1318    if trimmed.is_empty() {
1319        return None;
1320    }
1321    if let Some(path) = trimmed.strip_prefix("file://") {
1322        let local = PathBuf::from(path);
1323        if local.exists() {
1324            return Some(local);
1325        }
1326        return None;
1327    }
1328    if trimmed.contains("://") {
1329        return None;
1330    }
1331    let local = PathBuf::from(trimmed);
1332    if local.exists() { Some(local) } else { None }
1333}
1334
1335fn local_pack_digest(path: &Path) -> anyhow::Result<String> {
1336    use std::hash::{Hash, Hasher};
1337    let metadata =
1338        std::fs::metadata(path).with_context(|| format!("stat local pack {}", path.display()))?;
1339    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1340    path.display().to_string().hash(&mut hasher);
1341    metadata.len().hash(&mut hasher);
1342    metadata
1343        .modified()
1344        .ok()
1345        .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
1346        .map(|duration| duration.as_nanos())
1347        .hash(&mut hasher);
1348    Ok(format!("local:{:016x}", hasher.finish()))
1349}
1350
1351fn map_pack_reference(reference: &str) -> anyhow::Result<String> {
1352    let trimmed = reference.trim();
1353    if let Some(rest) = trimmed.strip_prefix("oci://") {
1354        return Ok(rest.to_string());
1355    }
1356    if let Some(rest) = trimmed.strip_prefix("repo://") {
1357        return map_registry_target(rest, std::env::var("GREENTIC_REPO_REGISTRY_BASE").ok())
1358            .ok_or_else(|| {
1359                anyhow!(
1360                    "repo:// reference {trimmed} requires GREENTIC_REPO_REGISTRY_BASE to map to OCI"
1361                )
1362            });
1363    }
1364    if let Some(rest) = trimmed.strip_prefix("store://") {
1365        return map_registry_target(rest, std::env::var("GREENTIC_STORE_REGISTRY_BASE").ok())
1366            .ok_or_else(|| {
1367                anyhow!(
1368                    "store:// reference {trimmed} requires GREENTIC_STORE_REGISTRY_BASE to map to OCI"
1369                )
1370            });
1371    }
1372    Ok(trimmed.to_string())
1373}
1374
1375fn map_registry_target(target: &str, base: Option<String>) -> Option<String> {
1376    if target.contains('/') && (target.contains('@') || target.contains(':')) {
1377        return Some(target.to_string());
1378    }
1379    let base = base?;
1380    let normalized_base = base.trim_end_matches('/');
1381    let normalized_target = target.trim_start_matches('/');
1382    Some(format!("{normalized_base}/{normalized_target}"))
1383}
1384
1385fn deterministic_pack_file_name(reference: &str, digest: &str) -> String {
1386    let mut slug = String::new();
1387    for ch in reference.chars() {
1388        if ch.is_ascii_alphanumeric() {
1389            slug.push(ch.to_ascii_lowercase());
1390        } else {
1391            slug.push('-');
1392        }
1393    }
1394    while slug.contains("--") {
1395        slug = slug.replace("--", "-");
1396    }
1397    slug = slug.trim_matches('-').to_string();
1398    if slug.len() > 40 {
1399        slug.truncate(40);
1400    }
1401    let short_digest = digest
1402        .trim_start_matches("sha256:")
1403        .chars()
1404        .take(12)
1405        .collect::<String>();
1406    format!("{slug}-{short_digest}.gtpack")
1407}
1408
1409fn copy_pack_into_bundle(bundle: &Path, pack: &ResolvedPackInfo) -> anyhow::Result<()> {
1410    let src = pack.cached_path.clone();
1411    if !src.exists() {
1412        return Err(anyhow!("cached pack not found at {}", src.display()));
1413    }
1414    let dst = bundle.join(&pack.output_path);
1415    if let Some(parent) = dst.parent() {
1416        std::fs::create_dir_all(parent)?;
1417    }
1418    std::fs::copy(src, dst)?;
1419    Ok(())
1420}
1421
1422/// Copy each resolved pack into the corresponding `providers/{domain}/` directory
1423/// so that `discovery::discover()` can detect them at `demo start` time.
1424fn link_packs_to_provider_dirs(bundle: &Path, packs: &[ResolvedPackInfo]) -> anyhow::Result<()> {
1425    for pack in packs {
1426        let domain_dir = if pack.pack_id.starts_with("messaging-") {
1427            "messaging"
1428        } else if pack.pack_id.starts_with("events-") {
1429            "events"
1430        } else if pack.pack_id.starts_with("oauth-") {
1431            "oauth"
1432        } else {
1433            continue;
1434        };
1435        let src = bundle.join(&pack.output_path);
1436        if !src.exists() {
1437            continue;
1438        }
1439        let dest_dir = bundle.join("providers").join(domain_dir);
1440        std::fs::create_dir_all(&dest_dir)?;
1441        let file_name = src
1442            .file_name()
1443            .ok_or_else(|| anyhow!("bad pack path {}", src.display()))?;
1444        let dst = dest_dir.join(file_name);
1445        if !dst.exists() {
1446            std::fs::copy(&src, &dst)?;
1447        }
1448    }
1449    Ok(())
1450}
1451
1452/// Seed secrets from the `setup_answers` map in the wizard answers.
1453///
1454/// For each provider in `setup_answers`, calls `persist_all_config_as_secrets`
1455/// so that WASM components can read the values via the secrets API at runtime.
1456fn seed_setup_answers(
1457    bundle: &Path,
1458    tenants: &[TenantSelection],
1459    setup_answers: &serde_json::Map<String, serde_json::Value>,
1460    warnings: &mut Vec<String>,
1461) -> anyhow::Result<()> {
1462    let env = crate::secrets_setup::resolve_env(None);
1463    let rt = tokio::runtime::Builder::new_current_thread()
1464        .enable_all()
1465        .build()
1466        .context("build tokio runtime for secret seeding")?;
1467
1468    // Seed for each tenant declared in the plan.
1469    let tenant_ids: Vec<String> = if tenants.is_empty() {
1470        vec!["demo".to_string()]
1471    } else {
1472        tenants.iter().map(|t| t.tenant.clone()).collect()
1473    };
1474
1475    for (provider_id, config) in setup_answers {
1476        if !config.is_object() || config.as_object().is_some_and(|m| m.is_empty()) {
1477            continue;
1478        }
1479        // Try to find the pack path so secret-requirements aliases are seeded.
1480        let pack_path = find_provider_pack_path(bundle, provider_id);
1481        for tenant in &tenant_ids {
1482            match rt.block_on(crate::qa_persist::persist_all_config_as_secrets(
1483                bundle,
1484                &env,
1485                tenant,
1486                None, // team
1487                provider_id,
1488                config,
1489                pack_path.as_deref(),
1490            )) {
1491                Ok(keys) => {
1492                    if !keys.is_empty() {
1493                        crate::operator_log::info(
1494                            module_path!(),
1495                            format!(
1496                                "seeded {} secret(s) for provider={} tenant={}",
1497                                keys.len(),
1498                                provider_id,
1499                                tenant
1500                            ),
1501                        );
1502                    }
1503                }
1504                Err(err) => {
1505                    warnings.push(format!(
1506                        "failed to seed secrets for provider={} tenant={}: {err}",
1507                        provider_id, tenant
1508                    ));
1509                }
1510            }
1511        }
1512    }
1513    Ok(())
1514}
1515
1516/// Locate a provider's .gtpack file in the bundle by provider_id stem.
1517fn find_provider_pack_path(bundle: &Path, provider_id: &str) -> Option<std::path::PathBuf> {
1518    // Search in providers/messaging, providers/events, packs
1519    for subdir in &["providers/messaging", "providers/events", "packs"] {
1520        let dir = bundle.join(subdir);
1521        let candidate = dir.join(format!("{provider_id}.gtpack"));
1522        if candidate.exists() {
1523            return Some(candidate);
1524        }
1525    }
1526    None
1527}
1528
1529/// Run webhook auto-setup for providers that have answers with public_base_url.
1530/// Called during wizard execute so webhooks are registered without needing demo start.
1531fn run_webhook_setup_from_answers(
1532    bundle: &Path,
1533    tenants: &[TenantSelection],
1534    setup_answers: &serde_json::Map<String, serde_json::Value>,
1535) {
1536    let tenant_ids: Vec<String> = if tenants.is_empty() {
1537        vec!["demo".to_string()]
1538    } else {
1539        tenants.iter().map(|t| t.tenant.clone()).collect()
1540    };
1541
1542    for (provider_id, answers) in setup_answers {
1543        let Some(obj) = answers.as_object() else {
1544            continue;
1545        };
1546        if obj.is_empty() {
1547            continue;
1548        }
1549        // Need public_base_url to register webhooks
1550        let Some(public_url) = obj
1551            .get("public_base_url")
1552            .and_then(|v| v.as_str())
1553            .filter(|s| !s.is_empty())
1554        else {
1555            continue;
1556        };
1557        if !public_url.starts_with("https://") {
1558            crate::operator_log::info(
1559                module_path!(),
1560                format!(
1561                    "[wizard] webhook skipped provider={} (public_base_url is not HTTPS: {})",
1562                    provider_id, public_url
1563                ),
1564            );
1565            continue;
1566        }
1567
1568        let pack_path = bundle.join("packs").join(format!("{provider_id}.gtpack"));
1569        let pack = crate::domains::ProviderPack {
1570            pack_id: provider_id.clone(),
1571            file_name: pack_path
1572                .file_name()
1573                .and_then(|v| v.to_str())
1574                .unwrap_or_default()
1575                .to_string(),
1576            path: pack_path,
1577            entry_flows: Vec::new(),
1578        };
1579
1580        for tenant in &tenant_ids {
1581            let config = serde_json::Value::Object(obj.clone());
1582            match crate::onboard::webhook_setup::try_provider_setup_webhook(
1583                bundle,
1584                crate::domains::Domain::Messaging,
1585                &pack,
1586                provider_id,
1587                tenant,
1588                None,
1589                &config,
1590            ) {
1591                Some(result) => {
1592                    let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
1593                    if ok {
1594                        crate::operator_log::info(
1595                            module_path!(),
1596                            format!(
1597                                "[wizard] webhook auto-setup ok provider={} tenant={} result={}",
1598                                provider_id, tenant, result
1599                            ),
1600                        );
1601                        println!(
1602                            "webhook: {} registered ({})",
1603                            provider_id,
1604                            result
1605                                .get("webhook_url")
1606                                .and_then(|v| v.as_str())
1607                                .unwrap_or("ok")
1608                        );
1609                    } else {
1610                        crate::operator_log::warn(
1611                            module_path!(),
1612                            format!(
1613                                "[wizard] webhook auto-setup failed provider={} tenant={} result={}",
1614                                provider_id, tenant, result
1615                            ),
1616                        );
1617                        let err = result
1618                            .get("error")
1619                            .and_then(|v| v.as_str())
1620                            .unwrap_or("unknown");
1621                        println!("webhook: {} failed ({})", provider_id, err);
1622                    }
1623                }
1624                None => {
1625                    crate::operator_log::info(
1626                        module_path!(),
1627                        format!(
1628                            "[wizard] webhook skipped provider={} (unsupported or missing config)",
1629                            provider_id
1630                        ),
1631                    );
1632                }
1633            }
1634        }
1635    }
1636}
1637
1638fn upsert_provider_registry(bundle: &Path, packs: &[ResolvedPackInfo]) -> anyhow::Result<usize> {
1639    if packs.is_empty() {
1640        return Ok(0);
1641    }
1642    let path = bundle.join("providers").join("providers.json");
1643    let mut root = if path.exists() {
1644        let raw = std::fs::read_to_string(&path)
1645            .with_context(|| format!("read provider registry {}", path.display()))?;
1646        serde_json::from_str::<serde_json::Value>(&raw)
1647            .with_context(|| format!("parse provider registry {}", path.display()))?
1648    } else {
1649        serde_json::json!({ "providers": [] })
1650    };
1651
1652    let root_obj = root
1653        .as_object_mut()
1654        .ok_or_else(|| anyhow!("provider registry {} must be a JSON object", path.display()))?;
1655    if !root_obj.contains_key("providers") {
1656        root_obj.insert("providers".to_string(), serde_json::json!([]));
1657    }
1658    let providers = root_obj
1659        .get_mut("providers")
1660        .and_then(serde_json::Value::as_array_mut)
1661        .ok_or_else(|| {
1662            anyhow!(
1663                "provider registry {}.providers must be an array",
1664                path.display()
1665            )
1666        })?;
1667
1668    let mut updates = 0usize;
1669    for pack in packs {
1670        let mut found = false;
1671        for entry in providers.iter_mut() {
1672            let Some(entry_obj) = entry.as_object_mut() else {
1673                continue;
1674            };
1675            let same_id = entry_obj
1676                .get("id")
1677                .and_then(serde_json::Value::as_str)
1678                .map(|id| id == pack.pack_id)
1679                .unwrap_or(false);
1680            if !same_id {
1681                continue;
1682            }
1683            found = true;
1684            let current_ref = entry_obj
1685                .get("ref")
1686                .and_then(serde_json::Value::as_str)
1687                .unwrap_or_default();
1688            let current_enabled = entry_obj
1689                .get("enabled")
1690                .and_then(serde_json::Value::as_bool)
1691                .unwrap_or(false);
1692            if current_ref != pack.source_ref || !current_enabled {
1693                entry_obj.insert(
1694                    "ref".to_string(),
1695                    serde_json::Value::String(pack.source_ref.clone()),
1696                );
1697                entry_obj.insert("enabled".to_string(), serde_json::Value::Bool(true));
1698                updates += 1;
1699            }
1700            break;
1701        }
1702        if !found {
1703            providers.push(serde_json::json!({
1704                "id": pack.pack_id,
1705                "ref": pack.source_ref,
1706                "enabled": true
1707            }));
1708            updates += 1;
1709        }
1710    }
1711
1712    if let Some(parent) = path.parent() {
1713        std::fs::create_dir_all(parent)?;
1714    }
1715    let payload = serde_json::to_string_pretty(&root)
1716        .with_context(|| format!("serialize provider registry {}", path.display()))?;
1717    std::fs::write(&path, payload)
1718        .with_context(|| format!("write provider registry {}", path.display()))?;
1719    Ok(updates)
1720}
1721
1722fn upsert_provider_ids(
1723    bundle: &Path,
1724    provider_ids: &[String],
1725    _warnings: &mut Vec<String>,
1726) -> anyhow::Result<usize> {
1727    if provider_ids.is_empty() {
1728        return Ok(0);
1729    }
1730    let path = bundle.join("providers").join("providers.json");
1731    let mut root = load_provider_registry_file(&path)?;
1732    let providers = ensure_provider_array_mut(&mut root, &path)?;
1733    let mut updates = 0usize;
1734    for provider_id in provider_ids {
1735        let id = provider_id.trim();
1736        if id.is_empty() {
1737            continue;
1738        }
1739        let mut found = false;
1740        for entry in providers.iter_mut() {
1741            let Some(entry_obj) = entry.as_object_mut() else {
1742                continue;
1743            };
1744            let same_id = entry_obj
1745                .get("id")
1746                .and_then(serde_json::Value::as_str)
1747                .is_some_and(|value| value == id);
1748            if !same_id {
1749                continue;
1750            }
1751            found = true;
1752            let enabled = entry_obj
1753                .get("enabled")
1754                .and_then(serde_json::Value::as_bool)
1755                .unwrap_or(false);
1756            if !enabled {
1757                entry_obj.insert("enabled".to_string(), serde_json::Value::Bool(true));
1758                updates += 1;
1759            }
1760            break;
1761        }
1762        if !found {
1763            providers.push(serde_json::json!({
1764                "id": id,
1765                "ref": id,
1766                "enabled": true
1767            }));
1768            updates += 1;
1769        }
1770    }
1771    write_provider_registry_file(&path, &root)?;
1772    Ok(updates)
1773}
1774
1775fn remove_provider_ids(
1776    bundle: &Path,
1777    provider_ids: &[String],
1778    warnings: &mut Vec<String>,
1779) -> anyhow::Result<usize> {
1780    if provider_ids.is_empty() {
1781        return Ok(0);
1782    }
1783    let path = bundle.join("providers").join("providers.json");
1784    if !path.exists() {
1785        for provider_id in provider_ids {
1786            warnings.push(format!(
1787                "provider {provider_id} already absent (providers/providers.json missing)"
1788            ));
1789        }
1790        return Ok(0);
1791    }
1792
1793    let mut root = load_provider_registry_file(&path)?;
1794    let providers = ensure_provider_array_mut(&mut root, &path)?;
1795    let mut updates = 0usize;
1796    for provider_id in provider_ids {
1797        let id = provider_id.trim();
1798        if id.is_empty() {
1799            continue;
1800        }
1801        let mut found = false;
1802        for entry in providers.iter_mut() {
1803            let Some(entry_obj) = entry.as_object_mut() else {
1804                continue;
1805            };
1806            let same_id = entry_obj
1807                .get("id")
1808                .and_then(serde_json::Value::as_str)
1809                .is_some_and(|value| value == id);
1810            if !same_id {
1811                continue;
1812            }
1813            found = true;
1814            let enabled = entry_obj
1815                .get("enabled")
1816                .and_then(serde_json::Value::as_bool)
1817                .unwrap_or(false);
1818            if enabled {
1819                entry_obj.insert("enabled".to_string(), serde_json::Value::Bool(false));
1820                updates += 1;
1821            }
1822            break;
1823        }
1824        if !found {
1825            warnings.push(format!("provider {id} already absent"));
1826        }
1827    }
1828    write_provider_registry_file(&path, &root)?;
1829    Ok(updates)
1830}
1831
1832fn load_provider_registry_file(path: &Path) -> anyhow::Result<serde_json::Value> {
1833    if path.exists() {
1834        let raw = std::fs::read_to_string(path)
1835            .with_context(|| format!("read provider registry {}", path.display()))?;
1836        serde_json::from_str::<serde_json::Value>(&raw)
1837            .with_context(|| format!("parse provider registry {}", path.display()))
1838    } else {
1839        Ok(serde_json::json!({ "providers": [] }))
1840    }
1841}
1842
1843fn ensure_provider_array_mut<'a>(
1844    root: &'a mut serde_json::Value,
1845    path: &Path,
1846) -> anyhow::Result<&'a mut Vec<serde_json::Value>> {
1847    let root_obj = root
1848        .as_object_mut()
1849        .ok_or_else(|| anyhow!("provider registry {} must be a JSON object", path.display()))?;
1850    if !root_obj.contains_key("providers") {
1851        root_obj.insert("providers".to_string(), serde_json::json!([]));
1852    }
1853    root_obj
1854        .get_mut("providers")
1855        .and_then(serde_json::Value::as_array_mut)
1856        .ok_or_else(|| {
1857            anyhow!(
1858                "provider registry {}.providers must be an array",
1859                path.display()
1860            )
1861        })
1862}
1863
1864fn write_provider_registry_file(path: &Path, root: &serde_json::Value) -> anyhow::Result<()> {
1865    if let Some(parent) = path.parent() {
1866        std::fs::create_dir_all(parent)?;
1867    }
1868    let payload = serde_json::to_string_pretty(root)
1869        .with_context(|| format!("serialize provider registry {}", path.display()))?;
1870    std::fs::write(path, payload).with_context(|| format!("write {}", path.display()))
1871}
1872
1873fn apply_pack_remove(
1874    bundle: &Path,
1875    selection: &PackRemoveSelection,
1876    warnings: &mut Vec<String>,
1877) -> anyhow::Result<()> {
1878    let pack_id = resolve_pack_identifier(bundle, &selection.pack_identifier)?;
1879    let mut removed_any = false;
1880    let packs_dir = bundle.join("packs");
1881    if packs_dir.exists() {
1882        for entry in std::fs::read_dir(&packs_dir)? {
1883            let entry = entry?;
1884            let path = entry.path();
1885            let name = entry.file_name().to_string_lossy().to_string();
1886            if name == pack_id || name.starts_with(&format!("{pack_id}.")) {
1887                removed_any = true;
1888                if path.is_dir() {
1889                    std::fs::remove_dir_all(&path)?;
1890                } else {
1891                    std::fs::remove_file(&path)?;
1892                }
1893            }
1894        }
1895    }
1896    let scope = selection.scope.as_ref().unwrap_or(&PackScope::Bundle);
1897    match scope {
1898        PackScope::Bundle => {
1899            mark_dangling_defaults(bundle, &pack_id, warnings)?;
1900        }
1901        PackScope::Global => {
1902            remove_if_exists(&bundle.join("default.gtpack"), &mut removed_any)?;
1903        }
1904        PackScope::Tenant { tenant_id } => {
1905            remove_if_exists(
1906                &bundle
1907                    .join("tenants")
1908                    .join(tenant_id)
1909                    .join("default.gtpack"),
1910                &mut removed_any,
1911            )?;
1912        }
1913        PackScope::Team { tenant_id, team_id } => {
1914            remove_if_exists(
1915                &bundle
1916                    .join("tenants")
1917                    .join(tenant_id)
1918                    .join("teams")
1919                    .join(team_id)
1920                    .join("default.gtpack"),
1921                &mut removed_any,
1922            )?;
1923        }
1924    }
1925    if !removed_any {
1926        warnings.push(format!(
1927            "pack {} already absent (scope={scope:?})",
1928            selection.pack_identifier
1929        ));
1930    }
1931    Ok(())
1932}
1933
1934fn resolve_pack_identifier(bundle: &Path, identifier: &str) -> anyhow::Result<String> {
1935    canonical_pack_identifier(bundle, identifier)
1936}
1937
1938fn canonical_pack_identifier(bundle: &Path, identifier: &str) -> anyhow::Result<String> {
1939    let trimmed = identifier.trim();
1940    if trimmed.is_empty() {
1941        return Err(anyhow!("pack identifier must not be empty"));
1942    }
1943    if !trimmed.contains("://") && !trimmed.contains('/') && !trimmed.contains('.') {
1944        return Ok(trimmed.to_string());
1945    }
1946    let metadata = load_packs_metadata(bundle).unwrap_or_default();
1947    if let Some(record) = metadata
1948        .packs
1949        .iter()
1950        .find(|record| record.pack_id == trimmed)
1951    {
1952        return Ok(record.pack_id.clone());
1953    }
1954    if let Some(record) = metadata
1955        .packs
1956        .iter()
1957        .find(|record| record.original_ref == trimmed)
1958    {
1959        return Ok(record.pack_id.clone());
1960    }
1961    Ok(derive_pack_id_from_reference(trimmed))
1962}
1963
1964fn mark_dangling_defaults(
1965    bundle: &Path,
1966    pack_id: &str,
1967    warnings: &mut Vec<String>,
1968) -> anyhow::Result<()> {
1969    let global = bundle.join("default.gtpack");
1970    if default_mentions_pack(&global, pack_id)? {
1971        warnings.push(format!(
1972            "global default.gtpack references removed pack {pack_id} and may now be dangling"
1973        ));
1974    }
1975    let tenants_root = bundle.join("tenants");
1976    if !tenants_root.exists() {
1977        return Ok(());
1978    }
1979    for tenant in std::fs::read_dir(tenants_root)? {
1980        let tenant = tenant?;
1981        let tenant_path = tenant.path();
1982        let tenant_name = tenant.file_name().to_string_lossy().to_string();
1983        let tenant_default = tenant_path.join("default.gtpack");
1984        if default_mentions_pack(&tenant_default, pack_id)? {
1985            warnings.push(format!(
1986                "tenant {tenant_name} default.gtpack references removed pack {pack_id}"
1987            ));
1988        }
1989        let teams_root = tenant_path.join("teams");
1990        if !teams_root.exists() {
1991            continue;
1992        }
1993        for team in std::fs::read_dir(teams_root)? {
1994            let team = team?;
1995            let team_path = team.path();
1996            let team_name = team.file_name().to_string_lossy().to_string();
1997            let team_default = team_path.join("default.gtpack");
1998            if default_mentions_pack(&team_default, pack_id)? {
1999                warnings.push(format!(
2000                    "team {tenant_name}:{team_name} default.gtpack references removed pack {pack_id}"
2001                ));
2002            }
2003        }
2004    }
2005    Ok(())
2006}
2007
2008fn default_mentions_pack(path: &Path, pack_id: &str) -> anyhow::Result<bool> {
2009    if !path.exists() {
2010        return Ok(false);
2011    }
2012    let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
2013    Ok(raw.contains(pack_id))
2014}
2015
2016fn remove_if_exists(path: &Path, removed: &mut bool) -> anyhow::Result<()> {
2017    if !path.exists() {
2018        return Ok(());
2019    }
2020    *removed = true;
2021    if path.is_dir() {
2022        std::fs::remove_dir_all(path)?;
2023    } else {
2024        std::fs::remove_file(path)?;
2025    }
2026    Ok(())
2027}
2028
2029fn remove_tenant_or_team(
2030    bundle: &Path,
2031    selection: &TenantSelection,
2032    warnings: &mut Vec<String>,
2033) -> anyhow::Result<()> {
2034    let path = if let Some(team) = selection.team.as_deref() {
2035        bundle
2036            .join("tenants")
2037            .join(&selection.tenant)
2038            .join("teams")
2039            .join(team)
2040    } else {
2041        bundle.join("tenants").join(&selection.tenant)
2042    };
2043    if !path.exists() {
2044        warnings.push(format!(
2045            "tenant/team {}:{} already absent",
2046            selection.tenant,
2047            selection.team.clone().unwrap_or_default()
2048        ));
2049        return Ok(());
2050    }
2051    std::fs::remove_dir_all(path)?;
2052    Ok(())
2053}
2054
2055fn copy_resolved_for_targets<I>(
2056    bundle: &Path,
2057    targets: I,
2058    warnings: &mut Vec<String>,
2059) -> anyhow::Result<Vec<PathBuf>>
2060where
2061    I: IntoIterator<Item = (String, Option<String>)>,
2062{
2063    let mut copied = Vec::new();
2064    let mut seen = BTreeSet::new();
2065    for (tenant, team) in targets {
2066        if !seen.insert((tenant.clone(), team.clone())) {
2067            continue;
2068        }
2069        let filename = resolved_manifest_filename(&tenant, team.as_deref());
2070        let src = bundle.join("state").join("resolved").join(&filename);
2071        if !src.exists() {
2072            warnings.push(format!(
2073                "resolved manifest {} missing after resolver run",
2074                src.display()
2075            ));
2076            continue;
2077        }
2078        let dst = bundle.join("resolved").join(&filename);
2079        if let Some(parent) = dst.parent() {
2080            std::fs::create_dir_all(parent)?;
2081        }
2082        std::fs::copy(&src, &dst)?;
2083        copied.push(dst);
2084    }
2085    Ok(copied)
2086}
2087
2088fn apply_access_and_sync(
2089    bundle: &Path,
2090    tenants: &[TenantSelection],
2091    access_changes: &[AccessChangeSelection],
2092    warnings: &mut Vec<String>,
2093) -> anyhow::Result<Vec<PathBuf>> {
2094    let mut copy_targets: BTreeSet<(String, Option<String>)> = BTreeSet::new();
2095    for tenant in tenants {
2096        ensure_tenant_and_team(bundle, tenant)?;
2097        copy_targets.insert((tenant.tenant.clone(), tenant.team.clone()));
2098        for path in &tenant.allow_paths {
2099            if path.trim().is_empty() {
2100                continue;
2101            }
2102            let gmap_path = demo_bundle_gmap_path(bundle, &tenant.tenant, tenant.team.as_deref());
2103            gmap::upsert_policy(&gmap_path, path, Policy::Public)?;
2104        }
2105    }
2106    for change in access_changes {
2107        ensure_tenant_and_team(
2108            bundle,
2109            &TenantSelection {
2110                tenant: change.tenant_id.clone(),
2111                team: change.team_id.clone(),
2112                allow_paths: Vec::new(),
2113            },
2114        )?;
2115        copy_targets.insert((change.tenant_id.clone(), change.team_id.clone()));
2116        let gmap_path = demo_bundle_gmap_path(bundle, &change.tenant_id, change.team_id.as_deref());
2117        gmap::upsert_policy(&gmap_path, &change.pack_id, change.operation.policy())?;
2118    }
2119    if copy_targets.is_empty() {
2120        return Ok(Vec::new());
2121    }
2122    project::sync_project(bundle)?;
2123    copy_resolved_for_targets(bundle, copy_targets, warnings)
2124}
2125
2126fn apply_default_assignments(
2127    bundle: &Path,
2128    defaults: &[PackDefaultSelection],
2129    warnings: &mut Vec<String>,
2130) -> anyhow::Result<()> {
2131    for assignment in defaults {
2132        let pack_id = resolve_pack_identifier(bundle, &assignment.pack_identifier)?;
2133        let pack_file = format!("packs/{pack_id}.gtpack");
2134        let target = match &assignment.scope {
2135            PackScope::Bundle => continue,
2136            PackScope::Global => bundle.join("default.gtpack"),
2137            PackScope::Tenant { tenant_id } => bundle
2138                .join("tenants")
2139                .join(tenant_id)
2140                .join("default.gtpack"),
2141            PackScope::Team { tenant_id, team_id } => bundle
2142                .join("tenants")
2143                .join(tenant_id)
2144                .join("teams")
2145                .join(team_id)
2146                .join("default.gtpack"),
2147        };
2148        if !bundle.join(&pack_file).exists() {
2149            warnings.push(format!(
2150                "default assignment for {} skipped: {} not found",
2151                assignment.pack_identifier, pack_file
2152            ));
2153            continue;
2154        }
2155        if let Some(parent) = target.parent() {
2156            std::fs::create_dir_all(parent)?;
2157        }
2158        std::fs::write(&target, format!("{pack_file}\n"))?;
2159    }
2160    Ok(())
2161}
2162
2163fn assign_pack_ids_and_persist_metadata(
2164    bundle: &Path,
2165    packs: &mut [ResolvedPackInfo],
2166) -> anyhow::Result<()> {
2167    if packs.is_empty() {
2168        return Ok(());
2169    }
2170
2171    let mut metadata = load_packs_metadata(bundle)?;
2172    let mut by_original_ref = BTreeMap::new();
2173    let mut used_ids = BTreeSet::new();
2174    for record in &metadata.packs {
2175        if !record.original_ref.trim().is_empty() {
2176            by_original_ref.insert(record.original_ref.clone(), record.pack_id.clone());
2177        }
2178        used_ids.insert(record.pack_id.clone());
2179    }
2180
2181    for pack in packs.iter_mut() {
2182        let assigned_pack_id = if let Some(existing) = by_original_ref.get(&pack.source_ref) {
2183            existing.clone()
2184        } else {
2185            let base = derive_pack_id_from_reference(&pack.source_ref);
2186            let unique = allocate_unique_pack_id(&base, &used_ids);
2187            by_original_ref.insert(pack.source_ref.clone(), unique.clone());
2188            unique
2189        };
2190        used_ids.insert(assigned_pack_id.clone());
2191        pack.pack_id = assigned_pack_id.clone();
2192        pack.output_path = PathBuf::from("packs").join(format!("{assigned_pack_id}.gtpack"));
2193    }
2194
2195    for pack in packs.iter() {
2196        upsert_pack_mapping(
2197            &mut metadata,
2198            PackMappingRecord {
2199                pack_id: pack.pack_id.clone(),
2200                original_ref: pack.source_ref.clone(),
2201                local_path_in_bundle: pack.output_path.display().to_string(),
2202                digest: Some(pack.resolved_digest.clone()),
2203            },
2204        );
2205    }
2206    metadata.packs.sort_by(|a, b| a.pack_id.cmp(&b.pack_id));
2207    write_packs_metadata(bundle, &metadata)?;
2208    Ok(())
2209}
2210
2211fn upsert_pack_mapping(metadata: &mut PacksMetadata, next: PackMappingRecord) {
2212    if let Some(existing) = metadata
2213        .packs
2214        .iter_mut()
2215        .find(|record| record.pack_id == next.pack_id)
2216    {
2217        *existing = next;
2218        return;
2219    }
2220    metadata.packs.push(next);
2221}
2222
2223fn packs_metadata_path(bundle: &Path) -> PathBuf {
2224    bundle.join(".greentic").join("packs.json")
2225}
2226
2227fn load_packs_metadata(bundle: &Path) -> anyhow::Result<PacksMetadata> {
2228    let path = packs_metadata_path(bundle);
2229    if !path.exists() {
2230        return Ok(PacksMetadata::default());
2231    }
2232    let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2233    serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
2234}
2235
2236fn write_packs_metadata(bundle: &Path, metadata: &PacksMetadata) -> anyhow::Result<()> {
2237    let path = packs_metadata_path(bundle);
2238    if let Some(parent) = path.parent() {
2239        std::fs::create_dir_all(parent)?;
2240    }
2241    let payload = serde_json::to_string_pretty(metadata)
2242        .with_context(|| format!("serialize {}", path.display()))?;
2243    std::fs::write(&path, payload).with_context(|| format!("write {}", path.display()))?;
2244    Ok(())
2245}
2246
2247fn allocate_unique_pack_id(base: &str, used_ids: &BTreeSet<String>) -> String {
2248    if !used_ids.contains(base) {
2249        return base.to_string();
2250    }
2251    for index in 2.. {
2252        let candidate = format!("{base}-{index}");
2253        if !used_ids.contains(&candidate) {
2254            return candidate;
2255        }
2256    }
2257    unreachable!("unbounded index must eventually produce unique pack id")
2258}
2259
2260fn derive_pack_id_from_reference(reference: &str) -> String {
2261    let trimmed = reference.trim();
2262    if trimmed.is_empty() {
2263        return "pack".to_string();
2264    }
2265
2266    let value = if let Some(rest) = trimmed.strip_prefix("file://") {
2267        rest
2268    } else if let Some((_, rest)) = trimmed.split_once("://") {
2269        rest
2270    } else {
2271        trimmed
2272    };
2273    let (path_part, tag_part) = value
2274        .split_once('@')
2275        .map_or((value, None), |(p, t)| (p, Some(t)));
2276    let tail = path_part.rsplit('/').next().unwrap_or(path_part);
2277    let stem = tail.rsplit_once('.').map_or(tail, |(base, _)| base);
2278
2279    let mut id = slug_for_pack_id(stem);
2280    if id.is_empty() {
2281        id = "pack".to_string();
2282    }
2283    if let Some(tag) = tag_part {
2284        let tag_slug = slug_for_tag(tag);
2285        if !tag_slug.is_empty() {
2286            id.push('-');
2287            id.push_str(&tag_slug);
2288        }
2289    }
2290    id
2291}
2292
2293fn slug_for_pack_id(value: &str) -> String {
2294    let mut out = String::new();
2295    let mut prev_dash = false;
2296    for ch in value.chars() {
2297        if ch.is_ascii_alphanumeric() {
2298            out.push(ch.to_ascii_lowercase());
2299            prev_dash = false;
2300        } else if !prev_dash {
2301            out.push('-');
2302            prev_dash = true;
2303        }
2304    }
2305    out.trim_matches('-').to_string()
2306}
2307
2308fn slug_for_tag(value: &str) -> String {
2309    value
2310        .chars()
2311        .map(|ch| {
2312            if ch.is_ascii_alphanumeric() {
2313                ch.to_ascii_lowercase()
2314            } else {
2315                '_'
2316            }
2317        })
2318        .collect::<String>()
2319        .trim_matches('_')
2320        .to_string()
2321}
2322
2323#[cfg(test)]
2324mod tests {
2325    use super::*;
2326
2327    #[test]
2328    fn plan_is_deterministic() {
2329        let req = WizardCreateRequest {
2330            bundle: PathBuf::from("bundle"),
2331            bundle_name: None,
2332            pack_refs: vec![
2333                "repo://zeta/pack@1".to_string(),
2334                "repo://alpha/pack@1".to_string(),
2335                "repo://alpha/pack@1".to_string(),
2336            ],
2337            tenants: vec![
2338                TenantSelection {
2339                    tenant: "demo".to_string(),
2340                    team: Some("default".to_string()),
2341                    allow_paths: vec!["pack/b".to_string(), "pack/a".to_string()],
2342                },
2343                TenantSelection {
2344                    tenant: "alpha".to_string(),
2345                    team: None,
2346                    allow_paths: vec!["x".to_string()],
2347                },
2348            ],
2349            default_assignments: Vec::new(),
2350            providers: Vec::new(),
2351            update_ops: BTreeSet::new(),
2352            remove_targets: BTreeSet::new(),
2353            packs_remove: Vec::new(),
2354            providers_remove: Vec::new(),
2355            tenants_remove: Vec::new(),
2356            access_changes: Vec::new(),
2357            setup_answers: serde_json::Map::new(),
2358        };
2359        let plan = apply_create(&req, true).unwrap();
2360        assert_eq!(
2361            plan.metadata.pack_refs,
2362            vec![
2363                "repo://alpha/pack@1".to_string(),
2364                "repo://zeta/pack@1".to_string()
2365            ]
2366        );
2367        assert_eq!(plan.metadata.tenants[0].tenant, "alpha");
2368        assert_eq!(
2369            plan.metadata.tenants[1].allow_paths,
2370            vec!["pack/a".to_string(), "pack/b".to_string()]
2371        );
2372    }
2373
2374    #[test]
2375    fn dry_run_does_not_create_files() {
2376        let temp = tempfile::tempdir().unwrap();
2377        let bundle = temp.path().join("demo-bundle");
2378        let req = WizardCreateRequest {
2379            bundle: bundle.clone(),
2380            bundle_name: None,
2381            pack_refs: Vec::new(),
2382            tenants: vec![TenantSelection {
2383                tenant: "demo".to_string(),
2384                team: Some("default".to_string()),
2385                allow_paths: vec!["packs/default".to_string()],
2386            }],
2387            default_assignments: Vec::new(),
2388            providers: Vec::new(),
2389            update_ops: BTreeSet::new(),
2390            remove_targets: BTreeSet::new(),
2391            packs_remove: Vec::new(),
2392            providers_remove: Vec::new(),
2393            tenants_remove: Vec::new(),
2394            access_changes: Vec::new(),
2395            setup_answers: serde_json::Map::new(),
2396        };
2397        let _plan = apply_create(&req, true).unwrap();
2398        assert!(!bundle.exists());
2399    }
2400
2401    #[test]
2402    fn execute_creates_bundle_and_resolved_manifest() {
2403        let temp = tempfile::tempdir().unwrap();
2404        let bundle = temp.path().join("demo-bundle");
2405        let req = WizardCreateRequest {
2406            bundle: bundle.clone(),
2407            bundle_name: None,
2408            pack_refs: Vec::new(),
2409            tenants: vec![TenantSelection {
2410                tenant: "demo".to_string(),
2411                team: Some("default".to_string()),
2412                allow_paths: vec!["packs/default".to_string()],
2413            }],
2414            default_assignments: Vec::new(),
2415            providers: Vec::new(),
2416            update_ops: BTreeSet::new(),
2417            remove_targets: BTreeSet::new(),
2418            packs_remove: Vec::new(),
2419            providers_remove: Vec::new(),
2420            tenants_remove: Vec::new(),
2421            access_changes: Vec::new(),
2422            setup_answers: serde_json::Map::new(),
2423        };
2424        let plan = apply_create(&req, false).unwrap();
2425        let report = execute_create_plan(&plan, true).unwrap();
2426        assert!(report.bundle.exists());
2427        assert!(
2428            bundle
2429                .join("state")
2430                .join("resolved")
2431                .join("demo.default.yaml")
2432                .exists()
2433        );
2434        assert!(bundle.join("resolved").join("demo.default.yaml").exists());
2435    }
2436
2437    #[test]
2438    fn update_mode_executes() {
2439        let temp = tempfile::tempdir().unwrap();
2440        let bundle = temp.path().join("demo-bundle");
2441        let create_req = WizardCreateRequest {
2442            bundle: bundle.clone(),
2443            bundle_name: None,
2444            pack_refs: Vec::new(),
2445            tenants: vec![TenantSelection {
2446                tenant: "demo".to_string(),
2447                team: None,
2448                allow_paths: vec!["packs/default".to_string()],
2449            }],
2450            default_assignments: Vec::new(),
2451            providers: Vec::new(),
2452            update_ops: BTreeSet::new(),
2453            remove_targets: BTreeSet::new(),
2454            packs_remove: Vec::new(),
2455            providers_remove: Vec::new(),
2456            tenants_remove: Vec::new(),
2457            access_changes: Vec::new(),
2458            setup_answers: serde_json::Map::new(),
2459        };
2460        let create_plan = apply_create(&create_req, false).unwrap();
2461        let _ = execute_create_plan(&create_plan, true).unwrap();
2462
2463        let req = WizardCreateRequest {
2464            bundle: bundle.clone(),
2465            bundle_name: None,
2466            pack_refs: Vec::new(),
2467            tenants: vec![TenantSelection {
2468                tenant: "demo".to_string(),
2469                team: None,
2470                allow_paths: vec!["packs/new".to_string()],
2471            }],
2472            default_assignments: Vec::new(),
2473            providers: Vec::new(),
2474            update_ops: BTreeSet::new(),
2475            remove_targets: BTreeSet::new(),
2476            packs_remove: Vec::new(),
2477            providers_remove: Vec::new(),
2478            tenants_remove: Vec::new(),
2479            access_changes: Vec::new(),
2480            setup_answers: serde_json::Map::new(),
2481        };
2482        let plan = apply_update(&req, false).unwrap();
2483        assert_eq!(plan.mode, "update");
2484        let report = execute_update_plan(&plan, true).unwrap();
2485        assert!(report.bundle.exists());
2486    }
2487
2488    #[test]
2489    fn remove_mode_forbids_rule() {
2490        let temp = tempfile::tempdir().unwrap();
2491        let bundle = temp.path().join("demo-bundle");
2492        let create_req = WizardCreateRequest {
2493            bundle: bundle.clone(),
2494            bundle_name: None,
2495            pack_refs: Vec::new(),
2496            tenants: vec![TenantSelection {
2497                tenant: "demo".to_string(),
2498                team: None,
2499                allow_paths: vec!["packs/default".to_string()],
2500            }],
2501            default_assignments: Vec::new(),
2502            providers: Vec::new(),
2503            update_ops: BTreeSet::new(),
2504            remove_targets: BTreeSet::new(),
2505            packs_remove: Vec::new(),
2506            providers_remove: Vec::new(),
2507            tenants_remove: Vec::new(),
2508            access_changes: Vec::new(),
2509            setup_answers: serde_json::Map::new(),
2510        };
2511        let create_plan = apply_create(&create_req, false).unwrap();
2512        let _ = execute_create_plan(&create_plan, true).unwrap();
2513
2514        let remove_req = WizardCreateRequest {
2515            bundle: bundle.clone(),
2516            bundle_name: None,
2517            pack_refs: Vec::new(),
2518            tenants: Vec::new(),
2519            default_assignments: Vec::new(),
2520            providers: Vec::new(),
2521            update_ops: BTreeSet::new(),
2522            remove_targets: [WizardRemoveTarget::TenantsTeams].into_iter().collect(),
2523            packs_remove: Vec::new(),
2524            providers_remove: Vec::new(),
2525            tenants_remove: vec![TenantSelection {
2526                tenant: "demo".to_string(),
2527                team: Some("default".to_string()),
2528                allow_paths: Vec::new(),
2529            }],
2530            access_changes: Vec::new(),
2531            setup_answers: serde_json::Map::new(),
2532        };
2533        let remove_plan = apply_remove(&remove_req, false).unwrap();
2534        let _ = execute_remove_plan(&remove_plan).unwrap();
2535        assert!(
2536            !bundle
2537                .join("tenants")
2538                .join("demo")
2539                .join("teams")
2540                .join("default")
2541                .exists()
2542        );
2543    }
2544
2545    #[test]
2546    fn derive_pack_id_handles_oci_and_local_refs() {
2547        assert_eq!(
2548            derive_pack_id_from_reference("oci://ghcr.io/greentic/packs/sales@0.6.0"),
2549            "sales-0_6_0"
2550        );
2551        assert_eq!(
2552            derive_pack_id_from_reference("store://sales/lead-to-cash@latest"),
2553            "lead-to-cash-latest"
2554        );
2555        assert_eq!(
2556            derive_pack_id_from_reference("/tmp/local/foo-pack.gtpack"),
2557            "foo-pack"
2558        );
2559        assert_eq!(
2560            derive_pack_id_from_reference("file:///tmp/local/foo_pack.gtpack"),
2561            "foo-pack"
2562        );
2563    }
2564
2565    #[test]
2566    fn metadata_assigns_stable_pack_ids() {
2567        let temp = tempfile::tempdir().unwrap();
2568        let bundle = temp.path().join("demo-bundle");
2569        std::fs::create_dir_all(&bundle).unwrap();
2570
2571        let mut packs = vec![
2572            ResolvedPackInfo {
2573                source_ref: "oci://ghcr.io/greentic/packs/sales@0.6.0".to_string(),
2574                mapped_ref: "ghcr.io/greentic/packs/sales@0.6.0".to_string(),
2575                resolved_digest: "sha256:abc".to_string(),
2576                pack_id: "ignored".to_string(),
2577                entry_flows: Vec::new(),
2578                cached_path: temp.path().join("cached-a.gtpack"),
2579                output_path: PathBuf::from("packs/ignored-a.gtpack"),
2580            },
2581            ResolvedPackInfo {
2582                source_ref: "oci://ghcr.io/greentic/packs/sales@0.6.0".to_string(),
2583                mapped_ref: "ghcr.io/greentic/packs/sales@0.6.0".to_string(),
2584                resolved_digest: "sha256:def".to_string(),
2585                pack_id: "ignored2".to_string(),
2586                entry_flows: Vec::new(),
2587                cached_path: temp.path().join("cached-b.gtpack"),
2588                output_path: PathBuf::from("packs/ignored-b.gtpack"),
2589            },
2590        ];
2591        assign_pack_ids_and_persist_metadata(&bundle, &mut packs).unwrap();
2592        assert_eq!(packs[0].pack_id, "sales-0_6_0");
2593        assert_eq!(packs[1].pack_id, "sales-0_6_0");
2594        assert_eq!(
2595            packs[0].output_path,
2596            PathBuf::from("packs/sales-0_6_0.gtpack")
2597        );
2598
2599        let metadata = load_packs_metadata(&bundle).unwrap();
2600        assert_eq!(metadata.packs.len(), 1);
2601        assert_eq!(metadata.packs[0].pack_id, "sales-0_6_0");
2602    }
2603
2604    #[test]
2605    fn load_catalog_supports_provider_registry_shape() {
2606        let temp = tempfile::tempdir().unwrap();
2607        let registry_path = temp.path().join("providers.json");
2608        std::fs::write(
2609            &registry_path,
2610            r#"{
2611  "registry_version": "providers@1",
2612  "items": [
2613    {
2614      "id": "messaging.telegram",
2615      "label": {"i18n_key": "provider.telegram", "fallback": "Telegram"},
2616      "ref": "oci://ghcr.io/greentic/providers/messaging-telegram@0.6.0"
2617    }
2618  ]
2619}"#,
2620        )
2621        .unwrap();
2622        let loaded = load_catalog_from_file(&registry_path).unwrap();
2623        assert_eq!(loaded.len(), 1);
2624        assert_eq!(loaded[0].id, "messaging.telegram");
2625        assert_eq!(loaded[0].label, "Telegram");
2626        assert_eq!(
2627            loaded[0].reference,
2628            "oci://ghcr.io/greentic/providers/messaging-telegram@0.6.0"
2629        );
2630    }
2631
2632    #[test]
2633    fn provider_registry_upserts_by_id() {
2634        let temp = tempfile::tempdir().unwrap();
2635        let bundle = temp.path().join("demo-bundle");
2636        std::fs::create_dir_all(bundle.join("providers")).unwrap();
2637        std::fs::write(
2638            bundle.join("providers").join("providers.json"),
2639            r#"{
2640  "providers": [
2641    {"id":"messaging.telegram","ref":"oci://old","enabled":false,"extra":"keep"}
2642  ],
2643  "top_level":"keep"
2644}"#,
2645        )
2646        .unwrap();
2647
2648        let packs = vec![ResolvedPackInfo {
2649            source_ref: "oci://ghcr.io/greentic/providers/messaging-telegram@0.6.0".to_string(),
2650            mapped_ref: "ghcr.io/greentic/providers/messaging-telegram@0.6.0".to_string(),
2651            resolved_digest: "sha256:abc".to_string(),
2652            pack_id: "messaging.telegram".to_string(),
2653            entry_flows: Vec::new(),
2654            cached_path: temp.path().join("cached.gtpack"),
2655            output_path: PathBuf::from("packs/messaging.telegram.gtpack"),
2656        }];
2657
2658        let updates = upsert_provider_registry(&bundle, &packs).unwrap();
2659        assert_eq!(updates, 1);
2660        let raw = std::fs::read_to_string(bundle.join("providers").join("providers.json")).unwrap();
2661        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2662        assert_eq!(parsed["top_level"], "keep");
2663        assert_eq!(parsed["providers"][0]["id"], "messaging.telegram");
2664        assert_eq!(
2665            parsed["providers"][0]["ref"],
2666            "oci://ghcr.io/greentic/providers/messaging-telegram@0.6.0"
2667        );
2668        assert_eq!(parsed["providers"][0]["enabled"], true);
2669        assert_eq!(parsed["providers"][0]["extra"], "keep");
2670    }
2671
2672    #[test]
2673    fn local_pack_ref_detection_supports_path_and_file_scheme() {
2674        let temp = tempfile::tempdir().unwrap();
2675        let pack = temp.path().join("sample.gtpack");
2676        std::fs::write(&pack, "pack").unwrap();
2677
2678        let direct = parse_local_pack_ref(pack.to_string_lossy().as_ref());
2679        assert_eq!(direct, Some(pack.clone()));
2680
2681        let file_ref = format!("file://{}", pack.display());
2682        let scheme = parse_local_pack_ref(&file_ref);
2683        assert_eq!(scheme, Some(pack));
2684    }
2685
2686    #[test]
2687    fn normalize_request_resolves_pack_ref_to_pack_id() {
2688        let temp = tempfile::tempdir().unwrap();
2689        let bundle = temp.path().join("bundle");
2690        std::fs::create_dir_all(bundle.join(".greentic")).unwrap();
2691        std::fs::write(
2692            bundle.join(".greentic").join("packs.json"),
2693            r#"{
2694  "packs": [
2695    {
2696      "pack_id": "sales-0_6_0",
2697      "original_ref": "oci://ghcr.io/greentic/packs/sales@0.6.0",
2698      "local_path_in_bundle": "packs/sales-0_6_0.gtpack",
2699      "digest": "sha256:abc"
2700    }
2701  ]
2702}"#,
2703        )
2704        .unwrap();
2705        let request = WizardCreateRequest {
2706            bundle: bundle.clone(),
2707            bundle_name: None,
2708            pack_refs: Vec::new(),
2709            tenants: Vec::new(),
2710            default_assignments: Vec::new(),
2711            providers: Vec::new(),
2712            update_ops: [WizardUpdateOp::AccessChange].into_iter().collect(),
2713            remove_targets: BTreeSet::new(),
2714            packs_remove: vec![PackRemoveSelection {
2715                pack_identifier: "oci://ghcr.io/greentic/packs/sales@0.6.0".to_string(),
2716                scope: None,
2717            }],
2718            providers_remove: Vec::new(),
2719            tenants_remove: Vec::new(),
2720            access_changes: vec![AccessChangeSelection {
2721                pack_id: "oci://ghcr.io/greentic/packs/sales@0.6.0".to_string(),
2722                operation: AccessOperation::AllowAdd,
2723                tenant_id: "demo".to_string(),
2724                team_id: None,
2725            }],
2726            setup_answers: serde_json::Map::new(),
2727        };
2728        let normalized = normalize_request_for_plan(&request).unwrap();
2729        assert_eq!(normalized.packs_remove[0].pack_identifier, "sales-0_6_0");
2730        assert_eq!(normalized.access_changes[0].pack_id, "sales-0_6_0");
2731    }
2732
2733    #[test]
2734    fn remove_pack_already_absent_is_idempotent_warning() {
2735        let temp = tempfile::tempdir().unwrap();
2736        let bundle = temp.path().join("bundle");
2737        std::fs::create_dir_all(&bundle).unwrap();
2738        create_demo_bundle_structure(&bundle, None).unwrap();
2739        let request = WizardCreateRequest {
2740            bundle: bundle.clone(),
2741            bundle_name: None,
2742            pack_refs: Vec::new(),
2743            tenants: Vec::new(),
2744            default_assignments: Vec::new(),
2745            providers: Vec::new(),
2746            update_ops: BTreeSet::new(),
2747            remove_targets: [WizardRemoveTarget::Packs].into_iter().collect(),
2748            packs_remove: vec![PackRemoveSelection {
2749                pack_identifier: "missing-pack".to_string(),
2750                scope: None,
2751            }],
2752            providers_remove: Vec::new(),
2753            tenants_remove: Vec::new(),
2754            access_changes: Vec::new(),
2755            setup_answers: serde_json::Map::new(),
2756        };
2757        let plan = apply_remove(&request, false).unwrap();
2758        let report = execute_remove_plan(&plan).unwrap();
2759        assert_eq!(report.provider_updates, 0);
2760        assert!(!report.warnings.is_empty());
2761    }
2762
2763    #[test]
2764    fn update_applies_global_default_assignment_and_bundle_name_written() {
2765        let temp = tempfile::tempdir().unwrap();
2766        let bundle = temp.path().join("demo-bundle");
2767        let create_request = WizardCreateRequest {
2768            bundle: bundle.clone(),
2769            bundle_name: Some("Demo Bundle".to_string()),
2770            pack_refs: Vec::new(),
2771            tenants: vec![TenantSelection {
2772                tenant: "demo".to_string(),
2773                team: Some("default".to_string()),
2774                allow_paths: vec!["packs/default".to_string()],
2775            }],
2776            default_assignments: Vec::new(),
2777            providers: Vec::new(),
2778            update_ops: BTreeSet::new(),
2779            remove_targets: BTreeSet::new(),
2780            packs_remove: Vec::new(),
2781            providers_remove: Vec::new(),
2782            tenants_remove: Vec::new(),
2783            access_changes: Vec::new(),
2784            setup_answers: serde_json::Map::new(),
2785        };
2786        let create_plan = apply_create(&create_request, false).unwrap();
2787        let _create_report = execute_create_plan(&create_plan, true).unwrap();
2788        std::fs::create_dir_all(bundle.join("packs")).unwrap();
2789        std::fs::write(bundle.join("packs").join("sales.gtpack"), "dummy").unwrap();
2790
2791        let update_request = WizardCreateRequest {
2792            bundle: bundle.clone(),
2793            bundle_name: None,
2794            pack_refs: Vec::new(),
2795            tenants: vec![TenantSelection {
2796                tenant: "demo".to_string(),
2797                team: Some("default".to_string()),
2798                allow_paths: vec!["packs/default".to_string()],
2799            }],
2800            default_assignments: vec![PackDefaultSelection {
2801                pack_identifier: "sales".to_string(),
2802                scope: PackScope::Global,
2803            }],
2804            providers: Vec::new(),
2805            update_ops: BTreeSet::new(),
2806            remove_targets: BTreeSet::new(),
2807            packs_remove: Vec::new(),
2808            providers_remove: Vec::new(),
2809            tenants_remove: Vec::new(),
2810            access_changes: Vec::new(),
2811            setup_answers: serde_json::Map::new(),
2812        };
2813        let update_plan = apply_update(&update_request, false).unwrap();
2814        let _report = execute_update_plan(&update_plan, true).unwrap();
2815        let default_raw = std::fs::read_to_string(bundle.join("default.gtpack")).unwrap();
2816        assert!(default_raw.contains("packs/sales.gtpack"));
2817        let demo_yaml = std::fs::read_to_string(bundle.join("greentic.demo.yaml")).unwrap();
2818        assert!(demo_yaml.contains("bundle_name: \"Demo Bundle\""));
2819    }
2820}