2026-07-20 09:04:57 +07:00
|
|
|
//! Utility helpers inlined from `zesdex-utils` — safe integer casts,
|
|
|
|
|
//! atomic JSON file writing, and slugification.
|
|
|
|
|
//!
|
|
|
|
|
//! # Inlining rationale
|
|
|
|
|
//!
|
|
|
|
|
//! These helpers are ported from `zesdex-utils` to avoid a hard
|
|
|
|
|
//! dependency on that crate during the clean-architecture migration.
|
|
|
|
|
//! Once `zesdex-utils` is fully migrated, these can be re-exported
|
|
|
|
|
//! or removed.
|
|
|
|
|
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// CastOr — safe integer narrowing
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Extension trait for checked integer narrowing with a fallback default.
|
|
|
|
|
///
|
|
|
|
|
/// Implementations use `U::try_from(self).unwrap_or(default)` so overflow
|
|
|
|
|
/// never panics.
|
|
|
|
|
pub trait CastOr<U> {
|
|
|
|
|
fn cast_or(self, default: U) -> U;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
macro_rules! impl_cast_or {
|
|
|
|
|
($from:ty => $($to:ty),+ $(,)?) => {
|
|
|
|
|
$(
|
|
|
|
|
impl CastOr<$to> for $from {
|
|
|
|
|
#[inline]
|
|
|
|
|
fn cast_or(self, default: $to) -> $to {
|
|
|
|
|
<$to as TryFrom<$from>>::try_from(self).unwrap_or(default)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)+
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl_cast_or!(usize => u64, i64, u32, i32, u16);
|
|
|
|
|
impl_cast_or!(u64 => i64, u32, i32, u16, u8);
|
|
|
|
|
impl_cast_or!(i64 => u64, i32, u16, u8);
|
|
|
|
|
impl_cast_or!(u32 => i32, u16, u8);
|
|
|
|
|
|
|
|
|
|
impl CastOr<u64> for u128 {
|
|
|
|
|
#[inline]
|
|
|
|
|
fn cast_or(self, default: u64) -> u64 {
|
|
|
|
|
u64::try_from(self).unwrap_or(default)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CastOr<i64> for u128 {
|
|
|
|
|
#[inline]
|
|
|
|
|
fn cast_or(self, default: i64) -> i64 {
|
|
|
|
|
i64::try_from(self).unwrap_or(default)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CastOr<u32> for u128 {
|
|
|
|
|
#[inline]
|
|
|
|
|
fn cast_or(self, default: u32) -> u32 {
|
|
|
|
|
u32::try_from(self).unwrap_or(default)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Atomic JSON write
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Atomically write serializable `data` to `path`.
|
|
|
|
|
///
|
2026-07-20 12:42:53 +07:00
|
|
|
/// Flow: serialize to pretty JSON -> write to `path.tmp.<uuid>` -> fsync -> rename
|
2026-07-20 09:04:57 +07:00
|
|
|
/// -> fsync parent. If `mode` is `Some`, set permissions before rename (Unix only).
|
2026-07-20 12:42:53 +07:00
|
|
|
///
|
|
|
|
|
/// A UUID-based temporary filename avoids collisions from concurrent writes.
|
|
|
|
|
/// Orphaned tmp files are cleaned up on any error after creation.
|
2026-07-20 09:04:57 +07:00
|
|
|
pub fn write_json_atomic<T: Serialize>(path: &Path, data: &T, mode: Option<u32>) -> std::io::Result<()> {
|
2026-07-20 12:42:53 +07:00
|
|
|
let tmp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4()));
|
|
|
|
|
let result = (|| -> std::io::Result<()> {
|
|
|
|
|
let bytes = serde_json::to_vec_pretty(data)
|
|
|
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
2026-07-20 09:04:57 +07:00
|
|
|
{
|
2026-07-20 12:42:53 +07:00
|
|
|
let mut f = std::fs::OpenOptions::new()
|
|
|
|
|
.create(true)
|
|
|
|
|
.truncate(true)
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(&tmp)?;
|
|
|
|
|
f.write_all(&bytes)?;
|
|
|
|
|
f.sync_all()?;
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
2026-07-20 12:42:53 +07:00
|
|
|
if let Some(m) = mode {
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(m))?;
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(unix))]
|
|
|
|
|
{ let _ = m; }
|
|
|
|
|
}
|
|
|
|
|
std::fs::rename(&tmp, path)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
})();
|
|
|
|
|
// Clean up orphaned temp file on error after creation
|
|
|
|
|
if let Err(e) = result {
|
|
|
|
|
let _ = std::fs::remove_file(&tmp);
|
|
|
|
|
return Err(e);
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Slugify
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Convert an arbitrary string into a filesystem-safe slug.
|
|
|
|
|
pub fn slugify(s: &str) -> Option<String> {
|
|
|
|
|
let slug: String = s
|
|
|
|
|
.to_lowercase()
|
|
|
|
|
.chars()
|
|
|
|
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
|
|
|
|
.collect();
|
|
|
|
|
let slug: String = slug
|
|
|
|
|
.split('-')
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("-");
|
|
|
|
|
if slug.is_empty() || slug.len() > 80 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(slug)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Clipboard (OSC-52)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Write `text` to the terminal's clipboard using the OSC-52 escape sequence.
|
|
|
|
|
///
|
|
|
|
|
/// OSC-52 (`\x1b]52;c;<base64>\x1b\\`) is supported by many terminal emulators
|
|
|
|
|
/// (iTerm2, Kitty, tmux, etc.) and allows writing to the system clipboard
|
|
|
|
|
/// without external binaries.
|
|
|
|
|
pub fn write_osc52(output: &mut impl Write, text: &str) -> std::io::Result<()> {
|
|
|
|
|
use base64::Engine as _;
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
|
|
|
|
write!(output, "\x1b]52;c;{encoded}\x1b\\")?;
|
|
|
|
|
output.flush()?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|