18 Commits
1.4.0 ... 1.5.0

Author SHA1 Message Date
4a1b8705cc Release 1.5.0 2020-11-08 10:32:46 +01:00
7c8396ca45 run_editor: Pass the editor command through the shell
wordexp doesn’t work on OpenBSD, and escaping the path ourselves then
calling system() is actually easier than using wordexp.
2020-11-01 11:57:48 +01:00
639d46ed0f Introduce ot::shell_escape 2020-11-01 10:41:24 +01:00
d54bada7e6 Open handles with O_CLOEXEC
opustags’s only use of a sub-process is for spawning the EDITOR, and we
don’t want it to access our file handles.
2020-10-31 18:44:46 +01:00
57a4c0d5a0 Flush the writer before exec’ing
In the unlikely event the child process fails without exec’ing, we don’t
want both the child process and parent process to flush the OpusHead
header.

Thanks @omar-polo for reporting this!
2020-10-31 18:44:46 +01:00
d071b6cabd Fix error reporting when EDITOR fails 2020-10-31 18:10:33 +01:00
d8c36a3d3f Forbid mixing --edit with non-interactive edition options 2020-10-31 12:15:01 +01:00
ba2236facb Cancel --edit when the editor closes without saving 2020-10-31 12:11:26 +01:00
b3b092d241 Expand EDITOR/VISUAL with wordexp 2020-10-25 11:09:18 +01:00
8f0f29c056 Support VISUAL with --edit 2020-10-24 12:00:43 +02:00
e4ca6ca6ef Introduce the --edit option 2020-10-12 07:55:27 +02:00
df03cdf951 Introduce ot::execute_process 2020-10-11 18:06:40 +02:00
8252f94084 --set-all: Ignore comments starting with # 2020-10-11 18:06:39 +02:00
a1dcc8c47e Fix print_comments when output is not stdout 2020-10-11 17:43:04 +02:00
7206604f85 Make read_comments work on std::list
For consistency with ot::opus_tags.
2020-10-11 17:43:04 +02:00
6da5545b30 Flatten option compatibility checking
The more options we have the more nested it gets. It was getting
complicated.
2020-10-11 17:40:52 +02:00
537094fd53 use CMake’s FindIconv to detect iconv portably 2020-10-10 15:20:19 +02:00
be9740fe05 Explicitely include <optional>
It should have been included since we use std::optional, and not
including it breaks the build on OpenBSD.
2020-10-10 15:10:59 +02:00
10 changed files with 271 additions and 40 deletions

View File

@ -1,6 +1,11 @@
opustags changelog
==================
1.5.0 - 2020-11-08
------------------
- Introduce --edit for interactive edition.
1.4.0 - 2020-10-04
------------------

View File

@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.9)
project(
opustags
VERSION 1.4.0
VERSION 1.5.0
LANGUAGES CXX
)
@ -19,8 +19,10 @@ pkg_check_modules(OGG REQUIRED ogg)
add_compile_options(${OGG_CFLAGS})
link_directories(${OGG_LIBRARY_DIRS})
include(FindIconv)
configure_file(src/config.h.in config.h @ONLY)
include_directories(BEFORE src "${CMAKE_BINARY_DIR}" ${OGG_INCLUDE_DIRS})
include_directories(BEFORE src "${CMAKE_BINARY_DIR}" ${OGG_INCLUDE_DIRS} ${Iconv_INCLUDE_DIRS})
add_library(
ot
@ -30,11 +32,7 @@ add_library(
src/opus.cc
src/system.cc
)
target_link_libraries(ot PUBLIC ${OGG_LIBRARIES})
if (APPLE)
target_link_libraries(ot PUBLIC iconv)
endif()
target_link_libraries(ot PUBLIC ${OGG_LIBRARIES} ${Iconv_LIBRARIES})
add_executable(opustags src/opustags.cc)
target_link_libraries(opustags ot)

View File

@ -61,5 +61,6 @@ Documentation
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-e, --edit edit tags interactively in VISUAL/EDITOR
See the man page, `opustags.1`, for extensive documentation.

View File

@ -97,7 +97,12 @@ Delete all the previously existing tags.
Sets the tags from scratch.
All the original tags are deleted and new ones are read from standard input.
Each line must specify a \fIFIELD=VALUE\fP pair and be separated with line feeds.
Blank lines are ignored.
Blank lines and lines starting with \fI#\fP are ignored.
.TP
.B \-e, \-\-edit
Edit tags interactively by spawning the program specified by the EDITOR
environment variable. The allowed format is the same as \fB--set-all\fP.
If TERM and VISUAL are set, VISUAL takes precedence over EDITOR.
.SH EXAMPLES
.PP
List all the tags in file foo.opus:
@ -116,6 +121,10 @@ Remove the previously existing ARTIST tags and add the two X and Y ARTIST tags,
tags without writing them to the Opus file:
.PP
opustags in.opus --add ARTIST=X --add ARTIST=Y --delete ARTIST
.PP
Edit tags interactively in Vim:
.PP
EDITOR=vim opustags --in-place --edit file.opus
.SH CAVEATS
.PP
\fBopustags\fP currently has the following limitations:

View File

@ -36,6 +36,7 @@ Options:
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-e, --edit edit tags interactively in VISUAL/EDITOR
See the man page for extensive documentation.
)raw";
@ -50,6 +51,7 @@ static struct option getopt_options[] = {
{"set", required_argument, 0, 's'},
{"delete-all", no_argument, 0, 'D'},
{"set-all", no_argument, 0, 'S'},
{"edit", no_argument, 0, 'e'},
{NULL, 0, 0, 0}
};
@ -65,7 +67,7 @@ ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comm
return {st::bad_arguments, "No arguments specified. Use -h for help."};
int c;
optind = 0;
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DS", getopt_options, NULL)) != -1) {
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSe", getopt_options, NULL)) != -1) {
switch (c) {
case 'h':
opt.print_help = true;
@ -77,6 +79,7 @@ ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comm
break;
case 'i':
opt.in_place = true;
opt.overwrite = true;
break;
case 'y':
opt.overwrite = true;
@ -105,6 +108,9 @@ ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comm
case 'D':
opt.delete_all = true;
break;
case 'e':
opt.edit_interactively = true;
break;
case ':':
return {st::bad_arguments,
"Missing value for option '"s + argv[optind - 1] + "'."};
@ -115,32 +121,42 @@ ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comm
}
if (opt.print_help)
return st::ok;
if (opt.in_place) {
if (opt.path_out)
return {st::bad_arguments, "Cannot combine --in-place and --output."};
opt.overwrite = true;
for (int i = optind; i < argc; i++) {
if (strcmp(argv[i], "-") == 0)
return {st::bad_arguments, "Cannot modify standard input in place."};
opt.paths_in.emplace_back(argv[i]);
}
} else {
if (optind != argc - 1)
return {st::bad_arguments, "Exactly one input file must be specified."};
if (set_all && strcmp(argv[optind], "-") == 0)
return {st::bad_arguments,
"Cannot use standard input as input file when --set-all is specified."};
opt.paths_in.emplace_back(argv[optind]);
// All non-option arguments are input files.
bool stdin_as_input = false;
for (int i = optind; i < argc; i++) {
stdin_as_input = stdin_as_input || strcmp(argv[i], "-") == 0;
opt.paths_in.emplace_back(argv[i]);
}
if (opt.in_place && opt.path_out)
return {st::bad_arguments, "Cannot combine --in-place and --output."};
if (opt.in_place && stdin_as_input)
return {st::bad_arguments, "Cannot modify standard input in place."};
if ((!opt.in_place || opt.edit_interactively) && opt.paths_in.size() != 1)
return {st::bad_arguments, "Exactly one input file must be specified."};
if (set_all && stdin_as_input)
return {st::bad_arguments, "Cannot use standard input as input file when --set-all is specified."};
if (opt.edit_interactively && (stdin_as_input || opt.path_out == "-"))
return {st::bad_arguments, "Cannot edit interactively when standard input or standard output are already used."};
if (opt.edit_interactively && !opt.path_out.has_value() && !opt.in_place)
return {st::bad_arguments, "Cannot edit interactively when no output is specified."};
if (opt.edit_interactively && (opt.delete_all || !opt.to_add.empty() || !opt.to_delete.empty()))
return {st::bad_arguments, "Cannot mix --edit with -adDsS."};
if (set_all) {
// Read comments from stdin and prepend them to opt.to_add.
std::vector<std::string> comments;
std::list<std::string> comments;
auto rc = read_comments(comments_input, comments);
if (rc != st::ok)
return rc;
comments.reserve(comments.size() + opt.to_add.size());
std::move(opt.to_add.begin(), opt.to_add.end(), std::back_inserter(comments));
opt.to_add = std::move(comments);
opt.to_add.splice(opt.to_add.begin(), std::move(comments));
}
return st::ok;
}
@ -175,7 +191,7 @@ void ot::print_comments(const std::list<std::string>& comments, FILE* output)
has_control = true;
}
fwrite(local.data(), 1, local.size(), output);
putchar('\n');
putc('\n', output);
}
if (info_lost)
fputs("warning: Some tags have been transliterated to your system encoding.\n", stderr);
@ -188,7 +204,7 @@ void ot::print_comments(const std::list<std::string>& comments, FILE* output)
fputs("warning: Some tags contain control characters.\n", stderr);
}
ot::status ot::read_comments(FILE* input, std::vector<std::string>& comments)
ot::status ot::read_comments(FILE* input, std::list<std::string>& comments)
{
static ot::encoding_converter to_utf8("", "UTF-8");
comments.clear();
@ -200,6 +216,8 @@ ot::status ot::read_comments(FILE* input, std::vector<std::string>& comments)
--nread;
if (nread == 0)
continue;
if (line[0] == '#') // comment
continue;
if (memchr(line, '=', nread) == nullptr) {
ot::status rc = {ot::st::error, "Malformed tag: " + std::string(line, nread)};
free(line);
@ -256,6 +274,68 @@ static ot::status edit_tags(ot::opus_tags& tags, const ot::options& opt)
return ot::st::ok;
}
/** Spawn VISUAL or EDITOR to edit the given tags. */
static ot::status edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path)
{
const char* editor = nullptr;
if (getenv("TERM") != nullptr)
editor = getenv("VISUAL");
if (editor == nullptr) // without a terminal, or if VISUAL is unset
editor = getenv("EDITOR");
if (editor == nullptr)
return {ot::st::error,
"No editor specified in environment variable VISUAL or EDITOR."};
// Building the temporary tags file.
std::string tags_path = base_path.value_or("tags") + ".XXXXXX.opustags";
int fd = mkstemps(const_cast<char*>(tags_path.data()), 9);
FILE* tags_file;
if (fd == -1 || (tags_file = fdopen(fd, "w")) == nullptr)
return {ot::st::standard_error,
"Could not open '" + tags_path + "': " + strerror(errno)};
ot::print_comments(tags.comments, tags_file);
if (fclose(tags_file) != 0)
return {ot::st::standard_error, tags_path + ": fclose error: "s + strerror(errno)};
// Spawn the editor, and watch the modification timestamps.
ot::status rc;
timespec before, after;
if ((rc = ot::get_file_timestamp(tags_path.c_str(), before)) != ot::st::ok)
return rc;
ot::status editor_rc = ot::run_editor(editor, tags_path);
if ((rc = ot::get_file_timestamp(tags_path.c_str(), after)) != ot::st::ok)
return rc; // probably because the file was deleted
bool modified = (before.tv_sec != after.tv_sec || before.tv_nsec != after.tv_nsec);
if (editor_rc != ot::st::ok) {
if (modified)
fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
else
remove(tags_path.c_str());
return editor_rc;
} else if (!modified) {
remove(tags_path.c_str());
fputs("Cancelling edition because the tags file was not modified.\n", stderr);
return ot::st::cancel;
}
// Applying the new tags.
tags_file = fopen(tags_path.c_str(), "re");
if (tags_file == nullptr)
return {ot::st::standard_error, "Error opening " + tags_path + ": " + strerror(errno)};
if ((rc = ot::read_comments(tags_file, tags.comments)) != ot::st::ok) {
fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
return rc;
}
fclose(tags_file);
// Remove the temporary tags file only on success, because unlike the
// partial Ogg file that is irrecoverable, the edited tags file
// contains user data, so lets leave users a chance to recover it.
remove(tags_path.c_str());
return ot::st::ok;
}
/**
* Main loop of opustags. Read the packets from the reader, and forwards them to the writer.
* Transform the OpusTags packet on the fly.
@ -302,6 +382,11 @@ static ot::status process(ot::ogg_reader& reader, ot::ogg_writer* writer, const
if ((rc = edit_tags(tags, opt)) != ot::st::ok)
return rc;
if (writer) {
if (opt.edit_interactively) {
fflush(writer->file); // flush before calling the subprocess
if ((rc = edit_tags_interactively(tags, writer->path)) != ot::st::ok)
return rc;
}
auto packet = ot::render_tags(tags);
rc = writer->write_header_packet(serialno, pageno, packet);
if (rc != ot::st::ok)
@ -325,7 +410,7 @@ static ot::status run_single(const ot::options& opt, const std::string& path_in,
ot::file input;
if (path_in == "-")
input = stdin;
else if ((input = fopen(path_in.c_str(), "r")) == nullptr)
else if ((input = fopen(path_in.c_str(), "re")) == nullptr)
return {ot::st::standard_error,
"Could not open '" + path_in + "' for reading: " + strerror(errno)};
ot::ogg_reader reader(input.get());
@ -365,7 +450,7 @@ static ot::status run_single(const ot::options& opt, const std::string& path_in,
/* The output file exists. */
if (!S_ISREG(output_info.st_mode)) {
/* Special files are opened for writing directly. */
if ((final_output = fopen(path_out->c_str(), "w")) == nullptr)
if ((final_output = fopen(path_out->c_str(), "we")) == nullptr)
rc = {ot::st::standard_error,
"Could not open '" + path_out.value() + "' for writing: " +
strerror(errno)};
@ -388,6 +473,7 @@ static ot::status run_single(const ot::options& opt, const std::string& path_in,
return rc;
ot::ogg_writer writer(output);
writer.path = path_out;
rc = process(reader, &writer, opt);
if (rc == ot::st::ok)
rc = temporary_output.commit();

View File

@ -27,11 +27,14 @@
#include <iconv.h>
#include <ogg/ogg.h>
#include <stdio.h>
#include <time.h>
#include <functional>
#include <list>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ot {
@ -57,9 +60,11 @@ enum class st {
error,
standard_error, /**< Error raised by the C standard library. */
int_overflow,
cancel,
/* System */
badly_encoded,
information_lost,
child_process_failed,
/* Ogg */
bad_stream,
end_of_stream,
@ -161,6 +166,24 @@ private:
iconv_t cd; /**< conversion descriptor */
};
/** Escape a string so that a POSIX shell interprets it as a single argument. */
std::string shell_escape(std::string_view word);
/**
* Execute the editor process specified in editor. Wait for the process to exit and
* return st::ok on success, or st::child_process_failed if it did not exit with 0.
*
* editor is passed unescaped to the shell, and may contain CLI options.
* path is the name of the file to edit, which will be passed as the last argument to editor.
*/
ot::status run_editor(std::string_view editor, std::string_view path);
/**
* Return the specified paths mtime, i.e. the last data modification
* timestamp.
*/
ot::status get_file_timestamp(const char* path, timespec& mtime);
/** \} */
/***********************************************************************************************//**
@ -282,6 +305,10 @@ struct ogg_writer {
* represented as a block of data and a length.
*/
FILE* file;
/**
* Path to the output file.
*/
std::optional<std::string> path;
};
/**
@ -401,6 +428,15 @@ struct options {
* Options: --in-place
*/
bool in_place = false;
/**
* Spawn EDITOR to edit tags interactively.
*
* stdin and stdout must be left free for the editor, so paths_in and
* path_out cant take `-`, and --set-all is not supported.
*
* Option: --edit
*/
bool edit_interactively = false;
/**
* List of comments to delete. Each string is a selector according to the definition of
* #delete_comments.
@ -429,7 +465,7 @@ struct options {
*
* Options: --add, --set, --set-all
*/
std::vector<std::string> to_add;
std::list<std::string> to_add;
};
/**
@ -455,7 +491,7 @@ void print_comments(const std::list<std::string>& comments, FILE* output);
*
* The comments are converted from the system encoding to UTF-8, and returned as UTF-8.
*/
status read_comments(FILE* input, std::vector<std::string>& comments);
status read_comments(FILE* input, std::list<std::string>& comments);
/**
* Remove all comments matching the specified selector, which may either be a field name or a

View File

@ -12,10 +12,14 @@
#include <opustags.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std::string_literals;
ot::status ot::partial_file::open(const char* destination)
{
abort();
@ -132,3 +136,49 @@ ot::status ot::encoding_converter::operator()(const char* in, size_t n, std::str
"in string '" + std::string(in, n) + "'."};
return ot::st::ok;
}
std::string ot::shell_escape(std::string_view word)
{
std::string escaped_word;
// Pre-allocate the result, assuming most of the time enclosing it in single quotes is enough.
escaped_word.reserve(2 + word.size());
escaped_word += '\'';
for (char c : word) {
if (c == '\'')
escaped_word += "'\\''";
else if (c == '!')
escaped_word += "'\\!'";
else
escaped_word += c;
}
escaped_word += '\'';
return escaped_word;
}
ot::status ot::run_editor(std::string_view editor, std::string_view path)
{
std::string command = std::string(editor) + " " + shell_escape(path);
int status = system(command.c_str());
if (status == -1)
return {st::standard_error, "waitpid error: "s + strerror(errno)};
else if (!WIFEXITED(status))
return {st::child_process_failed,
"Child process did not terminate normally: "s + strerror(errno)};
else if (WEXITSTATUS(status) != 0)
return {st::child_process_failed,
"Child process exited with " + std::to_string(WEXITSTATUS(status))};
return st::ok;
}
ot::status ot::get_file_timestamp(const char* path, timespec& mtime)
{
struct stat st;
if (stat(path, &st) == -1)
return {st::standard_error, path + ": stat error: "s + strerror(errno)};
mtime = st.st_mtim; // more precise than st_mtime
return st::ok;
}

View File

@ -7,7 +7,7 @@ using namespace std::literals::string_literals;
void check_read_comments()
{
std::vector<std::string> comments;
std::list<std::string> comments;
ot::status rc;
{
std::string txt = "TITLE=a b c\n\nARTIST=X\nArtist=Y\n"s;
@ -72,19 +72,24 @@ void check_good_arguments()
if (opt.paths_in.size() != 1 || opt.paths_in.front() != "x" || !opt.path_out ||
opt.path_out != "y" || !opt.delete_all || opt.overwrite || opt.to_delete.size() != 2 ||
opt.to_delete[0] != "X" || opt.to_delete[1] != "a=b" ||
opt.to_add.size() != 1 || opt.to_add[0] != "X=Y Z")
opt.to_add != std::list<std::string>{"X=Y Z"})
throw failure("unexpected option parsing result for case #1");
opt = parse({"opustags", "-S", "x", "-S", "-a", "x=y z", "-i"});
if (opt.paths_in.size() != 1 || opt.paths_in.front() != "x" || opt.path_out ||
!opt.overwrite || opt.to_delete.size() != 0 ||
opt.to_add.size() != 2 || opt.to_add[0] != "N=1" || opt.to_add[1] != "x=y z")
opt.to_add != std::list<std::string>{"N=1", "x=y z"})
throw failure("unexpected option parsing result for case #2");
opt = parse({"opustags", "-i", "x", "y", "z"});
if (opt.paths_in.size() != 3 || opt.paths_in[0] != "x" || opt.paths_in[1] != "y" ||
opt.paths_in[2] != "z" || !opt.overwrite || !opt.in_place)
throw failure("unexpected option parsing result for case #3");
opt = parse({"opustags", "-ie", "x"});
if (opt.paths_in.size() != 1 || opt.paths_in[0] != "x" ||
!opt.edit_interactively || !opt.overwrite || !opt.in_place)
throw failure("unexpected option parsing result for case #4");
}
void check_bad_arguments()
@ -121,6 +126,19 @@ void check_bad_arguments()
error_code_case({"opustags", "-S", "x"}, "Malformed tag: INVALID", ot::st::error, "attempt to read invalid argument with -S");
error_case({"opustags", "-o", "", "--output", "y", "z"},
"Cannot specify --output more than once.", "double output with first filename empty");
error_case({"opustags", "-e", "-i", "x", "y"},
"Exactly one input file must be specified.", "editing interactively two files at once");
error_case({"opustags", "--edit", "-", "-o", "x"},
"Cannot edit interactively when standard input or standard output are already used.",
"editing interactively from stdandard intput");
error_case({"opustags", "--edit", "x", "-o", "-"},
"Cannot edit interactively when standard input or standard output are already used.",
"editing interactively to stdandard output");
error_case({"opustags", "--edit", "x"}, "Cannot edit interactively when no output is specified.", "editing without output");
error_case({"opustags", "--edit", "x", "-i", "-a", "X=Y"}, "Cannot mix --edit with -adDsS.", "mixing -e and -a");
error_case({"opustags", "--edit", "x", "-i", "-d", "X"}, "Cannot mix --edit with -adDsS.", "mixing -e and -d");
error_case({"opustags", "--edit", "x", "-i", "-D"}, "Cannot mix --edit with -adDsS.", "mixing -e and -D");
error_case({"opustags", "--edit", "x", "-i", "-S"}, "Cannot mix --edit with -adDsS.", "mixing -e and -S");
}
static void check_delete_comments()

View File

@ -4,7 +4,7 @@ use strict;
use warnings;
use utf8;
use Test::More tests => 41;
use Test::More tests => 47;
use Digest::MD5;
use File::Basename;
@ -71,6 +71,7 @@ Options:
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-e, --edit edit tags interactively in VISUAL/EDITOR
See the man page for extensive documentation.
EOF
@ -179,6 +180,7 @@ ARTIST=七面鳥
A=A
X=Y
#IGNORE=COMMENTS
END_IN
OK=yes again
ARTIST=七面鳥
@ -221,6 +223,23 @@ is(md5('out2.opus'), '0a4d20c287b2e46b26cb0eee353c2069', 'the tags were added co
unlink('out.opus');
unlink('out2.opus');
####################################################################################################
# Interactive edition
$ENV{EDITOR} = 'sed -i -e y/aeiou/AEIOU/ `sleep 0.1`';
is_deeply(opustags('gobble.opus', '-eo', "'screaming !'.opus"), ['', '', 0], 'edit a file with EDITOR');
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were modified');
$ENV{EDITOR} = 'true';
is_deeply(opustags('-ie', "'screaming !'.opus"), ['', "Cancelling edition because the tags file was not modified.\n", 256], 'close -e without saving');
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were not modified');
$ENV{EDITOR} = 'false';
is_deeply(opustags('-ie', "'screaming !'.opus"), ['', "'screaming !'.opus: error: Child process exited with 1\n", 256], 'editor exiting with an error');
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were not modified');
unlink("'screaming !'.opus");
####################################################################################################
# Test muxed streams

View File

@ -49,10 +49,19 @@ void check_converter()
is(rc, ot::st::badly_encoded, "conversion from bad UTF-8 fails");
}
void check_shell_esape()
{
is(ot::shell_escape("foo"), "'foo'", "simple string");
is(ot::shell_escape("a'b"), "'a'\\''b'", "string with a simple quote");
is(ot::shell_escape("a!b"), "'a'\\!'b'", "string with a bang");
is(ot::shell_escape("a!b'c!d'e"), "'a'\\!'b'\\''c'\\!'d'\\''e'", "string with a bang");
}
int main(int argc, char **argv)
{
plan(2);
plan(3);
run(check_partial_files, "test partial files");
run(check_converter, "test encoding converter");
run(check_shell_esape, "test shell escaping");
return 0;
}