fabro/lib/components/fabro-environment/src/store.rs
2026-07-23 17:59:34 -04:00

834 lines
27 KiB
Rust

use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use chrono::Utc;
use fabro_config::{
EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer,
EnvironmentNetworkLayer, EnvironmentResourcesLayer, MergeMap, StickyMap,
};
use fabro_db::DbPool;
use fabro_types::settings::run::{DockerfileSource, EnvironmentProvider, EnvironmentSettings};
use fabro_types::settings::{Duration, InterpString, Size};
use serde::de::DeserializeOwned;
use sqlx::Row as _;
use sqlx::sqlite::SqliteRow;
use tokio::fs;
use tokio::sync::Mutex;
use tracing::info;
use crate::{
Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError,
EnvironmentValidationError,
};
/// Built-in default environment seeded by install/test setup. The server itself
/// never seeds during normal startup: an uninstalled instance has no persisted
/// managed environments, and a run that selects an absent environment fails
/// explicitly. `local` is intentionally absent from SQLite because it is a
/// reserved, in-memory environment.
const DEFAULT_ENVIRONMENT_ID: &str = "default";
/// `local` is a reserved environment: it is synthesized in memory only when the
/// local sandbox provider is enabled, is never persisted, and cannot be
/// created, replaced, or deleted through the store.
const RESERVED_LOCAL_ID: &str = "local";
/// Returns the built-in seeded environment catalog as a `MergeMap` of
/// `EnvironmentLayer`s. Useful for client-side manifest validation where no
/// live `EnvironmentStore` is available. Includes the reserved `local` entry so
/// manifests selecting `id = "local"` validate; server-side provider-enablement
/// policy decides whether such a run may actually execute.
pub fn seeded_catalog_layer() -> MergeMap<EnvironmentLayer> {
let mut catalog: HashMap<String, EnvironmentLayer> = HashMap::new();
let default: EnvironmentLayer =
toml::from_str(DEFAULT_ENVIRONMENT_TOML).expect("built-in environment seed should parse");
catalog.insert(DEFAULT_ENVIRONMENT_ID.to_string(), default);
let local: EnvironmentLayer = toml::from_str(LOCAL_ENVIRONMENT_TOML)
.expect("built-in local environment seed should parse");
catalog.insert(RESERVED_LOCAL_ID.to_string(), local);
MergeMap::from(catalog)
}
const DEFAULT_ENVIRONMENT_TOML: &str = r#"provider = "docker"
[image]
docker = "buildpack-deps:noble"
[resources]
cpu = 2
memory = "4GB"
[lifecycle]
preserve = false
stop_on_terminal = true
"#;
const LOCAL_ENVIRONMENT_TOML: &str = r#"provider = "local"
"#;
const DAYTONA_DEFAULT_ENVIRONMENT_TOML: &str = r#"provider = "daytona"
[image]
dockerfile = "FROM buildpack-deps:noble\n"
[resources]
cpu = 2
memory = "4GB"
[lifecycle]
preserve = false
stop_on_terminal = true
"#;
#[derive(Debug)]
pub struct EnvironmentStore {
pool: DbPool,
mutations: Mutex<()>,
state: std::sync::RwLock<CatalogState>,
}
#[derive(Debug, Clone)]
pub struct ImportReport {
pub source_path: PathBuf,
pub backup_path: PathBuf,
pub imported_rows: i64,
pub skipped_rows: i64,
pub environment_ids: Vec<String>,
}
#[derive(Debug, Clone)]
struct CatalogState {
environments: HashMap<EnvironmentId, Environment>,
catalog: Arc<MergeMap<EnvironmentLayer>>,
}
impl CatalogState {
fn new(environments: HashMap<EnvironmentId, Environment>) -> Self {
let catalog = Arc::new(build_catalog_layer(&environments));
Self {
environments,
catalog,
}
}
fn insert(&mut self, environment: Environment) {
self.environments
.insert(environment.id.clone(), environment);
self.rebuild_catalog();
}
fn remove(&mut self, id: &EnvironmentId) {
self.environments.remove(id);
self.rebuild_catalog();
}
fn rebuild_catalog(&mut self) {
self.catalog = Arc::new(build_catalog_layer(&self.environments));
}
}
/// Builds the reserved, in-memory `local` environment. It carries only
/// `provider = "local"`; image/resources/network/etc. are irrelevant to the
/// local sandbox and stay at their defaults.
fn synthetic_local_environment() -> Result<Environment, EnvironmentStoreError> {
let id = EnvironmentId::new(RESERVED_LOCAL_ID).expect("reserved local id is valid");
let settings = EnvironmentSettings {
provider: EnvironmentProvider::Local,
..EnvironmentSettings::default()
};
Environment::synthetic(id, &settings)
}
fn build_catalog_layer(
environments: &HashMap<EnvironmentId, Environment>,
) -> MergeMap<EnvironmentLayer> {
let catalog: HashMap<String, EnvironmentLayer> = environments
.iter()
.map(|(id, environment)| (id.to_string(), environment.to_layer()))
.collect();
MergeMap::from(catalog)
}
impl EnvironmentStore {
/// Load all persisted environments from SQLite and build the synchronous
/// in-memory catalog cache used by request paths.
pub async fn load(pool: DbPool, local_enabled: bool) -> Result<Self, EnvironmentStoreError> {
let environments = load_environments(&pool, local_enabled).await?;
Ok(Self {
pool,
mutations: Mutex::new(()),
state: std::sync::RwLock::new(CatalogState::new(environments)),
})
}
fn read_state(&self) -> std::sync::RwLockReadGuard<'_, CatalogState> {
self.state.read().expect("environment store lock poisoned")
}
fn write_state(&self) -> std::sync::RwLockWriteGuard<'_, CatalogState> {
self.state.write().expect("environment store lock poisoned")
}
pub fn list(&self) -> Vec<Environment> {
let state = self.read_state();
let mut values = state.environments.values().cloned().collect::<Vec<_>>();
values.sort_by(|left, right| left.id.cmp(&right.id));
values
}
pub fn get(&self, id: &EnvironmentId) -> Option<Environment> {
self.read_state().environments.get(id).cloned()
}
pub async fn create(
&self,
draft: EnvironmentDraft,
) -> Result<Environment, EnvironmentStoreError> {
let EnvironmentDraft { id, settings } = draft;
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id });
}
let environment = Environment::from_settings(id.clone(), &settings)?;
let _mutation = self.mutations.lock().await;
let mut transaction = self.pool.begin().await?;
if !insert_environment_ignoring_conflict(&mut transaction, &environment).await? {
return Err(EnvironmentStoreError::AlreadyExists { id });
}
transaction.commit().await?;
self.write_state().insert(environment.clone());
Ok(environment)
}
pub async fn replace(
&self,
id: &EnvironmentId,
expected: &EnvironmentRevision,
settings: EnvironmentSettings,
) -> Result<Environment, EnvironmentStoreError> {
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id: id.clone() });
}
let environment = Environment::from_settings(id.clone(), &settings)?;
let _mutation = self.mutations.lock().await;
let mut transaction = self.pool.begin().await?;
update_environment(&mut transaction, &environment, expected).await?;
transaction.commit().await?;
self.write_state().insert(environment.clone());
Ok(environment)
}
pub async fn delete(
&self,
id: &EnvironmentId,
expected: &EnvironmentRevision,
) -> Result<(), EnvironmentStoreError> {
// `default` is an ordinary deletable environment. Deleting it removes the
// run fallback, which is intentional: a run that selects `default` after
// it is gone fails explicitly rather than silently using a built-in.
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id: id.clone() });
}
let _mutation = self.mutations.lock().await;
let mut transaction = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM environments WHERE id = ? AND revision = ?")
.bind(id.as_str())
.bind(expected.as_str())
.execute(&mut *transaction)
.await?;
if result.rows_affected() == 0 {
return Err(revision_mismatch_error(&mut transaction, id, expected).await?);
}
transaction.commit().await?;
self.write_state().remove(id);
Ok(())
}
pub fn catalog_layer(&self) -> Arc<MergeMap<EnvironmentLayer>> {
Arc::clone(&self.read_state().catalog)
}
}
async fn load_environments(
pool: &DbPool,
local_enabled: bool,
) -> Result<HashMap<EnvironmentId, Environment>, EnvironmentStoreError> {
let rows = sqlx::query(
r"
SELECT
id,
revision,
provider,
cwd,
image_docker,
image_dockerfile_inline,
resources_cpu,
resources_memory,
resources_disk,
network_mode,
network_allow_json,
lifecycle_preserve,
lifecycle_stop_on_terminal,
lifecycle_auto_stop,
labels_json,
env_json
FROM environments
ORDER BY id
",
)
.fetch_all(pool)
.await?;
let mut environments = HashMap::new();
for row in rows {
let environment = environment_from_row(&row)?;
environments.insert(environment.id.clone(), environment);
}
if local_enabled {
let local = synthetic_local_environment()?;
environments.insert(local.id.clone(), local);
}
Ok(environments)
}
fn environment_from_row(row: &SqliteRow) -> Result<Environment, EnvironmentStoreError> {
let id_text = row.get::<String, _>("id");
let id = EnvironmentId::new(id_text)?;
let revision_text = row.get::<String, _>("revision");
let revision = EnvironmentRevision::from_str(&revision_text).map_err(|source| {
EnvironmentStoreError::InvalidRevision {
id: id.clone(),
source,
}
})?;
let network_allow_json = row.get::<String, _>("network_allow_json");
let labels_json = row.get::<String, _>("labels_json");
let env_json = row.get::<String, _>("env_json");
let layer = EnvironmentLayer {
provider: Some(row.get("provider")),
cwd: row.get("cwd"),
image: image_layer_from_row(row),
resources: resources_layer_from_row(row)?,
network: Some(EnvironmentNetworkLayer {
mode: Some(row.get("network_mode")),
allow: decode_json("network_allow_json", &network_allow_json)?,
}),
lifecycle: Some(EnvironmentLifecycleLayer {
preserve: Some(row.get("lifecycle_preserve")),
stop_on_terminal: Some(row.get("lifecycle_stop_on_terminal")),
auto_stop: parse_duration(
"lifecycle_auto_stop",
row.get("lifecycle_auto_stop"),
)?,
}),
labels: StickyMap::from(decode_json::<HashMap<String, String>>(
"labels_json",
&labels_json,
)?),
env: StickyMap::from(decode_env_json(&env_json)?),
};
Environment::from_row(id, revision, &layer)
}
fn image_layer_from_row(row: &SqliteRow) -> Option<EnvironmentImageLayer> {
let docker: Option<String> = row.get("image_docker");
let dockerfile_inline: Option<String> = row.get("image_dockerfile_inline");
if docker.is_none() && dockerfile_inline.is_none() {
return None;
}
Some(EnvironmentImageLayer {
docker,
dockerfile: dockerfile_inline.map(EnvironmentDockerfileLayer::Inline),
})
}
fn resources_layer_from_row(
row: &SqliteRow,
) -> Result<Option<EnvironmentResourcesLayer>, EnvironmentStoreError> {
let cpu: Option<i32> = row.get("resources_cpu");
let memory = parse_size("resources_memory", row.get("resources_memory"))?;
let disk = parse_size("resources_disk", row.get("resources_disk"))?;
if cpu.is_none() && memory.is_none() && disk.is_none() {
return Ok(None);
}
Ok(Some(EnvironmentResourcesLayer { cpu, memory, disk }))
}
fn parse_optional_field<T>(
field: &'static str,
value: Option<String>,
) -> Result<Option<T>, EnvironmentValidationError>
where
T: FromStr,
T::Err: std::fmt::Display,
{
value
.map(|value| {
value
.parse()
.map_err(|err| EnvironmentValidationError::InvalidSettings {
errors: vec![format!("environment.{field}: {err}")],
})
})
.transpose()
}
fn parse_size(
field: &'static str,
value: Option<String>,
) -> Result<Option<Size>, EnvironmentValidationError> {
parse_optional_field(field, value)
}
fn parse_duration(
field: &'static str,
value: Option<String>,
) -> Result<Option<Duration>, EnvironmentValidationError> {
parse_optional_field(field, value)
}
async fn current_revision(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &EnvironmentId,
) -> Result<Option<EnvironmentRevision>, EnvironmentStoreError> {
let current: Option<String> =
sqlx::query_scalar("SELECT revision FROM environments WHERE id = ?")
.bind(id.as_str())
.fetch_optional(&mut **transaction)
.await?;
current
.map(|revision| {
EnvironmentRevision::from_str(&revision).map_err(|source| {
EnvironmentStoreError::InvalidRevision {
id: id.clone(),
source,
}
})
})
.transpose()
}
async fn revision_mismatch_error(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &EnvironmentId,
expected: &EnvironmentRevision,
) -> Result<EnvironmentStoreError, EnvironmentStoreError> {
let Some(actual) = current_revision(transaction, id).await? else {
return Err(EnvironmentStoreError::NotFound { id: id.clone() });
};
Ok(EnvironmentStoreError::StaleRevision {
id: id.clone(),
expected: expected.clone(),
actual,
})
}
async fn insert_environment_ignoring_conflict(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
environment: &Environment,
) -> Result<bool, EnvironmentStoreError> {
let result = execute_environment_insert_sql(
transaction,
environment,
INSERT_ENVIRONMENT_IGNORE_CONFLICT_SQL,
)
.await?;
Ok(result > 0)
}
async fn execute_environment_insert_sql(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
environment: &Environment,
sql: &'static str,
) -> Result<u64, EnvironmentStoreError> {
let row = EnvironmentSqlRow::from_environment(environment)?;
let result = sqlx::query(sql)
.bind(row.id)
.bind(row.revision)
.bind(row.provider)
.bind(row.cwd)
.bind(row.image_docker)
.bind(row.image_dockerfile_inline)
.bind(row.resources_cpu)
.bind(row.resources_memory)
.bind(row.resources_disk)
.bind(row.network_mode)
.bind(row.network_allow_json)
.bind(row.lifecycle_preserve)
.bind(row.lifecycle_stop_on_terminal)
.bind(row.lifecycle_auto_stop)
.bind(row.labels_json)
.bind(row.env_json)
.execute(&mut **transaction)
.await?;
Ok(result.rows_affected())
}
async fn update_environment(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
environment: &Environment,
expected: &EnvironmentRevision,
) -> Result<(), EnvironmentStoreError> {
let row = EnvironmentSqlRow::from_environment(environment)?;
let result = sqlx::query(UPDATE_ENVIRONMENT_SQL)
.bind(row.revision)
.bind(row.provider)
.bind(row.cwd)
.bind(row.image_docker)
.bind(row.image_dockerfile_inline)
.bind(row.resources_cpu)
.bind(row.resources_memory)
.bind(row.resources_disk)
.bind(row.network_mode)
.bind(row.network_allow_json)
.bind(row.lifecycle_preserve)
.bind(row.lifecycle_stop_on_terminal)
.bind(row.lifecycle_auto_stop)
.bind(row.labels_json)
.bind(row.env_json)
.bind(row.id)
.bind(expected.as_str())
.execute(&mut **transaction)
.await?;
if result.rows_affected() == 0 {
return Err(revision_mismatch_error(transaction, &environment.id, expected).await?);
}
Ok(())
}
const INSERT_ENVIRONMENT_IGNORE_CONFLICT_SQL: &str = r"
INSERT INTO environments (
id,
revision,
provider,
cwd,
image_docker,
image_dockerfile_inline,
resources_cpu,
resources_memory,
resources_disk,
network_mode,
network_allow_json,
lifecycle_preserve,
lifecycle_stop_on_terminal,
lifecycle_auto_stop,
labels_json,
env_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
";
const UPDATE_ENVIRONMENT_SQL: &str = r"
UPDATE environments SET
revision = ?,
provider = ?,
cwd = ?,
image_docker = ?,
image_dockerfile_inline = ?,
resources_cpu = ?,
resources_memory = ?,
resources_disk = ?,
network_mode = ?,
network_allow_json = ?,
lifecycle_preserve = ?,
lifecycle_stop_on_terminal = ?,
lifecycle_auto_stop = ?,
labels_json = ?,
env_json = ?
WHERE id = ? AND revision = ?
";
struct EnvironmentSqlRow {
id: String,
revision: String,
provider: String,
cwd: Option<String>,
image_docker: Option<String>,
image_dockerfile_inline: Option<String>,
resources_cpu: Option<i32>,
resources_memory: Option<String>,
resources_disk: Option<String>,
network_mode: String,
network_allow_json: String,
lifecycle_preserve: bool,
lifecycle_stop_on_terminal: bool,
lifecycle_auto_stop: Option<String>,
labels_json: String,
env_json: String,
}
impl EnvironmentSqlRow {
fn from_environment(environment: &Environment) -> Result<Self, EnvironmentStoreError> {
let settings = &environment.settings;
let image_dockerfile_inline = match &settings.image.dockerfile {
Some(DockerfileSource::Inline(value)) => Some(value.clone()),
Some(DockerfileSource::Path { .. }) => {
return Err(EnvironmentValidationError::DockerfilePathUnsupported.into());
}
None => None,
};
Ok(Self {
id: environment.id.to_string(),
revision: environment.revision.to_string(),
provider: settings.provider.to_string(),
cwd: settings.cwd.clone(),
image_docker: settings.image.docker.clone(),
image_dockerfile_inline,
resources_cpu: settings.resources.cpu,
resources_memory: settings.resources.memory.map(|size| size.to_string()),
resources_disk: settings.resources.disk.map(|size| size.to_string()),
network_mode: settings.network.mode.to_string(),
network_allow_json: encode_json("network_allow_json", &settings.network.allow)?,
lifecycle_preserve: settings.lifecycle.preserve,
lifecycle_stop_on_terminal: settings.lifecycle.stop_on_terminal,
lifecycle_auto_stop: settings
.lifecycle
.auto_stop
.map(|duration| duration.to_string()),
labels_json: encode_string_map_json("labels_json", &settings.labels)?,
env_json: encode_env_json(&settings.env)?,
})
}
}
pub async fn seed_environments(pool: &DbPool) -> Result<(), EnvironmentStoreError> {
seed_default_environment(pool, EnvironmentProvider::Docker).await
}
pub async fn seed_default_environment(
pool: &DbPool,
provider: EnvironmentProvider,
) -> Result<(), EnvironmentStoreError> {
let content = match provider {
EnvironmentProvider::Docker => DEFAULT_ENVIRONMENT_TOML,
EnvironmentProvider::Daytona => DAYTONA_DEFAULT_ENVIRONMENT_TOML,
EnvironmentProvider::Local => LOCAL_ENVIRONMENT_TOML,
};
let layer: EnvironmentLayer = toml::from_str(content).map_err(|source| {
EnvironmentStoreError::parse(PathBuf::from("built-in-default-environment.toml"), source)
})?;
let settings =
fabro_config::resolve_environment_layer(&layer, "environment").map_err(|errors| {
EnvironmentValidationError::InvalidSettings {
errors: errors.into_iter().map(|err| err.to_string()).collect(),
}
})?;
let environment = Environment::from_settings(
EnvironmentId::new(DEFAULT_ENVIRONMENT_ID).expect("default environment id is valid"),
&settings,
)?;
let mut transaction = pool.begin().await?;
insert_environment_ignoring_conflict(&mut transaction, &environment).await?;
transaction.commit().await?;
Ok(())
}
pub async fn import_legacy_directory_once(
pool: &DbPool,
source_dir: impl AsRef<Path>,
) -> Result<Option<ImportReport>, EnvironmentStoreError> {
let source_dir = source_dir.as_ref();
let paths = legacy_environment_paths(source_dir).await?;
let Some(paths) = paths else {
return Ok(None);
};
let existing_ids = existing_environment_ids(pool).await?;
let candidates = read_legacy_environment_directory(paths, &existing_ids).await?;
let mut transaction = pool.begin().await?;
let mut imported_ids = Vec::new();
let mut skipped_rows = candidates.skipped_rows;
for environment in &candidates.environments {
if !insert_environment_ignoring_conflict(&mut transaction, environment).await? {
skipped_rows += 1;
continue;
}
imported_ids.push(environment.id.to_string());
}
transaction.commit().await?;
let backup_path = rename_imported_legacy_directory(source_dir).await?;
let report = ImportReport {
source_path: source_dir.to_path_buf(),
backup_path,
imported_rows: row_count(imported_ids.len())?,
skipped_rows: row_count(skipped_rows)?,
environment_ids: imported_ids,
};
info!(
source_path = %source_dir.display(),
backup_path = %report.backup_path.display(),
imported_rows = report.imported_rows,
skipped_rows = report.skipped_rows,
environment_ids = ?report.environment_ids,
"imported legacy environments directory into sqlite"
);
Ok(Some(report))
}
struct LegacyCandidates {
environments: Vec<Environment>,
skipped_rows: usize,
}
struct LegacyEnvironmentPath {
id: EnvironmentId,
path: PathBuf,
}
async fn legacy_environment_paths(
source_dir: &Path,
) -> Result<Option<Vec<LegacyEnvironmentPath>>, EnvironmentStoreError> {
let mut entries = match fs::read_dir(source_dir).await {
Ok(entries) => entries,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(EnvironmentStoreError::io(source_dir, source)),
};
let mut paths = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|source| EnvironmentStoreError::io(source_dir, source))?
{
let path = entry.path();
let file_type = entry
.file_type()
.await
.map_err(|source| EnvironmentStoreError::io(&path, source))?;
if file_type.is_file() && is_toml_file(&path) {
paths.push(LegacyEnvironmentPath {
id: id_from_path(&path)?,
path,
});
}
}
paths.sort_by(|left, right| left.path.cmp(&right.path));
Ok(Some(paths))
}
async fn read_legacy_environment_directory(
paths: Vec<LegacyEnvironmentPath>,
existing_ids: &HashSet<EnvironmentId>,
) -> Result<LegacyCandidates, EnvironmentStoreError> {
let mut environments = Vec::new();
let mut skipped_rows = 0usize;
for LegacyEnvironmentPath { id, path } in paths {
if id.as_str() == RESERVED_LOCAL_ID || existing_ids.contains(&id) {
skipped_rows += 1;
continue;
}
let bytes = fs::read(&path)
.await
.map_err(|source| EnvironmentStoreError::io(&path, source))?;
let environment = Environment::from_legacy_path(id, &bytes, &path).await?;
environments.push(environment);
}
environments.sort_by(|left, right| left.id.cmp(&right.id));
Ok(LegacyCandidates {
environments,
skipped_rows,
})
}
async fn existing_environment_ids(
pool: &DbPool,
) -> Result<HashSet<EnvironmentId>, EnvironmentStoreError> {
let rows = sqlx::query_scalar::<_, String>("SELECT id FROM environments")
.fetch_all(pool)
.await?;
rows.into_iter()
.map(|id| EnvironmentId::new(id).map_err(EnvironmentStoreError::from))
.collect()
}
async fn rename_imported_legacy_directory(
source_dir: &Path,
) -> Result<PathBuf, EnvironmentStoreError> {
let backup_path = fabro_db::legacy_backup_path(source_dir, "environments", Utc::now());
fs::rename(source_dir, &backup_path)
.await
.map_err(|source| EnvironmentStoreError::io(&backup_path, source))?;
Ok(backup_path)
}
fn id_from_path(path: &Path) -> Result<EnvironmentId, EnvironmentStoreError> {
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| EnvironmentStoreError::InvalidFilename {
path: path.to_path_buf(),
reason: "filename is not valid UTF-8".to_string(),
})?;
EnvironmentId::new(stem).map_err(|source| EnvironmentStoreError::InvalidFilename {
path: path.to_path_buf(),
reason: source.to_string(),
})
}
fn is_toml_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension == "toml")
}
fn encode_json<T: serde::Serialize>(
field: &'static str,
value: &T,
) -> Result<String, EnvironmentStoreError> {
serde_json::to_string(value)
.map_err(|source| EnvironmentStoreError::JsonEncode { field, source })
}
fn decode_json<T: DeserializeOwned>(
field: &'static str,
value: &str,
) -> Result<T, EnvironmentStoreError> {
serde_json::from_str(value)
.map_err(|source| EnvironmentStoreError::JsonDecode { field, source })
}
fn encode_string_map_json(
field: &'static str,
map: &HashMap<String, String>,
) -> Result<String, EnvironmentStoreError> {
let ordered = map
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect::<BTreeMap<_, _>>();
encode_json(field, &ordered)
}
#[expect(
clippy::disallowed_methods,
reason = "persisting InterpString source text; resolution happens at consumption time"
)]
fn encode_env_json(map: &HashMap<String, InterpString>) -> Result<String, EnvironmentStoreError> {
let ordered = map
.iter()
.map(|(key, value)| (key.as_str(), value.as_source()))
.collect::<BTreeMap<_, _>>();
encode_json("env_json", &ordered)
}
fn decode_env_json(value: &str) -> Result<HashMap<String, InterpString>, EnvironmentStoreError> {
let decoded = decode_json::<BTreeMap<String, String>>("env_json", value)?;
Ok(decoded
.into_iter()
.map(|(key, value)| (key, InterpString::parse(&value)))
.collect())
}
fn row_count(count: usize) -> Result<i64, EnvironmentStoreError> {
i64::try_from(count).map_err(|_| EnvironmentStoreError::RowCountOverflow { count })
}