Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions crates/workshop-rs-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use workshop_rs::census;
use workshop_rs::convert::{self, ConvertOptions};
use workshop_rs::detect;
use workshop_rs::emitter::{self, EmitOptions};
use workshop_rs::live_capture;
use workshop_rs::parser;

mod corpus;
Expand Down Expand Up @@ -47,6 +48,9 @@ commands:
Run an offline provenance-linked real-project corpus manifest and print
its #18 conformance report. Known gaps remain visible and do not count
as matches; unexpected regressions return exit code 1.
seasonal-diff <previous.json> <current.json> [--json]
Validate two provenance-rich live-client capture documents and emit a
structured offline drift report. This command never captures a client.
";

pub fn run(args: Vec<String>) -> i32 {
Expand All @@ -64,6 +68,7 @@ pub fn run(args: Vec<String>) -> i32 {
"version" => version_command(rest),
"census" => census_command(rest),
"corpus" => corpus_command(rest),
"seasonal-diff" => seasonal_diff_command(rest),
"help" | "--help" | "-h" => {
print!("{USAGE}");
0
Expand Down Expand Up @@ -453,6 +458,70 @@ fn corpus_command(args: Vec<String>) -> i32 {
}
}

fn seasonal_diff_command(args: Vec<String>) -> i32 {
let mut paths = Vec::new();
let mut json = false;
for argument in args {
if argument == "--json" {
json = true;
} else if paths.len() < 2 {
paths.push(PathBuf::from(argument));
} else {
return usage_error("seasonal-diff accepts two capture files and --json");
}
}
if paths.len() != 2 {
return usage_error("seasonal-diff requires previous and current capture files");
}
let previous = match read_file(&paths[0]) {
Ok(text) => text,
Err(error) => {
eprintln!("workshop-rs-cli: {error}");
return 1;
}
};
let current = match read_file(&paths[1]) {
Ok(text) => text,
Err(error) => {
eprintln!("workshop-rs-cli: {error}");
return 1;
}
};
let previous = match live_capture::LiveCapture::from_json(&previous) {
Ok(capture) => capture,
Err(error) => {
eprintln!("workshop-rs-cli: seasonal-diff: {error}");
return 1;
}
};
let current = match live_capture::LiveCapture::from_json(&current) {
Ok(capture) => capture,
Err(error) => {
eprintln!("workshop-rs-cli: seasonal-diff: {error}");
return 1;
}
};
let diff = match previous.diff(&current) {
Ok(diff) => diff,
Err(error) => {
eprintln!("workshop-rs-cli: seasonal-diff: {error}");
return 1;
}
};
if json {
match diff.to_json() {
Ok(text) => println!("{text}"),
Err(error) => {
eprintln!("workshop-rs-cli: cannot serialize seasonal diff: {error}");
return 1;
}
}
} else {
print!("{}", diff.human_summary());
}
0
}

fn usage_error(message: &str) -> i32 {
eprintln!("workshop-rs-cli: {message}");
eprintln!("{USAGE}");
Expand Down
135 changes: 135 additions & 0 deletions crates/workshop-rs-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
use std::path::PathBuf;
use std::process::Command;

use workshop_rs::catalog::{Catalog, Locale};
use workshop_rs::conformance::CONFORMANCE_SCHEMA_VERSION;
use workshop_rs::conformance::{
Comparison, ConformanceReason, ConformanceResult, ConformanceStatus, Equivalence, Evidence,
EvidenceArtifact, EvidenceBasis, EvidenceClass, ExpectationSource, FeatureId, FeatureKind,
FeatureNamespace, ReasonCode,
};
use workshop_rs::live_capture::{
CENSUS_IDENTITY_SCHEMA_VERSION, CensusIdentity, LIVE_CAPTURE_SCHEMA_VERSION, LiveCapture,
};

fn bin() -> &'static str {
env!("CARGO_BIN_EXE_workshop-rs-cli")
}
Expand All @@ -19,6 +30,80 @@ fn run(args: &[&str]) -> std::process::Output {
Command::new(bin()).args(args).output().expect("cli runs")
}

// Synthetic schema/diff input only. This helper deliberately creates no
// client artifact and is not an Overwatch evidence fixture.
fn synthetic_capture(id: &str) -> String {
let catalog = Catalog::builtin().unwrap();
let locale = Locale::new("en-US");
let raw = EvidenceArtifact {
name: "synthetic-cli/raw.ws".to_string(),
revision: Some("synthetic-cli".to_string()),
path: Some("synthetic-cli/raw.ws".to_string()),
sha256: Some(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
),
license: Some("test-only".to_string()),
};
let result = ConformanceResult {
schema_version: CONFORMANCE_SCHEMA_VERSION,
case_id: "wir/variables-global".to_string(),
features: vec![
FeatureId::owned(FeatureNamespace::Wir, FeatureKind::Variable, "global").unwrap(),
],
status: ConformanceStatus::Inconclusive,
comparison: Comparison {
mode: Equivalence::NotComparable,
expected: None,
observed: None,
normalizer: None,
},
evidence: Evidence {
class: EvidenceClass::LiveClient,
fixture: raw.clone(),
expectation: ExpectationSource {
basis: EvidenceBasis::WorkshopClient,
artifact: EvidenceArtifact::new("synthetic-cli/client-expectation"),
tracking_ref: None,
},
catalog: catalog.identity(),
locale: Some(locale.clone()),
client: Some(workshop_rs::conformance::ClientEvidence {
game: "overwatch-2".to_string(),
client_version: Some("synthetic-client".to_string()),
season: Some("synthetic-season".to_string()),
captured_at: "2026-08-18T00:00:00Z".to_string(),
environment: Some("synthetic CLI unit input".to_string()),
}),
implementation: None,
},
reason: Some(ConformanceReason {
code: ReasonCode::Inconclusive,
detail: "synthetic CLI schema input".to_string(),
tracking_ref: None,
}),
};
LiveCapture {
schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
capture_id: id.to_string(),
game: "overwatch-2".to_string(),
client: "synthetic-client".to_string(),
season: "synthetic-season".to_string(),
captured_at: "2026-08-18T00:00:00Z".to_string(),
environment: "synthetic CLI unit input".to_string(),
locale,
catalog: catalog.identity(),
census: CensusIdentity {
schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
shards: vec!["synthetic-cli-shard".to_string()],
},
raw_artifact: raw,
results: vec![result],
}
.to_json()
.unwrap()
}

#[test]
fn version_reports_the_machine_readable_identity() {
let output = run(&["version"]);
Expand Down Expand Up @@ -170,6 +255,56 @@ fn usage_errors_exit_2() {
assert_eq!(output.status.code(), Some(2));
}

#[test]
fn seasonal_diff_requires_two_capture_files() {
let output = run(&["seasonal-diff"]);
assert_eq!(output.status.code(), Some(2));
let output = run(&["seasonal-diff", "previous.json"]);
assert_eq!(output.status.code(), Some(2));
let output = run(&["seasonal-diff", "previous.json", "current.json", "extra"]);
assert_eq!(output.status.code(), Some(2));
}

#[test]
fn seasonal_diff_missing_capture_fails_without_fabricating_evidence() {
let output = run(&["seasonal-diff", "/no/previous.json", "/no/current.json"]);
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read"));
}

#[test]
fn seasonal_diff_runs_text_and_json_success_paths_with_synthetic_schema_input() {
let dir = std::env::temp_dir().join(format!("workshop-rs-cli-seasonal-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let previous = dir.join("previous.json");
let current = dir.join("current.json");
std::fs::write(&previous, synthetic_capture("capture-a")).unwrap();
std::fs::write(&current, synthetic_capture("capture-b")).unwrap();
let previous_arg = previous.to_string_lossy().to_string();
let current_arg = current.to_string_lossy().to_string();

let output = run(&["seasonal-diff", &previous_arg, &current_arg]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("RuntimeUncertainty"));

let output = run(&["seasonal-diff", &previous_arg, &current_arg, "--json"]);
assert!(output.status.success());
let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(
report["runtimeUncertainty"]
.as_array()
.is_some_and(|entries| !entries.is_empty())
);

let _ = std::fs::remove_file(previous);
let _ = std::fs::remove_file(current);
let _ = std::fs::remove_dir(&dir);
}

#[test]
fn census_runs_with_machine_readable_results() {
let output = run(&["census", "--json"]);
Expand Down
3 changes: 3 additions & 0 deletions crates/workshop-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
//! * [`roundtrip`] — cross-locale round-trip validation;
//! * [`convert`] — raw Workshop locale conversion (parse -> canonical
//! semantics -> emit).
//! * [`live_capture`] — offline validation and structured comparison contracts
//! for manually recorded client evidence; it never controls a client.
//!
//! The catalog is locale-independent at the identity layer: analyzer and WIR
//! APIs never need locale-specific strings to identify a builtin. Locale
Expand All @@ -38,6 +40,7 @@ mod error;
pub mod format;
pub mod ids;
pub mod lexer;
pub mod live_capture;
pub mod parser;
pub mod roundtrip;
pub mod settings;
Expand Down
Loading