droxi 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Rakefile +9 -9
- data/droxi.1.template +2 -2
- data/droxi.gemspec +2 -2
- data/lib/droxi.rb +13 -47
- data/lib/droxi/cache.rb +40 -0
- data/lib/droxi/commands.rb +34 -45
- data/lib/droxi/complete.rb +52 -8
- data/lib/droxi/state.rb +7 -50
- data/lib/droxi/text.rb +21 -7
- data/spec/all.rb +2 -2
- data/spec/commands_spec.rb +42 -32
- data/spec/complete_spec.rb +74 -126
- data/spec/state_spec.rb +4 -4
- data/spec/testutils.rb +10 -7
- data/spec/text_spec.rb +6 -6
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: aa77e9e1c5c65c6d9552b77bee94b0c4395b4a74
|
4
|
+
data.tar.gz: 404cfd0e77d97c811daa867f544b6a2faf440a4a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 273cb971fa6a4412c44aafe050e33767063cb4d0c38203858e8b8e6988c19b1884c4c99b688dffdc7cbb661a8e3851a9322ee6fcd2ca3b3f487964eae0a869ac
|
7
|
+
data.tar.gz: 7c18c86aef2cdfb0cb46c571bf01db930ab87b396ac6ee1f6b5e26de758151b4ce0eb16ce3775245b173cb6eb50579c8e3df1f71a14c0985a3978bde474f7123
|
data/Rakefile
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
task :
|
1
|
+
task default: :build
|
2
2
|
|
3
3
|
desc 'run unit tests'
|
4
4
|
task :test do
|
@@ -52,7 +52,7 @@ task :build do
|
|
52
52
|
|
53
53
|
def date(gemspec)
|
54
54
|
require 'time'
|
55
|
-
Time.parse(/\d{4}-\d{2}-\d{2}
|
55
|
+
Time.parse(gemspec[/\d{4}-\d{2}-\d{2}/]).strftime('%B %Y')
|
56
56
|
end
|
57
57
|
|
58
58
|
def commands
|
@@ -66,15 +66,15 @@ task :build do
|
|
66
66
|
def build_page
|
67
67
|
gemspec = IO.read('droxi.gemspec')
|
68
68
|
|
69
|
-
contents = IO.read('droxi.1.template')
|
70
|
-
|
71
|
-
|
72
|
-
|
69
|
+
contents = format(IO.read('droxi.1.template'),
|
70
|
+
date: date(gemspec),
|
71
|
+
version: gemspec[/\d+\.\d+\.\d+/],
|
72
|
+
commands: commands)
|
73
73
|
|
74
74
|
IO.write('build/droxi.1', contents)
|
75
75
|
end
|
76
76
|
|
77
|
-
Dir.mkdir('build') unless Dir.
|
77
|
+
Dir.mkdir('build') unless Dir.exist?('build')
|
78
78
|
build_exe
|
79
79
|
build_page
|
80
80
|
end
|
@@ -91,7 +91,7 @@ task :install do
|
|
91
91
|
FileUtils.cp('build/droxi', BIN_PATH)
|
92
92
|
FileUtils.mkdir_p(MAN_PATH)
|
93
93
|
FileUtils.cp('build/droxi.1', MAN_PATH)
|
94
|
-
rescue
|
94
|
+
rescue => error
|
95
95
|
puts error
|
96
96
|
end
|
97
97
|
end
|
@@ -102,7 +102,7 @@ task :uninstall do
|
|
102
102
|
begin
|
103
103
|
FileUtils.rm("#{BIN_PATH}/droxi")
|
104
104
|
FileUtils.rm("#{MAN_PATH}/droxi.1")
|
105
|
-
rescue
|
105
|
+
rescue => error
|
106
106
|
puts error
|
107
107
|
end
|
108
108
|
end
|
data/droxi.1.template
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
.TH DROXI 1 "{date}" "droxi {version}"
|
1
|
+
.TH DROXI 1 "%{date}" "droxi %{version}"
|
2
2
|
.SH NAME
|
3
3
|
droxi \- ftp-like command-line interface to Dropbox
|
4
4
|
.SH SYNOPSIS
|
@@ -9,6 +9,6 @@ Features include smart tab completion, globbing, and interactive help. If
|
|
9
9
|
invoked without arguments, runs in interactive mode. If invoked with arguments,
|
10
10
|
parses the arguments as a command invocation, executes the command, and exits.
|
11
11
|
.SH COMMANDS
|
12
|
-
{commands}
|
12
|
+
%{commands}
|
13
13
|
.SH AUTHOR
|
14
14
|
Written by Brandon Mulcahy (brandon@jangler.info).
|
data/droxi.gemspec
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'droxi'
|
3
|
-
s.version = '0.1.
|
4
|
-
s.date = '2014-06-
|
3
|
+
s.version = '0.1.1'
|
4
|
+
s.date = '2014-06-06'
|
5
5
|
s.summary = 'ftp-like command-line interface to Dropbox'
|
6
6
|
s.description = "A command-line Dropbox interface inspired by GNU \
|
7
7
|
coreutils, GNU ftp, and lftp. Features include smart tab \
|
data/lib/droxi.rb
CHANGED
@@ -35,7 +35,7 @@ module Droxi
|
|
35
35
|
# Attempt to authorize the user for app usage.
|
36
36
|
def self.authorize
|
37
37
|
app_key = '5sufyfrvtro9zp7'
|
38
|
-
app_secret = 'h99ihzv86jyypho'
|
38
|
+
app_secret = 'h99ihzv86jyypho' # Not so secret, is it?
|
39
39
|
|
40
40
|
flow = DropboxOAuth2FlowNoRedirect.new(app_key, app_secret)
|
41
41
|
|
@@ -43,7 +43,7 @@ module Droxi
|
|
43
43
|
code = get_auth_code(authorize_url)
|
44
44
|
|
45
45
|
begin
|
46
|
-
Settings[:access_token] = flow.finish(code)
|
46
|
+
Settings[:access_token] = flow.finish(code).first
|
47
47
|
rescue DropboxError
|
48
48
|
puts 'Invalid authorization code.'
|
49
49
|
end
|
@@ -69,54 +69,24 @@ module Droxi
|
|
69
69
|
init_readline(state)
|
70
70
|
with_interrupt_handling { do_interaction_loop(client, state, info) }
|
71
71
|
|
72
|
-
# Set pwd before exiting so that the oldpwd setting is saved to pwd
|
72
|
+
# Set pwd before exiting so that the oldpwd setting is saved to the pwd.
|
73
73
|
state.pwd = '/'
|
74
74
|
end
|
75
75
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
case type
|
80
|
-
when 'COMMAND' then Complete.command(word, Commands::NAMES)
|
81
|
-
when 'LOCAL_FILE' then Complete.local(word)
|
82
|
-
when 'LOCAL_DIR' then Complete.local_dir(word)
|
83
|
-
when 'REMOTE_FILE' then Complete.remote(word, state)
|
84
|
-
when 'REMOTE_DIR' then Complete.remote_dir(word, state)
|
85
|
-
else []
|
76
|
+
def self.init_readline(state)
|
77
|
+
Readline.completion_proc = proc do
|
78
|
+
Complete.complete(Readline.line_buffer, state)
|
86
79
|
end
|
87
|
-
end
|
88
80
|
|
89
|
-
|
90
|
-
# performed, given the current line buffer state.
|
91
|
-
def self.completion_type
|
92
|
-
words = Readline.line_buffer.split
|
93
|
-
index = words.length
|
94
|
-
index += 1 if Readline.line_buffer.end_with?(' ')
|
95
|
-
if index <= 1
|
96
|
-
'COMMAND'
|
97
|
-
elsif Commands::NAMES.include?(words[0])
|
98
|
-
cmd = Commands.const_get(words[0].upcase.to_sym)
|
99
|
-
cmd.type_of_arg(index - 2)
|
100
|
-
end
|
81
|
+
ignore_not_yet_implemented { Readline.completion_append_character = nil }
|
101
82
|
end
|
102
83
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
option.gsub(' ', '\ ').sub(/\\ $/, ' ')
|
108
|
-
end
|
109
|
-
end
|
110
|
-
|
111
|
-
begin
|
112
|
-
Readline.completion_append_character = nil
|
113
|
-
rescue NotImplementedError
|
114
|
-
nil
|
115
|
-
end
|
84
|
+
def self.ignore_not_yet_implemented
|
85
|
+
yield
|
86
|
+
rescue NotImplementedError
|
87
|
+
nil
|
116
88
|
end
|
117
89
|
|
118
|
-
# Run the associated block, handling Interrupt errors by printing a blank
|
119
|
-
# line.
|
120
90
|
def self.with_interrupt_handling
|
121
91
|
yield
|
122
92
|
rescue Interrupt
|
@@ -134,13 +104,9 @@ module Droxi
|
|
134
104
|
puts unless line
|
135
105
|
end
|
136
106
|
|
137
|
-
# Instruct the user to enter an authorization code and return the code. If
|
138
|
-
# the user gives EOF, exit the program.
|
139
107
|
def self.get_auth_code(url)
|
140
|
-
puts '
|
141
|
-
|
142
|
-
puts '3. Copy the authorization code'
|
143
|
-
print '4. Enter the authorization code here: '
|
108
|
+
puts 'Authorize this app to access your Dropbox at: ' + url
|
109
|
+
print 'Enter authorization code: '
|
144
110
|
code = $stdin.gets
|
145
111
|
code ? code.strip! : exit
|
146
112
|
end
|
data/lib/droxi/cache.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# Special +Hash+ of remote file paths to cached file metadata.
|
2
|
+
class Cache < Hash
|
3
|
+
# Add a metadata +Hash+ and its contents to the +Cache+ and return the
|
4
|
+
# +Cache+.
|
5
|
+
def add(metadata)
|
6
|
+
path = metadata['path']
|
7
|
+
store(path, metadata)
|
8
|
+
dirname = File.dirname(path)
|
9
|
+
if dirname != path
|
10
|
+
contents = fetch(dirname, {}).fetch('contents', nil)
|
11
|
+
contents << metadata if contents && !contents.include?(metadata)
|
12
|
+
end
|
13
|
+
return self unless metadata.include?('contents')
|
14
|
+
metadata['contents'].each { |content| add(content) }
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
# Remove a path's metadata from the +Cache+ and return the +Cache+.
|
19
|
+
def remove(path)
|
20
|
+
recursive_remove(path)
|
21
|
+
contents = fetch(File.dirname(path), {}).fetch('contents', nil)
|
22
|
+
contents.delete_if { |item| item['path'] == path } if contents
|
23
|
+
self
|
24
|
+
end
|
25
|
+
|
26
|
+
# Return +true+ if the path's information is cached, +false+ otherwise.
|
27
|
+
def full_info?(path, require_contents = true)
|
28
|
+
info = fetch(path, nil)
|
29
|
+
info && (!require_contents || !info['is_dir'] || info.include?('contents'))
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
# Recursively remove a path and its sub-files and directories.
|
35
|
+
def recursive_remove(path)
|
36
|
+
contents = fetch(path, {}).fetch('contents', nil)
|
37
|
+
contents.each { |item| recursive_remove(item['path']) } if contents
|
38
|
+
delete(path)
|
39
|
+
end
|
40
|
+
end
|
data/lib/droxi/commands.rb
CHANGED
@@ -6,8 +6,7 @@ require_relative 'text'
|
|
6
6
|
module Commands
|
7
7
|
# Exception indicating that a client command was given the wrong number of
|
8
8
|
# arguments.
|
9
|
-
|
10
|
-
end
|
9
|
+
UsageError = Class.new(ArgumentError)
|
11
10
|
|
12
11
|
# A client command. Contains metadata as well as execution procedure.
|
13
12
|
class Command
|
@@ -34,7 +33,7 @@ module Commands
|
|
34
33
|
# given. Raises a +UsageError+ if an invalid number of command-line
|
35
34
|
# arguments is given.
|
36
35
|
def exec(client, state, *args)
|
37
|
-
fail UsageError, @usage unless num_args_ok?(args.
|
36
|
+
fail UsageError, @usage unless num_args_ok?(args.size)
|
38
37
|
block = proc { |line| yield line if block_given? }
|
39
38
|
@procedure.yield(client, state, args, block)
|
40
39
|
end
|
@@ -45,7 +44,7 @@ module Commands
|
|
45
44
|
def type_of_arg(index)
|
46
45
|
args = @usage.split.drop(1).reject { |arg| arg.include?('-') }
|
47
46
|
return nil if args.empty?
|
48
|
-
index = [index, args.
|
47
|
+
index = [index, args.size - 1].min
|
49
48
|
args[index].tr('[].', '')
|
50
49
|
end
|
51
50
|
|
@@ -55,11 +54,11 @@ module Commands
|
|
55
54
|
# command, +false+ otherwise.
|
56
55
|
def num_args_ok?(num_args)
|
57
56
|
args = @usage.split.drop(1)
|
58
|
-
min_args = args.reject { |arg| arg.start_with?('[') }.
|
57
|
+
min_args = args.reject { |arg| arg.start_with?('[') }.size
|
59
58
|
max_args = if args.any? { |arg| arg.end_with?('...') }
|
60
59
|
num_args
|
61
60
|
else
|
62
|
-
args.
|
61
|
+
args.size
|
63
62
|
end
|
64
63
|
(min_args..max_args).include?(num_args)
|
65
64
|
end
|
@@ -75,13 +74,13 @@ module Commands
|
|
75
74
|
lambda do |_client, state, args, output|
|
76
75
|
case
|
77
76
|
when args.empty? then state.pwd = '/'
|
78
|
-
when args
|
77
|
+
when args.first == '-' then state.pwd = state.oldpwd
|
79
78
|
else
|
80
|
-
path = state.resolve_path(args
|
79
|
+
path = state.resolve_path(args.first)
|
81
80
|
if state.directory?(path)
|
82
81
|
state.pwd = path
|
83
82
|
else
|
84
|
-
output.call("cd: #{args
|
83
|
+
output.call("cd: #{args.first}: no such directory")
|
85
84
|
end
|
86
85
|
end
|
87
86
|
end
|
@@ -114,7 +113,7 @@ module Commands
|
|
114
113
|
output.call(error.inspect)
|
115
114
|
end
|
116
115
|
else
|
117
|
-
output.call('
|
116
|
+
output.call('debug: not enabled.')
|
118
117
|
end
|
119
118
|
end
|
120
119
|
)
|
@@ -175,7 +174,7 @@ module Commands
|
|
175
174
|
if args.empty?
|
176
175
|
Text.table(NAMES).each { |line| output.call(line) }
|
177
176
|
else
|
178
|
-
cmd_name = args
|
177
|
+
cmd_name = args.first
|
179
178
|
if NAMES.include?(cmd_name)
|
180
179
|
cmd = const_get(cmd_name.upcase.to_s)
|
181
180
|
output.call(cmd.usage)
|
@@ -197,12 +196,12 @@ module Commands
|
|
197
196
|
lambda do |_client, state, args, output|
|
198
197
|
path = case
|
199
198
|
when args.empty? then File.expand_path('~')
|
200
|
-
when args
|
199
|
+
when args.first == '-' then state.local_oldpwd
|
201
200
|
else
|
202
201
|
begin
|
203
|
-
File.expand_path(args
|
202
|
+
File.expand_path(args.first)
|
204
203
|
rescue ArgumentError
|
205
|
-
args
|
204
|
+
args.first
|
206
205
|
end
|
207
206
|
end
|
208
207
|
|
@@ -210,7 +209,7 @@ module Commands
|
|
210
209
|
state.local_oldpwd = Dir.pwd
|
211
210
|
Dir.chdir(path)
|
212
211
|
else
|
213
|
-
output.call("lcd: #{args
|
212
|
+
output.call("lcd: #{args.first}: no such file or directory")
|
214
213
|
end
|
215
214
|
end
|
216
215
|
)
|
@@ -238,17 +237,17 @@ module Commands
|
|
238
237
|
|
239
238
|
dirs << state.pwd if args.empty?
|
240
239
|
|
241
|
-
# First list files
|
240
|
+
# First list files.
|
242
241
|
list(state, files, files, long) { |line| output.call(line) }
|
243
242
|
output.call('') unless dirs.empty? || files.empty?
|
244
243
|
|
245
|
-
# Then list directory contents
|
244
|
+
# Then list directory contents.
|
246
245
|
dirs.each_with_index do |dir, i|
|
247
|
-
output.call(dir + ':') if dirs.
|
246
|
+
output.call(dir + ':') if dirs.size + files.size > 1
|
248
247
|
contents = state.contents(dir)
|
249
248
|
names = contents.map { |path| File.basename(path) }
|
250
249
|
list(state, contents, names, long) { |line| output.call(line) }
|
251
|
-
output.call('') if i < dirs.
|
250
|
+
output.call('') if i < dirs.size - 1
|
252
251
|
end
|
253
252
|
end
|
254
253
|
)
|
@@ -302,14 +301,16 @@ module Commands
|
|
302
301
|
# Upload a local file.
|
303
302
|
PUT = Command.new(
|
304
303
|
'put LOCAL_FILE [REMOTE_FILE]',
|
305
|
-
"Upload a local file to a remote path. If
|
306
|
-
|
307
|
-
|
308
|
-
remote
|
304
|
+
"Upload a local file to a remote path. If the remote path names a \
|
305
|
+
directory, the file will be placed in that directory. If a remote file \
|
306
|
+
of the same name already exists, Dropbox will rename the upload. When \
|
307
|
+
given only a local file path, the remote path defaults to a file of the \
|
308
|
+
same name in the remote working directory.",
|
309
309
|
lambda do |client, state, args, output|
|
310
|
-
from_path = args
|
311
|
-
to_path = (args.
|
310
|
+
from_path = args.first
|
311
|
+
to_path = (args.size == 2) ? args[1] : File.basename(from_path)
|
312
312
|
to_path = state.resolve_path(to_path)
|
313
|
+
to_path << "/#{from_path}" if state.directory?(to_path)
|
313
314
|
|
314
315
|
try_and_handle(Exception, output) do
|
315
316
|
File.open(File.expand_path(from_path), 'rb') do |file|
|
@@ -373,10 +374,10 @@ module Commands
|
|
373
374
|
# Parse and execute a line of user input in the given context.
|
374
375
|
def self.exec(input, client, state)
|
375
376
|
if input.start_with?('!')
|
376
|
-
shell(input[1, input.
|
377
|
+
shell(input[1, input.size - 1]) { |line| puts line }
|
377
378
|
elsif !input.empty?
|
378
|
-
tokens = tokenize(input)
|
379
|
-
cmd, args = tokens
|
379
|
+
tokens = Text.tokenize(input)
|
380
|
+
cmd, args = tokens.first, tokens.drop(1)
|
380
381
|
try_command(cmd, args, client, state)
|
381
382
|
end
|
382
383
|
end
|
@@ -406,18 +407,6 @@ module Commands
|
|
406
407
|
end
|
407
408
|
end
|
408
409
|
|
409
|
-
# Split a +String+ into tokens, allowing for backslash-escaped spaces, and
|
410
|
-
# return the resulting +Array+.
|
411
|
-
def self.tokenize(string)
|
412
|
-
string.split.reduce([]) do |list, token|
|
413
|
-
list << if !list.empty? && list.last.end_with?('\\')
|
414
|
-
"#{list.pop.chop} #{token}"
|
415
|
-
else
|
416
|
-
token
|
417
|
-
end
|
418
|
-
end
|
419
|
-
end
|
420
|
-
|
421
410
|
# Return a +String+ of information about a remote file for ls -l.
|
422
411
|
def self.long_info(state, path, name)
|
423
412
|
meta = state.metadata(state.resolve_path(path), false)
|
@@ -470,18 +459,18 @@ module Commands
|
|
470
459
|
metadata = client.send(method, from_path, to_path)
|
471
460
|
state.cache.remove(from_path) if method == :file_move
|
472
461
|
state.cache.add(metadata)
|
473
|
-
output.call("#{args
|
462
|
+
output.call("#{args.first} -> #{args[1]}")
|
474
463
|
end
|
475
464
|
end
|
476
465
|
|
477
466
|
# Execute a 'mv' or 'cp' operation depending on arguments given.
|
478
467
|
def self.cp_mv(client, state, args, output, cmd)
|
479
|
-
sources = expand(state, args.take(args.
|
468
|
+
sources = expand(state, args.take(args.size - 1), true, output, cmd)
|
480
469
|
method = (cmd == 'cp') ? :file_copy : :file_move
|
481
470
|
dest = state.resolve_path(args.last)
|
482
471
|
|
483
|
-
if sources.
|
484
|
-
copy_move(method, [sources
|
472
|
+
if sources.size == 1 && !state.directory?(dest)
|
473
|
+
copy_move(method, [sources.first, args.last], client, state, output)
|
485
474
|
else
|
486
475
|
cp_mv_to_dir(args, client, state, cmd, output)
|
487
476
|
end
|
@@ -489,7 +478,7 @@ module Commands
|
|
489
478
|
|
490
479
|
# Copies or moves files into a directory.
|
491
480
|
def self.cp_mv_to_dir(args, client, state, cmd, output)
|
492
|
-
sources = expand(state, args.take(args.
|
481
|
+
sources = expand(state, args.take(args.size - 1), true, nil, cmd)
|
493
482
|
method = (cmd == 'cp') ? :file_copy : :file_move
|
494
483
|
if state.metadata(state.resolve_path(args.last))
|
495
484
|
sources.each do |source|
|
data/lib/droxi/complete.rb
CHANGED
@@ -1,16 +1,58 @@
|
|
1
|
+
require_relative 'commands'
|
2
|
+
require_relative 'text'
|
3
|
+
|
1
4
|
# Module containing tab-completion logic and methods.
|
2
5
|
module Complete
|
6
|
+
# Return an +Array+ of completion options for the given input line and
|
7
|
+
# client state.
|
8
|
+
def self.complete(line, state)
|
9
|
+
tokens = Text.tokenize(line, include_empty: true)
|
10
|
+
type = completion_type(tokens)
|
11
|
+
completion_options(type, tokens.last, state).map do |option|
|
12
|
+
option.gsub(' ', '\ ').sub(/\\ $/, ' ')
|
13
|
+
.split.drop(tokens.last.count(' ')).join(' ')
|
14
|
+
.sub(/[^\\\/]$/, '\0 ')
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
# Return an +Array+ of potential tab-completion options for a given
|
21
|
+
# completion type, word, and client state.
|
22
|
+
def self.completion_options(type, word, state)
|
23
|
+
case type
|
24
|
+
when 'COMMAND' then command(word, Commands::NAMES)
|
25
|
+
when 'LOCAL_FILE' then local(word)
|
26
|
+
when 'LOCAL_DIR' then local_dir(word)
|
27
|
+
when 'REMOTE_FILE' then remote(word, state)
|
28
|
+
when 'REMOTE_DIR' then remote_dir(word, state)
|
29
|
+
else []
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Return a +String+ representing the type of tab-completion that should be
|
34
|
+
# performed, given the current line buffer state.
|
35
|
+
def self.completion_type(tokens)
|
36
|
+
index = tokens.size
|
37
|
+
if index <= 1
|
38
|
+
'COMMAND'
|
39
|
+
elsif Commands::NAMES.include?(tokens.first)
|
40
|
+
cmd = Commands.const_get(tokens.first.upcase.to_sym)
|
41
|
+
cmd.type_of_arg(index - 2)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
3
45
|
# Return an +Array+ of potential command name tab-completions for a +String+.
|
4
46
|
def self.command(string, names)
|
5
47
|
names.select { |n| n.start_with?(string) }.map { |n| n + ' ' }
|
6
48
|
end
|
7
49
|
|
8
50
|
# Return the directory in which to search for potential local tab-completions
|
9
|
-
# for a +String+.
|
51
|
+
# for a +String+.
|
10
52
|
def self.local_search_path(string)
|
11
53
|
File.expand_path(strip_filename(string))
|
12
54
|
rescue ArgumentError
|
13
|
-
|
55
|
+
string
|
14
56
|
end
|
15
57
|
|
16
58
|
# Return an +Array+ of potential local tab-completions for a +String+.
|
@@ -18,9 +60,13 @@ module Complete
|
|
18
60
|
dir = local_search_path(string)
|
19
61
|
basename = basename(string)
|
20
62
|
|
21
|
-
|
22
|
-
|
23
|
-
|
63
|
+
begin
|
64
|
+
matches = Dir.entries(dir).select { |entry| match?(basename, entry) }
|
65
|
+
matches.map do |entry|
|
66
|
+
final_match(string, entry, File.directory?(dir + '/' + entry))
|
67
|
+
end
|
68
|
+
rescue Errno::ENOENT
|
69
|
+
[]
|
24
70
|
end
|
25
71
|
end
|
26
72
|
|
@@ -60,14 +106,12 @@ module Complete
|
|
60
106
|
remote(string, state).select { |result| result.end_with?('/') }
|
61
107
|
end
|
62
108
|
|
63
|
-
private
|
64
|
-
|
65
109
|
def self.basename(string)
|
66
110
|
string.end_with?('/') ? '' : File.basename(string)
|
67
111
|
end
|
68
112
|
|
69
113
|
def self.match?(prefix, candidate)
|
70
|
-
candidate.start_with?(prefix) &&
|
114
|
+
candidate.start_with?(prefix) && !candidate[/^\.\.?$/]
|
71
115
|
end
|
72
116
|
|
73
117
|
def self.final_match(string, candidate, is_dir)
|
data/lib/droxi/state.rb
CHANGED
@@ -1,53 +1,8 @@
|
|
1
|
+
require_relative 'cache'
|
1
2
|
require_relative 'settings'
|
2
3
|
|
3
4
|
# Represents a failure of a glob expression to match files.
|
4
|
-
|
5
|
-
end
|
6
|
-
|
7
|
-
# Special +Hash+ of remote file paths to cached file metadata.
|
8
|
-
class Cache < Hash
|
9
|
-
# Add a metadata +Hash+ and its contents to the +Cache+ and return the
|
10
|
-
# +Cache+.
|
11
|
-
def add(metadata)
|
12
|
-
store(metadata['path'], metadata)
|
13
|
-
dirname = File.dirname(metadata['path'])
|
14
|
-
if dirname != metadata['path']
|
15
|
-
contents = fetch(dirname, {}).fetch('contents', nil)
|
16
|
-
contents << metadata if contents && !contents.include?(metadata)
|
17
|
-
end
|
18
|
-
return self unless metadata.include?('contents')
|
19
|
-
metadata['contents'].each { |content| add(content) }
|
20
|
-
self
|
21
|
-
end
|
22
|
-
|
23
|
-
# Remove a path from the +Cache+ and return the +Cache+.
|
24
|
-
def remove(path)
|
25
|
-
recursive_remove(path)
|
26
|
-
|
27
|
-
dir = File.dirname(path)
|
28
|
-
return self unless fetch(dir, {}).include?('contents')
|
29
|
-
fetch(dir)['contents'].delete_if { |item| item['path'] == path }
|
30
|
-
|
31
|
-
self
|
32
|
-
end
|
33
|
-
|
34
|
-
# Return +true+ if the path's information is cached, +false+ otherwise.
|
35
|
-
def full_info?(path, require_contents = true)
|
36
|
-
info = fetch(path, nil)
|
37
|
-
info && (!require_contents || !info['is_dir'] || info.include?('contents'))
|
38
|
-
end
|
39
|
-
|
40
|
-
private
|
41
|
-
|
42
|
-
# Recursively remove a path and its sub-files and directories.
|
43
|
-
def recursive_remove(path)
|
44
|
-
if fetch(path, {}).include?('contents')
|
45
|
-
fetch(path)['contents'].each { |item| recursive_remove(item['path']) }
|
46
|
-
end
|
47
|
-
|
48
|
-
delete(path)
|
49
|
-
end
|
50
|
-
end
|
5
|
+
GlobError = Class.new(ArgumentError)
|
51
6
|
|
52
7
|
# Encapsulates the session state of the client.
|
53
8
|
class State
|
@@ -82,7 +37,7 @@ class State
|
|
82
37
|
def metadata(path, require_contents = true)
|
83
38
|
tokens = path.split('/').drop(1)
|
84
39
|
|
85
|
-
(0..tokens.
|
40
|
+
(0..tokens.size).each do |i|
|
86
41
|
partial_path = '/' + tokens.take(i).join('/')
|
87
42
|
next if @cache.full_info?(partial_path, require_contents)
|
88
43
|
return nil unless fetch_metadata(partial_path)
|
@@ -117,26 +72,28 @@ class State
|
|
117
72
|
|
118
73
|
# Expand a Dropbox file path and return the result.
|
119
74
|
def resolve_path(arg)
|
75
|
+
# REVIEW: See if we can do this in fewer lines (e.g. without two gsub!s).
|
120
76
|
path = arg.start_with?('/') ? arg.dup : "#{@pwd}/#{arg}"
|
121
77
|
path.gsub!('//', '/')
|
122
78
|
nil while path.sub!(%r{/([^/]+?)/\.\.}, '')
|
123
79
|
nil while path.sub!('./', '')
|
124
80
|
path.sub!(/\/\.$/, '')
|
125
81
|
path.chomp!('/')
|
82
|
+
path.gsub!('//', '/')
|
126
83
|
path.empty? ? '/' : path
|
127
84
|
end
|
128
85
|
|
129
86
|
# Expand an +Array+ of file globs into an an +Array+ of Dropbox file paths
|
130
87
|
# and return the result.
|
131
88
|
def expand_patterns(patterns, preserve_root = false)
|
132
|
-
patterns.
|
89
|
+
patterns.flat_map do |pattern|
|
133
90
|
path = resolve_path(pattern)
|
134
91
|
if directory?(path)
|
135
92
|
preserve_root ? pattern : path
|
136
93
|
else
|
137
94
|
get_matches(pattern, path, preserve_root)
|
138
95
|
end
|
139
|
-
end
|
96
|
+
end
|
140
97
|
end
|
141
98
|
|
142
99
|
# Recursively remove directory contents from metadata cache. Yield lines of
|
data/lib/droxi/text.rb
CHANGED
@@ -8,7 +8,7 @@ module Text
|
|
8
8
|
def self.table(items)
|
9
9
|
return [] if items.empty?
|
10
10
|
width = terminal_width
|
11
|
-
item_width = items.map { |item| item.
|
11
|
+
item_width = items.map { |item| item.size }.max + 2
|
12
12
|
items_per_line = [1, width / item_width].max
|
13
13
|
format_table(items, item_width, items_per_line)
|
14
14
|
end
|
@@ -18,13 +18,27 @@ module Text
|
|
18
18
|
def self.wrap(text)
|
19
19
|
width, position = terminal_width, 0
|
20
20
|
lines = []
|
21
|
-
while position < text.
|
22
|
-
lines << get_wrap_segment(text[position, text.
|
23
|
-
position += lines.last.
|
21
|
+
while position < text.size
|
22
|
+
lines << get_wrap_segment(text[position, text.size], width)
|
23
|
+
position += lines.last.size + 1
|
24
24
|
end
|
25
25
|
lines
|
26
26
|
end
|
27
27
|
|
28
|
+
# Split a +String+ into tokens, allowing for backslash-escaped spaces, and
|
29
|
+
# return the resulting +Array+.
|
30
|
+
def self.tokenize(string, include_empty: false)
|
31
|
+
tokens = string.split
|
32
|
+
tokens << '' if include_empty && (string.empty? || string.end_with?(' '))
|
33
|
+
tokens.reduce([]) do |list, token|
|
34
|
+
list << if !list.empty? && list.last.end_with?('\\')
|
35
|
+
"#{list.pop.chop} #{token}"
|
36
|
+
else
|
37
|
+
token
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
28
42
|
private
|
29
43
|
|
30
44
|
# Return the width of the terminal in columns.
|
@@ -51,10 +65,10 @@ module Text
|
|
51
65
|
loop do
|
52
66
|
head, _, text = text.partition(' ')
|
53
67
|
line << "#{head} "
|
54
|
-
break if text.empty? || line.
|
68
|
+
break if text.empty? || line.size >= width
|
55
69
|
end
|
56
70
|
line.strip!
|
57
|
-
trim_last_word = line.
|
58
|
-
trim_last_word ? line.rpartition(' ')
|
71
|
+
trim_last_word = line.size > width && line.include?(' ')
|
72
|
+
trim_last_word ? line.rpartition(' ').first : line
|
59
73
|
end
|
60
74
|
end
|
data/spec/all.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# Use SimpleCov coverage-tracking library if available
|
1
|
+
# Use SimpleCov coverage-tracking library if available.
|
2
2
|
begin
|
3
3
|
require 'simplecov'
|
4
4
|
SimpleCov.start do
|
@@ -8,7 +8,7 @@ rescue LoadError
|
|
8
8
|
nil
|
9
9
|
end
|
10
10
|
|
11
|
-
# Run all spec tests
|
11
|
+
# Run all spec tests.
|
12
12
|
Dir.glob('spec/*_spec.rb').each do |spec|
|
13
13
|
require_relative File.basename(spec, '.rb')
|
14
14
|
end
|
data/spec/commands_spec.rb
CHANGED
@@ -28,7 +28,7 @@ describe Commands do
|
|
28
28
|
|
29
29
|
it 'must give an error message for an invalid command' do
|
30
30
|
lines = TestUtils.output_of(Commands, :shell, 'bogus')
|
31
|
-
lines.
|
31
|
+
lines.size.must_equal 1
|
32
32
|
end
|
33
33
|
end
|
34
34
|
|
@@ -104,16 +104,17 @@ describe Commands do
|
|
104
104
|
it 'must give an error message if trying to copy a bogus file' do
|
105
105
|
lines = TestUtils.output_of(Commands::CP, :exec, client, state,
|
106
106
|
'bogus', '/testing')
|
107
|
-
lines.
|
108
|
-
lines
|
107
|
+
lines.size.must_equal 1
|
108
|
+
lines.first.start_with?('cp: ').must_equal true
|
109
109
|
end
|
110
110
|
end
|
111
111
|
|
112
112
|
describe 'when executing the debug command' do
|
113
113
|
it 'must fail with an error message if debug mode is not enabled' do
|
114
114
|
ARGV.clear
|
115
|
-
TestUtils.output_of(Commands::DEBUG, :exec, client, state, '1')
|
116
|
-
|
115
|
+
lines = TestUtils.output_of(Commands::DEBUG, :exec, client, state, '1')
|
116
|
+
lines.size.must_equal 1
|
117
|
+
lines.first.start_with?('debug: ').must_equal true
|
117
118
|
end
|
118
119
|
|
119
120
|
it 'must evaluate the string if debug mode is enabled' do
|
@@ -125,8 +126,8 @@ describe Commands do
|
|
125
126
|
it 'must print the resulting exception if given exceptional input' do
|
126
127
|
ARGV << '--debug'
|
127
128
|
lines = TestUtils.output_of(Commands::DEBUG, :exec, client, state, 'x')
|
128
|
-
lines.
|
129
|
-
lines
|
129
|
+
lines.size.must_equal 1
|
130
|
+
lines.first.must_match(/^#<.+>$/)
|
130
131
|
end
|
131
132
|
|
132
133
|
it 'must fail with UsageError when given no args' do
|
@@ -145,13 +146,13 @@ describe Commands do
|
|
145
146
|
it 'must accept multiple arguments' do
|
146
147
|
args = %w(bogus1, bogus2)
|
147
148
|
TestUtils.output_of(Commands::FORGET, :exec, client, state, *args)
|
148
|
-
.
|
149
|
+
.size.must_equal 2
|
149
150
|
end
|
150
151
|
|
151
152
|
it 'must recursively clear contents of directory argument' do
|
152
153
|
Commands::LS.exec(client, state, '/', '/testing')
|
153
154
|
Commands::FORGET.exec(client, state, '/')
|
154
|
-
state.cache.
|
155
|
+
state.cache.size.must_equal 1
|
155
156
|
end
|
156
157
|
end
|
157
158
|
|
@@ -170,8 +171,8 @@ describe Commands do
|
|
170
171
|
|
171
172
|
it 'must give an error message if trying to get a bogus file' do
|
172
173
|
lines = TestUtils.output_of(Commands::GET, :exec, client, state, 'bogus')
|
173
|
-
lines.
|
174
|
-
lines
|
174
|
+
lines.size.must_equal 1
|
175
|
+
lines.first.start_with?('get: ').must_equal true
|
175
176
|
end
|
176
177
|
end
|
177
178
|
|
@@ -235,14 +236,14 @@ describe Commands do
|
|
235
236
|
TestUtils.exact_structure(client, state, 'test')
|
236
237
|
lines = TestUtils.output_of(Commands::LS, :exec, client, state,
|
237
238
|
'-l', '/testing')
|
238
|
-
lines.
|
239
|
-
/d +0 \w{3} .\d \d\d:\d\d test
|
239
|
+
lines.size.must_equal 1
|
240
|
+
lines.first[/d +0 \w{3} .\d \d\d:\d\d test/].wont_be_nil
|
240
241
|
end
|
241
242
|
|
242
243
|
it 'must give an error message if trying to list a bogus file' do
|
243
244
|
lines = TestUtils.output_of(Commands::LS, :exec, client, state, 'bogus')
|
244
|
-
lines.
|
245
|
-
lines
|
245
|
+
lines.size.must_equal 1
|
246
|
+
lines.first.start_with?('ls: ').must_equal true
|
246
247
|
end
|
247
248
|
end
|
248
249
|
|
@@ -251,15 +252,15 @@ describe Commands do
|
|
251
252
|
TestUtils.structure(client, state, 'test.txt')
|
252
253
|
path = '/testing/test.txt'
|
253
254
|
lines = TestUtils.output_of(Commands::MEDIA, :exec, client, state, path)
|
254
|
-
lines.
|
255
|
-
%r{https://.+\..+/}.match(lines
|
255
|
+
lines.size.must_equal 1
|
256
|
+
%r{https://.+\..+/}.match(lines.first).wont_be_nil
|
256
257
|
end
|
257
258
|
|
258
259
|
it 'must fail with error when given directory path' do
|
259
260
|
lines = TestUtils.output_of(Commands::MEDIA, :exec, client, state,
|
260
261
|
'/testing')
|
261
|
-
lines.
|
262
|
-
%r{https://.+\..+/}.match(lines
|
262
|
+
lines.size.must_equal 1
|
263
|
+
%r{https://.+\..+/}.match(lines.first).must_be_nil
|
263
264
|
end
|
264
265
|
|
265
266
|
it 'must fail with UsageError when given no args' do
|
@@ -269,8 +270,8 @@ describe Commands do
|
|
269
270
|
|
270
271
|
it 'must give an error message if trying to link a bogus file' do
|
271
272
|
lines = TestUtils.output_of(Commands::MEDIA, :exec, client, state, '%')
|
272
|
-
lines.
|
273
|
-
lines
|
273
|
+
lines.size.must_equal 1
|
274
|
+
lines.first.start_with?('media: ').must_equal true
|
274
275
|
end
|
275
276
|
end
|
276
277
|
|
@@ -327,7 +328,7 @@ describe Commands do
|
|
327
328
|
it 'must give an error message if trying to move a bogus file' do
|
328
329
|
lines = TestUtils.output_of(Commands::MV, :exec, client, state,
|
329
330
|
'bogus1', 'bogus2', 'bogus3')
|
330
|
-
lines.
|
331
|
+
lines.size.must_equal 3
|
331
332
|
lines.all? { |line| line.start_with?('mv: ') }.must_equal true
|
332
333
|
end
|
333
334
|
end
|
@@ -351,6 +352,15 @@ describe Commands do
|
|
351
352
|
state.metadata('/testing/dest.txt').wont_be_nil
|
352
353
|
end
|
353
354
|
|
355
|
+
it 'must put file in directory if second arg is directory' do
|
356
|
+
TestUtils.not_structure(client, state, 'test.txt')
|
357
|
+
state.pwd = '/'
|
358
|
+
`touch test.txt`
|
359
|
+
Commands::PUT.exec(client, state, 'test.txt', 'testing')
|
360
|
+
`rm test.txt`
|
361
|
+
state.metadata('/testing/test.txt').wont_be_nil
|
362
|
+
end
|
363
|
+
|
354
364
|
it 'must fail with UsageError when given no args' do
|
355
365
|
proc { Commands::PUT.exec(client, state) }
|
356
366
|
.must_raise Commands::UsageError
|
@@ -362,8 +372,8 @@ describe Commands do
|
|
362
372
|
TestUtils.structure(client, state, 'test.txt')
|
363
373
|
lines = TestUtils.output_of(Commands::SHARE, :exec, client, state,
|
364
374
|
'/testing/test.txt')
|
365
|
-
lines.
|
366
|
-
%r{https://.+\..+/}.match(lines
|
375
|
+
lines.size.must_equal 1
|
376
|
+
%r{https://.+\..+/}.match(lines.first).wont_be_nil
|
367
377
|
end
|
368
378
|
|
369
379
|
it 'must fail with UsageError when given no args' do
|
@@ -373,8 +383,8 @@ describe Commands do
|
|
373
383
|
|
374
384
|
it 'must give an error message if trying to share a bogus file' do
|
375
385
|
lines = TestUtils.output_of(Commands::SHARE, :exec, client, state, '%')
|
376
|
-
lines.
|
377
|
-
lines
|
386
|
+
lines.size.must_equal 1
|
387
|
+
lines.first.start_with?('share: ').must_equal true
|
378
388
|
end
|
379
389
|
end
|
380
390
|
|
@@ -400,27 +410,27 @@ describe Commands do
|
|
400
410
|
|
401
411
|
it 'must give an error message if trying to remove a bogus file' do
|
402
412
|
lines = TestUtils.output_of(Commands::RM, :exec, client, state, 'bogus')
|
403
|
-
lines.
|
404
|
-
lines
|
413
|
+
lines.size.must_equal 1
|
414
|
+
lines.first.start_with?('rm: ').must_equal true
|
405
415
|
end
|
406
416
|
end
|
407
417
|
|
408
418
|
describe 'when executing the help command' do
|
409
419
|
it 'must print a list of commands when given no args' do
|
410
420
|
TestUtils.output_of(Commands::HELP, :exec, client, state)
|
411
|
-
.join.split.
|
421
|
+
.join.split.size.must_equal Commands::NAMES.size
|
412
422
|
end
|
413
423
|
|
414
424
|
it 'must print help for a command when given it as an arg' do
|
415
425
|
lines = TestUtils.output_of(Commands::HELP, :exec, client, state, 'help')
|
416
|
-
lines.
|
417
|
-
lines
|
426
|
+
lines.size.must_be :>=, 2
|
427
|
+
lines.first.must_equal Commands::HELP.usage
|
418
428
|
lines.drop(1).join(' ').must_equal Commands::HELP.description
|
419
429
|
end
|
420
430
|
|
421
431
|
it 'must print an error message if given a bogus name as an arg' do
|
422
432
|
TestUtils.output_of(Commands::HELP, :exec, client, state, 'bogus')
|
423
|
-
.
|
433
|
+
.size.must_equal 1
|
424
434
|
end
|
425
435
|
|
426
436
|
it 'must fail with UsageError when given multiple args' do
|
data/spec/complete_spec.rb
CHANGED
@@ -1,175 +1,123 @@
|
|
1
|
-
require 'dropbox_sdk'
|
2
1
|
require 'minitest/autorun'
|
3
2
|
|
4
3
|
require_relative 'testutils'
|
5
4
|
require_relative '../lib/droxi/commands'
|
6
5
|
require_relative '../lib/droxi/complete'
|
7
|
-
require_relative '../lib/droxi/settings'
|
8
|
-
require_relative '../lib/droxi/state'
|
9
6
|
|
10
7
|
describe Complete do
|
11
8
|
CHARACTERS = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a
|
12
9
|
|
10
|
+
_, state = TestUtils.create_client_and_state
|
11
|
+
|
13
12
|
def random_string(length)
|
14
13
|
rand(length).times.map { CHARACTERS.sample }.join
|
15
14
|
end
|
16
15
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
end
|
22
|
-
|
23
|
-
it 'must resolve / to root directory' do
|
24
|
-
Complete.local_search_path('/').must_equal '/'
|
25
|
-
Complete.local_search_path('/f').must_equal '/'
|
26
|
-
end
|
27
|
-
|
28
|
-
it 'must resolve directory name to named directory' do
|
29
|
-
Complete.local_search_path('/home/').must_equal '/home'
|
30
|
-
Complete.local_search_path('/home/f').must_equal '/home'
|
31
|
-
end
|
32
|
-
|
33
|
-
it 'must resolve ~/ to home directory' do
|
34
|
-
Complete.local_search_path('~/').must_equal Dir.home
|
35
|
-
Complete.local_search_path('~/f').must_equal Dir.home
|
36
|
-
end
|
37
|
-
|
38
|
-
it 'must resolve ./ to working directory' do
|
39
|
-
Complete.local_search_path('./').must_equal Dir.pwd
|
40
|
-
Complete.local_search_path('./f').must_equal Dir.pwd
|
41
|
-
end
|
42
|
-
|
43
|
-
it 'must resolve ../ to parent directory' do
|
44
|
-
Complete.local_search_path('../').must_equal File.dirname(Dir.pwd)
|
45
|
-
Complete.local_search_path('../f').must_equal File.dirname(Dir.pwd)
|
46
|
-
end
|
47
|
-
|
48
|
-
it 'must resolve a bogus string to working directory' do
|
49
|
-
Complete.local_search_path('~bogus/bogus').must_equal Dir.pwd
|
16
|
+
def remote_contents(state, path)
|
17
|
+
state.contents(path).map do |entry|
|
18
|
+
entry += (state.directory?(entry) ? '/' : ' ')
|
19
|
+
entry[1, entry.size].gsub(' ', '\\ ').sub(/\\ $/, ' ')
|
50
20
|
end
|
51
21
|
end
|
52
22
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
prefix = path + random_string(5)
|
57
|
-
Complete.local(prefix).all? { |match| match.start_with?(prefix) }
|
58
|
-
end.must_equal true
|
59
|
-
1000.times.any? do
|
60
|
-
prefix = path + random_string(5)
|
61
|
-
!Complete.local(prefix).empty?
|
62
|
-
end.must_equal true
|
63
|
-
end
|
64
|
-
|
65
|
-
it 'seed must prefix results for unqualified string' do
|
66
|
-
check('')
|
67
|
-
end
|
68
|
-
|
69
|
-
it 'seed must prefix results for /' do
|
70
|
-
check('/')
|
71
|
-
end
|
72
|
-
|
73
|
-
it 'seed must prefix results for named directory' do
|
74
|
-
check('/home/')
|
75
|
-
end
|
76
|
-
|
77
|
-
it 'seed must prefix results for ~/' do
|
78
|
-
check('~/')
|
79
|
-
end
|
80
|
-
|
81
|
-
it 'seed must prefix results for ./' do
|
82
|
-
check('./')
|
23
|
+
def local_contents
|
24
|
+
files = Dir.entries(Dir.pwd).map do |entry|
|
25
|
+
entry << (File.directory?(entry) ? '/' : ' ')
|
83
26
|
end
|
27
|
+
files.reject { |file| file[/^\.\.?\/$/] }
|
28
|
+
end
|
84
29
|
|
85
|
-
|
86
|
-
|
30
|
+
describe 'when given an empty string or whitespace' do
|
31
|
+
it 'lists all command names' do
|
32
|
+
names = Commands::NAMES.map { |n| "#{n} " }
|
33
|
+
Complete.complete('', state).must_equal names
|
34
|
+
Complete.complete(' ', state).must_equal names
|
87
35
|
end
|
36
|
+
end
|
88
37
|
|
89
|
-
|
90
|
-
|
38
|
+
describe 'when given a letter' do
|
39
|
+
it 'lists all command names starting with that letter' do
|
40
|
+
letter = 'c'
|
41
|
+
names = Commands::NAMES.map { |n| "#{n} " }
|
42
|
+
matches = names.select { |n| n.start_with?(letter) }
|
43
|
+
Complete.complete(letter, state).sort.must_equal matches.sort
|
91
44
|
end
|
92
45
|
end
|
93
46
|
|
94
|
-
describe 'when
|
95
|
-
it '
|
96
|
-
|
97
|
-
File.directory?(entry) && !/^..?$/.match(entry)
|
98
|
-
end
|
99
|
-
matches = Complete.local_dir('').map { |match| match.chomp('/') }
|
100
|
-
matches.sort.must_equal entries.sort
|
47
|
+
describe 'when given a context for local files' do
|
48
|
+
it 'lists all local files except . and .. and end with correct char' do
|
49
|
+
Complete.complete('put ', state).sort.must_equal local_contents.sort
|
101
50
|
end
|
51
|
+
end
|
102
52
|
|
103
|
-
|
104
|
-
|
53
|
+
describe 'when given a context for local directories' do
|
54
|
+
it 'lists all local dirs except . and .. and end with correct char' do
|
55
|
+
dirs = local_contents.reject { |entry| entry.end_with?(' ') }
|
56
|
+
Complete.complete('lcd ', state).sort.must_equal dirs.sort
|
105
57
|
end
|
106
58
|
end
|
107
59
|
|
108
|
-
describe 'when
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
state.pwd = '/testing'
|
113
|
-
|
114
|
-
it 'must resolve unqualified string to working directory' do
|
115
|
-
Complete.remote_search_path('', state).must_equal state.pwd
|
116
|
-
Complete.remote_search_path('f', state).must_equal state.pwd
|
60
|
+
describe 'when given local context and faulty path' do
|
61
|
+
it 'must return empty list' do
|
62
|
+
Complete.complete('put bogus/', state).must_equal [] # fictional
|
63
|
+
Complete.complete('put ~bogus/', state).must_equal [] # malformed
|
117
64
|
end
|
65
|
+
end
|
118
66
|
|
119
|
-
|
120
|
-
|
121
|
-
|
67
|
+
describe 'when given an implicit context for remote files' do
|
68
|
+
it 'lists all remote files and end with correct char' do
|
69
|
+
state.pwd = '/'
|
70
|
+
entries = remote_contents(state, '/')
|
71
|
+
Complete.complete('put thing ', state).sort.must_equal entries.sort
|
122
72
|
end
|
73
|
+
end
|
123
74
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
end
|
75
|
+
describe 'when given an explicit, absolute context for remote files' do
|
76
|
+
it 'lists all remote files in path and end with correct char' do
|
77
|
+
state.pwd = '/'
|
128
78
|
|
129
|
-
|
130
|
-
Complete.
|
131
|
-
Complete.remote_search_path('./f', state).must_equal state.pwd
|
132
|
-
end
|
79
|
+
entries = remote_contents(state, '/testing').map { |e| "/#{e}" }
|
80
|
+
Complete.complete('ls /testing/', state).sort.must_equal entries.sort
|
133
81
|
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
Complete.remote_search_path('../f', state).must_equal parent
|
82
|
+
entries.map! { |e| e.sub('/testing/', '/testing/../testing/./') }
|
83
|
+
Complete.complete('ls /testing/../testing/./', state).sort
|
84
|
+
.must_equal entries.sort
|
138
85
|
end
|
139
86
|
end
|
140
87
|
|
141
|
-
describe 'when
|
142
|
-
|
143
|
-
|
144
|
-
state.pwd = '/testing'
|
145
|
-
Commands::RM.exec(client, state, '/testing/*')
|
146
|
-
%w(/testing /testing/one /testing/two).each do |dir|
|
147
|
-
Commands::MKDIR.exec(client, state, dir) unless state.metadata(dir)
|
148
|
-
end
|
149
|
-
`echo hello > test.txt`
|
150
|
-
Commands::PUT.exec(client, state, 'test.txt')
|
151
|
-
`rm test.txt`
|
88
|
+
describe 'when given an explicit, relative context for remote files' do
|
89
|
+
it 'lists all remote files in path and end with correct char' do
|
90
|
+
state.pwd = '/'
|
152
91
|
|
153
|
-
|
154
|
-
Complete.
|
155
|
-
end
|
92
|
+
entries = remote_contents(state, '/testing')
|
93
|
+
Complete.complete('ls testing/', state).sort.must_equal entries.sort
|
156
94
|
|
157
|
-
|
158
|
-
Complete.
|
95
|
+
entries.map! { |e| e.sub('testing/', 'testing/../testing/./') }
|
96
|
+
Complete.complete('ls testing/../testing/./', state).sort
|
97
|
+
.must_equal entries.sort
|
159
98
|
end
|
160
99
|
end
|
161
100
|
|
162
|
-
describe 'when
|
163
|
-
|
164
|
-
|
101
|
+
describe 'when given a context for remote directories' do
|
102
|
+
it 'lists all remote dirs and end with correct char' do
|
103
|
+
state.pwd = '/'
|
104
|
+
dirs = remote_contents(state, '/').select { |e| e.end_with?('/') }
|
105
|
+
Complete.complete('cd ', state).sort.must_equal dirs.sort
|
165
106
|
end
|
107
|
+
end
|
166
108
|
|
167
|
-
|
168
|
-
|
109
|
+
describe 'when given name with spaces' do
|
110
|
+
it 'must continue to match correctly' do
|
111
|
+
`touch a\\ b\\ c`
|
112
|
+
matches = local_contents.select { |entry| entry.start_with?('a\\ ') }
|
113
|
+
Complete.complete('lcd a\\ ', state).sort.must_equal matches.sort
|
114
|
+
`rm a\\ b\\ c`
|
169
115
|
end
|
116
|
+
end
|
170
117
|
|
171
|
-
|
172
|
-
|
118
|
+
describe 'when given an unworkable context' do
|
119
|
+
it 'lists nothing' do
|
120
|
+
Complete.complete('debug ', state).must_equal []
|
173
121
|
end
|
174
122
|
end
|
175
123
|
end
|
data/spec/state_spec.rb
CHANGED
@@ -65,18 +65,18 @@ describe State do
|
|
65
65
|
end
|
66
66
|
|
67
67
|
it 'must yield an error for a bogus path' do
|
68
|
-
TestUtils.output_of(state, :forget_contents, 'bogus').
|
68
|
+
TestUtils.output_of(state, :forget_contents, 'bogus').size.must_equal 1
|
69
69
|
end
|
70
70
|
|
71
71
|
it 'must yield an error for a non-directory path' do
|
72
72
|
TestUtils.output_of(state, :forget_contents, '/dir/file0')
|
73
|
-
.
|
73
|
+
.size.must_equal 1
|
74
74
|
end
|
75
75
|
|
76
76
|
it 'must yield an error for an already forgotten path' do
|
77
77
|
state.forget_contents('/dir')
|
78
78
|
TestUtils.output_of(state, :forget_contents, '/dir')
|
79
|
-
.
|
79
|
+
.size.must_equal 1
|
80
80
|
end
|
81
81
|
|
82
82
|
it 'must forget contents of given directory' do
|
@@ -91,7 +91,7 @@ describe State do
|
|
91
91
|
state.forget_contents('/')
|
92
92
|
state.cache['/'].include?('contents').must_equal false
|
93
93
|
state.cache.keys.any? do |key|
|
94
|
-
key.
|
94
|
+
key.size > 1
|
95
95
|
end.must_equal false
|
96
96
|
end
|
97
97
|
end
|
data/spec/testutils.rb
CHANGED
@@ -1,4 +1,8 @@
|
|
1
|
+
require 'dropbox_sdk'
|
2
|
+
|
1
3
|
require_relative '../lib/droxi/commands'
|
4
|
+
require_relative '../lib/droxi/settings'
|
5
|
+
require_relative '../lib/droxi/state'
|
2
6
|
|
3
7
|
# Module of helper methods for testing.
|
4
8
|
module TestUtils
|
@@ -6,13 +10,6 @@ module TestUtils
|
|
6
10
|
# take place.
|
7
11
|
TEST_ROOT = '/testing'
|
8
12
|
|
9
|
-
# Run the attached block, rescuing the given +Exception+ class.
|
10
|
-
def self.ignore(error_class)
|
11
|
-
yield
|
12
|
-
rescue error_class
|
13
|
-
nil
|
14
|
-
end
|
15
|
-
|
16
13
|
# Call the method on the reciever with the given args and return an +Array+
|
17
14
|
# of lines of output from the method.
|
18
15
|
def self.output_of(receiver, method, *args)
|
@@ -52,6 +49,12 @@ module TestUtils
|
|
52
49
|
Commands::RM.exec(client, state, *dead_paths)
|
53
50
|
end
|
54
51
|
|
52
|
+
# Returns a new +DropboxClient+ and +State+.
|
53
|
+
def self.create_client_and_state
|
54
|
+
client = DropboxClient.new(Settings[:access_token])
|
55
|
+
[client, State.new(client)]
|
56
|
+
end
|
57
|
+
|
55
58
|
private
|
56
59
|
|
57
60
|
# Creates a remote file at the given path.
|
data/spec/text_spec.rb
CHANGED
@@ -23,19 +23,19 @@ describe Text do
|
|
23
23
|
describe 'when wrapping text' do
|
24
24
|
it "won't return any line larger than the screen width if unnecessary" do
|
25
25
|
Text.wrap(@paragraph).all? do |line|
|
26
|
-
line.
|
26
|
+
line.size <= @columns
|
27
27
|
end.must_equal true
|
28
28
|
end
|
29
29
|
|
30
30
|
it "won't split a word larger than the screen width" do
|
31
|
-
Text.wrap(@big_word).
|
31
|
+
Text.wrap(@big_word).size.must_equal 1
|
32
32
|
end
|
33
33
|
end
|
34
34
|
|
35
35
|
describe 'when tabulating text' do
|
36
36
|
it 'must space items equally' do
|
37
37
|
lines = Text.table(@paragraph.split)
|
38
|
-
lines = lines[0, lines.
|
38
|
+
lines = lines[0, lines.size - 1]
|
39
39
|
|
40
40
|
space_positions = [0]
|
41
41
|
while lines.first.index(/ \S/, space_positions.last + 3)
|
@@ -43,18 +43,18 @@ describe Text do
|
|
43
43
|
end
|
44
44
|
|
45
45
|
space_positions.drop(1).all? do |position|
|
46
|
-
lines.all? { |line|
|
46
|
+
lines.all? { |line| line[position, 3][/ \S/] }
|
47
47
|
end.must_equal true
|
48
48
|
end
|
49
49
|
|
50
50
|
it "won't return any line larger than the screen width if unnecessary" do
|
51
51
|
Text.table(@paragraph.split).all? do |line|
|
52
|
-
line.
|
52
|
+
line.size <= @columns
|
53
53
|
end.must_equal true
|
54
54
|
end
|
55
55
|
|
56
56
|
it "won't split a word larger than the screen width" do
|
57
|
-
Text.table([@big_word]).
|
57
|
+
Text.table([@big_word]).size.must_equal 1
|
58
58
|
end
|
59
59
|
end
|
60
60
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: droxi
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Brandon Mulcahy
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-06-
|
11
|
+
date: 2014-06-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: dropbox-sdk
|
@@ -45,6 +45,7 @@ files:
|
|
45
45
|
- droxi.1.template
|
46
46
|
- droxi.gemspec
|
47
47
|
- lib/droxi.rb
|
48
|
+
- lib/droxi/cache.rb
|
48
49
|
- lib/droxi/commands.rb
|
49
50
|
- lib/droxi/complete.rb
|
50
51
|
- lib/droxi/settings.rb
|