feat: initial commit, add fs revision storage and reporter

This commit is contained in:
2026-02-02 00:22:51 -08:00
commit c48880d0ca
9 changed files with 1228 additions and 0 deletions

107
src/archive.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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")
}