feat: initial commit, add fs revision storage and reporter
This commit is contained in:
107
src/archive.rs
Normal file
107
src/archive.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::{
|
||||
fs::{self, DirEntry},
|
||||
io::BufWriter,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use tar::Builder;
|
||||
use tempfile::NamedTempFile;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{
|
||||
Data,
|
||||
error::{Error, Result},
|
||||
reporter::Reporter,
|
||||
storage::{IGNORE_FILES, Storage, base_path_prism},
|
||||
};
|
||||
|
||||
pub struct ArchiveResult {
|
||||
pub temp_file: NamedTempFile,
|
||||
}
|
||||
|
||||
pub fn create_archive(app_state: Data, reporter: &Reporter) -> Result<ArchiveResult> {
|
||||
let entries = get_sync_entries()?;
|
||||
|
||||
let temp_file = match app_state.storage {
|
||||
Storage::FS(_) => {
|
||||
let target = crate::storage::fs::base_path();
|
||||
tempfile::Builder::new()
|
||||
.prefix(".revision_")
|
||||
.suffix(".tar.gz.tmp")
|
||||
.tempfile_in(&target)?
|
||||
}
|
||||
};
|
||||
let file = temp_file.reopen()?;
|
||||
let buffered = BufWriter::with_capacity(64 * 1024, file);
|
||||
let encoder = GzEncoder::new(buffered, Compression::default());
|
||||
let mut archive = Builder::new(encoder);
|
||||
let total_files: u64 = entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
WalkDir::new(&path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.count() as u64
|
||||
} else {
|
||||
1
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
|
||||
reporter.start_archive_progress(total_files);
|
||||
reporter.set_message("creating revision");
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let base_name = entry.file_name();
|
||||
if path.is_dir() {
|
||||
for walk_entry in WalkDir::new(&path) {
|
||||
let walk_entry = walk_entry.map_err(|e| Error::create_archive(e, &path))?;
|
||||
let full_path = walk_entry.path();
|
||||
let relative = Path::new(&base_name).join(full_path.strip_prefix(&path).unwrap());
|
||||
|
||||
if walk_entry.file_type().is_file() {
|
||||
reporter.set_subtle(relative.display().to_string());
|
||||
archive
|
||||
.append_path_with_name(full_path, &relative)
|
||||
.map_err(|e| Error::create_archive(e, &path))?;
|
||||
reporter.inc_archive_progress();
|
||||
} else {
|
||||
archive
|
||||
.append_dir(&relative, full_path)
|
||||
.map_err(|e| Error::create_archive(e, &path))?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
archive
|
||||
.append_path_with_name(&path, &base_name)
|
||||
.map_err(|e| Error::create_archive(e, &path))?;
|
||||
reporter.inc_archive_progress();
|
||||
}
|
||||
}
|
||||
|
||||
archive
|
||||
.finish()
|
||||
.map_err(|e| Error::create_archive(e, &temp_file.path().file_name().unwrap()))?;
|
||||
reporter.finish_archive_progress("created revision");
|
||||
|
||||
Ok(ArchiveResult { temp_file })
|
||||
}
|
||||
|
||||
fn get_sync_entries() -> Result<Vec<DirEntry>> {
|
||||
let base = base_path_prism();
|
||||
let entries = fs::read_dir(&base)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|name| !IGNORE_FILES.contains(&name))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
Ok(entries)
|
||||
}
|
||||
33
src/error.rs
Normal file
33
src/error.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
use thiserror_ext::{Box, Construct};
|
||||
|
||||
#[derive(Error, Box, Debug, Construct)]
|
||||
#[thiserror_ext(newtype(name = Error))]
|
||||
pub enum ErrorKind {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("error parsing revision lockfile")]
|
||||
RevisionLock,
|
||||
#[error("error reading revision lockfile at {path}")]
|
||||
ReadRevision {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("error creating archive at {path}")]
|
||||
CreateArchive {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("error persisting archive to {path}")]
|
||||
PersistError {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: tempfile::PersistError,
|
||||
},
|
||||
}
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
95
src/main.rs
Normal file
95
src/main.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
mod archive;
|
||||
mod error;
|
||||
mod reporter;
|
||||
mod storage;
|
||||
|
||||
use std::{ops::Deref, sync::Arc, time::Duration};
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use indicatif::MultiProgress;
|
||||
use thiserror_ext::AsReport;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::{
|
||||
EnvFilter, fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
reporter::Reporter,
|
||||
storage::{Storage, StorageImpl, get_storage_from_env},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "minecraft-sync", long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// push current state to storage
|
||||
Push,
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
multi: MultiProgress,
|
||||
storage: Storage,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
multi: MultiProgress::new(),
|
||||
storage: get_storage_from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Data(Arc<AppState>);
|
||||
|
||||
impl Deref for Data {
|
||||
type Target = AppState;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Data {
|
||||
pub fn new(inner: AppState) -> Self {
|
||||
Self(Arc::new(inner))
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> crate::error::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let app_state = Data::new(AppState::new());
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Push) => {
|
||||
app_state.storage.store_revision(app_state.clone())?;
|
||||
}
|
||||
_ => Cli::command().print_long_help()?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let tracing_env_filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.compact()
|
||||
.with_span_events(FmtSpan::CLOSE),
|
||||
)
|
||||
.init();
|
||||
if let Err(e) = run() {
|
||||
eprintln!("Error: {}", e.as_report());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
57
src/reporter.rs
Normal file
57
src/reporter.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::{borrow::Cow, time::Duration};
|
||||
|
||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
|
||||
use crate::Data;
|
||||
|
||||
pub struct Reporter {
|
||||
spinner: ProgressBar,
|
||||
}
|
||||
|
||||
pub const TICK_CHARS: &str = "⣷⣯⣟⡿⢿⣻⣽⣾";
|
||||
|
||||
impl Reporter {
|
||||
pub fn new(app_state: Data) -> Self {
|
||||
let spinner = app_state.multi.add(ProgressBar::new_spinner());
|
||||
spinner.set_style(
|
||||
ProgressStyle::with_template("{msg:>8.214/yellow} {spinner} [{elapsed_precise}]")
|
||||
.unwrap()
|
||||
.tick_chars(TICK_CHARS),
|
||||
);
|
||||
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
spinner.set_draw_target(ProgressDrawTarget::stderr_with_hz(20));
|
||||
Self { spinner }
|
||||
}
|
||||
|
||||
pub fn start_archive_progress(&self, total: u64) {
|
||||
self.spinner.set_length(total);
|
||||
self.spinner.set_position(0);
|
||||
self.spinner.set_style(
|
||||
ProgressStyle::with_template(
|
||||
"{msg:>12.214/yellow} {spinner} [{elapsed_precise}] [{bar:30.yellow/blue}] {pos}/{len}\n └─ {prefix:.dim}",
|
||||
)
|
||||
.unwrap()
|
||||
.tick_chars(TICK_CHARS)
|
||||
.progress_chars("█▉▊▋▌▍▎▏ "),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn inc_archive_progress(&self) {
|
||||
self.spinner.inc(1);
|
||||
}
|
||||
|
||||
pub fn set_message(&self, msg: impl Into<std::borrow::Cow<'static, str>>) {
|
||||
self.spinner.set_message(msg);
|
||||
}
|
||||
|
||||
pub fn set_subtle(&self, msg: impl Into<Cow<'static, str>>) {
|
||||
self.spinner.set_prefix(msg);
|
||||
}
|
||||
|
||||
pub fn finish_archive_progress(&self, msg: impl Into<std::borrow::Cow<'static, str>>) {
|
||||
self.spinner.set_style(
|
||||
ProgressStyle::with_template("{msg:>12.green} ✓ [{elapsed_precise}]").unwrap(),
|
||||
);
|
||||
self.spinner.finish_with_message(msg);
|
||||
}
|
||||
}
|
||||
63
src/storage/fs.rs
Normal file
63
src/storage/fs.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use std::fs::DirEntry;
|
||||
use std::io::BufWriter;
|
||||
use std::path::{self, Path};
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use tar::Builder;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::Data;
|
||||
use crate::archive::create_archive;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::reporter::Reporter;
|
||||
use crate::storage::{IGNORE_FILES, StorageImpl, base_path_prism};
|
||||
|
||||
pub struct FSStorage;
|
||||
|
||||
const LOCKFILE_NAME: &str = "revision.lock";
|
||||
|
||||
impl StorageImpl for FSStorage {
|
||||
fn get_revision(&self) -> Result<usize> {
|
||||
let path = base_path().join(LOCKFILE_NAME);
|
||||
let revision = match fs::read_to_string(&path) {
|
||||
Ok(rev) => rev.parse::<usize>().map_err(|_| Error::revision_lock())?,
|
||||
Err(_) => {
|
||||
tracing::debug!("creating directory {:?}", path.parent().unwrap());
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
fs::write(&path, "0").map_err(|e| Error::read_revision(e, &path))?;
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
fn store_revision(&self, app_state: Data) -> Result<()> {
|
||||
let revision = self.get_revision()?;
|
||||
let new_revision = revision + 1;
|
||||
let reporter = Reporter::new(app_state.clone());
|
||||
let result = create_archive(app_state, &reporter)?;
|
||||
let archive_path = base_path().join(&format!("revision_{new_revision}.tar.gz"));
|
||||
|
||||
result
|
||||
.temp_file
|
||||
.persist(&archive_path)
|
||||
.map_err(|e| Error::persist_error(e, &archive_path))?;
|
||||
let lockfile_path = base_path().join(LOCKFILE_NAME);
|
||||
fs::write(&lockfile_path, new_revision.to_string())?;
|
||||
|
||||
tracing::info!(
|
||||
revision = new_revision,
|
||||
path = ?archive_path,
|
||||
"stored revision"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base_path() -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("minecraft-sync")
|
||||
}
|
||||
40
src/storage/mod.rs
Normal file
40
src/storage/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
pub mod fs;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{Data, error::Result};
|
||||
|
||||
pub trait StorageImpl {
|
||||
fn get_revision(&self) -> Result<usize>;
|
||||
fn store_revision(&self, app_state: Data) -> Result<()>;
|
||||
}
|
||||
|
||||
pub enum Storage {
|
||||
FS(fs::FSStorage),
|
||||
}
|
||||
|
||||
pub const IGNORE_FILES: [&str; 6] = ["assets", "cache", "catpacks", "logs", "meta", "metacache"];
|
||||
|
||||
impl StorageImpl for Storage {
|
||||
fn get_revision(&self) -> Result<usize> {
|
||||
match self {
|
||||
Self::FS(storage) => storage.get_revision(),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_revision(&self, app_state: Data) -> Result<()> {
|
||||
match self {
|
||||
Self::FS(storage) => storage.store_revision(app_state),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_storage_from_env() -> Storage {
|
||||
Storage::FS(fs::FSStorage)
|
||||
}
|
||||
|
||||
pub fn base_path_prism() -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("PrismLauncher")
|
||||
}
|
||||
Reference in New Issue
Block a user