farm_config.rs (20064B)
1 use std::fs; 2 use std::path::{Path, PathBuf}; 3 4 use radroots_events::farm::RadrootsFarm; 5 use radroots_events::listing::{RadrootsListingDeliveryMethod, RadrootsListingLocation}; 6 use radroots_events::profile::RadrootsProfile; 7 use radroots_events_codec::d_tag::is_d_tag_base64url; 8 use serde::{Deserialize, Serialize}; 9 10 use crate::runtime::RuntimeError; 11 use crate::runtime::config::{PathsConfig, RuntimeConfig}; 12 13 const FARM_CONFIG_FILE_NAME: &str = "farm.toml"; 14 pub const SUPPORTED_FARM_CONFIG_VERSION: u32 = 1; 15 16 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 17 #[serde(rename_all = "snake_case")] 18 pub enum FarmConfigScope { 19 User, 20 Workspace, 21 } 22 23 impl FarmConfigScope { 24 pub fn as_str(self) -> &'static str { 25 match self { 26 Self::User => "user", 27 Self::Workspace => "workspace", 28 } 29 } 30 } 31 32 #[derive(Debug, Clone, Serialize, Deserialize)] 33 #[serde(deny_unknown_fields)] 34 pub struct FarmConfigDocument { 35 pub version: u32, 36 pub selection: FarmConfigSelection, 37 pub profile: RadrootsProfile, 38 pub farm: RadrootsFarm, 39 pub listing_defaults: FarmListingDefaults, 40 #[serde(default)] 41 pub publication: FarmPublicationStatus, 42 } 43 44 #[derive(Debug, Clone, Serialize, Deserialize)] 45 #[serde(deny_unknown_fields)] 46 pub struct FarmConfigSelection { 47 pub scope: FarmConfigScope, 48 pub account: String, 49 pub farm_d_tag: String, 50 } 51 52 #[derive(Debug, Clone, Serialize, Deserialize)] 53 #[serde(deny_unknown_fields)] 54 pub struct FarmListingDefaults { 55 pub delivery_method: String, 56 pub location: RadrootsListingLocation, 57 } 58 59 impl FarmListingDefaults { 60 pub fn delivery_method_model(&self) -> Result<RadrootsListingDeliveryMethod, RuntimeError> { 61 parse_delivery_method(self.delivery_method.as_str()) 62 } 63 } 64 65 #[derive(Debug, Clone, Default, Serialize, Deserialize)] 66 #[serde(deny_unknown_fields)] 67 pub struct FarmPublicationStatus { 68 #[serde(default, skip_serializing_if = "Option::is_none")] 69 pub profile_event_id: Option<String>, 70 #[serde(default, skip_serializing_if = "Option::is_none")] 71 pub farm_event_id: Option<String>, 72 #[serde(default, skip_serializing_if = "Option::is_none")] 73 pub profile_published_at: Option<u64>, 74 #[serde(default, skip_serializing_if = "Option::is_none")] 75 pub farm_published_at: Option<u64>, 76 } 77 78 #[derive(Debug, Clone)] 79 pub struct ResolvedFarmConfig { 80 pub scope: FarmConfigScope, 81 pub path: PathBuf, 82 pub document: FarmConfigDocument, 83 } 84 85 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 86 pub enum FarmMissingField { 87 Name, 88 Location, 89 Delivery, 90 Country, 91 } 92 93 impl FarmMissingField { 94 pub fn label(self) -> &'static str { 95 match self { 96 Self::Name => "Farm name", 97 Self::Location => "Location", 98 Self::Delivery => "Delivery method", 99 Self::Country => "Country", 100 } 101 } 102 } 103 104 pub fn resolve_scope( 105 paths: &PathsConfig, 106 explicit_scope: Option<FarmConfigScope>, 107 ) -> Result<FarmConfigScope, RuntimeError> { 108 if let Some(scope) = explicit_scope { 109 return Ok(scope); 110 } 111 match paths.profile.as_str() { 112 "repo_local" => Ok(FarmConfigScope::Workspace), 113 "interactive_user" => Ok(FarmConfigScope::User), 114 other => Err(RuntimeError::Config(format!( 115 "unsupported farm config path profile `{other}`" 116 ))), 117 } 118 } 119 120 pub fn user_config_path(paths: &PathsConfig) -> Result<PathBuf, RuntimeError> { 121 let Some(parent) = paths.app_config_path.parent() else { 122 return Err(RuntimeError::Config(format!( 123 "app config path {} has no parent directory", 124 paths.app_config_path.display() 125 ))); 126 }; 127 Ok(parent.join(FARM_CONFIG_FILE_NAME)) 128 } 129 130 pub fn workspace_config_path(paths: &PathsConfig) -> Result<PathBuf, RuntimeError> { 131 let Some(path) = paths.workspace_config_path.as_ref() else { 132 return Err(RuntimeError::Config(format!( 133 "workspace farm config requires repo_local path profile, got `{}`", 134 paths.profile 135 ))); 136 }; 137 let Some(parent) = path.parent() else { 138 return Err(RuntimeError::Config(format!( 139 "workspace config path {} has no parent directory", 140 path.display() 141 ))); 142 }; 143 Ok(parent.join("config/apps/cli").join(FARM_CONFIG_FILE_NAME)) 144 } 145 146 pub fn config_path(paths: &PathsConfig, scope: FarmConfigScope) -> Result<PathBuf, RuntimeError> { 147 match scope { 148 FarmConfigScope::User => user_config_path(paths), 149 FarmConfigScope::Workspace => workspace_config_path(paths), 150 } 151 } 152 153 pub fn load( 154 config: &RuntimeConfig, 155 explicit_scope: Option<FarmConfigScope>, 156 ) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { 157 load_from_paths(&config.paths, explicit_scope) 158 } 159 160 pub fn load_from_paths( 161 paths: &PathsConfig, 162 explicit_scope: Option<FarmConfigScope>, 163 ) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { 164 let scope = resolve_scope(paths, explicit_scope)?; 165 let path = config_path(paths, scope)?; 166 load_from_path(path.as_path(), scope) 167 } 168 169 pub fn load_from_path( 170 path: &Path, 171 scope: FarmConfigScope, 172 ) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { 173 if !path.exists() { 174 return Ok(None); 175 } 176 let contents = fs::read_to_string(path)?; 177 let document: FarmConfigDocument = toml::from_str(contents.as_str()).map_err(|error| { 178 RuntimeError::Config(format!("parse farm config {}: {error}", path.display())) 179 })?; 180 validate(&document, scope)?; 181 Ok(Some(ResolvedFarmConfig { 182 scope, 183 path: path.to_path_buf(), 184 document, 185 })) 186 } 187 188 pub fn write( 189 paths: &PathsConfig, 190 scope: FarmConfigScope, 191 document: &FarmConfigDocument, 192 ) -> Result<PathBuf, RuntimeError> { 193 validate(document, scope)?; 194 let path = config_path(paths, scope)?; 195 let Some(parent) = path.parent() else { 196 return Err(RuntimeError::Config(format!( 197 "farm config path {} has no parent directory", 198 path.display() 199 ))); 200 }; 201 fs::create_dir_all(parent)?; 202 let encoded = toml::to_string_pretty(document).map_err(|error| { 203 RuntimeError::Config(format!("encode farm config {}: {error}", path.display())) 204 })?; 205 fs::write(&path, encoded)?; 206 Ok(path) 207 } 208 209 pub fn validate( 210 document: &FarmConfigDocument, 211 resolved_scope: FarmConfigScope, 212 ) -> Result<(), RuntimeError> { 213 if document.version != SUPPORTED_FARM_CONFIG_VERSION { 214 return Err(RuntimeError::Config(format!( 215 "farm config version must be {}, got {}", 216 SUPPORTED_FARM_CONFIG_VERSION, document.version 217 ))); 218 } 219 if document.selection.scope != resolved_scope { 220 return Err(RuntimeError::Config(format!( 221 "farm config scope `{}` does not match resolved `{}` scope", 222 document.selection.scope.as_str(), 223 resolved_scope.as_str() 224 ))); 225 } 226 if trimmed(document.selection.account.as_str()).is_empty() { 227 return Err(RuntimeError::Config( 228 "farm config selection.account must not be empty".to_owned(), 229 )); 230 } 231 if trimmed(document.selection.farm_d_tag.as_str()).is_empty() { 232 return Err(RuntimeError::Config( 233 "farm config selection.farm_d_tag must not be empty".to_owned(), 234 )); 235 } 236 if !is_d_tag_base64url(trimmed(document.selection.farm_d_tag.as_str())) { 237 return Err(RuntimeError::Config( 238 "farm config selection.farm_d_tag must be a 22-character base64url identifier" 239 .to_owned(), 240 )); 241 } 242 if trimmed(document.farm.d_tag.as_str()).is_empty() { 243 return Err(RuntimeError::Config( 244 "farm config farm.d_tag must not be empty".to_owned(), 245 )); 246 } 247 if !is_d_tag_base64url(trimmed(document.farm.d_tag.as_str())) { 248 return Err(RuntimeError::Config( 249 "farm config farm.d_tag must be a 22-character base64url identifier".to_owned(), 250 )); 251 } 252 if trimmed(document.selection.farm_d_tag.as_str()) != trimmed(document.farm.d_tag.as_str()) { 253 return Err(RuntimeError::Config( 254 "farm config selection.farm_d_tag must match farm.d_tag".to_owned(), 255 )); 256 } 257 if !trimmed(document.listing_defaults.delivery_method.as_str()).is_empty() { 258 let _ = document.listing_defaults.delivery_method_model()?; 259 } 260 Ok(()) 261 } 262 263 pub fn missing_fields(document: &FarmConfigDocument) -> Vec<FarmMissingField> { 264 let mut missing = Vec::new(); 265 266 if farm_name(document).is_none() { 267 missing.push(FarmMissingField::Name); 268 } 269 270 let location_present = location_primary(document).is_some(); 271 if !location_present { 272 missing.push(FarmMissingField::Location); 273 } 274 275 if trimmed(document.listing_defaults.delivery_method.as_str()).is_empty() { 276 missing.push(FarmMissingField::Delivery); 277 } 278 279 if location_present && location_country(document).is_none() { 280 missing.push(FarmMissingField::Country); 281 } 282 283 missing 284 } 285 286 fn farm_name(document: &FarmConfigDocument) -> Option<&str> { 287 non_empty_ref(document.profile.name.as_str()) 288 .or_else(|| non_empty_ref(document.farm.name.as_str())) 289 } 290 291 fn location_primary(document: &FarmConfigDocument) -> Option<&str> { 292 non_empty_ref(document.listing_defaults.location.primary.as_str()).or_else(|| { 293 document 294 .farm 295 .location 296 .as_ref() 297 .and_then(|location| location.primary.as_deref()) 298 .and_then(non_empty_ref) 299 }) 300 } 301 302 fn location_country(document: &FarmConfigDocument) -> Option<&str> { 303 document 304 .listing_defaults 305 .location 306 .country 307 .as_deref() 308 .and_then(non_empty_ref) 309 .or_else(|| { 310 document 311 .farm 312 .location 313 .as_ref() 314 .and_then(|location| location.country.as_deref()) 315 .and_then(non_empty_ref) 316 }) 317 } 318 319 fn parse_delivery_method(value: &str) -> Result<RadrootsListingDeliveryMethod, RuntimeError> { 320 let method = trimmed(value); 321 if method.is_empty() { 322 return Err(RuntimeError::Config( 323 "farm config listing_defaults.delivery_method must not be empty".to_owned(), 324 )); 325 } 326 Ok(match method { 327 "pickup" => RadrootsListingDeliveryMethod::Pickup, 328 "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery, 329 "shipping" => RadrootsListingDeliveryMethod::Shipping, 330 other => RadrootsListingDeliveryMethod::Other { 331 method: other.to_owned(), 332 }, 333 }) 334 } 335 336 fn trimmed(value: &str) -> &str { 337 value.trim() 338 } 339 340 fn non_empty_ref(value: &str) -> Option<&str> { 341 let trimmed = trimmed(value); 342 if trimmed.is_empty() { 343 None 344 } else { 345 Some(trimmed) 346 } 347 } 348 349 #[cfg(test)] 350 mod tests { 351 use super::*; 352 353 use std::path::PathBuf; 354 355 use radroots_events::farm::RadrootsFarmLocation; 356 use tempfile::tempdir; 357 358 fn sample_paths(profile: &str, root: &Path) -> PathsConfig { 359 let repo_local_root = root.join("infra/local/runtime/radroots"); 360 PathsConfig { 361 profile: profile.to_owned(), 362 profile_source: "test".to_owned(), 363 allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned()], 364 root_source: "test".to_owned(), 365 repo_local_root: Some(repo_local_root.clone()), 366 repo_local_root_source: Some("test".to_owned()), 367 subordinate_path_override_source: "test".to_owned(), 368 app_namespace: "apps/cli".to_owned(), 369 shared_accounts_namespace: "shared/accounts".to_owned(), 370 shared_identities_namespace: "shared/identities".to_owned(), 371 app_config_path: root.join("home/.radroots/config/apps/cli/config.toml"), 372 workspace_config_path: (profile == "repo_local") 373 .then(|| repo_local_root.join("config.toml")), 374 app_data_root: root.join("home/.radroots/data/apps/cli"), 375 app_logs_root: root.join("home/.radroots/logs/apps/cli"), 376 shared_accounts_data_root: root.join("home/.radroots/data/shared/accounts"), 377 shared_accounts_secrets_root: root.join("home/.radroots/secrets/shared/accounts"), 378 default_identity_path: root 379 .join("home/.radroots/secrets/shared/identities/default.json"), 380 } 381 } 382 383 fn sample_document(scope: FarmConfigScope) -> FarmConfigDocument { 384 FarmConfigDocument { 385 version: SUPPORTED_FARM_CONFIG_VERSION, 386 selection: FarmConfigSelection { 387 scope, 388 account: "seller".to_owned(), 389 farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), 390 }, 391 profile: RadrootsProfile { 392 name: "La Huerta".to_owned(), 393 display_name: Some("La Huerta".to_owned()), 394 nip05: None, 395 about: Some("Small mixed vegetable farm.".to_owned()), 396 website: Some("https://example.invalid/la-huerta".to_owned()), 397 picture: None, 398 banner: None, 399 lud06: None, 400 lud16: None, 401 bot: None, 402 }, 403 farm: RadrootsFarm { 404 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), 405 name: "La Huerta".to_owned(), 406 about: Some("Small mixed vegetable farm.".to_owned()), 407 website: Some("https://example.invalid/la-huerta".to_owned()), 408 picture: None, 409 banner: None, 410 location: Some(RadrootsFarmLocation { 411 primary: Some("San Francisco, CA".to_owned()), 412 city: Some("San Francisco".to_owned()), 413 region: Some("CA".to_owned()), 414 country: Some("US".to_owned()), 415 gcs: None, 416 }), 417 tags: None, 418 }, 419 listing_defaults: FarmListingDefaults { 420 delivery_method: "pickup".to_owned(), 421 location: RadrootsListingLocation { 422 primary: "San Francisco, CA".to_owned(), 423 city: Some("San Francisco".to_owned()), 424 region: Some("CA".to_owned()), 425 country: Some("US".to_owned()), 426 lat: None, 427 lng: None, 428 geohash: None, 429 }, 430 }, 431 publication: FarmPublicationStatus::default(), 432 } 433 } 434 435 #[test] 436 fn resolve_scope_defaults_from_runtime_profile() { 437 let dir = tempdir().expect("tempdir"); 438 let interactive_paths = sample_paths("interactive_user", dir.path()); 439 let repo_local_paths = sample_paths("repo_local", dir.path()); 440 441 assert_eq!( 442 resolve_scope(&interactive_paths, None).expect("interactive scope"), 443 FarmConfigScope::User 444 ); 445 assert_eq!( 446 resolve_scope(&repo_local_paths, None).expect("repo_local scope"), 447 FarmConfigScope::Workspace 448 ); 449 } 450 451 #[test] 452 fn explicit_scope_override_selects_requested_document() { 453 let dir = tempdir().expect("tempdir"); 454 let paths = sample_paths("repo_local", dir.path()); 455 let document = sample_document(FarmConfigScope::User); 456 let path = write(&paths, FarmConfigScope::User, &document).expect("write user farm config"); 457 458 let resolved = 459 load_from_paths(&paths, Some(FarmConfigScope::User)).expect("load user farm config"); 460 let resolved = resolved.expect("resolved farm config"); 461 462 assert_eq!(resolved.scope, FarmConfigScope::User); 463 assert_eq!(resolved.path, path); 464 assert_eq!(resolved.document.selection.account, "seller"); 465 assert_eq!(resolved.document.selection.scope, FarmConfigScope::User); 466 } 467 468 #[test] 469 fn write_and_load_workspace_config_round_trip() { 470 let dir = tempdir().expect("tempdir"); 471 let paths = sample_paths("repo_local", dir.path()); 472 let document = sample_document(FarmConfigScope::Workspace); 473 let expected_path = PathBuf::from(dir.path()) 474 .join("infra/local/runtime/radroots/config/apps/cli/farm.toml"); 475 476 let written_path = 477 write(&paths, FarmConfigScope::Workspace, &document).expect("write workspace config"); 478 let resolved = load_from_paths(&paths, None).expect("load workspace config"); 479 let resolved = resolved.expect("resolved farm config"); 480 481 assert_eq!(written_path, expected_path); 482 assert_eq!(resolved.path, expected_path); 483 assert_eq!(resolved.scope, FarmConfigScope::Workspace); 484 assert_eq!( 485 resolved.document.selection.scope, 486 FarmConfigScope::Workspace 487 ); 488 assert_eq!( 489 resolved.document.selection.farm_d_tag, 490 "AAAAAAAAAAAAAAAAAAAAAA" 491 ); 492 assert_eq!(resolved.document.farm.d_tag, "AAAAAAAAAAAAAAAAAAAAAA"); 493 assert_eq!( 494 resolved.document.listing_defaults.location.primary, 495 "San Francisco, CA" 496 ); 497 } 498 499 #[test] 500 fn workspace_config_write_requires_repo_local_profile() { 501 let dir = tempdir().expect("tempdir"); 502 let paths = sample_paths("interactive_user", dir.path()); 503 let document = sample_document(FarmConfigScope::Workspace); 504 let repo_local_root = dir.path().join("infra/local/runtime/radroots"); 505 506 let error = write(&paths, FarmConfigScope::Workspace, &document) 507 .expect_err("interactive workspace farm config should fail"); 508 509 match error { 510 RuntimeError::Config(message) => { 511 assert!(message.contains("requires repo_local path profile")); 512 assert!(message.contains("interactive_user")); 513 } 514 other => panic!("expected config error, got {other:?}"), 515 } 516 assert!(!repo_local_root.exists()); 517 } 518 519 #[test] 520 fn load_rejects_scope_mismatch() { 521 let dir = tempdir().expect("tempdir"); 522 let paths = sample_paths("repo_local", dir.path()); 523 let path = workspace_config_path(&paths).expect("workspace farm path"); 524 let Some(parent) = path.parent() else { 525 panic!("workspace farm path should have parent"); 526 }; 527 fs::create_dir_all(parent).expect("create workspace farm config dir"); 528 let contents = toml::to_string_pretty(&sample_document(FarmConfigScope::User)) 529 .expect("encode mismatched farm config"); 530 fs::write(&path, contents).expect("write mismatched farm config"); 531 532 let error = load_from_paths(&paths, None).expect_err("scope mismatch should fail"); 533 match error { 534 RuntimeError::Config(message) => { 535 assert!(message.contains("does not match resolved `workspace` scope")); 536 } 537 other => panic!("expected config error, got {other:?}"), 538 } 539 } 540 541 #[test] 542 fn load_rejects_unsupported_version() { 543 let dir = tempdir().expect("tempdir"); 544 let paths = sample_paths("interactive_user", dir.path()); 545 let path = user_config_path(&paths).expect("user farm path"); 546 let Some(parent) = path.parent() else { 547 panic!("user farm path should have parent"); 548 }; 549 fs::create_dir_all(parent).expect("create user farm config dir"); 550 let mut document = sample_document(FarmConfigScope::User); 551 document.version = 2; 552 let contents = toml::to_string_pretty(&document).expect("encode version mismatch"); 553 fs::write(&path, contents).expect("write version mismatch config"); 554 555 let error = load_from_paths(&paths, None).expect_err("version mismatch should fail"); 556 match error { 557 RuntimeError::Config(message) => { 558 assert!(message.contains("farm config version must be 1, got 2")); 559 } 560 other => panic!("expected config error, got {other:?}"), 561 } 562 } 563 }