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 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 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 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 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
1422fn 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
1452fn 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 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 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, 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
1516fn find_provider_pack_path(bundle: &Path, provider_id: &str) -> Option<std::path::PathBuf> {
1518 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
1529fn 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 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 ®istry_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(®istry_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}