Files
any2anexoj/cmd/any2anexoj-cli/main.go
T
natercioandnatercio 9bd4230ff1
Badges / coveralls (push) Successful in 1m4s
Internal state persistence (#27)
Allow us to generate year over year reports without having to rerun everything from the beginning.

Co-authored-by: Natercio Moniz <[email protected]>
2026-08-03 00:33:06 +01:00

146 lines
4.3 KiB
Go

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)
}
}