buf.build protobuf type counter for their pricing so you have an understanding what the costs will be
go build -o protocounter .
protocounter /path/to/your/directory
🔍 Vehicle (message) appears in 2 locations:
• /path/to/v1/vehicle.proto
• /path/to/v2/cars.proto
📊 Protobuf Statistics:
Proto Files: 1,234
Messages: 2,567
Enums: 345
RPCs: 789
Total Types: 3,701
Duplicate Types: 156
Unique Types: 3,545
Duplicate Percentage: 4.2%
💰 Cost Analysis:
Community Plan: Free per month
Team Plan: $1.85K per month ($22.2K per year)
Pro Plan: $18.5K per month ($222K per year)
| package main | |
| import ( | |
| "bufio" | |
| "fmt" | |
| "os" | |
| "path/filepath" | |
| "regexp" | |
| "strings" | |
| ) | |
| type TypeInfo struct { | |
| Name string | |
| TypeKind string // "message", "enum", or "rpc" | |
| FilePath string | |
| } | |
| type DuplicateInfo struct { | |
| TypeName string | |
| TypeKind string | |
| Locations []string | |
| } | |
| type ProtoStats struct { | |
| TotalFiles int | |
| TotalMessages int | |
| TotalEnums int | |
| TotalRPCs int | |
| TotalTypes int | |
| DuplicateTypes int | |
| AllTypes map[string][]TypeInfo // key is the typename only | |
| DuplicateDetails []DuplicateInfo | |
| } | |
| func newProtoStats() ProtoStats { | |
| return ProtoStats{ | |
| AllTypes: make(map[string][]TypeInfo), | |
| DuplicateDetails: make([]DuplicateInfo, 0), | |
| } | |
| } | |
| // costs per type per month - last updated 2025-04-21 | |
| const ( | |
| communityCost float64 = 0.0 | |
| teamCost float64 = 0.50 | |
| proCost float64 = 5.00 | |
| ) | |
| // humanizeNumber formats a number with comma separators and scales large numbers | |
| func humanizeNumber(n int) string { | |
| if n < 1000 { | |
| return fmt.Sprintf("%d", n) | |
| } | |
| return fmt.Sprintf("%d,%03d", n/1000, n%1000) | |
| } | |
| // humanizeCurrency formats currency with appropriate scaling and precision | |
| func humanizeCurrency(amount float64) string { | |
| switch { | |
| case amount >= 1000000: | |
| return fmt.Sprintf("$%.2fM", amount/1000000) | |
| case amount >= 1000: | |
| return fmt.Sprintf("$%.2fK", amount/1000) | |
| default: | |
| return fmt.Sprintf("$%.2f", amount) | |
| } | |
| } | |
| // humanizePercentage formats a percentage with appropriate precision | |
| func humanizePercentage(percentage float64) string { | |
| if percentage == 0 { | |
| return "0%" | |
| } | |
| if percentage < 0.1 { | |
| return "< 0.1%" | |
| } | |
| if percentage == 100 { | |
| return "100%" | |
| } | |
| if percentage > 99.9 { | |
| return "> 99.9%" | |
| } | |
| return fmt.Sprintf("%.1f%%", percentage) | |
| } | |
| func main() { | |
| if len(os.Args) != 2 { | |
| fmt.Println("Usage: go run main.go <directory>") | |
| os.Exit(1) | |
| } | |
| rootDir := os.Args[1] | |
| stats := scanDirectory(rootDir) | |
| // Calculate duplicates | |
| stats.calculateDuplicates() | |
| // Calculate percentage | |
| var duplicatePercentage float64 | |
| if stats.TotalTypes > 0 { | |
| duplicatePercentage = (float64(stats.DuplicateTypes) / float64(stats.TotalTypes)) * 100 | |
| } | |
| if len(stats.DuplicateDetails) > 0 { | |
| fmt.Printf("\n📋 Duplicate Type Details:\n") | |
| fmt.Printf("Found %s duplicate types across your proto files:\n", humanizeNumber(len(stats.DuplicateDetails))) | |
| for _, dup := range stats.DuplicateDetails { | |
| fmt.Printf("\n🔍 %s (%s) appears in %s locations:\n", dup.TypeName, dup.TypeKind, humanizeNumber(len(dup.Locations))) | |
| for _, loc := range dup.Locations { | |
| fmt.Printf(" • %s\n", loc) | |
| } | |
| } | |
| } | |
| fmt.Printf("\n📊 Protobuf Statistics:\n") | |
| fmt.Printf("%-25s %s\n", "Proto Files:", humanizeNumber(stats.TotalFiles)) | |
| fmt.Printf("%-25s %s\n", "Messages:", humanizeNumber(stats.TotalMessages)) | |
| fmt.Printf("%-25s %s\n", "Enums:", humanizeNumber(stats.TotalEnums)) | |
| fmt.Printf("%-25s %s\n", "RPCs:", humanizeNumber(stats.TotalRPCs)) | |
| fmt.Printf("\n%-25s %s\n", "Total Types:", humanizeNumber(stats.TotalTypes)) | |
| fmt.Printf("%-25s %s\n", "Duplicate Types:", humanizeNumber(stats.DuplicateTypes)) | |
| fmt.Printf("%-25s %s\n", "Unique Types:", humanizeNumber(stats.TotalTypes - stats.DuplicateTypes)) | |
| fmt.Printf("%-25s %s\n", "Duplicate Percentage:", humanizePercentage(duplicatePercentage)) | |
| monthlyTeamCost := float64(stats.TotalTypes) * teamCost | |
| monthlyProCost := float64(stats.TotalTypes) * proCost | |
| yearlyTeamCost := monthlyTeamCost * 12 | |
| yearlyProCost := monthlyProCost * 12 | |
| fmt.Printf("\n💰 Cost Analysis:\n") | |
| fmt.Printf("%-25s %s per month\n", "Community Plan:", "Free") | |
| fmt.Printf("%-25s %s per month (%s per year)\n", "Team Plan:", humanizeCurrency(monthlyTeamCost), humanizeCurrency(yearlyTeamCost)) | |
| fmt.Printf("%-25s %s per month (%s per year)\n", "Pro Plan:", humanizeCurrency(monthlyProCost), humanizeCurrency(yearlyProCost)) | |
| } | |
| func (stats *ProtoStats) calculateDuplicates() { | |
| // Reset duplicate count and details | |
| stats.DuplicateTypes = 0 | |
| stats.DuplicateDetails = make([]DuplicateInfo, 0) | |
| // Check each type by its name | |
| for typeName, typeInfos := range stats.AllTypes { | |
| if len(typeInfos) > 1 { | |
| stats.DuplicateTypes++ | |
| // Create duplicate info entry | |
| dupInfo := DuplicateInfo{ | |
| TypeName: typeName, | |
| TypeKind: typeInfos[0].TypeKind, | |
| Locations: make([]string, 0, len(typeInfos)), | |
| } | |
| // Add all locations where this type appears | |
| for _, info := range typeInfos { | |
| dupInfo.Locations = append(dupInfo.Locations, info.FilePath) | |
| } | |
| stats.DuplicateDetails = append(stats.DuplicateDetails, dupInfo) | |
| } | |
| } | |
| } | |
| func scanDirectory(root string) ProtoStats { | |
| stats := newProtoStats() | |
| err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { | |
| if err != nil { | |
| return err | |
| } | |
| if !info.IsDir() && strings.HasSuffix(info.Name(), ".proto") { | |
| stats.TotalFiles++ | |
| fileStats := analyzeProtoFile(path) | |
| stats.TotalMessages += fileStats.TotalMessages | |
| stats.TotalEnums += fileStats.TotalEnums | |
| stats.TotalRPCs += fileStats.TotalRPCs | |
| stats.TotalTypes += fileStats.TotalTypes | |
| // Merge the type information | |
| for key, types := range fileStats.AllTypes { | |
| stats.AllTypes[key] = append(stats.AllTypes[key], types...) | |
| } | |
| } | |
| return nil | |
| }) | |
| if err != nil { | |
| fmt.Printf("Error walking directory: %v\n", err) | |
| os.Exit(1) | |
| } | |
| return stats | |
| } | |
| func analyzeProtoFile(path string) ProtoStats { | |
| stats := newProtoStats() | |
| file, err := os.Open(path) | |
| if err != nil { | |
| fmt.Printf("Error opening file %s: %v\n", path, err) | |
| return stats | |
| } | |
| defer file.Close() | |
| messageRegex := regexp.MustCompile(`^[\s]*message\s+(\w+)\s*{`) | |
| enumRegex := regexp.MustCompile(`^[\s]*enum\s+(\w+)\s*{`) | |
| rpcRegex := regexp.MustCompile(`^[\s]*rpc\s+(\w+)\s*\(`) | |
| scanner := bufio.NewScanner(file) | |
| for scanner.Scan() { | |
| line := scanner.Text() | |
| if matches := messageRegex.FindStringSubmatch(line); matches != nil { | |
| typeName := matches[1] | |
| stats.AllTypes[typeName] = append(stats.AllTypes[typeName], TypeInfo{ | |
| Name: typeName, | |
| TypeKind: "message", | |
| FilePath: path, | |
| }) | |
| stats.TotalMessages++ | |
| stats.TotalTypes++ | |
| } else if matches := enumRegex.FindStringSubmatch(line); matches != nil { | |
| typeName := matches[1] | |
| stats.AllTypes[typeName] = append(stats.AllTypes[typeName], TypeInfo{ | |
| Name: typeName, | |
| TypeKind: "enum", | |
| FilePath: path, | |
| }) | |
| stats.TotalEnums++ | |
| stats.TotalTypes++ | |
| } else if matches := rpcRegex.FindStringSubmatch(line); matches != nil { | |
| typeName := matches[1] | |
| stats.AllTypes[typeName] = append(stats.AllTypes[typeName], TypeInfo{ | |
| Name: typeName, | |
| TypeKind: "rpc", | |
| FilePath: path, | |
| }) | |
| stats.TotalRPCs++ | |
| stats.TotalTypes++ | |
| } | |
| } | |
| if err := scanner.Err(); err != nil { | |
| fmt.Printf("Error reading file %s: %v\n", path, err) | |
| } | |
| return stats | |
| } |