wf_market/models/
request.rs

1//! Request models for creating and updating orders.
2//!
3//! This module provides builder-pattern types for constructing
4//! API requests with compile-time safety.
5
6use serde::Serialize;
7
8use super::common::OrderType;
9
10/// Request to create a new order.
11///
12/// Use the builder methods to construct orders with proper validation.
13///
14/// # Example
15///
16/// ```
17/// use wf_market::{CreateOrder, OrderType};
18///
19/// // Simple sell order
20/// let order = CreateOrder::sell("nikana_prime_set", 100, 1);
21///
22/// // Mod order with rank
23/// let mod_order = CreateOrder::sell("serration", 50, 1)
24///     .with_mod_rank(10);
25///
26/// // Hidden order
27/// let hidden = CreateOrder::buy("mesa_prime_set", 200, 1)
28///     .hidden();
29///
30/// // Sculpture with stars
31/// let sculpture = CreateOrder::sell("ayatan_anasa_sculpture", 25, 1)
32///     .with_sculpture_stars(2, 4);
33/// ```
34#[derive(Debug, Clone, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct CreateOrder {
37    /// ID of the item to trade
38    pub item_id: String,
39
40    /// Order type (buy or sell)
41    #[serde(rename = "type")]
42    pub order_type: OrderType,
43
44    /// Price in platinum
45    pub platinum: u32,
46
47    /// Quantity to trade
48    pub quantity: u32,
49
50    /// Whether the order is visible
51    pub visible: bool,
52
53    /// Minimum items per trade
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub per_trade: Option<u32>,
56
57    /// Mod rank (for rankable mods)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub rank: Option<u8>,
60
61    /// Charges remaining (for consumable mods)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub charges: Option<u8>,
64
65    /// Item subtype (e.g., blueprint, crafted)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub subtype: Option<String>,
68
69    /// Amber stars installed (for sculptures)
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub amber_stars: Option<u8>,
72
73    /// Cyan stars installed (for sculptures)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub cyan_stars: Option<u8>,
76}
77
78impl CreateOrder {
79    /// Create a new sell order.
80    ///
81    /// # Arguments
82    ///
83    /// * `item_id` - The item's ID or slug
84    /// * `platinum` - Price in platinum (must be > 0)
85    /// * `quantity` - Number of items to sell (must be > 0)
86    ///
87    /// # Panics
88    ///
89    /// Panics in debug builds if `platinum` or `quantity` is 0.
90    pub fn sell(item_id: impl Into<String>, platinum: u32, quantity: u32) -> Self {
91        debug_assert!(platinum > 0, "platinum must be greater than 0");
92        debug_assert!(quantity > 0, "quantity must be greater than 0");
93        Self {
94            item_id: item_id.into(),
95            order_type: OrderType::Sell,
96            platinum,
97            quantity,
98            visible: true,
99            per_trade: None,
100            rank: None,
101            charges: None,
102            subtype: None,
103            amber_stars: None,
104            cyan_stars: None,
105        }
106    }
107
108    /// Create a new buy order.
109    ///
110    /// # Arguments
111    ///
112    /// * `item_id` - The item's ID or slug
113    /// * `platinum` - Price in platinum (must be > 0)
114    /// * `quantity` - Number of items to buy (must be > 0)
115    ///
116    /// # Panics
117    ///
118    /// Panics in debug builds if `platinum` or `quantity` is 0.
119    pub fn buy(item_id: impl Into<String>, platinum: u32, quantity: u32) -> Self {
120        debug_assert!(platinum > 0, "platinum must be greater than 0");
121        debug_assert!(quantity > 0, "quantity must be greater than 0");
122        Self {
123            item_id: item_id.into(),
124            order_type: OrderType::Buy,
125            platinum,
126            quantity,
127            visible: true,
128            per_trade: None,
129            rank: None,
130            charges: None,
131            subtype: None,
132            amber_stars: None,
133            cyan_stars: None,
134        }
135    }
136
137    /// Set the mod rank (for rankable mods).
138    pub fn with_mod_rank(mut self, rank: u8) -> Self {
139        self.rank = Some(rank);
140        self
141    }
142
143    /// Set the charges (for consumable mods like Requiem).
144    pub fn with_charges(mut self, charges: u8) -> Self {
145        self.charges = Some(charges);
146        self
147    }
148
149    /// Set the item subtype (e.g., "blueprint", "crafted").
150    pub fn with_subtype(mut self, subtype: impl Into<String>) -> Self {
151        self.subtype = Some(subtype.into());
152        self
153    }
154
155    /// Set the installed stars (for Ayatan sculptures).
156    pub fn with_sculpture_stars(mut self, amber: u8, cyan: u8) -> Self {
157        self.amber_stars = Some(amber);
158        self.cyan_stars = Some(cyan);
159        self
160    }
161
162    /// Set the minimum items per trade.
163    pub fn with_per_trade(mut self, per_trade: u32) -> Self {
164        self.per_trade = Some(per_trade);
165        self
166    }
167
168    /// Make the order hidden (not visible to others).
169    pub fn hidden(mut self) -> Self {
170        self.visible = false;
171        self
172    }
173
174    /// Make the order visible (default).
175    pub fn visible(mut self) -> Self {
176        self.visible = true;
177        self
178    }
179}
180
181/// Request to update an existing order.
182///
183/// Only include the fields you want to change.
184///
185/// # Example
186///
187/// ```
188/// use wf_market::UpdateOrder;
189///
190/// // Update just the price
191/// let update = UpdateOrder::new().platinum(90);
192///
193/// // Update price and quantity
194/// let update = UpdateOrder::new()
195///     .platinum(85)
196///     .quantity(10);
197///
198/// // Hide the order
199/// let update = UpdateOrder::new().visible(false);
200///
201/// // Update sculpture stars
202/// let update = UpdateOrder::new()
203///     .amber_stars(2)
204///     .cyan_stars(4);
205/// ```
206#[derive(Debug, Clone, Default, Serialize)]
207#[serde(rename_all = "camelCase")]
208pub struct UpdateOrder {
209    /// New price in platinum
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub platinum: Option<u32>,
212
213    /// New quantity
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub quantity: Option<u32>,
216
217    /// New visibility
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub visible: Option<bool>,
220
221    /// New minimum items per trade
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub per_trade: Option<u32>,
224
225    /// New mod rank
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub rank: Option<u8>,
228
229    /// New charges count (for consumable mods)
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub charges: Option<u8>,
232
233    /// New amber stars count (for sculptures)
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub amber_stars: Option<u8>,
236
237    /// New cyan stars count (for sculptures)
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub cyan_stars: Option<u8>,
240
241    /// New subtype
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub subtype: Option<String>,
244}
245
246impl UpdateOrder {
247    /// Create a new empty update request.
248    pub fn new() -> Self {
249        Self::default()
250    }
251
252    /// Set the new price.
253    ///
254    /// # Panics
255    ///
256    /// Panics in debug builds if `platinum` is 0.
257    pub fn platinum(mut self, platinum: u32) -> Self {
258        debug_assert!(platinum > 0, "platinum must be greater than 0");
259        self.platinum = Some(platinum);
260        self
261    }
262
263    /// Set the new quantity.
264    ///
265    /// # Panics
266    ///
267    /// Panics in debug builds if `quantity` is 0.
268    pub fn quantity(mut self, quantity: u32) -> Self {
269        debug_assert!(quantity > 0, "quantity must be greater than 0");
270        self.quantity = Some(quantity);
271        self
272    }
273
274    /// Set the new visibility.
275    pub fn visible(mut self, visible: bool) -> Self {
276        self.visible = Some(visible);
277        self
278    }
279
280    /// Set the new minimum items per trade.
281    pub fn per_trade(mut self, per_trade: u32) -> Self {
282        self.per_trade = Some(per_trade);
283        self
284    }
285
286    /// Set the new mod rank.
287    pub fn rank(mut self, rank: u8) -> Self {
288        self.rank = Some(rank);
289        self
290    }
291
292    /// Set the new charges count (for consumable mods).
293    pub fn charges(mut self, charges: u8) -> Self {
294        self.charges = Some(charges);
295        self
296    }
297
298    /// Set the new amber stars count (for Ayatan sculptures).
299    pub fn amber_stars(mut self, stars: u8) -> Self {
300        self.amber_stars = Some(stars);
301        self
302    }
303
304    /// Set the new cyan stars count (for Ayatan sculptures).
305    pub fn cyan_stars(mut self, stars: u8) -> Self {
306        self.cyan_stars = Some(stars);
307        self
308    }
309
310    /// Set the new subtype.
311    pub fn subtype(mut self, subtype: impl Into<String>) -> Self {
312        self.subtype = Some(subtype.into());
313        self
314    }
315
316    /// Check if any fields are set.
317    pub fn is_empty(&self) -> bool {
318        self.platinum.is_none()
319            && self.quantity.is_none()
320            && self.visible.is_none()
321            && self.per_trade.is_none()
322            && self.rank.is_none()
323            && self.charges.is_none()
324            && self.amber_stars.is_none()
325            && self.cyan_stars.is_none()
326            && self.subtype.is_none()
327    }
328}
329
330/// Filter options for fetching top orders.
331///
332/// Use the builder methods to construct filters for the `get_top_orders` endpoint.
333///
334/// # Example
335///
336/// ```
337/// use wf_market::TopOrderFilters;
338///
339/// // Filter for max rank mods
340/// let filters = TopOrderFilters::new().rank(10);
341///
342/// // Filter for mods with rank less than 5
343/// let filters = TopOrderFilters::new().rank_lt(5);
344///
345/// // Filter for sculptures with specific star counts
346/// let filters = TopOrderFilters::new()
347///     .amber_stars(2)
348///     .cyan_stars(4);
349///
350/// // Filter by subtype
351/// let filters = TopOrderFilters::new().subtype("blueprint");
352/// ```
353#[derive(Debug, Clone, Default, Serialize)]
354#[serde(rename_all = "camelCase")]
355pub struct TopOrderFilters {
356    /// Filter by exact mod rank
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub rank: Option<u8>,
359
360    /// Filter by mod rank less than this value
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub rank_lt: Option<u8>,
363
364    /// Filter by exact charges (for consumable mods)
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub charges: Option<u8>,
367
368    /// Filter by charges less than this value
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub charges_lt: Option<u8>,
371
372    /// Filter by exact amber stars (for sculptures)
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub amber_stars: Option<u8>,
375
376    /// Filter by amber stars less than this value
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub amber_stars_lt: Option<u8>,
379
380    /// Filter by exact cyan stars (for sculptures)
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub cyan_stars: Option<u8>,
383
384    /// Filter by cyan stars less than this value
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub cyan_stars_lt: Option<u8>,
387
388    /// Filter by item subtype (e.g., "blueprint", "crafted")
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub subtype: Option<String>,
391}
392
393impl TopOrderFilters {
394    /// Create new empty filters.
395    pub fn new() -> Self {
396        Self::default()
397    }
398
399    /// Filter by exact mod rank.
400    pub fn rank(mut self, rank: u8) -> Self {
401        self.rank = Some(rank);
402        self
403    }
404
405    /// Filter by mod rank less than this value.
406    ///
407    /// # Panics
408    ///
409    /// Panics in debug builds if `rank` is 0.
410    pub fn rank_lt(mut self, rank: u8) -> Self {
411        debug_assert!(rank > 0, "rank_lt must be greater than 0");
412        self.rank_lt = Some(rank);
413        self
414    }
415
416    /// Filter by exact charges (for consumable mods like Requiem).
417    pub fn charges(mut self, charges: u8) -> Self {
418        self.charges = Some(charges);
419        self
420    }
421
422    /// Filter by charges less than this value.
423    ///
424    /// # Panics
425    ///
426    /// Panics in debug builds if `charges` is 0.
427    pub fn charges_lt(mut self, charges: u8) -> Self {
428        debug_assert!(charges > 0, "charges_lt must be greater than 0");
429        self.charges_lt = Some(charges);
430        self
431    }
432
433    /// Filter by exact amber stars (for Ayatan sculptures).
434    pub fn amber_stars(mut self, stars: u8) -> Self {
435        self.amber_stars = Some(stars);
436        self
437    }
438
439    /// Filter by amber stars less than this value.
440    ///
441    /// # Panics
442    ///
443    /// Panics in debug builds if `stars` is 0.
444    pub fn amber_stars_lt(mut self, stars: u8) -> Self {
445        debug_assert!(stars > 0, "amber_stars_lt must be greater than 0");
446        self.amber_stars_lt = Some(stars);
447        self
448    }
449
450    /// Filter by exact cyan stars (for Ayatan sculptures).
451    pub fn cyan_stars(mut self, stars: u8) -> Self {
452        self.cyan_stars = Some(stars);
453        self
454    }
455
456    /// Filter by cyan stars less than this value.
457    ///
458    /// # Panics
459    ///
460    /// Panics in debug builds if `stars` is 0.
461    pub fn cyan_stars_lt(mut self, stars: u8) -> Self {
462        debug_assert!(stars > 0, "cyan_stars_lt must be greater than 0");
463        self.cyan_stars_lt = Some(stars);
464        self
465    }
466
467    /// Filter by item subtype (e.g., "blueprint", "crafted").
468    pub fn subtype(mut self, subtype: impl Into<String>) -> Self {
469        self.subtype = Some(subtype.into());
470        self
471    }
472
473    /// Check if any filters are set.
474    pub fn is_empty(&self) -> bool {
475        self.rank.is_none()
476            && self.rank_lt.is_none()
477            && self.charges.is_none()
478            && self.charges_lt.is_none()
479            && self.amber_stars.is_none()
480            && self.amber_stars_lt.is_none()
481            && self.cyan_stars.is_none()
482            && self.cyan_stars_lt.is_none()
483            && self.subtype.is_none()
484    }
485
486    /// Build query string for the filters.
487    pub(crate) fn to_query_string(&self) -> String {
488        let mut params = Vec::new();
489
490        if let Some(v) = self.rank {
491            params.push(format!("rank={}", v));
492        }
493        if let Some(v) = self.rank_lt {
494            params.push(format!("rankLt={}", v));
495        }
496        if let Some(v) = self.charges {
497            params.push(format!("charges={}", v));
498        }
499        if let Some(v) = self.charges_lt {
500            params.push(format!("chargesLt={}", v));
501        }
502        if let Some(v) = self.amber_stars {
503            params.push(format!("amberStars={}", v));
504        }
505        if let Some(v) = self.amber_stars_lt {
506            params.push(format!("amberStarsLt={}", v));
507        }
508        if let Some(v) = self.cyan_stars {
509            params.push(format!("cyanStars={}", v));
510        }
511        if let Some(v) = self.cyan_stars_lt {
512            params.push(format!("cyanStarsLt={}", v));
513        }
514        if let Some(ref v) = self.subtype {
515            // URL-encode the subtype to handle special characters safely
516            params.push(format!("subtype={}", urlencoding::encode(v)));
517        }
518
519        if params.is_empty() {
520            String::new()
521        } else {
522            format!("?{}", params.join("&"))
523        }
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_create_sell_order() {
533        let order = CreateOrder::sell("test-item", 100, 5);
534
535        assert_eq!(order.item_id, "test-item");
536        assert!(matches!(order.order_type, OrderType::Sell));
537        assert_eq!(order.platinum, 100);
538        assert_eq!(order.quantity, 5);
539        assert!(order.visible);
540    }
541
542    #[test]
543    fn test_create_buy_order() {
544        let order = CreateOrder::buy("test-item", 50, 10);
545
546        assert!(matches!(order.order_type, OrderType::Buy));
547        assert_eq!(order.platinum, 50);
548    }
549
550    #[test]
551    fn test_order_builder_chain() {
552        let order = CreateOrder::sell("mod-item", 100, 1)
553            .with_mod_rank(10)
554            .hidden();
555
556        assert_eq!(order.rank, Some(10));
557        assert!(!order.visible);
558    }
559
560    #[test]
561    fn test_update_order() {
562        let update = UpdateOrder::new().platinum(90).quantity(5);
563
564        assert_eq!(update.platinum, Some(90));
565        assert_eq!(update.quantity, Some(5));
566        assert!(!update.is_empty());
567    }
568
569    #[test]
570    fn test_update_order_empty() {
571        let update = UpdateOrder::new();
572        assert!(update.is_empty());
573    }
574
575    #[test]
576    fn test_serialization() {
577        let order = CreateOrder::sell("item", 100, 1).with_mod_rank(5);
578
579        let json = serde_json::to_string(&order).unwrap();
580        assert!(json.contains("\"rank\":5"));
581        assert!(!json.contains("charges")); // None fields should be skipped
582    }
583}