summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2021-04-17 14:06:10 +0300
committerLars Wirzenius <liw@liw.fi>2021-04-17 15:53:21 +0300
commitf236aef565c1f9c99dcf24979830b232ab6749bc (patch)
tree0d1053ad8ef0b6f7de9a74485b641b81dd85c0c4
parente325fc5e47e5ef34969a30fc6568a58a6026f39b (diff)
downloadclab-f236aef565c1f9c99dcf24979830b232ab6749bc.tar.gz
rewrite in rust
-rw-r--r--.gitignore5
-rw-r--r--Cargo.lock349
-rw-r--r--Cargo.toml14
-rw-r--r--NEWS23
-rw-r--r--README45
-rwxr-xr-xabook-to-clab47
-rwxr-xr-xcheck40
-rwxr-xr-xclab182
-rw-r--r--clab.md74
-rw-r--r--clab.yarn133
-rw-r--r--clablib/__init__.py1
-rw-r--r--clablib/version.py2
-rw-r--r--debian/changelog31
-rw-r--r--debian/compat1
-rw-r--r--debian/control18
-rw-r--r--debian/copyright23
-rwxr-xr-xdebian/rules5
-rw-r--r--debian/source/format1
-rw-r--r--setup.py30
-rw-r--r--src/lib.rs1
-rw-r--r--src/main.rs189
-rw-r--r--subplot/clab.py17
-rw-r--r--subplot/clab.yaml5
-rw-r--r--subplot/vendor/files.py194
-rw-r--r--subplot/vendor/files.yaml83
-rw-r--r--subplot/vendor/runcmd.py261
-rw-r--r--subplot/vendor/runcmd.yaml91
27 files changed, 1323 insertions, 542 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b36c8f8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+clab.html
+clab.pdf
+test.log
+test.py
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..ca7d2a8
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,349 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+[[package]]
+name = "ansi_term"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b"
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clab"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "directories-next",
+ "serde",
+ "serde_yaml",
+ "structopt",
+]
+
+[[package]]
+name = "clap"
+version = "2.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
+dependencies = [
+ "ansi_term",
+ "atty",
+ "bitflags",
+ "strsim",
+ "textwrap",
+ "unicode-width",
+ "vec_map",
+]
+
+[[package]]
+name = "directories-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "dtoa"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0"
+
+[[package]]
+name = "getrandom"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "heck"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41"
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec"
+dependencies = [
+ "unicode-xid",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
+dependencies = [
+ "getrandom",
+ "redox_syscall",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.8.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23"
+dependencies = [
+ "dtoa",
+ "linked-hash-map",
+ "serde",
+ "yaml-rust",
+]
+
+[[package]]
+name = "strsim"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
+
+[[package]]
+name = "structopt"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5277acd7ee46e63e5168a80734c9f6ee81b1367a7d8772a2d765df2a3705d28c"
+dependencies = [
+ "clap",
+ "lazy_static",
+ "structopt-derive",
+]
+
+[[package]]
+name = "structopt-derive"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ba9cdfda491b814720b6b06e0cac513d922fc407582032e8706e9f137976f90"
+dependencies = [
+ "heck",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
+dependencies = [
+ "unicode-width",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
+
+[[package]]
+name = "vec_map"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
+
+[[package]]
+name = "version_check"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
+
+[[package]]
+name = "wasi"
+version = "0.10.2+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..c61226c
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "clab"
+version = "0.1.0"
+authors = ["Lars Wirzenius <liw@liw.fi>"]
+edition = "2018"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1"
+directories-next = "2"
+serde = { version = "1", features = ["derive"] }
+serde_yaml = "0.8"
+structopt = "0.3"
diff --git a/NEWS b/NEWS
deleted file mode 100644
index ad83a42..0000000
--- a/NEWS
+++ /dev/null
@@ -1,23 +0,0 @@
-NEWS for clab
-=============
-
-clab is a command line address book program.
-
-Version 0.4+git, not yet released
----------------------------------
-
-
-Version 0.4, released 2016-04-16
------------------------------
-
-* Multiline values (e.g., addrsses) with Unicode are now shown as
- multiline values, not a single-line encoded value.
-
-Version 0.3, released 2016-01-08
---------------------------------
-
-* First NEWS file entry.
-
-* Show results of `clab find` with pretty multiline strings, rather
- than the default YAML formatting.
-
diff --git a/README b/README
deleted file mode 100644
index eae853b..0000000
--- a/README
+++ /dev/null
@@ -1,45 +0,0 @@
-clab README
-===========
-
-This is a quick hack, for now. I will want to rewrite it from scratch
-if it turns out to be a useful tool, and provide a test suite and
-documentation and such.
-
-If you can decipher the code, feel free to use it. If not, do not
-ask me about it.
-
-----
-
-Random junk:
-
- address book command line aplication
-
- bbook list
- bbook find PATTERN
- bbook add KEY[.SUBKEY]=VALUE...
- bbook edit ID KEY[.SUBKEY]=VALUE...
- bbook delete ID...
- bbook delete-key ID KEY[=VALUE]...
- bbook show ID...
- bbook mutt-query PATTERN
- bbook add-from-email
-
- clab
-
- store data in a git repo, in YAML files, one file per person
-
- data model:
- - each person is a YAML dict
- - the dict has key/value pairs
- - a value can be a straight string, or a sub-dict
- - keys in the sub-dict are called sub-keys
- - only one level of sub-dict (at least for now)
- - dict and sub-dict keys are arbitrary
-
- questions:
- - do I need a unique id for everyone? just filename?
-
- thoughts:
- - I can edit files by hand at first, priority should be for mutt-query subcmd
- so I can start using this for real asap
-
diff --git a/abook-to-clab b/abook-to-clab
deleted file mode 100755
index f182972..0000000
--- a/abook-to-clab
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/usr/bin/python
-
-
-import ConfigParser
-import os
-import yaml
-import sys
-
-
-records = {}
-
-for filename in sys.argv[1:]:
- cp = ConfigParser.ConfigParser()
- cp.read([filename])
- for section in cp.sections():
- if section == 'format':
- continue
- obj = {}
- for option in cp.options(section):
- assert option not in obj
- v = cp.get(section, option, raw=True)
- if ',' in v:
- vs = v.split(',')
- obj[option] = dict((str(i), x) for i, x in enumerate(vs))
- else:
- obj[option] = v
-
- assert type(obj['name']) is str
- uid = ''.join(obj['name'].split()).lower()
- if uid in records:
- r = records[uid]
- for key in obj:
- if key in r:
- v = r[key]
- if type(v) is dict:
- v[str(len(v) + 1)] = obj[key]
- else:
- r[key] = { '0': v, '1': obj[key] }
- else:
- r[key] = obj[key]
- else:
- records[uid] = obj
-
-for uid, r in records.iteritems():
- with open(os.path.join('db', uid + '.yaml'), 'w') as f:
- yaml.safe_dump(r, stream=f, default_flow_style=False)
-
diff --git a/check b/check
new file mode 100755
index 0000000..bc01c0c
--- /dev/null
+++ b/check
@@ -0,0 +1,40 @@
+#!/bin/sh
+#
+# Run the automated tests for the project.
+
+set -eu
+
+hideok=chronic
+if [ "$#" -gt 0 ]
+then
+ case "$1" in
+ verbose | -v | --verbose)
+ hideok=
+ shift
+ ;;
+ esac
+fi
+
+got_cargo_cmd()
+{
+ cargo "$1" --help > /dev/null
+}
+
+got_cargo_cmd clippy && cargo clippy -q --all-targets
+$hideok cargo build --all-targets
+got_cargo_cmd fmt && $hideok cargo fmt -- --check
+$hideok cargo test
+
+subplot docgen clab.md -o clab.html
+subplot docgen clab.md -o clab.pdf
+
+subplot codegen clab.md -o test.py
+rm -f test.log
+if [ "$(id -un)" = root ]
+then
+ echo Not running tests as root.
+else
+ $hideok python3 test.py --log test.log "$@"
+fi
+
+echo "Everything seems to be in order."
diff --git a/clab b/clab
deleted file mode 100755
index a1272dc..0000000
--- a/clab
+++ /dev/null
@@ -1,182 +0,0 @@
-#!/usr/bin/python
-#
-# Copyright 2013-2016 Lars Wirzenius
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-
-import cliapp
-import logging
-import os
-import sys
-import yaml
-
-
-__version__ = '0.3'
-
-
-class Entry(object):
-
- def __init__(self, parsed_yaml):
- assert type(parsed_yaml) is dict, repr(parsed_yaml)
- self._dict = parsed_yaml
-
- def as_yaml(self):
- return yaml.dump(
- self._dict, default_flow_style=False, allow_unicode=True)
-
- def get_single(self, key, default):
- names = self.get_subdict(key)
- if not names:
- return default
- keys = sorted(names.keys())
- return names[keys[0]]
-
- def get_subdict(self, key):
- if key in self._dict:
- v = self._dict[key]
- if type(v) is dict:
- return v
- return {'': v}
- return {}
-
-
-class AddressBook(object):
-
- def __init__(self):
- self.entries = []
-
- def find_yaml_files(self, database_root):
- for dirname, subdirs, basenames in os.walk(database_root):
- subdirs.sort()
- for basename in sorted(basenames):
- if basename.endswith('.yaml'):
- yield os.path.join(dirname, basename)
-
- def add_from_database(self, database_root):
- logging.info('Adding from database %s' % database_root)
- for yaml_filename in self.find_yaml_files(database_root):
- self.add_from_file(yaml_filename)
-
- def add_from_file(self, filename):
- logging.info('Adding from file %s' % filename)
- with open(filename) as f:
- parsed_yaml = yaml.safe_load(f)
- if type(parsed_yaml) is list:
- for parsed_entry in parsed_yaml:
- entry = Entry(parsed_entry)
- self.entries.append(entry)
- else:
- entry = Entry(parsed_yaml)
- self.entries.append(entry)
-
- def find(self, patterns):
- return [e for e in self.entries if self.matches(e, patterns)]
-
- def matches(self, entry, patterns):
- s = entry.as_yaml().lower()
- return any(p.lower() in s for p in patterns)
-
-
-class CommandLineAddressBook(cliapp.Application):
-
- cmd_synopsis = {
- 'list': '',
- 'find': '[PATTERN...]',
- 'mutt-query': '[PATTERN...]',
- }
-
- def add_settings(self):
- self.settings.string_list(
- ['database', 'db', 'd'],
- 'add DIR to list of databases to use',
- metavar='DIR',
- default=[os.path.expanduser('~/.local/share/clab')])
-
- def setup(self):
- # Configure yaml.dump (but not yaml.safe_dump) to print multiline
- # strings prettier.
-
- def text_representer(dumper, data):
- if '\n' in data:
- return dumper.represent_scalar(
- u'tag:yaml.org,2002:str', data, style='|')
- return dumper.represent_scalar(
- u'tag:yaml.org,2002:str', data, style='')
-
- yaml.add_representer(str, text_representer)
- yaml.add_representer(unicode, text_representer)
-
- def load_address_book(self):
- book = AddressBook()
- for database in self.settings['database']:
- book.add_from_database(database)
- return book
-
- def cmd_list(self, args):
- '''List all entries in the database.
-
- This lists all the entries. See the find subcommand for
- searching specific entries.
-
- '''
- book = self.load_address_book()
- for entry in book.entries:
- self.output.write(entry.as_yaml() + '\n')
-
- def cmd_find(self, args):
- '''List entries that match patterns.
-
- Each pattern is a fixed string (not a regular expression).
- Matching is for any text in the entry.
-
- '''
-
- book = self.load_address_book()
- for entry in book.find(args):
- self.output.write(entry.as_yaml() + '\n')
-
- def cmd_mutt_query(self, args):
- '''Find entries for use with mutt.
-
- This is like the find subcommand, but output is formatted
- so it's suitable for the mutt query hook.
-
- '''
-
- if len(args) != 1:
- raise cliapp.AppException(
- 'mutt-query requires exactly one argument')
-
- book = self.load_address_book()
- entries = book.find(args)
- if not entries:
- self.output.write('No matches\n')
- sys.exit(1)
- self.output.write('clab found matches:\n')
-
- name_addr_pairs = set()
- for entry in entries:
- name = entry.get_single('name', '')
- emails = entry.get_subdict('email')
- for email in emails:
- name_addr_pairs.add((name, emails[email]))
-
- for name, addr in sorted(name_addr_pairs):
- n = name.encode('utf-8')
- a = addr.encode('utf-8')
- self.output.write('%s\t%s\n' % (a, n))
-
-
-CommandLineAddressBook().run()
diff --git a/clab.md b/clab.md
new file mode 100644
index 0000000..9b2bef4
--- /dev/null
+++ b/clab.md
@@ -0,0 +1,74 @@
+# Introduction
+
+`clab` is a command line address book application. It has no
+interactive features. This document collects its acceptance criteria.
+
+# Empty database
+
+~~~scenario
+given an installed clab
+when I run clab lint
+then command is successful
+
+when I run clab search Alice
+then command is successful
+then stdout is exactly ""
+~~~
+
+# Alice and Bob
+
+Next, let's add records for Alice and Bob, and make
+sure searches find only the right records.
+
+
+~~~scenario
+given an installed clab
+
+given file .local/share/clab/address-book.yaml from address-book.yaml
+
+when I run clab lint
+then command is successful
+
+when I run clab list
+then command is successful
+then stdout is valid YAML
+then stdout contains "Alice Atherthon"
+then stdout contains "Bob Bobbington"
+
+when I run clab search Alice
+then command is successful
+then stdout is valid YAML
+then stdout contains "Alice Atherthon"
+then stdout doesn't contain "Bob"
+
+when I run clab mutt-query Alice
+then command is successful
+then stdout contains "Alice Atherthon"
+then stdout contains "alice@example.com"
+then stdout doesn't contain "Bob"
+then stdout doesn't contain "bob@example.com"
+~~~
+
+~~~{#address-book.yaml .file .yaml}
+- name: Alice Atherthon
+ email:
+ work: alice@example.com
+- name: Bob Bobbington
+ email:
+ personal: bob@example.com
+~~~
+
+
+---
+title: "clab; &ndash; command line address book"
+author: Lars Wirzenius
+template: python
+bindings:
+- subplot/clab.yaml
+- subplot/vendor/files.yaml
+- subplot/vendor/runcmd.yaml
+functions:
+- subplot/clab.py
+- subplot/vendor/files.py
+- subplot/vendor/runcmd.py
+...
diff --git a/clab.yarn b/clab.yarn
deleted file mode 100644
index 745eb09..0000000
--- a/clab.yarn
+++ /dev/null
@@ -1,133 +0,0 @@
-Black box tests for clab
-========================
-
-Clab is a command line address book application.
-It has no interactive features, so black box testing it
-is fairly easy.
-
-Let's start with an empty database.
-
- SCENARIO empty database
- GIVEN an empty database
-
- WHEN listing all records
- THEN nothing is listed
-
- WHEN searching for Alice
- THEN output is empty
-
- WHEN mutt-querying for Alice
- THEN no matches
-
-Next, let's add records for Alice and Bob, and make
-sure searches find only the right records.
-
- SCENARIO database with records
- GIVEN an empty database
- AND a record for "Alice Atherton" with e-mail alice@example.com
- AND a record for "Bob Bobbington" with e-mail bob@example.com
-
- WHEN listing all records
- THEN Alice is found
- AND Bob is found
-
- WHEN searching for Alice
- THEN Alice is found
- AND Bob is not found
-
- WHEN mutt-querying for Alice
- THEN Alice is found
- AND alice@example.com is found
- AND Bob is not found
- AND bob@example.com is not found
-
-Put several records in one file.
-
- SCENARIO files with multiple records
- GIVEN an empty database
- AND records for Alice (alice@example.com) and Bob (bob@example.com)
- WHEN listing all records
- THEN Alice is found
- AND Bob is found
-
- WHEN searching for Alice
- THEN Alice is found
- AND Bob is not found
-
- WHEN mutt-querying for Alice
- THEN Alice is found
- AND alice@example.com is found
- AND Bob is not found
- AND bob@example.com is not found
-
-Sometimes the same person is in the database multiple times
-(e.g., different records for home and work personas, where the
-work persona is automatically generated from LDAP or something).
-In this case, only find each person once.
-
- GIVEN another record for Alice (alice@example.com)
- WHEN mutt-querying for example
- THEN Alice is found only once
-
-Implementation
---------------
-
-These implement the various steps.
-
- IMPLEMENTS GIVEN an empty database
- mkdir "$DATADIR/db"
-
- IMPLEMENTS GIVEN a record for "([^"]+)" with e-mail (.*)
- cat << EOF > "$DATADIR/db/$MATCH_1.yaml"
- name: $MATCH_1
- email: $MATCH_2
- EOF
-
- IMPLEMENTS GIVEN records for (\S+) \((\S+)\) and (\S+) \((\S+)\)
- cat << EOF > "$DATADIR/db/foo.yaml"
- - name: $MATCH_1
- email: $MATCH_2
- - name: $MATCH_3
- email: $MATCH_4
- EOF
-
- IMPLEMENTS GIVEN another record for (\S+) \((\S+)\)
- cat << EOF >> "$DATADIR/db/foo.yaml"
- - name: $MATCH_1
- email: $MATCH_2
- EOF
-
- IMPLEMENTS WHEN listing all records
- ./clab --no-default-config --db "$DATADIR/db" list > "$DATADIR/output"
-
- IMPLEMENTS WHEN searching for (.*)
- ./clab --no-default-config --db "$DATADIR/db" find "$MATCH_1" > "$DATADIR/output"
-
- IMPLEMENTS WHEN mutt-querying for (.*)
- ./clab --no-default-config --db "$DATADIR/db" mutt-query "$MATCH_1" > "$DATADIR/output" || true
-
- IMPLEMENTS THEN nothing is listed
- stat -c %s "$DATADIR/output" | grep -x 0
-
- IMPLEMENTS THEN output is empty
- stat -c %s "$DATADIR/output" | grep -x 0
-
- IMPLEMENTS THEN no matches
- diff -u "$DATADIR/output" - << EOF
- No matches
- EOF
-
- IMPLEMENTS THEN (.*) is found
- set -x
- grep -F "$MATCH_1" "$DATADIR/output"
-
- IMPLEMENTS THEN (.*) is found only once
- n=$(grep -cF "$MATCH_1" "$DATADIR/output")
- if [ "$n" != 1 ]
- then
- echo "$MATCH_1 was found $n times, expected 1" 1>&2
- exit 1
- fi
-
- IMPLEMENTS THEN (.*) is not found
- ! grep -F "$MATCH_1" "$DATADIR/output"
diff --git a/clablib/__init__.py b/clablib/__init__.py
deleted file mode 100644
index 03a5053..0000000
--- a/clablib/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .version import __version__, __version_info__
diff --git a/clablib/version.py b/clablib/version.py
deleted file mode 100644
index 43a4b73..0000000
--- a/clablib/version.py
+++ /dev/null
@@ -1,2 +0,0 @@
-__version__ = "0.4+git"
-__version_info__ = (0, 4, '+git')
diff --git a/debian/changelog b/debian/changelog
deleted file mode 100644
index 8563324..0000000
--- a/debian/changelog
+++ /dev/null
@@ -1,31 +0,0 @@
-clab (0.4+git-1) UNRELEASED; urgency=medium
-
- * New upstream version.
- * Require Python 2.7.
-
- -- Lars Wirzenius <liw@liw.fi> Sat, 16 Apr 2016 16:49:15 +0300
-
-clab (0.4-1) unstable; urgency=medium
-
- *
-
- -- Lars Wirzenius <liw@liw.fi> Sat, 16 Apr 2016 16:49:14 +0300
-
-clab (0.3-1) unstable; urgency=medium
-
- * New upstream release.
-
- -- Lars Wirzenius <liw@liw.fi> Fri, 08 Jan 2016 20:30:10 +0200
-
-clab (0.2-1) unstable; urgency=medium
-
- * New version to bump build for jessie.
-
- -- Lars Wirzenius <liw@liw.fi> Sat, 25 Jul 2015 14:35:22 +0300
-
-clab (0.1-1) unstable; urgency=low
-
- * Initial packaging. This is not intended to be uploaded to Debian, so
- no closing of an ITP bug.
-
- -- Lars Wirzenius <liw@liw.fi> Sat, 05 Oct 2013 14:38:33 +0100
diff --git a/debian/compat b/debian/compat
deleted file mode 100644
index ec63514..0000000
--- a/debian/compat
+++ /dev/null
@@ -1 +0,0 @@
-9
diff --git a/debian/control b/debian/control
deleted file mode 100644
index 7a83f3e..0000000
--- a/debian/control
+++ /dev/null
@@ -1,18 +0,0 @@
-Source: clab
-Maintainer: Lars Wirzenius <liw@liw.fi>
-Section: python
-Priority: optional
-Standards-Version: 3.9.8
-Build-Depends: debhelper (>= 9), python-all (>= 2.7~)
-X-Python-Version: >= 2.7
-
-Package: clab
-Architecture: all
-Depends: ${python:Depends}, ${misc:Depends}, python (>= 2.7)
-Description: command line address book application
- A very basic address book application, with a command line interface
- only.
- .
- Address book data is stored in YAML files, which can easily be
- kept in a version control system by the user.
-Homepage: http://liw.fi/clab/
diff --git a/debian/copyright b/debian/copyright
deleted file mode 100644
index df2f234..0000000
--- a/debian/copyright
+++ /dev/null
@@ -1,23 +0,0 @@
-Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
-Upstream-Name: clab
-Upstream-Contact: Lars Wirzenius <liw@liw.fi>
-Source: git://git.gitano.org.uk/personal/liw/clab
-
-Files: *
-Copyright: 2013, Lars Wirzenius
-License: GPL-3+
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
- .
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
- .
- You should have received a copy of the GNU General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
- .
- On a Debian system, you can find a copy of GPL version 3 at
- /usr/share/common-licenses/GPL-3 .
diff --git a/debian/rules b/debian/rules
deleted file mode 100755
index abde6ef..0000000
--- a/debian/rules
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/usr/bin/make -f
-
-%:
- dh $@
-
diff --git a/debian/source/format b/debian/source/format
deleted file mode 100644
index 163aaf8..0000000
--- a/debian/source/format
+++ /dev/null
@@ -1 +0,0 @@
-3.0 (quilt)
diff --git a/setup.py b/setup.py
deleted file mode 100644
index c559c34..0000000
--- a/setup.py
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/usr/bin/python
-# Copyright (C) 2013-2016 Lars Wirzenius <liw@liw.fi>
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-
-from distutils.core import setup
-
-import clablib
-
-setup(
- name='clab',
- version=clablib.__version__,
- description='command line address book',
- author='Lars Wirzenius',
- author_email='liw@liw.fi',
- url='http://liw.fi/clab/',
- scripts=['clab'],
-)
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..08fc655
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,189 @@
+use directories_next::ProjectDirs;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use structopt::StructOpt;
+
+const APP: &str = "clab";
+
+fn main() -> anyhow::Result<()> {
+ let mut opt = Opt::from_args();
+ let book = if let Some(filename) = &opt.db {
+ AddressBook::load(filename)?
+ } else {
+ let proj_dirs = ProjectDirs::from("", "", APP).expect("couldn't find home directory");
+ let filename = proj_dirs.data_dir().join("address-book.yaml");
+ opt.db = Some(filename.clone());
+ if filename.exists() {
+ AddressBook::load(&filename)?
+ } else {
+ AddressBook::default()
+ }
+ };
+ match &opt.cmd {
+ Cmd::Config(x) => x.run(&opt, &book),
+ Cmd::Lint(x) => x.run(&opt, &book),
+ Cmd::List(x) => x.run(&opt, &book)?,
+ Cmd::Search(x) => x.run(&opt, &book)?,
+ Cmd::MuttQuery(x) => x.run(&opt, &book),
+ }
+ Ok(())
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Entry {
+ name: String,
+ org: Option<String>,
+ url: Option<Vec<String>>,
+ notes: Option<String>,
+ aliases: Option<Vec<String>>,
+ email: Option<HashMap<String, String>>,
+ phone: Option<HashMap<String, String>>,
+ irc: Option<HashMap<String, String>>,
+ address: Option<HashMap<String, String>>,
+}
+
+impl Entry {
+ fn is_match(&self, needle: &str) -> bool {
+ contains(&self.name, needle)
+ }
+
+ fn emails(&self) -> Vec<String> {
+ if let Some(map) = &self.email {
+ map.values().map(|x| x.to_string()).collect()
+ } else {
+ vec![]
+ }
+ }
+}
+
+fn output_entries(entries: &[Entry]) -> anyhow::Result<()> {
+ if !entries.is_empty() {
+ serde_yaml::to_writer(std::io::stdout(), entries)?;
+ }
+ Ok(())
+}
+
+fn contains(haystack: &str, needle: &str) -> bool {
+ let haystack = haystack.to_lowercase();
+ let needle = needle.to_lowercase();
+ haystack.contains(&needle)
+}
+
+#[derive(std::default::Default)]
+struct AddressBook {
+ entries: Vec<Entry>,
+}
+
+impl AddressBook {
+ fn load(db: &Path) -> anyhow::Result<Self> {
+ let mut book = Self::default();
+ book.add_from(db)?;
+ Ok(book)
+ }
+
+ fn add_from(&mut self, filename: &Path) -> anyhow::Result<()> {
+ let text = std::fs::read(&filename)?;
+ let mut entries: Vec<Entry> = serde_yaml::from_slice(&text)?;
+ self.entries.append(&mut entries);
+ Ok(())
+ }
+
+ fn entries(&self) -> &[Entry] {
+ &self.entries
+ }
+
+ fn iter(&self) -> impl Iterator<Item = &Entry> {
+ self.entries.iter()
+ }
+}
+
+#[derive(Debug, StructOpt)]
+struct Opt {
+ #[structopt(long, parse(from_os_str))]
+ db: Option<PathBuf>,
+
+ #[structopt(subcommand)]
+ cmd: Cmd,
+}
+
+#[derive(Debug, StructOpt)]
+enum Cmd {
+ Config(ConfigCommand),
+ Lint(LintCommand),
+ List(ListCommand),
+ Search(SearchCommand),
+ MuttQuery(MuttCommand),
+}
+
+#[derive(Debug, StructOpt)]
+struct ConfigCommand {}
+
+impl ConfigCommand {
+ fn run(&self, opt: &Opt, _book: &AddressBook) {
+ println!("{:#?}", opt);
+ }
+}
+
+#[derive(Debug, StructOpt)]
+struct LintCommand {
+ #[structopt(parse(from_os_str))]
+ filenames: Vec<PathBuf>,
+}
+
+impl LintCommand {
+ fn run(&self, _opt: &Opt, _book: &AddressBook) {}
+}
+
+#[derive(Debug, StructOpt)]
+struct ListCommand {}
+
+impl ListCommand {
+ fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> {
+ output_entries(book.entries())
+ }
+}
+
+#[derive(Debug, StructOpt)]
+#[structopt(alias = "find")]
+struct SearchCommand {
+ #[structopt()]
+ words: Vec<String>,
+}
+
+impl SearchCommand {
+ fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> {
+ let matches: Vec<Entry> = book.iter().filter(|e| self.is_match(e)).cloned().collect();
+ output_entries(&matches)
+ }
+
+ fn is_match(&self, entry: &Entry) -> bool {
+ for word in self.words.iter() {
+ if !entry.is_match(word) {
+ return false;
+ }
+ }
+ true
+ }
+}
+
+#[derive(Debug, StructOpt)]
+struct MuttCommand {
+ #[structopt()]
+ word: String,
+}
+
+impl MuttCommand {
+ fn run(&self, _opt: &Opt, book: &AddressBook) {
+ for e in book.iter().filter(|e| self.is_match(e)) {
+ for email in e.emails() {
+ println!("{}\t{}", e.name, email);
+ }
+ }
+ }
+
+ fn is_match(&self, entry: &Entry) -> bool {
+ entry.is_match(&self.word)
+ }
+}
diff --git a/subplot/clab.py b/subplot/clab.py
new file mode 100644
index 0000000..94b2e51
--- /dev/null
+++ b/subplot/clab.py
@@ -0,0 +1,17 @@
+import io
+import os
+import yaml
+
+
+def install_clab(ctx):
+ runcmd_prepend_to_path = globals()["runcmd_prepend_to_path"]
+ srcdir = globals()["srcdir"]
+
+ # Add the directory with built Rust binaries to the path.
+ runcmd_prepend_to_path(ctx, dirname=os.path.join(srcdir, "target", "debug"))
+
+
+def stdout_is_yaml(ctx):
+ runcmd_get_stdout = globals()["runcmd_get_stdout"]
+ stdout = runcmd_get_stdout(ctx)
+ yaml.safe_load(io.StringIO(stdout))
diff --git a/subplot/clab.yaml b/subplot/clab.yaml
new file mode 100644
index 0000000..7e013ff
--- /dev/null
+++ b/subplot/clab.yaml
@@ -0,0 +1,5 @@
+- given: "an installed clab"
+ function: install_clab
+
+- then: "stdout is valid YAML"
+ function: stdout_is_yaml
diff --git a/subplot/vendor/files.py b/subplot/vendor/files.py
new file mode 100644
index 0000000..d3b96fc
--- /dev/null
+++ b/subplot/vendor/files.py
@@ -0,0 +1,194 @@
+import logging
+import os
+import re
+import shutil
+import time
+
+
+def files_create_from_embedded(ctx, filename=None):
+ files_make_directory(ctx, path=os.path.dirname(filename) or ".")
+ files_create_from_embedded_with_other_name(
+ ctx, filename_on_disk=filename, embedded_filename=filename
+ )
+
+
+def files_create_from_embedded_with_other_name(
+ ctx, filename_on_disk=None, embedded_filename=None
+):
+ get_file = globals()["get_file"]
+
+ files_make_directory(ctx, path=os.path.dirname(filename_on_disk) or ".")
+ with open(filename_on_disk, "wb") as f:
+ f.write(get_file(embedded_filename))
+
+
+def files_create_from_text(ctx, filename=None, text=None):
+ files_make_directory(ctx, path=os.path.dirname(filename) or ".")
+ with open(filename, "w") as f:
+ f.write(text)
+
+
+def files_make_directory(ctx, path=None):
+ path = "./" + path
+ if not os.path.exists(path):
+ os.makedirs(path)
+
+
+def files_remove_directory(ctx, path=None):
+ path = "./" + path
+ shutil.rmtree(path)
+
+
+def files_file_exists(ctx, filename=None):
+ assert_eq = globals()["assert_eq"]
+ assert_eq(os.path.exists(filename), True)
+
+
+def files_file_does_not_exist(ctx, filename=None):
+ assert_eq = globals()["assert_eq"]
+ assert_eq(os.path.exists(filename), False)
+
+
+def files_directory_exists(ctx, path=None):
+ assert_eq = globals()["assert_eq"]
+ assert_eq(os.path.isdir(path), True)
+
+
+def files_directory_does_not_exist(ctx, path=None):
+ assert_eq = globals()["assert_eq"]
+ assert_eq(os.path.isdir(path), False)
+
+
+def files_directory_is_empty(ctx, path=None):
+ assert_eq = globals()["assert_eq"]
+ assert_eq(os.listdir(path), [])
+
+
+def files_directory_is_not_empty(ctx, path=None):
+ assert_ne = globals()["assert_ne"]
+ assert_ne(os.listdir(path), False)
+
+
+def files_only_these_exist(ctx, filenames=None):
+ assert_eq = globals()["assert_eq"]
+ filenames = filenames.replace(",", "").split()
+ assert_eq(set(os.listdir(".")), set(filenames))
+
+
+def files_file_contains(ctx, filename=None, data=None):
+ assert_eq = globals()["assert_eq"]
+ with open(filename, "rb") as f:
+ actual = f.read()
+ actual = actual.decode("UTF-8")
+ assert_eq(data in actual, True)
+
+
+def files_file_matches_regex(ctx, filename=None, regex=None):
+ assert_eq = globals()["assert_eq"]
+ with open(filename) as f:
+ content = f.read()
+ m = re.search(regex, content)
+ if m is None:
+ logging.debug(f"files_file_matches_regex: no match")
+ logging.debug(f" filenamed: {filename}")
+ logging.debug(f" regex: {regex}")
+ logging.debug(f" content: {content}")
+ logging.debug(f" match: {m}")
+ assert_eq(bool(m), True)
+
+
+def files_match(ctx, filename1=None, filename2=None):
+ assert_eq = globals()["assert_eq"]
+ with open(filename1, "rb") as f:
+ data1 = f.read()
+ with open(filename2, "rb") as f:
+ data2 = f.read()
+ assert_eq(data1, data2)
+
+
+def files_touch_with_timestamp(
+ ctx,
+ filename=None,
+ year=None,
+ month=None,
+ day=None,
+ hour=None,
+ minute=None,
+ second=None,
+):
+ t = (
+ int(year),
+ int(month),
+ int(day),
+ int(hour),
+ int(minute),
+ int(second),
+ -1,
+ -1,
+ -1,
+ )
+ ts = time.mktime(t)
+ _files_touch(filename, ts)
+
+
+def files_touch(ctx, filename=None):
+ _files_touch(filename, None)
+
+
+def _files_touch(filename, ts):
+ if not os.path.exists(filename):
+ open(filename, "w").close()
+ times = None
+ if ts is not None:
+ times = (ts, ts)
+ os.utime(filename, times=times)
+
+
+def files_mtime_is_recent(ctx, filename=None):
+ st = os.stat(filename)
+ age = abs(st.st_mtime - time.time())
+ assert age < 1.0
+
+
+def files_mtime_is_ancient(ctx, filename=None):
+ st = os.stat(filename)
+ age = abs(st.st_mtime - time.time())
+ year = 365 * 24 * 60 * 60
+ required = 39 * year
+ logging.debug(f"ancient? mtime={st.st_mtime} age={age} required={required}")
+ assert age > required
+
+
+def files_remember_metadata(ctx, filename=None):
+ meta = _files_remembered(ctx)
+ meta[filename] = _files_get_metadata(filename)
+ logging.debug("files_remember_metadata:")
+ logging.debug(f" meta: {meta}")
+ logging.debug(f" ctx: {ctx}")
+
+
+# Check that current metadata of a file is as stored in the context.
+def files_has_remembered_metadata(ctx, filename=None):
+ assert_eq = globals()["assert_eq"]
+ meta = _files_remembered(ctx)
+ logging.debug("files_has_remembered_metadata:")
+ logging.debug(f" meta: {meta}")
+ logging.debug(f" ctx: {ctx}")
+ assert_eq(meta[filename], _files_get_metadata(filename))
+
+
+def files_has_different_metadata(ctx, filename=None):
+ assert_ne = globals()["assert_ne"]
+ meta = _files_remembered(ctx)
+ assert_ne(meta[filename], _files_get_metadata(filename))
+
+
+def _files_remembered(ctx):
+ ns = ctx.declare("_files")
+ return ns.get("remembered-metadata", {})
+
+
+def _files_get_metadata(filename):
+ st = os.lstat(filename)
+ keys = ["st_dev", "st_gid", "st_ino", "st_mode", "st_mtime", "st_size", "st_uid"]
+ return {key: getattr(st, key) for key in keys}
diff --git a/subplot/vendor/files.yaml b/subplot/vendor/files.yaml
new file mode 100644
index 0000000..f18b8cd
--- /dev/null
+++ b/subplot/vendor/files.yaml
@@ -0,0 +1,83 @@
+- given: file {filename}
+ function: files_create_from_embedded
+ types:
+ filename: file
+
+- given: file {filename_on_disk} from {embedded_filename}
+ function: files_create_from_embedded_with_other_name
+ types:
+ embedded_filename: file
+
+- given: file {filename} has modification time {year}-{month}-{day} {hour}:{minute}:{second}
+ function: files_touch_with_timestamp
+
+- when: I write "(?P<text>.*)" to file (?P<filename>\S+)
+ regex: true
+ function: files_create_from_text
+
+- when: I remember metadata for file {filename}
+ function: files_remember_metadata
+
+- when: I touch file {filename}
+ function: files_touch
+
+- then: file {filename} exists
+ function: files_file_exists
+
+- then: file {filename} does not exist
+ function: files_file_does_not_exist
+
+- then: only files (?P<filenames>.+) exist
+ function: files_only_these_exist
+ regex: true
+
+- then: file (?P<filename>\S+) contains "(?P<data>.*)"
+ regex: true
+ function: files_file_contains
+
+- then: file (?P<filename>\S+) matches regex /(?P<regex>.*)/
+ regex: true
+ function: files_file_matches_regex
+
+- then: file (?P<filename>\S+) matches regex "(?P<regex>.*)"
+ regex: true
+ function: files_file_matches_regex
+
+- then: files {filename1} and {filename2} match
+ function: files_match
+
+- then: file {filename} has same metadata as before
+ function: files_has_remembered_metadata
+
+- then: file {filename} has different metadata from before
+ function: files_has_different_metadata
+
+- then: file {filename} has changed from before
+ function: files_has_different_metadata
+
+- then: file {filename} has a very recent modification time
+ function: files_mtime_is_recent
+
+- then: file {filename} has a very old modification time
+ function: files_mtime_is_ancient
+
+- given: a directory {path}
+ function: files_make_directory
+
+- when: I create directory {path}
+ function: files_make_directory
+
+- when: I remove directory {path}
+ function: files_remove_directory
+
+- then: directory {path} exists
+ function: files_directory_exists
+
+- then: directory {path} does not exist
+ function: files_directory_does_not_exist
+
+- then: directory {path} is empty
+ function: files_directory_is_empty
+
+- then: directory {path} is not empty
+ function: files_directory_is_not_empty
diff --git a/subplot/vendor/runcmd.py b/subplot/vendor/runcmd.py
new file mode 100644
index 0000000..cc4fd38
--- /dev/null
+++ b/subplot/vendor/runcmd.py
@@ -0,0 +1,261 @@
+import logging
+import os
+import re
+import shlex
+import subprocess
+
+
+#
+# Helper functions.
+#
+
+# Get exit code or other stored data about the latest command run by
+# runcmd_run.
+
+
+def _runcmd_get(ctx, name):
+ ns = ctx.declare("_runcmd")
+ return ns[name]
+
+
+def runcmd_get_exit_code(ctx):
+ return _runcmd_get(ctx, "exit")
+
+
+def runcmd_get_stdout(ctx):
+ return _runcmd_get(ctx, "stdout")
+
+
+def runcmd_get_stdout_raw(ctx):
+ return _runcmd_get(ctx, "stdout.raw")
+
+
+def runcmd_get_stderr(ctx):
+ return _runcmd_get(ctx, "stderr")
+
+
+def runcmd_get_stderr_raw(ctx):
+ return _runcmd_get(ctx, "stderr.raw")
+
+
+def runcmd_get_argv(ctx):
+ return _runcmd_get(ctx, "argv")
+
+
+# Run a command, given an argv and other arguments for subprocess.Popen.
+#
+# This is meant to be a helper function, not bound directly to a step. The
+# stdout, stderr, and exit code are stored in the "_runcmd" namespace in the
+# ctx context.
+def runcmd_run(ctx, argv, **kwargs):
+ ns = ctx.declare("_runcmd")
+
+ # The Subplot Python template empties os.environ at startup, modulo a small
+ # number of variables with carefully chosen values. Here, we don't need to
+ # care about what those variables are, but we do need to not overwrite
+ # them, so we just add anything in the env keyword argument, if any, to
+ # os.environ.
+ env = dict(os.environ)
+ for key, arg in kwargs.pop("env", {}).items():
+ env[key] = arg
+
+ pp = ns.get("path-prefix")
+ if pp:
+ env["PATH"] = pp + ":" + env["PATH"]
+
+ logging.debug(f"runcmd_run")
+ logging.debug(f" argv: {argv}")
+ logging.debug(f" env: {env}")
+ p = subprocess.Popen(
+ argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, **kwargs
+ )
+ stdout, stderr = p.communicate("")
+ ns["argv"] = argv
+ ns["stdout.raw"] = stdout
+ ns["stderr.raw"] = stderr
+ ns["stdout"] = stdout.decode("utf-8")
+ ns["stderr"] = stderr.decode("utf-8")
+ ns["exit"] = p.returncode
+ logging.debug(f" ctx: {ctx}")
+ logging.debug(f" ns: {ns}")
+
+
+# Step: prepend srcdir to PATH whenever runcmd runs a command.
+def runcmd_helper_srcdir_path(ctx):
+ srcdir = globals()["srcdir"]
+ runcmd_prepend_to_path(ctx, srcdir)
+
+
+# Step: This creates a helper script.
+def runcmd_helper_script(ctx, filename=None):
+ get_file = globals()["get_file"]
+ with open(filename, "wb") as f:
+ f.write(get_file(filename))
+
+
+#
+# Step functions for running commands.
+#
+
+
+def runcmd_prepend_to_path(ctx, dirname=None):
+ ns = ctx.declare("_runcmd")
+ pp = ns.get("path-prefix", "")
+ if pp:
+ pp = f"{pp}:{dirname}"
+ else:
+ pp = dirname
+ ns["path-prefix"] = pp
+
+
+def runcmd_step(ctx, argv0=None, args=None):
+ runcmd_try_to_run(ctx, argv0=argv0, args=args)
+ runcmd_exit_code_is_zero(ctx)
+
+
+def runcmd_step_in(ctx, dirname=None, argv0=None, args=None):
+ runcmd_try_to_run_in(ctx, dirname=dirname, argv0=argv0, args=args)
+ runcmd_exit_code_is_zero(ctx)
+
+
+def runcmd_try_to_run(ctx, argv0=None, args=None):
+ runcmd_try_to_run_in(ctx, dirname=None, argv0=argv0, args=args)
+
+
+def runcmd_try_to_run_in(ctx, dirname=None, argv0=None, args=None):
+ argv = [shlex.quote(argv0)] + shlex.split(args)
+ runcmd_run(ctx, argv, cwd=dirname)
+
+
+#
+# Step functions for examining exit codes.
+#
+
+
+def runcmd_exit_code_is_zero(ctx):
+ runcmd_exit_code_is(ctx, exit=0)
+
+
+def runcmd_exit_code_is(ctx, exit=None):
+ assert_eq = globals()["assert_eq"]
+ assert_eq(runcmd_get_exit_code(ctx), int(exit))
+
+
+def runcmd_exit_code_is_nonzero(ctx):
+ runcmd_exit_code_is_not(ctx, exit=0)
+
+
+def runcmd_exit_code_is_not(ctx, exit=None):
+ assert_ne = globals()["assert_ne"]
+ assert_ne(runcmd_get_exit_code(ctx), int(exit))
+
+
+#
+# Step functions and helpers for examining output in various ways.
+#
+
+
+def runcmd_stdout_is(ctx, text=None):
+ _runcmd_output_is(runcmd_get_stdout(ctx), text)
+
+
+def runcmd_stdout_isnt(ctx, text=None):
+ _runcmd_output_isnt(runcmd_get_stdout(ctx), text)
+
+
+def runcmd_stderr_is(ctx, text=None):
+ _runcmd_output_is(runcmd_get_stderr(ctx), text)
+
+
+def runcmd_stderr_isnt(ctx, text=None):
+ _runcmd_output_isnt(runcmd_get_stderr(ctx), text)
+
+
+def _runcmd_output_is(actual, wanted):
+ assert_eq = globals()["assert_eq"]
+ wanted = bytes(wanted, "utf8").decode("unicode_escape")
+ logging.debug("_runcmd_output_is:")
+ logging.debug(f" actual: {actual!r}")
+ logging.debug(f" wanted: {wanted!r}")
+ assert_eq(actual, wanted)
+
+
+def _runcmd_output_isnt(actual, wanted):
+ assert_ne = globals()["assert_ne"]
+ wanted = bytes(wanted, "utf8").decode("unicode_escape")
+ logging.debug("_runcmd_output_isnt:")
+ logging.debug(f" actual: {actual!r}")
+ logging.debug(f" wanted: {wanted!r}")
+ assert_ne(actual, wanted)
+
+
+def runcmd_stdout_contains(ctx, text=None):
+ _runcmd_output_contains(runcmd_get_stdout(ctx), text)
+
+
+def runcmd_stdout_doesnt_contain(ctx, text=None):
+ _runcmd_output_doesnt_contain(runcmd_get_stdout(ctx), text)
+
+
+def runcmd_stderr_contains(ctx, text=None):
+ _runcmd_output_contains(runcmd_get_stderr(ctx), text)
+
+
+def runcmd_stderr_doesnt_contain(ctx, text=None):
+ _runcmd_output_doesnt_contain(runcmd_get_stderr(ctx), text)
+
+
+def _runcmd_output_contains(actual, wanted):
+ assert_eq = globals()["assert_eq"]
+ wanted = bytes(wanted, "utf8").decode("unicode_escape")
+ logging.debug("_runcmd_output_contains:")
+ logging.debug(f" actual: {actual!r}")
+ logging.debug(f" wanted: {wanted!r}")
+ assert_eq(wanted in actual, True)
+
+
+def _runcmd_output_doesnt_contain(actual, wanted):
+ assert_ne = globals()["assert_ne"]
+ wanted = bytes(wanted, "utf8").decode("unicode_escape")
+ logging.debug("_runcmd_output_doesnt_contain:")
+ logging.debug(f" actual: {actual!r}")
+ logging.debug(f" wanted: {wanted!r}")
+ assert_ne(wanted in actual, True)
+
+
+def runcmd_stdout_matches_regex(ctx, regex=None):
+ _runcmd_output_matches_regex(runcmd_get_stdout(ctx), regex)
+
+
+def runcmd_stdout_doesnt_match_regex(ctx, regex=None):
+ _runcmd_output_doesnt_match_regex(runcmd_get_stdout(ctx), regex)
+
+
+def runcmd_stderr_matches_regex(ctx, regex=None):
+ _runcmd_output_matches_regex(runcmd_get_stderr(ctx), regex)
+
+
+def runcmd_stderr_doesnt_match_regex(ctx, regex=None):
+ _runcmd_output_doesnt_match_regex(runcmd_get_stderr(ctx), regex)
+
+
+def _runcmd_output_matches_regex(actual, regex):
+ assert_ne = globals()["assert_ne"]
+ r = re.compile(regex)
+ m = r.search(actual)
+ logging.debug("_runcmd_output_matches_regex:")
+ logging.debug(f" actual: {actual!r}")
+ logging.debug(f" regex: {regex!r}")
+ logging.debug(f" match: {m}")
+ assert_ne(m, None)
+
+
+def _runcmd_output_doesnt_match_regex(actual, regex):
+ assert_eq = globals()["assert_eq"]
+ r = re.compile(regex)
+ m = r.search(actual)
+ logging.debug("_runcmd_output_doesnt_match_regex:")
+ logging.debug(f" actual: {actual!r}")
+ logging.debug(f" regex: {regex!r}")
+ logging.debug(f" match: {m}")
+ assert_eq(m, None)
diff --git a/subplot/vendor/runcmd.yaml b/subplot/vendor/runcmd.yaml
new file mode 100644
index 0000000..a5119d8
--- /dev/null
+++ b/subplot/vendor/runcmd.yaml
@@ -0,0 +1,91 @@
+# Steps to run commands.
+
+- given: helper script {filename} for runcmd
+ function: runcmd_helper_script
+
+- given: srcdir is in the PATH
+ function: runcmd_helper_srcdir_path
+
+- when: I run (?P<argv0>\S+)(?P<args>.*)
+ regex: true
+ function: runcmd_step
+
+- when: I run, in (?P<dirname>\S+), (?P<argv0>\S+)(?P<args>.*)
+ regex: true
+ function: runcmd_step_in
+
+- when: I try to run (?P<argv0>\S+)(?P<args>.*)
+ regex: true
+ function: runcmd_try_to_run
+
+- when: I try to run, in (?P<dirname>\S+), (?P<argv0>\S+)(?P<args>.*)
+ regex: true
+ function: runcmd_try_to_run_in
+
+# Steps to examine exit code of latest command.
+
+- then: exit code is {exit}
+ function: runcmd_exit_code_is
+
+- then: exit code is not {exit}
+ function: runcmd_exit_code_is_not
+
+- then: command is successful
+ function: runcmd_exit_code_is_zero
+
+- then: command fails
+ function: runcmd_exit_code_is_nonzero
+
+# Steps to examine stdout/stderr for exact content.
+
+- then: stdout is exactly "(?P<text>.*)"
+ regex: true
+ function: runcmd_stdout_is
+
+- then: "stdout isn't exactly \"(?P<text>.*)\""
+ regex: true
+ function: runcmd_stdout_isnt
+
+- then: stderr is exactly "(?P<text>.*)"
+ regex: true
+ function: runcmd_stderr_is
+
+- then: "stderr isn't exactly \"(?P<text>.*)\""
+ regex: true
+ function: runcmd_stderr_isnt
+
+# Steps to examine stdout/stderr for sub-strings.
+
+- then: stdout contains "(?P<text>.*)"
+ regex: true
+ function: runcmd_stdout_contains
+
+- then: "stdout doesn't contain \"(?P<text>.*)\""
+ regex: true
+ function: runcmd_stdout_doesnt_contain
+
+- then: stderr contains "(?P<text>.*)"
+ regex: true
+ function: runcmd_stderr_contains
+
+- then: "stderr doesn't contain \"(?P<text>.*)\""
+ regex: true
+ function: runcmd_stderr_doesnt_contain
+
+# Steps to match stdout/stderr against regular expressions.
+
+- then: stdout matches regex (?P<regex>.*)
+ regex: true
+ function: runcmd_stdout_matches_regex
+
+- then: stdout doesn't match regex (?P<regex>.*)
+ regex: true
+ function: runcmd_stdout_doesnt_match_regex
+
+- then: stderr matches regex (?P<regex>.*)
+ regex: true
+ function: runcmd_stderr_matches_regex
+
+- then: stderr doesn't match regex (?P<regex>.*)
+ regex: true
+ function: runcmd_stderr_doesnt_match_regex