#![warn(
bad_style,
dead_code,
future_incompatible,
illegal_floating_point_literal_pattern,
improper_ctypes,
late_bound_lifetime_arguments,
missing_copy_implementations,
missing_debug_implementations,
missing_docs,
no_mangle_generic_items,
non_shorthand_field_patterns,
nonstandard_style,
overflowing_literals,
path_statements,
patterns_in_fns_without_body,
private_in_public,
proc_macro_derive_resolution_fallback,
renamed_and_removed_lints,
rust_2018_compatibility,
rust_2018_idioms,
stable_features,
trivial_bounds,
trivial_numeric_casts,
type_alias_bounds,
tyvar_behind_raw_pointer,
unaligned_references,
unconditional_recursion,
unreachable_code,
unreachable_patterns,
unstable_features,
unstable_name_collisions,
unused,
unused_comparisons,
unused_import_braces,
unused_lifetimes,
unused_qualifications,
where_clauses_object_safety,
while_true,
clippy::dbg_macro,
rustdoc::broken_intra_doc_links
)]
#![allow(
deref_into_dyn_supertrait,
unreachable_pub,
clippy::collapsible_if,
clippy::derive_partial_eq_without_eq,
clippy::let_and_return,
clippy::let_unit_value,
clippy::new_ret_no_self,
clippy::new_without_default,
clippy::redundant_field_names
)]
mod cap;
mod colors;
mod db;
mod id;
mod line;
mod ops;
mod position;
mod resize;
mod ser;
mod state;
mod tags;
mod tasks;
#[cfg(any(test, feature = "test"))]
pub mod test;
mod ui;
mod view;
pub use crate::cap::DirCap;
pub use crate::state::TaskState;
pub use crate::ui::Config as UiConfig;
pub use crate::ui::State as UiState;
use std::env::args_os;
use std::ffi::OsString;
use std::fs::create_dir_all;
use std::fs::remove_file;
use std::fs::File;
use std::io::stdin;
use std::io::stdout;
use std::io::ErrorKind;
use std::io::Read;
use std::io::Result as IoResult;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::mpsc::channel;
use std::sync::mpsc::Receiver;
use std::sync::mpsc::Sender;
use std::thread;
use anyhow::anyhow;
use anyhow::bail;
use anyhow::Context as _;
use anyhow::Result;
#[cfg(feature = "coredump")]
use cdump::register_panic_handler;
use dirs::cache_dir;
use dirs::config_dir;
use termion::event::Event as TermEvent;
use termion::event::Key;
use termion::input::TermReadEventsAndRaw;
use termion::raw::IntoRawMode;
use termion::screen::IntoAlternateScreen as _;
use tokio::runtime::Builder;
use gui::Renderer;
use gui::Ui;
use crate::resize::receive_window_resizes;
use crate::ui::Event as UiEvent;
use crate::ui::Message;
use crate::ui::Renderer as TermUiRenderer;
use crate::ui::Ui as TermUi;
use crate::ui::UiData as TermUiData;
type FilePath = (PathBuf, OsString);
#[derive(Clone, Debug)]
pub enum Event {
Key(Key, Vec<u8>),
Resize,
}
fn lock_file() -> Result<PathBuf> {
let path = cache_dir()
.ok_or_else(|| anyhow!("unable to determine cache directory"))?
.join("notnow.lock");
Ok(path)
}
fn tasks_root() -> Result<PathBuf> {
Ok(
config_dir()
.ok_or_else(|| anyhow!("unable to determine config directory"))?
.join("notnow")
.join("tasks"),
)
}
fn ui_config() -> Result<FilePath> {
let config_dir = config_dir()
.ok_or_else(|| anyhow!("unable to determine config directory"))?
.join("notnow");
let config_file = OsString::from("notnow.json");
Ok((config_dir, config_file))
}
fn ui_state() -> Result<FilePath> {
let cache_dir = cache_dir()
.ok_or_else(|| anyhow!("unable to determine cache directory"))?
.join("notnow");
let state_file = OsString::from("ui-state.json");
Ok((cache_dir, state_file))
}
fn receive_keys<R>(stdin: R, send_event: Sender<IoResult<Event>>)
where
R: Read + Send + 'static,
{
thread::spawn(move || {
let events = stdin.events_and_raw();
for event in events {
let result = match event {
Ok((TermEvent::Key(key), data)) => Ok(Event::Key(key, data)),
Ok(..) => continue,
Err(err) => Err(err),
};
send_event.send(result).unwrap();
}
});
}
async fn run_loop<R>(
mut ui: Ui<UiEvent, Message>,
renderer: &R,
recv_event: &Receiver<IoResult<Event>>,
) -> Result<()>
where
R: Renderer,
{
'handler: loop {
let mut render = false;
let event = recv_event.recv().unwrap();
for event in Some(event).into_iter().chain(recv_event.try_iter()) {
match event? {
Event::Key(key, _raw) => {
#[cfg(not(feature = "readline"))]
let event = UiEvent::Key(key, ());
#[cfg(feature = "readline")]
let event = UiEvent::Key(key, _raw);
if let Some(event) = ui.handle(event).await {
match event {
UiEvent::Quit => break 'handler,
UiEvent::Updated => render = true,
UiEvent::Key(..) => {},
}
}
},
Event::Resize => render = true,
}
}
if render {
ui.render(renderer);
}
}
Ok(())
}
pub async fn run_prog<R, W>(
in_: R,
out: W,
tasks_root: PathBuf,
ui_config_path: FilePath,
ui_state_path: FilePath,
) -> Result<()>
where
R: Read + Send + 'static,
W: Write,
{
let task_state = TaskState::load(&tasks_root)
.await
.context("failed to load task state")?;
let ui_config_file = ui_config_path.0.join(&ui_config_path.1);
let ui_state_file = ui_state_path.0.join(&ui_state_path.1);
let ui_config = UiConfig::load(&ui_config_file, &task_state)
.await
.context("failed to load UI configuration")?;
let UiConfig {
colors,
toggle_tag,
views,
} = ui_config;
let ui_state = UiState::load(&ui_state_file)
.await
.context("failed to load UI state")?;
let screen = out
.into_alternate_screen()?
.into_raw_mode()
.context("failed to switch program output to raw mode")?;
let renderer =
TermUiRenderer::new(screen, colors).context("failed to instantiate terminal based renderer")?;
let ui_config_dir_cap = DirCap::for_dir(ui_config_path.0).await?;
let ui_config_file = ui_config_path.1;
let ui_state_dir_cap = DirCap::for_dir(ui_state_path.0).await?;
let ui_state_file = ui_state_path.1;
let tasks_root_cap = DirCap::for_dir(tasks_root).await?;
let (ui, _) = Ui::new(
|| {
Box::new(TermUiData::new(
tasks_root_cap,
task_state,
(ui_config_dir_cap, ui_config_file),
(ui_state_dir_cap, ui_state_file),
colors,
toggle_tag,
))
},
|id, cap| Box::new(TermUi::new(id, cap, views, ui_state)),
);
let (send_event, recv_event) = channel();
receive_window_resizes(send_event.clone())
.context("failed to instantiate infrastructure for handling window resize events")?;
receive_keys(in_, send_event);
ui.render(&renderer);
run_loop(ui, &renderer, &recv_event).await
}
fn with_lockfile<F>(lock_file: &Path, force: bool, f: F) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
if let Some(dir) = lock_file.parent() {
let () = create_dir_all(dir)
.with_context(|| format!("failed to create directory {}", dir.display()))?;
}
if force {
let _file = File::options()
.create(true)
.write(true)
.open(lock_file)
.with_context(|| {
format!(
"failed to take ownership of lock file {}",
lock_file.display()
)
});
} else {
let result = File::options().create_new(true).write(true).open(lock_file);
if matches!(&result, Err(err) if err.kind() == ErrorKind::AlreadyExists) {
eprintln!(
"lock file {} already present; is another program instance running?",
lock_file.display()
);
eprintln!("re-run with --force/-f if you are sure that the file is stale");
}
let _file =
result.with_context(|| format!("failed to create lock file {}", lock_file.display()))?;
}
let result = f();
match (result, remove_file(lock_file)) {
(Ok(()), Ok(())) => Ok(()),
(Ok(()), r @ Err(_)) => {
r.with_context(|| format!("failed to remove lock file {}", lock_file.display()))
},
(r @ Err(_), Ok(())) => r,
(r @ Err(_), Err(_)) => {
eprintln!("failed to remove lock file {}", lock_file.display());
r
},
}
}
fn run_now() -> Result<()> {
let ui_config = ui_config()?;
let ui_state = ui_state()?;
let tasks_root = tasks_root()?;
let rt = Builder::new_current_thread()
.build()
.context("failed to instantiate async runtime")?;
let stdin = stdin();
let stdout = stdout();
let future = run_prog(stdin, stdout.lock(), tasks_root, ui_config, ui_state);
rt.block_on(future)
}
fn run_with_args(lock_file: &Path) -> Result<()> {
match args_os().len() {
0 | 1 => with_lockfile(lock_file, false, run_now),
2 if args_os().any(|arg| &arg == "--force" || &arg == "-f") => {
with_lockfile(lock_file, true, run_now)
},
2 if args_os().any(|arg| &arg == "--version" || &arg == "-V") => {
println!("{} {}", env!("CARGO_CRATE_NAME"), env!("NOTNOW_VERSION"));
Ok(())
},
_ => bail!("encountered unsupported number of program arguments"),
}
}
fn run_with_result() -> Result<()> {
#[cfg(feature = "coredump")]
{
let () = register_panic_handler().or_else(|(ctx, err)| {
Err(err)
.context(ctx)
.context("failed to register core dump panic handler")
})?;
}
run_with_args(&lock_file()?)
}
pub fn run() -> i32 {
match run_with_result() {
Ok(_) => 0,
Err(err) => {
eprintln!("{:?}", err);
1
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn lock_file_present() {
let lock_file = NamedTempFile::new().unwrap();
let force = false;
let error = with_lockfile(lock_file.path(), force, || Ok(())).unwrap_err();
assert!(error
.to_string()
.contains(&lock_file.path().display().to_string()));
}
#[test]
fn lock_file_present_force() {
let lock_file = NamedTempFile::new().unwrap();
let force = true;
let () = with_lockfile(lock_file.path(), force, || Ok(())).unwrap();
assert!(!lock_file.path().exists());
}
#[test]
fn lock_file_error_when_present() {
let lock_file = NamedTempFile::new().unwrap();
let force = false;
let error = with_lockfile(lock_file.path(), force, || bail!("42")).unwrap_err();
assert!(error
.to_string()
.contains(&lock_file.path().display().to_string()));
}
#[test]
fn lock_file_not_present() {
let lock_file_path = {
let lock_file = NamedTempFile::new().unwrap();
lock_file.path().to_path_buf()
};
let force = false;
let () = with_lockfile(&lock_file_path, force, || Ok(())).unwrap();
}
#[test]
fn lock_file_error_when_not_present() {
let lock_file_path = {
let lock_file = NamedTempFile::new().unwrap();
lock_file.path().to_path_buf()
};
let force = false;
let error = with_lockfile(&lock_file_path, force, || bail!("42")).unwrap_err();
assert_eq!(&error.to_string(), "42");
}
}