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 steps.push(step(
99 SetupStepKind::BuildFlowIndex,
100 "Build fast2flow routing indexes and intents.md",
101 [("output", "state/indexes/".to_string())],
102 ));
103
104 Ok(SetupPlan {
105 mode: "create".to_string(),
106 dry_run,
107 bundle: request.bundle.clone(),
108 steps,
109 metadata: build_metadata(request, pack_refs, tenants),
110 })
111}
112
113pub fn apply_update(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
115 let pack_refs = dedup_sorted(&request.pack_refs);
116 let tenants = normalize_tenants(&request.tenants);
117
118 let mut ops = request.update_ops.clone();
119 if ops.is_empty() {
120 infer_update_ops(&mut ops, &pack_refs, request, &tenants);
121 }
122
123 let mut steps = vec![step(
124 SetupStepKind::ValidateBundle,
125 "Validate target bundle exists before update",
126 [("mode", "update".to_string())],
127 )];
128
129 if ops.is_empty() {
130 steps.push(step(
131 SetupStepKind::NoOp,
132 "No update operations selected",
133 [("reason", "empty_update_ops".to_string())],
134 ));
135 }
136 if ops.contains(&UpdateOp::PacksAdd) {
137 if pack_refs.is_empty() {
138 steps.push(step(
139 SetupStepKind::NoOp,
140 "packs_add selected without pack refs",
141 [("reason", "empty_pack_refs".to_string())],
142 ));
143 } else {
144 steps.push(step(
145 SetupStepKind::ResolvePacks,
146 "Resolve selected pack refs via distributor client",
147 [("count", pack_refs.len().to_string())],
148 ));
149 steps.push(step(
150 SetupStepKind::AddPacksToBundle,
151 "Copy fetched packs into bundle/packs",
152 [("count", pack_refs.len().to_string())],
153 ));
154 }
155 }
156 if ops.contains(&UpdateOp::PacksRemove) {
157 if request.packs_remove.is_empty() {
158 steps.push(step(
159 SetupStepKind::NoOp,
160 "packs_remove selected without targets",
161 [("reason", "empty_packs_remove".to_string())],
162 ));
163 } else {
164 steps.push(step(
165 SetupStepKind::AddPacksToBundle,
166 "Remove pack artifacts/default links from bundle",
167 [("count", request.packs_remove.len().to_string())],
168 ));
169 }
170 }
171 if ops.contains(&UpdateOp::ProvidersAdd) {
172 if request.providers.is_empty() && pack_refs.is_empty() {
173 steps.push(step(
174 SetupStepKind::NoOp,
175 "providers_add selected without providers or new packs",
176 [("reason", "empty_providers_add".to_string())],
177 ));
178 } else {
179 steps.push(step(
180 SetupStepKind::ApplyPackSetup,
181 "Enable providers in providers/providers.json",
182 [("count", request.providers.len().to_string())],
183 ));
184 }
185 }
186 if ops.contains(&UpdateOp::ProvidersRemove) {
187 if request.providers_remove.is_empty() {
188 steps.push(step(
189 SetupStepKind::NoOp,
190 "providers_remove selected without providers",
191 [("reason", "empty_providers_remove".to_string())],
192 ));
193 } else {
194 steps.push(step(
195 SetupStepKind::ApplyPackSetup,
196 "Disable/remove providers in providers/providers.json",
197 [("count", request.providers_remove.len().to_string())],
198 ));
199 }
200 }
201 if ops.contains(&UpdateOp::TenantsAdd) {
202 if tenants.is_empty() {
203 steps.push(step(
204 SetupStepKind::NoOp,
205 "tenants_add selected without tenant targets",
206 [("reason", "empty_tenants_add".to_string())],
207 ));
208 } else {
209 steps.push(step(
210 SetupStepKind::WriteGmapRules,
211 "Ensure tenant/team directories and allow rules",
212 [("targets", tenants.len().to_string())],
213 ));
214 }
215 }
216 if ops.contains(&UpdateOp::TenantsRemove) {
217 if request.tenants_remove.is_empty() {
218 steps.push(step(
219 SetupStepKind::NoOp,
220 "tenants_remove selected without tenant targets",
221 [("reason", "empty_tenants_remove".to_string())],
222 ));
223 } else {
224 steps.push(step(
225 SetupStepKind::WriteGmapRules,
226 "Remove tenant/team directories and related rules",
227 [("targets", request.tenants_remove.len().to_string())],
228 ));
229 }
230 }
231 if ops.contains(&UpdateOp::AccessChange) {
232 let access_count = request.access_changes.len()
233 + tenants.iter().filter(|t| !t.allow_paths.is_empty()).count();
234 if access_count == 0 {
235 steps.push(step(
236 SetupStepKind::NoOp,
237 "access_change selected without mutations",
238 [("reason", "empty_access_changes".to_string())],
239 ));
240 } else {
241 steps.push(step(
242 SetupStepKind::WriteGmapRules,
243 "Apply access rule updates",
244 [("changes", access_count.to_string())],
245 ));
246 steps.push(step(
247 SetupStepKind::RunResolver,
248 "Run resolver pipeline (same as demo allow/forbid)",
249 [("resolver", "project::sync_project".to_string())],
250 ));
251 steps.push(step(
252 SetupStepKind::CopyResolvedManifest,
253 "Copy state/resolved manifests into resolved/ for demo start",
254 [("targets", tenants.len().to_string())],
255 ));
256 }
257 }
258 steps.push(step(
259 SetupStepKind::ValidateBundle,
260 "Validate bundle is loadable by internal demo pipeline",
261 [("check", "resolved manifests present".to_string())],
262 ));
263 steps.push(step(
264 SetupStepKind::BuildFlowIndex,
265 "Rebuild fast2flow routing indexes after update",
266 [("output", "state/indexes/".to_string())],
267 ));
268
269 Ok(SetupPlan {
270 mode: SetupMode::Update.as_str().to_string(),
271 dry_run,
272 bundle: request.bundle.clone(),
273 steps,
274 metadata: build_metadata_with_ops(request, pack_refs, tenants, ops),
275 })
276}
277
278pub fn apply_remove(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
280 let tenants = normalize_tenants(&request.tenants);
281
282 let mut targets = request.remove_targets.clone();
283 if targets.is_empty() {
284 if !request.packs_remove.is_empty() {
285 targets.insert(RemoveTarget::Packs);
286 }
287 if !request.providers_remove.is_empty() {
288 targets.insert(RemoveTarget::Providers);
289 }
290 if !request.tenants_remove.is_empty() {
291 targets.insert(RemoveTarget::TenantsTeams);
292 }
293 }
294
295 let mut steps = vec![step(
296 SetupStepKind::ValidateBundle,
297 "Validate target bundle exists before remove",
298 [("mode", "remove".to_string())],
299 )];
300
301 if targets.is_empty() {
302 steps.push(step(
303 SetupStepKind::NoOp,
304 "No remove targets selected",
305 [("reason", "empty_remove_targets".to_string())],
306 ));
307 }
308 if targets.contains(&RemoveTarget::Packs) {
309 if request.packs_remove.is_empty() {
310 steps.push(step(
311 SetupStepKind::NoOp,
312 "packs target selected without pack identifiers",
313 [("reason", "empty_packs_remove".to_string())],
314 ));
315 } else {
316 steps.push(step(
317 SetupStepKind::AddPacksToBundle,
318 "Delete pack files/default links from bundle",
319 [("count", request.packs_remove.len().to_string())],
320 ));
321 }
322 }
323 if targets.contains(&RemoveTarget::Providers) {
324 if request.providers_remove.is_empty() {
325 steps.push(step(
326 SetupStepKind::NoOp,
327 "providers target selected without provider ids",
328 [("reason", "empty_providers_remove".to_string())],
329 ));
330 } else {
331 steps.push(step(
332 SetupStepKind::ApplyPackSetup,
333 "Remove provider entries from providers/providers.json",
334 [("count", request.providers_remove.len().to_string())],
335 ));
336 }
337 }
338 if targets.contains(&RemoveTarget::TenantsTeams) {
339 if request.tenants_remove.is_empty() {
340 steps.push(step(
341 SetupStepKind::NoOp,
342 "tenants_teams target selected without tenant/team ids",
343 [("reason", "empty_tenants_remove".to_string())],
344 ));
345 } else {
346 steps.push(step(
347 SetupStepKind::WriteGmapRules,
348 "Delete tenant/team directories and access rules",
349 [("count", request.tenants_remove.len().to_string())],
350 ));
351 steps.push(step(
352 SetupStepKind::RunResolver,
353 "Run resolver pipeline after tenant/team removals",
354 [("resolver", "project::sync_project".to_string())],
355 ));
356 steps.push(step(
357 SetupStepKind::CopyResolvedManifest,
358 "Copy state/resolved manifests into resolved/ for demo start",
359 [("targets", tenants.len().to_string())],
360 ));
361 }
362 }
363 steps.push(step(
364 SetupStepKind::ValidateBundle,
365 "Validate bundle is loadable by internal demo pipeline",
366 [("check", "resolved manifests present".to_string())],
367 ));
368
369 Ok(SetupPlan {
370 mode: SetupMode::Remove.as_str().to_string(),
371 dry_run,
372 bundle: request.bundle.clone(),
373 steps,
374 metadata: SetupPlanMetadata {
375 bundle_name: request.bundle_name.clone(),
376 pack_refs: Vec::new(),
377 tenants,
378 default_assignments: request.default_assignments.clone(),
379 providers: request.providers.clone(),
380 update_ops: request.update_ops.clone(),
381 remove_targets: targets,
382 packs_remove: request.packs_remove.clone(),
383 providers_remove: request.providers_remove.clone(),
384 tenants_remove: request.tenants_remove.clone(),
385 access_changes: request.access_changes.clone(),
386 static_routes: request.static_routes.clone(),
387 deployment_targets: request.deployment_targets.clone(),
388 setup_answers: request.setup_answers.clone(),
389 },
390 })
391}
392
393pub fn print_plan_summary(plan: &SetupPlan) {
395 println!("wizard plan: mode={} dry_run={}", plan.mode, plan.dry_run);
396 println!("bundle: {}", plan.bundle.display());
397 let noop_count = plan
398 .steps
399 .iter()
400 .filter(|s| s.kind == SetupStepKind::NoOp)
401 .count();
402 if noop_count > 0 {
403 println!("no-op steps: {noop_count}");
404 }
405 for (index, s) in plan.steps.iter().enumerate() {
406 println!("{}. {}", index + 1, s.description);
407 }
408}
409
410pub fn dedup_sorted(refs: &[String]) -> Vec<String> {
414 let mut v: Vec<String> = refs
415 .iter()
416 .map(|r| r.trim().to_string())
417 .filter(|r| !r.is_empty())
418 .collect();
419 v.sort();
420 v.dedup();
421 v
422}
423
424pub fn normalize_tenants(tenants: &[TenantSelection]) -> Vec<TenantSelection> {
426 let mut result: Vec<TenantSelection> = tenants
427 .iter()
428 .map(|t| {
429 let mut t = t.clone();
430 t.allow_paths.sort();
431 t.allow_paths.dedup();
432 t
433 })
434 .collect();
435 result.sort_by(|a, b| {
436 a.tenant
437 .cmp(&b.tenant)
438 .then_with(|| a.team.cmp(&b.team))
439 .then_with(|| a.allow_paths.cmp(&b.allow_paths))
440 });
441 result
442}
443
444pub fn infer_update_ops(
446 ops: &mut BTreeSet<UpdateOp>,
447 pack_refs: &[String],
448 request: &SetupRequest,
449 tenants: &[TenantSelection],
450) {
451 if !pack_refs.is_empty() {
452 ops.insert(UpdateOp::PacksAdd);
453 }
454 if !request.providers.is_empty() {
455 ops.insert(UpdateOp::ProvidersAdd);
456 }
457 if !request.providers_remove.is_empty() {
458 ops.insert(UpdateOp::ProvidersRemove);
459 }
460 if !request.packs_remove.is_empty() {
461 ops.insert(UpdateOp::PacksRemove);
462 }
463 if !tenants.is_empty() {
464 ops.insert(UpdateOp::TenantsAdd);
465 }
466 if !request.tenants_remove.is_empty() {
467 ops.insert(UpdateOp::TenantsRemove);
468 }
469 if !request.access_changes.is_empty() || tenants.iter().any(|t| !t.allow_paths.is_empty()) {
470 ops.insert(UpdateOp::AccessChange);
471 }
472}
473
474pub fn build_metadata(
476 request: &SetupRequest,
477 pack_refs: Vec<String>,
478 tenants: Vec<TenantSelection>,
479) -> SetupPlanMetadata {
480 SetupPlanMetadata {
481 bundle_name: request.bundle_name.clone(),
482 pack_refs,
483 tenants,
484 default_assignments: request.default_assignments.clone(),
485 providers: request.providers.clone(),
486 update_ops: request.update_ops.clone(),
487 remove_targets: request.remove_targets.clone(),
488 packs_remove: request.packs_remove.clone(),
489 providers_remove: request.providers_remove.clone(),
490 tenants_remove: request.tenants_remove.clone(),
491 access_changes: request.access_changes.clone(),
492 static_routes: request.static_routes.clone(),
493 deployment_targets: request.deployment_targets.clone(),
494 setup_answers: request.setup_answers.clone(),
495 }
496}
497
498pub fn build_metadata_with_ops(
500 request: &SetupRequest,
501 pack_refs: Vec<String>,
502 tenants: Vec<TenantSelection>,
503 ops: BTreeSet<UpdateOp>,
504) -> SetupPlanMetadata {
505 let mut meta = build_metadata(request, pack_refs, tenants);
506 meta.update_ops = ops;
507 meta
508}
509
510pub fn compute_simple_hash(input: &str) -> String {
512 use std::collections::hash_map::DefaultHasher;
513 use std::hash::{Hash, Hasher};
514
515 let mut hasher = DefaultHasher::new();
516 input.hash(&mut hasher);
517 format!("{:016x}", hasher.finish())
518}
519
520pub fn infer_default_value(question: &SetupQuestion) -> Value {
527 if let Some(default) = question.default.clone() {
529 return default;
530 }
531
532 if let Some(ref help) = question.help
535 && let Some(default) = extract_default_from_help(help)
536 {
537 return Value::String(default);
538 }
539
540 Value::String(String::new())
542}
543
544pub fn extract_default_from_help(help: &str) -> Option<String> {
551 use regex::Regex;
552
553 let re = Regex::new(r"(?i)[\(\[]?\s*default:\s*([^\)\]\n,]+)\s*[\)\]]?").ok()?;
555 if let Some(caps) = re.captures(help) {
556 let value = caps.get(1)?.as_str().trim();
557 let cleaned = value.trim_end_matches(|c: char| c == '.' || c == ',' || c.is_whitespace());
559 if !cleaned.is_empty() {
560 return Some(cleaned.to_string());
561 }
562 }
563
564 None
565}