summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--.gitlab-ci.yml12
-rw-r--r--Cargo.lock1119
-rw-r--r--Cargo.toml19
-rwxr-xr-xcheck52
-rw-r--r--debian/cargo-checksum.json0
-rw-r--r--debian/changelog6
-rw-r--r--debian/compat2
-rw-r--r--debian/control18
-rw-r--r--debian/copyright23
-rwxr-xr-xdebian/rules14
-rw-r--r--debian/source/format1
-rw-r--r--jt.md339
-rw-r--r--jt.subplot15
-rw-r--r--src/bin/jt.rs29
-rw-r--r--src/cmd.rs143
-rw-r--r--src/config.rs130
-rw-r--r--src/error.rs116
-rw-r--r--src/git.rs62
-rw-r--r--src/journal.rs258
-rw-r--r--src/lib.rs7
-rw-r--r--src/opt.rs75
-rw-r--r--src/template.rs66
-rw-r--r--subplot/jt.py192
-rw-r--r--subplot/jt.yaml115
25 files changed, 2818 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3b10284
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/target
+*.pdf
+*.html
+test.py
+test.log
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..42a4224
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,12 @@
+default:
+ image: rust:latest
+
+build-job:
+ stage: build
+ script:
+ - curl -s https://gitlab.com/subplot/subplot/-/raw/main/install-debian.sh | bash
+ - apt-get update
+ - apt-get install -y black subplot texlive-latex-base texlive-latex-recommended texlive-fonts-recommended plantuml jq
+ - rustup component add clippy
+ - rustup component add rustfmt
+ - if ! ./check; then echo =====================; tail -n 500 test.log; exit 1; fi
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..4b4d361
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1119 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd2405b3ac1faab2990b74d728624cd9fd115651fcecc7c2d8daf01376275ba"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
+dependencies = [
+ "anstyle",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca"
+
+[[package]]
+name = "autocfg"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bstr"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
+
+[[package]]
+name = "cc"
+version = "1.0.83"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "clap"
+version = "4.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58e54881c004cec7895b0068a0a954cd5d62da01aef83fa35b1e594497bf5445"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59cb82d7f531603d2fd1f507441cdd35184fa81beff7bd489570de7f773460bb"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[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 = "env_logger"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95b3f3e67048839cb0d0781f445682a35113da7121f7c949db0e2be96a4fbece"
+dependencies = [
+ "humantime",
+ "is-terminal",
+ "log",
+ "regex",
+ "termcolor",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "errno"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
+
+[[package]]
+name = "globset"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "globwalk"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "ignore",
+ "walkdir",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
+
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ignore"
+version = "0.4.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bad00257d07be169d870ab665980b06cdb366d792ad690bf2e76876dc503455"
+dependencies = [
+ "hermit-abi",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
+
+[[package]]
+name = "js-sys"
+version = "0.3.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "jt"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "clap",
+ "directories-next",
+ "glob",
+ "log",
+ "pretty_env_logger",
+ "regex",
+ "serde",
+ "serde_yaml",
+ "tera",
+ "thiserror",
+]
+
+[[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.152"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7"
+
+[[package]]
+name = "libredox"
+version = "0.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8"
+dependencies = [
+ "bitflags 2.4.1",
+ "libc",
+ "redox_syscall",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456"
+
+[[package]]
+name = "log"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
+
+[[package]]
+name = "memchr"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
+
+[[package]]
+name = "num-traits"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "pest"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f200d8d83c44a45b21764d1916299752ca035d15ecd46faca3e9a2a2bf6ad06"
+dependencies = [
+ "memchr",
+ "thiserror",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bcd6ab1236bbdb3a49027e920e693192ebfe8913f6d60e294de57463a493cfde"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a31940305ffc96863a735bef7c7994a00b325a7138fdbc5bda0f1a0476d3275"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7ff62f5259e53b78d1af898941cdcdccfae7385cf7d793a6e55de5d05bb4b7d"
+dependencies = [
+ "once_cell",
+ "pest",
+ "sha2",
+]
+
+[[package]]
+name = "pretty_env_logger"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c"
+dependencies = [
+ "env_logger",
+ "log",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
+
+[[package]]
+name = "rustix"
+version = "0.38.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca"
+dependencies = [
+ "bitflags 2.4.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.195"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.195"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1bf28c79a99f70ee1f1d83d10c875d2e70618417fda01ad1785e027579d9d38"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "syn"
+version = "2.0.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tera"
+version = "1.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8"
+dependencies = [
+ "globwalk",
+ "lazy_static",
+ "pest",
+ "pest_derive",
+ "regex",
+ "serde",
+ "serde_json",
+ "unic-segment",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "typenum"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9"
+
+[[package]]
+name = "unic-char-property"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
+dependencies = [
+ "unic-char-range",
+]
+
+[[package]]
+name = "unic-char-range"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
+
+[[package]]
+name = "unic-common"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
+
+[[package]]
+name = "unic-segment"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23"
+dependencies = [
+ "unic-ucd-segment",
+]
+
+[[package]]
+name = "unic-ucd-segment"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700"
+dependencies = [
+ "unic-char-property",
+ "unic-char-range",
+ "unic-ucd-version",
+]
+
+[[package]]
+name = "unic-ucd-version"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
+dependencies = [
+ "unic-common",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
+[[package]]
+name = "version_check"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+
+[[package]]
+name = "walkdir"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.90"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.90"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.90"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.90"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.90"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b"
+
+[[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-util"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596"
+dependencies = [
+ "winapi",
+]
+
+[[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 = "windows-core"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+dependencies = [
+ "windows-targets 0.52.0",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.0",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.0",
+ "windows_aarch64_msvc 0.52.0",
+ "windows_i686_gnu 0.52.0",
+ "windows_i686_msvc 0.52.0",
+ "windows_x86_64_gnu 0.52.0",
+ "windows_x86_64_gnullvm 0.52.0",
+ "windows_x86_64_msvc 0.52.0",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..ae7c12b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "jt"
+version = "0.1.0"
+authors = ["Lars Wirzenius <liw@liw.fi>"]
+edition = "2018"
+
+[dependencies]
+anyhow = "1.0.79"
+chrono = "0.4.31"
+clap = { version = "4.4.16", features = ["derive"] }
+directories-next = "2.0.0"
+glob = "0.3.1"
+log = "0.4.20"
+pretty_env_logger = "0.5.0"
+regex = "1.10.2"
+serde = { version = "1.0.195", features = ["derive"] }
+serde_yaml = "0.9.30"
+tera = { version = "1.19.1", default-features = false }
+thiserror = "1.0.56"
diff --git a/check b/check
new file mode 100755
index 0000000..642dfbe
--- /dev/null
+++ b/check
@@ -0,0 +1,52 @@
+#!/bin/sh
+
+set -eu
+
+verbose=false
+offline=
+if [ "$#" -gt 0 ]; then
+ case "$1" in
+ verbose | -v | --verbose)
+ verbose=true
+ shift
+ ;;
+ --offline)
+ offline=--offline
+ shift
+ ;;
+ esac
+fi
+
+hideok=
+
+if command -v chronic >/dev/null; then
+ hideok=chronic
+fi
+
+if $verbose; then
+ hideok=
+fi
+
+$hideok cargo check --all-targets $offline
+cargo clippy -q $offline
+$hideok cargo build --all-targets $offline
+$hideok cargo test $offline
+
+$hideok cargo fmt -- --check
+if command -v black >/dev/null; then
+ $hideok find . -type f -name '*.py' ! -name test.py -exec black --check '{}' +
+fi
+
+$hideok subplot docgen jt.subplot --output jt.html
+
+$hideok subplot codegen jt.subplot --output test.py
+rm -f test.log
+target="$(
+ cargo metadata --format-version=1 |
+ python3 -c 'import json, sys; o = json.load(sys.stdin); print (o["target_directory"])'
+)"
+if ! $hideok python3 test.py --log test.log --env "CARGO_TARGET_DIR=$target" "$@"; then
+ cat test.log
+fi
+
+echo "Everything seems to be in order."
diff --git a/debian/cargo-checksum.json b/debian/cargo-checksum.json
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/debian/cargo-checksum.json
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..d1a3b83
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,6 @@
+jt (0.1.0-1) UNRELEASED; urgency=medium
+
+ * Initial packaging. This is not intended to be uploaded to Debian, so
+ no closing of an ITP bug.
+
+ -- Lars Wirzenius <liw@liw.fi> Sat, 28 Sep 2019 16:45:49 +0300
diff --git a/debian/compat b/debian/compat
new file mode 100644
index 0000000..021ea30
--- /dev/null
+++ b/debian/compat
@@ -0,0 +1,2 @@
+10
+
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..9d0d6c3
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,18 @@
+Source: jt
+Maintainer: Lars Wirzenius <liw@liw.fi>
+Section: misc
+Priority: optional
+Standards-Version: 4.2.0
+Build-Depends:
+ debhelper (>= 10~),
+ moreutils,
+ python3,
+ subplot
+Homepage: https://jt2.liw.fi
+
+Package: jt
+Architecture: any
+Depends: ${misc:Depends}, ${shlibs:Depends}
+Built-Using: ${cargo:Built-Using}
+Description: personal journalling tool
+ jt adds entries to a personal journal.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..9bf2a22
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,23 @@
+Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: jt
+Upstream-Contact: Lars Wirzenius <liw@liw.fi>
+Source: http://git.liw.fi/jt2
+
+Files: *
+Copyright: 2021, Lars Wirzenius, Daniel Silverstone
+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
new file mode 100755
index 0000000..6bf3503
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,14 @@
+#!/usr/bin/make -f
+
+%:
+ dh $@
+
+override_dh_auto_build:
+ true
+
+override_dh_auto_install:
+ cargo install --path=. --root=debian/jt --offline
+ find debian/jt -name '.crates*' -delete
+
+override_dh_auto_test:
+ echo disabled tests
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..163aaf8
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (quilt)
diff --git a/jt.md b/jt.md
new file mode 100644
index 0000000..ca4f108
--- /dev/null
+++ b/jt.md
@@ -0,0 +1,339 @@
+# Introduction
+
+The **jt** software (short for "journalling tool") is a helper for
+maintaining a journal or personal knowledge base. It has been written
+for the personal use of its authors, but might be useful for others.
+
+The guiding principle for jt is that having longevity for one's data
+and complete control over it are crucial. Because of these, the
+approach taken by jt is to build a static web site from source files
+stored in a version control system. The files will be edited with a
+text editor chosen by the journal writer, rather than via a custom
+web, desktop, or mobile application. The built journal is then served
+with some suitable web server.
+
+The role of jt is to make it easier to create new journal entries (new
+Markdown files), and to make all the common tasks of maintaining a
+knowledge base have minimal friction.
+
+## Example
+
+The following example creates a new journal, which will be the default
+journal for the user, and a new journal entry. The entry is a draft
+until it's finished.
+
+~~~sh
+$ jt --dirname ~/Journal init default "My private journal"
+$ jt new --tag meta --tag journalling "My first journal entry"
+... # text editor is opened so the new entry can be written
+$ jt finish 0 first-entry
+~~~
+
+
+# Acceptance criteria and their verification
+
+This chapter defines detailed acceptance criteria and how they're
+verified using *scenarios* for the [Subplot][] tool.
+
+[Subplot]: https://subplot.liw.fi/
+
+## Configuration file handling
+
+These scenarios verify that jt handles its configuration file
+correctly.
+
+### Shows defaults if no configuration file is present
+
+~~~scenario
+given an installed jt
+when I run jt config
+then stdout matches regex dirname:.*/\.local.share/jt
+then stdout matches regex editor: "/usr/bin/editor"
+~~~
+
+### Gives error if configuration missing file specified
+
+~~~scenario
+given an installed jt
+when I try to run jt --config does-not-exist config
+then command fails
+then stderr contains "does-not-exist"
+~~~
+
+### Accepts empty configuration file
+
+~~~scenario
+given an installed jt
+given file empty.yaml
+when I run jt --config empty.yaml config
+then stdout matches regex dirname:.*/\.local.share/jt
+then stdout matches regex editor: "/usr/bin/editor"
+~~~
+
+~~~{#empty.yaml .file .yaml}
+{}
+~~~
+
+### Accepts configuration file
+
+Note that the configuration file uses a tilde syntax to refer to the
+user's home directory.
+
+~~~scenario
+given an installed jt
+given file config.yaml
+when I run jt --config config.yaml config
+then stdout matches regex dirname:.*/.*/journal
+then stdout matches regex editor: "emacs"
+~~~
+
+~~~{#config.yaml .file .yaml}
+dirname: ~/journal
+editor: emacs
+~~~
+
+### Command line options override configuration file fields
+
+~~~scenario
+given an installed jt
+given file config.yaml
+when I run jt --config config.yaml --dirname xxx --editor yyy config
+then stdout matches regex dirname: "xxx"
+then stdout matches regex editor: "yyy"
+~~~
+
+
+
+### Rejects configuration file with extra entries
+
+~~~scenario
+given an installed jt
+given file toomuch.yaml
+when I try to run jt --config toomuch.yaml config
+then command fails
+then stderr contains "unknown_field"
+~~~
+
+~~~{#toomuch.yaml .file .yaml}
+unknown_field: foo
+~~~
+
+
+## Create a new local journal repository
+
+`jt` works on a local repository, and it can be created an initialised
+using the tool.
+
+~~~scenario
+given an installed jt
+
+when I run jt --dirname jrnl init default "My test journal"
+then command is successful
+and directory jrnl exists
+then there are no uncommitted changes in jrnl
+
+when I run jt --dirname jrnl is-journal
+then command is successful
+
+when I try to run jt --dirname bogus is-journal
+then command fails
+~~~
+
+
+## Create a new draft, edit it, then publish it
+
+Verify that we can create a new draft entry for the journal.
+
+~~~scenario
+given an installed jt
+
+when I run jt --dirname jrnl init default "My test journal"
+then command is successful
+and there are no drafts in jrnl
+and there are no journal entries in jrnl
+
+when I run jt --editor=none --dirname=jrnl new "Abracadabra"
+then command is successful
+and there is one draft in jrnl
+and draft 0 in jrnl contains "Abracadabra"
+and draft 0 in jrnl contains "!meta date="
+
+when I run jt --dirname=jrnl list
+then stdout matches regex ^0 Abracadabra$
+
+given an executable script append.sh
+when I run jt --editor=./append.sh --dirname=jrnl edit 0
+then command is successful
+and draft 0 in jrnl contains "Open sesame!"
+
+when I run jt --dirname=jrnl finish 0 abra
+then command is successful
+and there is one journal entry in jrnl, at FILE
+and file name <FILE> ends with .mdwn
+and journal entry <FILE> contains "Abracadabra"
+and journal entry <FILE> contains "Open sesame!"
+and there are no drafts in jrnl
+and there are no uncommitted changes in jrnl
+~~~
+
+~~~{#append.sh .file .numberLines}
+#!/bin/sh
+set -eux
+echo "Open sesame!" >> "$1"
+~~~
+
+## Create two drafts
+
+Verify that we can create two draft entries at the same time.
+
+~~~scenario
+given an installed jt
+
+when I run jt --dirname jrnl init default "My test journal"
+then command is successful
+then there are no drafts in jrnl
+then there are no journal entries in jrnl
+
+when I run jt --editor=none --dirname=jrnl new "Abracadabra"
+then command is successful
+then there is one draft in jrnl
+then draft 0 in jrnl contains "Abracadabra"
+
+when I run jt --editor=none --dirname=jrnl new "Simsalabim"
+then command is successful
+then there are two drafts in jrnl
+then draft 0 in jrnl contains "Abracadabra"
+then draft 1 in jrnl contains "Simsalabim"
+
+given an executable script append.sh
+when I run jt --editor=./append.sh --dirname=jrnl edit 0
+then draft 0 in jrnl contains "Open sesame!"
+when I run jt --editor=./append.sh --dirname=jrnl edit 1
+then draft 1 in jrnl contains "Open sesame!"
+
+when I run jt --dirname=jrnl finish 0 abra
+then command is successful
+then there is one journal entry in jrnl, at FILE
+then journal entry <FILE> contains "Abracadabra"
+then journal entry <FILE> contains "Open sesame!"
+then there is one draft in jrnl
+
+when I run jt --dirname=jrnl finish 1 sim
+then command is successful
+then there are two journal entries in jrnl, at FILE1 and FILE2
+then journal entry <FILE1> contains "Abracadabra"
+then journal entry <FILE2> contains "Simsalabim"
+then there are no drafts in jrnl
+then there are no uncommitted changes in jrnl
+~~~
+
+
+
+
+
+## Remove a draft
+
+Verify that we can remove a draft, and then create a new one.
+
+~~~scenario
+given an installed jt
+
+when I run jt --dirname jrnl init default "My test journal"
+then command is successful
+and there are no drafts in jrnl
+and there are no journal entries in jrnl
+
+when I run jt --editor=none --dirname=jrnl new "Hulabaloo"
+then command is successful
+and there is one draft in jrnl
+and draft 0 in jrnl contains "Hulabaloo"
+and draft 0 in jrnl contains "!meta date="
+
+when I run jt --dirname=jrnl remove 0
+then command is successful
+and there are no drafts in jrnl
+and there are no journal entries in jrnl
+
+when I run jt --editor=none --dirname=jrnl new "Abracadabra"
+then command is successful
+and there is one draft in jrnl
+and draft 0 in jrnl contains "Abracadabra"
+and draft 0 in jrnl contains "!meta date="
+~~~
+
+## Override template for new journal entries
+
+Verify that we can have a custom template for new journal entries.
+
+~~~scenario
+given an installed jt
+
+when I run jt --dirname jrnl init default "My test journal"
+then command is successful
+
+given file jrnl/.config/templates/new_entry from new_entry_template
+
+when I run jt --editor=none --dirname=jrnl new "Abracadabra"
+then command is successful
+and there is one draft in jrnl
+and draft 0 in jrnl contains "custom new entry template"
+~~~
+
+~~~{#new_entry_template .file .numberLines}
+This is a custom new entry template.
+~~~
+
+## Use topic pages
+
+Verify that we can create a new topic page and a new entry referring
+to that topic page.
+
+~~~scenario
+given an installed jt
+
+when I run jt --dirname jrnl init default "My test journal"
+then command is successful
+
+when I try to run jt --editor=none --dirname=jrnl new --topic foo.bar "Abracadabra"
+then command fails
+then stderr contains "foo.bar"
+
+when I run jt --editor=none --dirname=jrnl new-topic topics/foo.bar "Things about Foobars"
+then command is successful
+then file jrnl/topics/foo.bar.mdwn contains "Things about Foobars"
+then there are no uncommitted changes in jrnl
+
+when I run jt --editor=none --dirname=jrnl new --topic topics/foo.bar "Abracadabra"
+then command is successful
+and there is one draft in jrnl
+and draft 0 in jrnl links to "topics/foo.bar"
+~~~
+
+## Allow many topics per post
+
+Sometimes a post relates to several topics.
+
+~~~scenario
+given an installed jt
+
+when I run jt --dirname jrnl init default "My test journal"
+then command is successful
+
+when I run jt --editor=none --dirname=jrnl new-topic topics/foo "Foo"
+when I run jt --editor=none --dirname=jrnl new-topic topics/bar "Bar"
+
+when I run jt --editor=none --dirname=jrnl new --topic topics/foo --topic topics/bar "Abracadabra"
+then command is successful
+then there is one draft in jrnl
+then draft 0 in jrnl links to "topics/foo"
+then draft 0 in jrnl links to "topics/bar"
+~~~
+
+
+# Colophon
+
+This document is meant to be processed with the [Subplot][] program to
+typeset into HTML or PDF or to generate a program that automatically
+verifies that all acceptance criteria are met.
+
+[Subplot]: https://subplot.liw.fi/
diff --git a/jt.subplot b/jt.subplot
new file mode 100644
index 0000000..ea3f1b2
--- /dev/null
+++ b/jt.subplot
@@ -0,0 +1,15 @@
+title: "jt&mdash;a journalling tool"
+authors:
+ - Lars Wirzenius
+ - Daniel Silverstone
+markdowns:
+- jt.md
+bindings:
+- subplot/jt.yaml
+- lib/files.yaml
+- lib/runcmd.yaml
+impls:
+ python:
+ - subplot/jt.py
+ - lib/files.py
+ - lib/runcmd.py
diff --git a/src/bin/jt.rs b/src/bin/jt.rs
new file mode 100644
index 0000000..21bf254
--- /dev/null
+++ b/src/bin/jt.rs
@@ -0,0 +1,29 @@
+use jt::config::Configuration;
+use jt::opt::{Opt, SubCommand};
+
+use clap::Parser;
+
+fn main() {
+ if let Err(err) = do_work() {
+ eprintln!("ERROR: {:?}", err);
+ std::process::exit(1);
+ }
+}
+
+fn do_work() -> anyhow::Result<()> {
+ pretty_env_logger::init_custom_env("JT_LOG");
+ let opt = Opt::parse();
+ let config = Configuration::read(&opt)?;
+ match opt.cmd {
+ SubCommand::Config(x) => x.run(&config)?,
+ SubCommand::Init(x) => x.run(&config)?,
+ SubCommand::IsJournal(x) => x.run(&config)?,
+ SubCommand::New(x) => x.run(&config)?,
+ SubCommand::NewTopic(x) => x.run(&config)?,
+ SubCommand::List(x) => x.run(&config)?,
+ SubCommand::Edit(x) => x.run(&config)?,
+ SubCommand::Remove(x) => x.run(&config)?,
+ SubCommand::Finish(x) => x.run(&config)?,
+ }
+ Ok(())
+}
diff --git a/src/cmd.rs b/src/cmd.rs
new file mode 100644
index 0000000..9c96af1
--- /dev/null
+++ b/src/cmd.rs
@@ -0,0 +1,143 @@
+use crate::config::Configuration;
+use crate::error::JournalError;
+use crate::journal::Journal;
+use clap::Parser;
+use log::debug;
+use std::path::PathBuf;
+
+#[derive(Debug, Parser)]
+pub struct Config {}
+
+impl Config {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ config.dump();
+ Ok(())
+ }
+}
+
+#[derive(Debug, Parser)]
+pub struct Init {
+ #[clap(help = "Short name for journal")]
+ journalname: String,
+
+ #[clap(help = "Short description of journal, its title")]
+ description: String,
+}
+
+impl Init {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ debug!(
+ "init: journalname={:?} description={:?}",
+ self.journalname, self.description
+ );
+ Journal::init(&config.dirname, &config.entries)?;
+ Ok(())
+ }
+}
+
+#[derive(Debug, Parser)]
+pub struct IsJournal {}
+
+impl IsJournal {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ if !Journal::is_journal(&config.dirname, &config.entries) {
+ return Err(JournalError::NotAJournal(
+ config.dirname.display().to_string(),
+ ));
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Parser)]
+pub struct New {
+ #[clap(help = "Title of new draft")]
+ title: String,
+
+ #[clap(long, help = "Add links to topic pages")]
+ topic: Vec<PathBuf>,
+}
+
+impl New {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ let journal = Journal::new(&config.dirname, &config.entries)?;
+ journal.new_draft(&self.title, &self.topic, &config.editor)?;
+ Ok(())
+ }
+}
+
+#[derive(Debug, Parser)]
+pub struct List {}
+
+impl List {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ let journal = Journal::new(&config.dirname, &config.entries)?;
+ journal.list_drafts()?;
+ Ok(())
+ }
+}
+
+#[derive(Debug, Parser)]
+pub struct NewTopic {
+ #[clap(help = "Path to topic page in journal")]
+ path: PathBuf,
+
+ #[clap(help = "Title of topic page")]
+ title: String,
+}
+
+impl NewTopic {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ let journal = Journal::new(&config.dirname, &config.entries)?;
+ journal.new_topic(&self.path, &self.title, &config.editor)?;
+ Ok(())
+ }
+}
+
+#[derive(Debug, Parser)]
+pub struct Edit {
+ /// Draft id.
+ draft: String,
+}
+
+impl Edit {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ let journal = Journal::new(&config.dirname, &config.entries)?;
+ let filename = journal.pick_draft(&self.draft)?;
+ journal.edit_draft(&config.editor, &filename)?;
+ Ok(())
+ }
+}
+
+#[derive(Debug, Parser)]
+pub struct Remove {
+ /// Draft id.
+ draft: String,
+}
+
+impl Remove {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ let journal = Journal::new(&config.dirname, &config.entries)?;
+ let filename = journal.pick_draft(&self.draft)?;
+ journal.remove_draft(&filename)?;
+ Ok(())
+ }
+}
+
+#[derive(Debug, Parser)]
+pub struct Finish {
+ /// Draft id.
+ draft: String,
+
+ /// Set base name of published file.
+ basename: String,
+}
+
+impl Finish {
+ pub fn run(&self, config: &Configuration) -> Result<(), JournalError> {
+ let journal = Journal::new(&config.dirname, &config.entries)?;
+ let filename = journal.pick_draft(&self.draft)?;
+ journal.finish_draft(&filename, &self.basename)?;
+ Ok(())
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..6995e24
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,130 @@
+//! Configuration file handling.
+
+use crate::error::JournalError;
+use crate::opt::Opt;
+
+use directories_next::ProjectDirs;
+use serde::Deserialize;
+use std::default::Default;
+use std::path::{Path, PathBuf};
+
+const APP: &str = "jt";
+
+// The configuration file we read.
+//
+// Some of the fields are optional in the file. We will use default
+// values for those, or get them command line options.
+#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct InputConfiguration {
+ dirname: Option<PathBuf>,
+ editor: Option<String>,
+ entries: Option<PathBuf>,
+}
+
+impl InputConfiguration {
+ fn read(filename: &Path) -> Result<Self, JournalError> {
+ let text = std::fs::read(filename)
+ .map_err(|err| JournalError::ReadConfig(filename.to_path_buf(), err))?;
+ let config = serde_yaml::from_slice(&text)
+ .map_err(|err| JournalError::ConfigSyntax(filename.to_path_buf(), err))?;
+ Ok(config)
+ }
+}
+
+/// The run-time configuration.
+///
+/// This is the configuration as read from the configuration file, if
+/// any, and with all command line options applied. Nothing here is
+/// optional.
+#[derive(Debug, Deserialize)]
+pub struct Configuration {
+ /// The directory where the journal is stored.
+ pub dirname: PathBuf,
+
+ /// The editor to open for editing journal entry drafts.
+ pub editor: String,
+
+ /// The directory where new entries are put.
+ ///
+ /// This is the full path name, not relative to `dirname`.
+ pub entries: PathBuf,
+}
+
+impl Configuration {
+ /// Read configuration file.
+ ///
+ /// The configuration is read from the file specified by the user
+ /// on the command line, or from a default location following the
+ /// XDG base directory specification. Note that only one of those
+ /// is read.
+ ///
+ /// It's OK for the default configuration file to be missing, but
+ /// if one is specified by the user explicitly, that one MUST
+ /// exist.
+ pub fn read(opt: &Opt) -> Result<Self, JournalError> {
+ let proj_dirs =
+ ProjectDirs::from("", "", APP).expect("could not figure out home directory");
+ let filename = match &opt.global.config {
+ Some(path) => {
+ if !path.exists() {
+ return Err(JournalError::ConfigMissing(path.to_path_buf()));
+ }
+ path.to_path_buf()
+ }
+ None => proj_dirs.config_dir().to_path_buf().join("config.yaml"),
+ };
+ let input = if filename.exists() {
+ InputConfiguration::read(&filename)?
+ } else {
+ InputConfiguration::default()
+ };
+
+ let dirname = if let Some(path) = &opt.global.dirname {
+ path.to_path_buf()
+ } else if let Some(path) = &input.dirname {
+ expand_tilde(path)
+ } else {
+ proj_dirs.data_dir().to_path_buf()
+ };
+
+ Ok(Self {
+ dirname: dirname.clone(),
+ editor: if let Some(name) = &opt.global.editor {
+ name.to_string()
+ } else if let Some(name) = &input.editor {
+ name.to_string()
+ } else {
+ "/usr/bin/editor".to_string()
+ },
+ entries: if let Some(entries) = &opt.global.entries {
+ dirname.join(entries)
+ } else if let Some(entries) = &input.entries {
+ dirname.join(entries)
+ } else {
+ dirname.join("entries")
+ },
+ })
+ }
+
+ /// Write configuration to stdout.
+ pub fn dump(&self) {
+ println!("{:#?}", self);
+ }
+}
+
+fn expand_tilde(path: &Path) -> PathBuf {
+ if path.starts_with("~/") {
+ if let Some(home) = std::env::var_os("HOME") {
+ let mut expanded = PathBuf::from(home);
+ for comp in path.components().skip(1) {
+ expanded.push(comp);
+ }
+ expanded
+ } else {
+ path.to_path_buf()
+ }
+ } else {
+ path.to_path_buf()
+ }
+}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..c86ad13
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,116 @@
+//! Errors returned from functions in this crate.
+
+use std::path::PathBuf;
+
+#[derive(Debug, thiserror::Error)]
+pub enum JournalError {
+ /// Configuration file does not exist.
+ #[error("specified configuration file does not exist: {0}")]
+ ConfigMissing(PathBuf),
+
+ /// Failed to read the configuration file.
+ ///
+ /// This is for permission problems and such.
+ #[error("failed to read configuration file {0}")]
+ ReadConfig(PathBuf, #[source] std::io::Error),
+
+ /// Configuration file has a syntax error.
+ #[error("failed to understand configuration file syntax: {0}")]
+ ConfigSyntax(PathBuf, #[source] serde_yaml::Error),
+
+ /// The specified directory does not look like a journal.
+ #[error("directory {0} is not a journal")]
+ NotAJournal(String),
+
+ /// The specified directory for the journal does not exist.
+ #[error("journal directory does not exist: {0}")]
+ NoJournalDirectory(PathBuf),
+
+ /// Failed to create the directory for the journal.
+ #[error("failed to create journal directory {0}")]
+ CreateDirectory(PathBuf, #[source] std::io::Error),
+
+ /// Failed to rename entry when finishing it.
+ #[error("failed to rename journal entry {0} to {1}: {2}")]
+ RenameEntry(PathBuf, PathBuf, #[source] std::io::Error),
+
+ /// Failed to rename draft.
+ #[error("failed to remove draft {0}: {1}")]
+ RemoveDraft(PathBuf, #[source] std::io::Error),
+
+ /// Failed to write entry.
+ #[error("failed to create journal entry {0}: {1}")]
+ WriteEntry(PathBuf, #[source] std::io::Error),
+
+ /// Failed to write topic page.
+ #[error("failed to create topic page {0}: {1}")]
+ WriteTopic(PathBuf, #[source] std::io::Error),
+
+ /// User chose a draft that doesn't exist.
+ #[error("No draft {0} in {1}")]
+ NoSuchDraft(String, PathBuf),
+
+ /// Too many drafts.
+ #[error("there are already {0} drafts in {1}, can't create more")]
+ TooManyDrafts(usize, PathBuf),
+
+ /// User chose a topic that doesn't exist.
+ #[error("No topic page {0}")]
+ NoSuchTopic(PathBuf),
+
+ /// Failed to read draft.
+ #[error("failed to drafts {0}: {1}")]
+ ReadDraft(PathBuf, #[source] std::io::Error),
+
+ /// Draft is not UTF8.
+ #[error("draft {0} is not UTF8: {1}")]
+ DraftNotUUtf8(PathBuf, #[source] std::string::FromUtf8Error),
+
+ /// Failed to get metadata for specific file in drafts folder.
+ #[error("failed to stat draft in {0}: {1}")]
+ StatDraft(PathBuf, #[source] std::io::Error),
+
+ /// Error spawning git.
+ #[error("failed to start git in {0}: {1}")]
+ SpawnGit(PathBuf, std::io::Error),
+
+ /// Git init failed.
+ #[error("git init failed in {0}: {1}")]
+ GitInit(PathBuf, String),
+
+ /// Git add failed.
+ #[error("git add failed in {0}: {1}")]
+ GitAdd(PathBuf, String),
+
+ /// Git commit failed.
+ #[error("git commit failed in {0}: {1}")]
+ GitCommit(PathBuf, String),
+
+ /// Error spawning editor.
+ #[error("failed to start editor {0}: {1}")]
+ SpawnEditor(PathBuf, #[source] std::io::Error),
+
+ /// Editor failed.
+ #[error("editor {0} failed: {1}")]
+ EditorFailed(PathBuf, String),
+
+ /// Template not found.
+ #[error("template not found: {0}")]
+ TemplateNotFound(String),
+
+ /// Failed to render a Tera template.
+ #[error("template {0} failed to render: {1}")]
+ TemplateRender(String, #[source] tera::Error),
+
+ /// Failed to make a path relative to a directory.
+ #[error("failed to make {0} relative to {1}: {2}")]
+ RelativePath(PathBuf, PathBuf, std::path::StripPrefixError),
+
+ /// Problem with glob pattern.
+ #[error("Error in glob pattern {0}: {1}")]
+ PatternError(String, #[source] glob::PatternError),
+
+ /// Problem when matching glob pattern on actual files.
+ #[error("Failed to match glob pattern {0}: {1}")]
+ GlobError(String, #[source] glob::GlobError),
+}
diff --git a/src/git.rs b/src/git.rs
new file mode 100644
index 0000000..5bb53f0
--- /dev/null
+++ b/src/git.rs
@@ -0,0 +1,62 @@
+use crate::error::JournalError;
+use log::debug;
+use std::ffi::OsString;
+use std::path::Path;
+use std::process::Command;
+
+pub fn init<P: AsRef<Path>>(dirname: P) -> Result<(), JournalError> {
+ let dirname = dirname.as_ref();
+ debug!("git init {}", dirname.display());
+
+ let output = Command::new("git")
+ .arg("init")
+ .current_dir(dirname)
+ .output()
+ .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?;
+ debug!("git init exit code was {:?}", output.status.code());
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
+ return Err(JournalError::GitInit(dirname.to_path_buf(), stderr));
+ }
+ Ok(())
+}
+
+pub fn add<P: AsRef<Path>>(dirname: P, files: &[P]) -> Result<(), JournalError> {
+ let args = &["add", "--"];
+ let mut args: Vec<OsString> = args.iter().map(OsString::from).collect();
+ for f in files {
+ args.push(OsString::from(f.as_ref()));
+ }
+
+ let dirname = dirname.as_ref();
+ debug!("git add in {}", dirname.display());
+
+ let output = Command::new("git")
+ .args(&args)
+ .current_dir(dirname)
+ .output()
+ .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?;
+ debug!("git exit code was {:?}", output.status.code());
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
+ return Err(JournalError::GitAdd(dirname.to_path_buf(), stderr));
+ }
+ Ok(())
+}
+
+pub fn commit<P: AsRef<Path>>(dirname: P, msg: &str) -> Result<(), JournalError> {
+ let dirname = dirname.as_ref();
+ debug!("git commit in {}", dirname.display());
+
+ let output = Command::new("git")
+ .args(["commit", "-m", msg])
+ .current_dir(dirname)
+ .output()
+ .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?;
+ debug!("git exit code was {:?}", output.status.code());
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
+ return Err(JournalError::GitCommit(dirname.to_path_buf(), stderr));
+ }
+ Ok(())
+}
diff --git a/src/journal.rs b/src/journal.rs
new file mode 100644
index 0000000..a0d7cda
--- /dev/null
+++ b/src/journal.rs
@@ -0,0 +1,258 @@
+use crate::error::JournalError;
+use crate::git;
+use crate::template::Templates;
+use chrono::{DateTime, Local};
+use glob::glob;
+use regex::Regex;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use tera::Context;
+
+const MAX_DRAFT_COUNT: usize = 1000;
+
+pub struct Journal {
+ dirname: PathBuf,
+ entries: PathBuf,
+ templates: Templates,
+}
+
+impl Journal {
+ pub fn is_journal(path: &Path, entries: &Path) -> bool {
+ is_dir(path) && is_dir(entries)
+ }
+
+ pub fn init(path: &Path, entries: &Path) -> Result<Self, JournalError> {
+ std::fs::create_dir(path)
+ .map_err(|err| JournalError::CreateDirectory(path.to_path_buf(), err))?;
+ git::init(path)?;
+ std::fs::create_dir(entries)
+ .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?;
+ Ok(Self {
+ dirname: path.to_path_buf(),
+ entries: entries.to_path_buf(),
+ templates: Templates::new(path)?,
+ })
+ }
+
+ pub fn new(path: &Path, entries: &Path) -> Result<Self, JournalError> {
+ if Self::is_journal(path, entries) {
+ let dirname = path.to_path_buf();
+ let entries = entries.to_path_buf();
+ let templates = Templates::new(path)?;
+ Ok(Self {
+ dirname,
+ entries,
+ templates,
+ })
+ } else {
+ Err(JournalError::NotAJournal(path.display().to_string()))
+ }
+ }
+
+ fn dirname(&self) -> &Path {
+ &self.dirname
+ }
+
+ fn relative(&self, path: &Path) -> Result<PathBuf, JournalError> {
+ let path = path.strip_prefix(self.dirname()).map_err(|err| {
+ JournalError::RelativePath(path.to_path_buf(), self.dirname().to_path_buf(), err)
+ })?;
+ Ok(path.to_path_buf())
+ }
+
+ fn drafts(&self) -> PathBuf {
+ self.dirname().join("drafts")
+ }
+
+ fn entries(&self) -> PathBuf {
+ self.entries.clone()
+ }
+
+ pub fn new_draft(
+ &self,
+ title: &str,
+ topics: &[PathBuf],
+ editor: &str,
+ ) -> Result<(), JournalError> {
+ let drafts = self.drafts();
+ if !drafts.exists() {
+ std::fs::create_dir(&drafts)
+ .map_err(|err| JournalError::CreateDirectory(drafts.to_path_buf(), err))?;
+ }
+
+ let mut context = Context::new();
+ context.insert("title", title);
+ context.insert("date", &current_timestamp());
+ let mut full_topics = vec![];
+ for topic in topics.iter() {
+ let pathname = topic_path(self.dirname(), topic);
+ if !pathname.exists() {
+ return Err(JournalError::NoSuchTopic(topic.to_path_buf()));
+ }
+ full_topics.push(topic.display().to_string());
+ }
+ context.insert("topics", &full_topics);
+
+ let pathname = self.pick_file_id(&drafts)?;
+ let text = self.templates.new_draft(&context)?;
+ std::fs::write(&pathname, text)
+ .map_err(|err| JournalError::WriteEntry(pathname.to_path_buf(), err))?;
+ self.edit(editor, &pathname)?;
+ Ok(())
+ }
+
+ fn pick_file_id(&self, dirname: &Path) -> Result<PathBuf, JournalError> {
+ for i in 0..MAX_DRAFT_COUNT {
+ let basename = format!("{}.md", i);
+ let pathname = dirname.join(basename);
+ if !pathname.exists() {
+ return Ok(pathname);
+ }
+ }
+ Err(JournalError::TooManyDrafts(
+ MAX_DRAFT_COUNT,
+ dirname.to_path_buf(),
+ ))
+ }
+
+ pub fn pick_draft(&self, id: &str) -> Result<PathBuf, JournalError> {
+ let drafts = self.drafts();
+ let filename = drafts.join(format!("{}.md", id));
+ if filename.exists() {
+ Ok(filename)
+ } else {
+ Err(JournalError::NoSuchDraft(id.to_string(), self.drafts()))
+ }
+ }
+
+ pub fn list_drafts(&self) -> Result<(), JournalError> {
+ let prefix = format!("{}/", self.drafts().display());
+ let pattern = format!("{}*.md", prefix);
+ let entries =
+ glob(&pattern).map_err(|err| JournalError::PatternError(pattern.to_string(), err))?;
+ for entry in entries {
+ let entry = entry.map_err(|err| JournalError::GlobError(pattern.to_string(), err))?;
+ let title = get_title(&entry)?;
+ let entry = entry.file_stem().unwrap().to_string_lossy();
+ let entry = entry.strip_prefix(&prefix).unwrap_or(&entry);
+ println!("{} {}", entry, title);
+ }
+ Ok(())
+ }
+
+ fn edit(&self, editor: &str, filename: &Path) -> Result<(), JournalError> {
+ if editor == "none" {
+ return Ok(());
+ }
+ match Command::new(editor).arg(filename).output() {
+ Err(err) => Err(JournalError::SpawnEditor(filename.to_path_buf(), err)),
+ Ok(output) => {
+ if output.status.success() {
+ Ok(())
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ Err(JournalError::EditorFailed(
+ filename.to_path_buf(),
+ stderr.into_owned(),
+ ))
+ }
+ }
+ }
+ }
+
+ pub fn edit_draft(&self, editor: &str, filename: &Path) -> Result<(), JournalError> {
+ self.edit(editor, filename)?;
+ Ok(())
+ }
+
+ pub fn remove_draft(&self, filename: &Path) -> Result<(), JournalError> {
+ std::fs::remove_file(filename)
+ .map_err(|err| JournalError::RemoveDraft(filename.to_path_buf(), err))?;
+ Ok(())
+ }
+
+ pub fn finish_draft(&self, filename: &Path, basename: &str) -> Result<(), JournalError> {
+ let entries = self.entries();
+ if !entries.exists() {
+ std::fs::create_dir(&entries)
+ .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?;
+ }
+
+ let subdir = entries.join(Local::now().format("%Y/%m/%d").to_string());
+ std::fs::create_dir_all(&subdir)
+ .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?;
+
+ let basename = PathBuf::from(format!("{}.mdwn", basename));
+ let entry = subdir.join(basename);
+ std::fs::rename(filename, &entry).map_err(|err| {
+ JournalError::RenameEntry(filename.to_path_buf(), entry.to_path_buf(), err)
+ })?;
+
+ let entry = self.relative(&entry)?;
+ git::add(self.dirname(), &[&entry])?;
+
+ let msg = format!("journal entry {}", entry.display());
+ git::commit(self.dirname(), &msg)?;
+ Ok(())
+ }
+
+ pub fn new_topic(&self, path: &Path, title: &str, editor: &str) -> Result<(), JournalError> {
+ let mut context = Context::new();
+ context.insert("title", title);
+
+ let dirname = self.dirname();
+ if !dirname.exists() {
+ return Err(JournalError::NoJournalDirectory(dirname.to_path_buf()));
+ }
+
+ let pathname = topic_path(self.dirname(), path);
+ let parent = pathname.parent().unwrap();
+ if !parent.is_dir() {
+ std::fs::create_dir_all(parent)
+ .map_err(|err| JournalError::CreateDirectory(parent.to_path_buf(), err))?;
+ }
+
+ let text = self.templates.new_topic(&context)?;
+ std::fs::write(&pathname, text)
+ .map_err(|err| JournalError::WriteTopic(pathname.to_path_buf(), err))?;
+ self.edit(editor, &pathname)?;
+
+ let topic = self.relative(&pathname)?;
+ git::add(self.dirname(), &[&topic])?;
+
+ let msg = format!("new topic {}", topic.display());
+ git::commit(self.dirname(), &msg)?;
+
+ Ok(())
+ }
+}
+
+fn is_dir(path: &Path) -> bool {
+ if let Ok(meta) = std::fs::symlink_metadata(path) {
+ meta.is_dir()
+ } else {
+ false
+ }
+}
+
+fn topic_path(dirname: &Path, topic: &Path) -> PathBuf {
+ dirname.join(format!("{}.mdwn", topic.display()))
+}
+
+fn current_timestamp() -> String {
+ let now: DateTime<Local> = Local::now();
+ now.to_rfc2822()
+}
+
+fn get_title(filename: &Path) -> Result<String, JournalError> {
+ let text = std::fs::read(filename)
+ .map_err(|err| JournalError::ReadDraft(filename.to_path_buf(), err))?;
+ let text = String::from_utf8_lossy(&text);
+ let pat = Regex::new(r#"^\[\[!meta title="(?P<title>.*)"\]\]"#).unwrap();
+ if let Some(caps) = pat.captures(&text) {
+ if let Some(m) = caps.name("title") {
+ return Ok(m.as_str().to_string());
+ }
+ }
+ Ok("(untitled)".to_string())
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..509e6f1
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,7 @@
+pub mod cmd;
+pub mod config;
+pub mod error;
+pub mod git;
+pub mod journal;
+pub mod opt;
+pub mod template;
diff --git a/src/opt.rs b/src/opt.rs
new file mode 100644
index 0000000..5a81ab6
--- /dev/null
+++ b/src/opt.rs
@@ -0,0 +1,75 @@
+//! Command line options.
+
+use crate::cmd;
+use clap::Parser;
+use std::path::PathBuf;
+
+/// A parsed command line.
+#[derive(Debug, Parser)]
+#[clap(about = "maintain a journal")]
+pub struct Opt {
+ /// Global options, common for all subcommands.
+ #[clap(flatten)]
+ pub global: GlobalOptions,
+
+ /// The subcommand.
+ #[clap(subcommand)]
+ pub cmd: SubCommand,
+}
+
+/// Global options.
+///
+/// These options are common to all subcommands.
+#[derive(Debug, Parser)]
+pub struct GlobalOptions {
+ /// Which configuration file to read.
+ #[structopt(short, long, help = "Configuration file")]
+ pub config: Option<PathBuf>,
+
+ /// Which directory to use for the journal.
+ #[structopt(short, long, help = "Directory where journal should be stored")]
+ pub dirname: Option<PathBuf>,
+
+ /// Sub-directory in journal where new entries are put.
+ #[structopt(long)]
+ pub entries: Option<PathBuf>,
+
+ /// Which editor to invoke for editing journal entry drafts.
+ #[structopt(
+ long,
+ short,
+ help = "Invoke EDITOR for user to edit draft journal entry"
+ )]
+ pub editor: Option<String>,
+}
+
+/// A subcommand.
+#[derive(Debug, Parser)]
+pub enum SubCommand {
+ /// Show configuration.
+ Config(cmd::Config),
+
+ /// Create a new journal in the chosen directory.
+ Init(cmd::Init),
+
+ /// Check if a directory is a journal.
+ IsJournal(cmd::IsJournal),
+
+ /// Create draft for a new journal entry.
+ New(cmd::New),
+
+ /// List current drafts.
+ List(cmd::List),
+
+ /// Create topic page.
+ NewTopic(cmd::NewTopic),
+
+ /// Invoke editor on journal entry draft.
+ Edit(cmd::Edit),
+
+ /// Remove a journal entry draft.
+ Remove(cmd::Remove),
+
+ /// Finish a journal entry draft.
+ Finish(cmd::Finish),
+}
diff --git a/src/template.rs b/src/template.rs
new file mode 100644
index 0000000..f001a34
--- /dev/null
+++ b/src/template.rs
@@ -0,0 +1,66 @@
+use crate::error::JournalError;
+use std::path::Path;
+use tera::{Context, Tera};
+
+const NEW_ENTRY: &str = r#"[[!meta title="{{ title }}"]]
+[[!meta date="{{ date }}"]]
+{% for topic in topics %}
+[[!meta link="{{ topic }}"]]
+{% endfor %}
+
+"#;
+
+const NEW_TOPIC: &str = r#"[[!meta title="{{ title }}"]]
+
+Describe the topic here.
+
+# Entries
+
+[[!inline pages="link(.)" archive=yes reverse=yes trail=yes]]
+"#;
+
+pub struct Templates {
+ tera: Tera,
+}
+
+impl Templates {
+ pub fn new(dirname: &Path) -> Result<Self, JournalError> {
+ let glob = format!("{}/.config/templates/*", dirname.display());
+ let mut tera = if let Ok(tera) = Tera::new(&glob) {
+ tera
+ } else {
+ Tera::default()
+ };
+ add_default_template(&mut tera, "new_entry", NEW_ENTRY);
+ add_default_template(&mut tera, "new_topic", NEW_TOPIC);
+ Ok(Self { tera })
+ }
+
+ pub fn new_draft(&self, context: &Context) -> Result<String, JournalError> {
+ self.render("new_entry", context)
+ }
+
+ pub fn new_topic(&self, context: &Context) -> Result<String, JournalError> {
+ self.render("new_topic", context)
+ }
+
+ fn render(&self, name: &str, context: &Context) -> Result<String, JournalError> {
+ match self.tera.render(name, context) {
+ Ok(s) => Ok(s),
+ Err(e) => match e.kind {
+ tera::ErrorKind::TemplateNotFound(x) => Err(JournalError::TemplateNotFound(x)),
+ _ => Err(JournalError::TemplateRender(name.to_string(), e)),
+ },
+ }
+ }
+}
+
+fn add_default_template(tera: &mut Tera, name: &str, template: &str) {
+ let context = Context::new();
+ if let Err(err) = tera.render(name, &context) {
+ if let tera::ErrorKind::TemplateNotFound(_) = err.kind {
+ tera.add_raw_template(name, template)
+ .expect("Tera::add_raw_template");
+ }
+ }
+}
diff --git a/subplot/jt.py b/subplot/jt.py
new file mode 100644
index 0000000..416e830
--- /dev/null
+++ b/subplot/jt.py
@@ -0,0 +1,192 @@
+import logging
+import os
+
+
+def install_jt(ctx):
+ runcmd_prepend_to_path = globals()["runcmd_prepend_to_path"]
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"]
+ srcdir = globals()["srcdir"]
+
+ target = os.environ.get("CARGO_TARGET_DIR", os.path.join(srcdir, "target"))
+ bindir = os.path.join(target, "debug")
+ runcmd_prepend_to_path(ctx, bindir)
+
+ # Configure git.
+
+ runcmd_run(ctx, ["git", "config", "--global", "user.name", "J. Random Hacker"])
+ runcmd_exit_code_is_zero(ctx)
+
+ runcmd_run(ctx, ["git", "config", "--global", "user.email", "subplot@example.com"])
+ runcmd_exit_code_is_zero(ctx)
+
+
+def create_script(ctx, filename=None):
+ get_file = globals()["get_file"]
+ text = get_file(filename)
+ open(filename, "wb").write(text)
+ os.chmod(filename, 0o755)
+
+
+def run_jt_init(ctx, dirname=None, journalname=None, title=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_run(ctx, [_binary("jt"), "init", dirname, journalname, title])
+
+
+def run_jt_list_journals(ctx):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_run(ctx, [_binary("jt"), "list-journals"])
+
+
+def run_jt_is_journal(ctx, dirname=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_run(ctx, [_binary("jt"), "is-journal", dirname])
+
+
+def run_jt_new(ctx, title=None, dirname=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_run(
+ ctx, [_binary("jt"), "new", title, "--dirname", dirname, "--editor=none"]
+ )
+
+
+def run_jt_edit(ctx, editor=None, dirname=None):
+ runcmd_run = globals()["runcmd_run"]
+ env = dict(os.environ)
+ env["JT_LOG"] = "jt"
+ runcmd_run(
+ ctx, [_binary("jt"), "edit", "--dirname", dirname, "--editor", editor], env=env
+ )
+
+
+def run_jt_finish(ctx, dirname=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_run(ctx, [_binary("jt"), "finish", "--dirname", dirname])
+
+
+def _binary(name):
+ srcdir = globals()["srcdir"]
+ return os.path.join(srcdir, "target", "debug", "jt")
+
+
+def output_contains(ctx, pattern=None):
+ assert_eq = globals()["assert_eq"]
+ logging.debug("checking if %r contains", ctx["stdout"], pattern)
+ assert_eq(pattern in ctx["stdout"], True)
+
+
+def journal_has_no_drafts(ctx, dirname=None):
+ _journal_has_n_drafts(ctx, 0, dirname=dirname)
+
+
+def journal_has_one_draft(ctx, dirname=None):
+ _journal_has_n_drafts(ctx, 1, dirname=dirname)
+
+
+def journal_has_two_drafts(ctx, dirname=None):
+ _journal_has_n_drafts(ctx, 2, dirname=dirname)
+
+
+def _journal_has_n_drafts(ctx, n, dirname=None):
+ assert_eq = globals()["assert_eq"]
+ logging.debug(f"checking {dirname} has {n} drafts")
+ drafts = os.path.join(dirname, "drafts")
+ assert_eq(len(_find_files(drafts)), n)
+
+
+def journal_has_no_entries(ctx, dirname=None):
+ assert_eq = globals()["assert_eq"]
+ logging.debug(f"checking {dirname} has no entries")
+ entries = os.path.join(dirname, "entries")
+ assert_eq(_find_files(entries), [])
+
+
+def journal_has_one_entry(ctx, dirname=None, variable=None):
+ assert_eq = globals()["assert_eq"]
+ logging.debug(
+ f"checking {dirname} has one entry, whose filename is remembered as {variable}"
+ )
+ entries = os.path.join(dirname, "entries")
+ files = _find_files(entries)
+ assert_eq(len(files), 1)
+ variables = ctx.get("variables", {})
+ variables[variable] = files[0]
+ ctx["variables"] = variables
+
+
+def journal_has_two_entries(ctx, dirname=None, variable1=None, variable2=None):
+ assert_eq = globals()["assert_eq"]
+ logging.debug(f"checking {dirname} has two entries")
+ entries = os.path.join(dirname, "entries")
+ files = list(sorted(_find_files(entries)))
+ assert_eq(len(files), 2)
+ variables = ctx.get("variables", {})
+ variables[variable1] = files[0]
+ variables[variable2] = files[1]
+ ctx["variables"] = variables
+
+
+def _find_files(root):
+ if not os.path.exists(root):
+ return []
+
+ files = []
+ for dirname, _, basenames in os.walk(root):
+ for basename in basenames:
+ files.append(os.path.join(dirname, basename))
+ return files
+
+
+def draft_contains_string(ctx, dirname=None, draftno=None, pattern=None):
+ logging.debug(f"checking draft {draftno} in {dirname} has contains {pattern!r}")
+ draft = os.path.join(dirname, "drafts", f"{draftno}.md")
+ with open(draft) as f:
+ data = f.read()
+ logging.debug(f"draft content: {data!r}")
+ assert pattern in data
+
+
+def draft_links_to_topic(ctx, dirname=None, draftno=None, topic=None):
+ logging.debug(f"checking draft {draftno} in {dirname} links to {topic!r}")
+ draft_contains_string(
+ ctx, dirname=dirname, draftno=draftno, pattern=f'\n[[!meta link="{topic}"]]\n'
+ )
+
+
+def edit_draft(ctx, dirname=None, draftno=None, text=None):
+ logging.debug(f"editing draft {draftno} in {dirname} to also contain {text!r}")
+ draft = os.path.join(dirname, "drafts", f"{draftno}.md")
+ with open(draft, "a") as f:
+ f.write(text)
+
+
+def file_contains(ctx, variable=None, pattern=None):
+ logging.debug(f"checking {variable} contains {pattern!r}")
+
+ variables = ctx.get("variables", {})
+ logging.debug(f"variables: {variables!r}")
+
+ filename = variables[variable]
+ with open(filename) as f:
+ data = f.read()
+ logging.debug(f"file content: {data!r}")
+ assert pattern in data
+
+
+def file_name_has_suffix(ctx, varname=None, suffix=None):
+ variables = ctx.get("variables", {})
+ filename = variables[varname]
+ assert filename.endswith(suffix)
+
+
+def git_is_clean(ctx, dirname=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"]
+ runcmd_get_stdout = globals()["runcmd_get_stdout"]
+ assert_eq = globals()["assert_eq"]
+
+ runcmd_run(ctx, ["git", "status", "--porcelain"], cwd=dirname)
+ runcmd_exit_code_is_zero(ctx)
+
+ stdout = runcmd_get_stdout(ctx)
+ assert_eq(stdout, "")
diff --git a/subplot/jt.yaml b/subplot/jt.yaml
new file mode 100644
index 0000000..a121ddf
--- /dev/null
+++ b/subplot/jt.yaml
@@ -0,0 +1,115 @@
+- given: "an installed jt"
+ impl:
+ python:
+ function: install_jt
+
+- given: "an executable script {filename}"
+ types:
+ filename: file
+ impl:
+ python:
+ function: create_script
+
+- when: I invoke jt init (?P<dirname>\S+) (?P<journalname>\S+) "(?P<title>.*)"
+ types:
+ dirname: word
+ journalname: word
+ title: text
+ regex: true
+ impl:
+ python:
+ function: run_jt_init
+
+- when: I invoke jt list-journals
+ impl:
+ python:
+ function: run_jt_list_journals
+
+- when: I invoke jt is-journal {dirname}
+ impl:
+ python:
+ function: run_jt_is_journal
+
+- when: I invoke jt new "{title:text}" --editor=none --dirname={dirname}
+ impl:
+ python:
+ function: run_jt_new
+
+- when: "I invoke jt edit --editor={editor} --dirname={dirname}"
+ regex: false
+ impl:
+ python:
+ function: run_jt_edit
+
+- when: I invoke jt finish --dirname={dirname}
+ impl:
+ python:
+ function: run_jt_finish
+
+- when: I edit draft {draftno} in {dirname} to also contain "{text}"
+ impl:
+ python:
+ function: edit_draft
+
+- then: output contains "(?P<pattern>.*)"
+ types:
+ pattern: text
+ regex: true
+ impl:
+ python:
+ function: output_contains
+
+- then: there are no drafts in {dirname}
+ impl:
+ python:
+ function: journal_has_no_drafts
+
+- then: there is one draft in {dirname}
+ impl:
+ python:
+ function: journal_has_one_draft
+
+- then: there are two drafts in {dirname}
+ impl:
+ python:
+ function: journal_has_two_drafts
+
+- then: draft {draftno} in {dirname} contains "{pattern:text}"
+ impl:
+ python:
+ function: draft_contains_string
+
+- then: draft {draftno} in {dirname} links to "{topic}"
+ impl:
+ python:
+ function: draft_links_to_topic
+
+- then: there are no journal entries in {dirname}
+ impl:
+ python:
+ function: journal_has_no_entries
+
+- then: there is one journal entry in {dirname}, at {variable}
+ impl:
+ python:
+ function: journal_has_one_entry
+
+- then: there are two journal entries in {dirname}, at {variable1} and {variable2}
+ impl:
+ python:
+ function: journal_has_two_entries
+
+- then: journal entry <{variable}> contains "{pattern:text}"
+ impl:
+ python:
+ function: file_contains
+
+- then: file name <{varname}> ends with {suffix}
+ impl:
+ python:
+ function: file_name_has_suffix
+
+- then: there are no uncommitted changes in {dirname}
+ impl:
+ python:
+ function: git_is_clean