Skip to main content

component_dwbase/
qa.rs

1use greentic_types::i18n_text::I18nText;
2use greentic_types::schemas::component::v0_6_0::{QaMode, Question, QuestionKind};
3use serde_json::{json, Map as JsonMap, Value as JsonValue};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum NormalizedMode {
7    Setup,
8    Update,
9    Remove,
10}
11
12impl NormalizedMode {
13    pub fn as_str(self) -> &'static str {
14        match self {
15            Self::Setup => "setup",
16            Self::Update => "update",
17            Self::Remove => "remove",
18        }
19    }
20}
21
22pub fn normalize_mode(raw: &str) -> Option<NormalizedMode> {
23    match raw {
24        "default" | "setup" | "install" => Some(NormalizedMode::Setup),
25        "update" | "upgrade" => Some(NormalizedMode::Update),
26        "remove" => Some(NormalizedMode::Remove),
27        _ => None,
28    }
29}
30
31pub fn qa_spec_json(mode: NormalizedMode) -> JsonValue {
32    let (title_key, description_key, questions) = match mode {
33        NormalizedMode::Setup => (
34            "qa.install.title",
35            Some("qa.install.description"),
36            vec![
37                question(
38                    "data_dir",
39                    "qa.field.data_dir.label",
40                    "qa.field.data_dir.help",
41                    true,
42                ),
43                question(
44                    "default_tenant",
45                    "qa.field.default_tenant.label",
46                    "qa.field.default_tenant.help",
47                    true,
48                ),
49                question(
50                    "public_base_url",
51                    "qa.field.public_base_url.label",
52                    "qa.field.public_base_url.help",
53                    true,
54                ),
55                question(
56                    "public_path_prefix",
57                    "qa.field.public_path_prefix.label",
58                    "qa.field.public_path_prefix.help",
59                    false,
60                ),
61                question(
62                    "nats_url",
63                    "qa.field.nats_url.label",
64                    "qa.field.nats_url.help",
65                    false,
66                ),
67                question(
68                    "swarm_enable",
69                    "qa.field.swarm_enable.label",
70                    "qa.field.swarm_enable.help",
71                    false,
72                ),
73            ],
74        ),
75        NormalizedMode::Update => (
76            "qa.update.title",
77            Some("qa.update.description"),
78            vec![
79                question(
80                    "data_dir",
81                    "qa.field.data_dir.label",
82                    "qa.field.data_dir.help",
83                    false,
84                ),
85                question(
86                    "default_tenant",
87                    "qa.field.default_tenant.label",
88                    "qa.field.default_tenant.help",
89                    false,
90                ),
91                question(
92                    "public_base_url",
93                    "qa.field.public_base_url.label",
94                    "qa.field.public_base_url.help",
95                    false,
96                ),
97                question(
98                    "public_path_prefix",
99                    "qa.field.public_path_prefix.label",
100                    "qa.field.public_path_prefix.help",
101                    false,
102                ),
103                question(
104                    "nats_url",
105                    "qa.field.nats_url.label",
106                    "qa.field.nats_url.help",
107                    false,
108                ),
109                question(
110                    "swarm_enable",
111                    "qa.field.swarm_enable.label",
112                    "qa.field.swarm_enable.help",
113                    false,
114                ),
115            ],
116        ),
117        NormalizedMode::Remove => (
118            "qa.remove.title",
119            Some("qa.remove.description"),
120            vec![question(
121                "confirm_remove",
122                "qa.field.confirm_remove.label",
123                "qa.field.confirm_remove.help",
124                true,
125            )],
126        ),
127    };
128
129    json!({
130        "mode": match mode {
131            NormalizedMode::Setup => QaMode::Setup,
132            NormalizedMode::Update => QaMode::Update,
133            NormalizedMode::Remove => QaMode::Remove,
134        },
135        "title": I18nText::new(title_key, None),
136        "description": description_key.map(|key| I18nText::new(key, None)),
137        "questions": questions,
138        "defaults": {}
139    })
140}
141
142fn question(id: &str, label_key: &str, help_key: &str, required: bool) -> Question {
143    Question {
144        id: id.to_string(),
145        label: I18nText::new(label_key, None),
146        help: Some(I18nText::new(help_key, None)),
147        error: None,
148        kind: QuestionKind::Text,
149        required,
150        default: None,
151        skip_if: None,
152    }
153}
154
155pub fn i18n_keys() -> Vec<String> {
156    crate::i18n::all_keys()
157}
158
159pub fn requirements_json() -> JsonValue {
160    json!({
161        "cap_id": "greentic.cap.dwbase.memory.v1",
162        "provider_op": "dwbase.configure",
163        "requires_http_ingress": true,
164        "required_config_keys": ["data_dir", "default_tenant", "public_base_url"],
165        "optional_config_keys": ["public_path_prefix", "nats_url", "swarm_enable"],
166        "public_base_url_field": "public_base_url",
167        "public_path_prefix_default": "/dwbase",
168        "supports": ["component_config"],
169        "secrets": []
170    })
171}
172
173pub fn configure(payload: &JsonValue) -> JsonValue {
174    let mode = payload
175        .get("mode")
176        .and_then(|value| value.as_str())
177        .and_then(normalize_mode)
178        .unwrap_or(NormalizedMode::Setup);
179    let mut result = apply_answers(mode, payload);
180    if let Some(map) = result.as_object_mut() {
181        map.insert("requirements".to_string(), requirements_json());
182    }
183    result
184}
185
186pub fn apply_answers(mode: NormalizedMode, payload: &JsonValue) -> JsonValue {
187    let answers = payload
188        .get("answers")
189        .cloned()
190        .or_else(|| payload.get("config").cloned())
191        .unwrap_or_else(|| json!({}));
192    let current_config = payload
193        .get("current_config")
194        .cloned()
195        .unwrap_or_else(|| json!({}));
196
197    let mut errors = Vec::new();
198    match mode {
199        NormalizedMode::Setup => {
200            for key in ["data_dir", "default_tenant", "public_base_url"] {
201                if string_value(&answers, key).is_none() {
202                    errors.push(json!({
203                        "key": "qa.error.required",
204                        "msg_key": "qa.error.required",
205                        "fields": [key]
206                    }));
207                }
208            }
209        }
210        NormalizedMode::Remove => {
211            if !bool_value(&answers, "confirm_remove").unwrap_or(false) {
212                errors.push(json!({
213                    "key": "qa.error.remove_confirmation",
214                    "msg_key": "qa.error.remove_confirmation",
215                    "fields": ["confirm_remove"]
216                }));
217            }
218        }
219        NormalizedMode::Update => {}
220    }
221
222    validate_non_empty(
223        &answers,
224        "data_dir",
225        "qa.error.invalid_data_dir",
226        &mut errors,
227    );
228    validate_non_empty(
229        &answers,
230        "default_tenant",
231        "qa.error.invalid_default_tenant",
232        &mut errors,
233    );
234    validate_url(&answers, "public_base_url", &mut errors);
235    validate_nats_url(&answers, &mut errors);
236
237    if !errors.is_empty() {
238        return json!({
239            "ok": false,
240            "warnings": [],
241            "errors": errors,
242            "meta": {
243                "mode": mode.as_str(),
244                "version": "v1"
245            }
246        });
247    }
248
249    let mut config = match current_config {
250        JsonValue::Object(map) => map,
251        _ => serde_json::Map::new(),
252    };
253    if let JsonValue::Object(answer_map) = answers {
254        merge_answers(&mut config, &answer_map);
255    }
256    if mode != NormalizedMode::Remove {
257        if !config.contains_key("public_path_prefix") {
258            config.insert(
259                "public_path_prefix".to_string(),
260                JsonValue::String("/dwbase".to_string()),
261            );
262        }
263        if !config.contains_key("swarm_enable") {
264            config.insert("swarm_enable".to_string(), JsonValue::Bool(false));
265        }
266        config.insert(
267            "ingress".to_string(),
268            JsonValue::Object(build_ingress_config(&config)),
269        );
270        config.insert("enabled".to_string(), JsonValue::Bool(true));
271    } else {
272        config.insert("enabled".to_string(), JsonValue::Bool(false));
273        config.insert("ingress_enabled".to_string(), JsonValue::Bool(false));
274    }
275
276    json!({
277        "ok": true,
278        "config": config,
279        "warnings": [],
280        "errors": [],
281        "meta": {
282            "mode": mode.as_str(),
283            "version": "v1"
284        },
285        "audit": {
286            "reasons": ["qa.apply_answers", "dwbase.configure"],
287            "timings_ms": {}
288        }
289    })
290}
291
292fn merge_answers(config: &mut JsonMap<String, JsonValue>, answers: &JsonMap<String, JsonValue>) {
293    for (key, value) in answers {
294        match key.as_str() {
295            "data_dir" | "default_tenant" | "public_base_url" | "public_path_prefix" => {
296                if let Some(value) = string_json(value) {
297                    config.insert(key.clone(), JsonValue::String(value));
298                }
299            }
300            "nats_url" => {
301                if let Some(value) = string_json(value) {
302                    if value.is_empty() {
303                        config.remove(key);
304                    } else {
305                        config.insert(key.clone(), JsonValue::String(value));
306                    }
307                }
308            }
309            "swarm_enable" | "confirm_remove" => {
310                if let Some(value) = bool_json(value) {
311                    config.insert(key.clone(), JsonValue::Bool(value));
312                }
313            }
314            _ => {
315                config.insert(key.clone(), value.clone());
316            }
317        }
318    }
319}
320
321fn build_ingress_config(config: &JsonMap<String, JsonValue>) -> JsonMap<String, JsonValue> {
322    let base_url = config
323        .get("public_base_url")
324        .and_then(JsonValue::as_str)
325        .unwrap_or_default()
326        .trim_end_matches('/')
327        .to_string();
328    let path_prefix = config
329        .get("public_path_prefix")
330        .and_then(JsonValue::as_str)
331        .unwrap_or("/dwbase");
332    let normalized_path = normalize_path_prefix(path_prefix);
333    let public_api_base_url = if base_url.is_empty() {
334        JsonValue::Null
335    } else {
336        JsonValue::String(format!("{base_url}{normalized_path}"))
337    };
338
339    JsonMap::from_iter([
340        ("required".to_string(), JsonValue::Bool(true)),
341        (
342            "public_base_url".to_string(),
343            config
344                .get("public_base_url")
345                .cloned()
346                .unwrap_or(JsonValue::Null),
347        ),
348        (
349            "public_path_prefix".to_string(),
350            JsonValue::String(normalized_path),
351        ),
352        ("public_api_base_url".to_string(), public_api_base_url),
353        ("ingress_enabled".to_string(), JsonValue::Bool(true)),
354    ])
355}
356
357fn normalize_path_prefix(value: &str) -> String {
358    let trimmed = value.trim();
359    if trimmed.is_empty() || trimmed == "/" {
360        return "/dwbase".to_string();
361    }
362    let mut normalized = trimmed.to_string();
363    if !normalized.starts_with('/') {
364        normalized.insert(0, '/');
365    }
366    normalized.trim_end_matches('/').to_string()
367}
368
369fn validate_non_empty(
370    answers: &JsonValue,
371    key: &str,
372    error_key: &str,
373    errors: &mut Vec<JsonValue>,
374) {
375    if let Some(value) = string_value(answers, key) {
376        if value.trim().is_empty() {
377            errors.push(json!({
378                "key": error_key,
379                "msg_key": error_key,
380                "fields": [key]
381            }));
382        }
383    }
384}
385
386fn validate_url(answers: &JsonValue, key: &str, errors: &mut Vec<JsonValue>) {
387    if let Some(value) = string_value(answers, key) {
388        let trimmed = value.trim();
389        let valid = trimmed.starts_with("https://") || trimmed.starts_with("http://");
390        if trimmed.is_empty() || !valid {
391            errors.push(json!({
392                "key": "qa.error.invalid_public_base_url",
393                "msg_key": "qa.error.invalid_public_base_url",
394                "fields": [key]
395            }));
396        }
397    }
398}
399
400fn validate_nats_url(answers: &JsonValue, errors: &mut Vec<JsonValue>) {
401    if let Some(value) = string_value(answers, "nats_url") {
402        let trimmed = value.trim();
403        if !(trimmed.is_empty()
404            || trimmed.starts_with("nats://")
405            || trimmed.starts_with("tls://")
406            || trimmed.starts_with("ws://")
407            || trimmed.starts_with("wss://"))
408        {
409            errors.push(json!({
410                "key": "qa.error.invalid_nats_url",
411                "msg_key": "qa.error.invalid_nats_url",
412                "fields": ["nats_url"]
413            }));
414        }
415    }
416}
417
418fn string_value<'a>(value: &'a JsonValue, key: &str) -> Option<&'a str> {
419    value.get(key).and_then(JsonValue::as_str)
420}
421
422fn string_json(value: &JsonValue) -> Option<String> {
423    value.as_str().map(|value| value.trim().to_string())
424}
425
426fn bool_value(value: &JsonValue, key: &str) -> Option<bool> {
427    value.get(key).and_then(bool_json)
428}
429
430fn bool_json(value: &JsonValue) -> Option<bool> {
431    match value {
432        JsonValue::Bool(value) => Some(*value),
433        JsonValue::String(value) => match value.trim().to_ascii_lowercase().as_str() {
434            "true" | "1" | "yes" | "on" => Some(true),
435            "false" | "0" | "no" | "off" => Some(false),
436            _ => None,
437        },
438        _ => None,
439    }
440}