use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::infra::io::durable::atomic_write;
use crate::platform::error::PlatformError;
pub const CURRENT_LAYOUT_VERSION: u32 = 1;
pub const LAYOUT_FILE_REL: &str = "platform/layout.json";
type MigrationStep = fn(&Path) -> Result<(), PlatformError>;
const MIGRATIONS: &[(u32, MigrationStep)] = &[];
#[derive(Debug, Serialize, Deserialize)]
struct LayoutFile {
version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
created_at: Option<i64>,
}
pub fn open_data_root(data_root: &Path) -> Result<u32, PlatformError> {
let path = layout_file_path(data_root);
let recorded = read_layout_file(&path)?;
let (found_version, created_at) = match recorded {
Some(file) if file.version > CURRENT_LAYOUT_VERSION => {
return Err(PlatformError::new(
"PLATFORM_LAYOUT_NEWER",
format!(
"data root '{}' has layout version {}, but this binary understands \
up to version {}; refusing to touch a newer root — run a newer \
Zebflow binary against it instead",
data_root.display(),
file.version,
CURRENT_LAYOUT_VERSION
),
));
}
Some(file) => (file.version, file.created_at),
None if root_is_populated(data_root)? => (1, None),
None => (CURRENT_LAYOUT_VERSION, None),
};
for (target, step) in MIGRATIONS {
if *target > found_version && *target <= CURRENT_LAYOUT_VERSION {
step(data_root)?;
}
}
if found_version != CURRENT_LAYOUT_VERSION || !path.is_file() {
let stamped = LayoutFile {
version: CURRENT_LAYOUT_VERSION,
created_at: Some(created_at.unwrap_or_else(crate::platform::model::now_ts)),
};
atomic_write(&path, serde_json::to_string_pretty(&stamped)?.as_bytes())?;
}
Ok(found_version)
}
pub fn layout_file_path(data_root: &Path) -> PathBuf {
data_root.join(LAYOUT_FILE_REL)
}
fn read_layout_file(path: &Path) -> Result<Option<LayoutFile>, PlatformError> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(PlatformError::new(
"PLATFORM_LAYOUT_READ",
format!("failed reading '{}': {error}", path.display()),
));
}
};
serde_json::from_slice::<LayoutFile>(&bytes)
.map(Some)
.map_err(|error| {
PlatformError::new(
"PLATFORM_LAYOUT_READ",
format!(
"'{}' is not a layout version file ({error}); refusing to guess \
this root's version — restore or repair the file by hand",
path.display()
),
)
})
}
fn root_is_populated(data_root: &Path) -> Result<bool, PlatformError> {
match std::fs::read_dir(data_root) {
Ok(mut entries) => Ok(entries.next().is_some()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(PlatformError::new(
"PLATFORM_LAYOUT_READ",
format!("failed reading '{}': {error}", data_root.display()),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_root_is_stamped_with_the_current_version() {
let root = tempfile::tempdir().expect("root");
let found = open_data_root(root.path()).expect("open");
assert_eq!(found, CURRENT_LAYOUT_VERSION);
let stored: serde_json::Value =
serde_json::from_slice(&std::fs::read(layout_file_path(root.path())).unwrap())
.expect("valid json");
assert_eq!(stored["version"], CURRENT_LAYOUT_VERSION);
assert!(stored["created_at"].is_i64());
}
#[test]
fn a_populated_root_without_a_file_is_a_pre_versioned_root() {
let root = tempfile::tempdir().expect("root");
std::fs::create_dir_all(root.path().join("users/owner/project/data")).unwrap();
let found = open_data_root(root.path()).expect("open");
assert_eq!(found, 1);
assert!(layout_file_path(root.path()).is_file());
}
#[test]
fn a_second_open_is_a_no_op_and_keeps_created_at() {
let root = tempfile::tempdir().expect("root");
open_data_root(root.path()).expect("first open");
let first = std::fs::read(layout_file_path(root.path())).unwrap();
open_data_root(root.path()).expect("second open");
assert_eq!(first, std::fs::read(layout_file_path(root.path())).unwrap());
}
#[test]
fn a_newer_root_refuses() {
let root = tempfile::tempdir().expect("root");
atomic_write(
&layout_file_path(root.path()),
format!("{{\"version\":{}}}", CURRENT_LAYOUT_VERSION + 1).as_bytes(),
)
.unwrap();
let error = open_data_root(root.path()).expect_err("must refuse");
assert_eq!(error.code, "PLATFORM_LAYOUT_NEWER");
let stored: serde_json::Value =
serde_json::from_slice(&std::fs::read(layout_file_path(root.path())).unwrap()).unwrap();
assert_eq!(stored["version"], CURRENT_LAYOUT_VERSION + 1);
}
#[test]
fn a_malformed_file_refuses_rather_than_guessing() {
let root = tempfile::tempdir().expect("root");
atomic_write(&layout_file_path(root.path()), b"not json").unwrap();
let error = open_data_root(root.path()).expect_err("must refuse");
assert_eq!(error.code, "PLATFORM_LAYOUT_READ");
}
}