Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@

### Fixed

- Linux DHCP lease discovery now matches explicit dhclient interface names
exactly, so a lease for a similarly named interface such as `eth0-backup`
is not attributed to `eth0`, and parses lease blocks without being confused
by braces inside comments or quoted strings. Malformed lease blocks,
unterminated quoted strings, and malformed interface values no longer stop
later valid blocks from being scanned or suppress lease filename fallback,
and file-level interface declarations with multiple unqualified historical
lease blocks now use the latest DHCP server identifier. Multiple exact
matching lease blocks now let the latest matching block control the server
value, preventing stale servers from older leases when the newest lease has
no `dhcp-server-identifier`, and commented DHCP server option text is no
longer treated as a real DHCP server.
- YAML output now renders map values inside sequences with flow-map braces,
preserving each key when a nested sequence item has more than one map key.
- Plan 9 `system_uptime.days`, `system_uptime.hours`, and
`system_uptime.seconds` now use the same 64-bit numeric value types as other
supported platforms.
- Snapshot lookups and defensive copies now clone mutable values, including
pointer targets, pointer-bearing map keys, and cyclic graph references,
instead of returning shared Snapshot state for the public fact graph.
- QEMU/KVM lab guests on OpenBSD, NetBSD, DragonFly BSD, illumos, and Windows
now report `virtual: "kvm"` and `is_virtual: true` when native DMI/SMBIOS,
PCI, or WMI indicators expose the VM.
Expand Down
18 changes: 13 additions & 5 deletions cmd/facts/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,30 @@ package main

import (
"fmt"
"io"
"os"

"github.com/ncode/facts/internal/app"
"github.com/ncode/facts/internal/cli"
)

func main() {
if err := app.Run(os.Stdout, os.Stderr, os.Args[1:]); err != nil {
if code := runMain(os.Stdout, os.Stderr, os.Args[1:]); code != 0 {
os.Exit(code)
}
}

func runMain(stdout, stderr io.Writer, args []string) int {
if err := app.Run(stdout, stderr, args); err != nil {
if status, ok := err.(app.ExitStatus); ok {
os.Exit(status.Code())
return status.Code()
}
if cli.IsOptionError(err) {
fmt.Fprintf(os.Stderr, "ERROR Facts::OptionsValidator - %v\n", err)
fmt.Fprintf(stderr, "ERROR Facts::OptionsValidator - %v\n", err)
} else {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(stderr, err)
}
os.Exit(1)
return 1
}
return 0
}
74 changes: 74 additions & 0 deletions cmd/facts/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,72 @@ func TestFactsCommand_version(t *testing.T) {
}
}

func TestMainFunctionVersion(t *testing.T) {
oldArgs, oldStdout, oldStderr := os.Args, os.Stdout, os.Stderr
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
os.Args, os.Stdout, os.Stderr = oldArgs, oldStdout, oldStderr
_ = stdoutR.Close()
_ = stderrR.Close()
})
os.Args, os.Stdout, os.Stderr = []string{"facts", "--version"}, stdoutW, stderrW

main()
_ = stdoutW.Close()
_ = stderrW.Close()

stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
if _, err := stdout.ReadFrom(stdoutR); err != nil {
t.Fatal(err)
}
if _, err := stderr.ReadFrom(stderrR); err != nil {
t.Fatal(err)
}
if got, want := stdout.String(), engine.Version+"\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}

func TestRunMainReportsOptionErrors(t *testing.T) {
var stdout, stderr bytes.Buffer

if code := runMain(&stdout, &stderr, []string{"-z"}); code != 1 {
t.Fatalf("runMain() code = %d, want 1", code)
}
if !strings.Contains(stdout.String(), "facts [options] [query]") {
t.Fatalf("stdout = %q, want usage text", stdout.String())
}
if got, want := stderr.String(), "ERROR Facts::OptionsValidator - unrecognised option '-z'\n"; got != want {
t.Fatalf("stderr = %q, want %q", got, want)
}
}

func TestRunMainReportsGenericErrors(t *testing.T) {
writeErr := errors.New("stdout closed")
var stderr bytes.Buffer

if code := runMain(errorWriter{err: writeErr}, &stderr, []string{"--version"}); code != 1 {
t.Fatalf("runMain() code = %d, want 1", code)
}
if got := stderr.String(); got == "" || strings.Contains(got, "Facts::OptionsValidator") {
t.Fatalf("stderr = %q, want generic app error", got)
}
if got := stderr.String(); !strings.Contains(got, writeErr.Error()) {
t.Fatalf("stderr = %q, want %q", got, writeErr)
}
}

func TestFactsCommand_noQueryPrintsStructuredFacts(t *testing.T) {
bin := buildFactsCommand(t)

Expand Down Expand Up @@ -174,6 +240,14 @@ func TestFactsCommand_invalidConcatenatedShortFlagReportsOptionsValidatorError(t
}
}

type errorWriter struct {
err error
}

func (w errorWriter) Write([]byte) (int, error) {
return 0, w.err
}

func buildFactsCommand(t *testing.T) string {
t.Helper()
commandBuild.once.Do(func() {
Expand Down
43 changes: 43 additions & 0 deletions engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"log/slog"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
Expand Down Expand Up @@ -62,6 +63,15 @@ func TestNew_defaultEngineIsHermetic(t *testing.T) {
}
}

func TestNewIgnoresNilOptionsAndReturnsOptionErrors(t *testing.T) {
if eng, err := New(nil); err != nil || eng == nil {
t.Fatalf("New(nil) = %#v, %v; want engine, nil", eng, err)
}
if eng, err := New(WithConfigFile("")); err == nil || eng != nil {
t.Fatalf("New(WithConfigFile(empty)) = %#v, %v; want nil engine and error", eng, err)
}
}

func TestEngineDiscover_uninitializedReceiverReturnsError(t *testing.T) {
var nilEngine *Engine
if snap, err := nilEngine.Discover(context.Background()); err == nil || snap != nil {
Expand Down Expand Up @@ -625,6 +635,39 @@ func TestAs_rejectsMapAnyKeyStringCollisions(t *testing.T) {
}
}

func TestAs_normalizesNestedMapAnyValues(t *testing.T) {
eng, err := New(WithFact("nested", func(context.Context) (any, error) {
return map[any]any{
"items": []any{
map[any]any{"name": "web", "port": 443},
},
}, nil
}))
if err != nil {
t.Fatal(err)
}
snap, err := eng.Discover(context.Background())
if err != nil {
t.Fatal(err)
}

type item struct {
Name string `json:"name"`
Port int `json:"port"`
}
type nestedFact struct {
Items []item `json:"items"`
}
got, err := As[nestedFact](snap, "nested")
if err != nil {
t.Fatal(err)
}
want := nestedFact{Items: []item{{Name: "web", Port: 443}}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("As[nestedFact](nested) = %#v, want %#v", got, want)
}
}

func TestAs_missingFactReturnsErrFactNotFound(t *testing.T) {
snap := hermeticSnapshot()

Expand Down
123 changes: 123 additions & 0 deletions internal/app/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package app

import (
"bytes"
"errors"
"os"
"strings"
"testing"
)

func TestExitStatusReportsCodeAndError(t *testing.T) {
status := ExitStatus(42)

if got := status.Code(); got != 42 {
t.Fatalf("Code() = %d, want 42", got)
}
if got := status.Error(); got != "exit status 42" {
t.Fatalf("Error() = %q, want exit status 42", got)
}
}

func TestOptionErrorWritesHelpAndReturnsOriginalError(t *testing.T) {
var stdout bytes.Buffer
err := errors.New("bad option")

got := optionError(&stdout, err)
if got != err {
t.Fatalf("optionError() = %v, want original error", got)
}
if got, want := stdout.String(), helpText(); got != want {
t.Fatalf("stdout = %q, want help text %q", got, want)
}
for _, marker := range []string{"Usage", "--config", "--help"} {
if !strings.Contains(stdout.String(), marker) {
t.Fatalf("stdout missing help marker %q: %q", marker, stdout.String())
}
}
}

func TestOptionErrorReturnsHelpWriteError(t *testing.T) {
writeErr := errors.New("stdout closed")
originalErr := errors.New("bad option")

got := optionError(errorWriter{err: writeErr}, originalErr)
if !errors.Is(got, writeErr) {
t.Fatalf("optionError() = %v, want help write error %v", got, writeErr)
}
}

func TestResolveColorHonorsFlagsAndWriter(t *testing.T) {
if !resolveColor(true, false, &bytes.Buffer{}) {
t.Fatal("resolveColor(force=true) = false, want true")
}
if resolveColor(true, true, &bytes.Buffer{}) {
t.Fatal("resolveColor(disable=true) = true, want false")
}
if resolveColor(false, false, &bytes.Buffer{}) {
t.Fatal("resolveColor(non-file writer) = true, want false")
}

file, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
defer file.Close()
if resolveColor(false, false, file) {
t.Fatal("resolveColor(regular file) = true, want false")
}
}

func TestResolvedLogOptionsConflict(t *testing.T) {
tests := []struct {
name string
debug bool
verbose bool
logLevel string
want bool
}{
{name: "debug and verbose conflict", debug: true, verbose: true, want: true},
{name: "no log level has no conflict", debug: true},
{name: "placeholder log_level has no conflict", debug: true, logLevel: "log_level"},
{name: "debug with debug log level is redundant", debug: true, logLevel: "debug"},
{name: "debug with trace log level is redundant", debug: true, logLevel: "trace"},
{name: "verbose with info log level is redundant", verbose: true, logLevel: "info"},
{name: "debug with info log level conflicts", debug: true, logLevel: "info", want: true},
{name: "verbose with debug log level conflicts", verbose: true, logLevel: "debug", want: true},
{name: "bare log level has no conflict", logLevel: "debug"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolvedLogOptionsConflict(tt.debug, tt.verbose, tt.logLevel)
if got != tt.want {
t.Fatalf("resolvedLogOptionsConflict(%v, %v, %q) = %v, want %v", tt.debug, tt.verbose, tt.logLevel, got, tt.want)
}
})
}
}

func TestWriteErrorColorContract(t *testing.T) {
var stderr bytes.Buffer

writeError(&stderr, "boom", true)

if got, want := stderr.String(), "\x1b[31mERROR Facts - boom\x1b[0m\n"; got != want {
t.Fatalf("stderr = %q, want %q", got, want)
}

stderr.Reset()
writeError(&stderr, "boom", false)

if got, want := stderr.String(), "ERROR Facts - boom\n"; got != want {
t.Fatalf("stderr = %q, want %q", got, want)
}
}

type errorWriter struct {
err error
}

func (w errorWriter) Write([]byte) (int, error) {
return 0, w.err
}
13 changes: 13 additions & 0 deletions internal/app/loghandler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,16 @@ func TestStderrLogHandlerConcurrentHandle(t *testing.T) {
}
wg.Wait()
}

func TestStderrLogHandlerAttrsAndGroupsDoNotRender(t *testing.T) {
var stderr bytes.Buffer
handler := &stderrLogHandler{stderr: &stderr, verbose: true}

grouped := handler.WithAttrs([]slog.Attr{slog.String("ignored", "value")}).
WithGroup("group")

slog.New(grouped).Info("hello", "also", "ignored")
if got, want := stderr.String(), "INFO Facts - hello\n"; got != want {
t.Fatalf("stderr = %q, want %q", got, want)
}
}
Loading
Loading