From 48c93654519c9dd029e262d9fb6bbd5ab3e78a6d Mon Sep 17 00:00:00 2001 From: Takumasa Sakao Date: Fri, 16 Aug 2024 23:30:55 +0900 Subject: [PATCH] Implement save & lookback feature --- .config/config.json5 | 3 +- Cargo.lock | 67 +++++++++- Cargo.toml | 6 +- src/app.rs | 110 +++++++++++++--- src/cli.rs | 26 +++- src/components/execution_result.rs | 12 ++ src/components/home.rs | 12 +- src/components/interval.rs | 1 - src/components/status.rs | 44 ++++--- src/config.rs | 12 +- src/main.rs | 44 ++++++- src/runner.rs | 33 ++--- src/store.rs | 19 ++- src/store/memory.rs | 46 +++++-- src/store/sqlite.rs | 194 +++++++++++++++++++++++++++++ src/types.rs | 18 +++ 16 files changed, 562 insertions(+), 85 deletions(-) create mode 100644 src/store/sqlite.rs diff --git a/.config/config.json5 b/.config/config.json5 index 6883563..02ef7a6 100644 --- a/.config/config.json5 +++ b/.config/config.json5 @@ -55,7 +55,8 @@ "border": "rgb111", "title": "rgb111", "scrollbar": "rgb111", - "search_highlight": "black on yellow" + "search_highlight": "black on yellow", + "readonly": "bold yellow" } } } diff --git a/Cargo.lock b/Cargo.lock index 81a73eb..5e3f900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -758,6 +758,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + [[package]] name = "futures" version = "0.3.30" @@ -1973,6 +1979,34 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + [[package]] name = "ratatui" version = "0.26.3" @@ -1994,6 +2028,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + [[package]] name = "redox_syscall" version = "0.5.3" @@ -2058,6 +2101,15 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "ron" version = "0.8.1" @@ -2077,6 +2129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ "bitflags", + "chrono", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2400,6 +2453,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand", + "remove_dir_all", +] + [[package]] name = "tempfile" version = "3.12.0" @@ -2809,7 +2872,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "viddy" -version = "1.0.0-rc.0" +version = "1.0.0-rc.2" dependencies = [ "ansi-parser", "ansi-to-tui", @@ -2841,6 +2904,8 @@ dependencies = [ "similar", "strip-ansi-escapes", "strum", + "tempdir", + "tempfile", "tokio", "tokio-util", "toml", diff --git a/Cargo.toml b/Cargo.toml index 939ed30..09f9a07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "viddy" -version = "1.0.0-rc.0" +version = "1.0.0-rc.2" edition = "2021" description = "A modern watch command" @@ -32,7 +32,7 @@ libc = "0.2.155" log = "0.4.21" pretty_assertions = "1.4.0" ratatui = { version = "0.26.3", features = ["serde", "macros"] } -rusqlite = { version = "0.32.1", features = ["bundled"] } +rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] } serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.117" serde_with = { version = "3.8.1", features = ["chrono_0_4"] } @@ -40,6 +40,8 @@ signal-hook = "0.3.17" similar = "2.5.0" strip-ansi-escapes = "0.2.0" strum = { version = "0.26.2", features = ["derive"] } +tempdir = "0.3.7" +tempfile = "3.12.0" tokio = { version = "1.38.0", features = ["full"] } tokio-util = "0.7.11" toml = "0.8.19" diff --git a/src/app.rs b/src/app.rs index 5b1e672..e8157ed 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use core::time; use std::sync::Arc; use anstyle::{Color, RgbColor, Style}; @@ -13,10 +14,21 @@ use tokio::{ use tracing_subscriber::field::debug; use crate::{ - action::{self, Action, DiffMode}, cli::Cli, components::{fps::FpsCounter, home::Home, Component}, config::{Config, RuntimeConfig}, diff::{diff_and_mark, diff_and_mark_delete}, mode::Mode, old_config::OldConfig, runner::{run_executor, run_executor_precise}, search::search_and_mark, store::Store, termtext, tui, types::ExecutionId + action::{self, Action, DiffMode}, + cli::Cli, + components::{fps::FpsCounter, home::Home, Component}, + config::{Config, RuntimeConfig}, + diff::{diff_and_mark, diff_and_mark_delete}, + mode::Mode, + old_config::OldConfig, + runner::{run_executor, run_executor_precise}, + search::search_and_mark, + store::{self, RuntimeConfig as StoreRuntimeConfig, Store}, + termtext, tui, + types::ExecutionId, }; -pub struct App { +pub struct App { pub config: Config, pub runtime_config: RuntimeConfig, pub tick_rate: f64, @@ -28,7 +40,6 @@ pub struct App { pub last_tick_key_events: Vec, pub timemachine_mode: bool, pub search_query: Option, - store: S, is_precise: bool, diff_mode: Option, is_suspend: Arc>, @@ -38,14 +49,39 @@ pub struct App { is_skip_empty_diffs: bool, showing_execution_id: Option, shell: Option<(String, Vec)>, + store: S, + read_only: bool, } -impl App { - pub fn new(cli: Cli, store: S) -> Result { - let runtime_config = RuntimeConfig { - interval: cli.interval, - command: cli.command, +impl App { + pub fn new(cli: Cli, mut store: S, read_only: bool) -> Result { + let runtime_config = if read_only { + let store_runtime_config = store.get_runtime_config()?.unwrap_or_default(); + + RuntimeConfig { + interval: Duration::from_std(humantime::parse_duration( + &store_runtime_config.interval, + )?)?, + command: store_runtime_config + .command + .split(' ') + .map(|s| s.to_string()) + .collect(), + } + } else { + let runtime_config = RuntimeConfig { + interval: cli.interval, + command: cli.command.clone(), + }; + + let interval = + humantime::format_duration(cli.interval.to_std().unwrap_or_default()).to_string(); + let command = cli.command.join(" "); + store.set_runtime_config(StoreRuntimeConfig { interval, command })?; + + runtime_config }; + let diff_mode = match (cli.is_diff, cli.is_deletion_diff) { (true, false) => Some(DiffMode::Add), (false, true) => Some(DiffMode::Delete), @@ -81,6 +117,7 @@ impl App { )) }; + let timemachine_mode = false; let home = Home::new( config.clone(), runtime_config.clone(), @@ -88,13 +125,14 @@ impl App { diff_mode, cli.is_bell, cli.is_no_title, + read_only, + timemachine_mode, ); let mut components: Vec> = vec![Box::new(home)]; if cli.is_debug { components.push(Box::new(FpsCounter::new())); } - log::debug!("{:?}", config.general); let default_skip_empty_diffs = config.general.skip_empty_diffs.unwrap_or_default(); let is_skip_empty_diffs = cli.is_skip_empty_diffs || default_skip_empty_diffs; @@ -104,12 +142,13 @@ impl App { frame_rate: 20.0, components, should_quit: false, + read_only, should_suspend: false, config, runtime_config, mode: Mode::All, last_tick_key_events: Vec::new(), - timemachine_mode: false, + timemachine_mode, search_query: None, is_precise: cli.is_precise, is_bell: cli.is_bell, @@ -130,7 +169,27 @@ impl App { pub async fn run(&mut self) -> Result<()> { let (action_tx, mut action_rx) = mpsc::unbounded_channel(); - let executor_handle = if self.is_precise { + let records = self.store.get_records()?; + for r in records { + action_tx.send(Action::StartExecution(r.id, r.start_time))?; + action_tx.send(Action::FinishExecution( + r.id, + r.end_time, + r.diff, + r.exit_code, + ))?; + } + if self.read_only { + action_tx.send(Action::SetTimemachineMode(true))?; + } + + let executor_handle = if self.read_only { + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::seconds(1).to_std().unwrap()).await; + } + }) + } else if self.is_precise { tokio::spawn(run_executor_precise( action_tx.clone(), self.store.clone(), @@ -262,7 +321,7 @@ impl App { action_tx.send(Action::UpdateHistoryResult(id, diff, exit_code))?; } - if !self.timemachine_mode { + if !self.timemachine_mode && !self.read_only { action_tx.send(Action::ShowExecution(id, id))?; } } @@ -274,7 +333,7 @@ impl App { Action::ShowExecution(id, end_id) => { let style = termtext::convert_to_anstyle(self.config.get_style("background")); - let record = self.store.get_record(id); + let record = self.store.get_record(id)?; let mut string = "".to_string(); if let Some(record) = record { action_tx.send(Action::SetClock(record.start_time))?; @@ -292,7 +351,7 @@ impl App { string = result.plain_text(); if let Some(diff_mode) = self.diff_mode { if let Some(previous_id) = record.previous_id { - let previous_record = self.store.get_record(previous_id); + let previous_record = self.store.get_record(previous_id)?; if let Some(previous_record) = previous_record { let previous_result = termtext::Converter::new(style) .convert(&previous_record.stdout); @@ -336,10 +395,9 @@ impl App { } Action::SetTimemachineMode(timemachine_mode) => { self.timemachine_mode = timemachine_mode; - if !timemachine_mode { - if let Some(latest_id) = self.store.get_latest_id() { - action_tx.send(Action::ShowExecution(latest_id, latest_id))?; - } + if let Some(latest_id) = self.store.get_latest_id()? { + log::debug!("Latest ID: {latest_id}"); + action_tx.send(Action::ShowExecution(latest_id, latest_id))?; } } Action::ExecuteSearch => { @@ -425,6 +483,12 @@ impl App { }; } } + + if executor_handle.is_finished() { + tui.stop()?; + break; + } + if self.should_suspend { tui.suspend()?; action_tx.send(Action::Resume)?; @@ -438,8 +502,14 @@ impl App { break; } } - executor_handle.abort(); tui.exit()?; - Ok(()) + + if !executor_handle.is_finished() { + log::debug!("Waiting for executor to finish"); + executor_handle.abort(); + return Ok(()); + } + + executor_handle.await? } } diff --git a/src/cli.rs b/src/cli.rs index 514a854..5b84dbb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -84,7 +84,7 @@ pub struct Cli { )] pub is_bell: bool, - #[arg(value_name = "COMMAND", num_args(1..), required = true, allow_hyphen_values = true, help = "Command to run")] + #[arg(value_name = "COMMAND", num_args(0..), allow_hyphen_values = true, help = "Command to run")] pub command: Vec, #[arg( @@ -97,6 +97,30 @@ pub struct Cli { #[arg(long = "debug")] pub is_debug: bool, + + #[arg( + long = "save", + value_name = "FILE", + help = "Path to the backup file. If not provided, a temporary file will be created", + conflicts_with_all = ["disable_auto_save", "load"] + )] + pub save: Option, + + #[arg( + long = "disable_auto_save", + help = "Disable to save automatically", + conflicts_with_all = ["save", "load"] + )] + pub disable_auto_save: bool, + + #[arg( + long = "load", + alias = "lookback", + value_name = "FILE", + help = "Path to the backup file", + conflicts_with_all = ["save", "disable_auto_save", "shell", "shell_options", "is_exec", "is_bell", "is_precise", "interval"] + )] + pub load: Option, } fn parse_duration_from_str(s: &str) -> Result { diff --git a/src/components/execution_result.rs b/src/components/execution_result.rs index f236e06..13f4825 100644 --- a/src/components/execution_result.rs +++ b/src/components/execution_result.rs @@ -207,6 +207,18 @@ impl Component for ExecutionResult { } } + if y_scrollable > 0 { + body.width = area.width.saturating_sub(1); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .symbols(scrollbar::VERTICAL) + .style(scroll_style) + .thumb_symbol("║"); + f.render_stateful_widget(scrollbar, area, &mut self.y_state); + if x_max > body.width as usize { + x_scrollable = x_scrollable.saturating_add(1); + } + } + self.y_state = self.y_state.content_length(y_scrollable); self.x_state = self.x_state.content_length(x_scrollable); diff --git a/src/components/home.rs b/src/components/home.rs index 53b2f7b..2c43cb1 100644 --- a/src/components/home.rs +++ b/src/components/home.rs @@ -1,3 +1,4 @@ +use core::time; use std::{collections::HashMap, time::Duration}; use color_eyre::{eyre::Result, owo_colors::OwoColorize}; @@ -14,11 +15,14 @@ use crate::{ action::{Action, DiffMode}, config::{Config, KeyBindings, RuntimeConfig}, mode::Mode, + store::Record, + widget::history_item::HisotryItem, }; pub struct Home { command_tx: Option>, config: Config, + runtime_config: RuntimeConfig, is_no_title: bool, mode: Mode, @@ -34,6 +38,7 @@ pub struct Home { } impl Home { + #[allow(clippy::too_many_arguments)] pub fn new( config: Config, runtime_config: RuntimeConfig, @@ -41,8 +46,11 @@ impl Home { diff_mode: Option, is_bell: bool, is_no_title: bool, + read_only: bool, + timemachine_mode: bool, ) -> Self { Self { + runtime_config: runtime_config.clone(), command_tx: None, config: config.clone(), is_no_title, @@ -53,9 +61,9 @@ impl Home { execution_result_component: ExecutionResult::new(is_fold), history_component: History::new(runtime_config.clone()), prompt_component: Prompt::new(), - status_component: Status::new(is_fold, diff_mode, is_bell), + status_component: Status::new(is_fold, diff_mode, is_bell, read_only), help_component: Help::new(config), - timemachine_mode: false, + timemachine_mode, } } diff --git a/src/components/interval.rs b/src/components/interval.rs index 98202e2..44f5c78 100644 --- a/src/components/interval.rs +++ b/src/components/interval.rs @@ -48,7 +48,6 @@ impl Component for Interval { .title("Every") .borders(Borders::ALL) .border_style(self.config.get_style("border")) - // .border_style(Color::Indexed(90)) .title_style(self.config.get_style("title")); let text = humantime::format_duration(self.runtime_config.interval.to_std().unwrap_or_default()) diff --git a/src/components/status.rs b/src/components/status.rs index d209ae5..cd0eae9 100644 --- a/src/components/status.rs +++ b/src/components/status.rs @@ -21,10 +21,11 @@ pub struct Status { diff_mode: Option, is_suspend: bool, is_bell: bool, + read_only: bool, } impl Status { - pub fn new(is_fold: bool, diff_mode: Option, is_bell: bool) -> Self { + pub fn new(is_fold: bool, diff_mode: Option, is_bell: bool, read_only: bool) -> Self { Self { command_tx: None, config: Config::new().unwrap(), @@ -32,6 +33,7 @@ impl Status { diff_mode, is_suspend: false, is_bell, + read_only, } } } @@ -87,22 +89,30 @@ impl Component for Status { } else { status.push(Span::styled(" [D]iff±", disabled_style)); }; - status.push(Span::styled( - " [S]uspend", - if self.is_suspend { - enabled_style - } else { - disabled_style - }, - )); - status.push(Span::styled( - " [B]ell", - if self.is_bell { - enabled_style - } else { - disabled_style - }, - )); + + if !self.read_only { + status.push(Span::styled( + " [S]uspend", + if self.is_suspend { + enabled_style + } else { + disabled_style + }, + )); + status.push(Span::styled( + " [B]ell", + if self.is_bell { + enabled_style + } else { + disabled_style + }, + )); + } else { + status.push(Span::styled( + " Read-only", + self.config.get_style("readonly"), + )); + } let line = Line::raw("").spans(status); let paragraph = Paragraph::new(line).alignment(Alignment::Right); diff --git a/src/config.rs b/src/config.rs index 1b50649..36cc860 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,12 @@ use crate::{ old_config::{General as OldGeneral, OldConfig}, }; +#[derive(Clone, Debug)] +pub struct RuntimeConfig { + pub interval: Duration, + pub command: Vec, +} + const CONFIG: &str = include_str!("../.config/config.json5"); #[derive(Clone, Debug, Deserialize, Default)] @@ -63,12 +69,6 @@ pub struct Config { pub general: General, } -#[derive(Clone, Debug)] -pub struct RuntimeConfig { - pub interval: Duration, - pub command: Vec, -} - impl Config { pub fn new() -> Result { let data_dir = crate::utils::get_data_dir(); diff --git a/src/main.rs b/src/main.rs index 1c49eff..c67750c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,10 +21,15 @@ mod types; pub mod utils; mod widget; +use std::path::PathBuf; + use chrono::Duration; use clap::Parser; use cli::Cli; -use color_eyre::eyre::Result; +use color_eyre::eyre::{eyre, Result}; +use directories::ProjectDirs; +use store::Store; +use tempdir::TempDir; use crate::{ app::App, @@ -38,9 +43,40 @@ async fn tokio_main() -> Result<()> { let args = Cli::parse(); let interval = Duration::from(args.interval); - let store = store::memory::MemoryStore::new(); - let mut app = App::new(args, store)?; - app.run().await?; + + if args.load.is_none() && args.command.is_empty() { + return Err(eyre!("No command provided")); + } + if args.load.is_some() && args.command.len() > 1 { + return Err(eyre!("Can not use --load with command")); + } + + if args.disable_auto_save { + let store = store::memory::MemoryStore::new(); + let mut app = App::new(args, store, false)?; + app.run().await?; + } else if let Some(l) = &args.load { + let store = store::sqlite::SQLiteStore::new(l.clone(), false)?; + let mut app = App::new(args.clone(), store, true)?; + app.run().await?; + } else if let Some(b) = &args.save { + let store = store::sqlite::SQLiteStore::new(b.clone(), true)?; + let mut app = App::new(args.clone(), store, false)?; + app.run().await?; + } else { + let tmp_dir = TempDir::new("viddy")?; + let tmp_path = tmp_dir.into_path(); + let file_path = tmp_path.join("backup.sqlite"); + let store = store::sqlite::SQLiteStore::new(file_path.clone(), true)?; + let mut app = App::new(args.clone(), store, false)?; + app.run().await?; + + println!("Backup saved at {}", file_path.to_str().unwrap()); + println!( + "Run `viddy --lookback {}` to load backup", + file_path.to_str().unwrap() + ); + } Ok(()) } diff --git a/src/runner.rs b/src/runner.rs index f076930..1ed8c32 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,3 +1,4 @@ +use color_eyre::Result; use std::{ops::Sub, sync::Arc}; use dissimilar::{diff, Chunk}; @@ -14,15 +15,17 @@ use crate::{ types::ExecutionId, }; -pub async fn run_executor( +pub async fn run_executor( actions: mpsc::UnboundedSender, mut store: S, runtime_config: RuntimeConfig, shell: Option<(String, Vec)>, is_suspend: Arc>, -) { - let mut counter = 0; +) -> Result<()> { + let latest_id = store.get_latest_id()?; + let mut counter = latest_id.map(|id| id.0 + 1).unwrap_or(0); loop { + counter += 1; if *is_suspend.lock().await { tokio::time::sleep(std::time::Duration::from_secs(1)).await; continue; @@ -46,9 +49,9 @@ pub async fn run_executor( let utf8_stderr = String::from_utf8_lossy(&stderr).to_string(); let end_time = chrono::Local::now(); - let latest_id = store.get_latest_id(); + let latest_id = store.get_latest_id()?; let diff = if let Some(latest_id) = latest_id { - if let Some(record) = store.get_record(latest_id) { + if let Some(record) = store.get_record(latest_id)? { let old_stdout = String::from_utf8_lossy(&record.stdout).to_string(); Some(count_diff(&old_stdout, &utf8_stdout)) } else { @@ -76,27 +79,27 @@ pub async fn run_executor( diff, previous_id: latest_id, }; - store.add_record(record); + store.add_record(record)?; if let Err(e) = actions.send(Action::FinishExecution(id, start_time, diff, exit_code)) { eprintln!("Failed to send result: {:?}", e); } - counter += 1; - tokio::time::sleep(runtime_config.interval.to_std().unwrap()).await; } } -pub async fn run_executor_precise( +pub async fn run_executor_precise( actions: mpsc::UnboundedSender, mut store: S, runtime_config: RuntimeConfig, shell: Option<(String, Vec)>, is_suspend: Arc>, -) { - let mut counter = 0; +) -> Result<()> { + let latest_id = store.get_latest_id()?; + let mut counter = latest_id.map(|id| id.0 + 1).unwrap_or(0); loop { + counter += 1; let start_time = chrono::Local::now(); if *is_suspend.lock().await { tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -120,9 +123,9 @@ pub async fn run_executor_precise( let utf8_stderr = String::from_utf8_lossy(&stderr).to_string(); let end_time = chrono::Local::now(); - let latest_id = store.get_latest_id(); + let latest_id = store.get_latest_id()?; let diff = if let Some(latest_id) = latest_id { - if let Some(record) = store.get_record(latest_id) { + if let Some(record) = store.get_record(latest_id)? { let old_stdout = String::from_utf8_lossy(&record.stdout).to_string(); Some(count_diff(&old_stdout, &utf8_stdout)) } else { @@ -150,14 +153,12 @@ pub async fn run_executor_precise( diff, previous_id: latest_id, }; - store.add_record(record); + store.add_record(record)?; if let Err(e) = actions.send(Action::FinishExecution(id, start_time, diff, exit_code)) { eprintln!("Failed to send result: {:?}", e); } - counter += 1; - let elapased = chrono::Local::now().signed_duration_since(start_time); let sleep_time = runtime_config.interval.sub(elapased); if let Ok(sleep_time) = sleep_time.to_std() { diff --git a/src/store.rs b/src/store.rs index 8d49bcd..cb21760 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,5 +1,7 @@ pub mod memory; +pub mod sqlite; +use color_eyre::eyre::Result; use std::{ collections::HashMap, sync::{Arc, RwLock}, @@ -9,10 +11,13 @@ use chrono::{DateTime, Local}; use crate::types::ExecutionId; -pub trait Store { - fn add_record(&mut self, record: Record); - fn get_record(&self, id: ExecutionId) -> Option; - fn get_latest_id(&self) -> Option; +pub trait Store: Clone + Send + Sync + 'static { + fn add_record(&mut self, record: Record) -> Result<()>; + fn get_record(&self, id: ExecutionId) -> Result>; + fn get_latest_id(&self) -> Result>; + fn get_records(&self) -> Result>; + fn get_runtime_config(&self) -> Result>; + fn set_runtime_config(&mut self, config: RuntimeConfig) -> Result<()>; } #[derive(Debug, Clone)] @@ -26,3 +31,9 @@ pub struct Record { pub diff: Option<(u32, u32)>, pub previous_id: Option, } + +#[derive(Debug, Clone, Default)] +pub struct RuntimeConfig { + pub interval: String, + pub command: String, +} diff --git a/src/store/memory.rs b/src/store/memory.rs index 70b7d5d..adfd6de 100644 --- a/src/store/memory.rs +++ b/src/store/memory.rs @@ -1,18 +1,19 @@ +use crate::store::{Record, RuntimeConfig, Store}; +use crate::types::ExecutionId; +use color_eyre::eyre::Result; use std::collections::HashMap; use std::sync::{Arc, RwLock}; -use crate::types::ExecutionId; -use crate::store::{Record, Store}; - #[derive(Debug)] struct MemoryStoreData { records: HashMap, latest_id: Option, } -#[derive(Clone, Debug)] +#[derive(Debug, Clone)] pub struct MemoryStore { data: Arc>, + runtime_config: Arc>>, } impl MemoryStore { @@ -22,31 +23,56 @@ impl MemoryStore { records: HashMap::new(), latest_id: None, })), + runtime_config: Arc::new(RwLock::new(None)), } } } impl Store for MemoryStore { - fn add_record(&mut self, record: Record) { + fn add_record(&mut self, record: Record) -> Result<()> { if let Ok(mut data) = self.data.write() { data.latest_id = Some(record.id); data.records.insert(record.id, record); } + Ok(()) } - fn get_record(&self, id: ExecutionId) -> Option { - if let Ok(data) = self.data.read() { + fn get_record(&self, id: ExecutionId) -> Result> { + Ok(if let Ok(data) = self.data.read() { data.records.get(&id).cloned() } else { None - } + }) } - fn get_latest_id(&self) -> Option { - if let Ok(data) = self.data.read() { + fn get_latest_id(&self) -> Result> { + Ok(if let Ok(data) = self.data.read() { data.latest_id } else { None + }) + } + + fn get_records(&self) -> Result> { + Ok(if let Ok(data) = self.data.read() { + data.records.values().cloned().collect() + } else { + vec![] + }) + } + + fn get_runtime_config(&self) -> Result> { + Ok(if let Ok(runtime_config) = self.runtime_config.read() { + runtime_config.clone() + } else { + None + }) + } + + fn set_runtime_config(&mut self, config: RuntimeConfig) -> Result<()> { + if let Ok(mut runtime_config) = self.runtime_config.write() { + *runtime_config = Some(config); } + Ok(()) } } diff --git a/src/store/sqlite.rs b/src/store/sqlite.rs new file mode 100644 index 0000000..2054a0f --- /dev/null +++ b/src/store/sqlite.rs @@ -0,0 +1,194 @@ +use chrono::{DateTime, Local, Utc}; +use color_eyre::Result; +use rusqlite::Connection; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use crate::store::{Record, Store}; +use crate::types::ExecutionId; +use crate::widget::history_item::HisotryItem; + +#[derive(Debug, Clone)] +pub struct SQLiteStore { + conn: Arc>, +} + +impl SQLiteStore { + pub fn new(path: PathBuf, init: bool) -> Result { + if init && path.exists() { + std::fs::remove_file(&path)?; + } + + let conn = Connection::open_with_flags( + path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE, + )?; + + if init { + conn.execute( + "CREATE TABLE record ( + id INTEGER PRIMARY KEY, + start_time TEXT NOT NULL, + stdout BLOB NOT NULL, + stderr BLOB NOT NULL, + end_time TEXT NOT NULL, + exit_code INTEGER NOT NULL, + diff_add INTEGER, + diff_delete INTEGER, + previous_id INTEGER + )", + (), + )?; + + conn.execute( + "CREATE TABLE runtime_config ( + interval INTEGER NOT NULL, + command TEXT NOT NULL + )", + (), + )?; + } + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } +} + +impl Store for SQLiteStore { + fn add_record(&mut self, record: Record) -> Result<()> { + if let Ok(conn) = self.conn.lock() { + conn.execute( + "INSERT INTO record ( + id, start_time, stdout, stderr, end_time, exit_code, diff_add, diff_delete, previous_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ( + record.id, + record.start_time.to_utc().to_rfc3339(), + record.stdout, + record.stderr, + record.end_time.to_utc().to_rfc3339(), + record.exit_code, + record.diff.map(|(add, delete)| add as i64), + record.diff.map(|(add, delete)| delete as i64), + record.previous_id, + ), + )?; + Ok(()) + } else { + color_eyre::eyre::bail!("Failed to get connection") + } + } + + fn get_record(&self, id: ExecutionId) -> Result> { + if let Ok(conn) = self.conn.lock() { + let r = conn.query_row("SELECT * FROM record WHERE id = ?1", [id], |row| { + let start_time = row.get::<_, DateTime>(1)?; + let end_time = row.get::<_, DateTime>(4)?; + let diff_add: Option = row.get(6)?; + let diff_delete: Option = row.get(7)?; + let diff = diff_add.zip(diff_delete); + Ok(Record { + id: row.get(0)?, + start_time: start_time.with_timezone(&Local), + stdout: row.get(2)?, + stderr: row.get(3)?, + end_time: end_time.with_timezone(&Local), + exit_code: row.get(5)?, + diff, + previous_id: row.get(8)?, + }) + }); + + match r { + Ok(record) => Ok(Some(record)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } else { + color_eyre::eyre::bail!("Failed to get connection") + } + } + + fn get_latest_id(&self) -> Result> { + if let Ok(conn) = self.conn.lock() { + let r = conn.query_row( + "SELECT id FROM record ORDER BY id DESC LIMIT 1", + [], + |row| row.get(0), + ); + + match r { + Ok(id) => Ok(Some(id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } else { + color_eyre::eyre::bail!("Failed to get connection") + } + } + + fn get_records(&self) -> Result> { + if let Ok(conn) = self.conn.lock() { + let mut stmt = conn.prepare("SELECT * FROM record")?; + let records = stmt + .query_map([], |row| { + let start_time = row.get::<_, DateTime>(1)?; + let end_time = row.get::<_, DateTime>(4)?; + let diff_add: Option = row.get(6)?; + let diff_delete: Option = row.get(7)?; + let diff = diff_add.zip(diff_delete); + Ok(Record { + id: row.get(0)?, + start_time: start_time.with_timezone(&Local), + stdout: row.get(2)?, + stderr: row.get(3)?, + end_time: end_time.with_timezone(&Local), + exit_code: row.get(5)?, + diff, + previous_id: row.get(8)?, + }) + })? + .collect::>>()?; + Ok(records) + } else { + color_eyre::eyre::bail!("Failed to get connection") + } + } + + fn get_runtime_config(&self) -> Result> { + if let Ok(conn) = self.conn.lock() { + let r = conn.query_row( + "SELECT * FROM runtime_config ORDER BY ROWID DESC LIMIT 1", + [], + |row| { + Ok(crate::store::RuntimeConfig { + interval: row.get(0)?, + command: row.get(1)?, + }) + }, + ); + + match r { + Ok(config) => Ok(Some(config)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } else { + color_eyre::eyre::bail!("Failed to get connection") + } + } + + fn set_runtime_config(&mut self, config: crate::store::RuntimeConfig) -> Result<()> { + if let Ok(conn) = self.conn.lock() { + conn.execute( + "INSERT INTO runtime_config (interval, command) VALUES (?1, ?2)", + (config.interval, config.command), + )?; + Ok(()) + } else { + color_eyre::eyre::bail!("Failed to get connection") + } + } +} diff --git a/src/types.rs b/src/types.rs index f0dac64..3bf29ec 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,9 @@ use std::fmt::{self, Display, Formatter}; +use rusqlite::{ + types::{FromSql, ToSql, ToSqlOutput}, + Result, +}; use serde::{Deserialize, Serialize}; #[derive(Debug, Copy, PartialEq, Clone, Eq, Hash, Serialize, Deserialize)] @@ -10,3 +14,17 @@ impl Display for ExecutionId { write!(f, "{}", self.0) } } + +impl ToSql for ExecutionId { + fn to_sql(&self) -> Result> { + Ok(ToSqlOutput::Owned(rusqlite::types::Value::Integer( + i64::from(self.0), + ))) + } +} + +impl FromSql for ExecutionId { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + value.as_i64().map(|v| ExecutionId(v as u32)) + } +}