Skip to content
17 changes: 11 additions & 6 deletions docs/schema/facts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
#
# type: string | integer | double | boolean | map | array
# description: what the fact means, one line
# platforms: the platforms that can emit it (linux, darwin, windows,
# freebsd, openbsd, netbsd, dragonfly, illumos, plan9)
# platforms: schema-visible platform target IDs from internal/platform
# conditional: true when presence depends on host state (cloud instances,
# swap, DMI visibility, installed tools); such entries may be
# absent from a discovery without it being a bug
# open_subtree: true when provider-shaped descendants are intentionally
# accepted without documenting every leaf
#
# A `map`- or `array`-typed entry covers every deeper leaf under it, so
# provider-shaped subtrees (ec2_metadata, gce, az_metadata, system_profiler)
# are documented at the subtree root.
# A `*` path segment matches exactly one segment. A `map`-typed entry only
# covers the entry path itself unless `open_subtree: true` is set. Array
# entries cover the synthetic `path.*` leaf used by the conformance test.
#
# TestFactsSchemaConformance (schema_test.go) enforces this file on every
# platform CI gate: an undocumented fact fails the gate, and a non-conditional
Expand All @@ -32,6 +33,7 @@ augeas.version:

az_metadata:
type: map
open_subtree: true
description: The Azure Instance Metadata Service tree, on Azure virtual machines.
platforms: [linux, windows]
conditional: true
Expand Down Expand Up @@ -156,6 +158,7 @@ dmi.product.version:

ec2_metadata:
type: map
open_subtree: true
description: The EC2 instance metadata tree, on AWS instances.
platforms: [linux, windows, freebsd]
conditional: true
Expand All @@ -182,6 +185,7 @@ fips_enabled:

gce:
type: map
open_subtree: true
description: The Google Compute Engine metadata tree, on GCE instances.
platforms: [linux, windows]
conditional: true
Expand Down Expand Up @@ -584,7 +588,7 @@ networking.interfaces.*.scope6:
networking.interfaces.*.speed:
type: integer
description: The negotiated speed of the interface, in Mbit/s.
platforms: [linux]
platforms: [linux, freebsd]
conditional: true
networking.ip:
type: string
Expand Down Expand Up @@ -917,6 +921,7 @@ ssh.*.type:

system_profiler:
type: map
open_subtree: true
description: macOS system_profiler hardware, software, and Ethernet details (provider-shaped keys, such as model_name and serial_number).
platforms: [darwin]

Expand Down
2 changes: 1 addition & 1 deletion docs/supported-facts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ These pages are generated from [`docs/schema/facts.yaml`](../schema/facts.yaml).
| [Linux](linux.md) | 175 |
| [macOS / Darwin](darwin.md) | 107 |
| [Windows](windows.md) | 101 |
| [FreeBSD](freebsd.md) | 130 |
| [FreeBSD](freebsd.md) | 131 |
| [OpenBSD](openbsd.md) | 113 |
| [NetBSD](netbsd.md) | 117 |
| [DragonFly BSD](dragonfly.md) | 115 |
Expand Down
3 changes: 2 additions & 1 deletion docs/supported-facts/freebsd.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ $ facts --json

## Fact Contract

130 schema entries include `freebsd`.
131 schema entries include `freebsd`.

| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
Expand Down Expand Up @@ -146,6 +146,7 @@ $ facts --json
| `networking.interfaces.*.network6` | `string` | yes | The IPv6 network of the interface's first binding. |
| `networking.interfaces.*.operational_state` | `string` | yes | The operational state of the interface, such as up or down. |
| `networking.interfaces.*.scope6` | `string` | yes | The IPv6 scope of the interface's first binding, such as global or link. |
| `networking.interfaces.*.speed` | `integer` | yes | The negotiated speed of the interface, in Mbit/s. |
| `networking.ip` | `string` | yes | The IPv4 address of the primary interface. |
| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. |
| `networking.mac` | `string` | yes | The MAC address of the primary interface. |
Expand Down
108 changes: 44 additions & 64 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,27 @@ func effectiveExternalDirs(explicit []string) []string {
}

func configPathFromArgs(args []string) string {
for i := range args {
for i := 0; i < len(args); i++ {
arg := args[i]
if value, ok := strings.CutPrefix(arg, "--config="); ok {
return value
option, ok := cli.LookupOption(arg)
if !ok {
continue
}
if value, ok := strings.CutPrefix(arg, "-c="); ok {
return value
if value, hasInlineValue := inlineOptionValue(arg); hasInlineValue {
if option.Canonical == "--config" {
return value
}
continue
}
if arg == "--config" || arg == "-c" {
if option.Canonical == "--config" {
if i+1 < len(args) {
return args[i+1]
}
return ""
}
if option.Arity == cli.RequiredValue && i+1 < len(args) {
i++
}
}
return ""
}
Expand All @@ -108,68 +115,55 @@ func externalDirsFromArgs(args []string) []string {
dirs := []string{}
for i := 0; i < len(args); i++ {
arg := args[i]
if value, ok := strings.CutPrefix(arg, "--external-dir="); ok {
dirs = append(dirs, value)
option, ok := cli.LookupOption(arg)
if !ok {
continue
}
if arg == "--external-dir" {
if value, hasInlineValue := inlineOptionValue(arg); hasInlineValue {
if option.Canonical == "--external-dir" {
dirs = append(dirs, value)
}
continue
}
if option.Canonical == "--external-dir" {
if i+1 < len(args) {
dirs = append(dirs, args[i+1])
i++
}
continue
}
if optionTakesValueForGroupListing(arg) && i+1 < len(args) {
if option.Arity == cli.RequiredValue && i+1 < len(args) {
i++
}
}
return dirs
}

func optionTakesValueForGroupListing(arg string) bool {
switch arg {
case "--config", "-c", "--log-level", "-l":
return true
default:
return false
}
func inlineOptionValue(arg string) (string, bool) {
_, value, ok := strings.Cut(arg, "=")
return value, ok
}

func helpText() string {
return `Usage
var b strings.Builder
b.WriteString(`Usage
=====

facts [options] [query] [query] [...]

Options
=======
[--color] Force color output (default: enabled when writing to a terminal). In the default output format, fact keys are colored by nesting depth.
[--no-color] Disable color output.
-c [--config] The location of the config file.
-d [--debug] Enable debug output.
[--external-dir] A directory to use for external facts.
[--hocon] Output in Hocon format.
-j [--json] Output in JSON format.
-l [--log-level] Set logging level.
[--no-block] Disable fact blocking.
[--no-cache] Disable loading and refreshing facts from the cache.
[--no-external-facts] Disable external facts.
[--verbose] Enable verbose output.
-y [--yaml] Output in YAML format.
[--strict] Enable more aggressive error reporting.
-t [--timing] Show how much time it took to resolve each fact.
[--sequential] Resolve facts sequentially.
[--http-debug] Write HTTP request and responses to stderr.
-h [--help] Help for all arguments
--version, -v Print the version
--man Display manual.
--list-block-groups List block groups
--list-cache-groups List cache groups
`
`)
for _, option := range cli.DocumentedOptions() {
b.WriteString(option.Documentation.Help)
b.WriteByte('\n')
}
return b.String()
}

func manText() string {
return `facts - collect and display facts about the current system
var b strings.Builder
b.WriteString(`facts - collect and display facts about the current system
==========================================================

SYNOPSIS
Expand All @@ -186,27 +180,12 @@ Many command line options can also be set via the HOCON config file. This file c

OPTIONS
-------
* --color: Force color output (default: enabled when writing to a terminal). In the default output format, fact keys are colored by nesting depth.
* --no-color: Disable color output.
* -c, --config: The location of the config file.
* -d, --debug: Enable debug output.
* --external-dir: A directory to use for external facts.
* --hocon: Output in Hocon format.
* -j, --json: Output in JSON format.
* -l, --log-level: Set logging level.
* --no-block: Disable fact blocking.
* --no-cache: Disable loading and refreshing facts from the cache.
* --no-external-facts: Disable external facts.
* --verbose: Enable verbose output.
* -y, --yaml: Output in YAML format.
* --strict: Enable more aggressive error reporting.
* -t, --timing: Show how much time it took to resolve each fact.
* --sequential: Resolve facts sequentially.
* --http-debug: Write HTTP request and responses to stderr.
* --version, -v: Print the version.
* --man: Display manual.
* --list-block-groups: List block groups.
* --list-cache-groups: List cache groups.
`)
for _, option := range cli.DocumentedOptions() {
b.WriteString(option.Documentation.Man)
b.WriteByte('\n')
}
b.WriteString(`

FILES
-----
Expand All @@ -231,7 +210,8 @@ Display a single structured fact:
Format facts as JSON:

facts --json os.name os.release.major processors.isa
`
`)
return b.String()
}

func runQuery(stdout, stderr io.Writer, args []string) error {
Expand Down
51 changes: 51 additions & 0 deletions internal/app/cli_option_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package app

import (
"os"
"path/filepath"
"strings"
"testing"

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

func TestCLIOptionDocumentationIncludesAcceptedNonHiddenOptions(t *testing.T) {
installedManPage, err := os.ReadFile(filepath.Join("..", "..", "man", "man8", "facts.8"))
if err != nil {
t.Fatal(err)
}

surfaces := []struct {
name string
text string
}{
{name: "help", text: helpText()},
{name: "man", text: manText()},
{name: "installed man page", text: normalizeManPage(string(installedManPage))},
}

for _, option := range cli.Options() {
if option.Hidden {
continue
}
names := append([]string{option.Canonical}, option.Aliases...)
for _, surface := range surfaces {
for _, name := range names {
if !strings.Contains(surface.text, name) {
t.Fatalf("%s output missing documented option %q:\n%s", surface.name, name, surface.text)
}
}
}
}
}

func normalizeManPage(text string) string {
replacer := strings.NewReplacer(
`\fB`, "",
`\fR`, "",
`\-`, "-",
`\.`, ".",
`\&`, "",
)
return replacer.Replace(text)
}
38 changes: 5 additions & 33 deletions internal/cli/arguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ func PrepareArguments(args []string) []string {
normal := make([]string, 0, len(prepared))
for i := 0; i < len(prepared); i++ {
arg := prepared[i]
if mappedFlags[arg] || tasks[arg] {
if IsTaskFlag(arg) || IsTask(arg) {
priority = append(priority, arg)
continue
}
normal = append(normal, arg)
if optionTakesSeparateValue(arg) && i+1 < len(prepared) {
if OptionTakesSeparateValue(arg) && i+1 < len(prepared) {
i++
normal = append(normal, prepared[i])
}
Expand All @@ -34,7 +34,7 @@ func expandShortOptions(args []string) []string {
expanded = append(expanded, arg)
continue
}
if shortOptionTakesAttachedValue(arg[1]) {
if ShortOptionTakesAttachedValue(arg[1]) {
expanded = append(expanded, arg[:2], arg[2:])
continue
}
Expand All @@ -45,41 +45,13 @@ func expandShortOptions(args []string) []string {
return expanded
}

func shortOptionTakesAttachedValue(flag byte) bool {
switch flag {
case 'c', 'l':
return true
default:
return false
}
}

var tasks = map[string]bool{
"help": true,
"query": true,
"version": true,
"man": true,
"list_block_groups": true,
"list_cache_groups": true,
}

var mappedFlags = map[string]bool{
"-h": true,
"--help": true,
"--man": true,
"-v": true,
"--version": true,
"--list-block-groups": true,
"--list-cache-groups": true,
}

func containsKnownTaskOrMappedFlag(args []string) bool {
for i := 0; i < len(args); i++ {
arg := args[i]
if tasks[arg] || mappedFlags[arg] {
if IsTask(arg) || IsTaskFlag(arg) {
return true
}
if optionTakesSeparateValue(arg) && i+1 < len(args) {
if OptionTakesSeparateValue(arg) && i+1 < len(args) {
i++
}
}
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/arguments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ func TestPrepareArguments_reordersShortVersionFlag(t *testing.T) {
}
}

func TestPrepareArguments_doesNotPromoteTaskFlagWithInlineValue(t *testing.T) {
got := PrepareArguments([]string{"--help=topic"})
want := []string{"query", "--help=topic"}
if !slices.Equal(got, want) {
t.Fatalf("PrepareArguments() = %v, want %v", got, want)
}
}

func TestPrepareArguments_preservesShortOptionsWithEquals(t *testing.T) {
got := PrepareArguments([]string{"-l=debug", "os.name"})
want := []string{"query", "-l=debug", "os.name"}
Expand Down
Loading
Loading