Skip to content
Merged
13 changes: 9 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,15 @@ because it turns other people's test suites red.
lets you choose another language. The choice takes effect the next time
the window opens, and Restart now does that at once. It is off while files
are being made, and anything typed on the other tabs is cleared. In Polish
the settings of every format, the presets and the sizes of a run are Polish
as well, so a size reads 10,0 MB. The values you pick from a list
(portrait, a4), the names of the formats and the reason a run is refused
stay in English, and so do the command line, recipes and manifests. The
everything the window says is Polish: the settings of every format, the
values you pick from a list (pionowo for portrait), the names of the
formats, the titles of the presets, the reason a run is refused, the time
left, the licence on About and the words of the menus and dialogs, such as
Copy and Cancel. A size reads 10,0 MB. A value you typed is shown as you
typed it, and so are values that are names, such as a4 or utf-8. The
command line, recipes and manifests stay in English, so a recipe written
from the window still says portrait. A fault in the program itself is said
in Polish with its English detail under it, for whoever reports it. The
same tab says what the window keeps between runs (the output directory,
the window size and the language), shows the folder they are kept in, and
Forget clears them. `tfg-gui --pseudo-language` opens the window with
Expand Down
13 changes: 7 additions & 6 deletions internal/cli/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -106,14 +105,16 @@ func inOurWords(err error) string {
// systemReason is our own English sentence for a system error, with the number
// beside it. The number is the part that survives translation.
func systemReason(errno syscall.Errno) string {
reason := "the system refused it"
switch {
case errors.Is(errno, fs.ErrNotExist):
var reason string
switch core.SystemKindOf(errno) {
case core.SystemNothingThere:
reason = "there is nothing at that path"
case errors.Is(errno, fs.ErrPermission):
case core.SystemNoPermission:
reason = "the system refused permission"
case errors.Is(errno, fs.ErrExist):
case core.SystemAlreadyThere:
reason = "something is already there"
default:
reason = "the system refused it"
}
return fmt.Sprintf("%s (system error %d)", reason, uintptr(errno))
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/recipecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*preset.Re
var invalid *recipe.ValidationError
if errors.As(err, &invalid) {
for _, p := range invalid.Problems {
report.Problems = append(report.Problems, validateProblem{What: p.What, Why: p.Why, Fix: p.Fix, At: p.At})
report.Problems = append(report.Problems, validateProblem{What: p.What.String(), Why: p.Why.String(), Fix: p.Fix.String(), At: p.At})
}
} else {
report.Problems = append(report.Problems, validateProblem{What: err.Error()})
Expand Down
27 changes: 24 additions & 3 deletions internal/core/humanise.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,33 @@ func Noun(n int, one, many string) string {
// Roughly keeps an estimate at the precision it deserves. Seconds on a two
// minute estimate are noise that changes every redraw.
func Roughly(d time.Duration) string {
r := Rough(d)
switch r.Unit {
case 's':
return fmt.Sprintf("%ds", r.Seconds)
case 'm':
return fmt.Sprintf("%dm", r.Minutes)
default:
return fmt.Sprintf("%dh%dm", r.Hours, r.Minutes)
}
}

// RoughTime is Roughly in parts, for a window that writes the units in its own
// language: Unit is 's' for seconds alone, 'm' for minutes alone and 'h' for
// hours with minutes. One arithmetic, so the two surfaces round alike.
type RoughTime struct {
Unit rune
Hours, Minutes, Seconds int
}

// Rough is an estimate at the precision Roughly gives it, in parts.
func Rough(d time.Duration) RoughTime {
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds())+1)
return RoughTime{Unit: 's', Seconds: int(d.Seconds()) + 1}
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes())+1)
return RoughTime{Unit: 'm', Minutes: int(d.Minutes()) + 1}
default:
return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
return RoughTime{Unit: 'h', Hours: int(d.Hours()), Minutes: int(d.Minutes()) % 60}
}
}
24 changes: 11 additions & 13 deletions internal/core/limits.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package core

import (
"errors"
"fmt"
"math"
"strings"
)
Expand Down Expand Up @@ -40,7 +38,7 @@ const MaxFilesPerRun = 1_000_000
//
// It carries the four parts every refusal in this tool carries, so both callers
// report it in the same words rather than each phrasing it again.
var ErrTooManyFiles = errors.New(TooManyFilesWhy + ". " + TooManyFilesFix)
var ErrTooManyFiles = Refuse(Says("core.TooManyFiles", "%s. %s", A("Why", TooManyFilesWhy), A("Fix", TooManyFilesFix)))

// The same refusal in the two parts a report keeps apart. ErrTooManyFiles is
// built from them rather than beside them, so the sentence and the parts cannot
Expand All @@ -52,10 +50,10 @@ var (
// the sentence saying the old one, which is a refusal that lies about its
// own rule. A variable rather than a constant because Sprintf is not a
// constant expression, and that is the whole cost.
TooManyFilesWhy = fmt.Sprintf(
TooManyFilesWhy = Says("core.TooManyFilesWhy",
"this build plans at most %d files in one run, because the whole plan is worked out in memory before anything is written - "+
"that is what lets a run that cannot succeed be refused before the first byte", MaxFilesPerRun)
TooManyFilesFix = "Ask for fewer files, or split the work into several runs"
"that is what lets a run that cannot succeed be refused before the first byte", A("Most", MaxFilesPerRun))
TooManyFilesFix = Says("core.TooManyFilesFix", "Ask for fewer files, or split the work into several runs")

// The same refusal for the quantity that actually runs out.
//
Expand All @@ -65,11 +63,11 @@ var (
// thousand pages - so the file ceiling lets a run ask for about 52 GB of
// plan while every number in it is legal. The sentence says memory rather
// than files because that is what the person has to change.
PlanTooLargeWhy = fmt.Sprintf(
PlanTooLargeWhy = Says("core.PlanTooLargeWhy",
"the whole plan is held in memory before anything is written, and this build works to a ceiling of %s for it - "+
"how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this",
HumanBytes(MaxPlanBytes))
PlanTooLargeFix = "Ask for fewer files, or make each one cheaper to plan - fewer pages, fewer entries - or split the work into several runs"
A("Ceiling", Bytes(MaxPlanBytes)))
PlanTooLargeFix = Says("core.PlanTooLargeFix", "Ask for fewer files, or make each one cheaper to plan - fewer pages, fewer entries - or split the work into several runs")
)

// MaxPlanBytes is the most memory this build will let a plan take.
Expand Down Expand Up @@ -201,12 +199,12 @@ func IsRunLockName(name string) bool {
// code 0, which is the guard being satisfied rather than skipped.
func AddSizes(total, size int64) (int64, error) {
if size < 0 {
return 0, errors.New("a file cannot be smaller than zero bytes")
return 0, Refuse(Says("core.SizeBelowZero", "a file cannot be smaller than zero bytes"))
}
if total > math.MaxInt64-size {
return 0, errors.New(
"the sizes in this run add up to more than a number of bytes can hold, so the total cannot be measured and the free space check cannot be trusted. " +
"Ask for fewer files or smaller ones")
return 0, Refuse(Says("core.SizesOverflow",
"the sizes in this run add up to more than a number of bytes can hold, so the total cannot be measured and the free space check cannot be trusted. "+
"Ask for fewer files or smaller ones"))
}
return total + size, nil
}
6 changes: 3 additions & 3 deletions internal/core/records.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ type Record interface {
func FillRecords(ctx context.Context, w io.Writer, rng *rand.Rand, remaining int64, rec Record) error {
shortest := rec.Shortest()
if remaining < shortest {
return fmt.Errorf("core: %d B are owed and the shortest whole record needs %d B", remaining, shortest)
return Defect(fmt.Errorf("core: %d B are owed and the shortest whole record needs %d B", remaining, shortest))
}

// One buffer, reused. A record built into its own allocation costs a
Expand All @@ -90,7 +90,7 @@ func FillRecords(ctx context.Context, w io.Writer, rng *rand.Rand, remaining int
// today and the interface does not forbid it, so it is named here
// rather than left to a hang somebody has to interrupt.
if len(buf) == mark {
return fmt.Errorf("core: a record of this format added no bytes, so the file could never be filled")
return Defect(fmt.Errorf("core: a record of this format added no bytes, so the file could never be filled"))
}
// Stop while what is left still fits a whole closing record. That is
// what keeps the last one from being a stub.
Expand All @@ -115,7 +115,7 @@ func FillRecords(ctx context.Context, w io.Writer, rng *rand.Rand, remaining int
mark := len(buf)
buf = rec.AppendExact(buf, rng, remaining)
if got := int64(len(buf) - mark); got != remaining {
return fmt.Errorf("core: the closing record came to %d B and %d B were owed", got, remaining)
return Defect(fmt.Errorf("core: the closing record came to %d B and %d B were owed", got, remaining))
}
return WriteAll(w, buf)
}
Expand Down
139 changes: 139 additions & 0 deletions internal/core/refusal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package core

import "errors"

// Refusal is a refusal that is one sentence and nothing else - the shape every
// fmt.Errorf in the engine had before refusals were said as data.
//
// It answers the three questions the surfaces ask of any refusal: what it says
// (Error, for the command line), what it says in parts (Said, for a window that
// speaks another language) and which setting it is about (AboutSetting, for a
// window that marks a box). What it wraps stays reachable through Unwrap, so a
// cause that decides the exit code is still found by errors.As.
type Refusal struct {
said Said
setting string
}

// Refuse is a refusal about the run as a whole.
func Refuse(s Said) error { return &Refusal{said: s} }

// RefuseAbout is a refusal about one setting, named by its recipe key. The
// layout may name the setting through SettingSlot, and each surface puts its
// own name there - see setting.go.
func RefuseAbout(setting string, s Said) error { return &Refusal{said: s, setting: setting} }

// Error is the sentence the command line prints.
func (r *Refusal) Error() string { return r.InTheWordsOf(r.setting) }

// InTheWordsOf is the sentence with the setting named the way one surface names
// it. The slots are filled in the layout before the values go in, so a value
// somebody typed as "{setting}" is printed as typed rather than read as a slot.
func (r *Refusal) InTheWordsOf(name string) string {
if name == "" {
name = r.setting
}
return r.said.InTheWordsOf(name)
}

// Said is the sentence in parts.
func (r *Refusal) Said() Said { return r.said }

// AboutSetting is the recipe key this refusal is about, or empty.
//
// A refusal about no setting of its own that wraps one about a setting answers
// for the one it wraps - the way fmt.Errorf did, which has no such method and
// so let errors.As find the one inside. Answering empty would put the refusal
// at the foot of the form, away from the box it is about.
func (r *Refusal) AboutSetting() string {
if r.setting != "" {
return r.setting
}
for _, w := range r.said.wrapped() {
var about interface{ AboutSetting() string }
if errors.As(w, &about) {
return about.AboutSetting()
}
}
return ""
}

// Unwrap is the error the layout wraps with %w, or nil - one at most, the way
// fmt.Errorf with one %w gives one. Not every error it wraps: a window opens
// an error that unwraps to several into separate refusals, one per box
// (spread in internal/gui/window), and this sentence is one refusal whatever
// it carries. It unwrapped to a list until 2026-10-06, and a refusal wrapping
// nothing spread into no refusal at all, so a box typed wrong stayed unmarked.
func (r *Refusal) Unwrap() error {
if wrapped := r.said.wrapped(); len(wrapped) > 0 {
return wrapped[0]
}
return nil
}

// InTheWordsOf is String with the setting slots of the layout filled first, so
// a value somebody typed as "{setting}" is printed as typed rather than read as
// a slot.
func (s Said) InTheWordsOf(name string) string {
if name == "" {
return s.String()
}
filled := s
filled.layout = InTheWordsOf(s.layout, name)
if s.one != "" {
filled.one = InTheWordsOf(s.one, name)
}
return filled.String()
}

// wrapped is every value the layout takes with %w.
func (s Said) wrapped() []error {
var out []error
for i, d := range directivesOf(s.Layout()) {
if i >= len(s.args) || d[len(d)-1] != 'w' {
continue
}
if err, ok := s.args[i].Value.(error); ok {
out = append(out, err)
}
}
return out
}

// SaidOf is the sentence an error says: its own, when it is one of ours and
// the whole of what it says, and otherwise the error itself as the one value
// of a sentence - which a window words as an error of the system, of a library
// or of the program. Either way the English is the error's text to the byte.
func SaidOf(err error) Said {
var said interface{ Said() Said }
if errors.As(err, &said) && said.Said().String() == err.Error() {
return said.Said()
}
return Says("core.Error", "%v", A("Error", err))
}

// DefectError is an error that says the program is wrong rather than the
// request: a plan this generator did not make, an encoder breaking its own
// contract. Nobody can correct it from a form, so a window says that in its
// own words and shows this text as it came, for whoever reports it - the
// owner's decision of 2026-10-05.
type DefectError struct {
Err error
}

// Defect marks an error as one only a fault in the program can produce.
func Defect(err error) error {
if err == nil {
return nil
}
return &DefectError{Err: err}
}

func (d *DefectError) Error() string { return d.Err.Error() }
func (d *DefectError) Unwrap() error { return d.Err }

// IsDefect is whether an error, anywhere in its chain, is a defect.
func IsDefect(err error) bool {
var d *DefectError
return errors.As(err, &d)
}
Loading
Loading