diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..44106834b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +distrib +current_state.json diff --git a/README.md b/README.md index f1c342f65..387b97d10 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,19 @@ INCLUDE: HUBLI-KARNATAKA-INDIA ``` Again, `DISTRIBUTOR2` cannot authorize `DISTRIBUTOR3` with a region that they themselves do not have access to. -We've provided a CSV with the list of all countries, states and cities in the world that we know of - please use the data mentioned there for this program. *The codes you see there may be different from what you see here, so please always use the codes in the CSV*. This Readme is only an example. +## Steps to run -Write a program in any language you want (If you're here from Gophercon, use Go :D) that does this. Feel free to make your own input and output format / command line tool / GUI / Webservice / whatever you want. Feel free to hold the dataset in whatever structure you want, but try not to use external databases - as far as possible stick to your langauage without bringing in MySQL/Postgres/MongoDB/Redis/Etc. - -To submit a solution, fork this repo and send a Pull Request on Github. +``` +make run +``` -For any questions or clarifications, raise an issue on this repo and we'll answer your questions as fast as we can. +1. Add distributor - `./distrib add distributor1` +2. To add permissions for a distributor - `./distrib include distributor1 INDIA` +3. To check if distributor has permission - `./distrib check distributor1 ONGOLE-ANDHRAPRADESH-INDIA` +4. To list all distributors - `./distrib list` +5. To exclude a city for a distributor - `./distrib exclude distributor1 TAMILNADU-INDIA` +6. To show distributor information - `./distrib show distributor1` +7. To copy permissions from a distributor - `./distrib add distributor2 --parent distributor1` +We store the current state in `current_state.json`. If we want to reset all permissions, just remove or delete the json file. diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 000000000..482176eb8 --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "os" + + "challenge/cmd/commands" +) + +func main() { + config := &commands.Config{ + StateFile: "current_state.json", + CitiesFile: "cities.csv", + LogLevel: "info", + } + + logger := commands.InitLogger(config.LogLevel) + app := commands.NewApp(logger, config) + rootCmd := app.BuildRootCommand() + if err := rootCmd.Execute(); err != nil { + logger.Error("command execution failed", "error", err.Error()) + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go new file mode 100644 index 000000000..de0bc5829 --- /dev/null +++ b/cmd/commands/commands.go @@ -0,0 +1,277 @@ +package commands + +import ( + "fmt" + "log/slog" + "os" + "strings" + + "challenge/pkg/services" + + "challenge/pkg/utils" + + "github.com/spf13/cobra" +) + +type Config struct { + StateFile string + CitiesFile string + LogLevel string + LogFormat string +} + +type App struct { + service *services.DistributionService + jsonRepo *utils.JSONUtil + csvRepo *utils.CSVUtil + config *Config + logger *slog.Logger +} + +func NewApp(logger *slog.Logger, config *Config) *App { + return &App{ + service: services.NewDistributionService(logger), + jsonRepo: utils.NewJSONUtil(logger), + csvRepo: utils.NewCSVUtil(logger), + config: config, + logger: logger, + } +} + +func InitLogger(level string) *slog.Logger { + var logLevel slog.Level + switch strings.ToLower(level) { + case "debug": + logLevel = slog.LevelDebug + case "info": + logLevel = slog.LevelInfo + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + + opts := &slog.HandlerOptions{ + Level: logLevel, + } + + handler := slog.NewJSONHandler(os.Stdout, opts) + + return slog.New(handler) +} + +// LoadData loads cities and state +func (a *App) LoadData() error { + a.logger.Info("starting distribution system", + slog.String("version", "1.0.0"), + slog.String("log_level", a.config.LogLevel), + slog.String("log_format", a.config.LogFormat)) + + // Load cities if file exists + if a.config.CitiesFile != "" { + if _, err := os.Stat(a.config.CitiesFile); err == nil { + locations, err := a.csvRepo.LoadCities(a.config.CitiesFile) + if err != nil { + a.logger.Warn("could not load cities file", + slog.String("error", err.Error())) + } else { + a.service.SetLocations(locations) + } + } else { + a.logger.Debug("cities file not found, skipping", + slog.String("filename", a.config.CitiesFile)) + } + } + + // Load state if file exists + if a.config.StateFile != "" { + if a.jsonRepo.Exists(a.config.StateFile) { + distributors, err := a.jsonRepo.Load(a.config.StateFile) + if err != nil { + a.logger.Warn("could not load state file", + slog.String("error", err.Error())) + } else { + a.service.SetDistributors(distributors) + } + } else { + a.logger.Debug("state file not found, starting fresh", + slog.String("filename", a.config.StateFile)) + } + } + + return nil +} + +func (a *App) SaveData() error { + if a.config.StateFile != "" { + return a.jsonRepo.Save(a.config.StateFile, a.service.GetDistributors()) + } + return nil +} + +func (a *App) BuildRootCommand() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "distrib", + Short: "Distribution Permission Management System", + Long: "A CLI tool to manage movie distribution permissions across geographical territories", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + a.LoadData() + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if err := a.SaveData(); err != nil { + a.logger.Error("failed to save state", + slog.String("error", err.Error())) + } + }, + } + + rootCmd.AddCommand( + a.buildAddCommand(), + a.buildIncludeCommand(), + a.buildExcludeCommand(), + a.buildCheckCommand(), + a.buildShowCommand(), + a.buildListCommand(), + ) + + return rootCmd +} + +func (a *App) buildAddCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "add [distributor-name]", + Short: "Add a new distributor", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + parent, _ := cmd.Flags().GetString("parent") + err := a.service.AddDistributor(args[0], parent) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Added distributor: %s\n", args[0]) + if parent != "" { + fmt.Printf("Parent: %s\n", parent) + } + }, + } + cmd.Flags().StringP("parent", "p", "", "Parent distributor") + return cmd +} + +func (a *App) buildIncludeCommand() *cobra.Command { + return &cobra.Command{ + Use: "include [distributor] [location]", + Short: "Add an include permission for a distributor", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + err := a.service.AddPermission(args[0], true, args[1]) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Added INCLUDE permission for %s: %s\n", args[0], args[1]) + }, + } +} + +func (a *App) buildExcludeCommand() *cobra.Command { + return &cobra.Command{ + Use: "exclude [distributor] [location]", + Short: "Add an exclude permission for a distributor", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + err := a.service.AddPermission(args[0], false, args[1]) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Added EXCLUDE permission for %s: %s\n", args[0], args[1]) + }, + } +} + +func (a *App) buildCheckCommand() *cobra.Command { + return &cobra.Command{ + Use: "check [distributor] [location]", + Short: "Check if a distributor can distribute in a location", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + can, err := a.service.CanDistribute(args[0], args[1]) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + if can { + fmt.Println("Yes can distribute") + } else { + fmt.Println("Can't distribute") + } + }, + } +} + +func (a *App) buildShowCommand() *cobra.Command { + return &cobra.Command{ + Use: "show [distributor]", + Short: "Show distributor information", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + dist, err := a.service.GetDistributor(args[0]) + if err != nil { + fmt.Printf("Distributor %s not found\n", args[0]) + os.Exit(1) + } + + fmt.Printf("\n┌─ Distributor: %s\n", dist.Name) + if dist.Parent != "" { + fmt.Printf("├─ Parent: %s\n", dist.Parent) + } + fmt.Printf("├─ Permissions:\n") + + if len(dist.Permissions) == 0 { + fmt.Printf("│ (none)\n") + } else { + for i, perm := range dist.Permissions { + prefix := "├──" + if i == len(dist.Permissions)-1 { + prefix = "└──" + } + + permType := "EXCLUDE" + if perm.IsInclude { + permType = "INCLUDE" + } + fmt.Printf("│ %s %s: %s\n", prefix, permType, perm.Location.String()) + } + } + fmt.Println() + }, + } +} + +func (a *App) buildListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all distributors", + Run: func(cmd *cobra.Command, args []string) { + distributors := a.service.GetDistributors() + if len(distributors) == 0 { + fmt.Println("No distributors found") + return + } + + fmt.Println("\nDistributors:") + for name, dist := range distributors { + parent := "root" + if dist.Parent != "" { + parent = dist.Parent + } + fmt.Printf(" • %s (parent: %s, permissions: %d)\n", name, parent, len(dist.Permissions)) + } + fmt.Println() + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..567b53dbd --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module challenge + +go 1.22.12 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..a6ee3e0fb --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/makefile b/makefile new file mode 100644 index 000000000..76b8151d8 --- /dev/null +++ b/makefile @@ -0,0 +1,3 @@ +run: + CGO_ENABLED=0 go build -o distrib ./cmd/app/ + ./distrib diff --git a/pkg/models/models.go b/pkg/models/models.go new file mode 100644 index 000000000..86608e638 --- /dev/null +++ b/pkg/models/models.go @@ -0,0 +1,40 @@ +package models + +type Location struct { + City string `json:"city,omitempty"` + State string `json:"state,omitempty"` + Country string `json:"country"` +} + +type Permission struct { + IsInclude bool `json:"is_include"` + Location Location `json:"location"` +} + +type Distributor struct { + Name string `json:"name"` + Parent string `json:"parent,omitempty"` + Permissions []Permission `json:"permissions"` +} + +func (l Location) String() string { + if l.City != "" { + return l.City + "-" + l.State + "-" + l.Country + } + if l.State != "" { + return l.State + "-" + l.Country + } + return l.Country +} + +func (l Location) IsCountryLevel() bool { + return l.City == "" && l.State == "" +} + +func (l Location) IsStateLevel() bool { + return l.City == "" && l.State != "" +} + +func (l Location) IsCityLevel() bool { + return l.City != "" +} diff --git a/pkg/services/distribution.go b/pkg/services/distribution.go new file mode 100644 index 000000000..29b0257c7 --- /dev/null +++ b/pkg/services/distribution.go @@ -0,0 +1,271 @@ +package services + +import ( + "fmt" + "log/slog" + "strings" + + "challenge/pkg/models" +) + +type DistributionService struct { + distributors map[string]*models.Distributor + locations map[string]models.Location + logger *slog.Logger +} + +func NewDistributionService(logger *slog.Logger) *DistributionService { + return &DistributionService{ + distributors: make(map[string]*models.Distributor), + locations: make(map[string]models.Location), + logger: logger, + } +} + +func (s *DistributionService) SetLocations(locations map[string]models.Location) { + s.locations = locations +} + +func (s *DistributionService) SetDistributors(distributors map[string]*models.Distributor) { + s.distributors = distributors +} + +func (s *DistributionService) GetDistributors() map[string]*models.Distributor { + return s.distributors +} + +func (s *DistributionService) AddDistributor(name string, parentName string) error { + s.logger.Debug("attempting to add distributor", + slog.String("name", name), + slog.String("parent", parentName)) + + if _, exists := s.distributors[name]; exists { + s.logger.Warn("distributor already exists", + slog.String("name", name)) + return fmt.Errorf("distributor %s already exists", name) + } + + if parentName != "" { + if _, exists := s.distributors[parentName]; !exists { + s.logger.Warn("parent distributor not found", + slog.String("parent", parentName)) + return fmt.Errorf("parent distributor %s not found", parentName) + } + } + + dist := &models.Distributor{ + Name: name, + Parent: parentName, + Permissions: []models.Permission{}, + } + + s.distributors[name] = dist + + s.logger.Info("distributor added successfully", + slog.String("name", name), + slog.String("parent", parentName)) + + return nil +} + +func (s *DistributionService) AddPermission(distName string, isInclude bool, locationStr string) error { + s.logger.Debug("attempting to add permission", + slog.String("distributor", distName), + slog.Bool("is_include", isInclude), + slog.String("location", locationStr)) + + dist, exists := s.distributors[distName] + if !exists { + s.logger.Warn("distributor not found", + slog.String("distributor", distName)) + return fmt.Errorf("distributor %s not found", distName) + } + + loc := s.parseLocation(locationStr) + + if dist.Parent != "" { + parentDistributor, exists := s.distributors[dist.Parent] + if !exists { + s.logger.Warn("parent not found", slog.String("parent", dist.Parent)) + return fmt.Errorf("parent not found") + } + + canPDistribute, err := s.CanDistribute(parentDistributor.Name, locationStr) + if err != nil || !canPDistribute { + s.logger.Error("parent can't distribute", "location", locationStr) + return fmt.Errorf("parent can't distribute") + } + } + + perm := models.Permission{ + IsInclude: isInclude, + Location: loc, + } + + dist.Permissions = append(dist.Permissions, perm) + + permType := "EXCLUDE" + if isInclude { + permType = "INCLUDE" + } + + s.logger.Info("permission added successfully", + slog.String("distributor", distName), + slog.String("permission_type", permType), + slog.String("location", locationStr), + slog.String("city", loc.City), + slog.String("state", loc.State), + slog.String("country", loc.Country)) + + return nil +} + +func (s *DistributionService) CanDistribute(distName string, locationStr string) (bool, error) { + s.logger.Debug("checking distribution permission", + slog.String("distributor", distName), + slog.String("location", locationStr)) + + dist, exists := s.distributors[distName] + if !exists { + s.logger.Warn("distributor not found for check", + slog.String("distributor", distName)) + return false, fmt.Errorf("distributor %s not found", distName) + } + + targetLoc := s.parseLocation(locationStr) + result := s.checkPermission(dist, targetLoc) + + s.logger.Info("permission check completed", + slog.String("distributor", distName), + slog.String("location", locationStr), + slog.Bool("can_distribute", result)) + + return result, nil +} + +func (s *DistributionService) GetDistributor(name string) (*models.Distributor, error) { + dist, exists := s.distributors[name] + if !exists { + return nil, fmt.Errorf("distributor %s not found", name) + } + return dist, nil +} + +// parseLocation parses a location string like "HYDERABAD-TELANGANA-INDIA" +func (s *DistributionService) parseLocation(locStr string) models.Location { + parts := strings.Split(locStr, "-") + + if len(parts) == 3 { + return models.Location{City: parts[0], State: parts[1], Country: parts[2]} + } else if len(parts) == 2 { + return models.Location{State: parts[0], Country: parts[1]} + } else if len(parts) == 1 { + return models.Location{Country: parts[0]} + } + + return models.Location{} +} + +// checkPermission determines whether a distributor is allowed to operate +// in the given target location. +// +// Permission resolution rules: +// 1. Only permissions that match the target location are considered. +// 2. Among matching permissions, the MOST SPECIFIC rule wins +// (CITY > STATE > COUNTRY). +// 3. If both INCLUDE and EXCLUDE rules exist at the same specificity, +// EXCLUDE wins (safer default). +// 4. A more specific INCLUDE can override a broader EXCLUDE. +// +// Example: +// +// EXCLUDE ANDHRAPRADESH-INDIA (state) +// INCLUDE ONGOLE-ANDHRAPRADESH-INDIA (city) +// TARGET ONGOLE-ANDHRAPRADESH-INDIA +// → Allowed (include is more specific). +func (s *DistributionService) checkPermission(dist *models.Distributor, target models.Location) bool { + allPermissions := s.collectPermissions(dist) + + s.logger.Debug("checking permissions", + slog.String("distributor", dist.Name), + slog.Int("total_permissions", len(allPermissions)), + slog.String("target_city", target.City), + slog.String("target_state", target.State), + slog.String("target_country", target.Country)) + + var mostSpecificInclude *models.Permission + var mostSpecificExclude *models.Permission + + for i := range allPermissions { + perm := &allPermissions[i] + if s.matchesLocation(target, perm.Location) { + specificity := s.getSpecificity(perm.Location) + + if perm.IsInclude { + if mostSpecificInclude == nil || specificity > s.getSpecificity(mostSpecificInclude.Location) { + mostSpecificInclude = perm + } + } else { + if mostSpecificExclude == nil || specificity > s.getSpecificity(mostSpecificExclude.Location) { + mostSpecificExclude = perm + } + } + } + } + + // No include permission found, we return false + if mostSpecificInclude == nil { + return false + } + + // No exclude permission found, we can continue the flow + if mostSpecificExclude == nil { + return true + } + + // Both include and exclude exist - compare specificity + includeSpec := s.getSpecificity(mostSpecificInclude.Location) + excludeSpec := s.getSpecificity(mostSpecificExclude.Location) + + // More specific permission wins + // If equal specificity, exclude wins (safer default) + return includeSpec > excludeSpec +} + +func (s *DistributionService) getSpecificity(loc models.Location) int { + if loc.IsCityLevel() { + return 3 // Most specific: CITY-STATE-COUNTRY + } + if loc.IsStateLevel() { + return 2 // Medium specific: STATE-COUNTRY + } + return 1 // Least specific: COUNTRY +} + +// collectPermissions collects all permissions from distributor chain +func (s *DistributionService) collectPermissions(dist *models.Distributor) []models.Permission { + var perms []models.Permission + + // if dist.Parent != "" { + // if parent, exists := s.distributors[dist.Parent]; exists { + // perms = append(perms, s.collectPermissions(parent)...) + // } + // } + + perms = append(perms, dist.Permissions...) + + return perms +} + +// matchesLocation checks if target location matches permission location +func (s *DistributionService) matchesLocation(target models.Location, perm models.Location) bool { + if perm.IsCountryLevel() { + return target.Country == perm.Country + } + + if perm.IsStateLevel() { + return target.State == perm.State && target.Country == perm.Country + } + + return target.City == perm.City && target.State == perm.State && target.Country == perm.Country +} diff --git a/pkg/utils/csv.go b/pkg/utils/csv.go new file mode 100644 index 000000000..f02da01b7 --- /dev/null +++ b/pkg/utils/csv.go @@ -0,0 +1,101 @@ +package utils + +import ( + "encoding/csv" + "fmt" + "io" + "log/slog" + "os" + "strings" + + "challenge/pkg/models" +) + +type CSVUtil struct { + logger *slog.Logger +} + +func NewCSVUtil(logger *slog.Logger) *CSVUtil { + return &CSVUtil{ + logger: logger, + } +} + +// LoadCities loads cities from a CSV file and returns a map of locations +func (r *CSVUtil) LoadCities(filename string) (map[string]models.Location, error) { + r.logger.Info("loading cities from CSV", + slog.String("filename", filename)) + + file, err := os.Open(filename) + if err != nil { + r.logger.Error("failed to open cities file", + slog.String("filename", filename), + slog.String("error", err.Error())) + return nil, fmt.Errorf("error opening file: %v", err) + } + defer file.Close() + + reader := csv.NewReader(file) + + // Skip header + _, err = reader.Read() + if err != nil { + r.logger.Error("failed to read CSV header", + slog.String("error", err.Error())) + return nil, fmt.Errorf("error reading header: %v", err) + } + + locations := make(map[string]models.Location) + count := 0 + + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + r.logger.Error("failed to read CSV record", + slog.String("error", err.Error())) + return nil, fmt.Errorf("error reading record: %v", err) + } + + if len(record) >= 3 { + city := strings.TrimSpace(record[0]) + state := strings.TrimSpace(record[1]) + country := strings.TrimSpace(record[2]) + + // Store city-state-country combination + loc := models.Location{ + City: city, + State: state, + Country: country, + } + key := fmt.Sprintf("%s-%s-%s", city, state, country) + locations[key] = loc + + // Store state-country combination + stateKey := fmt.Sprintf("%s-%s", state, country) + if _, exists := locations[stateKey]; !exists { + locations[stateKey] = models.Location{ + State: state, + Country: country, + } + } + + // Store country + if _, exists := locations[country]; !exists { + locations[country] = models.Location{ + Country: country, + } + } + + count++ + } + } + + r.logger.Info("successfully loaded cities", + slog.Int("total_locations", count), + slog.Int("unique_keys", len(locations))) + + return locations, nil +} diff --git a/pkg/utils/json.go b/pkg/utils/json.go new file mode 100644 index 000000000..e40444926 --- /dev/null +++ b/pkg/utils/json.go @@ -0,0 +1,87 @@ +package utils + +import ( + "encoding/json" + "log/slog" + "os" + + "challenge/pkg/models" +) + +type JSONUtil struct { + logger *slog.Logger +} + +func NewJSONUtil(logger *slog.Logger) *JSONUtil { + return &JSONUtil{ + logger: logger, + } +} + +type StateData struct { + Distributors map[string]*models.Distributor `json:"distributors"` +} + +// Save saves the distributor state to a JSON file +func (r *JSONUtil) Save(filename string, distributors map[string]*models.Distributor) error { + r.logger.Debug("saving state to file", + slog.String("filename", filename)) + + state := StateData{ + Distributors: distributors, + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + r.logger.Error("failed to marshal state", + slog.String("error", err.Error())) + return err + } + + err = os.WriteFile(filename, data, 0644) + if err != nil { + r.logger.Error("failed to write state file", + slog.String("filename", filename), + slog.String("error", err.Error())) + return err + } + + r.logger.Info("state saved successfully", + slog.String("filename", filename), + slog.Int("distributors", len(distributors))) + + return nil +} + +// Load loads the distributor state from a JSON file +func (r *JSONUtil) Load(filename string) (map[string]*models.Distributor, error) { + r.logger.Debug("loading state from file", + slog.String("filename", filename)) + + data, err := os.ReadFile(filename) + if err != nil { + r.logger.Error("failed to read state file", + slog.String("filename", filename), + slog.String("error", err.Error())) + return nil, err + } + + var state StateData + err = json.Unmarshal(data, &state) + if err != nil { + r.logger.Error("failed to unmarshal state", + slog.String("error", err.Error())) + return nil, err + } + + r.logger.Info("state loaded successfully", + slog.String("filename", filename), + slog.Int("distributors", len(state.Distributors))) + + return state.Distributors, nil +} + +func (r *JSONUtil) Exists(filename string) bool { + _, err := os.Stat(filename) + return err == nil +}