From d68ed957f785f4e6969a213e05e4a6bbfc7c391a Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Fri, 26 Mar 2021 09:00:31 +0200 Subject: 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. --- Cargo.lock | 195 +++++++++++++++++++++++++-------- Cargo.toml | 3 + check | 2 +- jt.md | 112 ++++++++++++++++--- src/bin/jt2.rs | 76 +++++++++++++ src/config.rs | 115 ++++++++++++++++++++ src/error.rs | 28 +++++ src/lib.rs | 4 +- src/main.rs | 127 ---------------------- src/opt.rs | 70 ++++++++++++ subplot/jt.py | 14 +-- subplot/jt.yaml | 8 +- subplot/runcmd.py | 252 ------------------------------------------- subplot/runcmd.yaml | 83 -------------- subplot/vendored/files.py | 194 +++++++++++++++++++++++++++++++++ subplot/vendored/files.yaml | 83 ++++++++++++++ subplot/vendored/runcmd.py | 252 +++++++++++++++++++++++++++++++++++++++++++ subplot/vendored/runcmd.yaml | 83 ++++++++++++++ 18 files changed, 1169 insertions(+), 532 deletions(-) create mode 100644 src/bin/jt2.rs create mode 100644 src/config.rs create mode 100644 src/error.rs delete mode 100644 src/main.rs create mode 100644 src/opt.rs delete mode 100644 subplot/runcmd.py delete mode 100644 subplot/runcmd.yaml create mode 100644 subplot/vendored/files.py create mode 100644 subplot/vendored/files.yaml create mode 100644 subplot/vendored/runcmd.py create mode 100644 subplot/vendored/runcmd.yaml 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" @@ -62,6 +62,33 @@ dependencies = [ "vec_map", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dtoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e" + [[package]] name = "env_logger" version = "0.7.1" @@ -75,20 +102,31 @@ dependencies = [ "termcolor", ] +[[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,38 +374,29 @@ 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", "syn", ] -[[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 contains "Abracadabra" -and file contains "Open sesame!" +and journal entry contains "Abracadabra" +and journal entry 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, + editor: Option, +} + +impl InputConfiguration { + fn read(filename: &Path) -> Result { + 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 { + 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, + + /// Which directory to use for the journal. + #[structopt(short, long, help = "Directory where journal should be stored")] + pub dirname: Option, + + /// 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, +} + +/// 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.*)" 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/runcmd.py b/subplot/runcmd.py deleted file mode 100644 index a2564c6..0000000 --- a/subplot/runcmd.py +++ /dev/null @@ -1,252 +0,0 @@ -import logging -import os -import re -import shlex -import subprocess - - -# -# Helper functions. -# - -# Get exit code or other stored data about the latest command run by -# runcmd_run. - - -def _runcmd_get(ctx, name): - ns = ctx.declare("_runcmd") - return ns[name] - - -def runcmd_get_exit_code(ctx): - return _runcmd_get(ctx, "exit") - - -def runcmd_get_stdout(ctx): - return _runcmd_get(ctx, "stdout") - - -def runcmd_get_stdout_raw(ctx): - return _runcmd_get(ctx, "stdout.raw") - - -def runcmd_get_stderr(ctx): - return _runcmd_get(ctx, "stderr") - - -def runcmd_get_stderr_raw(ctx): - return _runcmd_get(ctx, "stderr.raw") - - -def runcmd_get_argv(ctx): - return _runcmd_get(ctx, "argv") - - -# Run a command, given an argv and other arguments for subprocess.Popen. -# -# This is meant to be a helper function, not bound directly to a step. The -# stdout, stderr, and exit code are stored in the "_runcmd" namespace in the -# ctx context. -def runcmd_run(ctx, argv, **kwargs): - ns = ctx.declare("_runcmd") - - # The Subplot Python template empties os.environ at startup, modulo a small - # number of variables with carefully chosen values. Here, we don't need to - # care about what those variables are, but we do need to not overwrite - # them, so we just add anything in the env keyword argument, if any, to - # os.environ. - env = dict(os.environ) - for key, arg in kwargs.pop("env", {}).items(): - env[key] = arg - - pp = ns.get("path-prefix") - if pp: - env["PATH"] = pp + ":" + env["PATH"] - - logging.debug(f"runcmd_run") - logging.debug(f" argv: {argv}") - logging.debug(f" env: {env}") - p = subprocess.Popen( - argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, **kwargs - ) - stdout, stderr = p.communicate("") - ns["argv"] = argv - ns["stdout.raw"] = stdout - ns["stderr.raw"] = stderr - ns["stdout"] = stdout.decode("utf-8") - ns["stderr"] = stderr.decode("utf-8") - ns["exit"] = p.returncode - logging.debug(f" ctx: {ctx}") - logging.debug(f" ns: {ns}") - - -# Step: prepend srcdir to PATH whenever runcmd runs a command. -def runcmd_helper_srcdir_path(ctx): - srcdir = globals()["srcdir"] - runcmd_prepend_to_path(ctx, srcdir) - - -# Step: This creates a helper script. -def runcmd_helper_script(ctx, filename=None): - get_file = globals()["get_file"] - with open(filename, "wb") as f: - f.write(get_file(filename)) - - -# -# Step functions for running commands. -# - - -def runcmd_prepend_to_path(ctx, dirname=None): - ns = ctx.declare("_runcmd") - pp = ns.get("path-prefix", "") - if pp: - pp = f"{pp}:{dirname}" - else: - pp = dirname - ns["path-prefix"] = pp - - -def runcmd_step(ctx, argv0=None, args=None): - runcmd_try_to_run(ctx, argv0=argv0, args=args) - runcmd_exit_code_is_zero(ctx) - - -def runcmd_try_to_run(ctx, argv0=None, args=None): - argv = [shlex.quote(argv0)] + shlex.split(args) - runcmd_run(ctx, argv) - - -# -# Step functions for examining exit codes. -# - - -def runcmd_exit_code_is_zero(ctx): - runcmd_exit_code_is(ctx, exit=0) - - -def runcmd_exit_code_is(ctx, exit=None): - assert_eq = globals()["assert_eq"] - assert_eq(runcmd_get_exit_code(ctx), int(exit)) - - -def runcmd_exit_code_is_nonzero(ctx): - runcmd_exit_code_is_not(ctx, exit=0) - - -def runcmd_exit_code_is_not(ctx, exit=None): - assert_ne = globals()["assert_ne"] - assert_ne(runcmd_get_exit_code(ctx), int(exit)) - - -# -# Step functions and helpers for examining output in various ways. -# - - -def runcmd_stdout_is(ctx, text=None): - _runcmd_output_is(runcmd_get_stdout(ctx), text) - - -def runcmd_stdout_isnt(ctx, text=None): - _runcmd_output_isnt(runcmd_get_stdout(ctx), text) - - -def runcmd_stderr_is(ctx, text=None): - _runcmd_output_is(runcmd_get_stderr(ctx), text) - - -def runcmd_stderr_isnt(ctx, text=None): - _runcmd_output_isnt(runcmd_get_stderr(ctx), text) - - -def _runcmd_output_is(actual, wanted): - assert_eq = globals()["assert_eq"] - wanted = bytes(wanted, "utf8").decode("unicode_escape") - logging.debug("_runcmd_output_is:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" wanted: {wanted!r}") - assert_eq(actual, wanted) - - -def _runcmd_output_isnt(actual, wanted): - assert_ne = globals()["assert_ne"] - wanted = bytes(wanted, "utf8").decode("unicode_escape") - logging.debug("_runcmd_output_isnt:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" wanted: {wanted!r}") - assert_ne(actual, wanted) - - -def runcmd_stdout_contains(ctx, text=None): - _runcmd_output_contains(runcmd_get_stdout(ctx), text) - - -def runcmd_stdout_doesnt_contain(ctx, text=None): - _runcmd_output_doesnt_contain(runcmd_get_stdout(ctx), text) - - -def runcmd_stderr_contains(ctx, text=None): - _runcmd_output_contains(runcmd_get_stderr(ctx), text) - - -def runcmd_stderr_doesnt_contain(ctx, text=None): - _runcmd_output_doesnt_contain(runcmd_get_stderr(ctx), text) - - -def _runcmd_output_contains(actual, wanted): - assert_eq = globals()["assert_eq"] - wanted = bytes(wanted, "utf8").decode("unicode_escape") - logging.debug("_runcmd_output_contains:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" wanted: {wanted!r}") - assert_eq(wanted in actual, True) - - -def _runcmd_output_doesnt_contain(actual, wanted): - assert_ne = globals()["assert_ne"] - wanted = bytes(wanted, "utf8").decode("unicode_escape") - logging.debug("_runcmd_output_doesnt_contain:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" wanted: {wanted!r}") - assert_ne(wanted in actual, True) - - -def runcmd_stdout_matches_regex(ctx, regex=None): - _runcmd_output_matches_regex(runcmd_get_stdout(ctx), regex) - - -def runcmd_stdout_doesnt_match_regex(ctx, regex=None): - _runcmd_output_doesnt_match_regex(runcmd_get_stdout(ctx), regex) - - -def runcmd_stderr_matches_regex(ctx, regex=None): - _runcmd_output_matches_regex(runcmd_get_stderr(ctx), regex) - - -def runcmd_stderr_doesnt_match_regex(ctx, regex=None): - _runcmd_output_doesnt_match_regex(runcmd_get_stderr(ctx), regex) - - -def _runcmd_output_matches_regex(actual, regex): - assert_ne = globals()["assert_ne"] - r = re.compile(regex) - m = r.search(actual) - logging.debug("_runcmd_output_matches_regex:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" regex: {regex!r}") - logging.debug(f" match: {m}") - assert_ne(m, None) - - -def _runcmd_output_doesnt_match_regex(actual, regex): - assert_eq = globals()["assert_eq"] - r = re.compile(regex) - m = r.search(actual) - logging.debug("_runcmd_output_doesnt_match_regex:") - logging.debug(f" actual: {actual!r}") - logging.debug(f" regex: {regex!r}") - logging.debug(f" match: {m}") - assert_eq(m, None) diff --git a/subplot/runcmd.yaml b/subplot/runcmd.yaml deleted file mode 100644 index 48dde90..0000000 --- a/subplot/runcmd.yaml +++ /dev/null @@ -1,83 +0,0 @@ -# Steps to run commands. - -- given: helper script {filename} for runcmd - function: runcmd_helper_script - -- given: srcdir is in the PATH - function: runcmd_helper_srcdir_path - -- when: I run (?P\S+)(?P.*) - regex: true - function: runcmd_step - -- when: I try to run (?P\S+)(?P.*) - regex: true - function: runcmd_try_to_run - -# Steps to examine exit code of latest command. - -- then: exit code is {exit} - function: runcmd_exit_code_is - -- then: exit code is not {exit} - function: runcmd_exit_code_is_not - -- then: command is successful - function: runcmd_exit_code_is_zero - -- then: command fails - function: runcmd_exit_code_is_nonzero - -# Steps to examine stdout/stderr for exact content. - -- then: stdout is exactly "(?P.*)" - regex: true - function: runcmd_stdout_is - -- then: "stdout isn't exactly \"(?P.*)\"" - regex: true - function: runcmd_stdout_isnt - -- then: stderr is exactly "(?P.*)" - regex: true - function: runcmd_stderr_is - -- then: "stderr isn't exactly \"(?P.*)\"" - regex: true - function: runcmd_stderr_isnt - -# Steps to examine stdout/stderr for sub-strings. - -- then: stdout contains "(?P.*)" - regex: true - function: runcmd_stdout_contains - -- then: "stdout doesn't contain \"(?P.*)\"" - regex: true - function: runcmd_stdout_doesnt_contain - -- then: stderr contains "(?P.*)" - regex: true - function: runcmd_stderr_contains - -- then: "stderr doesn't contain \"(?P.*)\"" - regex: true - function: runcmd_stderr_doesnt_contain - -# Steps to match stdout/stderr against regular expressions. - -- then: stdout matches regex (?P.*) - regex: true - function: runcmd_stdout_matches_regex - -- then: stdout doesn't match regex (?P.*) - regex: true - function: runcmd_stdout_doesnt_match_regex - -- then: stderr matches regex (?P.*) - regex: true - function: runcmd_stderr_matches_regex - -- then: stderr doesn't match regex (?P.*) - regex: true - function: runcmd_stderr_doesnt_match_regex 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.*)" to file (?P\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.+) exist + function: files_only_these_exist + regex: true + +- then: file (?P\S+) contains "(?P.*)" + regex: true + function: files_file_contains + +- then: file (?P\S+) matches regex /(?P.*)/ + regex: true + function: files_file_matches_regex + +- then: file (?P\S+) matches regex "(?P.*)" + 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/vendored/runcmd.py b/subplot/vendored/runcmd.py new file mode 100644 index 0000000..a2564c6 --- /dev/null +++ b/subplot/vendored/runcmd.py @@ -0,0 +1,252 @@ +import logging +import os +import re +import shlex +import subprocess + + +# +# Helper functions. +# + +# Get exit code or other stored data about the latest command run by +# runcmd_run. + + +def _runcmd_get(ctx, name): + ns = ctx.declare("_runcmd") + return ns[name] + + +def runcmd_get_exit_code(ctx): + return _runcmd_get(ctx, "exit") + + +def runcmd_get_stdout(ctx): + return _runcmd_get(ctx, "stdout") + + +def runcmd_get_stdout_raw(ctx): + return _runcmd_get(ctx, "stdout.raw") + + +def runcmd_get_stderr(ctx): + return _runcmd_get(ctx, "stderr") + + +def runcmd_get_stderr_raw(ctx): + return _runcmd_get(ctx, "stderr.raw") + + +def runcmd_get_argv(ctx): + return _runcmd_get(ctx, "argv") + + +# Run a command, given an argv and other arguments for subprocess.Popen. +# +# This is meant to be a helper function, not bound directly to a step. The +# stdout, stderr, and exit code are stored in the "_runcmd" namespace in the +# ctx context. +def runcmd_run(ctx, argv, **kwargs): + ns = ctx.declare("_runcmd") + + # The Subplot Python template empties os.environ at startup, modulo a small + # number of variables with carefully chosen values. Here, we don't need to + # care about what those variables are, but we do need to not overwrite + # them, so we just add anything in the env keyword argument, if any, to + # os.environ. + env = dict(os.environ) + for key, arg in kwargs.pop("env", {}).items(): + env[key] = arg + + pp = ns.get("path-prefix") + if pp: + env["PATH"] = pp + ":" + env["PATH"] + + logging.debug(f"runcmd_run") + logging.debug(f" argv: {argv}") + logging.debug(f" env: {env}") + p = subprocess.Popen( + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, **kwargs + ) + stdout, stderr = p.communicate("") + ns["argv"] = argv + ns["stdout.raw"] = stdout + ns["stderr.raw"] = stderr + ns["stdout"] = stdout.decode("utf-8") + ns["stderr"] = stderr.decode("utf-8") + ns["exit"] = p.returncode + logging.debug(f" ctx: {ctx}") + logging.debug(f" ns: {ns}") + + +# Step: prepend srcdir to PATH whenever runcmd runs a command. +def runcmd_helper_srcdir_path(ctx): + srcdir = globals()["srcdir"] + runcmd_prepend_to_path(ctx, srcdir) + + +# Step: This creates a helper script. +def runcmd_helper_script(ctx, filename=None): + get_file = globals()["get_file"] + with open(filename, "wb") as f: + f.write(get_file(filename)) + + +# +# Step functions for running commands. +# + + +def runcmd_prepend_to_path(ctx, dirname=None): + ns = ctx.declare("_runcmd") + pp = ns.get("path-prefix", "") + if pp: + pp = f"{pp}:{dirname}" + else: + pp = dirname + ns["path-prefix"] = pp + + +def runcmd_step(ctx, argv0=None, args=None): + runcmd_try_to_run(ctx, argv0=argv0, args=args) + runcmd_exit_code_is_zero(ctx) + + +def runcmd_try_to_run(ctx, argv0=None, args=None): + argv = [shlex.quote(argv0)] + shlex.split(args) + runcmd_run(ctx, argv) + + +# +# Step functions for examining exit codes. +# + + +def runcmd_exit_code_is_zero(ctx): + runcmd_exit_code_is(ctx, exit=0) + + +def runcmd_exit_code_is(ctx, exit=None): + assert_eq = globals()["assert_eq"] + assert_eq(runcmd_get_exit_code(ctx), int(exit)) + + +def runcmd_exit_code_is_nonzero(ctx): + runcmd_exit_code_is_not(ctx, exit=0) + + +def runcmd_exit_code_is_not(ctx, exit=None): + assert_ne = globals()["assert_ne"] + assert_ne(runcmd_get_exit_code(ctx), int(exit)) + + +# +# Step functions and helpers for examining output in various ways. +# + + +def runcmd_stdout_is(ctx, text=None): + _runcmd_output_is(runcmd_get_stdout(ctx), text) + + +def runcmd_stdout_isnt(ctx, text=None): + _runcmd_output_isnt(runcmd_get_stdout(ctx), text) + + +def runcmd_stderr_is(ctx, text=None): + _runcmd_output_is(runcmd_get_stderr(ctx), text) + + +def runcmd_stderr_isnt(ctx, text=None): + _runcmd_output_isnt(runcmd_get_stderr(ctx), text) + + +def _runcmd_output_is(actual, wanted): + assert_eq = globals()["assert_eq"] + wanted = bytes(wanted, "utf8").decode("unicode_escape") + logging.debug("_runcmd_output_is:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" wanted: {wanted!r}") + assert_eq(actual, wanted) + + +def _runcmd_output_isnt(actual, wanted): + assert_ne = globals()["assert_ne"] + wanted = bytes(wanted, "utf8").decode("unicode_escape") + logging.debug("_runcmd_output_isnt:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" wanted: {wanted!r}") + assert_ne(actual, wanted) + + +def runcmd_stdout_contains(ctx, text=None): + _runcmd_output_contains(runcmd_get_stdout(ctx), text) + + +def runcmd_stdout_doesnt_contain(ctx, text=None): + _runcmd_output_doesnt_contain(runcmd_get_stdout(ctx), text) + + +def runcmd_stderr_contains(ctx, text=None): + _runcmd_output_contains(runcmd_get_stderr(ctx), text) + + +def runcmd_stderr_doesnt_contain(ctx, text=None): + _runcmd_output_doesnt_contain(runcmd_get_stderr(ctx), text) + + +def _runcmd_output_contains(actual, wanted): + assert_eq = globals()["assert_eq"] + wanted = bytes(wanted, "utf8").decode("unicode_escape") + logging.debug("_runcmd_output_contains:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" wanted: {wanted!r}") + assert_eq(wanted in actual, True) + + +def _runcmd_output_doesnt_contain(actual, wanted): + assert_ne = globals()["assert_ne"] + wanted = bytes(wanted, "utf8").decode("unicode_escape") + logging.debug("_runcmd_output_doesnt_contain:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" wanted: {wanted!r}") + assert_ne(wanted in actual, True) + + +def runcmd_stdout_matches_regex(ctx, regex=None): + _runcmd_output_matches_regex(runcmd_get_stdout(ctx), regex) + + +def runcmd_stdout_doesnt_match_regex(ctx, regex=None): + _runcmd_output_doesnt_match_regex(runcmd_get_stdout(ctx), regex) + + +def runcmd_stderr_matches_regex(ctx, regex=None): + _runcmd_output_matches_regex(runcmd_get_stderr(ctx), regex) + + +def runcmd_stderr_doesnt_match_regex(ctx, regex=None): + _runcmd_output_doesnt_match_regex(runcmd_get_stderr(ctx), regex) + + +def _runcmd_output_matches_regex(actual, regex): + assert_ne = globals()["assert_ne"] + r = re.compile(regex) + m = r.search(actual) + logging.debug("_runcmd_output_matches_regex:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" regex: {regex!r}") + logging.debug(f" match: {m}") + assert_ne(m, None) + + +def _runcmd_output_doesnt_match_regex(actual, regex): + assert_eq = globals()["assert_eq"] + r = re.compile(regex) + m = r.search(actual) + logging.debug("_runcmd_output_doesnt_match_regex:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" regex: {regex!r}") + logging.debug(f" match: {m}") + assert_eq(m, None) diff --git a/subplot/vendored/runcmd.yaml b/subplot/vendored/runcmd.yaml new file mode 100644 index 0000000..48dde90 --- /dev/null +++ b/subplot/vendored/runcmd.yaml @@ -0,0 +1,83 @@ +# Steps to run commands. + +- given: helper script {filename} for runcmd + function: runcmd_helper_script + +- given: srcdir is in the PATH + function: runcmd_helper_srcdir_path + +- when: I run (?P\S+)(?P.*) + regex: true + function: runcmd_step + +- when: I try to run (?P\S+)(?P.*) + regex: true + function: runcmd_try_to_run + +# Steps to examine exit code of latest command. + +- then: exit code is {exit} + function: runcmd_exit_code_is + +- then: exit code is not {exit} + function: runcmd_exit_code_is_not + +- then: command is successful + function: runcmd_exit_code_is_zero + +- then: command fails + function: runcmd_exit_code_is_nonzero + +# Steps to examine stdout/stderr for exact content. + +- then: stdout is exactly "(?P.*)" + regex: true + function: runcmd_stdout_is + +- then: "stdout isn't exactly \"(?P.*)\"" + regex: true + function: runcmd_stdout_isnt + +- then: stderr is exactly "(?P.*)" + regex: true + function: runcmd_stderr_is + +- then: "stderr isn't exactly \"(?P.*)\"" + regex: true + function: runcmd_stderr_isnt + +# Steps to examine stdout/stderr for sub-strings. + +- then: stdout contains "(?P.*)" + regex: true + function: runcmd_stdout_contains + +- then: "stdout doesn't contain \"(?P.*)\"" + regex: true + function: runcmd_stdout_doesnt_contain + +- then: stderr contains "(?P.*)" + regex: true + function: runcmd_stderr_contains + +- then: "stderr doesn't contain \"(?P.*)\"" + regex: true + function: runcmd_stderr_doesnt_contain + +# Steps to match stdout/stderr against regular expressions. + +- then: stdout matches regex (?P.*) + regex: true + function: runcmd_stdout_matches_regex + +- then: stdout doesn't match regex (?P.*) + regex: true + function: runcmd_stdout_doesnt_match_regex + +- then: stderr matches regex (?P.*) + regex: true + function: runcmd_stderr_matches_regex + +- then: stderr doesn't match regex (?P.*) + regex: true + function: runcmd_stderr_doesnt_match_regex -- cgit v1.2.1