diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5f623ec..7cddac0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,12 +17,12 @@ jobs:
ruby-version: ['4.0.1']
steps:
- uses: actions/checkout@v4
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libmagic-dev
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- - name: Run tests
- run: bundle exec rake test
- - name: Run RuboCop
- run: bundle exec rake rubocop
+ - name: Run tests and specs
+ run: bundle exec rake
diff --git a/.gitignore b/.gitignore
index b68f031..81fa929 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@
Gemfile.lock
*.gem
.rspec_status
+.patches/
diff --git a/.rubocop.yml b/.rubocop.yml
index 1a402e8..0278610 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -54,6 +54,11 @@ Style/StringLiteralsInInterpolation:
Style/FrozenStringLiteralComment:
EnforcedStyle: never
+# Freezing constants breaks objects that need post-assignment setup (e.g.
+# Zeitwerk loaders). Not worth the churn.
+Style/MutableConstant:
+ Enabled: false
+
# Pipeline style. Chaining multi-line blocks is the whole point.
#
# # good — this is how we write Ruby
diff --git a/Gemfile b/Gemfile
index 41518a5..7c8858e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,5 +1,3 @@
-# frozen_string_literal: true
-
source "https://rubygems.org"
gemspec
@@ -7,7 +5,7 @@ gemspec
gem "command_kit"
gem "minitest", "~> 6.0"
gem "minitest-mock", "~> 5.0"
-gem "minitest-reporters", github: 'minitest-reporters/minitest-reporters'
+gem "minitest-reporters", github: "minitest-reporters/minitest-reporters"
gem "rake"
gem "rubocop"
gem "rubocop-md"
@@ -17,4 +15,5 @@ gem "rubocop-rake"
gem "ruby-filemagic", "~> 0.7.3"
gem "warning", "~> 1.5"
+gem "rspec", "~> 3.0"
gem "rubocop-claude", "~> 0.1"
diff --git a/Rakefile b/Rakefile
index de0fa4c..9e83a32 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,8 +1,10 @@
require "bundler/gem_tasks"
require "minitest/test_task"
+require "rspec/core/rake_task"
require "rubocop/rake_task"
Minitest::TestTask.create
+RSpec::Core::RakeTask.new(:spec)
RuboCop::RakeTask.new
namespace :zeitwerk do
@@ -16,4 +18,4 @@ namespace :zeitwerk do
end
end
-task default: [:test, :rubocop]
+task default: [:test, :spec, :rubocop]
diff --git a/codeball.gemspec b/codeball.gemspec
index a8f67b8..f11aa22 100644
--- a/codeball.gemspec
+++ b/codeball.gemspec
@@ -5,16 +5,16 @@ Gem::Specification.new do |spec|
spec.version = Codeball::VERSION
spec.authors = ["David Gillis"]
spec.email = ["david@flipmine.com"]
- spec.summary = "Bidirectional file bundler for clipboard-friendly LLM workflows"
- spec.description = "Pack multiple source files into a single plaintext bundle for " \
+ spec.summary = "Bidirectional file packer for clipboard-friendly LLM workflows"
+ spec.description = "Pack multiple source files into a single plaintext codeball for " \
"pasting into LLM context windows, then unpack the response back into files."
spec.homepage = "https://github.com/gillisd/codeball"
spec.license = "MIT"
- spec.required_ruby_version = ">= 4.0.1"
+ spec.required_ruby_version = ">= 3.4"
gemspec_file = File.basename(__FILE__)
files = IO.popen(["git", "ls-files", "-z"], chdir: __dir__, err: IO::NULL) { |ls|
- ls.readlines("\x0", chomp: true).reject do |f|
+ ls.readlines(0.chr, chomp: true).reject do |f|
(f == gemspec_file) ||
f.start_with?("bin/", "test/", "spec/", "features/", ".git", "Gemfile")
end
diff --git a/issues.rec b/issues.rec
index 43e2033..4990c06 100644
--- a/issues.rec
+++ b/issues.rec
@@ -2,7 +2,7 @@
%key: Id
%typedef: text_t regexp /^.*$/
%typedef: Status_t enum open in_progress closed
-%type: Id int
+%type: Id uuid
%type: Title line
%type: Description text_t
%type: Status Status_t
@@ -31,3 +31,14 @@ Description: Version number is not appearing, instead showing:
+ An idiomatic solution should lean on CommandKit's version number feature
Status: open
+Id: 631BE27A-2A48-11F1-93E9-FE6CB9572C2D
+Updated: Fri, 27 Mar 2026 21:49:41 -0400
+Title: Fix all rubocop issues
+Description: Many violations are present that claude code is responsible for. They need to be addressed, and no rubocop configuration should be edited unless it would be unreasonable to work around the rule
+Status: open
+
+Id: f2ca5c36-31c2-11f1-bf73-fa9e1a133f8e
+Updated: Mon, 06 Apr 2026 14:14:39 +0000
+Title: Test suite writes to /tmp via Dir.mktmpdir
+Description: Dir.mktmpdir uses Dir.tmpdir to resolve the parent directory. Dir.tmpdir checks in order (per /usr/share/ruby/tmpdir.rb lines 130-135): ENV['TMPDIR'], ENV['TMP'], ENV['TEMP'], Etc.systmpdir, /tmp, then current directory. When none of the env vars are set, it falls back to /tmp. This violates the CLAUDE.md hard rule: NEVER write to /tmp. Affected files: spec/spec_helper.rb (CLIHelper#tmp_dir), spec/codeball/destination_spec.rb, and test/entry_test.rb. All calls have after/teardown cleanup so /tmp is not leaked permanently -- the issue is that writes happen to /tmp at all. Fix: set ENV['TMPDIR'] to a project-local directory in spec_helper.rb and test_helper.rb, or pass an explicit second argument to Dir.mktmpdir.
+Status: open
diff --git a/lib/codeball.rb b/lib/codeball.rb
index a06e2bf..d7b5a8f 100644
--- a/lib/codeball.rb
+++ b/lib/codeball.rb
@@ -1,14 +1,20 @@
-require 'warning'
+require "warning"
require "zeitwerk"
+##
+# Bidirectional file packer for clipboard-friendly LLM workflows.
+#
+# Packs multiple source files into a single plaintext codeball and extracts
+# them back to disk. Uses Zeitwerk for autoloading.
module Codeball
LOADER = Zeitwerk::Loader.for_gem
- LOADER.inflector.inflect( 'cli' => 'CLI' )
+ LOADER.inflector.inflect("cli" => "CLI")
+ LOADER.ignore("#{__dir__}/command_kit")
LOADER.setup
-# CLI requires command_kit gem - only load if available
+ # CLI requires command_kit gem - only load if available
begin
- require 'command_kit'
+ require "command_kit"
require_relative "codeball/cli"
Warning.ignore(/FileMagic/)
rescue LoadError
diff --git a/lib/codeball/ball.rb b/lib/codeball/ball.rb
new file mode 100644
index 0000000..7eb6bf1
--- /dev/null
+++ b/lib/codeball/ball.rb
@@ -0,0 +1,54 @@
+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.
+ #
+ class Ball
+ def self.parse(text)
+ raise MalformedBallError, "empty input, nothing to extract" if text.nil? || text.strip.empty?
+
+ ball = new
+ stream = Stream.new(cursor: Cursor.new(text))
+ stream.each_entry { |entry| ball.add_entry(entry) }
+ ball.validate!
+ ball
+ end
+
+ def initialize
+ @entries = []
+ @warnings = []
+ end
+
+ def add_entry(entry)
+ @entries << entry
+ @warnings << entry.error if entry.errors?
+ @warnings << "truncated entry for #{entry.path.inspect} - missing END marker" if entry.truncated?
+ end
+
+ def validate!
+ valid = entries.select(&:valid?)
+ if valid.empty? && warnings.any?
+ raise MalformedBallError, "no valid entries found (#{warnings.length} malformed)"
+ elsif valid.empty?
+ raise MalformedBallError, "no content found - is this a codeball?"
+ end
+ 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_warning(&) = warnings.each(&)
+ def all_text? = entries.select(&:valid?).all?(&:text?)
+ def warning_count = warnings.length
+
+ def serialize
+ entries.select(&:valid?).select(&:text?).map(&:serialize).join
+ end
+
+ private
+
+ attr_reader :entries, :warnings
+ end
+end
diff --git a/lib/codeball/body.rb b/lib/codeball/body.rb
new file mode 100644
index 0000000..a788203
--- /dev/null
+++ b/lib/codeball/body.rb
@@ -0,0 +1,9 @@
+require "delegate"
+
+module Codeball
+ # File content extracted from between markers in a codeball.
+ #
+ # String wrapper providing identity for pattern matching.
+ #
+ class Body < SimpleDelegator; end
+end
diff --git a/lib/codeball/border.rb b/lib/codeball/border.rb
new file mode 100644
index 0000000..258b2db
--- /dev/null
+++ b/lib/codeball/border.rb
@@ -0,0 +1,47 @@
+module Codeball
+ # Domain knowledge about the visual delimiter between sections in a codeball.
+ #
+ # Borders are repeated punctuation patterns that separate entries in
+ # serialized codeball text. The pattern is fixed, not configurable.
+ #
+ # During serialization, SEPARATOR is used as-is.
+ # During parsing, recognition is heuristic to tolerate mangling
+ # by browsers, editors, and clipboard transfer.
+ #
+ module Border
+ PATTERN = "---\t"
+ WIDTH = 10
+ SEPARATOR = (PATTERN * WIDTH).freeze
+ SUFFIX = /[-#=~*_|+][-#=~*_|+\s]{8,}\s*\z/
+ MIN_LENGTH = 6
+ MIN_PUNCTUATION_LENGTH = 9
+
+ module_function
+
+ def recognize?(line)
+ return false if line.empty?
+ return false if line.start_with?("BEGIN ", "END ")
+
+ stripped = line.gsub(/\s+/, "")
+ return false if stripped.empty?
+ return false if stripped.length < MIN_LENGTH
+
+ single_char?(stripped) || punctuation_run?(stripped)
+ end
+
+ def strip_suffix(text)
+ text.match?(SUFFIX) ? text.sub(SUFFIX, "").chomp : text
+ end
+
+ def single_char?(stripped)
+ chars = stripped.chars.uniq
+ chars.length == 1 && !chars.first.match?(/[a-zA-Z0-9]/)
+ end
+
+ def punctuation_run?(stripped)
+ stripped.match?(/\A[-#=~*_|+]+\z/) && stripped.length >= MIN_PUNCTUATION_LENGTH
+ end
+
+ private_class_method :single_char?, :punctuation_run?
+ end
+end
diff --git a/lib/codeball/bundle.rb b/lib/codeball/bundle.rb
deleted file mode 100644
index b598ac2..0000000
--- a/lib/codeball/bundle.rb
+++ /dev/null
@@ -1,229 +0,0 @@
-require "pathname"
-
-module Codeball
- # A collection of file entries that can be serialized to text (packed)
- # or extracted to disk (unpacked).
- #
- # Bundle is the core domain object. It can be created from files on disk,
- # parsed from serialized text, serialized to stdout, or extracted to disk.
- #
- # ## Examples
- #
- # Packing files into a bundle:
- #
- # ```ruby
- # bundle = Bundle.from_files(["lib/foo.rb", "lib/bar.rb"])
- # bundle.serialize # writes to stdout
- # ```
- #
- # Unpacking a bundle from text:
- #
- # ```ruby
- # bundle = Bundle.parse(clipboard_contents)
- # bundle.extract # writes files to disk
- # ```
- #
- class Bundle
- attr_reader :entries, :config, :parse_errors
-
- def text_entries
- entries.select(&:text?)
- end
-
- def non_text_entries
- entries.reject(&:text?)
- end
-
- # Creates a bundle by reading files from disk.
- def self.from_files(paths, config: Config.default)
- entries = paths.filter_map { |path| Entry.from_file(path) }
- new(entries, config: config)
- end
-
- # Parses a bundle from serialized text.
- # Resilient to partial or truncated input - extracts what it can and warns about the rest.
- # Parse errors are stored in `parse_errors` for later reporting.
- def self.parse(text, config: Config.default)
- raise MalformedBundleError, "empty input, nothing to extract" if text.nil? || text.strip.empty?
-
- entries = []
- errors = []
-
- # Find all BEGIN markers that follow a border line
- lines = text.lines
- i = 0
-
- while i < lines.length
- line = lines[i].strip
-
- # Only recognize BEGIN if preceded by a border line
- if line.start_with?("BEGIN ") && i > 0 && looks_like_border?(lines[i - 1].strip)
- path = extract_path_from_line(line)
- if path
- content_start = find_content_start(lines, i + 1)
- if content_start
- content_end, footer_line = find_content_end(lines, content_start, path)
- if content_end
- content = extract_content(lines, content_start, content_end)
- entries << Entry.new(path: path, contents: content)
- i = footer_line + 1
- next
- else
- errors << "truncated entry for #{path.inspect} - missing END marker"
- end
- else
- errors << "malformed entry for #{path.inspect} - no content border found"
- end
- end
- end
-
- i += 1
- end
-
- if entries.empty? && errors.any?
- raise MalformedBundleError, "no valid entries found (#{errors.length} malformed)"
- elsif entries.empty?
- raise MalformedBundleError, "no content found - is this a codeball bundle?"
- end
-
- new(entries, config: config, parse_errors: errors)
- end
-
- # Extracts path from a BEGIN line like: BEGIN "path/to/file.rb"
- def self.extract_path_from_line(line)
- match = line.match(/\ABEGIN\s+["']?(.+?)["']?\s*\z/)
- match[1] if match
- end
-
- # Finds the line index where content starts (after the border following BEGIN)
- def self.find_content_start(lines, from)
- i = from
- # Skip any border line(s) to find content
- while i < lines.length
- line = lines[i].strip
- break unless looks_like_border?(line)
- i += 1
- end
- # If we found non-border content, the content starts here
- # But we need to back up if we hit the content - the border was the line before
- # Actually, content starts at i (the first non-border line after BEGIN's border)
- i < lines.length ? i : nil
- end
-
- # Finds where content ends by looking for END marker with matching path
- # Finds where content ends by looking for END marker with matching path
- def self.find_content_end(lines, content_start, path)
- i = content_start
- while i < lines.length
- line = lines[i]
- stripped = line.strip
-
- # Check if this line contains END marker for this exact path
- if stripped.include?("END \"#{path}\"") || stripped.include?("END '#{path}'") || stripped == "END #{path}"
- return [i - 1, i]
- end
-
- # Check if line is a border followed by END on next line
- if looks_like_border?(stripped) && i + 1 < lines.length
- next_stripped = lines[i + 1].strip
- if next_stripped.start_with?("END ")
- end_path = extract_path_from_line(next_stripped.sub(/\AEND/, "BEGIN"))
- return [i - 1, i + 1] if end_path == path
- end
- end
-
- i += 1
- end
- nil
- end
-
- # Extracts content from lines between start and end indices (inclusive)
- # Handles borders appearing at end of content lines
- def self.extract_content(lines, start_idx, end_idx)
- return "" if end_idx < start_idx
-
- # Skip leading border lines
- while start_idx <= end_idx && looks_like_border?(lines[start_idx].strip)
- start_idx += 1
- end
-
- return "" if start_idx > end_idx
-
- content_lines = lines[start_idx..end_idx]
- result = content_lines.join
-
- # Check if last line has border suffix (content and border on same line)
- # Pattern: content followed by repeated punctuation (border)
- border_suffix = result.match(/[-#=~*_|+][-#=~*_|+\s]{8,}\s*\z/)
-
- if border_suffix
- # Content didn't end with newline - border was on same line
- # Strip the border and the trailing newline (which belongs to the line, not content)
- result = result.sub(/[-#=~*_|+][-#=~*_|+\s]{8,}\s*\z/, "").chomp
- else
- # Content ended with newline, border was on its own line
- # Remove only the final newline that's an artifact of line joining
- # But preserve trailing newlines that are part of content
- # Actually, the join preserves everything correctly, we just need to not add extra
- end
-
- result
- end
-
- # Heuristic: does this line look like a border?
- # Borders are lines consisting mainly of repeated punctuation like --- or ###
- # possibly separated by whitespace (tabs converted to spaces, etc.)
- def self.looks_like_border?(line)
- return false if line.empty?
- return false if line.start_with?("BEGIN ", "END ")
-
- # Remove all whitespace and check what's left
- stripped = line.gsub(/\s+/, "")
- return false if stripped.empty?
- return false if stripped.length < 6 # Too short to be a border
-
- # A border is made of repeated punctuation characters
- # Check if it's all the same punctuation char, or a repeating pattern
- chars = stripped.chars.uniq
-
- # All same character (e.g., "----------")
- return true if chars.length == 1 && !chars.first.match?(/[a-zA-Z0-9]/)
-
- # Repeating pattern like "---" repeated = all dashes
- # Or "###" repeated = all hashes
- # Check if it's only punctuation/dashes
- return true if stripped.match?(/\A[-#=~*_|+]+\z/) && stripped.length >= 9
-
- false
- end
-
- # Returns the border pattern detected in the bundle, or nil if not determinable.
- def self.detect_border(text)
- return nil if text.nil? || text.empty?
- first_line = text.lines.first&.chomp
- first_line if looks_like_border?(first_line.to_s)
- end
-
- def initialize(entries, config: Config.default, parse_errors: [])
- @entries = entries
- @config = config
- @parse_errors = parse_errors
- end
-
- # Serializes the bundle to stdout for piping to clipboard.
- def serialize
- puts text_entries.map { it.serialize(config.full_border) }
- end
-
- # Extracts all entries to disk.
- # Returns an ExtractionSummary with per-file results.
- def extract
- output_dir = Pathname.new(config.output_dir).expand_path
- results = entries.map { |entry| entry.write_to(output_dir, dry_run: config.dry_run) }
- ExtractionSummary.new(results, malformed: parse_errors.length)
- end
-
- private
-
- end
-end
diff --git a/lib/codeball/cli.rb b/lib/codeball/cli.rb
index 7df8d43..b671b57 100644
--- a/lib/codeball/cli.rb
+++ b/lib/codeball/cli.rb
@@ -13,8 +13,8 @@ class CLI
# Auto-load subcommands from lib/codeball/commands/*.rb
include CommandKit::Commands::AutoLoad.new(
- dir: File.join(__dir__, "commands"),
- namespace: "Codeball::Commands"
+ dir: File.join(__dir__, "commands"),
+ namespace: "Codeball::Commands",
)
command_name "codeball"
diff --git a/lib/codeball/commands/diff.rb b/lib/codeball/commands/diff.rb
index 850261c..1ea2f9d 100644
--- a/lib/codeball/commands/diff.rb
+++ b/lib/codeball/commands/diff.rb
@@ -4,62 +4,47 @@
module Codeball
module Commands
- # Extract files from a codeball bundle.
+ # Diff extracted files against local copies.
+ #
+ # Incomplete -- diff output is not yet implemented.
#
class Diff < CommandKit::Command
include CommandKit::Colors
usage "[options] [FILE]"
- description "Extract files from a bundle"
-
- option :border, short: "-b",
- value: { type: String, default: "---\t" },
- desc: "Border pattern"
-
- option :border_width, short: "-w",
- value: { type: Integer, default: 10 },
- desc: "Border repetitions"
+ description "Diff codeball entries against local files"
option :output_dir, short: "-o",
value: { type: String, default: "." },
- desc: "Output directory"
-
- option :dry_run, short: "-n",
- desc: "Preview extraction without writing files"
+ desc: "Directory to compare against"
argument :file, required: false,
- desc: "Bundle file (or stdin if omitted)"
+ desc: "Codeball file (or stdin if omitted)"
examples [
"bundle.txt",
- "-n bundle.txt",
- "< bundle.txt"
+ "< bundle.txt",
]
def run(file = nil)
- config = Config.new(
- border: options[:border],
- border_width: options[:border_width]
- )
+ input = read_input(file)
+ ball = Ball.parse(input)
- ARGV.replace(file ? [file] : [])
- input = ARGF.read
+ ball.each_warning { |msg| stderr.puts colors.yellow("warning: #{msg}") }
+
+ # Diff output not yet implemented
+ end
- if input.nil? || input.strip.empty?
- print_error "no input"
- end
+ private
- bundle = Bundle.parse(input, config: config)
+ def read_input(file)
+ ARGV.replace(file ? [file] : [])
+ input = ARGF.read
- # Print parse warnings
- bundle.parse_errors.each do |msg|
- stderr.puts colors.yellow("warning: #{msg}")
- end
+ return input unless input.nil? || input.strip.empty?
- # Extract and print results
- summary = bundle.extract
- print_results(summary.results, config.dry_run)
- print_summary(summary, config.dry_run)
+ print_error "no input"
+ exit 1
end
end
end
diff --git a/lib/codeball/commands/list.rb b/lib/codeball/commands/list.rb
index 293bba4..1387229 100644
--- a/lib/codeball/commands/list.rb
+++ b/lib/codeball/commands/list.rb
@@ -1,117 +1,38 @@
-# frozen_string_literal: true
-
-require 'command_kit/commands/command'
-require 'command_kit/printing/tables'
-require 'command_kit/colors'
-require 'command_kit/open'
-
-module CommandKit
- ##
- # Extends +CommandKit::Printing+ with color-aware table printing.
- #
- # Computes column widths from raw text, then applies ANSI color
- # after padding so escape sequences don't break alignment.
- module Printing
- def print_table_color(rows, header: nil, color: :green, index: 0, **)
- all_rows = header ? [header] + rows : rows
- widths = column_widths(all_rows)
- print_header(header, widths) if header
- rows.each do |row|
- line = format_row(row, widths, color, index)
- puts line.join(' ')
- end
- end
-
- private
-
- def print_header(header, widths)
- line = header.each_with_index.map do |cell, i|
- colors.bold(cell.to_s.ljust(widths[i]))
- end
- puts line.join(' ')
- end
-
- def format_row(row, widths, color, index)
- row.each_with_index.map do |cell, i|
- padded = cell.to_s.ljust(widths[i])
- i == index ? colors.public_send(color, padded) : padded
- end
- end
-
- def column_widths(rows)
- rows.each_with_object(Hash.new(0)) do |row, widths|
- row.each_with_index do |cell, i|
- len = cell.to_s.length
- widths[i] = len if len > widths[i]
- end
- end
- end
- end
-end
-
-module CommandKit
- ##
- # Opens readable arguments as IO streams, defaulting to stdin.
- # Uses CommandKit::Open#open to handle filenames and +"-"+ for stdin.
- module CombinedIO
- include CommandKit::Open
-
- def self.included(base)
- base.prepend Prepended
- end
-
- ##
- # Prepends +run+ to open file arguments (or stdin) as IO streams.
- module Prepended
- def run(*args)
- args << '-' if args.empty?
- # rubocop:disable Security/Open -- delegates to CommandKit::Open#open, not Kernel#open
- ios = args.map { |readable| open(readable) }
- # rubocop:enable Security/Open
- begin
- super(*ios)
- ensure
- ios.each(&:close)
- end
- end
- end
- end
-end
+require "command_kit/commands/command"
+require "command_kit/printing/tables"
+require "command_kit/colors"
+require_relative "../../command_kit/printing"
+require_relative "../../command_kit/combined_io"
module Codeball
module Commands
- ##
- # Lists files contained in a codeball bundle.
+ # List files contained in a codeball.
class List < CommandKit::Commands::Command
include CommandKit::CombinedIO
include CommandKit::Colors
include CommandKit::Printing::Tables
- usage '[options] [FILE]'
- description 'List files in a bundle'
-
- option :show_border, short: '-b', desc: 'Show detected border pattern'
+ usage "[options] [FILE]"
+ description "List files in a codeball"
- argument :file, required: false, desc: 'Bundle file (or stdin if omitted)'
+ argument :file, required: false, desc: "Codeball file (or stdin if omitted)"
- examples ['bundle.txt', '-b bundle.txt', '< bundle.txt']
+ examples ["bundle.txt", "< bundle.txt"]
- ##
- # Forces ANSI color support even when stdout is not a TTY
- # (e.g. when piped from +codeball pack+).
def env
- (super || {}).merge('TERM' => '1')
+ (super || {}).merge("TERM" => "1")
end
def run(io)
input = io.read
abort_if_empty(input)
- print_border(input) if options[:show_border]
- bundle = Bundle.parse(input, config: Config.default)
- print_warnings(bundle.parse_errors)
+ ball = Ball.parse(input)
- rows = bundle.entries.map { |e| [e.path, "#{e.line_count} lines"] }
+ ball.each_warning { |msg| stderr.puts colors.yellow("warning: #{msg}") }
+
+ rows = []
+ ball.each_entry { |e| rows << [e.path, "#{e.line_count} lines"] }
print_table_color(rows, header: %w[File Lines], color: :green, index: 0)
end
@@ -120,19 +41,9 @@ def run(io)
def abort_if_empty(input)
return unless input.nil? || input.strip.empty?
- print_error 'no input'
+ print_error "no input"
exit 1
end
-
- def print_border(input)
- border = Bundle.detect_border(input)
- puts "#{colors.bold('border')}: #{border.inspect}" if border
- puts
- end
-
- def print_warnings(errors)
- errors.each { |msg| stderr.puts colors.yellow("warning: #{msg}") }
- end
end
end
end
diff --git a/lib/codeball/commands/pack.rb b/lib/codeball/commands/pack.rb
index c072091..007c0d2 100644
--- a/lib/codeball/commands/pack.rb
+++ b/lib/codeball/commands/pack.rb
@@ -2,73 +2,52 @@
module Codeball
module Commands
- # Pack multiple files into a single clipboard-friendly bundle.
- #
- # Reads files from disk and serializes them into a bordered text format
- # suitable for pasting into LLM context windows.
+ # Pack files into a codeball for clipboard transfer.
#
class Pack < CommandKit::Commands::Command
-
usage "[options] FILE..."
- description "Bundle files into a single stream for clipboard transfer"
-
- option :border, short: "-b",
- value: { type: String, default: "---\t" },
- desc: "The border pattern repeated between sections."
+ description "Pack files into a codeball for clipboard transfer"
- option :border_width, short: "-w",
- value: { type: Integer, default: 10 },
- desc: "How many times to repeat the border pattern"
-
- option :quiet, short: '-q', long: '--quiet', desc: "Suppress non-error output"
+ option :quiet, short: "-q", long: "--quiet", desc: "Suppress non-error output"
argument :files, required: true,
repeats: true,
- desc: "Files to pack into bundle"
+ desc: "Files to pack"
examples [
"lib/*.rb",
- "src/**/*.py --border '###'",
- "-w 5 README.md lib/*.rb"
+ "src/**/*.py",
+ "README.md lib/*.rb",
]
def run(*files)
- if files.empty?
- print_error "no files specified"
- exit 1
- end
-
readable, unreadable = validate_files(files)
- bundle = Bundle.from_files(readable, config: build_config)
+ ball = Ball.new
+ readable.each do |path|
+ entry = Entry.from_file(path)
+ ball.add_entry(entry) if entry
+ end
- warn_skipped(unreadable, bundle.non_text_entries)
- bundle.serialize
+ warn_skipped(unreadable, ball)
+ puts ball.serialize
- exit 1 if unreadable.any? || bundle.non_text_entries.any?
+ exit 1 if unreadable.any? || !ball.all_text?
end
private
- def build_config
- Config.new(
- border: options[:border],
- border_width: options[:border_width],
- output_dir: ".",
- dry_run: false
- )
- end
-
def validate_files(files)
files
.map { Pathname(it) }
.partition { it.exist? && it.readable? }
end
- def warn_skipped(unreadable, non_text)
- return if options[:quiet]
+ def warn_skipped(unreadable, ball)
+ return if options[:quiet]
+
unreadable.each { print_error "cannot read file: #{it}" }
- non_text.each { print_error "skipping non-text file: #{it.path} (#{it.mime_type})" }
+ ball.each_non_text_entry { |entry| print_error "skipping non-text file: #{entry.path} (#{entry.mime_type})" }
end
end
end
diff --git a/lib/codeball/commands/unpack.rb b/lib/codeball/commands/unpack.rb
index 6e2b828..54a1a9c 100644
--- a/lib/codeball/commands/unpack.rb
+++ b/lib/codeball/commands/unpack.rb
@@ -3,21 +3,13 @@
module Codeball
module Commands
- # Extract files from a codeball bundle.
+ # Extract files from a codeball.
#
class Unpack < CommandKit::Commands::Command
include CommandKit::Colors
usage "[options] [FILE]"
- description "Extract files from a bundle"
-
- option :border, short: "-b",
- value: { type: String, default: "---\t" },
- desc: "Border pattern"
-
- option :border_width, short: "-w",
- value: { type: Integer, default: 10 },
- desc: "Border repetitions"
+ description "Extract files from a codeball"
option :output_dir, short: "-o",
value: { type: String, default: "." },
@@ -26,84 +18,105 @@ class Unpack < CommandKit::Commands::Command
option :dry_run, short: "-n",
desc: "Preview extraction without writing files"
- option :quiet, short: '-q', long: '--quiet', desc: "Suppress non-error output"
+ option :quiet, short: "-q", long: "--quiet", desc: "Suppress non-error output"
argument :file, required: false,
- desc: "Bundle file (or stdin if omitted)"
+ desc: "Codeball file (or stdin if omitted)"
examples [
"bundle.txt",
"-n bundle.txt",
"-o extracted/ bundle.txt",
- "< bundle.txt"
+ "< bundle.txt",
]
def run(file = nil)
- config = Config.new(
- border: options[:border],
- border_width: options[:border_width],
- output_dir: options[:output_dir],
- dry_run: options[:dry_run] || false
- )
+ ball = Ball.parse(read_input(file))
+ dest = build_destination
- ARGV.replace(file ? [file] : [])
- input = ARGF.read
+ ball.each_warning { |msg| warn colors.yellow("warning: #{msg}") }
+ ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } }
- if input.nil? || input.strip.empty?
- print_error "no input"
- exit 1
- end
+ print_summary(dest.summary(malformed: ball.warning_count))
+ end
+
+ private
- bundle = Bundle.parse(input, config: config)
+ def build_destination
+ Destination.new(options[:output_dir], dry_run: options[:dry_run])
+ end
- # Print parse warnings
- bundle.parse_errors.each do |msg|
- warn colors.yellow("warning: #{msg}")
- end
+ def read_input(file)
+ ARGV.replace(file ? [file] : [])
+ input = ARGF.read
- # Extract and print results
- summary = bundle.extract
- print_results(summary.results, config.dry_run)
- print_summary(summary, config.dry_run)
+ abort_on_empty(input)
+ input
end
- private
+ def abort_on_empty(input)
+ return unless input.nil? || input.strip.empty?
+
+ print_error "no input"
+ exit 1
+ end
def puts(...)
return if options[:quiet]
+
stdout.puts(...)
end
def warn(...)
return if options[:quiet]
+
stderr.puts(...)
end
- def print_results(results, dry_run)
- results.each do |result|
- case result.status
- when :written
- puts "#{colors.green('wrote')}: #{result.path} (#{result.size} lines)"
- when :dry_run
- puts "#{colors.cyan('[dry-run]')} would write: #{result.path} (#{result.size} lines)"
- when :unsafe
- warn colors.yellow("warning: skipping unsafe path #{result.path.inspect}")
- when :failed
- warn colors.red("error: #{result.path}: #{result.error}")
- end
+ def print_outcome(outcome)
+ case outcome.status
+ when :written then print_written(outcome)
+ when :dry_run then print_dry_run(outcome)
+ when :unsafe then print_unsafe(outcome)
+ when :failed then print_failed(outcome)
end
end
- def print_summary(summary, dry_run)
- prefix = dry_run ? "#{colors.cyan('[dry-run]')} " : ""
+ def print_written(outcome)
+ puts "#{colors.green("wrote")}: #{outcome.path} (#{outcome.line_count} lines)"
+ end
+
+ def print_dry_run(outcome)
+ puts "#{colors.cyan("[dry-run]")} would write: #{outcome.path} (#{outcome.line_count} lines)"
+ end
+
+ def print_unsafe(outcome)
+ warn colors.yellow("warning: skipping unsafe path #{outcome.path.inspect}")
+ end
+
+ def print_failed(outcome)
+ warn colors.red("error: #{outcome.path}: #{outcome.error}")
+ end
+
+ def print_summary(summary)
+ prefix = summary.dry_run? ? "#{colors.cyan("[dry-run]")} " : ""
puts "---"
+ puts "#{prefix}#{summary_parts(summary).join(", ")}"
+ end
- parts = []
- parts << "#{colors.green("extracted: #{summary.extracted}")}"
- parts << (summary.skipped > 0 ? colors.yellow("skipped: #{summary.skipped}") : "skipped: 0")
- parts << colors.yellow("malformed: #{summary.malformed}") if summary.malformed > 0
+ def summary_parts(summary)
+ parts = [colors.green("extracted: #{summary.extracted}").to_s]
+ parts << skipped_part(summary)
+ parts << colors.yellow("malformed: #{summary.malformed}") if summary.malformed.positive?
+ parts
+ end
- puts "#{prefix}#{parts.join(', ')}"
+ def skipped_part(summary)
+ if summary.skipped.positive?
+ colors.yellow("skipped: #{summary.skipped}")
+ else
+ "skipped: 0"
+ end
end
end
end
diff --git a/lib/codeball/config.rb b/lib/codeball/config.rb
deleted file mode 100644
index 5335d3b..0000000
--- a/lib/codeball/config.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-module Codeball
- # Configuration for bundle format and extraction behavior.
- #
- # ## Examples
- #
- # Using default configuration:
- #
- # ```ruby
- # config = Config.default
- # config.full_border # => "---\t---\t---\t..." (repeated 10 times)
- # ```
- #
- # Custom border for markdown-heavy codebases:
- #
- # ```ruby
- # config = Config.new(border: "~~~", border_width: 5, output_dir: ".", dry_run: false)
- # ```
- #
- Config = Struct.new(:border, :border_width, :output_dir, :dry_run, keyword_init: true) do
- # The complete border string used to delimit sections in a bundle.
- # Returns the border pattern repeated `border_width` times.
- def full_border
- border * border_width
- end
-
- # The character used to ensure proper line termination.
- # Derived from the last character of the border pattern.
- def terminator
- border.chars.last
- end
- end
-
- Config::DEFAULTS = {
- border: "---\t",
- border_width: 10,
- output_dir: ".",
- dry_run: false
- }.freeze
-
- # Returns a new Config with sensible defaults.
- def Config.default
- new(**Config::DEFAULTS)
- end
-end
diff --git a/lib/codeball/cursor.rb b/lib/codeball/cursor.rb
new file mode 100644
index 0000000..a886fbd
--- /dev/null
+++ b/lib/codeball/cursor.rb
@@ -0,0 +1,115 @@
+module Codeball
+ # A lexer for codeball-formatted text.
+ #
+ # Cursor walks text line by line and classifies each meaningful
+ # element as a typed token: Header, Body, or Footer. Borders are
+ # delimiters consumed internally -- they are never yielded.
+ #
+ # Cursor does not correlate tokens or enforce sequencing.
+ # Stream handles assembly; Entry enforces invariants.
+ #
+ class Cursor
+ BEGIN_PATTERN = /\ABEGIN\s+["']?(.+?)["']?\s*\z/
+ END_PATTERN = /\AEND\s+["']?(.+?)["']?\s*\z/
+
+ # Sentinel returned when all tokens have been consumed.
+ module EOF; end
+
+ def initialize(text)
+ @lines = text.lines
+ @position = 0
+ @pending_footer = nil
+ @body_lines = nil
+ end
+
+ def next_item
+ return emit_footer if @pending_footer
+
+ skip_borders
+ return EOF if finished?
+
+ if @body_lines
+ read_body
+ else
+ read_header_or_eof
+ end
+ end
+
+ private
+
+ attr_reader :lines, :position
+
+ def finished? = position >= lines.length
+ def current_line = lines[position]&.strip
+ def raw_line = lines[position]
+
+ def advance
+ @position += 1
+ end
+
+ def peek_line
+ lines[position + 1]&.strip
+ end
+
+ def skip_borders
+ advance while !finished? && Border.recognize?(current_line)
+ end
+
+ def read_header_or_eof
+ match = current_line&.match(BEGIN_PATTERN)
+ return EOF unless match
+
+ advance
+ skip_borders
+ @body_lines = []
+ Header.new(match[1])
+ end
+
+ def read_body
+ collect_body_lines
+ body = Body.new(Border.strip_suffix(@body_lines.join))
+ @body_lines = nil
+ body
+ end
+
+ def collect_body_lines
+ until finished?
+ return found_end_after_border if border_before_end?
+ return found_end_inline if inline_end_marker?
+
+ @body_lines << raw_line
+ advance
+ end
+ end
+
+ def border_before_end?
+ Border.recognize?(current_line) &&
+ peek_line&.match?(END_PATTERN)
+ end
+
+ def inline_end_marker?
+ return false unless current_line&.match?(END_PATTERN)
+
+ @body_lines.empty? || @body_lines.last&.match?(Border::SUFFIX)
+ end
+
+ def found_end_inline
+ match = current_line.match(END_PATTERN)
+ @pending_footer = match[1]
+ advance
+ end
+
+ def found_end_after_border
+ advance
+ end_match = current_line.match(END_PATTERN)
+ @pending_footer = end_match[1]
+ advance
+ end
+
+ def emit_footer
+ path = @pending_footer
+ @pending_footer = nil
+ Footer.new(path)
+ end
+ end
+end
diff --git a/lib/codeball/destination.rb b/lib/codeball/destination.rb
new file mode 100644
index 0000000..7103728
--- /dev/null
+++ b/lib/codeball/destination.rb
@@ -0,0 +1,76 @@
+require "pathname"
+
+module Codeball
+ # A filesystem context that writes entries to an output directory.
+ #
+ # Destination decorates a directory path with the ability to receive
+ # codeball entries. It owns path safety validation, parent directory
+ # creation, and dry-run simulation.
+ #
+ # Tracks outcomes internally and provides a summary when asked.
+ #
+ class Destination
+ DANGEROUS_PATTERNS = [
+ /\A\.\./,
+ %r{/\.\.},
+ %r{\A/},
+ /\A~/,
+ ].freeze
+
+ attr_reader :output_dir
+
+ def initialize(output_dir = ".", dry_run: false)
+ @output_dir = Pathname.new(output_dir).expand_path
+ @dry_run = dry_run ? true : false
+ @results = []
+ end
+
+ def dry_run? = @dry_run
+
+ def write(entry)
+ outcome = write_entry(entry)
+ @results << outcome
+ yield outcome if block_given?
+ outcome
+ end
+
+ def summary(malformed: 0)
+ ExtractionSummary.new(@results, malformed: malformed)
+ end
+
+ private
+
+ def write_entry(entry)
+ return unsafe_result(entry) unless safe_path?(entry.path)
+
+ resolved = resolve(entry.path)
+ dry_run? ? dry_run_result(entry, resolved) : persist(entry, resolved)
+ rescue SystemCallError => e
+ ExtractionResult.new(path: entry.path, error: e.message, status: :failed)
+ end
+
+ def safe_path?(path)
+ return false if DANGEROUS_PATTERNS.any? { |pattern| path.match?(pattern) }
+
+ resolve(path).to_s.start_with?(output_dir.to_s)
+ end
+
+ def resolve(path)
+ (output_dir / path).expand_path
+ end
+
+ def unsafe_result(entry)
+ ExtractionResult.new(path: entry.path, status: :unsafe)
+ end
+
+ def dry_run_result(entry, resolved)
+ ExtractionResult.new(path: resolved, line_count: entry.line_count, status: :dry_run)
+ end
+
+ def persist(entry, resolved)
+ resolved.parent.mkpath
+ resolved.write(entry.contents)
+ ExtractionResult.new(path: resolved, line_count: entry.line_count, status: :written)
+ end
+ end
+end
diff --git a/lib/codeball/entry.rb b/lib/codeball/entry.rb
index 105ad77..c3b576e 100644
--- a/lib/codeball/entry.rb
+++ b/lib/codeball/entry.rb
@@ -1,94 +1,107 @@
require "pathname"
-require 'filemagic'
+require "filemagic"
module Codeball
- # A single file entry within a bundle, with path and contents.
+ # A single file within a codeball.
#
- # Entry is the atomic unit of a bundle. It knows how to read itself from disk,
- # validate its path for safe extraction, and write itself to an output directory.
+ # Entry is a state machine with write-once setters for header, body,
+ # and footer. It enforces the Header -> Body -> Footer sequence by
+ # rejecting duplicate assignments and detecting mismatched footers.
+ #
+ # Two construction paths, same invariants:
+ # 1. Token-by-token via Stream (parsing)
+ # 2. All-at-once via Entry.from_file (packing)
#
class Entry
- attr_reader :path, :contents
+ attr_reader :header, :body, :footer, :error
- # Reads a file from disk and wraps it in an Entry.
- # Returns `nil` if the file doesn't exist or isn't readable.
def self.from_file(path)
- path = Pathname.new(path)
- return nil unless path.exist? && path.readable?
-
- new(path: path.to_s, contents: path.read)
+ 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
end
def self.magic_client
@magic_client ||= FileMagic.mime
end
- def initialize(path:, contents:, magic_client: nil)
- raise ArgumentError, "Path must be present" if path.nil? || path.strip.empty?
- @path = path
- @contents = contents
- @magic_client = magic_client || self.class.magic_client
+ def initialize
+ @header = nil
+ @body = nil
+ @footer = nil
+ @error = nil
+ @magic_client = self.class.magic_client
end
- def empty? = contents.empty?
- def byte_size = contents.bytesize
-
- def line_count
- return 0 if contents.empty?
-
- contents.count("\n") + (contents.end_with?("\n") ? 0 : 1)
+ def header=(header)
+ if @header
+ @error = "duplicate header: already have #{@header}, received #{header}"
+ return
+ end
+ @header = header
end
- def text?
- contents.empty? || !mime_type.include?("charset=binary")
+ def body=(body)
+ if @body
+ @error = "duplicate body for #{path}"
+ return
+ end
+ @body = body
end
- def serialize(border)
- header = "#{border}\nBEGIN #{path.inspect}\n#{border}\n"
- footer = "#{border}\nEND #{path.inspect}\n#{border}\n"
- "#{header}#{contents}#{footer}"
+ 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 mime_type
- @mime_type ||= @magic_client.buffer(@contents)
- end
+ def valid? = !!(header && body && footer && !errors? && footer_matches_header?)
+ def incomplete? = !valid? && !errors?
+ def errors? = !error.nil?
+ def truncated? = !!(header && (body.nil? || footer.nil?) && !errors?)
- def safe_for?(output_dir)
- dangerous_patterns = [
- /\A\.\./, # starts with ..
- %r{/\.\.}, # contains /..
- %r{\A/}, # absolute path
- /\A~/ # home directory expansion
- ]
+ def path = header&.to_s
+ def contents = body&.to_s
- return false if dangerous_patterns.any? { |pattern| path.match?(pattern) }
+ def empty? = contents&.empty? || contents.nil?
+ def byte_size = contents&.bytesize || 0
- resolved_path(output_dir).to_s.start_with?(output_dir.to_s)
- end
+ def line_count
+ return 0 if contents.nil? || contents.empty?
- def resolved_path(output_dir)
- (output_dir / path).expand_path
+ contents.count("\n") + (contents.end_with?("\n") ? 0 : 1)
end
- # Writes this entry to disk. Returns an ExtractionResult.
- def write_to(output_dir, dry_run: false)
- return ExtractionResult.new(path: path, status: :unsafe) unless safe_for?(output_dir)
+ def text?
+ contents.nil? || contents.empty? || !mime_type.include?("charset=binary")
+ end
- resolved = resolved_path(output_dir)
+ def serialize
+ border = Border::SEPARATOR
+ "#{border}\nBEGIN #{path.inspect}\n#{border}\n#{contents}#{border}\nEND #{path.inspect}\n#{border}\n"
+ end
- if dry_run
- ExtractionResult.new(path: resolved, size: line_count, status: :dry_run)
- else
- resolved.parent.mkpath
- resolved.write(contents)
- ExtractionResult.new(path: resolved, size: line_count, status: :written)
- end
- rescue SystemCallError => e
- ExtractionResult.new(path: path, error: e.message, status: :failed)
+ def mime_type
+ @mime_type ||= @magic_client.buffer(contents)
end
private
attr_reader :magic_client
+
+ def footer_matches_header?
+ return true unless header && footer
+
+ header.to_s == footer.to_s
+ end
end
end
diff --git a/lib/codeball/extraction_result.rb b/lib/codeball/extraction_result.rb
index d84f2fd..58cefb1 100644
--- a/lib/codeball/extraction_result.rb
+++ b/lib/codeball/extraction_result.rb
@@ -1,25 +1,16 @@
module Codeball
- # Represents the outcome of extracting a single entry from a bundle.
+ # Represents the outcome of extracting a single entry from a codeball.
#
# ## Example
#
# ```ruby
- # result = entry.write_to(output_dir)
- # if result.success?
# puts "Wrote #{result.path}"
- # else
# puts "Failed: #{result.error}"
- # end
# ```
#
- ExtractionResult = Struct.new(:path, :size, :status, :error, keyword_init: true) do
+ ExtractionResult = Struct.new(:path, :line_count, :status, :error) do
# Whether the extraction completed successfully.
# Both actual writes and dry-run simulations count as success.
- def success? = status.in?(%i[written dry_run])
+ def success? = %i[written dry_run].include?(status)
end
end
-
-# Add in? to Symbol for cleaner predicate
-class Symbol
- def in?(collection) = collection.include?(self)
-end
diff --git a/lib/codeball/extraction_summary.rb b/lib/codeball/extraction_summary.rb
index 3ebbb82..d282365 100644
--- a/lib/codeball/extraction_summary.rb
+++ b/lib/codeball/extraction_summary.rb
@@ -11,6 +11,7 @@ def initialize(results, malformed: 0)
end
def extracted = results.count(&:success?)
- def skipped = results.count { !_1.success? }
+ def skipped = results.count { !it.success? }
+ def dry_run? = results.any? { it.status == :dry_run }
end
end
diff --git a/lib/codeball/footer.rb b/lib/codeball/footer.rb
new file mode 100644
index 0000000..3c8a980
--- /dev/null
+++ b/lib/codeball/footer.rb
@@ -0,0 +1,9 @@
+require "delegate"
+
+module Codeball
+ # A file path extracted from an END marker in a codeball.
+ #
+ # String wrapper providing identity for pattern matching.
+ #
+ class Footer < SimpleDelegator; end
+end
diff --git a/lib/codeball/header.rb b/lib/codeball/header.rb
new file mode 100644
index 0000000..d54f960
--- /dev/null
+++ b/lib/codeball/header.rb
@@ -0,0 +1,9 @@
+require "delegate"
+
+module Codeball
+ # A file path extracted from a BEGIN marker in a codeball.
+ #
+ # String wrapper providing identity for pattern matching.
+ #
+ class Header < SimpleDelegator; end
+end
diff --git a/lib/codeball/malformed_ball_error.rb b/lib/codeball/malformed_ball_error.rb
new file mode 100644
index 0000000..7dd6d05
--- /dev/null
+++ b/lib/codeball/malformed_ball_error.rb
@@ -0,0 +1,3 @@
+module Codeball
+ class MalformedBallError < Error; end
+end
diff --git a/lib/codeball/malformed_bundle_error.rb b/lib/codeball/malformed_bundle_error.rb
deleted file mode 100644
index 38be990..0000000
--- a/lib/codeball/malformed_bundle_error.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-module Codeball
- class MalformedBundleError < Error; end
-end
diff --git a/lib/codeball/stream.rb b/lib/codeball/stream.rb
new file mode 100644
index 0000000..b32d4f7
--- /dev/null
+++ b/lib/codeball/stream.rb
@@ -0,0 +1,60 @@
+module Codeball
+ # Assembles Entry objects from a stream of tokens produced by Cursor.
+ #
+ # Stream pulls tokens one at a time, feeds them to the current Entry,
+ # and emits it when Entry reports valid or errored. Stream does not
+ # know the Header -> Body -> Footer rules -- Entry enforces those
+ # through its write-once setters.
+ #
+ # Nothing is discarded. Every entry -- valid, errored, or truncated
+ # -- is emitted so the consumer can decide what to do with it.
+ #
+ class Stream
+ include Enumerable
+
+ def initialize(cursor:)
+ @cursor = cursor
+ new_entry
+ end
+
+ def each(&block)
+ return enum_for(:each) unless block
+
+ consume_tokens(&block)
+ emit_incomplete(&block)
+ end
+
+ alias each_entry each
+
+ private
+
+ attr_reader :cursor
+
+ def consume_tokens
+ while (item = cursor.next_item) != Cursor::EOF
+ feed(item)
+
+ if @current_entry.valid? || @current_entry.errors?
+ yield @current_entry
+ new_entry
+ end
+ end
+ end
+
+ def emit_incomplete
+ yield @current_entry if @current_entry&.incomplete? && @current_entry.header
+ end
+
+ def new_entry
+ @current_entry = Entry.new
+ end
+
+ def feed(item)
+ case item
+ in Header => header then @current_entry.header = header
+ in Body => body then @current_entry.body = body
+ in Footer => footer then @current_entry.footer = footer
+ end
+ end
+ end
+end
diff --git a/lib/command_kit/combined_io.rb b/lib/command_kit/combined_io.rb
new file mode 100644
index 0000000..6bb4939
--- /dev/null
+++ b/lib/command_kit/combined_io.rb
@@ -0,0 +1,28 @@
+require "command_kit/open"
+
+module CommandKit
+ # Opens readable arguments as IO streams, defaulting to stdin.
+ # Uses CommandKit::Open#open to handle filenames and +"-"+ for stdin.
+ module CombinedIO
+ include CommandKit::Open
+
+ def self.included(base)
+ base.prepend Prepended
+ end
+
+ # Prepends +run+ to open file arguments (or stdin) as IO streams.
+ module Prepended
+ def run(*args)
+ args << "-" if args.empty?
+
+ ios = args.map { |readable| self.open(readable) }
+
+ begin
+ super(*ios)
+ ensure
+ ios.each(&:close)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_kit/printing.rb b/lib/command_kit/printing.rb
new file mode 100644
index 0000000..f625c97
--- /dev/null
+++ b/lib/command_kit/printing.rb
@@ -0,0 +1,42 @@
+module CommandKit
+ # Extends +CommandKit::Printing+ with color-aware table printing.
+ #
+ # Computes column widths from raw text, then applies ANSI color
+ # after padding so escape sequences don't break alignment.
+ module Printing
+ def print_table_color(rows, header: nil, color: :green, index: 0, **)
+ all_rows = header ? [header] + rows : rows
+ widths = column_widths(all_rows)
+ print_header(header, widths) if header
+ rows.each do |row|
+ line = format_row(row, widths, color, index)
+ puts line.join(" ")
+ end
+ end
+
+ private
+
+ def print_header(header, widths)
+ line = header.each_with_index.map do |cell, i|
+ colors.bold(cell.to_s.ljust(widths[i]))
+ end
+ puts line.join(" ")
+ end
+
+ def format_row(row, widths, color, index)
+ row.each_with_index.map do |cell, i|
+ padded = cell.to_s.ljust(widths[i])
+ i == index ? colors.public_send(color, padded) : padded
+ end
+ end
+
+ def column_widths(rows)
+ rows.each_with_object(Hash.new(0)) do |row, widths|
+ row.each_with_index do |cell, i|
+ len = cell.to_s.length
+ widths[i] = len if len > widths[i]
+ end
+ end
+ end
+ end
+end
diff --git a/rakelib/version.rake b/rakelib/version.rake
index 21d2334..266a768 100644
--- a/rakelib/version.rake
+++ b/rakelib/version.rake
@@ -1,57 +1,76 @@
-namespace :version do
- version_path = File.expand_path("../lib/codeball/version.rb", __dir__)
+VERSION_PATTERN = /VERSION\s*=\s*"(\d+\.\d+\.\d+)"/
+
+VERSION_FILE = File.expand_path("../lib/codeball/version.rb", __dir__)
+namespace :version do
desc "Display the current version"
- task :current do
- require_relative "../lib/codeball/version"
- puts "Current version: #{Codeball::VERSION}"
- end
+ task(:current) { print_current_version }
desc "Bump the patch version"
- task :bump do
- File.open(version_path, File::RDWR, 0o644) do |f|
- f.flock(File::LOCK_EX)
- source = f.read
- match = source.match(/VERSION\s*=\s*"(\d+\.\d+\.\d+)"/)
+ task(:bump) { bump_version(VERSION_FILE) }
- abort "Could not find VERSION in #{version_path}" unless match
+ desc "Commit the version change"
+ task(:commit) { commit_version(VERSION_FILE) }
- old_version = match[1]
- parts = old_version.split(".").map(&:to_i)
- parts[-1] += 1
- new_version = parts.join(".")
+ desc "Revert the last version bump commit"
+ task(:revert) { revert_version_bump }
+end
- new_source = source.sub(/VERSION\s*=\s*"#{Regexp.escape(old_version)}"/, "VERSION = \"#{new_version}\"")
+namespace :release do
+ desc "Bump version, commit, and release"
+ task full: ["version:bump", "version:commit", :release]
+end
- f.rewind
- f.write(new_source)
- f.truncate(f.pos)
+def print_current_version
+ require_relative "../lib/codeball/version"
+ puts "Current version: #{Codeball::VERSION}"
+end
- puts "Version bumped from #{old_version} to #{new_version}"
- end
- end
+def commit_version(version_path)
+ require_relative "../lib/codeball/version"
+ sh "git add #{version_path}"
+ sh "git commit -m 'Bump version to #{Codeball::VERSION}'"
+ puts "Version change committed."
+end
- desc "Commit the version change"
- task :commit do
- require_relative "../lib/codeball/version"
- sh "git add #{version_path}"
- sh "git commit -m 'Bump version to #{Codeball::VERSION}'"
- puts "Version change committed."
- end
+def bump_version(version_path)
+ source = read_locked(version_path)
+ match = source.match(VERSION_PATTERN)
+ abort "Could not find VERSION in #{version_path}" unless match
- desc "Revert the last version bump commit"
- task :revert do
- last_message = `git log -1 --pretty=%B`.strip
- if last_message.start_with?("Bump version to ")
- sh "git revert HEAD --no-edit"
- puts "Version bump reverted."
- else
- abort "Last commit does not appear to be a version bump."
- end
+ old_version = match[1]
+ new_version = increment_patch(old_version)
+ write_locked(version_path, source, old_version, new_version)
+ puts "Version bumped from #{old_version} to #{new_version}"
+end
+
+def read_locked(path)
+ File.open(path, "r") do |f|
+ f.flock(File::LOCK_SH)
+ f.read
end
end
-namespace :release do
- desc "Bump version, commit, and release"
- task full: ["version:bump", "version:commit", :release]
+def write_locked(path, source, old_ver, new_ver)
+ new_source = source.sub(
+ /VERSION\s*=\s*"#{Regexp.escape(old_ver)}"/,
+ "VERSION = \"#{new_ver}\"",
+ )
+ File.write(path, new_source)
+end
+
+def increment_patch(version)
+ parts = version.split(".").map(&:to_i)
+ parts[-1] += 1
+ parts.join(".")
+end
+
+def revert_version_bump
+ last_message = `git log -1 --pretty=%B`.strip
+ if last_message.start_with?("Bump version to ")
+ sh "git revert HEAD --no-edit"
+ puts "Version bump reverted."
+ else
+ abort "Last commit does not appear to be a version bump."
+ end
end
diff --git a/scripts/codeball_xtract b/scripts/codeball_xtract
new file mode 100755
index 0000000..b0a4249
--- /dev/null
+++ b/scripts/codeball_xtract
@@ -0,0 +1,42 @@
+#!/usr/bin/env zsh
+
+typeset -A opt_args
+
+zparseopts \
+ -D \
+ -E \
+ -K \
+ -A \
+ opt_args \
+ -F \
+ - \
+ -envelope
+
+(( ? != 0 )) && return 1
+
+filter() {
+ local pattern1=${1:?}
+ {
+ if (( $+opt_args[--envelope] )); then
+ print -u2 envelope
+ noglob sed -n "/BEGIN ${(qqq)pattern1}/,/END/p"
+ else
+ noglob sed -n "/BEGIN ${(qqq)pattern1}/,/END/{//d;p}"
+ fi
+ } \
+ | sed '1d;$d'
+}
+
+ codeball_xtract () {
+ emulate -L zsh
+ setopt pipefail
+ setopt errreturn
+ setopt warnnestedvar
+ setopt warncreateglobal
+ local matcher=${1:?}
+ local file=${2:-/dev/stdin}
+ local pattern1="[^\"]*${matcher}[^\"]*"
+ filter $pattern1 < $file
+}
+
+codeball_xtract ${@}
diff --git a/spec/codeball/ball_spec.rb b/spec/codeball/ball_spec.rb
new file mode 100644
index 0000000..f29deb3
--- /dev/null
+++ b/spec/codeball/ball_spec.rb
@@ -0,0 +1,300 @@
+require "codeball"
+
+RSpec.describe Codeball::Ball do
+ def valid_entry(path: "hello.rb", contents: "puts 'hello'\n")
+ entry = Codeball::Entry.new
+ entry.header = Codeball::Header.new(path)
+ entry.body = Codeball::Body.new(contents)
+ entry.footer = Codeball::Footer.new(path)
+ entry
+ end
+
+ def truncated_entry(path: "orphan.rb")
+ entry = Codeball::Entry.new
+ entry.header = Codeball::Header.new(path)
+ entry
+ end
+
+ def errored_entry
+ entry = Codeball::Entry.new
+ entry.header = Codeball::Header.new("first.rb")
+ entry.header = Codeball::Header.new("second.rb")
+ entry
+ end
+
+ def binary_entry
+ entry = valid_entry(path: "image.png", contents: "binary")
+ allow(entry).to receive(:text?).and_return(false)
+ entry
+ end
+
+ def serialize_entry(path, contents)
+ border = Codeball::Border::SEPARATOR
+ "#{border}\nBEGIN #{path.inspect}\n#{border}\n#{contents}#{border}\nEND #{path.inspect}\n#{border}\n"
+ end
+
+ let(:ball_text) do
+ serialize_entry("hello.rb", "puts 'hello'\n") +
+ serialize_entry("lib/greet.rb", "def greet\n 'hi'\nend\n")
+ end
+
+ describe ".parse" do
+ context "with valid two-entry codeball text" do
+ let(:ball) { described_class.parse(ball_text) }
+
+ it "returns a Ball" do
+ expect(ball).to be_a(described_class)
+ end
+
+ it "has no warnings" do
+ expect(ball.warning_count).to eq(0)
+ end
+ end
+
+ context "with empty text" do
+ it "raises MalformedBallError" do
+ expect { described_class.parse("") }.to raise_error(Codeball::MalformedBallError, /empty input/)
+ end
+ end
+
+ context "with nil" do
+ it "raises MalformedBallError" do
+ expect { described_class.parse(nil) }.to raise_error(Codeball::MalformedBallError)
+ end
+ end
+
+ context "with whitespace-only text" do
+ it "raises MalformedBallError" do
+ expect { described_class.parse(" \n\n ") }.to raise_error(Codeball::MalformedBallError, /empty input/)
+ end
+ end
+
+ context "with garbage text" do
+ it "raises MalformedBallError" do
+ expect { described_class.parse("not a codeball\n") }
+ .to raise_error(Codeball::MalformedBallError, /no content found/)
+ end
+ end
+
+ context "with a truncated codeball" do
+ let(:truncated_text) do
+ border = Codeball::Border::SEPARATOR
+ complete = serialize_entry("hello.rb", "puts 'hello'\n")
+ incomplete = "#{border}\nBEGIN \"orphan.rb\"\n#{border}\norphan content\n"
+ complete + incomplete
+ end
+ let(:ball) { described_class.parse(truncated_text) }
+
+ it "returns a Ball" do
+ expect(ball).to be_a(described_class)
+ end
+
+ it "has one warning" do
+ expect(ball.warning_count).to eq(1)
+ end
+
+ it "each_warning yields a truncation message" do
+ warnings = []
+ ball.each_warning { |w| warnings << w }
+ expect(warnings.first).to include("truncated")
+ end
+
+ it "each_entry yields only the valid entry" do
+ paths = []
+ ball.each_entry { |e| paths << e.path }
+ expect(paths).to eq(["hello.rb"])
+ end
+ end
+ end
+
+ describe ".new" do
+ let(:ball) { described_class.new }
+
+ it "creates an empty Ball" do
+ entries = []
+ ball.each_entry { |e| entries << e }
+ expect(entries).to be_empty
+ end
+
+ it "has zero warnings" do
+ expect(ball.warning_count).to eq(0)
+ end
+ end
+
+ describe "#add_entry" do
+ let(:ball) { described_class.new }
+
+ context "with a valid entry" do
+ before { ball.add_entry(valid_entry) }
+
+ it "is retrievable via each_entry" do
+ paths = []
+ ball.each_entry { |e| paths << e.path }
+ expect(paths).to eq(["hello.rb"])
+ end
+
+ it "does not add warnings" do
+ expect(ball.warning_count).to eq(0)
+ end
+ end
+
+ context "with an errored entry" do
+ before { ball.add_entry(errored_entry) }
+
+ it "adds the error to warnings" do
+ warnings = []
+ ball.each_warning { |w| warnings << w }
+ expect(warnings.first).to include("duplicate header")
+ end
+
+ it "does not yield via each_entry" do
+ entries = []
+ ball.each_entry { |e| entries << e }
+ expect(entries).to be_empty
+ end
+ end
+
+ context "with a truncated entry" do
+ before { ball.add_entry(truncated_entry) }
+
+ it "adds a truncation warning" do
+ warnings = []
+ ball.each_warning { |w| warnings << w }
+ expect(warnings.first).to include("truncated")
+ end
+
+ it "does not yield via each_entry" do
+ entries = []
+ ball.each_entry { |e| entries << e }
+ expect(entries).to be_empty
+ end
+ end
+ end
+
+ describe "#each_entry" do
+ let(:ball) { described_class.new }
+
+ before do
+ ball.add_entry(valid_entry(path: "hello.rb"))
+ ball.add_entry(valid_entry(path: "lib/greet.rb", contents: "greet\n"))
+ end
+
+ it "yields valid entries in insertion order" do
+ paths = []
+ ball.each_entry { |e| paths << e.path }
+ expect(paths).to eq(["hello.rb", "lib/greet.rb"])
+ end
+
+ it "first yielded entry has path hello.rb" do
+ first = nil
+ ball.each_entry { |e| first ||= e }
+ expect(first.path).to eq("hello.rb")
+ end
+ end
+
+ describe "#each_text_entry" do
+ let(:ball) { described_class.new }
+
+ before do
+ ball.add_entry(valid_entry)
+ ball.add_entry(binary_entry)
+ end
+
+ it "yields only text entries" do
+ paths = []
+ ball.each_text_entry { |e| paths << e.path }
+ expect(paths).to eq(["hello.rb"])
+ end
+ end
+
+ describe "#each_non_text_entry" do
+ let(:ball) { described_class.new }
+
+ before do
+ ball.add_entry(valid_entry)
+ ball.add_entry(binary_entry)
+ end
+
+ it "yields only non-text entries" do
+ paths = []
+ ball.each_non_text_entry { |e| paths << e.path }
+ expect(paths).to eq(["image.png"])
+ end
+ end
+
+ describe "#all_text?" do
+ let(:ball) { described_class.new }
+
+ context "when all entries are text" do
+ before { ball.add_entry(valid_entry) }
+
+ it "returns true" do
+ expect(ball.all_text?).to be true
+ end
+ end
+
+ context "when any entry is binary" do
+ before do
+ ball.add_entry(valid_entry)
+ ball.add_entry(binary_entry)
+ end
+
+ it "returns false" do
+ expect(ball.all_text?).to be false
+ end
+ end
+ end
+
+ describe "#serialize" do
+ let(:ball) { described_class.new }
+
+ describe "output format" do
+ before { ball.add_entry(valid_entry) }
+
+ it "includes border, markers, and content" do
+ output = ball.serialize
+ expect(output).to include(Codeball::Border::SEPARATOR)
+ expect(output).to include('BEGIN "hello.rb"')
+ expect(output).to include('END "hello.rb"')
+ expect(output).to include("puts 'hello'\n")
+ end
+ end
+
+ context "with a binary entry among text entries" do
+ before do
+ ball.add_entry(valid_entry)
+ ball.add_entry(binary_entry)
+ end
+
+ it "does not include the binary entry" do
+ expect(ball.serialize).not_to include("image.png")
+ end
+ end
+ end
+
+ describe "#validate!" do
+ let(:ball) { described_class.new }
+
+ context "with entries present" do
+ before { ball.add_entry(valid_entry) }
+
+ it "does not raise" do
+ expect { ball.validate! }.not_to raise_error
+ end
+ end
+
+ context "with no entries and no warnings" do
+ it "raises MalformedBallError" do
+ expect { ball.validate! }.to raise_error(Codeball::MalformedBallError, /no content found/)
+ end
+ end
+
+ context "with no entries but warnings present" do
+ before { ball.add_entry(truncated_entry) }
+
+ it "raises MalformedBallError" do
+ expect { ball.validate! }.to raise_error(Codeball::MalformedBallError, /no valid entries found/)
+ end
+ end
+ end
+end
diff --git a/spec/codeball/body_spec.rb b/spec/codeball/body_spec.rb
new file mode 100644
index 0000000..1fa20ef
--- /dev/null
+++ b/spec/codeball/body_spec.rb
@@ -0,0 +1,23 @@
+require "codeball"
+
+RSpec.describe Codeball::Body do
+ describe "delegation" do
+ let(:body) { described_class.new("puts 'hello'\n") }
+
+ it "delegates to_s to the wrapped string" do
+ expect(body.to_s).to eq("puts 'hello'\n")
+ end
+
+ it "delegates empty? to the wrapped string" do
+ expect(body.empty?).to be false
+ end
+ end
+
+ context "with empty content" do
+ let(:body) { described_class.new("") }
+
+ it "reports empty" do
+ expect(body.empty?).to be true
+ end
+ end
+end
diff --git a/spec/codeball/border_spec.rb b/spec/codeball/border_spec.rb
new file mode 100644
index 0000000..082c31e
--- /dev/null
+++ b/spec/codeball/border_spec.rb
@@ -0,0 +1,86 @@
+require "codeball"
+
+RSpec.describe Codeball::Border do
+ describe "SEPARATOR" do
+ it "equals the border pattern repeated 10 times" do
+ expect(described_class::SEPARATOR).to eq("---\t" * 10)
+ expect(described_class::SEPARATOR.length).to eq(40)
+ end
+ end
+
+ describe ".recognize?" do
+ context "with a line of repeated dashes" do
+ it "returns true" do
+ expect(described_class.recognize?("----------")).to be true
+ end
+ end
+
+ context "with a line of repeated hashes" do
+ it "returns true" do
+ expect(described_class.recognize?("###########")).to be true
+ end
+ end
+
+ context "with the default border pattern" do
+ it "returns true" do
+ expect(described_class.recognize?(described_class::SEPARATOR)).to be true
+ end
+ end
+
+ context "with mixed punctuation" do
+ it "returns true" do
+ expect(described_class.recognize?("---+---+---+---")).to be true
+ end
+ end
+
+ context "with a short line" do
+ it "returns false" do
+ expect(described_class.recognize?("---")).to be false
+ end
+ end
+
+ context "with alphanumeric content" do
+ it "returns false" do
+ expect(described_class.recognize?("hello world")).to be false
+ end
+ end
+
+ context "with a BEGIN line" do
+ it "returns false" do
+ expect(described_class.recognize?('BEGIN "foo.rb"')).to be false
+ end
+ end
+
+ context "with an END line" do
+ it "returns false" do
+ expect(described_class.recognize?('END "foo.rb"')).to be false
+ end
+ end
+
+ context "with an empty string" do
+ it "returns false" do
+ expect(described_class.recognize?("")).to be false
+ end
+ end
+
+ context "with whitespace-mangled border" do
+ it "returns true" do
+ expect(described_class.recognize?("--- --- --- ---")).to be true
+ end
+ end
+ end
+
+ describe ".strip_suffix" do
+ context "with trailing border on content" do
+ it "strips the border suffix" do
+ expect(described_class.strip_suffix("puts 'hello'\n----------\n")).to eq("puts 'hello'")
+ end
+ end
+
+ context "with no border suffix" do
+ it "returns the text unchanged" do
+ expect(described_class.strip_suffix("clean content\n")).to eq("clean content\n")
+ end
+ end
+ end
+end
diff --git a/spec/codeball/cursor_spec.rb b/spec/codeball/cursor_spec.rb
new file mode 100644
index 0000000..28b7b95
--- /dev/null
+++ b/spec/codeball/cursor_spec.rb
@@ -0,0 +1,165 @@
+require "codeball"
+
+RSpec.describe Codeball::Cursor do
+ let(:hello_content) { "puts 'hello'\n" }
+ let(:greet_content) { "def greet\n 'hi'\nend\n" }
+
+ def serialize_entry(path, contents)
+ border = Codeball::Border::SEPARATOR
+ "#{border}\nBEGIN #{path.inspect}\n#{border}\n#{contents}#{border}\nEND #{path.inspect}\n#{border}\n"
+ end
+
+ let(:ball_text) { serialize_entry("hello.rb", hello_content) + serialize_entry("lib/greet.rb", greet_content) }
+ let(:cursor) { described_class.new(ball_text) }
+
+ describe "#next_item" do
+ context "at the start of a valid codeball" do
+ let(:first) { cursor.next_item }
+
+ it "returns a Header" do
+ expect(first).to be_a(Codeball::Header)
+ end
+
+ it "the Header wraps hello.rb" do
+ expect(first.to_s).to eq("hello.rb")
+ end
+ end
+
+ context "after a Header" do
+ before { cursor.next_item }
+
+ let(:second) { cursor.next_item }
+
+ it "returns a Body" do
+ expect(second).to be_a(Codeball::Body)
+ end
+
+ it "the Body wraps the file content" do
+ expect(second.to_s).to eq(hello_content)
+ end
+ end
+
+ context "after a Body" do
+ before { 2.times { cursor.next_item } }
+
+ let(:third) { cursor.next_item }
+
+ it "returns a Footer" do
+ expect(third).to be_a(Codeball::Footer)
+ end
+
+ it "the Footer wraps hello.rb" do
+ expect(third.to_s).to eq("hello.rb")
+ end
+ end
+
+ context "after a complete entry" do
+ before { 3.times { cursor.next_item } }
+
+ let(:fourth) { cursor.next_item }
+
+ it "returns a Header for the second entry" do
+ expect(fourth).to be_a(Codeball::Header)
+ end
+
+ it "the Header wraps lib/greet.rb" do
+ expect(fourth.to_s).to eq("lib/greet.rb")
+ end
+ end
+
+ context "at end of text" do
+ before { 7.times { cursor.next_item } }
+
+ it "returns EOF" do
+ expect(cursor.next_item).to eq(Codeball::Cursor::EOF)
+ end
+ end
+
+ context "with consecutive calls through entire text" do
+ it "returns Header, Body, Footer, Header, Body, Footer, EOF in sequence" do
+ types = Array.new(7) { cursor.next_item.class }
+ expected = [
+ Codeball::Header,
+ Codeball::Body,
+ Codeball::Footer,
+ Codeball::Header,
+ Codeball::Body,
+ Codeball::Footer,
+ Codeball::Cursor::EOF.class,
+ ]
+ expect(types).to eq(expected)
+ end
+ end
+
+ context "with borders between tokens" do
+ def collect_tokens(cursor)
+ [].tap do |tokens|
+ loop do
+ token = cursor.next_item
+ break if token == Codeball::Cursor::EOF
+
+ tokens << token
+ end
+ end
+ end
+
+ it "never returns a border string as a token" do
+ collect_tokens(cursor).each do |token|
+ expect(Codeball::Border.recognize?(token.to_s))
+ .to be(false), "Token #{token.class} was a border: #{token}"
+ end
+ end
+ end
+
+ context "with content that has no trailing newline" do
+ let(:ball_text) { serialize_entry("no_nl.txt", "no newline") }
+
+ it "returns a Body with border suffix stripped" do
+ cursor.next_item
+ body = cursor.next_item
+ expect(body.to_s).to eq("no newline")
+ end
+ end
+
+ context "with content containing a bare END marker with non-matching path" do
+ let(:content) { "before\nEND \"other_file\"\nafter\n" }
+ let(:ball_text) { serialize_entry("real.rb", content) }
+
+ it "does not terminate body collection on the bare END" do
+ cursor.next_item
+ body = cursor.next_item
+ expect(body.to_s).to eq(content)
+ end
+ end
+
+ context "with content containing a bare END marker with matching path" do
+ let(:content) { "before\nEND \"real.rb\"\nafter\n" }
+ let(:ball_text) { serialize_entry("real.rb", content) }
+
+ it "does not terminate body collection on the bare END" do
+ cursor.next_item
+ body = cursor.next_item
+ expect(body.to_s).to eq(content)
+ end
+ end
+
+ context "with whitespace-mangled borders" do
+ let(:mangled_border) { "--- " * 10 }
+ let(:ball_text) do
+ b = mangled_border
+ "#{b}\nBEGIN \"mangled.rb\"\n#{b}\nhello\n#{b}\nEND \"mangled.rb\"\n#{b}\n"
+ end
+
+ it "still produces Header, Body, Footer tokens" do
+ tokens = []
+ loop do
+ token = cursor.next_item
+ break if token == Codeball::Cursor::EOF
+
+ tokens << token.class
+ end
+ expect(tokens).to eq([Codeball::Header, Codeball::Body, Codeball::Footer])
+ end
+ end
+ end
+end
diff --git a/spec/codeball/destination_spec.rb b/spec/codeball/destination_spec.rb
new file mode 100644
index 0000000..f40533e
--- /dev/null
+++ b/spec/codeball/destination_spec.rb
@@ -0,0 +1,209 @@
+require "codeball"
+require "tmpdir"
+require "fileutils"
+
+RSpec.describe Codeball::Destination do
+ def make_entry(path:, contents:)
+ entry = Codeball::Entry.new
+ entry.header = Codeball::Header.new(path)
+ entry.body = Codeball::Body.new(contents)
+ entry.footer = Codeball::Footer.new(path)
+ entry
+ end
+
+ let(:tmp_dir) { Dir.mktmpdir("destination-spec") }
+ let(:destination) { described_class.new(tmp_dir) }
+ let(:hello_entry) { make_entry(path: "hello.rb", contents: "puts \"hello\"\n") }
+
+ after { FileUtils.rm_rf(tmp_dir) }
+
+ describe "#write" do
+ context "with a normal entry" do
+ let(:result) { destination.write(hello_entry) }
+
+ describe "file system" do
+ before { result }
+
+ it "creates the file at the entry path" do
+ expect(Pathname.new(tmp_dir) / "hello.rb").to exist
+ end
+
+ it "writes the entry contents to the file" do
+ expect(File.read(File.join(tmp_dir, "hello.rb"))).to eq("puts \"hello\"\n")
+ end
+ end
+
+ describe "return value" do
+ it "returns status :written" do
+ expect(result.status).to eq(:written)
+ end
+
+ it "returns the correct line count" do
+ expect(result.line_count).to eq(1)
+ end
+
+ it "returns a path ending with the entry name" do
+ expect(result.path.to_s).to end_with("hello.rb")
+ end
+ end
+ end
+
+ context "with a nested path" do
+ let(:nested_entry) { make_entry(path: "lib/greet.rb", contents: "def greet; end\n") }
+ let(:result) { destination.write(nested_entry) }
+
+ describe "file system" do
+ before { result }
+
+ it "creates parent directories" do
+ expect(Pathname.new(tmp_dir) / "lib").to exist
+ end
+
+ it "creates the file at the entry path" do
+ expect(Pathname.new(tmp_dir) / "lib/greet.rb").to exist
+ end
+ end
+
+ describe "return value" do
+ it "returns status :written" do
+ expect(result.status).to eq(:written)
+ end
+ end
+ end
+
+ context "with an empty entry" do
+ let(:empty_entry) { make_entry(path: "empty.txt", contents: "") }
+ let(:result) { destination.write(empty_entry) }
+
+ describe "file system" do
+ before { result }
+
+ it "creates a zero-byte file" do
+ path = Pathname.new(tmp_dir) / "empty.txt"
+ expect(path).to exist
+ expect(path.size).to eq(0)
+ end
+ end
+
+ describe "return value" do
+ it "returns status :written" do
+ expect(result.status).to eq(:written)
+ end
+
+ it "returns line count 0" do
+ expect(result.line_count).to eq(0)
+ end
+ end
+ end
+
+ context "with dry_run: true" do
+ let(:destination) { described_class.new(tmp_dir, dry_run: true) }
+ let(:result) { destination.write(hello_entry) }
+
+ describe "file system" do
+ before { result }
+
+ it "does NOT create the file" do
+ expect(Pathname.new(tmp_dir) / "hello.rb").not_to exist
+ end
+ end
+
+ describe "return value" do
+ it "returns status :dry_run" do
+ expect(result.status).to eq(:dry_run)
+ end
+
+ it "returns the correct line count" do
+ expect(result.line_count).to eq(1)
+ end
+ end
+ end
+
+ context "with an unsafe path starting with .." do
+ let(:unsafe_entry) { make_entry(path: "../escape.txt", contents: "danger\n") }
+
+ it "does NOT create any file" do
+ destination.write(unsafe_entry)
+ expect(Pathname.new(tmp_dir) / "../escape.txt").not_to exist
+ end
+
+ it "returns status :unsafe" do
+ expect(destination.write(unsafe_entry).status).to eq(:unsafe)
+ end
+ end
+
+ context "with an absolute path" do
+ let(:absolute_entry) { make_entry(path: "/etc/passwd", contents: "hacked\n") }
+
+ it "returns status :unsafe" do
+ expect(destination.write(absolute_entry).status).to eq(:unsafe)
+ end
+ end
+
+ context "with a home expansion path" do
+ let(:home_entry) { make_entry(path: "~/evil.txt", contents: "danger\n") }
+
+ it "returns status :unsafe" do
+ expect(destination.write(home_entry).status).to eq(:unsafe)
+ end
+ end
+
+ context "with a path traversal in the middle" do
+ let(:traversal_entry) { make_entry(path: "foo/../../../etc/passwd", contents: "hacked\n") }
+
+ it "returns status :unsafe" do
+ expect(destination.write(traversal_entry).status).to eq(:unsafe)
+ end
+ end
+
+ context "when the file write raises a system error" do
+ let(:destination) { described_class.new("/dev/null/impossible") }
+ let(:entry) { make_entry(path: "file.txt", contents: "content\n") }
+
+ it "returns status :failed" do
+ expect(destination.write(entry).status).to eq(:failed)
+ end
+
+ it "includes the error message" do
+ expect(destination.write(entry).error).not_to be_nil
+ end
+ end
+
+ it "yields the outcome to a block" do
+ yielded = nil
+ destination.write(hello_entry) { |outcome| yielded = outcome }
+ expect(yielded.status).to eq(:written)
+ end
+ end
+
+ describe "#summary" do
+ it "aggregates write outcomes" do
+ destination.write(hello_entry)
+ summary = destination.summary(malformed: 1)
+ expect(summary.extracted).to eq(1)
+ expect(summary.malformed).to eq(1)
+ end
+ end
+
+ describe "#write" do
+ context "overwriting an existing file" do
+ let(:new_entry) { make_entry(path: "hello.rb", contents: "new content\n") }
+
+ before { File.write(File.join(tmp_dir, "hello.rb"), "old content") }
+
+ describe "file system" do
+ before { destination.write(new_entry) }
+
+ it "replaces the file contents" do
+ expect(File.read(File.join(tmp_dir, "hello.rb"))).to eq("new content\n")
+ end
+ end
+
+ describe "return value" do
+ it "returns status :written" do
+ expect(destination.write(new_entry).status).to eq(:written)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/codeball/entry_spec.rb b/spec/codeball/entry_spec.rb
new file mode 100644
index 0000000..1ca5c9e
--- /dev/null
+++ b/spec/codeball/entry_spec.rb
@@ -0,0 +1,331 @@
+require "codeball"
+require "tmpdir"
+require "fileutils"
+
+RSpec.describe Codeball::Entry do
+ context "when newly created" do
+ let(:entry) { described_class.new }
+
+ it "is not valid" do
+ expect(entry.valid?).to be false
+ end
+
+ it "is incomplete" do
+ expect(entry.incomplete?).to be true
+ end
+
+ it "has no errors" do
+ expect(entry.errors?).to be false
+ end
+
+ it "is not truncated" do
+ expect(entry.truncated?).to be false
+ end
+
+ it "has nil path" do
+ expect(entry.path).to be_nil
+ end
+
+ it "has nil contents" do
+ expect(entry.contents).to be_nil
+ end
+ end
+
+ describe "#header=" do
+ let(:entry) { described_class.new }
+
+ context "setting header once" do
+ before { entry.header = Codeball::Header.new("hello.rb") }
+
+ it "sets path to hello.rb" do
+ expect(entry.path).to eq("hello.rb")
+ end
+
+ it "remains incomplete" do
+ expect(entry.incomplete?).to be true
+ end
+ end
+
+ context "setting header twice" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.header = Codeball::Header.new("other.rb")
+ end
+
+ it "has errors" do
+ expect(entry.errors?).to be true
+ end
+
+ it "error includes duplicate header" do
+ expect(entry.error).to include("duplicate header")
+ end
+ end
+ end
+
+ describe "#body=" do
+ let(:entry) { described_class.new }
+
+ context "setting body once" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("puts 'hello'\n")
+ end
+
+ it "sets contents" do
+ expect(entry.contents).to eq("puts 'hello'\n")
+ end
+
+ it "remains incomplete" do
+ expect(entry.incomplete?).to be true
+ end
+ end
+
+ context "setting body twice" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("first")
+ entry.body = Codeball::Body.new("second")
+ end
+
+ it "has errors" do
+ expect(entry.errors?).to be true
+ end
+
+ it "error includes duplicate body" do
+ expect(entry.error).to include("duplicate body")
+ end
+ end
+ end
+
+ describe "#footer=" do
+ let(:entry) { described_class.new }
+
+ context "setting footer that matches header" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("puts 'hello'\n")
+ entry.footer = Codeball::Footer.new("hello.rb")
+ end
+
+ it "is valid" do
+ expect(entry.valid?).to be true
+ end
+
+ it "is not incomplete" do
+ expect(entry.incomplete?).to be false
+ end
+ end
+
+ context "setting footer that does not match header" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("puts 'hello'\n")
+ entry.footer = Codeball::Footer.new("wrong.rb")
+ end
+
+ it "is not valid" do
+ expect(entry.valid?).to be false
+ end
+
+ it "has errors" do
+ expect(entry.errors?).to be true
+ end
+ end
+
+ context "setting footer twice" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("content")
+ entry.footer = Codeball::Footer.new("hello.rb")
+ entry.footer = Codeball::Footer.new("hello.rb")
+ end
+
+ it "has errors" do
+ expect(entry.errors?).to be true
+ end
+
+ it "error includes duplicate footer" do
+ expect(entry.error).to include("duplicate footer")
+ end
+ end
+ end
+
+ describe "#truncated?" do
+ let(:entry) { described_class.new }
+
+ context "with header and body but no footer" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("content")
+ end
+
+ it "is truncated" do
+ expect(entry.truncated?).to be true
+ end
+ end
+
+ context "with header only" do
+ before { entry.header = Codeball::Header.new("hello.rb") }
+
+ it "is truncated" do
+ expect(entry.truncated?).to be true
+ end
+ end
+
+ context "when valid" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("content")
+ entry.footer = Codeball::Footer.new("hello.rb")
+ end
+
+ it "is not truncated" do
+ expect(entry.truncated?).to be false
+ end
+ end
+
+ context "when errored" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.header = Codeball::Header.new("other.rb")
+ end
+
+ it "is not truncated" do
+ expect(entry.truncated?).to be false
+ end
+ end
+ end
+
+ describe "#serialize" do
+ context "when valid" do
+ let(:entry) { described_class.new }
+
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("puts 'hello'\n")
+ entry.footer = Codeball::Footer.new("hello.rb")
+ end
+
+ it "includes border, markers, and content" do
+ output = entry.serialize
+ expect(output).to include(Codeball::Border::SEPARATOR)
+ expect(output).to include('BEGIN "hello.rb"')
+ expect(output).to include('END "hello.rb"')
+ expect(output).to include("puts 'hello'\n")
+ end
+ end
+ end
+
+ describe "#text?" do
+ let(:entry) { described_class.new }
+
+ context "with text content" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("puts 'hello'\n")
+ entry.footer = Codeball::Footer.new("hello.rb")
+ end
+
+ it "returns true" do
+ expect(entry.text?).to be true
+ end
+ end
+
+ context "with binary content" do
+ before do
+ entry.header = Codeball::Header.new("image.png")
+ entry.body = Codeball::Body.new("binary")
+ entry.footer = Codeball::Footer.new("image.png")
+ allow(entry).to receive(:text?).and_return(false)
+ end
+
+ it "returns false" do
+ expect(entry.text?).to be false
+ end
+ end
+
+ context "with empty content" do
+ before do
+ entry.header = Codeball::Header.new("empty.txt")
+ entry.body = Codeball::Body.new("")
+ entry.footer = Codeball::Footer.new("empty.txt")
+ end
+
+ it "returns true" do
+ expect(entry.text?).to be true
+ end
+ end
+ end
+
+ describe "#line_count" do
+ let(:entry) { described_class.new }
+
+ context "with single line ending in newline" do
+ before do
+ entry.header = Codeball::Header.new("hello.rb")
+ entry.body = Codeball::Body.new("puts 'hello'\n")
+ entry.footer = Codeball::Footer.new("hello.rb")
+ end
+
+ it "returns 1" do
+ expect(entry.line_count).to eq(1)
+ end
+ end
+
+ context "with empty contents" do
+ before do
+ entry.header = Codeball::Header.new("empty.txt")
+ entry.body = Codeball::Body.new("")
+ entry.footer = Codeball::Footer.new("empty.txt")
+ end
+
+ it "returns 0" do
+ expect(entry.line_count).to eq(0)
+ end
+ end
+ end
+
+ describe ".from_file" do
+ let(:tmp_dir) { Dir.mktmpdir("entry-spec") }
+
+ after { FileUtils.rm_rf(tmp_dir) }
+
+ context "with a readable file" do
+ let(:file_path) { File.join(tmp_dir, "hello.rb") }
+
+ before { File.write(file_path, "puts 'hello'\n") }
+
+ it "returns a valid Entry" do
+ expect(described_class.from_file(file_path).valid?).to be true
+ end
+
+ it "has the file path" do
+ expect(described_class.from_file(file_path).path).to eq(file_path)
+ end
+
+ it "has the file contents" do
+ expect(described_class.from_file(file_path).contents).to eq("puts 'hello'\n")
+ end
+ end
+
+ context "with a nonexistent file" do
+ it "returns nil" do
+ expect(described_class.from_file("/nonexistent/path")).to be_nil
+ end
+ end
+
+ context "with an empty file" do
+ let(:file_path) { File.join(tmp_dir, "empty.txt") }
+
+ before { FileUtils.touch(file_path) }
+
+ it "returns a valid Entry" do
+ expect(described_class.from_file(file_path).valid?).to be true
+ end
+
+ it "has empty contents" do
+ expect(described_class.from_file(file_path).contents).to eq("")
+ end
+ end
+ end
+end
diff --git a/spec/codeball/footer_spec.rb b/spec/codeball/footer_spec.rb
new file mode 100644
index 0000000..e63602c
--- /dev/null
+++ b/spec/codeball/footer_spec.rb
@@ -0,0 +1,15 @@
+require "codeball"
+
+RSpec.describe Codeball::Footer do
+ let(:footer) { described_class.new("hello.rb") }
+
+ describe "delegation" do
+ it "delegates to_s to the wrapped string" do
+ expect(footer.to_s).to eq("hello.rb")
+ end
+
+ it "delegates == to the wrapped string" do
+ expect(footer).to eq("hello.rb")
+ end
+ end
+end
diff --git a/spec/codeball/header_spec.rb b/spec/codeball/header_spec.rb
new file mode 100644
index 0000000..3834b38
--- /dev/null
+++ b/spec/codeball/header_spec.rb
@@ -0,0 +1,30 @@
+require "codeball"
+
+RSpec.describe Codeball::Header do
+ let(:header) { described_class.new("hello.rb") }
+
+ describe "delegation" do
+ it "delegates to_s to the wrapped string" do
+ expect(header.to_s).to eq("hello.rb")
+ end
+
+ it "delegates == to the wrapped string" do
+ expect(header).to eq("hello.rb")
+ end
+ end
+
+ describe "pattern matching" do
+ it "matches in Header in a case expression" do
+ matched = case header
+ in Codeball::Header then true
+ else false
+ end
+ expect(matched).to be true
+ end
+
+ it "does not match in Body or in Footer" do
+ expect(header).not_to be_a(Codeball::Body)
+ expect(header).not_to be_a(Codeball::Footer)
+ end
+ end
+end
diff --git a/spec/codeball/stream_spec.rb b/spec/codeball/stream_spec.rb
new file mode 100644
index 0000000..3069ee9
--- /dev/null
+++ b/spec/codeball/stream_spec.rb
@@ -0,0 +1,126 @@
+require "codeball"
+
+RSpec.describe Codeball::Stream do
+ def serialize_entry(path, contents)
+ border = Codeball::Border::SEPARATOR
+ "#{border}\nBEGIN #{path.inspect}\n#{border}\n#{contents}#{border}\nEND #{path.inspect}\n#{border}\n"
+ end
+
+ let(:ball_text) do
+ serialize_entry("hello.rb", "puts 'hello'\n") +
+ serialize_entry("lib/greet.rb", "def greet\n 'hi'\nend\n")
+ end
+
+ context "with a valid two-entry codeball" do
+ let(:entries) { described_class.new(cursor: Codeball::Cursor.new(ball_text)).to_a }
+
+ it "emits two entries" do
+ expect(entries.length).to eq(2)
+ end
+
+ it "first entry is valid with path hello.rb" do
+ expect(entries[0].valid?).to be true
+ expect(entries[0].path).to eq("hello.rb")
+ end
+
+ it "second entry is valid with path lib/greet.rb" do
+ expect(entries[1].valid?).to be true
+ expect(entries[1].path).to eq("lib/greet.rb")
+ end
+
+ it "first entry contents is the file content" do
+ expect(entries[0].contents).to eq("puts 'hello'\n")
+ end
+ end
+
+ context "with a truncated codeball" do
+ let(:truncated_text) do
+ border = Codeball::Border::SEPARATOR
+ complete = serialize_entry("good.rb", "valid\n")
+ incomplete = "#{border}\nBEGIN \"orphan.rb\"\n#{border}\norphan content\n"
+ complete + incomplete
+ end
+ let(:entries) { described_class.new(cursor: Codeball::Cursor.new(truncated_text)).to_a }
+
+ it "emits two entries" do
+ expect(entries.length).to eq(2)
+ end
+
+ it "first entry is valid" do
+ expect(entries[0].valid?).to be true
+ end
+
+ it "second entry is truncated" do
+ expect(entries[1].truncated?).to be true
+ end
+
+ it "second entry has path from its Header" do
+ expect(entries[1].path).to eq("orphan.rb")
+ end
+ end
+
+ context "with a malformed codeball" do
+ let(:mock_cursor) { instance_double(Codeball::Cursor) }
+ let(:tokens) do
+ [
+ Codeball::Header.new("first.rb"),
+ Codeball::Header.new("second.rb"),
+ Codeball::Cursor::EOF,
+ ]
+ end
+
+ before do
+ call_count = 0
+ allow(mock_cursor).to receive(:next_item) { tokens[call_count].tap { call_count += 1 } }
+ end
+
+ let(:entries) { described_class.new(cursor: mock_cursor).to_a }
+
+ it "emits an errored entry" do
+ errored = entries.select(&:errors?)
+ expect(errored).not_to be_empty
+ end
+
+ it "the errored entry has errors" do
+ errored = entries.find(&:errors?)
+ expect(errored.errors?).to be true
+ end
+
+ it "the error message includes duplicate header" do
+ errored = entries.find(&:errors?)
+ expect(errored.error).to include("duplicate header")
+ end
+ end
+
+ context "with empty text producing only EOF" do
+ let(:entries) { described_class.new(cursor: Codeball::Cursor.new("")).to_a }
+
+ it "emits no entries" do
+ expect(entries).to be_empty
+ end
+ end
+
+ describe "Enumerable" do
+ let(:stream) { described_class.new(cursor: Codeball::Cursor.new(ball_text)) }
+
+ it "responds to map" do
+ expect(stream).to respond_to(:map)
+ end
+
+ it "responds to select" do
+ expect(stream).to respond_to(:select)
+ end
+
+ it "responds to count" do
+ expect(stream).to respond_to(:count)
+ end
+ end
+
+ describe "#each_entry" do
+ let(:stream) { described_class.new(cursor: Codeball::Cursor.new(ball_text)) }
+
+ it "is aliased to each" do
+ expect(stream.method(:each_entry)).to eq(stream.method(:each))
+ end
+ end
+end
diff --git a/spec/integration/help_spec.rb b/spec/integration/help_spec.rb
new file mode 100644
index 0000000..60e7921
--- /dev/null
+++ b/spec/integration/help_spec.rb
@@ -0,0 +1,81 @@
+require_relative "../spec_helper"
+
+RSpec.describe "codeball help", type: :integration do
+ include CLIHelper
+
+ describe "codeball with no arguments" do
+ let(:result) { run_codeball }
+
+ it "prints usage and available commands" do
+ expect(result.stdout).to include("Usage: codeball")
+ expect(result.stdout).to include("pack")
+ expect(result.stdout).to include("unpack")
+ expect(result.stdout).to include("list")
+ end
+
+ it "exits non-zero" do
+ expect(result.exit_code).not_to eq(0)
+ end
+ end
+
+ describe "codeball --help" do
+ let(:result) { run_codeball("--help") }
+
+ it "prints usage and available commands" do
+ expect(result.stdout).to include("Usage: codeball")
+ expect(result.stdout).to include("pack")
+ expect(result.stdout).to include("unpack")
+ expect(result.stdout).to include("list")
+ end
+
+ it "exits 0" do
+ expect(result.exit_code).to eq(0)
+ end
+ end
+
+ describe "codeball help" do
+ let(:result) { run_codeball("help") }
+
+ it "prints usage and available commands" do
+ expect(result.stdout).to include("Usage: codeball")
+ expect(result.stdout).to include("Commands:")
+ end
+ end
+
+ describe "codeball pack --help" do
+ let(:result) { run_codeball("pack", "--help") }
+
+ it "prints pack usage with options and examples" do
+ expect(result.stdout).to include("Usage: codeball pack")
+ expect(result.stdout).to include("--quiet")
+ expect(result.stdout).to include("Examples:")
+ end
+ end
+
+ describe "codeball list --help" do
+ let(:result) { run_codeball("list", "--help") }
+
+ it "prints list usage" do
+ expect(result.stdout).to include("Usage: codeball list")
+ end
+ end
+
+ describe "codeball unpack --help" do
+ let(:result) { run_codeball("unpack", "--help") }
+
+ it "prints unpack usage with options" do
+ expect(result.stdout).to include("Usage: codeball unpack")
+ expect(result.stdout).to include("--output-dir")
+ expect(result.stdout).to include("--dry-run")
+ end
+ end
+
+ describe "codeball nonexistent" do
+ let(:result) { run_codeball("nonexistent") }
+
+ it "prints an error for unknown commands" do
+ expect(result.stderr).to include("'nonexistent' is not a codeball command")
+ expect(result.exit_code).not_to eq(0)
+ end
+ end
+end
diff --git a/spec/integration/list_spec.rb b/spec/integration/list_spec.rb
new file mode 100644
index 0000000..893a9cf
--- /dev/null
+++ b/spec/integration/list_spec.rb
@@ -0,0 +1,104 @@
+require_relative "../spec_helper"
+
+RSpec.describe "codeball list", type: :integration do
+ include CLIHelper
+
+ describe "listing from a file argument" do
+ let(:bundle_text) { pack_bundle(["hello.rb", "puts 'hi'\n"]) }
+ let(:bundle_file) { create_file("bundle.txt", bundle_text) }
+ let(:result) { run_codeball("list", bundle_file) }
+
+ it "prints a table with file paths and line counts" do
+ expect(result.stdout).to include("File")
+ expect(result.stdout).to include("hello.rb")
+ expect(result.stdout).to include("1 lines")
+ end
+
+ it "exits 0" do
+ expect(result.exit_code).to eq(0)
+ end
+ end
+
+ describe "listing from stdin" do
+ let(:bundle_text) { pack_bundle(["greeting.rb", "puts 'hello'\nputs 'world'\n"]) }
+ let(:result) { run_codeball("list", stdin: bundle_text) }
+
+ it "prints a table with file paths and line counts" do
+ expect(result.stdout).to include("File")
+ expect(result.stdout).to include("greeting.rb")
+ expect(result.stdout).to include("2 lines")
+ end
+
+ it "exits 0" do
+ expect(result.exit_code).to eq(0)
+ end
+ end
+
+ describe "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 "exits non-zero" do
+ expect(result.exit_code).not_to eq(0)
+ end
+ end
+
+ describe "with a bundle containing multiple files" do
+ let(:bundle_text) do
+ pack_bundle(
+ ["alpha.rb", "a = 1\n"],
+ ["beta.rb", "b = 2\nb = 3\n"],
+ ["gamma.rb", "c = 4\nc = 5\nc = 6\n"],
+ )
+ end
+ let(:result) { run_codeball("list", stdin: bundle_text) }
+
+ it "lists all files" do
+ expect(result.stdout).to include("alpha.rb")
+ expect(result.stdout).to include("beta.rb")
+ expect(result.stdout).to include("gamma.rb")
+ expect(result.stdout).to include("1 lines")
+ expect(result.stdout).to include("2 lines")
+ expect(result.stdout).to include("3 lines")
+ end
+ end
+
+ describe "with a truncated bundle" do
+ let(:full_bundle) do
+ pack_bundle(
+ ["complete.rb", "good = true\n"],
+ ["truncated.rb", "this will be cut\n"],
+ )
+ end
+ let(:truncated_bundle) { full_bundle[0...(full_bundle.rindex("END"))] }
+ let(:result) { run_codeball("list", stdin: truncated_bundle) }
+
+ it "lists the valid entries" do
+ expect(result.stdout).to include("complete.rb")
+ end
+
+ it "prints a warning about the truncated entry" do
+ expect(result.stderr).to include("warning:")
+ expect(result.stderr).to include("truncated")
+ end
+
+ it "exits 0 since valid entries were found" do
+ expect(result.exit_code).to eq(0)
+ end
+ end
+
+ describe "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
+ expect(result.stderr).to include("no content found")
+ end
+
+ it "exits non-zero" do
+ expect(result.exit_code).not_to eq(0)
+ end
+ end
+end
diff --git a/spec/integration/pack_spec.rb b/spec/integration/pack_spec.rb
new file mode 100644
index 0000000..eb6148c
--- /dev/null
+++ b/spec/integration/pack_spec.rb
@@ -0,0 +1,155 @@
+require_relative "../spec_helper"
+
+RSpec.describe "codeball pack", type: :integration do
+ include CLIHelper
+
+ describe "packing a single file" do
+ let(:file_path) { create_file("hello.txt", "hello world\n") }
+ let(:result) { run_codeball("pack", file_path) }
+
+ it "writes the bundle to stdout only, not to any file" do
+ expect(result.stdout).not_to be_empty
+ expect(Dir.glob(File.join(tmp_dir, "*.codeball"))).to be_empty
+ end
+
+ it "writes bordered output to stdout" do
+ expect(result.stdout).to include("---\t")
+ end
+
+ it "includes BEGIN and END markers with the file path" do
+ expect(result.stdout).to include("BEGIN #{file_path.inspect}")
+ expect(result.stdout).to include("END #{file_path.inspect}")
+ end
+
+ it "includes the file contents between markers" do
+ expect(result.stdout).to include("hello world\n")
+ end
+
+ it "exits 0" do
+ expect(result.exit_code).to eq(0)
+ end
+ end
+
+ describe "packing multiple files" do
+ let(:first_path) { create_file("one.txt", "first\n") }
+ let(:second_path) { create_file("two.txt", "second\n") }
+ let(:result) { run_codeball("pack", first_path, second_path) }
+
+ it "includes all files in the output" do
+ expect(result.stdout).to include("BEGIN #{first_path.inspect}")
+ expect(result.stdout).to include("BEGIN #{second_path.inspect}")
+ end
+
+ it "separates entries with borders" do
+ expect(result.stdout).to include("END #{first_path.inspect}")
+ expect(result.stdout).to include("BEGIN #{second_path.inspect}")
+ end
+ end
+
+ describe "stdout purity" do
+ it "writes nothing to stderr on a successful pack" do
+ path = create_file("clean.txt", "clean\n")
+ result = run_codeball("pack", path)
+
+ expect(result.stderr).to be_empty
+ end
+
+ it "does not mix warnings into stdout when a binary file is skipped" do
+ text_path = create_file("good.txt", "good\n")
+ binary_path = create_binary_file("image.png")
+ result = run_codeball("pack", text_path, binary_path)
+
+ expect(result.stdout).not_to include("skipping")
+ expect(result.stdout).not_to include("codeball:")
+ end
+ end
+
+ describe "with no file arguments" do
+ let(:result) { run_codeball("pack") }
+
+ it "prints an error to stderr" do
+ expect(result.stderr).to include("insufficient number of arguments")
+ end
+
+ it "exits non-zero" do
+ expect(result.exit_code).not_to eq(0)
+ end
+ end
+
+ describe "with a nonexistent file" do
+ let(:result) { run_codeball("pack", "/no/such/file.txt") }
+
+ it "prints a cannot-read warning to stderr" do
+ expect(result.stderr).to include("cannot read file:")
+ expect(result.stderr).to include("/no/such/file.txt")
+ end
+
+ it "exits non-zero" do
+ expect(result.exit_code).not_to eq(0)
+ end
+ end
+
+ describe "with a binary file" do
+ let(:binary_path) { create_binary_file("photo.png") }
+ let(:result) { run_codeball("pack", binary_path) }
+
+ it "prints a skipping-non-text warning to stderr" do
+ expect(result.stderr).to include("skipping non-text file:")
+ expect(result.stderr).to include("photo.png")
+ end
+
+ it "exits non-zero" do
+ expect(result.exit_code).not_to eq(0)
+ end
+ end
+
+ describe "with --quiet" do
+ it "suppresses warnings to stderr" do
+ binary_path = create_binary_file("quiet.png")
+ result = run_codeball("pack", "--quiet", binary_path)
+
+ expect(result.stderr).not_to include("skipping")
+ end
+ end
+
+ describe "packing an empty file" do
+ it "includes the entry with empty contents" do
+ path = create_file("empty.txt", "")
+ result = run_codeball("pack", path)
+
+ expect(result.stdout).to include("BEGIN #{path.inspect}")
+ expect(result.stdout).to include("END #{path.inspect}")
+ expect(result.exit_code).to eq(0)
+ end
+ end
+
+ describe "with a mix of valid and nonexistent files" do
+ let(:valid_path) { create_file("exists.txt", "here\n") }
+ let(:missing_path) { File.join(tmp_dir, "missing.txt") }
+ let(:result) { run_codeball("pack", valid_path, missing_path) }
+
+ it "packs the valid files to stdout" do
+ expect(result.stdout).to include("BEGIN #{valid_path.inspect}")
+ expect(result.stdout).to include("here\n")
+ end
+
+ it "warns about the invalid files on stderr" do
+ expect(result.stderr).to include("cannot read file:")
+ expect(result.stderr).to include("missing.txt")
+ end
+
+ it "exits non-zero" do
+ expect(result.exit_code).not_to eq(0)
+ end
+ end
+
+ describe "packing files with nested paths" do
+ it "preserves the relative path in BEGIN/END markers" do
+ path = create_file("lib/codeball/nested.rb", "module Nested; end\n")
+ result = run_codeball("pack", path)
+
+ expect(result.stdout).to include("BEGIN #{path.inspect}")
+ expect(result.stdout).to include("END #{path.inspect}")
+ end
+ end
+end
diff --git a/spec/integration/round_trip_spec.rb b/spec/integration/round_trip_spec.rb
new file mode 100644
index 0000000..7c0e431
--- /dev/null
+++ b/spec/integration/round_trip_spec.rb
@@ -0,0 +1,182 @@
+require_relative "../spec_helper"
+
+RSpec.describe "codeball pack | unpack round trip", type: :integration do
+ include CLIHelper
+
+ describe "single file" do
+ let(:content) { "puts 'hello world'\n" }
+
+ before { create_file("hello.rb", content) }
+
+ it "preserves file contents through pack and unpack" do
+ pack_result = run_codeball("pack", "hello.rb")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("hello.rb")).to eq(content)
+ end
+ end
+
+ describe "multiple files" do
+ let(:content_a) { "class Foo; end\n" }
+ let(:content_b) { "class Bar; end\n" }
+
+ before do
+ create_file("foo.rb", content_a)
+ create_file("bar.rb", content_b)
+ end
+
+ it "preserves all file contents and paths" do
+ pack_result = run_codeball("pack", "foo.rb", "bar.rb")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("foo.rb")).to eq(content_a)
+ expect(read_output_file("bar.rb")).to eq(content_b)
+ end
+ end
+
+ describe "file with special characters" do
+ let(:content) { "col1\tcol2\nline \"two\"\nline 'three'\n" }
+
+ before { create_file("special.txt", content) }
+
+ it "preserves tabs, newlines, and quotes" do
+ pack_result = run_codeball("pack", "special.txt")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("special.txt")).to eq(content)
+ end
+ end
+
+ describe "nested directory structure" do
+ let(:content) { "module Nested; end\n" }
+
+ before { create_file("lib/codeball/nested.rb", content) }
+
+ it "recreates the directory tree" do
+ pack_result = run_codeball("pack", "lib/codeball/nested.rb")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("lib/codeball/nested.rb")).to eq(content)
+ end
+ end
+
+ describe "empty file among non-empty files" do
+ let(:nonempty_content) { "something\n" }
+
+ before do
+ create_file("nonempty.rb", nonempty_content)
+ create_file("empty.rb", "")
+ end
+
+ it "preserves the empty file as zero bytes" do
+ pack_result = run_codeball("pack", "nonempty.rb", "empty.rb")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("nonempty.rb")).to eq(nonempty_content)
+ expect(read_output_file("empty.rb")).to eq("")
+ end
+ end
+
+ describe "pack to file, then unpack from file" do
+ let(:content) { "file-based round trip\n" }
+
+ before { create_file("original.rb", content) }
+
+ it "works with intermediate file instead of pipe" do
+ pack_result = run_codeball("pack", "original.rb")
+ create_file("bundle.txt", pack_result.stdout)
+ bundle_path = File.join(tmp_dir, "bundle.txt")
+
+ run_codeball("unpack", bundle_path)
+
+ expect(read_output_file("original.rb")).to eq(content)
+ end
+ end
+
+ describe "unicode and multibyte content" do
+ it "preserves CJK characters" do
+ content = "こんにちは世界\n"
+ create_file("cjk.txt", content)
+
+ pack_result = run_codeball("pack", "cjk.txt")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("cjk.txt")).to eq(content)
+ end
+
+ it "preserves emoji" do
+ content = "#{[0x1F389, 0x1F680, 0x1F48E].pack("U*")}\n"
+ create_file("emoji.txt", content)
+
+ pack_result = run_codeball("pack", "emoji.txt")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("emoji.txt")).to eq(content)
+ end
+
+ it "preserves combining marks" do
+ content = "e\u0301 is e with acute\n"
+ create_file("combining.txt", content)
+
+ pack_result = run_codeball("pack", "combining.txt")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("combining.txt")).to eq(content)
+ end
+ end
+
+ describe "file containing border-like content" do
+ let(:content) { "before\n----------\n###########\n~~~~~~~~~~\nafter\n" }
+
+ before { create_file("borders.txt", content) }
+
+ it "preserves content that looks like a border line" do
+ pack_result = run_codeball("pack", "borders.txt")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("borders.txt")).to eq(content)
+ end
+ end
+
+ describe "file containing BEGIN/END markers in content" do
+ let(:content) { "BEGIN \"foo\"\nsome middle text\nEND \"foo\"\n" }
+
+ before { create_file("markers.txt", content) }
+
+ it "preserves content that contains BEGIN and END keywords" do
+ pack_result = run_codeball("pack", "markers.txt")
+ run_codeball("unpack", "-o", "out", stdin: pack_result.stdout)
+
+ expect(read_output_file("out/markers.txt")).to eq(content)
+ end
+ end
+
+ describe "file without trailing newline" do
+ let(:content) { "no newline at end" }
+
+ before { create_file("no_newline.txt", content) }
+
+ it "preserves the exact content without adding a newline" do
+ pack_result = run_codeball("pack", "no_newline.txt")
+ run_codeball("unpack", stdin: pack_result.stdout)
+
+ expect(read_output_file("no_newline.txt")).to eq(content)
+ end
+ end
+
+ describe "large file" do
+ let(:content) { (1..10_000).map { |i| "line #{i}: #{"x" * 40}\n" }.join }
+
+ before { create_file("large.txt", content) }
+
+ it "round-trips a 10,000 line file without error" do
+ pack_result = run_codeball("pack", "large.txt")
+ expect(pack_result.exit_code).to eq(0)
+
+ unpack_result = run_codeball("unpack", stdin: pack_result.stdout)
+ expect(unpack_result.exit_code).to eq(0)
+
+ expect(read_output_file("large.txt")).to eq(content)
+ end
+ end
+end
diff --git a/spec/integration/unpack_spec.rb b/spec/integration/unpack_spec.rb
new file mode 100644
index 0000000..7cac80d
--- /dev/null
+++ b/spec/integration/unpack_spec.rb
@@ -0,0 +1,204 @@
+require_relative "../spec_helper"
+
+RSpec.describe "codeball unpack", type: :integration do
+ include CLIHelper
+
+ describe "extracting from a file argument" do
+ let(:bundle) { pack_bundle(["hello.txt", "hello world\n"]) }
+ let(:bundle_path) { create_file("bundle.txt", bundle) }
+ let(:result) { run_codeball("unpack", "-o", "out", bundle_path) }
+
+ it "writes the extracted file to disk" do
+ result
+ expect(read_output_file("out/hello.txt")).to eq("hello world\n")
+ end
+
+ it "prints a wrote summary to stdout" do
+ expect(result.stdout).to include("wrote")
+ expect(result.stdout).to include("hello.txt")
+ end
+
+ it "prints an extraction summary line" do
+ expect(result.stdout).to include("---")
+ expect(result.stdout).to include("extracted: 1")
+ end
+
+ it "exits 0" do
+ expect(result.exit_code).to eq(0)
+ end
+ end
+
+ describe "extracting from stdin" do
+ let(:bundle) { pack_bundle(["greeting.txt", "hi there\n"]) }
+ let(:result) { run_codeball("unpack", "-o", "out", stdin: bundle) }
+
+ it "writes the extracted file to disk" do
+ result
+ expect(read_output_file("out/greeting.txt")).to eq("hi there\n")
+ end
+ end
+
+ describe "extracting multiple files" do
+ let(:bundle) do
+ pack_bundle(
+ ["one.txt", "first\n"],
+ ["nested/two.txt", "second\n"],
+ )
+ end
+ let(:result) { run_codeball("unpack", "-o", "out", stdin: bundle) }
+
+ it "writes all files to disk" do
+ result
+ expect(read_output_file("out/one.txt")).to eq("first\n")
+ expect(read_output_file("out/nested/two.txt")).to eq("second\n")
+ end
+
+ it "creates nested directories as needed" do
+ result
+ expect(output_path("out/nested/two.txt")).to exist
+ end
+ end
+
+ describe "with --output-dir" do
+ let(:bundle) { pack_bundle(["note.txt", "content\n"]) }
+ let(:result) { run_codeball("unpack", "-o", "outdir", stdin: bundle) }
+
+ it "writes files to the specified directory" do
+ result
+ expect(output_path("outdir/note.txt")).to exist
+ expect(read_output_file("outdir/note.txt")).to eq("content\n")
+ end
+ end
+
+ describe "with --output-dir pointing to a nonexistent directory" do
+ let(:bundle) { pack_bundle(["data.txt", "stuff\n"]) }
+ let(:result) { run_codeball("unpack", "-o", "deep/nested/dir", stdin: bundle) }
+
+ it "creates the directory and writes files" do
+ result
+ expect(output_path("deep/nested/dir/data.txt")).to exist
+ expect(read_output_file("deep/nested/dir/data.txt")).to eq("stuff\n")
+ end
+ end
+
+ describe "with --dry-run" do
+ let(:bundle) { pack_bundle(["phantom.txt", "invisible\n"]) }
+ let(:result) { run_codeball("unpack", "--dry-run", "-o", "dryout", stdin: bundle) }
+
+ it "does not create any files" do
+ result
+ expect(output_path("dryout")).not_to exist
+ end
+
+ it "prints dry-run prefixed output" do
+ expect(result.stdout).to include("[dry-run]")
+ expect(result.stdout).to include("would write:")
+ expect(result.stdout).to include("phantom.txt")
+ end
+
+ it "prints the extraction summary" do
+ expect(result.stdout).to include("[dry-run]")
+ expect(result.stdout).to include("extracted: 1")
+ end
+ end
+
+ describe "with --quiet" do
+ let(:bundle) { pack_bundle(["silent.txt", "shh\n"]) }
+
+ context "with a normal bundle" do
+ let(:result) { run_codeball("unpack", "--quiet", "-o", "qout", stdin: bundle) }
+
+ it "suppresses stdout output" do
+ expect(result.stdout).to be_empty
+ end
+
+ it "still writes files to disk" do
+ result
+ expect(read_output_file("qout/silent.txt")).to eq("shh\n")
+ end
+ end
+
+ context "with an unsafe path in the bundle" do
+ let(:unsafe_bundle) { ball_text_for("../escape.txt", "danger\n") }
+ let(:result) { run_codeball("unpack", "--quiet", stdin: unsafe_bundle) }
+
+ it "suppresses warnings on stderr" do
+ expect(result.stderr).to be_empty
+ end
+ end
+ end
+
+ describe "with empty input" do
+ let(:result) { run_codeball("unpack", 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 bundle containing an unsafe path" do
+ let(:unsafe_bundle) { ball_text_for("../etc/passwd", "hacked\n") }
+ let(:result) { run_codeball("unpack", stdin: unsafe_bundle) }
+
+ it "skips the unsafe entry" do
+ result
+ expect(output_path("../etc/passwd")).not_to exist
+ end
+
+ it "prints a warning about the unsafe path" do
+ expect(result.stderr).to include("warning:")
+ expect(result.stderr).to include("unsafe path")
+ end
+
+ it "reports it in the skipped count" do
+ expect(result.stdout).to include("skipped: 1")
+ end
+ end
+
+ describe "with a truncated bundle" do
+ let(:truncated_bundle) do
+ valid = ball_text_for("good.txt", "valid content\n")
+ incomplete = "#{CLIHelper::BORDER}\nBEGIN \"orphan.txt\"\n#{CLIHelper::BORDER}\norphan content\n"
+ valid + incomplete
+ end
+ let(:result) { run_codeball("unpack", stdin: truncated_bundle) }
+
+ it "extracts valid entries" do
+ result
+ expect(read_output_file("good.txt")).to eq("valid content\n")
+ end
+
+ it "prints warnings about truncated entries" do
+ expect(result.stderr).to include("warning:")
+ expect(result.stderr).to include("truncated")
+ end
+ end
+
+ describe "extracting an empty file" do
+ let(:bundle) { pack_bundle(["blank.txt", ""]) }
+ let(:result) { run_codeball("unpack", "-o", "out", stdin: bundle) }
+
+ it "creates a zero-byte file on disk" do
+ result
+ expect(output_path("out/blank.txt")).to exist
+ expect(output_path("out/blank.txt").size).to eq(0)
+ end
+ end
+
+ describe "overwriting an existing file" do
+ let(:bundle) { pack_bundle(["target.txt", "new content\n"]) }
+ let(:result) do
+ create_file("target.txt", "old content\n")
+ run_codeball("unpack", stdin: bundle)
+ end
+
+ it "replaces the existing file contents" do
+ result
+ expect(read_output_file("target.txt")).to eq("new content\n")
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..7fa0b27
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,84 @@
+require "open3"
+require "tmpdir"
+require "pathname"
+require "fileutils"
+require_relative "support/have_output_line"
+
+##
+# Harness for running the codeball CLI as a subprocess.
+#
+# Every helper runs against +tmp_dir+, a per-example temp directory
+# that is cleaned up automatically after each spec.
+module CLIHelper
+ CLIResult = Struct.new(:stdout, :stderr, :exit_code)
+
+ PROJECT_ROOT = File.expand_path("..", __dir__).freeze
+ EXE = File.join(PROJECT_ROOT, "exe/codeball").freeze
+ RUBY_CMD = [RbConfig.ruby, "-I", File.join(PROJECT_ROOT, "lib"), EXE].freeze
+
+ def run_codeball(*args, stdin: nil)
+ stdout, stderr, status = Open3.capture3(
+ *RUBY_CMD, *args,
+ stdin_data: stdin,
+ chdir: tmp_dir
+ )
+ CLIResult.new(stdout: stdout, stderr: stderr, exit_code: status.exitstatus)
+ end
+
+ def tmp_dir
+ @tmp_dir ||= Dir.mktmpdir("codeball-spec")
+ end
+
+ def create_file(path, contents)
+ full = File.join(tmp_dir, path)
+ FileUtils.mkdir_p(File.dirname(full))
+ File.write(full, contents)
+ full
+ end
+
+ def create_binary_file(path)
+ full = File.join(tmp_dir, path)
+ FileUtils.mkdir_p(File.dirname(full))
+ # Minimal PNG header -- detected as image/png by libmagic
+ png_stub = ([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + ([0] * 64)).pack("C*")
+ File.binwrite(full, png_stub)
+ full
+ end
+
+ def read_output_file(path)
+ File.read(File.join(tmp_dir, path))
+ end
+
+ def output_path(path)
+ Pathname.new(tmp_dir) / path
+ end
+
+ BORDER = ("---\t" * 10).freeze
+
+ def ball_text_for(path, contents)
+ header = "#{BORDER}\nBEGIN #{path.inspect}\n#{BORDER}\n"
+ footer = "#{BORDER}\nEND #{path.inspect}\n#{BORDER}\n"
+ "#{header}#{contents}#{footer}"
+ end
+
+ def pack_bundle(*file_pairs)
+ file_pairs.each { |name, contents| create_file(name, contents) }
+ names = file_pairs.map(&:first)
+ result = run_codeball("pack", *names)
+ raise "pack_bundle failed (exit #{result.exit_code}): #{result.stderr}" unless result.exit_code.zero?
+
+ result.stdout
+ end
+end
+
+RSpec.configure do |config|
+ config.include CLIHelper, type: :integration
+
+ config.define_derived_metadata do |meta|
+ meta[:aggregate_failures] = true unless meta.key?(:aggregate_failures)
+ end
+
+ config.after(:each, type: :integration) do
+ FileUtils.rm_rf(@tmp_dir) if @tmp_dir
+ end
+end
diff --git a/spec/support/have_output_line.rb b/spec/support/have_output_line.rb
new file mode 100644
index 0000000..9617246
--- /dev/null
+++ b/spec/support/have_output_line.rb
@@ -0,0 +1,70 @@
+require "command_kit/colors"
+
+##
+# Fluent matcher for asserting styled terminal output.
+# Chain style methods that correspond to CommandKit::Colors::ANSI
+# color names to build expected ANSI-colored segments.
+class HaveOutputLine
+ def initialize
+ @segments = []
+ end
+
+ def method_missing(name, *args)
+ return super unless CommandKit::Colors::ANSI.respond_to?(name)
+
+ text = args.first or raise ArgumentError, "#{name}() requires a text argument"
+ @segments << { style: name, text: text }
+ self
+ end
+
+ def respond_to_missing?(name, include_private = false)
+ CommandKit::Colors::ANSI.respond_to?(name) || super
+ end
+
+ def matches?(actual)
+ @actual = actual
+ @actual.to_s.lines.any? { |line| line_matches?(line) }
+ end
+
+ def does_not_match?(actual)
+ @actual = actual
+ @actual.to_s.lines.none? { |line| line_matches?(line) }
+ end
+
+ def failure_message
+ "expected output to contain a line matching:\n " \
+ "#{expected_readable}\ngot:\n " \
+ "#{@actual.to_s.lines.map(&:chomp).join("\n ")}"
+ end
+
+ def failure_message_when_negated
+ "expected output not to contain a line matching:\n " \
+ "#{expected_readable}\n" \
+ "but it was found"
+ end
+
+ def description
+ "have output line #{expected_readable}"
+ end
+
+ private
+
+ def line_matches?(line)
+ pattern = @segments.map { |seg|
+ Regexp.escape(CommandKit::Colors::ANSI.public_send(seg[:style], seg[:text]))
+ }.join(".*?")
+ Regexp.new(pattern).match?(line)
+ end
+
+ def expected_readable
+ @segments.map { |seg| "[#{seg[:style]}]#{seg[:text]}[/]" }.join
+ end
+end
+
+RSpec.configure do |config|
+ config.include(Module.new do
+ def have_output_line
+ HaveOutputLine.new
+ end
+ end)
+end
diff --git a/test/bundle_extraction_test.rb b/test/bundle_extraction_test.rb
deleted file mode 100644
index 7edcf9c..0000000
--- a/test/bundle_extraction_test.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-require_relative "test_helper"
-
-class BundleExtractionTest < Minitest::Test
- include BundleTestHelper
-
- def setup
- @tmpdir = Dir.mktmpdir
- @config = Codeball::Config.new(
- border: "---\t",
- border_width: 10,
- output_dir: @tmpdir,
- dry_run: false
- )
- @border = @config.full_border
- end
-
- def teardown
- FileUtils.rm_rf(@tmpdir)
- end
-
- def test_extract_creates_files
- input = build_bundle(["test.txt", "hello"])
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- capture_io { bundle.extract }
-
- assert_equal "hello", File.read(File.join(@tmpdir, "test.txt"))
- end
-
- def test_extract_creates_nested_directories
- input = build_bundle(["a/b/c/deep.txt", "nested"])
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- capture_io { bundle.extract }
-
- assert_equal "nested", File.read(File.join(@tmpdir, "a/b/c/deep.txt"))
- end
-
- def test_extract_handles_empty_files
- input = build_bundle(["empty.txt", ""])
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- capture_io { bundle.extract }
-
- assert_path_exists File.join(@tmpdir, "empty.txt")
- assert_empty File.read(File.join(@tmpdir, "empty.txt"))
- end
-
- def test_extract_skips_unsafe_paths
- input = build_bundle(["../escape.txt", "malicious"])
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- summary = bundle.extract
-
- refute_path_exists File.join(@tmpdir, "../escape.txt")
- assert_equal 1, summary.skipped
- end
-
- def test_extract_dry_run_does_not_write
- dry_config = Codeball::Config.new(
- border: "---\t",
- border_width: 10,
- output_dir: @tmpdir,
- dry_run: true
- )
- input = build_bundle(["test.txt", "hello"])
- bundle = Codeball::Bundle.parse(input, config: dry_config)
-
- summary = bundle.extract
-
- refute_path_exists File.join(@tmpdir, "test.txt")
- assert_equal 1, summary.extracted
- assert_equal :dry_run, summary.results.first.status
- end
-
- def test_extract_returns_summary
- input = build_bundle(["good.txt", "ok"], ["../bad.txt", "nope"])
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- summary = nil
- capture_io { summary = bundle.extract }
-
- assert_equal 1, summary.extracted
- assert_equal 1, summary.skipped
- end
-end
diff --git a/test/bundle_parsing_test.rb b/test/bundle_parsing_test.rb
deleted file mode 100644
index 2834932..0000000
--- a/test/bundle_parsing_test.rb
+++ /dev/null
@@ -1,115 +0,0 @@
-require_relative "test_helper"
-
-class BundleParsingTest < Minitest::Test
- include BundleTestHelper
-
- def setup
- @config = Codeball::Config.default
- @border = @config.full_border
- end
-
- def test_parse_single_file
- input = build_bundle(["test.txt", "hello"])
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 1, bundle.entries.length
- assert_equal "test.txt", bundle.entries.first.path
- assert_equal "hello", bundle.entries.first.contents
- end
-
- def test_parse_multiple_files
- input = build_bundle(["a.txt", "aaa"], ["b.txt", "bbb"])
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 2, bundle.entries.length
- assert_equal "a.txt", bundle.entries[0].path
- assert_equal "aaa", bundle.entries[0].contents
- assert_equal "b.txt", bundle.entries[1].path
- assert_equal "bbb", bundle.entries[1].contents
- end
-
- def test_parse_empty_file_content
- input = build_bundle(["empty.txt", ""])
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 1, bundle.entries.length
- assert_equal "empty.txt", bundle.entries.first.path
- assert_empty bundle.entries.first.contents
- end
-
- def test_parse_empty_file_among_nonempty
- input = build_bundle(["empty.txt", ""], ["nonempty.txt", "content"])
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 2, bundle.entries.length
- assert_empty bundle.entries[0].contents
- assert_equal "content", bundle.entries[1].contents
- end
-
- def test_parse_raises_on_empty_input
- assert_raises(Codeball::MalformedBundleError) do
- Codeball::Bundle.parse("", config: @config)
- end
- end
-
- def test_parse_raises_on_whitespace_only_input
- assert_raises(Codeball::MalformedBundleError) do
- Codeball::Bundle.parse(" \n\n ", config: @config)
- end
- end
-
- def test_parse_raises_on_malformed_segment_count
- input = "#{@border}\nBEGIN \"test.txt\"\n#{@border}\ncontent"
-
- assert_raises(Codeball::MalformedBundleError) do
- Codeball::Bundle.parse(input, config: @config)
- end
- end
-
- def test_parse_handles_nested_paths
- input = build_bundle(["a/b/c/deep.txt", "nested"])
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal "a/b/c/deep.txt", bundle.entries.first.path
- end
-
- def test_parse_with_custom_border
- custom_config = Codeball::Config.new(border: "###", border_width: 5, output_dir: ".", dry_run: false)
- custom_border = custom_config.full_border
-
- input = "#{custom_border}\n" +
- "BEGIN \"test.txt\"\n" +
- "#{custom_border}\n" +
- "content" +
- "#{custom_border}\n" +
- "END \"test.txt\"\n" +
- "#{custom_border}\n"
-
- bundle = Codeball::Bundle.parse(input, config: custom_config)
-
- assert_equal 1, bundle.entries.length
- assert_equal "content", bundle.entries.first.contents
- end
-
- def test_parse_with_regex_special_chars_in_border
- custom_config = Codeball::Config.new(border: "+++", border_width: 3, output_dir: ".", dry_run: false)
- custom_border = custom_config.full_border
-
- input = "#{custom_border}\n" +
- "BEGIN \"test.txt\"\n" +
- "#{custom_border}\n" +
- "content" +
- "#{custom_border}\n" +
- "END \"test.txt\"\n" +
- "#{custom_border}\n"
-
- bundle = Codeball::Bundle.parse(input, config: custom_config)
-
- assert_equal "content", bundle.entries.first.contents
- end
-end
diff --git a/test/bundle_serialization_test.rb b/test/bundle_serialization_test.rb
deleted file mode 100644
index b6c0954..0000000
--- a/test/bundle_serialization_test.rb
+++ /dev/null
@@ -1,90 +0,0 @@
-require_relative "test_helper"
-
-class BundleSerializationTest < Minitest::Test
- def setup
- @tmpdir = Dir.mktmpdir
- @config = Codeball::Config.default
- end
-
- def teardown
- FileUtils.rm_rf(@tmpdir)
- end
-
- def test_serialize_produces_bordered_output
- entry = Codeball::Entry.new(path: "test.txt", contents: "hello")
- bundle = Codeball::Bundle.new([entry], config: @config)
-
- output = capture_io { bundle.serialize }.first
-
- assert_includes output, @config.full_border
- assert_includes output, 'BEGIN "test.txt"'
- assert_includes output, "hello"
- assert_includes output, 'END "test.txt"'
- end
-
- def test_serialize_handles_empty_file
- entry = Codeball::Entry.new(path: "empty.txt", contents: "")
- bundle = Codeball::Bundle.new([entry], config: @config)
-
- output = capture_io { bundle.serialize }.first
-
- assert_includes output, 'BEGIN "empty.txt"'
- assert_includes output, 'END "empty.txt"'
- end
-
- def test_serialize_multiple_files_separated
- entries = [
- Codeball::Entry.new(path: "a.txt", contents: "aaa"),
- Codeball::Entry.new(path: "b.txt", contents: "bbb")
- ]
- bundle = Codeball::Bundle.new(entries, config: @config)
-
- output = capture_io { bundle.serialize }.first
-
- assert_includes output, 'BEGIN "a.txt"'
- assert_includes output, 'END "a.txt"'
- assert_includes output, 'BEGIN "b.txt"'
- assert_includes output, 'END "b.txt"'
- end
-
- def test_from_files_reads_actual_files
- File.write(File.join(@tmpdir, "real.txt"), "real content")
-
- bundle = Codeball::Bundle.from_files([File.join(@tmpdir, "real.txt")], config: @config)
-
- assert_equal 1, bundle.entries.length
- assert_equal "real content", bundle.entries.first.contents
- end
-
- def test_from_files_skips_nonexistent
- bundle = Codeball::Bundle.from_files(["/no/such/file.txt"], config: @config)
-
- assert_empty bundle.entries
- end
-
- def test_serialize_includes_entry_with_non_text_mime_and_text_charset
- entry = Codeball::Entry.new(path: "code.md", contents: "var x = 1;")
- bundle = Codeball::Bundle.new([entry], config: @config)
-
- entry.stub(:mime_type, "application/javascript; charset=us-ascii") do
- output = capture_io { bundle.serialize }.first
-
- assert_includes output, 'BEGIN "code.md"'
- assert_includes output, "var x = 1;"
- end
- end
-
- def test_serialize_skips_non_text_without_trailing_blank_line
- text_entry = Codeball::Entry.new(path: "hello.txt", contents: "hello")
- non_text_entry = Codeball::Entry.new(path: "image.png", contents: "binary data")
- bundle = Codeball::Bundle.new([text_entry, non_text_entry], config: @config)
-
- non_text_entry.stub(:text?, false) do
- output = capture_io { bundle.serialize }.first
-
- assert_includes output, 'BEGIN "hello.txt"'
- refute_includes output, 'BEGIN "image.png"'
- refute output.end_with?("\n\n"), "Should not have trailing blank line after last entry"
- end
- end
-end
diff --git a/test/config_test.rb b/test/config_test.rb
deleted file mode 100644
index a55cdb5..0000000
--- a/test/config_test.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-require_relative "test_helper"
-
-class ConfigTest < Minitest::Test
- def test_default_config_values
- config = Codeball::Config.default
-
- assert_equal "---\t", config.border
- assert_equal 10, config.border_width
- assert_equal ".", config.output_dir
- refute_predicate config, :dry_run
- end
-
- def test_full_border_repeats_border_pattern
- config = Codeball::Config.new(border: "ab", border_width: 3, output_dir: ".", dry_run: false)
-
- assert_equal "ababab", config.full_border
- end
-
- def test_terminator_is_last_character_of_border
- config = Codeball::Config.new(border: "---\t", border_width: 1, output_dir: ".", dry_run: false)
- assert_equal "\t", config.terminator
-
- config = Codeball::Config.new(border: "###", border_width: 1, output_dir: ".", dry_run: false)
- assert_equal "#", config.terminator
- end
-end
diff --git a/test/entry_test.rb b/test/entry_test.rb
deleted file mode 100644
index 3124754..0000000
--- a/test/entry_test.rb
+++ /dev/null
@@ -1,174 +0,0 @@
-require_relative "test_helper"
-
-class EntryTest < Minitest::Test
- def setup
- @tmpdir = Dir.mktmpdir
- @output_dir = Pathname.new(@tmpdir)
- end
-
- def teardown
- FileUtils.rm_rf(@tmpdir)
- end
-
- def test_from_file_reads_content
- path = File.join(@tmpdir, "test.txt")
- File.write(path, "hello world")
-
- entry = Codeball::Entry.from_file(path)
-
- assert_equal "test.txt", File.basename(entry.path)
- assert_equal "hello world", entry.contents
- end
-
- def test_from_file_returns_nil_for_nonexistent
- entry = Codeball::Entry.from_file("/nonexistent/path/file.txt")
-
- assert_nil entry
- end
-
- def test_from_file_handles_empty_files
- path = File.join(@tmpdir, "empty.txt")
- FileUtils.touch(path)
-
- entry = Codeball::Entry.from_file(path)
-
- assert_empty entry.contents
- assert_predicate entry, :empty?
- assert_equal 0, entry.byte_size
- end
-
- def test_byte_size_returns_content_length
- entry = Codeball::Entry.new(path: "test.txt", contents: "hello")
-
- assert_equal 5, entry.byte_size
- end
-
- def test_empty_predicate
- empty = Codeball::Entry.new(path: "empty.txt", contents: "")
- nonempty = Codeball::Entry.new(path: "nonempty.txt", contents: "x")
-
- assert_predicate empty, :empty?
- refute_predicate nonempty, :empty?
- end
-
- def test_empty_entry_is_text
- entry = Codeball::Entry.new(path: "empty.txt", contents: "")
-
- assert_predicate entry, :text?
- end
-
- def test_text_for_non_text_mime_with_text_charset
- entry = Codeball::Entry.new(path: "code.md", contents: "var x = 1;")
-
- entry.stub(:mime_type, "application/javascript; charset=us-ascii") do
- assert_predicate entry, :text?
- end
- end
-
- def test_not_text_for_binary_charset
- entry = Codeball::Entry.new(path: "image.png", contents: "PNG\r\n".b)
-
- entry.stub(:mime_type, "image/png; charset=binary") do
- refute_predicate entry, :text?
- end
- end
-
- def test_entries_share_magic_client_by_default
- a = Codeball::Entry.new(path: "a.txt", contents: "aaa")
- b = Codeball::Entry.new(path: "b.txt", contents: "bbb")
-
- assert_same a.send(:magic_client), b.send(:magic_client)
- end
-
- def test_safe_for_rejects_dotdot_at_start
- entry = Codeball::Entry.new(path: "../etc/passwd", contents: "x")
-
- refute entry.safe_for?(@output_dir)
- end
-
- def test_safe_for_rejects_dotdot_in_middle
- entry = Codeball::Entry.new(path: "foo/../../../etc/passwd", contents: "x")
-
- refute entry.safe_for?(@output_dir)
- end
-
- def test_safe_for_rejects_absolute_paths
- entry = Codeball::Entry.new(path: "/etc/passwd", contents: "x")
-
- refute entry.safe_for?(@output_dir)
- end
-
- def test_safe_for_rejects_home_expansion
- entry = Codeball::Entry.new(path: "~/secret", contents: "x")
-
- refute entry.safe_for?(@output_dir)
- end
-
- def test_rejects_empty_path_at_initialization
- assert_raises(ArgumentError) do
- Codeball::Entry.new(path: "", contents: "x")
- end
- end
-
- def test_rejects_whitespace_only_path_at_initialization
- assert_raises(ArgumentError) do
- Codeball::Entry.new(path: " ", contents: "x")
- end
- end
-
- def test_safe_for_accepts_simple_filename
- entry = Codeball::Entry.new(path: "file.txt", contents: "x")
-
- assert entry.safe_for?(@output_dir)
- end
-
- def test_safe_for_accepts_nested_path
- entry = Codeball::Entry.new(path: "a/b/c/file.txt", contents: "x")
-
- assert entry.safe_for?(@output_dir)
- end
-
- def test_resolved_path_joins_with_output_dir
- entry = Codeball::Entry.new(path: "sub/file.txt", contents: "x")
-
- resolved = entry.resolved_path(@output_dir)
-
- assert_equal @output_dir.join("sub/file.txt").expand_path, resolved
- end
-
- def test_write_to_creates_file
- entry = Codeball::Entry.new(path: "test.txt", contents: "hello")
-
- result = entry.write_to(@output_dir)
-
- assert_equal :written, result.status
- assert_equal "hello", File.read(@output_dir.join("test.txt"))
- end
-
- def test_write_to_creates_parent_directories
- entry = Codeball::Entry.new(path: "a/b/c/deep.txt", contents: "nested")
-
- result = entry.write_to(@output_dir)
-
- assert_equal :written, result.status
- assert_equal "nested", File.read(@output_dir.join("a/b/c/deep.txt"))
- end
-
- def test_write_to_dry_run_does_not_create_file
- entry = Codeball::Entry.new(path: "test.txt", contents: "hello")
-
- result = entry.write_to(@output_dir, dry_run: true)
-
- assert_equal :dry_run, result.status
- refute_path_exists @output_dir.join("test.txt")
- end
-
- def test_write_to_returns_unsafe_for_dangerous_paths
- entry = Codeball::Entry.new(path: "../escape.txt", contents: "malicious")
-
- result = entry.write_to(@output_dir)
-
- assert_equal :unsafe, result.status
- refute_path_exists @output_dir.join("../escape.txt")
- end
-end
diff --git a/test/extraction_summary_test.rb b/test/extraction_summary_test.rb
index f3a39b3..5fe20aa 100644
--- a/test/extraction_summary_test.rb
+++ b/test/extraction_summary_test.rb
@@ -5,7 +5,7 @@ def test_counts_successful_extractions
results = [
Codeball::ExtractionResult.new(path: "a", status: :written),
Codeball::ExtractionResult.new(path: "b", status: :written),
- Codeball::ExtractionResult.new(path: "c", status: :unsafe)
+ Codeball::ExtractionResult.new(path: "c", status: :unsafe),
]
summary = Codeball::ExtractionSummary.new(results)
@@ -17,7 +17,7 @@ def test_counts_successful_extractions
def test_dry_run_counts_as_extracted
results = [
Codeball::ExtractionResult.new(path: "a", status: :dry_run),
- Codeball::ExtractionResult.new(path: "b", status: :dry_run)
+ Codeball::ExtractionResult.new(path: "b", status: :dry_run),
]
summary = Codeball::ExtractionSummary.new(results)
diff --git a/test/resilient_parsing_test.rb b/test/resilient_parsing_test.rb
deleted file mode 100644
index 9082912..0000000
--- a/test/resilient_parsing_test.rb
+++ /dev/null
@@ -1,152 +0,0 @@
-require_relative "test_helper"
-
-class ResilientParsingTest < Minitest::Test
- def setup
- @config = Codeball::Config.default
- end
-
- def test_parses_valid_entries_despite_truncated_final_entry
- # Two complete entries, one truncated
- input = <<~BUNDLE
- ##############################
- BEGIN "good1.txt"
- ##############################
- content one
- ##############################
- END "good1.txt"
- ##############################
-
- ##############################
- BEGIN "good2.txt"
- ##############################
- content two
- ##############################
- END "good2.txt"
- ##############################
-
- ##############################
- BEGIN "truncated.txt"
- ##############################
- this entry is truncated and has no END marker
- BUNDLE
-
- capture_io do
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 2, bundle.entries.length
- assert_equal "good1.txt", bundle.entries[0].path
- assert_equal "good2.txt", bundle.entries[1].path
- assert_equal 1, bundle.parse_errors.length
- assert_includes bundle.parse_errors.first, "truncated"
- end
- end
-
- def test_parses_with_tabs_converted_to_spaces
- # Browsers often convert tabs to spaces.
- # When content has no trailing newline, border appears on same line.
- # Test that parsing works when tabs become spaces.
- border = "--- " * 10
- input = [
- border,
- 'BEGIN "test.txt"',
- border,
- "hello world" + border,
- 'END "test.txt"',
- border
- ].join("\n") + "\n"
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 1, bundle.entries.length
- assert_equal "test.txt", bundle.entries.first.path
- assert_equal "hello world", bundle.entries.first.contents
- end
-
- def test_parses_single_truncated_entry_raises
- input = <<~BUNDLE
- ##############################
- BEGIN "only.txt"
- ##############################
- this is truncated
- BUNDLE
-
- assert_raises(Codeball::MalformedBundleError) do
- Codeball::Bundle.parse(input, config: @config)
- end
- end
-
- def test_extracts_content_with_border_like_content
- # Content that looks vaguely like a border but isn't
- input = <<~BUNDLE
- ##############################
- BEGIN "tricky.txt"
- ##############################
- some content
- --- not a border ---
- more content
- ##############################
- END "tricky.txt"
- ##############################
- BUNDLE
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 1, bundle.entries.length
- assert_includes bundle.entries.first.contents, "--- not a border ---"
- end
-
- def test_handles_empty_file_entries
- input = <<~BUNDLE
- ##############################
- BEGIN "empty.txt"
- ##############################
- ##############################
- END "empty.txt"
- ##############################
- BUNDLE
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 1, bundle.entries.length
- assert_empty bundle.entries.first.contents
- end
-
- def test_handles_path_without_quotes
- input = <<~BUNDLE
- ##############################
- BEGIN simple.txt
- ##############################
- content
- ##############################
- END simple.txt
- ##############################
- BUNDLE
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal "simple.txt", bundle.entries.first.path
- end
-
- def test_begin_marker_in_content_is_not_treated_as_new_entry
- # A BEGIN/END pair without borders should NOT create an entry.
- # Only BEGIN markers preceded by a border line are valid entry starts.
- input = <<~BUNDLE
- BEGIN "fake.txt"
- fake content
- END "fake.txt"
- ##############################
- BEGIN "real.txt"
- ##############################
- real content
- ##############################
- END "real.txt"
- ##############################
- BUNDLE
-
- bundle = Codeball::Bundle.parse(input, config: @config)
-
- assert_equal 1, bundle.entries.length, "Should only find 1 entry (real.txt), not 2"
- assert_equal "real.txt", bundle.entries.first.path
- assert_equal "real content\n", bundle.entries.first.contents
- end
-end
diff --git a/test/round_trip_test.rb b/test/round_trip_test.rb
deleted file mode 100644
index 652991c..0000000
--- a/test/round_trip_test.rb
+++ /dev/null
@@ -1,154 +0,0 @@
-require_relative "test_helper"
-
-class RoundTripTest < Minitest::Test
- def setup
- @tmpdir = Dir.mktmpdir
- @config = Codeball::Config.new(
- border: "---\t",
- border_width: 10,
- output_dir: @tmpdir,
- dry_run: false
- )
- end
-
- def teardown
- FileUtils.rm_rf(@tmpdir)
- end
-
- def test_round_trip_single_file
- original = Codeball::Entry.new(path: "test.txt", contents: "hello world")
- bundle = Codeball::Bundle.new([original], config: @config)
-
- serialized = capture_io { bundle.serialize }.first
- parsed = Codeball::Bundle.parse(serialized, config: @config)
-
- assert_equal 1, parsed.entries.length
- assert_equal "test.txt", parsed.entries.first.path
- assert_equal "hello world", parsed.entries.first.contents
- end
-
- def test_round_trip_multiple_files
- originals = [
- Codeball::Entry.new(path: "a.txt", contents: "aaa"),
- Codeball::Entry.new(path: "b.txt", contents: "bbb"),
- Codeball::Entry.new(path: "c.txt", contents: "ccc")
- ]
- bundle = Codeball::Bundle.new(originals, config: @config)
-
- serialized = capture_io { bundle.serialize }.first
- parsed = Codeball::Bundle.parse(serialized, config: @config)
-
- assert_equal 3, parsed.entries.length
- assert_equal "aaa", parsed.entries[0].contents
- assert_equal "bbb", parsed.entries[1].contents
- assert_equal "ccc", parsed.entries[2].contents
- end
-
- def test_round_trip_empty_file
- original = Codeball::Entry.new(path: "empty.txt", contents: "")
- bundle = Codeball::Bundle.new([original], config: @config)
-
- serialized = capture_io { bundle.serialize }.first
- parsed = Codeball::Bundle.parse(serialized, config: @config)
-
- assert_equal 1, parsed.entries.length
- assert_empty parsed.entries.first.contents
- end
-
- def test_round_trip_empty_file_among_nonempty
- originals = [
- Codeball::Entry.new(path: "before.txt", contents: "before"),
- Codeball::Entry.new(path: "empty.txt", contents: ""),
- Codeball::Entry.new(path: "after.txt", contents: "after")
- ]
- bundle = Codeball::Bundle.new(originals, config: @config)
-
- serialized = capture_io { bundle.serialize }.first
- parsed = Codeball::Bundle.parse(serialized, config: @config)
-
- assert_equal 3, parsed.entries.length
- assert_equal "before", parsed.entries[0].contents
- assert_empty parsed.entries[1].contents
- assert_equal "after", parsed.entries[2].contents
- end
-
- def test_round_trip_nested_paths
- original = Codeball::Entry.new(path: "a/b/c/deep.txt", contents: "deep")
- bundle = Codeball::Bundle.new([original], config: @config)
-
- serialized = capture_io { bundle.serialize }.first
- parsed = Codeball::Bundle.parse(serialized, config: @config)
-
- assert_equal "a/b/c/deep.txt", parsed.entries.first.path
- end
-
- def test_round_trip_with_custom_border
- custom_config = Codeball::Config.new(
- border: "###",
- border_width: 5,
- output_dir: @tmpdir,
- dry_run: false
- )
- original = Codeball::Entry.new(path: "test.txt", contents: "custom border")
- bundle = Codeball::Bundle.new([original], config: custom_config)
-
- serialized = capture_io { bundle.serialize }.first
- parsed = Codeball::Bundle.parse(serialized, config: custom_config)
-
- assert_equal "custom border", parsed.entries.first.contents
- end
-
- def test_round_trip_multiline_content
- content = "line 1\nline 2\nline 3\n"
- original = Codeball::Entry.new(path: "multi.txt", contents: content)
- bundle = Codeball::Bundle.new([original], config: @config)
-
- serialized = capture_io { bundle.serialize }.first
- parsed = Codeball::Bundle.parse(serialized, config: @config)
-
- assert_equal content, parsed.entries.first.contents
- end
-
- def test_round_trip_content_with_special_characters
- content = "tabs\there\nnewlines\n\nand 'quotes' and \"double quotes\""
- original = Codeball::Entry.new(path: "special.txt", contents: content)
- bundle = Codeball::Bundle.new([original], config: @config)
-
- serialized = capture_io { bundle.serialize }.first
- parsed = Codeball::Bundle.parse(serialized, config: @config)
-
- assert_equal content, parsed.entries.first.contents
- end
-
- def test_full_round_trip_to_disk
- source_dir = File.join(@tmpdir, "source")
- dest_dir = File.join(@tmpdir, "dest")
- Dir.mkdir(source_dir)
- Dir.mkdir(dest_dir)
-
- File.write(File.join(source_dir, "a.txt"), "content a")
- File.write(File.join(source_dir, "b.txt"), "content b")
- FileUtils.touch(File.join(source_dir, "empty.txt"))
-
- Dir.chdir(source_dir) do
- files = Dir.glob("*").sort
- bundle = Codeball::Bundle.from_files(files, config: @config)
- @serialized = capture_io { bundle.serialize }.first
- end
-
- dest_config = Codeball::Config.new(
- border: @config.border,
- border_width: @config.border_width,
- output_dir: dest_dir,
- dry_run: false
- )
- parsed = Codeball::Bundle.parse(@serialized, config: dest_config)
- capture_io { parsed.extract }
-
- %w[a.txt b.txt empty.txt].each do |basename|
- original = File.read(File.join(source_dir, basename))
- extracted = File.read(File.join(dest_dir, basename))
- assert_equal original, extracted, "Content mismatch for #{basename}"
- end
- end
-end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 8f3c30f..4067042 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -6,17 +6,3 @@
require_relative "../lib/codeball"
Minitest::Reporters.use!
-
-module BundleTestHelper
- def build_bundle(*files)
- files.map do |path, contents|
- "#{@border}\n" \
- "BEGIN #{path.inspect}\n" \
- "#{@border}\n" \
- "#{contents}" \
- "#{@border}\n" \
- "END #{path.inspect}\n" \
- "#{@border}\n"
- end.join("\n")
- end
-end