diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e557b..ebe40cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in - `demo/handbuch/`: die Beispiele aus dem Lehrbuchteil (Kapitel 2, 3, 6, 7, 10, 11, 15) mit ihren Tabellen. `beispiele.txt` hält Befehl und gedruckte Ausgabe, `test/handbuch_test.rb` vergleicht sie; `mise run test` führt ihn mit aus. Druckfehler des Buchs und Stellen, an denen wir abweichen, sind dort vermerkt. ### Geändert +- `testall` und die Tests setzen `EDITOR=true`, damit kein echter Editor startet. - `search` und `seek` ohne Schlüsselspalte nehmen die Spaltennamen aus dem Kopf einer Schlüsseltabelle auf der Standardeingabe (`search -mh mailtable < name`, Handbuch S. 105). Vorher galt immer die erste Spalte der Datentabelle. - `gregorian -u` schreibt den Monat ohne führende Null (`6/01/89`), wie im Handbuch. - `mean` schreibt einen ganzzahligen Mittelwert ohne `.00` (`4786`, Handbuch S. 26). @@ -23,7 +24,15 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in - `vilock`: die Sperre wird atomar über einen Hardlink genommen (vorher `exist?` und `rename` mit Lücke dazwischen). ### Behoben -- `EDITOR` mit Argumenten (`nvim -f`) in `vilock` und `update.rdb` wurde als ein Programmname behandelt; der Editor startete nie. +- `EDITOR` mit Argumenten (`nvim -f`) in `vilock` und `update.rdb` wurde als ein Programmname behandelt; der Editor startete nie. Scheitert der Editor, meldet `update.rdb` das, statt „record replaced“ zu schreiben. +- `update.rdb e`: Der Satz blieb leer, wenn der Editor scheiterte oder abgebrochen wurde. Er wird jetzt in jedem Fall zurückgeschrieben. +- Spaltennamen mit Umlauten (`row 'Über > 3'`) scheiterten mit einem awk-Fehler. Der Ausdrucksumsetzer kennt jetzt Unicode-Buchstaben. +- Dateien, die kein UTF-8 sind (Latin-1 aus COHERENT-Zeiten), brachen mit `invalid byte sequence` ab. Sie werden als Latin-1 gelesen und als UTF-8 ausgegeben. +- Fehler wie ungültige Regex, ungültiges Datum oder Ein-/Ausgabefehler endeten mit einem Ruby-Backtrace. Jetzt eine Zeile; `RDB_DEBUG=1` zeigt den Backtrace. Ein geschlossenes Pipe-Ende (`| head`) und Ctrl-C beenden still. +- `append`, `enter`, `replace` und `ve` schrieben Werte mit Tab oder Zeilenumbruch und machten die Tabelle kaputt. Sie lehnen solche Werte jetzt ab. +- Tabellen, Indexdateien und `substitute` werden über eine temporäre Datei mit `rename` geschrieben; ein Abbruch lässt die alte Datei statt einer halben neuen. +- `index -mb` und `-mi` sortieren nach dem Schlüssel ohne umgebende Leerzeichen, so wie `search` vergleicht. +- `sorttable -r` kehrte auch die Reihenfolge gleicher Schlüssel um; sie bleibt jetzt erhalten. - Spaltennamen, die `justify` mit Leerzeichen aufgefüllt hat, wurden nicht mehr gefunden: `column Name` lieferte eine leere Spalte, `index` brach ab. Leerraum um die Namen im Kopf zählt jetzt nicht mehr. ## [0.7.0] - 2026-10-12 diff --git a/README.md b/README.md index 6ca8d18..9bf6465 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ sind nach dem Handbuch neu geschrieben. mise trust # einmal nach dem Clone: mise.toml freigeben mise install # Ruby 3.4 mise run install # legt die Symlinks row, column, total ... in bin/ an -mise run test # 114 Tests und die Beispiele aus dem Handbuch +mise run test # 126 Tests und die Beispiele aus dem Handbuch ``` Ohne `mise trust` brechen in einem frisch geklonten Ordner alle `mise`-Befehle ab. @@ -245,6 +245,9 @@ er nicht mehr passt (außer mit `-a`). Beide halten die Indizes aktuell. - Die Indexdateien sind dem Original nachgebaut, aber nicht gleich (Handbuch S. 103–108): die Hashtabelle `.h` hat 2n statt 2n+1 Plätze, und in `.i` steht der Schlüssel vor dem Offset statt dahinter. Alte /rdb-Indizes muss man neu bauen. +- Dateien, die kein UTF-8 sind, liest `rdb` als Latin-1 und schreibt sie als UTF-8. Fehler + erscheinen als eine Zeile; mit `RDB_DEBUG=1` kommt der Ruby-Backtrace. Werte mit Tab oder + Zeilenumbruch lehnen `append`, `enter`, `replace` und `ve` ab. - `lock`/`unlock` legen ihre Sperrdateien in `/tmp/rdb-locks-/` ab (Modus 0700, `flock`), nicht als `/tmp/L` wie in den Man-Pages. Eine Sperre eines abgestürzten Prozesses wird nicht erkannt; `unlock` hebt sie auf. diff --git a/doc/review.md b/doc/review.md index 48cc39f..fbcc704 100644 --- a/doc/review.md +++ b/doc/review.md @@ -27,7 +27,7 @@ Stand: 2026-10-30, Version 0.7.0. Die 114 Tests und der Handbuch-Test liefen gr Race zwischen `File.exist?` und `rename`; nach `kill -9` bleibt `LOCK…` liegen. **Behoben:** atomare Sperre über `File.link`. Offen: nach `kill -9` bleibt `LOCK…` liegen. -## Fehler +## Fehler (1–8 behoben, 9 teilweise) 1. **Umlaute in Spaltennamen (bestätigt).** `to_awk` erkennt nur `[A-Za-z_#]` und ASCII-`\w`. `row 'Über > 3'` scheitert mit awk-Fehler. Relevant für die @@ -62,6 +62,16 @@ Stand: 2026-10-30, Version 0.7.0. Die 114 Tests und der Handbuch-Test liefen gr ignoriert; `total`/`mean` lehnen `1e3` ab; eine Zeile aus lauter leeren Zellen gilt als gelöscht ([fast.rb:21](../lib/rdb/fast.rb)). +**Stand der Behebung:** 1 (Umlaute: Unicode-fähiger Umsetzer), 2 (Latin-1 wird als UTF-8 +gelesen), 3 (Fehler als eine Zeile, `RDB_DEBUG=1` für den Backtrace), 4 (`EDITOR` mit +Argumenten, Editorfehler wird gemeldet), 5 (Satz wird in jedem Fall zurückgeschrieben), +6 (`Rdb.check_cells!`), 7 (`Rdb.write_file`: temporäre Datei plus `rename`) und 8 +(Sortierschlüssel ohne Leerzeichen) sind behoben, jeweils mit Test. +Bei 9 ist nur `sorttable -r` behoben. Bewusst offen: `-nrf` gilt global (so beschreibt es +das Handbuch), `jointable -n` ist unklar spezifiziert, `1e3` als Zahl und die Zeile aus +leeren Zellen als „gelöscht“ sind Eigenheiten des Formats. Bei 8 bleibt `-x` (Faltung) beim +Index ohne Entsprechung in der Suche offen. + ## Verbesserungspotential 1. **Indexe sind nicht schnell.** Jede Suche liest Tabelle und Indexdatei @@ -88,5 +98,5 @@ Stand: 2026-10-30, Version 0.7.0. Die 114 Tests und der Handbuch-Test liefen gr ## Empfohlene Reihenfolge -Die Schwachstellen sind erledigt. Weiter mit: Umlaute (Fehler 1), Encoding (2), Top-Level-Rescue (3), `report`-Einschleusung -(Schwachstelle 1); danach Hash-Funktion und Index-Lesezugriffe. +Schwachstellen und Fehler sind erledigt. Weiter mit: Hash-Funktion und +Index-Lesezugriffe (Verbesserung 1 und 2), dann Float-Geld. diff --git a/lib/rdb.rb b/lib/rdb.rb index f3aac25..b8209af 100644 --- a/lib/rdb.rb +++ b/lib/rdb.rb @@ -46,9 +46,38 @@ module Rdb COMMANDS[name] = Command.new(name, cmd.desc, cmd.syntax.sub(target, name), cmd.block) end + # Write a file all at once: a temporary file next to it, then rename, so + # an interrupted run leaves the old file rather than half a new one. + def write_file(path, data) + path = File.realpath(path) if File.symlink?(path) + tmp = "#{path}.tmp#{Process.pid}" + File.binwrite(tmp, data) + File.chmod(File.stat(path).mode & 0o7777, tmp) if File.exist?(path) + File.rename(tmp, path) + ensure + File.delete(tmp) if tmp && File.exist?(tmp) + end + + # A value is one cell of a tab-separated line: no tab, no line break. + def check_cells!(cells) + bad = cells.find { |c| c.to_s.match?(/[\t\r\n]/) } + die "value #{bad.to_s.inspect} contains a tab or line break" if bad + end + + # Errors reach the user as one line; RDB_DEBUG=1 keeps the backtrace. def run(name, args) cmd = COMMANDS[name] or die("unknown command '#{name}'") Env.new(name).instance_exec(args.dup, &cmd.block) + rescue Errno::EPIPE + exit 0 + rescue Interrupt + exit 130 + rescue SystemExit + raise + rescue StandardError => e + raise if ENV['RDB_DEBUG'].to_s.match?(/\A(1|yes|true)\z/i) + + die("#{e.message} (#{e.class}; RDB_DEBUG=1 shows where)") end end end diff --git a/lib/rdb/cmd/access.rb b/lib/rdb/cmd/access.rb index fbca26f..66df065 100644 --- a/lib/rdb/cmd/access.rb +++ b/lib/rdb/cmd/access.rb @@ -259,7 +259,7 @@ Rdb.register('vilock', 'Lock a table before editing, unlock afterward', 'vilock end File.unlink(file) begin - system(*Shellwords.split(ENV['EDITOR'] || 'vi'), locked) + system(*Shellwords.split(ENV['EDITOR'] || 'vi'), locked) or warn "vilock: editor #{ENV['EDITOR'] || 'vi'} failed" ensure File.rename(locked, file) end @@ -276,7 +276,7 @@ def edit_cells(head, cells) Tempfile.create('rdb-edit') do |f| f.write Rdb::Table.new(head, [cells], list: true).to_s f.flush - system(*Shellwords.split(ENV['EDITOR'] || 'vi'), f.path) + system(*Shellwords.split(ENV['EDITOR'] || 'vi'), f.path) or Rdb.die("editor #{ENV['EDITOR'] || 'vi'} failed") t = Rdb::Table.parse(File.read(f.path)) return nil if t.rows.empty? @@ -319,20 +319,27 @@ Rdb.register('update.rdb', 'Display and edit records in any sized file', r = rows[cur] cells = r.cells + # Unless -l, the record is blanked while it is edited (that is the lock). + # Whatever happens, it must not stay blank: put it back unless replaced. Rdb::Fast.splice(table, r.from, r.to, Rdb::Fast.blanked(r.line) + "\n") unless o[:l] - new = edit_cells(data.head, cells.map(&:strip)) - if new - hit = Rdb::Fast::Hit.new(r, 0, 0) - xf, xt = 0, 0 - if Rdb::Fast::SUFFIX[o[:m]] - h = Rdb::Fast.search(Rdb::Fast::Data.new(table), o[:m], keycols, cells.values_at(*keycols.map { |c| data.column(c) }), first: true).first - xf, xt = h.xfrom, h.xto if h + replaced = false + begin + new = edit_cells(data.head, cells.map(&:strip)) + if new + hit = Rdb::Fast::Hit.new(r, 0, 0) + xf, xt = 0, 0 + if Rdb::Fast::SUFFIX[o[:m]] + h = Rdb::Fast.search(Rdb::Fast::Data.new(table), o[:m], keycols, cells.values_at(*keycols.map { |c| data.column(c) }), first: true).first + xf, xt = h.xfrom, h.xto if h + end + Rdb::Fast.replace_row(table, hit.row.from, hit.row.to, new, o[:m], xf, xt, keycols[0]) + replaced = true + puts 'record replaced' + else + puts 'unchanged' end - Rdb::Fast.replace_row(table, hit.row.from, hit.row.to, new, o[:m], xf, xt, keycols[0]) - puts 'record replaced' - else - Rdb::Fast.splice(table, r.from, r.to, r.line + "\n") - puts 'unchanged' + ensure + Rdb::Fast.splice(table, r.from, r.to, r.line + "\n") unless o[:l] || replaced end next when /\Ad/i @@ -393,7 +400,9 @@ Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [fil next puts('no such field') unless (0...t.head.size).cover?(f) print "#{t.head[f]}> " - rows[cur][f] = ($stdin.gets || '').chomp + value = ($stdin.gets || '').chomp + Rdb.check_cells!([value]) + rows[cur][f] = value File.write(file, t.to_s) when 'a' row = t.head.each_with_index.map do |h, i| @@ -404,6 +413,7 @@ Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [fil print "#{h}> " ($stdin.gets || '').chomp end + Rdb.check_cells!(row) rows << row File.write(file, t.to_s) cur = rows.size - 1 diff --git a/lib/rdb/cmd/relational.rb b/lib/rdb/cmd/relational.rb index 32c463e..6ce3a59 100644 --- a/lib/rdb/cmd/relational.rb +++ b/lib/rdb/cmd/relational.rb @@ -48,8 +48,8 @@ Rdb.register('sorttable', 'Sort a table by one or more columns', end cols = (0...t.head.size).to_a if cols.empty? key = ->(r) { cols.map { |i| numeric ? r[i].to_f : (fold ? r[i].downcase : r[i]) } } - sorted = t.rows.each_with_index.sort_by { |r, n| [key.(r), n] }.map(&:first) - sorted.reverse! if reverse + keyed = t.rows.each_with_index.map { |r, n| [key.(r), n, r] } + sorted = keyed.sort { |a, b| (reverse ? b[0] <=> a[0] : a[0] <=> b[0]).nonzero? || a[1] <=> b[1] }.map(&:last) t.with(rows: sorted).write end diff --git a/lib/rdb/cmd/text.rb b/lib/rdb/cmd/text.rb index 8115e65..b28e037 100644 --- a/lib/rdb/cmd/text.rb +++ b/lib/rdb/cmd/text.rb @@ -142,7 +142,7 @@ end Rdb.register('substitute', 'Replace old string with new string', 'substitute oldstring newstring file ...') do |args| die 'usage: substitute oldstring newstring file ...' if args.size < 3 old, new, *files = args - files.each { |f| File.write(f, File.read(f).gsub(old, new)) } + files.each { |f| Rdb.write_file(f, File.read(f).gsub(old, new)) } end Rdb.register('trimblank', 'Remove leading and trailing blanks from a string', 'trimblank [string] [< file]') do |args| diff --git a/lib/rdb/env.rb b/lib/rdb/env.rb index de3beac..55851ee 100644 --- a/lib/rdb/env.rb +++ b/lib/rdb/env.rb @@ -50,7 +50,7 @@ module Rdb def icase_column(names, expr, i) return unless Rdb.icase? - word = expr[i..][/\A\w+/].to_s + word = expr[i..][/\A[[:alnum:]_]+/].to_s return if word.empty? || AWK_WORDS.include?(word) || expr[i + word.size..].to_s.match?(/\A\s*\(/) hits = names.select { |n, _| n.casecmp?(word) } @@ -75,14 +75,14 @@ module Rdb j += expr[j] == '\\' ? 2 : 1 while j < expr.size && expr[j] != '/' out << expr[i..j] i = j + 1 - elsif c.match?(/[A-Za-z_#]/) && (i.zero? || !expr[i - 1].match?(/[\w$]/)) - hit = names.find { |n, _| !n.empty? && expr[i, n.size] == n && !expr[i + n.size].to_s.match?(/\w/) } + elsif c.match?(/[[:alpha:]_#]/) && (i.zero? || !expr[i - 1].match?(/[[:alnum:]_$]/)) + hit = names.find { |n, _| !n.empty? && expr[i, n.size] == n && !expr[i + n.size].to_s.match?(/[[:alnum:]_]/) } hit ||= icase_column(names, expr, i) if hit out << "$#{hit[1] + 1}" i += hit[0].size else - m = expr[i..][/\A\w+/] || c + m = expr[i..][/\A[[:alnum:]_]+/] || c out << m i += m.size end diff --git a/lib/rdb/fast.rb b/lib/rdb/fast.rb index 01ba370..dcdaa82 100644 --- a/lib/rdb/fast.rb +++ b/lib/rdb/fast.rb @@ -42,7 +42,7 @@ module Rdb off += l.bytesize end Rdb.die("#{path}: is a list; fast access needs a table") if bytes.start_with?("\n") - @head = lines.shift&.line.to_s.split("\t", -1).map(&:strip) + @head = lines.shift&.line.to_s.split("\t", -1).map { |h| h.dup.force_encoding(Encoding::UTF_8).strip } lines.shift if lines.first && Table.dash_line?(lines.first.cells) @rows = lines @by_from = lines.to_h { |r| [r.from, r] } @@ -95,22 +95,22 @@ module Rdb rows = live_rows(data) case method when 'r' - File.binwrite(path + '.r', FIXED_HEAD + rows.map { |r| fmt_entry(r.from) }.join) + Rdb.write_file(path + '.r', FIXED_HEAD + rows.map { |r| fmt_entry(r.from) }.join) when 'i' k = data.column(keycol) ents = rows.map { |r| [r.cells[k].to_s, r.from] } - ents = ents.sort_by { |key, off| [fold ? key.downcase : key, off] } - File.binwrite(path + '.i', "#{keycol}\tOffset\n#{'-' * keycol.size}\t------\n" + ents.map { |key, off| "#{key}\t#{off}\n" }.join) + ents = ents.sort_by { |key, off| [fold ? key.strip.downcase : key.strip, off] } + Rdb.write_file(path + '.i', "#{keycol}\tOffset\n#{'-' * keycol.size}\t------\n" + ents.map { |key, off| "#{key}\t#{off}\n" }.join) when 'h' k = data.column(keycol) slots = [rows.size * 2, 2].max table = Array.new(slots, 0) rows.each { |r| table[free_slot(table, hash_slot(slots, r.cells[k].to_s))] = r.from } - File.binwrite(path + '.h', FIXED_HEAD + table.map { |o| fmt_entry(o) }.join) + Rdb.write_file(path + '.h', FIXED_HEAD + table.map { |o| fmt_entry(o) }.join) when 'b' k = data.column(keycol) - body = live_rows(data).sort_by { |r| r.cells[k].to_s }.map { |r| r.line + "\n" } - File.binwrite(path, data.head.join("\t") + "\n" + Table.dash_row(data.head) + "\n" + body.join) + body = live_rows(data).sort_by { |r| r.cells[k].to_s.strip }.map { |r| r.line + "\n" } + Rdb.write_file(path, data.head.join("\t") + "\n" + Table.dash_row(data.head) + "\n" + body.join) end end @@ -217,7 +217,7 @@ module Rdb def splice(path, from, to, text) b = File.binread(path) - File.binwrite(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s) + Rdb.write_file(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s) end # Remove a record's entry from the secondary index for method. @@ -227,11 +227,11 @@ module Rdb ip = path + '.r' ents = read_fixed(ip) ents.delete(row_from) - File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) + Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) when 'h' ip = path + '.h' ents = read_fixed(ip).map { |o| o == row_from ? -1 : o } - File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) + Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) when 'i' ip = path + '.i' e = read_inverted(ip).find { |x| x[:offset] == row_from } @@ -242,16 +242,16 @@ module Rdb def index_add(path, method, row_from, cells, data_head, keycol) case method when 'r' - File.binwrite(path + '.r', File.binread(path + '.r') + fmt_entry(row_from)) + Rdb.write_file(path + '.r', File.binread(path + '.r') + fmt_entry(row_from)) when 'i' ip = path + '.i' name = File.binread(ip).lines.first.split("\t")[0] key = cells[data_head.index(name)].to_s ents = read_inverted(ip) - pos = ents.find { |e| e[:key] > key } + pos = ents.find { |e| e[:key].strip > key.strip } line = "#{key}\t#{row_from}\n" if pos then splice(ip, pos[:from], pos[:from], line) - else File.binwrite(ip, File.binread(ip) + line) + else Rdb.write_file(ip, File.binread(ip) + line) end when 'h' ip = path + '.h' @@ -259,7 +259,7 @@ module Rdb if ents.any? { |o| o <= 0 } key = cells[data_head.index(keycol)].to_s ents[free_slot(ents, hash_slot(ents.size, key))] = row_from - File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) + Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) else build(path, 'h', keycol) end @@ -269,11 +269,12 @@ module Rdb end def append_row(path, cells, method, keycol) + Rdb.check_cells!(cells) line = cells.join("\t") b = File.binread(path) b += "\n" unless b.empty? || b.end_with?("\n") from = b.bytesize - File.binwrite(path, b + line + "\n") + Rdb.write_file(path, b + line + "\n") index_add(path, method, from, cells, Data.new(path).head, keycol) if SUFFIX[method] || method == 'b' from end @@ -287,6 +288,7 @@ module Rdb # Put cells where the record at hit was. If the new record is too long # even after trimming blanks, append it (unless append is false). def replace_row(path, from, to, cells, method, xfrom, xto, keycol, allow_append: true) + Rdb.check_cells!(cells) room = to - from - 1 line = cells.join("\t") line = cells.map(&:strip).join("\t") if line.bytesize > room diff --git a/lib/rdb/table.rb b/lib/rdb/table.rb index 3a827de..744dbce 100644 --- a/lib/rdb/table.rb +++ b/lib/rdb/table.rb @@ -18,7 +18,18 @@ module Rdb cells.all? { |c| c.match?(/\A\s*-*\s*\z/) } && cells.any? { |c| c.include?('-') } end + # COHERENT-era files are often Latin-1. Text that is not valid UTF-8 is + # read as Latin-1 and comes out as UTF-8. + def self.utf8(text) + return text if text.encoding == Encoding::UTF_8 && text.valid_encoding? + + text.dup.force_encoding(Encoding::UTF_8).then do |t| + t.valid_encoding? ? t : text.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8) + end + end + def self.parse(text) + text = utf8(text) return parse_list(text) if text.start_with?("\n") lines = text.lines.map { |l| l.chomp("\n") } diff --git a/test/rdb_test.rb b/test/rdb_test.rb index e9e39b8..d9c59b5 100644 --- a/test/rdb_test.rb +++ b/test/rdb_test.rb @@ -876,3 +876,51 @@ class TaxTablesTest < RdbCase end end end + +class RobustnessTest < RdbCase + def test_umlauts_in_column_names + write('uml', "Größe\tÜber\n-----\t----\n5\t7\n1\t2\n") + assert_equal ['5', '7'], data(rdb('row', 'Über > 3', file: 'uml')).first + assert_equal 1, data(rdb('row', 'Größe > 3', file: 'uml')).size + end + + def test_latin1_data_is_read_and_written_as_utf8 + File.binwrite(File.join(@dir, 'lat'), "Name\n----\n\xE4pfel\n") + assert_equal "Äpfel".downcase, data(rdb('row', 'Name ~ /pfel/', file: 'lat')).first.first + end + + def test_errors_are_one_line_not_a_backtrace + _o, e, st = run3('update.rdb', 'inventory', stdin: "/[\n") + refute st.success? + assert_match(/update\.rdb: .*char-class/, e) + refute_match(/\.rb:\d+:in/, e) + end + + def test_enter_refuses_a_value_with_a_tab + _o, e, st = run3('enter', '-1', 'inventory', stdin: "9\t9\n1\n2\n3\nthing\n") + refute st.success? + assert_match(/contains a tab/, e) + end + + def test_sorttable_reverse_keeps_ties_in_order + out = rdb('sorttable', '-r', 'B', stdin: "A\tB\n-\t-\n1\tb\n2\ta\n3\tb\n4\ta\n") + assert_equal %w[1 3 2 4], data(out).map(&:first) + end + + def test_update_keeps_the_record_when_the_editor_fails + before = File.read(File.join(@dir, 'inventory')) + _o, e, = run3('update.rdb', 'inventory', stdin: "1\ne\nq\n", env: { 'EDITOR' => 'false' }) + assert_match(/editor false failed/, e) + assert_equal before, File.read(File.join(@dir, 'inventory')) + end + + def test_write_file_is_complete_and_keeps_the_mode + path = File.join(@dir, 'wf') + File.write(path, 'old') + File.chmod(0o640, path) + Rdb.write_file(path, 'new') + assert_equal 'new', File.read(path) + assert_equal 0o640, File.stat(path).mode & 0o777 + assert_empty Dir[File.join(@dir, 'wf.tmp*')] + end +end