Skip to main content

greentic_setup/engine/
plan_builders.rs

1//! Plan builders for create/update/remove operations.
2//!
3//! These functions construct `SetupPlan` objects based on `SetupRequest` input.
4
5use 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
15/// Build a plan for create mode.
16pub 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        // No new packs to fetch, but answers were provided for existing packs
61        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
108/// Build a plan for update mode.
109pub 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
268/// Build a plan for remove mode.
269pub 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
383/// Print a human-readable plan summary.
384pub 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
400// ── Helpers ─────────────────────────────────────────────────────────────────
401
402/// Deduplicate and sort a list of strings.
403pub 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
414/// Normalize tenant selections (sort and deduplicate allow_paths).
415pub 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
434/// Infer update operations from request content.
435pub 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
464/// Build metadata for a plan.
465pub 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
488/// Build metadata with explicit update operations.
489pub 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
500/// Compute a simple hash for a string (used for digest placeholders).
501pub 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
510/// Infer a default value for a setup question.
511///
512/// Priority:
513/// 1. Explicit `default` field from setup.yaml
514/// 2. Extract from help text pattern "(default: VALUE)"
515/// 3. Return empty string
516pub fn infer_default_value(question: &SetupQuestion) -> Value {
517    // First, use explicit default if present
518    if let Some(default) = question.default.clone() {
519        return default;
520    }
521
522    // Try to extract default from help text
523    // Pattern: "(default: VALUE)" or "[default: VALUE]"
524    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    // Fallback to empty string
531    Value::String(String::new())
532}
533
534/// Extract default value from help text.
535///
536/// Matches patterns like:
537/// - "(default: <https://slack.com/api>)"
538/// - "[default: true]"
539/// - "Default: some_value"
540pub fn extract_default_from_help(help: &str) -> Option<String> {
541    use regex::Regex;
542
543    // Pattern 1: (default: VALUE) or [default: VALUE]
544    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        // Clean up the value - remove trailing punctuation
548        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}