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}