diff --git a/.gitignore b/.gitignore
index 0034f66..540bf58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ go.work.sum
# .idea/
# .vscode/
+machineid
.DS_Store
build/
dist/
\ No newline at end of file
diff --git a/.testcoverage.yml b/.testcoverage.yml
new file mode 100644
index 0000000..7d31083
--- /dev/null
+++ b/.testcoverage.yml
@@ -0,0 +1,55 @@
+# (mandatory)
+# Path to coverage profile file (output of `go test -coverprofile` command).
+#
+# For cases where there are many coverage profiles, such as when running
+# unit tests and integration tests separately, you can combine all those
+# profiles into one. In this case, the profile should have a comma-separated list
+# of profile files, e.g., 'cover_unit.out,cover_integration.out'.
+profile: build/coverage.txt
+
+# (optional; but recommended to set)
+# When specified reported file paths will not contain local prefix in the output.
+local-prefix: "github.com/slashdevops/machineid"
+
+# Holds coverage thresholds percentages, values should be in range [0-100].
+threshold:
+ # (optional; default 0)
+ # Minimum coverage percentage required for individual files.
+ file: 0
+
+ # (optional; default 0)
+ # Minimum coverage percentage required for each package.
+ package: 20
+
+ # (optional; default 0)
+ # Minimum overall project coverage percentage required.
+ total: 25
+
+# Holds regexp rules which will override thresholds for matched files or packages
+# using their paths.
+#
+# First rule from this list that matches file or package is going to apply
+# new threshold to it. If project has multiple rules that match same path,
+# override rules should be listed in order from specific to more general rules.
+override:
+ # Increase coverage threshold to 100% for `foo` package
+ # (default is 80, as configured above in this example).
+ - path: ^pkg/lib/foo$
+ threshold: 100
+
+# Holds regexp rules which will exclude matched files or packages
+# from coverage statistics.
+exclude:
+ # Exclude files or packages matching their paths
+ paths:
+ - ^cmd/ # exclude package main
+ - ^mocks/ # exclude package mocks
+
+# File name of go-test-coverage breakdown file, which can be used to
+# analyze coverage difference.
+breakdown-file-name: ""
+
+diff:
+ # File name of go-test-coverage breakdown file which will be used to
+ # report coverage difference.
+ base-breakdown-file-name: ""
diff --git a/README.md b/README.md
index 06e6459..914a949 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ A **zero-dependency** Go library that generates unique, deterministic machine id
- **Thread-Safe** — safe for concurrent use after configuration
- **Diagnostic API** — inspect which components succeeded or failed
- **Optional Logging** — `*slog.Logger` support for observability with zero overhead when disabled
+- **Structured Errors** — sentinel errors and typed errors for programmatic handling via `errors.Is` / `errors.As`
- **Testable** — dependency-injectable command executor
## Installation
@@ -262,6 +263,54 @@ The logger is compatible with `slog.Default()` which bridges to the standard `lo
provider.WithLogger(slog.Default())
```
+### Error Handling
+
+The package provides sentinel errors for `errors.Is` and typed errors for `errors.As`:
+
+```go
+id, err := provider.ID(ctx)
+if errors.Is(err, machineid.ErrNoIdentifiers) {
+ // No hardware identifiers were collected
+}
+```
+
+#### Sentinel Errors
+
+| Error | Meaning |
+|-----------------------|------------------------------------------------------------------|
+| `ErrNoIdentifiers` | No hardware identifiers collected with current config |
+| `ErrEmptyValue` | A component returned an empty value |
+| `ErrNoValues` | A multi-value component (MAC, disk) returned no values |
+| `ErrNotFound` | A value was not found in command output or system files |
+| `ErrOEMPlaceholder` | A value matches a BIOS/UEFI placeholder ("To be filled...") |
+| `ErrAllMethodsFailed` | All collection methods for a component were exhausted |
+
+#### Typed Errors
+
+Use `errors.As` to extract structured context from errors:
+
+```go
+// Check if a system command failed
+var cmdErr *machineid.CommandError
+if errors.As(err, &cmdErr) {
+ fmt.Println("command:", cmdErr.Command) // e.g. "sysctl", "ioreg", "wmic"
+}
+
+// Check if output parsing failed
+var parseErr *machineid.ParseError
+if errors.As(err, &parseErr) {
+ fmt.Println("source:", parseErr.Source) // e.g. "system_profiler JSON"
+}
+
+// Inspect diagnostic errors per component
+diag := provider.Diagnostics()
+var compErr *machineid.ComponentError
+if errors.As(diag.Errors["cpu"], &compErr) {
+ fmt.Println("component:", compErr.Component)
+ fmt.Println("cause:", compErr.Err)
+}
+```
+
## CLI Tool
A ready-to-use command-line tool is included.
@@ -286,6 +335,12 @@ machineid -cpu -uuid -json -diagnostics
# Validate a previously stored ID
machineid -cpu -uuid -validate "b5c42832542981af58c9dc3bc241219e780ff7d276cfad05fac222846edb84f7"
+# Info-level logging (fallbacks, lifecycle events)
+machineid -cpu -uuid -verbose
+
+# Debug-level logging (command details, raw values, timing)
+machineid -all -debug
+
# Version information
machineid -version
machineid -version.long
@@ -293,22 +348,24 @@ machineid -version.long
### All Flags
-| Flag | Description |
-|-----------------|------------------------------------------------------|
-| `-cpu` | Include CPU identifier |
-| `-motherboard` | Include motherboard serial number |
-| `-uuid` | Include system UUID |
-| `-mac` | Include network MAC addresses |
-| `-disk` | Include disk serial numbers |
-| `-all` | Include all hardware identifiers |
-| `-vm` | VM-friendly mode (CPU + UUID only) |
-| `-format N` | Output length: `32`, `64` (default), `128`, or `256` |
-| `-salt STRING` | Custom salt for application-specific IDs |
-| `-validate ID` | Validate an ID against the current machine |
-| `-diagnostics` | Show collected/failed components |
-| `-json` | Output as JSON |
-| `-version` | Show version information |
-| `-version.long` | Show detailed version information |
+| Flag | Description |
+|-----------------|-----------------------------------------------------------------|
+| `-cpu` | Include CPU identifier |
+| `-motherboard` | Include motherboard serial number |
+| `-uuid` | Include system UUID |
+| `-mac` | Include network MAC addresses |
+| `-disk` | Include disk serial numbers |
+| `-all` | Include all hardware identifiers |
+| `-vm` | VM-friendly mode (CPU + UUID only) |
+| `-format N` | Output length: `32`, `64` (default), `128`, or `256` |
+| `-salt STRING` | Custom salt for application-specific IDs |
+| `-validate ID` | Validate an ID against the current machine |
+| `-diagnostics` | Show collected/failed components |
+| `-json` | Output as JSON |
+| `-verbose` | Enable info-level logging to stderr (fallbacks, lifecycle) |
+| `-debug` | Enable debug-level logging to stderr (commands, values, timing) |
+| `-version` | Show version information |
+| `-version.long` | Show detailed version information |
## How It Works
diff --git a/cmd/machineid/main_test.go b/cmd/machineid/main_test.go
new file mode 100644
index 0000000..da0c83c
--- /dev/null
+++ b/cmd/machineid/main_test.go
@@ -0,0 +1,174 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "os"
+ "testing"
+
+ "github.com/slashdevops/machineid"
+)
+
+func TestParseFormatMode(t *testing.T) {
+ tests := []struct {
+ input int
+ want machineid.FormatMode
+ wantErr bool
+ }{
+ {32, machineid.Format32, false},
+ {64, machineid.Format64, false},
+ {128, machineid.Format128, false},
+ {256, machineid.Format256, false},
+ {0, 0, true},
+ {16, 0, true},
+ {512, 0, true},
+ {-1, 0, true},
+ }
+
+ for _, tt := range tests {
+ got, err := parseFormatMode(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseFormatMode(%d) error = %v, wantErr %v", tt.input, err, tt.wantErr)
+ continue
+ }
+ if got != tt.want {
+ t.Errorf("parseFormatMode(%d) = %v, want %v", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestFormatDiagnosticsNil(t *testing.T) {
+ provider := machineid.New()
+ // Before ID() call, Diagnostics() is nil
+ result := formatDiagnostics(provider)
+ if result != nil {
+ t.Error("Expected nil for provider without diagnostics")
+ }
+}
+
+func TestFormatDiagnosticsWithData(t *testing.T) {
+ provider := machineid.New().WithCPU().WithSystemUUID()
+ // Generate ID to populate diagnostics
+ _, err := provider.ID(t.Context())
+ if err != nil {
+ t.Fatalf("ID() error: %v", err)
+ }
+
+ result := formatDiagnostics(provider)
+ if result == nil {
+ t.Fatal("Expected non-nil diagnostics")
+ }
+
+ if _, ok := result["collected"]; !ok {
+ t.Error("Expected 'collected' key in diagnostics")
+ }
+}
+
+func TestPrintDiagnosticsNil(t *testing.T) {
+ provider := machineid.New()
+ // Should not panic
+ printDiagnostics(provider)
+}
+
+func TestPrintDiagnosticsWithData(t *testing.T) {
+ provider := machineid.New().WithCPU().WithSystemUUID()
+ _, err := provider.ID(t.Context())
+ if err != nil {
+ t.Fatalf("ID() error: %v", err)
+ }
+ // Should not panic
+ printDiagnostics(provider)
+}
+
+func TestFormatDiagnosticsWithErrors(t *testing.T) {
+ provider := machineid.New().WithCPU().WithDisk()
+ _, _ = provider.ID(t.Context())
+
+ result := formatDiagnostics(provider)
+ if result == nil {
+ t.Fatal("Expected non-nil diagnostics")
+ }
+
+ // At least collected should be present
+ if _, ok := result["collected"]; !ok {
+ t.Error("Expected 'collected' key in diagnostics")
+ }
+}
+
+func TestPrintJSON(t *testing.T) {
+ // Capture stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ printJSON(map[string]any{"key": "value"})
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ var buf bytes.Buffer
+ io.Copy(&buf, r)
+
+ var result map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
+ t.Fatalf("printJSON output is not valid JSON: %v", err)
+ }
+ if result["key"] != "value" {
+ t.Errorf("Expected key=value, got %v", result["key"])
+ }
+}
+
+func TestHandleValidateValid(t *testing.T) {
+ provider := machineid.New().WithCPU().WithSystemUUID()
+ id, err := provider.ID(t.Context())
+ if err != nil {
+ t.Fatalf("ID() error: %v", err)
+ }
+
+ // Capture stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ handleValidate(t.Context(), provider, id, false)
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ var buf bytes.Buffer
+ io.Copy(&buf, r)
+
+ if !bytes.Contains(buf.Bytes(), []byte("valid: machine ID matches")) {
+ t.Errorf("Expected 'valid: machine ID matches', got %q", buf.String())
+ }
+}
+
+func TestHandleValidateValidJSON(t *testing.T) {
+ provider := machineid.New().WithCPU().WithSystemUUID()
+ id, err := provider.ID(t.Context())
+ if err != nil {
+ t.Fatalf("ID() error: %v", err)
+ }
+
+ // Capture stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ handleValidate(t.Context(), provider, id, true)
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ var buf bytes.Buffer
+ io.Copy(&buf, r)
+
+ var result map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
+ t.Fatalf("handleValidate JSON output is not valid JSON: %v", err)
+ }
+ if result["valid"] != true {
+ t.Errorf("Expected valid=true, got %v", result["valid"])
+ }
+}
diff --git a/darwin_test.go b/darwin_test.go
index d4a3b1b..b069bfe 100644
--- a/darwin_test.go
+++ b/darwin_test.go
@@ -3,8 +3,11 @@
package machineid
import (
+ "bytes"
"context"
+ "errors"
"fmt"
+ "log/slog"
"strings"
"testing"
)
@@ -342,3 +345,379 @@ func TestMacOSDiskInfoSuccess(t *testing.T) {
t.Errorf("Expected 1 disk entry, got %d", len(result))
}
}
+
+// TestMacOSHardwareUUIDWithLogger tests UUID fallback with logger enabled.
+func TestMacOSHardwareUUIDWithLogger(t *testing.T) {
+ t.Run("system_profiler parse error falls back to ioreg with logging", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setOutput("system_profiler", "not json") // Will cause parse error
+ mock.setOutput("ioreg", `"IOPlatformUUID" = "FALLBACK-UUID-123"`)
+
+ result, err := macOSHardwareUUID(context.Background(), mock, logger)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ if result != "FALLBACK-UUID-123" {
+ t.Errorf("Expected 'FALLBACK-UUID-123', got %q", result)
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("system_profiler UUID parsing failed")) {
+ t.Error("Expected 'system_profiler UUID parsing failed' in log output")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("falling back to ioreg for hardware UUID")) {
+ t.Error("Expected 'falling back to ioreg' in log output")
+ }
+ })
+
+ t.Run("system_profiler command error falls back with logging", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setError("system_profiler", fmt.Errorf("command failed"))
+ mock.setOutput("ioreg", `"IOPlatformUUID" = "FALLBACK-UUID-456"`)
+
+ result, err := macOSHardwareUUID(context.Background(), mock, logger)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ if result != "FALLBACK-UUID-456" {
+ t.Errorf("Expected 'FALLBACK-UUID-456', got %q", result)
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("falling back to ioreg for hardware UUID")) {
+ t.Error("Expected fallback log message")
+ }
+ })
+}
+
+// TestMacOSSerialNumberWithLogger tests serial fallback with logger enabled.
+func TestMacOSSerialNumberWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setOutput("system_profiler", "not json") // Will cause parse error
+ mock.setOutput("ioreg", `"IOPlatformSerialNumber" = "SERIAL-LOG"`)
+
+ result, err := macOSSerialNumber(context.Background(), mock, logger)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ if result != "SERIAL-LOG" {
+ t.Errorf("Expected 'SERIAL-LOG', got %q", result)
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("system_profiler serial parsing failed")) {
+ t.Error("Expected 'system_profiler serial parsing failed' in log output")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("falling back to ioreg for serial number")) {
+ t.Error("Expected 'falling back to ioreg' in log output")
+ }
+}
+
+// TestMacOSHardwareUUIDViaIORegNotFoundWithLogger tests ioreg not-found with logger.
+func TestMacOSHardwareUUIDViaIORegNotFoundWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setOutput("ioreg", "output without UUID pattern")
+
+ _, err := macOSHardwareUUIDViaIOReg(context.Background(), mock, logger)
+ if err == nil {
+ t.Error("Expected error")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("hardware UUID not found in ioreg output")) {
+ t.Error("Expected 'hardware UUID not found in ioreg output' in log")
+ }
+}
+
+// TestMacOSSerialNumberViaIORegNotFoundWithLogger tests serial not-found with logger.
+func TestMacOSSerialNumberViaIORegNotFoundWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setOutput("ioreg", "output without serial pattern")
+
+ _, err := macOSSerialNumberViaIOReg(context.Background(), mock, logger)
+ if err == nil {
+ t.Error("Expected error")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("serial number not found in ioreg output")) {
+ t.Error("Expected 'serial number not found in ioreg output' in log")
+ }
+}
+
+// TestMacOSCPUInfoEmptyBrand tests sysctl returning empty brand string.
+func TestMacOSCPUInfoEmptyBrand(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "") // Empty brand string
+ mock.setOutput("system_profiler", `{
+ "SPHardwareDataType": [{
+ "chip_type": "Apple M2",
+ "machine_model": "Mac",
+ "platform_UUID": "UUID",
+ "serial_number": "SER"
+ }]
+ }`)
+
+ result, err := macOSCPUInfo(context.Background(), mock, nil)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ if result != "Apple M2" {
+ t.Errorf("Expected 'Apple M2', got %q", result)
+ }
+}
+
+// TestMacOSCPUInfoEmptyBrandWithLogger tests empty brand path with logger.
+func TestMacOSCPUInfoEmptyBrandWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "") // Empty brand → falls to system_profiler
+ mock.setOutput("system_profiler", `{
+ "SPHardwareDataType": [{
+ "chip_type": "Apple M2 Pro",
+ "machine_model": "Mac",
+ "platform_UUID": "UUID",
+ "serial_number": "SER"
+ }]
+ }`)
+
+ result, err := macOSCPUInfo(context.Background(), mock, logger)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ if result != "Apple M2 Pro" {
+ t.Errorf("Expected 'Apple M2 Pro', got %q", result)
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("falling back to system_profiler for CPU info")) {
+ t.Error("Expected fallback log message")
+ }
+}
+
+// TestMacOSCPUInfoProfilerJSONParseFailWithLogger tests JSON parse failure with logger.
+func TestMacOSCPUInfoProfilerJSONParseFailWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setError("sysctl", fmt.Errorf("not found"))
+ mock.setOutput("system_profiler", "not valid json")
+
+ _, err := macOSCPUInfo(context.Background(), mock, logger)
+ if err == nil {
+ t.Error("Expected error when all methods fail")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("system_profiler CPU JSON parsing failed")) {
+ t.Error("Expected 'system_profiler CPU JSON parsing failed' in log")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("all CPU info methods failed")) {
+ t.Error("Expected 'all CPU info methods failed' in log")
+ }
+}
+
+// TestMacOSCPUInfoEmptyChipTypeWithLogger tests empty chip_type with logger.
+func TestMacOSCPUInfoEmptyChipTypeWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setError("sysctl", fmt.Errorf("not found"))
+ mock.setOutput("system_profiler", `{
+ "SPHardwareDataType": [{
+ "chip_type": "",
+ "machine_model": "Mac",
+ "platform_UUID": "UUID",
+ "serial_number": "SER"
+ }]
+ }`)
+
+ _, err := macOSCPUInfo(context.Background(), mock, logger)
+ if err == nil {
+ t.Error("Expected error when chip_type is empty")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("system_profiler returned empty chip_type")) {
+ t.Error("Expected 'system_profiler returned empty chip_type' in log")
+ }
+}
+
+// TestMacOSCPUInfoSysctlBrandWithFeaturesFail tests brand success but features fail.
+func TestMacOSCPUInfoSysctlBrandWithFeaturesFail(t *testing.T) {
+ mock := newMockExecutor()
+ // The mock returns same output for all "sysctl" calls. To test the
+ // features-fail path, we set sysctl to error on the second call.
+ // Since mockExecutor doesn't support per-arg differentiation, we test
+ // the code path where sysctl succeeds for brand but the features call
+ // also "succeeds" (same mock behavior). The brand-only path is tested
+ // by making sysctl return error after first call isn't straightforward,
+ // so we test that a non-empty brand with features returns formatted output.
+ mock.setOutput("sysctl", "Intel Core i7")
+
+ result, err := macOSCPUInfo(context.Background(), mock, nil)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ // Both brand and features calls go to same mock → "Intel Core i7:Intel Core i7"
+ if !strings.Contains(result, "Intel Core i7") {
+ t.Errorf("Expected result to contain 'Intel Core i7', got %q", result)
+ }
+}
+
+// TestParseStorageJSONEmptyDeviceName tests entries with empty device_name are skipped.
+func TestParseStorageJSONEmptyDeviceName(t *testing.T) {
+ jsonOutput := `{
+ "SPStorageDataType": [
+ {
+ "_name": "Volume 1",
+ "physical_drive": {
+ "device_name": "",
+ "is_internal_disk": "yes"
+ }
+ },
+ {
+ "_name": "Volume 2",
+ "physical_drive": {
+ "device_name": "APPLE SSD",
+ "is_internal_disk": "yes"
+ }
+ }
+ ]
+ }`
+
+ result, err := parseStorageJSON(jsonOutput)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ if len(result) != 1 {
+ t.Errorf("Expected 1 disk entry (empty name skipped), got %d", len(result))
+ }
+ if result[0] != "APPLE SSD" {
+ t.Errorf("Expected 'APPLE SSD', got %q", result[0])
+ }
+}
+
+// TestParseStorageJSONAllEmpty tests when all entries have empty device_name.
+func TestParseStorageJSONAllEmpty(t *testing.T) {
+ jsonOutput := `{
+ "SPStorageDataType": [
+ {
+ "_name": "Volume 1",
+ "physical_drive": {
+ "device_name": "",
+ "is_internal_disk": "yes"
+ }
+ }
+ ]
+ }`
+
+ _, err := parseStorageJSON(jsonOutput)
+ if err == nil {
+ t.Error("Expected error when all disk entries have empty device_name")
+ }
+}
+
+// TestParseStorageJSONEmptyArray tests empty storage array.
+func TestParseStorageJSONEmptyArray(t *testing.T) {
+ jsonOutput := `{"SPStorageDataType": []}`
+ _, err := parseStorageJSON(jsonOutput)
+ if err == nil {
+ t.Error("Expected error for empty storage array")
+ }
+}
+
+// TestCollectMACAddressesWithLogger tests MAC collection with logger enabled.
+func TestCollectMACAddressesWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ macs, err := collectMACAddresses(logger)
+ if err != nil {
+ t.Logf("collectMACAddresses error (may be expected): %v", err)
+ return
+ }
+
+ // On most systems, some interfaces should produce log output
+ output := buf.String()
+ t.Logf("Found %d MACs, log output length: %d", len(macs), len(output))
+
+ // We can't assert specific log messages since they depend on system interfaces,
+ // but we can verify no panic occurred and logs were produced
+ if len(macs) > 0 && !bytes.Contains(buf.Bytes(), []byte("including interface")) {
+ t.Error("Expected 'including interface' log when MACs are found")
+ }
+}
+
+// TestExtractHardwareFieldErrorTypes tests that extractHardwareField returns correct error types.
+func TestExtractHardwareFieldErrorTypes(t *testing.T) {
+ t.Run("invalid JSON returns ParseError", func(t *testing.T) {
+ _, err := extractHardwareField("not json", func(e spHardwareEntry) string {
+ return e.PlatformUUID
+ })
+ var parseErr *ParseError
+ if !errors.As(err, &parseErr) {
+ t.Fatalf("Expected ParseError, got %T: %v", err, err)
+ }
+ if parseErr.Source != "system_profiler hardware JSON" {
+ t.Errorf("ParseError.Source = %q, want %q", parseErr.Source, "system_profiler hardware JSON")
+ }
+ })
+
+ t.Run("no data returns ParseError with ErrNotFound", func(t *testing.T) {
+ _, err := extractHardwareField(`{"SPHardwareDataType": []}`, func(e spHardwareEntry) string {
+ return e.PlatformUUID
+ })
+ var parseErr *ParseError
+ if !errors.As(err, &parseErr) {
+ t.Fatalf("Expected ParseError, got %T: %v", err, err)
+ }
+ if !errors.Is(err, ErrNotFound) {
+ t.Error("Expected ErrNotFound in error chain")
+ }
+ })
+
+ t.Run("empty field returns ParseError with ErrEmptyValue", func(t *testing.T) {
+ _, err := extractHardwareField(`{"SPHardwareDataType": [{"platform_UUID": ""}]}`, func(e spHardwareEntry) string {
+ return e.PlatformUUID
+ })
+ var parseErr *ParseError
+ if !errors.As(err, &parseErr) {
+ t.Fatalf("Expected ParseError, got %T: %v", err, err)
+ }
+ if !errors.Is(err, ErrEmptyValue) {
+ t.Error("Expected ErrEmptyValue in error chain")
+ }
+ })
+}
+
+// TestMacOSHardwareUUIDViaIORegErrorType tests error type from ioreg not-found.
+func TestMacOSHardwareUUIDViaIORegErrorType(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("ioreg", "no UUID here")
+
+ _, err := macOSHardwareUUIDViaIOReg(context.Background(), mock, nil)
+
+ var parseErr *ParseError
+ if !errors.As(err, &parseErr) {
+ t.Fatalf("Expected ParseError, got %T: %v", err, err)
+ }
+ if !errors.Is(err, ErrNotFound) {
+ t.Error("Expected ErrNotFound in error chain")
+ }
+}
+
+// TestMacOSCPUInfoAllFailErrorType tests error type when all CPU methods fail.
+func TestMacOSCPUInfoAllFailErrorType(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setError("sysctl", fmt.Errorf("not found"))
+ mock.setError("system_profiler", fmt.Errorf("not found"))
+
+ _, err := macOSCPUInfo(context.Background(), mock, nil)
+ if !errors.Is(err, ErrAllMethodsFailed) {
+ t.Errorf("Expected ErrAllMethodsFailed, got %v", err)
+ }
+}
diff --git a/example_darwin_test.go b/example_darwin_test.go
new file mode 100644
index 0000000..7461a5a
--- /dev/null
+++ b/example_darwin_test.go
@@ -0,0 +1,64 @@
+//go:build darwin
+
+package machineid_test
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/slashdevops/machineid"
+)
+
+// ExampleProvider_Diagnostics demonstrates inspecting which hardware components
+// were successfully collected.
+func ExampleProvider_Diagnostics() {
+ provider := machineid.New().
+ WithCPU().
+ WithSystemUUID()
+
+ _, _ = provider.ID(context.Background())
+
+ diag := provider.Diagnostics()
+ if diag == nil {
+ fmt.Println("no diagnostics")
+ return
+ }
+
+ fmt.Printf("Components collected: %d\n", len(diag.Collected))
+ fmt.Printf("Has collected data: %v\n", len(diag.Collected) > 0)
+ // Output:
+ // Components collected: 2
+ // Has collected data: true
+}
+
+// Example_integrity demonstrates that the format maintains integrity without collisions.
+func Example_integrity() {
+ // Generate multiple IDs to show consistency and uniqueness
+ p1 := machineid.New().WithCPU().WithSystemUUID()
+ p2 := machineid.New().WithCPU().WithSystemUUID().WithMotherboard()
+ p3 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app1")
+ p4 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app2")
+
+ id1, _ := p1.ID(context.Background())
+ id2, _ := p2.ID(context.Background())
+ id3, _ := p3.ID(context.Background())
+ id4, _ := p4.ID(context.Background())
+
+ // Same configuration always produces same ID
+ id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background())
+ fmt.Printf("Consistency: %v\n", id1 == id1Again)
+
+ // Different configurations produce different IDs
+ fmt.Printf("Different hardware: %v\n", id1 != id2)
+ fmt.Printf("Different salts: %v\n", id3 != id4)
+
+ // All IDs are 64 characters (power of 2)
+ fmt.Printf("All are 64 chars: %v\n",
+ len(id1) == 64 && len(id2) == 64 && len(id3) == 64 && len(id4) == 64)
+
+ // Output:
+ // Consistency: true
+ // Different hardware: true
+ // Different salts: true
+ // All are 64 chars: true
+}
diff --git a/example_poweroftwo_test.go b/example_poweroftwo_test.go
index 82a3c26..4f8c982 100644
--- a/example_poweroftwo_test.go
+++ b/example_poweroftwo_test.go
@@ -27,38 +27,6 @@ func Example_powerOfTwo() {
// Format64 bits: 256 (2^256 possible values)
}
-// Example_integrity demonstrates that the format maintains integrity without collisions.
-func Example_integrity() {
- // Generate multiple IDs to show consistency and uniqueness
- p1 := machineid.New().WithCPU().WithSystemUUID()
- p2 := machineid.New().WithCPU().WithSystemUUID().WithMotherboard()
- p3 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app1")
- p4 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app2")
-
- id1, _ := p1.ID(context.Background())
- id2, _ := p2.ID(context.Background())
- id3, _ := p3.ID(context.Background())
- id4, _ := p4.ID(context.Background())
-
- // Same configuration always produces same ID
- id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background())
- fmt.Printf("Consistency: %v\n", id1 == id1Again)
-
- // Different configurations produce different IDs
- fmt.Printf("Different hardware: %v\n", id1 != id2)
- fmt.Printf("Different salts: %v\n", id3 != id4)
-
- // All IDs are 64 characters (power of 2)
- fmt.Printf("All are 64 chars: %v\n",
- len(id1) == 64 && len(id2) == 64 && len(id3) == 64 && len(id4) == 64)
-
- // Output:
- // Consistency: true
- // Different hardware: true
- // Different salts: true
- // All are 64 chars: true
-}
-
// Example_collisionResistance demonstrates the collision resistance of different formats.
func Example_collisionResistance() {
// Calculate collision probability (simplified)
diff --git a/example_test.go b/example_test.go
index 63feb8e..9455fc5 100644
--- a/example_test.go
+++ b/example_test.go
@@ -94,28 +94,6 @@ func ExampleProvider_Validate() {
// Wrong ID valid: false
}
-// ExampleProvider_Diagnostics demonstrates inspecting which hardware components
-// were successfully collected.
-func ExampleProvider_Diagnostics() {
- provider := machineid.New().
- WithCPU().
- WithSystemUUID()
-
- _, _ = provider.ID(context.Background())
-
- diag := provider.Diagnostics()
- if diag == nil {
- fmt.Println("no diagnostics")
- return
- }
-
- fmt.Printf("Components collected: %d\n", len(diag.Collected))
- fmt.Printf("Has collected data: %v\n", len(diag.Collected) > 0)
- // Output:
- // Components collected: 2
- // Has collected data: true
-}
-
// ExampleProvider_VMFriendly_preset demonstrates the VM-friendly preset.
func ExampleProvider_VMFriendly_preset() {
id, err := machineid.New().
diff --git a/machineid_internal_darwin_test.go b/machineid_internal_darwin_test.go
new file mode 100644
index 0000000..655387c
--- /dev/null
+++ b/machineid_internal_darwin_test.go
@@ -0,0 +1,358 @@
+//go:build darwin
+
+package machineid
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "testing"
+)
+
+// TestProviderWithMockExecutor tests using a mock executor for deterministic testing.
+func TestProviderWithMockExecutor(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "Test CPU Brand String")
+
+ g := New().
+ WithExecutor(mock).
+ WithCPU()
+
+ id, err := g.ID(context.Background())
+ if err != nil {
+ t.Fatalf("ID() with mock executor error = %v", err)
+ }
+
+ if len(id) != 64 {
+ t.Errorf("ID() returned ID of length %d, expected 64", len(id))
+ }
+
+ // Verify the ID is consistent with the same mock
+ id2, err := g.ID(context.Background())
+ if err != nil {
+ t.Fatalf("Second ID() call error = %v", err)
+ }
+
+ if id != id2 {
+ t.Error("ID() returned different IDs with same mock executor")
+ }
+}
+
+// TestProviderErrorHandling tests various error conditions.
+func TestProviderErrorHandling(t *testing.T) {
+ tests := []struct {
+ name string
+ setupMock func(*mockExecutor)
+ configure func(*Provider) *Provider
+ expectError bool
+ wantErr error
+ }{
+ {
+ name: "command execution fails but no fallback available",
+ setupMock: func(m *mockExecutor) {
+ m.setError("sysctl", fmt.Errorf("command not found"))
+ },
+ configure: func(p *Provider) *Provider {
+ return p.WithCPU()
+ },
+ expectError: true,
+ wantErr: ErrNoIdentifiers,
+ },
+ {
+ name: "no identifiers collected",
+ setupMock: func(m *mockExecutor) {
+ // All commands fail
+ m.setError("sysctl", fmt.Errorf("failed"))
+ m.setError("ioreg", fmt.Errorf("failed"))
+ m.setError("system_profiler", fmt.Errorf("failed"))
+ },
+ configure: func(p *Provider) *Provider {
+ return p.WithCPU().WithSystemUUID()
+ },
+ expectError: true,
+ wantErr: ErrNoIdentifiers,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mock := newMockExecutor()
+ if tt.setupMock != nil {
+ tt.setupMock(mock)
+ }
+
+ p := New().WithExecutor(mock)
+ p = tt.configure(p)
+
+ _, err := p.ID(context.Background())
+ if tt.expectError && err == nil {
+ t.Error("Expected error but got none")
+ }
+ if !tt.expectError && err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if tt.expectError && err != nil && tt.wantErr != nil {
+ if !errors.Is(err, tt.wantErr) {
+ t.Errorf("got error %v, want %v", err, tt.wantErr)
+ }
+ }
+ })
+ }
+}
+
+// TestValidateError tests Validate method when ID generation fails.
+func TestValidateError(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setError("sysctl", fmt.Errorf("command failed"))
+
+ p := New().WithExecutor(mock).WithCPU()
+
+ valid, err := p.Validate(context.Background(), "some-id")
+ if err == nil {
+ t.Error("Expected error when ID generation fails")
+ }
+ if valid {
+ t.Error("Validation should fail when error occurs")
+ }
+}
+
+// TestDiagnosticsAvailableAfterID tests that Diagnostics() returns data after ID().
+func TestDiagnosticsAvailableAfterID(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "Test CPU")
+ mock.setOutput("system_profiler", `{
+ "SPHardwareDataType": [{
+ "chip_type": "Apple M1",
+ "machine_model": "Mac",
+ "platform_UUID": "UUID-123",
+ "serial_number": "SERIAL"
+ }]
+ }`)
+
+ p := New().WithExecutor(mock).WithCPU().WithSystemUUID()
+
+ // Before ID(), Diagnostics should be nil
+ if p.Diagnostics() != nil {
+ t.Error("Diagnostics should be nil before ID() call")
+ }
+
+ _, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("ID() error: %v", err)
+ }
+
+ diag := p.Diagnostics()
+ if diag == nil {
+ t.Fatal("Diagnostics should not be nil after ID() call")
+ }
+
+ if len(diag.Collected) == 0 {
+ t.Error("Expected at least one collected component")
+ }
+}
+
+// TestDiagnosticsRecordsFailures tests that failed components are recorded.
+func TestDiagnosticsRecordsFailures(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "Test CPU")
+ mock.setOutput("system_profiler", `{
+ "SPHardwareDataType": [{
+ "chip_type": "Apple M1",
+ "machine_model": "Mac",
+ "platform_UUID": "",
+ "serial_number": ""
+ }]
+ }`)
+ mock.setError("ioreg", fmt.Errorf("ioreg not available"))
+
+ p := New().WithExecutor(mock).WithCPU().WithSystemUUID()
+
+ _, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("ID() error: %v", err)
+ }
+
+ diag := p.Diagnostics()
+ if diag == nil {
+ t.Fatal("Diagnostics should not be nil")
+ }
+
+ // CPU should succeed, UUID should fail (empty in JSON + ioreg fails)
+ if len(diag.Collected) == 0 {
+ t.Error("Expected at least one collected component")
+ }
+}
+
+// TestProviderCachedIDNotModified tests that cached ID is not modified on subsequent calls.
+func TestProviderCachedIDNotModified(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "CPU1")
+
+ p := New().WithExecutor(mock).WithCPU()
+
+ id1, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("First ID() call failed: %v", err)
+ }
+
+ // Change the mock output
+ mock.setOutput("sysctl", "CPU2")
+
+ // Should still return cached value
+ id2, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("Second ID() call failed: %v", err)
+ }
+
+ if id1 != id2 {
+ t.Error("Cached ID was modified on subsequent call")
+ }
+
+ // Verify mock was only called once (due to caching)
+ if mock.callCount["sysctl"] > 2 {
+ t.Errorf("Expected sysctl to be called at most twice, got %d", mock.callCount["sysctl"])
+ }
+}
+
+// TestProviderAllIdentifiers tests using all identifier types.
+func TestProviderAllIdentifiers(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "Intel CPU")
+ mock.setOutput("system_profiler", `platform_UUID: "UUID123"`)
+ mock.setOutput("ioreg", "some data")
+ mock.setOutput("diskutil", `/dev/disk0`)
+
+ p := New().
+ WithExecutor(mock).
+ WithCPU().
+ WithSystemUUID().
+ WithMotherboard().
+ WithMAC().
+ WithDisk()
+
+ id, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("ID() with all identifiers failed: %v", err)
+ }
+
+ if len(id) != 64 {
+ t.Errorf("Expected 64-character ID (default Format64), got %d", len(id))
+ }
+}
+
+// TestCollectIdentifiersError tests when collectIdentifiers returns an error.
+func TestCollectIdentifiersError(t *testing.T) {
+ mock := newMockExecutor()
+ // Don't set any outputs, so all commands will fail with "not configured"
+
+ p := New().WithExecutor(mock).WithCPU()
+
+ _, err := p.ID(context.Background())
+ if err == nil {
+ t.Error("Expected error when collectIdentifiers fails")
+ }
+}
+
+// TestProviderValidateMismatch tests validation with mismatched ID.
+func TestProviderValidateMismatch(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "CPU1")
+
+ p := New().WithExecutor(mock).WithCPU()
+
+ // Generate ID
+ id, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("ID() failed: %v", err)
+ }
+
+ // Validate with different ID
+ valid, err := p.Validate(context.Background(), id+"different")
+ if err != nil {
+ t.Errorf("Validate() error: %v", err)
+ }
+
+ if valid {
+ t.Error("Expected validation to fail for different ID")
+ }
+}
+
+// TestWithLoggerOutput verifies that log output appears when a logger is set.
+func TestWithLoggerOutput(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "Test CPU Brand")
+
+ p := New().
+ WithExecutor(mock).
+ WithLogger(logger).
+ WithCPU()
+
+ _, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("ID() error: %v", err)
+ }
+
+ output := buf.String()
+ if output == "" {
+ t.Error("Expected log output when logger is set, got empty string")
+ }
+
+ // Check for key log messages
+ if !bytes.Contains(buf.Bytes(), []byte("generating machine ID")) {
+ t.Error("Expected 'generating machine ID' in log output")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("machine ID generated")) {
+ t.Error("Expected 'machine ID generated' in log output")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component collected")) {
+ t.Error("Expected 'component collected' in log output")
+ }
+}
+
+// TestWithoutLoggerNoOutput verifies that no logging occurs without a logger.
+func TestWithoutLoggerNoOutput(t *testing.T) {
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "Test CPU Brand")
+
+ p := New().
+ WithExecutor(mock).
+ WithCPU()
+
+ // Should not panic or produce any output
+ _, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("ID() error: %v", err)
+ }
+}
+
+// TestProviderCachedIDWithLogger tests the cached ID debug log path.
+func TestProviderCachedIDWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setOutput("sysctl", "Test CPU")
+
+ p := New().WithExecutor(mock).WithLogger(logger).WithCPU()
+
+ // First call generates ID
+ _, err := p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("First ID() error: %v", err)
+ }
+
+ // Second call returns cached
+ _, err = p.ID(context.Background())
+ if err != nil {
+ t.Fatalf("Second ID() error: %v", err)
+ }
+
+ if !bytes.Contains(buf.Bytes(), []byte("returning cached machine ID")) {
+ t.Error("Expected 'returning cached machine ID' in log output")
+ }
+}
diff --git a/machineid_internal_test.go b/machineid_internal_test.go
index ff07868..87f98d3 100644
--- a/machineid_internal_test.go
+++ b/machineid_internal_test.go
@@ -9,97 +9,6 @@ import (
"testing"
)
-// TestProviderWithMockExecutor tests using a mock executor for deterministic testing.
-func TestProviderWithMockExecutor(t *testing.T) {
- mock := newMockExecutor()
- mock.setOutput("sysctl", "Test CPU Brand String")
-
- g := New().
- WithExecutor(mock).
- WithCPU()
-
- id, err := g.ID(context.Background())
- if err != nil {
- t.Fatalf("ID() with mock executor error = %v", err)
- }
-
- if len(id) != 64 {
- t.Errorf("ID() returned ID of length %d, expected 64", len(id))
- }
-
- // Verify the ID is consistent with the same mock
- id2, err := g.ID(context.Background())
- if err != nil {
- t.Fatalf("Second ID() call error = %v", err)
- }
-
- if id != id2 {
- t.Error("ID() returned different IDs with same mock executor")
- }
-}
-
-// TestProviderErrorHandling tests various error conditions.
-func TestProviderErrorHandling(t *testing.T) {
- tests := []struct {
- name string
- setupMock func(*mockExecutor)
- configure func(*Provider) *Provider
- expectError bool
- wantErr error
- }{
- {
- name: "command execution fails but no fallback available",
- setupMock: func(m *mockExecutor) {
- m.setError("sysctl", fmt.Errorf("command not found"))
- },
- configure: func(p *Provider) *Provider {
- return p.WithCPU()
- },
- expectError: true,
- wantErr: ErrNoIdentifiers,
- },
- {
- name: "no identifiers collected",
- setupMock: func(m *mockExecutor) {
- // All commands fail
- m.setError("sysctl", fmt.Errorf("failed"))
- m.setError("ioreg", fmt.Errorf("failed"))
- m.setError("system_profiler", fmt.Errorf("failed"))
- },
- configure: func(p *Provider) *Provider {
- return p.WithCPU().WithSystemUUID()
- },
- expectError: true,
- wantErr: ErrNoIdentifiers,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- mock := newMockExecutor()
- if tt.setupMock != nil {
- tt.setupMock(mock)
- }
-
- p := New().WithExecutor(mock)
- p = tt.configure(p)
-
- _, err := p.ID(context.Background())
- if tt.expectError && err == nil {
- t.Error("Expected error but got none")
- }
- if !tt.expectError && err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- if tt.expectError && err != nil && tt.wantErr != nil {
- if !errors.Is(err, tt.wantErr) {
- t.Errorf("got error %v, want %v", err, tt.wantErr)
- }
- }
- })
- }
-}
-
// TestHashIdentifiersEmpty tests hashing with empty identifiers.
func TestHashIdentifiersEmpty(t *testing.T) {
result := hashIdentifiers([]string{}, "", Format64)
@@ -133,22 +42,6 @@ func TestHashIdentifiersWithoutSalt(t *testing.T) {
}
}
-// TestValidateError tests Validate method when ID generation fails.
-func TestValidateError(t *testing.T) {
- mock := newMockExecutor()
- mock.setError("sysctl", fmt.Errorf("command failed"))
-
- p := New().WithExecutor(mock).WithCPU()
-
- valid, err := p.Validate(context.Background(), "some-id")
- if err == nil {
- t.Error("Expected error when ID generation fails")
- }
- if valid {
- t.Error("Validation should fail when error occurs")
- }
-}
-
// TestAppendIdentifierIfValidEmpty tests with empty value.
func TestAppendIdentifierIfValidEmpty(t *testing.T) {
diag := &DiagnosticInfo{Errors: make(map[string]error)}
@@ -288,214 +181,315 @@ func TestAppendIdentifierNilDiag(t *testing.T) {
}
}
-// TestDiagnosticsAvailableAfterID tests that Diagnostics() returns data after ID().
-func TestDiagnosticsAvailableAfterID(t *testing.T) {
- mock := newMockExecutor()
- mock.setOutput("sysctl", "Test CPU")
- mock.setOutput("system_profiler", `{
- "SPHardwareDataType": [{
- "chip_type": "Apple M1",
- "machine_model": "Mac",
- "platform_UUID": "UUID-123",
- "serial_number": "SERIAL"
- }]
- }`)
+// TestAppendIdentifierIfValidWithLogger tests all logger paths in appendIdentifierIfValid.
+func TestAppendIdentifierIfValidWithLogger(t *testing.T) {
+ t.Run("error with logger", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ diag := &DiagnosticInfo{Errors: make(map[string]error)}
+
+ result := appendIdentifierIfValid(nil, func() (string, error) {
+ return "", fmt.Errorf("test error")
+ }, "prefix:", diag, "test-comp", logger)
+
+ if len(result) != 0 {
+ t.Errorf("Expected 0 identifiers, got %d", len(result))
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component failed")) {
+ t.Error("Expected 'component failed' in log output")
+ }
+ })
+
+ t.Run("empty value with logger", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ diag := &DiagnosticInfo{Errors: make(map[string]error)}
+
+ result := appendIdentifierIfValid(nil, func() (string, error) {
+ return "", nil
+ }, "prefix:", diag, "test-comp", logger)
+
+ if len(result) != 0 {
+ t.Errorf("Expected 0 identifiers, got %d", len(result))
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component returned empty value")) {
+ t.Error("Expected 'component returned empty value' in log output")
+ }
+ })
+
+ t.Run("success with logger", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ diag := &DiagnosticInfo{Errors: make(map[string]error)}
+
+ result := appendIdentifierIfValid(nil, func() (string, error) {
+ return "good-value", nil
+ }, "prefix:", diag, "test-comp", logger)
+
+ if len(result) != 1 {
+ t.Errorf("Expected 1 identifier, got %d", len(result))
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component collected")) {
+ t.Error("Expected 'component collected' in log output")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component value")) {
+ t.Error("Expected 'component value' in log output")
+ }
+ })
+}
- p := New().WithExecutor(mock).WithCPU().WithSystemUUID()
+// TestAppendIdentifiersIfValidWithLogger tests all logger paths in appendIdentifiersIfValid.
+func TestAppendIdentifiersIfValidWithLogger(t *testing.T) {
+ t.Run("error with logger", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ diag := &DiagnosticInfo{Errors: make(map[string]error)}
+
+ result := appendIdentifiersIfValid(nil, func() ([]string, error) {
+ return nil, fmt.Errorf("test error")
+ }, "prefix:", diag, "test-comp", logger)
+
+ if len(result) != 0 {
+ t.Errorf("Expected 0 identifiers, got %d", len(result))
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component failed")) {
+ t.Error("Expected 'component failed' in log output")
+ }
+ })
+
+ t.Run("empty values with logger", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ diag := &DiagnosticInfo{Errors: make(map[string]error)}
+
+ result := appendIdentifiersIfValid(nil, func() ([]string, error) {
+ return []string{}, nil
+ }, "prefix:", diag, "test-comp", logger)
+
+ if len(result) != 0 {
+ t.Errorf("Expected 0 identifiers, got %d", len(result))
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component returned no values")) {
+ t.Error("Expected 'component returned no values' in log output")
+ }
+ })
+
+ t.Run("success with logger", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ diag := &DiagnosticInfo{Errors: make(map[string]error)}
+
+ result := appendIdentifiersIfValid(nil, func() ([]string, error) {
+ return []string{"val1", "val2"}, nil
+ }, "prefix:", diag, "test-comp", logger)
+
+ if len(result) != 2 {
+ t.Errorf("Expected 2 identifiers, got %d", len(result))
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component collected")) {
+ t.Error("Expected 'component collected' in log output")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("component values")) {
+ t.Error("Expected 'component values' in log output")
+ }
+ })
+}
- // Before ID(), Diagnostics should be nil
- if p.Diagnostics() != nil {
- t.Error("Diagnostics should be nil before ID() call")
- }
+// TestAppendIdentifiersIfValidEmptyWithDiag tests empty values record ErrNoValues in diagnostics.
+func TestAppendIdentifiersIfValidEmptyWithDiag(t *testing.T) {
+ diag := &DiagnosticInfo{Errors: make(map[string]error)}
+ result := appendIdentifiersIfValid(nil, func() ([]string, error) {
+ return []string{}, nil
+ }, "prefix:", diag, "test", nil)
- _, err := p.ID(context.Background())
- if err != nil {
- t.Fatalf("ID() error: %v", err)
+ if len(result) != 0 {
+ t.Errorf("Expected 0 identifiers, got %d", len(result))
}
-
- diag := p.Diagnostics()
- if diag == nil {
- t.Fatal("Diagnostics should not be nil after ID() call")
+ diagErr, ok := diag.Errors["test"]
+ if !ok {
+ t.Fatal("Expected error recorded in diagnostics for empty values")
}
-
- if len(diag.Collected) == 0 {
- t.Error("Expected at least one collected component")
+ if !errors.Is(diagErr, ErrNoValues) {
+ t.Errorf("Expected ErrNoValues in diagnostic, got %v", diagErr)
}
-}
-
-// TestDiagnosticsRecordsFailures tests that failed components are recorded.
-func TestDiagnosticsRecordsFailures(t *testing.T) {
- mock := newMockExecutor()
- mock.setOutput("sysctl", "Test CPU")
- mock.setOutput("system_profiler", `{
- "SPHardwareDataType": [{
- "chip_type": "Apple M1",
- "machine_model": "Mac",
- "platform_UUID": "",
- "serial_number": ""
- }]
- }`)
- mock.setError("ioreg", fmt.Errorf("ioreg not available"))
-
- p := New().WithExecutor(mock).WithCPU().WithSystemUUID()
-
- _, err := p.ID(context.Background())
- if err != nil {
- t.Fatalf("ID() error: %v", err)
+ var compErr *ComponentError
+ if !errors.As(diagErr, &compErr) {
+ t.Fatal("Expected ComponentError in diagnostic")
}
-
- diag := p.Diagnostics()
- if diag == nil {
- t.Fatal("Diagnostics should not be nil")
+ if compErr.Component != "test" {
+ t.Errorf("ComponentError.Component = %q, want %q", compErr.Component, "test")
}
+}
- // CPU should succeed, UUID should fail (empty in JSON + ioreg fails)
- if len(diag.Collected) == 0 {
- t.Error("Expected at least one collected component")
+// TestFormatHashInvalidLength tests formatHash with non-64-char input.
+func TestFormatHashInvalidLength(t *testing.T) {
+ short := "abc123"
+ result := formatHash(short, Format64)
+ if result != short {
+ t.Errorf("Expected input returned unchanged for invalid length, got %q", result)
}
}
-// TestProviderCachedIDNotModified tests that cached ID is not modified on subsequent calls.
-func TestProviderCachedIDNotModified(t *testing.T) {
- mock := newMockExecutor()
- mock.setOutput("sysctl", "CPU1")
-
- p := New().WithExecutor(mock).WithCPU()
-
- id1, err := p.ID(context.Background())
- if err != nil {
- t.Fatalf("First ID() call failed: %v", err)
+// TestFormatHashDefaultCase tests formatHash with an unknown FormatMode.
+func TestFormatHashDefaultCase(t *testing.T) {
+ // Create a valid 64-char hex string
+ hash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
+ result := formatHash(hash, FormatMode(999))
+ if result != hash {
+ t.Errorf("Expected input returned unchanged for unknown format mode, got %q", result)
}
+}
- // Change the mock output
- mock.setOutput("sysctl", "CPU2")
+// TestFormatHashAllModes tests formatHash produces correct lengths for all modes.
+func TestFormatHashAllModes(t *testing.T) {
+ hash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
- // Should still return cached value
- id2, err := p.ID(context.Background())
- if err != nil {
- t.Fatalf("Second ID() call failed: %v", err)
+ tests := []struct {
+ mode FormatMode
+ wantLength int
+ }{
+ {Format32, 32},
+ {Format64, 64},
+ {Format128, 128},
+ {Format256, 256},
}
- if id1 != id2 {
- t.Error("Cached ID was modified on subsequent call")
+ for _, tt := range tests {
+ result := formatHash(hash, tt.mode)
+ if len(result) != tt.wantLength {
+ t.Errorf("formatHash(mode=%d) length = %d, want %d", tt.mode, len(result), tt.wantLength)
+ }
}
+}
- // Verify mock was only called once (due to caching)
- if mock.callCount["sysctl"] > 2 {
- t.Errorf("Expected sysctl to be called at most twice, got %d", mock.callCount["sysctl"])
- }
+// TestLogMethodsNilLogger tests that log methods don't panic with nil logger.
+func TestLogMethodsNilLogger(t *testing.T) {
+ p := New() // no logger set
+
+ // These should not panic
+ p.logDebug("test debug")
+ p.logInfo("test info")
+ p.logWarn("test warn")
}
-// TestProviderAllIdentifiers tests using all identifier types.
-func TestProviderAllIdentifiers(t *testing.T) {
- mock := newMockExecutor()
- mock.setOutput("sysctl", "Intel CPU")
- mock.setOutput("system_profiler", `platform_UUID: "UUID123"`)
- mock.setOutput("ioreg", "some data")
- mock.setOutput("diskutil", `/dev/disk0`)
+// TestLogMethodsWithLogger tests that log methods produce output with logger set.
+func TestLogMethodsWithLogger(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ p := New().WithLogger(logger)
- p := New().
- WithExecutor(mock).
- WithCPU().
- WithSystemUUID().
- WithMotherboard().
- WithMAC().
- WithDisk()
+ p.logDebug("test debug msg")
+ p.logInfo("test info msg")
+ p.logWarn("test warn msg")
- id, err := p.ID(context.Background())
- if err != nil {
- t.Fatalf("ID() with all identifiers failed: %v", err)
+ output := buf.String()
+ if !bytes.Contains([]byte(output), []byte("test debug msg")) {
+ t.Error("Expected 'test debug msg' in log output")
}
-
- if len(id) != 64 {
- t.Errorf("Expected 64-character ID (default Format64), got %d", len(id))
+ if !bytes.Contains([]byte(output), []byte("test info msg")) {
+ t.Error("Expected 'test info msg' in log output")
+ }
+ if !bytes.Contains([]byte(output), []byte("test warn msg")) {
+ t.Error("Expected 'test warn msg' in log output")
}
}
-// TestCollectIdentifiersError tests when collectIdentifiers returns an error.
-func TestCollectIdentifiersError(t *testing.T) {
- mock := newMockExecutor()
- // Don't set any outputs, so all commands will fail with "not configured"
+// TestEnabledComponents tests that enabledComponents returns correct names.
+func TestEnabledComponents(t *testing.T) {
+ p := New().WithCPU().WithSystemUUID().WithDisk()
+ components := p.enabledComponents()
- p := New().WithExecutor(mock).WithCPU()
+ if len(components) != 3 {
+ t.Fatalf("Expected 3 components, got %d: %v", len(components), components)
+ }
- _, err := p.ID(context.Background())
- if err == nil {
- t.Error("Expected error when collectIdentifiers fails")
+ want := []string{ComponentCPU, ComponentSystemUUID, ComponentDisk}
+ for i, c := range components {
+ if c != want[i] {
+ t.Errorf("Component[%d] = %q, want %q", i, c, want[i])
+ }
}
}
-// TestProviderValidateMismatch tests validation with mismatched ID.
-func TestProviderValidateMismatch(t *testing.T) {
- mock := newMockExecutor()
- mock.setOutput("sysctl", "CPU1")
-
- p := New().WithExecutor(mock).WithCPU()
+// TestEnabledComponentsAll tests all components enabled.
+func TestEnabledComponentsAll(t *testing.T) {
+ p := New().WithCPU().WithMotherboard().WithSystemUUID().WithMAC().WithDisk()
+ components := p.enabledComponents()
- // Generate ID
- id, err := p.ID(context.Background())
- if err != nil {
- t.Fatalf("ID() failed: %v", err)
+ if len(components) != 5 {
+ t.Fatalf("Expected 5 components, got %d: %v", len(components), components)
}
+}
- // Validate with different ID
- valid, err := p.Validate(context.Background(), id+"different")
- if err != nil {
- t.Errorf("Validate() error: %v", err)
- }
+// TestEnabledComponentsNone tests no components enabled.
+func TestEnabledComponentsNone(t *testing.T) {
+ p := New()
+ components := p.enabledComponents()
- if valid {
- t.Error("Expected validation to fail for different ID")
+ if len(components) != 0 {
+ t.Errorf("Expected 0 components, got %d: %v", len(components), components)
}
}
-// TestWithLoggerOutput verifies that log output appears when a logger is set.
-func TestWithLoggerOutput(t *testing.T) {
+// TestProviderWithLoggerNoIdentifiersWarning tests logWarn path when no identifiers collected.
+func TestProviderWithLoggerNoIdentifiersWarning(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
- mock := newMockExecutor()
- mock.setOutput("sysctl", "Test CPU Brand")
-
- p := New().
- WithExecutor(mock).
- WithLogger(logger).
- WithCPU()
+ p := New().WithLogger(logger)
+ // No components enabled → ErrNoIdentifiers
_, err := p.ID(context.Background())
- if err != nil {
- t.Fatalf("ID() error: %v", err)
+ if !errors.Is(err, ErrNoIdentifiers) {
+ t.Errorf("Expected ErrNoIdentifiers, got %v", err)
}
- output := buf.String()
- if output == "" {
- t.Error("Expected log output when logger is set, got empty string")
- }
-
- // Check for key log messages
- if !bytes.Contains(buf.Bytes(), []byte("generating machine ID")) {
- t.Error("Expected 'generating machine ID' in log output")
- }
- if !bytes.Contains(buf.Bytes(), []byte("machine ID generated")) {
- t.Error("Expected 'machine ID generated' in log output")
- }
- if !bytes.Contains(buf.Bytes(), []byte("component collected")) {
- t.Error("Expected 'component collected' in log output")
+ if !bytes.Contains(buf.Bytes(), []byte("no hardware identifiers collected")) {
+ t.Error("Expected 'no hardware identifiers collected' warning in log output")
}
}
-// TestWithoutLoggerNoOutput verifies that no logging occurs without a logger.
-func TestWithoutLoggerNoOutput(t *testing.T) {
- mock := newMockExecutor()
- mock.setOutput("sysctl", "Test CPU Brand")
-
- p := New().
- WithExecutor(mock).
- WithCPU()
-
- // Should not panic or produce any output
- _, err := p.ID(context.Background())
- if err != nil {
- t.Fatalf("ID() error: %v", err)
- }
+// TestExecuteCommandWithLogger tests executeCommand logger output paths.
+func TestExecuteCommandWithLogger(t *testing.T) {
+ t.Run("success with logger", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setOutput("testcmd", "output")
+
+ result, err := executeCommand(context.Background(), mock, logger, "testcmd", "arg1")
+ if err != nil {
+ t.Fatalf("executeCommand error: %v", err)
+ }
+ if result != "output" {
+ t.Errorf("Expected 'output', got %q", result)
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("executing command")) {
+ t.Error("Expected 'executing command' in log output")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("command completed")) {
+ t.Error("Expected 'command completed' in log output")
+ }
+ })
+
+ t.Run("error with logger", func(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ mock := newMockExecutor()
+ mock.setError("testcmd", fmt.Errorf("mock failure"))
+
+ _, err := executeCommand(context.Background(), mock, logger, "testcmd")
+ if err == nil {
+ t.Fatal("Expected error")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("executing command")) {
+ t.Error("Expected 'executing command' in log output")
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("command failed")) {
+ t.Error("Expected 'command failed' in log output")
+ }
+ })
}