v0.22.1 cli flag and behavior, and test output improvement

This commit is contained in:
lixen committed 2026-10-03 21:57:41 -04:00
1 parent 71d1b82f83
commit 4eba6fc834
89 files changed
+1531 -661

No files matched your search

+1
View File
@@ -244,6 +244,7 @@ type NullSinkOptions struct{}
type ConsoleSinkOptions struct {
Target string `toml:"target"` // "stdout", "stderr"
BufferSize int64 `toml:"buffer_size"`
Escape string `toml:"escape"` // control characters as <hex>: "auto" (on a terminal), "always", "never"
}
// FileSinkOptions defines settings for a file-based sink
+23 -49
View File
@@ -3,11 +3,12 @@ package config
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/core"
lconfig "github.com/lixenwraith/config"
)
@@ -44,6 +45,11 @@ func Load(args []string) (*Manager, error) {
}
m := &Manager{path: configPath, explicit: isExplicit, specs: specs}
initial := defaults()
if _, err := os.Stat(configPath); errors.Is(err, fs.ErrNotExist) {
// Without a file lw is a command-line tool: warnings and errors only
initial.Logging.Level = "warn"
initial.StatusReporter = false
}
cfg, err := lconfig.NewBuilder().
WithTarget(initial).
WithEnvPrefix("LOGWISP_").
@@ -134,20 +140,13 @@ func (m *Manager) Watch() <-chan string {
func (m *Manager) Close() { m.config.StopAutoUpdate() }
// defaults provides the default configuration values for the application
// defaults provides the default configuration values for the application.
// Data goes to stdout, so lw's own log goes to stderr.
func defaults() *Config {
return &Config{
// Top-level flag defaults
ShowVersion: false,
Quiet: false,
// Runtime behavior defaults
StatusReporter: true,
ConfigAutoReload: false,
// Existing defaults
StatusReporter: true,
Logging: &LogConfig{
Output: "stdout",
Output: "stderr",
Level: "info",
Format: "txt",
File: &LogFileConfig{
@@ -158,45 +157,20 @@ func defaults() *Config {
RetentionHours: 168, // 7 days
},
Console: &LogConsoleConfig{
Target: "stdout",
},
},
Pipelines: []PipelineConfig{
{
Name: "default_pipeline",
Flow: &FlowConfig{
RateLimit: &RateLimitConfig{
Rate: 5,
Burst: 10,
Policy: "drop",
MaxEntrySizeBytes: 65536,
},
Format: &FormatConfig{
Type: "json",
SanitizerPolicy: "json",
},
},
PluginSources: []PluginSourceConfig{
{
ID: "default_source",
Type: "random",
Config: map[string]any{
"special": true,
},
},
},
PluginSinks: []PluginSinkConfig{
{
ID: "default_sink",
Type: "console",
Config: map[string]any{
"target": "stdout",
"buffer_size": 100,
},
},
},
Target: "stderr",
},
},
Pipelines: []PipelineConfig{pipeDefault("pipe")},
}
}
// pipeDefault is a filter in the Unix sense: stdin to stdout, line for line
func pipeDefault(name string) PipelineConfig {
return PipelineConfig{
Name: name,
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{}}},
}
}
+36 -3
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/testutil"
)
func TestResolveConfigArguments(t *testing.T) {
@@ -56,9 +56,9 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
t.Fatalf("snapshot: %+v %v", first, err)
}
first.Logging.Level = "mutated"
first.Pipelines[0].PluginSources[0].Config["special"] = false
first.Pipelines[0].PluginSources[0].Config["buffer_size"] = 1
second, err := m.Snapshot()
if err != nil || second.Logging.Level != "warn" || second.Pipelines[0].PluginSources[0].Config["special"] != true {
if _, aliased := second.Pipelines[0].PluginSources[0].Config["buffer_size"]; err != nil || second.Logging.Level != "warn" || aliased {
t.Fatalf("snapshot aliases another result: %+v %v", second, err)
}
env, err := Load([]string{"-c=selected.toml"})
@@ -268,3 +268,36 @@ port = 9000
}
}
}
// A file makes lw a service, logging at info with the status reporter; without
// one it is a command-line tool that reports warnings and errors only.
func TestNoConfigFileMeansQuietLogging(t *testing.T) {
isolateConfig(t)
for _, c := range []struct {
file, level string
reporter bool
}{{"", "warn", false}, {"[logging]\noutput = \"stderr\"\n", "info", true}} {
if c.file != "" {
testutil.WriteFile(t, "logwisp.toml", c.file)
}
m, err := Load(nil)
if err != nil {
t.Fatal(err)
}
cfg, err := m.Snapshot()
m.Close()
if err != nil || cfg.Logging.Level != c.level || cfg.StatusReporter != c.reporter || cfg.Logging.Output != "stderr" {
t.Fatalf("file %q: %+v %+v %v", c.file, cfg, cfg.Logging, err)
}
}
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
os.Remove("logwisp.toml")
m, err := Load(nil)
if err != nil {
t.Fatal(err)
}
defer m.Close()
if cfg, err := m.Snapshot(); err != nil || cfg.Logging.Level != "debug" {
t.Fatalf("environment over the file-less default: %+v %v", cfg, err)
}
}
+11 -1
View File
@@ -131,7 +131,8 @@ func envPipelineSpecs() []pipelineSpec {
}
// buildPipelines turns specs, in order, into pipelines; specs before the first
// --pipeline go to one named "cli". Every call returns fresh maps.
// --pipeline go to one named "cli". A pipeline without a source reads stdin,
// one without a sink writes stdout. Every call returns fresh maps.
func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
var pipelines []PipelineConfig
var seen map[*specKind]bool
@@ -160,6 +161,15 @@ func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
return nil, fmt.Errorf("%s %s: %w", s.name, s.value, err)
}
}
for i := range pipelines {
pipe := pipeDefault(pipelines[i].Name)
if len(pipelines[i].PluginSources) == 0 {
pipelines[i].PluginSources = pipe.PluginSources
}
if len(pipelines[i].PluginSinks) == 0 {
pipelines[i].PluginSinks = pipe.PluginSinks
}
}
return pipelines, nil
}
+20 -4
View File
@@ -4,9 +4,10 @@ import (
"errors"
"os"
"reflect"
"strings"
"testing"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/testutil"
)
func loadPipelines(t *testing.T, args ...string) []PipelineConfig {
@@ -160,6 +161,21 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
}
}
// A spec pipeline without a source reads stdin and one without a sink writes
// stdout, as a filter does; two pipelines cannot both read stdin.
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
isolateConfig(t)
got := loadPipelines(t, "--filter", "include,patterns=ERROR")
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
t.Fatalf("pipeline: %+v", p)
}
_, err := Load([]string{"--pipeline", "a", "--sink", "null", "--pipeline", "b", "--sink", "null"})
if err == nil || !strings.Contains(err.Error(), `console source already reads stdin in pipeline "a"`) {
t.Fatalf("second stdin reader: %v", err)
}
}
// Without a file a reload still rebuilds; once the discovered default
// appears it is read, and its pipelines still yield to the specs. Its later
// removal fails the reload instead of keeping the removed file's values.
@@ -171,13 +187,13 @@ func TestReloadKeepsSpecPipelines(t *testing.T) {
}
defer m.Close()
first, err := m.Reload()
if err != nil || !first.StatusReporter {
if err != nil || first.StatusReporter {
t.Fatalf("reload without a file: %+v %v", first, err)
}
first.Pipelines[0].PluginSources[0].Config["special"] = "mutated"
testutil.WriteFile(t, "logwisp.toml", "status_reporter = false\n[[pipelines]]\nname = \"file\"\n")
testutil.WriteFile(t, "logwisp.toml", "status_reporter = true\n[[pipelines]]\nname = \"file\"\n")
next, err := m.Reload()
if err != nil || next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" ||
if err != nil || !next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" ||
next.Pipelines[0].PluginSources[0].Config["special"] != "true" {
t.Fatalf("reload: %+v %v", next, err)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package config
import (
"testing"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/testutil"
)
func isolateConfig(t *testing.T) {
+10
View File
@@ -38,6 +38,7 @@ func ValidateConfig(cfg *Config) error {
return fmt.Errorf("logging: %w", err)
}
stdinReader := ""
for i, p := range cfg.Pipelines {
if err := lconfig.NonEmpty(p.Name); err != nil {
return fmt.Errorf("pipeline[%d].name: %w", i, err)
@@ -48,6 +49,15 @@ func ValidateConfig(cfg *Config) error {
if len(p.PluginSinks) == 0 {
return fmt.Errorf("pipeline[%d]: no sinks defined", i)
}
for _, src := range p.PluginSources {
if src.Type != "console" {
continue
}
if stdinReader != "" {
return fmt.Errorf("pipeline %q: a console source already reads stdin in pipeline %q", p.Name, stdinReader)
}
stdinReader = p.Name
}
}
return nil