diff --git a/.gitignore b/.gitignore index 81fa929..0a0c8ad 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ Gemfile.lock *.gem .rspec_status .patches/ +env.sh +off.sh +on.sh +codeball.txt +ball.tar.gz +docs/superpowers/ diff --git a/.idea/codeball.iml b/.idea/codeball.iml index 3c21da6..42d4d85 100644 --- a/.idea/codeball.iml +++ b/.idea/codeball.iml @@ -1,8 +1,5 @@ - - - @@ -14,7 +11,7 @@ - + @@ -53,6 +50,7 @@ + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 9166713..80b1919 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,5 +2,6 @@ \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index 0278610..203f9c5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,6 +19,9 @@ plugins: AllCops: NewCops: enable TargetRubyVersion: 3.4.8 + Exclude: + - "docs/**/*" + - "references/**/*" # =========================================================================== # Overrides from stock — personal style preferences @@ -338,5 +341,3 @@ Style/TrailingCommaInHashLiteral: Style/TrailingCommaInArguments: EnforcedStyleForMultiline: comma - - diff --git a/Gemfile b/Gemfile index 7086925..58da6aa 100644 --- a/Gemfile +++ b/Gemfile @@ -16,5 +16,6 @@ gem "rubocop-md" gem "rubocop-minitest" gem "rubocop-performance" gem "rubocop-rake" +gem "rubocop-rspec" gem "ruby-filemagic", "~> 0.7.3" gem "warning", "~> 1.5" diff --git a/codeball.gemspec b/codeball.gemspec index f11aa22..caec465 100644 --- a/codeball.gemspec +++ b/codeball.gemspec @@ -26,6 +26,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "command_kit", "~> 0.6" + spec.add_dependency "warning" spec.add_dependency "zeitwerk" spec.metadata["rubygems_mfa_required"] = "true" end diff --git a/issues.rec b/issues.rec index df5e2b3..4dacb51 100644 --- a/issues.rec +++ b/issues.rec @@ -76,4 +76,4 @@ Id: 370D054E-8144-11F1-9126-FE6CB9572C2F Updated: Thu, 16 Jul 2026 11:29:00 -0700 Title: "cannot load such file -- filemagic" Description: filemagic should be optional -Status: open \ No newline at end of file +Status: open diff --git a/lib/codeball/ball.rb b/lib/codeball/ball.rb index 7eb6bf1..d33b7e4 100644 --- a/lib/codeball/ball.rb +++ b/lib/codeball/ball.rb @@ -2,8 +2,10 @@ module Codeball # A codeball -- the aggregate root. # # Ball starts empty and grows as entries are added, like a snowball. - # It does not touch the filesystem. Parse is a thin factory that - # wires Cursor -> Stream -> Ball. + # An instance holds parsed entries in memory and does no I/O itself. + # Two class factories build one from source: parse (from an in-memory + # string, wiring Cursor -> Stream -> Ball) and load_file (which reads + # the source file from disk, then parses it). # class Ball def self.parse(text) @@ -16,17 +18,40 @@ def self.parse(text) ball end + def self.load_file(path) + pathname = Pathname(path) + raise "No file found" unless pathname.file? + + parse(pathname.read) + end + def initialize @entries = [] @warnings = [] end - def add_entry(entry) + def add_entry(entry = Entry.new) + yield entry if block_given? + raise ArgumentError, "Entry cannot be nil" if entry.nil? + @entries << entry @warnings << entry.error if entry.errors? @warnings << "truncated entry for #{entry.path.inspect} - missing END marker" if entry.truncated? end + def remove_entry(identifier) + to_remove = ( + case identifier + in Entry then identifier + in String then each_entry.find { it.header == identifier } + else raise ArgumentError, "#{identifier} is not a valid Entry or identifier" + end + ) + raise ArgumentError, "#{identifier} did not match an existing Entry in this Ball" unless to_remove + + @entries.delete(to_remove) + end + def validate! valid = entries.select(&:valid?) if valid.empty? && warnings.any? @@ -36,15 +61,23 @@ def validate! end end + def files + each_entry.map(&:header) + end + + def entry_count + each_entry.count + end + def each_entry(&) = entries.select(&:valid?).each(&) - def each_text_entry(&) = entries.select(&:valid?).select(&:text?).each(&) - def each_non_text_entry(&) = entries.select(&:valid?).reject(&:text?).each(&) + def each_text_entry(&) = each_entry.select(&:text?).each(&) + def each_non_text_entry(&) = each_entry.reject(&:text?).each(&) def each_warning(&) = warnings.each(&) - def all_text? = entries.select(&:valid?).all?(&:text?) + def all_text? = each_entry.all?(&:text?) def warning_count = warnings.length def serialize - entries.select(&:valid?).select(&:text?).map(&:serialize).join + each_text_entry.map(&:serialize).join end private diff --git a/lib/codeball/commands/filter.rb b/lib/codeball/commands/filter.rb new file mode 100644 index 0000000..ad74603 --- /dev/null +++ b/lib/codeball/commands/filter.rb @@ -0,0 +1,78 @@ +require "command_kit/command" +require "command_kit/colors" + +module Codeball + module Commands + # Filter entries in a codeball by glob pattern. + class Filter < CommandKit::Command + include CommandKit::Colors + + usage "[options] [FILE]" + description "Filter entries in a codeball" + + option :inverse, short: "-v", desc: "Reverse direction of filtering" + + # Flags chosen so glob semantics match what users expect from shell + # globs: FNM_PATHNAME makes '*' stop at '/' and enables '**/' for + # recursive matching; FNM_EXTGLOB enables '{rb,py}' brace expansion. + FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB + + argument :patterns, required: true, repeats: true, desc: "Patterns to filter on" + argument :file, required: false, desc: "Codeball file (or stdin if omitted)" + + examples [ + "'*.rb' bundle.txt", + "'*.rb' < bundle.txt", + "'lib/**/*.rb' bundle.txt", + "-v 'test/**' bundle.txt", + ] + + def run(*args) + patterns, file = split_source(args) + ball = Ball.parse(read_input(file)) + report_warnings(ball) + drop_unmatched(ball, patterns) + stdout.puts ball.serialize + end + + private + + # The trailing argument is the codeball file only when there is at + # least one preceding pattern and it names a readable file on disk; + # otherwise every argument is a pattern and the ball is read from + # stdin. Requiring a preceding pattern keeps a lone argument a pattern + # (so `filter '*.rb'` filters stdin instead of trying to open '*.rb') + # and guarantees patterns is never empty. + def split_source(args) + *leading, last = args + leading.any? && File.file?(last) && File.readable?(last) ? [leading, last] : [args, nil] + end + + def read_input(file) + input = file ? File.read(file) : stdin.read + abort_if_empty(input) + input + end + + def report_warnings(ball) + ball.each_warning { |msg| stderr.puts colors(stderr).yellow("warning: #{msg}") } + end + + def drop_unmatched(ball, patterns) + ball.each_entry.reject { match?(patterns, it) }.each { ball.remove_entry(it) } + end + + def match?(patterns, entry) + verb = options[:inverse] ? :none? : :any? + patterns.public_send(verb) { |pattern| File.fnmatch?(pattern, entry.path, FNMATCH_FLAGS) } + end + + def abort_if_empty(input) + return unless input.nil? || input.strip.empty? + + print_error "no input" + exit 1 + end + end + end +end diff --git a/lib/codeball/commands/unpack.rb b/lib/codeball/commands/unpack.rb index 54a1a9c..d0e42c9 100644 --- a/lib/codeball/commands/unpack.rb +++ b/lib/codeball/commands/unpack.rb @@ -1,11 +1,11 @@ -require "command_kit/commands/command" +require "command_kit/command" require "command_kit/colors" module Codeball module Commands # Extract files from a codeball. # - class Unpack < CommandKit::Commands::Command + class Unpack < CommandKit::Command include CommandKit::Colors usage "[options] [FILE]" @@ -15,6 +15,7 @@ class Unpack < CommandKit::Commands::Command value: { type: String, default: "." }, desc: "Output directory" + option :stdout, short: "-O", desc: "Write file contents to stdout instead of to files. (Analogous to tar -Ox)" option :dry_run, short: "-n", desc: "Preview extraction without writing files" @@ -32,29 +33,45 @@ class Unpack < CommandKit::Commands::Command def run(file = nil) ball = Ball.parse(read_input(file)) - dest = build_destination + report_warnings(ball) + return dump_to_stdout(ball) if options[:stdout] + + extract_to_disk(ball) + end + + private + def report_warnings(ball) ball.each_warning { |msg| warn colors.yellow("warning: #{msg}") } - ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } } + end - print_summary(dest.summary(malformed: ball.warning_count)) + def dump_to_stdout(ball) + ball.each_entry { |entry| stdout.print entry.contents } end - private + def extract_to_disk(ball) + dest = build_destination + ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } } + print_summary(dest.summary(malformed: ball.warning_count)) + end def build_destination Destination.new(options[:output_dir], dry_run: options[:dry_run]) end def read_input(file) - ARGV.replace(file ? [file] : []) - input = ARGF.read - - abort_on_empty(input) + input = case file + when nil, "-" then stdin.read + else File.read(file) + end + abort_if_empty(input) input + rescue Errno::ENOENT + print_error "no such file: #{file}" + exit 1 end - def abort_on_empty(input) + def abort_if_empty(input) return unless input.nil? || input.strip.empty? print_error "no input" diff --git a/lib/codeball/entry.rb b/lib/codeball/entry.rb index c3b576e..0ffcc3f 100644 --- a/lib/codeball/entry.rb +++ b/lib/codeball/entry.rb @@ -19,12 +19,10 @@ def self.from_file(path) pathname = Pathname.new(path) return nil unless pathname.exist? && pathname.readable? - entry = new - name = pathname.to_s - entry.header = Header.new(name) - entry.body = Body.new(pathname.read) - entry.footer = Footer.new(name) - entry + new do |entry| + entry.body = Body.new(pathname.read) + entry.name = pathname.to_s + end end def self.magic_client @@ -37,6 +35,12 @@ def initialize @footer = nil @error = nil @magic_client = self.class.magic_client + yield self if block_given? + end + + def name=(name) + self.header = Header.new(name) + self.footer = Footer.new(name) end def header=(header) @@ -47,6 +51,15 @@ def header=(header) @header = header end + def footer=(footer) + if @footer + @error = "duplicate footer for #{path}" + return + end + @footer = footer + @error = "footer #{footer} does not match header #{header}" unless footer_matches_header? + end + def body=(body) if @body @error = "duplicate body for #{path}" @@ -55,34 +68,56 @@ def body=(body) @body = body end - def footer=(footer) - if @footer - @error = "duplicate footer for #{path}" - return - end - @footer = footer - @error = "footer #{footer} does not match header #{header}" unless footer_matches_header? + def valid? + return false unless header + return false unless body + return false unless footer + return false if errors? + return false unless footer_matches_header? + + true end - def valid? = !!(header && body && footer && !errors? && footer_matches_header?) - def incomplete? = !valid? && !errors? + def contents = body&.to_s + + def header? = !header.nil? && !header.empty? + + def footer? = !footer.nil? && !footer.empty? + + def contents? = !contents.nil? && !contents.empty? + + def empty? = !contents? + def errors? = !error.nil? - def truncated? = !!(header && (body.nil? || footer.nil?) && !errors?) + + def invalid? = !valid? + + def incomplete? = invalid? && !errors? + + def truncated? + missing_body_or_footer = body.nil? || footer.nil? + return false unless header? + return false unless missing_body_or_footer + return false if errors? + + true + end def path = header&.to_s - def contents = body&.to_s - def empty? = contents&.empty? || contents.nil? def byte_size = contents&.bytesize || 0 def line_count - return 0 if contents.nil? || contents.empty? + return 0 if empty? contents.count("\n") + (contents.end_with?("\n") ? 0 : 1) end def text? - contents.nil? || contents.empty? || !mime_type.include?("charset=binary") + return true if empty? + return false if mime_type.include?("charset=binary") + + true end def serialize diff --git a/lib/codeball/version.rb b/lib/codeball/version.rb index 29b8708..637e1e5 100644 --- a/lib/codeball/version.rb +++ b/lib/codeball/version.rb @@ -1,3 +1,3 @@ module Codeball - VERSION = "0.2.0".freeze + VERSION = "0.2.2".freeze end diff --git a/spec/codeball/ball_spec.rb b/spec/codeball/ball_spec.rb index f29deb3..c18d449 100644 --- a/spec/codeball/ball_spec.rb +++ b/spec/codeball/ball_spec.rb @@ -38,9 +38,21 @@ def serialize_entry(path, contents) serialize_entry("lib/greet.rb", "def greet\n 'hi'\nend\n") end + describe "encapsulation" do + it "keeps entries a private reader" do + expect(described_class.public_method_defined?(:entries)).to be false + expect(described_class.private_method_defined?(:entries)).to be true + end + + it "keeps warnings a private reader" do + expect(described_class.public_method_defined?(:warnings)).to be false + expect(described_class.private_method_defined?(:warnings)).to be true + end + end + describe ".parse" do context "with valid two-entry codeball text" do - let(:ball) { described_class.parse(ball_text) } + subject(:ball) { described_class.parse(ball_text) } it "returns a Ball" do expect(ball).to be_a(described_class) @@ -83,7 +95,7 @@ def serialize_entry(path, contents) incomplete = "#{border}\nBEGIN \"orphan.rb\"\n#{border}\norphan content\n" complete + incomplete end - let(:ball) { described_class.parse(truncated_text) } + subject(:ball) { described_class.parse(truncated_text) } it "returns a Ball" do expect(ball).to be_a(described_class) @@ -108,7 +120,7 @@ def serialize_entry(path, contents) end describe ".new" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } it "creates an empty Ball" do entries = [] @@ -122,7 +134,13 @@ def serialize_entry(path, contents) end describe "#add_entry" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } + + context "with a nil entry" do + it "raises an ArgumentError" do + expect { ball.add_entry(nil) }.to raise_error ArgumentError + end + end context "with a valid entry" do before { ball.add_entry(valid_entry) } @@ -169,10 +187,30 @@ def serialize_entry(path, contents) expect(entries).to be_empty end end + + context "when a block is supplied" do + let(:body) { "mybody" } + let(:name) { "myname" } + let(:entry) { spy "Entry" } + + before do + allow(Codeball::Entry).to receive(:new).and_return(entry) + end + + it "yields the entry for assignment" do + ball.add_entry do |s| + s.name = name + s.body = body + end + + expect(entry).to have_received(:name=).with(name) + expect(entry).to have_received(:body=).with(body) + end + end end describe "#each_entry" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } before do ball.add_entry(valid_entry(path: "hello.rb")) @@ -193,7 +231,7 @@ def serialize_entry(path, contents) end describe "#each_text_entry" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } before do ball.add_entry(valid_entry) @@ -208,7 +246,7 @@ def serialize_entry(path, contents) end describe "#each_non_text_entry" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } before do ball.add_entry(valid_entry) @@ -223,7 +261,7 @@ def serialize_entry(path, contents) end describe "#all_text?" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } context "when all entries are text" do before { ball.add_entry(valid_entry) } @@ -246,7 +284,7 @@ def serialize_entry(path, contents) end describe "#serialize" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } describe "output format" do before { ball.add_entry(valid_entry) } @@ -273,7 +311,7 @@ def serialize_entry(path, contents) end describe "#validate!" do - let(:ball) { described_class.new } + subject(:ball) { described_class.new } context "with entries present" do before { ball.add_entry(valid_entry) } diff --git a/spec/codeball/entry_spec.rb b/spec/codeball/entry_spec.rb index 1ca5c9e..f741ce3 100644 --- a/spec/codeball/entry_spec.rb +++ b/spec/codeball/entry_spec.rb @@ -31,6 +31,23 @@ end end + describe "#name" do + subject { described_class.new } + let(:name) { "myclass.rb" } + + it "sets the header" do + expect { + subject.name = name + }.to change { subject.header }.from(nil).to(Codeball::Header.new(name)) + end + + it "sets the footer" do + expect { + subject.name = name + }.to change { subject.footer }.from(nil).to(Codeball::Footer.new(name)) + end + end + describe "#header=" do let(:entry) { described_class.new } @@ -148,6 +165,17 @@ expect(entry.error).to include("duplicate footer") end end + + context "with a matching footer but no body" do + before do + entry.header = Codeball::Header.new("hello.rb") + entry.footer = Codeball::Footer.new("hello.rb") + end + + it "is not valid" do + expect(entry.valid?).to be false + end + end end describe "#truncated?" do diff --git a/spec/integration/filter_spec.rb b/spec/integration/filter_spec.rb new file mode 100644 index 0000000..dfde67c --- /dev/null +++ b/spec/integration/filter_spec.rb @@ -0,0 +1,123 @@ +require_relative "../spec_helper" + +RSpec.describe "codeball filter", type: :integration do + include CLIHelper + + # A ball with entries at several depths so glob semantics are observable. + let(:bundle) do + pack_bundle( + ["lib/app.rb", "puts :app\n"], + ["lib/nested/helper.rb", "puts :helper\n"], + ["test/app_test.rb", "puts :test\n"], + ["README.md", "# readme\n"], + ) + end + let(:bundle_path) { create_file("bundle.txt", bundle) } + + # Extract the entry paths from a serialized ball (BEGIN "path" lines). + def entries_in(ball_text) + ball_text.scan(/^BEGIN "(.+)"$/).flatten + end + + describe "with a FILE argument and non-interactive stdin" do + # Regression: the FILE argument must be honored even when stdin is not a + # TTY (pipes, scripts, CI). Previously the file was ignored and treated + # as another pattern, so this errored with "no input". + let(:result) { run_codeball("filter", "lib/**/*.rb", bundle_path) } + + it "reads the ball from the file and keeps only matching entries" do + expect(entries_in(result.stdout)).to contain_exactly("lib/app.rb", "lib/nested/helper.rb") + end + + it "exits 0" do + expect(result.exit_code).to eq(0) + end + end + + describe "reading from stdin" do + let(:result) { run_codeball("filter", "lib/**/*.rb", stdin: bundle) } + + it "keeps only matching entries" do + expect(entries_in(result.stdout)).to contain_exactly("lib/app.rb", "lib/nested/helper.rb") + end + end + + describe "glob semantics" do + it "treats '*' as non-recursive (stops at '/')" do + result = run_codeball("filter", "*.md", stdin: bundle) + expect(entries_in(result.stdout)).to contain_exactly("README.md") + end + + it "ORs multiple patterns together" do + result = run_codeball("filter", "README.md", "test/**", stdin: bundle) + expect(entries_in(result.stdout)).to contain_exactly("README.md", "test/app_test.rb") + end + end + + describe "with --inverse (-v)" do + let(:result) { run_codeball("filter", "-v", "test/**", stdin: bundle) } + + it "keeps entries that do NOT match" do + expect(entries_in(result.stdout)).to contain_exactly( + "lib/app.rb", "lib/nested/helper.rb", "README.md" + ) + end + end + + describe "with empty input" do + let(:result) { run_codeball("filter", "*.rb", stdin: "") } + + it "prints an error to stderr" do + expect(result.stderr).to include("no input") + end + + it "exits non-zero" do + expect(result.exit_code).not_to eq(0) + end + end + + describe "with a nonexistent trailing argument and no stdin" do + # A trailing arg that is not a file is treated as another pattern; with + # no piped ball there is nothing to filter, so the command fails loudly. + let(:result) { run_codeball("filter", "*.rb", "no_such_ball.txt", stdin: "") } + + it "exits non-zero" do + expect(result.exit_code).not_to eq(0) + end + + it "reports no input on stderr" do + expect(result.stderr).to include("no input") + end + end + + describe "when the sole argument also names a file on disk (finding A)" do + # pack_bundle wrote lib/app.rb into the working dir, so that name is now + # both a valid glob pattern AND an existing file. A lone argument must + # stay a pattern -- it must never be consumed as the ball file, which + # would leave zero patterns and silently drop every entry. + let(:result) { run_codeball("filter", "lib/app.rb", stdin: bundle) } + + it "treats it as a pattern and filters stdin" do + expect(entries_in(result.stdout)).to contain_exactly("lib/app.rb") + end + + it "exits 0" do + expect(result.exit_code).to eq(0) + end + end + + describe "when the trailing argument is an unreadable file (finding E)" do + before { skip "chmod has no effect when running as root" if Process.uid.zero? } + + let(:result) do + path = create_file("locked.ball", "secret\n") + File.chmod(0o000, path) + run_codeball("filter", "lib/**/*.rb", "locked.ball", stdin: bundle) + end + + it "treats the unreadable file as a pattern and filters stdin instead of crashing" do + expect(result.exit_code).to eq(0) + expect(entries_in(result.stdout)).to contain_exactly("lib/app.rb", "lib/nested/helper.rb") + end + end +end diff --git a/spec/integration/list_spec.rb b/spec/integration/list_spec.rb index 893a9cf..c621441 100644 --- a/spec/integration/list_spec.rb +++ b/spec/integration/list_spec.rb @@ -32,21 +32,21 @@ it "exits 0" do expect(result.exit_code).to eq(0) end - end - describe "with empty input" do - let(:result) { run_codeball("list", stdin: "") } + context "with empty input" do + let(:result) { run_codeball("list", stdin: "") } - it "prints an error to stderr" do - expect(result.stderr).to include("no input") - end + it "prints an error to stderr" do + expect(result.stderr).to include("no input") + end - it "exits non-zero" do - expect(result.exit_code).not_to eq(0) + it "exits non-zero" do + expect(result.exit_code).not_to eq(0) + end end end - describe "with a bundle containing multiple files" do + context "with a bundle containing multiple files" do let(:bundle_text) do pack_bundle( ["alpha.rb", "a = 1\n"], @@ -66,7 +66,7 @@ end end - describe "with a truncated bundle" do + context "with a truncated bundle" do let(:full_bundle) do pack_bundle( ["complete.rb", "good = true\n"], @@ -90,7 +90,7 @@ end end - describe "with a fully malformed bundle (no valid entries)" do + context "with a fully malformed bundle (no valid entries)" do let(:result) { run_codeball("list", stdin: "this is not a bundle at all\njust garbage\n") } it "prints an error to stderr" do diff --git a/spec/integration/unpack_spec.rb b/spec/integration/unpack_spec.rb index 7cac80d..243c55f 100644 --- a/spec/integration/unpack_spec.rb +++ b/spec/integration/unpack_spec.rb @@ -3,6 +3,52 @@ RSpec.describe "codeball unpack", type: :integration do include CLIHelper + describe "with --stdout (-O)" do + # Include a file with NO trailing newline to prove -O emits raw bytes + # (tar -Ox parity) and does not append a newline of its own. + let(:bundle) do + ball_text_for("a.txt", "alpha\n") + ball_text_for("b.txt", "beta-no-newline") + end + let(:result) { run_codeball("unpack", "-O", stdin: bundle) } + + it "writes raw file contents to stdout with no added newline" do + expect(result.stdout).to eq("alpha\nbeta-no-newline") + end + + it "does not write any files to disk" do + result + expect(output_path("a.txt")).not_to exist + expect(output_path("b.txt")).not_to exist + end + + it "exits 0" do + expect(result.exit_code).to eq(0) + end + end + + describe "with a nonexistent FILE argument" do + let(:result) { run_codeball("unpack", "no_such_bundle.txt") } + + it "prints a helpful error naming the file" do + expect(result.stderr).to match(/no such file/i) + expect(result.stderr).to include("no_such_bundle.txt") + end + + it "exits non-zero" do + expect(result.exit_code).not_to eq(0) + end + end + + describe "with an explicit '-' file argument" do + let(:bundle) { ball_text_for("dash.txt", "via dash\n") } + let(:result) { run_codeball("unpack", "-O", "-", stdin: bundle) } + + it "treats - as stdin" do + expect(result.stdout).to include("via dash") + expect(result.exit_code).to eq(0) + end + end + describe "extracting from a file argument" do let(:bundle) { pack_bundle(["hello.txt", "hello world\n"]) } let(:bundle_path) { create_file("bundle.txt", bundle) }