diff --git a/README.md b/README.md index d4c773d..77a2417 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,114 @@ mping batch --count 5 192.168.1.0/29 mping batch -f hosts.txt --count 5 ``` +## Target Expansion + +mping supports powerful target expansion syntax for monitoring large numbers of hosts efficiently: + +### Bracket Expansion +Use square brackets `[]` to specify multiple targets with patterns: + +#### Numeric Ranges +```bash +# Basic numeric range +mping server[1-5].example.com +# Expands to: server1.example.com, server2.example.com, ..., server5.example.com + +# Zero-padded ranges (auto-detected from start format) +mping web[01-10].example.com +# Expands to: web01.example.com, web02.example.com, ..., web10.example.com + +# Three-digit padding +mping host[001-100].datacenter.com +# Expands to: host001.datacenter.com, host002.datacenter.com, ..., host100.datacenter.com +``` + +#### Character Ranges +```bash +# Lowercase letters +mping server[a-d].example.com +# Expands to: servera.example.com, serverb.example.com, serverc.example.com, serverd.example.com + +# Uppercase letters +mping db[A-C].cluster.internal +# Expands to: dbA.cluster.internal, dbB.cluster.internal, dbC.cluster.internal +``` + +#### List Expansion +```bash +# Comma-separated lists +mping service[web,api,db].example.com +# Expands to: serviceweb.example.com, serviceapi.example.com, servicedb.example.com + +# Mixed environments +mping app[dev,staging,prod].company.com +# Expands to: appdev.company.com, appstaging.company.com, appprod.company.com +``` + +#### Multiple Brackets (Combinations) +```bash +# Multiple expansion points +mping server[1-2].[dev,prod].example.com +# Expands to: server1.dev.example.com, server1.prod.example.com, +# server2.dev.example.com, server2.prod.example.com + +# Protocol-specific monitoring +mping https://api[1-3].[us,eu].service.com/health +``` + +#### Advanced Use Cases +```bash +# Infrastructure monitoring +mping db[01-05].cluster[a-c].datacenter.com + +# Multi-region web services +mping https://[web,api,cdn][1-3].[us-west,eu-central].example.com + +# Network equipment +mping switch[01-24].rack[a-f].datacenter.internal +``` + +### File-based Expansion +Bracket expansion also works in target files (`-f` option): + +```bash +# Create targets.txt +cat > targets.txt << EOF +# Web tier +web[01-05].prod.example.com +api[1-3].prod.example.com + +# Database tier +db[01-02].[master,slave].prod.example.com + +# Monitoring endpoints +https://health[1-5].prod.example.com/status +EOF + +# Monitor all expanded targets +mping -f targets.txt +``` + +### Combination with CIDR Ranges +Bracket expansion works alongside CIDR notation: + +```bash +# Mix bracket expansion with subnet ranges +mping server[1-3].example.com 192.168.1.0/29 + +# In files +echo "web[01-03].prod.example.com" >> targets.txt +echo "10.0.1.0/28" >> targets.txt +mping -f targets.txt +``` + +### Key Features +- **Cross-platform**: Works on Windows, macOS, and Linux +- **Shell-independent**: Unlike bash `{}` expansion, works in files and non-bash environments +- **Zero-padding intelligence**: Automatically detects and maintains padding format +- **Error handling**: Invalid patterns are preserved as-is (e.g., `[5-1]` stays `[5-1]`) +- **Protocol support**: Works with all protocol prefixes (`https://`, `tcp://`, `dns://`, etc.) + ## DNS Monitoring Details ### DNS Target Format diff --git a/internal/command/batch.go b/internal/command/batch.go index 82fe3b6..e561dff 100644 --- a/internal/command/batch.go +++ b/internal/command/batch.go @@ -54,7 +54,7 @@ mping batch dns://8.8.8.8/google.com`, return err } - hosts := parseHostnames(args, filename) + hosts := ExpandTargets(args, filename) if len(hosts) == 0 { cmd.Println("Please set hostname or ip.") cmd.Help() @@ -68,34 +68,34 @@ mping batch dns://8.8.8.8/google.com`, // Create ProbeManager and MetricsManager probeManager := prober.NewProbeManager(cfg.Prober, cfg.Default) metricsManager := stats.NewMetricsManager() - + // Add targets err = probeManager.AddTargets(hosts...) if err != nil { return fmt.Errorf("failed to add targets: %w", err) } - + // Subscribe to events for metrics collection metricsManager.Subscribe(probeManager.Events()) - + // Start probing with timeout context ctx, cancel := context.WithTimeout(context.Background(), time.Duration(counter)*_interval) defer cancel() - + cmd.Print("probe") go func() { if err := probeManager.Run(ctx, _interval, _timeout); err != nil { fmt.Printf("ProbeManager error: %v\n", err) } }() - + // Wait for specified duration for counter > 0 { counter-- cmd.Print(".") time.Sleep(_interval) } - + // Stop probing probeManager.Stop() cmd.Print("\r") diff --git a/internal/command/mping.go b/internal/command/mping.go index 81622d1..ea97a3c 100644 --- a/internal/command/mping.go +++ b/internal/command/mping.go @@ -61,7 +61,7 @@ mping dns://8.8.8.8/google.com`, return err } - hosts := parseHostnames(args, filename) + hosts := ExpandTargets(args, filename) if len(hosts) == 0 { cmd.Println("Please set hostname or ip.") cmd.Help() diff --git a/internal/command/util.go b/internal/command/util.go index 6fbce68..0806870 100644 --- a/internal/command/util.go +++ b/internal/command/util.go @@ -3,14 +3,150 @@ package command import ( "bufio" "errors" + "fmt" "io" "net" "os" "regexp" + "strconv" "strings" ) -func parseCidr(_hosts []string) []string { +// ExpandTargets processes target specifications through a pipeline of expansions +func ExpandTargets(args []string, filePath string) []string { + targets := collectTargets(args, filePath) + targets = parseBrackets(targets) + targets = parseCIDR(targets) + return targets +} + +func parseBrackets(_hosts []string) []string { + hosts := []string{} + bracketRegex := regexp.MustCompile(`\[([^\]]+)\]`) + + for _, h := range _hosts { + matches := bracketRegex.FindAllStringSubmatch(h, -1) + if len(matches) == 0 { + hosts = append(hosts, h) + continue + } + + // Generate all combinations for this host + expanded := expandHost(h, matches) + hosts = append(hosts, expanded...) + } + + return hosts +} + +func expandHost(host string, matches [][]string) []string { + results := []string{host} + + for _, match := range matches { + fullMatch := match[0] // [1-10] or [web,db,cache] + content := match[1] // 1-10 or web,db,cache + + var expansions []string + var isValidExpansion bool + + if strings.Contains(content, "-") && !strings.Contains(content, ",") { + // Range expansion: [1-10], [01-05] + expansions = expandRange(content) + // Check if expansion was successful (not just returning original content) + isValidExpansion = len(expansions) != 1 || expansions[0] != content + } else if strings.Contains(content, ",") { + // List expansion: [web,db,cache] + expansions = expandList(content) + isValidExpansion = true + } else { + // Single value: [5] + expansions = []string{content} + isValidExpansion = true + } + + // If expansion failed, skip this match and keep original brackets + if !isValidExpansion { + continue + } + + // Apply expansions to all current results + var newResults []string + for _, result := range results { + for _, expansion := range expansions { + newResults = append(newResults, strings.Replace(result, fullMatch, expansion, 1)) + } + } + results = newResults + } + + return results +} + +func expandRange(content string) []string { + parts := strings.Split(content, "-") + if len(parts) != 2 { + return []string{content} // Invalid range format + } + + startStr, endStr := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + + // Try to parse as integers + start, startErr := strconv.Atoi(startStr) + end, endErr := strconv.Atoi(endStr) + + if startErr != nil || endErr != nil { + // Try character range (a-z, A-Z) + if len(startStr) == 1 && len(endStr) == 1 { + return expandCharRange(startStr[0], endStr[0]) + } + return []string{content} // Invalid range + } + + if start > end { + return []string{content} // Invalid range + } + + // Determine zero-padding from the start string + padding := len(startStr) + if startStr[0] != '0' && start != 0 { + padding = 0 // No padding unless starts with 0 + } + + var result []string + for i := start; i <= end; i++ { + if padding > 0 { + result = append(result, fmt.Sprintf("%0*d", padding, i)) + } else { + result = append(result, strconv.Itoa(i)) + } + } + + return result +} + +func expandCharRange(start, end byte) []string { + if start > end { + return []string{string(start) + "-" + string(end)} // Invalid range + } + + var result []string + for i := start; i <= end; i++ { + result = append(result, string(i)) + } + + return result +} + +func expandList(content string) []string { + parts := strings.Split(content, ",") + var result []string + for _, part := range parts { + result = append(result, strings.TrimSpace(part)) + } + return result +} + +func parseCIDR(_hosts []string) []string { hosts := []string{} for _, h := range _hosts { ip, ipnet, err := net.ParseCIDR(h) @@ -60,18 +196,20 @@ func file2hostnames(fp *os.File) []string { return hosts } -func parseHostnames(args []string, fpath string) []string { - hosts := []string{} - - // Only attempt to open file if path is not empty - if fpath != "" { - fp, err := os.Open(fpath) +// collectTargets gathers targets from command-line arguments and optional file +func collectTargets(args []string, filePath string) []string { + targets := []string{} + + // Load targets from file if specified + if filePath != "" { + fp, err := os.Open(filePath) if err == nil { - hosts = file2hostnames(fp) + targets = file2hostnames(fp) fp.Close() // Critical fix: close file to prevent resource leak } } - hosts = append(hosts, args...) - return parseCidr(hosts) + // Append command-line arguments + targets = append(targets, args...) + return targets } diff --git a/internal/command/util_test.go b/internal/command/util_test.go new file mode 100644 index 0000000..a74cbd7 --- /dev/null +++ b/internal/command/util_test.go @@ -0,0 +1,557 @@ +package command + +import ( + "net" + "os" + "reflect" + "testing" +) + +func TestParseBrackets(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "no brackets", + input: []string{"example.com", "google.com"}, + expected: []string{"example.com", "google.com"}, + }, + { + name: "simple numeric range", + input: []string{"server[1-3].example.com"}, + expected: []string{"server1.example.com", "server2.example.com", "server3.example.com"}, + }, + { + name: "zero-padded range", + input: []string{"web[01-03].example.com"}, + expected: []string{"web01.example.com", "web02.example.com", "web03.example.com"}, + }, + { + name: "three-digit padding", + input: []string{"host[001-003].example.com"}, + expected: []string{"host001.example.com", "host002.example.com", "host003.example.com"}, + }, + { + name: "character range", + input: []string{"server[a-c].example.com"}, + expected: []string{"servera.example.com", "serverb.example.com", "serverc.example.com"}, + }, + { + name: "uppercase character range", + input: []string{"server[A-C].example.com"}, + expected: []string{"serverA.example.com", "serverB.example.com", "serverC.example.com"}, + }, + { + name: "list expansion", + input: []string{"server[web,db,cache].example.com"}, + expected: []string{"serverweb.example.com", "serverdb.example.com", "servercache.example.com"}, + }, + { + name: "list with spaces", + input: []string{"server[web, db, cache].example.com"}, + expected: []string{"serverweb.example.com", "serverdb.example.com", "servercache.example.com"}, + }, + { + name: "single value", + input: []string{"server[5].example.com"}, + expected: []string{"server5.example.com"}, + }, + { + name: "multiple brackets - combination", + input: []string{"server[1-2].[dev,prod].example.com"}, + expected: []string{ + "server1.dev.example.com", "server1.prod.example.com", + "server2.dev.example.com", "server2.prod.example.com", + }, + }, + { + name: "protocol with brackets", + input: []string{"https://api[1-2].example.com"}, + expected: []string{"https://api1.example.com", "https://api2.example.com"}, + }, + { + name: "invalid range - start > end", + input: []string{"server[5-3].example.com"}, + expected: []string{"server[5-3].example.com"}, + }, + { + name: "invalid range format", + input: []string{"server[1-2-3].example.com"}, + expected: []string{"server[1-2-3].example.com"}, + }, + { + name: "mixed valid and invalid", + input: []string{"server[1-3].example.com", "invalid[brackets", "normal.com"}, + expected: []string{"server1.example.com", "server2.example.com", "server3.example.com", "invalid[brackets", "normal.com"}, + }, + { + name: "range with hyphenated domain", + input: []string{"server[1-3].my-domain.com"}, + expected: []string{"server1.my-domain.com", "server2.my-domain.com", "server3.my-domain.com"}, + }, + { + name: "list with hyphens", + input: []string{"server[web-1,db-2,cache-3].example.com"}, + expected: []string{"serverweb-1.example.com", "serverdb-2.example.com", "servercache-3.example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseBrackets(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("parseBrackets() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestExpandRange(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "simple range", + input: "1-3", + expected: []string{"1", "2", "3"}, + }, + { + name: "zero-padded range", + input: "01-03", + expected: []string{"01", "02", "03"}, + }, + { + name: "three-digit range", + input: "001-003", + expected: []string{"001", "002", "003"}, + }, + { + name: "character range", + input: "a-c", + expected: []string{"a", "b", "c"}, + }, + { + name: "uppercase character range", + input: "A-C", + expected: []string{"A", "B", "C"}, + }, + { + name: "invalid numeric range", + input: "3-1", + expected: []string{"3-1"}, + }, + { + name: "invalid character range", + input: "z-a", + expected: []string{"z-a"}, + }, + { + name: "single number", + input: "5", + expected: []string{"5"}, + }, + { + name: "invalid format", + input: "1-2-3", + expected: []string{"1-2-3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := expandRange(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("expandRange() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestExpandList(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "simple list", + input: "web,db,cache", + expected: []string{"web", "db", "cache"}, + }, + { + name: "list with spaces", + input: "web, db, cache", + expected: []string{"web", "db", "cache"}, + }, + { + name: "single item", + input: "web", + expected: []string{"web"}, + }, + { + name: "empty items", + input: "web,,cache", + expected: []string{"web", "", "cache"}, + }, + { + name: "hyphenated items", + input: "web-server,db-master,cache-redis", + expected: []string{"web-server", "db-master", "cache-redis"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := expandList(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("expandList() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestParseCidr(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "no CIDR notation", + input: []string{"example.com", "8.8.8.8"}, + expected: []string{"example.com", "8.8.8.8"}, + }, + { + name: "single host /32", + input: []string{"192.168.1.1/32"}, + expected: []string{"192.168.1.1"}, + }, + { + name: "small subnet /30", + input: []string{"192.168.1.0/30"}, + expected: []string{"192.168.1.0", "192.168.1.1", "192.168.1.2", "192.168.1.3"}, + }, + { + name: "IPv6 single host /128", + input: []string{"2001:db8::1/128"}, + expected: []string{"2001:db8::1"}, + }, + { + name: "mixed input with and without CIDR", + input: []string{"example.com", "192.168.1.0/31", "8.8.8.8"}, + expected: []string{"example.com", "192.168.1.0", "192.168.1.1", "8.8.8.8"}, + }, + { + name: "invalid CIDR notation", + input: []string{"192.168.1.0/33", "invalid/cidr"}, + expected: []string{"192.168.1.0/33", "invalid/cidr"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseCIDR(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("parseCidr() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIpInc(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "IPv4 increment", + input: "192.168.1.1", + expected: "192.168.1.2", + }, + { + name: "IPv4 overflow to next octet", + input: "192.168.1.255", + expected: "192.168.2.0", + }, + { + name: "IPv6 increment", + input: "2001:db8::1", + expected: "2001:db8::2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip := net.ParseIP(tt.input) + if ip == nil { + t.Fatalf("Invalid IP address: %s", tt.input) + } + ipInc(ip) + if ip.String() != tt.expected { + t.Errorf("ipInc() = %v, expected %v", ip.String(), tt.expected) + } + }) + } +} + +func TestFile2hostnames(t *testing.T) { + tests := []struct { + name string + content string + expected []string + }{ + { + name: "simple hostnames", + content: "example.com\ngoogle.com\n8.8.8.8", + expected: []string{"example.com", "google.com", "8.8.8.8"}, + }, + { + name: "with comments", + content: "# This is a comment\nexample.com # inline comment\n; semicolon comment\ngoogle.com", + expected: []string{"example.com", "google.com"}, + }, + { + name: "with empty lines and whitespace", + content: "\n example.com \n\n\tgoogle.com\t\n\n", + expected: []string{"example.com", "google.com"}, + }, + { + name: "URLs with # in them", + content: "http://example.com#anchor\nhttps://test.com/path#hash", + expected: []string{"http://example.com#anchor", "https://test.com/path#hash"}, + }, + { + name: "mixed comments and URLs", + content: "# Configuration file\nhttp://api.example.com # API endpoint\n; Another comment\nhttps://web.example.com#main", + expected: []string{"http://api.example.com", "https://web.example.com#main"}, + }, + { + name: "protocol prefixes", + content: "icmpv4://example.com\nhttps://api.example.com\ntcp://db.example.com:5432", + expected: []string{"icmpv4://example.com", "https://api.example.com", "tcp://db.example.com:5432"}, + }, + { + name: "CIDR notation", + content: "192.168.1.0/24\n10.0.0.0/16 # Internal network", + expected: []string{"192.168.1.0/24", "10.0.0.0/16"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary file with test content + tmpfile, err := os.CreateTemp("", "test_hostnames_*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpfile.Name()) + defer tmpfile.Close() + + if _, err := tmpfile.WriteString(tt.content); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + + // Reset file pointer to beginning + if _, err := tmpfile.Seek(0, 0); err != nil { + t.Fatalf("Failed to seek temp file: %v", err) + } + + result := file2hostnames(tmpfile) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("file2hostnames() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestProcessTargets(t *testing.T) { + tests := []struct { + name string + args []string + fileContent string + expected []string + }{ + { + name: "args only", + args: []string{"example.com", "google.com"}, + expected: []string{"example.com", "google.com"}, + }, + { + name: "file only", + args: []string{}, + fileContent: "example.com\ngoogle.com", + expected: []string{"example.com", "google.com"}, + }, + { + name: "args and file combined", + args: []string{"cli-host.com"}, + fileContent: "file-host.com", + expected: []string{"file-host.com", "cli-host.com"}, + }, + { + name: "with bracket expansion", + args: []string{"server[1-2].example.com"}, + fileContent: "web[01-02].example.com", + expected: []string{"web01.example.com", "web02.example.com", "server1.example.com", "server2.example.com"}, + }, + { + name: "with CIDR expansion", + args: []string{"192.168.1.0/30"}, + fileContent: "10.0.0.0/31", + expected: []string{"10.0.0.0", "10.0.0.1", "192.168.1.0", "192.168.1.1", "192.168.1.2", "192.168.1.3"}, + }, + { + name: "combined bracket and CIDR expansion", + args: []string{"server[1-2].example.com", "192.168.1.0/30"}, + fileContent: "web[a-b].example.com", + expected: []string{"weba.example.com", "webb.example.com", "server1.example.com", "server2.example.com", "192.168.1.0", "192.168.1.1", "192.168.1.2", "192.168.1.3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var fpath string + if tt.fileContent != "" { + // Create temporary file + tmpfile, err := os.CreateTemp("", "test_parse_*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(tt.fileContent); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + tmpfile.Close() + fpath = tmpfile.Name() + } + + result := ExpandTargets(tt.args, fpath) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ProcessTargets() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestProcessTargetsIntegration(t *testing.T) { + // Test the complete flow: file reading -> bracket expansion -> CIDR expansion + fileContent := `# Test configuration +# Web servers with bracket expansion +web[01-02].prod.example.com + +# Database subnet +10.0.1.0/30 # DB cluster IPs + +# Mixed protocols +https://api[1-2].example.com +icmpv4://monitor.example.com +` + + tmpfile, err := os.CreateTemp("", "test_integration_*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(fileContent); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + tmpfile.Close() + + args := []string{"server[a-b].test.com", "192.168.1.0/31"} + result := ExpandTargets(args, tmpfile.Name()) + + expected := []string{ + // From file: bracket expansion + "web01.prod.example.com", "web02.prod.example.com", + // From file: CIDR expansion + "10.0.1.0", "10.0.1.1", "10.0.1.2", "10.0.1.3", + // From file: protocols with bracket expansion + "https://api1.example.com", "https://api2.example.com", + // From file: plain hostname + "icmpv4://monitor.example.com", + // From args: bracket expansion + "servera.test.com", "serverb.test.com", + // From args: CIDR expansion + "192.168.1.0", "192.168.1.1", + } + + if !reflect.DeepEqual(result, expected) { + t.Errorf("Integration test failed.\nGot: %v\nExpected: %v", result, expected) + + // Helper debug output + t.Logf("Result length: %d, Expected length: %d", len(result), len(expected)) + for i, item := range result { + if i < len(expected) { + if item != expected[i] { + t.Logf(" [%d] Got: %q, Expected: %q", i, item, expected[i]) + } + } else { + t.Logf(" [%d] Extra item: %q", i, item) + } + } + } +} + +func TestCollectTargets(t *testing.T) { + tests := []struct { + name string + args []string + fileContent string + expected []string + }{ + { + name: "args only", + args: []string{"server1.com", "server2.com"}, + expected: []string{"server1.com", "server2.com"}, + }, + { + name: "file only", + args: []string{}, + fileContent: "file1.com\nfile2.com", + expected: []string{"file1.com", "file2.com"}, + }, + { + name: "file and args combined", + args: []string{"arg.com"}, + fileContent: "file.com", + expected: []string{"file.com", "arg.com"}, + }, + { + name: "file with comments", + args: []string{}, + fileContent: "# Comment\nserver.com # inline\n; another comment\nvalid.com", + expected: []string{"server.com", "valid.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var filePath string + if tt.fileContent != "" { + tmpfile, err := os.CreateTemp("", "test_collect_*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(tt.fileContent); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + tmpfile.Close() + filePath = tmpfile.Name() + } + + result := collectTargets(tt.args, filePath) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("CollectTargets() = %v, expected %v", result, tt.expected) + } + }) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b4f14c2..8e38f5d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,7 +22,7 @@ func TestValidationErrors(t *testing.T) { t.Run("single validation error", func(t *testing.T) { ve := &ValidationErrors{} ve.Add(fmt.Errorf("test error")) - + if !ve.HasErrors() { t.Error("Expected errors") } @@ -35,11 +35,11 @@ func TestValidationErrors(t *testing.T) { ve := &ValidationErrors{} ve.Add(fmt.Errorf("error 1")) ve.Add(fmt.Errorf("error 2")) - + if !ve.HasErrors() { t.Error("Expected errors") } - + expected := "multiple validation errors: error 1; error 2" if ve.Error() != expected { t.Errorf("Expected '%s', got %s", expected, ve.Error()) @@ -49,7 +49,7 @@ func TestValidationErrors(t *testing.T) { t.Run("add nil error should be ignored", func(t *testing.T) { ve := &ValidationErrors{} ve.Add(nil) - + if ve.HasErrors() { t.Error("Expected no errors when adding nil") } @@ -67,12 +67,12 @@ func TestConfigValidate(t *testing.T) { t.Run("invalid default prober", func(t *testing.T) { cfg := DefaultConfig() cfg.Default = "nonexistent" - + err := cfg.Validate() if err == nil { t.Error("Expected validation error for invalid default prober") } - + if !strings.Contains(err.Error(), "default prober 'nonexistent' not found") { t.Errorf("Expected error about nonexistent default prober, got: %v", err) } @@ -86,12 +86,12 @@ func TestConfigValidate(t *testing.T) { ExpectCodes: "invalid-pattern", }, } - + err := cfg.Validate() if err == nil { t.Error("Expected validation error for invalid prober config") } - + if !strings.Contains(err.Error(), "prober 'invalid'") { t.Errorf("Expected error about invalid prober, got: %v", err) } @@ -114,12 +114,12 @@ func TestConfigValidate(t *testing.T) { Port: 53, }, } - + err := cfg.Validate() if err == nil { t.Error("Expected validation errors") } - + errMsg := err.Error() if !strings.Contains(errMsg, "multiple validation errors") { t.Errorf("Expected multiple validation errors message, got: %v", err) @@ -132,7 +132,7 @@ func TestConfigValidate(t *testing.T) { t.Run("empty default prober should be valid", func(t *testing.T) { cfg := DefaultConfig() cfg.Default = "" - + if err := cfg.Validate(); err != nil { t.Errorf("Empty default prober should be valid: %v", err) } @@ -182,7 +182,7 @@ prober: if err == nil { t.Error("Invalid config should fail validation") } - + if !strings.Contains(err.Error(), "invalid TOS value") { t.Errorf("Expected TOS validation error, got: %v", err) } @@ -201,7 +201,7 @@ prober: if err == nil { t.Error("Invalid HTTP config should fail validation") } - + if !strings.Contains(err.Error(), "invalid expect_codes pattern") { t.Errorf("Expected expect_codes validation error, got: %v", err) } @@ -222,9 +222,9 @@ prober: if err == nil { t.Error("Invalid DNS config should fail validation") } - + if !strings.Contains(err.Error(), "DNS server is required") { t.Errorf("Expected DNS server validation error, got: %v", err) } }) -} \ No newline at end of file +} diff --git a/internal/prober/accept_test.go b/internal/prober/accept_test.go index 7f28465..33e65d3 100644 --- a/internal/prober/accept_test.go +++ b/internal/prober/accept_test.go @@ -105,7 +105,7 @@ func TestHTTPProberAccept(t *testing.T) { prefix: "my-https", config: &HTTPConfig{ ExpectCodes: "200", - TLS: &TLSConfig{SkipVerify: true}, + TLS: &TLSConfig{SkipVerify: true}, }, target: "my-https://secure.example.com", }, diff --git a/internal/prober/code_matcher.go b/internal/prober/code_matcher.go index 0bb1465..bb00059 100644 --- a/internal/prober/code_matcher.go +++ b/internal/prober/code_matcher.go @@ -100,4 +100,4 @@ func isValidSinglePattern(pattern string) bool { // Single code pattern _, err := strconv.Atoi(pattern) return err == nil -} \ No newline at end of file +} diff --git a/internal/prober/code_matcher_test.go b/internal/prober/code_matcher_test.go index a3a9938..ea55e18 100644 --- a/internal/prober/code_matcher_test.go +++ b/internal/prober/code_matcher_test.go @@ -185,9 +185,9 @@ func TestMatchCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MatchCode(tt.code, tt.pattern) - + if result != tt.expected { - t.Errorf("Pattern %q with code %d: expected %v, got %v", + t.Errorf("Pattern %q with code %d: expected %v, got %v", tt.pattern, tt.code, tt.expected, result) } }) @@ -220,12 +220,11 @@ func TestIsValidCodePattern(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := IsValidCodePattern(tt.pattern) - + if result != tt.expected { - t.Errorf("Pattern %q validation: expected %v, got %v", + t.Errorf("Pattern %q validation: expected %v, got %v", tt.pattern, tt.expected, result) } }) } } - diff --git a/internal/prober/details.go b/internal/prober/details.go index 7c7c97f..f2e5865 100644 --- a/internal/prober/details.go +++ b/internal/prober/details.go @@ -45,4 +45,4 @@ type NTPDetails struct { Stratum int `json:"stratum"` Offset int64 `json:"offset_microseconds"` // In microseconds Precision int `json:"precision"` -} \ No newline at end of file +} diff --git a/internal/prober/dns.go b/internal/prober/dns.go index 679b1d7..596a39f 100644 --- a/internal/prober/dns.go +++ b/internal/prober/dns.go @@ -267,7 +267,7 @@ func (p *DNSProber) success(result chan *Event, target *DNSTarget, sentTime time for _, ans := range resp.Answer { answers = append(answers, ans.String()) } - + details := &ProbeDetails{ ProbeType: "dns", DNS: &DNSDetails{ @@ -281,7 +281,7 @@ func (p *DNSProber) success(result chan *Event, target *DNSTarget, sentTime time UseTCP: target.UseTCP, }, } - + result <- &Event{ Key: target.OriginalTarget, DisplayName: target.OriginalTarget, diff --git a/internal/prober/http.go b/internal/prober/http.go index 57dbc0c..2aa2ee4 100644 --- a/internal/prober/http.go +++ b/internal/prober/http.go @@ -12,7 +12,6 @@ import ( "strings" "sync" "time" - ) const ( @@ -189,19 +188,19 @@ func (p *HTTPProber) probe(r chan *Event, target string) { headers[key] = values[0] // Get only the first value } } - + var redirects []string if resp.Request.URL.String() != target { redirects = append(redirects, resp.Request.URL.String()) } - + var probeType string if strings.HasPrefix(target, "https://") { probeType = "https" } else { probeType = "http" } - + details := &ProbeDetails{ ProbeType: probeType, HTTP: &HTTPDetails{ @@ -211,7 +210,7 @@ func (p *HTTPProber) probe(r chan *Event, target string) { Redirects: redirects, }, } - + r <- &Event{ Key: target, DisplayName: target, diff --git a/internal/prober/http_status_test.go b/internal/prober/http_status_test.go index ce661fc..55783ed 100644 --- a/internal/prober/http_status_test.go +++ b/internal/prober/http_status_test.go @@ -178,12 +178,11 @@ func TestHTTPStatusCodeMatching(t *testing.T) { t.Run(tt.name, func(t *testing.T) { prober := NewHTTPProber(tt.config, "test") result := prober.isExpectedStatusCode(tt.statusCode) - + if result != tt.expected { - t.Errorf("Expected %v, got %v for status code %d with config %+v", + t.Errorf("Expected %v, got %v for status code %d with config %+v", tt.expected, result, tt.statusCode, tt.config) } }) } } - diff --git a/internal/prober/icmp.go b/internal/prober/icmp.go index ff80922..b8295bc 100644 --- a/internal/prober/icmp.go +++ b/internal/prober/icmp.go @@ -190,7 +190,7 @@ func (p *ICMPProber) success(r chan *Event, runCnt int, addr string, payload icm // Extract detailed packet information icmpDetails := p.extractICMPDetails(runCnt, addr, payload, packetData, packetSize) - + // Create ICMP detail information details := &ProbeDetails{ ProbeType: string(p.version), @@ -213,21 +213,20 @@ func (p *ICMPProber) success(r chan *Event, runCnt int, addr string, payload icm func (p *ICMPProber) extractICMPDetails(runCnt int, addr string, payload icmp.Message, packetData []byte, packetSize int) *ICMPDetails { var payloadContent string var checksum uint16 - + // Extract echo data if available if echoBody, ok := payload.Body.(*icmp.Echo); ok { - + // Format payload content with length limit payloadContent = formatPayloadContent(echoBody.Data) } - + // Extract checksum from raw packet data if available // ICMP checksum is at offset 2-3 in the ICMP header if len(packetData) >= 4 { checksum = binary.BigEndian.Uint16(packetData[2:4]) } - - + // Convert ICMP type to int safely var icmpType int switch payload.Type { @@ -243,7 +242,7 @@ func (p *ICMPProber) extractICMPDetails(runCnt int, addr string, payload icmp.Me icmpType = -1 // Unknown type } } - + details := &ICMPDetails{ Sequence: runCnt, PacketSize: packetSize, @@ -252,20 +251,18 @@ func (p *ICMPProber) extractICMPDetails(runCnt int, addr string, payload icmp.Me Checksum: checksum, Payload: payloadContent, } - + return details } - - // formatPayloadContent formats payload bytes for display with length limit func formatPayloadContent(data []byte) string { const maxDisplayLength = 32 // Maximum characters to display - + if len(data) == 0 { return "" } - + // Convert to string, replacing non-printable characters var result strings.Builder for _, b := range data { @@ -275,14 +272,14 @@ func formatPayloadContent(data []byte) string { result.WriteString(fmt.Sprintf("\\x%02x", b)) } } - + payloadStr := result.String() - + // Truncate if too long if len(payloadStr) > maxDisplayLength { payloadStr = payloadStr[:maxDisplayLength-3] + "..." } - + return payloadStr } diff --git a/internal/prober/icmp_test.go b/internal/prober/icmp_test.go index 6fa2045..eb7217f 100644 --- a/internal/prober/icmp_test.go +++ b/internal/prober/icmp_test.go @@ -6,76 +6,76 @@ import ( func TestResolveSourceInterface(t *testing.T) { tests := []struct { - name string + name string sourceInterface string - probeType ProbeType - expectError bool - expectedAddr string + probeType ProbeType + expectError bool + expectedAddr string }{ { - name: "Empty interface returns default IPv4", + name: "Empty interface returns default IPv4", sourceInterface: "", - probeType: ICMPV4, - expectError: false, - expectedAddr: "0.0.0.0", + probeType: ICMPV4, + expectError: false, + expectedAddr: "0.0.0.0", }, { - name: "Empty interface returns default IPv6", + name: "Empty interface returns default IPv6", sourceInterface: "", - probeType: ICMPV6, - expectError: false, - expectedAddr: "::", + probeType: ICMPV6, + expectError: false, + expectedAddr: "::", }, { - name: "Valid IPv4 address", + name: "Valid IPv4 address", sourceInterface: "127.0.0.1", - probeType: ICMPV4, - expectError: false, - expectedAddr: "127.0.0.1", + probeType: ICMPV4, + expectError: false, + expectedAddr: "127.0.0.1", }, { - name: "Valid IPv6 address", + name: "Valid IPv6 address", sourceInterface: "::1", - probeType: ICMPV6, - expectError: false, - expectedAddr: "::1", + probeType: ICMPV6, + expectError: false, + expectedAddr: "::1", }, { - name: "IPv6 address for IPv4 probe should fail", + name: "IPv6 address for IPv4 probe should fail", sourceInterface: "::1", - probeType: ICMPV4, - expectError: true, + probeType: ICMPV4, + expectError: true, }, { - name: "IPv4 address for IPv6 probe should fail", + name: "IPv4 address for IPv6 probe should fail", sourceInterface: "127.0.0.1", - probeType: ICMPV6, - expectError: true, + probeType: ICMPV6, + expectError: true, }, { - name: "Invalid interface name", + name: "Invalid interface name", sourceInterface: "nonexistent-interface", - probeType: ICMPV4, - expectError: true, + probeType: ICMPV4, + expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { addr, err := resolveSourceInterface(tt.sourceInterface, tt.probeType) - + if tt.expectError { if err == nil { t.Errorf("Expected error but got none") } return } - + if err != nil { t.Errorf("Unexpected error: %v", err) return } - + if addr != tt.expectedAddr { t.Errorf("Expected address %s, got %s", tt.expectedAddr, addr) } @@ -91,8 +91,8 @@ func TestResolveSourceInterfaceLoopback(t *testing.T) { t.Skipf("Loopback interface test skipped: %v", err) return } - + if addr != "127.0.0.1" { t.Logf("Loopback IPv4 address: %s (expected 127.0.0.1, but this may vary)", addr) } -} \ No newline at end of file +} diff --git a/internal/prober/manager_detailed_test.go b/internal/prober/manager_detailed_test.go index 98cb0ea..7648212 100644 --- a/internal/prober/manager_detailed_test.go +++ b/internal/prober/manager_detailed_test.go @@ -14,7 +14,7 @@ func TestProbeManagerDetailedScenarios(t *testing.T) { Probe: HTTP, HTTP: &HTTPConfig{ ExpectCodes: "200", - TLS: &TLSConfig{SkipVerify: true}, + TLS: &TLSConfig{SkipVerify: true}, }, }, } @@ -106,7 +106,7 @@ func TestGetOrCreateProber(t *testing.T) { Probe: HTTP, HTTP: &HTTPConfig{ ExpectCodes: "200", - TLS: &TLSConfig{SkipVerify: true}, + TLS: &TLSConfig{SkipVerify: true}, }, }, "tcp": { diff --git a/internal/prober/manager_test.go b/internal/prober/manager_test.go index b8f332b..294cc6f 100644 --- a/internal/prober/manager_test.go +++ b/internal/prober/manager_test.go @@ -185,7 +185,7 @@ func TestProbeManagerIntegration(t *testing.T) { Probe: HTTP, HTTP: &HTTPConfig{ ExpectCodes: "200", - TLS: &TLSConfig{SkipVerify: true}, + TLS: &TLSConfig{SkipVerify: true}, }, }, } @@ -250,7 +250,7 @@ func TestHTTPProberTLSConfig(t *testing.T) { name: "HTTPS with TLS config", config: &HTTPConfig{ ExpectCodes: "200", - TLS: &TLSConfig{SkipVerify: true}, + TLS: &TLSConfig{SkipVerify: true}, }, target: "my-https://secure.example.com", expectedURL: "https://secure.example.com", diff --git a/internal/prober/ntp.go b/internal/prober/ntp.go index 9654fdb..564fe70 100644 --- a/internal/prober/ntp.go +++ b/internal/prober/ntp.go @@ -7,7 +7,6 @@ import ( "strings" "sync" "time" - ) const ( @@ -260,7 +259,7 @@ func (p *NTPProber) success(result chan *Event, serverAddr, displayName string, port = p } } - + details := &ProbeDetails{ ProbeType: "ntp", NTP: &NTPDetails{ @@ -271,7 +270,7 @@ func (p *NTPProber) success(result chan *Event, serverAddr, displayName string, Precision: int(resp.Precision), }, } - + result <- &Event{ Key: serverAddr, DisplayName: displayName, diff --git a/internal/prober/ntp_test.go b/internal/prober/ntp_test.go index f07d04f..d04372b 100644 --- a/internal/prober/ntp_test.go +++ b/internal/prober/ntp_test.go @@ -71,7 +71,7 @@ func TestNTPConfigValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() - + if tt.wantErr { if err == nil { t.Error("Expected validation error but got none") @@ -143,7 +143,7 @@ func TestNTPProberAccept(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := prober.Accept(tt.target) - + if tt.wantErr { if err == nil { t.Error("Expected error but got none") @@ -226,7 +226,7 @@ func TestNTPProberParseTarget(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server, port, err := prober.parseTarget(tt.target) - + if tt.wantErr { if err == nil { t.Error("Expected error but got none") @@ -251,11 +251,11 @@ func TestNTPProberParseTarget(t *testing.T) { func TestNTPTimeConversion(t *testing.T) { // Test time conversion functions now := time.Now() - + // Convert to NTP format and back sec, frac := ntpTimeFromTime(now) converted := ntpTimeToTime(sec, frac) - + // Allow small difference due to precision loss diff := converted.Sub(now).Abs() if diff > time.Microsecond { @@ -300,4 +300,4 @@ func TestNTPProberRegistration(t *testing.T) { if count != len(targets) { t.Errorf("Expected %d registration events, got %d", len(targets), count) } -} \ No newline at end of file +} diff --git a/internal/prober/tcp.go b/internal/prober/tcp.go index ca84e81..0cbe732 100644 --- a/internal/prober/tcp.go +++ b/internal/prober/tcp.go @@ -191,7 +191,7 @@ func (p *TCPProber) sent(result chan *Event, target string, sentTime time.Time) func (p *TCPProber) success(result chan *Event, target string, sentTime time.Time, rtt time.Duration) { displayName := p.targets[target] // Get displayName from targets map - + // TCP only checks connectivity, so no detailed information result <- &Event{ Key: target, diff --git a/internal/prober/validate_test.go b/internal/prober/validate_test.go index c78e61d..16d76b4 100644 --- a/internal/prober/validate_test.go +++ b/internal/prober/validate_test.go @@ -75,7 +75,7 @@ func TestICMPConfigValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() - + if tt.wantErr { if err == nil { t.Error("Expected validation error but got none") @@ -155,7 +155,7 @@ func TestHTTPConfigValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() - + if tt.wantErr { if err == nil { t.Error("Expected validation error but got none") @@ -177,7 +177,7 @@ func TestTCPConfigValidate(t *testing.T) { config *TCPConfig }{ { - name: "valid config - empty", + name: "valid config - empty", config: &TCPConfig{}, }, { @@ -308,7 +308,7 @@ func TestDNSConfigValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() - + if tt.wantErr { if err == nil { t.Error("Expected validation error but got none") @@ -488,7 +488,7 @@ func TestProberConfigValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.config.Validate() - + if tt.wantErr { if err == nil { t.Error("Expected validation error but got none") @@ -502,4 +502,4 @@ func TestProberConfigValidate(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/stats/history.go b/internal/stats/history.go index 8522931..de51abd 100644 --- a/internal/stats/history.go +++ b/internal/stats/history.go @@ -2,16 +2,16 @@ package stats import ( "time" - + "github.com/servak/mping/internal/prober" ) // History entry type HistoryEntry struct { - Timestamp time.Time `json:"timestamp"` - RTT time.Duration `json:"rtt"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` + Timestamp time.Time `json:"timestamp"` + RTT time.Duration `json:"rtt"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` Details *prober.ProbeDetails `json:"details,omitempty"` } @@ -130,7 +130,7 @@ func (th *TargetHistory) GetSuccessRateInPeriod(duration time.Duration) float64 since := time.Now().Add(-duration) entries := th.GetEntriesSince(since) - + if len(entries) == 0 { return 0.0 } @@ -149,4 +149,4 @@ func (th *TargetHistory) GetSuccessRateInPeriod(duration time.Duration) float64 func (th *TargetHistory) Clear() { th.index = 0 th.count = 0 -} \ No newline at end of file +} diff --git a/internal/ui/shared/table_data.go b/internal/ui/shared/table_data.go index a62b5c9..c0220f1 100644 --- a/internal/ui/shared/table_data.go +++ b/internal/ui/shared/table_data.go @@ -94,7 +94,7 @@ func (td *TableData) ToGoPrettyTable() table.Writer { func (td *TableData) ToTviewTable() *tview.Table { // Use default dark theme colors as fallback theme := PredefinedThemes["dark"] - + t := tview.NewTable(). SetFixed(1, 0). SetSelectable(true, false). diff --git a/internal/ui/tui/app_test.go b/internal/ui/tui/app_test.go index 00cd2b2..c36ccd6 100644 --- a/internal/ui/tui/app_test.go +++ b/internal/ui/tui/app_test.go @@ -179,4 +179,4 @@ func TestTUIAppCreateHelpModal(t *testing.T) { if modal == nil { t.Error("createHelpModal() returned nil") } -} \ No newline at end of file +} diff --git a/internal/ui/tui/state/interfaces.go b/internal/ui/tui/state/interfaces.go index 2502766..9477535 100644 --- a/internal/ui/tui/state/interfaces.go +++ b/internal/ui/tui/state/interfaces.go @@ -38,4 +38,4 @@ type FullUIState interface { SortState FilterState RenderState -} \ No newline at end of file +} diff --git a/internal/ui/tui/state/state_test.go b/internal/ui/tui/state/state_test.go index d7a6af5..1ca77a3 100644 --- a/internal/ui/tui/state/state_test.go +++ b/internal/ui/tui/state/state_test.go @@ -195,4 +195,3 @@ func TestUIStateInterfaces(t *testing.T) { var _ RenderState = state var _ FullUIState = state } -