Files
thptqg/parser/internal/config/config.go
T
tiennm99 933adf70c9 refactor: comments state current behavior, not project history
Comments across the tree justified the code by pointing at a Rust
implementation that is no longer in the repository, citing files and line
numbers (config.rs:132, schema.rs:26-54, reader.rs:42) that cannot be
opened, plus crates and datasets that are equally gone. A reader could not
check any of it.

Every invariant those comments carried is kept and restated so it stands on
its own: the bytewise sort that decides which row survives a duplicate exam
number, the literal U+0300..U+036F range that must match the site's toAscii,
the trailing space in "SINH ", the BIFF and shared-string corrections, the
VACUUM-after-COMMIT rule, the deploy-from-main guard.

The reader's contract is now anchored to the frozen oracle in
parser/testdata, which still exists and is still checked, rather than to the
tool that originally produced it.

TestDDLMatchesRust becomes TestDDLIsFrozen: it compares against a copy of
the DDL inside the test and never read schema.rs, so both the name and the
failure message were misleading.

ToAscii no longer claims the d-replacement must precede lowercasing. Both
cases map to 'd' and ToLower runs last, so the order has no effect.
2026-08-14 09:40:41 +07:00

112 lines
3.8 KiB
Go

// Package config loads per-dataset parse rules.
//
// The config deliberately carries no SQL. The table shape, the INSERT and the
// subject regexes are identical for every dataset and live in internal/schema;
// keeping them here means one copy of the DDL per dataset, which is how the
// 2016 and 2017 schemas drifted apart.
package config
import (
"bytes"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// SheetMode selects which sheets of a workbook are read.
type SheetMode string
const (
// SheetModeAll iterates every sheet, which is what recovers the Hanoi and
// HCM rows that overflow past Excel's 65,536-row cap into a second sheet.
SheetModeAll SheetMode = "all"
// SheetModeFirst reads sheet 0 only.
SheetModeFirst SheetMode = "first"
)
// valid reports whether m is one of the two accepted sheet modes.
//
// Checked after decoding rather than via a custom unmarshaler: the decoder
// assigns named string types directly, so a typo would otherwise decode
// silently and be read as "not all" downstream.
func (m SheetMode) valid() bool {
return m == SheetModeAll || m == SheetModeFirst
}
// DatasetConfig is the per-dataset parse rule set.
type DatasetConfig struct {
Reader ReaderCfg `yaml:"reader"`
// Columns holds fixed column indices. Nil when FormatDetection handles
// per-file mapping — a pointer rather than a value because a zero ColumnMap
// would silently mean "every column is index 0".
Columns *ColumnMap `yaml:"columns"`
Validation ValidationCfg `yaml:"validation"`
Header HeaderCfg `yaml:"header"`
// FormatDetection, when set to "thptqg2016", enables per-file format
// auto-detection: each file's header row is inspected at runtime to choose
// the column layout (separate-scores / mapped / default-positional).
FormatDetection *string `yaml:"format_detection"`
}
type ReaderCfg struct {
SheetMode SheetMode `yaml:"sheet_mode"`
// StripBlankRows skips rows where every cell is empty before counting them
// as source rows.
StripBlankRows bool `yaml:"strip_blank_rows"`
}
// ColumnMap holds zero-indexed column positions in the source row. Used by the
// 2017 configs; 2016 uses runtime format detection instead.
type ColumnMap struct {
HoTen int `yaml:"ho_ten"`
NgaySinh int `yaml:"ngay_sinh"`
SoBaoDanh int `yaml:"so_bao_danh"`
DiemThi int `yaml:"diem_thi"`
}
type ValidationCfg struct {
// RequireNumericSbd skips rows whose so_bao_danh is not all ASCII digits.
RequireNumericSbd bool `yaml:"require_numeric_sbd"`
RequireNonemptyName bool `yaml:"require_nonempty_name"`
RequireNonemptySbd bool `yaml:"require_nonempty_sbd"`
}
type HeaderCfg struct {
// Tokens are matched against the uppercased first cell to detect a header row.
Tokens []string `yaml:"tokens"`
}
// Parse decodes a config, rejecting any key the struct does not declare.
//
// Strictness is load-bearing and has its own test. YAML decoders ignore unknown
// keys by default, so KnownFields(true) has to be set explicitly. Without it a
// leftover `schema:` mapping would look effective while internal/schema actually
// drove the build — the drift that produced two divergent schemas.
func Parse(src []byte) (*DatasetConfig, error) {
var cfg DatasetConfig
dec := yaml.NewDecoder(bytes.NewReader(src))
dec.KnownFields(true)
if err := dec.Decode(&cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if !cfg.Reader.SheetMode.valid() {
return nil, fmt.Errorf("invalid sheet_mode %q (want %q or %q)",
cfg.Reader.SheetMode, SheetModeAll, SheetModeFirst)
}
return &cfg, nil
}
// Load reads and parses a config file.
func Load(path string) (*DatasetConfig, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
cfg, err := Parse(src)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return cfg, nil
}