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