breb 0.5.0

the blog/reblog library and command-line tool
Documentation
use std::{
	env, fs::{self, File}, io::{self, BufReader}, path::{Path, PathBuf}, sync::{Arc, RwLock}, thread::{self, JoinHandle}, time::{Duration, SystemTime},
};

use sha2::Digest;

use crate::{Blog, friendly, live::ProvidedConfig, raw};

pub struct Current {
	pub version: Version,
	pub blog: io::Result<Blog>,
}

impl ProvidedConfig {
	fn path(&self) -> Option<&Path> {
		match self {
			Self::Live(p) => Some(p),
			Self::Static(_) => None,
		}
	}

	fn load(&self) -> io::Result<raw::Config> {
		match self {
			Self::Live(path) => {
				let file = File::open(path)?;
				let friendly: friendly::Config = yaml_serde::from_reader(BufReader::new(file))
					.map_err(|e| io::Error::other(format!("invalid config: {e}")))?;
				let raw = friendly.try_into()
					.map_err(|e| io::Error::other(format!("invalid config: {e}")))?;
				Ok(raw)
			}
			Self::Static(cfg) => Ok(cfg.clone()),
		}
	}
}

/// build the current state just once
pub fn once(cfg: ProvidedConfig, dir: impl Into<PathBuf>) -> Current {
	let dir = dir.into();
	Current {
		version: hash_inputs(cfg.path(), &dir),
		blog: cfg.load().and_then(|c| c.build(&dir)),
	}
}

/// build the current state whenever it changes
///
/// this automatically stops when you lose the last `Arc`.
pub fn watch(
	cfg: ProvidedConfig,
	dir: impl Into<PathBuf>,
) -> (Arc<RwLock<Current>>, JoinHandle<()>) {
	let dir = dir.into();
	let mut version = hash_inputs(cfg.path(), &dir);
	let res_dest = Arc::new(RwLock::new(Current {
		version,
		blog: cfg.load().and_then(|c| c.build(&dir)),
	}));
	let dest = Arc::downgrade(&res_dest);
	let worker = thread::spawn(move || {
		loop {
			let Some(dest) = dest.upgrade() else {
				break;
			};
			thread::sleep(Duration::from_millis(200));

			let new = hash_inputs(cfg.path(), &dir);
			if version == new {
				// no rebuild needed, wait again
				continue;
			}
			version = new;
			let blog = cfg.load().and_then(|c| c.build(&dir));
			*dest.write().unwrap() = Current {
				version,
				blog,
			};
		}
	});

	(res_dest, worker)
}

pub type Version = u64;
type Algorithm = sha2::Sha256;

fn hash_inputs(cfg: Option<&Path>, dir: &Path) -> Version {
	let mut hasher = Algorithm::new();

	// add the binary's own modified time, if possible, purely for my own dev experience --
	// when i update the binary (incl. refresher.js) i want the autoupdate to show that too.
	// requires a little more scaffolding to recompile and restart the binary,
	// but that's just a `cargo watch -- cargo run serve ...`.
	if let Ok(exe) = env::current_exe() {
		add_file(&mut hasher, &exe);
	}

	if let Some(cfg) = cfg {
		add_file(&mut hasher, cfg);
	}

	add_dir(&mut hasher, dir);

	let digest = hasher.finalize();
	const BYTES: usize = Version::BITS as usize / 8;
	assert!(digest.len() >= BYTES);

	Version::from_ne_bytes(digest[..BYTES].try_into().unwrap())
}

fn add_path(hasher: &mut Algorithm, path: &Path) {
	let bytes = path.as_os_str().as_encoded_bytes();
	hasher.update(bytes.len().to_ne_bytes());
	hasher.update(bytes);
}

fn add_dir(hasher: &mut Algorithm, dir: &Path) {
	let Ok(rd) = fs::read_dir(dir) else {
		return;
	};
	let mut entries: Vec<_> = rd
		.into_iter()
		.filter_map(|de| de.ok())
		.filter_map(|de| de.file_type().ok().map(|ft| (ft, de.path())))
		.collect();
	entries.sort_by(|(_, lp), (_, rp)| lp.cmp(rp));
	for (ftype, path) in entries {
		add_path(hasher, &path);
		if ftype.is_file() {
			add_file(hasher, &path);
		} else if ftype.is_dir() {
			add_dir(hasher, &path);
		} else {
			hasher.update(b"non-file, non-dir. /shrug");
		}
	}
}

fn add_file(hasher: &mut Algorithm, file: &Path) {
	let Ok(metadata) = file.metadata() else {
		return;
	};
	hasher.update(metadata.len().to_ne_bytes());
	let Ok(mtime) = metadata.modified() else {
		return;
	};
	match mtime.duration_since(SystemTime::UNIX_EPOCH) {
		Ok(ut) => hasher.update(ut.as_millis().to_ne_bytes()),
		Err(_) => hasher.update(b"some other time?"),
	}
}