Files
pad/internal/artifact/encode.go
T
xarmian 7ea552fa9b feat(artifact): MD+frontmatter export/import core for playbooks & conventions (#754)
* feat(artifact): add MD+frontmatter export/import core for playbooks & conventions

Pure-Go internal/artifact package: Encode/Decode a single playbook or
convention item to a portable Markdown + YAML-frontmatter artifact with
deterministic key order and a provenance block. Field-key tables map item
fields to frontmatter per kind; FieldKeysForKind is exported for the
server layer. Round-trip + golden + determinism + error-case tests.

Server-side validation, forgiving coercion, slug-collision handling, and
YAML input limits are deferred to Phase 2 (HTTP boundary).

Implements Phase 1 of PLAN-1867 (TASK-1868, TASK-1869, TASK-1870).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ

* fix(artifact): tolerate CRLF line endings on decode

Normalize CRLF to LF at Decode's entry point so artifacts authored or
transported on Windows decode identically (fence detection + body
preservation operate on canonical LF). Addresses Codex P2 round 1.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-22 11:07:37 -04:00

138 lines
3.0 KiB
Go

package artifact
import (
"bytes"
"fmt"
"strings"
"go.yaml.in/yaml/v4"
)
// Encode serializes an Artifact to its portable on-disk form:
//
// ---
// <yaml frontmatter>
// ---
//
// <body>
//
// Output is deterministic: two Encode calls on the same Artifact produce
// byte-identical output. The body is emitted verbatim, normalized to end with
// exactly one trailing newline, separated from the closing fence by exactly
// one blank line.
func Encode(a Artifact) ([]byte, error) {
keys, err := FieldKeysForKind(a.Kind)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
fm := frontmatter{
PadArtifact: a.Kind,
FormatVersion: a.FormatVersion,
Title: a.Title,
Provenance: a.Provenance,
}
if fm.FormatVersion == 0 {
fm.FormatVersion = FormatVersion
}
if fm.Provenance.FormatVersion == 0 {
fm.Provenance.FormatVersion = FormatVersion
}
for _, k := range keys {
v, ok := a.Fields[k]
if !ok || v == nil {
continue
}
switch k {
case "status":
fm.Status = toString(v)
case "trigger":
fm.Trigger = toString(v)
case "scope":
fm.Scope = toString(v)
case "invocation_slug":
fm.InvocationSlug = toString(v)
case "priority":
fm.Priority = toString(v)
case "role":
fm.Role = toString(v)
case "arguments":
fm.Arguments = normalizeArguments(v)
}
}
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(&fm); err != nil {
return nil, fmt.Errorf("encode: marshal frontmatter: %w", err)
}
if err := enc.Close(); err != nil {
return nil, fmt.Errorf("encode: close encoder: %w", err)
}
body := normalizeBody(a.Body)
var out bytes.Buffer
out.WriteString("---\n")
out.Write(buf.Bytes())
out.WriteString("---\n\n")
out.WriteString(body)
return out.Bytes(), nil
}
// normalizeBody trims a trailing run of whitespace/newlines and ensures the
// body ends with exactly one trailing "\n". An empty body becomes "\n".
func normalizeBody(body string) string {
trimmed := strings.TrimRight(body, "\n")
return trimmed + "\n"
}
// toString coerces a scalar frontmatter value to string. Values originate as
// strings in practice; non-strings are rendered defensively.
func toString(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
default:
return fmt.Sprintf("%v", v)
}
}
// normalizeArguments coerces the arguments value into []map[string]any so it
// serializes as a stable YAML sequence of maps. Accepts []map[string]any or
// []any (e.g. JSON-decoded) whose elements are map[string]any. Anything it
// cannot interpret as a map is skipped.
func normalizeArguments(v any) []map[string]any {
switch xs := v.(type) {
case []map[string]any:
if len(xs) == 0 {
return nil
}
return xs
case []any:
out := make([]map[string]any, 0, len(xs))
for _, el := range xs {
if m := toStringMap(el); m != nil {
out = append(out, m)
}
}
if len(out) == 0 {
return nil
}
return out
default:
return nil
}
}
func toStringMap(v any) map[string]any {
if m, ok := v.(map[string]any); ok {
return m
}
return nil
}