storage

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 26, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package storage provides a generic layered YAML store engine.

Both internal/config and internal/project compose a Store[T] with their own schema types. The store handles file discovery (static paths or walk-up), per-file loading with migrations, N-way merge with provenance tracking, and scoped writes with atomic I/O.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotInProject = errors.New("storage: CWD is not within a registered project")

ErrNotInProject is returned when CWD is not within a registered project's directory tree. Walk-up discovery falls back to home-level configs only.

View Source
var ErrRegistryNotFound = errors.New("storage: project registry not found")

ErrRegistryNotFound is returned when the project registry file cannot be located during walk-up discovery. Discovery continues with explicit paths.

Functions

func GenerateDefaultsYAML added in v0.6.0

func GenerateDefaultsYAML[T Schema]() string

GenerateDefaultsYAML walks the struct tags of T and produces a YAML string containing all fields that have a non-empty `default` tag. The output is suitable for WithDefaults — it provides the same base-layer behavior as a handwritten YAML constant, but derived from the struct definition.

Type coercion ensures YAML types match the Go field type:

  • KindBool → YAML bool (true/false)
  • KindInt → YAML int
  • KindStringSlice → YAML sequence (comma-separated tag → []string)
  • KindDuration → YAML string (e.g. "30s")
  • KindText → YAML string

func ResolveProjectRoot

func ResolveProjectRoot() (string, error)

ResolveProjectRoot resolves the project root for the current working directory by reading the project registry and finding the deepest registered root that contains CWD. Returns ErrRegistryNotFound if the registry does not exist, and ErrNotInProject if CWD is not within any registered project.

func ValidateDirectories added in v0.3.2

func ValidateDirectories() error

ValidateDirectories resolves all four XDG-style directories and returns an error if any two resolve to the same path. This catches misconfiguration (e.g. CLAWKER_DATA_DIR accidentally pointing at the config directory).

Types

type Field added in v0.6.0

type Field interface {
	Path() string        // Dotted YAML path (e.g. "build.image").
	Kind() FieldKind     // Data type classification.
	Label() string       // Short human-readable name (from `label` tag or derived from YAML key).
	Description() string // Help text (from `desc` tag).
	Default() string     // Default value hint (from `default` tag), may be empty.
	Required() bool      // Whether this field must have a value (from `required:"true"` tag).
	MergeTag() string    // Merge strategy hint (from `merge` tag): "union", "overwrite", or "".
}

Field describes a single configuration field's schema metadata. Concrete implementations are created by NewField or NormalizeFields.

func NewField added in v0.6.0

func NewField(path string, kind FieldKind, label, desc, def string, required bool) Field

NewField creates a Field with explicit values. Use this when struct tags are not available (e.g. manually-assembled schemas).

type FieldKind added in v0.6.0

type FieldKind int

FieldKind classifies a configuration field's data type for schema consumers (TUI editors, doc generators, CLI help).

const (
	KindText        FieldKind = iota // string
	KindBool                         // bool or *bool
	KindSelect                       // constrained string (options set by consumer)
	KindInt                          // int, int64
	KindStringSlice                  // []string
	KindDuration                     // time.Duration
	KindMap                          // map[string]string (only — other map types must register via KindFunc)
	KindStructSlice                  // []struct (non-string slice of structs)

	// KindLast is the boundary for storage-defined kinds. Consumer packages
	// define domain-specific kinds starting here:
	//
	//   const KindMyType storage.FieldKind = storage.KindLast + 1
	KindLast
)

func (FieldKind) String added in v0.6.0

func (k FieldKind) String() string

String returns the human-readable name of the field kind.

type FieldSet added in v0.6.0

type FieldSet interface {
	All() []Field                // All fields in discovery order.
	Get(path string) Field       // Lookup by dotted path; returns nil if not found.
	Group(prefix string) []Field // Fields whose path starts with prefix + ".".
	Len() int                    // Number of fields.
}

FieldSet is an ordered, indexed collection of Field values.

func NewFieldSet added in v0.6.0

func NewFieldSet(fields []Field) FieldSet

NewFieldSet creates a FieldSet from a slice of Field values. The slice order is preserved by [FieldSet.All].

func NormalizeFields added in v0.6.0

func NormalizeFields[T any](v T, opts ...NormalizeOption) FieldSet

NormalizeFields reflects over v's exported struct fields and produces a FieldSet containing schema metadata derived from struct tags:

  • `yaml:"name"` — dotted path key (falls back to lowercased field name)
  • `label:"Display Name"` — human-readable label (falls back to YAML key)
  • `desc:"Help text"` — field description
  • `default:"value"` — default value hint
  • `required:"true"` — marks load-bearing fields that must have a value

Go type → FieldKind mapping:

  • string → KindText
  • bool, *bool → KindBool
  • int, int64 → KindInt
  • []string → KindStringSlice
  • time.Duration → KindDuration
  • struct, *struct → recursed (not a leaf field)
  • map[string]string → KindMap
  • []struct → KindStructSlice
  • unrecognized types → KindFunc (if registered) → panic

NormalizeFields does NOT extract runtime values — only schema metadata. Panics if v is not a struct or pointer-to-struct, or if any exported field has an unsupported type and no KindFunc claims it.

type KindFunc added in v0.6.0

type KindFunc func(reflect.Type) (FieldKind, bool)

KindFunc classifies a reflect.Type that the normalizer does not recognize. Return (kind, true) to claim the type. Return (0, false) to fall through to the default panic.

type LayerInfo

type LayerInfo struct {
	Filename string         // which filename matched (e.g., "clawker.yaml")
	Path     string         // resolved absolute path
	Data     map[string]any // raw YAML data from this file only (read-only copy)
}

LayerInfo describes a discovered configuration layer.

type Migration

type Migration func(raw map[string]any) bool

Migration is a caller-provided function that inspects a raw YAML map and optionally transforms it. Returns true if the map was modified (triggers an atomic re-save of the source file).

type NormalizeOption added in v0.6.0

type NormalizeOption func(*normalizeOpts)

NormalizeOption configures NormalizeFields behavior.

func WithKindFunc added in v0.6.0

func WithKindFunc(fn KindFunc) NormalizeOption

WithKindFunc registers a classifier for domain-specific types. When NormalizeFields encounters a Go type it does not recognize, it calls fn before panicking. This lets consumer packages define custom FieldKind values (starting at KindLast) without modifying the storage package.

type Option

type Option func(*options)

Option configures store construction via NewStore.

func WithCacheDir

func WithCacheDir() Option

WithCacheDir adds the resolved cache directory to the explicit path list. Resolution: CLAWKER_CACHE_DIR > XDG_CACHE_HOME > ~/.cache/clawker

func WithConfigDir

func WithConfigDir() Option

WithConfigDir adds the resolved config directory to the explicit path list. Resolution: CLAWKER_CONFIG_DIR > XDG_CONFIG_HOME > ~/.config/clawker

func WithDataDir

func WithDataDir() Option

WithDataDir adds the resolved data directory to the explicit path list. Resolution: CLAWKER_DATA_DIR > XDG_DATA_HOME > ~/.local/share/clawker

func WithDefaultFilename added in v0.6.0

func WithDefaultFilename(name string) Option

WithDefaultFilename sets the filename used when writing to a directory with no existing file layers. Without this, filenames[0] is used, which may be a local override variant rather than the main config file.

func WithDefaults

func WithDefaults(yaml string) Option

WithDefaults provides a YAML string as the lowest-priority base layer. The string is parsed and merged before any discovered files. The same constant can be used for scaffolding (clawker init) and defaults.

func WithDefaultsFromStruct added in v0.6.0

func WithDefaultsFromStruct[T Schema]() Option

WithDefaultsFromStruct generates a defaults YAML string from the `default` struct tags of T and registers it as the lowest-priority base layer. This is equivalent to WithDefaults(GenerateDefaultsYAML[T]()).

func WithDirs added in v0.2.4

func WithDirs(dirs ...string) Option

WithDirs adds directories to be probed with dual placement discovery. Each directory uses the same dual-placement logic as walk-up: if a .clawker/ subdirectory exists, it probes .clawker/{filename} (dir form); otherwise it probes .{filename} (flat dotfile form). Both .yaml and .yml extensions are accepted. No registry required. Directories are probed in the order given (first = highest priority). Priority: walk-up > dirs > explicit paths (WithPaths/WithConfigDir/etc.).

func WithDotDefault added in v0.6.0

func WithDotDefault() Option

WithDotDefault enables dual-placement dot-prefix logic in defaultWritePath. When the store has no file layers and falls back to CWD, the filename is written as .{filename} (or .clawker/{filename} if .clawker/ exists) instead of the raw filename. Use this for stores discovered via walk-up where files are dot-prefixed by convention.

func WithFilenames

func WithFilenames(names ...string) Option

WithFilenames sets the ordered list of filenames to discover. All filenames must share the same schema type T. At each walk-up level the first filename in the list takes merge precedence when discovered at the same depth.

func WithLock

func WithLock() Option

WithLock enables flock-based advisory locking for Write operations. Use for stores that need cross-process mutual exclusion (e.g. registry).

func WithMigrations

func WithMigrations(fns ...Migration) Option

WithMigrations registers precondition-based migration functions. Each migration runs independently on every discovered file's raw map. Migrations that return true trigger an atomic re-save of that file.

func WithPaths

func WithPaths(dirs ...string) Option

WithPaths adds explicit directories to the discovery path list. Files are probed as {dir}/{filename} for each configured filename.

func WithStateDir

func WithStateDir() Option

WithStateDir adds the resolved state directory to the explicit path list. Resolution: CLAWKER_STATE_DIR > XDG_STATE_HOME > ~/.local/state/clawker

func WithWalkUp

func WithWalkUp() Option

WithWalkUp enables bounded walk-up discovery from CWD to the registered project root. The store resolves both CWD and project root internally: CWD via os.Getwd(), project root by reading the registry at dataDir(). At each level the store checks for .clawker/{filename} (dir form) first, then .{filename} (flat dotfile form). Walk-up never proceeds past the project root. If CWD is not within a registered project, walk-up is skipped and discovery falls back to explicit paths only.

type Schema added in v0.6.0

type Schema interface {
	Fields() FieldSet
}

Schema is the contract that configuration types implement to expose their field metadata. Store is constrained to Schema, making field descriptions a compile-time requirement for all stored types.

type Store

type Store[T Schema] struct {
	// contains filtered or unexported fields
}

func New added in v0.6.0

func New[T Schema](yaml string, opts ...Option) (*Store[T], error)

New constructs a store. It delegates directly to NewFromString.

func NewFromString

func NewFromString[T Schema](raw string, opts ...Option) (*Store[T], error)

NewFromString constructs a store with an explicit YAML string as the virtual layer, merged on top of defaults. File discovery, migrations, and all other options work normally.

The virtual layer (defaults + raw string) is the lowest-priority data source. Discovered file layers override it. Fields that remain from the virtual layer (not overridden by files) are marked dirty since they have never been persisted.

With no options, the store has no file discovery — useful for seeding a new config that will be written via Write(ToPath(...)).

func NewStore deprecated

func NewStore[T Schema](opts ...Option) (*Store[T], error)

NewStore is an alias for New.

Deprecated: use New.

func (*Store[T]) Delete added in v0.6.0

func (s *Store[T]) Delete(path string) (bool, error)

Delete removes a dotted field path from the node tree (e.g. "agent.editor") and republishes the snapshot. This allows a field to be "unset" so that lower-priority layers can show through on next load.

Empty parent maps are NOT pruned — the tree retains the structure. Returns true if the key was found and deleted, false if it wasn't in the tree.

func (*Store[T]) Get deprecated

func (s *Store[T]) Get() *T

Deprecated: Use Read. Get returns the current snapshot pointer. Identical to Read — exists only to ease migration of call sites.

func (*Store[T]) Layers

func (s *Store[T]) Layers() []LayerInfo

Layers returns information about the discovered configuration layers. Layers are ordered from highest priority (index 0) to lowest. No lock needed — layers are immutable after construction.

func (*Store[T]) MarkForWrite added in v0.6.0

func (s *Store[T]) MarkForWrite(path string)

MarkForWrite adds a dotted field path to the write set so the next Write includes it regardless of whether Set detected a change.

Use this when persisting a value to a specific layer file where the merged result is already identical (e.g. writing the current winning value to a lower-priority layer). Normal Set-based dirty tracking won't catch this because the merged tree didn't change.

func (*Store[T]) Provenance added in v0.6.0

func (s *Store[T]) Provenance(path string) (LayerInfo, bool)

Provenance returns the layer that provided the winning value for the given dotted field path (e.g. "build.image", "security.firewall.enable"). Returns the LayerInfo and true if provenance is known, or zero value and false for fields that came from defaults or have no provenance record.

No lock needed — provenance is immutable after construction.

func (*Store[T]) ProvenanceMap added in v0.6.0

func (s *Store[T]) ProvenanceMap() map[string]string

ProvenanceMap returns a mapping of dotted field paths to their source layer paths. Virtual layer fields (defaults) have an empty path.

No lock needed — provenance is immutable after construction.

func (*Store[T]) Read added in v0.3.2

func (s *Store[T]) Read() *T

Read returns the current immutable snapshot. The returned pointer is safe to hold, inspect, and pass around — it will never be mutated by the store. Set publishes new snapshots via atomic swap; existing readers are unaffected.

Lock-free: uses atomic.Pointer.Load.

func (*Store[T]) Refresh added in v0.6.0

func (s *Store[T]) Refresh() error

Refresh re-discovers layer files, re-reads them from disk, and re-merges into a fresh snapshot. This picks up external modifications to existing files and newly created files found via discovery that weren't written by this store.

Note: Write() already remerges and injects new layers for files it writes, so Refresh is only needed for external changes (e.g. another process modified a config file).

func (*Store[T]) Set

func (s *Store[T]) Set(fn func(*T)) error

Set applies a mutation function to a deep copy of the current value, syncs the change into the node tree, and atomically publishes the new snapshot. Changes are not persisted until Write is called.

The copy-on-write approach means existing Read callers holding the old snapshot are unaffected — they see consistent (stale) data.

After fn runs, the mutated copy is serialized back into the tree using structToMap (which ignores omitempty tags). This ensures that explicit zero-value assignments (e.g. setting a bool to false) are captured in the tree for persistence.

func (*Store[T]) Write

func (s *Store[T]) Write(opts ...WriteOption) error

Write persists dirty fields to disk, then refreshes layer data from the written files so that subsequent Layers() calls return current values.

Only fields mutated since the last Write (via Set or Delete) are written. Set fields are merged into the target file; deleted fields are removed from it. This ensures per-field precision in multi-layer configurations.

Without options, each dirty field is routed to the layer it originated from (via provenance). Fields without provenance route to the highest-priority layer.

With ToPath, all dirty fields are written to the given absolute path. With ToLayer, all dirty fields are written to the specified layer.

Write sequence per target: read existing file → merge set fields → remove deleted fields → atomic write (temp+rename). If locking is enabled (WithLock), each file write is wrapped in a cross-process flock.

After a successful write, dirty tracking is cleared and layer data is refreshed from disk.

func (*Store[T]) WriteTo added in v0.6.0

func (s *Store[T]) WriteTo(path string) error

WriteTo persists dirty fields to the given absolute path. Convenience wrapper for Write(ToPath(path)) so callers don't need to import the storage package for the WriteOption constructor.

type WriteOption added in v0.6.0

type WriteOption struct {
	// contains filtered or unexported fields
}

WriteOption configures how Write persists data.

func ToLayer added in v0.6.0

func ToLayer(idx int) WriteOption

ToLayer targets Write to a specific discovered layer by index. Layer indices correspond to Layers() ordering (0 = highest priority).

func ToPath added in v0.6.0

func ToPath(path string) WriteOption

ToPath targets Write to an explicit absolute filesystem path. Use this when writing to a new file or a known path outside the discovered layer set.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL