package memory import ( "bytes" "errors" "fmt" "sort" "strings" "time" ) const ( fmDelim = "---" ) // ParseFact reads a fact file's raw bytes and returns the parsed Fact. // The file must begin with a `---` line, contain a single-line // `key: value` frontmatter block (blank lines and `#` comments allowed), // be terminated by another `---` line, and may be followed by an // arbitrary body. ParseFact validates required fields before returning. func ParseFact(content []byte) (*Fact, error) { lines := splitLines(string(content)) if len(lines) == 0 || strings.TrimSpace(lines[0]) != fmDelim { return nil, errors.New("frontmatter: missing opening '---'") } endIdx := -1 for i := 1; i < len(lines); i++ { if strings.TrimSpace(lines[i]) == fmDelim { endIdx = i break } } if endIdx < 0 { return nil, errors.New("frontmatter: missing closing '---'") } f := &Fact{} for i := 1; i < endIdx; i++ { line := lines[i] trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } key, value, ok := strings.Cut(trimmed, ":") if !ok { return nil, fmt.Errorf("frontmatter line %d: missing ':'", i+1) } key = strings.TrimSpace(key) val := unquoteFM(strings.TrimSpace(value)) if err := setField(f, key, val); err != nil { return nil, fmt.Errorf("frontmatter line %d: %w", i+1, err) } } bodyLines := lines[endIdx+1:] body := strings.Join(bodyLines, "\n") body = strings.TrimLeft(body, "\n") body = strings.TrimRight(body, " \t\r\n") + "\n" if strings.TrimSpace(body) == "" { body = "" } f.Body = body if err := f.Validate(); err != nil { return nil, fmt.Errorf("frontmatter: %w", err) } return f, nil } func setField(f *Fact, key, val string) error { switch key { case "name": f.Name = val case "description": f.Description = val case "type": f.Type = FactType(val) case "created": t, err := time.Parse(DateLayout, val) if err != nil { return fmt.Errorf("created: %w", err) } f.Created = t case "last_used": t, err := time.Parse(DateLayout, val) if err != nil { return fmt.Errorf("last_used: %w", err) } f.LastUsed = t case "ref": f.Ref = val case "expires": if val == "" { f.Expires = nil return nil } t, err := time.Parse(DateLayout, val) if err != nil { return fmt.Errorf("expires: %w", err) } f.Expires = &t case "status": f.Status = val case "pin": switch val { case "true": f.Pin = true case "false", "": f.Pin = false default: return fmt.Errorf("pin: must be true or false (got %q)", val) } case "source": f.Source = val case "agent": f.Agent = val case "disabled": switch val { case "true": f.Disabled = true case "false", "": f.Disabled = false default: return fmt.Errorf("disabled: must be true or false (got %q)", val) } default: // Unknown keys tolerated for forward-compatibility. } return nil } // Serialize renders a Fact to disk-ready bytes. Field order is stable: // name, description, type, created, last_used, then optional fields // (ref, expires, status) only when set, then pin, then optional // provenance fields (source, agent) and disabled when true. The body is // appended after the closing `---` separator with a single blank line. // The output always ends with a newline. func Serialize(f *Fact) ([]byte, error) { if err := f.Validate(); err != nil { return nil, err } var buf bytes.Buffer buf.WriteString(fmDelim) buf.WriteByte('\n') writeKV(&buf, "name", f.Name) writeKV(&buf, "description", f.Description) writeKV(&buf, "type", string(f.Type)) writeKV(&buf, "created", f.Created.UTC().Format(DateLayout)) writeKV(&buf, "last_used", f.LastUsed.UTC().Format(DateLayout)) if f.Ref != "" { writeKV(&buf, "ref", f.Ref) } if f.Expires != nil { writeKV(&buf, "expires", f.Expires.UTC().Format(DateLayout)) } if f.Status != "" { writeKV(&buf, "status", f.Status) } if f.Pin { writeKV(&buf, "pin", "true") } else { writeKV(&buf, "pin", "false") } if f.Source != "" { writeKV(&buf, "source", f.Source) } if f.Agent != "" { writeKV(&buf, "agent", f.Agent) } if f.Disabled { writeKV(&buf, "disabled", "true") } buf.WriteString(fmDelim) buf.WriteByte('\n') if f.Body != "" { buf.WriteByte('\n') body := strings.TrimRight(f.Body, "\n") buf.WriteString(body) buf.WriteByte('\n') } return buf.Bytes(), nil } func writeKV(buf *bytes.Buffer, k, v string) { buf.WriteString(k) buf.WriteString(": ") buf.WriteString(v) buf.WriteByte('\n') } // splitLines splits on `\n` and strips trailing `\r` so files written // with CRLF round-trip correctly. func splitLines(s string) []string { lines := strings.Split(s, "\n") for i, line := range lines { lines[i] = strings.TrimRight(line, "\r") } return lines } // unquoteFM strips matching surrounding single or double quotes. The // memory store does not interpret escape sequences inside quoted // strings; values that need quoting should be one-liners. func unquoteFM(s string) string { if len(s) >= 2 { first, last := s[0], s[len(s)-1] if (first == '"' || first == '\'') && first == last { return s[1 : len(s)-1] } } return s } // sortedFacts returns a copy of facts ordered by name. Used by the // index and by tests that need a deterministic iteration order. func sortedFacts(facts []*Fact) []*Fact { out := make([]*Fact, len(facts)) copy(out, facts) sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out }