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. --- 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 +++++++++++++++++++++++++++++++ 6 files changed, 292 insertions(+), 128 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 (limited to 'src') 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, +} -- cgit v1.2.1