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    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
113/// Build a plan for update mode.
114pub 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
278/// Build a plan for remove mode.
279pub 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
393/// Print a human-readable plan summary.
394pub 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
410// ── Helpers ─────────────────────────────────────────────────────────────────
411
412/// Deduplicate and sort a list of strings.
413pub 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
424/// Normalize tenant selections (sort and deduplicate allow_paths).
425pub 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
444/// Infer update operations from request content.
445pub 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
474/// Build metadata for a plan.
475pub 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
498/// Build metadata with explicit update operations.
499pub 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
510/// Compute a simple hash for a string (used for digest placeholders).
511pub 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
520/// Infer a default value for a setup question.
521///
522/// Priority:
523/// 1. Explicit `default` field from setup.yaml
524/// 2. Extract from help text pattern "(default: VALUE)"
525/// 3. Return empty string
526pub fn infer_default_value(question: &SetupQuestion) -> Value {
527    // First, use explicit default if present
528    if let Some(default) = question.default.clone() {
529        return default;
530    }
531
532    // Try to extract default from help text
533    // Pattern: "(default: VALUE)" or "[default: VALUE]"
534    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    // Fallback to empty string
541    Value::String(String::new())
542}
543
544/// Extract default value from help text.
545///
546/// Matches patterns like:
547/// - "(default: <https://slack.com/api>)"
548/// - "[default: true]"
549/// - "Default: some_value"
550pub fn extract_default_from_help(help: &str) -> Option<String> {
551    use regex::Regex;
552
553    // Pattern 1: (default: VALUE) or [default: VALUE]
554    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        // Clean up the value - remove trailing punctuation
558        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}