diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go
index 3a46285..4de4204 100644
--- a/cmd/any2anexoj-cli/main.go
+++ b/cmd/any2anexoj-cli/main.go
@@ -13,6 +13,9 @@ import (
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/nmoniz/any2anexoj/internal/trading212"
+ "github.com/nmoniz/any2anexoj/internal/writing"
+ "github.com/nmoniz/any2anexoj/internal/writing/csv"
+ "github.com/nmoniz/any2anexoj/internal/writing/pretty"
"github.com/spf13/pflag"
"golang.org/x/text/language"
)
@@ -98,13 +101,13 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
switch *format {
case "csv":
- return NewCSVWriter(stdout).Render(writer)
+ return csv.NewCSVWriter(stdout).Render(writer)
case "table":
- loc, err := NewLocalizer(*lang)
+ loc, err := writing.NewLocalizer(*lang)
if err != nil {
return fmt.Errorf("create localizer: %w", err)
}
- NewPrettyPrinter(stdout, loc).Render(writer)
+ pretty.NewPrettyPrinter(stdout, loc).Render(writer)
return nil
default:
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
diff --git a/cmd/any2anexoj-cli/csv_writer.go b/internal/writing/csv/writer.go
similarity index 92%
rename from cmd/any2anexoj-cli/csv_writer.go
rename to internal/writing/csv/writer.go
index 9f31464..3468d47 100644
--- a/cmd/any2anexoj-cli/csv_writer.go
+++ b/internal/writing/csv/writer.go
@@ -1,4 +1,6 @@
-package main
+// Package csv renders an internal.AggregatorWriter as the CSV schema
+// expected by Modelo 3 do Anexo J.
+package csv
import (
"encoding/csv"
diff --git a/internal/writing/csv/writer_test.go b/internal/writing/csv/writer_test.go
new file mode 100644
index 0000000..bb4e087
--- /dev/null
+++ b/internal/writing/csv/writer_test.go
@@ -0,0 +1,78 @@
+package csv_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/nmoniz/any2anexoj/internal"
+ "github.com/nmoniz/any2anexoj/internal/writing/csv"
+ "github.com/shopspring/decimal"
+)
+
+func TestCSVWriter_Render(t *testing.T) {
+ aw := internal.NewAggregatorWriter()
+ ctx := t.Context()
+
+ items := []internal.ReportItem{
+ {
+ Symbol: "AAPL",
+ Nature: internal.NatureG01,
+ BrokerCountry: 826, // United Kingdom
+ AssetCountry: 840, // United States
+ BuyValue: decimal.NewFromFloat(100.50),
+ BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
+ SellValue: decimal.NewFromFloat(150.75),
+ SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
+ Fees: decimal.NewFromFloat(2.50),
+ Taxes: decimal.NewFromFloat(5.00),
+ },
+ {
+ Symbol: "VWRL",
+ Nature: internal.NatureG20,
+ BrokerCountry: 196, // Portugal equivalent demo
+ AssetCountry: 372, // Ireland
+ BuyValue: decimal.NewFromFloat(200.00),
+ BuyTimestamp: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
+ SellValue: decimal.NewFromFloat(250.25),
+ SellTimestamp: time.Date(2024, 7, 10, 0, 0, 0, 0, time.UTC),
+ Fees: decimal.NewFromFloat(1.00),
+ Taxes: decimal.NewFromFloat(0.00),
+ },
+ }
+
+ for _, ri := range items {
+ if err := aw.Write(ctx, ri); err != nil {
+ t.Fatalf("write report item: %v", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ if err := csv.NewCSVWriter(&buf).Render(aw); err != nil {
+ t.Fatalf("render: %v", err)
+ }
+
+ out := buf.String()
+
+ wantHeader := "source_country,code,realization_year,realization_month,realization_day,realization_value,acquisition_year,acquisition_month,acquisition_day,acquisition_value,expenses,foreign_tax_paid,counter_country"
+ if !strings.HasPrefix(out, wantHeader+"\n") {
+ t.Errorf("expected header row %q, got output:\n%s", wantHeader, out)
+ }
+
+ wantRow1 := "840,G01,2023,6,20,150.75,2023,1,15,100.50,2.50,5.00,826"
+ if !strings.Contains(out, wantRow1+"\n") {
+ t.Errorf("expected row 1 %q in output:\n%s", wantRow1, out)
+ }
+
+ wantRow2 := "372,G20,2024,7,10,250.25,2024,2,1,200.00,1.00,0.00,196"
+ if !strings.Contains(out, wantRow2+"\n") {
+ t.Errorf("expected row 2 %q in output:\n%s", wantRow2, out)
+ }
+
+ // Make sure the header only appears once and we have exactly 1 header + 2 data rows.
+ lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
+ if got, want := len(lines), 3; got != want {
+ t.Errorf("expected %d lines (1 header + 2 rows), got %d:\n%s", want, got, out)
+ }
+}
diff --git a/cmd/any2anexoj-cli/localizer.go b/internal/writing/localizer.go
similarity index 88%
rename from cmd/any2anexoj-cli/localizer.go
rename to internal/writing/localizer.go
index a8cf756..0d2fe06 100644
--- a/cmd/any2anexoj-cli/localizer.go
+++ b/internal/writing/localizer.go
@@ -1,4 +1,8 @@
+<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer.go
package main
+========
+package writing
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer.go
import (
"embed"
diff --git a/cmd/any2anexoj-cli/localizer_test.go b/internal/writing/localizer_test.go
similarity index 53%
rename from cmd/any2anexoj-cli/localizer_test.go
rename to internal/writing/localizer_test.go
index 2bad62f..9381e3c 100644
--- a/cmd/any2anexoj-cli/localizer_test.go
+++ b/internal/writing/localizer_test.go
@@ -1,6 +1,16 @@
+<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer_test.go
package main
import "testing"
+========
+package writing_test
+
+import (
+ "testing"
+
+ "github.com/nmoniz/any2anexoj/internal/writing"
+)
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer_test.go
func TestNewLocalizer(t *testing.T) {
tests := []struct {
@@ -15,7 +25,11 @@ func TestNewLocalizer(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer_test.go
_, err := NewLocalizer(tt.lang)
+========
+ _, err := writing.NewLocalizer(tt.lang)
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer_test.go
if err != nil {
t.Fatalf("want success call but failed: %v", err)
}
diff --git a/cmd/any2anexoj-cli/pretty_printer.go b/internal/writing/pretty/printer.go
similarity index 60%
rename from cmd/any2anexoj-cli/pretty_printer.go
rename to internal/writing/pretty/printer.go
index f102573..56bcf58 100644
--- a/cmd/any2anexoj-cli/pretty_printer.go
+++ b/internal/writing/pretty/printer.go
@@ -1,4 +1,8 @@
+<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer.go
package main
+========
+package pretty
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer.go
import (
"fmt"
@@ -11,20 +15,35 @@ import (
)
// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each
-// ReportItem received.
+// ReportItem received. Render and RenderHTML each build a fresh table.Writer so that
+// invoking them (in any order or repeatedly) on the same instance never duplicates rows.
type PrettyPrinter struct {
- table table.Writer
output io.Writer
translator Translator
}
+<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer.go
type Translator interface {
Translate(key string, count int, values map[string]any) string
}
func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter {
+========
+func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter {
+ return &PrettyPrinter{
+ output: w,
+ translator: tr,
+ }
+}
+
+// buildTable constructs a fully configured table.Writer from the items in aw.
+// Each call returns a new table.Writer so callers can render the same data
+// multiple times without duplicating rows. The returned table is not wired to
+// pp.output via an output mirror; callers that want the rendered output
+// written to pp.output must set the mirror themselves before calling .Render().
+func (pp *PrettyPrinter) buildTable(aw *internal.AggregatorWriter) table.Writer {
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer.go
tw := table.NewWriter()
- tw.SetOutputMirror(w)
tw.SetAutoIndex(true)
tw.SetStyle(table.StyleLight)
tw.SetColumnConfigs([]table.ColumnConfig{
@@ -43,14 +62,6 @@ func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter {
colCountry(13),
})
- return &PrettyPrinter{
- table: tw,
- output: w,
- translator: tr,
- }
-}
-
-func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
realizationTxt := pp.translator.Translate("realization", 1, nil)
acquisitionTxt := pp.translator.Translate("acquisition", 1, nil)
yearTxt := pp.translator.Translate("year", 1, nil)
@@ -58,8 +69,8 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
dayTxt := pp.translator.Translate("day", 1, nil)
valorTxt := pp.translator.Translate("value", 1, nil)
- pp.table.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
- pp.table.AppendHeader(table.Row{
+ tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
+ tw.AppendHeader(table.Row{
pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil),
yearTxt, monthTxt, dayTxt, valorTxt,
yearTxt, monthTxt, dayTxt, valorTxt,
@@ -67,7 +78,7 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
})
for ri := range aw.Iter() {
- pp.table.AppendRow(table.Row{
+ tw.AppendRow(table.Row{
ri.AssetCountry, ri.Nature,
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
@@ -75,8 +86,29 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
})
}
- pp.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
- pp.table.Render()
+ sumTxt := pp.translator.Translate("sum", 1, nil)
+ tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
+
+ return tw
+}
+
+func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
+ t := pp.buildTable(aw)
+ t.SetOutputMirror(pp.output)
+ t.Render()
+}
+
+// RenderHTML builds a fresh table from aw and renders it as an HTML string
+// using go-pretty's HTMLOptions.
+func (pp *PrettyPrinter) RenderHTML(aw *internal.AggregatorWriter) string {
+ t := pp.buildTable(aw)
+ t.Style().HTML = table.HTMLOptions{
+ CSSClass: "table table-striped table-hover table-bordered",
+ EscapeText: true,
+ Newline: "
",
+ EmptyColumn: " ",
+ }
+ return t.RenderHTML()
}
func colEuros(n int) table.ColumnConfig {
diff --git a/cmd/any2anexoj-cli/pretty_printer_test.go b/internal/writing/pretty/printer_test.go
similarity index 63%
rename from cmd/any2anexoj-cli/pretty_printer_test.go
rename to internal/writing/pretty/printer_test.go
index 72db220..e0201fa 100644
--- a/cmd/any2anexoj-cli/pretty_printer_test.go
+++ b/internal/writing/pretty/printer_test.go
@@ -1,11 +1,21 @@
+<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go
package main
+========
+package pretty_test
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go
import (
"bytes"
+ "strings"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
+<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go
+========
+ "github.com/nmoniz/any2anexoj/internal/writing"
+ "github.com/nmoniz/any2anexoj/internal/writing/pretty"
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go
"github.com/shopspring/decimal"
)
@@ -48,14 +58,22 @@ func TestPrettyPrinter_Render(t *testing.T) {
}
// Create English localizer
+<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go
localizer, err := NewLocalizer("en")
+========
+ localizer, err := writing.NewLocalizer("en")
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go
if err != nil {
t.Fatalf("failed to create localizer: %v", err)
}
- // Create pretty printer with buffer
+ // Create writing printer with buffer
var buf bytes.Buffer
+<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go
pp := NewPrettyPrinter(&buf, localizer)
+========
+ pp := pretty.NewPrettyPrinter(&buf, localizer)
+>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go
// Render the table
pp.Render(aw)
@@ -81,3 +99,71 @@ func TestPrettyPrinter_Render(t *testing.T) {
t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
}
}
+
+func TestRenderHTML(t *testing.T) {
+ // Create test data
+ aw := internal.NewAggregatorWriter()
+ ctx := t.Context()
+
+ err := aw.Write(ctx, internal.ReportItem{
+ Symbol: "AAPL",
+ Nature: internal.NatureG01,
+ BrokerCountry: 826, // United Kingdom
+ AssetCountry: 840, // United States
+ BuyValue: decimal.NewFromFloat(100.50),
+ BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
+ SellValue: decimal.NewFromFloat(150.75),
+ SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
+ Fees: decimal.NewFromFloat(2.50),
+ Taxes: decimal.NewFromFloat(5.00),
+ })
+ if err != nil {
+ t.Fatalf("failed to write report item: %v", err)
+ }
+
+ // Create Portuguese localizer so the header text is in pt
+ localizer, err := writing.NewLocalizer("pt")
+ if err != nil {
+ t.Fatalf("failed to create localizer: %v", err)
+ }
+
+ // Output buffer used only to satisfy the constructor contract; RenderHTML
+ // must not write to it.
+ var buf bytes.Buffer
+ pp := pretty.NewPrettyPrinter(&buf, localizer)
+
+ html := pp.RenderHTML(aw)
+
+ // RenderHTML must not side-write to pp.output.
+ if buf.Len() != 0 {
+ t.Errorf("RenderHTML should not write to pp.output, but got %d bytes: %q", buf.Len(), buf.String())
+ }
+
+ // Structural assertions.
+ wantSubstrings := []string{
+ `