chore: cleanups and refactorings for cleaner code

This commit is contained in:
2026-07-18 21:39:16 +02:00
parent b438a99999
commit e5080b7282
63 changed files with 3454 additions and 4093 deletions

View File

@@ -1,6 +1,10 @@
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);
/// Write `content` to `path` atomically: write to a temp file in the same
/// directory, then rename. Creates parent directories if missing.
@@ -8,12 +12,36 @@ pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension("tmp");
let mut file = fs::File::create(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
fs::rename(&tmp_path, path)?;
Ok(())
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
loop {
let mut temp_name = OsString::from(".");
temp_name.push(file_name);
temp_name.push(format!(
".{}.{}.tmp",
std::process::id(),
NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed)
));
let tmp_path = parent.join(temp_name);
let mut file = match fs::File::create_new(&tmp_path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
};
let result = (|| {
file.write_all(content)?;
file.sync_all()?;
drop(file);
fs::rename(&tmp_path, path)
})();
if result.is_err() {
let _ = fs::remove_file(&tmp_path);
}
return result;
}
}
/// Convenience wrapper for UTF-8 string content.
@@ -50,4 +78,27 @@ mod tests {
atomic_write_str(&path, "v2").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
}
#[test]
fn concurrent_sibling_writes_do_not_share_a_temp_file() {
let dir = TempDir::new().unwrap();
let markdown = dir.path().join("a.en.md");
let metadata = dir.path().join("a.en.meta");
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let write = |path: std::path::PathBuf, content: &'static str| {
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
atomic_write_str(&path, content)
})
};
let first = write(markdown.clone(), "markdown");
let second = write(metadata.clone(), "metadata");
first.join().unwrap().unwrap();
second.join().unwrap().unwrap();
assert_eq!(fs::read_to_string(markdown).unwrap(), "markdown");
assert_eq!(fs::read_to_string(metadata).unwrap(), "metadata");
}
}

View File

@@ -7,7 +7,7 @@ pub fn content_hash(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
let result = hasher.finalize();
hex::encode(result)
encode_hex(result)
}
/// Compute a hex-encoded SHA-256 hash of a file by streaming (8 KB chunks).
@@ -22,22 +22,15 @@ pub fn file_hash(path: &Path) -> std::io::Result<String> {
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
Ok(encode_hex(hasher.finalize()))
}
// sha2 doesn't include hex encoding, so we use a tiny inline helper.
mod hex {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
let bytes = bytes.as_ref();
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX_CHARS[(b >> 4) as usize] as char);
s.push(HEX_CHARS[(b & 0xf) as usize] as char);
}
s
}
fn encode_hex(bytes: impl AsRef<[u8]>) -> String {
bytes
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(test)]

View File

@@ -1,5 +1,149 @@
use crate::model::{Post, PostTranslation};
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
use serde::{Deserialize, Deserializer};
fn scalar_string(value: serde_yaml::Value) -> Option<String> {
match value {
serde_yaml::Value::String(value) => Some(value),
serde_yaml::Value::Number(value) => Some(value.to_string()),
serde_yaml::Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
fn deserialize_scalar_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
scalar_string(serde_yaml::Value::deserialize(deserializer)?)
.ok_or_else(|| serde::de::Error::custom("expected a scalar string"))
}
fn deserialize_optional_scalar_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?.and_then(scalar_string))
}
fn deserialize_optional_nonempty_scalar_string<'de, D>(
deserializer: D,
) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_scalar_string(deserializer)?.filter(|value| !value.is_empty()))
}
fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(|value| value.as_str().map(str::to_owned)))
}
fn deserialize_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
serde_yaml::Value::deserialize(deserializer)?
.as_str()
.map(str::to_owned)
.ok_or_else(|| serde::de::Error::custom("expected a string"))
}
fn deserialize_optional_nonempty_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_string(deserializer)?.filter(|value| !value.is_empty()))
}
fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(match serde_yaml::Value::deserialize(deserializer)? {
serde_yaml::Value::Sequence(values) => values
.iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect(),
_ => Vec::new(),
})
}
fn deserialize_timestamp<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: Deserializer<'de>,
{
let value = deserialize_scalar_string(deserializer)?;
iso_to_unix_ms(&value).map_err(serde::de::Error::custom)
}
fn deserialize_optional_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_scalar_string(deserializer)?
.and_then(|value| iso_to_unix_ms(&value).ok()))
}
fn deserialize_optional_string_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_string(deserializer)?.and_then(|value| iso_to_unix_ms(&value).ok()))
}
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(scalar_string)
.is_some_and(|value| value == "true"))
}
fn deserialize_bool_default_true<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(scalar_string)
.is_none_or(|value| value == "true"))
}
fn default_true() -> bool {
true
}
fn default_version() -> i32 {
1
}
fn default_entrypoint() -> String {
"render".to_owned()
}
fn deserialize_version<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(scalar_string)
.and_then(|value| value.parse().ok())
.unwrap_or(1))
}
fn deserialize_entrypoint<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(scalar_string)
.unwrap_or_else(default_entrypoint))
}
/// Split content at `---` delimiters into (yaml, body).
/// Returns `None` if the content does not start with `---`.
@@ -25,21 +169,48 @@ pub fn format_frontmatter(yaml: &str, body: &str) -> String {
// --- Post Frontmatter ---
/// Parsed post frontmatter fields (camelCase for YAML compatibility).
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostFrontmatter {
#[serde(deserialize_with = "deserialize_scalar_string")]
pub id: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub title: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub slug: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub status: String,
#[serde(deserialize_with = "deserialize_timestamp")]
pub created_at: i64,
#[serde(deserialize_with = "deserialize_timestamp")]
pub updated_at: i64,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub tags: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub categories: Vec<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub excerpt: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub author: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub language: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub template_slug: Option<String>,
#[serde(default, deserialize_with = "deserialize_bool")]
pub do_not_translate: bool,
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
pub published_at: Option<i64>,
}
@@ -50,10 +221,7 @@ impl PostFrontmatter {
id: post.id.clone(),
title: post.title.clone(),
slug: post.slug.clone(),
status: serde_json::to_string(&post.status)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
status: post.status.as_str().to_owned(),
created_at: post.created_at,
updated_at: post.updated_at,
tags: post.tags.clone(),
@@ -130,56 +298,7 @@ impl PostFrontmatter {
/// Parse from a YAML string.
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc
.as_mapping()
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
let get_string_list = |key: &str| -> Vec<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
};
let get_timestamp = |key: &str| -> Result<i64, String> {
let s = get_str(key).ok_or(format!("missing required field '{key}'"))?;
iso_to_unix_ms(&s)
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
title: get_str("title").ok_or("missing 'title'")?,
slug: get_str("slug").ok_or("missing 'slug'")?,
status: get_str("status").ok_or("missing 'status'")?,
created_at: get_timestamp("createdAt")?,
updated_at: get_timestamp("updatedAt")?,
tags: get_string_list("tags"),
categories: get_string_list("categories"),
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
author: get_str("author").filter(|s| !s.is_empty()),
language: get_str("language").filter(|s| !s.is_empty()),
template_slug: get_str("templateSlug").filter(|s| !s.is_empty()),
do_not_translate: get_str("doNotTranslate")
.map(|s| s == "true")
.unwrap_or(false),
published_at: get_str("publishedAt").and_then(|s| iso_to_unix_ms(&s).ok()),
})
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
}
}
@@ -199,16 +318,26 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
// --- Translation Frontmatter ---
/// Parsed translation frontmatter.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TranslationFrontmatter {
#[serde(default, deserialize_with = "deserialize_optional_string")]
pub id: Option<String>,
#[serde(deserialize_with = "deserialize_string")]
pub translation_for: String,
#[serde(deserialize_with = "deserialize_string")]
pub language: String,
#[serde(deserialize_with = "deserialize_string")]
pub title: String,
#[serde(default, deserialize_with = "deserialize_optional_nonempty_string")]
pub excerpt: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
pub status: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
pub created_at: Option<i64>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
pub updated_at: Option<i64>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
pub published_at: Option<i64>,
}
@@ -220,12 +349,7 @@ impl TranslationFrontmatter {
language: t.language.clone(),
title: t.title.clone(),
excerpt: t.excerpt.clone(),
status: Some(
serde_json::to_string(&t.status)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
),
status: Some(t.status.as_str().to_owned()),
created_at: Some(t.created_at),
updated_at: Some(t.updated_at),
published_at: t.published_at,
@@ -261,29 +385,7 @@ impl TranslationFrontmatter {
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc
.as_mapping()
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
};
Ok(Self {
id: get_str("id"),
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
language: get_str("language").ok_or("missing 'language'")?,
title: get_str("title").ok_or("missing 'title'")?,
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
status: get_str("status"),
created_at: get_str("createdAt").and_then(|value| iso_to_unix_ms(&value).ok()),
updated_at: get_str("updatedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
published_at: get_str("publishedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
})
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
}
}
@@ -303,16 +405,29 @@ pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, S
// --- Template Frontmatter ---
/// Parsed template frontmatter (double-quoted strings, matching TypeScript output).
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateFrontmatter {
#[serde(deserialize_with = "deserialize_scalar_string")]
pub id: String,
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
pub project_id: Option<String>,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub slug: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub title: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub kind: String,
#[serde(
default = "default_true",
deserialize_with = "deserialize_bool_default_true"
)]
pub enabled: bool,
#[serde(default = "default_version", deserialize_with = "deserialize_version")]
pub version: i32,
#[serde(deserialize_with = "deserialize_timestamp")]
pub created_at: i64,
#[serde(deserialize_with = "deserialize_timestamp")]
pub updated_at: i64,
}
@@ -341,37 +456,7 @@ impl TemplateFrontmatter {
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
project_id: get_str("projectId"),
slug: get_str("slug").ok_or("missing 'slug'")?,
title: get_str("title").ok_or("missing 'title'")?,
kind: get_str("kind").ok_or("missing 'kind'")?,
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
version: get_str("version")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1),
created_at: get_str("createdAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'createdAt'")?,
updated_at: get_str("updatedAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'updatedAt'")?,
})
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
}
}
@@ -390,17 +475,34 @@ pub fn write_template_file(fm: &TemplateFrontmatter, body: &str) -> String {
// --- Script Frontmatter ---
/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint).
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScriptFrontmatter {
#[serde(deserialize_with = "deserialize_scalar_string")]
pub id: String,
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
pub project_id: Option<String>,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub slug: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub title: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub kind: String,
#[serde(
default = "default_entrypoint",
deserialize_with = "deserialize_entrypoint"
)]
pub entrypoint: String,
#[serde(
default = "default_true",
deserialize_with = "deserialize_bool_default_true"
)]
pub enabled: bool,
#[serde(default = "default_version", deserialize_with = "deserialize_version")]
pub version: i32,
#[serde(deserialize_with = "deserialize_timestamp")]
pub created_at: i64,
#[serde(deserialize_with = "deserialize_timestamp")]
pub updated_at: i64,
}
@@ -430,38 +532,7 @@ impl ScriptFrontmatter {
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
project_id: get_str("projectId"),
slug: get_str("slug").ok_or("missing 'slug'")?,
title: get_str("title").ok_or("missing 'title'")?,
kind: get_str("kind").ok_or("missing 'kind'")?,
entrypoint: get_str("entrypoint").unwrap_or_else(|| "render".to_string()),
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
version: get_str("version")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1),
created_at: get_str("createdAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'createdAt'")?,
updated_at: get_str("updatedAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'updatedAt'")?,
})
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
}
}
@@ -551,6 +622,27 @@ mod tests {
assert!(split_frontmatter("no frontmatter here").is_none());
}
#[test]
fn derived_deserialization_preserves_lenient_scalar_coercion() {
let yaml = "id: 42\ntitle: true\nslug: 7\nstatus: false\ncreatedAt: '2026-01-02T03:04:05.000Z'\nupdatedAt: '2026-01-02T03:04:05.000Z'\ntags: [rust, 2]\ncategories: invalid\nauthor: 9\ndoNotTranslate: true";
let parsed = PostFrontmatter::from_yaml(yaml).unwrap();
assert_eq!(parsed.id, "42");
assert_eq!(parsed.title, "true");
assert_eq!(parsed.slug, "7");
assert_eq!(parsed.status, "false");
assert_eq!(parsed.tags, vec!["rust"]);
assert!(parsed.categories.is_empty());
assert_eq!(parsed.author.as_deref(), Some("9"));
assert!(parsed.do_not_translate);
}
#[test]
fn translation_frontmatter_remains_string_only() {
let yaml = "translationFor: 42\nlanguage: en\ntitle: Title";
assert!(TranslationFrontmatter::from_yaml(yaml).is_err());
}
#[test]
fn parse_esmeralda() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");