diff --git a/Cargo.lock b/Cargo.lock index b1fecba..ebb6c6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,7 +562,7 @@ dependencies = [ [[package]] name = "comline-codegen" version = "0.1.0" -source = "git+https://github.com/ComlineProject/generation?rev=de7ceb4c266d1fb5d6dc3c918d84cb226003f6b6#de7ceb4c266d1fb5d6dc3c918d84cb226003f6b6" +source = "git+https://github.com/ComlineProject/generation?rev=0601f004302ebb93e6804732419ccf459902e84a#0601f004302ebb93e6804732419ccf459902e84a" dependencies = [ "comline-core", "eyre", @@ -576,7 +576,7 @@ dependencies = [ [[package]] name = "comline-codegen-rust" version = "0.1.0" -source = "git+https://github.com/ComlineProject/comline-rust?rev=5346678b82fc275fca741bf5ca0cfad1e8edf075#5346678b82fc275fca741bf5ca0cfad1e8edf075" +source = "git+https://github.com/ComlineProject/comline-rust?rev=6e8715ac126ab78316dd6a2712165d716351fab1#6e8715ac126ab78316dd6a2712165d716351fab1" dependencies = [ "comline-codegen", "comline-core", @@ -586,7 +586,7 @@ dependencies = [ [[package]] name = "comline-codegen-typescript" version = "0.1.0" -source = "git+https://github.com/ComlineProject/comline-typescript?rev=c2096cd500f7cd3a83d495fbbbf5df72bc12ecc7#c2096cd500f7cd3a83d495fbbbf5df72bc12ecc7" +source = "git+https://github.com/ComlineProject/comline-typescript?rev=470dc5bad2f7e36e1cc2f44e3e57fde864a42315#470dc5bad2f7e36e1cc2f44e3e57fde864a42315" dependencies = [ "comline-codegen", "comline-core", @@ -596,7 +596,7 @@ dependencies = [ [[package]] name = "comline-core" version = "0.1.0" -source = "git+https://github.com/ComlineProject/core?rev=2ae998634bad418e4a03ce7494e673275dafcc01#2ae998634bad418e4a03ce7494e673275dafcc01" +source = "git+https://github.com/ComlineProject/core?rev=47066583002dbca27975827106f9e34761d0dc64#47066583002dbca27975827106f9e34761d0dc64" dependencies = [ "ariadne", "blake3", @@ -624,7 +624,7 @@ dependencies = [ [[package]] name = "comline-core-stdlib" version = "0.1.0" -source = "git+https://github.com/ComlineProject/core?rev=2ae998634bad418e4a03ce7494e673275dafcc01#2ae998634bad418e4a03ce7494e673275dafcc01" +source = "git+https://github.com/ComlineProject/core?rev=47066583002dbca27975827106f9e34761d0dc64#47066583002dbca27975827106f9e34761d0dc64" [[package]] name = "comline-runtime" diff --git a/Cargo.toml b/Cargo.toml index 4a0cf10..c6214f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,15 +32,15 @@ eyre = "0.6.8" # needs real I/O (git fetch, filesystem), so it's off by default for # `comline-core`'s wasm32 consumers (the playground editor, the language # server); the CLI is the one consumer that actually runs `comline build`. -comline-core = { git = "https://github.com/ComlineProject/core", rev = "2ae998634bad418e4a03ce7494e673275dafcc01", features = ["deps"] } +comline-core = { git = "https://github.com/ComlineProject/core", rev = "47066583002dbca27975827106f9e34761d0dc64", features = ["deps"] } # All three pinned to revs that agree on one `comline-codegen`, and bumped # together: comline-rust and comline-typescript's codegen crates consume # `comline-codegen`'s types, so the rev below must be byte-identical to the # one *they* pin - Cargo only unifies a git dependency across identical rev # strings, and two copies of `GenRequest` are two incompatible types. -comline-codegen = { git = "https://github.com/ComlineProject/generation", rev = "de7ceb4c266d1fb5d6dc3c918d84cb226003f6b6" } -comline-codegen-rust = { git = "https://github.com/ComlineProject/comline-rust", rev = "5346678b82fc275fca741bf5ca0cfad1e8edf075", optional = true } -comline-codegen-typescript = { git = "https://github.com/ComlineProject/comline-typescript", rev = "c2096cd500f7cd3a83d495fbbbf5df72bc12ecc7", optional = true } +comline-codegen = { git = "https://github.com/ComlineProject/generation", rev = "0601f004302ebb93e6804732419ccf459902e84a" } +comline-codegen-rust = { git = "https://github.com/ComlineProject/comline-rust", rev = "6e8715ac126ab78316dd6a2712165d716351fab1", optional = true } +comline-codegen-typescript = { git = "https://github.com/ComlineProject/comline-typescript", rev = "470dc5bad2f7e36e1cc2f44e3e57fde864a42315", optional = true } # `comline conformance`: the conformance runner (corpus loading, judging, # profile proofs) and its case format, a public spec. Pinned by git rev. comline-runtime-conformance = { git = "https://github.com/ComlineProject/runtime", rev = "6f8f73a247f0aa4ac8b7041b2fdc4b02bce0e0d6" } diff --git a/docs/cli.md b/docs/cli.md index 5a48bc2..ace90a1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,6 +16,7 @@ This is the long-form reference. For a two-minute tour see the - [`new`](#comline-new) - [`add`](#comline-add) - [`check`](#comline-check) + - [`inspect`](#comline-inspect) - [`build`](#comline-build) - [`generate`](#comline-generate) - [`diff`](#comline-diff) @@ -195,6 +196,62 @@ comline --path ./services/users check Exit `0` if valid, `1` if a schema or the config is invalid, `2` if the directory is not a Comline project. +### `comline inspect` + +``` +comline inspect [--json] +``` + +What each protocol speaks on the wire, and which guarantees it keeps. Like +`check`, it compiles the package and writes nothing to `.comline/`. + +For each protocol, a short header names the format, framing and handshake it +resolves to, with their parameters and where each came from: + +| Source | Meaning | +|---|---| +| `from @framing` / `from @handshake` | the protocol's own annotation | +| `from config.idp ...` | the package's `wire.format`, `wire.framing` or `handshake` | +| `core's default` | nothing named one: `packed@1`, `datagram@2`, `comline@1` | +| `JSON-RPC's format` | the framing is JSON-RPC and the package names no format | + +Then one row per guarantee (schema agreement, encoding agreement, encoding +conformance, version honesty), held or given up. A given-up row says why, and +whether it has been accepted. A dependency's protocol is accepted by this +package's `accept_unverified`. Who accepts this package's own protocols isn't +known here: each consumer does that in its own `config.idp`. A protocol's +`@unverified` reason is in its header. + +``` +$ comline --plain inspect +protocol api::Legacy (shop) + format json@1 JSON-RPC's format + framing jsonrpc (method_names = qualified) from @framing + handshake none from @handshake + unverified "the 2009 gateway can't run a handshake" + + schema agreement given up + because handshake `none` + acceptance: up to each consumer's config.idp + encoding agreement given up + because handshake `none` + acceptance: up to each consumer's config.idp + encoding conformance held + version honesty held +``` + +The report goes to stdout. `--json` prints the same as JSON, as +`{ "protocols": [...] }`, with each choice's `source` given as `annotation`, +`package_default`, `core_default` or `framing`. Each guarantee's `acceptance` +is `{ "state": "not_needed" | "unknown" | "not_accepted" }`, or +`{ "state": "accepted", "by": "" }`. + +A protocol's compatibility facts (its line, version and shape hash) aren't +shown, because they aren't built yet (core#119). + +Exit `0` on success, `1` if a schema or the config is invalid, `2` if the +directory is not a Comline project. + ### `comline build` ``` diff --git a/src/cli.rs b/src/cli.rs index 126329b..be085a9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -77,6 +77,29 @@ first error found. Unlike `build`, this never writes to `.comline/` and never \ bumps the version, so it is safe to run in editors and pre-commit hooks.")] Check, + /// Show what each protocol speaks on the wire and which guarantees it keeps + #[command(long_about = "\ +For each protocol, the format, framing and handshake it resolves to, their \ +parameters, and where each came from: the protocol's own `@framing` or \ +`@handshake`, the package's `wire` or `handshake` in `config.idp`, or core's \ +default. Then every guarantee (schema agreement, encoding agreement, encoding \ +conformance, version honesty), held or given up, with the cause, the \ +`@unverified` reason, and whether it has been accepted. A dependency's \ +protocol is accepted by this package's `accept_unverified`; who accepts this \ +package's own protocols isn't known here. + +Like `check`, this never writes to `.comline/`. `--json` prints the same as \ +JSON. + +Examples: + comline inspect + comline inspect --json")] + Inspect { + /// Print the report as JSON + #[arg(long)] + json: bool, + }, + /// Validate the project and write generated code for each configured target #[command(long_about = "\ Validate the project (no version bump, no `.comline/` write), then write \ diff --git a/src/commands/inspect.rs b/src/commands/inspect.rs new file mode 100644 index 0000000..c2ee2eb --- /dev/null +++ b/src/commands/inspect.rs @@ -0,0 +1,152 @@ +//! `comline inspect` — what each protocol resolves to on the wire, where +//! each choice came from, and which guarantees it keeps. Read-only, like +//! `check`: nothing is written to `.comline/`. +//! +//! The model is core's (`comline_core::inspect`), shared with the language +//! server's hover. The report goes to stdout; `--json` prints the same as +//! JSON. + +use std::io::Write as _; +use std::path::Path; + +use anstyle::{AnsiColor, Color, Style}; +use comline_core::inspect::{self, Acceptance, Choice, ProtocolSummary, Source}; +use miette::{IntoDiagnostic, Result}; + +use crate::commands::{core_err, ensure_project}; +use crate::ui; + +const HELD: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green))); +const GIVEN_UP: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow))); +const DIM: Style = Style::new().dimmed(); +const BOLD: Style = Style::new().bold(); + +pub fn run(work_dir: &Path, json: bool) -> Result<()> { + ensure_project(work_dir)?; + + let spinner = ui::spinner("compiling schemas"); + let outcome = comline_core::package::build::compile_package(work_dir); + ui::finish_spinner(spinner); + let context = outcome.map_err(|e| core_err("validation failed", e))?; + + let protocols = inspect::protocols(&context); + let mut out = anstream::stdout().lock(); + if json { + let report = serde_json::json!({ "protocols": protocols }); + writeln!( + out, + "{}", + serde_json::to_string_pretty(&report).into_diagnostic()? + ) + .into_diagnostic()?; + return Ok(()); + } + + if protocols.is_empty() { + ui::note("No protocols to inspect"); + return Ok(()); + } + let text = protocols + .iter() + .map(|p| render(p, ui::plain())) + .collect::>() + .join("\n"); + write!(out, "{text}").into_diagnostic()?; + Ok(()) +} + +/// One protocol: a header of choices, then a row per guarantee. Fits 80 +/// columns: anything long goes on its own indented line. +pub fn render(p: &ProtocolSummary, plain: bool) -> String { + let style = |s: Style, text: &str| { + if plain { + text.to_string() + } else { + format!("{s}{text}{s:#}") + } + }; + let mut out = String::new(); + + let whose = if p.dependency { + format!("dependency {}", p.package) + } else { + p.package.clone() + }; + out += &format!( + "{} {}\n", + style(BOLD, &format!("protocol {}::{}", p.schema, p.name)), + style(DIM, &format!("({whose})")) + ); + for (key, choice) in [ + ("format", &p.format), + ("framing", &p.framing), + ("handshake", &p.handshake), + ] { + out += &format!( + " {key:<10} {:<24} {}\n", + choice_value(choice), + style(DIM, &source(choice.source, key)) + ); + } + let reason = p.guarantees.iter().find_map(|g| g.reason.as_deref()); + if p.unverified { + match reason { + Some(reason) => out += &format!(" {:<10} \"{reason}\"\n", "unverified"), + None => out += &format!(" {:<10} (no reason given)\n", "unverified"), + } + } + + out += "\n"; + for row in &p.guarantees { + let (mark, state) = match (row.held, plain) { + (true, true) => ("", style(HELD, "held")), + (true, false) => ("✓ ", style(HELD, "held")), + (false, true) => ("", style(GIVEN_UP, "given up")), + (false, false) => ("✗ ", style(GIVEN_UP, "given up")), + }; + out += &format!(" {mark}{:<22} {state}\n", row.guarantee.describe()); + for cause in &row.causes { + out += &format!(" because {cause}\n"); + } + match &row.acceptance { + Acceptance::NotNeeded => {} + Acceptance::Unknown => { + out += &format!( + " {}\n", + style(DIM, "acceptance: up to each consumer's config.idp") + ); + } + Acceptance::Accepted(by) => out += &format!(" accepted by {by}\n"), + Acceptance::NotAccepted => { + out += &format!(" {}\n", style(GIVEN_UP, "not accepted")) + } + } + } + out +} + +/// `jsonrpc (method_names = qualified)`. +fn choice_value(choice: &Choice) -> String { + if choice.params.is_empty() { + return choice.value.clone(); + } + let params: Vec = choice + .params + .iter() + .map(|(k, v)| format!("{k} = {v}")) + .collect(); + format!("{} ({})", choice.value, params.join(", ")) +} + +/// Where a choice came from, for the header. +fn source(source: Source, key: &str) -> String { + match source { + Source::Annotation => format!("from @{key}"), + Source::PackageDefault => match key { + "handshake" => "from config.idp `handshake`".to_string(), + _ => format!("from config.idp `wire.{key}`"), + }, + Source::CoreDefault => "core's default".to_string(), + Source::Framing => "JSON-RPC's format".to_string(), + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 78113e3..a2b43c4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod completions; pub mod conformance; pub mod diff; pub mod generate; +pub mod inspect; pub mod new; pub mod release; pub mod reset; diff --git a/src/main.rs b/src/main.rs index e46840d..e18c213 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,6 +118,7 @@ fn run(cli: Cli) -> Result<()> { match cli.command { Commands::Build { watch } => commands::build::run(&work_dir, watch), Commands::Check => commands::check::run(&work_dir), + Commands::Inspect { json } => commands::inspect::run(&work_dir, json), Commands::Generate { target, out, diff --git a/tests/cli/inspect.rs b/tests/cli/inspect.rs new file mode 100644 index 0000000..bf1e3d5 --- /dev/null +++ b/tests/cli/inspect.rs @@ -0,0 +1,178 @@ +//! `comline inspect` + +use std::fs; +use std::path::Path; + +use predicates::prelude::*; + +use crate::util::*; + +fn package(dir: &Path, config: &str, schema: &str) { + fs::create_dir_all(dir.join("src")).unwrap(); + let name = dir.file_name().unwrap().to_str().unwrap(); + fs::write( + dir.join("config.idp"), + format!("congregation {name}\nspecification_version = 1\n{config}"), + ) + .unwrap(); + fs::write(dir.join("src/api.ids"), schema).unwrap(); +} + +const ALLOW: &str = "settings = { annotations = { unverified = { allowed = true } framing = { allowed = true } } }\n"; + +fn shop(temp: &Path) -> std::path::PathBuf { + let shop = temp.join("shop"); + package( + &shop, + &format!("wire = {{ framing = \"datagram@1\" }}\n{ALLOW}"), + "protocol Orders {\n function place(id: u64) -> bool;\n}\n\n\ + @framing = { name = \"jsonrpc\", method_names = \"qualified\" }\n\ + @handshake = \"none\"\n\ + @unverified = \"the 2009 gateway can't run a handshake\"\n\ + protocol Legacy {\n function ping();\n}\n", + ); + shop +} + +#[test] +fn shows_each_choice_its_source_and_every_guarantee() { + let temp = tempfile::tempdir().unwrap(); + let shop = shop(temp.path()); + + let out = comline_cmd() + .current_dir(&shop) + .args(["--plain", "inspect"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let out = String::from_utf8(out).unwrap(); + let expected = "\ +protocol api::Orders (shop) + format packed@1 core's default + framing datagram from config.idp `wire.framing` + handshake comline@1 core's default + + schema agreement held + encoding agreement held + encoding conformance held + version honesty held + +protocol api::Legacy (shop) + format json@1 JSON-RPC's format + framing jsonrpc (method_names = qualified) from @framing + handshake none from @handshake + unverified \"the 2009 gateway can't run a handshake\" + + schema agreement given up + because handshake `none` + acceptance: up to each consumer's config.idp + encoding agreement given up + because handshake `none` + acceptance: up to each consumer's config.idp + encoding conformance held + version honesty held +"; + assert_eq!(out, expected); + assert!(out.lines().all(|l| l.chars().count() <= 80), "{out}"); + assert!( + !shop.join(".comline").exists(), + "inspect must not create the CAS store" + ); +} + +#[test] +fn json_carries_the_same() { + let temp = tempfile::tempdir().unwrap(); + let shop = shop(temp.path()); + + let out = comline_cmd() + .current_dir(&shop) + .args(["inspect", "--json"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let json: serde_json::Value = serde_json::from_slice(&out).unwrap(); + let legacy = &json["protocols"][1]; + assert_eq!(legacy["name"], "Legacy"); + assert_eq!(legacy["framing"]["source"], "annotation"); + assert_eq!(legacy["framing"]["params"]["method_names"], "qualified"); + assert_eq!(legacy["format"]["source"], "framing"); + assert_eq!(json["protocols"][0]["framing"]["source"], "package_default"); + assert_eq!(json["protocols"][0]["format"]["source"], "core_default"); + let row = &legacy["guarantees"][0]; + assert_eq!(row["guarantee"], "schema_agreement"); + assert_eq!(row["held"], false); + assert_eq!(row["causes"], serde_json::json!(["handshake `none`"])); + assert_eq!(row["reason"], "the 2009 gateway can't run a handshake"); + assert_eq!(row["acceptance"], serde_json::json!({ "state": "unknown" })); + assert_eq!( + legacy["guarantees"][3]["acceptance"], + serde_json::json!({ "state": "not_needed" }) + ); +} + +#[test] +fn a_dependencys_protocol_shows_who_accepted_it() { + let temp = tempfile::tempdir().unwrap(); + package( + &temp.path().join("billing"), + &format!("handshake = \"none\"\n{ALLOW}"), + "@unverified = \"checked out of band\"\nprotocol Billing {\n function charge();\n}\n", + ); + let app = temp.path().join("app"); + package( + &app, + "dependencies = {\n billing = {\n path = \"../billing\"\n \ + accept_unverified = { Billing = [\"schema_agreement\", \"encoding_agreement\"] }\n }\n}\n", + "struct A {\n x: u32\n}\n", + ); + + comline_cmd() + .current_dir(&app) + .args(["--plain", "inspect"]) + .assert() + .success() + .stdout(predicate::str::contains( + "protocol billing::api::Billing (dependency billing)", + )) + .stdout(predicate::str::contains( + "handshake none from config.idp `handshake`", + )) + .stdout(predicate::str::contains( + " because handshake `none`\n accepted by app\n", + )); +} + +#[test] +fn a_project_without_protocols_says_so() { + let temp = tempfile::tempdir().unwrap(); + let app = temp.path().join("app"); + package(&app, "", "struct A {\n x: u32\n}\n"); + + comline_cmd() + .current_dir(&app) + .args(["--plain", "inspect"]) + .assert() + .success() + .stdout("") + .stderr(predicate::str::contains("No protocols to inspect")); +} + +#[test] +fn reports_a_broken_schema() { + let temp = tempfile::tempdir().unwrap(); + let project = fixture_project(temp.path()); + fs::write(project.join("src/api.ids"), "struct Broken {").unwrap(); + + comline_cmd() + .current_dir(&project) + .arg("inspect") + .assert() + .failure() + .code(1) + .stderr(predicate::str::contains("validation failed")); +} diff --git a/tests/cli/main.rs b/tests/cli/main.rs index c408d87..c00249d 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -17,6 +17,7 @@ mod end_to_end; mod external; mod generate; mod global; +mod inspect; mod new; mod release; mod reset; diff --git a/tests/fixtures/generators/Cargo.lock b/tests/fixtures/generators/Cargo.lock index 2e3ff69..05a057e 100644 --- a/tests/fixtures/generators/Cargo.lock +++ b/tests/fixtures/generators/Cargo.lock @@ -73,7 +73,7 @@ dependencies = [ [[package]] name = "comline-codegen" version = "0.1.0" -source = "git+https://github.com/ComlineProject/generation?rev=de7ceb4c266d1fb5d6dc3c918d84cb226003f6b6#de7ceb4c266d1fb5d6dc3c918d84cb226003f6b6" +source = "git+https://github.com/ComlineProject/generation?rev=0601f004302ebb93e6804732419ccf459902e84a#0601f004302ebb93e6804732419ccf459902e84a" dependencies = [ "comline-core", "eyre", @@ -87,7 +87,7 @@ dependencies = [ [[package]] name = "comline-codegen-rust" version = "0.1.0" -source = "git+https://github.com/ComlineProject/comline-rust?rev=5346678b82fc275fca741bf5ca0cfad1e8edf075#5346678b82fc275fca741bf5ca0cfad1e8edf075" +source = "git+https://github.com/ComlineProject/comline-rust?rev=6e8715ac126ab78316dd6a2712165d716351fab1#6e8715ac126ab78316dd6a2712165d716351fab1" dependencies = [ "comline-codegen", "comline-core", @@ -97,7 +97,7 @@ dependencies = [ [[package]] name = "comline-core" version = "0.1.0" -source = "git+https://github.com/ComlineProject/core?rev=2ae998634bad418e4a03ce7494e673275dafcc01#2ae998634bad418e4a03ce7494e673275dafcc01" +source = "git+https://github.com/ComlineProject/core?rev=47066583002dbca27975827106f9e34761d0dc64#47066583002dbca27975827106f9e34761d0dc64" dependencies = [ "blake3", "ciborium", @@ -114,7 +114,7 @@ dependencies = [ [[package]] name = "comline-core-stdlib" version = "0.1.0" -source = "git+https://github.com/ComlineProject/core?rev=2ae998634bad418e4a03ce7494e673275dafcc01#2ae998634bad418e4a03ce7494e673275dafcc01" +source = "git+https://github.com/ComlineProject/core?rev=47066583002dbca27975827106f9e34761d0dc64#47066583002dbca27975827106f9e34761d0dc64" [[package]] name = "comline-test-generators" diff --git a/tests/fixtures/generators/Cargo.toml b/tests/fixtures/generators/Cargo.toml index 9eb1857..bda1f1b 100644 --- a/tests/fixtures/generators/Cargo.toml +++ b/tests/fixtures/generators/Cargo.toml @@ -12,5 +12,5 @@ publish = false [dependencies] # The same revs the CLI pins, so the real generator's output is comparable # byte for byte with the CLI's built-in one. -comline-codegen = { git = "https://github.com/ComlineProject/generation", rev = "de7ceb4c266d1fb5d6dc3c918d84cb226003f6b6" } -comline-codegen-rust = { git = "https://github.com/ComlineProject/comline-rust", rev = "5346678b82fc275fca741bf5ca0cfad1e8edf075" } +comline-codegen = { git = "https://github.com/ComlineProject/generation", rev = "0601f004302ebb93e6804732419ccf459902e84a" } +comline-codegen-rust = { git = "https://github.com/ComlineProject/comline-rust", rev = "6e8715ac126ab78316dd6a2712165d716351fab1" }