use std::fs::{self, OpenOptions}; use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; use chrono::{SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{Error, Result}; pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml"; /// Stable local Workspace identity persisted as a tracked, safe project record. /// /// The v0 TOML schema intentionally contains identity metadata only: /// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected /// instead of preserved because this loader cannot safely round-trip future local /// runtime settings without risking accidental path or secret persistence. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceIdentity { pub workspace_id: String, pub created_at: String, pub display_name: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct WorkspaceIdentityFile { workspace_id: String, created_at: String, display_name: String, } impl WorkspaceIdentity { pub fn load_or_init(workspace_root: impl AsRef) -> Result { Self::load_or_init_with_clock(workspace_root.as_ref(), || { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) }) } pub fn path(workspace_root: impl AsRef) -> PathBuf { workspace_root .as_ref() .join(WORKSPACE_IDENTITY_RELATIVE_PATH) } pub fn parse_str(raw: &str, path: impl AsRef) -> Result { let path = path.as_ref(); let parsed: WorkspaceIdentityFile = toml::from_str(raw).map_err(|error| { workspace_identity_error(path, format!("failed to parse TOML: {error}")) })?; Self::from_file(parsed, path) } fn load_or_init_with_clock( workspace_root: &Path, now_utc_rfc3339: impl FnOnce() -> String, ) -> Result { let path = Self::path(workspace_root); match fs::read_to_string(&path) { Ok(raw) => Self::parse_str(&raw, &path), Err(error) if error.kind() == ErrorKind::NotFound => { Self::init(workspace_root, &path, now_utc_rfc3339()) } Err(error) => Err(Error::Io(error)), } } fn init(workspace_root: &Path, path: &Path, created_at: String) -> Result { validate_created_at(&created_at, path)?; let display_name = workspace_display_name_from_root(workspace_root, path)?; let workspace_id = Uuid::now_v7().to_string(); let identity = Self { workspace_id, created_at, display_name, }; identity.write_new_or_read_existing(path) } fn from_file(parsed: WorkspaceIdentityFile, path: &Path) -> Result { let workspace_id = validate_workspace_id(&parsed.workspace_id, path)?; validate_created_at(&parsed.created_at, path)?; validate_display_name(&parsed.display_name, path)?; Ok(Self { workspace_id, created_at: parsed.created_at, display_name: parsed.display_name, }) } fn write_new_or_read_existing(&self, path: &Path) -> Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let raw = toml::to_string_pretty(&WorkspaceIdentityFile { workspace_id: self.workspace_id.clone(), created_at: self.created_at.clone(), display_name: self.display_name.clone(), }) .map_err(|error| { workspace_identity_error(path, format!("failed to encode TOML: {error}")) })?; match OpenOptions::new().write(true).create_new(true).open(path) { Ok(mut file) => { file.write_all(raw.as_bytes())?; file.sync_all()?; Ok(self.clone()) } Err(error) if error.kind() == ErrorKind::AlreadyExists => { let raw = fs::read_to_string(path)?; Self::parse_str(&raw, path) } Err(error) => Err(Error::Io(error)), } } } fn validate_workspace_id(value: &str, path: &Path) -> Result { let uuid = Uuid::parse_str(value).map_err(|error| { workspace_identity_error(path, format!("workspace_id is not a UUID: {error}")) })?; if uuid.get_version_num() != 7 { return Err(workspace_identity_error( path, "workspace_id must be a UUIDv7 canonical string".to_string(), )); } let canonical = uuid.to_string(); if value != canonical { return Err(workspace_identity_error( path, "workspace_id must use lowercase hyphenated UUID canonical form".to_string(), )); } Ok(canonical) } fn validate_created_at(value: &str, path: &Path) -> Result<()> { let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|error| { workspace_identity_error(path, format!("created_at is not RFC3339: {error}")) })?; if parsed.offset().local_minus_utc() != 0 || !value.ends_with('Z') { return Err(workspace_identity_error( path, "created_at must be a UTC RFC3339 timestamp ending in Z".to_string(), )); } Ok(()) } fn validate_display_name(value: &str, path: &Path) -> Result<()> { if value.trim().is_empty() { return Err(workspace_identity_error( path, "display_name must not be empty".to_string(), )); } if value.contains('\0') || value.chars().any(|ch| ch.is_control()) { return Err(workspace_identity_error( path, "display_name must not contain control characters".to_string(), )); } Ok(()) } fn workspace_display_name_from_root(workspace_root: &Path, path: &Path) -> Result { let display_name = workspace_root .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| { workspace_identity_error( path, "workspace root must have a UTF-8 final path component".to_string(), ) })? .to_string(); validate_display_name(&display_name, path)?; Ok(display_name) } fn workspace_identity_error(path: &Path, message: String) -> Error { Error::WorkspaceIdentity(format!("{}: {message}", path.display())) } #[cfg(test)] mod tests { use super::*; const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z"; #[test] fn missing_identity_file_is_created_with_safe_fields() { let temp = tempfile::tempdir().unwrap(); let workspace_root = temp.path().join("example-workspace"); fs::create_dir_all(&workspace_root).unwrap(); let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { FIXED_CREATED_AT.to_string() }) .unwrap(); assert_eq!(identity.display_name, "example-workspace"); assert_eq!(identity.created_at, FIXED_CREATED_AT); validate_workspace_id( &identity.workspace_id, &WorkspaceIdentity::path(&workspace_root), ) .unwrap(); let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap(); assert!(raw.contains("workspace_id")); assert!(raw.contains("display_name")); assert!(raw.contains("created_at")); assert!(!raw.contains(&workspace_root.to_string_lossy().to_string())); let reloaded = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { "2026-06-24T00:00:00Z".to_string() }) .unwrap(); assert_eq!(reloaded, identity); } #[test] fn existing_identity_file_is_stable() { let temp = tempfile::tempdir().unwrap(); let workspace_root = temp.path().join("moved-workspace"); let yoi_dir = workspace_root.join(".yoi"); fs::create_dir_all(&yoi_dir).unwrap(); let path = yoi_dir.join("workspace.toml"); let raw = format!( "workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\n" ); fs::write(&path, &raw).unwrap(); let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { "2026-06-24T00:00:00Z".to_string() }) .unwrap(); assert_eq!(identity.workspace_id, FIXED_WORKSPACE_ID); assert_eq!(identity.created_at, FIXED_CREATED_AT); assert_eq!(identity.display_name, "Stable Project"); assert_eq!(fs::read_to_string(path).unwrap(), raw); } #[test] fn create_new_race_returns_existing_persisted_identity() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join(".yoi/workspace.toml"); fs::create_dir_all(path.parent().unwrap()).unwrap(); let persisted_raw = format!( "workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Persisted Project\"\n" ); fs::write(&path, &persisted_raw).unwrap(); let generated = WorkspaceIdentity { workspace_id: "0192f0e8-4d84-7d6e-b000-000000000002".to_string(), created_at: "2026-06-24T00:00:00Z".to_string(), display_name: "Generated Project".to_string(), }; let returned = generated.write_new_or_read_existing(&path).unwrap(); assert_eq!(returned.workspace_id, FIXED_WORKSPACE_ID); assert_eq!(returned.created_at, FIXED_CREATED_AT); assert_eq!(returned.display_name, "Persisted Project"); assert_eq!(fs::read_to_string(path).unwrap(), persisted_raw); } #[test] fn invalid_identity_file_fails_closed_without_rewriting() { let temp = tempfile::tempdir().unwrap(); let workspace_root = temp.path().join("invalid-workspace"); let yoi_dir = workspace_root.join(".yoi"); fs::create_dir_all(&yoi_dir).unwrap(); let path = yoi_dir.join("workspace.toml"); let raw = "workspace_id = \"not-a-uuid\"\ncreated_at = \"2026-06-23T06:43:28Z\"\ndisplay_name = \"Invalid\"\n"; fs::write(&path, raw).unwrap(); let error = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { FIXED_CREATED_AT.to_string() }) .unwrap_err(); assert!(error.to_string().contains("workspace_id is not a UUID")); assert_eq!(fs::read_to_string(path).unwrap(), raw); } #[test] fn generated_identity_does_not_leak_parent_paths() { let temp = tempfile::tempdir().unwrap(); let secret_parent = temp.path().join("user-secret-parent"); let workspace_root = secret_parent.join("public-project-name"); fs::create_dir_all(&workspace_root).unwrap(); WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { FIXED_CREATED_AT.to_string() }) .unwrap(); let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap(); assert!(raw.contains("public-project-name")); assert!(!raw.contains(&secret_parent.to_string_lossy().to_string())); assert!(!raw.contains("user-secret-parent")); assert!(!raw.contains("/")); } #[test] fn unknown_fields_are_rejected() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("workspace.toml"); let raw = format!( "workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\nlocal_root = \"/tmp/secret\"\n" ); let error = WorkspaceIdentity::parse_str(&raw, &path).unwrap_err(); assert!(error.to_string().contains("unknown field")); } }