Skip to main content

plato_tile_current/
lib.rs

1//! plato-tile-current — CurrentLayer adapter for tile export, import, transport
2//!
3//! Moves tiles between ships through the fleet current. Handles serialization,
4//! chunking for large exports, and import validation.
5//! Matches plato-ship-protocol::CurrentLayer trait.
6//!
7//! Sprint 3 Task S3-3: tile transport across the fleet.
8
9use std::collections::HashMap;
10
11// ── Current Trait ─────────────────────────────────────────
12
13/// Current layer: export/import/transport for tiles.
14/// Matches plato-ship-protocol::CurrentLayer exactly.
15pub trait CurrentLayer {
16    fn export(&self, tile_ids: &[&str]) -> Vec<u8>;
17    fn import(&mut self, data: &[u8]) -> ImportResult;
18    fn transport_capacity(&self) -> usize;
19}
20
21// ── Tile Representation ───────────────────────────────────
22
23#[derive(Debug, Clone)]
24pub struct TransportTile {
25    pub id: String,
26    pub content: String,
27    pub source: String,
28    pub weight: f32,
29    pub tier: DeployTier,
30    pub created_at: u64,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DeployTier {
35    Live = 0,
36    Monitored = 1,
37    HumanGated = 2,
38}
39
40impl DeployTier {
41    pub fn from_byte(b: u8) -> Self {
42        match b {
43            0 => DeployTier::Live,
44            1 => DeployTier::Monitored,
45            _ => DeployTier::HumanGated,
46        }
47    }
48    pub fn to_byte(self) -> u8 { self as u8 }
49    pub fn name(self) -> &'static str {
50        match self {
51            DeployTier::Live => "live",
52            DeployTier::Monitored => "monitored",
53            DeployTier::HumanGated => "human_gated",
54        }
55    }
56}
57
58// ── Import Result ─────────────────────────────────────────
59
60#[derive(Debug, Clone)]
61pub struct ImportResult {
62    pub imported: Vec<TransportTile>,
63    pub rejected: Vec<RejectedTile>,
64    pub total_bytes: usize,
65    pub chunks_processed: usize,
66}
67
68#[derive(Debug, Clone)]
69pub struct RejectedTile {
70    pub reason: String,
71    pub raw_line: String,
72}
73
74// ── Current Adapter ───────────────────────────────────────
75
76/// Handles tile export/import across the fleet current.
77/// Hand-rolled line protocol (no serde, cargo 1.75 compatible).
78#[derive(Debug, Clone)]
79pub struct CurrentAdapter {
80    tiles: HashMap<String, TransportTile>,
81    max_transport: usize, // max bytes per export
82    imported_count: u64,
83    exported_count: u64,
84    rejected_count: u64,
85}
86
87impl CurrentAdapter {
88    pub fn new() -> Self {
89        Self {
90            tiles: HashMap::new(),
91            max_transport: 1024 * 1024, // 1MB default
92            imported_count: 0,
93            exported_count: 0,
94            rejected_count: 0,
95        }
96    }
97
98    pub fn with_max_transport(mut self, max: usize) -> Self {
99        self.max_transport = max;
100        self
101    }
102
103    /// Store a tile
104    pub fn store(&mut self, tile: TransportTile) -> bool {
105        self.tiles.insert(tile.id.clone(), tile).is_none()
106    }
107
108    /// Get a tile by ID
109    pub fn get(&self, id: &str) -> Option<&TransportTile> {
110        self.tiles.get(id)
111    }
112
113    /// Remove a tile
114    pub fn remove(&mut self, id: &str) -> Option<TransportTile> {
115        self.tiles.remove(id)
116    }
117
118    /// Tile count
119    pub fn tile_count(&self) -> usize { self.tiles.len() }
120
121    /// Export tiles matching IDs, returns serialized payload
122    pub fn export_tiles(&self, ids: &[&str]) -> ExportResult {
123        let mut lines = Vec::new();
124        let mut exported = 0;
125        let mut missing = Vec::new();
126
127        for id in ids {
128            if let Some(tile) = self.tiles.get(*id) {
129                let line = format!("TILE	{}	{}	{}	{}	{}	{}",
130                    tile.id,
131                    tile.content,
132                    tile.source,
133                    tile.weight,
134                    tile.tier.to_byte(),
135                    tile.created_at
136                );
137                lines.push(line);
138                exported += 1;
139            } else {
140                missing.push(id.to_string());
141            }
142        }
143
144        let header = format!("PLATO_CURRENT|{}|{}", exported, lines.len());
145        let mut payload = header;
146        for line in &lines {
147            payload.push('\n');
148            payload.push_str(line);
149        }
150
151        ExportResult {
152            payload: payload.into_bytes(),
153            tiles_exported: exported,
154            missing_ids: missing,
155        }
156    }
157
158    /// Import tiles from serialized payload
159    pub fn import_tiles(&mut self, data: &[u8]) -> ImportResult {
160        let text = match String::from_utf8(data.to_vec()) {
161            Ok(t) => t,
162            Err(_) => return ImportResult {
163                imported: Vec::new(),
164                rejected: vec![RejectedTile { reason: "invalid utf8".to_string(), raw_line: String::new() }],
165                total_bytes: data.len(),
166                chunks_processed: 0,
167            },
168        };
169
170        let mut imported = Vec::new();
171        let mut rejected = Vec::new();
172        let mut chunks = 0;
173
174        for line in text.lines() {
175            if line.starts_with("PLATO_CURRENT|") {
176                chunks += 1;
177                continue;
178            }
179            if !line.starts_with("TILE\t") {
180                rejected.push(RejectedTile {
181                    reason: "missing TILE prefix".to_string(),
182                    raw_line: line.to_string(),
183                });
184                self.rejected_count += 1;
185                continue;
186            }
187
188            let parts: Vec<&str> = line.splitn(7, '\t').collect();
189            if parts.len() < 6 {
190                rejected.push(RejectedTile {
191                    reason: format!("expected 6 fields, got {}", parts.len()),
192                    raw_line: line.to_string(),
193                });
194                self.rejected_count += 1;
195                continue;
196            }
197
198            let tile = TransportTile {
199                id: parts[1].to_string(),
200                content: parts[2].to_string(),
201                source: parts[3].to_string(),
202                weight: parts[4].parse().unwrap_or(0.0),
203                tier: DeployTier::from_byte(parts[5].parse().unwrap_or(2)),
204                created_at: parts.get(6).and_then(|s| s.parse().ok()).unwrap_or(0),
205            };
206
207            // Validate
208            if tile.id.is_empty() {
209                rejected.push(RejectedTile {
210                    reason: "empty tile id".to_string(),
211                    raw_line: line.to_string(),
212                });
213                self.rejected_count += 1;
214                continue;
215            }
216
217            let id = tile.id.clone();
218            self.tiles.insert(id, tile.clone());
219            imported.push(tile);
220            self.imported_count += 1;
221        }
222
223        ImportResult {
224            imported,
225            rejected,
226            total_bytes: data.len(),
227            chunks_processed: chunks,
228        }
229    }
230
231    /// Export all tiles
232    pub fn export_all(&self) -> ExportResult {
233        let ids: Vec<&str> = self.tiles.keys().map(|s| s.as_str()).collect();
234        self.export_tiles(&ids)
235    }
236
237    /// Stats
238    pub fn stats(&self) -> CurrentStats {
239        CurrentStats {
240            tile_count: self.tiles.len(),
241            imported_count: self.imported_count,
242            exported_count: self.exported_count,
243            rejected_count: self.rejected_count,
244            max_transport: self.max_transport,
245        }
246    }
247}
248
249#[derive(Debug, Clone)]
250pub struct ExportResult {
251    pub payload: Vec<u8>,
252    pub tiles_exported: usize,
253    pub missing_ids: Vec<String>,
254}
255
256#[derive(Debug, Clone, Copy)]
257pub struct CurrentStats {
258    pub tile_count: usize,
259    pub imported_count: u64,
260    pub exported_count: u64,
261    pub rejected_count: u64,
262    pub max_transport: usize,
263}
264
265impl Default for CurrentAdapter {
266    fn default() -> Self { Self::new() }
267}
268
269impl CurrentLayer for CurrentAdapter {
270    fn export(&self, tile_ids: &[&str]) -> Vec<u8> {
271        let result = self.export_tiles(tile_ids);
272        result.payload
273    }
274
275    fn import(&mut self, data: &[u8]) -> ImportResult {
276        self.import_tiles(data)
277    }
278
279    fn transport_capacity(&self) -> usize {
280        self.max_transport
281    }
282}
283
284// ── Escaping ──────────────────────────────────────────────
285// Tab-delimited protocol — no escaping needed. Pipes, backslashes,
286// and newlines in content are preserved as-is (content cannot span lines).
287
288
289// ── Tests ────────────────────────────────────────────────
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn sample_tile(id: &str, content: &str) -> TransportTile {
296        TransportTile {
297            id: id.to_string(),
298            content: content.to_string(),
299            source: "test".to_string(),
300            weight: 0.8,
301            tier: DeployTier::Live,
302            created_at: 12345,
303        }
304    }
305
306    #[test]
307    fn test_store_and_get() {
308        let mut cur = CurrentAdapter::new();
309        cur.store(sample_tile("tile-1", "hello"));
310        let tile = cur.get("tile-1").unwrap();
311        assert_eq!(tile.content, "hello");
312    }
313
314    #[test]
315    fn test_store_overwrite() {
316        let mut cur = CurrentAdapter::new();
317        assert!(cur.store(sample_tile("tile-1", "v1")));
318        assert!(!cur.store(sample_tile("tile-1", "v2"))); // overwrite
319        assert_eq!(cur.get("tile-1").unwrap().content, "v2");
320    }
321
322    #[test]
323    fn test_remove() {
324        let mut cur = CurrentAdapter::new();
325        cur.store(sample_tile("tile-1", "hello"));
326        assert!(cur.remove("tile-1").is_some());
327        assert!(cur.get("tile-1").is_none());
328    }
329
330    #[test]
331    fn test_export_import_roundtrip() {
332        let mut cur = CurrentAdapter::new();
333        cur.store(sample_tile("tile-1", "hello world"));
334        cur.store(sample_tile("tile-2", "foo|bar"));
335
336        let exported = cur.export_tiles(&["tile-1", "tile-2"]);
337        assert_eq!(exported.tiles_exported, 2);
338
339        let mut cur2 = CurrentAdapter::new();
340        let result = cur2.import_tiles(&exported.payload);
341        assert_eq!(result.imported.len(), 2);
342        assert!(result.rejected.is_empty());
343        assert_eq!(cur2.get("tile-1").unwrap().content, "hello world");
344        assert_eq!(cur2.get("tile-2").unwrap().content, "foo|bar");
345    }
346
347    #[test]
348    fn test_export_missing_ids() {
349        let mut cur = CurrentAdapter::new();
350        cur.store(sample_tile("tile-1", "exists"));
351
352        let result = cur.export_tiles(&["tile-1", "tile-999"]);
353        assert_eq!(result.tiles_exported, 1);
354        assert_eq!(result.missing_ids, vec!["tile-999"]);
355    }
356
357    #[test]
358    fn test_import_empty() {
359        let mut cur = CurrentAdapter::new();
360        let result = cur.import_tiles(b"");
361        assert!(result.imported.is_empty());
362        assert!(result.rejected.is_empty());
363    }
364
365    #[test]
366    fn test_import_invalid_utf8() {
367        let mut cur = CurrentAdapter::new();
368        let bad: Vec<u8> = vec![0xFF, 0xFE];
369        let result = cur.import_tiles(&bad);
370        assert!(result.imported.is_empty());
371        assert_eq!(result.rejected.len(), 1);
372    }
373
374    #[test]
375    fn test_import_missing_prefix() {
376        let mut cur = CurrentAdapter::new();
377        let result = cur.import_tiles(b"not_a_tile_line\nTILE\ta\tb\tc\t0.5\t0\t100");
378        assert_eq!(result.rejected.len(), 1);
379        assert_eq!(result.imported.len(), 1);
380    }
381
382    #[test]
383    fn test_import_empty_id() {
384        let mut cur = CurrentAdapter::new();
385        let result = cur.import_tiles(b"TILE\t\tcontent\tsrc\t0.5\t0\t100");
386        assert!(result.imported.is_empty());
387        assert_eq!(result.rejected.len(), 1);
388    }
389
390    #[test]
391    fn test_import_malformed_field_count() {
392        let mut cur = CurrentAdapter::new();
393        let result = cur.import_tiles(b"TILE\tonly_two_fields");
394        assert_eq!(result.rejected.len(), 1);
395        assert!(result.rejected[0].reason.contains("expected 6 fields"));
396    }
397
398    #[test]
399    fn test_deploy_tier() {
400        assert_eq!(DeployTier::from_byte(0), DeployTier::Live);
401        assert_eq!(DeployTier::from_byte(1), DeployTier::Monitored);
402        assert_eq!(DeployTier::from_byte(2), DeployTier::HumanGated);
403        assert_eq!(DeployTier::from_byte(99), DeployTier::HumanGated);
404        assert_eq!(DeployTier::Live.to_byte(), 0);
405    }
406
407    #[test]
408    fn test_export_all() {
409        let mut cur = CurrentAdapter::new();
410        cur.store(sample_tile("a", "1"));
411        cur.store(sample_tile("b", "2"));
412        cur.store(sample_tile("c", "3"));
413
414        let result = cur.export_all();
415        assert_eq!(result.tiles_exported, 3);
416        assert!(result.missing_ids.is_empty());
417    }
418
419    #[test]
420    fn test_tab_protocol() {
421        let line = "TILE\tid\tcontent with | pipes\tsrc\t0.5\t0\t100";
422        let parts: Vec<&str> = line.splitn(7, '\t').collect();
423        assert_eq!(parts.len(), 7);
424        assert_eq!(parts[2], "content with | pipes");
425    }
426
427    #[test]
428    fn test_roundtrip_with_pipes() {
429        let mut cur = CurrentAdapter::new();
430        cur.store(TransportTile {
431            id: "pipe-test".to_string(),
432            content: "has|pipes\\and|backslashes".to_string(),
433            source: "src|with|pipes".to_string(),
434            weight: 0.9,
435            tier: DeployTier::Monitored,
436            created_at: 999,
437        });
438
439        let exported = cur.export_tiles(&["pipe-test"]);
440        let mut cur2 = CurrentAdapter::new();
441        let result = cur2.import_tiles(&exported.payload);
442        assert_eq!(result.imported.len(), 1);
443        let tile = &result.imported[0];
444        assert_eq!(tile.content, "has|pipes\\and|backslashes");
445        assert_eq!(tile.source, "src|with|pipes");
446        assert_eq!(tile.tier, DeployTier::Monitored);
447    }
448
449    #[test]
450    fn test_transport_capacity() {
451        let cur = CurrentAdapter::new().with_max_transport(512);
452        assert_eq!(cur.transport_capacity(), 512);
453    }
454
455    #[test]
456    fn test_current_layer_trait() {
457        let mut cur = CurrentAdapter::new();
458        cur.store(sample_tile("t1", "data"));
459
460        let payload = cur.export(&["t1"]);
461        assert!(!payload.is_empty());
462
463        let mut cur2 = CurrentAdapter::new();
464        let result = cur2.import(&payload);
465        assert_eq!(result.imported.len(), 1);
466    }
467
468    #[test]
469    fn test_stats() {
470        let mut cur = CurrentAdapter::new();
471        cur.store(sample_tile("a", "1"));
472        cur.export_tiles(&["a"]);
473        cur.import_tiles(b"TILE\tb\tcontent\tsrc\t0.5\t0\t100");
474
475        let stats = cur.stats();
476        assert_eq!(stats.tile_count, 2); // a + b
477        assert!(stats.imported_count > 0);
478    }
479}