cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

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 }