NS4TDPLNAWJYJN37PZDYXMG6OJSAWZCMTPSPKX73JCLZZAMY25BAC type expectedTS struct {Year, Month, Day, Hour, Minute, Second int}func assertTimestamp(t *testing.T, got time.Time, want expectedTS) {t.Helper()t.Helper()if got.Year() != want.Year {t.Errorf("Year: got %d, want %d", got.Year(), want.Year)}if got.Month() != time.Month(want.Month) {t.Errorf("Month: got %d, want %d", got.Month(), want.Month)}if got.Day() != want.Day {t.Errorf("Day: got %d, want %d", got.Day(), want.Day)}if got.Hour() != want.Hour {t.Errorf("Hour: got %d, want %d", got.Hour(), want.Hour)}if got.Minute() != want.Minute {t.Errorf("Minute: got %d, want %d", got.Minute(), want.Minute)}if got.Second() != want.Second {t.Errorf("Second: got %d, want %d", got.Second(), want.Second)}}func assertOffset(t *testing.T, got time.Time, wantSeconds int) {t.Helper()_, offset := got.Zone()if offset != wantSeconds {t.Errorf("Offset: got %d seconds, want %d seconds", offset, wantSeconds)}}// parseAndApply is a test helper that parses filenames and applies a timezone offset.func parseAndApply(t *testing.T, filenames []string, tz string) []time.Time {t.Helper()parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}results, err := ApplyTimezoneOffset(parsed, tz)if err != nil {t.Fatalf("Failed to apply timezone: %v", err)}return results}
if results[0].Timestamp.Year() != 2020 {t.Errorf("Year incorrect for file 0: got %d, want 2020", results[0].Timestamp.Year())}if results[0].Timestamp.Month() != 10 { // Octobert.Errorf("Month incorrect for file 0: got %d, want 10", results[0].Timestamp.Month())}if results[0].Timestamp.Day() != 12 {t.Errorf("Day incorrect for file 0: got %d, want 12", results[0].Timestamp.Day())}if results[0].Timestamp.Hour() != 12 {t.Errorf("Hour incorrect for file 0: got %d, want 12", results[0].Timestamp.Hour())}if results[0].Timestamp.Minute() != 34 {t.Errorf("Minute incorrect for file 0: got %d, want 34", results[0].Timestamp.Minute())}if results[0].Timestamp.Second() != 56 {t.Errorf("Second incorrect for file 0: got %d, want 56", results[0].Timestamp.Second())}if results[3].Timestamp.Year() != 2021 {t.Errorf("Year incorrect for file 3: got %d, want 2021", results[3].Timestamp.Year())}if results[3].Timestamp.Month() != 11 { // Novembert.Errorf("Month incorrect for file 3: got %d, want 11", results[3].Timestamp.Month())}if results[3].Timestamp.Day() != 22 {t.Errorf("Day incorrect for file 3: got %d, want 22", results[3].Timestamp.Day())}
assertTimestamp(t, results[0].Timestamp, expectedTS{2020, 10, 12, 12, 34, 56})assertTimestamp(t, results[3].Timestamp, expectedTS{2021, 11, 22, 12, 34, 56})
if results[0].Timestamp.Day() != 12 {t.Errorf("Day incorrect for file 0: got %d, want 12", results[0].Timestamp.Day())}if results[0].Timestamp.Month() != 10 { // Octobert.Errorf("Month incorrect for file 0: got %d, want 10", results[0].Timestamp.Month())}if results[0].Timestamp.Year() != 2020 {t.Errorf("Year incorrect for file 0: got %d, want 2020", results[0].Timestamp.Year())}if results[2].Timestamp.Day() != 17 {t.Errorf("Day incorrect for file 2: got %d, want 17", results[2].Timestamp.Day())}if results[2].Timestamp.Month() != 12 { // Decembert.Errorf("Month incorrect for file 2: got %d, want 12", results[2].Timestamp.Month())}if results[2].Timestamp.Year() != 2020 {t.Errorf("Year incorrect for file 2: got %d, want 2020", results[2].Timestamp.Year())}
assertTimestamp(t, results[0].Timestamp, expectedTS{2020, 10, 12, 12, 34, 56})assertTimestamp(t, results[2].Timestamp, expectedTS{2020, 12, 17, 12, 34, 56})
if results[0].Timestamp.Year() != 2023 {t.Errorf("Year incorrect: got %d, want 2023", results[0].Timestamp.Year())}if results[0].Timestamp.Month() != 6 { // Junet.Errorf("Month incorrect: got %d, want 6", results[0].Timestamp.Month())}if results[0].Timestamp.Day() != 9 {t.Errorf("Day incorrect: got %d, want 9", results[0].Timestamp.Day())}if results[0].Timestamp.Hour() != 10 {t.Errorf("Hour incorrect: got %d, want 10", results[0].Timestamp.Hour())}if results[0].Timestamp.Minute() != 30 {t.Errorf("Minute incorrect: got %d, want 30", results[0].Timestamp.Minute())}if results[0].Timestamp.Second() != 0 {t.Errorf("Second incorrect: got %d, want 0", results[0].Timestamp.Second())}if results[1].Timestamp.Year() != 2024 {t.Errorf("Year incorrect: got %d, want 2024", results[1].Timestamp.Year())}
assertTimestamp(t, results[0].Timestamp, expectedTS{2023, 6, 9, 10, 30, 0})assertTimestamp(t, results[1].Timestamp, expectedTS{2024, 11, 9, 20, 15, 4})
if results[0].Timestamp.Day() != 12 {t.Errorf("Day incorrect: got %d, want 12", results[0].Timestamp.Day())}if results[0].Timestamp.Month() != 1 { // Januaryt.Errorf("Month incorrect: got %d, want 1", results[0].Timestamp.Month())}if results[0].Timestamp.Year() != 2019 {t.Errorf("Year incorrect: got %d, want 2019", results[0].Timestamp.Year())}if results[4].Timestamp.Day() != 31 {t.Errorf("Day incorrect for file 4: got %d, want 31", results[4].Timestamp.Day())}if results[4].Timestamp.Month() != 3 { // Marcht.Errorf("Month incorrect for file 4: got %d, want 3", results[4].Timestamp.Month())}})t.Run("should throw error for empty filename array", func(t *testing.T) {_, err := ParseFilenameTimestamps([]string{})if err == nil {t.Error("Expected error for empty filename array")}if err != nil && err.Error() != "no filenames provided" {t.Logf("Error message: %v", err)}
assertTimestamp(t, results[0].Timestamp, expectedTS{2019, 1, 12, 0, 30, 2})assertTimestamp(t, results[4].Timestamp, expectedTS{2020, 3, 31, 23, 15, 2})
if results[0].Timestamp.Year() != 2023 {t.Errorf("Year incorrect: got %d, want 2023", results[0].Timestamp.Year())}if results[0].Timestamp.Month() != 6 { // Junet.Errorf("Month incorrect: got %d, want 6", results[0].Timestamp.Month())}if results[0].Timestamp.Day() != 9 {t.Errorf("Day incorrect: got %d, want 9", results[0].Timestamp.Day())}if results[0].Timestamp.Hour() != 10 {t.Errorf("Hour incorrect: got %d, want 10", results[0].Timestamp.Hour())}if results[0].Timestamp.Minute() != 30 {t.Errorf("Minute incorrect: got %d, want 30", results[0].Timestamp.Minute())}if results[0].Timestamp.Second() != 0 {t.Errorf("Second incorrect: got %d, want 0", results[0].Timestamp.Second())}if results[1].Timestamp.Year() != 2024 {t.Errorf("Year incorrect: got %d, want 2024", results[1].Timestamp.Year())}if results[1].Timestamp.Month() != 11 { // Novembert.Errorf("Month incorrect: got %d, want 11", results[1].Timestamp.Month())}if results[1].Timestamp.Day() != 9 {t.Errorf("Day incorrect: got %d, want 9", results[1].Timestamp.Day())}if results[1].Timestamp.Hour() != 20 {t.Errorf("Hour incorrect: got %d, want 20", results[1].Timestamp.Hour())}if results[1].Timestamp.Minute() != 15 {t.Errorf("Minute incorrect: got %d, want 15", results[1].Timestamp.Minute())}if results[1].Timestamp.Second() != 4 {t.Errorf("Second incorrect: got %d, want 4", results[1].Timestamp.Second())}
assertTimestamp(t, results[0].Timestamp, expectedTS{2023, 6, 9, 10, 30, 0})assertTimestamp(t, results[1].Timestamp, expectedTS{2024, 11, 9, 20, 15, 4})
if results[0].Timestamp.Day() != 18 {t.Errorf("Day incorrect: got %d, want 18", results[0].Timestamp.Day())}if results[0].Timestamp.Month() != 1 { // Januaryt.Errorf("Month incorrect: got %d, want 1", results[0].Timestamp.Month())}if results[0].Timestamp.Year() != 2020 {t.Errorf("Year incorrect: got %d, want 2020", results[0].Timestamp.Year())}if results[0].Timestamp.Hour() != 23 {t.Errorf("Hour incorrect: got %d, want 23", results[0].Timestamp.Hour())}if results[0].Timestamp.Minute() != 15 {t.Errorf("Minute incorrect: got %d, want 15", results[0].Timestamp.Minute())}if results[0].Timestamp.Second() != 2 {t.Errorf("Second incorrect: got %d, want 2", results[0].Timestamp.Second())}
assertTimestamp(t, results[0].Timestamp, expectedTS{2020, 1, 18, 23, 15, 2})assertTimestamp(t, results[1].Timestamp, expectedTS{2019, 1, 12, 0, 30, 2})assertTimestamp(t, results[4].Timestamp, expectedTS{2020, 3, 31, 23, 15, 2})})}
if results[1].Timestamp.Day() != 12 {t.Errorf("Day incorrect: got %d, want 12", results[1].Timestamp.Day())
func TestParseFilenameTimestampsErrors(t *testing.T) {t.Run("should throw error for empty filename array", func(t *testing.T) {_, err := ParseFilenameTimestamps([]string{})if err == nil {t.Error("Expected error for empty filename array")
if results[4].Timestamp.Day() != 31 {t.Errorf("Day incorrect: got %d, want 31", results[4].Timestamp.Day())
t.Run("should throw error for filenames without date patterns", func(t *testing.T) {_, err := ParseFilenameTimestamps([]string{"invalid_filename.wav"})if err == nil {t.Error("Expected error for filenames without date patterns")
filenames := []string{"201012_123456.wav","201014_123456.WAV",}parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}results, err := ApplyTimezoneOffset(parsed, "UTC")if err != nil {t.Fatalf("Failed to apply timezone: %v", err)}
results := parseAndApply(t, []string{"201012_123456.wav", "201014_123456.WAV"}, "UTC")
// Test files spanning the Auckland DST transition in April 2021// DST ended on April 4, 2021 (UTC+13 -> UTC+12)filenames := []string{"20210401_120000.wav", // April 1st - DST still active (UTC+13)
// Auckland DST ended April 4, 2021 (UTC+13 -> UTC+12)results := parseAndApply(t, []string{"20210401_120000.wav", // April 1st - DST active (UTC+13)
"20210420_120000.wav", // April 20th - Standard time (would be UTC+12 if DST applied)}parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}
"20210420_120000.wav", // April 20th - Standard time}, "Pacific/Auckland")
// All files should use the same offset (from April 1st - earliest file)offsets := make([]int, len(results))for i, r := range results {_, offset := r.Zone()offsets[i] = offset
// All files should use UTC+13 offset (from earliest file: April 1st)for _, r := range results {assertOffset(t, r, 13*3600)
// Check all offsets are the samefirstOffset := offsets[0]for i, offset := range offsets {if offset != firstOffset {t.Errorf("File %d has different offset: got %d, want %d", i, offset, firstOffset)}}// The offset should be UTC+13 (from the earliest file: April 1st)expectedOffsetSeconds := 13 * 3600if firstOffset != expectedOffsetSeconds {t.Errorf("Offset incorrect: got %d seconds, want %d seconds (UTC+13)", firstOffset, expectedOffsetSeconds)}// Verify UTC conversion uses the fixed offset consistently// All files at 12:00 local should convert to the same UTC hour (with UTC+13 offset)// 12:00 Auckland time - 13 hours = 23:00 UTC previous dayfor i, utcTime := range results {utc := utcTime.UTC()if utc.Hour() != 23 {t.Errorf("File %d UTC hour incorrect: got %d, want 23", i, utc.Hour())}}
// All at 12:00 local - 13h = 23:00 UTC previous dayassertTimestamp(t, results[0].UTC(), expectedTS{2021, 3, 31, 23, 0, 0})assertTimestamp(t, results[1].UTC(), expectedTS{2021, 4, 9, 23, 0, 0})assertTimestamp(t, results[2].UTC(), expectedTS{2021, 4, 19, 23, 0, 0})
}parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}results, err := ApplyTimezoneOffset(parsed, "Pacific/Auckland")if err != nil {t.Fatalf("Failed to apply timezone: %v", err)}
}, "Pacific/Auckland")
// All files should use UTC+13 offset (from April 1st, the earliest)for i, r := range results {_, offset := r.Zone()expectedOffset := 13 * 3600if offset != expectedOffset {t.Errorf("File %d offset incorrect: got %d, want %d", i, offset, expectedOffset)}
// All files use UTC+13 (from April 1st, the earliest)for _, r := range results {assertOffset(t, r, 13*3600)
// Results should maintain original filename orderif results[0].Day() != 10 {t.Errorf("Result 0 should be April 10th, got day %d", results[0].Day())}if results[1].Day() != 1 {t.Errorf("Result 1 should be April 1st, got day %d", results[1].Day())}if results[2].Day() != 5 {t.Errorf("Result 2 should be April 5th, got day %d", results[2].Day())}
// Results maintain original filename orderassertTimestamp(t, results[0], expectedTS{2021, 4, 10, 12, 0, 0})assertTimestamp(t, results[1], expectedTS{2021, 4, 1, 12, 0, 0})assertTimestamp(t, results[2], expectedTS{2021, 4, 5, 12, 0, 0})
// Test files spanning multiple months with different DST periodsfilenames := []string{"20210215_120000.wav", // February 15th (summer, UTC+13)"20210615_120000.wav", // June 15th (winter, would be UTC+12 if DST applied)"20210815_120000.wav", // August 15th (winter, would be UTC+12 if DST applied)}
results := parseAndApply(t, []string{"20210215_120000.wav", // February (summer, UTC+13)"20210615_120000.wav", // June (winter, would be UTC+12 if DST applied)"20210815_120000.wav", // August (winter)}, "Pacific/Auckland")
parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}results, err := ApplyTimezoneOffset(parsed, "Pacific/Auckland")if err != nil {t.Fatalf("Failed to apply timezone: %v", err)}// All files should use the same offset from the earliest file (February)expectedOffset := 13 * 3600for i, r := range results {_, offset := r.Zone()if offset != expectedOffset {t.Errorf("File %d offset incorrect: got %d, want %d", i, offset, expectedOffset)}
// All files use offset from earliest (February): UTC+13for _, r := range results {assertOffset(t, r, 13*3600)
// Verify UTC conversion is consistent with fixed offsetfor i, r := range results {utc := r.UTC()if utc.Hour() != 23 { // 12 - 13 = -1 hour (23:00 previous day)t.Errorf("File %d UTC hour incorrect: got %d, want 23", i, utc.Hour())}}
// 12:00 local - 13h = 23:00 UTC previous dayassertTimestamp(t, results[0].UTC(), expectedTS{2021, 2, 14, 23, 0, 0})assertTimestamp(t, results[1].UTC(), expectedTS{2021, 6, 14, 23, 0, 0})assertTimestamp(t, results[2].UTC(), expectedTS{2021, 8, 14, 23, 0, 0})
"20210320_120000.wav", // March 20th - after DST (would be UTC-4 if DST applied)}parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}
"20210320_120000.wav", // March 20th - after DST (would be UTC-4)}, "America/New_York")
// All files should use the same offset from earliest file (March 10th)expectedOffset := -5 * 3600for i, r := range results {_, offset := r.Zone()if offset != expectedOffset {t.Errorf("File %d offset incorrect: got %d, want %d", i, offset, expectedOffset)}}// Verify UTC conversion uses fixed offsetfor i, r := range results {utc := r.UTC()if utc.Hour() != 17 { // 12 + 5 = 17t.Errorf("File %d UTC hour incorrect: got %d, want 17", i, utc.Hour())}}
// 12:00 local + 5h = 17:00 UTCassertTimestamp(t, results[0].UTC(), expectedTS{2021, 3, 10, 17, 0, 0})assertTimestamp(t, results[1].UTC(), expectedTS{2021, 3, 20, 17, 0, 0})
// Test a night recording: 21:00 (9 PM) Pacific/Auckland// In May 2021, Pacific/Auckland is UTC+12 (standard time)// So 21:00 Pacific/Auckland should become 09:00 UTC same dayfilenames := []string{"20210505_210000.wav"}parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}results, err := ApplyTimezoneOffset(parsed, "Pacific/Auckland")if err != nil {t.Fatalf("Failed to apply timezone: %v", err)}utcDate := results[0].UTC()if utcDate.Year() != 2021 {t.Errorf("Year incorrect: got %d, want 2021", utcDate.Year())}if utcDate.Month() != 5 {t.Errorf("Month incorrect: got %d, want 5", utcDate.Month())}if utcDate.Day() != 5 {t.Errorf("Day incorrect: got %d, want 5 (same day)", utcDate.Day())}if utcDate.Hour() != 9 {t.Errorf("Hour incorrect: got %d, want 9 (21 - 12 = 9)", utcDate.Hour())}
// 21:00 Pacific/Auckland (May = UTC+12) → 09:00 UTC same dayresults := parseAndApply(t, []string{"20210505_210000.wav"}, "Pacific/Auckland")assertTimestamp(t, results[0].UTC(), expectedTS{2021, 5, 5, 9, 0, 0})
// Test a day recording: 12:00 (noon) Pacific/Auckland// Should become 00:00 UTC same day (midnight)filenames := []string{"20210505_120000.wav"}parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}results, err := ApplyTimezoneOffset(parsed, "Pacific/Auckland")if err != nil {t.Fatalf("Failed to apply timezone: %v", err)}utcDate := results[0].UTC()if utcDate.Hour() != 0 {t.Errorf("Hour incorrect: got %d, want 0 (12 - 12 = 0, midnight UTC)", utcDate.Hour())}if utcDate.Day() != 5 {t.Errorf("Day incorrect: got %d, want 5 (same day)", utcDate.Day())}
// 12:00 Pacific/Auckland (May = UTC+12) → 00:00 UTC same dayresults := parseAndApply(t, []string{"20210505_120000.wav"}, "Pacific/Auckland")assertTimestamp(t, results[0].UTC(), expectedTS{2021, 5, 5, 0, 0, 0})
// Test early morning: 02:00 Pacific/Auckland// Should become 14:00 UTC previous dayfilenames := []string{"20210505_020000.wav"}parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}results, err := ApplyTimezoneOffset(parsed, "Pacific/Auckland")if err != nil {t.Fatalf("Failed to apply timezone: %v", err)}utcDate := results[0].UTC()if utcDate.Day() != 4 {t.Errorf("Day incorrect: got %d, want 4 (previous day)", utcDate.Day())}if utcDate.Hour() != 14 {t.Errorf("Hour incorrect: got %d, want 14 (2 - 12 = -10, so previous day 14:00)", utcDate.Hour())}
// 02:00 Pacific/Auckland (May = UTC+12) → 14:00 UTC previous dayresults := parseAndApply(t, []string{"20210505_020000.wav"}, "Pacific/Auckland")assertTimestamp(t, results[0].UTC(), expectedTS{2021, 5, 4, 14, 0, 0})
// Test 15:00 (3 PM) New York in June (UTC-4 during DST)// Should become 19:00 UTC same dayfilenames := []string{"20210615_150000.wav"}parsed, err := ParseFilenameTimestamps(filenames)if err != nil {t.Fatalf("Failed to parse filenames: %v", err)}results, err := ApplyTimezoneOffset(parsed, "America/New_York")if err != nil {t.Fatalf("Failed to apply timezone: %v", err)}utcDate := results[0].UTC()if utcDate.Hour() != 19 {t.Errorf("Hour incorrect: got %d, want 19 (15 + 4 = 19)", utcDate.Hour())}if utcDate.Day() != 15 {t.Errorf("Day incorrect: got %d, want 15 (same day)", utcDate.Day())}
// 15:00 New York (June = UTC-4 during DST) → 19:00 UTC same dayresults := parseAndApply(t, []string{"20210615_150000.wav"}, "America/New_York")assertTimestamp(t, results[0].UTC(), expectedTS{2021, 6, 15, 19, 0, 0})
func TestFix(t *testing.T) {cmd := exec.Command("go", "fix", "./...")cmd.Dir = "."out, err := cmd.CombinedOutput()if err != nil {t.Errorf("go fix failed:\n%s", out)}}
// RunCallsClassify handles the "calls classify" subcommandfunc RunCallsClassify(args []string) {var folder, file, filter, species, gotoFile, timezone stringvar certainty, sample intvar night, day boolvar lat, lng float64var latSet, lngSet bool
// classifyArgs holds parsed CLI arguments for the classify subcommand.type classifyArgs struct {folder stringfile stringfilter stringspecies stringgotoFile stringtimezone stringcertainty intsample intnight boolday boollat float64lng float64latSet boollngSet bool}
// Default to -1 (no filter / no sampling)certainty = -1sample = -1
// parseClassifyArgs parses the argument slice and returns classified args.// Exits on parse errors.func parseClassifyArgs(args []string) classifyArgs {a := classifyArgs{certainty: -1, sample: -1}
if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --filter requires a value\n")os.Exit(1)}if filter != "" {fmt.Fprintf(os.Stderr, "Error: --filter can only be specified once\n")os.Exit(1)}filter = args[i+1]
a.filter = a.requireUniqueValue(args, i, "--filter", a.filter)
if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --species requires a value\n")os.Exit(1)}if species != "" {fmt.Fprintf(os.Stderr, "Error: --species can only be specified once\n")os.Exit(1)}species = args[i+1]
a.species = a.requireUniqueValue(args, i, "--species", a.species)
if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --certainty requires a value\n")os.Exit(1)}v, err := strconv.Atoi(args[i+1])if err != nil {fmt.Fprintf(os.Stderr, "Error: --certainty must be an integer\n")os.Exit(1)}if v < 0 || v > 100 {fmt.Fprintf(os.Stderr, "Error: --certainty must be between 0 and 100\n")os.Exit(1)}certainty = v
a.certainty = a.requireIntRange(args, i, "--certainty", 0, 100)
case "--night":night = truei++case "--day":day = truei++case "--lat":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --lat requires a value\n")os.Exit(1)}v, err := strconv.ParseFloat(args[i+1], 64)if err != nil {fmt.Fprintf(os.Stderr, "Error: --lat must be a number\n")os.Exit(1)}lat = vlatSet = true
case "--sample":a.sample = a.requireIntRange(args, i, "--sample", 1, 100)
case "--lng":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --lng requires a value\n")os.Exit(1)}v, err := strconv.ParseFloat(args[i+1], 64)if err != nil {fmt.Fprintf(os.Stderr, "Error: --lng must be a number\n")os.Exit(1)}lng = vlngSet = true
case "--goto":a.gotoFile = a.requireValue(args, i, "--goto")
if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --timezone requires a value\n")os.Exit(1)}timezone = args[i+1]
a.timezone = a.requireValue(args, i, "--timezone")i += 2case "--lat":a.lat = a.requireFloat(args, i, "--lat")a.latSet = truei += 2case "--lng":a.lng = a.requireFloat(args, i, "--lng")a.lngSet = true
case "--sample":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --sample requires a value\n")os.Exit(1)}v, err := strconv.Atoi(args[i+1])if err != nil {fmt.Fprintf(os.Stderr, "Error: --sample must be an integer\n")os.Exit(1)}if v <= 0 || v > 100 {fmt.Fprintf(os.Stderr, "Error: --sample must be between 1 and 100\n")os.Exit(1)}sample = vi += 2case "--goto":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --goto requires a value\n")os.Exit(1)}gotoFile = args[i+1]i += 2
// --sample 1-99 requires --certainty; --sample 100 is a no-opif sample > 0 && sample < 100 && certainty < 0 {fmt.Fprintf(os.Stderr, "Error: --sample requires --certainty to be set\n")
return a}// requireValue returns the next argument or exits if missing.func (classifyArgs) requireValue(args []string, i int, flag string) string {if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: %s requires a value\n", flag)
// Validate required flagsif folder == "" && file == "" {fmt.Fprintf(os.Stderr, "Error: missing required flag: --folder or --file\n\n")printClassifyUsage()
// requireUniqueValue is like requireValue but exits if the flag was already set.func (a classifyArgs) requireUniqueValue(args []string, i int, flag, current string) string {if current != "" {fmt.Fprintf(os.Stderr, "Error: %s can only be specified once\n", flag)
if night && day {fmt.Fprintf(os.Stderr, "Error: --night and --day are mutually exclusive\n\n")printClassifyUsage()
// requireIntRange parses the next arg as int and validates range [lo,hi].func (a classifyArgs) requireIntRange(args []string, i int, flag string, lo, hi int) int {val := a.requireValue(args, i, flag)v, err := strconv.Atoi(val)if err != nil {fmt.Fprintf(os.Stderr, "Error: %s must be an integer\n", flag)
if (night || day) && (!latSet || !lngSet) {fmt.Fprintf(os.Stderr, "Error: --night/--day requires both --lat and --lng\n\n")printClassifyUsage()
if v < lo || v > hi {fmt.Fprintf(os.Stderr, "Error: %s must be between %d and %d\n", flag, lo, hi)
// Load reviewer, bindings, and display flags from ~/.skraak/config.json.cfg, cfgPath, err := utils.LoadConfig()
// requireFloat parses the next arg as float64.func (a classifyArgs) requireFloat(args []string, i int, flag string) float64 {val := a.requireValue(args, i, flag)v, err := strconv.ParseFloat(val, 64)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)fmt.Fprintf(os.Stderr, "Create %s with a \"classify\" section; run `skraak calls classify --help` for an example.\n", cfgPath)
fmt.Fprintf(os.Stderr, "Error: %s must be a number\n", flag)
// Validate config contentsif cfg.Classify.Reviewer == "" {fmt.Fprintf(os.Stderr, "Error: %s is missing \"classify.reviewer\"\n", cfgPath)
// validate checks cross-flag constraints after parsing.func (a classifyArgs) validate() {if a.sample > 0 && a.sample < 100 && a.certainty < 0 {fmt.Fprintf(os.Stderr, "Error: --sample requires --certainty to be set\n")os.Exit(1)}if a.folder == "" && a.file == "" {fmt.Fprintf(os.Stderr, "Error: missing required flag: --folder or --file\n\n")printClassifyUsage()
if len(cfg.Classify.Bindings) == 0 {fmt.Fprintf(os.Stderr, "Error: %s is missing \"classify.bindings\" (need at least one key)\n", cfgPath)
if a.night && a.day {fmt.Fprintf(os.Stderr, "Error: --night and --day are mutually exclusive\n\n")printClassifyUsage()os.Exit(1)}if (a.night || a.day) && (!a.latSet || !a.lngSet) {fmt.Fprintf(os.Stderr, "Error: --night/--day requires both --lat and --lng\n\n")printClassifyUsage()
}return bindings}// RunCallsClassify handles the "calls classify" subcommandfunc RunCallsClassify(args []string) {a := parseClassifyArgs(args)a.validate()// Load reviewer, bindings, and display flags from ~/.skraak/config.json.cfg, cfgPath, err := utils.LoadConfig()if err != nil {fmt.Fprintf(os.Stderr, "Error: %v\n", err)fmt.Fprintf(os.Stderr, "Create %s with a \"classify\" section; run `skraak calls classify --help` for an example.\n", cfgPath)os.Exit(1)
// Validate config contentsif cfg.Classify.Reviewer == "" {fmt.Fprintf(os.Stderr, "Error: %s is missing \"classify.reviewer\"\n", cfgPath)os.Exit(1)}if len(cfg.Classify.Bindings) == 0 {fmt.Fprintf(os.Stderr, "Error: %s is missing \"classify.bindings\" (need at least one key)\n", cfgPath)os.Exit(1)}bindings := validateBindings(&cfg, cfgPath)
The MIT License (MIT)Copyright © 2026 David CaryPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.