use std::{
fs,
io::{self, stderr},
sync::{
atomic::{AtomicBool, Ordering},
mpsc::{self, RecvTimeoutError},
Arc, Mutex,
},
thread::{self, ScopedJoinHandle},
time::{Duration, Instant},
};
use anyhow::Context;
use args::Args;
use camino::{Utf8Path, Utf8PathBuf};
use chrono::Local;
use crossterm::{
event::{KeyCode, KeyModifiers},
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
LeaveAlternateScreen,
},
ExecutableCommand,
};
use directories::ProjectDirs;
use log::{debug, error, info, trace, LevelFilter};
use rand::{rng, seq::SliceRandom};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::Size,
style::Stylize,
widgets::Widget,
CompletedFrame, Terminal,
};
use redu::{
cache::{self, filetree::SizeTree, Cache, Migrator},
reporter::{Counter, NullReporter, Reporter, TermReporter},
restic::{self, escape_for_exclude, Restic, Snapshot},
};
use scopeguard::defer;
use simplelog::{ThreadLogMode, WriteLogger};
use thiserror::Error;
use util::snapshot_short_id;
use crate::ui::{Action, App, Event};
mod args;
mod ui;
mod util;
macro_rules! info_report {
($reporter:expr, $($arg:expr),+) => {{
let msg = format!($($arg),+);
$reporter.print(&msg);
info!("{msg}");
}};
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
let restic = Restic::new(args.repository, args.password, args.no_cache);
let dirs = ProjectDirs::from("eu", "drdo", "redu")
.expect("unable to determine project directory");
let log_config = simplelog::ConfigBuilder::new()
.set_target_level(LevelFilter::Error)
.set_thread_mode(ThreadLogMode::Names)
.build();
if args.non_interactive {
WriteLogger::init(args.log_level, log_config, stderr())?;
} else {
fn generate_filename() -> String {
format!("{}.log", Local::now().format("%Y-%m-%dT%H-%M-%S%.f%z"))
}
let mut path = dirs.data_local_dir().to_path_buf();
path.push(Utf8Path::new("logs"));
fs::create_dir_all(&path)?;
path.push(generate_filename());
let file = loop {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
path.set_file_name(generate_filename())
}
x => break x,
}
}?;
eprintln!("Logging to {:#?}", path);
WriteLogger::init(args.log_level, log_config, file)?;
}
unsafe {
rusqlite::trace::config_log(Some(|code, msg| {
error!(target: "sqlite", "({code}) {msg}");
}))?;
}
let reporter: Arc<dyn Reporter + Send + Sync> = if args.non_interactive {
Arc::new(NullReporter::new())
} else {
Arc::new(TermReporter::new())
};
let mut cache = {
let progress = reporter.add_loader(0, "Getting restic config");
let repo_id = restic.config()?.id;
progress.end();
let cache_file = {
let mut path = dirs.cache_dir().to_path_buf();
path.push(format!("{repo_id}.db"));
path
};
let err_msg = format!(
"unable to create cache directory at {}",
dirs.cache_dir().to_string_lossy(),
);
fs::create_dir_all(dirs.cache_dir()).expect(&err_msg);
info_report!(reporter, "Using cache file {cache_file:#?}");
let migrator =
Migrator::open(&cache_file).context("unable to open cache file")?;
if let Some((old, new)) = migrator.need_to_migrate() {
info_report!(
reporter,
"Need to upgrade cache version from {old:?} to {new:?}"
);
let mut msg = String::from("Upgrading cache version");
if migrator.resync_necessary() {
msg.push_str(" (a resync will be necessary)");
}
let progress = reporter.add_loader(0, &msg);
let cache = migrator.migrate().context("cache migration failed")?;
progress.end();
cache
} else {
migrator.migrate().context("there is a problem with the cache")?
}
};
sync_snapshots(&restic, &mut cache, reporter.clone(), args.parallelism)?;
if args.non_interactive {
info_report!(reporter, "Finished syncing");
} else {
let paths = ui(&*reporter, cache)?;
for line in paths {
println!("{}", escape_for_exclude(line.as_str()));
}
}
Ok(())
}
fn sync_snapshots<R: Reporter + Send + Sync + ?Sized>(
restic: &Restic,
cache: &mut Cache,
reporter: Arc<R>,
fetching_thread_count: usize,
) -> anyhow::Result<()> {
let progress = reporter.add_loader(0, "Fetching repository snapshot list");
let repo_snapshots = restic.snapshots()?;
progress.end();
let cache_snapshots = cache.get_snapshots()?;
let snapshots_to_delete: Vec<&Snapshot> = cache_snapshots
.iter()
.filter(|cache_snapshot| {
!repo_snapshots
.iter()
.any(|repo_snapshot| cache_snapshot.id == repo_snapshot.id)
})
.collect();
if !snapshots_to_delete.is_empty() {
info_report!(
reporter,
"Need to delete {} snapshot(s)",
snapshots_to_delete.len()
);
let mut bar = reporter.add_bar(
0,
"Deleting snapshots ",
snapshots_to_delete.len() as u64,
);
for snapshot in snapshots_to_delete {
cache.delete_snapshot(&snapshot.id)?;
info!("deleted snapshot {}", snapshot.id);
bar.inc(1);
}
bar.end();
}
let mut missing_snapshots: Vec<Snapshot> = repo_snapshots
.into_iter()
.filter(|repo_snapshot| {
!cache_snapshots
.iter()
.any(|cache_snapshot| cache_snapshot.id == repo_snapshot.id)
})
.collect();
missing_snapshots.shuffle(&mut rng());
let total_missing_snapshots = match missing_snapshots.len() {
0 => {
info_report!(reporter, "Snapshots up to date");
return Ok(());
}
n => {
info_report!(reporter, "Need to fetch {n} snapshot(s)");
n
}
};
let missing_queue = FixedSizeQueue::new(missing_snapshots);
let fetch_snapshots_bar = reporter.add_bar(
0,
"Fetching snapshots ",
total_missing_snapshots as u64,
);
const SHOULD_QUIT_POLL_PERIOD: Duration = Duration::from_millis(500);
thread::scope(|scope| {
macro_rules! spawn {
($name_fmt:literal, $scope:expr, $thunk:expr) => {
thread::Builder::new()
.name(format!($name_fmt))
.spawn_scoped($scope, $thunk)?
};
}
let mut handles: Vec<ScopedJoinHandle<anyhow::Result<()>>> = Vec::new();
let should_quit: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
let (snapshot_sender, snapshot_receiver) =
mpsc::sync_channel::<(Snapshot, SizeTree)>(fetching_thread_count);
for i in 0..fetching_thread_count {
let missing_queue = missing_queue.clone();
let snapshot_sender = snapshot_sender.clone();
let reporter = reporter.clone();
let should_quit = should_quit.clone();
handles.push(spawn!("fetching-{i}", &scope, move || {
fetching_thread_body(
restic,
missing_queue,
reporter,
snapshot_sender,
should_quit.clone(),
)
.inspect_err(|_| should_quit.store(true, Ordering::SeqCst))
.map_err(anyhow::Error::from)
}));
}
drop(snapshot_sender);
handles.push({
let reporter = reporter.clone();
let should_quit = should_quit.clone();
spawn!("db", &scope, move || {
db_thread_body(
cache,
&*reporter,
fetch_snapshots_bar,
snapshot_receiver,
should_quit.clone(),
SHOULD_QUIT_POLL_PERIOD,
)
.inspect_err(|_| should_quit.store(true, Ordering::SeqCst))
.map_err(anyhow::Error::from)
})
});
for handle in handles {
handle.join().unwrap()?
}
Ok(())
})
}
#[derive(Debug, Error)]
#[error("error in fetching thread")]
enum FetchingThreadError {
ResticLaunch(#[from] restic::LaunchError),
Restic(#[from] restic::Error),
Cache(#[from] rusqlite::Error),
}
fn fetching_thread_body<R: Reporter + ?Sized>(
restic: &Restic,
missing_queue: FixedSizeQueue<Snapshot>,
reporter: Arc<R>,
snapshot_sender: mpsc::SyncSender<(Snapshot, SizeTree)>,
should_quit: Arc<AtomicBool>,
) -> Result<(), FetchingThreadError> {
defer! { trace!("terminated") }
trace!("started");
while let Some(snapshot) = missing_queue.pop() {
let short_id = snapshot_short_id(&snapshot.id);
let mut progress = reporter.add_counter(
4,
&format!("fetching {short_id} "),
" file(s)",
);
let mut sizetree = SizeTree::new();
let files = restic.ls(&snapshot.id)?;
trace!("started fetching snapshot ({short_id})");
let start = Instant::now();
for r in files {
if should_quit.load(Ordering::SeqCst) {
return Ok(());
}
let file = r?;
sizetree
.insert(file.path.components(), file.size)
.expect("repeated entry in restic snapshot ls");
progress.inc(1);
}
progress.end();
info!(
"snapshot fetched in {}s ({short_id})",
start.elapsed().as_secs_f64()
);
if should_quit.load(Ordering::SeqCst) {
return Ok(());
}
let start = Instant::now();
snapshot_sender.send((snapshot.clone(), sizetree)).unwrap();
debug!(
"waited {}s to send snapshot ({short_id})",
start.elapsed().as_secs_f64()
);
}
Ok(())
}
#[derive(Debug, Error)]
#[error("error in db thread")]
enum DBThreadError {
CacheError(#[from] rusqlite::Error),
}
fn db_thread_body<R: Reporter + ?Sized>(
cache: &mut Cache,
reporter: &R,
mut fetch_snapshots_bar: Box<dyn Counter>,
snapshot_receiver: mpsc::Receiver<(Snapshot, SizeTree)>,
should_quit: Arc<AtomicBool>,
should_quit_poll_period: Duration,
) -> Result<(), DBThreadError> {
defer! { trace!("terminated") }
trace!("started");
loop {
trace!("waiting for snapshot");
if should_quit.load(Ordering::SeqCst) {
return Ok(());
}
let start = Instant::now();
match snapshot_receiver.recv_timeout(should_quit_poll_period) {
Ok((snapshot, sizetree)) => {
debug!(
"waited {}s to get snapshot",
start.elapsed().as_secs_f64()
);
trace!("got snapshot, saving");
if should_quit.load(Ordering::SeqCst) {
return Ok(());
}
let short_id = snapshot_short_id(&snapshot.id);
let progress =
reporter.add_loader(4, &format!("saving {short_id}"));
let start = Instant::now();
let file_count = cache.save_snapshot(&snapshot, sizetree)?;
progress.end();
fetch_snapshots_bar.inc(1);
info!(
"waited {}s to save snapshot ({} files)",
start.elapsed().as_secs_f64(),
file_count
);
trace!("snapshot saved");
}
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => {
trace!("loop done");
break Ok(());
}
}
}
}
fn convert_event(event: crossterm::event::Event) -> Option<Event> {
use crossterm::event::{Event as TermEvent, KeyEventKind};
use ui::Event::*;
const KEYBINDINGS: &[((KeyModifiers, KeyCode), Event)] = &[
((KeyModifiers::empty(), KeyCode::Left), Left),
((KeyModifiers::empty(), KeyCode::Char('h')), Left),
((KeyModifiers::empty(), KeyCode::Right), Right),
((KeyModifiers::empty(), KeyCode::Char('l')), Right),
((KeyModifiers::empty(), KeyCode::Up), Up),
((KeyModifiers::empty(), KeyCode::Char('k')), Up),
((KeyModifiers::empty(), KeyCode::Down), Down),
((KeyModifiers::empty(), KeyCode::Char('j')), Down),
((KeyModifiers::empty(), KeyCode::PageUp), PageUp),
((KeyModifiers::CONTROL, KeyCode::Char('b')), PageUp),
((KeyModifiers::empty(), KeyCode::PageDown), PageDown),
((KeyModifiers::CONTROL, KeyCode::Char('f')), PageDown),
((KeyModifiers::empty(), KeyCode::Enter), Enter),
((KeyModifiers::empty(), KeyCode::Esc), Exit),
((KeyModifiers::empty(), KeyCode::Char('m')), Mark),
((KeyModifiers::empty(), KeyCode::Char('u')), Unmark),
((KeyModifiers::empty(), KeyCode::Char('c')), UnmarkAll),
((KeyModifiers::empty(), KeyCode::Char('q')), Quit),
((KeyModifiers::empty(), KeyCode::Char('g')), Generate),
];
match event {
TermEvent::Resize(w, h) => Some(Resize(Size::new(w, h))),
TermEvent::Key(event) if event.kind == KeyEventKind::Press => {
KEYBINDINGS.iter().find_map(|((mods, code), ui_event)| {
if event.modifiers == *mods && event.code == *code {
Some(ui_event.clone())
} else {
None
}
})
}
_ => None,
}
}
fn ui<R: Reporter + ?Sized>(
reporter: &R,
mut cache: Cache,
) -> anyhow::Result<Vec<Utf8PathBuf>> {
let entries = cache.get_entries(None)?;
if entries.is_empty() {
info_report!(reporter, "The repository is empty!");
return Ok(vec![]);
}
stderr().execute(EnterAlternateScreen)?;
defer! {
stderr().execute(LeaveAlternateScreen).unwrap();
}
enable_raw_mode()?;
defer! {
disable_raw_mode().unwrap();
}
let mut terminal = Terminal::new(CrosstermBackend::new(stderr()))?;
terminal.clear()?;
let mut app = {
let rect = terminal.size()?;
App::new(
rect,
None,
Utf8PathBuf::new(),
entries,
cache.get_marks()?,
vec![
"Enter".bold(),
":Details ".into(),
"m".bold(),
":Mark ".into(),
"u".bold(),
":Unmark ".into(),
"c".bold(),
":ClearAllMarks ".into(),
"g".bold(),
":Generate ".into(),
"q".bold(),
":Quit".into(),
],
)
};
render(&mut terminal, &app)?;
loop {
let mut o_event = convert_event(crossterm::event::read()?);
while let Some(event) = o_event {
o_event = match app.update(event) {
Action::Nothing => None,
Action::Render => {
render(&mut terminal, &app)?;
None
}
Action::Quit => return Ok(vec![]),
Action::Generate(paths) => return Ok(paths),
Action::GetParentEntries(path_id) => {
let parent_id = cache.get_parent_id(path_id)?
.expect("The UI requested a GetParentEntries with a path_id that does not exist");
let entries = cache.get_entries(parent_id)?;
Some(Event::Entries { path_id: parent_id, entries })
}
Action::GetEntries(path_id) => {
let entries = cache.get_entries(path_id)?;
Some(Event::Entries { path_id, entries })
}
Action::GetEntryDetails(path_id) =>
Some(Event::EntryDetails(cache.get_entry_details(path_id)?
.expect("The UI requested a GetEntryDetails with a path_id that does not exist"))),
Action::UpsertMark(path) => {
cache.upsert_mark(&path)?;
Some(Event::Marks(cache.get_marks()?))
}
Action::DeleteMark(loc) => {
cache.delete_mark(&loc).unwrap();
Some(Event::Marks(cache.get_marks()?))
}
Action::DeleteAllMarks => {
cache.delete_all_marks()?;
Some(Event::Marks(Vec::new()))
}
}
}
}
}
fn render<'a, B: Backend>(
terminal: &'a mut Terminal<B>,
app: &App,
) -> Result<CompletedFrame<'a>, B::Error> {
terminal.draw(|frame| {
let area = frame.area();
let buf = frame.buffer_mut();
app.render(area, buf)
})
}
#[derive(Clone)]
struct FixedSizeQueue<T>(Arc<Mutex<Vec<T>>>);
impl<T> FixedSizeQueue<T> {
fn new(data: Vec<T>) -> Self {
FixedSizeQueue(Arc::new(Mutex::new(data)))
}
fn pop(&self) -> Option<T> {
self.0.lock().unwrap().pop()
}
}