package workflow import ( "encoding/json" "fmt" "sort" "strconv" "strings" "cloud.o-forge.io/core/oc-lib/models/resources" "cloud.o-forge.io/core/oc-lib/models/workflow/graph" ) // --------------------------------------------------------------------------- // PlantUML export // --------------------------------------------------------------------------- // ToPlantUML serializes the workflow graph back to a PlantUML file that is // compatible with ExtractFromPlantUML (round-trip). // Resource attributes and instance attributes are merged and written as // human-readable comments: "key: value, nested.key: value". func (w *Workflow) ToPlantUML() string { var sb strings.Builder sb.WriteString("@startuml\n\n") varNames := plantUMLVarNames(w.Graph.Items) // --- resource declarations --- for id, item := range w.Graph.Items { sb.WriteString(plantUMLItemLine(varNames[id], item)) sb.WriteByte('\n') } sb.WriteByte('\n') // --- links --- for _, link := range w.Graph.Links { src := varNames[link.Source.ID] dst := varNames[link.Destination.ID] if src == "" || dst == "" { continue } line := fmt.Sprintf("%s --> %s", src, dst) if comment := plantUMLLinkComment(link); comment != "" { line += " ' " + comment } sb.WriteString(line + "\n") } sb.WriteString("\n@enduml\n") return sb.String() } // plantUMLVarNames assigns short, deterministic variable names to each graph // item (d1, d2, p1, s1, c1, e1, wf1 …). func plantUMLVarNames(items map[string]graph.GraphItem) map[string]string { counters := map[string]int{} varNames := map[string]string{} // Sort IDs for deterministic output ids := make([]string, 0, len(items)) for id := range items { ids = append(ids, id) } sort.Strings(ids) for _, id := range ids { prefix := plantUMLPrefix(items[id]) counters[prefix]++ varNames[id] = fmt.Sprintf("%s%d", prefix, counters[prefix]) } return varNames } func plantUMLPrefix(item graph.GraphItem) string { switch { case item.NativeTool != nil: return "e" case item.Data != nil: return "d" case item.Processing != nil: return "p" case item.Storage != nil: return "s" case item.Compute != nil: return "c" case item.Workflow != nil: return "wf" } return "u" } // plantUMLItemLine builds the PlantUML declaration line for one graph item. func plantUMLItemLine(varName string, item graph.GraphItem) string { switch { case item.NativeTool != nil: // WorkflowEvent has no instance and no configurable attributes. return fmt.Sprintf("WorkflowEvent(%s, \"%s\")", varName, item.NativeTool.GetName()) case item.Data != nil: return plantUMLResourceLine("Data", varName, item.Data) case item.Processing != nil: return plantUMLResourceLine("Processing", varName, item.Processing) case item.Storage != nil: return plantUMLResourceLine("Storage", varName, item.Storage) case item.Compute != nil: return plantUMLResourceLine("ComputeUnit", varName, item.Compute) case item.Workflow != nil: return plantUMLResourceLine("Workflow", varName, item.Workflow) } return "" } func plantUMLResourceLine(macro, varName string, res resources.ResourceInterface) string { line := fmt.Sprintf("%s(%s, \"%s\")", macro, varName, res.GetName()) if comment := plantUMLResourceComment(res); comment != "" { line += " ' " + comment } return line } // plantUMLResourceComment merges resource-level fields with the first instance // fields (instance overrides resource) and formats them as human-readable pairs. func plantUMLResourceComment(res resources.ResourceInterface) string { m := plantUMLToFlatMap(res) if inst := res.GetSelectedInstance(nil); inst != nil { for k, v := range plantUMLToFlatMap(inst) { m[k] = v } } return plantUMLFlatMapToComment(m) } // plantUMLLinkComment serializes StorageLinkInfos (first entry) as flat // human-readable pairs prefixed with "storage_link_infos.". func plantUMLLinkComment(link graph.GraphLink) string { if len(link.StorageLinkInfos) == 0 { return "" } infoFlat := plantUMLToFlatMap(link.StorageLinkInfos[0]) prefixed := make(map[string]string, len(infoFlat)) for k, v := range infoFlat { prefixed["storage_link_infos."+k] = v } return plantUMLFlatMapToComment(prefixed) } // --------------------------------------------------------------------------- // Flat-map helpers (shared by import & export) // --------------------------------------------------------------------------- // plantUMLSkipFields lists JSON field names that must never appear in comments. // Checked against the root key of each dot-notation path. var plantUMLSkipFields = map[string]bool{ // identity / meta "uuid": true, "name": true, "created_at": true, "updated_at": true, "creator_id": true, "is_draft": true, // relationships / pricing "instances": true, "partnerships": true, "allowed_booking_modes": true, "usage_restrictions": true, // display / admin "logo": true, "description": true, "short_description": true, "owners": true, // runtime params "env": true, "inputs": true, "outputs": true, // NativeTool internals "kind": true, "params": true, } // plantUMLToFlatMap marshals v to JSON and flattens the resulting object into // a map[string]string using dot notation for nested keys, skipping zero values // and known meta fields. func plantUMLToFlatMap(v interface{}) map[string]string { b, err := json.Marshal(v) if err != nil { return nil } var raw map[string]interface{} if err := json.Unmarshal(b, &raw); err != nil { return nil } result := map[string]string{} plantUMLFlattenJSON(raw, "", result) return result } // plantUMLFlattenJSON recursively walks a JSON object and writes scalar leaf // values into result using dot-notation keys. func plantUMLFlattenJSON(m map[string]interface{}, prefix string, result map[string]string) { for k, v := range m { fullKey := k if prefix != "" { fullKey = prefix + "." + k } // Skip fields whose root key is in the deny-list if plantUMLSkipFields[strings.SplitN(fullKey, ".", 2)[0]] { continue } switch val := v.(type) { case map[string]interface{}: plantUMLFlattenJSON(val, fullKey, result) case []interface{}: // Arrays are not representable in flat human-readable format; skip. case float64: if val != 0 { if val == float64(int64(val)) { result[fullKey] = strconv.FormatInt(int64(val), 10) } else { result[fullKey] = strconv.FormatFloat(val, 'f', -1, 64) } } case bool: if val { result[fullKey] = "true" } case string: if val != "" { result[fullKey] = val } } } } // plantUMLFlatMapToComment converts a flat map to a sorted "key: value, …" string. func plantUMLFlatMapToComment(m map[string]string) string { if len(m) == 0 { return "" } keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { parts = append(parts, k+": "+m[k]) } return strings.Join(parts, ", ") }