1use std::collections::HashMap;
10
11pub 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#[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#[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#[derive(Debug, Clone)]
79pub struct CurrentAdapter {
80 tiles: HashMap<String, TransportTile>,
81 max_transport: usize, 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, 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 pub fn store(&mut self, tile: TransportTile) -> bool {
105 self.tiles.insert(tile.id.clone(), tile).is_none()
106 }
107
108 pub fn get(&self, id: &str) -> Option<&TransportTile> {
110 self.tiles.get(id)
111 }
112
113 pub fn remove(&mut self, id: &str) -> Option<TransportTile> {
115 self.tiles.remove(id)
116 }
117
118 pub fn tile_count(&self) -> usize { self.tiles.len() }
120
121 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 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 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 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 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#[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"))); 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); assert!(stats.imported_count > 0);
478 }
479}