feat: complete DTO pattern — struct literals, no constructors

- All New* constructors removed (NewApp, NewIO, NewCoreCli, NewBus, NewService, NewCoreI18n, NewConfig)
- New() uses pure struct literals: &App{}, &Fs{}, &Config{ConfigOpts:}, &Cli{opts:}, &Service{}, &Ipc{}, &I18n{}
- Ipc methods moved to func (c *Core) — Ipc is now a DTO
- LockApply only called from WithServiceLock, not on every New()
- Service map lazy-inits on first write
- CliOpts DTO with Version/Name/Description

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-18 10:53:13 +00:00
parent 173067719e
commit c2227fbb33
10 changed files with 122 additions and 205 deletions

View file

@ -6,7 +6,6 @@
package core
import (
"os"
"os/exec"
"path/filepath"
)
@ -33,25 +32,6 @@ type App struct {
Runtime any
}
// NewApp creates a App with the given identity.
// Filename and Path are auto-detected from the running binary.
func NewApp(name, description, version string) *App {
app := &App{
Name: name,
Version: version,
Description: description,
}
// Auto-detect executable identity
if exe, err := os.Executable(); err == nil {
if abs, err := filepath.Abs(exe); err == nil {
app.Path = abs
app.Filename = filepath.Base(abs)
}
}
return app
}
// Find locates a program on PATH and returns a App for it.
// Returns nil if not found.

View file

@ -13,9 +13,16 @@ import (
// CliAction represents a function called when a command is invoked.
type CliAction func() error
// CliOpts configures a Cli.
type CliOpts struct {
Version string
Name string
Description string
}
// Cli is the CLI command framework.
type Cli struct {
app *App
opts *CliOpts
rootCommand *Command
defaultCommand *Command
preRunCommand func(*Cli) error
@ -27,14 +34,14 @@ type Cli struct {
// defaultBannerFunction prints a banner for the application.
func defaultBannerFunction(c *Cli) string {
version := ""
if c.app != nil && c.app.Version != "" {
version = " " + c.app.Version
if c.opts != nil && c.opts.Version != "" {
version = " " + c.opts.Version
}
name := ""
description := ""
if c.app != nil {
name = c.app.Name
description = c.app.Description
if c.opts != nil {
name = c.opts.Name
description = c.opts.Description
}
if description != "" {
return fmt.Sprintf("%s%s - %s", name, version, description)
@ -42,24 +49,6 @@ func defaultBannerFunction(c *Cli) string {
return fmt.Sprintf("%s%s", name, version)
}
// NewCoreCli creates a new CLI bound to the given App identity.
func NewCoreCli(app *App) *Cli {
name := ""
description := ""
if app != nil {
name = app.Name
description = app.Description
}
result := &Cli{
app: app,
bannerFunction: defaultBannerFunction,
}
result.rootCommand = NewCommand(name, description)
result.rootCommand.setApp(result)
result.rootCommand.setParentCommandPath("")
return result
}
// Command returns the root command.
func (c *Cli) Command() *Command {
@ -68,24 +57,24 @@ func (c *Cli) Command() *Command {
// Version returns the application version string.
func (c *Cli) Version() string {
if c.app != nil {
return c.app.Version
if c.opts != nil {
return c.opts.Version
}
return ""
}
// Name returns the application name.
func (c *Cli) Name() string {
if c.app != nil {
return c.app.Name
if c.opts != nil {
return c.opts.Name
}
return c.rootCommand.name
}
// ShortDescription returns the application short description.
func (c *Cli) ShortDescription() string {
if c.app != nil {
return c.app.Description
if c.opts != nil {
return c.opts.Description
}
return c.rootCommand.shortdescription
}

View file

@ -1,7 +1,6 @@
// SPDX-License-Identifier: EUPL-1.2
// Settings, feature flags, and typed configuration for the Core framework.
// Named after /etc — the configuration directory.
package core
@ -9,62 +8,62 @@ import (
"sync"
)
// Var is a variable that can be set, unset, and queried for its state.
// Zero value is unset.
// ConfigVar is a variable that can be set, unset, and queried for its state.
type ConfigVar[T any] struct {
val T
set bool
}
// Get returns the value, or the zero value if unset.
func (v *ConfigVar[T]) Get() T { return v.val }
// Set sets the value and marks it as set.
func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true }
// IsSet returns true when a value has been set.
func (v *ConfigVar[T]) Get() T { return v.val }
func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true }
func (v *ConfigVar[T]) IsSet() bool { return v.set }
// Unset resets to zero value and marks as unset.
func (v *ConfigVar[T]) Unset() {
v.set = false
var zero T
v.val = zero
}
// NewVar creates a Var with the given value (marked as set).
func NewConfigVar[T any](val T) ConfigVar[T] {
return ConfigVar[T]{val: val, set: true}
}
// Config holds configuration settings and feature flags.
type Config struct {
mu sync.RWMutex
settings map[string]any
features map[string]bool
// ConfigOpts holds configuration data.
type ConfigOpts struct {
Settings map[string]any
Features map[string]bool
}
// NewConfig creates a new configuration store.
func NewConfig() *Config {
return &Config{
settings: make(map[string]any),
features: make(map[string]bool),
func (o *ConfigOpts) init() {
if o.Settings == nil {
o.Settings = make(map[string]any)
}
if o.Features == nil {
o.Features = make(map[string]bool)
}
}
// Config holds configuration settings and feature flags.
type Config struct {
*ConfigOpts
mu sync.RWMutex
}
// Set stores a configuration value by key.
func (e *Config) Set(key string, val any) {
e.mu.Lock()
e.settings[key] = val
e.ConfigOpts.init()
e.Settings[key] = val
e.mu.Unlock()
}
// Get retrieves a configuration value by key.
// Returns (value, true) if found, (zero, false) if not.
func (e *Config) Get(key string) (any, bool) {
e.mu.RLock()
val, ok := e.settings[key]
e.mu.RUnlock()
defer e.mu.RUnlock()
if e.ConfigOpts == nil || e.Settings == nil {
return nil, false
}
val, ok := e.Settings[key]
return val, ok
}
@ -73,7 +72,6 @@ func (e *Config) Int(key string) int { return ConfigGet[int](e, key) }
func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) }
// ConfigGet retrieves a typed configuration value.
// Returns zero value if key is missing or type doesn't match.
func ConfigGet[T any](e *Config, key string) T {
val, ok := e.Get(key)
if !ok {
@ -86,34 +84,32 @@ func ConfigGet[T any](e *Config, key string) T {
// --- Feature Flags ---
// Enable enables a feature flag.
func (e *Config) Enable(feature string) {
e.mu.Lock()
e.features[feature] = true
e.ConfigOpts.init()
e.Features[feature] = true
e.mu.Unlock()
}
// Disable disables a feature flag.
func (e *Config) Disable(feature string) {
e.mu.Lock()
e.features[feature] = false
e.ConfigOpts.init()
e.Features[feature] = false
e.mu.Unlock()
}
// Enabled returns true if the feature is enabled.
func (e *Config) Enabled(feature string) bool {
e.mu.RLock()
v := e.features[feature]
v := e.Features[feature]
e.mu.RUnlock()
return v
}
// Features returns all enabled feature names.
func (e *Config) EnabledFeatures() []string {
e.mu.RLock()
defer e.mu.RUnlock()
var result []string
for k, v := range e.features {
for k, v := range e.Features {
if v {
result = append(result, k)
}

View file

@ -9,6 +9,7 @@ import (
"embed"
"fmt"
"io/fs"
"path/filepath"
"reflect"
"strings"
)
@ -88,20 +89,18 @@ type ActionTaskCompleted struct {
// New creates a Core instance with the provided options.
func New(opts ...Option) (*Core, error) {
defaultFS, _ := NewIO("/")
app := NewApp("", "", "")
c := &Core{
app: app,
fs: defaultFS,
cfg: &Config{settings: make(map[string]any), features: make(map[string]bool)},
app: &App{},
fs: &Fs{root: "/"},
cfg: &Config{ConfigOpts: &ConfigOpts{}},
err: &ErrPan{},
log: &ErrLog{&ErrOpts{Log: defaultLog}},
cli: NewCoreCli(app),
srv: &Service{Services: make(map[string]any)},
cli: &Cli{opts: &CliOpts{}},
srv: &Service{},
lock: &Lock{},
ipc: &Ipc{},
i18n: &I18n{},
}
c.ipc = &Ipc{core: c}
for _, o := range opts {
if err := o(c); err != nil {
@ -109,7 +108,6 @@ func New(opts ...Option) (*Core, error) {
}
}
c.LockApply()
return c, nil
}
@ -193,11 +191,14 @@ func WithAssets(efs embed.FS) Option {
// WithIO sandboxes filesystem I/O to a root path.
func WithIO(root string) Option {
return func(c *Core) error {
io, err := NewIO(root)
abs, err := filepath.Abs(root)
if err != nil {
return E("core.WithIO", "failed to create IO at "+root, err)
}
c.fs = io
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
c.fs = &Fs{root: abs}
return nil
}
}
@ -218,6 +219,7 @@ func WithMount(fsys fs.FS, basedir string) Option {
func WithServiceLock() Option {
return func(c *Core) error {
c.LockEnable()
c.LockApply()
return nil
}
}

View file

@ -15,9 +15,9 @@ import (
// Core is the central application object that manages services, assets, and communication.
type Core struct {
app *App // c.App() — Application identity + optional GUI runtime
emb *Embed // c.Embed() — Mounted embedded assets (read-only)
emb *Embed // c.Embed() — Mounted embedded assets (read-only)
fs *Fs // c.Fs() — Local filesystem I/O (sandboxable)
cfg *Config // c.Config() — Configuration, settings, feature flags
cfg *Config // c.Config() — Configuration, settings, feature flags
err *ErrPan // c.Error() — Panic recovery and crash reporting
log *ErrLog // c.Log() — Structured logging + error wrapping
cli *Cli // c.Cli() — CLI command framework
@ -34,9 +34,9 @@ type Core struct {
// --- Accessors ---
func (c *Core) App() *App { return c.app }
func (c *Core) Embed() *Embed { return c.emb }
func (c *Core) Embed() *Embed { return c.emb }
func (c *Core) Fs() *Fs { return c.fs }
func (c *Core) Config() *Config { return c.cfg }
func (c *Core) Config() *Config { return c.cfg }
func (c *Core) Error() *ErrPan { return c.err }
func (c *Core) Log() *ErrLog { return c.log }
func (c *Core) Cli() *Cli { return c.cli }
@ -44,16 +44,14 @@ func (c *Core) IPC() *Ipc { return c.ipc }
func (c *Core) I18n() *I18n { return c.i18n }
func (c *Core) Core() *Core { return c }
// --- IPC ---
// --- IPC (uppercase aliases) ---
func (c *Core) ACTION(msg Message) error { return c.ipc.Action(msg) }
func (c *Core) RegisterAction(handler func(*Core, Message) error) { c.ipc.RegisterAction(handler) }
func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { c.ipc.RegisterActions(handlers...) }
func (c *Core) QUERY(q Query) (any, bool, error) { return c.ipc.Query(q) }
func (c *Core) QUERYALL(q Query) ([]any, error) { return c.ipc.QueryAll(q) }
func (c *Core) PERFORM(t Task) (any, bool, error) { return c.ipc.Perform(t) }
func (c *Core) RegisterQuery(handler QueryHandler) { c.ipc.RegisterQuery(handler) }
func (c *Core) RegisterTask(handler TaskHandler) { c.ipc.RegisterTask(handler) }
func (c *Core) ACTION(msg Message) error { return c.Action(msg) }
func (c *Core) RegisterAction(handler func(*Core, Message) error) { c.RegisterAction(handler) }
func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { c.RegisterActions(handlers...) }
func (c *Core) QUERY(q Query) (any, bool, error) { return c.Query(q) }
func (c *Core) QUERYALL(q Query) ([]any, error) { return c.QueryAll(q) }
func (c *Core) PERFORM(t Task) (any, bool, error) { return c.Perform(t) }
// --- Error+Log ---
@ -73,4 +71,3 @@ func (c *Core) Must(err error, op, msg string) {
}
// --- Global Instance ---

View file

@ -17,22 +17,6 @@ type Fs struct {
root string
}
// NewIO creates a Fs rooted at the given directory.
// Pass "/" for full filesystem access, or a specific path to sandbox.
func NewIO(root string) (*Fs, error) {
abs, err := filepath.Abs(root)
if err != nil {
return nil, err
}
// Resolve symlinks so sandbox checks compare like-for-like.
// On macOS, /var is a symlink to /private/var — without this,
// EvalSymlinks on child paths resolves to /private/var/... while
// root stays /var/..., causing false sandbox escape detections.
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
return &Fs{root: abs}, nil
}
// path sanitises and returns the full path.
// Absolute paths are sandboxed under root (unless root is "/").

View file

@ -47,10 +47,6 @@ type I18n struct {
translator Translator // registered implementation (nil until set)
}
// NewCoreI18n creates a new i18n manager.
func NewCoreI18n() *I18n {
return &I18n{}
}
// AddLocales adds locale mounts (called during service registration).
func (i *I18n) AddLocales(mounts ...*Embed) {

View file

@ -12,10 +12,8 @@ import (
"sync"
)
// Ipc owns action, query, and task dispatch between services.
// Ipc holds IPC dispatch data.
type Ipc struct {
core *Core
ipcMu sync.RWMutex
ipcHandlers []func(*Core, Message) error
@ -26,48 +24,27 @@ type Ipc struct {
taskHandlers []TaskHandler
}
// NewBus creates an empty message bus bound to the given Core.
func NewBus(c *Core) *Ipc {
return &Ipc{core: c}
}
// Action dispatches a message to all registered IPC handlers.
func (b *Ipc) Action(msg Message) error {
b.ipcMu.RLock()
handlers := slices.Clone(b.ipcHandlers)
b.ipcMu.RUnlock()
func (c *Core) Action(msg Message) error {
c.ipc.ipcMu.RLock()
handlers := slices.Clone(c.ipc.ipcHandlers)
c.ipc.ipcMu.RUnlock()
var agg error
for _, h := range handlers {
if err := h(b.core, msg); err != nil {
if err := h(c, msg); err != nil {
agg = errors.Join(agg, err)
}
}
return agg
}
// RegisterAction adds a single IPC handler.
func (b *Ipc) RegisterAction(handler func(*Core, Message) error) {
b.ipcMu.Lock()
b.ipcHandlers = append(b.ipcHandlers, handler)
b.ipcMu.Unlock()
}
// RegisterActions adds multiple IPC handlers.
func (b *Ipc) RegisterActions(handlers ...func(*Core, Message) error) {
b.ipcMu.Lock()
b.ipcHandlers = append(b.ipcHandlers, handlers...)
b.ipcMu.Unlock()
}
// Query dispatches a query to handlers until one responds.
func (b *Ipc) Query(q Query) (any, bool, error) {
b.queryMu.RLock()
handlers := slices.Clone(b.queryHandlers)
b.queryMu.RUnlock()
func (c *Core) Query(q Query) (any, bool, error) {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(b.core, q)
result, handled, err := h(c, q)
if handled {
return result, true, err
}
@ -75,16 +52,15 @@ func (b *Ipc) Query(q Query) (any, bool, error) {
return nil, false, nil
}
// QueryAll dispatches a query to all handlers and collects all responses.
func (b *Ipc) QueryAll(q Query) ([]any, error) {
b.queryMu.RLock()
handlers := slices.Clone(b.queryHandlers)
b.queryMu.RUnlock()
func (c *Core) QueryAll(q Query) ([]any, error) {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
var results []any
var agg error
for _, h := range handlers {
result, handled, err := h(b.core, q)
result, handled, err := h(c, q)
if err != nil {
agg = errors.Join(agg, err)
}
@ -95,31 +71,8 @@ func (b *Ipc) QueryAll(q Query) ([]any, error) {
return results, agg
}
// RegisterQuery adds a query handler.
func (b *Ipc) RegisterQuery(handler QueryHandler) {
b.queryMu.Lock()
b.queryHandlers = append(b.queryHandlers, handler)
b.queryMu.Unlock()
}
// Perform dispatches a task to handlers until one executes it.
func (b *Ipc) Perform(t Task) (any, bool, error) {
b.taskMu.RLock()
handlers := slices.Clone(b.taskHandlers)
b.taskMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(b.core, t)
if handled {
return result, true, err
}
}
return nil, false, nil
}
// RegisterTask adds a task handler.
func (b *Ipc) RegisterTask(handler TaskHandler) {
b.taskMu.Lock()
b.taskHandlers = append(b.taskHandlers, handler)
b.taskMu.Unlock()
func (c *Core) RegisterQuery(handler QueryHandler) {
c.ipc.queryMu.Lock()
c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler)
c.ipc.queryMu.Unlock()
}

View file

@ -17,12 +17,6 @@ type Service struct {
locked bool
}
// NewService creates an empty service registry.
func NewService() *Service {
return &Service{
Services: make(map[string]any),
}
}
// --- Core service methods ---
@ -58,6 +52,9 @@ func (c *Core) Service(args ...any) any {
return E("core.Service", fmt.Sprintf("service %q already registered", name), nil)
}
svc := args[1]
if c.srv.Services == nil {
c.srv.Services = make(map[string]any)
}
c.srv.Services[name] = svc
if st, ok := svc.(Startable); ok {
c.srv.startables = append(c.srv.startables, st)

View file

@ -4,7 +4,10 @@
package core
import "fmt"
import (
"fmt"
"slices"
)
// TaskState holds background task state.
type TaskState struct {
@ -38,3 +41,23 @@ func (c *Core) PerformAsync(t Task) string {
func (c *Core) Progress(taskID string, progress float64, message string, t Task) {
_ = c.ACTION(ActionTaskProgress{TaskID: taskID, Task: t, Progress: progress, Message: message})
}
func (c *Core) Perform(t Task) (any, bool, error) {
c.ipc.taskMu.RLock()
handlers := slices.Clone(c.ipc.taskHandlers)
c.ipc.taskMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(c, t)
if handled {
return result, true, err
}
}
return nil, false, nil
}
func (c *Core) RegisterTask(handler TaskHandler) {
c.ipc.taskMu.Lock()
c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler)
c.ipc.taskMu.Unlock()
}