summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2021-03-26 09:00:31 +0200
committerLars Wirzenius <liw@liw.fi>2021-03-26 11:27:51 +0200
commitd68ed957f785f4e6969a213e05e4a6bbfc7c391a (patch)
tree27f29481bb21112449802bd071592e8e143852f4
parent2288d53ce4e55e28cf8f17e3cd06cc5905b88223 (diff)
downloadjt2-d68ed957f785f4e6969a213e05e4a6bbfc7c391a.tar.gz
feat! rewrite code
This started out as a change to re-do the command line parsing. I ended up rewriting everything, and failed to do it in a way that could be rebased into a sensible series of small commits.
-rw-r--r--Cargo.lock195
-rw-r--r--Cargo.toml3
-rwxr-xr-xcheck2
-rw-r--r--jt.md112
-rw-r--r--src/bin/jt2.rs76
-rw-r--r--src/config.rs115
-rw-r--r--src/error.rs28
-rw-r--r--src/lib.rs4
-rw-r--r--src/main.rs127
-rw-r--r--src/opt.rs70
-rw-r--r--subplot/jt.py14
-rw-r--r--subplot/jt.yaml8
-rw-r--r--subplot/vendored/files.py194
-rw-r--r--subplot/vendored/files.yaml83
-rw-r--r--subplot/vendored/runcmd.py (renamed from subplot/runcmd.py)0
-rw-r--r--subplot/vendored/runcmd.yaml (renamed from subplot/runcmd.yaml)0
16 files changed, 834 insertions, 197 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 2f48602..f7f4a1b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -20,9 +20,9 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.32"
+version = "1.0.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b602bfe940d21c130f3895acd65221e8a61270debe89d628b9cb4e3ccb8569b"
+checksum = "81cddc5f91628367664cc7c69714ff08deee8a3efc54623011c772544d7b2767"
[[package]]
name = "atty"
@@ -43,9 +43,9 @@ checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "cfg-if"
-version = "0.1.10"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
@@ -63,6 +63,33 @@ dependencies = [
]
[[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.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e"
+
+[[package]]
name = "env_logger"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -76,19 +103,30 @@ dependencies = [
]
[[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.1"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205"
+checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "hermit-abi"
-version = "0.1.15"
+version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3deed196b6e7f9e44a2ae8d94225d80302d81208b1bb673fd21fe634645c85a9"
+checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c"
dependencies = [
"libc",
]
@@ -107,8 +145,11 @@ name = "jt2"
version = "0.1.0"
dependencies = [
"anyhow",
+ "directories-next",
"log",
"pretty_env_logger",
+ "serde",
+ "serde_yaml",
"structopt",
"thiserror",
]
@@ -121,15 +162,21 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
-version = "0.2.76"
+version = "0.2.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8916b1f6ca17130ec6568feccee27c156ad12037880833a3b842a823236502e7"
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "755456fae044e6fa1ebbbd1b3e902ae19e73097ed4ed87bb79934a867c007bc3"
+checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]]
name = "log"
-version = "0.4.11"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b"
+checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
dependencies = [
"cfg-if",
]
@@ -176,9 +223,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
-version = "1.0.20"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "175c513d55719db99da20232b06cda8bab6b83ec2d04e3283edf0213c37c1a29"
+checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
dependencies = [
"unicode-xid",
]
@@ -191,30 +238,80 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
-version = "1.0.7"
+version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37"
+checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
dependencies = [
"proc-macro2",
]
[[package]]
+name = "redox_syscall"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9"
+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 = "regex"
-version = "1.4.2"
+version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38cf2c13ed4745de91a5eb834e11c00bcc3709e773173b2ce4c56c9fbde04b9c"
+checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
- "thread_local",
]
[[package]]
name = "regex-syntax"
-version = "0.6.21"
+version = "0.6.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548"
+
+[[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 = "3b181ba2dcf07aaccad5448e8ead58db5b742cf85dfe035e2227f137a539a189"
+checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23"
+dependencies = [
+ "dtoa",
+ "linked-hash-map",
+ "serde",
+ "yaml-rust",
+]
[[package]]
name = "strsim"
@@ -224,9 +321,9 @@ checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "structopt"
-version = "0.3.17"
+version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cc388d94ffabf39b5ed5fadddc40147cb21e605f53db6f8f36a625d27489ac5"
+checksum = "5277acd7ee46e63e5168a80734c9f6ee81b1367a7d8772a2d765df2a3705d28c"
dependencies = [
"clap",
"lazy_static",
@@ -235,9 +332,9 @@ dependencies = [
[[package]]
name = "structopt-derive"
-version = "0.4.10"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e2513111825077552a6751dfad9e11ce0fba07d7276a3943a037d7e93e64c5f"
+checksum = "5ba9cdfda491b814720b6b06e0cac513d922fc407582032e8706e9f137976f90"
dependencies = [
"heck",
"proc-macro-error",
@@ -248,9 +345,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "1.0.39"
+version = "1.0.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891d8d6567fe7c7f8835a3a98af4208f3846fba258c1bc3c31d6e506239f11f9"
+checksum = "3fd9d1e9976102a03c542daa2eff1b43f9d72306342f3f8b3ed5fb8908195d6f"
dependencies = [
"proc-macro2",
"quote",
@@ -259,9 +356,9 @@ dependencies = [
[[package]]
name = "termcolor"
-version = "1.1.0"
+version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb6bfa289a4d7c5766392812c0a1f4c1ba45afa1ad47803c11e1f407d846d75f"
+checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
dependencies = [
"winapi-util",
]
@@ -277,18 +374,18 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "1.0.20"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dfdd070ccd8ccb78f4ad66bf1982dc37f620ef696c6b5028fe2ed83dd3d0d08"
+checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
-version = "1.0.20"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd80fc12f73063ac132ac92aceea36734f04a1d93c1240c6944e23a3b8841793"
+checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0"
dependencies = [
"proc-macro2",
"quote",
@@ -296,19 +393,10 @@ dependencies = [
]
[[package]]
-name = "thread_local"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14"
-dependencies = [
- "lazy_static",
-]
-
-[[package]]
name = "unicode-segmentation"
-version = "1.6.0"
+version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0"
+checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796"
[[package]]
name = "unicode-width"
@@ -330,9 +418,15 @@ checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
-version = "0.9.2"
+version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed"
+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"
@@ -364,3 +458,12 @@ 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
index a5617a9..a8e81bf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,3 +10,6 @@ anyhow = "1"
thiserror = "1"
pretty_env_logger = "0.4"
log = "0.4"
+directories-next = "2.0.0"
+serde = { version = "1", features = ["derive"] }
+serde_yaml = "0.8"
diff --git a/check b/check
index f9d68e1..1f92df9 100755
--- a/check
+++ b/check
@@ -25,7 +25,7 @@ then
fi
$hideok cargo check --all-targets
-$hideok cargo clippy
+cargo clippy -q
$hideok cargo build --all-targets
$hideok cargo test
diff --git a/jt.md b/jt.md
index bf22c52..dcd3c51 100644
--- a/jt.md
+++ b/jt.md
@@ -23,7 +23,7 @@ journal for the user, and a new journal entry. The entry is a draft
until it's finished.
~~~sh
-$ jt init ~/Journal default "My private journal"
+$ 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
@@ -37,20 +37,104 @@ 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 jt2 config
+then stdout matches regex dirname:.*/\.local.share/jt2
+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 jt2 --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 jt2 --config empty.yaml config
+then stdout matches regex dirname:.*/\.local.share/jt2
+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 jt2 --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 jt2 --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 jt2 --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
-when I invoke jt init jrnl default "My test journal"
+given an installed jt
+
+when I run jt2 --dirname jrnl init default "My test journal"
then command is successful
and directory jrnl exists
-when I invoke jt is-journal jrnl
+when I run jt2 --dirname jrnl is-journal
then command is successful
-when I invoke jt is-journal bogus
+when I try to run jt2 --dirname bogus is-journal
then command fails
~~~
@@ -60,26 +144,28 @@ then command fails
Verify that we can create a new draft entry for the journal.
~~~scenario
-when I invoke jt init jrnl default "My test journal"
+given an installed jt
+
+when I run jt2 --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 invoke jt new "Abracadabra" --editor=none --dirname=jrnl
+when I run jt2 --editor=none --dirname=jrnl new "Abracadabra"
then command is successful
and there is one draft in jrnl
and draft 0 in jrnl contains "Abracadabra"
given an executable script append.sh
-when I invoke jt edit --editor=./append.sh --dirname=jrnl
+when I run jt2 --editor=./append.sh --dirname=jrnl edit
then command is successful
and draft 0 in jrnl contains "Open sesame!"
-when I invoke jt finish --dirname=jrnl
+when I run jt2 --dirname=jrnl finish
then command is successful
and there is one journal entry in jrnl, at FILE
-and file <FILE> contains "Abracadabra"
-and file <FILE> contains "Open sesame!"
+and journal entry <FILE> contains "Abracadabra"
+and journal entry <FILE> contains "Open sesame!"
and there are no drafts in jrnl
~~~
@@ -97,8 +183,10 @@ author:
template: python
bindings:
- subplot/jt.yaml
-- subplot/runcmd.yaml
+- subplot/vendored/files.yaml
+- subplot/vendored/runcmd.yaml
functions:
- subplot/jt.py
-- subplot/runcmd.py
+- subplot/vendored/files.py
+- subplot/vendored/runcmd.py
...
diff --git a/src/bin/jt2.rs b/src/bin/jt2.rs
new file mode 100644
index 0000000..cf55aef
--- /dev/null
+++ b/src/bin/jt2.rs
@@ -0,0 +1,76 @@
+use jt2::config::Configuration;
+use jt2::error::JournalError;
+use jt2::opt::{Opt, SubCommand};
+
+use log::debug;
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+use structopt::StructOpt;
+
+fn main() -> anyhow::Result<()> {
+ pretty_env_logger::init_custom_env("JT_LOG");
+ let opt = Opt::from_args();
+ let config = Configuration::read(&opt)?;
+ match opt.cmd {
+ SubCommand::Config => config.dump(),
+ SubCommand::Init {
+ journalname,
+ description,
+ } => init(&config.dirname, &journalname, &description)?,
+ SubCommand::IsJournal => is_journal(&config.dirname)?,
+ SubCommand::New { title } => new_draft(&title, &config.dirname, &config.editor)?,
+ SubCommand::Edit => edit_draft(&config.dirname, &config.editor)?,
+ SubCommand::Finish => finish_draft(&config.dirname)?,
+ }
+ Ok(())
+}
+
+fn init(dirname: &Path, _journalname: &str, _description: &str) -> anyhow::Result<()> {
+ std::fs::create_dir(dirname)
+ .map_err(|err| JournalError::CreateDirectory(dirname.to_path_buf(), err))?;
+ Ok(())
+}
+
+fn is_journal(dirname: &Path) -> anyhow::Result<()> {
+ let meta = fs::symlink_metadata(dirname)?;
+ if !meta.is_dir() {
+ return Err(JournalError::NotAJournal(dirname.display().to_string()).into());
+ }
+ Ok(())
+}
+
+fn new_draft(title: &str, dirname: &Path, _editor: &str) -> anyhow::Result<()> {
+ let drafts = dirname.join("drafts");
+ if !drafts.exists() {
+ std::fs::create_dir(&drafts)?;
+ }
+ let draft_filename = drafts.join("0.md");
+ std::fs::write(draft_filename, title)?;
+ Ok(())
+}
+
+fn edit_draft(dirname: &Path, editor: &str) -> anyhow::Result<()> {
+ debug!("edit_draft: dirname={:?}", dirname);
+ debug!("edit_draft: editor={:?}", editor);
+ let drafts = dirname.join("drafts");
+ let draft_filename = drafts.join("0.md");
+ debug!("edit_draft: draft_filename={:?}", draft_filename);
+ Command::new(editor).arg(draft_filename).status()?;
+ debug!("edit_draft: editor finished");
+ Ok(())
+}
+
+fn finish_draft(dirname: &Path) -> anyhow::Result<()> {
+ let drafts = dirname.join("drafts");
+ let draft = drafts.join("0.md");
+
+ let entries = dirname.join("entries");
+ if !entries.exists() {
+ std::fs::create_dir(&entries)?;
+ }
+ let entry = entries.join("0.md");
+
+ std::fs::rename(draft, entry)?;
+ Ok(())
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..5041492
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,115 @@
+//! 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 = "jt2";
+
+// 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>,
+}
+
+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))?;
+ return 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,
+}
+
+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(),
+ };
+ let input = if filename.exists() {
+ InputConfiguration::read(&filename)?
+ } else {
+ InputConfiguration::default()
+ };
+
+ Ok(Self {
+ 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()
+ },
+ 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()
+ },
+ })
+ }
+
+ /// 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..00dae56
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,28 @@
+//! 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),
+
+ /// Failed to create the directory for the journal.
+ #[error("failed to create journal directory {0}")]
+ CreateDirectory(PathBuf, #[source] std::io::Error),
+}
diff --git a/src/lib.rs b/src/lib.rs
index 8b13789..523317b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1,3 @@
-
+pub mod config;
+pub mod error;
+pub mod opt;
diff --git a/src/main.rs b/src/main.rs
deleted file mode 100644
index c5117c8..0000000
--- a/src/main.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-use anyhow::Result;
-use log::debug;
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::process::Command;
-use structopt::StructOpt;
-use thiserror::Error;
-
-#[derive(Debug, StructOpt)]
-#[structopt(about = "maintain a journal")]
-enum JT {
- Init {
- #[structopt(help = "Directory where journal should be stored")]
- dirname: PathBuf,
- #[structopt(help = "Short name for journal")]
- journalname: String,
- #[structopt(help = "Short description of journal, its title")]
- description: String,
- },
- IsJournal {
- #[structopt(help = "Directory that may or may not be a journal")]
- dirname: PathBuf,
- },
- New {
- #[structopt(long, short, help = "Use DIRNAME as the location of the journal")]
- dirname: PathBuf,
- #[structopt(
- long,
- short,
- help = "Invoke EDITOR for user to edit draft",
- default_value = "/usr/bin/editor"
- )]
- editor: String,
- #[structopt(help = "Title of new draft")]
- title: String,
- },
- Edit {
- #[structopt(long, short, help = "Use DIRNAME as the location of the journal")]
- dirname: PathBuf,
- #[structopt(
- long,
- short,
- help = "Invoke EDITOR for user to edit draft",
- default_value = "/usr/bin/editor"
- )]
- editor: String,
- },
- Finish {
- #[structopt(long, short, help = "Use DIRNAME as the location of the journal")]
- dirname: PathBuf,
- },
-}
-
-#[derive(Debug, Error)]
-enum JournalError {
- #[error("directory {0} is not a journal")]
- NotAJournal(String),
-}
-
-fn main() -> Result<()> {
- pretty_env_logger::init_custom_env("JT_LOG");
- let opt = JT::from_args();
- match opt {
- JT::Init {
- dirname,
- journalname,
- description,
- } => init(&dirname, &journalname, &description)?,
- JT::IsJournal { dirname } => is_journal(&dirname)?,
- JT::New {
- title,
- dirname,
- editor,
- } => new_draft(&title, &dirname, &editor)?,
- JT::Edit { dirname, editor } => edit_draft(&dirname, &editor)?,
- JT::Finish { dirname } => finish_draft(&dirname)?,
- }
- Ok(())
-}
-
-fn init(dirname: &Path, _journalname: &str, _description: &str) -> anyhow::Result<()> {
- std::fs::create_dir(dirname)?;
- Ok(())
-}
-
-fn is_journal(dirname: &Path) -> anyhow::Result<()> {
- let meta = fs::symlink_metadata(dirname)?;
- if !meta.is_dir() {
- return Err(JournalError::NotAJournal(dirname.display().to_string()).into());
- }
- Ok(())
-}
-
-fn new_draft(title: &str, dirname: &Path, editor: &str) -> anyhow::Result<()> {
- let drafts = dirname.join("drafts");
- if !drafts.exists() {
- std::fs::create_dir(&drafts)?;
- }
- let draft_filename = drafts.join("0.md");
- std::fs::write(draft_filename, title)?;
- Ok(())
-}
-
-fn edit_draft(dirname: &Path, editor: &str) -> anyhow::Result<()> {
- debug!("edit_draft: dirname={:?}", dirname);
- debug!("edit_draft: editor={:?}", editor);
- let drafts = dirname.join("drafts");
- let draft_filename = drafts.join("0.md");
- debug!("edit_draft: draft_filename={:?}", draft_filename);
- let status = Command::new(editor).arg(draft_filename).status()?;
- debug!("edit_draft: editor finished");
- Ok(())
-}
-
-fn finish_draft(dirname: &Path) -> anyhow::Result<()> {
- let drafts = dirname.join("drafts");
- let draft = drafts.join("0.md");
-
- let entries = dirname.join("entries");
- if !entries.exists() {
- std::fs::create_dir(&entries)?;
- }
- let entry = entries.join("0.md");
-
- std::fs::rename(draft, entry)?;
- Ok(())
-}
diff --git a/src/opt.rs b/src/opt.rs
new file mode 100644
index 0000000..12c3d26
--- /dev/null
+++ b/src/opt.rs
@@ -0,0 +1,70 @@
+//! Command line options.
+
+use std::path::PathBuf;
+use structopt::StructOpt;
+
+/// A parsed command line.
+#[derive(Debug, StructOpt)]
+#[structopt(about = "maintain a journal")]
+pub struct Opt {
+ /// Global options, common for all subcommands.
+ #[structopt(flatten)]
+ pub global: GlobalOptions,
+
+ /// The subcommand.
+ #[structopt(subcommand)]
+ pub cmd: SubCommand,
+}
+
+/// Global options.
+///
+/// These options are common to all subcommands.
+#[derive(Debug, StructOpt)]
+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>,
+
+ /// 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, StructOpt)]
+pub enum SubCommand {
+ /// Show configuration.
+ Config,
+
+ /// Create a new journal in the chosen directory.
+ Init {
+ #[structopt(help = "Short name for journal")]
+ journalname: String,
+
+ #[structopt(help = "Short description of journal, its title")]
+ description: String,
+ },
+
+ /// Check if a directory is a journal.
+ IsJournal,
+
+ /// Create draft for a new journal entry.
+ New {
+ #[structopt(help = "Title of new draft")]
+ title: String,
+ },
+
+ /// Invoke editor on journal entry draft.
+ Edit,
+
+ /// Finish a journal entry draft.
+ Finish,
+}
diff --git a/subplot/jt.py b/subplot/jt.py
index 380cde4..31bceb7 100644
--- a/subplot/jt.py
+++ b/subplot/jt.py
@@ -2,6 +2,14 @@ import logging
import os
+def install_jt(ctx):
+ runcmd_prepend_to_path = globals()["runcmd_prepend_to_path"]
+ srcdir = globals()["srcdir"]
+
+ bindir = os.path.join(srcdir, "target", "debug")
+ runcmd_prepend_to_path(ctx, bindir)
+
+
def create_script(ctx, filename=None):
get_file = globals()["get_file"]
text = get_file(filename)
@@ -50,12 +58,6 @@ def _binary(name):
return os.path.join(srcdir, "target", "debug", "jt2")
-def is_directory(ctx, dirname=None):
- assert_eq = globals()["assert_eq"]
- logging.debug("checking if %r is a directory", dirname)
- assert_eq(os.path.isdir(dirname), True)
-
-
def output_contains(ctx, pattern=None):
assert_eq = globals()["assert_eq"]
logging.debug("checking if %r contains", ctx["stdout"], pattern)
diff --git a/subplot/jt.yaml b/subplot/jt.yaml
index 6d10a3a..9c0983c 100644
--- a/subplot/jt.yaml
+++ b/subplot/jt.yaml
@@ -1,3 +1,6 @@
+- given: "an installed jt"
+ function: install_jt
+
- given: "an executable script {filename}"
function: create_script
@@ -24,9 +27,6 @@
- when: I edit draft {draftno} in {dirname} to also contain "{text}"
function: edit_draft
-- then: directory {dirname} exists
- function: is_directory
-
- then: output contains "(?P<pattern>.*)"
regex: true
function: output_contains
@@ -46,5 +46,5 @@
- then: there is one journal entry in {dirname}, at {variable}
function: journal_has_one_entry
-- then: file <{variable}> contains "{pattern:text}"
+- then: journal entry <{variable}> contains "{pattern:text}"
function: file_contains
diff --git a/subplot/vendored/files.py b/subplot/vendored/files.py
new file mode 100644
index 0000000..dd5b9f8
--- /dev/null
+++ b/subplot/vendored/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: {regex}")
+ 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/vendored/files.yaml b/subplot/vendored/files.yaml
new file mode 100644
index 0000000..f18b8cd
--- /dev/null
+++ b/subplot/vendored/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/runcmd.py b/subplot/vendored/runcmd.py
index a2564c6..a2564c6 100644
--- a/subplot/runcmd.py
+++ b/subplot/vendored/runcmd.py
diff --git a/subplot/runcmd.yaml b/subplot/vendored/runcmd.yaml
index 48dde90..48dde90 100644
--- a/subplot/runcmd.yaml
+++ b/subplot/vendored/runcmd.yaml