diff --git a/.rubocop.yml b/.rubocop.yml index 762eebb..a9b083a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,5 @@ AllCops: + NewCops: enable TargetRubyVersion: 3.0 Style/StringLiterals: @@ -6,3 +7,7 @@ Style/StringLiterals: Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes +Documentation: + Enabled: false +Style/FrozenStringLiteralComment: + Enabled: false diff --git a/Gemfile.lock b/Gemfile.lock index 214887b..5c37e41 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,19 +3,32 @@ PATH specs: rutorrent (0.1.0) bencode (~> 0.8.2) + bytesize (~> 0.1.0) + concurrent-ruby (~> 1.2) + pry (~> 0.14.1) + tty-prompt (~> 0.23.1) GEM remote: https://rubygems.org/ specs: ast (2.4.2) bencode (0.8.2) + bytesize (0.1.2) + coderay (1.1.3) + concurrent-ruby (1.2.3) diff-lcs (1.5.1) json (2.7.2) language_server-protocol (3.17.0.3) + method_source (1.1.0) parallel (1.24.0) parser (3.3.1.0) ast (~> 2.4.1) racc + pastel (0.8.0) + tty-color (~> 0.5) + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) racc (1.8.0) rainbow (3.1.1) rake (13.2.1) @@ -50,7 +63,18 @@ GEM parser (>= 3.3.1.0) ruby-progressbar (1.13.0) strscan (3.1.0) + tty-color (0.6.0) + tty-cursor (0.7.1) + tty-prompt (0.23.1) + pastel (~> 0.8) + tty-reader (~> 0.8) + tty-reader (0.9.0) + tty-cursor (~> 0.7) + tty-screen (~> 0.8) + wisper (~> 2.0) + tty-screen (0.8.2) unicode-display_width (2.5.0) + wisper (2.0.1) PLATFORMS ruby diff --git a/README.md b/README.md index 25063ec..9eb1e6f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # Rutorrent -TODO: Delete this and the text below, and describe your gem - -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/rutorrent`. To experiment with that code, run `bin/console` for an interactive prompt. +A Ruby torrent client made using concurrent-ruby for your CLI. Still in development. ## Installation @@ -18,7 +16,7 @@ If bundler is not being used to manage dependencies, install the gem by executin ## Usage -TODO: Write usage instructions here +You can simply run `bundle exec rutorrent` and follow the instructions in the console. ## Development diff --git a/bin/rutorrent b/bin/rutorrent index f2edc01..f3bb9dc 100755 --- a/bin/rutorrent +++ b/bin/rutorrent @@ -1,3 +1,5 @@ #!/usr/bin/env ruby -require 'rutorrent' -puts Rutorrent::VERSION + +require "rutorrent" + +Rutorrent::CLI.start diff --git a/lib/rutorrent.rb b/lib/rutorrent.rb index 2566d9d..50d9c0e 100644 --- a/lib/rutorrent.rb +++ b/lib/rutorrent.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -require_relative "rutorrent/version" +Dir["#{__dir__}/rutorrent/**/*.rb"].each { |f| require f } module Rutorrent class Error < StandardError; end - # Your code goes here... end diff --git a/lib/rutorrent/available_piece.rb b/lib/rutorrent/available_piece.rb new file mode 100644 index 0000000..6e158d5 --- /dev/null +++ b/lib/rutorrent/available_piece.rb @@ -0,0 +1,13 @@ +module Rutorrent + class AvailablePiece + attr_accessor :payload + + def initialize(payload) + @payload = payload.chars.map!(&:to_i) + end + + def available_pieces + payload.each_index.select { |i| payload[i] == 1 } + end + end +end diff --git a/lib/rutorrent/cli.rb b/lib/rutorrent/cli.rb new file mode 100644 index 0000000..021bcf6 --- /dev/null +++ b/lib/rutorrent/cli.rb @@ -0,0 +1,43 @@ +require "tty-prompt" +require "bencode" +require "bytesize" + +module Rutorrent + class CLI + def self.start + puts Rutorrent::MESSAGES[:no_torrents_available] if Dir["#{Dir.home}/**/*.torrent"].empty? && exit + + selected_torrent_path = prompt.select(Rutorrent::MESSAGES[:select_torrent], Dir["#{Dir.home}/**/*.torrent"]) + decoded_torrent = BEncode.load_file(selected_torrent_path) + torrent_files = prompt.multi_select(Rutorrent::MESSAGES[:instructions], + format_torrent_files_with_size(decoded_torrent)) + + torrent_files = format_torrent_files_with_size(decoded_torrent, show_byte_size: false) if torrent_files.empty? + + Rutorrent::Downloader.new(decoded_torrent, torrent_files).start + end + + def self.format_torrent_files_with_size(torrent, show_byte_size: true) + formatted_files = if torrent["info"]["files"].nil? + [torrent["info"]["name"]] + else + torrent["info"]["files"].map { |f| f["path"].join("/") } + end + + if show_byte_size + formatted_files = formatted_files.map do |file| + return "#{file} (#{ByteSize.new(torrent["info"]["length"])})" if torrent["info"]["files"].nil? + + index = torrent["info"]["files"].find_index { |f| f["path"].join("/") == file } + "#{file} (#{ByteSize.new(torrent["info"]["files"][index]["length"])})" + end + end + + formatted_files + end + + def self.prompt + @prompt ||= TTY::Prompt.new + end + end +end diff --git a/lib/rutorrent/cli_messages.rb b/lib/rutorrent/cli_messages.rb new file mode 100644 index 0000000..9203597 --- /dev/null +++ b/lib/rutorrent/cli_messages.rb @@ -0,0 +1,7 @@ +module Rutorrent + MESSAGES = { + select_torrent: "Please select a .torrent file to download", + instructions: "Use ↑/↓ arrow keys to choose a file, press Space to select and Enter to finish (by default, all files will be downloaded):", + no_torrents_available: "No .torrent files found in your home directory. Please download at least one and try again." + }.freeze +end diff --git a/lib/rutorrent/constants.rb b/lib/rutorrent/constants.rb new file mode 100644 index 0000000..413c835 --- /dev/null +++ b/lib/rutorrent/constants.rb @@ -0,0 +1,15 @@ +module Rutorrent + module Constants + PEER_MESSAGES_MAPPING = { + 0x0 => "choke", + 0x1 => "unchoke", + 0x2 => "interested", + 0x3 => "not interested", + 0x4 => "have", + 0x5 => "bitfield", + 0x6 => "request", + 0x7 => "piece", + 0x8 => "cancel" + }.freeze + end +end diff --git a/lib/rutorrent/downloader.rb b/lib/rutorrent/downloader.rb new file mode 100644 index 0000000..4d1dc69 --- /dev/null +++ b/lib/rutorrent/downloader.rb @@ -0,0 +1,20 @@ +module Rutorrent + class Downloader + def initialize(torrent, torrent_files) + @torrent = torrent + @torrent_files = torrent_files + end + + def start + tracker.connect + end + + private + + def tracker + @tracker ||= Rutorrent::Tracker.new(torrent, torrent_files) + end + + attr_reader :torrent, :torrent_files + end +end diff --git a/lib/rutorrent/file_saver.rb b/lib/rutorrent/file_saver.rb new file mode 100644 index 0000000..82c57ff --- /dev/null +++ b/lib/rutorrent/file_saver.rb @@ -0,0 +1,46 @@ +require "digest/sha1" + +class FileSaver + attr_reader :expected_hashes, :pieces, :total_pieces, :piece_length, :file_path, :file + + def initialize(file_path, piece_length, total_pieces, expected_hashes) + @file_path = file_path + @file = File.open(file_path, "wb") + @piece_length = piece_length + @total_pieces = total_pieces + @expected_hashes = expected_hashes + @pieces = Array.new(total_pieces) { "" } + end + + def save_block(piece_index, begin_offset, block_data) + @pieces[piece_index] ||= "" + @pieces[piece_index][begin_offset, block_data.length] = block_data + + @file.seek(piece_index * @piece_length) + @file.write(@pieces[piece_index]) + verify_piece(piece_index) + @pieces[piece_index] = nil + end + + def save_piece_to_file(piece_index) + @file.seek(piece_index * piece_length) + @file.write(pieces[piece_index]) + puts "Verified and saved piece #{piece_index}" + end + + def expected_final_piece_size + @total_length - (@piece_length * (@total_pieces - 1)) + end + + def verify_piece(piece_index) + piece_data = @pieces[piece_index] + piece_hash = Digest::SHA1.digest(piece_data) + expected_hash = @expected_hashes[piece_index] + + return save_piece_to_file(piece_index) + end + + def close + file.close + end +end diff --git a/lib/rutorrent/http_connection.rb b/lib/rutorrent/http_connection.rb new file mode 100644 index 0000000..952edc6 --- /dev/null +++ b/lib/rutorrent/http_connection.rb @@ -0,0 +1,74 @@ +require "net/http" + +module Rutorrent + class HTTPConnection + attr_reader :torrent, :options, :torrent_files + + def initialize(torrent, torrent_files, options = {}) + @torrent = torrent + @options = options + @torrent_files = torrent_files + + create_methods_by_options + end + + def connect + uri = build_uri + response = fetch_response(uri) + handle_response(response) + end + + private + + def request_params + { + info_hash: info_hash, + peer_id: peer_id, + port: port, + uploaded: uploaded, + downloaded: downloaded, + left: left, + compact: compact, + event: event + } + end + + def build_uri + URI("#{torrent["announce"]}?#{URI.encode_www_form(request_params)}") + end + + def create_methods_by_options + options.each do |method_name, method_value| + define_singleton_method(method_name) { method_value } + end + end + + def fetch_response(uri) + Net::HTTP.get_response(uri) + rescue StandardError => e + puts "Something went wrong: #{e}" + exit + end + + def handle_response(response) + return if response.nil? + + response = BEncode.load(response.body) + + peers = response["peers"].scan(/.{6}/) + puts "There are #{peers.size} peers available" + + unpacked_peers = unpack_peers(peers) + peer_classes = [] + + unpacked_peers.map do |ip, port| + handshake = "\x13BitTorrent protocol\x00\x00\x00\x00\x00\x00\x00\x00#{info_hash}#{peer_id}" + peer_classes << Peer.new(ip, port, handshake, info_hash) + end + end + + def unpack_peers(peers) + peers.map { |peer| peer.unpack("a4n") } + end + end +end diff --git a/lib/rutorrent/message_code_handler.rb b/lib/rutorrent/message_code_handler.rb new file mode 100644 index 0000000..eceab4d --- /dev/null +++ b/lib/rutorrent/message_code_handler.rb @@ -0,0 +1,123 @@ +module Rutorrent + class MessageCodeHandler + attr_accessor :socket, :file_savers, :downloaded_pieces, :requested_pieces, :piece_availability, :mutex, + :download_complete + + def initialize(socket, file_savers, downloaded_pieces, download_complete) + @socket = socket + @file_savers = file_savers + @downloaded_pieces = downloaded_pieces + @requested_pieces = Set.new + @piece_availability = Hash.new { |hash, key| hash[key] = 0 } + @mutex = Mutex.new + @download_complete = download_complete + end + + def handle_choke(_payload) + puts "Received choke" + sleep(5) + handle_interested(nil, sending: true) + end + + def handle_unchoke(_payload) + puts "Received unchoked" + return if downloaded_pieces.size + 1 == file_savers.sum(&:total_pieces) + + handle_request(nil, sending: true) + end + + def handle_interested(_payload, sending: false) + if sending + puts "Sending interested" + length = "\0\0\0\1" + message_id = "\2" + socket.write(length + message_id) + message = MessageReader.read_message(socket) + if Constants::PEER_MESSAGES_MAPPING[message[0]] == "unchoke" + handle_unchoke(nil) + else + puts "waiting for unchoke" + handle_interested(nil, sending: sending) + end + else + puts "Received interested" + end + end + + def handle_not_interested(payload); end + + def handle_have(payload) + puts "Received have: #{payload.unpack1("B*")}" + end + + def handle_bitfield(payload) + puts "Received bitfield" + @available_pieces = AvailablePiece.new(payload.unpack1("B*")).available_pieces + handle_interested(nil, sending: true) + end + + def handle_request(_payload, sending: false) + if sending + puts "Sending requests" + @available_pieces.each do |_available_piece| + request_info = next_request_piece + break unless request_info + + piece_index = request_info[:piece_index] + next if requested_pieces.include?(piece_index) + + begin_offset = request_info[:begin_offset] + request_length = request_info[:request_length] + + length_prefix = [13].pack("N") + message_id = [6].pack("C") + payload = [piece_index, begin_offset, request_length].pack("N*") + packet = length_prefix + message_id + payload + + socket.send(packet, 0) + requested_pieces.add(piece_index) + puts "Requested piece #{piece_index} from offset #{begin_offset} with length #{request_length}" + if download_complete.true? + puts "download completed" + return + end + end + else + puts "Received request" + end + end + + def handle_piece(payload) + puts "Received piece" + piece_index, begin_offset = payload.unpack("N2") + block_data = payload[8..] + + file_savers.each do |file_saver| + file_saver.save_block(piece_index, begin_offset, block_data) + end + + mutex.synchronize do + downloaded_pieces.add(piece_index) + requested_pieces.delete(piece_index) + end + + return if downloaded_pieces.size + 1 == file_savers.sum(&:total_pieces) + + handle_request(nil, sending: true) + end + + def handle_cancel(payload) + puts "Received cancel: #{payload.unpack1("B*")}" + end + + def next_request_piece + piece_index = @available_pieces.find { |index| !requested_pieces.include?(index) } + return nil unless piece_index + + begin_offset = 0 + request_length = 16_384 + + { piece_index: piece_index, begin_offset: begin_offset, request_length: request_length } + end + end +end diff --git a/lib/rutorrent/message_reader.rb b/lib/rutorrent/message_reader.rb new file mode 100644 index 0000000..ca62d14 --- /dev/null +++ b/lib/rutorrent/message_reader.rb @@ -0,0 +1,13 @@ +module Rutorrent + module MessageReader + def self.read_message(socket) + length_prefix = socket.read(4)&.unpack1("N") + + return nil if length_prefix.nil? || length_prefix.zero? + + message_id = socket.read(1)&.unpack1("C") + payload = socket.read(length_prefix - 1) + [message_id, payload] + end + end +end diff --git a/lib/rutorrent/peer.rb b/lib/rutorrent/peer.rb new file mode 100644 index 0000000..115ad23 --- /dev/null +++ b/lib/rutorrent/peer.rb @@ -0,0 +1,19 @@ +module Rutorrent + class Peer + attr_reader :ip, :port, :bitfield + + def initialize(ip, port) + @ip = IPAddr.new_ntoh(ip).to_s + @port = port + @bitfield = [] + end + + def update_bitfield + @bitfield = bitfield + end + + def has_piece?(piece_index) + @bitfield[piece_index] == 1 + end + end +end diff --git a/lib/rutorrent/peer_connection.rb b/lib/rutorrent/peer_connection.rb new file mode 100644 index 0000000..1e77260 --- /dev/null +++ b/lib/rutorrent/peer_connection.rb @@ -0,0 +1,105 @@ +require "concurrent-ruby" + +module Rutorrent + class PeerConnection + attr_reader :peers, :info_hash, :peer_id, :torrent_files, :file_savers, :interval, :downloaded_pieces, + :pool, :torrent_info, :piece_length, :total_pieces, :pieces_hashes, :download_complete + + def initialize(peers, torrent_info, interval) + @peers = peers + @info_hash = torrent_info[:info_hash] + @peer_id = torrent_info[:peer_id] + @torrent_files = torrent_info[:torrent_files] + format_pieces_from(torrent_info[:torrent]) + @file_savers = initialize_file_savers + @interval = interval + @downloaded_pieces = Set.new + @pool = Concurrent::FixedThreadPool.new(100) + @download_complete = Concurrent::AtomicBoolean.new(false) + end + + def start! + peers.each do |peer| + pool.post { connect_to_peer(peer) } + end + ensure + pool.shutdown + pool.wait_for_termination + file_savers.map(&:close) + report_progress + end + + private + + def start_connection(socket) + perform_handshake(socket) + message_code_class = MessageCodeHandler.new(socket, file_savers, downloaded_pieces, download_complete) + + loop do + message = MessageReader.read_message(socket) + message_code = Constants::PEER_MESSAGES_MAPPING[message[0]] + message_code_class.send("handle_#{message_code}", message[1]) + report_progress + end + end + + def shutdown_pool + puts "Shutting down thread pool..." + pool.shutdown + pool.wait_for_termination + puts "All threads terminated." + end + + def initialize_file_savers + torrent_files.each_with_index.map do |torrent_file, _index| + file_path = "#{Dir.home}/Downloads/#{torrent_file}" + FileSaver.new(file_path, piece_length, total_pieces, pieces_hashes) + end + end + + def connect_to_peer(peer) + puts "Connecting to peer #{peer.ip}:#{peer.port}" + socket = TCPSocket.new(peer.ip, peer.port) + start_connection(socket) + rescue IO::ECONNREFUSED => e + puts "Failed to connect to peer #{peer.ip}:#{peer.port} - #{e.message}" + rescue StandardError => e + puts "Something went wrong: #{e}" + ensure + socket.close if socket && !socket.closed? + end + + def perform_handshake(socket) + socket.write(handshake) + response = socket.read(68) + + raise "No response from peer" unless response + + received_info_hash = response[28, 20] + received_peer_id = response[48, 20] + + raise "Info hash mismatch" if received_info_hash != info_hash + + puts "Connected to peer: #{received_peer_id.unpack1("H*")}" + response + end + + def report_progress + puts "Download progress: #{downloaded_pieces.size} pieces downloaded." + return unless downloaded_pieces.size + 1 == @file_savers.sum(&:total_pieces) + + puts "Download complete!" + download_complete.make_true + end + + def handshake + @handshake ||= "\x13BitTorrent protocol\x00\x00\x00\x00\x00\x00\x00\x00#{info_hash}#{peer_id}" + end + + def format_pieces_from(torrent) + @piece_length = torrent["info"]["piece length"] + @pieces_hashes = torrent["info"]["pieces"].scan(/.{20}/m) + @total_pieces = @pieces_hashes.size + end + end +end diff --git a/lib/rutorrent/tracker.rb b/lib/rutorrent/tracker.rb new file mode 100644 index 0000000..7df4424 --- /dev/null +++ b/lib/rutorrent/tracker.rb @@ -0,0 +1,37 @@ +require "digest" + +module Rutorrent + class Tracker + attr_reader :torrent, :torrent_files + + def initialize(torrent, torrent_files) + @torrent = torrent + @torrent_files = torrent_files + end + + def connect + if torrent["announce-list"][2][0].include?("udp") + return UDPConnection.new(torrent, torrent_files, options).connect + end + if torrent["announce"].include?("http") + HTTPConnection.new(torrent, torrent_files, options).connect + + elsif torrent["announce"].include?("udp") + UDPConnection.new(torrent, torrent_files, options).connect + end + end + + def options + { + info_hash: Digest::SHA1.new.digest(torrent["info"].bencode), + peer_id: "-RB0001-#{Array.new(12) { rand(0..9) }.join}", + port: 6881, + uploaded: 0, + downloaded: 0, + left: torrent["info"]["length"], + compact: 1, + event: 2 + } + end + end +end diff --git a/lib/rutorrent/udp_connection.rb b/lib/rutorrent/udp_connection.rb new file mode 100644 index 0000000..c7868a1 --- /dev/null +++ b/lib/rutorrent/udp_connection.rb @@ -0,0 +1,106 @@ +module Rutorrent + class UDPConnection + attr_reader :torrent, :options, :torrent_files + + def initialize(torrent, torrent_files, options = {}) + @torrent = torrent + @options = options + @torrent_files = torrent_files + + create_methods_by_options + end + + def connect + socket = UDPSocket.new + socket.connect(format_udp_url[:host], format_udp_url[:port]) + transaction_id = rand(0..65_535) + action = 0 + socket.send(connection_request(transaction_id, action), 0) + connection_response, = socket.recvfrom(16) + if connection_response.size < 16 + puts "Invalid response size" + exit + end + + recv_action, recv_transaction_id, connection_id_high, connection_id_low = connection_response.unpack("NNNN") + if recv_action != action || recv_transaction_id != transaction_id + puts "Invalid response action or transaction id" + exit + end + + action = 1 + connection_id = (connection_id_high << 32) | connection_id_low + packet = announce_request(connection_id, transaction_id, action) + socket.send(packet, 0) + + announce_response, = socket.recvfrom(1024) + + if announce_response.size < 20 + puts "Invalid response size" + exit + end + + recv_action, recv_transaction_id, interval, leechers, seeders = announce_response.unpack("NNNNN") + if recv_action != action || recv_transaction_id != transaction_id + puts "Invalid response action or transaction id" + exit + end + + puts "Interval: #{interval}" + puts "Leechers: #{leechers}" + puts "Seeders: #{seeders}" + + peers = announce_response[20..].scan(/.{6}/) + unpacked_peers = unpack_peers(peers) + peer_classes = [] + unpacked_peers.map do |ip, port| + peer_classes << Peer.new(ip, port) + end + + torrent_info = { + info_hash: info_hash, + peer_id: peer_id, + torrent_files: torrent_files, + torrent: torrent + } + + PeerConnection.new(peer_classes, torrent_info, interval).start! + + socket.close + end + + def connection_request(transaction_id, action) + connection_id = 0x41727101980 + + [connection_id >> 32, connection_id & 0xFFFFFFFF, action, transaction_id].pack("NNNN") + end + + def announce_request(connection_id, transaction_id, action) + ip = 0 + key = rand(0..65_535) + num_want = -1 + [ + connection_id >> 32, connection_id & 0xFFFFFFFF, action, transaction_id, + info_hash, peer_id, downloaded >> 32, downloaded & 0xFFFFFFFF, + left >> 32, left & 0xFFFFFFFF, uploaded >> 32, uploaded & 0xFFFFFFFF, + event, ip, key, num_want, port + ].flatten.pack("NNNNa20a20NNNNNNNNNNn") + end + + def create_methods_by_options + options.each do |method_name, method_value| + define_singleton_method(method_name) { method_value } + end + end + + def format_udp_url + host, port = torrent["announce-list"][1][0].split("://").last.split(":") + + { host: host, port: port.to_i } + end + + def unpack_peers(peers) + peers.map { |peer| peer.unpack("a4n") } + end + end +end diff --git a/lib/rutorrent/version.rb b/lib/rutorrent/version.rb index 78952a6..c97716f 100644 --- a/lib/rutorrent/version.rb +++ b/lib/rutorrent/version.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module Rutorrent - VERSION = "0.1.0" + VERSION = "0.1.0".freeze end diff --git a/rutorrent.gemspec b/rutorrent.gemspec index 0e514e8..498a327 100644 --- a/rutorrent.gemspec +++ b/rutorrent.gemspec @@ -36,7 +36,12 @@ Gem::Specification.new do |spec| # Uncomment to register a new dependency of your gem spec.add_dependency "bencode", "~> 0.8.2" + spec.add_dependency "bytesize", "~> 0.1.0" + spec.add_dependency "concurrent-ruby", "~> 1.2" + spec.add_dependency "pry", "~> 0.14.1" + spec.add_dependency "tty-prompt", "~> 0.23.1" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html + spec.metadata["rubygems_mfa_required"] = "true" end diff --git a/spec/cli_messages_spec.rb b/spec/cli_messages_spec.rb new file mode 100644 index 0000000..7719d58 --- /dev/null +++ b/spec/cli_messages_spec.rb @@ -0,0 +1,23 @@ +RSpec.describe Rutorrent::MESSAGES do + let(:messages) { Rutorrent::MESSAGES } + + it "should have messages" do + expect(messages).not_to be nil + end + + it "returns no torrents available" do + expect(messages[:no_torrents_available]).to eq("No .torrent files found in your home directory. Please download at least one and try again.") + end + + it "returns select torrent" do + expect(messages[:select_torrent]).to eq("Please select a .torrent file to download") + end + + it "returns instructions" do + expect(messages[:instructions]).to eq("Use ↑/↓ arrow keys to choose a file, press Space to select and Enter to finish (by default, all files will be downloaded):") + end + + it "should have 3 messages" do + expect(messages.size).to eq(3) + end +end diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb new file mode 100644 index 0000000..beb58f3 --- /dev/null +++ b/spec/cli_spec.rb @@ -0,0 +1,7 @@ +RSpec.describe Rutorrent::CLI do + context ".start" do + it "should exist" do + expect(Rutorrent::CLI.respond_to?(:start)).to be true + end + end +end