package main import ( "context" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "time" "github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal/ofigi" "github.com/nmoniz/any2anexoj/internal/trading212" "github.com/spf13/pflag" "golang.org/x/text/language" ) var ( // TODO: once we support more brokers or exchanges we should make this parameter required and // remove/change default platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms") lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code") debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages") format = pflag.StringP("format", "f", "table", "Output format: table or csv") ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)") selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry") stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing") ) func main() { pflag.Parse() err := run(context.Background()) if err != nil { slog.Error("found a fatal issue", slog.Any("err", err)) os.Exit(1) } } // run is the production entry point. It wires the CLI flags to runWithIO // using the process's actual stdin/stdout and installs OS-signal-driven // cancellation so a Ctrl-C cancels in-flight processing. func run(ctx context.Context) error { ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt) defer cancel() return runWithIO(ctx, os.Stdin, os.Stdout) } // runWithIO is the testable core of the CLI. It reads broker records from // stdin, writes the formatted report to stdout, and optionally persists // incremental state to the path supplied via --state-file. func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { logLevel := slog.LevelInfo if *debug { logLevel = slog.LevelDebug } slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))) if platform == nil || len(*platform) == 0 { return fmt.Errorf("--platform flag is required") } if lang == nil || len(*lang) == 0 { return fmt.Errorf("--language flag is required") } figiClient := ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, *ofAPIKey) reader, err := getReader(*platform, stdin, figiClient) if err != nil { return fmt.Errorf("getting reader: %w", err) } store, err := buildStore(*stateFile, *platform, figiClient) if err != nil { return err } writer := internal.NewAggregatorWriter() selector, err := internal.ParseSelectors(*selectors) if err != nil { return fmt.Errorf("parsing selectors: %w", err) } err = internal.BuildReport( ctx, reader, writer, internal.WithSelector(selector), internal.WithStore(store), ) if err != nil { return err } switch *format { case "csv": return NewCSVWriter(stdout).Render(writer) case "table": loc, err := NewLocalizer(*lang) if err != nil { return fmt.Errorf("create localizer: %w", err) } NewPrettyPrinter(stdout, loc).Render(writer) return nil default: return fmt.Errorf("unsupported format %q: must be table or csv", *format) } } // buildStore returns the Store implementation that BuildReport should use. // When --state-file is empty an EphemeralStore is used so behaviour is // identical to pre-persistence runs. Otherwise a JSON-backed FileStore is // returned, wired to a platform-specific RecordSerializer. func buildStore(stateFile, platform string, figi *ofigi.Client) (internal.Store, error) { if stateFile == "" { return internal.EphemeralStore{}, nil } var serializer internal.RecordSerializer switch platform { case "trading212": serializer = trading212.NewRecordSerializer(figi) default: return nil, fmt.Errorf("unsupported platform for state persistence: %s", platform) } store, err := internal.NewFileStore(stateFile, platform, serializer) if err != nil { return nil, fmt.Errorf("creating file store: %w", err) } return store, nil } func getReader(platform string, r io.Reader, figi *ofigi.Client) (internal.RecordReader, error) { switch platform { case "trading212": return trading212.NewRecordReader(r, figi), nil default: return nil, fmt.Errorf("unsupported platform: %s", platform) } }