package tide import ( "bytes" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "github.com/goccy/go-yaml" "github.com/goccy/go-yaml/token" ) func (b *Body) UnmarshalYAML(data []byte) error { if b == nil { return fmt.Errorf("tide: nil body") } var s string if err := yaml.Unmarshal(data, &s); err != nil { return err } *b = Body(s) return nil } // LoadFlow reads and validates a version-1 YAML flow from path. func LoadFlow(path string) (Flow, error) { raw, err := os.ReadFile(path) if err != nil { return Flow{}, fmt.Errorf("tide: read %s: %w", path, err) } return ParseFlow(raw) } // ParseFlow decodes a version-1 YAML flow, rejecting unknown fields. func ParseFlow(raw []byte) (Flow, error) { var flow Flow dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) if err := dec.Decode(&flow); err != nil { return Flow{}, fmt.Errorf("tide: parse flow: %w", err) } if err := validateFlow(flow); err != nil { return Flow{}, err } return flow, nil } // SaveFlow writes a validated flow atomically, syncing before rename. func SaveFlow(path string, flow Flow) error { if err := validateFlow(flow); err != nil { return err } raw, err := marshalFlow(flow) if err != nil { return err } dir := filepath.Dir(path) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("tide: create fixture dir: %w", err) } } tmp, err := os.CreateTemp(dir, ".tide-*.tmp") if err != nil { return fmt.Errorf("tide: create temp fixture: %w", err) } tmpName := tmp.Name() ok := false defer func() { if !ok { _ = os.Remove(tmpName) } }() if _, err := tmp.Write(raw); err != nil { _ = tmp.Close() return fmt.Errorf("tide: write fixture: %w", err) } if err := tmp.Sync(); err != nil { _ = tmp.Close() return fmt.Errorf("tide: sync fixture: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("tide: close fixture: %w", err) } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("tide: commit fixture: %w", err) } ok = true return nil } func marshalFlow(flow Flow) ([]byte, error) { var b strings.Builder fmt.Fprintf(&b, "version: %d\n", flow.Version) writeKV(&b, 0, "name", flow.Name) if flow.Description != "" { writeKV(&b, 0, "description", flow.Description) } if flow.SeedHook != "" { writeKV(&b, 0, "seed_hook", flow.SeedHook) } b.WriteString("steps:\n") for _, step := range flow.Steps { writeStep(&b, step) } return []byte(b.String()), nil } func writeStep(b *strings.Builder, step Step) { fmt.Fprintf(b, " - id: %s\n", encodeScalar(step.ID)) if step.RouteID != "" { writeKV(b, 4, "route_id", step.RouteID) } b.WriteString(" request:\n") writeRequest(b, 6, step.Request) b.WriteString(" response:\n") writeResponse(b, 6, step.Response) writeCapture(b, 4, step.Capture) writeNormalize(b, 4, step.Normalize) writeHeaders(b, 4, step.Headers) } func writeRequest(b *strings.Builder, indent int, req Request) { writeKV(b, indent, "method", req.Method) writeKV(b, indent, "path", req.Path) if req.Query != "" { writeKV(b, indent, "query", req.Query) } writeHeaders(b, indent, req.Headers) writeBody(b, indent, string(req.Body)) } func writeResponse(b *strings.Builder, indent int, resp Response) { if resp.Status != 0 { fmt.Fprintf(b, "%sstatus: %d\n", strings.Repeat(" ", indent), resp.Status) } writeHeaders(b, indent, resp.Headers) writeBody(b, indent, string(resp.Body)) if resp.BodyFile != "" { writeKV(b, indent, "body_file", resp.BodyFile) } if resp.SHA256 != "" { writeKV(b, indent, "sha256", resp.SHA256) } } func writeCapture(b *strings.Builder, indent int, rules []CaptureRule) { if len(rules) == 0 { return } pad := strings.Repeat(" ", indent) fmt.Fprintf(b, "%scapture:\n", pad) inner := strings.Repeat(" ", indent+2) for _, rule := range rules { fmt.Fprintf(b, "%s- path: %s\n", inner, encodeScalar(rule.Path)) fmt.Fprintf(b, "%s as: %s\n", inner, encodeScalar(rule.As)) } } func writeNormalize(b *strings.Builder, indent int, rules []NormalizeRule) { if len(rules) == 0 { return } pad := strings.Repeat(" ", indent) fmt.Fprintf(b, "%snormalize:\n", pad) inner := strings.Repeat(" ", indent+2) for _, rule := range rules { fmt.Fprintf(b, "%s- path: %s\n", inner, encodeScalar(rule.Path)) if rule.Disable { fmt.Fprintf(b, "%s disable: true\n", inner) } } } func writeHeaders(b *strings.Builder, indent int, headers map[string]string) { if len(headers) == 0 { return } pad := strings.Repeat(" ", indent) fmt.Fprintf(b, "%sheaders:\n", pad) keys := make([]string, 0, len(headers)) for k := range headers { keys = append(keys, k) } sort.Strings(keys) inner := strings.Repeat(" ", indent+2) for _, k := range keys { fmt.Fprintf(b, "%s%s: %s\n", inner, k, encodeScalar(headers[k])) } } func writeBody(b *strings.Builder, indent int, body string) { if body == "" { return } pad := strings.Repeat(" ", indent) header := token.LiteralBlockHeader(body) if header == "" { header = "|-" } fmt.Fprintf(b, "%sbody: %s\n", pad, header) inner := strings.Repeat(" ", indent+2) content := body if strings.HasSuffix(content, "\n") { content = content[:len(content)-1] } for _, line := range strings.Split(content, "\n") { b.WriteString(inner) b.WriteString(line) b.WriteByte('\n') } } func writeKV(b *strings.Builder, indent int, key, value string) { pad := strings.Repeat(" ", indent) fmt.Fprintf(b, "%s%s: %s\n", pad, key, encodeScalar(value)) } func encodeScalar(v string) string { if v == "" { return `""` } if token.IsNeedQuoted(v) || strings.ContainsAny(v, " \t") { return strconv.Quote(v) } return v }