use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::{fs, io}; use serde::Deserialize; use crate::hosts::RemoteRuntimeConfig; use crate::identity::WorkspaceIdentity; use crate::server::{AuthConfig, ServerConfig}; use crate::{Error, Result}; pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml"; pub const WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.default.toml"; pub const WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE: &str = include_str!("../../../resources/workspace-backend.default.toml"); const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; const DEFAULT_MAX_RECORDS: usize = 200; #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendConfigFile { #[serde(default)] pub server: WorkspaceBackendServerConfig, #[serde(default)] pub data: WorkspaceBackendDataConfig, #[serde(default)] pub limits: WorkspaceBackendLimitsConfig, #[serde(default)] pub runtimes: WorkspaceBackendRuntimesConfig, } #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendServerConfig { #[serde(default)] pub listen: Option, #[serde(default)] pub frontend_url: Option, #[serde(default)] pub static_assets_dir: Option, } #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendDataConfig { #[serde(default)] pub root: Option, #[serde(default)] pub workspace_database_path: Option, #[serde(default)] pub embedded_runtime_store_root: Option, } #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendLimitsConfig { #[serde(default)] pub max_records: Option, } #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendRuntimesConfig { #[serde(default)] pub remote: Vec, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct RemoteRuntimeConfigFile { pub id: String, pub endpoint: String, #[serde(default)] pub display_name: Option, #[serde(default)] pub token_ref: Option, } #[derive(Clone)] pub struct ResolvedWorkspaceBackendConfig { pub server: ServerConfig, pub listen: SocketAddr, pub database_path: PathBuf, } impl WorkspaceBackendConfigFile { pub fn path_for_workspace(workspace_root: impl AsRef) -> PathBuf { workspace_root .as_ref() .join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH) } pub fn default_template_path_for_workspace(workspace_root: impl AsRef) -> PathBuf { workspace_root .as_ref() .join(WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH) } pub fn ensure_default_template_for_workspace(workspace_root: impl AsRef) -> Result<()> { let path = Self::default_template_path_for_workspace(workspace_root); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } match fs::OpenOptions::new() .write(true) .create_new(true) .open(&path) { Ok(mut file) => { use std::io::Write; file.write_all(WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE.as_bytes())?; file.sync_all()?; Ok(()) } Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()), Err(error) => Err(Error::Io(error)), } } pub fn load_for_workspace(workspace_root: impl AsRef) -> Result { let path = Self::path_for_workspace(workspace_root); match fs::read_to_string(&path) { Ok(raw) => Self::parse_str(&raw, &path), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()), Err(error) => Err(Error::Io(error)), } } pub fn parse_str(raw: &str, path: impl AsRef) -> Result { toml::from_str(raw).map_err(|error| { Error::Config(format!( "failed to parse workspace backend config `{}`: {error}", path.as_ref().display() )) }) } pub fn resolve( &self, workspace_root: impl AsRef, identity: WorkspaceIdentity, ) -> Result { let workspace_root = workspace_root.as_ref(); let data_root = self .data .root .as_ref() .map(|path| resolve_workspace_path(workspace_root, path)) .unwrap_or_else(|| { ServerConfig::default_workspace_backend_data_root(&identity.workspace_id) }); let database_path = self .data .workspace_database_path .as_ref() .map(|path| resolve_workspace_path(workspace_root, path)) .unwrap_or_else(|| data_root.join("workspace.db")); let embedded_runtime_store_root = self .data .embedded_runtime_store_root .as_ref() .map(|path| resolve_workspace_path(workspace_root, path)) .unwrap_or_else(|| data_root.join("embedded-runtime")); let listen = self .server .listen .as_deref() .unwrap_or(DEFAULT_LISTEN) .parse::() .map_err(|_| { Error::Config(format!( "invalid workspace backend server.listen `{}`", self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN) )) })?; let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); server.frontend_url = self .server .frontend_url .clone() .unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string()); server.static_assets_dir = self .server .static_assets_dir .as_ref() .map(|path| resolve_workspace_path(workspace_root, path)); server.embedded_runtime_store_root = embedded_runtime_store_root; server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS); server.remote_runtime_sources = self .runtimes .remote .iter() .map(resolve_remote_runtime) .collect::>>()?; server.auth = AuthConfig::LocalDevToken { token_configured: false, }; Ok(ResolvedWorkspaceBackendConfig { server, listen, database_path, }) } } impl ResolvedWorkspaceBackendConfig { pub fn with_database_path(mut self, path: impl Into) -> Self { self.database_path = path.into(); self } pub fn with_static_assets_dir(mut self, path: Option) -> Self { self.server.static_assets_dir = path; self } pub fn with_listen(mut self, listen: SocketAddr) -> Self { self.listen = listen; self } } fn resolve_remote_runtime(config: &RemoteRuntimeConfigFile) -> Result { if let Some(token_ref) = config.token_ref.as_deref() { return Err(Error::Config(format!( "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet", config.id ))); } Ok(RemoteRuntimeConfig::new( config.id.clone(), config .display_name .clone() .unwrap_or_else(|| config.id.clone()), config.endpoint.clone(), None, )) } fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() } else { workspace_root.join(path) } } #[cfg(test)] mod tests { use super::*; fn identity() -> WorkspaceIdentity { WorkspaceIdentity { workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), display_name: "Workspace".to_string(), } } #[test] fn missing_config_path_uses_defaults() { let dir = tempfile::tempdir().unwrap(); let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); let resolved = config.resolve(dir.path(), identity()).unwrap(); assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL); assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS); assert!(resolved.database_path.ends_with("workspace.db")); assert!( resolved .server .embedded_runtime_store_root .ends_with("embedded-runtime") ); } #[test] fn rejects_unknown_fields() { let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test") .unwrap_err(); assert!( error.to_string().contains("unknown field"), "unexpected error: {error}" ); } #[test] fn resolves_relative_paths_against_workspace_root() { let dir = tempfile::tempdir().unwrap(); let config = WorkspaceBackendConfigFile::parse_str( r#" [server] static_assets_dir = "web/build" [data] root = ".yoi/backend-data" workspace_database_path = ".yoi/custom.db" embedded_runtime_store_root = ".yoi/runtime-store" "#, "test", ) .unwrap(); let resolved = config.resolve(dir.path(), identity()).unwrap(); assert_eq!( resolved.server.static_assets_dir, Some(dir.path().join("web/build")) ); assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db")); assert_eq!( resolved.server.embedded_runtime_store_root, dir.path().join(".yoi/runtime-store") ); } #[test] fn absolute_paths_are_preserved() { let dir = tempfile::tempdir().unwrap(); let config = WorkspaceBackendConfigFile::parse_str( r#" [data] workspace_database_path = "/tmp/yoi-workspace.db" embedded_runtime_store_root = "/tmp/yoi-runtime" "#, "test", ) .unwrap(); let resolved = config.resolve(dir.path(), identity()).unwrap(); assert_eq!( resolved.database_path, PathBuf::from("/tmp/yoi-workspace.db") ); assert_eq!( resolved.server.embedded_runtime_store_root, PathBuf::from("/tmp/yoi-runtime") ); } #[test] fn data_root_derives_database_and_runtime_store_paths() { let dir = tempfile::tempdir().unwrap(); let config = WorkspaceBackendConfigFile::parse_str( r#" [data] root = ".local-data" "#, "test", ) .unwrap(); let resolved = config.resolve(dir.path(), identity()).unwrap(); assert_eq!( resolved.database_path, dir.path().join(".local-data/workspace.db") ); assert_eq!( resolved.server.embedded_runtime_store_root, dir.path().join(".local-data/embedded-runtime") ); } #[test] fn copies_default_template_without_overwriting() { let dir = tempfile::tempdir().unwrap(); WorkspaceBackendConfigFile::ensure_default_template_for_workspace(dir.path()).unwrap(); let path = WorkspaceBackendConfigFile::default_template_path_for_workspace(dir.path()); let raw = fs::read_to_string(&path).unwrap(); assert_eq!(raw, WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE); WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap(); fs::write(&path, "# custom template\n").unwrap(); WorkspaceBackendConfigFile::ensure_default_template_for_workspace(dir.path()).unwrap(); assert_eq!(fs::read_to_string(&path).unwrap(), "# custom template\n"); } #[test] fn token_value_field_is_not_in_schema() { let error = WorkspaceBackendConfigFile::parse_str( r#" [[runtimes.remote]] id = "remote" endpoint = "http://127.0.0.1:8790" token = "secret" "#, "test", ) .unwrap_err(); assert!( error.to_string().contains("unknown field"), "unexpected error: {error}" ); } #[test] fn token_ref_fails_closed_until_secret_resolution_exists() { let dir = tempfile::tempdir().unwrap(); let config = WorkspaceBackendConfigFile::parse_str( r#" [[runtimes.remote]] id = "remote" endpoint = "http://127.0.0.1:8790" token_ref = "local:remote-token" "#, "test", ) .unwrap(); let error = match config.resolve(dir.path(), identity()) { Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), Err(error) => error, }; assert!( error .to_string() .contains("secret ref resolution is not implemented"), "unexpected error: {error}" ); } }