cli

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

basket.rs (73022B)


      1 use std::fs;
      2 use std::path::{Path, PathBuf};
      3 use std::sync::atomic::{AtomicU64, Ordering};
      4 use std::time::{SystemTime, UNIX_EPOCH};
      5 
      6 use radroots_events::order::RadrootsOrderEconomics;
      7 use radroots_replica_db::{ReplicaSql, trade_product};
      8 use radroots_replica_db_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany};
      9 use radroots_sql_core::SqliteExecutor;
     10 use serde::{Deserialize, Serialize};
     11 use serde_json::{Value, json};
     12 
     13 use crate::cli::global::{OrderDraftAdjustmentArgs, OrderDraftCreateArgs};
     14 use crate::ops::{
     15     BasketAdjustmentAddRequest, BasketAdjustmentAddResult, BasketAdjustmentRemoveRequest,
     16     BasketAdjustmentRemoveResult, BasketCreateRequest, BasketCreateResult, BasketGetRequest,
     17     BasketGetResult, BasketItemAddRequest, BasketItemAddResult, BasketItemRemoveRequest,
     18     BasketItemRemoveResult, BasketItemUpdateRequest, BasketItemUpdateResult, BasketListRequest,
     19     BasketListResult, BasketQuoteCreateRequest, BasketQuoteCreateResult, BasketValidateRequest,
     20     BasketValidateResult, OperationAdapterError, OperationRequest, OperationRequestData,
     21     OperationRequestPayload, OperationResult, OperationResultData, OperationService,
     22 };
     23 use crate::runtime::config::RuntimeConfig;
     24 use crate::view::runtime::OrderNewView;
     25 
     26 const BASKET_KIND: &str = "basket_v1";
     27 const BASKET_SOURCE: &str = "local baskets - local first";
     28 const BASKET_QUOTE_SOURCE: &str = "local baskets - deterministic quote";
     29 const BASKETS_DIR: &str = "baskets";
     30 
     31 static BASKET_COUNTER: AtomicU64 = AtomicU64::new(0);
     32 
     33 #[derive(Debug, Clone, Serialize, Deserialize)]
     34 #[serde(deny_unknown_fields)]
     35 struct BasketDocument {
     36     version: u32,
     37     kind: String,
     38     basket: BasketState,
     39     #[serde(default, skip_serializing_if = "Option::is_none")]
     40     quote: Option<BasketQuote>,
     41 }
     42 
     43 #[derive(Debug, Clone, Serialize, Deserialize)]
     44 #[serde(deny_unknown_fields)]
     45 struct BasketState {
     46     basket_id: String,
     47     created_at_unix: u64,
     48     updated_at_unix: u64,
     49     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     50     items: Vec<BasketItem>,
     51     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     52     adjustments: Vec<BasketAdjustment>,
     53 }
     54 
     55 #[derive(Debug, Clone, Serialize, Deserialize)]
     56 #[serde(deny_unknown_fields)]
     57 struct BasketItem {
     58     item_id: String,
     59     #[serde(default, skip_serializing_if = "Option::is_none")]
     60     listing: Option<String>,
     61     #[serde(default, skip_serializing_if = "Option::is_none")]
     62     listing_addr: Option<String>,
     63     bin_id: String,
     64     quantity: u32,
     65 }
     66 
     67 #[derive(Debug, Clone, Serialize, Deserialize)]
     68 #[serde(deny_unknown_fields)]
     69 struct BasketAdjustment {
     70     id: String,
     71     effect: String,
     72     amount: String,
     73     currency: String,
     74     reason: String,
     75 }
     76 
     77 #[derive(Debug, Clone, Serialize, Deserialize)]
     78 #[serde(deny_unknown_fields)]
     79 struct BasketQuote {
     80     quote_id: String,
     81     quote_version: u32,
     82     order_id: String,
     83     order_file: String,
     84     #[serde(default, skip_serializing_if = "Option::is_none")]
     85     economics: Option<RadrootsOrderEconomics>,
     86     ready_for_submit: bool,
     87     created_at_unix: u64,
     88     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     89     issues: Vec<BasketIssue>,
     90 }
     91 
     92 #[derive(Debug, Clone, Serialize, Deserialize)]
     93 #[serde(deny_unknown_fields)]
     94 struct BasketIssue {
     95     code: String,
     96     field: String,
     97     message: String,
     98 }
     99 
    100 #[derive(Debug, Clone)]
    101 struct LoadedBasket {
    102     file: PathBuf,
    103     document: BasketDocument,
    104 }
    105 
    106 #[derive(Debug, Clone)]
    107 struct BasketProductBinState {
    108     primary_bin_id: Option<String>,
    109     verified_primary_bin_id: Option<String>,
    110 }
    111 
    112 #[derive(Debug, Clone)]
    113 enum BasketProductResolution {
    114     Resolved(BasketProductBinState),
    115     Unresolved,
    116     Ambiguous(usize),
    117 }
    118 
    119 pub struct BasketOperationService<'a> {
    120     config: &'a RuntimeConfig,
    121 }
    122 
    123 impl<'a> BasketOperationService<'a> {
    124     pub fn new(config: &'a RuntimeConfig) -> Self {
    125         Self { config }
    126     }
    127 }
    128 
    129 impl OperationService<BasketCreateRequest> for BasketOperationService<'_> {
    130     type Result = BasketCreateResult;
    131 
    132     fn execute(
    133         &self,
    134         request: OperationRequest<BasketCreateRequest>,
    135     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    136         let basket_id = string_input(&request, "basket_id").unwrap_or_else(next_basket_id);
    137         let initial_item = optional_item_from_request(&request, None)?;
    138         let file = basket_lookup_path(self.config, basket_id.as_str());
    139         if file.exists() {
    140             return Err(invalid_input(
    141                 request.operation_id(),
    142                 format!("basket `{basket_id}` already exists"),
    143             ));
    144         }
    145         if request.context.dry_run {
    146             return json_operation_result::<BasketCreateResult>(json!({
    147                 "state": "dry_run",
    148                 "source": BASKET_SOURCE,
    149                 "basket_id": basket_id,
    150                 "file": file.display().to_string(),
    151                 "item_count": initial_item.as_ref().map(|_| 1).unwrap_or(0),
    152                 "actions": ["radroots basket create"],
    153             }));
    154         }
    155 
    156         let now = now_unix();
    157         let document = BasketDocument {
    158             version: 1,
    159             kind: BASKET_KIND.to_owned(),
    160             basket: BasketState {
    161                 basket_id,
    162                 created_at_unix: now,
    163                 updated_at_unix: now,
    164                 items: initial_item.into_iter().collect(),
    165                 adjustments: Vec::new(),
    166             },
    167             quote: None,
    168         };
    169         save_basket(file.as_path(), &document)?;
    170         json_operation_result::<BasketCreateResult>(basket_view(
    171             self.config,
    172             &document,
    173             file.as_path(),
    174             None,
    175         )?)
    176     }
    177 }
    178 
    179 impl OperationService<BasketGetRequest> for BasketOperationService<'_> {
    180     type Result = BasketGetResult;
    181 
    182     fn execute(
    183         &self,
    184         request: OperationRequest<BasketGetRequest>,
    185     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    186         let lookup = required_basket_id(&request)?;
    187         let Some(loaded) = load_basket_optional(self.config, lookup.as_str())? else {
    188             return json_operation_result::<BasketGetResult>(missing_basket_view(
    189                 self.config,
    190                 lookup.as_str(),
    191             ));
    192         };
    193         json_operation_result::<BasketGetResult>(basket_view(
    194             self.config,
    195             &loaded.document,
    196             loaded.file.as_path(),
    197             None,
    198         )?)
    199     }
    200 }
    201 
    202 impl OperationService<BasketListRequest> for BasketOperationService<'_> {
    203     type Result = BasketListResult;
    204 
    205     fn execute(
    206         &self,
    207         _request: OperationRequest<BasketListRequest>,
    208     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    209         let baskets = list_basket_summaries(self.config)?;
    210         json_operation_result::<BasketListResult>(json!({
    211             "state": if baskets.is_empty() { "empty" } else { "ready" },
    212             "source": BASKET_SOURCE,
    213             "count": baskets.len(),
    214             "baskets": baskets,
    215             "actions": if baskets.is_empty() {
    216                 vec!["radroots basket create".to_owned()]
    217             } else {
    218                 Vec::new()
    219             },
    220         }))
    221     }
    222 }
    223 
    224 impl OperationService<BasketItemAddRequest> for BasketOperationService<'_> {
    225     type Result = BasketItemAddResult;
    226 
    227     fn execute(
    228         &self,
    229         request: OperationRequest<BasketItemAddRequest>,
    230     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    231         let basket_id = required_basket_id(&request)?;
    232         let mut loaded =
    233             load_required_basket(self.config, basket_id.as_str(), request.operation_id())?;
    234         let item = required_item_from_request(&request, Some(next_item_id(&loaded.document)))?;
    235         if request.context.dry_run {
    236             return json_operation_result::<BasketItemAddResult>(json!({
    237                 "state": "dry_run",
    238                 "source": BASKET_SOURCE,
    239                 "basket_id": basket_id,
    240                 "item": item,
    241                 "actions": ["radroots basket item add"],
    242             }));
    243         }
    244 
    245         loaded.document.basket.items.push(item);
    246         touch_basket(&mut loaded.document);
    247         loaded.document.quote = None;
    248         save_basket(loaded.file.as_path(), &loaded.document)?;
    249         json_operation_result::<BasketItemAddResult>(basket_view(
    250             self.config,
    251             &loaded.document,
    252             loaded.file.as_path(),
    253             Some("updated"),
    254         )?)
    255     }
    256 }
    257 
    258 impl OperationService<BasketItemUpdateRequest> for BasketOperationService<'_> {
    259     type Result = BasketItemUpdateResult;
    260 
    261     fn execute(
    262         &self,
    263         request: OperationRequest<BasketItemUpdateRequest>,
    264     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    265         let basket_id = required_basket_id(&request)?;
    266         let item_id = required_string(&request, "item_id")?;
    267         let mut loaded =
    268             load_required_basket(self.config, basket_id.as_str(), request.operation_id())?;
    269         let Some(index) = loaded
    270             .document
    271             .basket
    272             .items
    273             .iter()
    274             .position(|item| item.item_id == item_id)
    275         else {
    276             return Err(invalid_input(
    277                 request.operation_id(),
    278                 format!("basket item `{item_id}` was not found"),
    279             ));
    280         };
    281 
    282         let updated =
    283             update_item_from_request(&request, loaded.document.basket.items[index].clone())?;
    284         if request.context.dry_run {
    285             return json_operation_result::<BasketItemUpdateResult>(json!({
    286                 "state": "dry_run",
    287                 "source": BASKET_SOURCE,
    288                 "basket_id": basket_id,
    289                 "item": updated,
    290                 "actions": ["radroots basket item update"],
    291             }));
    292         }
    293 
    294         loaded.document.basket.items[index] = updated;
    295         touch_basket(&mut loaded.document);
    296         loaded.document.quote = None;
    297         save_basket(loaded.file.as_path(), &loaded.document)?;
    298         json_operation_result::<BasketItemUpdateResult>(basket_view(
    299             self.config,
    300             &loaded.document,
    301             loaded.file.as_path(),
    302             Some("updated"),
    303         )?)
    304     }
    305 }
    306 
    307 impl OperationService<BasketItemRemoveRequest> for BasketOperationService<'_> {
    308     type Result = BasketItemRemoveResult;
    309 
    310     fn execute(
    311         &self,
    312         request: OperationRequest<BasketItemRemoveRequest>,
    313     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    314         let basket_id = required_basket_id(&request)?;
    315         let item_id = required_string(&request, "item_id")?;
    316         let mut loaded =
    317             load_required_basket(self.config, basket_id.as_str(), request.operation_id())?;
    318         let Some(index) = loaded
    319             .document
    320             .basket
    321             .items
    322             .iter()
    323             .position(|item| item.item_id == item_id)
    324         else {
    325             return Err(invalid_input(
    326                 request.operation_id(),
    327                 format!("basket item `{item_id}` was not found"),
    328             ));
    329         };
    330 
    331         if request.context.dry_run {
    332             return json_operation_result::<BasketItemRemoveResult>(json!({
    333                 "state": "dry_run",
    334                 "source": BASKET_SOURCE,
    335                 "basket_id": basket_id,
    336                 "item_id": item_id,
    337                 "actions": ["radroots basket item remove"],
    338             }));
    339         }
    340 
    341         loaded.document.basket.items.remove(index);
    342         touch_basket(&mut loaded.document);
    343         loaded.document.quote = None;
    344         save_basket(loaded.file.as_path(), &loaded.document)?;
    345         json_operation_result::<BasketItemRemoveResult>(basket_view(
    346             self.config,
    347             &loaded.document,
    348             loaded.file.as_path(),
    349             Some("updated"),
    350         )?)
    351     }
    352 }
    353 
    354 impl OperationService<BasketAdjustmentAddRequest> for BasketOperationService<'_> {
    355     type Result = BasketAdjustmentAddResult;
    356 
    357     fn execute(
    358         &self,
    359         request: OperationRequest<BasketAdjustmentAddRequest>,
    360     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    361         let basket_id = required_basket_id(&request)?;
    362         let mut loaded =
    363             load_required_basket(self.config, basket_id.as_str(), request.operation_id())?;
    364         let adjustment = required_adjustment_from_request(&request)?;
    365         if loaded
    366             .document
    367             .basket
    368             .adjustments
    369             .iter()
    370             .any(|existing| existing.id == adjustment.id)
    371         {
    372             return Err(invalid_input(
    373                 request.operation_id(),
    374                 format!("basket adjustment `{}` already exists", adjustment.id),
    375             ));
    376         }
    377         if request.context.dry_run {
    378             return json_operation_result::<BasketAdjustmentAddResult>(json!({
    379                 "state": "dry_run",
    380                 "source": BASKET_SOURCE,
    381                 "basket_id": basket_id,
    382                 "adjustment": adjustment,
    383                 "actions": ["radroots basket adjustment add"],
    384             }));
    385         }
    386 
    387         loaded.document.basket.adjustments.push(adjustment);
    388         touch_basket(&mut loaded.document);
    389         loaded.document.quote = None;
    390         save_basket(loaded.file.as_path(), &loaded.document)?;
    391         json_operation_result::<BasketAdjustmentAddResult>(basket_view(
    392             self.config,
    393             &loaded.document,
    394             loaded.file.as_path(),
    395             Some("updated"),
    396         )?)
    397     }
    398 }
    399 
    400 impl OperationService<BasketAdjustmentRemoveRequest> for BasketOperationService<'_> {
    401     type Result = BasketAdjustmentRemoveResult;
    402 
    403     fn execute(
    404         &self,
    405         request: OperationRequest<BasketAdjustmentRemoveRequest>,
    406     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    407         let basket_id = required_basket_id(&request)?;
    408         let adjustment_id = required_string(&request, "id")?;
    409         let mut loaded =
    410             load_required_basket(self.config, basket_id.as_str(), request.operation_id())?;
    411         let Some(index) = loaded
    412             .document
    413             .basket
    414             .adjustments
    415             .iter()
    416             .position(|adjustment| adjustment.id == adjustment_id)
    417         else {
    418             return Err(invalid_input(
    419                 request.operation_id(),
    420                 format!("basket adjustment `{adjustment_id}` was not found"),
    421             ));
    422         };
    423         if request.context.dry_run {
    424             return json_operation_result::<BasketAdjustmentRemoveResult>(json!({
    425                 "state": "dry_run",
    426                 "source": BASKET_SOURCE,
    427                 "basket_id": basket_id,
    428                 "adjustment_id": adjustment_id,
    429                 "actions": ["radroots basket adjustment remove"],
    430             }));
    431         }
    432 
    433         loaded.document.basket.adjustments.remove(index);
    434         touch_basket(&mut loaded.document);
    435         loaded.document.quote = None;
    436         save_basket(loaded.file.as_path(), &loaded.document)?;
    437         json_operation_result::<BasketAdjustmentRemoveResult>(basket_view(
    438             self.config,
    439             &loaded.document,
    440             loaded.file.as_path(),
    441             Some("updated"),
    442         )?)
    443     }
    444 }
    445 
    446 impl OperationService<BasketValidateRequest> for BasketOperationService<'_> {
    447     type Result = BasketValidateResult;
    448 
    449     fn execute(
    450         &self,
    451         request: OperationRequest<BasketValidateRequest>,
    452     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    453         let basket_id = required_basket_id(&request)?;
    454         let Some(loaded) = load_basket_optional(self.config, basket_id.as_str())? else {
    455             return json_operation_result::<BasketValidateResult>(missing_basket_view(
    456                 self.config,
    457                 basket_id.as_str(),
    458             ));
    459         };
    460         json_operation_result::<BasketValidateResult>(basket_validation_view(
    461             self.config,
    462             &loaded.document,
    463             loaded.file.as_path(),
    464         )?)
    465     }
    466 }
    467 
    468 impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> {
    469     type Result = BasketQuoteCreateResult;
    470 
    471     fn execute(
    472         &self,
    473         request: OperationRequest<BasketQuoteCreateRequest>,
    474     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    475         let basket_id = required_basket_id(&request)?;
    476         let mut loaded =
    477             load_required_basket(self.config, basket_id.as_str(), request.operation_id())?;
    478         let issues = basket_issues(self.config, &loaded.document)?;
    479         if !issues.is_empty() {
    480             let actions = basket_actions(&loaded.document, issues.as_slice());
    481             return json_operation_result::<BasketQuoteCreateResult>(json!({
    482                 "state": "unconfigured",
    483                 "source": BASKET_QUOTE_SOURCE,
    484                 "basket_id": basket_id,
    485                 "file": loaded.file.display().to_string(),
    486                 "ready_for_quote": false,
    487                 "issues": issues,
    488                 "actions": actions,
    489             }));
    490         }
    491 
    492         let item = loaded
    493             .document
    494             .basket
    495             .items
    496             .first()
    497             .expect("validated basket has one item")
    498             .clone();
    499         if request.context.dry_run {
    500             let order = crate::runtime::order::scaffold_preflight(
    501                 self.config,
    502                 &OrderDraftCreateArgs {
    503                     listing: item.listing.clone(),
    504                     listing_addr: item.listing_addr.clone(),
    505                     bin_id: Some(item.bin_id.clone()),
    506                     bin_count: Some(item.quantity),
    507                     adjustments: order_adjustments_from_basket(&loaded.document),
    508                 },
    509             )
    510             .map_err(|error| {
    511                 OperationAdapterError::runtime_failure(request.operation_id(), error)
    512             })?;
    513             return json_operation_result::<BasketQuoteCreateResult>(json!({
    514                 "state": "dry_run",
    515                 "source": BASKET_QUOTE_SOURCE,
    516                 "basket_id": basket_id,
    517                 "file": loaded.file.display().to_string(),
    518                 "item": item,
    519                 "order": order,
    520                 "actions": ["radroots basket quote create"],
    521             }));
    522         }
    523 
    524         let order = crate::runtime::order::scaffold(
    525             self.config,
    526             &OrderDraftCreateArgs {
    527                 listing: item.listing.clone(),
    528                 listing_addr: item.listing_addr.clone(),
    529                 bin_id: Some(item.bin_id.clone()),
    530                 bin_count: Some(item.quantity),
    531                 adjustments: order_adjustments_from_basket(&loaded.document),
    532             },
    533         )
    534         .map_err(|error| OperationAdapterError::runtime_failure(request.operation_id(), error))?;
    535         let quote_economics = order.economics.clone();
    536         let quote = BasketQuote {
    537             quote_id: quote_economics
    538                 .as_ref()
    539                 .map(|economics| economics.quote_id.to_string())
    540                 .unwrap_or_else(|| format!("quote_{}", loaded.document.basket.basket_id)),
    541             quote_version: quote_economics
    542                 .as_ref()
    543                 .map(|economics| economics.quote_version)
    544                 .unwrap_or(1),
    545             order_id: order.order_id.clone(),
    546             order_file: order.file.clone(),
    547             economics: quote_economics,
    548             ready_for_submit: order.ready_for_submit,
    549             created_at_unix: now_unix(),
    550             issues: quote_issues_from_order(&order),
    551         };
    552         loaded.document.quote = Some(quote.clone());
    553         touch_basket(&mut loaded.document);
    554         save_basket(loaded.file.as_path(), &loaded.document)?;
    555 
    556         json_operation_result::<BasketQuoteCreateResult>(json!({
    557             "state": "quoted",
    558             "source": BASKET_QUOTE_SOURCE,
    559             "basket_id": loaded.document.basket.basket_id,
    560             "file": loaded.file.display().to_string(),
    561             "quote": quote,
    562             "order": order,
    563             "actions": quote_actions(&order),
    564         }))
    565     }
    566 }
    567 
    568 fn optional_item_from_request<P>(
    569     request: &OperationRequest<P>,
    570     item_id: Option<String>,
    571 ) -> Result<Option<BasketItem>, OperationAdapterError>
    572 where
    573     P: OperationRequestPayload + OperationRequestData,
    574 {
    575     if string_input(request, "listing").is_none()
    576         && string_input(request, "listing_addr").is_none()
    577         && string_input(request, "bin_id").is_none()
    578     {
    579         return Ok(None);
    580     }
    581     required_item_from_request(request, item_id).map(Some)
    582 }
    583 
    584 fn required_item_from_request<P>(
    585     request: &OperationRequest<P>,
    586     item_id: Option<String>,
    587 ) -> Result<BasketItem, OperationAdapterError>
    588 where
    589     P: OperationRequestPayload + OperationRequestData,
    590 {
    591     let listing = string_input(request, "listing");
    592     let listing_addr = string_input(request, "listing_addr");
    593     if listing.is_none() && listing_addr.is_none() {
    594         return Err(invalid_input(
    595             request.operation_id(),
    596             "missing required `listing` or `listing_addr` input".to_owned(),
    597         ));
    598     }
    599     let bin_id = required_string(request, "bin_id")?;
    600     let quantity = quantity_input(request)?.unwrap_or(1);
    601     if quantity == 0 {
    602         return Err(invalid_input(
    603             request.operation_id(),
    604             "`quantity` must be greater than 0".to_owned(),
    605         ));
    606     }
    607 
    608     Ok(BasketItem {
    609         item_id: item_id
    610             .or_else(|| string_input(request, "item_id"))
    611             .unwrap_or_else(|| "item_1".to_owned()),
    612         listing,
    613         listing_addr,
    614         bin_id,
    615         quantity,
    616     })
    617 }
    618 
    619 fn update_item_from_request<P>(
    620     request: &OperationRequest<P>,
    621     mut item: BasketItem,
    622 ) -> Result<BasketItem, OperationAdapterError>
    623 where
    624     P: OperationRequestPayload + OperationRequestData,
    625 {
    626     let mut changed = false;
    627     if let Some(listing) = string_input(request, "listing") {
    628         item.listing = Some(listing);
    629         changed = true;
    630     }
    631     if let Some(listing_addr) = string_input(request, "listing_addr") {
    632         item.listing_addr = Some(listing_addr);
    633         changed = true;
    634     }
    635     if let Some(bin_id) = string_input(request, "bin_id") {
    636         item.bin_id = bin_id;
    637         changed = true;
    638     }
    639     if let Some(quantity) = quantity_input(request)? {
    640         if quantity == 0 {
    641             return Err(invalid_input(
    642                 request.operation_id(),
    643                 "`quantity` must be greater than 0".to_owned(),
    644             ));
    645         }
    646         item.quantity = quantity;
    647         changed = true;
    648     }
    649     if !changed {
    650         return Err(invalid_input(
    651             request.operation_id(),
    652             "no item update input was provided".to_owned(),
    653         ));
    654     }
    655     Ok(item)
    656 }
    657 
    658 fn required_adjustment_from_request<P>(
    659     request: &OperationRequest<P>,
    660 ) -> Result<BasketAdjustment, OperationAdapterError>
    661 where
    662     P: OperationRequestPayload + OperationRequestData,
    663 {
    664     let id = required_string(request, "id")?.trim().to_owned();
    665     if id.is_empty() {
    666         return Err(invalid_input(
    667             request.operation_id(),
    668             "`id` must not be empty".to_owned(),
    669         ));
    670     }
    671     let effect = required_string(request, "effect")?.trim().to_owned();
    672     if effect != "increase" && effect != "decrease" {
    673         return Err(invalid_input(
    674             request.operation_id(),
    675             "`effect` must be increase or decrease".to_owned(),
    676         ));
    677     }
    678     let amount = required_string(request, "amount")?.trim().to_owned();
    679     let parsed_amount = amount
    680         .parse::<radroots_core::RadrootsCoreDecimal>()
    681         .map_err(|_| {
    682             invalid_input(
    683                 request.operation_id(),
    684                 "`amount` must be a valid decimal value".to_owned(),
    685             )
    686         })?;
    687     if parsed_amount.is_sign_negative() || parsed_amount.is_zero() {
    688         return Err(invalid_input(
    689             request.operation_id(),
    690             "`amount` must be greater than zero".to_owned(),
    691         ));
    692     }
    693     let currency = required_string(request, "currency")?
    694         .trim()
    695         .to_ascii_uppercase();
    696     if radroots_core::RadrootsCoreCurrency::from_str_upper(currency.as_str()).is_err() {
    697         return Err(invalid_input(
    698             request.operation_id(),
    699             "`currency` must be a valid ISO currency code".to_owned(),
    700         ));
    701     }
    702     let reason = required_string(request, "reason")?.trim().to_owned();
    703     if reason.is_empty() {
    704         return Err(invalid_input(
    705             request.operation_id(),
    706             "`reason` must not be empty".to_owned(),
    707         ));
    708     }
    709     Ok(BasketAdjustment {
    710         id,
    711         effect,
    712         amount,
    713         currency,
    714         reason,
    715     })
    716 }
    717 
    718 fn basket_view(
    719     config: &RuntimeConfig,
    720     document: &BasketDocument,
    721     file: &Path,
    722     state: Option<&str>,
    723 ) -> Result<Value, OperationAdapterError> {
    724     let issues = basket_issues(config, document)?;
    725     let ready_for_quote = issues.is_empty();
    726     let actions = basket_actions(document, issues.as_slice());
    727     Ok(json!({
    728         "state": state.unwrap_or("ready"),
    729         "source": BASKET_SOURCE,
    730         "basket_id": document.basket.basket_id,
    731         "file": file.display().to_string(),
    732         "item_count": document.basket.items.len(),
    733         "items": document.basket.items,
    734         "adjustment_count": document.basket.adjustments.len(),
    735         "adjustments": document.basket.adjustments,
    736         "quote": document.quote,
    737         "ready_for_quote": ready_for_quote,
    738         "issues": issues,
    739         "actions": actions,
    740     }))
    741 }
    742 
    743 fn basket_validation_view(
    744     config: &RuntimeConfig,
    745     document: &BasketDocument,
    746     file: &Path,
    747 ) -> Result<Value, OperationAdapterError> {
    748     let issues = basket_issues(config, document)?;
    749     let ready_for_quote = issues.is_empty();
    750     let actions = basket_actions(document, issues.as_slice());
    751     Ok(json!({
    752         "state": if ready_for_quote { "ready" } else { "unconfigured" },
    753         "source": BASKET_SOURCE,
    754         "basket_id": document.basket.basket_id,
    755         "file": file.display().to_string(),
    756         "ready_for_quote": ready_for_quote,
    757         "item_count": document.basket.items.len(),
    758         "adjustment_count": document.basket.adjustments.len(),
    759         "issues": issues,
    760         "actions": actions,
    761     }))
    762 }
    763 
    764 fn missing_basket_view(config: &RuntimeConfig, lookup: &str) -> Value {
    765     json!({
    766         "state": "missing",
    767         "source": BASKET_SOURCE,
    768         "lookup": lookup,
    769         "file": basket_lookup_path(config, lookup).display().to_string(),
    770         "reason": format!("basket `{lookup}` was not found"),
    771         "actions": ["radroots basket list", "radroots basket create"],
    772     })
    773 }
    774 
    775 fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, OperationAdapterError> {
    776     let dir = baskets_dir(config);
    777     if !dir.exists() {
    778         return Ok(Vec::new());
    779     }
    780 
    781     let mut baskets = Vec::new();
    782     for entry in fs::read_dir(&dir).map_err(|error| {
    783         OperationAdapterError::Runtime(format!("read basket directory {}: {error}", dir.display()))
    784     })? {
    785         let entry = entry.map_err(|error| {
    786             OperationAdapterError::Runtime(format!(
    787                 "read basket directory {}: {error}",
    788                 dir.display()
    789             ))
    790         })?;
    791         let path = entry.path();
    792         if path.extension().and_then(|value| value.to_str()) != Some("json") {
    793             continue;
    794         }
    795         let loaded = load_basket_path(path.as_path())?;
    796         let issues = basket_issues(config, &loaded.document)?;
    797         let ready_for_quote = issues.is_empty();
    798         baskets.push(json!({
    799             "basket_id": loaded.document.basket.basket_id,
    800             "state": if ready_for_quote { "ready" } else { "unconfigured" },
    801             "file": loaded.file.display().to_string(),
    802             "item_count": loaded.document.basket.items.len(),
    803             "adjustment_count": loaded.document.basket.adjustments.len(),
    804             "ready_for_quote": ready_for_quote,
    805             "issues": issues,
    806             "quote": loaded.document.quote,
    807             "updated_at_unix": loaded.document.basket.updated_at_unix,
    808         }));
    809     }
    810     baskets.sort_by(|left, right| {
    811         right["updated_at_unix"]
    812             .as_u64()
    813             .cmp(&left["updated_at_unix"].as_u64())
    814             .then_with(|| {
    815                 left["basket_id"]
    816                     .as_str()
    817                     .unwrap_or_default()
    818                     .cmp(right["basket_id"].as_str().unwrap_or_default())
    819             })
    820     });
    821     Ok(baskets)
    822 }
    823 
    824 fn basket_issues(
    825     config: &RuntimeConfig,
    826     document: &BasketDocument,
    827 ) -> Result<Vec<BasketIssue>, OperationAdapterError> {
    828     let mut issues = Vec::new();
    829     if document.basket.items.is_empty() {
    830         issues.push(basket_issue(
    831             "basket_items_missing",
    832             "basket.items",
    833             "basket must contain one item before quote creation",
    834         ));
    835     }
    836     if document.basket.items.len() > 1 {
    837         issues.push(basket_issue(
    838             "basket_items_unsupported",
    839             "basket.items",
    840             "basket quotes support exactly one item",
    841         ));
    842     }
    843     for item in &document.basket.items {
    844         if item.listing.is_none() && item.listing_addr.is_none() {
    845             issues.push(basket_issue(
    846                 "basket_item_listing_missing",
    847                 format!("basket.items.{}.listing", item.item_id),
    848                 "item must include listing or listing_addr",
    849             ));
    850         }
    851         if item.bin_id.trim().is_empty() {
    852             issues.push(basket_issue(
    853                 "basket_item_bin_missing",
    854                 format!("basket.items.{}.bin_id", item.item_id),
    855                 "item must include bin_id",
    856             ));
    857         }
    858         if item.quantity == 0 {
    859             issues.push(basket_issue(
    860                 "basket_item_quantity_invalid",
    861                 format!("basket.items.{}.quantity", item.item_id),
    862                 "item quantity must be greater than 0",
    863             ));
    864         }
    865     }
    866     if issues.is_empty() {
    867         issues.extend(basket_market_issues(config, document)?);
    868     }
    869     Ok(issues)
    870 }
    871 
    872 fn basket_market_issues(
    873     config: &RuntimeConfig,
    874     document: &BasketDocument,
    875 ) -> Result<Vec<BasketIssue>, OperationAdapterError> {
    876     if !config.local.replica_db_path.exists() {
    877         return Ok(vec![basket_issue(
    878             "basket_market_replica_missing",
    879             "local.replica_db",
    880             "current local replica data is required before quote creation; run `radroots store init` and `radroots market refresh`",
    881         )]);
    882     }
    883     let executor = SqliteExecutor::open(&config.local.replica_db_path).map_err(|error| {
    884         OperationAdapterError::Runtime(format!(
    885             "open local replica {}: {error}",
    886             config.local.replica_db_path.display()
    887         ))
    888     })?;
    889     let mut issues = Vec::new();
    890     for item in &document.basket.items {
    891         let product = match basket_product_bin_state(config, &executor, item)? {
    892             BasketProductResolution::Resolved(product) => product,
    893             BasketProductResolution::Unresolved => {
    894                 issues.push(basket_issue(
    895                     "basket_item_listing_unresolved",
    896                     basket_item_listing_field(item),
    897                     "basket item listing is not active in the current local replica; run `radroots market refresh` before quote creation",
    898                 ));
    899                 continue;
    900             }
    901             BasketProductResolution::Ambiguous(count) => {
    902                 issues.push(basket_issue(
    903                     "basket_item_listing_ambiguous",
    904                     basket_item_listing_field(item),
    905                     format!(
    906                         "basket item listing matched {count} active local replica rows; choose a unique listing before quote creation"
    907                     ),
    908                 ));
    909                 continue;
    910             }
    911         };
    912         let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else {
    913             issues.push(basket_issue(
    914                 "listing_primary_bin_missing",
    915                 format!("basket.items.{}.bin_id", item.item_id),
    916                 "current local replica listing primary bin is required before quote creation",
    917             ));
    918             continue;
    919         };
    920         let Some(verified_primary_bin_id) = product
    921             .verified_primary_bin_id
    922             .as_deref()
    923             .and_then(non_empty_ref)
    924         else {
    925             issues.push(basket_issue(
    926                 "listing_primary_bin_invalid",
    927                 format!("basket.items.{}.bin_id", item.item_id),
    928                 format!("current local replica primary bin `{primary_bin_id}` is not verified"),
    929             ));
    930             continue;
    931         };
    932         if verified_primary_bin_id != primary_bin_id {
    933             issues.push(basket_issue(
    934                 "listing_primary_bin_invalid",
    935                 format!("basket.items.{}.bin_id", item.item_id),
    936                 format!(
    937                     "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`"
    938                 ),
    939             ));
    940             continue;
    941         }
    942         if item.bin_id != primary_bin_id {
    943             issues.push(basket_issue(
    944                 "order_bin_unknown",
    945                 format!("basket.items.{}.bin_id", item.item_id),
    946                 format!(
    947                     "basket bin `{}` is not in the current local listing bin set; expected primary bin `{primary_bin_id}`",
    948                     item.bin_id
    949                 ),
    950             ));
    951         }
    952     }
    953     Ok(issues)
    954 }
    955 
    956 fn basket_product_bin_state(
    957     config: &RuntimeConfig,
    958     executor: &SqliteExecutor,
    959     item: &BasketItem,
    960 ) -> Result<BasketProductResolution, OperationAdapterError> {
    961     if let Some(listing_addr) = item.listing_addr.as_deref().and_then(non_empty_ref) {
    962         let product_rows = trade_product::find_many(
    963             executor,
    964             &ITradeProductFindMany {
    965                 filter: Some(trade_product_listing_addr_filter(listing_addr)),
    966             },
    967         )
    968         .map_err(|error| {
    969             OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}"))
    970         })?
    971         .results;
    972         let product = match product_rows.as_slice() {
    973             [] => return Ok(BasketProductResolution::Unresolved),
    974             [product] => product,
    975             rows => return Ok(BasketProductResolution::Ambiguous(rows.len())),
    976         };
    977         return Ok(BasketProductResolution::Resolved(BasketProductBinState {
    978             primary_bin_id: product.primary_bin_id.clone(),
    979             verified_primary_bin_id: product.verified_primary_bin_id.clone(),
    980         }));
    981     }
    982 
    983     let Some(listing_lookup) = item.listing.as_deref().and_then(non_empty_ref) else {
    984         return Ok(BasketProductResolution::Unresolved);
    985     };
    986     let lookup_executor = SqliteExecutor::open(&config.local.replica_db_path).map_err(|error| {
    987         OperationAdapterError::Runtime(format!(
    988             "open local replica {}: {error}",
    989             config.local.replica_db_path.display()
    990         ))
    991     })?;
    992     let rows = ReplicaSql::new(lookup_executor)
    993         .trade_product_lookup(listing_lookup)
    994         .map_err(|error| {
    995             OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}"))
    996         })?;
    997     let product = match rows.as_slice() {
    998         [] => return Ok(BasketProductResolution::Unresolved),
    999         [product] => product,
   1000         rows => return Ok(BasketProductResolution::Ambiguous(rows.len())),
   1001     };
   1002     Ok(BasketProductResolution::Resolved(BasketProductBinState {
   1003         primary_bin_id: product.primary_bin_id.clone(),
   1004         verified_primary_bin_id: product.verified_primary_bin_id.clone(),
   1005     }))
   1006 }
   1007 
   1008 fn basket_item_listing_field(item: &BasketItem) -> String {
   1009     if item
   1010         .listing_addr
   1011         .as_deref()
   1012         .and_then(non_empty_ref)
   1013         .is_some()
   1014     {
   1015         format!("basket.items.{}.listing_addr", item.item_id)
   1016     } else {
   1017         format!("basket.items.{}.listing", item.item_id)
   1018     }
   1019 }
   1020 
   1021 fn basket_issue(
   1022     code: impl Into<String>,
   1023     field: impl Into<String>,
   1024     message: impl Into<String>,
   1025 ) -> BasketIssue {
   1026     BasketIssue {
   1027         code: code.into(),
   1028         field: field.into(),
   1029         message: message.into(),
   1030     }
   1031 }
   1032 
   1033 fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter {
   1034     ITradeProductFieldsFilter {
   1035         id: None,
   1036         created_at: None,
   1037         updated_at: None,
   1038         key: None,
   1039         category: None,
   1040         title: None,
   1041         summary: None,
   1042         process: None,
   1043         lot: None,
   1044         profile: None,
   1045         year: None,
   1046         qty_amt: None,
   1047         qty_amt_exact: None,
   1048         qty_unit: None,
   1049         qty_label: None,
   1050         qty_avail: None,
   1051         price_amt: None,
   1052         price_amt_exact: None,
   1053         price_currency: None,
   1054         price_qty_amt: None,
   1055         price_qty_amt_exact: None,
   1056         price_qty_unit: None,
   1057         listing_addr: Some(listing_addr.to_owned()),
   1058         primary_bin_id: None,
   1059         verified_primary_bin_id: None,
   1060         notes: None,
   1061     }
   1062 }
   1063 
   1064 fn non_empty_ref(value: &str) -> Option<&str> {
   1065     let value = value.trim();
   1066     if value.is_empty() { None } else { Some(value) }
   1067 }
   1068 
   1069 fn basket_actions(document: &BasketDocument, issues: &[BasketIssue]) -> Vec<String> {
   1070     let basket_id = document.basket.basket_id.as_str();
   1071     if document.basket.items.is_empty() {
   1072         return vec![format!("radroots basket item add {basket_id}")];
   1073     }
   1074     if issues.is_empty() {
   1075         vec![
   1076             format!("radroots basket validate {basket_id}"),
   1077             format!("radroots basket quote create {basket_id}"),
   1078         ]
   1079     } else {
   1080         vec![format!("radroots basket get {basket_id}")]
   1081     }
   1082 }
   1083 
   1084 fn quote_actions(order: &OrderNewView) -> Vec<String> {
   1085     if order.ready_for_submit {
   1086         vec![format!("radroots order submit {}", order.order_id)]
   1087     } else {
   1088         let mut actions = vec![format!("radroots order get {}", order.order_id)];
   1089         actions.extend(order.actions.iter().cloned());
   1090         actions
   1091     }
   1092 }
   1093 
   1094 fn quote_issues_from_order(order: &OrderNewView) -> Vec<BasketIssue> {
   1095     order
   1096         .issues
   1097         .iter()
   1098         .map(|issue| BasketIssue {
   1099             code: issue.code.clone(),
   1100             field: issue.field.clone(),
   1101             message: issue.message.clone(),
   1102         })
   1103         .collect()
   1104 }
   1105 
   1106 fn order_adjustments_from_basket(document: &BasketDocument) -> Vec<OrderDraftAdjustmentArgs> {
   1107     document
   1108         .basket
   1109         .adjustments
   1110         .iter()
   1111         .map(|adjustment| OrderDraftAdjustmentArgs {
   1112             id: adjustment.id.clone(),
   1113             effect: adjustment.effect.clone(),
   1114             amount: adjustment.amount.clone(),
   1115             currency: adjustment.currency.clone(),
   1116             reason: adjustment.reason.clone(),
   1117         })
   1118         .collect()
   1119 }
   1120 
   1121 fn load_required_basket(
   1122     config: &RuntimeConfig,
   1123     lookup: &str,
   1124     operation_id: &str,
   1125 ) -> Result<LoadedBasket, OperationAdapterError> {
   1126     load_basket_optional(config, lookup)?.ok_or_else(|| {
   1127         invalid_input(
   1128             operation_id,
   1129             format!("basket `{lookup}` was not found; run `radroots basket create` first"),
   1130         )
   1131     })
   1132 }
   1133 
   1134 fn load_basket_optional(
   1135     config: &RuntimeConfig,
   1136     lookup: &str,
   1137 ) -> Result<Option<LoadedBasket>, OperationAdapterError> {
   1138     let path = basket_lookup_path(config, lookup);
   1139     if !path.exists() {
   1140         return Ok(None);
   1141     }
   1142     load_basket_path(path.as_path()).map(Some)
   1143 }
   1144 
   1145 fn load_basket_path(path: &Path) -> Result<LoadedBasket, OperationAdapterError> {
   1146     let contents = fs::read_to_string(path).map_err(|error| {
   1147         OperationAdapterError::Runtime(format!("read basket {}: {error}", path.display()))
   1148     })?;
   1149     let document = serde_json::from_str::<BasketDocument>(contents.as_str()).map_err(|error| {
   1150         OperationAdapterError::Runtime(format!("parse basket {}: {error}", path.display()))
   1151     })?;
   1152     Ok(LoadedBasket {
   1153         file: path.to_path_buf(),
   1154         document,
   1155     })
   1156 }
   1157 
   1158 fn save_basket(path: &Path, document: &BasketDocument) -> Result<(), OperationAdapterError> {
   1159     if let Some(parent) = path.parent() {
   1160         fs::create_dir_all(parent).map_err(|error| {
   1161             OperationAdapterError::Runtime(format!(
   1162                 "create basket directory {}: {error}",
   1163                 parent.display()
   1164             ))
   1165         })?;
   1166     }
   1167     let contents = serde_json::to_string_pretty(document)
   1168         .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?;
   1169     fs::write(path, contents).map_err(|error| {
   1170         OperationAdapterError::Runtime(format!("write basket {}: {error}", path.display()))
   1171     })
   1172 }
   1173 
   1174 fn baskets_dir(config: &RuntimeConfig) -> PathBuf {
   1175     config.paths.app_data_root.join(BASKETS_DIR)
   1176 }
   1177 
   1178 fn basket_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf {
   1179     let candidate = PathBuf::from(lookup);
   1180     if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) {
   1181         return candidate;
   1182     }
   1183     let file_name = if lookup.ends_with(".json") {
   1184         lookup.to_owned()
   1185     } else {
   1186         format!("{lookup}.json")
   1187     };
   1188     baskets_dir(config).join(file_name)
   1189 }
   1190 
   1191 fn touch_basket(document: &mut BasketDocument) {
   1192     document.basket.updated_at_unix = now_unix();
   1193 }
   1194 
   1195 fn next_item_id(document: &BasketDocument) -> String {
   1196     for index in 1.. {
   1197         let candidate = format!("item_{index}");
   1198         if document
   1199             .basket
   1200             .items
   1201             .iter()
   1202             .all(|item| item.item_id != candidate)
   1203         {
   1204             return candidate;
   1205         }
   1206     }
   1207     unreachable!("unbounded item id search should always return")
   1208 }
   1209 
   1210 fn next_basket_id() -> String {
   1211     let sequence = BASKET_COUNTER.fetch_add(1, Ordering::Relaxed) + 1;
   1212     format!("basket_{}_{}", now_unix(), sequence)
   1213 }
   1214 
   1215 fn now_unix() -> u64 {
   1216     SystemTime::now()
   1217         .duration_since(UNIX_EPOCH)
   1218         .map(|duration| duration.as_secs())
   1219         .unwrap_or_default()
   1220 }
   1221 
   1222 fn required_basket_id<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError>
   1223 where
   1224     P: OperationRequestPayload + OperationRequestData,
   1225 {
   1226     string_input(request, "basket_id")
   1227         .or_else(|| string_input(request, "key"))
   1228         .ok_or_else(|| {
   1229             invalid_input(
   1230                 request.operation_id(),
   1231                 "missing required `basket_id` input".to_owned(),
   1232             )
   1233         })
   1234 }
   1235 
   1236 fn required_string<P>(
   1237     request: &OperationRequest<P>,
   1238     key: &str,
   1239 ) -> Result<String, OperationAdapterError>
   1240 where
   1241     P: OperationRequestPayload + OperationRequestData,
   1242 {
   1243     string_input(request, key).ok_or_else(|| {
   1244         invalid_input(
   1245             request.operation_id(),
   1246             format!("missing required `{key}` input"),
   1247         )
   1248     })
   1249 }
   1250 
   1251 fn quantity_input<P>(request: &OperationRequest<P>) -> Result<Option<u32>, OperationAdapterError>
   1252 where
   1253     P: OperationRequestPayload + OperationRequestData,
   1254 {
   1255     let value = request
   1256         .payload
   1257         .input()
   1258         .get("quantity")
   1259         .or_else(|| request.payload.input().get("bin_count"));
   1260     let Some(value) = value else {
   1261         return Ok(None);
   1262     };
   1263     match value {
   1264         Value::Number(number) => number
   1265             .as_u64()
   1266             .and_then(|value| u32::try_from(value).ok())
   1267             .map(Some)
   1268             .ok_or_else(|| {
   1269                 invalid_input(
   1270                     request.operation_id(),
   1271                     "`quantity` input must fit in u32".to_owned(),
   1272                 )
   1273             }),
   1274         Value::String(value) => value.parse::<u32>().map(Some).map_err(|error| {
   1275             invalid_input(
   1276                 request.operation_id(),
   1277                 format!("`quantity` input must be a u32: {error}"),
   1278             )
   1279         }),
   1280         _ => Err(invalid_input(
   1281             request.operation_id(),
   1282             "`quantity` input must be a number or string".to_owned(),
   1283         )),
   1284     }
   1285 }
   1286 
   1287 fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError>
   1288 where
   1289     R: OperationResultData,
   1290 {
   1291     OperationResult::new(R::from_value(value))
   1292 }
   1293 
   1294 fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String>
   1295 where
   1296     P: OperationRequestPayload + OperationRequestData,
   1297 {
   1298     request
   1299         .payload
   1300         .input()
   1301         .get(key)
   1302         .and_then(Value::as_str)
   1303         .map(str::to_owned)
   1304 }
   1305 
   1306 fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError {
   1307     OperationAdapterError::InvalidInput {
   1308         operation_id: operation_id.to_owned(),
   1309         message,
   1310     }
   1311 }
   1312 
   1313 #[cfg(test)]
   1314 mod tests {
   1315     use std::path::{Path, PathBuf};
   1316 
   1317     use radroots_events::RadrootsNostrEvent;
   1318     use radroots_events::ids::RadrootsListingAddress;
   1319     use radroots_events::kinds::{KIND_FARM, KIND_LISTING};
   1320     use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event};
   1321     use radroots_runtime_paths::RadrootsMigrationReport;
   1322     use radroots_secret_vault::RadrootsSecretBackend;
   1323     use radroots_sql_core::{SqlExecutor, SqliteExecutor};
   1324     use serde_json::{Map, Value, json};
   1325     use tempfile::tempdir;
   1326 
   1327     use super::BasketOperationService;
   1328     use crate::ops::{
   1329         BasketAdjustmentAddRequest, BasketAdjustmentRemoveRequest, BasketCreateRequest,
   1330         BasketGetRequest, BasketItemAddRequest, BasketItemRemoveRequest, BasketItemUpdateRequest,
   1331         BasketListRequest, BasketQuoteCreateRequest, BasketValidateRequest, OperationAdapter,
   1332         OperationContext, OperationData, OperationRequest,
   1333     };
   1334     use crate::runtime::account;
   1335     use crate::runtime::config::{
   1336         AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig,
   1337         LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat,
   1338         PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig,
   1339         RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend,
   1340         SignerConfig, Verbosity,
   1341     };
   1342 
   1343     const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg";
   1344 
   1345     #[test]
   1346     fn basket_service_creates_gets_and_lists_local_baskets() {
   1347         let dir = tempdir().expect("tempdir");
   1348         let config = sample_config(dir.path());
   1349         let service = OperationAdapter::new(BasketOperationService::new(&config));
   1350         let create = OperationRequest::new(
   1351             OperationContext::default(),
   1352             BasketCreateRequest::from_data(data(&[("basket_id", "basket_test")])),
   1353         )
   1354         .expect("basket create request");
   1355         let create_envelope = service
   1356             .execute(create)
   1357             .expect("basket create result")
   1358             .to_envelope(OperationContext::default().envelope_context("req_basket_create"))
   1359             .expect("basket create envelope");
   1360         assert_eq!(create_envelope.operation_id, "basket.create");
   1361         assert_eq!(create_envelope.result["basket_id"], "basket_test");
   1362         assert_eq!(create_envelope.result["item_count"], 0);
   1363 
   1364         let get = OperationRequest::new(
   1365             OperationContext::default(),
   1366             BasketGetRequest::from_data(data(&[("basket_id", "basket_test")])),
   1367         )
   1368         .expect("basket get request");
   1369         let get_envelope = service
   1370             .execute(get)
   1371             .expect("basket get result")
   1372             .to_envelope(OperationContext::default().envelope_context("req_basket_get"))
   1373             .expect("basket get envelope");
   1374         assert_eq!(get_envelope.operation_id, "basket.get");
   1375         assert_eq!(get_envelope.result["state"], "ready");
   1376 
   1377         let list = OperationRequest::new(OperationContext::default(), BasketListRequest::default())
   1378             .expect("basket list request");
   1379         let list_envelope = service
   1380             .execute(list)
   1381             .expect("basket list result")
   1382             .to_envelope(OperationContext::default().envelope_context("req_basket_list"))
   1383             .expect("basket list envelope");
   1384         assert_eq!(list_envelope.operation_id, "basket.list");
   1385         assert_eq!(list_envelope.result["count"], 1);
   1386     }
   1387 
   1388     #[test]
   1389     fn basket_service_mutates_items_and_validates_readiness() {
   1390         let dir = tempdir().expect("tempdir");
   1391         let config = sample_config(dir.path());
   1392         let service = OperationAdapter::new(BasketOperationService::new(&config));
   1393         create_basket(&service, "basket_items");
   1394 
   1395         let add = OperationRequest::new(
   1396             OperationContext::default(),
   1397             BasketItemAddRequest::from_data(data(&[
   1398                 ("basket_id", "basket_items"),
   1399                 ("listing_addr", LISTING_ADDR),
   1400                 ("bin_id", "bin-1"),
   1401                 ("quantity", "2"),
   1402             ])),
   1403         )
   1404         .expect("basket item add request");
   1405         let add_envelope = service
   1406             .execute(add)
   1407             .expect("basket item add result")
   1408             .to_envelope(OperationContext::default().envelope_context("req_basket_add"))
   1409             .expect("basket item add envelope");
   1410         assert_eq!(add_envelope.operation_id, "basket.item.add");
   1411         assert_eq!(add_envelope.result["item_count"], 1);
   1412 
   1413         let update = OperationRequest::new(
   1414             OperationContext::default(),
   1415             BasketItemUpdateRequest::from_data(data(&[
   1416                 ("basket_id", "basket_items"),
   1417                 ("item_id", "item_1"),
   1418                 ("quantity", "3"),
   1419             ])),
   1420         )
   1421         .expect("basket item update request");
   1422         let update_envelope = service
   1423             .execute(update)
   1424             .expect("basket item update result")
   1425             .to_envelope(OperationContext::default().envelope_context("req_basket_update"))
   1426             .expect("basket item update envelope");
   1427         assert_eq!(update_envelope.operation_id, "basket.item.update");
   1428         assert_eq!(update_envelope.result["items"][0]["quantity"], 3);
   1429 
   1430         let validate = OperationRequest::new(
   1431             OperationContext::default(),
   1432             BasketValidateRequest::from_data(data(&[("basket_id", "basket_items")])),
   1433         )
   1434         .expect("basket validate request");
   1435         let validate_envelope = service
   1436             .execute(validate)
   1437             .expect("basket validate result")
   1438             .to_envelope(OperationContext::default().envelope_context("req_basket_validate"))
   1439             .expect("basket validate envelope");
   1440         assert_eq!(validate_envelope.operation_id, "basket.validate");
   1441         assert_eq!(validate_envelope.result["ready_for_quote"], false);
   1442         assert_eq!(
   1443             validate_envelope.result["issues"][0]["code"],
   1444             "basket_market_replica_missing"
   1445         );
   1446 
   1447         let adjustment_add = OperationRequest::new(
   1448             OperationContext::default(),
   1449             BasketAdjustmentAddRequest::from_data(data(&[
   1450                 ("basket_id", "basket_items"),
   1451                 ("id", "adj_pickup"),
   1452                 ("effect", "decrease"),
   1453                 ("amount", "1.00"),
   1454                 ("currency", "USD"),
   1455                 ("reason", "pickup"),
   1456             ])),
   1457         )
   1458         .expect("basket adjustment add request");
   1459         let adjustment_add_envelope = service
   1460             .execute(adjustment_add)
   1461             .expect("basket adjustment add result")
   1462             .to_envelope(OperationContext::default().envelope_context("req_basket_adjust_add"))
   1463             .expect("basket adjustment add envelope");
   1464         assert_eq!(
   1465             adjustment_add_envelope.operation_id,
   1466             "basket.adjustment.add"
   1467         );
   1468         assert_eq!(adjustment_add_envelope.result["adjustment_count"], 1);
   1469 
   1470         let adjustment_remove = OperationRequest::new(
   1471             OperationContext::default(),
   1472             BasketAdjustmentRemoveRequest::from_data(data(&[
   1473                 ("basket_id", "basket_items"),
   1474                 ("id", "adj_pickup"),
   1475             ])),
   1476         )
   1477         .expect("basket adjustment remove request");
   1478         let adjustment_remove_envelope = service
   1479             .execute(adjustment_remove)
   1480             .expect("basket adjustment remove result")
   1481             .to_envelope(OperationContext::default().envelope_context("req_basket_adjust_remove"))
   1482             .expect("basket adjustment remove envelope");
   1483         assert_eq!(
   1484             adjustment_remove_envelope.operation_id,
   1485             "basket.adjustment.remove"
   1486         );
   1487         assert_eq!(adjustment_remove_envelope.result["adjustment_count"], 0);
   1488 
   1489         let remove = OperationRequest::new(
   1490             OperationContext::default(),
   1491             BasketItemRemoveRequest::from_data(data(&[
   1492                 ("basket_id", "basket_items"),
   1493                 ("item_id", "item_1"),
   1494             ])),
   1495         )
   1496         .expect("basket item remove request");
   1497         let remove_envelope = service
   1498             .execute(remove)
   1499             .expect("basket item remove result")
   1500             .to_envelope(OperationContext::default().envelope_context("req_basket_remove"))
   1501             .expect("basket item remove envelope");
   1502         assert_eq!(remove_envelope.operation_id, "basket.item.remove");
   1503         assert_eq!(remove_envelope.result["item_count"], 0);
   1504         assert_eq!(remove_envelope.result["ready_for_quote"], false);
   1505     }
   1506 
   1507     #[test]
   1508     fn basket_quote_create_materializes_order_draft() {
   1509         let dir = tempdir().expect("tempdir");
   1510         let config = sample_config(dir.path());
   1511         seed_current_listing(&config);
   1512         account::create_or_migrate_default_account(&config).expect("create buyer account");
   1513         let service = OperationAdapter::new(BasketOperationService::new(&config));
   1514         create_basket(&service, "basket_quote");
   1515         add_listing_item(&service, "basket_quote");
   1516 
   1517         let quote = OperationRequest::new(
   1518             OperationContext::default(),
   1519             BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_quote")])),
   1520         )
   1521         .expect("basket quote request");
   1522         let envelope = service
   1523             .execute(quote)
   1524             .expect("basket quote result")
   1525             .to_envelope(OperationContext::default().envelope_context("req_basket_quote"))
   1526             .expect("basket quote envelope");
   1527 
   1528         assert_eq!(envelope.operation_id, "basket.quote.create");
   1529         assert_eq!(envelope.result["state"], "quoted");
   1530         assert!(
   1531             envelope.result["quote"]["order_id"]
   1532                 .as_str()
   1533                 .unwrap()
   1534                 .starts_with("ord_")
   1535         );
   1536         assert!(
   1537             envelope.result["order"]["buyer_account_id"]
   1538                 .as_str()
   1539                 .expect("buyer account id")
   1540                 .len()
   1541                 > 8
   1542         );
   1543         assert!(
   1544             envelope.result["order"]["buyer_pubkey"]
   1545                 .as_str()
   1546                 .expect("buyer pubkey")
   1547                 .len()
   1548                 == 64
   1549         );
   1550         assert_eq!(
   1551             envelope.result["order"]["buyer_actor_source"],
   1552             "resolved_account"
   1553         );
   1554         let order_file = PathBuf::from(envelope.result["quote"]["order_file"].as_str().unwrap());
   1555         assert!(order_file.exists());
   1556         let draft = std::fs::read_to_string(order_file).expect("read order draft");
   1557         assert!(draft.contains("[buyer_actor]"));
   1558         assert!(draft.contains("source = \"resolved_account\""));
   1559     }
   1560 
   1561     #[test]
   1562     fn basket_quote_create_dry_run_skips_order_draft() {
   1563         let dir = tempdir().expect("tempdir");
   1564         let config = sample_config(dir.path());
   1565         seed_current_listing(&config);
   1566         account::create_or_migrate_default_account(&config).expect("create buyer account");
   1567         let service = OperationAdapter::new(BasketOperationService::new(&config));
   1568         create_basket(&service, "basket_dry_run");
   1569         add_listing_item(&service, "basket_dry_run");
   1570 
   1571         let mut context = OperationContext::default();
   1572         context.dry_run = true;
   1573         let quote = OperationRequest::new(
   1574             context.clone(),
   1575             BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_dry_run")])),
   1576         )
   1577         .expect("basket quote request");
   1578         let envelope = service
   1579             .execute(quote)
   1580             .expect("basket quote dry run")
   1581             .to_envelope(context.envelope_context("req_basket_quote"))
   1582             .expect("basket quote envelope");
   1583 
   1584         assert_eq!(envelope.operation_id, "basket.quote.create");
   1585         assert_eq!(envelope.dry_run, true);
   1586         assert_eq!(envelope.result["state"], "dry_run");
   1587         assert_eq!(envelope.result["order"]["state"], "dry_run");
   1588         assert_eq!(
   1589             envelope.result["order"]["buyer_actor_source"],
   1590             "resolved_account"
   1591         );
   1592         assert!(!PathBuf::from(envelope.result["order"]["file"].as_str().unwrap()).exists());
   1593     }
   1594 
   1595     #[test]
   1596     fn basket_quote_create_requires_resolved_buyer_account() {
   1597         let dir = tempdir().expect("tempdir");
   1598         let config = sample_config(dir.path());
   1599         seed_current_listing(&config);
   1600         let service = OperationAdapter::new(BasketOperationService::new(&config));
   1601         create_basket(&service, "basket_no_buyer");
   1602         add_listing_item(&service, "basket_no_buyer");
   1603 
   1604         let quote = OperationRequest::new(
   1605             OperationContext::default(),
   1606             BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_no_buyer")])),
   1607         )
   1608         .expect("basket quote request");
   1609         let error = service.execute(quote).expect_err("missing buyer account");
   1610 
   1611         let output_error = error.to_output_error();
   1612         assert_eq!(output_error.code, "account_unresolved");
   1613         let detail = output_error.detail.expect("account detail");
   1614         assert_eq!(detail["buyer_actor_source"], "resolved_account");
   1615         assert_eq!(detail["actions"][0], "radroots account create");
   1616     }
   1617 
   1618     #[test]
   1619     fn basket_readiness_fails_closed_without_replica_data() {
   1620         let dir = tempdir().expect("tempdir");
   1621         let config = sample_config(dir.path());
   1622         let service = OperationAdapter::new(BasketOperationService::new(&config));
   1623         create_basket(&service, "basket_missing_replica");
   1624         let add = add_listing_item(&service, "basket_missing_replica");
   1625         assert_eq!(add.result["ready_for_quote"], false);
   1626         assert_eq!(
   1627             add.result["issues"][0]["code"],
   1628             "basket_market_replica_missing"
   1629         );
   1630 
   1631         let list = OperationRequest::new(OperationContext::default(), BasketListRequest::default())
   1632             .expect("basket list request");
   1633         let list_envelope = service
   1634             .execute(list)
   1635             .expect("basket list result")
   1636             .to_envelope(OperationContext::default().envelope_context("req_basket_list"))
   1637             .expect("basket list envelope");
   1638         assert_eq!(
   1639             list_envelope.result["baskets"][0]["issues"][0]["code"],
   1640             "basket_market_replica_missing"
   1641         );
   1642 
   1643         let validate = OperationRequest::new(
   1644             OperationContext::default(),
   1645             BasketValidateRequest::from_data(data(&[("basket_id", "basket_missing_replica")])),
   1646         )
   1647         .expect("basket validate request");
   1648         let validate_envelope = service
   1649             .execute(validate)
   1650             .expect("basket validate result")
   1651             .to_envelope(OperationContext::default().envelope_context("req_basket_validate"))
   1652             .expect("basket validate envelope");
   1653         assert_eq!(validate_envelope.result["state"], "unconfigured");
   1654         assert_eq!(
   1655             validate_envelope.result["issues"][0]["code"],
   1656             "basket_market_replica_missing"
   1657         );
   1658 
   1659         let quote = OperationRequest::new(
   1660             OperationContext::default(),
   1661             BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_missing_replica")])),
   1662         )
   1663         .expect("basket quote request");
   1664         let quote_envelope = service
   1665             .execute(quote)
   1666             .expect("basket quote result")
   1667             .to_envelope(OperationContext::default().envelope_context("req_basket_quote"))
   1668             .expect("basket quote envelope");
   1669         assert_eq!(quote_envelope.result["state"], "unconfigured");
   1670         assert_eq!(
   1671             quote_envelope.result["issues"][0]["code"],
   1672             "basket_market_replica_missing"
   1673         );
   1674         assert!(!config.paths.app_data_root.join("orders/drafts").exists());
   1675     }
   1676 
   1677     #[test]
   1678     fn basket_readiness_fails_closed_for_unresolved_listing() {
   1679         let dir = tempdir().expect("tempdir");
   1680         let config = sample_config(dir.path());
   1681         crate::runtime::store::init(&config).expect("store init");
   1682         let service = OperationAdapter::new(BasketOperationService::new(&config));
   1683         create_basket(&service, "basket_unresolved");
   1684         let add = add_listing_item(&service, "basket_unresolved");
   1685         assert_eq!(add.result["ready_for_quote"], false);
   1686         assert_eq!(
   1687             add.result["issues"][0]["code"],
   1688             "basket_item_listing_unresolved"
   1689         );
   1690 
   1691         let quote = OperationRequest::new(
   1692             OperationContext::default(),
   1693             BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_unresolved")])),
   1694         )
   1695         .expect("basket quote request");
   1696         let quote_envelope = service
   1697             .execute(quote)
   1698             .expect("basket quote result")
   1699             .to_envelope(OperationContext::default().envelope_context("req_basket_quote"))
   1700             .expect("basket quote envelope");
   1701         assert_eq!(quote_envelope.result["state"], "unconfigured");
   1702         assert_eq!(
   1703             quote_envelope.result["issues"][0]["code"],
   1704             "basket_item_listing_unresolved"
   1705         );
   1706         assert!(!config.paths.app_data_root.join("orders/drafts").exists());
   1707     }
   1708 
   1709     #[test]
   1710     fn basket_readiness_fails_closed_for_ambiguous_listing() {
   1711         let dir = tempdir().expect("tempdir");
   1712         let config = sample_config(dir.path());
   1713         seed_current_listing(&config);
   1714         duplicate_current_listing_row(&config);
   1715         let service = OperationAdapter::new(BasketOperationService::new(&config));
   1716         create_basket(&service, "basket_ambiguous");
   1717         let add = add_listing_item(&service, "basket_ambiguous");
   1718         assert_eq!(add.result["ready_for_quote"], false);
   1719         assert_eq!(
   1720             add.result["issues"][0]["code"],
   1721             "basket_item_listing_ambiguous"
   1722         );
   1723 
   1724         let quote = OperationRequest::new(
   1725             OperationContext::default(),
   1726             BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_ambiguous")])),
   1727         )
   1728         .expect("basket quote request");
   1729         let quote_envelope = service
   1730             .execute(quote)
   1731             .expect("basket quote result")
   1732             .to_envelope(OperationContext::default().envelope_context("req_basket_quote"))
   1733             .expect("basket quote envelope");
   1734         assert_eq!(quote_envelope.result["state"], "unconfigured");
   1735         assert_eq!(
   1736             quote_envelope.result["issues"][0]["code"],
   1737             "basket_item_listing_ambiguous"
   1738         );
   1739         assert!(!config.paths.app_data_root.join("orders/drafts").exists());
   1740     }
   1741 
   1742     fn create_basket(service: &OperationAdapter<BasketOperationService<'_>>, basket_id: &str) {
   1743         let request = OperationRequest::new(
   1744             OperationContext::default(),
   1745             BasketCreateRequest::from_data(data(&[("basket_id", basket_id)])),
   1746         )
   1747         .expect("basket create request");
   1748         service.execute(request).expect("basket create result");
   1749     }
   1750 
   1751     fn add_listing_item(
   1752         service: &OperationAdapter<BasketOperationService<'_>>,
   1753         basket_id: &str,
   1754     ) -> crate::out::envelope::OutputEnvelope {
   1755         let request = OperationRequest::new(
   1756             OperationContext::default(),
   1757             BasketItemAddRequest::from_data(data(&[
   1758                 ("basket_id", basket_id),
   1759                 ("listing_addr", LISTING_ADDR),
   1760                 ("bin_id", "bin-1"),
   1761                 ("quantity", "1"),
   1762             ])),
   1763         )
   1764         .expect("basket item add request");
   1765         service
   1766             .execute(request)
   1767             .expect("basket item add result")
   1768             .to_envelope(OperationContext::default().envelope_context("req_basket_add"))
   1769             .expect("basket item add envelope")
   1770     }
   1771 
   1772     fn seed_current_listing(config: &RuntimeConfig) {
   1773         crate::runtime::store::init(config).expect("store init");
   1774         let (seller_pubkey, listing_id) = listing_addr_parts(LISTING_ADDR);
   1775         let event = RadrootsNostrEvent {
   1776             id: "2".repeat(64),
   1777             author: seller_pubkey.clone(),
   1778             created_at: 1,
   1779             kind: KIND_LISTING,
   1780             tags: vec![
   1781                 vec!["d".to_owned(), listing_id],
   1782                 vec![
   1783                     "a".to_owned(),
   1784                     format!(
   1785                         "{}:{}:{}",
   1786                         KIND_FARM, seller_pubkey, "AAAAAAAAAAAAAAAAAAAAAA"
   1787                     ),
   1788                 ],
   1789                 vec!["p".to_owned(), seller_pubkey],
   1790                 vec!["key".to_owned(), "pasture-eggs".to_owned()],
   1791                 vec!["title".to_owned(), "Market Eggs".to_owned()],
   1792                 vec!["category".to_owned(), "eggs".to_owned()],
   1793                 vec!["summary".to_owned(), "Pasture-raised eggs".to_owned()],
   1794                 vec!["process".to_owned(), "washed".to_owned()],
   1795                 vec!["lot".to_owned(), "lot-a".to_owned()],
   1796                 vec!["profile".to_owned(), "dozen".to_owned()],
   1797                 vec!["year".to_owned(), "2026".to_owned()],
   1798                 vec!["radroots:primary_bin".to_owned(), "bin-1".to_owned()],
   1799                 vec![
   1800                     "radroots:bin".to_owned(),
   1801                     "bin-1".to_owned(),
   1802                     "12".to_owned(),
   1803                     "each".to_owned(),
   1804                     "12".to_owned(),
   1805                     "each".to_owned(),
   1806                     "dozen".to_owned(),
   1807                 ],
   1808                 vec![
   1809                     "radroots:price".to_owned(),
   1810                     "bin-1".to_owned(),
   1811                     "6".to_owned(),
   1812                     "USD".to_owned(),
   1813                     "1".to_owned(),
   1814                     "each".to_owned(),
   1815                     "6".to_owned(),
   1816                     "each".to_owned(),
   1817                 ],
   1818                 vec!["inventory".to_owned(), "5".to_owned()],
   1819                 vec!["status".to_owned(), "active".to_owned()],
   1820             ],
   1821             content: "# Market Eggs".to_owned(),
   1822             sig: "f".repeat(128),
   1823         };
   1824         let executor = SqliteExecutor::open(&config.local.replica_db_path).expect("open replica");
   1825         assert_eq!(
   1826             radroots_replica_ingest_event(&executor, &event).expect("ingest listing"),
   1827             RadrootsReplicaIngestOutcome::Applied
   1828         );
   1829     }
   1830 
   1831     fn listing_addr_parts(listing_addr: &str) -> (String, String) {
   1832         let parsed = RadrootsListingAddress::parse(listing_addr).expect("listing addr");
   1833         let (_, rest) = parsed.as_str().split_once(':').expect("listing addr kind");
   1834         let (seller_pubkey, listing_id) = rest.split_once(':').expect("listing addr parts");
   1835         (seller_pubkey.to_owned(), listing_id.to_owned())
   1836     }
   1837 
   1838     fn duplicate_current_listing_row(config: &RuntimeConfig) {
   1839         let executor = SqliteExecutor::open(&config.local.replica_db_path).expect("open replica");
   1840         let params = json!(["33333333-3333-3333-3333-333333333333", LISTING_ADDR]).to_string();
   1841         executor
   1842             .exec(
   1843                 "INSERT INTO trade_product (id, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes, listing_addr, primary_bin_id, qty_amt_exact, price_amt_exact, price_qty_amt_exact, verified_primary_bin_id) SELECT ?, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes, listing_addr, primary_bin_id, qty_amt_exact, price_amt_exact, price_qty_amt_exact, verified_primary_bin_id FROM trade_product WHERE listing_addr = ?;",
   1844                 params.as_str(),
   1845             )
   1846             .expect("duplicate listing row");
   1847     }
   1848 
   1849     fn sample_config(root: &Path) -> RuntimeConfig {
   1850         let data = root.join("data");
   1851         let logs = root.join("logs");
   1852         let secrets = root.join("secrets");
   1853         RuntimeConfig {
   1854             output: OutputConfig {
   1855                 format: OutputFormat::Human,
   1856                 verbosity: Verbosity::Normal,
   1857                 color: true,
   1858                 dry_run: false,
   1859             },
   1860             interaction: InteractionConfig {
   1861                 input_enabled: true,
   1862                 assume_yes: false,
   1863                 stdin_tty: false,
   1864                 stdout_tty: false,
   1865                 prompts_allowed: false,
   1866                 confirmations_allowed: false,
   1867             },
   1868             paths: PathsConfig {
   1869                 profile: "interactive_user".into(),
   1870                 profile_source: "test".into(),
   1871                 allowed_profiles: vec!["interactive_user".into(), "repo_local".into()],
   1872                 root_source: "test".into(),
   1873                 repo_local_root: None,
   1874                 repo_local_root_source: None,
   1875                 subordinate_path_override_source: "runtime_config".into(),
   1876                 app_namespace: "apps/cli".into(),
   1877                 shared_accounts_namespace: "shared/accounts".into(),
   1878                 shared_identities_namespace: "shared/identities".into(),
   1879                 app_config_path: root.join("config/apps/cli/config.toml"),
   1880                 workspace_config_path: None,
   1881                 app_data_root: data.join("apps/cli"),
   1882                 app_logs_root: logs.join("apps/cli"),
   1883                 shared_accounts_data_root: data.join("shared/accounts"),
   1884                 shared_accounts_secrets_root: secrets.join("shared/accounts"),
   1885                 default_identity_path: secrets.join("shared/identities/default.json"),
   1886             },
   1887             migration: MigrationConfig {
   1888                 report: RadrootsMigrationReport::empty(),
   1889             },
   1890             logging: LoggingConfig {
   1891                 filter: "info".into(),
   1892                 directory: None,
   1893                 stdout: false,
   1894             },
   1895             account: AccountConfig {
   1896                 selector: None,
   1897                 store_path: data.join("shared/accounts/store.json"),
   1898                 secrets_dir: secrets.join("shared/accounts"),
   1899                 secret_backend: RadrootsSecretBackend::EncryptedFile,
   1900                 secret_fallback: None,
   1901             },
   1902             account_secret_contract: AccountSecretContractConfig {
   1903                 default_backend: "host_vault".into(),
   1904                 default_fallback: Some("encrypted_file".into()),
   1905                 allowed_backends: vec!["host_vault".into(), "encrypted_file".into()],
   1906                 host_vault_policy: Some("desktop".into()),
   1907                 uses_protected_store: true,
   1908             },
   1909             identity: IdentityConfig {
   1910                 path: secrets.join("shared/identities/default.json"),
   1911             },
   1912             signer: SignerConfig {
   1913                 backend: SignerBackend::Local,
   1914             },
   1915             publish: PublishConfig {
   1916                 transport: PublishTransport::DirectNostrRelay,
   1917                 source: PublishTransportSource::Defaults,
   1918                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   1919             },
   1920             relay: RelayConfig {
   1921                 urls: Vec::new(),
   1922                 publish_policy: RelayPublishPolicy::Any,
   1923                 source: RelayConfigSource::Defaults,
   1924             },
   1925             local: LocalConfig {
   1926                 root: data.join("apps/cli/replica"),
   1927                 replica_db_path: data.join("apps/cli/replica/replica.sqlite"),
   1928                 backups_dir: data.join("apps/cli/replica/backups"),
   1929                 exports_dir: data.join("apps/cli/replica/exports"),
   1930             },
   1931             myc: MycConfig {
   1932                 executable: PathBuf::from("myc"),
   1933                 status_timeout_ms: 2_000,
   1934             },
   1935             hyf: HyfConfig {
   1936                 enabled: false,
   1937                 executable: PathBuf::from("hyfd"),
   1938             },
   1939             rpc: RpcConfig {
   1940                 url: "http://127.0.0.1:7070".into(),
   1941             },
   1942             rhi: crate::runtime::config::RhiConfig {
   1943                 trusted_worker_pubkeys: Vec::new(),
   1944             },
   1945             capability_bindings: Vec::new(),
   1946         }
   1947     }
   1948 
   1949     fn data(entries: &[(&str, &str)]) -> OperationData {
   1950         entries
   1951             .iter()
   1952             .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned())))
   1953             .collect::<Map<String, Value>>()
   1954     }
   1955 }