mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
7ea552fa9b
* 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
138 lines
3.8 KiB
Go
138 lines
3.8 KiB
Go
package artifact
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
// Decode parses an artifact's portable on-disk form back into an Artifact.
|
|
//
|
|
// It splits a leading "---\n ... \n---" frontmatter block from the body,
|
|
// unmarshals the frontmatter, validates pad_artifact and format_version, and
|
|
// reconstructs Fields from only the keys valid for the kind (omitting
|
|
// empty/zero values). The body is everything after the closing fence with the
|
|
// single separating blank line trimmed.
|
|
//
|
|
// CRLF line endings are normalized to LF up front so artifacts authored or
|
|
// transported on Windows decode identically — fence detection and body
|
|
// preservation both operate on canonical LF text.
|
|
func Decode(data []byte) (Artifact, error) {
|
|
normalized := strings.ReplaceAll(string(data), "\r\n", "\n")
|
|
fmText, body, err := splitFrontmatter(normalized)
|
|
if err != nil {
|
|
return Artifact{}, err
|
|
}
|
|
|
|
var fm frontmatter
|
|
if err := yaml.Unmarshal([]byte(fmText), &fm); err != nil {
|
|
return Artifact{}, fmt.Errorf("decode: unmarshal frontmatter: %w", ErrMalformed)
|
|
}
|
|
|
|
if _, err := FieldKeysForKind(fm.PadArtifact); err != nil {
|
|
return Artifact{}, fmt.Errorf("decode: pad_artifact %q: %w", fm.PadArtifact, ErrUnknownKind)
|
|
}
|
|
if fm.FormatVersion != FormatVersion {
|
|
return Artifact{}, fmt.Errorf("decode: format_version %d: %w", fm.FormatVersion, ErrUnsupportedVersion)
|
|
}
|
|
|
|
a := Artifact{
|
|
Kind: fm.PadArtifact,
|
|
FormatVersion: fm.FormatVersion,
|
|
Title: fm.Title,
|
|
Fields: map[string]any{},
|
|
Body: body,
|
|
Provenance: fm.Provenance,
|
|
}
|
|
|
|
keys, _ := FieldKeysForKind(fm.PadArtifact)
|
|
for _, k := range keys {
|
|
switch k {
|
|
case "status":
|
|
putString(a.Fields, k, fm.Status)
|
|
case "trigger":
|
|
putString(a.Fields, k, fm.Trigger)
|
|
case "scope":
|
|
putString(a.Fields, k, fm.Scope)
|
|
case "invocation_slug":
|
|
putString(a.Fields, k, fm.InvocationSlug)
|
|
case "priority":
|
|
putString(a.Fields, k, fm.Priority)
|
|
case "role":
|
|
putString(a.Fields, k, fm.Role)
|
|
case "arguments":
|
|
if len(fm.Arguments) > 0 {
|
|
a.Fields[k] = fm.Arguments
|
|
}
|
|
}
|
|
}
|
|
|
|
return a, nil
|
|
}
|
|
|
|
func putString(m map[string]any, k, v string) {
|
|
if v != "" {
|
|
m[k] = v
|
|
}
|
|
}
|
|
|
|
// splitFrontmatter separates a leading "---\n...\n---" block from the body.
|
|
// Returns ErrMalformed if the opening or closing fence is missing or the
|
|
// frontmatter block is empty. The returned body has the single separating
|
|
// blank line (if present) trimmed; the rest is preserved.
|
|
func splitFrontmatter(s string) (fm, body string, err error) {
|
|
// Require the opening fence at the very start.
|
|
const fence = "---"
|
|
if !strings.HasPrefix(s, fence+"\n") {
|
|
return "", "", fmt.Errorf("decode: missing opening fence: %w", ErrMalformed)
|
|
}
|
|
rest := s[len(fence)+1:]
|
|
|
|
// Find the closing fence: a line containing exactly "---".
|
|
idx := indexClosingFence(rest)
|
|
if idx < 0 {
|
|
return "", "", fmt.Errorf("decode: missing closing fence: %w", ErrMalformed)
|
|
}
|
|
fm = rest[:idx]
|
|
if strings.TrimSpace(fm) == "" {
|
|
return "", "", fmt.Errorf("decode: empty frontmatter: %w", ErrMalformed)
|
|
}
|
|
|
|
// Advance past the closing fence line.
|
|
after := rest[idx:]
|
|
if nl := strings.IndexByte(after, '\n'); nl >= 0 {
|
|
after = after[nl+1:]
|
|
} else {
|
|
after = ""
|
|
}
|
|
|
|
// Trim exactly one leading blank line between fence and body.
|
|
after = strings.TrimPrefix(after, "\n")
|
|
|
|
return fm, after, nil
|
|
}
|
|
|
|
// indexClosingFence returns the byte offset within s of the start of the first
|
|
// line equal to "---" (optionally with trailing whitespace), or -1.
|
|
func indexClosingFence(s string) int {
|
|
offset := 0
|
|
for {
|
|
line := s[offset:]
|
|
nl := strings.IndexByte(line, '\n')
|
|
var cur string
|
|
if nl >= 0 {
|
|
cur = line[:nl]
|
|
} else {
|
|
cur = line
|
|
}
|
|
if strings.TrimRight(cur, " \t\r") == "---" {
|
|
return offset
|
|
}
|
|
if nl < 0 {
|
|
return -1
|
|
}
|
|
offset += nl + 1
|
|
}
|
|
}
|