-
-
Notifications
You must be signed in to change notification settings - Fork 2
preset: eject -o writes the recipe to a file, and a UTF-16 recipe says why it is refused #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,9 +10,11 @@ package cli | |
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "flag" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "strings" | ||
|
|
||
| "github.com/donislawdev/TestingFilesGenerator/internal/core" | ||
|
|
@@ -51,7 +53,7 @@ func presetUsage(w io.Writer) { | |
| Usage: | ||
| tfg preset list what this build offers | ||
| tfg preset show <id> what it takes and what it would produce | ||
| tfg preset eject <id> > my.yaml the recipe it stands for, to edit | ||
| tfg preset eject <id> -o my.yaml the recipe it stands for, to edit | ||
|
|
||
| A preset is a recipe with a name. Ejecting one gives back an ordinary recipe | ||
| file, so nothing here is a closed box. | ||
|
|
@@ -65,10 +67,12 @@ Run "tfg generate --preset <id>" to produce the files. | |
| // The id has to be read before parsing, because the parameters of the preset | ||
| // are flags and there is no way to register them until it is known which they | ||
| // are. | ||
| // asJSON is filled in for the operations that have a machine readable form and | ||
| // nil for the one that does not - a recipe is already machine readable, and a | ||
| // second encoding of it would be a second thing to keep in step. | ||
| func presetFlagSet(name string, args []string, out, errOut io.Writer, usage func(io.Writer), asJSON *bool) ( | ||
| // | ||
| // own registers the operation's own flags: --json for show, which has a | ||
| // machine readable form, and -o for eject, which writes a file. Eject has no | ||
| // --json - a recipe is already machine readable, and a second encoding of it | ||
| // would be a second thing to keep in step. | ||
| func presetFlagSet(name string, args []string, out, errOut io.Writer, usage func(io.Writer), own func(*flag.FlagSet)) ( | ||
| *preset.Expansion, int) { | ||
|
|
||
| fs := flag.NewFlagSet("preset "+name, flag.ContinueOnError) | ||
|
|
@@ -80,9 +84,7 @@ func presetFlagSet(name string, args []string, out, errOut io.Writer, usage func | |
| } | ||
| // Registered before the parameters, so a preset declaring one called json | ||
| // is caught by the collision check rather than by the flag package panicking. | ||
| if asJSON != nil { | ||
| fs.BoolVar(asJSON, "json", false, "write the answer as JSON to standard output") | ||
| } | ||
| own(fs) | ||
|
|
||
| id, rest := splitLeadingPath(args) | ||
| if id == "" { | ||
|
|
@@ -189,7 +191,9 @@ Usage: | |
| `) | ||
| } | ||
| var asJSON bool | ||
| expanded, code := presetFlagSet("show", args, out, errOut, usage, &asJSON) | ||
| expanded, code := presetFlagSet("show", args, out, errOut, usage, func(fs *flag.FlagSet) { | ||
| fs.BoolVar(&asJSON, "json", false, "write the answer as JSON to standard output") | ||
| }) | ||
| if expanded == nil { | ||
| return code | ||
| } | ||
|
|
@@ -256,26 +260,93 @@ func presetEject(args []string, out, errOut io.Writer) int { | |
| Prints an ordinary recipe file. Edit it, commit it, run it with tfg generate - | ||
| from here on it is yours and nothing about it is special. | ||
|
|
||
| The recipe goes to standard output and everything else to standard error, so | ||
| "tfg preset eject size-boundaries > my.yaml" gives a clean file. | ||
| With -o the recipe is written to that file, byte for byte what would have been | ||
| printed. A file already at that name is refused and left as it is, because it | ||
| may be a recipe somebody ejected and then edited. | ||
|
|
||
| Without -o the recipe goes to standard output and everything else to standard | ||
| error, so "tfg preset eject size-boundaries > my.yaml" gives a clean file in | ||
| cmd, bash and PowerShell 7. Windows PowerShell 5.1 saves it as UTF-16, which | ||
| tfg refuses to read, so use -o there. | ||
|
|
||
| Usage: | ||
| tfg preset eject size-boundaries -o my.yaml | ||
| tfg preset eject size-boundaries --limit 20mb --format png -o my.yaml | ||
| tfg preset eject size-boundaries > my.yaml | ||
| tfg preset eject size-boundaries --limit 20mb --format png > my.yaml | ||
| `) | ||
| } | ||
| expanded, code := presetFlagSet("eject", args, out, errOut, usage, nil) | ||
| var to fileFlag | ||
| expanded, code := presetFlagSet("eject", args, out, errOut, usage, func(fs *flag.FlagSet) { | ||
| fs.Var(&to, "o", "write the recipe to this file rather than to standard output. A file already there is refused") | ||
| }) | ||
| if expanded == nil { | ||
| return code | ||
| } | ||
| if to.set && (to.name == "" || to.name == "-") { | ||
| fmt.Fprintf(errOut, "tfg: -o takes the name of the file to write the recipe to, and %q is not one. Leave -o out and the recipe goes to standard output.\n", to.name) | ||
| return ExitUsage | ||
| } | ||
|
|
||
| // The note goes to the error channel. The recipe is the data here, and a | ||
| // sentence about a number we chose has no business inside a file somebody | ||
| // is about to commit. | ||
| sayNotes(expanded.Notes(), errOut) | ||
| if to.set { | ||
| return writeEjected(to.name, expanded.Source, errOut) | ||
| } | ||
| if _, err := out.Write(expanded.Source); err != nil { | ||
| fmt.Fprintf(errOut, "tfg: cannot write the recipe: %s\n", describeError(err)) | ||
| return ExitIO | ||
| } | ||
| return ExitOK | ||
| } | ||
|
|
||
| // fileFlag is a file name given as a flag, and whether it was given at all. An | ||
| // empty name typed on purpose is a mistake to report rather than the default. | ||
| type fileFlag struct { | ||
| name string | ||
| set bool | ||
| } | ||
|
|
||
| func (f *fileFlag) String() string { return f.name } | ||
|
|
||
| func (f *fileFlag) Set(s string) error { | ||
| f.name, f.set = s, true | ||
| return nil | ||
| } | ||
|
|
||
| // writeEjected puts the recipe into a file nobody holds. | ||
| // | ||
| // Written by the tool rather than left to the shell because of O245, measured | ||
| // on 2026-09-25: Windows PowerShell 5.1 saves "> my.yaml" as UTF-16, and every | ||
| // way through PowerShell - Out-File included - first decodes the output with | ||
| // the console's code page, which on a stock console changes every letter | ||
| // outside ASCII. Only the bytes the tool writes itself arrive as they are. | ||
| // | ||
| // Claimed first and then replaced whole. The claim is exclusive and does not | ||
| // follow a link (core.CreateNew), which is what keeps an edited recipe from | ||
| // being written over. The replacement goes through a temporary name and a | ||
| // rename (core.ReplaceFile), so a run stopped part way leaves an empty file or | ||
| // none rather than a recipe cut short - and a YAML file cut short can still | ||
| // read as a smaller recipe. | ||
| func writeEjected(path string, source []byte, errOut io.Writer) int { | ||
| f, err := core.CreateNew(path, 0o644) | ||
| if err != nil { | ||
| var taken *core.NameTakenError | ||
| if errors.As(err, &taken) { | ||
| fmt.Fprintf(errOut, "tfg: %s is already there, and -o does not write over a file - it may be a recipe somebody edited. Nothing was written. Choose another name, or remove that file first.\n", core.Shown(path)) | ||
| return ExitIO | ||
| } | ||
| fmt.Fprintf(errOut, "tfg: cannot write the recipe to %s: %s\n", core.Shown(path), describeError(err)) | ||
| return ExitIO | ||
| } | ||
| _ = f.Close() | ||
| if err := core.ReplaceFile(path, source); err != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Do not replace a destination that changed after the claim.
🤖 Prompt for AI AgentsSource: Path instructions |
||
| // Only the empty claim this call made is there to take back. | ||
| _ = os.Remove(path) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Remove the claim only if it is still yours. If replacement fails after another process has replaced the empty claim, 🤖 Prompt for AI AgentsSource: Path instructions |
||
| fmt.Fprintf(errOut, "tfg: cannot write the recipe to %s: %s\n", core.Shown(path), describeError(err)) | ||
| return ExitIO | ||
| } | ||
| fmt.Fprintf(errOut, "recipe: %s\n", core.Shown(path)) | ||
| return ExitOK | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| package guard | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/binary" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
| "unicode/utf16" | ||
|
|
||
| "github.com/donislawdev/TestingFilesGenerator/internal/cli" | ||
| "github.com/donislawdev/TestingFilesGenerator/internal/core" | ||
| ) | ||
|
|
||
| // O245, measured on 2026-09-25: the help of "tfg preset eject" said "> my.yaml", | ||
| // and Windows PowerShell 5.1 saves that as UTF-16, which the tool then refused. | ||
| // Every other way through PowerShell 5.1 - Out-File included - decodes the | ||
| // output with the console's code page first, so on a stock console the letters | ||
| // outside ASCII were changed before anything reached the file. The answer is | ||
| // that the tool writes the file itself, and that the refusal of a UTF-16 file | ||
| // says where it came from. docs/O245-EJECT-2026-09-25.md. | ||
|
|
||
| // ejectedPreset has names in Polish, Korean and emoji, so a byte that moved on | ||
| // the way into the file has letters to move. | ||
| const ejectedPreset = "filename-handling" | ||
|
|
||
| // The file -o writes is byte for byte what eject would have printed, and | ||
| // nothing goes to standard output. | ||
| func TestEjectWritesTheFileByteForByteWhatItWouldPrint(t *testing.T) { | ||
| code, printed, errOut := run(t, "preset", "eject", ejectedPreset) | ||
| if code != cli.ExitOK || !bytes.ContainsFunc([]byte(printed), func(r rune) bool { return r > 0x7f }) { | ||
| t.Fatalf("eject to standard output ended %d with %d bytes, none outside ASCII, so this guard compares nothing worth comparing: %s", | ||
| code, len(printed), errOut) | ||
| } | ||
| path := filepath.Join(t.TempDir(), "my.yaml") | ||
| code, out, errOut := run(t, "preset", "eject", ejectedPreset, "-o", path) | ||
| if code != cli.ExitOK { | ||
| t.Fatalf("eject -o ended %d: %s", code, errOut) | ||
| } | ||
| if out != "" { | ||
| t.Errorf("eject -o put %d bytes on standard output as well as into the file", len(out)) | ||
| } | ||
| if !strings.Contains(errOut, "recipe: "+path) { | ||
| t.Errorf("eject -o does not say where the recipe went:\n%s", errOut) | ||
| } | ||
| written, err := os.ReadFile(path) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if !bytes.Equal(written, []byte(printed)) { | ||
| t.Errorf("the file holds %d bytes and eject prints %d - they differ, so the file is not the recipe PR5 promises", | ||
| len(written), len(printed)) | ||
| } | ||
| } | ||
|
|
||
| // A file already at the name is refused and left exactly as it was - it may | ||
| // be a recipe somebody ejected and then edited. Nothing else is left behind, | ||
| // and a directory that is not there is refused the same way. | ||
| func TestEjectLeavesAFileAlreadyThereAsItIs(t *testing.T) { | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "my.yaml") | ||
| edited := []byte("# somebody's edits\n") | ||
| if err := os.WriteFile(path, edited, 0o644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| code, _, errOut := run(t, "preset", "eject", ejectedPreset, "-o", path) | ||
| if code != cli.ExitIO { | ||
| t.Errorf("eject -o over a file ended %d rather than %d: %s", code, cli.ExitIO, errOut) | ||
| } | ||
| if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, edited) { | ||
| t.Errorf("eject -o changed a file that was already there: %q, %v", got, err) | ||
| } | ||
| if left := namesIn(t, dir); len(left) != 1 { | ||
| t.Errorf("the refused eject left %v in the directory", left) | ||
| } | ||
|
|
||
| missing := filepath.Join(dir, "not-there", "my.yaml") | ||
| if code, _, errOut := run(t, "preset", "eject", ejectedPreset, "-o", missing); code != cli.ExitIO { | ||
| t.Errorf("eject -o into a directory that is not there ended %d rather than %d: %s", code, cli.ExitIO, errOut) | ||
| } | ||
| } | ||
|
|
||
| // A write that fails after the name was claimed takes the claim back, so no | ||
| // empty my.yaml is left for somebody to run and wonder at. | ||
| func TestEjectThatCannotWriteLeavesNoEmptyFile(t *testing.T) { | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "my.yaml") | ||
| // A directory where the recipe is written first, under its temporary | ||
| // name, so the write fails after the claim and before the rename. | ||
| if err := os.MkdirAll(core.SiblingPath(path, core.WritingMarker), 0o755); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| code, _, errOut := run(t, "preset", "eject", ejectedPreset, "-o", path) | ||
| if code != cli.ExitIO { | ||
| t.Errorf("an eject that could not write ended %d rather than %d: %s", code, cli.ExitIO, errOut) | ||
| } | ||
| if _, err := os.Lstat(path); err == nil { | ||
| t.Error("the eject that could not write left the claimed name behind as an empty file") | ||
| } | ||
| } | ||
|
|
||
| // An empty name and "-" are refused as usage, and nothing is written - "-" is | ||
| // not standard output here, leaving -o out is. | ||
| func TestEjectRefusesAFileNameThatIsNotOne(t *testing.T) { | ||
| dir := t.TempDir() | ||
| t.Chdir(dir) | ||
| for _, name := range []string{"", "-"} { | ||
| code, out, errOut := run(t, "preset", "eject", ejectedPreset, "-o", name) | ||
| if code != cli.ExitUsage || out != "" { | ||
| t.Errorf("eject -o %q ended %d with %d bytes on standard output: %s", name, code, len(out), errOut) | ||
| } | ||
| } | ||
| if left := namesIn(t, dir); len(left) != 0 { | ||
| t.Errorf("a refused eject wrote %v", left) | ||
| } | ||
| } | ||
|
|
||
| // A recipe in UTF-16 is refused, in either byte order, and the refusal says | ||
| // where such a file comes from and what to do instead. The general refusal of | ||
| // a file that is not UTF-8 says none of that. | ||
| func TestARecipeSavedAsUTF16IsRefusedAndSaysWhere(t *testing.T) { | ||
| _, printed, _ := run(t, "preset", "eject", ejectedPreset) | ||
| units := utf16.Encode([]rune(printed)) | ||
| for _, order := range []struct { | ||
| name string | ||
| mark []byte | ||
| as binary.AppendByteOrder | ||
| }{ | ||
| {"little endian, as PowerShell 5.1 writes it", []byte{0xff, 0xfe}, binary.LittleEndian}, | ||
| {"big endian", []byte{0xfe, 0xff}, binary.BigEndian}, | ||
| } { | ||
| body := append([]byte{}, order.mark...) | ||
| for _, u := range units { | ||
| body = order.as.AppendUint16(body, u) | ||
| } | ||
| path := filepath.Join(t.TempDir(), "my.yaml") | ||
| if err := os.WriteFile(path, body, 0o644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| code, _, errOut := run(t, "validate", path) | ||
| if code != cli.ExitRecipe { | ||
| t.Errorf("%s: a UTF-16 recipe ended %d rather than %d", order.name, code, cli.ExitRecipe) | ||
| } | ||
| for _, want := range []string{"UTF-16", "PowerShell 5.1", "-o my.yaml"} { | ||
| if !strings.Contains(errOut, want) { | ||
| t.Errorf("%s: the refusal does not say %q:\n%s", order.name, want, errOut) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| cp1250 := filepath.Join(t.TempDir(), "r.yaml") | ||
| body := append([]byte("version: 1\ntargets:\n - id: t\n format: txt\n size: 1kb\n name: za"), 0xbf, 0xf3, 0xb3, 0xe6) | ||
| if err := os.WriteFile(cp1250, append(body, ".txt\n"...), 0o644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if _, _, errOut := run(t, "validate", cp1250); strings.Contains(errOut, "UTF-16") { | ||
| t.Errorf("a cp1250 recipe is refused as UTF-16:\n%s", errOut) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Require exclusive creation throughout the destination claim.
core.CreateNewretries withO_CREATE|O_TRUNCif exclusive creation fails andLstatthen finds no entry. If another process creates the destination afterLstat, that retry truncates its file. Use a claim operation that fails rather than falling back to truncation when exclusive creation cannot be guaranteed. As per path instructions, “Do not overwrite existing files or delete files a manifest does not list.”🤖 Prompt for AI Agents
Source: Path instructions