1use std::collections::BTreeSet;
6
7use anyhow::anyhow;
8
9use crate::plan::*;
10use crate::setup_input::SetupQuestion;
11use serde_json::Value;
12
13use super::types::SetupRequest;
14
15pub fn apply_create(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
17 if request.tenants.is_empty() {
18 return Err(anyhow!("at least one tenant selection is required"));
19 }
20
21 let pack_refs = dedup_sorted(&request.pack_refs);
22 let tenants = normalize_tenants(&request.tenants);
23
24 let mut steps = Vec::new();
25 if !pack_refs.is_empty() {
26 steps.push(step(
27 SetupStepKind::ResolvePacks,
28 "Resolve selected pack refs via distributor client",
29 [("count", pack_refs.len().to_string())],
30 ));
31 } else {
32 steps.push(step(
33 SetupStepKind::NoOp,
34 "No pack refs selected; skipping pack resolution",
35 [("reason", "empty_pack_refs".to_string())],
36 ));
37 }
38 steps.push(step(
39 SetupStepKind::CreateBundle,
40 "Create demo bundle scaffold using existing conventions",
41 [("bundle", request.bundle.display().to_string())],
42 ));
43 if !pack_refs.is_empty() {
44 steps.push(step(
45 SetupStepKind::AddPacksToBundle,
46 "Copy fetched packs into bundle/packs",
47 [("count", pack_refs.len().to_string())],
48 ));
49 steps.push(step(
50 SetupStepKind::ValidateCapabilities,
51 "Validate provider packs have capabilities extension",
52 [("check", "greentic.ext.capabilities.v1".to_string())],
53 ));
54 steps.push(step(
55 SetupStepKind::ApplyPackSetup,
56 "Apply pack-declared setup outputs through internal setup hooks",
57 [("status", "planned".to_string())],
58 ));
59 } else if !request.setup_answers.is_empty() {
60 steps.push(step(
62 SetupStepKind::ValidateCapabilities,
63 "Validate provider packs have capabilities extension",
64 [("check", "greentic.ext.capabilities.v1".to_string())],
65 ));
66 steps.push(step(
67 SetupStepKind::ApplyPackSetup,
68 "Apply setup answers to existing bundle packs",
69 [("providers", request.setup_answers.len().to_string())],
70 ));
71 } else {
72 steps.push(step(
73 SetupStepKind::NoOp,
74 "No fetched packs to add or setup",
75 [("reason", "empty_pack_refs".to_string())],
76 ));
77 }
78 steps.push(step(
79 SetupStepKind::WriteGmapRules,
80 "Write tenant/team allow rules to gmap",
81 [("targets", tenants.len().to_string())],
82 ));
83 steps.push(step(
84 SetupStepKind::RunResolver,
85 "Run resolver pipeline (same as demo allow)",
86 [("resolver", "project::sync_project".to_string())],
87 ));
88 steps.push(step(
89 SetupStepKind::CopyResolvedManifest,
90 "Copy state/resolved manifests into resolved/ for demo start",
91 [("targets", tenants.len().to_string())],
92 ));
93 steps.push(step(
94 SetupStepKind::ValidateBundle,
95 "Validate bundle is loadable by internal demo pipeline",
96 [("check", "resolved manifests present".to_string())],
97 ));
98
99 Ok(SetupPlan {
100 mode: "create".to_string(),
101 dry_run,
102 bundle: request.bundle.clone(),
103 steps,
104 metadata: build_metadata(request, pack_refs, tenants),
105 })
106}
107
108pub fn apply_update(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
110 let pack_refs = dedup_sorted(&request.pack_refs);
111 let tenants = normalize_tenants(&request.tenants);
112
113 let mut ops = request.update_ops.clone();
114 if ops.is_empty() {
115 infer_update_ops(&mut ops, &pack_refs, request, &tenants);
116 }
117
118 let mut steps = vec![step(
119 SetupStepKind::ValidateBundle,
120 "Validate target bundle exists before update",
121 [("mode", "update".to_string())],
122 )];
123
124 if ops.is_empty() {
125 steps.push(step(
126 SetupStepKind::NoOp,
127 "No update operations selected",
128 [("reason", "empty_update_ops".to_string())],
129 ));
130 }
131 if ops.contains(&UpdateOp::PacksAdd) {
132 if pack_refs.is_empty() {
133 steps.push(step(
134 SetupStepKind::NoOp,
135 "packs_add selected without pack refs",
136 [("reason", "empty_pack_refs".to_string())],
137 ));
138 } else {
139 steps.push(step(
140 SetupStepKind::ResolvePacks,
141 "Resolve selected pack refs via distributor client",
142 [("count", pack_refs.len().to_string())],
143 ));
144 steps.push(step(
145 SetupStepKind::AddPacksToBundle,
146 "Copy fetched packs into bundle/packs",
147 [("count", pack_refs.len().to_string())],
148 ));
149 }
150 }
151 if ops.contains(&UpdateOp::PacksRemove) {
152 if request.packs_remove.is_empty() {
153 steps.push(step(
154 SetupStepKind::NoOp,
155 "packs_remove selected without targets",
156 [("reason", "empty_packs_remove".to_string())],
157 ));
158 } else {
159 steps.push(step(
160 SetupStepKind::AddPacksToBundle,
161 "Remove pack artifacts/default links from bundle",
162 [("count", request.packs_remove.len().to_string())],
163 ));
164 }
165 }
166 if ops.contains(&UpdateOp::ProvidersAdd) {
167 if request.providers.is_empty() && pack_refs.is_empty() {
168 steps.push(step(
169 SetupStepKind::NoOp,
170 "providers_add selected without providers or new packs",
171 [("reason", "empty_providers_add".to_string())],
172 ));
173 } else {
174 steps.push(step(
175 SetupStepKind::ApplyPackSetup,
176 "Enable providers in providers/providers.json",
177 [("count", request.providers.len().to_string())],
178 ));
179 }
180 }
181 if ops.contains(&UpdateOp::ProvidersRemove) {
182 if request.providers_remove.is_empty() {
183 steps.push(step(
184 SetupStepKind::NoOp,
185 "providers_remove selected without providers",
186 [("reason", "empty_providers_remove".to_string())],
187 ));
188 } else {
189 steps.push(step(
190 SetupStepKind::ApplyPackSetup,
191 "Disable/remove providers in providers/providers.json",
192 [("count", request.providers_remove.len().to_string())],
193 ));
194 }
195 }
196 if ops.contains(&UpdateOp::TenantsAdd) {
197 if tenants.is_empty() {
198 steps.push(step(
199 SetupStepKind::NoOp,
200 "tenants_add selected without tenant targets",
201 [("reason", "empty_tenants_add".to_string())],
202 ));
203 } else {
204 steps.push(step(
205 SetupStepKind::WriteGmapRules,
206 "Ensure tenant/team directories and allow rules",
207 [("targets", tenants.len().to_string())],
208 ));
209 }
210 }
211 if ops.contains(&UpdateOp::TenantsRemove) {
212 if request.tenants_remove.is_empty() {
213 steps.push(step(
214 SetupStepKind::NoOp,
215 "tenants_remove selected without tenant targets",
216 [("reason", "empty_tenants_remove".to_string())],
217 ));
218 } else {
219 steps.push(step(
220 SetupStepKind::WriteGmapRules,
221 "Remove tenant/team directories and related rules",
222 [("targets", request.tenants_remove.len().to_string())],
223 ));
224 }
225 }
226 if ops.contains(&UpdateOp::AccessChange) {
227 let access_count = request.access_changes.len()
228 + tenants.iter().filter(|t| !t.allow_paths.is_empty()).count();
229 if access_count == 0 {
230 steps.push(step(
231 SetupStepKind::NoOp,
232 "access_change selected without mutations",
233 [("reason", "empty_access_changes".to_string())],
234 ));
235 } else {
236 steps.push(step(
237 SetupStepKind::WriteGmapRules,
238 "Apply access rule updates",
239 [("changes", access_count.to_string())],
240 ));
241 steps.push(step(
242 SetupStepKind::RunResolver,
243 "Run resolver pipeline (same as demo allow/forbid)",
244 [("resolver", "project::sync_project".to_string())],
245 ));
246 steps.push(step(
247 SetupStepKind::CopyResolvedManifest,
248 "Copy state/resolved manifests into resolved/ for demo start",
249 [("targets", tenants.len().to_string())],
250 ));
251 }
252 }
253 steps.push(step(
254 SetupStepKind::ValidateBundle,
255 "Validate bundle is loadable by internal demo pipeline",
256 [("check", "resolved manifests present".to_string())],
257 ));
258
259 Ok(SetupPlan {
260 mode: SetupMode::Update.as_str().to_string(),
261 dry_run,
262 bundle: request.bundle.clone(),
263 steps,
264 metadata: build_metadata_with_ops(request, pack_refs, tenants, ops),
265 })
266}
267
268pub fn apply_remove(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
270 let tenants = normalize_tenants(&request.tenants);
271
272 let mut targets = request.remove_targets.clone();
273 if targets.is_empty() {
274 if !request.packs_remove.is_empty() {
275 targets.insert(RemoveTarget::Packs);
276 }
277 if !request.providers_remove.is_empty() {
278 targets.insert(RemoveTarget::Providers);
279 }
280 if !request.tenants_remove.is_empty() {
281 targets.insert(RemoveTarget::TenantsTeams);
282 }
283 }
284
285 let mut steps = vec![step(
286 SetupStepKind::ValidateBundle,
287 "Validate target bundle exists before remove",
288 [("mode", "remove".to_string())],
289 )];
290
291 if targets.is_empty() {
292 steps.push(step(
293 SetupStepKind::NoOp,
294 "No remove targets selected",
295 [("reason", "empty_remove_targets".to_string())],
296 ));
297 }
298 if targets.contains(&RemoveTarget::Packs) {
299 if request.packs_remove.is_empty() {
300 steps.push(step(
301 SetupStepKind::NoOp,
302 "packs target selected without pack identifiers",
303 [("reason", "empty_packs_remove".to_string())],
304 ));
305 } else {
306 steps.push(step(
307 SetupStepKind::AddPacksToBundle,
308 "Delete pack files/default links from bundle",
309 [("count", request.packs_remove.len().to_string())],
310 ));
311 }
312 }
313 if targets.contains(&RemoveTarget::Providers) {
314 if request.providers_remove.is_empty() {
315 steps.push(step(
316 SetupStepKind::NoOp,
317 "providers target selected without provider ids",
318 [("reason", "empty_providers_remove".to_string())],
319 ));
320 } else {
321 steps.push(step(
322 SetupStepKind::ApplyPackSetup,
323 "Remove provider entries from providers/providers.json",
324 [("count", request.providers_remove.len().to_string())],
325 ));
326 }
327 }
328 if targets.contains(&RemoveTarget::TenantsTeams) {
329 if request.tenants_remove.is_empty() {
330 steps.push(step(
331 SetupStepKind::NoOp,
332 "tenants_teams target selected without tenant/team ids",
333 [("reason", "empty_tenants_remove".to_string())],
334 ));
335 } else {
336 steps.push(step(
337 SetupStepKind::WriteGmapRules,
338 "Delete tenant/team directories and access rules",
339 [("count", request.tenants_remove.len().to_string())],
340 ));
341 steps.push(step(
342 SetupStepKind::RunResolver,
343 "Run resolver pipeline after tenant/team removals",
344 [("resolver", "project::sync_project".to_string())],
345 ));
346 steps.push(step(
347 SetupStepKind::CopyResolvedManifest,
348 "Copy state/resolved manifests into resolved/ for demo start",
349 [("targets", tenants.len().to_string())],
350 ));
351 }
352 }
353 steps.push(step(
354 SetupStepKind::ValidateBundle,
355 "Validate bundle is loadable by internal demo pipeline",
356 [("check", "resolved manifests present".to_string())],
357 ));
358
359 Ok(SetupPlan {
360 mode: SetupMode::Remove.as_str().to_string(),
361 dry_run,
362 bundle: request.bundle.clone(),
363 steps,
364 metadata: SetupPlanMetadata {
365 bundle_name: request.bundle_name.clone(),
366 pack_refs: Vec::new(),
367 tenants,
368 default_assignments: request.default_assignments.clone(),
369 providers: request.providers.clone(),
370 update_ops: request.update_ops.clone(),
371 remove_targets: targets,
372 packs_remove: request.packs_remove.clone(),
373 providers_remove: request.providers_remove.clone(),
374 tenants_remove: request.tenants_remove.clone(),
375 access_changes: request.access_changes.clone(),
376 static_routes: request.static_routes.clone(),
377 deployment_targets: request.deployment_targets.clone(),
378 setup_answers: request.setup_answers.clone(),
379 },
380 })
381}
382
383pub fn print_plan_summary(plan: &SetupPlan) {
385 println!("wizard plan: mode={} dry_run={}", plan.mode, plan.dry_run);
386 println!("bundle: {}", plan.bundle.display());
387 let noop_count = plan
388 .steps
389 .iter()
390 .filter(|s| s.kind == SetupStepKind::NoOp)
391 .count();
392 if noop_count > 0 {
393 println!("no-op steps: {noop_count}");
394 }
395 for (index, s) in plan.steps.iter().enumerate() {
396 println!("{}. {}", index + 1, s.description);
397 }
398}
399
400pub fn dedup_sorted(refs: &[String]) -> Vec<String> {
404 let mut v: Vec<String> = refs
405 .iter()
406 .map(|r| r.trim().to_string())
407 .filter(|r| !r.is_empty())
408 .collect();
409 v.sort();
410 v.dedup();
411 v
412}
413
414pub fn normalize_tenants(tenants: &[TenantSelection]) -> Vec<TenantSelection> {
416 let mut result: Vec<TenantSelection> = tenants
417 .iter()
418 .map(|t| {
419 let mut t = t.clone();
420 t.allow_paths.sort();
421 t.allow_paths.dedup();
422 t
423 })
424 .collect();
425 result.sort_by(|a, b| {
426 a.tenant
427 .cmp(&b.tenant)
428 .then_with(|| a.team.cmp(&b.team))
429 .then_with(|| a.allow_paths.cmp(&b.allow_paths))
430 });
431 result
432}
433
434pub fn infer_update_ops(
436 ops: &mut BTreeSet<UpdateOp>,
437 pack_refs: &[String],
438 request: &SetupRequest,
439 tenants: &[TenantSelection],
440) {
441 if !pack_refs.is_empty() {
442 ops.insert(UpdateOp::PacksAdd);
443 }
444 if !request.providers.is_empty() {
445 ops.insert(UpdateOp::ProvidersAdd);
446 }
447 if !request.providers_remove.is_empty() {
448 ops.insert(UpdateOp::ProvidersRemove);
449 }
450 if !request.packs_remove.is_empty() {
451 ops.insert(UpdateOp::PacksRemove);
452 }
453 if !tenants.is_empty() {
454 ops.insert(UpdateOp::TenantsAdd);
455 }
456 if !request.tenants_remove.is_empty() {
457 ops.insert(UpdateOp::TenantsRemove);
458 }
459 if !request.access_changes.is_empty() || tenants.iter().any(|t| !t.allow_paths.is_empty()) {
460 ops.insert(UpdateOp::AccessChange);
461 }
462}
463
464pub fn build_metadata(
466 request: &SetupRequest,
467 pack_refs: Vec<String>,
468 tenants: Vec<TenantSelection>,
469) -> SetupPlanMetadata {
470 SetupPlanMetadata {
471 bundle_name: request.bundle_name.clone(),
472 pack_refs,
473 tenants,
474 default_assignments: request.default_assignments.clone(),
475 providers: request.providers.clone(),
476 update_ops: request.update_ops.clone(),
477 remove_targets: request.remove_targets.clone(),
478 packs_remove: request.packs_remove.clone(),
479 providers_remove: request.providers_remove.clone(),
480 tenants_remove: request.tenants_remove.clone(),
481 access_changes: request.access_changes.clone(),
482 static_routes: request.static_routes.clone(),
483 deployment_targets: request.deployment_targets.clone(),
484 setup_answers: request.setup_answers.clone(),
485 }
486}
487
488pub fn build_metadata_with_ops(
490 request: &SetupRequest,
491 pack_refs: Vec<String>,
492 tenants: Vec<TenantSelection>,
493 ops: BTreeSet<UpdateOp>,
494) -> SetupPlanMetadata {
495 let mut meta = build_metadata(request, pack_refs, tenants);
496 meta.update_ops = ops;
497 meta
498}
499
500pub fn compute_simple_hash(input: &str) -> String {
502 use std::collections::hash_map::DefaultHasher;
503 use std::hash::{Hash, Hasher};
504
505 let mut hasher = DefaultHasher::new();
506 input.hash(&mut hasher);
507 format!("{:016x}", hasher.finish())
508}
509
510pub fn infer_default_value(question: &SetupQuestion) -> Value {
517 if let Some(default) = question.default.clone() {
519 return default;
520 }
521
522 if let Some(ref help) = question.help
525 && let Some(default) = extract_default_from_help(help)
526 {
527 return Value::String(default);
528 }
529
530 Value::String(String::new())
532}
533
534pub fn extract_default_from_help(help: &str) -> Option<String> {
541 use regex::Regex;
542
543 let re = Regex::new(r"(?i)[\(\[]?\s*default:\s*([^\)\]\n,]+)\s*[\)\]]?").ok()?;
545 if let Some(caps) = re.captures(help) {
546 let value = caps.get(1)?.as_str().trim();
547 let cleaned = value.trim_end_matches(|c: char| c == '.' || c == ',' || c.is_whitespace());
549 if !cleaned.is_empty() {
550 return Some(cleaned.to_string());
551 }
552 }
553
554 None
555}