1use std::io::Read;
47use std::path::Path;
48
49use anyhow::{anyhow, bail, Context, Result};
50use scema_world::WorldState;
51
52use crate::observer::Observer;
53
54pub const MAX_IMPORT_BYTES: u64 = 16 * 1024 * 1024;
60
61#[derive(Clone, Copy, Debug, Default)]
63pub struct ImportObserver;
64
65impl ImportObserver {
66 pub fn new() -> Self {
67 ImportObserver
68 }
69
70 pub fn from_json(text: &str, source: &str) -> Result<WorldState> {
76 let mut world: WorldState = serde_json::from_str(text).with_context(|| {
77 format!("{source} is not a scema-world WorldState (see scema-world's JSON shape)")
78 })?;
79 check(&world).with_context(|| format!("{source} parsed but is not internally consistent"))?;
80 world.observer = stamp(&world.observer);
81 Ok(world)
82 }
83
84 pub fn from_stdin() -> Result<WorldState> {
86 let mut text = String::new();
87 std::io::stdin()
88 .take(MAX_IMPORT_BYTES)
89 .read_to_string(&mut text)
90 .context("reading a world from stdin")?;
91 if text.trim().is_empty() {
92 bail!(
93 "nothing arrived on stdin. A producer that printed its help text or failed \
94 silently looks exactly like this — check its exit code."
95 );
96 }
97 ImportObserver::from_json(&text, "stdin")
98 }
99
100 pub fn from_file(path: &Path) -> Result<WorldState> {
102 let meta = std::fs::metadata(path)
103 .with_context(|| format!("reading {}", path.display()))?;
104 if meta.len() > MAX_IMPORT_BYTES {
105 bail!(
106 "{} is {} bytes, over the {MAX_IMPORT_BYTES}-byte import cap. A world is a \
107 description of an environment, not a dump of it.",
108 path.display(),
109 meta.len()
110 );
111 }
112 let text = std::fs::read_to_string(path)
113 .with_context(|| format!("reading {}", path.display()))?;
114 ImportObserver::from_json(&text, &path.display().to_string())
115 }
116}
117
118fn stamp(observer: &str) -> String {
124 let name = observer.trim();
125 if name.is_empty() {
126 return "imported:unknown".to_string();
130 }
131 if name.starts_with("imported:") {
132 return name.to_string();
133 }
134 format!("imported:{name}")
135}
136
137fn check(w: &WorldState) -> Result<()> {
147 use crate::conform::{conform, has_failure, Level};
148 let findings = conform(w);
149 if !has_failure(&findings) {
150 return Ok(());
151 }
152 let mut msg = String::new();
153 for f in findings.iter().filter(|f| f.level == Level::Fail) {
154 msg.push_str("
155 - ");
156 msg.push_str(&f.message);
157 if let Some(fix) = &f.fix {
158 msg.push_str("
159 fix: ");
160 msg.push_str(fix);
161 }
162 }
163 msg.push_str("
164
165 `scema check <file>` prints the full report.");
166 bail!("{msg}");
167}
168
169impl Observer for ImportObserver {
170 fn name(&self) -> &str {
171 "import"
172 }
173
174 fn about(&self) -> &str {
175 "a WorldState produced elsewhere: `-` for stdin, or a path to a .json file"
176 }
177
178 fn handles(&self, locator: &str) -> bool {
179 let l = locator.trim();
180 l == "-" || l.eq_ignore_ascii_case("stdin") || l.to_ascii_lowercase().ends_with(".json")
181 }
182
183 fn observe(&self, locator: &str) -> Result<WorldState> {
184 let l = locator.trim();
185 if l == "-" || l.eq_ignore_ascii_case("stdin") {
186 return ImportObserver::from_stdin();
187 }
188 if !self.handles(l) {
189 return Err(anyhow!(
190 "`{l}` is not something this observer handles; it takes `-` or a path ending .json"
191 ));
192 }
193 ImportObserver::from_file(Path::new(l))
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use scema_world::{Domain, Entity, EntityKind, Extent, Polarity, Provenance, Signal};
201 use std::fs;
202
203 fn minimal() -> serde_json::Value {
204 serde_json::json!({
205 "schema": scema_world::WORLD_SCHEMA,
206 "observer": "mesh",
207 "entity": { "kind": "service", "locator": "/bot", "label": "bot" },
208 "domain": "trading",
209 "observed_at": 1_700_000_000i64,
210 "objects": [],
211 "facts": [],
212 "signals": [],
213 "extent": { "observed": 3, "total": 3, "note": "collected" },
214 "blind_spots": []
215 })
216 }
217
218 fn with_signals(signals: serde_json::Value) -> String {
219 let mut v = minimal();
220 v["signals"] = signals;
221 v.to_string()
222 }
223
224 #[test]
225 fn an_imported_world_can_never_claim_it_was_observed_here() {
226 let w = ImportObserver::from_json(&minimal().to_string(), "t").unwrap();
229 assert_eq!(w.observer, "imported:mesh");
230 }
231
232 #[test]
233 fn importing_twice_does_not_stack_prefixes() {
234 let mut v = minimal();
237 v["observer"] = serde_json::json!("imported:mesh");
238 let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
239 assert_eq!(w.observer, "imported:mesh");
240 }
241
242 #[test]
243 fn a_world_with_no_observer_name_is_attributed_to_nobody_rather_than_to_us() {
244 let mut v = minimal();
245 v["observer"] = serde_json::json!(" ");
246 let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
247 assert_eq!(w.observer, "imported:unknown");
248 }
249
250 #[test]
251 fn a_counted_signal_that_cites_nothing_is_refused() {
252 let text = with_signals(serde_json::json!([{
257 "id": "a", "polarity": "risk", "label": "x", "detail": "",
258 "magnitude": 0.5, "measured": true, "targets": [], "evidence": []
259 }]));
260 let err = ImportObserver::from_json(&text, "t").unwrap_err().to_string();
261 let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
262 assert!(chain.contains("cites no evidence"), "{err} / {chain}");
263 }
264
265 #[test]
266 fn an_estimated_signal_may_cite_nothing() {
267 let text = with_signals(serde_json::json!([{
270 "id": "a", "polarity": "risk", "label": "x", "detail": "",
271 "magnitude": 0.5, "measured": false, "targets": [], "evidence": []
272 }]));
273 assert!(ImportObserver::from_json(&text, "t").is_ok());
274 }
275
276 #[test]
277 fn a_magnitude_outside_the_unit_interval_is_refused_with_the_signal_named() {
278 for bad in [1.5, -0.2] {
281 let text = with_signals(serde_json::json!([{
282 "id": "loud", "polarity": "risk", "label": "x", "detail": "",
283 "magnitude": bad, "measured": true, "targets": [], "evidence": ["counted"]
284 }]));
285 let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
286 assert!(chain.contains("loud"), "{chain}");
287 assert!(chain.contains("outside [0,1]"), "{chain}");
288 }
289 }
290
291 #[test]
292 fn duplicate_signal_ids_are_refused_because_ground_could_not_name_one() {
293 let sig = |id: &str| {
294 serde_json::json!({
295 "id": id, "polarity": "risk", "label": "x", "detail": "",
296 "magnitude": 0.5, "measured": true, "targets": [], "evidence": ["counted"]
297 })
298 };
299 let text = with_signals(serde_json::json!([sig("a"), sig("a")]));
300 let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
301 assert!(chain.contains("share the id"), "{chain}");
302 }
303
304 #[test]
305 fn an_extent_whose_numerator_exceeds_its_denominator_is_refused() {
306 let mut v = minimal();
310 v["extent"] = serde_json::json!({ "observed": 9, "total": 3, "note": "?" });
311 let chain = format!("{:#}", ImportObserver::from_json(&v.to_string(), "t").unwrap_err());
312 assert!(chain.contains("not a smaller number"), "{chain}");
313 }
314
315 #[test]
316 fn an_unknown_denominator_is_accepted_and_is_the_correct_way_to_say_so() {
317 let mut v = minimal();
318 v["extent"] = serde_json::json!({ "observed": 9, "total": null, "note": "capped" });
319 let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
320 assert_eq!(w.extent.fraction(), None);
321 }
322
323 #[test]
324 fn an_entity_with_no_locator_is_refused() {
325 let mut v = minimal();
328 v["entity"]["locator"] = serde_json::json!("");
329 assert!(ImportObserver::from_json(&v.to_string(), "t").is_err());
330 }
331
332 #[test]
333 fn the_locator_grammar_is_narrow_so_repo_observer_still_wins_a_directory() {
334 let o = ImportObserver;
338 assert!(o.handles("-"));
339 assert!(o.handles("stdin"));
340 assert!(o.handles("mesh.json"));
341 assert!(o.handles("/tmp/World.JSON"));
342 assert!(!o.handles("."));
343 assert!(!o.handles("/some/project"));
344 assert!(!o.handles("crates/scema-tools"));
345 }
346
347 #[test]
348 fn a_file_that_is_not_json_says_what_it_should_have_been() {
349 let dir = std::env::temp_dir().join(format!("scema-import-{}", std::process::id()));
350 fs::create_dir_all(&dir).unwrap();
351 let path = dir.join("bad.json");
352 fs::write(&path, "not json").unwrap();
353 let chain = format!("{:#}", ImportObserver.observe(path.to_str().unwrap()).unwrap_err());
354 assert!(chain.contains("WorldState"), "{chain}");
355 fs::remove_dir_all(&dir).ok();
356 }
357
358 #[test]
359 fn a_real_world_round_trips_through_the_importer_unchanged_but_for_the_stamp() {
360 let original = WorldState {
363 schema: Some(scema_world::WORLD_SCHEMA.into()),
364 observer: "mesh".into(),
365 entity: Entity {
366 kind: EntityKind::Service,
367 locator: "/bot".into(),
368 label: "sniper".into(),
369 },
370 domain: Domain::Trading,
371 observed_at: 1_700_000_000,
372 objects: vec![],
373 facts: vec![],
374 signals: vec![Signal {
375 id: "veto:dqstar".into(),
376 polarity: Polarity::Risk,
377 label: "DQ* is suppressing buys".into(),
378 detail: String::new(),
379 magnitude: 0.8,
380 measured: true,
381 targets: vec!["learner.dqstar".into()],
382 evidence: vec!["counted 12 consecutive vetoes".into()],
383 }],
384 extent: Extent::complete(7, "collected"),
385 blind_spots: vec!["scematica-metrics.json: absent".into()],
386 };
387 let text = serde_json::to_string(&original).unwrap();
388 let back = ImportObserver::from_json(&text, "t").unwrap();
389
390 assert_eq!(back.observer, "imported:mesh");
391 assert_eq!(back.entity, original.entity);
392 assert_eq!(back.signals, original.signals);
393 assert_eq!(back.blind_spots, original.blind_spots);
394 assert_eq!(back.extent, original.extent);
395 }
396
397 fn fixture(name: &str) -> String {
409 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
410 .join("fixtures")
411 .join(name);
412 std::fs::read_to_string(&path)
413 .unwrap_or_else(|e| panic!("reading {}: {e}", path.display()))
414 }
415
416 #[test]
418 fn every_producer_fixture_imports() {
419 for (file, observer) in [
420 ("mesh-world.json", "imported:mesh"),
421 ("alchem-world.json", "imported:alchem-link"),
422 ("page-world.json", "imported:page"),
423 ] {
424 let w = ImportObserver::from_json(&fixture(file), file)
425 .unwrap_or_else(|e| panic!("{file}: {e:#}"));
426 assert_eq!(w.observer, observer, "{file}");
427 assert!(!w.entity.locator.trim().is_empty(), "{file}");
428 }
429 }
430
431 #[test]
436 fn every_producer_reports_what_it_could_not_see() {
437 for file in ["mesh-world.json", "alchem-world.json", "page-world.json"] {
438 let w = ImportObserver::from_json(&fixture(file), file).unwrap();
439 assert!(
440 !w.blind_spots.is_empty(),
441 "{file} reports perfect visibility, which no real observation has"
442 );
443 }
444 }
445
446 #[test]
453 fn no_producer_claims_a_measurement_it_cannot_cite() {
454 for file in ["mesh-world.json", "alchem-world.json", "page-world.json"] {
455 let w = ImportObserver::from_json(&fixture(file), file).unwrap();
456 for s in &w.signals {
457 if s.measured {
458 assert!(!s.evidence.is_empty(), "{file}: `{}` cites nothing", s.id);
459 }
460 assert!((0.0..=1.0).contains(&s.magnitude), "{file}: `{}`", s.id);
461 }
462 }
463 }
464
465 #[test]
473 fn stale_and_absent_survive_the_wire() {
474 let mesh = ImportObserver::from_json(&fixture("mesh-world.json"), "mesh").unwrap();
475 assert!(
476 mesh.objects.iter().any(|o| matches!(o.provenance, Provenance::Stale { .. })),
477 "the mesh fixture should carry at least one stale unit"
478 );
479 assert!(
480 mesh.objects.iter().any(|o| matches!(o.provenance, Provenance::Absent)),
481 "the mesh fixture should carry at least one unseen unit"
482 );
483
484 let feeds = ImportObserver::from_json(&fixture("alchem-world.json"), "alchem").unwrap();
485 assert!(
486 feeds.objects.iter().any(|o| matches!(o.provenance, Provenance::Stale { .. })),
487 "the oracle fixture should carry a feed past its own heartbeat"
488 );
489 for o in feeds.objects.iter().filter(|o| o.provenance == Provenance::Absent) {
492 assert!(o.attrs.is_empty(), "an unread feed must carry no values: {}", o.id);
493 }
494 }
495
496 #[test]
503 fn a_perceived_page_carries_no_query_string() {
504 let w = ImportObserver::from_json(&fixture("page-world.json"), "page").unwrap();
505 assert!(!w.entity.locator.contains('?'), "{}", w.entity.locator);
506 assert!(!w.entity.locator.contains("SECRET"), "{}", w.entity.locator);
507 }
508
509 #[test]
521 fn the_domain_lets_a_specialist_decline_correctly() {
522 let mesh = ImportObserver::from_json(&fixture("mesh-world.json"), "mesh").unwrap();
523 assert_eq!(mesh.domain, scema_world::Domain::Trading);
524
525 let feeds = ImportObserver::from_json(&fixture("alchem-world.json"), "a").unwrap();
526 assert_eq!(feeds.domain, scema_world::Domain::Data);
527
528 let page = ImportObserver::from_json(&fixture("page-world.json"), "p").unwrap();
529 assert_eq!(page.domain, scema_world::Domain::Web);
530
531 assert_ne!(feeds.domain, page.domain, "two different worlds must not read alike");
532 }
533
534 #[test]
539 fn every_producer_declares_the_contract_it_was_written_against() {
540 for file in ["mesh-world.json", "alchem-world.json", "page-world.json"] {
541 let w = ImportObserver::from_json(&fixture(file), file).unwrap();
542 assert_eq!(w.schema.as_deref(), Some(scema_world::WORLD_SCHEMA), "{file}");
543 }
544 }
545
546 #[test]
548 fn an_undeclared_contract_is_refused_with_the_line_to_paste() {
549 let mut v: serde_json::Value =
550 serde_json::from_str(&fixture("mesh-world.json")).unwrap();
551 v.as_object_mut().unwrap().remove("schema");
552 let err = ImportObserver::from_json(&v.to_string(), "t").unwrap_err();
553 let chain = format!("{err:#}");
554 assert!(chain.contains("scema.world/1"), "{chain}");
555 assert!(chain.contains("scema check"), "{chain}");
556 }
557
558 #[test]
560 fn a_producer_with_several_problems_is_told_about_all_of_them() {
561 let mut v = minimal();
565 v.as_object_mut().unwrap().remove("schema");
566 v["entity"]["locator"] = serde_json::json!(" ");
567 v["signals"] = serde_json::json!([
568 { "id": "dup", "polarity": "risk", "label": "l", "detail": "",
569 "magnitude": 0.5, "measured": true, "targets": [], "evidence": [] },
570 { "id": "dup", "polarity": "risk", "label": "l", "detail": "",
571 "magnitude": 4.0, "measured": false, "targets": [], "evidence": ["e"] }
572 ]);
573 let chain = format!("{:#}", ImportObserver::from_json(&v.to_string(), "t").unwrap_err());
574 for expected in ["schema", "locator", "cites no evidence", "share the id", "outside [0,1]"] {
575 assert!(chain.contains(expected), "missing `{expected}` in:
576{chain}");
577 }
578 }
579
580}