diff options
| -rw-r--r-- | .envrc | 1 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Cargo.lock | 65 | ||||
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | README.org | 1 | ||||
| -rw-r--r-- | cmd/git-leaderboard/main.go | 128 | ||||
| -rw-r--r-- | cmd/goget/main.go | 165 | ||||
| -rw-r--r-- | cmd/pviz/main.go | 266 | ||||
| -rw-r--r-- | cmd/seq-stat/main.go | 115 | ||||
| -rw-r--r-- | flake.lock | 142 | ||||
| -rw-r--r-- | flake.nix | 135 | ||||
| -rw-r--r-- | go.mod | 20 | ||||
| -rw-r--r-- | go.sum | 38 | ||||
| -rw-r--r-- | nix/modules/goget.nix | 66 | ||||
| -rw-r--r-- | nix/overlay.nix | 5 | ||||
| -rw-r--r-- | nix/packages/default.nix | 5 | ||||
| -rw-r--r-- | nix/packages/goget.nix | 31 | ||||
| -rw-r--r-- | rust-toolchain.toml | 3 | ||||
| -rw-r--r-- | src/apple_silicon/Cargo.lock | 65 | ||||
| -rw-r--r-- | src/apple_silicon/Cargo.toml | 18 | ||||
| -rw-r--r-- | src/apple_silicon/README.md | 1 | ||||
| -rw-r--r-- | src/apple_silicon/src/bin/apple_silicon.rs | 12 | ||||
| -rw-r--r-- | src/apple_silicon/src/error.rs | 23 | ||||
| -rw-r--r-- | src/apple_silicon/src/lib.rs | 2 | ||||
| -rw-r--r-- | src/apple_silicon/src/soc.rs | 302 | ||||
| -rw-r--r-- | treefmt.nix | 35 |
26 files changed, 1650 insertions, 0 deletions
@@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2af821e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.pre-commit-config.yaml +/result +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e7dedba --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,65 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "apple_silicon" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f0b7ea9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["src/apple_silicon/"] +resolver = "3" diff --git a/README.org b/README.org new file mode 100644 index 0000000..19764b6 --- /dev/null +++ b/README.org @@ -0,0 +1 @@ +My experimental monorepo for Go tools and packages. diff --git a/cmd/git-leaderboard/main.go b/cmd/git-leaderboard/main.go new file mode 100644 index 0000000..7c846a5 --- /dev/null +++ b/cmd/git-leaderboard/main.go @@ -0,0 +1,128 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "sort" + "strings" +) + +type Author struct { + Name string + Count int +} + +func main() { + lineFlag := flag.Bool("line", false, "Count lines changed instead of commits") + flag.Parse() + + if !isGitRepository() { + fmt.Println("Error: Not in a git repository") + os.Exit(1) + } + + authors, err := getAuthors(*lineFlag) + if err != nil { + fmt.Printf("Error getting authors: %v\n", err) + os.Exit(1) + } + + if len(authors) == 0 { + fmt.Println("No authors found. The repository might be empty or have no commits.") + os.Exit(0) + } + + sort.Slice(authors, func(i, j int) bool { + return authors[i].Count > authors[j].Count + }) + + maxCount := authors[0].Count + + fmt.Printf("%-30s %-10s %s\n", "Author", "Count", "Contribution") + fmt.Println(strings.Repeat("-", 80)) + + for _, author := range authors { + bar := generateBar(author.Count, maxCount) + fmt.Printf("%-30s %-10d %s\n", author.Name, author.Count, bar) + } +} + +func isGitRepository() bool { + cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") + err := cmd.Run() + return err == nil +} + +func getAuthors(countLines bool) ([]Author, error) { + var cmd *exec.Cmd + + if countLines { + cmd = exec.Command("git", "log", "--pretty=format:%aN", "--numstat") + } else { + cmd = exec.Command("git", "shortlog", "-sn", "--no-merges", "HEAD") + } + + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("git command failed: %s. Error output: %s", err, exitErr.Stderr) + } + return nil, fmt.Errorf("error executing git command: %v", err) + } + + lines := strings.Split(string(output), "\n") + authors := make(map[string]int) + + if countLines { + currentAuthor := "" + for _, line := range lines { + if line == "" { + currentAuthor = "" + continue + } + if currentAuthor == "" { + currentAuthor = line + continue + } else { + fields := strings.Fields(line) + if len(fields) >= 2 { + added, _ := parseInt(fields[0]) + deleted, _ := parseInt(fields[1]) + authors[currentAuthor] += added + deleted + } + } + } + } else { + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) >= 2 { + count, _ := parseInt(fields[0]) + name := strings.Join(fields[1:], " ") + authors[name] = count + } + } + } + + var result []Author + for name, count := range authors { + result = append(result, Author{Name: name, Count: count}) + } + return result, nil +} + +func parseInt(s string) (int, error) { + if s == "-" { + return 0, nil + } + var result int + _, err := fmt.Sscanf(s, "%d", &result) + return result, err +} + +func generateBar(count, maxCount int) string { + maxWidth := 38 + width := int(float64(count) / float64(maxCount) * float64(maxWidth)) + return strings.Repeat("▆", width) +} diff --git a/cmd/goget/main.go b/cmd/goget/main.go new file mode 100644 index 0000000..3f17448 --- /dev/null +++ b/cmd/goget/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "slices" + "strings" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" + "golang.org/x/mod/semver" +) + +const ( + forgeDomain = "code.fcuny.net" + forgeUser = "fcuny" + goPkgDomain = "go.fcuny.net" +) + +var ( + goGetReqs = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "goget_requests_total", + Help: "go get requests processed, by repository name.", + }, []string{"name"}) + + modules = []string{"x"} +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", promhttp.Handler()) + metricsServer := &http.Server{ + Addr: ":9091", Handler: metricsMux, + ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, + } + + go func() { + log.Println("Starting metrics server on :9091") + if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("Metrics server error: %v", err) + } + }() + + s := &http.Server{ + Addr: ":8070", + Handler: handler(), + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 1 * time.Minute, + } + + go func() { + log.Println("Starting main server on :8080") + if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("Main server error: %v", err) + } + }() + + <-ctx.Done() + log.Println("Shutdown signal received, starting graceful shutdown...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + var shutdownErr error + + log.Println("Shutting down main server...") + if err := s.Shutdown(shutdownCtx); err != nil { + log.Printf("Main server shutdown error: %v", err) + shutdownErr = err + } + + log.Println("Shutting down metrics server...") + if err := metricsServer.Shutdown(shutdownCtx); err != nil { + log.Printf("Metrics server shutdown error: %v", err) + if shutdownErr == nil { + shutdownErr = err + } + } + + if shutdownErr != nil { + log.Printf("Shutdown completed with errors") + os.Exit(1) + } + + log.Println("Shutdown completed successfully") +} + +func handler() http.Handler { + mux := http.NewServeMux() + + mux.Handle(goPkgDomain+"/{name}", siteHandler(modules)) + mux.Handle(goPkgDomain+"/{name}/", siteHandler(modules)) + + goGetMux := http.NewServeMux() + for _, name := range modules { + module := goPkgDomain + "/" + name + goGetMux.Handle( + module+"/", + goImportHandler(module, "https://"+forgeDomain+"/"+forgeUser+"/"+name), + ) + } + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + status := http.StatusMethodNotAllowed + http.Error(rw, http.StatusText(status), status) + return + } + + if r.URL.Query().Get("go-get") == "1" { + goGetMux.ServeHTTP(rw, r) + return + } + mux.ServeHTTP(rw, r) + }) +} + +func goImportHandler(module, repo string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + goGetReqs.WithLabelValues(module).Inc() + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + if _, err := fmt.Fprintf(w, `<head><meta name="go-import" content="%s git %s">`, module, repo); err != nil { + log.Printf("Error writing response: %v", err) + } + }) +} + +func siteHandler(names []string) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + name, version, hasV := strings.Cut(name, "@") + if !hasV { + name, _, _ = strings.Cut(name, ".") + } + if hasV && !semver.IsValid(version) || !slices.Contains(names, name) { + http.NotFound(rw, r) + return + } + u := &url.URL{ + Scheme: "https", + Host: forgeDomain, + Path: forgeUser + r.URL.Path, + } + if !hasV { + path, symbol, hasSymbol := strings.Cut(r.URL.Path, ".") + if hasSymbol { + u.Path = forgeUser + "/" + path + u.Fragment = symbol + } + } + http.Redirect(rw, r, u.String(), http.StatusFound) + }) +} diff --git a/cmd/pviz/main.go b/cmd/pviz/main.go new file mode 100644 index 0000000..89bd78d --- /dev/null +++ b/cmd/pviz/main.go @@ -0,0 +1,266 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "text/tabwriter" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type Period string + +const ( + PeriodDay Period = "day" + PeriodWeek Period = "week" + PeriodMonth Period = "month" + PeriodQuarter Period = "quarter" + PeriodYear Period = "year" +) + +var periodMinutes = map[Period]float64{ + PeriodDay: 24 * 60, + PeriodWeek: 7 * 24 * 60, + PeriodMonth: 30 * 24 * 60, + PeriodQuarter: 90 * 24 * 60, + PeriodYear: 365 * 24 * 60, +} + +var periodOrder = []Period{PeriodDay, PeriodWeek, PeriodMonth, PeriodQuarter, PeriodYear} + +type TimeUnit struct { + Minutes float64 + Formatted string +} + +type AvailabilityCalculator struct { + availability float64 +} + +func NewAvailabilityCalculator(availabilityPercentage float64) (*AvailabilityCalculator, error) { + if availabilityPercentage < 0 || availabilityPercentage > 100 { + return nil, fmt.Errorf("availability must be between 0 and 100") + } + return &AvailabilityCalculator{ + availability: availabilityPercentage / 100.0, + }, nil +} + +func (ac *AvailabilityCalculator) CalculateDowntime(period Period) TimeUnit { + totalMinutes := periodMinutes[period] + downtimeMinutes := totalMinutes * (1 - ac.availability) + return TimeUnit{ + Minutes: downtimeMinutes, + Formatted: formatDuration(downtimeMinutes), + } +} + +func (ac *AvailabilityCalculator) CalculateAllPeriods() map[Period]TimeUnit { + result := make(map[Period]TimeUnit) + for _, period := range periodOrder { + result[period] = ac.CalculateDowntime(period) + } + return result +} + +func formatDuration(minutes float64) string { + if minutes < 1 { + return fmt.Sprintf("%.1f seconds", minutes*60) + } + + hours := int(minutes / 60) + remainingMinutes := minutes - float64(hours*60) + + if hours == 0 { + return fmt.Sprintf("%.1f minutes", remainingMinutes) + } else if remainingMinutes == 0 { + return fmt.Sprintf("%d hours", hours) + } else { + return fmt.Sprintf("%d hours %.1f minutes", hours, remainingMinutes) + } +} + +func printTable(headers []string, rows [][]string) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + for i, header := range headers { + if i > 0 { + _, _ = fmt.Fprint(w, "\t") + } + _, _ = fmt.Fprint(w, header) + } + _, _ = fmt.Fprintln(w) + + for _, row := range rows { + for i, cell := range row { + if i > 0 { + _, _ = fmt.Fprint(w, "\t") + } + _, _ = fmt.Fprint(w, cell) + } + _, _ = fmt.Fprintln(w) + } + + _ = w.Flush() +} + +func analyzeSingle(availability float64, focusPeriod string) error { + calc, err := NewAvailabilityCalculator(availability) + if err != nil { + return err + } + + var periods []Period + if focusPeriod != "" { + periods = []Period{Period(focusPeriod)} + } else { + periods = periodOrder + } + + headers := []string{"Time Period", "Maximum Downtime"} + var rows [][]string + + caser := cases.Title(language.English) + for _, period := range periods { + downtime := calc.CalculateDowntime(period) + rows = append(rows, []string{ + caser.String(string(period)), + downtime.Formatted, + }) + } + + printTable(headers, rows) + return nil +} + +func compareAvailabilities(availability1, availability2 float64) error { + if availability1 == availability2 { + return fmt.Errorf("cannot compare identical availability values") + } + + calc1, err := NewAvailabilityCalculator(availability1) + if err != nil { + return err + } + + calc2, err := NewAvailabilityCalculator(availability2) + if err != nil { + return err + } + + improvement := ((100 - availability1) - (100 - availability2)) / (100 - availability1) * 100 + fmt.Printf("Downtime reduction: %.1f%%\n\n", improvement) + + headers := []string{ + "Time Period", + fmt.Sprintf("%.2f%%", availability1), + fmt.Sprintf("%.2f%%", availability2), + } + var rows [][]string + + caser := cases.Title(language.English) + for _, period := range periodOrder { + downtime1 := calc1.CalculateDowntime(period) + downtime2 := calc2.CalculateDowntime(period) + rows = append(rows, []string{ + caser.String(string(period)), + downtime1.Formatted, + downtime2.Formatted, + }) + } + + printTable(headers, rows) + return nil +} + +func printUsage() { + fmt.Println("Usage:") + fmt.Println(" pviz analyze <availability> - Analyze a single availability target") + fmt.Println(" pviz analyze <availability> --focus <period> - Focus on a specific time period") + fmt.Println(" pviz compare <availability1> <availability2> - Compare two availability targets") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" pviz analyze 99.99") + fmt.Println(" pviz analyze 99.99 --focus quarter") + fmt.Println(" pviz compare 99.9 99.95") + fmt.Println() + fmt.Println("Valid periods: day, week, month, quarter, year") +} + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + command := os.Args[1] + + switch command { + case "analyze": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "Error: analyze command requires availability argument\n") + os.Exit(1) + } + + availability, err := strconv.ParseFloat(os.Args[2], 64) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid availability value: %v\n", err) + os.Exit(1) + } + + var focusPeriod string + if len(os.Args) >= 5 && os.Args[3] == "--focus" { + focusPeriod = os.Args[4] + validPeriod := false + for _, p := range periodOrder { + if string(p) == focusPeriod { + validPeriod = true + break + } + } + if !validPeriod { + fmt.Fprintf( + os.Stderr, + "Error: invalid period '%s'. Valid periods: day, week, month, quarter, year\n", + focusPeriod, + ) + os.Exit(1) + } + } + + if err := analyzeSingle(availability, focusPeriod); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + case "compare": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "Error: compare command requires two availability arguments\n") + os.Exit(1) + } + + availability1, err := strconv.ParseFloat(os.Args[2], 64) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid first availability value: %v\n", err) + os.Exit(1) + } + + availability2, err := strconv.ParseFloat(os.Args[3], 64) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid second availability value: %v\n", err) + os.Exit(1) + } + + if err := compareAvailabilities(availability1, availability2); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + default: + fmt.Fprintf(os.Stderr, "Error: unknown command '%s'\n", command) + printUsage() + os.Exit(1) + } +} diff --git a/cmd/seq-stat/main.go b/cmd/seq-stat/main.go new file mode 100644 index 0000000..75bb0a3 --- /dev/null +++ b/cmd/seq-stat/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +var ticks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +func histogram(sequence []float64) []rune { + if len(sequence) == 0 { + return []rune{} + } + + minVal := sequence[0] + maxVal := sequence[0] + + for _, v := range sequence { + if v < minVal { + minVal = v + } + if v > maxVal { + maxVal = v + } + } + + scale := (maxVal - minVal) * 256 / float64(len(ticks)-1) + if scale < 1 { + scale = 1 + } + + result := make([]rune, len(sequence)) + for i, v := range sequence { + index := int(((v - minVal) * 256) / scale) + if index >= len(ticks) { + index = len(ticks) - 1 + } + result[i] = ticks[index] + } + + return result +} + +func parseNumbers(input string) ([]float64, error) { + fields := strings.Fields(input) + numbers := make([]float64, 0, len(fields)) + + for _, field := range fields { + num, err := strconv.ParseFloat(field, 64) + if err != nil { + return nil, fmt.Errorf("invalid number: %s", field) + } + numbers = append(numbers, num) + } + + return numbers, nil +} + +func readFromStdin() ([]float64, error) { + var numbers []float64 + scanner := bufio.NewScanner(os.Stdin) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + lineNumbers, err := parseNumbers(line) + if err != nil { + return nil, err + } + numbers = append(numbers, lineNumbers...) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading from stdin: %v", err) + } + + return numbers, nil +} + +func main() { + var numbers []float64 + var err error + + if len(os.Args) > 1 { + numbers = make([]float64, 0, len(os.Args)-1) + for _, arg := range os.Args[1:] { + num, parseErr := strconv.ParseFloat(arg, 64) + if parseErr != nil { + fmt.Fprintf(os.Stderr, "Invalid number: %s\n", arg) + os.Exit(1) + } + numbers = append(numbers, num) + } + } else { + numbers, err = readFromStdin() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if len(numbers) == 0 { + fmt.Fprintln(os.Stderr, "No numbers provided") + os.Exit(1) + } + } + + h := histogram(numbers) + fmt.Println(string(h)) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6d1a15e --- /dev/null +++ b/flake.lock @@ -0,0 +1,142 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1755274400, + "narHash": "sha256-rTInmnp/xYrfcMZyFMH3kc8oko5zYfxsowaLv1LVobY=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "ad7196ae55c295f53a7d1ec39e4a06d922f3b899", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755446520, + "narHash": "sha256-I0Ok1OGDwc1jPd8cs2VvAYZsHriUVFGIUqW+7uSsOUM=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "4b04db83821b819bbbe32ed0a025b31e7971f22e", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks", + "treefmt-nix": "treefmt-nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1754847726, + "narHash": "sha256-2vX8QjO5lRsDbNYvN9hVHXLU6oMl+V/PsmIiJREG4rE=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "7d81f6fb2e19bf84f1c65135d1060d829fae2408", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..0833885 --- /dev/null +++ b/flake.nix @@ -0,0 +1,135 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05"; + flake-utils.url = "github:numtide/flake-utils"; + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + pre-commit-hooks = { + url = "github:cachix/git-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + outputs = + { + self, + nixpkgs, + treefmt-nix, + flake-utils, + pre-commit-hooks, + }: + let + # Import our packages and overlay + overlay = import ./nix/overlay.nix; + + # Import NixOS modules + nixosModules = { + goget = import ./nix/modules/goget.nix; + default = { + imports = [ + ./nix/modules/goget.nix + ]; + }; + }; + in + { + # Export the overlay for others to use + overlays.default = overlay; + + # Export NixOS modules + inherit nixosModules; + nixosModule = nixosModules.default; + } + // flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ overlay ]; + }; + + packages = import ./nix/packages { inherit pkgs; }; + + treefmtEval = treefmt-nix.lib.evalModule pkgs ./treefmt.nix; + + ciPackages = with pkgs; [ + go # Need that obviously + gofumpt # Go formatter + golangci-lint # Local/CI linter + gotestsum # Pretty tester + goperf # Go performance suite + ]; + + devPackages = with pkgs; [ + gopls + gotools + ]; + in + { + packages = packages // { + default = packages.goget; + }; + + formatter = treefmtEval.config.build.wrapper; + + checks = { + # Throws an error if any of the source files are not correctly formatted + # when you run `nix flake check --print-build-logs`. Useful for CI + treefmt = treefmtEval.config.build.check self; + pre-commit-check = pre-commit-hooks.lib.${system}.run { + src = ./.; + hooks = { + format = { + enable = true; + name = "Format with treefmt"; + pass_filenames = true; + entry = "${treefmtEval.config.build.wrapper}/bin/treefmt"; + stages = [ + "pre-commit" + "pre-push" + ]; + }; + golangci-lint = { + enable = true; + package = pkgs.golangci-lint; + pass_filenames = true; + stages = [ "pre-push" ]; + }; + unit-tests-go = { + enable = true; + name = "Run unit tests"; + entry = "gotestsum --format testdox ./..."; + pass_filenames = false; + stages = [ "pre-push" ]; + types = [ "go" ]; + }; + }; + }; + }; + + devShells = { + default = pkgs.mkShell { + buildInputs = + [ ] ++ ciPackages ++ devPackages ++ self.checks.${system}.pre-commit-check.enabledPackages; + + shellHook = '' + ${self.checks.${system}.pre-commit-check.shellHook} + export GOTOOLCHAIN=local + export GOPRIVATE=go.fcuny.net/* + ''; + }; + + ci = pkgs.mkShell { + buildInputs = [ ] ++ ciPackages; + CI = true; + shellHook = '' + export GOTOOLCHAIN=local + export GOPRIVATE=go.fcuny.net/* + echo "Entering CI shell. Only essential CI tools available." + ''; + }; + }; + } + ); +} @@ -0,0 +1,20 @@ +module go.fcuny.net/x + +go 1.24.5 + +require ( + github.com/prometheus/client_golang v1.23.0 + golang.org/x/mod v0.27.0 + golang.org/x/text v0.28.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + golang.org/x/sys v0.33.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect +) @@ -0,0 +1,38 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/nix/modules/goget.nix b/nix/modules/goget.nix new file mode 100644 index 0000000..3ed5e04 --- /dev/null +++ b/nix/modules/goget.nix @@ -0,0 +1,66 @@ +{ + config, + lib, + pkgs, + ... +}: + +with lib; + +let + cfg = config.services.goget; +in +{ + options.services.goget = { + enable = mkEnableOption "goget service"; + + package = mkPackageOption pkgs "goget" { }; + + port = mkOption { + type = types.port; + default = 8070; + description = "Port to listen on"; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Whether to open the firewall for the goget service"; + }; + }; + + config = mkIf cfg.enable { + systemd.services.goget = { + description = "goget service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + wants = [ "network.target" ]; + + serviceConfig = { + Type = "exec"; + DynamicUser = true; + ExecStart = "${cfg.package}/bin/goget"; + Restart = "always"; + RestartSec = "5"; + + # Security settings + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + RestrictRealtime = true; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.port ]; + }; + }; +} diff --git a/nix/overlay.nix b/nix/overlay.nix new file mode 100644 index 0000000..6789627 --- /dev/null +++ b/nix/overlay.nix @@ -0,0 +1,5 @@ +final: prev: +let + packages = import ./packages { pkgs = final; }; +in +packages diff --git a/nix/packages/default.nix b/nix/packages/default.nix new file mode 100644 index 0000000..e598fa1 --- /dev/null +++ b/nix/packages/default.nix @@ -0,0 +1,5 @@ +{ pkgs }: + +{ + goget = pkgs.callPackage ./goget.nix { }; +} diff --git a/nix/packages/goget.nix b/nix/packages/goget.nix new file mode 100644 index 0000000..c767740 --- /dev/null +++ b/nix/packages/goget.nix @@ -0,0 +1,31 @@ +{ + lib, + buildGoModule, +}: + +buildGoModule rec { + pname = "goget"; + version = "0.1.0"; # Consider deriving from git tags: version = builtins.substring 0 8 self.rev; + + src = ../..; + + vendorHash = "sha256-pStRgjhjjZdsYSnYMcWNbHSF7CJ3+7ZQradZgBfi5Gw="; + + subPackages = [ "cmd/goget" ]; + + ldflags = [ + "-s" + "-w" + ]; + + doCheck = false; + + meta = with lib; { + description = "A Go tool for getting things"; # Update with actual description + homepage = "https://github.com/yourusername/yourrepo"; # Update with your repo + license = licenses.mit; + maintainers = with maintainers; [ fcuny ]; + platforms = platforms.unix; + mainProgram = "goget"; + }; +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..a978b3e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.89" +components = ["rustfmt", "clippy", "rust-analyzer"] diff --git a/src/apple_silicon/Cargo.lock b/src/apple_silicon/Cargo.lock new file mode 100644 index 0000000..1ee0f1d --- /dev/null +++ b/src/apple_silicon/Cargo.lock @@ -0,0 +1,65 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "apple_silicon" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" diff --git a/src/apple_silicon/Cargo.toml b/src/apple_silicon/Cargo.toml new file mode 100644 index 0000000..65dcf5e --- /dev/null +++ b/src/apple_silicon/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "apple_silicon" +version = "0.1.0" +edition = "2021" +description = "A common line tool for Apple Silicon" + +license = "MIT" +authors = ["Franck Cuny <franck@fcuny.net>"] +repository = "https://github.com/fcuny/apple_silicon" +homepage = "https://github.com/fcuny/apple_silicon" +categories = ["command-line-utilities"] + +[[bin]] +name = "apple_silicon" +path = "src/bin/apple_silicon.rs" + +[dependencies] +thiserror = "2.0.11" diff --git a/src/apple_silicon/README.md b/src/apple_silicon/README.md new file mode 100644 index 0000000..7d83327 --- /dev/null +++ b/src/apple_silicon/README.md @@ -0,0 +1 @@ +A command line utility to report information about Apple Silicon (inspired by [`asitop`](https://github.com/tlkh/asitop/tree/74ebe2cbc23d5b1eec874aebb1b9bacfe0e670cd)). diff --git a/src/apple_silicon/src/bin/apple_silicon.rs b/src/apple_silicon/src/bin/apple_silicon.rs new file mode 100644 index 0000000..0f71eeb --- /dev/null +++ b/src/apple_silicon/src/bin/apple_silicon.rs @@ -0,0 +1,12 @@ +use apple_silicon::soc::SocInfo; + +fn main() { + let cpu_info = SocInfo::new().unwrap(); + println!( + "our CPU is an {}, and we have {} CPU cores, and {} GPU cores. The TDP is {}.", + cpu_info.cpu_brand_name, + cpu_info.num_cpu_cores, + cpu_info.num_gpu_cores, + cpu_info.cpu_max_power.unwrap(), + ); +} diff --git a/src/apple_silicon/src/error.rs b/src/apple_silicon/src/error.rs new file mode 100644 index 0000000..a70b9b5 --- /dev/null +++ b/src/apple_silicon/src/error.rs @@ -0,0 +1,23 @@ +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("socinfo parsing error: `{0}`")] + Parse(String), + + #[error("I/O error: `{source}`")] + Io { + #[from] + source: std::io::Error, + }, + + #[error("utf8 conversion error: `{source}`")] + Utf8Conversion { + #[from] + source: std::string::FromUtf8Error, + }, + + #[error("integer parsing error: `{source}`")] + ParseInt { + #[from] + source: std::num::ParseIntError, + }, +} diff --git a/src/apple_silicon/src/lib.rs b/src/apple_silicon/src/lib.rs new file mode 100644 index 0000000..74bd62c --- /dev/null +++ b/src/apple_silicon/src/lib.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod soc; diff --git a/src/apple_silicon/src/soc.rs b/src/apple_silicon/src/soc.rs new file mode 100644 index 0000000..0e2bf5c --- /dev/null +++ b/src/apple_silicon/src/soc.rs @@ -0,0 +1,302 @@ +use crate::error::Error; + +use std::process::{Command, Output}; + +pub type Result<T> = std::result::Result<T, Error>; +pub type Watts = u32; +pub type Bandwidth = u32; +pub type CoreCount = u16; + +/// Information about the Silicon chip +pub struct SocInfo { + /// The CPU brand name string + pub cpu_brand_name: String, + /// Number of CPU cores + pub num_cpu_cores: CoreCount, + /// Number of GPU cores + pub num_gpu_cores: CoreCount, + /// Maximum CPU power in watts (if available) + pub cpu_max_power: Option<Watts>, + /// Maximum GPU power in watts (if available) + pub gpu_max_power: Option<Watts>, + /// Maximum CPU bandwidth in GB/s (if available) + pub cpu_max_bw: Option<Bandwidth>, + /// Maximum GPU bandwidth in GB/s (if available) + pub gpu_max_bw: Option<Bandwidth>, + /// Number of efficiency cores + pub e_core_count: CoreCount, + /// Number of performance cores + pub p_core_count: CoreCount, +} + +#[derive(Debug, PartialEq)] +enum AppleChip { + M1, + M1Pro, + M1Max, + M1Ultra, + M2, + M2Pro, + M2Max, + M2Ultra, + M3, + M3Pro, + M3Max, + Unknown, +} + +struct ChipSpecs { + cpu_tdp: Watts, + gpu_tdp: Watts, + cpu_bw: Bandwidth, + gpu_bw: Bandwidth, +} + +impl AppleChip { + fn from_brand_string(brand: &str) -> Self { + match brand { + s if s.contains("M1 Pro") => AppleChip::M1Pro, + s if s.contains("M1 Max") => AppleChip::M1Max, + s if s.contains("M1 Ultra") => AppleChip::M1Ultra, + s if s.contains("M1") => AppleChip::M1, + s if s.contains("M2 Pro") => AppleChip::M2Pro, + s if s.contains("M2 Max") => AppleChip::M2Max, + s if s.contains("M2 Ultra") => AppleChip::M2Ultra, + s if s.contains("M2") => AppleChip::M2, + s if s.contains("M3 Pro") => AppleChip::M3Pro, + s if s.contains("M3 Max") => AppleChip::M3Max, + s if s.contains("M3") => AppleChip::M3, + _ => AppleChip::Unknown, + } + } + + fn get_specs(&self) -> ChipSpecs { + match self { + AppleChip::M1 => ChipSpecs { + cpu_tdp: 20, + gpu_tdp: 20, + cpu_bw: 70, + gpu_bw: 70, + }, + AppleChip::M1Pro => ChipSpecs { + cpu_tdp: 30, + gpu_tdp: 30, + cpu_bw: 200, + gpu_bw: 200, + }, + AppleChip::M1Max => ChipSpecs { + cpu_tdp: 30, + gpu_tdp: 60, + cpu_bw: 250, + gpu_bw: 400, + }, + AppleChip::M1Ultra => ChipSpecs { + cpu_tdp: 60, + gpu_tdp: 120, + cpu_bw: 500, + gpu_bw: 800, + }, + AppleChip::M2 => ChipSpecs { + cpu_tdp: 25, + gpu_tdp: 15, + cpu_bw: 100, + gpu_bw: 100, + }, + AppleChip::M2Pro => ChipSpecs { + cpu_tdp: 30, + gpu_tdp: 35, + cpu_bw: 0, + gpu_bw: 0, + }, + AppleChip::M2Max => ChipSpecs { + cpu_tdp: 30, + gpu_tdp: 40, + cpu_bw: 0, + gpu_bw: 0, + }, + // Add more variants as needed + _ => ChipSpecs { + cpu_tdp: 0, + gpu_tdp: 0, + cpu_bw: 0, + gpu_bw: 0, + }, + } + } +} + +impl SocInfo { + pub fn new() -> Result<SocInfo> { + let (cpu_brand_name, num_cpu_cores, e_core_count, p_core_count) = cpu_info(&RealCommand)?; + let num_gpu_cores = gpu_info(&RealCommand)?; + + let chip = AppleChip::from_brand_string(&cpu_brand_name); + let specs = chip.get_specs(); + + Ok(SocInfo { + cpu_brand_name, + num_cpu_cores, + num_gpu_cores, + cpu_max_power: Some(specs.cpu_tdp), + gpu_max_power: Some(specs.gpu_tdp), + cpu_max_bw: Some(specs.cpu_bw), + gpu_max_bw: Some(specs.gpu_bw), + e_core_count, + p_core_count, + }) + } +} + +// https://github.com/tlkh/asitop/blob/74ebe2cbc23d5b1eec874aebb1b9bacfe0e670cd/asitop/utils.py#L94 +const SYSCTL_PATH: &str = "/usr/sbin/sysctl"; + +fn cpu_info(cmd: &impl SystemCommand) -> Result<(String, u16, u16, u16)> { + let binary = SYSCTL_PATH; + let args = &[ + // don't display the variable name + "-n", + "machdep.cpu.brand_string", + "machdep.cpu.core_count", + "hw.perflevel0.logicalcpu", + "hw.perflevel1.logicalcpu", + ]; + + let output = cmd.execute(binary, args)?; + let buffer = String::from_utf8(output.stdout)?; + + let mut iter = buffer.split('\n'); + let cpu_brand_name = match iter.next() { + Some(s) => s.to_string(), + None => return Err(Error::Parse(buffer.to_string())), + }; + + let num_cpu_cores = match iter.next() { + Some(s) => s.parse::<u16>()?, + None => return Err(Error::Parse(buffer.to_string())), + }; + + let num_performance_cores = match iter.next() { + Some(s) => s.parse::<u16>()?, + None => return Err(Error::Parse(buffer.to_string())), + }; + + let num_efficiency_cores = match iter.next() { + Some(s) => s.parse::<u16>()?, + None => return Err(Error::Parse(buffer.to_string())), + }; + + Ok(( + cpu_brand_name, + num_cpu_cores, + num_performance_cores, + num_efficiency_cores, + )) +} + +// https://github.com/tlkh/asitop/blob/74ebe2cbc23d5b1eec874aebb1b9bacfe0e670cd/asitop/utils.py#L120 +fn gpu_info(cmd: &impl SystemCommand) -> Result<u16> { + let binary = "/usr/sbin/system_profiler"; + let args = &["-detailLevel", "basic", "SPDisplaysDataType"]; + + let output = cmd.execute(binary, args)?; + let buffer = String::from_utf8(output.stdout)?; + + let num_gpu_cores_line = buffer + .lines() + .find(|&line| line.trim_start().starts_with("Total Number of Cores")); + + let num_gpu_cores = match num_gpu_cores_line { + Some(s) => match s.split(": ").last() { + Some(s) => s.parse::<u16>()?, + None => return Err(Error::Parse(buffer.to_string())), + }, + None => return Err(Error::Parse(buffer.to_string())), + }; + + Ok(num_gpu_cores) +} + +/// Trait for system command execution +pub trait SystemCommand { + fn execute(&self, binary: &str, args: &[&str]) -> Result<Output>; +} + +/// Real command executor +pub struct RealCommand; + +impl SystemCommand for RealCommand { + fn execute(&self, binary: &str, args: &[&str]) -> Result<Output> { + Ok(Command::new(binary).args(args).output()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::process::ExitStatusExt; + + struct MockCommand { + output: Vec<u8>, + } + + impl MockCommand { + fn new(output: &str) -> Self { + Self { + output: output.as_bytes().to_vec(), + } + } + } + + impl SystemCommand for MockCommand { + fn execute(&self, _binary: &str, _args: &[&str]) -> Result<Output> { + Ok(Output { + status: std::process::ExitStatus::from_raw(0), + stdout: self.output.clone(), + stderr: Vec::new(), + }) + } + } + + #[test] + fn test_gpu_info() { + let mock_output = r#"Graphics/Displays: + Apple M2: + Total Number of Cores: 10"#; + let cmd = MockCommand::new(mock_output); + + let result = gpu_info(&cmd); + assert_eq!(result.unwrap(), 10); + } + + #[test] + fn test_cpu_info_success() { + let mock_output = "Apple M2\n8\n4\n4\n"; + let cmd = MockCommand::new(mock_output); + + let result = cpu_info(&cmd); + assert!(result.is_ok()); + let (brand, cores, p_cores, e_cores) = result.unwrap(); + assert_eq!(brand, "Apple M2"); + assert_eq!(cores, 8); + assert_eq!(p_cores, 4); + assert_eq!(e_cores, 4); + } + + #[test] + fn test_cpu_info_missing_core_count() { + let mock_output = "Apple M2\n"; + let cmd = MockCommand::new(mock_output); + + let result = cpu_info(&cmd); + assert!(matches!(result, Err(Error::ParseInt { .. }))); + } + + #[test] + fn test_cpu_info_invalid_core_count() { + let mock_output = "Apple M2\ninvalid\n"; + let cmd = MockCommand::new(mock_output); + + let result = cpu_info(&cmd); + assert!(matches!(result, Err(Error::ParseInt { .. }))); + } +} diff --git a/treefmt.nix b/treefmt.nix new file mode 100644 index 0000000..b2e04da --- /dev/null +++ b/treefmt.nix @@ -0,0 +1,35 @@ +{ pkgs, ... }: +{ + # See https://github.com/numtide/treefmt-nix#supported-programs + projectRootFile = ".git/config"; + settings.global.includes = [ + "*.go" + "*.md" + "*.nix" + "*.rs" + "*.toml" + "*.yaml" + "*.yml" + ]; + settings.global.fail-on-change = true; + settings.global.no-cache = true; + programs.gofumpt = { + enable = true; + package = pkgs.gofumpt; + }; + programs.goimports.enable = true; + programs.golines.enable = true; + # GitHub Actions + programs.yamlfmt.enable = true; + programs.actionlint.enable = true; + # Markdown + programs.mdformat.enable = true; + # Rust + programs.rustfmt.enable = true; + programs.taplo.enable = true; + # Nix + programs.nixfmt = { + enable = true; + package = pkgs.nixfmt-rfc-style; + }; +} |
