diff --git a/crates/pcl/core/src/api.rs b/crates/pcl/core/src/api.rs index 58a281b..5967c6a 100644 --- a/crates/pcl/core/src/api.rs +++ b/crates/pcl/core/src/api.rs @@ -17,17 +17,12 @@ use clap::{ ArgGroup, ValueEnum, }; -use pcl_common::args::{ - CliArgs, - OutputMode, - current_output_mode, -}; +use pcl_common::args::CliArgs; use reqwest::header::{ HeaderMap, HeaderName, HeaderValue, }; -use serde::Serialize; use serde_json::{ Map, Value, @@ -35,8 +30,6 @@ use serde_json::{ }; use std::{ cell::Cell, - collections::BTreeMap, - fmt::Write as _, fs, io::Read, path::{ @@ -47,8 +40,76 @@ use std::{ }; mod manifest; +mod openapi; +mod render; +mod templates; +mod workflows; pub use manifest::api_manifest; +pub use render::{ + envelope_output_string, + human_string, + toon_string, +}; + +use openapi::{ + api_coverage, + command_next_actions, + inspect_operation, + list_operations, + next_actions_for_operations, + openapi_path_matches, + public_raw_call_path, + write_api_coverage_markdown, +}; +#[cfg(test)] +use openapi::{ + body_fields, + body_variants, + example_call, + openapi_body_template, + operation_auth_metadata, + operation_input_placeholders, + raw_api_use, + required_body_fields, + synthetic_operation_id, + workflow_alternatives, +}; +use render::print_output; +use templates::{ + access_body_template, + body_template, + contracts_body_template, + deployment_body_template, + integration_body_template, + project_body_template, + protocol_manager_body_template, + release_body_template, + template_envelope, + transfer_body_template, +}; +use workflows::{ + access_request, + account_request, + assertions_next_actions, + assertions_request, + contracts_request, + deployments_request, + events_request, + first_string_field, + incidents_next_actions, + incidents_request, + integrations_request, + project_segment, + projects_next_actions, + projects_request, + protocol_manager_request, + releases_request, + request_body, + search_next_actions, + search_request, + transfers_request, +}; pub const ENVELOPE_SCHEMA_VERSION: &str = "pcl.envelope.v1"; @@ -840,22 +901,6 @@ fn mutation_outcome_ambiguous(method: &str, status: u16) -> bool { method_side_effecting(method) && status >= 500 } -#[derive(Debug, Serialize)] -struct OperationSummary { - operation_id: String, - method: &'static str, - path: String, - summary: Option, - tags: Vec, - auth: Value, - workflow_alternatives: Vec, - raw_api_use: Value, - inspect_command: String, - call_command: String, - input_placeholders: Vec, - requires_input: bool, -} - struct ApiRequestInput<'a> { method: HttpMethod, path: &'a str, @@ -3005,5548 +3050,229 @@ pub(crate) fn response_body_value(content_type: &str, bytes: &[u8]) -> Value { .unwrap_or_else(|_| json!(String::from_utf8_lossy(bytes).to_string())) } -fn print_output(value: &Value, json_output: bool) -> Result<(), ApiCommandError> { - print!("{}", envelope_output_string(value, json_output)?); - Ok(()) -} - -pub fn envelope_output_string( - value: &Value, - json_output: bool, -) -> Result { - let value = with_envelope_metadata(value.clone()); - let output_mode = if json_output { - OutputMode::Json - } else { - current_output_mode() - }; - match output_mode { - OutputMode::Json => Ok(format!("{}\n", serde_json::to_string_pretty(&value)?)), - OutputMode::Toon => Ok(toon_string(&value)), - OutputMode::Human => Ok(human_string(&value)), - } -} - -/// Render an envelope for interactive humans. -pub fn human_string(value: &Value) -> String { - let value = with_envelope_metadata(value.clone()); - let status = value.get("status").and_then(Value::as_str).unwrap_or("ok"); - let mut output = String::new(); - output.push_str(match status { - "ok" => "OK", - "error" => "Error", - "action_required" => "Action required", - "pending" => "Pending", - other => other, - }); - output.push('\n'); - - if let Some(error) = value.get("error") { - render_human_error(&mut output, error); - } else if !render_human_special(&mut output, &value) - && !render_human_collection(&mut output, &value) - && let Some(data) = value.get("data") - { - render_human_summary(&mut output, data); - } - - let human_actions = human_next_actions(&value); - if !human_actions.is_empty() { - output.push_str("\nNext:\n"); - for (index, action) in human_actions.iter().enumerate() { - output.push_str(" "); - output.push_str(&(index + 1).to_string()); - output.push_str(". "); - output.push_str(action); - output.push('\n'); - } - } - render_human_request_id(&mut output, &value); - if !output.ends_with('\n') { - output.push('\n'); - } - output +fn ok_envelope(data: Value) -> Value { + with_envelope_metadata(json!({ + "status": "ok", + "data": data, + "next_actions": [ + "pcl api list", + "pcl api inspect get_views_public_incidents", + "pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated", + ], + })) } -fn human_next_actions(envelope: &Value) -> Vec { - let status = envelope - .get("status") - .and_then(Value::as_str) - .unwrap_or("ok"); - let is_empty_ok = status == "ok" && envelope_has_empty_results(envelope); - let terms_accepted = envelope_terms_accepted(envelope); - let preserve_agent_flags = envelope - .get("data") - .and_then(|data| data.get("consumption_order")) - .is_some(); - let integration_test_unavailable = envelope - .pointer("/data/test_available") - .or_else(|| envelope.pointer("/data/data/test_available")) +fn dry_run_envelope(data: Value) -> Value { + let auth_required = data + .pointer("/request/auth/required") .and_then(Value::as_bool) - == Some(false); - envelope - .get("next_actions") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .filter(|action| !is_dangerous_or_internal_action(action)) - .filter(|action| !(is_empty_ok && is_item_placeholder_action(action))) - .filter(|action| !(terms_accepted && action.contains("account --accept-terms"))) - .filter(|action| !(integration_test_unavailable && action.contains(" --test"))) - .map(|action| { - if preserve_agent_flags { - action.to_string() - } else { - human_action_str(action) - } - }) - .filter(|action| !action.is_empty()) - .collect() -} - -fn envelope_terms_accepted(envelope: &Value) -> bool { - envelope - .pointer("/data/terms_accepted") - .or_else(|| envelope.pointer("/data/data/terms_accepted")) + .unwrap_or(false); + let allow_unauthenticated = data + .pointer("/request/auth/allow_unauthenticated") .and_then(Value::as_bool) - .unwrap_or(false) -} - -fn is_dangerous_or_internal_action(action: &str) -> bool { - action.contains(" config delete") - || action.contains(" --delete") - || action.contains(" --remove") - || action.contains(" --revoke") - || action.contains(" --logout") - || action.starts_with("Read error.http.body") - || action.starts_with("Use data.") -} - -fn is_item_placeholder_action(action: &str) -> bool { - [ - "", - "", - "", - "", - "", - "", - "", - "", - "", - ] - .iter() - .any(|placeholder| action.contains(placeholder)) + .unwrap_or(false); + let stored_token_valid = data + .pointer("/request/auth/stored_token_valid") + .and_then(Value::as_bool) + .unwrap_or(false); + let next_actions = if auth_required && !allow_unauthenticated && !stored_token_valid { + vec![ + "pcl auth ensure --toon", + "Authenticate before removing --dry-run", + "Use --body-template when constructing mutation bodies", + ] + } else { + let mut actions = vec![ + "Remove --dry-run to execute this request", + "Use --toon for agent consumption or --json for strict JSON parsing", + ]; + let method = data + .pointer("/request/method") + .and_then(Value::as_str) + .unwrap_or_default(); + if method_side_effecting(method) { + actions.push("Use --body-template when constructing mutation bodies"); + } + actions + }; + with_envelope_metadata(json!({ + "status": "ok", + "data": data, + "next_actions": next_actions, + })) } -fn envelope_has_empty_results(envelope: &Value) -> bool { - let Some(data) = envelope.get("data") else { - return false; - }; - value_has_empty_results(data) +fn workflow_success_envelope(result: WorkflowCallResult, next_actions: Vec) -> Value { + with_envelope_metadata(json!({ + "status": "ok", + "data": result.body, + "request": result.request, + "response": result.response, + "next_actions": next_actions, + })) } -fn value_has_empty_results(value: &Value) -> bool { - match value { - Value::Array(values) => values.is_empty(), - Value::Object(object) => { - if let Some(inner) = object.get("data") - && value_has_empty_results(inner) - { - return true; - } - object.iter().any(|(key, value)| { - !key.starts_with('_') - && (value.as_array().is_some_and(Vec::is_empty) - || value_has_empty_results(value)) - }) - } - _ => false, - } +fn request_is_destructive(method: HttpMethod, path: &str) -> bool { + method == HttpMethod::Delete + || path.contains("/delete") + || path.contains("/remove") + || path.contains("/reject") + || path.contains("/logout") } -struct HumanCollection<'a> { - field: String, - name: String, - items: &'a [Value], - pagination: Option<&'a Value>, - meta: Option<&'a Value>, +fn query_pairs_value(query: &[(String, String)]) -> Value { + Value::Array( + query + .iter() + .map(|(name, value)| json!({ "name": name, "value": value })) + .collect(), + ) } -fn render_human_error(output: &mut String, error: &Value) { - output.push('\n'); - let code = error.get("code").and_then(Value::as_str); - if let Some(message) = error.get("message").and_then(Value::as_str) { - output.push_str(&human_error_message(code, message)); - output.push('\n'); - } else if let Some(error) = error.as_str() { - output.push_str(error); - output.push('\n'); +fn upsert_query(query: &mut Vec<(String, String)>, name: &str, value: String) { + if let Some((_, existing)) = query.iter_mut().find(|(key, _)| key == name) { + *existing = value; } else { - render_human_value(output, error, 0); - } - - if let Some(reason) = api_error_reason(error) { - output.push_str("API reason: "); - output.push_str(&reason); - output.push('\n'); - } - if let Some(request_id) = error.get("request_id").and_then(Value::as_str) { - output.push_str("Request ID: "); - output.push_str(request_id); - output.push('\n'); + query.push((name.to_string(), value)); } } -fn human_error_message(code: Option<&str>, message: &str) -> String { - if code.is_some_and(|value| value.starts_with("cli.")) { - return clean_cli_error_message(message); +fn extract_paginated_items(value: &Value, preferred_field: &str) -> Option> { + if let Some(items) = array_at_path(value, preferred_field) { + return Some(items.to_vec()); } - match code { - Some("api.not_found") => { - "Resource not found. Check the ID, slug, or API path and try again.".to_string() - } - Some("network.request_failed") => { - "Network request failed. Check --api-url and your network connection, then retry." - .to_string() - } - Some("api.server_error") => { - "The platform returned a server error. Retry later or report the request ID." - .to_string() + for path in [ + "items", + "incidents", + "results", + "data.items", + "data.incidents", + "data.results", + "data", + ] { + if let Some(items) = array_at_path(value, path) { + return Some(items.to_vec()); } - _ => message.to_string(), - } -} - -fn clean_cli_error_message(message: &str) -> String { - let lines = message - .lines() - .take_while(|line| !line.starts_with("Usage:") && !line.starts_with("For more information")) - .map(|line| line.strip_prefix("error: ").unwrap_or(line).trim_end()) - .filter(|line| !line.is_empty()) - .collect::>(); - if lines.first() == Some(&"the following required arguments were not provided:") - && let Some(argument) = lines.get(1) - { - return format!("Missing required argument: {}", argument.trim()); } - lines.join("\n") + value.as_array().cloned() } -fn api_error_reason(error: &Value) -> Option { - let body = error.pointer("/http/body")?; - for key in ["message", "error", "detail", "reason"] { - if let Some(value) = body.get(key).and_then(Value::as_str) - && !value.is_empty() - { - return Some(value.to_string()); +fn array_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a [Value]> { + let mut current = value; + for segment in path.split('.') { + if segment.is_empty() { + continue; } + current = current.get(segment)?; } - body.as_str().map(ToString::to_string) + current.as_array().map(Vec::as_slice) } -fn render_human_special(output: &mut String, envelope: &Value) -> bool { - let Some(data) = envelope.get("data") else { - return false; - }; - let display_data = data.get("data").unwrap_or(data); - - for render in [ - render_login_challenge as fn(&mut String, &Value) -> bool, - render_request_plan, - render_auth_status, - render_identity_status, - render_doctor, - ] { - if render(output, display_data) { - return true; - } - } - if render_project_home(output, data, display_data) { - return true; - } - for render in [ - render_project_detail as fn(&mut String, &Value) -> bool, - render_incident_detail, - render_search_results, - render_account_detail, - render_deployment_state, - render_transfer_state, - render_integration_status, - render_protocol_manager_status, - ] { - if render(output, display_data) { - return true; - } - } - if render_mutation_success(output, envelope, display_data) { - return true; - } - for render in [ - render_api_manifest as fn(&mut String, &Value) -> bool, - render_llms_guide, - render_workflow_detail, - render_schema_detail, - render_operation_detail, - render_api_coverage, - render_raw_api_response, - render_export_result, - render_job_detail, - render_path_or_toggle_result, - ] { - if render(output, display_data) { - return true; - } - } - if render_body_template(output, envelope, display_data) { - return true; - } - - false +fn parse_key_values( + kind: &'static str, + entries: &[String], +) -> Result, ApiCommandError> { + entries + .iter() + .map(|entry| { + let (key, value) = entry.split_once('=').ok_or_else(|| { + ApiCommandError::InvalidKeyValue { + kind, + input: entry.clone(), + } + })?; + Ok((key.to_string(), value.to_string())) + }) + .collect() } -fn render_login_challenge(output: &mut String, data: &Value) -> bool { - if data.get("state").and_then(Value::as_str) != Some("login_required") { - return false; - } - output.push_str("\nLogin required\n"); - if let Some(reason) = data.get("reason").and_then(Value::as_str) { - writeln!(output, "Reason: {}", human_label(reason)).expect("write to string"); - } - if let Some(url) = data.get("device_url").and_then(Value::as_str) { - writeln!(output, "Open: {url}").expect("write to string"); - } - if let Some(code) = data.get("code").and_then(Value::as_str) { - writeln!(output, "Code: {code}").expect("write to string"); - } - if let Some(expires_at) = data.get("expires_at").and_then(Value::as_str) { - writeln!(output, "Expires: {}", format_timestamp(expires_at)).expect("write to string"); - } - if let Some(command) = data.get("poll_command").and_then(Value::as_str) { - writeln!(output, "Poll: {}", humanize_command(command)).expect("write to string"); +fn parse_headers(entries: &[String]) -> Result { + let mut headers = HeaderMap::new(); + + for entry in entries { + let (name, value) = entry.split_once('=').ok_or_else(|| { + ApiCommandError::InvalidKeyValue { + kind: "header", + input: entry.clone(), + } + })?; + let header_name = HeaderName::from_str(name).map_err(|source| { + ApiCommandError::InvalidHeaderName { + name: name.to_string(), + source, + } + })?; + let header_value = HeaderValue::from_str(value).map_err(|source| { + ApiCommandError::InvalidHeaderValue { + name: name.to_string(), + source, + } + })?; + headers.insert(header_name, header_value); } - true + + Ok(headers) } -fn render_request_plan(output: &mut String, data: &Value) -> bool { - if data.get("dry_run").and_then(Value::as_bool) != Some(true) { - return false; +fn read_body( + body: Option<&str>, + body_file: Option<&PathBuf>, +) -> Result, ApiCommandError> { + if let Some(body) = body { + return Ok(Some(body.to_string())); } - output.push_str("\nDry run\n"); - if data.get("valid").and_then(Value::as_bool) == Some(false) { - output.push_str("Request is not valid.\n"); - if let Some(error) = data.get("error") { - render_human_error(output, error); + if let Some(path) = body_file { + if path.as_os_str() == "-" { + let mut body = String::new(); + std::io::stdin() + .read_to_string(&mut body) + .map_err(ApiCommandError::Stdin)?; + return Ok(Some(body)); } - return true; - } - let request = data.get("request").unwrap_or(data); - let method = request.get("method").and_then(Value::as_str).unwrap_or("-"); - let path = request.get("path").and_then(Value::as_str).unwrap_or("-"); - writeln!(output, "{method} {path}").expect("write to string"); - if let Some(query) = request.get("query").and_then(Value::as_array) - && !query.is_empty() - { - output.push_str("Query: "); - output.push_str(&name_value_pairs(query)); - output.push('\n'); - } - if let Some(auth) = request.get("auth") { - let required = auth - .get("required") - .and_then(Value::as_bool) - .unwrap_or(false); - let attached = auth - .get("will_attach_stored_token") - .and_then(Value::as_bool) - .unwrap_or(false); - writeln!( - output, - "Auth: {}{}", - if required { "required" } else { "not required" }, - if attached { - ", stored token will be attached" - } else { - "" + return fs::read_to_string(path).map(Some).map_err(|source| { + ApiCommandError::BodyFile { + path: path.clone(), + source, } - ) - .expect("write to string"); - } - if let Some(body) = request.get("body") - && !body.is_null() - { - output.push_str("Body: "); - output.push_str(&human_compact_summary(body)); - output.push('\n'); - } - if let Some(pagination) = data.get("pagination") - && !pagination.is_null() - { - output.push_str("Pagination: "); - output.push_str(&human_compact_summary(pagination)); - output.push('\n'); + }); } - true -} -fn render_auth_status(output: &mut String, data: &Value) -> bool { - if !data.get("authenticated").is_some_and(Value::is_boolean) - || data.get("auth").is_some() - || data.get("config_path").is_some() - { - return false; - } + Ok(None) +} - output.push_str("\nAuthentication\n"); - let authenticated = data - .get("authenticated") - .and_then(Value::as_bool) - .unwrap_or(false); - writeln!( - output, - "Status: {}", - if authenticated { - "authenticated" - } else { - "not logged in" +fn write_json_output_file(path: &PathBuf, value: &Value) -> Result<(), ApiCommandError> { + let body = serde_json::to_string_pretty(value)?; + fs::write(path, body).map_err(|source| { + ApiCommandError::OutputFile { + path: path.clone(), + source, } - ) - .expect("write to string"); - if let Some(user) = data.get("user").and_then(Value::as_str) { - writeln!(output, "User: {user}").expect("write to string"); - } - if let Some(email) = data.get("email").and_then(Value::as_str) - && data.get("user").and_then(Value::as_str) != Some(email) - { - writeln!(output, "Email: {email}").expect("write to string"); - } - if let Some(wallet) = data.get("wallet_address").and_then(Value::as_str) { - writeln!(output, "Wallet: {wallet}").expect("write to string"); - } - if let Some(expires_at) = data.get("expires_at").and_then(Value::as_str) { - writeln!(output, "Token expires: {}", format_timestamp(expires_at)) - .expect("write to string"); - } - if let Some(seconds) = data.get("seconds_remaining").and_then(Value::as_i64) { - writeln!(output, "Time remaining: {}", format_duration(seconds)).expect("write to string"); - } - if data.get("refreshed").and_then(Value::as_bool) == Some(true) { - output.push_str("Token refreshed.\n"); - } - if let Some(request_id) = data.get("request_id").and_then(Value::as_str) { - writeln!(output, "Request ID: {request_id}").expect("write to string"); - } - true + }) } -fn render_identity_status(output: &mut String, data: &Value) -> bool { - let Some(auth) = data.get("auth") else { - return false; - }; - if !auth.get("authenticated").is_some_and(Value::is_boolean) { - return false; - } - output.push_str("\nIdentity\n"); - let authenticated = auth - .get("authenticated") - .and_then(Value::as_bool) - .unwrap_or(false); - writeln!( - output, - "Status: {}", - if authenticated { - "authenticated" - } else { - "not logged in" - } - ) - .expect("write to string"); - if let Some(user) = auth.get("user").and_then(Value::as_str) { - writeln!(output, "User: {user}").expect("write to string"); - } - if let Some(user_id) = auth.get("user_id").and_then(Value::as_str) { - writeln!(output, "User ID: {user_id}").expect("write to string"); - } - if let Some(expires_at) = auth.get("expires_at").and_then(Value::as_str) { - writeln!(output, "Token expires: {}", format_timestamp(expires_at)) - .expect("write to string"); - } - if let Some(config_path) = data.get("config_path").and_then(Value::as_str) { - writeln!(output, "Config: {config_path}").expect("write to string"); - } - if data.get("offline").and_then(Value::as_bool) == Some(true) { - output.push_str("Network checks skipped.\n"); - } - true -} - -fn render_doctor(output: &mut String, data: &Value) -> bool { - let Some(checks) = data.get("checks").and_then(Value::as_array) else { - return false; - }; - output.push_str("\nDoctor\n"); - render_checks_table(output, checks); - if let Some(api_url) = data.get("api_url").and_then(Value::as_str) { - writeln!(output, "\nAPI: {api_url}").expect("write to string"); - } - output.push_str("Default output: human. Agents should pass --toon; scripts can pass --json.\n"); - true -} - -fn render_project_detail(output: &mut String, data: &Value) -> bool { - if data.get("project_id").is_none() || data.get("project_name").is_none() { - return false; - } - output.push_str("\nProject\n"); - write_string_field(output, "Name", data, "project_name"); - write_string_field(output, "ID", data, "project_id"); - write_string_field(output, "Slug", data, "slug"); - if let Some(private) = data.get("is_private").and_then(Value::as_bool) { - writeln!( - output, - "Visibility: {}", - if private { "private" } else { "public" } - ) - .expect("write to string"); - } - if let Some(dev) = data.get("is_dev").and_then(Value::as_bool) { - writeln!( - output, - "Mode: {}", - if dev { "development" } else { "production" } - ) - .expect("write to string"); - } - write_network_list_for_value(output, data); - write_optional_string_field(output, "Description", data, "project_description"); - write_optional_string_field(output, "GitHub", data, "github_url"); - write_timestamp_field(output, "Created", data, "created_at"); - write_timestamp_field(output, "Updated", data, "updated_at"); - if let Some(manager) = data - .get("protocol_manager_address") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - { - writeln!(output, "Protocol manager: {manager}").expect("write to string"); - } else { - output.push_str("Protocol manager: not set\n"); - } - write_count_field( - output, - "Submitted assertions", - data, - "submitted_assertion_ids", - ); - write_u64_field(output, "Saved by", data, "saved_count", Some("users")); - true -} - -fn render_incident_detail(output: &mut String, data: &Value) -> bool { - let Some(incident_id) = data.get("incident_id").and_then(Value::as_str) else { - return false; - }; - if data.get("invalidating_transactions").is_none() && data.get("transaction_count").is_none() { - return false; - } - - output.push_str("\nIncident\n"); - writeln!(output, "ID: {incident_id}").expect("write to string"); - write_optional_string_field(output, "Reference", data, "public_reference_id"); - write_u64_field(output, "Chain", data, "chain_id", None); - write_timestamp_field(output, "Window start", data, "window_start"); - write_string_field(output, "Environment", data, "environment"); - - if let Some(assertion) = data.get("assertion") { - output.push_str("\nAssertion\n"); - write_optional_string_field(output, "Title", assertion, "title"); - write_optional_string_field(output, "ID", assertion, "assertion_id"); - if let Some(description) = assertion - .get("description") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .filter(|value| !is_hex_blob(value)) - { - writeln!(output, "Description: {}", truncate(description, 96)) - .expect("write to string"); - } - } else { - write_optional_string_field(output, "Assertion ID", data, "assertion_id"); - } - - if let Some(adopter) = data.get("assertion_adopter") { - output.push_str("\nAssertion adopter\n"); - write_optional_string_field(output, "Name", adopter, "name"); - write_optional_string_field(output, "Address", adopter, "address"); - write_optional_string_field(output, "ID", adopter, "id"); - } else { - write_optional_string_field(output, "Assertion adopter ID", data, "assertion_adopter_id"); - } - - output.push_str("\nTrace summary\n"); - if let Some(value) = data.get("transaction_count").and_then(Value::as_u64) { - writeln!( - output, - "Invalidating transactions: {}", - plural_count(value, "transaction") - ) - .expect("write to string"); - } - write_u64_field(output, "Traces completed", data, "traces_completed", None); - write_u64_field(output, "Traces pending", data, "traces_pending", None); - - if let Some(transactions) = data - .get("invalidating_transactions") - .and_then(Value::as_array) - .filter(|transactions| !transactions.is_empty()) - { - let shown = transactions.len().min(5); - writeln!( - output, - "\nInvalidating transactions (first {shown} of {})", - transactions.len() - ) - .expect("write to string"); - writeln!( - output, - "{} {} {} {} Trace", - pad("#", 3), - pad("Time", 16), - pad("Tx hash", 20), - pad("Result", 11) - ) - .expect("write to string"); - for (index, tx) in transactions.iter().take(shown).enumerate() { - let time = tx - .get("incident_timestamp") - .and_then(Value::as_str) - .map_or_else(|| "-".to_string(), format_timestamp); - let hash = first_string_field(tx, &["transaction_hash", "hash", "tx_hash"]) - .map_or_else(|| "-".to_string(), |value| truncate(&value, 20)); - let result = match tx.get("landed_on_chain").and_then(Value::as_bool) { - Some(true) => "landed", - Some(false) => "invalidated", - None => "-", - }; - let trace = tx - .get("debug_traces") - .and_then(Value::as_array) - .and_then(|traces| traces.first()) - .and_then(|trace| trace.get("status")) - .and_then(Value::as_str) - .unwrap_or("-"); - writeln!( - output, - "{} {} {} {} {}", - pad(&(index + 1).to_string(), 3), - pad(&time, 16), - pad(&hash, 20), - pad(result, 11), - trace - ) - .expect("write to string"); - } - } - - true -} - -fn render_project_home(output: &mut String, envelope_data: &Value, data: &Value) -> bool { - let Some(member_projects) = data.get("member_projects").and_then(Value::as_array) else { - return false; - }; - let saved_projects = data - .get("saved_projects") - .and_then(Value::as_array) - .map_or(&[][..], Vec::as_slice); - let no_project_adopters = data - .get("no_project_adopters") - .and_then(Value::as_array) - .map_or(&[][..], Vec::as_slice); - - output.push_str("\nYour projects\n"); - writeln!( - output, - "Showing {} you belong to", - plural_count(member_projects.len(), "project") - ) - .expect("write to string"); - if let Some(meta) = envelope_data.get("_meta") { - render_collection_meta(output, meta); - } - output.push('\n'); - - if member_projects.is_empty() { - output.push_str("No projects found for your account.\n"); - } else { - render_projects_table(output, member_projects); - } - - writeln!( - output, - "\nSaved projects: {}", - plural_count(saved_projects.len(), "project") - ) - .expect("write to string"); - if !saved_projects.is_empty() { - render_projects_table(output, saved_projects); - } - writeln!( - output, - "Contracts without a project: {}", - plural_count(no_project_adopters.len(), "contract") - ) - .expect("write to string"); - true -} - -fn render_search_results(output: &mut String, data: &Value) -> bool { - let Some(projects) = data.get("projects").and_then(Value::as_array) else { - return false; - }; - let contracts = data - .get("contracts") - .and_then(Value::as_array) - .map_or(&[][..], Vec::as_slice); - let assertions = data - .get("assertions") - .and_then(Value::as_array) - .map_or(&[][..], Vec::as_slice); - - output.push_str("\nSearch results\n"); - writeln!(output, "Projects: {}", projects.len()).expect("write to string"); - writeln!(output, "Contracts: {}", contracts.len()).expect("write to string"); - writeln!(output, "Assertions: {}", assertions.len()).expect("write to string"); - - if projects.is_empty() && contracts.is_empty() && assertions.is_empty() { - output.push_str("\nNo search results found.\n"); - return true; - } - - if !projects.is_empty() { - output.push_str("\nProjects\n"); - render_generic_table(output, projects); - } - if !contracts.is_empty() { - output.push_str("\nContracts\n"); - render_search_contracts_table(output, contracts); - } - if !assertions.is_empty() { - output.push_str("\nAssertions\n"); - render_generic_table(output, assertions); - } - true -} - -fn render_search_contracts_table(output: &mut String, items: &[Value]) { - writeln!( - output, - "{:<32} {:<10} {:<22} Project", - "Contract", "Network", "Address" - ) - .expect("write to string"); - for item in items { - let data = item.get("data").unwrap_or(item); - let name = data - .get("contract_name") - .and_then(Value::as_str) - .unwrap_or("-"); - let network = data.get("network").and_then(Value::as_str).unwrap_or("-"); - let address = data.get("address").and_then(Value::as_str).unwrap_or("-"); - let project = data - .get("related_project_slug") - .or_else(|| data.get("related_project_id")) - .and_then(Value::as_str) - .unwrap_or("-"); - writeln!( - output, - "{:<32} {:<10} {:<22} {}", - pad(name, 32), - pad(network, 10), - pad(address, 22), - project - ) - .expect("write to string"); - } -} - -fn render_account_detail(output: &mut String, data: &Value) -> bool { - if data.get("email").is_none() || data.get("authMethod").is_none() { - return false; - } - output.push_str("\nAccount\n"); - write_string_field(output, "Email", data, "email"); - write_string_field(output, "User ID", data, "id"); - write_string_field(output, "Auth method", data, "authMethod"); - write_string_field(output, "Scope", data, "scope"); - write_bool_field(output, "Whitelisted", data, "whitelisted"); - write_bool_field(output, "Terms accepted", data, "terms_accepted"); - write_timestamp_field(output, "Terms accepted at", data, "terms_accepted_at"); - true -} - -fn render_deployment_state(output: &mut String, data: &Value) -> bool { - let Some(project) = data.get("project") else { - return false; - }; - if data.get("available_contracts").is_none() - || data.get("submitted_assertions").is_none() - || data.get("staging_assertions").is_none() - { - return false; - } - output.push_str("\nDeployments\n"); - if let Some(name) = project.get("project_name").and_then(Value::as_str) { - writeln!(output, "Project: {name}").expect("write to string"); - } - if let Some(id) = project.get("project_id").and_then(Value::as_str) { - writeln!(output, "Project ID: {id}").expect("write to string"); - } - write_network_list_for_value(output, project); - write_count_field(output, "Available contracts", data, "available_contracts"); - write_count_field(output, "Submitted assertions", data, "submitted_assertions"); - write_count_field(output, "Staging assertions", data, "staging_assertions"); - if let Some(meta) = data.get("_meta") { - render_collection_meta(output, meta); - } - true -} - -fn render_transfer_state(output: &mut String, data: &Value) -> bool { - let (Some(incoming), Some(outgoing)) = (data.get("incoming"), data.get("outgoing")) else { - return false; - }; - output.push_str("\nProtocol manager transfers\n"); - write_transfer_counts(output, "Incoming", incoming); - write_transfer_counts(output, "Outgoing", outgoing); - true -} - -fn render_integration_status(output: &mut String, data: &Value) -> bool { - if data.get("configured").is_none() || data.get("enabled").is_none() { - return false; - } - output.push_str("\nIntegration\n"); - write_bool_field(output, "Configured", data, "configured"); - write_bool_field(output, "Enabled", data, "enabled"); - write_optional_string_field(output, "Webhook URL", data, "webhook_url"); - write_timestamp_field(output, "Last notification", data, "last_notification_at"); - write_u64_field( - output, - "Notifications sent", - data, - "notification_count", - None, - ); - write_bool_field(output, "Test available", data, "test_available"); - true -} - -fn render_protocol_manager_status(output: &mut String, data: &Value) -> bool { - if data.get("has_pending_transfer").is_none() - || data.get("contracts_pending").is_none() - || data.get("contracts_total").is_none() - { - return false; - } - output.push_str("\nProtocol manager\n"); - write_bool_field(output, "Pending transfer", data, "has_pending_transfer"); - write_optional_string_field(output, "Current manager", data, "current_manager_address"); - write_optional_string_field(output, "New manager", data, "new_manager_address"); - write_u64_field(output, "Contracts pending", data, "contracts_pending", None); - write_u64_field(output, "Contracts total", data, "contracts_total", None); - true -} - -fn render_mutation_success(output: &mut String, envelope: &Value, data: &Value) -> bool { - if data.get("success").and_then(Value::as_bool) != Some(true) - || data - .as_object() - .is_some_and(|object| object.contains_key("message")) - { - return false; - } - let Some(request) = envelope.get("request") else { - return false; - }; - let method = request.get("method").and_then(Value::as_str).unwrap_or(""); - let path = request.get("path").and_then(Value::as_str).unwrap_or(""); - output.push('\n'); - output.push_str(mutation_success_message(method, path)); - output.push('\n'); - true -} - -fn mutation_success_message(method: &str, path: &str) -> &'static str { - match (method, path) { - ("POST", "/projects/saved") => "Project saved", - ("DELETE", "/projects/saved") => "Project removed from saved projects", - _ if method == "DELETE" - && path.starts_with("/projects/") - && path.contains("/invitations/") => - { - "Invitation revoked" - } - _ if method == "POST" && path.starts_with("/projects/") && path.ends_with("/resend") => { - "Invitation resent" - } - _ if method == "PATCH" && path.starts_with("/projects/") && path.contains("/members/") => { - "Member role updated" - } - _ if method == "DELETE" && path.starts_with("/projects/") && path.contains("/members/") => { - "Member removed" - } - _ if method == "DELETE" && path.ends_with("/protocol-manager") => { - "Protocol manager cleared" - } - _ if method == "POST" && path.ends_with("/confirm-transfer") => { - "Protocol manager transfer confirmed" - } - _ if method == "DELETE" - && path.starts_with("/projects/") - && !path.contains("/integrations/") - && !path.contains("/invitations/") - && !path.contains("/members/") - && !path.contains("/protocol-manager") => - { - "Project deleted" - } - _ => "Request completed", - } -} - -fn render_body_template(output: &mut String, envelope: &Value, data: &Value) -> bool { - if !is_body_template_envelope(envelope) { - return false; - } - if let Some(variants) = data.get("body_variants").and_then(Value::as_array) { - output.push_str("\nBody variants\n"); - for variant in variants { - let name = variant - .get("name") - .and_then(Value::as_str) - .unwrap_or("variant"); - writeln!(output, "- {name}").expect("write to string"); - if let Some(body) = variant.get("body") { - render_human_value(output, body, 4); - } - } - return true; - } - - let Some(object) = data.as_object() else { - return false; - }; - if object.is_empty() - || !object - .values() - .all(|value| is_scalar(value) || value.is_object() || value.is_array()) - { - return false; - } - if !object.keys().any(|key| is_body_template_key(key)) { - return false; - } - output.push_str("\nBody template\n"); - render_human_value(output, data, 2); - true -} - -fn is_body_template_envelope(envelope: &Value) -> bool { - envelope - .get("next_actions") - .and_then(Value::as_array) - .is_some_and(|actions| { - actions.iter().filter_map(Value::as_str).any(|action| { - action.starts_with("Pass the template") - || action.starts_with("Choose one entry from data.body_variants") - }) - }) -} - -fn render_api_manifest(output: &mut String, data: &Value) -> bool { - if data.get("name").and_then(Value::as_str) != Some("pcl") || data.get("commands").is_none() { - return false; - } - output.push_str("\nPCL command surface\n"); - if let Some(description) = data.get("description").and_then(Value::as_str) { - writeln!(output, "{description}").expect("write to string"); - } - output.push_str("\nStart here:\n"); - for command in ["pcl --llms", "pcl workflows", "pcl schema list"] { - writeln!(output, " - {command}").expect("write to string"); - } - if let Some(commands) = data.get("commands").and_then(Value::as_array) { - writeln!( - output, - "\n{} workflow/API command groups available.", - commands.len() - ) - .expect("write to string"); - } - true -} - -fn render_llms_guide(output: &mut String, data: &Value) -> bool { - if data.get("purpose").is_none() || data.get("consumption_order").is_none() { - return false; - } - output.push_str("\nLLM guide\n"); - if let Some(purpose) = data.get("purpose").and_then(Value::as_str) { - writeln!(output, "{purpose}").expect("write to string"); - } - if let Some(order) = data.get("consumption_order").and_then(Value::as_array) { - output.push_str("\nRecommended order:\n"); - for command in order.iter().filter_map(Value::as_str).take(8) { - writeln!(output, " - {command}").expect("write to string"); - } - } - true -} - -fn render_workflow_detail(output: &mut String, data: &Value) -> bool { - if data.get("steps").is_none() || data.get("name").is_none() { - return false; - } - output.push('\n'); - if let Some(name) = data.get("name").and_then(Value::as_str) { - writeln!(output, "Workflow: {name}").expect("write to string"); - } - if let Some(description) = data.get("description").and_then(Value::as_str) { - writeln!(output, "{description}").expect("write to string"); - } - if let Some(steps) = data.get("steps").and_then(Value::as_array) { - output.push_str("\nSteps:\n"); - for (index, step) in steps.iter().enumerate() { - let command = step.get("command").and_then(Value::as_str).unwrap_or("-"); - let description = step.get("output").and_then(Value::as_str).unwrap_or(""); - writeln!( - output, - " {}. {}{}", - index + 1, - humanize_command(command), - if description.is_empty() { - String::new() - } else { - format!(" -> {description}") - } - ) - .expect("write to string"); - } - } - true -} - -fn render_schema_detail(output: &mut String, data: &Value) -> bool { - if data.get("workflow").is_none() - || !(data.get("actions").is_some() || data.get("action").is_some()) - { - return false; - } - output.push('\n'); - if let Some(workflow) = data.get("workflow").and_then(Value::as_str) { - writeln!(output, "Schema: {workflow}").expect("write to string"); - } - if let Some(command) = data.get("command").and_then(Value::as_str) { - writeln!(output, "Command: {}", humanize_command(command)).expect("write to string"); - } - if let Some(actions) = data.get("actions").and_then(Value::as_array) { - render_actions_table(output, actions); - } else if let Some(action) = data.get("action") { - render_action_detail(output, action); - } - true -} - -fn render_operation_detail(output: &mut String, data: &Value) -> bool { - if data.get("operation_id").is_none() - || data.get("method").is_none() - || data.get("path").is_none() - { - return false; - } - output.push_str("\nAPI operation\n"); - let method = data.get("method").and_then(Value::as_str).unwrap_or("-"); - let path = data.get("path").and_then(Value::as_str).unwrap_or("-"); - writeln!(output, "{method} {path}").expect("write to string"); - if let Some(operation_id) = data.get("operation_id").and_then(Value::as_str) { - writeln!(output, "Operation: {operation_id}").expect("write to string"); - } - if let Some(summary) = data.get("summary").and_then(Value::as_str) { - writeln!(output, "Summary: {summary}").expect("write to string"); - } - if let Some(policy) = data.pointer("/raw_api_use/policy").and_then(Value::as_str) { - writeln!(output, "Raw API policy: {}", human_label(policy)).expect("write to string"); - } - if let Some(alternatives) = data.get("workflow_alternatives").and_then(Value::as_array) - && !alternatives.is_empty() - { - output.push_str("Prefer:\n"); - for alternative in alternatives { - if let Some(example) = alternative.get("example").and_then(Value::as_str) { - writeln!(output, " - {}", humanize_command(example)).expect("write to string"); - } - } - } - if let Some(command) = data.get("call_command").and_then(Value::as_str) { - writeln!(output, "Raw call: {}", humanize_command(command)).expect("write to string"); - } - true -} - -fn render_api_coverage(output: &mut String, data: &Value) -> bool { - let Some(total) = data.get("total_operations").and_then(Value::as_u64) else { - return false; - }; - output.push_str("\nAPI coverage\n"); - writeln!(output, "Operations: {total}").expect("write to string"); - for (label, field) in [ - ("No request-log hit", "no_hit_count"), - ("Hit without 2xx", "no_2xx_count"), - ("Write hit without 2xx", "write_no_2xx_count"), - ("Unmatched records", "unmatched_record_count"), - ] { - if let Some(count) = data.get(field).and_then(Value::as_u64) { - writeln!(output, "{label}: {count}").expect("write to string"); - } - } - if let Some(by_method) = data.get("by_method").and_then(Value::as_object) { - output.push_str("\nBy method:\n"); - for (method, stats) in by_method { - let total = stats.get("total").and_then(Value::as_u64).unwrap_or(0); - let hit = stats.get("hit").and_then(Value::as_u64).unwrap_or(0); - let ok = stats.get("ok").and_then(Value::as_u64).unwrap_or(0); - writeln!(output, " {method}: {ok}/{total} 2xx, {hit} hit").expect("write to string"); - } - } - true -} - -fn render_raw_api_response(output: &mut String, data: &Value) -> bool { - if data.get("request").is_none() || data.get("response").is_none() { - return false; - } - let request = data.get("request").unwrap_or(&Value::Null); - let response = data.get("response").unwrap_or(&Value::Null); - output.push_str("\nAPI response\n"); - if let (Some(method), Some(path)) = ( - request.get("method").and_then(Value::as_str), - request.get("path").and_then(Value::as_str), - ) { - writeln!(output, "{method} {path}").expect("write to string"); - } - if let Some(status) = response.get("status").and_then(Value::as_u64) { - writeln!(output, "HTTP {status}").expect("write to string"); - } - if let Some(request_id) = response.get("request_id").and_then(Value::as_str) { - writeln!(output, "Request ID: {request_id}").expect("write to string"); - } - if let Some(body) = response.get("body") { - if let Some(collection) = find_collection_in_value(body, "") { - output.push('\n'); - output.push_str(&collection.name); - output.push('\n'); - output.push_str(&collection_summary(&collection)); - output.push_str("\n\n"); - if collection.items.is_empty() { - writeln!(output, "No {} found.", collection.name.to_ascii_lowercase()) - .expect("write to string"); - } else { - render_collection_items(output, &collection); - } - } else { - output.push_str("Body: "); - output.push_str(&human_compact_summary(body)); - output.push('\n'); - } - } - if let Some(path) = data.get("output_path").and_then(Value::as_str) { - writeln!(output, "Wrote: {path}").expect("write to string"); - } - true -} - -fn render_export_result(output: &mut String, data: &Value) -> bool { - if data.get("export").and_then(Value::as_str) != Some("incidents") - && !(data.get("plan").is_some() && data.get("job_id").is_some()) - { - return false; - } - output.push_str("\nIncident export\n"); - if let Some(job_id) = data.get("job_id").and_then(Value::as_str) { - writeln!(output, "Job: {job_id}").expect("write to string"); - } - let source = data.get("plan").unwrap_or(data); - for (label, field) in [ - ("Output", "out"), - ("Errors", "errors"), - ("Checkpoint", "checkpoint"), - ] { - if let Some(path) = source.get(field).and_then(Value::as_str) { - writeln!(output, "{label}: {path}").expect("write to string"); - } - } - for (label, field) in [ - ("Pages fetched", "pages_fetched"), - ("Incidents written", "incidents_written"), - ("Errors written", "errors_written"), - ("Retries", "retries_attempted"), - ] { - if let Some(count) = data.get(field).and_then(Value::as_u64) { - writeln!(output, "{label}: {count}").expect("write to string"); - } - } - if let Some(command) = data.get("resume_command").and_then(Value::as_str) { - writeln!(output, "Resume: {}", humanize_command(command)).expect("write to string"); - } - true -} - -fn render_job_detail(output: &mut String, data: &Value) -> bool { - let job = data.get("job").unwrap_or(data); - if job.get("job_id").is_none() { - return false; - } - output.push_str("\nJob\n"); - for (label, field) in [ - ("ID", "job_id"), - ("Kind", "kind"), - ("Status", "status"), - ("Updated", "updated_at"), - ] { - if let Some(value) = job.get(field) { - writeln!(output, "{label}: {}", human_cell(value)).expect("write to string"); - } - } - if let Some(stats) = job.get("stats") { - output.push_str("Stats: "); - output.push_str(&human_compact_summary(stats)); - output.push('\n'); - } - if let Some(command) = data - .get("resume_command") - .or_else(|| job.get("resume_command")) - .and_then(Value::as_str) - { - writeln!(output, "Resume: {}", humanize_command(command)).expect("write to string"); - } - true -} - -fn render_path_or_toggle_result(output: &mut String, data: &Value) -> bool { - if data - .as_object() - .is_some_and(|object| object.values().any(Value::is_array)) - { - return false; - } - let path_fields = [ - ("Config", "config_path"), - ("Artifacts", "artifact_dir"), - ("Request log", "request_log"), - ("Jobs", "jobs_path"), - ]; - let mut rendered = false; - for (label, field) in path_fields { - if let Some(path) = data.get(field).and_then(Value::as_str) { - if !rendered { - output.push('\n'); - rendered = true; - } - writeln!(output, "{label}: {path}").expect("write to string"); - } - } - for (label, field) in [("Created", "created"), ("Deleted", "deleted")] { - if let Some(value) = data.get(field).and_then(Value::as_bool) { - if !rendered { - output.push('\n'); - rendered = true; - } - writeln!(output, "{label}: {}", yes_no(value)).expect("write to string"); - } - } - rendered -} - -fn write_string_field(output: &mut String, label: &str, data: &Value, field: &str) { - if let Some(value) = data.get(field).and_then(Value::as_str) { - writeln!(output, "{label}: {value}").expect("write to string"); - } -} - -fn write_optional_string_field(output: &mut String, label: &str, data: &Value, field: &str) { - match data.get(field) { - Some(Value::String(value)) if !value.is_empty() => { - writeln!(output, "{label}: {value}").expect("write to string"); - } - Some(Value::Null) | None => {} - Some(value) if is_scalar(value) => { - writeln!(output, "{label}: {}", scalar_string(value)).expect("write to string"); - } - Some(_) => {} - } -} - -fn write_timestamp_field(output: &mut String, label: &str, data: &Value, field: &str) { - if let Some(value) = data.get(field).and_then(Value::as_str) { - writeln!(output, "{label}: {}", format_timestamp(value)).expect("write to string"); - } -} - -fn write_bool_field(output: &mut String, label: &str, data: &Value, field: &str) { - if let Some(value) = data.get(field).and_then(Value::as_bool) { - writeln!(output, "{label}: {}", yes_no(value)).expect("write to string"); - } -} - -fn write_u64_field( - output: &mut String, - label: &str, - data: &Value, - field: &str, - unit: Option<&str>, -) { - if let Some(value) = data.get(field).and_then(Value::as_u64) { - if let Some(unit) = unit { - writeln!(output, "{label}: {value} {unit}").expect("write to string"); - } else { - writeln!(output, "{label}: {value}").expect("write to string"); - } - } -} - -fn write_count_field(output: &mut String, label: &str, data: &Value, field: &str) { - if let Some(values) = data.get(field).and_then(Value::as_array) { - writeln!( - output, - "{label}: {}", - plural_count(values.len(), count_field_unit(label, field)) - ) - .expect("write to string"); - } -} - -fn count_field_unit(label: &str, field: &str) -> &'static str { - match (label, field) { - ("Available contracts", _) => "contract", - ("Submitted assertions", _) | ("Staging assertions", _) => "assertion", - (_, "available_contracts") => "contract", - (_, "submitted_assertions" | "staging_assertions" | "submitted_assertion_ids") => { - "assertion" - } - _ => "item", - } -} - -fn write_network_list_for_value(output: &mut String, data: &Value) { - let names = data - .get("chain_names") - .or_else(|| data.get("project_networks")) - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(|value| value.as_str().map(ToString::to_string)) - .collect::>() - }) - .unwrap_or_default(); - if names.is_empty() { - return; - } - writeln!(output, "Networks: {}", names.join(", ")).expect("write to string"); -} - -fn write_transfer_counts(output: &mut String, label: &str, value: &Value) { - let projects = value - .get("project_transfers") - .and_then(Value::as_array) - .map_or(0, Vec::len); - let contracts = value - .get("contract_transfers") - .and_then(Value::as_array) - .map_or(0, Vec::len); - writeln!( - output, - "{label}: {}, {}", - plural_count(projects, "project transfer"), - plural_count(contracts, "contract transfer") - ) - .expect("write to string"); -} - -fn render_human_collection(output: &mut String, envelope: &Value) -> bool { - let Some(collection) = find_human_collection(envelope) else { - return false; - }; - - output.push('\n'); - output.push_str(&collection.name); - output.push('\n'); - output.push_str(&collection_summary(&collection)); - output.push('\n'); - if let Some(meta) = collection.meta { - render_collection_meta(output, meta); - } - output.push('\n'); - - if collection.items.is_empty() { - writeln!(output, "No {} found.", collection.name.to_ascii_lowercase()) - .expect("write to string"); - return true; - } - - render_collection_items(output, &collection); - - if let Some(pagination) = collection.pagination - && pagination - .get("hasMore") - .or_else(|| pagination.get("has_more")) - .and_then(Value::as_bool) - .unwrap_or(false) - { - let next_page = pagination - .get("page") - .and_then(Value::as_u64) - .map_or(2, |page| page.saturating_add(1)); - let limit = pagination - .get("limit") - .and_then(Value::as_u64) - .unwrap_or(collection.items.len() as u64); - output.push('\n'); - writeln!( - output, - "More results available. Try --page {next_page} --limit {limit}." - ) - .expect("write to string"); - } - - true -} - -fn find_human_collection(envelope: &Value) -> Option> { - let data = envelope.get("data")?; - let request_path = envelope - .pointer("/request/path") - .and_then(Value::as_str) - .unwrap_or_default(); - - find_collection_in_value(data, request_path) -} - -fn find_collection_in_value<'a>( - data: &'a Value, - request_path: &str, -) -> Option> { - if let Some(inner) = data.get("data") - && let Some(collection) = find_collection_in_value(inner, request_path) - { - return Some(HumanCollection { - meta: data.get("_meta").or(collection.meta), - ..collection - }); - } - - if let Some(items) = data.as_array() { - return Some(HumanCollection { - field: infer_collection_field(request_path), - name: infer_collection_name("items", request_path, items), - items, - pagination: None, - meta: None, - }); - } - - if let Some(items) = data.get("items").and_then(Value::as_array) { - return Some(HumanCollection { - field: "items".to_string(), - name: infer_collection_name("items", request_path, items), - items, - pagination: data.get("pagination"), - meta: data.get("_meta"), - }); - } - - for field in [ - "incidents", - "assertions", - "contracts", - "releases", - "projects", - "deployments", - "events", - "operations", - "workflows", - "schemas", - "checks", - "records", - "jobs", - "artifacts", - "members", - "invitations", - "integrations", - "transfers", - "requests", - "no_hit", - "no_2xx", - "write_no_2xx", - "unmatched_records", - "body_variants", - "examples", - "product_surfaces", - ] { - if let Some(items) = data.get(field).and_then(Value::as_array) { - return Some(HumanCollection { - field: field.to_string(), - name: human_label(field), - items, - pagination: data.get("pagination"), - meta: data.get("_meta"), - }); - } - } - - None -} - -fn infer_collection_field(request_path: &str) -> String { - if request_path.contains("assertion_adopters") { - return "contracts".to_string(); - } - for field in [ - "incidents", - "projects", - "assertions", - "contracts", - "releases", - "deployments", - "events", - "members", - "invitations", - "transfers", - ] { - if request_path.contains(field) { - return field.to_string(); - } - } - "items".to_string() -} - -fn infer_collection_name(field: &str, request_path: &str, items: &[Value]) -> String { - if request_path.contains("assertion_adopters") { - return "Contracts".to_string(); - } - for name in [ - "incidents", - "assertions", - "contracts", - "releases", - "projects", - "deployments", - "events", - "operations", - "workflows", - "schemas", - "records", - "jobs", - "artifacts", - "requests", - ] { - if request_path.contains(name) { - return human_label(name); - } - } - if items.iter().any(has_incident_shape) { - return "Incidents".to_string(); - } - human_label(field) -} - -fn collection_summary(collection: &HumanCollection<'_>) -> String { - let shown = collection.items.len(); - if let Some(pagination) = collection.pagination { - let total = pagination - .get("total") - .and_then(Value::as_u64) - .unwrap_or(shown as u64); - let page = pagination.get("page").and_then(Value::as_u64); - let limit = pagination.get("limit").and_then(Value::as_u64); - let item_name = collection_item_name(&collection.name, total); - let mut summary = if total > shown as u64 { - format!("Showing {shown} of {total} {item_name}") - } else { - format!("Showing {shown} {item_name}") - }; - if let Some(page) = page { - write!(summary, " on page {page}").expect("write to string"); - } - if let Some(limit) = limit { - write!(summary, " (limit {limit})").expect("write to string"); - } - return summary; - } - let item_name = collection_item_name(&collection.name, shown as u64); - format!("Showing {shown} {item_name}") -} - -fn collection_item_name(name: &str, count: u64) -> String { - let lower = name.to_ascii_lowercase(); - if count != 1 { - return lower; - } - lower.strip_suffix("ies").map_or_else( - || lower.strip_suffix("s").unwrap_or(&lower).to_string(), - |stem| format!("{stem}y"), - ) -} - -fn render_collection_items(output: &mut String, collection: &HumanCollection<'_>) { - match collection.field.as_str() { - "checks" => render_checks_table(output, collection.items), - "operations" => render_operations_table(output, collection.items), - "workflows" => render_workflows_table(output, collection.items), - "schemas" => render_schemas_table(output, collection.items), - "records" | "requests" | "unmatched_records" => { - render_request_records_table(output, collection.items); - } - "jobs" => render_jobs_table(output, collection.items), - "artifacts" => render_artifacts_table(output, collection.items), - "members" => render_members_table(output, collection.items), - "invitations" => render_invitations_table(output, collection.items), - "projects" => render_projects_table(output, collection.items), - "releases" => render_releases_table(output, collection.items), - "events" => render_events_table(output, collection.items), - "no_hit" | "no_2xx" | "write_no_2xx" => render_coverage_table(output, collection.items), - "body_variants" => render_body_variant_table(output, collection.items), - _ if is_incident_collection(collection) => render_incident_table(output, collection.items), - _ => render_generic_table(output, collection.items), - } -} - -macro_rules! render_rows { - ($output:expr, $items:expr, $header:expr, $row:literal, |$item:ident| $($arg:expr),+ $(,)?) => {{ - writeln!($output, "{}", $header).expect("write to string"); - for $item in $items { - writeln!($output, $row, $($arg),+).expect("write to string"); - } - }}; -} - -fn str_field<'a>(item: &'a Value, field: &str) -> &'a str { - item.get(field).and_then(Value::as_str).unwrap_or("-") -} - -fn str_any<'a>(item: &'a Value, fields: &[&str], default: &'static str) -> &'a str { - fields - .iter() - .find_map(|field| item.get(*field).and_then(Value::as_str)) - .unwrap_or(default) -} - -fn render_checks_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<20} {:<10} Details", "Check", "Status"), - "{:<20} {:<10} {}", - |item| pad(str_field(item, "name"), 20), - pad(str_field(item, "status"), 10), - item.get("details") - .or_else(|| item.get("path")) - .map_or_else(String::new, human_compact_summary), - ); -} - -fn render_operations_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<7} {:<45} {:<36} Policy", "Method", "Path", "Operation"), - "{:<7} {:<45} {:<36} {}", - |item| str_field(item, "method"), - pad(str_field(item, "path"), 45), - pad(str_field(item, "operation_id"), 36), - human_label( - item.pointer("/raw_api_use/policy") - .and_then(Value::as_str) - .unwrap_or("-"), - ), - ); -} - -fn render_workflows_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<28} Steps Description", "Workflow"), - "{:<28} {:<5} {}", - |item| pad(str_field(item, "name"), 28), - item.get("steps") - .and_then(Value::as_array) - .map_or(0, Vec::len), - truncate( - item.get("description") - .and_then(Value::as_str) - .unwrap_or_default(), - 72, - ), - ); -} - -fn render_schemas_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<24} {:<7} Command", "Workflow", "Actions"), - "{:<24} {:<7} {}", - |item| pad(str_field(item, "workflow"), 24), - item.get("actions").and_then(Value::as_u64).unwrap_or(0), - truncate(&humanize_command(str_field(item, "command")), 96), - ); -} - -fn render_request_records_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!( - "{:<16} {:<7} {:<45} {:<6} Request ID", - "Time", "Method", "Path", "HTTP" - ), - "{:<16} {:<7} {:<45} {:<6} {}", - |item| { - pad( - &item - .get("timestamp") - .and_then(Value::as_str) - .map_or_else(String::new, format_timestamp), - 16, - ) - }, - str_field(item, "method"), - pad(str_field(item, "path"), 45), - item.get("status") - .and_then(Value::as_u64) - .map_or_else(|| "-".to_string(), |value| value.to_string()), - str_field(item, "request_id"), - ); -} - -fn render_jobs_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<38} {:<16} {:<12} Updated", "Job", "Kind", "Status"), - "{:<38} {:<16} {:<12} {}", - |item| pad(str_field(item, "job_id"), 38), - pad(str_field(item, "kind"), 16), - pad(str_field(item, "status"), 12), - item.get("updated_at") - .and_then(Value::as_str) - .map_or_else(String::new, format_timestamp), - ); -} - -fn render_artifacts_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<58} {:>10} Modified", "Path", "Bytes"), - "{:<58} {:>10} {}", - |item| pad(str_field(item, "path"), 58), - item.get("bytes") - .and_then(Value::as_u64) - .map_or_else(|| "-".to_string(), |value| value.to_string()), - item.get("modified") - .and_then(Value::as_u64) - .map_or_else(String::new, format_unix_timestamp), - ); -} - -fn render_projects_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!( - "{:<28} {:<22} {:<20} {:<10} ID", - "Project", "Slug", "Network", "Visibility" - ), - "{:<28} {:<22} {:<20} {:<10} {}", - |item| pad(str_any(item, &["project_name", "name"], "-"), 28), - pad(str_field(item, "slug"), 22), - pad(&first_project_network(item), 20), - item.get("is_private") - .and_then(Value::as_bool) - .map_or("-", |private| if private { "private" } else { "public" }), - str_any(item, &["project_id", "id"], "-"), - ); -} - -fn first_project_network(item: &Value) -> String { - item.get("chain_names") - .and_then(Value::as_array) - .and_then(|values| values.first()) - .or_else(|| { - item.get("project_networks") - .and_then(Value::as_array) - .and_then(|values| values.first()) - }) - .map_or_else(|| "-".to_string(), human_scalar) -} - -fn render_members_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<34} {:<12} User ID", "Email", "Role"), - "{:<34} {:<12} {}", - |item| pad(str_field(item, "email"), 34), - pad(str_field(item, "role"), 12), - str_field(item, "user_id"), - ); -} - -fn render_invitations_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<34} {:<12} {:<16} ID", "Email", "Role", "Status"), - "{:<34} {:<12} {:<16} {}", - |item| { - pad( - str_any(item, &["email", "identifier", "invitee_identifier"], "-"), - 34, - ) - }, - pad(str_field(item, "role"), 12), - pad(str_any(item, &["status"], "pending"), 16), - str_any(item, &["id", "invitation_id"], "-"), - ); -} - -fn render_releases_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!( - "{:<36} {:<14} {:<16} Created", - "Release", "Environment", "Status" - ), - "{:<36} {:<14} {:<16} {}", - |item| pad(str_any(item, &["release_id", "id"], "-"), 36), - pad(str_field(item, "environment"), 14), - pad(str_field(item, "status"), 16), - item.get("created_at") - .or_else(|| item.get("createdAt")) - .and_then(Value::as_str) - .map_or_else(String::new, format_timestamp), - ); -} - -fn render_events_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items, - format!("{:<34} {:<14} {:<16} Type", "Event", "Environment", "Time"), - "{:<34} {:<14} {:<16} {}", - |item| pad(str_field(item, "id"), 34), - pad(str_field(item, "environment"), 14), - pad( - &item - .get("timestamp") - .or_else(|| item.get("created_at")) - .and_then(Value::as_str) - .map_or_else(String::new, format_timestamp), - 16, - ), - str_any(item, &["type", "event_type"], "-"), - ); -} - -fn render_coverage_table(output: &mut String, items: &[Value]) { - render_rows!( - output, - items.iter().take(20), - format!( - "{:<7} {:<45} {:<7} {:<7} Request ID", - "Method", "Path", "Hits", "2xx" - ), - "{:<7} {:<45} {:<7} {:<7} {}", - |item| str_field(item, "method"), - pad(str_field(item, "path"), 45), - item.get("hits").and_then(Value::as_u64).unwrap_or(0), - item.get("ok").and_then(Value::as_u64).unwrap_or(0), - str_field(item, "latest_request_id"), - ); - if items.len() > 20 { - writeln!(output, "... {} more", items.len() - 20).expect("write to string"); - } -} - -fn render_body_variant_table(output: &mut String, items: &[Value]) { - for item in items { - let name = item - .get("name") - .and_then(Value::as_str) - .unwrap_or("variant"); - writeln!(output, "- {name}").expect("write to string"); - if let Some(body) = item.get("body") { - render_human_value(output, body, 4); - } - } -} - -fn render_collection_meta(output: &mut String, meta: &Value) { - let fetched_at = meta - .get("fetchedAt") - .or_else(|| meta.get("fetched_at")) - .and_then(Value::as_str); - let sources = meta.get("sources").and_then(Value::as_array); - if fetched_at.is_none() && sources.is_none_or(Vec::is_empty) { - return; - } - - if let Some(fetched_at) = fetched_at { - output.push_str("Updated: "); - output.push_str(&format_timestamp(fetched_at)); - output.push('\n'); - } - if let Some(sources) = sources { - let source_names = sources - .iter() - .filter_map(Value::as_str) - .map(human_source_name) - .collect::>() - .join(", "); - if !source_names.is_empty() { - output.push_str("Source: "); - output.push_str(&source_names); - output.push('\n'); - } - } -} - -fn human_source_name(source: &str) -> String { - match source { - "offchain" => "Phylax platform index".to_string(), - "onchain" => "on-chain data".to_string(), - "cache" => "cache".to_string(), - other => human_label(other), - } -} - -fn is_incident_collection(collection: &HumanCollection<'_>) -> bool { - collection.name == "Incidents" || collection.items.iter().any(has_incident_shape) -} - -fn has_incident_shape(value: &Value) -> bool { - value.get("referenceId").is_some() - || value.get("reference_id").is_some() - || (value.get("timestamp").is_some() - && value.get("network").is_some() - && value.get("title").is_some()) -} - -fn render_incident_table(output: &mut String, items: &[Value]) { - writeln!( - output, - "{:<3} {:<16} {:<24} {:<29} ID", - "#", "Time", "Network", "Title" - ) - .expect("write to string"); - for (index, item) in items.iter().enumerate() { - let timestamp = item - .get("timestamp") - .and_then(Value::as_str) - .map_or_else(String::new, format_timestamp); - let network = format_network(item.get("network")); - let title = item - .get("title") - .and_then(Value::as_str) - .unwrap_or("Untitled"); - let id = item.get("id").and_then(Value::as_str).unwrap_or("-"); - writeln!( - output, - "{:<3} {:<16} {:<24} {:<29} {}", - index + 1, - pad(×tamp, 16), - pad(&network, 24), - pad(title, 29), - id - ) - .expect("write to string"); - } -} - -fn render_generic_table(output: &mut String, items: &[Value]) { - let columns = generic_columns(items); - if columns.is_empty() { - render_human_value(output, &Value::Array(items.to_vec()), 0); - return; - } - - write!(output, "{:<3}", "#").expect("write to string"); - for column in &columns { - write!(output, " {:<22}", human_label(column)).expect("write to string"); - } - output.push('\n'); - - for (index, item) in items.iter().enumerate() { - write!(output, "{:<3}", index + 1).expect("write to string"); - for column in &columns { - let value = item.get(column).map_or_else(String::new, human_cell); - write!(output, " {:<22}", pad(&value, 22)).expect("write to string"); - } - output.push('\n'); - } -} - -fn generic_columns(items: &[Value]) -> Vec { - let mut columns = Vec::new(); - for preferred in [ - "name", - "title", - "id", - "status", - "environment", - "network", - "timestamp", - "createdAt", - "updatedAt", - ] { - if items.iter().any(|item| item.get(preferred).is_some()) { - columns.push(preferred.to_string()); - } - if columns.len() == 4 { - return columns; - } - } - - if columns.is_empty() - && let Some(object) = items.first().and_then(Value::as_object) - { - columns.extend(object.keys().take(4).cloned()); - } - columns -} - -fn human_cell(value: &Value) -> String { - match value { - Value::Object(object) if object.contains_key("name") => { - object - .get("name") - .and_then(Value::as_str) - .map_or_else(|| compact_json(value), ToString::to_string) - } - Value::Object(_) | Value::Array(_) => compact_json(value), - _ => human_scalar(value), - } -} - -fn human_action_str(value: &str) -> String { - if value.trim_start().starts_with("pcl ") { - humanize_command(value) - } else if matches!( - value, - "Use --toon for agent consumption or --json for strict JSON parsing" - | "Use --json for strict JSON parsing" - ) { - String::new() - } else if value == "Use --body-template when constructing mutation bodies" { - "Use --body-template to start from an example request body".to_string() - } else { - value.to_string() - } -} - -fn humanize_command(command: &str) -> String { - command - .replace(" --format toon", "") - .replace(" --toon", "") - .replace("--toon ", "") -} - -fn is_body_template_key(key: &str) -> bool { - matches!( - key, - "project_name" - | "project_description" - | "profile_image_url" - | "github_url" - | "chain_id" - | "is_private" - | "is_dev" - | "project_id" - | "identifier" - | "identifier_type" - | "role" - | "provider" - | "webhook_url" - | "routing_key" - | "enabled" - | "address" - | "signature" - | "nonce" - | "tx_hash" - | "contract_name" - | "assertions" - | "assertionsDir" - | "contracts" - | "environment" - | "mode" - | "new_manager_address" - | "ponder_transfer_id" - | "reason" - | "notify" - ) -} - -fn name_value_pairs(values: &[Value]) -> String { - values - .iter() - .map(|value| { - let name = value.get("name").and_then(Value::as_str).unwrap_or("?"); - let rendered = value - .get("value") - .map_or_else(|| "none".to_string(), scalar_string); - format!("{name}={rendered}") - }) - .collect::>() - .join(", ") -} - -fn render_actions_table(output: &mut String, actions: &[Value]) { - writeln!( - output, - "{:<24} {:<7} {:<8} Path", - "Action", "Auth", "Method" - ) - .expect("write to string"); - for action in actions { - let name = action.get("name").and_then(Value::as_str).unwrap_or("-"); - let auth = action - .get("auth") - .and_then(Value::as_bool) - .map_or("-", |value| if value { "yes" } else { "no" }); - let method = action.get("method").and_then(Value::as_str).unwrap_or("-"); - let path = action.get("path").and_then(Value::as_str).unwrap_or("-"); - writeln!( - output, - "{:<24} {:<7} {:<8} {}", - pad(name, 24), - auth, - method, - path - ) - .expect("write to string"); - } -} - -fn render_action_detail(output: &mut String, action: &Value) { - let name = action.get("name").and_then(Value::as_str).unwrap_or("-"); - writeln!(output, "Action: {name}").expect("write to string"); - if let (Some(method), Some(path)) = ( - action.get("method").and_then(Value::as_str), - action.get("path").and_then(Value::as_str), - ) { - writeln!(output, "Request: {method} {path}").expect("write to string"); - } - if let Some(auth) = action.get("auth").and_then(Value::as_bool) { - writeln!( - output, - "Auth: {}", - if auth { "required" } else { "not required" } - ) - .expect("write to string"); - } - if let Some(example) = action.get("example").and_then(Value::as_str) { - writeln!(output, "Example: {}", humanize_command(example)).expect("write to string"); - } - if let Some(flags) = action.get("required_flags").and_then(Value::as_array) - && !flags.is_empty() - { - writeln!(output, "Required flags: {}", string_list(flags)).expect("write to string"); - } - if let Some(flags) = action.get("optional_flags").and_then(Value::as_array) - && !flags.is_empty() - { - writeln!(output, "Optional flags: {}", string_list(flags)).expect("write to string"); - } -} - -fn string_list(values: &[Value]) -> String { - values - .iter() - .filter_map(Value::as_str) - .collect::>() - .join(", ") -} - -fn yes_no(value: bool) -> &'static str { - if value { "yes" } else { "no" } -} - -fn format_duration(seconds: i64) -> String { - if seconds < 0 { - return "expired".to_string(); - } - let days = seconds / 86_400; - let hours = (seconds % 86_400) / 3_600; - let minutes = (seconds % 3_600) / 60; - if days > 0 { - format!("{days}d {hours}h") - } else if hours > 0 { - format!("{hours}h {minutes}m") - } else { - format!("{minutes}m") - } -} - -fn render_human_summary(output: &mut String, data: &Value) { - let display_data = data.get("data").unwrap_or(data); - output.push('\n'); - if let Some(object) = display_data.as_object() { - for (key, value) in object { - if key.starts_with('_') { - continue; - } - output.push_str(&human_label(key)); - output.push_str(": "); - if is_scalar(value) { - output.push_str(&human_scalar(value)); - output.push('\n'); - } else { - output.push_str(&human_compact_summary(value)); - output.push('\n'); - } - } - } else { - render_human_value(output, display_data, 0); - } -} - -fn render_human_request_id(output: &mut String, envelope: &Value) { - let request_id = envelope - .pointer("/response/request_id") - .and_then(Value::as_str); - let status = envelope.pointer("/response/status").and_then(Value::as_u64); - if request_id.is_none() && status.is_none() { - return; - } - - output.push('\n'); - if let Some(request_id) = request_id { - output.push_str("Request ID: "); - output.push_str(request_id); - if let Some(status) = status { - write!(output, " (HTTP {status})").expect("write to string"); - } - output.push('\n'); - } else if let Some(status) = status { - writeln!(output, "HTTP status: {status}").expect("write to string"); - } -} - -fn human_compact_summary(value: &Value) -> String { - match value { - Value::Array(values) => plural_count(values.len(), "item"), - Value::Object(object) => { - if object.is_empty() { - return "empty object".to_string(); - } - object - .iter() - .filter(|(key, _)| !key.starts_with('_')) - .take(3) - .map(|(key, value)| { - if is_scalar(value) { - format!("{}={}", human_label(key), human_scalar(value)) - } else { - format!("{}={}", human_label(key), compact_json(value)) - } - }) - .collect::>() - .join(", ") - } - _ => human_scalar(value), - } -} - -fn format_network(value: Option<&Value>) -> String { - let Some(value) = value else { - return "-".to_string(); - }; - if let Some(name) = value.as_str() { - return name.to_string(); - } - let name = value - .get("name") - .and_then(Value::as_str) - .unwrap_or("Unknown network"); - if let Some(chain_id) = value.get("chainId").and_then(Value::as_u64) { - return format!("{name} ({chain_id})"); - } - if let Some(chain_id) = value.get("chain_id").and_then(Value::as_u64) { - return format!("{name} ({chain_id})"); - } - name.to_string() -} - -fn format_timestamp(value: &str) -> String { - if value.len() >= 16 && value.as_bytes().get(10) == Some(&b'T') { - return value[..16].replace('T', " "); - } - value.to_string() -} - -fn format_unix_timestamp(value: u64) -> String { - let Ok(seconds) = i64::try_from(value) else { - return value.to_string(); - }; - chrono::DateTime::from_timestamp(seconds, 0).map_or_else( - || value.to_string(), - |timestamp| timestamp.format("%Y-%m-%d %H:%M").to_string(), - ) -} - -fn human_label(value: &str) -> String { - let words = split_label_words(value); - let mut rendered = Vec::new(); - for (index, word) in words.iter().enumerate() { - let lower = word.to_ascii_lowercase(); - let text = match lower.as_str() { - "id" => "ID".to_string(), - "api" => "API".to_string(), - "http" => "HTTP".to_string(), - "url" => "URL".to_string(), - "json" => "JSON".to_string(), - "cli" => "CLI".to_string(), - "pcl" => "PCL".to_string(), - "uuid" => "UUID".to_string(), - "tx" => "tx".to_string(), - "github" => "GitHub".to_string(), - "authmethod" => "auth method".to_string(), - other if index == 0 => capitalize(other), - other => other.to_string(), - }; - rendered.push(text); - } - rendered.join(" ") -} - -fn split_label_words(value: &str) -> Vec { - let normalized = value.replace(['_', '-'], " "); - let mut words = Vec::new(); - for raw in normalized.split_whitespace() { - let mut current = String::new(); - let chars = raw.chars().collect::>(); - for (index, ch) in chars.iter().enumerate() { - if index > 0 - && ch.is_uppercase() - && chars - .get(index.saturating_sub(1)) - .is_some_and(|previous| previous.is_lowercase() || previous.is_ascii_digit()) - { - words.push(current); - current = String::new(); - } - current.push(*ch); - } - if !current.is_empty() { - words.push(current); - } - } - words -} - -fn capitalize(value: &str) -> String { - let mut chars = value.chars(); - chars.next().map_or_else(String::new, |first| { - first.to_uppercase().collect::() + chars.as_str() - }) -} - -fn plural_count(count: impl std::fmt::Display, item: &str) -> String { - let count = count.to_string(); - if count == "1" { - format!("1 {item}") - } else { - format!("{count} {item}s") - } -} - -fn human_scalar(value: &Value) -> String { - match value { - Value::Bool(value) => yes_no(*value).to_string(), - Value::String(value) => { - if value.len() >= 16 && value.as_bytes().get(10) == Some(&b'T') { - format_timestamp(value) - } else { - value.clone() - } - } - _ => scalar_string(value), - } -} - -fn pad(value: &str, width: usize) -> String { - let value = truncate(value, width); - format!("{value: String { - let char_count = value.chars().count(); - if char_count <= max_chars { - return value.to_string(); - } - if max_chars <= 3 { - return value.chars().take(max_chars).collect(); - } - let prefix: String = value.chars().take(max_chars - 3).collect(); - format!("{prefix}...") -} - -fn is_hex_blob(value: &str) -> bool { - let Some(hex) = value.strip_prefix("0x") else { - return false; - }; - hex.len() > 64 && hex.chars().all(|character| character.is_ascii_hexdigit()) -} - -fn render_human_value(output: &mut String, value: &Value, indent: usize) { - match value { - Value::Object(object) => { - for (key, value) in object { - write_indent(output, indent); - output.push_str(key); - output.push_str(": "); - if is_scalar(value) { - output.push_str(&scalar_string(value)); - output.push('\n'); - } else { - output.push('\n'); - render_human_value(output, value, indent + 2); - } - } - } - Value::Array(values) => { - for value in values { - write_indent(output, indent); - output.push_str("- "); - if is_scalar(value) { - output.push_str(&scalar_string(value)); - output.push('\n'); - } else { - output.push('\n'); - render_human_value(output, value, indent + 2); - } - } - } - _ => { - write_indent(output, indent); - output.push_str(&scalar_string(value)); - output.push('\n'); - } - } -} - -fn write_indent(output: &mut String, indent: usize) { - for _ in 0..indent { - output.push(' '); - } -} - -fn is_scalar(value: &Value) -> bool { - matches!( - value, - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) - ) -} - -fn scalar_string(value: &Value) -> String { - match value { - Value::Null => "none".to_string(), - Value::Bool(value) => value.to_string(), - Value::Number(value) => value.to_string(), - Value::String(value) => value.clone(), - Value::Array(_) | Value::Object(_) => compact_json(value), - } -} - -fn compact_json(value: &Value) -> String { - serde_json::to_string(value).unwrap_or_else(|_| value.to_string()) -} - -fn ok_envelope(data: Value) -> Value { - with_envelope_metadata(json!({ - "status": "ok", - "data": data, - "next_actions": [ - "pcl api list", - "pcl api inspect get_views_public_incidents", - "pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated", - ], - })) -} - -fn dry_run_envelope(data: Value) -> Value { - let auth_required = data - .pointer("/request/auth/required") - .and_then(Value::as_bool) - .unwrap_or(false); - let allow_unauthenticated = data - .pointer("/request/auth/allow_unauthenticated") - .and_then(Value::as_bool) - .unwrap_or(false); - let stored_token_valid = data - .pointer("/request/auth/stored_token_valid") - .and_then(Value::as_bool) - .unwrap_or(false); - let next_actions = if auth_required && !allow_unauthenticated && !stored_token_valid { - vec![ - "pcl auth ensure --toon", - "Authenticate before removing --dry-run", - "Use --body-template when constructing mutation bodies", - ] - } else { - let mut actions = vec![ - "Remove --dry-run to execute this request", - "Use --toon for agent consumption or --json for strict JSON parsing", - ]; - let method = data - .pointer("/request/method") - .and_then(Value::as_str) - .unwrap_or_default(); - if method_side_effecting(method) { - actions.push("Use --body-template when constructing mutation bodies"); - } - actions - }; - with_envelope_metadata(json!({ - "status": "ok", - "data": data, - "next_actions": next_actions, - })) -} - -fn workflow_success_envelope(result: WorkflowCallResult, next_actions: Vec) -> Value { - with_envelope_metadata(json!({ - "status": "ok", - "data": result.body, - "request": result.request, - "response": result.response, - "next_actions": next_actions, - })) -} - -fn request_is_destructive(method: HttpMethod, path: &str) -> bool { - method == HttpMethod::Delete - || path.contains("/delete") - || path.contains("/remove") - || path.contains("/reject") - || path.contains("/logout") -} - -fn search_request(args: &SearchArgs) -> Result { - if args.health { - return Ok(WorkflowRequest::get( - "/health", - false, - ["pcl search --system-status"], - )); - } - if args.system_status { - return Ok(WorkflowRequest::get( - "/system-status", - false, - ["pcl search --stats"], - )); - } - if args.stats { - return Ok(WorkflowRequest::get( - "/stats", - false, - ["pcl projects --limit 10"], - )); - } - if args.whitelist { - return Ok(WorkflowRequest::get( - "/whitelist", - true, - ["pcl projects --mine"], - )); - } - if args.verified_contract { - let address = required_arg(args.address.as_deref(), "--address")?; - let chain_id = args.chain_id.ok_or_else(|| { - ApiCommandError::InvalidWorkflowWithActions { - message: "--verified-contract requires --chain-id".to_string(), - next_actions: vec![ - "pcl search --verified-contract --address
--chain-id " - .to_string(), - "pcl search --help".to_string(), - ], - } - })?; - let mut request = WorkflowRequest::get( - "/web/verified-contract", - false, - ["pcl contracts --project "], - ); - push_query(&mut request.query, "address", Some(address)); - push_query(&mut request.query, "chainId", Some(chain_id)); - return Ok(request); - } - - let query = args - .query - .as_deref() - .or(args.term.as_deref()) - .filter(|query| !query.trim().is_empty()) - .ok_or_else(|| { - ApiCommandError::InvalidWorkflowWithActions { - message: "Search query is required unless you choose a specific search action" - .to_string(), - next_actions: vec![ - "pcl search ".to_string(), - "pcl search --query ".to_string(), - "pcl search --stats".to_string(), - "pcl search --help".to_string(), - ], - } - })?; - - let mut request = WorkflowRequest::get( - "/search", - false, - [ - "pcl projects --project ", - "pcl contracts --project ", - ], - ); - push_query(&mut request.query, "query", Some(query)); - Ok(request) -} - -fn account_request(args: &AccountArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - if args.accept_terms { - return Ok(workflow_with_body( - HttpMethod::Post, - "/web/auth/accept-terms", - true, - Some(body_or_empty(body)), - ["pcl account", "pcl projects --mine"], - )); - } - if args.logout { - return Ok(workflow_with_body( - HttpMethod::Post, - "/web/auth/logout", - true, - Some(body_or_empty(body)), - ["pcl auth logout"], - )); - } - Ok(WorkflowRequest::get( - "/web/auth/me", - true, - ["pcl account --accept-terms", "pcl projects --mine"], - )) -} - -fn contracts_request(args: &ContractsArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - "/assertion_adopters", - true, - body, - ["pcl contracts --unassigned --manager "], - )); - } - if args.assign_project { - return Ok(workflow_with_body( - HttpMethod::Post, - "/assertion_adopters/assign-project", - true, - body, - ["pcl contracts --project "], - )); - } - if args.unassigned { - let manager = required_arg(args.manager.as_deref(), "--manager")?; - let mut request = WorkflowRequest::get( - "/assertion_adopters/no-project", - true, - ["pcl contracts --assign-project --body-template"], - ); - push_query(&mut request.query, "manager", Some(manager)); - return Ok(request); - } - if args.remove_calldata { - let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; - if args.assertion_ids.is_empty() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--assertion-id is required for --remove-calldata".to_string(), - }); - } - let mut request = WorkflowRequest::get( - format!("/assertion_adopters/{address}/remove-assertions-calldata"), - true, - ["pcl releases --project "], - ); - push_query(&mut request.query, "network", args.network.as_deref()); - push_query( - &mut request.query, - "environment", - args.environment.as_deref(), - ); - for assertion_id in &args.assertion_ids { - push_query(&mut request.query, "assertion_ids", Some(assertion_id)); - } - return Ok(request); - } - if args.remove { - let project = required_arg(args.project.as_deref(), "--project")?; - let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/{address}"), - true, - body, - vec![format!("pcl contracts --project {project}")], - )); - } - if let Some(project) = &args.project { - if let Some(adopter_id) = &args.adopter_id { - return Ok(WorkflowRequest::get( - format!("/views/projects/{project}/contracts/{adopter_id}"), - true, - vec![format!("pcl contracts --project {project}")], - )); - } - return Ok(WorkflowRequest::get( - format!("/views/projects/{project}/contracts"), - true, - vec![format!( - "pcl contracts --project {project} --adopter-id " - )], - )); - } - - Ok(WorkflowRequest::get( - "/assertion_adopters", - true, - ["pcl contracts --unassigned --manager "], - )) -} - -fn releases_request(args: &ReleasesArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - let project = required_project_arg(args.project.as_deref(), "releases", "--project")?; - if args.preview { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/preview"), - true, - body, - vec![format!( - "pcl releases --project {project} --create --body-file release.json" - )], - )); - } - if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases"), - true, - body, - vec![format!("pcl releases --project {project}")], - )); - } - if args.deploy - || args.remove - || args.deploy_calldata - || args.remove_calldata - || args.backtest_progress - || args.retry_check - { - let release_id = required_arg(args.release_id.as_deref(), "--release-id")?; - if args.backtest_progress { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/backtest-progress"), - true, - vec![format!( - "pcl releases --project {project} --release-id {release_id}" - )], - )); - } - if args.retry_check { - let check_id = required_arg(args.check_id.as_deref(), "--check-id")?; - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/checks/{check_id}/retry"), - true, - Some(body_or_empty(body)), - vec![format!( - "pcl releases --project {project} --release-id {release_id} --backtest-progress" - )], - )); - } - if args.deploy { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/deploy"), - true, - body, - vec![format!( - "pcl releases --project {project} --release-id {release_id}" - )], - )); - } - if args.remove { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/remove"), - true, - body, - vec![format!("pcl releases --project {project}")], - )); - } - if args.deploy_calldata { - let signer_address = required_arg(args.signer_address.as_deref(), "--signer-address")?; - let mut request = WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/deploy-calldata"), - true, - vec![format!( - "pcl releases --project {project} --release-id {release_id} --deploy" - )], - ); - push_query(&mut request.query, "signerAddress", Some(signer_address)); - return Ok(request); - } - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/remove-calldata"), - true, - vec![format!( - "pcl releases --project {project} --release-id {release_id} --remove" - )], - )); - } - let Some(release_id) = &args.release_id else { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases"), - true, - vec![format!( - "pcl releases --project {project} --release-id " - )], - )); - }; - Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}"), - true, - vec![ - format!( - "pcl releases --project {project} --release-id {release_id} --deploy-calldata --signer-address " - ), - format!("pcl releases --project {project} --release-id {release_id} --remove-calldata"), - ], - )) -} - -fn deployments_request(args: &DeploymentsArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - let project = required_project_arg(args.project.as_deref(), "deployments", "--project")?; - if args.confirm { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/confirm-deployment"), - true, - body, - vec![format!("pcl deployments --project {project}")], - )); - } - Ok(WorkflowRequest::get( - format!("/views/projects/{project}/deployments"), - true, - vec![format!("pcl releases --project {project}")], - )) -} - -fn access_request(args: &AccessArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - if args.pending { - return Ok(WorkflowRequest::get( - "/invitations/pending", - true, - ["pcl access --token --accept"], - )); - } - if args.accept || args.preview { - let token = required_arg(args.token.as_deref(), "--token")?; - if args.accept { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/invitations/{token}/accept"), - true, - Some(body_or_empty(body)), - ["pcl projects --mine"], - )); - } - return Ok(WorkflowRequest::get( - format!("/invitations/{token}/preview"), - false, - vec![format!("pcl access --token {token} --accept")], - )); - } - if let Some(token) = &args.token { - return Ok(WorkflowRequest::get( - format!("/invitations/{token}/preview"), - false, - vec![format!("pcl access --token {token} --accept")], - )); - } - let project = required_project_arg(args.project.as_deref(), "access", "--project")?; - if args.my_role { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/my-role"), - true, - vec![format!("pcl access --project {project} --members")], - )); - } - if args.invite { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/invitations"), - true, - body, - vec![format!("pcl access --project {project} --invitations")], - )); - } - if args.resend || args.revoke { - let invitation_id = required_arg(args.invitation_id.as_deref(), "--invitation-id")?; - if args.resend { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/invitations/{invitation_id}/resend"), - true, - Some(body_or_empty(body)), - vec![format!("pcl access --project {project} --invitations")], - )); - } - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/invitations/{invitation_id}"), - true, - body, - vec![format!("pcl access --project {project} --invitations")], - )); - } - if args.update_role || args.remove { - let member_user_id = required_arg(args.member_user_id.as_deref(), "--member-user-id")?; - if args.update_role { - return Ok(workflow_with_body( - HttpMethod::Patch, - format!("/projects/{project}/members/{member_user_id}"), - true, - body, - vec![format!("pcl access --project {project} --members")], - )); - } - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/members/{member_user_id}"), - true, - body, - vec![format!("pcl access --project {project} --members")], - )); - } - if args.invitations { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/invitations"), - true, - vec![format!( - "pcl access --project {project} --invite --body-template" - )], - )); - } - Ok(WorkflowRequest::get( - format!("/projects/{project}/members"), - true, - vec![ - format!("pcl access --project {project} --my-role"), - format!("pcl access --project {project} --invitations"), - ], - )) -} - -fn integrations_request(args: &IntegrationsArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - let project = required_project_arg(args.project.as_deref(), "integrations", "--project")?; - let Some(provider) = args.provider else { - return Err(ApiCommandError::InvalidWorkflowWithActions { - message: "--provider is required".to_string(), - next_actions: vec![ - "pcl integrations --project --provider slack".to_string(), - "pcl integrations --project --provider pagerduty".to_string(), - "pcl integrations --help".to_string(), - ], - }); - }; - let provider = provider.path(); - let base = format!("/projects/{project}/integrations/{provider}"); - if args.configure { - return Ok(workflow_with_body( - HttpMethod::Post, - base, - true, - body, - vec![format!( - "pcl integrations --project {project} --provider {provider}" - )], - )); - } - if args.test { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("{base}/test"), - true, - Some(body_or_empty(body)), - vec![format!( - "pcl integrations --project {project} --provider {provider}" - )], - )); - } - if args.delete { - return Ok(workflow_with_body( - HttpMethod::Delete, - base, - true, - body, - vec![format!( - "pcl integrations --project {project} --provider {provider}" - )], - )); - } - Ok(WorkflowRequest::get( - base, - true, - vec![ - format!("pcl integrations --project {project} --provider {provider} --test"), - format!( - "pcl integrations --project {project} --provider {provider} --configure --body-template" - ), - ], - )) -} - -fn protocol_manager_request( - args: &ProtocolManagerArgs, -) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - let project = required_project_arg(args.project.as_deref(), "protocol-manager", "--project")?; - let base = format!("/projects/{project}/protocol-manager"); - if args.nonce { - let address = required_arg(args.address.as_deref(), "--address")?; - let mut request = WorkflowRequest::get( - format!("{base}/nonce"), - true, - vec![format!( - "pcl protocol-manager --project {project} --set --body-template" - )], - ); - push_query(&mut request.query, "address", Some(address)); - push_query(&mut request.query, "chain_id", args.chain_id); - return Ok(request); - } - if args.set { - return Ok(workflow_with_body( - HttpMethod::Post, - base, - true, - body, - vec![format!( - "pcl protocol-manager --project {project} --pending-transfer" - )], - )); - } - if args.clear { - return Ok(workflow_with_body( - HttpMethod::Delete, - base, - true, - body, - vec![format!( - "pcl protocol-manager --project {project} --nonce --address " - )], - )); - } - if args.transfer_calldata { - let new_manager = required_arg(args.new_manager.as_deref(), "--new-manager")?; - let mut request = WorkflowRequest::get( - format!("{base}/transfer-calldata"), - true, - vec![format!( - "pcl protocol-manager --project {project} --set --body-template" - )], - ); - push_query(&mut request.query, "new_manager", Some(new_manager)); - return Ok(request); - } - if args.accept_calldata { - return Ok(WorkflowRequest::get( - format!("{base}/accept-calldata"), - true, - vec![format!( - "pcl protocol-manager --project {project} --confirm-transfer --body-template" - )], - )); - } - if args.confirm_transfer { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("{base}/confirm-transfer"), - true, - body, - vec![format!( - "pcl protocol-manager --project {project} --pending-transfer" - )], - )); - } - Ok(WorkflowRequest::get( - format!("{base}/pending-transfer"), - true, - vec![ - format!("pcl protocol-manager --project {project} --nonce --address "), - format!( - "pcl protocol-manager --project {project} --transfer-calldata --new-manager " - ), - ], - )) -} - -fn transfers_request(args: &TransfersArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - if args.reject { - return Ok(workflow_with_body( - HttpMethod::Post, - "/transfers/reject", - true, - body, - ["pcl transfers --pending"], - )); - } - if let Some(transfer_id) = &args.transfer_id { - return Ok(WorkflowRequest::get( - format!("/views/transfers/{transfer_id}"), - true, - ["pcl transfers --pending"], - )); - } - Ok(WorkflowRequest::get( - "/views/transfers/pending", - true, - ["pcl transfers --transfer-id "], - )) -} - -fn events_request(args: &EventsArgs) -> Result { - let project = required_project_arg(args.project.as_deref(), "events", "--project")?; - let mut request = if args.audit_log { - WorkflowRequest::get( - format!("/views/projects/{project}/audit-log"), - true, - vec![format!("pcl events --project {project}")], - ) - } else { - WorkflowRequest::get( - format!("/views/projects/{project}/events"), - true, - vec![format!("pcl events --project {project} --audit-log")], - ) - }; - push_query(&mut request.query, "page", args.page); - push_query(&mut request.query, "limit", args.limit); - push_query( - &mut request.query, - "environment", - args.environment.as_deref(), - ); - Ok(request) -} - -fn workflow_with_body( - method: HttpMethod, - path: impl Into, - require_auth: bool, - body: Option, - next_actions: impl IntoIterator>, -) -> WorkflowRequest { - WorkflowRequest { - method, - path: path.into(), - query: Vec::new(), - body, - require_auth, - next_actions: next_actions.into_iter().map(Into::into).collect(), - } -} - -fn body_or_empty(body: Option) -> String { - body.unwrap_or_else(|| "{}".to_string()) -} - -fn request_body( - body: Option<&str>, - body_file: Option<&PathBuf>, - fields: &[String], -) -> Result, ApiCommandError> { - let body = read_body(body, body_file)?; - body_with_fields(body, fields) -} - -fn project_request_body(args: &ProjectsArgs) -> Result, ApiCommandError> { - let body = read_body(args.body.as_deref(), args.body_file.as_ref())?; - let mut object = match body { - Some(body) => serde_json::from_str::(&body)?, - None => Value::Object(Map::new()), - }; - let Value::Object(map) = &mut object else { - return Err(ApiCommandError::InvalidWorkflow { - message: "project body must be a JSON object".to_string(), - }); - }; - - insert_optional( - map, - "project_name", - args.project_name.clone().map(Value::String), - ); - insert_optional( - map, - "project_description", - args.project_description.clone().map(Value::String), - ); - insert_optional( - map, - "profile_image_url", - args.profile_image_url.clone().map(Value::String), - ); - insert_optional( - map, - "github_url", - args.github_url.clone().map(Value::String), - ); - insert_optional(map, "chain_id", args.chain_id.map(|value| json!(value))); - insert_optional(map, "is_private", args.is_private.map(|value| json!(value))); - insert_optional(map, "is_dev", args.is_dev.map(|value| json!(value))); - apply_fields(map, &args.field)?; - - if map.is_empty() { - Ok(None) - } else { - Ok(Some(Value::Object(map.clone()).to_string())) - } -} - -fn body_with_fields( - body: Option, - fields: &[String], -) -> Result, ApiCommandError> { - if fields.is_empty() { - return Ok(body); - } - let mut value = match body { - Some(body) => serde_json::from_str::(&body)?, - None => Value::Object(Map::new()), - }; - let Value::Object(map) = &mut value else { - return Err(ApiCommandError::InvalidWorkflow { - message: "--field requires the request body to be a JSON object".to_string(), - }); - }; - apply_fields(map, fields)?; - Ok(Some(Value::Object(map.clone()).to_string())) -} - -fn apply_fields(map: &mut Map, fields: &[String]) -> Result<(), ApiCommandError> { - for field in fields { - let (key, value) = field.split_once('=').ok_or_else(|| { - ApiCommandError::InvalidKeyValue { - kind: "field", - input: field.clone(), - } - })?; - map.insert(key.to_string(), parse_field_value(value)); - } - Ok(()) -} - -fn parse_field_value(value: &str) -> Value { - serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())) -} - -fn insert_optional(map: &mut Map, key: &str, value: Option) { - if let Some(value) = value { - map.insert(key.to_string(), value); - } -} - -fn template_envelope(data: Value) -> Value { - let next_actions = if data - .get("body_variants") - .and_then(Value::as_array) - .is_some_and(|variants| !variants.is_empty()) - { - vec![ - "Choose one entry from data.body_variants and pass only its body with --body-file ", - "Or pass fields from the chosen variant body with --field key=value", - ] - } else { - vec![ - "Pass the template with --body-file ", - "Or pass individual fields with --field key=value", - ] - }; - with_envelope_metadata(json!({ - "status": "ok", - "data": data, - "next_actions": next_actions, - })) -} - -fn project_body_template(args: &ProjectsArgs) -> Value { - if args.update { - return body_template("project_update"); - } - if args.save || args.unsave { - return body_template("project_saved"); - } - if args.delete || args.resolve || args.widget || args.mine || args.saved { - return body_template("empty_object"); - } - body_template("project_create") -} - -fn contracts_body_template(args: &ContractsArgs) -> Value { - if args.assign_project { - return body_template("contracts_assign_project"); - } - if args.unassigned || args.remove || args.remove_calldata || args.adopter_id.is_some() { - return body_template("empty_object"); - } - body_template("contracts") -} - -fn release_body_template(args: &ReleasesArgs) -> Value { - if args.deploy { - return body_template("release_deploy"); - } - if args.remove { - return body_template("release_remove"); - } - if args.deploy_calldata - || args.remove_calldata - || args.backtest_progress - || args.retry_check - || args.release_id.is_some() - { - return body_template("empty_object"); - } - body_template("release") -} - -fn deployment_body_template(args: &DeploymentsArgs) -> Value { - if !args.confirm { - return body_template("empty_object"); - } - body_template("deployment_confirmation") -} - -fn access_body_template(args: &AccessArgs) -> Value { - if args.update_role { - return body_template("role_update"); - } - if args.invite { - return body_template("access_invite"); - } - if args.accept - || args.resend - || args.revoke - || args.remove - || args.members - || args.invitations - || args.pending - || args.preview - || args.my_role - { - return body_template("empty_object"); - } - body_template("access_invite") -} - -fn integration_body_template(args: &IntegrationsArgs) -> Value { - if args.test || args.delete { - return body_template("empty_object"); - } - if let Some(provider) = args.provider { - return body_template(provider.path()); - } - json!({ - "body_variants": [ - { - "name": "slack", - "body": body_template("slack") - }, - { - "name": "pagerduty", - "body": body_template("pagerduty") - } - ] - }) -} - -fn protocol_manager_body_template(args: &ProtocolManagerArgs) -> Value { - if args.set { - return body_template("protocol_manager_set"); - } - if args.confirm_transfer { - return body_template("protocol_manager_confirm"); - } - if args.clear - || args.nonce - || args.transfer_calldata - || args.accept_calldata - || args.pending_transfer - { - return body_template("empty_object"); - } - body_template("protocol_manager_set") -} - -fn transfer_body_template(args: &TransfersArgs) -> Value { - if !args.reject { - return body_template("empty_object"); - } - body_template("transfer_reject") -} - -fn body_template(kind: &str) -> Value { - match kind { - "project_create" => { - json!({ - "project_name": "", - "chain_id": 1, - "project_description": "", - "profile_image_url": "https://example.com/project.png", - "is_private": false - }) - } - "project_update" => { - json!({ - "project_name": "", - "project_description": "", - "github_url": "https://github.com/org/repo", - "profile_image_url": "https://example.com/project.png", - "is_dev": false, - "is_private": false, - "assertion_adopters": [] - }) - } - "project_saved" => json!({ "project_id": "" }), - "release" => { - json!({ - "environment": "staging", - "assertionsDir": "assertions", - "contracts": { - "": { - "address": "0x...", - "name": "", - "assertions": [ - { - "file": "Assertion.sol", - "args": [], - "bytecode": "0x...", - "flattenedSource": "", - "compilerVersion": "0.8.28", - "contractName": "", - "evmVersion": "paris", - "optimizerRuns": 200, - "optimizerEnabled": true, - "metadataBytecodeHash": "none", - "libraries": {} - } - ] - } - }, - "compilerArgs": [] - }) - } - "access_invite" => { - json!({ - "identifier": "user@example.com", - "identifier_type": "email", - "role": "viewer" - }) - } - "role_update" => json!({ "role": "viewer" }), - "release_deploy" => { - json!({ - "chainId": 1, - "txHash": "0x..." - }) - } - "release_remove" => { - json!({ - "chainId": 1, - "txHash": "0x..." - }) - } - "deployment_confirmation" => { - json!({ - "tx_hash": "0x...", - "chainId": 1, - "environment": "staging", - "assertions": [ - { - "assertion_id": "0x...", - "assertion_adopters": [ - { - "id": "" - } - ] - } - ] - }) - } - "slack" => { - json!({ - "webhook_url": "https://hooks.slack.com/services/...", - "enabled": true - }) - } - "pagerduty" => { - json!({ - "routing_key": "", - "enabled": true - }) - } - "protocol_manager_set" => { - json!({ - "address": "0x...", - "signature": "0x...", - "nonce": "" - }) - } - "protocol_manager_confirm" => { - json!({ - "body_variants": [ - { - "name": "direct", - "body": { - "mode": "direct", - "new_manager_address": "0x..." - } - }, - { - "name": "onchain", - "body": { - "mode": "onchain", - "new_manager_address": "0x...", - "chain_id": 1, - "tx_hash": "0x..." - } - } - ] - }) - } - "transfer_reject" => { - json!({ - "ponder_transfer_id": "" - }) - } - "contracts" => { - json!({ - "network": "1", - "address": "0x...", - "contract_name": "", - "project_id": "" - }) - } - "contracts_assign_project" => { - json!({ - "project_id": "", - "assertion_adopter_ids": [""] - }) - } - "empty_object" => json!({}), - _ => json!({}), - } -} - -fn required_arg(value: Option<&str>, name: &str) -> Result { - value.map(ToString::to_string).ok_or_else(|| { - ApiCommandError::InvalidWorkflow { - message: format!("{name} is required"), - } - }) -} - -fn required_arg_with_actions( - value: Option<&str>, - name: &str, - next_actions: Vec, -) -> Result { - value.map(ToString::to_string).ok_or_else(|| { - ApiCommandError::InvalidWorkflowWithActions { - message: format!("{name} is required"), - next_actions, - } - }) -} - -fn required_project_arg( - value: Option<&str>, - command: &str, - flag: &str, -) -> Result { - required_arg_with_actions( - value, - flag, - vec![ - "pcl projects --mine".to_string(), - format!("pcl {command} {flag} "), - format!("pcl {command} --help"), - ], - ) -} - -fn project_segment(path: &str) -> Option<(&'static str, &str, &str)> { - if let Some(rest) = path.strip_prefix("/projects/") { - let (segment, suffix) = split_first_segment(rest); - if matches!(segment, "saved" | "resolve") { - return None; - } - return Some(("/projects/", segment, suffix)); - } - if let Some(rest) = path.strip_prefix("/views/projects/") { - let (segment, suffix) = split_first_segment(rest); - if segment == "home" { - return None; - } - return Some(("/views/projects/", segment, suffix)); - } - None -} - -fn split_first_segment(path: &str) -> (&str, &str) { - path.split_once('/').map_or((path, ""), |(segment, _rest)| { - (segment, &path[segment.len()..]) - }) -} - -fn incidents_request(args: &IncidentsArgs) -> Result { - if args.all && (args.incident_id.is_some() || args.stats || args.retry_trace) { - return Err(ApiCommandError::InvalidWorkflow { - message: "--all is only supported for incident list workflows".to_string(), - }); - } - if args.stats && args.project_id.is_none() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--stats requires --project-id".to_string(), - }); - } - if args.tx_id.is_some() && args.incident_id.is_none() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--tx-id requires --incident-id".to_string(), - }); - } - if args.retry_trace && args.tx_id.is_none() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--retry-trace requires --incident-id and --tx-id".to_string(), - }); - } - - let mut query = Vec::new(); - push_query(&mut query, "page", args.page); - push_query(&mut query, "limit", args.limit); - - if let Some(incident_id) = &args.incident_id { - if args.retry_trace { - let tx_id = required_arg(args.tx_id.as_deref(), "--tx-id")?; - return Ok(WorkflowRequest { - method: HttpMethod::Post, - path: format!("/incidents/{incident_id}/transactions/{tx_id}/trace/retry"), - query, - body: Some("{}".to_string()), - require_auth: true, - next_actions: vec![format!( - "pcl incidents --incident-id {incident_id} --tx-id {tx_id}" - )], - }); - } - let path = if let Some(tx_id) = &args.tx_id { - format!("/views/incidents/{incident_id}/transactions/{tx_id}/trace") - } else { - format!("/views/incidents/{incident_id}") - }; - let next_actions = vec![ - "pcl incidents --limit 5".to_string(), - format!("pcl api inspect get {}", path), - ]; - return Ok(WorkflowRequest::get_with_query( - path, - query, - true, - next_actions, - )); - } - - if let Some(project_id) = &args.project_id { - if args.stats { - let path = format!("/projects/{project_id}/incidents/stats"); - return Ok(WorkflowRequest::get_with_query( - path, - query, - true, - vec![format!( - "pcl incidents --project-id {project_id} --limit 10" - )], - )); - } - push_query(&mut query, "assertionId", args.assertion_id.as_deref()); - push_query( - &mut query, - "assertionAdopterId", - args.assertion_adopter_id.as_deref(), - ); - push_query(&mut query, "environment", args.environment.as_deref()); - push_query(&mut query, "fromDate", args.from_date.as_deref()); - push_query(&mut query, "toDate", args.to_date.as_deref()); - let path = format!("/views/projects/{project_id}/incidents"); - return Ok(WorkflowRequest::get_with_query( - path, - query, - true, - vec![ - format!("pcl assertions --project-id {project_id}"), - "pcl incidents --limit 5".to_string(), - ], - )); - } - - push_query(&mut query, "network", args.network); - push_query(&mut query, "sort", args.sort.as_deref()); - push_query(&mut query, "devMode", args.dev_mode.as_deref()); - Ok(WorkflowRequest::get_with_query( - "/views/public/incidents", - query, - false, - vec![ - "pcl incidents --project-id --limit 10".to_string(), - "pcl projects --limit 10".to_string(), - ], - )) -} - -fn incidents_next_actions( - data: &Value, - args: &IncidentsArgs, - fallback: Vec, -) -> Vec { - if let Some(incident_id) = &args.incident_id { - if args.tx_id.is_none() - && let Some(tx_id) = data - .get("data") - .and_then(|data| data.get("invalidating_transactions")) - .and_then(Value::as_array) - .and_then(|transactions| transactions.first()) - .and_then(|transaction| { - first_string_field(transaction, &["transaction_hash", "id", "tx_id"]) - }) - { - return vec![ - format!("pcl incidents --incident-id {incident_id} --tx-id {tx_id}"), - "pcl incidents --limit 5".to_string(), - ]; - } - return fallback; - } - first_string_field(data, &["id", "incidentId", "incident_id"]).map_or(fallback, |incident_id| { - vec![ - format!("pcl incidents --incident-id {incident_id}"), - "pcl projects --limit 10".to_string(), - ] - }) -} - -fn projects_next_actions(data: &Value, fallback: Vec) -> Vec { - if let Some(project_id) = data.get("project_id").and_then(Value::as_str) { - return vec![ - format!("pcl assertions --project-id {project_id}"), - format!("pcl incidents --project-id {project_id} --limit 10"), - ]; - } - first_string_field(data, &["project_id", "projectId", "id"]).map_or(fallback, |project_id| { - vec![ - format!("pcl projects --project-id {project_id}"), - format!("pcl assertions --project-id {project_id}"), - format!("pcl incidents --project-id {project_id} --limit 10"), - ] - }) -} - -fn assertions_next_actions( - data: &Value, - args: &AssertionsArgs, - fallback: Vec, -) -> Vec { - let Some(project_id) = &args.project_id else { - return first_string_field( - data, - &["assertion_adopter_address", "adopter_address", "address"], - ) - .map_or(fallback, |address| { - vec![format!("pcl assertions --adopter-address {address}")] - }); - }; - - first_string_field(data, &["assertion_id", "assertionId", "id"]).map_or( - fallback, - |assertion_id| { - vec![ - format!("pcl assertions --project-id {project_id} --assertion-id {assertion_id}",), - format!("pcl incidents --project-id {project_id} --assertion-id {assertion_id}",), - ] - }, - ) -} - -fn search_next_actions(data: &Value, fallback: Vec) -> Vec { - if let Some(project_id) = data - .get("projects") - .and_then(Value::as_array) - .and_then(|projects| projects.first()) - .and_then(|project| first_string_field(project, &["project_id", "projectId", "id", "slug"])) - { - return vec![ - format!("pcl projects --project-id {project_id}"), - format!("pcl contracts --project {project_id}"), - ]; - } - if let Some(project_id) = data - .get("contracts") - .and_then(Value::as_array) - .and_then(|contracts| contracts.first()) - .and_then(|contract| { - contract.get("data").map_or_else( - || first_string_field(contract, &["related_project_id", "related_project_slug"]), - |inner| first_string_field(inner, &["related_project_id", "related_project_slug"]), - ) - }) - { - return vec![ - format!("pcl projects --project-id {project_id}"), - format!("pcl contracts --project {project_id}"), - ]; - } - fallback -} - -fn first_string_field(value: &Value, keys: &[&str]) -> Option { - match value { - Value::Object(object) => { - for key in keys { - if let Some(value) = object.get(*key).and_then(Value::as_str) { - return Some(value.to_string()); - } - } - object - .values() - .find_map(|value| first_string_field(value, keys)) - } - Value::Array(values) => { - values - .iter() - .find_map(|value| first_string_field(value, keys)) - } - _ => None, - } -} - -fn projects_request(args: &ProjectsArgs) -> Result { - let mut query = Vec::new(); - push_query(&mut query, "page", args.page); - push_query(&mut query, "limit", args.limit); - push_query(&mut query, "search", args.search.as_deref()); - let body = project_request_body(args)?; - - if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - "/projects", - true, - body, - vec!["pcl projects --mine".to_string()], - )); - } - - if args.mine { - return Ok(WorkflowRequest::get_with_query( - "/views/projects/home", - query, - true, - vec![ - "pcl account".to_string(), - "pcl projects --saved --user-id ".to_string(), - ], - )); - } - if args.saved { - let user_id = required_arg(args.user_id.as_deref(), "--user-id")?; - push_query(&mut query, "user_id", Some(user_id)); - return Ok(WorkflowRequest::get_with_query( - "/projects/saved", - query, - true, - vec!["pcl projects --mine".to_string()], - )); - } - if args.project_id.is_none() - && (args.update || args.delete || args.save || args.unsave || args.resolve || args.widget) - { - required_project_arg(args.project_id.as_deref(), "projects", "--project-id")?; - } - if let Some(project_id) = &args.project_id { - if args.resolve { - return Ok(WorkflowRequest::get_with_query( - format!("/projects/resolve/{project_id}"), - query, - false, - vec![format!("pcl projects --project-id {project_id}")], - )); - } - if args.widget { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/widget"), - true, - vec![format!("pcl projects --project-id {project_id}")], - )); - } - if args.save || args.unsave { - return Ok(workflow_with_body( - if args.save { - HttpMethod::Post - } else { - HttpMethod::Delete - }, - "/projects/saved", - true, - Some(json!({ "project_id": project_id }).to_string()), - vec![ - format!("pcl projects --project-id {project_id}"), - "pcl projects --mine".to_string(), - ], - )); - } - if args.update { - return Ok(workflow_with_body( - HttpMethod::Put, - format!("/projects/{project_id}"), - true, - body, - vec![format!("pcl projects --project-id {project_id}")], - )); - } - if args.delete { - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project_id}"), - true, - body, - ["pcl projects --mine"], - )); - } - return Ok(WorkflowRequest::get_with_query( - format!("/projects/{project_id}"), - query, - true, - vec![ - format!("pcl assertions --project-id {project_id}"), - format!("pcl incidents --project-id {project_id} --limit 10"), - ], - )); - } - - Ok(WorkflowRequest::get_with_query( - "/views/projects", - query, - false, - [ - "pcl projects --project-id ", - "pcl incidents --limit 5", - ], - )) -} - -fn assertions_request(args: &AssertionsArgs) -> Result { - if args.submit || args.submitted { - return Err(ApiCommandError::InvalidWorkflow { - message: - "Submitted assertions have been removed from the API; use releases and registered assertions instead" - .to_string(), - }); - } - - if let Some(adopter_address) = &args.adopter_address { - let mut request = WorkflowRequest::get( - "/assertions", - false, - ["pcl contracts --project "], - ); - push_query(&mut request.query, "adopter_address", Some(adopter_address)); - push_query(&mut request.query, "network", args.network.as_deref()); - push_query( - &mut request.query, - "environment", - args.environment.as_deref(), - ); - push_query( - &mut request.query, - "include_onchain_only", - args.include_onchain_only, - ); - return Ok(request); - } - - let project_id = - required_project_arg(args.project_id.as_deref(), "assertions", "--project-id")?; - let mut query = Vec::new(); - push_query(&mut query, "page", args.page); - push_query(&mut query, "limit", args.limit); - push_query(&mut query, "assertionAdopterId", args.adopter_id.as_deref()); - push_query(&mut query, "environment", args.environment.as_deref()); - - if args.registered { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/registered-assertions"), - true, - vec![format!("pcl assertions --project-id {project_id}")], - )); - } - if args.remove_info { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/remove-assertions-info"), - true, - vec![format!( - "pcl assertions --project-id {project_id} --remove-calldata" - )], - )); - } - if args.remove_calldata { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/remove-assertions-calldata"), - true, - vec![format!("pcl releases --project {project_id}")], - )); - } - - if let Some(assertion_id) = &args.assertion_id { - return Ok(WorkflowRequest::get_with_query( - format!("/views/projects/{project_id}/assertions/{assertion_id}"), - query, - true, - vec![format!( - "pcl incidents --project-id {project_id} --assertion-id {assertion_id}", - )], - )); - } - - Ok(WorkflowRequest::get_with_query( - format!("/views/projects/{project_id}/assertions"), - query, - true, - vec![ - format!("pcl incidents --project-id {project_id} --limit 10"), - format!("pcl assertions --project-id {project_id} --assertion-id "), - ], - )) -} - -fn push_query(query: &mut Vec<(String, String)>, name: &str, value: Option) { - if let Some(value) = value { - query.push((name.to_string(), value.to_string())); - } -} - -fn query_pairs_value(query: &[(String, String)]) -> Value { - Value::Array( - query - .iter() - .map(|(name, value)| json!({ "name": name, "value": value })) - .collect(), - ) -} - -fn upsert_query(query: &mut Vec<(String, String)>, name: &str, value: String) { - if let Some((_, existing)) = query.iter_mut().find(|(key, _)| key == name) { - *existing = value; - } else { - query.push((name.to_string(), value)); - } -} - -fn extract_paginated_items(value: &Value, preferred_field: &str) -> Option> { - if let Some(items) = array_at_path(value, preferred_field) { - return Some(items.to_vec()); - } - for path in [ - "items", - "incidents", - "results", - "data.items", - "data.incidents", - "data.results", - "data", - ] { - if let Some(items) = array_at_path(value, path) { - return Some(items.to_vec()); - } - } - value.as_array().cloned() -} - -fn array_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a [Value]> { - let mut current = value; - for segment in path.split('.') { - if segment.is_empty() { - continue; - } - current = current.get(segment)?; - } - current.as_array().map(Vec::as_slice) -} - -/// Render a JSON value as the CLI's compact TOON-style text output. -pub fn toon_string(value: &Value) -> String { - let mut output = toon_format::encode_default(value).unwrap_or_else(|_| { - serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) - }); - if !output.ends_with('\n') { - output.push('\n'); - } - output -} - -fn parse_key_values( - kind: &'static str, - entries: &[String], -) -> Result, ApiCommandError> { - entries - .iter() - .map(|entry| { - let (key, value) = entry.split_once('=').ok_or_else(|| { - ApiCommandError::InvalidKeyValue { - kind, - input: entry.clone(), - } - })?; - Ok((key.to_string(), value.to_string())) - }) - .collect() -} - -fn parse_headers(entries: &[String]) -> Result { - let mut headers = HeaderMap::new(); - - for entry in entries { - let (name, value) = entry.split_once('=').ok_or_else(|| { - ApiCommandError::InvalidKeyValue { - kind: "header", - input: entry.clone(), - } - })?; - let header_name = HeaderName::from_str(name).map_err(|source| { - ApiCommandError::InvalidHeaderName { - name: name.to_string(), - source, - } - })?; - let header_value = HeaderValue::from_str(value).map_err(|source| { - ApiCommandError::InvalidHeaderValue { - name: name.to_string(), - source, - } - })?; - headers.insert(header_name, header_value); - } - - Ok(headers) -} - -fn read_body( - body: Option<&str>, - body_file: Option<&PathBuf>, -) -> Result, ApiCommandError> { - if let Some(body) = body { - return Ok(Some(body.to_string())); - } - - if let Some(path) = body_file { - if path.as_os_str() == "-" { - let mut body = String::new(); - std::io::stdin() - .read_to_string(&mut body) - .map_err(ApiCommandError::Stdin)?; - return Ok(Some(body)); - } - - return fs::read_to_string(path).map(Some).map_err(|source| { - ApiCommandError::BodyFile { - path: path.clone(), - source, - } - }); - } - - Ok(None) -} - -fn write_json_output_file(path: &PathBuf, value: &Value) -> Result<(), ApiCommandError> { - let body = serde_json::to_string_pretty(value)?; - fs::write(path, body).map_err(|source| { - ApiCommandError::OutputFile { - path: path.clone(), - source, - } - }) -} - -fn write_jsonl_items_output_file(path: &PathBuf, value: &Value) -> Result<(), ApiCommandError> { - let items = value - .get("items") - .and_then(Value::as_array) - .ok_or_else(|| { - ApiCommandError::InvalidWorkflow { - message: "--jsonl output requires paginated data with an items array".to_string(), - } - })?; - let mut body = String::new(); - for item in items { - body.push_str(&serde_json::to_string(item)?); - body.push('\n'); - } - fs::write(path, body).map_err(|source| { - ApiCommandError::OutputFile { - path: path.clone(), - source, - } - }) -} - -#[derive(Clone, Debug)] -struct OperationCoverage { - operation_id: String, - method: String, - path: String, - hit: u64, - ok: u64, - statuses: BTreeMap, - latest_request_id: Option, - latest_status: Option, - latest_timestamp: Option, - latest_kind: Option, -} - -impl OperationCoverage { - fn new(operation: &OperationSummary) -> Self { - Self { - operation_id: operation.operation_id.clone(), - method: operation.method.to_string(), - path: operation.path.clone(), - hit: 0, - ok: 0, - statuses: BTreeMap::new(), - latest_request_id: None, - latest_status: None, - latest_timestamp: None, - latest_kind: None, - } - } - - fn record_hit(&mut self, record: &Value) { - let status = record.get("status").and_then(Value::as_u64); - self.hit += 1; - if status.is_some_and(|status| (200..=299).contains(&status)) { - self.ok += 1; - } - if let Some(status) = status { - *self.statuses.entry(status.to_string()).or_insert(0) += 1; - } - self.latest_status = status; - self.latest_request_id = record - .get("request_id") - .and_then(Value::as_str) - .map(ToString::to_string); - self.latest_timestamp = record - .get("timestamp") - .and_then(Value::as_str) - .map(ToString::to_string); - self.latest_kind = record - .get("kind") - .and_then(Value::as_str) - .map(ToString::to_string); - } - - fn success_2xx(&self) -> bool { - self.ok > 0 - } - - fn side_effecting(&self) -> bool { - method_side_effecting(&self.method) - } - - fn to_value(&self) -> Value { - json!({ - "operation_id": self.operation_id, - "method": self.method, - "path": self.path, - "hit": self.hit > 0, - "hits": self.hit, - "success_2xx": self.success_2xx(), - "ok": self.ok, - "statuses": self.statuses, - "side_effecting": self.side_effecting(), - "latest_request_id": self.latest_request_id, - "latest_status": self.latest_status, - "latest_timestamp": self.latest_timestamp, - "latest_kind": self.latest_kind, - }) - } -} - -fn api_coverage( - spec: &Value, - request_log_path: &Path, - record_limit: usize, - api_url: &str, -) -> Result { - let operations = list_operations(spec, None, None)?; - let records = crate::request_log::read_request_records_at(request_log_path, record_limit) - .map_err(|source| { - ApiCommandError::RequestLog { - path: request_log_path.to_path_buf(), - source, - } - })?; - let mut coverage = operations - .iter() - .map(OperationCoverage::new) - .collect::>(); - let mut unmatched_records = Vec::new(); - - for record in &records { - let Some(index) = match_request_record_to_operation(&operations, record) else { - unmatched_records.push(record.clone()); - continue; - }; - coverage[index].record_hit(record); - } - - let mut by_method: BTreeMap> = BTreeMap::new(); - for entry in &coverage { - let method = by_method.entry(entry.method.clone()).or_default(); - *method.entry("total").or_insert(0) += 1; - if entry.hit > 0 { - *method.entry("hit").or_insert(0) += 1; - } - if entry.success_2xx() { - *method.entry("ok").or_insert(0) += 1; - } - } - - let no_hit = coverage - .iter() - .filter(|entry| entry.hit == 0) - .map(OperationCoverage::to_value) - .collect::>(); - let no_2xx = coverage - .iter() - .filter(|entry| entry.hit > 0 && !entry.success_2xx()) - .map(OperationCoverage::to_value) - .collect::>(); - let write_no_2xx = coverage - .iter() - .filter(|entry| entry.side_effecting() && entry.hit > 0 && !entry.success_2xx()) - .map(OperationCoverage::to_value) - .collect::>(); - let operations_value = coverage - .iter() - .map(OperationCoverage::to_value) - .collect::>(); - - Ok(json!({ - "generated_at": chrono::Utc::now().to_rfc3339(), - "api_url": api_url, - "request_log": request_log_path, - "records_considered": records.len(), - "record_limit": record_limit, - "total_operations": operations.len(), - "by_method": by_method, - "no_hit_count": no_hit.len(), - "no_2xx_count": no_2xx.len(), - "write_no_2xx_count": write_no_2xx.len(), - "unmatched_record_count": unmatched_records.len(), - "no_hit": no_hit, - "no_2xx": no_2xx, - "write_no_2xx": write_no_2xx, - "unmatched_records": unmatched_records, - "operations": operations_value, - })) -} - -fn write_api_coverage_markdown(path: &PathBuf, coverage: &Value) -> Result<(), ApiCommandError> { - let markdown = api_coverage_markdown(coverage); - fs::write(path, markdown).map_err(|source| { - ApiCommandError::OutputFile { - path: path.clone(), - source, - } - }) -} - -fn api_coverage_markdown(coverage: &Value) -> String { - let mut body = String::new(); - writeln!(body, "# PCL API Coverage").expect("writing to String cannot fail"); - writeln!(body).expect("writing to String cannot fail"); - writeln!( - body, - "- Generated: {}", - coverage - .get("generated_at") - .and_then(Value::as_str) - .unwrap_or("unknown") - ) - .expect("writing to String cannot fail"); - writeln!( - body, - "- API URL: {}", - coverage - .get("api_url") - .and_then(Value::as_str) - .unwrap_or("unknown") - ) - .expect("writing to String cannot fail"); - writeln!( - body, - "- Request log: {}", - coverage - .get("request_log") - .and_then(Value::as_str) - .unwrap_or("unknown") - ) - .expect("writing to String cannot fail"); - writeln!( - body, - "- Operations: {}", - coverage - .get("total_operations") - .and_then(Value::as_u64) - .unwrap_or(0) - ) - .expect("writing to String cannot fail"); - writeln!( - body, - "- Records considered: {}", - coverage - .get("records_considered") - .and_then(Value::as_u64) - .unwrap_or(0) - ) - .expect("writing to String cannot fail"); - writeln!( - body, - "- No-hit operations: {}", - coverage - .get("no_hit_count") - .and_then(Value::as_u64) - .unwrap_or(0) - ) - .expect("writing to String cannot fail"); - writeln!( - body, - "- Hit but no 2xx operations: {}", - coverage - .get("no_2xx_count") - .and_then(Value::as_u64) - .unwrap_or(0) - ) - .expect("writing to String cannot fail"); - writeln!(body).expect("writing to String cannot fail"); - - append_coverage_table(&mut body, "No-Hit Operations", coverage.get("no_hit")); - append_coverage_table( - &mut body, - "Hit But No 2xx Operations", - coverage.get("no_2xx"), - ); - append_coverage_table( - &mut body, - "Side-Effecting Operations Hit But No 2xx", - coverage.get("write_no_2xx"), - ); - body -} - -fn append_coverage_table(body: &mut String, title: &str, value: Option<&Value>) { - writeln!(body, "## {title}").expect("writing to String cannot fail"); - writeln!(body).expect("writing to String cannot fail"); - let Some(entries) = value.and_then(Value::as_array) else { - writeln!(body, "None.").expect("writing to String cannot fail"); - writeln!(body).expect("writing to String cannot fail"); - return; - }; - if entries.is_empty() { - writeln!(body, "None.").expect("writing to String cannot fail"); - writeln!(body).expect("writing to String cannot fail"); - return; - } - writeln!( - body, - "| Operation | Method | Path | Hits | Statuses | Latest Request |" - ) - .expect("writing to String cannot fail"); - writeln!(body, "| --- | --- | --- | ---: | --- | --- |") - .expect("writing to String cannot fail"); - for entry in entries { - writeln!( - body, - "| `{}` | `{}` | `{}` | {} | `{}` | `{}` |", - entry - .get("operation_id") - .and_then(Value::as_str) - .unwrap_or("unknown"), - entry - .get("method") - .and_then(Value::as_str) - .unwrap_or("unknown"), - entry - .get("path") - .and_then(Value::as_str) - .unwrap_or("unknown"), - entry.get("hits").and_then(Value::as_u64).unwrap_or(0), - entry - .get("statuses") - .map_or_else(|| "{}".to_string(), Value::to_string), - entry - .get("latest_request_id") - .and_then(Value::as_str) - .unwrap_or("") - ) - .expect("writing to String cannot fail"); - } - writeln!(body).expect("writing to String cannot fail"); -} - -fn match_request_record_to_operation( - operations: &[OperationSummary], - record: &Value, -) -> Option { - if let Some(operation_id) = record.get("operation_id").and_then(Value::as_str) - && let Some(index) = operations - .iter() - .position(|operation| operation.operation_id == operation_id) - { - return Some(index); - } - - let method = record.get("method").and_then(Value::as_str)?; - let path = record.get("path").and_then(Value::as_str)?; - let path = path.split_once('?').map_or(path, |(path, _)| path); - - operations.iter().position(|operation| { - operation.method.eq_ignore_ascii_case(method) && openapi_path_matches(&operation.path, path) - }) -} - -fn openapi_path_matches(openapi_path: &str, observed_path: &str) -> bool { - if openapi_path == observed_path { - return true; - } - let openapi_segments = openapi_path - .trim_matches('/') - .split('/') - .filter(|segment| !segment.is_empty()) - .collect::>(); - let observed_segments = observed_path - .trim_matches('/') - .split('/') - .filter(|segment| !segment.is_empty()) - .collect::>(); - if openapi_segments.len() != observed_segments.len() { - return false; - } - openapi_segments - .iter() - .zip(observed_segments) - .all(|(expected, observed)| { - (expected.starts_with('{') && expected.ends_with('}')) || *expected == observed - }) -} - -fn list_operations( - spec: &Value, - filter: Option<&str>, - method_filter: Option, -) -> Result, ApiCommandError> { - let paths = spec - .get("paths") - .and_then(Value::as_object) - .ok_or(ApiCommandError::MissingPaths)?; - let filter = filter.map(str::to_lowercase); - let mut operations = Vec::new(); - - for (path, path_item) in paths { - let Some(path_item) = path_item.as_object() else { - continue; - }; - - for method in [ - HttpMethod::Get, - HttpMethod::Post, - HttpMethod::Put, - HttpMethod::Patch, - HttpMethod::Delete, - ] { - if method_filter.is_some_and(|wanted| wanted.openapi_key() != method.openapi_key()) { - continue; - } - - let Some(operation) = path_item.get(method.openapi_key()) else { - continue; - }; - - let operation_id = operation - .get("operationId") - .and_then(Value::as_str) - .map_or_else(|| synthetic_operation_id(method, path), ToString::to_string); - let summary = operation - .get("summary") - .and_then(Value::as_str) - .map(ToString::to_string); - let tags = operation - .get("tags") - .and_then(Value::as_array) - .map(|tags| { - tags.iter() - .filter_map(Value::as_str) - .map(ToString::to_string) - .collect::>() - }) - .unwrap_or_default(); - - if let Some(filter) = &filter { - let haystack = format!( - "{} {} {} {}", - operation_id, - path, - summary.as_deref().unwrap_or_default(), - tags.join(" ") - ) - .to_lowercase(); - if !haystack.contains(filter) { - continue; - } - } - - let input_placeholders = operation_input_placeholders(path, operation); - let requires_input = !input_placeholders.is_empty(); - let workflow_alternatives = workflow_alternatives(method, path); - let raw_api_use = - raw_api_use(method, path, operation, !workflow_alternatives.is_empty()); - operations.push(OperationSummary { - inspect_command: format!("pcl api inspect {operation_id}"), - call_command: example_call(method, path, operation), - input_placeholders, - requires_input, - auth: operation_auth_metadata(method, path, operation), - workflow_alternatives, - raw_api_use, - operation_id, - method: method.as_str(), - path: path.clone(), - summary, - tags, - }); - } - } - - operations.sort_by(|a, b| { - a.path - .cmp(&b.path) - .then_with(|| a.method.cmp(b.method)) - .then_with(|| a.operation_id.cmp(&b.operation_id)) - }); - - Ok(operations) -} - -fn inspect_operation( - spec: &Value, - operation: &str, - path: Option<&str>, - full: bool, -) -> Result { - let paths = spec - .get("paths") - .and_then(Value::as_object) - .ok_or(ApiCommandError::MissingPaths)?; - - let operation_method = match operation.to_lowercase().as_str() { - "get" => Some(HttpMethod::Get), - "post" => Some(HttpMethod::Post), - "put" => Some(HttpMethod::Put), - "patch" => Some(HttpMethod::Patch), - "delete" => Some(HttpMethod::Delete), - _ => None, - }; - - if let (Some(method), Some(path)) = (operation_method, path) { - let operation = paths - .get(path) - .and_then(|path_item| path_item.get(method.openapi_key())) - .ok_or_else(|| { - ApiCommandError::OperationNotFound(format!("{} {}", method.as_str(), path)) - })?; - let operation_id = operation - .get("operationId") - .and_then(Value::as_str) - .map_or_else(|| synthetic_operation_id(method, path), ToString::to_string); - return Ok(operation_manifest( - operation_id, - method, - path, - operation, - full, - )); - } - - for (candidate_path, path_item) in paths { - let Some(path_item) = path_item.as_object() else { - continue; - }; - - for method in [ - HttpMethod::Get, - HttpMethod::Post, - HttpMethod::Put, - HttpMethod::Patch, - HttpMethod::Delete, - ] { - let Some(candidate) = path_item.get(method.openapi_key()) else { - continue; - }; - let candidate_id = candidate - .get("operationId") - .and_then(Value::as_str) - .map_or_else( - || synthetic_operation_id(method, candidate_path), - ToString::to_string, - ); - if candidate_id == operation { - return Ok(operation_manifest( - candidate_id, - method, - candidate_path, - candidate, - full, - )); - } - } - } - - Err(ApiCommandError::OperationNotFound(operation.to_string())) -} - -fn operation_manifest( - operation_id: String, - method: HttpMethod, - path: &str, - operation: &Value, - full: bool, -) -> Value { - let workflow_alternatives = workflow_alternatives(method, path); - let raw_api_use = raw_api_use(method, path, operation, !workflow_alternatives.is_empty()); - let mut manifest = json!({ - "operation_id": operation_id, - "method": method.as_str(), - "path": path, - "summary": operation.get("summary").and_then(Value::as_str), - "description": operation.get("description").and_then(Value::as_str), - "auth": operation_auth_metadata(method, path, operation), - "workflow_alternatives": workflow_alternatives, - "raw_api_use": raw_api_use, - "parameters": operation_parameters(operation), - "path_params": named_parameters(operation, "path", false), - "required_query": named_parameters(operation, "query", true), - "request_body": request_body_manifest(operation), - "body_fields": body_fields(operation), - "body_variants": body_variants(operation), - "required_body_fields": required_body_fields(operation), - "body_template": openapi_body_template(operation), - "input_placeholders": operation_input_placeholders(path, operation), - "response_statuses": response_statuses(operation), - "example_call": example_call(method, path, operation), - }); - - if full && let Some(object) = manifest.as_object_mut() { - object.insert("operation".to_string(), operation.clone()); - } - - manifest -} - -fn workflow_alternatives(method: HttpMethod, path: &str) -> Vec { - let mut alternatives = manifest_workflow_alternatives(method, path); - alternatives.extend(special_workflow_alternatives(method, path)); - alternatives -} - -fn manifest_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { - let Some(commands) = api_manifest() - .get("commands") - .and_then(Value::as_array) - .cloned() - else { - return Vec::new(); - }; - - let mut alternatives = Vec::new(); - for command in commands { - let Some(command_text) = command.get("command").and_then(Value::as_str) else { - continue; - }; - if command_text.starts_with("pcl api ") { - continue; - } - let workflow = command_text - .split_whitespace() - .nth(1) - .unwrap_or(command_text) - .to_string(); - let Some(actions) = command.get("actions").and_then(Value::as_array) else { - continue; - }; - - for action in actions { - if !manifest_action_matches_operation(action, method, path) { - continue; - } - let action_name = action.get("name").and_then(Value::as_str); - let example = workflow_example_for_operation(&workflow, action.get("example"), path); - alternatives.push(json!({ - "workflow": workflow, - "action": action_name, - "command": command_text, - "example": example, - "required_flags": action.get("required_flags").cloned().unwrap_or(Value::Null), - "body_template": action.get("body_template").cloned().unwrap_or(Value::Null), - })); - } - } - - alternatives -} - -fn workflow_example_for_operation( - workflow: &str, - example: Option<&Value>, - operation_path: &str, -) -> Option { - let example = example.and_then(Value::as_str)?; - if workflow == "integrations" { - if operation_path.contains("/integrations/pagerduty") { - return Some(example.replace("--provider slack", "--provider pagerduty")); - } - if operation_path.contains("/integrations/slack") { - return Some(example.replace("--provider pagerduty", "--provider slack")); - } - } - Some(example.to_string()) -} - -fn manifest_action_matches_operation(action: &Value, method: HttpMethod, path: &str) -> bool { - action - .get("method") - .and_then(Value::as_str) - .is_some_and(|action_method| action_method.eq_ignore_ascii_case(method.as_str())) - && action - .get("path") - .and_then(Value::as_str) - .is_some_and(|action_path| path_patterns_overlap(action_path, path)) -} - -fn path_patterns_overlap(left: &str, right: &str) -> bool { - openapi_path_matches(left, right) || openapi_path_matches(right, left) -} - -fn special_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { - let normalized_path = normalize_path_placeholders(path); - match (method, normalized_path.as_str()) { - (HttpMethod::Get, "/cli/auth/code") => { - single_special_workflow( - "auth", - "login_challenge", - "pcl auth login --no-wait --force --toon", - "Device-login challenge is exposed as a structured auth command.", - ) - } - (HttpMethod::Get, "/cli/auth/status") => { - single_special_workflow( - "auth", - "poll", - "pcl auth poll --session-id --device-secret --expires-at --toon", - "Polling is handled by the auth command returned in data.poll_command.", - ) - } - (HttpMethod::Post, "/cli/auth/verify") => { - single_special_workflow( - "auth", - "verify", - "pcl auth login --force --toon", - "The login command owns verification and stores the resulting credentials.", - ) - } - (HttpMethod::Post, "/auth/refresh") => { - single_special_workflow( - "auth", - "refresh", - "pcl auth refresh --toon", - "Refresh rotation is exposed as a structured auth command.", - ) - } - (HttpMethod::Get, "/openapi") => { - single_special_workflow( - "api", - "manifest", - "pcl api manifest --toon", - "Use the CLI manifest/list/inspect surfaces for discovery instead of raw OpenAPI retrieval.", - ) - } - (HttpMethod::Get, "/projects") => { - single_special_workflow( - "projects", - "explorer", - "pcl projects --limit 10", - "Project exploration uses the normalized project view endpoint.", - ) - } - (HttpMethod::Get, "/public/incidents") => { - single_special_workflow( - "incidents", - "list_public", - "pcl incidents --limit 5", - "Public incident listing uses the normalized incident view endpoint.", - ) - } - (HttpMethod::Get, "/projects/{}/incidents") => { - single_special_workflow( - "incidents", - "list_project", - "pcl incidents --project --limit 50", - "Project incident listing uses the normalized incident view endpoint.", - ) - } - (HttpMethod::Get, "/incidents/{}") => { - single_special_workflow( - "incidents", - "detail", - "pcl incidents --incident-id ", - "Incident detail uses the normalized incident view endpoint.", - ) - } - (HttpMethod::Get, "/incidents/{}/transactions/{}/trace") => { - single_special_workflow( - "incidents", - "trace", - "pcl incidents --incident-id --tx-id ", - "Incident traces use the normalized incident view endpoint.", - ) - } - (HttpMethod::Get, "/projects/{}/submitted-assertions") => { - vec![ - special_workflow( - "releases", - "list", - "pcl releases --project ", - "Submitted assertions were superseded by release and registered-assertion workflows.", - ), - special_workflow( - "assertions", - "registered", - "pcl assertions --project --registered", - "Submitted assertions were superseded by release and registered-assertion workflows.", - ), - ] - } - (HttpMethod::Post, "/projects/{}/submitted-assertions") => { - single_special_workflow( - "releases", - "create", - "pcl apply --json", - "Submitting assertions is now represented by creating a release through pcl apply or pcl releases.", - ) - } - _ => Vec::new(), - } -} - -fn single_special_workflow(workflow: &str, action: &str, example: &str, note: &str) -> Vec { - vec![special_workflow(workflow, action, example, note)] -} - -fn special_workflow(workflow: &str, action: &str, example: &str, note: &str) -> Value { - json!({ - "workflow": workflow, - "action": action, - "example": example, - "note": note, - }) -} - -fn normalize_path_placeholders(path: &str) -> String { - path.split('/') - .map(|segment| { - if segment.starts_with('{') && segment.ends_with('}') { - "{}" - } else { - segment - } - }) - .collect::>() - .join("/") -} - -fn raw_api_use( - method: HttpMethod, - path: &str, - operation: &Value, - has_workflow_alternative: bool, -) -> Value { - if has_workflow_alternative { - return json!({ - "policy": "prefer_workflow", - "reason": "A first-class CLI workflow exists. Use raw api call only for debugging, OpenAPI parity checks, or reproducing low-level API behavior.", - }); - } - if service_api_key_raw_call_path(method, path) { - return json!({ - "policy": "internal_service", - "reason": "Service callback endpoint; normal CLI users and agents should not call it directly.", - }); - } - if requires_browser_session_token(path, operation) { - return json!({ - "policy": "browser_session_bridge", - "reason": "Browser/Privy session bridge; use auth/account commands for CLI authentication state.", - }); - } - - json!({ - "policy": "debug_escape_hatch", - "reason": "No first-class workflow is advertised; inspect first and preserve request IDs when using raw calls.", - }) -} - -fn operation_parameters(operation: &Value) -> Vec { - operation - .get("parameters") - .and_then(Value::as_array) - .map(|parameters| { - parameters - .iter() - .map(|parameter| { - json!({ - "name": parameter.get("name").and_then(Value::as_str), - "in": parameter.get("in").and_then(Value::as_str), - "required": parameter.get("required").and_then(Value::as_bool).unwrap_or(false), - "schema": parameter.get("schema").cloned().unwrap_or(Value::Null), - }) - }) - .collect() - }) - .unwrap_or_default() -} - -fn named_parameters(operation: &Value, location: &str, required_only: bool) -> Vec { - operation - .get("parameters") - .and_then(Value::as_array) - .map(|parameters| { - parameters - .iter() - .filter(|parameter| parameter.get("in").and_then(Value::as_str) == Some(location)) - .filter(|parameter| { - !required_only - || parameter - .get("required") - .and_then(Value::as_bool) - .unwrap_or(false) - }) - .filter_map(|parameter| parameter.get("name").and_then(Value::as_str)) - .map(ToString::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn request_body_manifest(operation: &Value) -> Value { - let Some(body) = operation.get("requestBody") else { - return Value::Null; - }; - json!({ - "required": body.get("required").and_then(Value::as_bool).unwrap_or(false), - "content_types": body - .get("content") - .and_then(Value::as_object) - .map(|content| content.keys().cloned().collect::>()) - .unwrap_or_default(), - "schema_type": body - .pointer("/content/application~1json/schema") - .map_or_else(|| "unknown".to_string(), compact_schema_type), - }) -} - -fn body_schema(operation: &Value) -> Option<&Value> { - operation.pointer("/requestBody/content/application~1json/schema") -} - -fn required_body_fields(operation: &Value) -> Vec { - body_schema(operation) - .map(required_fields_for_schema) - .unwrap_or_default() -} - -fn required_fields_for_schema(schema: &Value) -> Vec { - schema - .get("required") - .and_then(Value::as_array) - .map(|required| { - required - .iter() - .filter_map(Value::as_str) - .map(ToString::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn body_fields(operation: &Value) -> Vec { - body_schema(operation) - .map(body_fields_for_schema) - .unwrap_or_default() -} - -fn body_fields_for_schema(schema: &Value) -> Vec { - let required = required_fields_for_schema(schema); - schema - .get("properties") - .and_then(Value::as_object) - .map(|properties| { - properties - .iter() - .map(|(name, schema)| { - json!({ - "name": name, - "required": required.iter().any(|required| required == name), - "type": compact_schema_type(schema), - "enum": schema.get("enum").cloned().unwrap_or(Value::Null), - "const": schema.get("const").cloned().unwrap_or(Value::Null), - }) - }) - .collect() - }) - .unwrap_or_default() -} - -fn body_variants(operation: &Value) -> Vec { - let Some(schema) = body_schema(operation) else { - return Vec::new(); - }; - let Some(variants) = schema - .get("oneOf") - .or_else(|| schema.get("anyOf")) +fn write_jsonl_items_output_file(path: &PathBuf, value: &Value) -> Result<(), ApiCommandError> { + let items = value + .get("items") .and_then(Value::as_array) - else { - return Vec::new(); - }; - - variants - .iter() - .enumerate() - .map(|(index, variant)| { - json!({ - "name": schema_variant_name(variant, index), - "schema_type": compact_schema_type(variant), - "required_body_fields": required_fields_for_schema(variant), - "body_fields": body_fields_for_schema(variant), - "body_template": template_from_schema(variant), - }) - }) - .collect() -} - -fn schema_variant_name(schema: &Value, index: usize) -> String { - schema - .pointer("/properties/mode/const") - .or_else(|| schema.pointer("/properties/mode/enum/0")) - .and_then(Value::as_str) - .map_or_else(|| format!("variant_{}", index + 1), ToString::to_string) -} - -fn compact_schema_type(schema: &Value) -> String { - if let Some(schema_type) = schema.get("type").and_then(Value::as_str) { - return schema_type.to_string(); - } - if schema.get("oneOf").is_some() { - return "oneOf".to_string(); - } - if schema.get("anyOf").is_some() { - return "anyOf".to_string(); - } - "unknown".to_string() -} - -fn openapi_body_template(operation: &Value) -> Value { - let Some(schema) = body_schema(operation) else { - return Value::Null; - }; - template_from_schema(schema) -} - -fn template_from_schema(schema: &Value) -> Value { - match schema.get("type").and_then(Value::as_str) { - Some("object") => { - let mut object = Map::new(); - if let Some(properties) = schema.get("properties").and_then(Value::as_object) { - for (name, property) in properties { - object.insert(name.clone(), template_from_schema(property)); - } - } - Value::Object(object) - } - Some("array") => { - Value::Array(vec![ - schema - .get("items") - .map_or(Value::String("".to_string()), template_from_schema), - ]) - } - Some("integer") | Some("number") => json!(0), - Some("boolean") => json!(false), - Some("string") => { - if let Some(value) = schema.get("const") { - return value.clone(); - } - schema - .get("enum") - .and_then(Value::as_array) - .and_then(|values| values.first()) - .cloned() - .unwrap_or_else(|| Value::String("".to_string())) - } - _ => { - if let Some(options) = schema.get("oneOf").and_then(Value::as_array) { - return options - .first() - .map_or(Value::String("".to_string()), template_from_schema); + .ok_or_else(|| { + ApiCommandError::InvalidWorkflow { + message: "--jsonl output requires paginated data with an items array".to_string(), } - Value::String("".to_string()) - } - } -} - -fn response_statuses(operation: &Value) -> Vec { - operation - .get("responses") - .and_then(Value::as_object) - .map(|responses| { - responses - .iter() - .map(|(status, response)| { - json!({ - "status": status, - "description": response.get("description").and_then(Value::as_str), - }) - }) - .collect() - }) - .unwrap_or_default() -} - -fn example_call(method: HttpMethod, path: &str, operation: &Value) -> String { - let path = example_path(path, operation); - let mut command = format!( - "pcl api call {} {}", - method.openapi_key(), - shell_quote_path(&path) - ); - if should_allow_unauthenticated_raw_call(method, &path, operation) { - command.push_str(" --allow-unauthenticated"); - } - if service_api_key_raw_call_path(method, &path) { - command.push_str(" --header 'x-api-key='"); - } - for parameter in required_header_parameters(operation) { - if service_api_key_raw_call_path(method, &path) - && parameter.eq_ignore_ascii_case("x-api-key") - { - continue; - } - write!( - command, - " --header {}", - shell_quote(&format!( - "{parameter}={}", - header_placeholder(¶meter, &path, operation) - )) - ) - .expect("writing to String cannot fail"); - } - for parameter in required_query_parameters(operation) { - write!( - command, - " --query {}", - shell_quote(&format!("{parameter}=<{parameter}>")) - ) - .expect("writing to String cannot fail"); + })?; + let mut body = String::new(); + for item in items { + body.push_str(&serde_json::to_string(item)?); + body.push('\n'); } - if operation.get("requestBody").is_some() { - let body = openapi_body_template(operation); - if body.is_null() { - command.push_str(" --body '{}'"); - } else { - let body = serde_json::to_string(&body).unwrap_or_else(|_| "{...}".to_string()); - write!(command, " --body {}", shell_quote(&body)) - .expect("writing to String cannot fail"); + fs::write(path, body).map_err(|source| { + ApiCommandError::OutputFile { + path: path.clone(), + source, } - } - command -} - -fn operation_auth_metadata(method: HttpMethod, path: &str, operation: &Value) -> Value { - let required_headers = required_header_parameters(operation); - let browser_token_required = requires_browser_session_token(path, operation); - let service_api_key_required = service_api_key_raw_call_path(method, path); - let stored_cli_auth = - !should_allow_unauthenticated_raw_call(method, path, operation) && !browser_token_required; - let mut notes = Vec::new(); - if browser_token_required { - notes.push( - "Requires a browser/Privy session bearer token supplied with --header authorization=Bearer .", - ); - } - if stored_cli_auth { - notes.push( - "PCL attaches the stored CLI bearer token unless --allow-unauthenticated is set.", - ); - } - if service_api_key_required { - notes.push("Requires a service API key supplied with --header x-api-key=."); - } - json!({ - "stored_cli_auth": stored_cli_auth, - "allow_unauthenticated_example": should_allow_unauthenticated_raw_call(method, path, operation), - "browser_session_token_required": browser_token_required, - "service_api_key_required": service_api_key_required, - "required_headers": required_headers, - "notes": notes, }) } -fn requires_browser_session_token(path: &str, operation: &Value) -> bool { - path == "/web/auth/bootstrap-session" && has_required_authorization_parameter(operation) -} - -fn header_placeholder(parameter: &str, path: &str, operation: &Value) -> String { - if parameter.eq_ignore_ascii_case("authorization") { - if requires_browser_session_token(path, operation) { - return "Bearer ".to_string(); - } - return "Bearer ".to_string(); - } - format!("<{parameter}>") -} - -fn should_allow_unauthenticated_raw_call( - method: HttpMethod, - path: &str, - operation: &Value, -) -> bool { - service_api_key_raw_call_path(method, path) - || (public_raw_call_path(method, path) && !has_required_authorization_parameter(operation)) -} - -fn service_api_key_raw_call_path(method: HttpMethod, path: &str) -> bool { - method == HttpMethod::Post - && (path.starts_with("/enforcer/") - || path.starts_with("/indexer/") - || path.starts_with("/tracer/") - || path.starts_with("/backtesting/")) -} - -fn public_raw_call_path(method: HttpMethod, path: &str) -> bool { - match method { - HttpMethod::Get => { - path == "/health" - || path == "/cli/auth/code" - || path == "/cli/auth/status" - || path == "/openapi" - || path == "/projects" - || path == "/public/incidents" - || path == "/stats" - || path == "/system-status" - || path == "/search" - || path == "/assertions" - || path == "/views/projects" - || path.starts_with("/views/public/") - || path.starts_with("/projects/resolve/") - || path.starts_with("/web/verified-contract") - || (path.starts_with("/invitations/") && path.ends_with("/preview")) - } - HttpMethod::Post => path == "/auth/refresh" || path == "/cli/auth/verify", - HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => false, - } -} - -fn has_required_authorization_parameter(operation: &Value) -> bool { - required_header_parameters(operation) - .iter() - .any(|name| name.eq_ignore_ascii_case("authorization")) -} - -fn example_path(path: &str, operation: &Value) -> String { - let mut path = path.to_string(); - for parameter in named_parameters(operation, "path", false) { - path = path.replace(&format!("{{{parameter}}}"), &format!("<{parameter}>")); - } - path -} - -fn shell_quote_path(path: &str) -> String { - if path.contains('<') || path.contains('>') { - shell_quote(path) - } else { - path.to_string() - } -} - -fn shell_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - -fn operation_input_placeholders(path: &str, operation: &Value) -> Vec { - let mut placeholders = named_parameters(operation, "path", false) - .into_iter() - .map(|parameter| format!("path:{parameter}")) - .collect::>(); - placeholders.extend( - required_header_parameters(operation) - .into_iter() - .map(|parameter| format!("header:{parameter}")), - ); - placeholders.extend( - required_query_parameters(operation) - .into_iter() - .map(|parameter| format!("query:{parameter}")), - ); - if operation.get("requestBody").is_some() { - placeholders.push("body".to_string()); - } - if placeholders.is_empty() && path.contains('{') { - placeholders.push("path".to_string()); - } - placeholders -} - -fn required_header_parameters(operation: &Value) -> Vec { - named_parameters(operation, "header", true) -} - -fn required_query_parameters(operation: &Value) -> Vec { - named_parameters(operation, "query", true) -} - -fn next_actions_for_operations(operations: &[OperationSummary]) -> Vec { - operations.first().map_or_else( - || vec!["pcl api list".to_string(), "pcl api manifest".to_string()], - |operation| { - if let Some(example) = operation - .workflow_alternatives - .first() - .and_then(|alternative| alternative.get("example")) - .and_then(Value::as_str) - { - return vec![ - example.to_string(), - format!("{} --toon", operation.inspect_command), - ]; - } - if operation.requires_input { - vec![ - format!("{} --toon", operation.inspect_command), - "Inspect the operation, then fill the placeholders in the example call" - .to_string(), - ] - } else { - vec![ - operation.inspect_command.clone(), - operation.call_command.clone(), - ] - } - }, - ) -} - -fn command_next_actions(inspected: &Value) -> Vec { - if let Some(example) = inspected - .get("workflow_alternatives") - .and_then(Value::as_array) - .and_then(|alternatives| alternatives.first()) - .and_then(|alternative| alternative.get("example")) - .and_then(Value::as_str) - { - return vec![example.to_string()]; - } - inspected - .get("example_call") - .and_then(Value::as_str) - .map_or_else( - || vec!["pcl api list".to_string()], - |command| vec![command.to_string()], - ) -} - -fn synthetic_operation_id(method: HttpMethod, path: &str) -> String { - let mut id = method.openapi_key().to_string(); - let mut previous_was_separator = false; - - for ch in path.chars() { - if ch.is_ascii_alphanumeric() { - if previous_was_separator && !id.ends_with('_') { - id.push('_'); - } - id.push(ch.to_ascii_lowercase()); - previous_was_separator = false; - } else { - previous_was_separator = true; - } - } - - id.trim_end_matches('_').to_string() -} - #[cfg(test)] mod tests; diff --git a/crates/pcl/core/src/api/openapi.rs b/crates/pcl/core/src/api/openapi.rs new file mode 100644 index 0000000..1a1dea8 --- /dev/null +++ b/crates/pcl/core/src/api/openapi.rs @@ -0,0 +1,1342 @@ +use super::{ + ApiCommandError, + HttpMethod, + api_manifest, + method_side_effecting, +}; +use serde::Serialize; +use serde_json::{ + Map, + Value, + json, +}; +use std::{ + collections::BTreeMap, + fmt::Write as _, + fs, + path::{ + Path, + PathBuf, + }, +}; + +#[derive(Debug, Serialize)] +pub(super) struct OperationSummary { + pub(super) operation_id: String, + pub(super) method: &'static str, + pub(super) path: String, + pub(super) summary: Option, + pub(super) tags: Vec, + pub(super) auth: Value, + pub(super) workflow_alternatives: Vec, + pub(super) raw_api_use: Value, + pub(super) inspect_command: String, + pub(super) call_command: String, + pub(super) input_placeholders: Vec, + pub(super) requires_input: bool, +} + +#[derive(Clone, Debug)] +struct OperationCoverage { + operation_id: String, + method: String, + path: String, + hit: u64, + ok: u64, + statuses: BTreeMap, + latest_request_id: Option, + latest_status: Option, + latest_timestamp: Option, + latest_kind: Option, +} + +impl OperationCoverage { + fn new(operation: &OperationSummary) -> Self { + Self { + operation_id: operation.operation_id.clone(), + method: operation.method.to_string(), + path: operation.path.clone(), + hit: 0, + ok: 0, + statuses: BTreeMap::new(), + latest_request_id: None, + latest_status: None, + latest_timestamp: None, + latest_kind: None, + } + } + + fn record_hit(&mut self, record: &Value) { + let status = record.get("status").and_then(Value::as_u64); + self.hit += 1; + if status.is_some_and(|status| (200..=299).contains(&status)) { + self.ok += 1; + } + if let Some(status) = status { + *self.statuses.entry(status.to_string()).or_insert(0) += 1; + } + self.latest_status = status; + self.latest_request_id = record + .get("request_id") + .and_then(Value::as_str) + .map(ToString::to_string); + self.latest_timestamp = record + .get("timestamp") + .and_then(Value::as_str) + .map(ToString::to_string); + self.latest_kind = record + .get("kind") + .and_then(Value::as_str) + .map(ToString::to_string); + } + + fn success_2xx(&self) -> bool { + self.ok > 0 + } + + fn side_effecting(&self) -> bool { + method_side_effecting(&self.method) + } + + fn to_value(&self) -> Value { + json!({ + "operation_id": self.operation_id, + "method": self.method, + "path": self.path, + "hit": self.hit > 0, + "hits": self.hit, + "success_2xx": self.success_2xx(), + "ok": self.ok, + "statuses": self.statuses, + "side_effecting": self.side_effecting(), + "latest_request_id": self.latest_request_id, + "latest_status": self.latest_status, + "latest_timestamp": self.latest_timestamp, + "latest_kind": self.latest_kind, + }) + } +} + +pub(super) fn api_coverage( + spec: &Value, + request_log_path: &Path, + record_limit: usize, + api_url: &str, +) -> Result { + let operations = list_operations(spec, None, None)?; + let records = crate::request_log::read_request_records_at(request_log_path, record_limit) + .map_err(|source| { + ApiCommandError::RequestLog { + path: request_log_path.to_path_buf(), + source, + } + })?; + let mut coverage = operations + .iter() + .map(OperationCoverage::new) + .collect::>(); + let mut unmatched_records = Vec::new(); + + for record in &records { + let Some(index) = match_request_record_to_operation(&operations, record) else { + unmatched_records.push(record.clone()); + continue; + }; + coverage[index].record_hit(record); + } + + let mut by_method: BTreeMap> = BTreeMap::new(); + for entry in &coverage { + let method = by_method.entry(entry.method.clone()).or_default(); + *method.entry("total").or_insert(0) += 1; + if entry.hit > 0 { + *method.entry("hit").or_insert(0) += 1; + } + if entry.success_2xx() { + *method.entry("ok").or_insert(0) += 1; + } + } + + let no_hit = coverage + .iter() + .filter(|entry| entry.hit == 0) + .map(OperationCoverage::to_value) + .collect::>(); + let no_2xx = coverage + .iter() + .filter(|entry| entry.hit > 0 && !entry.success_2xx()) + .map(OperationCoverage::to_value) + .collect::>(); + let write_no_2xx = coverage + .iter() + .filter(|entry| entry.side_effecting() && entry.hit > 0 && !entry.success_2xx()) + .map(OperationCoverage::to_value) + .collect::>(); + let operations_value = coverage + .iter() + .map(OperationCoverage::to_value) + .collect::>(); + + Ok(json!({ + "generated_at": chrono::Utc::now().to_rfc3339(), + "api_url": api_url, + "request_log": request_log_path, + "records_considered": records.len(), + "record_limit": record_limit, + "total_operations": operations.len(), + "by_method": by_method, + "no_hit_count": no_hit.len(), + "no_2xx_count": no_2xx.len(), + "write_no_2xx_count": write_no_2xx.len(), + "unmatched_record_count": unmatched_records.len(), + "no_hit": no_hit, + "no_2xx": no_2xx, + "write_no_2xx": write_no_2xx, + "unmatched_records": unmatched_records, + "operations": operations_value, + })) +} + +pub(super) fn write_api_coverage_markdown( + path: &PathBuf, + coverage: &Value, +) -> Result<(), ApiCommandError> { + let markdown = api_coverage_markdown(coverage); + fs::write(path, markdown).map_err(|source| { + ApiCommandError::OutputFile { + path: path.clone(), + source, + } + }) +} + +pub(super) fn api_coverage_markdown(coverage: &Value) -> String { + let mut body = String::new(); + writeln!(body, "# PCL API Coverage").expect("writing to String cannot fail"); + writeln!(body).expect("writing to String cannot fail"); + writeln!( + body, + "- Generated: {}", + coverage + .get("generated_at") + .and_then(Value::as_str) + .unwrap_or("unknown") + ) + .expect("writing to String cannot fail"); + writeln!( + body, + "- API URL: {}", + coverage + .get("api_url") + .and_then(Value::as_str) + .unwrap_or("unknown") + ) + .expect("writing to String cannot fail"); + writeln!( + body, + "- Request log: {}", + coverage + .get("request_log") + .and_then(Value::as_str) + .unwrap_or("unknown") + ) + .expect("writing to String cannot fail"); + writeln!( + body, + "- Operations: {}", + coverage + .get("total_operations") + .and_then(Value::as_u64) + .unwrap_or(0) + ) + .expect("writing to String cannot fail"); + writeln!( + body, + "- Records considered: {}", + coverage + .get("records_considered") + .and_then(Value::as_u64) + .unwrap_or(0) + ) + .expect("writing to String cannot fail"); + writeln!( + body, + "- No-hit operations: {}", + coverage + .get("no_hit_count") + .and_then(Value::as_u64) + .unwrap_or(0) + ) + .expect("writing to String cannot fail"); + writeln!( + body, + "- Hit but no 2xx operations: {}", + coverage + .get("no_2xx_count") + .and_then(Value::as_u64) + .unwrap_or(0) + ) + .expect("writing to String cannot fail"); + writeln!(body).expect("writing to String cannot fail"); + + append_coverage_table(&mut body, "No-Hit Operations", coverage.get("no_hit")); + append_coverage_table( + &mut body, + "Hit But No 2xx Operations", + coverage.get("no_2xx"), + ); + append_coverage_table( + &mut body, + "Side-Effecting Operations Hit But No 2xx", + coverage.get("write_no_2xx"), + ); + body +} + +fn append_coverage_table(body: &mut String, title: &str, value: Option<&Value>) { + writeln!(body, "## {title}").expect("writing to String cannot fail"); + writeln!(body).expect("writing to String cannot fail"); + let Some(entries) = value.and_then(Value::as_array) else { + writeln!(body, "None.").expect("writing to String cannot fail"); + writeln!(body).expect("writing to String cannot fail"); + return; + }; + if entries.is_empty() { + writeln!(body, "None.").expect("writing to String cannot fail"); + writeln!(body).expect("writing to String cannot fail"); + return; + } + writeln!( + body, + "| Operation | Method | Path | Hits | Statuses | Latest Request |" + ) + .expect("writing to String cannot fail"); + writeln!(body, "| --- | --- | --- | ---: | --- | --- |") + .expect("writing to String cannot fail"); + for entry in entries { + writeln!( + body, + "| `{}` | `{}` | `{}` | {} | `{}` | `{}` |", + entry + .get("operation_id") + .and_then(Value::as_str) + .unwrap_or("unknown"), + entry + .get("method") + .and_then(Value::as_str) + .unwrap_or("unknown"), + entry + .get("path") + .and_then(Value::as_str) + .unwrap_or("unknown"), + entry.get("hits").and_then(Value::as_u64).unwrap_or(0), + entry + .get("statuses") + .map_or_else(|| "{}".to_string(), Value::to_string), + entry + .get("latest_request_id") + .and_then(Value::as_str) + .unwrap_or("") + ) + .expect("writing to String cannot fail"); + } + writeln!(body).expect("writing to String cannot fail"); +} + +fn match_request_record_to_operation( + operations: &[OperationSummary], + record: &Value, +) -> Option { + if let Some(operation_id) = record.get("operation_id").and_then(Value::as_str) + && let Some(index) = operations + .iter() + .position(|operation| operation.operation_id == operation_id) + { + return Some(index); + } + + let method = record.get("method").and_then(Value::as_str)?; + let path = record.get("path").and_then(Value::as_str)?; + let path = path.split_once('?').map_or(path, |(path, _)| path); + + operations.iter().position(|operation| { + operation.method.eq_ignore_ascii_case(method) && openapi_path_matches(&operation.path, path) + }) +} + +pub(super) fn openapi_path_matches(openapi_path: &str, observed_path: &str) -> bool { + if openapi_path == observed_path { + return true; + } + let openapi_segments = openapi_path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + let observed_segments = observed_path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + if openapi_segments.len() != observed_segments.len() { + return false; + } + openapi_segments + .iter() + .zip(observed_segments) + .all(|(expected, observed)| { + (expected.starts_with('{') && expected.ends_with('}')) || *expected == observed + }) +} + +pub(super) fn list_operations( + spec: &Value, + filter: Option<&str>, + method_filter: Option, +) -> Result, ApiCommandError> { + let paths = spec + .get("paths") + .and_then(Value::as_object) + .ok_or(ApiCommandError::MissingPaths)?; + let filter = filter.map(str::to_lowercase); + let mut operations = Vec::new(); + + for (path, path_item) in paths { + let Some(path_item) = path_item.as_object() else { + continue; + }; + + for method in [ + HttpMethod::Get, + HttpMethod::Post, + HttpMethod::Put, + HttpMethod::Patch, + HttpMethod::Delete, + ] { + if method_filter.is_some_and(|wanted| wanted.openapi_key() != method.openapi_key()) { + continue; + } + + let Some(operation) = path_item.get(method.openapi_key()) else { + continue; + }; + + let operation_id = operation + .get("operationId") + .and_then(Value::as_str) + .map_or_else(|| synthetic_operation_id(method, path), ToString::to_string); + let summary = operation + .get("summary") + .and_then(Value::as_str) + .map(ToString::to_string); + let tags = operation + .get("tags") + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect::>() + }) + .unwrap_or_default(); + + if let Some(filter) = &filter { + let haystack = format!( + "{} {} {} {}", + operation_id, + path, + summary.as_deref().unwrap_or_default(), + tags.join(" ") + ) + .to_lowercase(); + if !haystack.contains(filter) { + continue; + } + } + + let input_placeholders = operation_input_placeholders(path, operation); + let requires_input = !input_placeholders.is_empty(); + let workflow_alternatives = workflow_alternatives(method, path); + let raw_api_use = + raw_api_use(method, path, operation, !workflow_alternatives.is_empty()); + operations.push(OperationSummary { + inspect_command: format!("pcl api inspect {operation_id}"), + call_command: example_call(method, path, operation), + input_placeholders, + requires_input, + auth: operation_auth_metadata(method, path, operation), + workflow_alternatives, + raw_api_use, + operation_id, + method: method.as_str(), + path: path.clone(), + summary, + tags, + }); + } + } + + operations.sort_by(|a, b| { + a.path + .cmp(&b.path) + .then_with(|| a.method.cmp(b.method)) + .then_with(|| a.operation_id.cmp(&b.operation_id)) + }); + + Ok(operations) +} + +pub(super) fn inspect_operation( + spec: &Value, + operation: &str, + path: Option<&str>, + full: bool, +) -> Result { + let paths = spec + .get("paths") + .and_then(Value::as_object) + .ok_or(ApiCommandError::MissingPaths)?; + + let operation_method = match operation.to_lowercase().as_str() { + "get" => Some(HttpMethod::Get), + "post" => Some(HttpMethod::Post), + "put" => Some(HttpMethod::Put), + "patch" => Some(HttpMethod::Patch), + "delete" => Some(HttpMethod::Delete), + _ => None, + }; + + if let (Some(method), Some(path)) = (operation_method, path) { + let operation = paths + .get(path) + .and_then(|path_item| path_item.get(method.openapi_key())) + .ok_or_else(|| { + ApiCommandError::OperationNotFound(format!("{} {}", method.as_str(), path)) + })?; + let operation_id = operation + .get("operationId") + .and_then(Value::as_str) + .map_or_else(|| synthetic_operation_id(method, path), ToString::to_string); + return Ok(operation_manifest( + operation_id, + method, + path, + operation, + full, + )); + } + + for (candidate_path, path_item) in paths { + let Some(path_item) = path_item.as_object() else { + continue; + }; + + for method in [ + HttpMethod::Get, + HttpMethod::Post, + HttpMethod::Put, + HttpMethod::Patch, + HttpMethod::Delete, + ] { + let Some(candidate) = path_item.get(method.openapi_key()) else { + continue; + }; + let candidate_id = candidate + .get("operationId") + .and_then(Value::as_str) + .map_or_else( + || synthetic_operation_id(method, candidate_path), + ToString::to_string, + ); + if candidate_id == operation { + return Ok(operation_manifest( + candidate_id, + method, + candidate_path, + candidate, + full, + )); + } + } + } + + Err(ApiCommandError::OperationNotFound(operation.to_string())) +} + +fn operation_manifest( + operation_id: String, + method: HttpMethod, + path: &str, + operation: &Value, + full: bool, +) -> Value { + let workflow_alternatives = workflow_alternatives(method, path); + let raw_api_use = raw_api_use(method, path, operation, !workflow_alternatives.is_empty()); + let mut manifest = json!({ + "operation_id": operation_id, + "method": method.as_str(), + "path": path, + "summary": operation.get("summary").and_then(Value::as_str), + "description": operation.get("description").and_then(Value::as_str), + "auth": operation_auth_metadata(method, path, operation), + "workflow_alternatives": workflow_alternatives, + "raw_api_use": raw_api_use, + "parameters": operation_parameters(operation), + "path_params": named_parameters(operation, "path", false), + "required_query": named_parameters(operation, "query", true), + "request_body": request_body_manifest(operation), + "body_fields": body_fields(operation), + "body_variants": body_variants(operation), + "required_body_fields": required_body_fields(operation), + "body_template": openapi_body_template(operation), + "input_placeholders": operation_input_placeholders(path, operation), + "response_statuses": response_statuses(operation), + "example_call": example_call(method, path, operation), + }); + + if full && let Some(object) = manifest.as_object_mut() { + object.insert("operation".to_string(), operation.clone()); + } + + manifest +} + +pub(super) fn workflow_alternatives(method: HttpMethod, path: &str) -> Vec { + let mut alternatives = manifest_workflow_alternatives(method, path); + alternatives.extend(special_workflow_alternatives(method, path)); + alternatives +} + +fn manifest_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { + let Some(commands) = api_manifest() + .get("commands") + .and_then(Value::as_array) + .cloned() + else { + return Vec::new(); + }; + + let mut alternatives = Vec::new(); + for command in commands { + let Some(command_text) = command.get("command").and_then(Value::as_str) else { + continue; + }; + if command_text.starts_with("pcl api ") { + continue; + } + let workflow = command_text + .split_whitespace() + .nth(1) + .unwrap_or(command_text) + .to_string(); + let Some(actions) = command.get("actions").and_then(Value::as_array) else { + continue; + }; + + for action in actions { + if !manifest_action_matches_operation(action, method, path) { + continue; + } + let action_name = action.get("name").and_then(Value::as_str); + let example = workflow_example_for_operation(&workflow, action.get("example"), path); + alternatives.push(json!({ + "workflow": workflow, + "action": action_name, + "command": command_text, + "example": example, + "required_flags": action.get("required_flags").cloned().unwrap_or(Value::Null), + "body_template": action.get("body_template").cloned().unwrap_or(Value::Null), + })); + } + } + + alternatives +} + +fn workflow_example_for_operation( + workflow: &str, + example: Option<&Value>, + operation_path: &str, +) -> Option { + let example = example.and_then(Value::as_str)?; + if workflow == "integrations" { + if operation_path.contains("/integrations/pagerduty") { + return Some(example.replace("--provider slack", "--provider pagerduty")); + } + if operation_path.contains("/integrations/slack") { + return Some(example.replace("--provider pagerduty", "--provider slack")); + } + } + Some(example.to_string()) +} + +fn manifest_action_matches_operation(action: &Value, method: HttpMethod, path: &str) -> bool { + action + .get("method") + .and_then(Value::as_str) + .is_some_and(|action_method| action_method.eq_ignore_ascii_case(method.as_str())) + && action + .get("path") + .and_then(Value::as_str) + .is_some_and(|action_path| path_patterns_overlap(action_path, path)) +} + +fn path_patterns_overlap(left: &str, right: &str) -> bool { + openapi_path_matches(left, right) || openapi_path_matches(right, left) +} + +fn special_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { + let normalized_path = normalize_path_placeholders(path); + match (method, normalized_path.as_str()) { + (HttpMethod::Get, "/cli/auth/code") => { + single_special_workflow( + "auth", + "login_challenge", + "pcl auth login --no-wait --force --toon", + "Device-login challenge is exposed as a structured auth command.", + ) + } + (HttpMethod::Get, "/cli/auth/status") => { + single_special_workflow( + "auth", + "poll", + "pcl auth poll --session-id --device-secret --expires-at --toon", + "Polling is handled by the auth command returned in data.poll_command.", + ) + } + (HttpMethod::Post, "/cli/auth/verify") => { + single_special_workflow( + "auth", + "verify", + "pcl auth login --force --toon", + "The login command owns verification and stores the resulting credentials.", + ) + } + (HttpMethod::Post, "/auth/refresh") => { + single_special_workflow( + "auth", + "refresh", + "pcl auth refresh --toon", + "Refresh rotation is exposed as a structured auth command.", + ) + } + (HttpMethod::Get, "/openapi") => { + single_special_workflow( + "api", + "manifest", + "pcl api manifest --toon", + "Use the CLI manifest/list/inspect surfaces for discovery instead of raw OpenAPI retrieval.", + ) + } + (HttpMethod::Get, "/projects") => { + single_special_workflow( + "projects", + "explorer", + "pcl projects --limit 10", + "Project exploration uses the normalized project view endpoint.", + ) + } + (HttpMethod::Get, "/public/incidents") => { + single_special_workflow( + "incidents", + "list_public", + "pcl incidents --limit 5", + "Public incident listing uses the normalized incident view endpoint.", + ) + } + (HttpMethod::Get, "/projects/{}/incidents") => { + single_special_workflow( + "incidents", + "list_project", + "pcl incidents --project --limit 50", + "Project incident listing uses the normalized incident view endpoint.", + ) + } + (HttpMethod::Get, "/incidents/{}") => { + single_special_workflow( + "incidents", + "detail", + "pcl incidents --incident-id ", + "Incident detail uses the normalized incident view endpoint.", + ) + } + (HttpMethod::Get, "/incidents/{}/transactions/{}/trace") => { + single_special_workflow( + "incidents", + "trace", + "pcl incidents --incident-id --tx-id ", + "Incident traces use the normalized incident view endpoint.", + ) + } + (HttpMethod::Get, "/projects/{}/submitted-assertions") => { + vec![ + special_workflow( + "releases", + "list", + "pcl releases --project ", + "Submitted assertions were superseded by release and registered-assertion workflows.", + ), + special_workflow( + "assertions", + "registered", + "pcl assertions --project --registered", + "Submitted assertions were superseded by release and registered-assertion workflows.", + ), + ] + } + (HttpMethod::Post, "/projects/{}/submitted-assertions") => { + single_special_workflow( + "releases", + "create", + "pcl apply --json", + "Submitting assertions is now represented by creating a release through pcl apply or pcl releases.", + ) + } + _ => Vec::new(), + } +} + +fn single_special_workflow(workflow: &str, action: &str, example: &str, note: &str) -> Vec { + vec![special_workflow(workflow, action, example, note)] +} + +fn special_workflow(workflow: &str, action: &str, example: &str, note: &str) -> Value { + json!({ + "workflow": workflow, + "action": action, + "example": example, + "note": note, + }) +} + +fn normalize_path_placeholders(path: &str) -> String { + path.split('/') + .map(|segment| { + if segment.starts_with('{') && segment.ends_with('}') { + "{}" + } else { + segment + } + }) + .collect::>() + .join("/") +} + +pub(super) fn raw_api_use( + method: HttpMethod, + path: &str, + operation: &Value, + has_workflow_alternative: bool, +) -> Value { + if has_workflow_alternative { + return json!({ + "policy": "prefer_workflow", + "reason": "A first-class CLI workflow exists. Use raw api call only for debugging, OpenAPI parity checks, or reproducing low-level API behavior.", + }); + } + if service_api_key_raw_call_path(method, path) { + return json!({ + "policy": "internal_service", + "reason": "Service callback endpoint; normal CLI users and agents should not call it directly.", + }); + } + if requires_browser_session_token(path, operation) { + return json!({ + "policy": "browser_session_bridge", + "reason": "Browser/Privy session bridge; use auth/account commands for CLI authentication state.", + }); + } + + json!({ + "policy": "debug_escape_hatch", + "reason": "No first-class workflow is advertised; inspect first and preserve request IDs when using raw calls.", + }) +} + +fn operation_parameters(operation: &Value) -> Vec { + operation + .get("parameters") + .and_then(Value::as_array) + .map(|parameters| { + parameters + .iter() + .map(|parameter| { + json!({ + "name": parameter.get("name").and_then(Value::as_str), + "in": parameter.get("in").and_then(Value::as_str), + "required": parameter.get("required").and_then(Value::as_bool).unwrap_or(false), + "schema": parameter.get("schema").cloned().unwrap_or(Value::Null), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn named_parameters(operation: &Value, location: &str, required_only: bool) -> Vec { + operation + .get("parameters") + .and_then(Value::as_array) + .map(|parameters| { + parameters + .iter() + .filter(|parameter| parameter.get("in").and_then(Value::as_str) == Some(location)) + .filter(|parameter| { + !required_only + || parameter + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .filter_map(|parameter| parameter.get("name").and_then(Value::as_str)) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn request_body_manifest(operation: &Value) -> Value { + let Some(body) = operation.get("requestBody") else { + return Value::Null; + }; + json!({ + "required": body.get("required").and_then(Value::as_bool).unwrap_or(false), + "content_types": body + .get("content") + .and_then(Value::as_object) + .map(|content| content.keys().cloned().collect::>()) + .unwrap_or_default(), + "schema_type": body + .pointer("/content/application~1json/schema") + .map_or_else(|| "unknown".to_string(), compact_schema_type), + }) +} + +fn body_schema(operation: &Value) -> Option<&Value> { + operation.pointer("/requestBody/content/application~1json/schema") +} + +pub(super) fn required_body_fields(operation: &Value) -> Vec { + body_schema(operation) + .map(required_fields_for_schema) + .unwrap_or_default() +} + +fn required_fields_for_schema(schema: &Value) -> Vec { + schema + .get("required") + .and_then(Value::as_array) + .map(|required| { + required + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() +} + +pub(super) fn body_fields(operation: &Value) -> Vec { + body_schema(operation) + .map(body_fields_for_schema) + .unwrap_or_default() +} + +fn body_fields_for_schema(schema: &Value) -> Vec { + let required = required_fields_for_schema(schema); + schema + .get("properties") + .and_then(Value::as_object) + .map(|properties| { + properties + .iter() + .map(|(name, schema)| { + json!({ + "name": name, + "required": required.iter().any(|required| required == name), + "type": compact_schema_type(schema), + "enum": schema.get("enum").cloned().unwrap_or(Value::Null), + "const": schema.get("const").cloned().unwrap_or(Value::Null), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +pub(super) fn body_variants(operation: &Value) -> Vec { + let Some(schema) = body_schema(operation) else { + return Vec::new(); + }; + let Some(variants) = schema + .get("oneOf") + .or_else(|| schema.get("anyOf")) + .and_then(Value::as_array) + else { + return Vec::new(); + }; + + variants + .iter() + .enumerate() + .map(|(index, variant)| { + json!({ + "name": schema_variant_name(variant, index), + "schema_type": compact_schema_type(variant), + "required_body_fields": required_fields_for_schema(variant), + "body_fields": body_fields_for_schema(variant), + "body_template": template_from_schema(variant), + }) + }) + .collect() +} + +fn schema_variant_name(schema: &Value, index: usize) -> String { + schema + .pointer("/properties/mode/const") + .or_else(|| schema.pointer("/properties/mode/enum/0")) + .and_then(Value::as_str) + .map_or_else(|| format!("variant_{}", index + 1), ToString::to_string) +} + +fn compact_schema_type(schema: &Value) -> String { + if let Some(schema_type) = schema.get("type").and_then(Value::as_str) { + return schema_type.to_string(); + } + if schema.get("oneOf").is_some() { + return "oneOf".to_string(); + } + if schema.get("anyOf").is_some() { + return "anyOf".to_string(); + } + "unknown".to_string() +} + +pub(super) fn openapi_body_template(operation: &Value) -> Value { + let Some(schema) = body_schema(operation) else { + return Value::Null; + }; + template_from_schema(schema) +} + +fn template_from_schema(schema: &Value) -> Value { + match schema.get("type").and_then(Value::as_str) { + Some("object") => { + let mut object = Map::new(); + if let Some(properties) = schema.get("properties").and_then(Value::as_object) { + for (name, property) in properties { + object.insert(name.clone(), template_from_schema(property)); + } + } + Value::Object(object) + } + Some("array") => { + Value::Array(vec![ + schema + .get("items") + .map_or(Value::String("".to_string()), template_from_schema), + ]) + } + Some("integer") | Some("number") => json!(0), + Some("boolean") => json!(false), + Some("string") => { + if let Some(value) = schema.get("const") { + return value.clone(); + } + schema + .get("enum") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .cloned() + .unwrap_or_else(|| Value::String("".to_string())) + } + _ => { + if let Some(options) = schema.get("oneOf").and_then(Value::as_array) { + return options + .first() + .map_or(Value::String("".to_string()), template_from_schema); + } + Value::String("".to_string()) + } + } +} + +fn response_statuses(operation: &Value) -> Vec { + operation + .get("responses") + .and_then(Value::as_object) + .map(|responses| { + responses + .iter() + .map(|(status, response)| { + json!({ + "status": status, + "description": response.get("description").and_then(Value::as_str), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +pub(super) fn example_call(method: HttpMethod, path: &str, operation: &Value) -> String { + let path = example_path(path, operation); + let mut command = format!( + "pcl api call {} {}", + method.openapi_key(), + shell_quote_path(&path) + ); + if should_allow_unauthenticated_raw_call(method, &path, operation) { + command.push_str(" --allow-unauthenticated"); + } + if service_api_key_raw_call_path(method, &path) { + command.push_str(" --header 'x-api-key='"); + } + for parameter in required_header_parameters(operation) { + if service_api_key_raw_call_path(method, &path) + && parameter.eq_ignore_ascii_case("x-api-key") + { + continue; + } + write!( + command, + " --header {}", + shell_quote(&format!( + "{parameter}={}", + header_placeholder(¶meter, &path, operation) + )) + ) + .expect("writing to String cannot fail"); + } + for parameter in required_query_parameters(operation) { + write!( + command, + " --query {}", + shell_quote(&format!("{parameter}=<{parameter}>")) + ) + .expect("writing to String cannot fail"); + } + if operation.get("requestBody").is_some() { + let body = openapi_body_template(operation); + if body.is_null() { + command.push_str(" --body '{}'"); + } else { + let body = serde_json::to_string(&body).unwrap_or_else(|_| "{...}".to_string()); + write!(command, " --body {}", shell_quote(&body)) + .expect("writing to String cannot fail"); + } + } + command +} + +pub(super) fn operation_auth_metadata(method: HttpMethod, path: &str, operation: &Value) -> Value { + let required_headers = required_header_parameters(operation); + let browser_token_required = requires_browser_session_token(path, operation); + let service_api_key_required = service_api_key_raw_call_path(method, path); + let stored_cli_auth = + !should_allow_unauthenticated_raw_call(method, path, operation) && !browser_token_required; + let mut notes = Vec::new(); + if browser_token_required { + notes.push( + "Requires a browser/Privy session bearer token supplied with --header authorization=Bearer .", + ); + } + if stored_cli_auth { + notes.push( + "PCL attaches the stored CLI bearer token unless --allow-unauthenticated is set.", + ); + } + if service_api_key_required { + notes.push("Requires a service API key supplied with --header x-api-key=."); + } + json!({ + "stored_cli_auth": stored_cli_auth, + "allow_unauthenticated_example": should_allow_unauthenticated_raw_call(method, path, operation), + "browser_session_token_required": browser_token_required, + "service_api_key_required": service_api_key_required, + "required_headers": required_headers, + "notes": notes, + }) +} + +fn requires_browser_session_token(path: &str, operation: &Value) -> bool { + path == "/web/auth/bootstrap-session" && has_required_authorization_parameter(operation) +} + +fn header_placeholder(parameter: &str, path: &str, operation: &Value) -> String { + if parameter.eq_ignore_ascii_case("authorization") { + if requires_browser_session_token(path, operation) { + return "Bearer ".to_string(); + } + return "Bearer ".to_string(); + } + format!("<{parameter}>") +} + +fn should_allow_unauthenticated_raw_call( + method: HttpMethod, + path: &str, + operation: &Value, +) -> bool { + service_api_key_raw_call_path(method, path) + || (public_raw_call_path(method, path) && !has_required_authorization_parameter(operation)) +} + +fn service_api_key_raw_call_path(method: HttpMethod, path: &str) -> bool { + method == HttpMethod::Post + && (path.starts_with("/enforcer/") + || path.starts_with("/indexer/") + || path.starts_with("/tracer/") + || path.starts_with("/backtesting/")) +} + +pub(super) fn public_raw_call_path(method: HttpMethod, path: &str) -> bool { + match method { + HttpMethod::Get => { + path == "/health" + || path == "/cli/auth/code" + || path == "/cli/auth/status" + || path == "/openapi" + || path == "/projects" + || path == "/public/incidents" + || path == "/stats" + || path == "/system-status" + || path == "/search" + || path == "/assertions" + || path == "/views/projects" + || path.starts_with("/views/public/") + || path.starts_with("/projects/resolve/") + || path.starts_with("/web/verified-contract") + || (path.starts_with("/invitations/") && path.ends_with("/preview")) + } + HttpMethod::Post => path == "/auth/refresh" || path == "/cli/auth/verify", + HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => false, + } +} + +fn has_required_authorization_parameter(operation: &Value) -> bool { + required_header_parameters(operation) + .iter() + .any(|name| name.eq_ignore_ascii_case("authorization")) +} + +fn example_path(path: &str, operation: &Value) -> String { + let mut path = path.to_string(); + for parameter in named_parameters(operation, "path", false) { + path = path.replace(&format!("{{{parameter}}}"), &format!("<{parameter}>")); + } + path +} + +fn shell_quote_path(path: &str) -> String { + if path.contains('<') || path.contains('>') { + shell_quote(path) + } else { + path.to_string() + } +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +pub(super) fn operation_input_placeholders(path: &str, operation: &Value) -> Vec { + let mut placeholders = named_parameters(operation, "path", false) + .into_iter() + .map(|parameter| format!("path:{parameter}")) + .collect::>(); + placeholders.extend( + required_header_parameters(operation) + .into_iter() + .map(|parameter| format!("header:{parameter}")), + ); + placeholders.extend( + required_query_parameters(operation) + .into_iter() + .map(|parameter| format!("query:{parameter}")), + ); + if operation.get("requestBody").is_some() { + placeholders.push("body".to_string()); + } + if placeholders.is_empty() && path.contains('{') { + placeholders.push("path".to_string()); + } + placeholders +} + +fn required_header_parameters(operation: &Value) -> Vec { + named_parameters(operation, "header", true) +} + +fn required_query_parameters(operation: &Value) -> Vec { + named_parameters(operation, "query", true) +} + +pub(super) fn next_actions_for_operations(operations: &[OperationSummary]) -> Vec { + operations.first().map_or_else( + || vec!["pcl api list".to_string(), "pcl api manifest".to_string()], + |operation| { + if let Some(example) = operation + .workflow_alternatives + .first() + .and_then(|alternative| alternative.get("example")) + .and_then(Value::as_str) + { + return vec![ + example.to_string(), + format!("{} --toon", operation.inspect_command), + ]; + } + if operation.requires_input { + vec![ + format!("{} --toon", operation.inspect_command), + "Inspect the operation, then fill the placeholders in the example call" + .to_string(), + ] + } else { + vec![ + operation.inspect_command.clone(), + operation.call_command.clone(), + ] + } + }, + ) +} + +pub(super) fn command_next_actions(inspected: &Value) -> Vec { + if let Some(example) = inspected + .get("workflow_alternatives") + .and_then(Value::as_array) + .and_then(|alternatives| alternatives.first()) + .and_then(|alternative| alternative.get("example")) + .and_then(Value::as_str) + { + return vec![example.to_string()]; + } + inspected + .get("example_call") + .and_then(Value::as_str) + .map_or_else( + || vec!["pcl api list".to_string()], + |command| vec![command.to_string()], + ) +} + +pub(super) fn synthetic_operation_id(method: HttpMethod, path: &str) -> String { + let mut id = method.openapi_key().to_string(); + let mut previous_was_separator = false; + + for ch in path.chars() { + if ch.is_ascii_alphanumeric() { + if previous_was_separator && !id.ends_with('_') { + id.push('_'); + } + id.push(ch.to_ascii_lowercase()); + previous_was_separator = false; + } else { + previous_was_separator = true; + } + } + + id.trim_end_matches('_').to_string() +} diff --git a/crates/pcl/core/src/api/render.rs b/crates/pcl/core/src/api/render.rs new file mode 100644 index 0000000..4f57449 --- /dev/null +++ b/crates/pcl/core/src/api/render.rs @@ -0,0 +1,2494 @@ +use super::{ + ApiCommandError, + first_string_field, + with_envelope_metadata, +}; +use pcl_common::args::{ + OutputMode, + current_output_mode, +}; +use serde_json::Value; +use std::fmt::Write as _; + +pub(super) fn print_output(value: &Value, json_output: bool) -> Result<(), ApiCommandError> { + print!("{}", envelope_output_string(value, json_output)?); + Ok(()) +} + +pub fn envelope_output_string( + value: &Value, + json_output: bool, +) -> Result { + let value = with_envelope_metadata(value.clone()); + let output_mode = if json_output { + OutputMode::Json + } else { + current_output_mode() + }; + match output_mode { + OutputMode::Json => Ok(format!("{}\n", serde_json::to_string_pretty(&value)?)), + OutputMode::Toon => Ok(toon_string(&value)), + OutputMode::Human => Ok(human_string(&value)), + } +} + +/// Render an envelope for interactive humans. +pub fn human_string(value: &Value) -> String { + let value = with_envelope_metadata(value.clone()); + let status = value.get("status").and_then(Value::as_str).unwrap_or("ok"); + let mut output = String::new(); + output.push_str(match status { + "ok" => "OK", + "error" => "Error", + "action_required" => "Action required", + "pending" => "Pending", + other => other, + }); + output.push('\n'); + + if let Some(error) = value.get("error") { + render_human_error(&mut output, error); + } else if !render_human_special(&mut output, &value) + && !render_human_collection(&mut output, &value) + && let Some(data) = value.get("data") + { + render_human_summary(&mut output, data); + } + + let human_actions = human_next_actions(&value); + if !human_actions.is_empty() { + output.push_str("\nNext:\n"); + for (index, action) in human_actions.iter().enumerate() { + output.push_str(" "); + output.push_str(&(index + 1).to_string()); + output.push_str(". "); + output.push_str(action); + output.push('\n'); + } + } + render_human_request_id(&mut output, &value); + if !output.ends_with('\n') { + output.push('\n'); + } + output +} + +fn human_next_actions(envelope: &Value) -> Vec { + let status = envelope + .get("status") + .and_then(Value::as_str) + .unwrap_or("ok"); + let is_empty_ok = status == "ok" && envelope_has_empty_results(envelope); + let terms_accepted = envelope_terms_accepted(envelope); + let preserve_agent_flags = envelope + .get("data") + .and_then(|data| data.get("consumption_order")) + .is_some(); + let integration_test_unavailable = envelope + .pointer("/data/test_available") + .or_else(|| envelope.pointer("/data/data/test_available")) + .and_then(Value::as_bool) + == Some(false); + envelope + .get("next_actions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|action| !is_dangerous_or_internal_action(action)) + .filter(|action| !(is_empty_ok && is_item_placeholder_action(action))) + .filter(|action| !(terms_accepted && action.contains("account --accept-terms"))) + .filter(|action| !(integration_test_unavailable && action.contains(" --test"))) + .map(|action| { + if preserve_agent_flags { + action.to_string() + } else { + human_action_str(action) + } + }) + .filter(|action| !action.is_empty()) + .collect() +} + +fn envelope_terms_accepted(envelope: &Value) -> bool { + envelope + .pointer("/data/terms_accepted") + .or_else(|| envelope.pointer("/data/data/terms_accepted")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn is_dangerous_or_internal_action(action: &str) -> bool { + action.contains(" config delete") + || action.contains(" --delete") + || action.contains(" --remove") + || action.contains(" --revoke") + || action.contains(" --logout") + || action.starts_with("Read error.http.body") + || action.starts_with("Use data.") +} + +fn is_item_placeholder_action(action: &str) -> bool { + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + ] + .iter() + .any(|placeholder| action.contains(placeholder)) +} + +fn envelope_has_empty_results(envelope: &Value) -> bool { + let Some(data) = envelope.get("data") else { + return false; + }; + value_has_empty_results(data) +} + +fn value_has_empty_results(value: &Value) -> bool { + match value { + Value::Array(values) => values.is_empty(), + Value::Object(object) => { + if let Some(inner) = object.get("data") + && value_has_empty_results(inner) + { + return true; + } + object.iter().any(|(key, value)| { + !key.starts_with('_') + && (value.as_array().is_some_and(Vec::is_empty) + || value_has_empty_results(value)) + }) + } + _ => false, + } +} + +struct HumanCollection<'a> { + field: String, + name: String, + items: &'a [Value], + pagination: Option<&'a Value>, + meta: Option<&'a Value>, +} + +fn render_human_error(output: &mut String, error: &Value) { + output.push('\n'); + let code = error.get("code").and_then(Value::as_str); + if let Some(message) = error.get("message").and_then(Value::as_str) { + output.push_str(&human_error_message(code, message)); + output.push('\n'); + } else if let Some(error) = error.as_str() { + output.push_str(error); + output.push('\n'); + } else { + render_human_value(output, error, 0); + } + + if let Some(reason) = api_error_reason(error) { + output.push_str("API reason: "); + output.push_str(&reason); + output.push('\n'); + } + if let Some(request_id) = error.get("request_id").and_then(Value::as_str) { + output.push_str("Request ID: "); + output.push_str(request_id); + output.push('\n'); + } +} + +fn human_error_message(code: Option<&str>, message: &str) -> String { + if code.is_some_and(|value| value.starts_with("cli.")) { + return clean_cli_error_message(message); + } + match code { + Some("api.not_found") => { + "Resource not found. Check the ID, slug, or API path and try again.".to_string() + } + Some("network.request_failed") => { + "Network request failed. Check --api-url and your network connection, then retry." + .to_string() + } + Some("api.server_error") => { + "The platform returned a server error. Retry later or report the request ID." + .to_string() + } + _ => message.to_string(), + } +} + +fn clean_cli_error_message(message: &str) -> String { + let lines = message + .lines() + .take_while(|line| !line.starts_with("Usage:") && !line.starts_with("For more information")) + .map(|line| line.strip_prefix("error: ").unwrap_or(line).trim_end()) + .filter(|line| !line.is_empty()) + .collect::>(); + if lines.first() == Some(&"the following required arguments were not provided:") + && let Some(argument) = lines.get(1) + { + return format!("Missing required argument: {}", argument.trim()); + } + lines.join("\n") +} + +fn api_error_reason(error: &Value) -> Option { + let body = error.pointer("/http/body")?; + for key in ["message", "error", "detail", "reason"] { + if let Some(value) = body.get(key).and_then(Value::as_str) + && !value.is_empty() + { + return Some(value.to_string()); + } + } + body.as_str().map(ToString::to_string) +} + +fn render_human_special(output: &mut String, envelope: &Value) -> bool { + let Some(data) = envelope.get("data") else { + return false; + }; + let display_data = data.get("data").unwrap_or(data); + + for render in [ + render_login_challenge as fn(&mut String, &Value) -> bool, + render_request_plan, + render_auth_status, + render_identity_status, + render_doctor, + ] { + if render(output, display_data) { + return true; + } + } + if render_project_home(output, data, display_data) { + return true; + } + for render in [ + render_project_detail as fn(&mut String, &Value) -> bool, + render_incident_detail, + render_search_results, + render_account_detail, + render_deployment_state, + render_transfer_state, + render_integration_status, + render_protocol_manager_status, + ] { + if render(output, display_data) { + return true; + } + } + if render_mutation_success(output, envelope, display_data) { + return true; + } + for render in [ + render_api_manifest as fn(&mut String, &Value) -> bool, + render_llms_guide, + render_workflow_detail, + render_schema_detail, + render_operation_detail, + render_api_coverage, + render_raw_api_response, + render_export_result, + render_job_detail, + render_path_or_toggle_result, + ] { + if render(output, display_data) { + return true; + } + } + if render_body_template(output, envelope, display_data) { + return true; + } + + false +} + +fn render_login_challenge(output: &mut String, data: &Value) -> bool { + if data.get("state").and_then(Value::as_str) != Some("login_required") { + return false; + } + output.push_str("\nLogin required\n"); + if let Some(reason) = data.get("reason").and_then(Value::as_str) { + writeln!(output, "Reason: {}", human_label(reason)).expect("write to string"); + } + if let Some(url) = data.get("device_url").and_then(Value::as_str) { + writeln!(output, "Open: {url}").expect("write to string"); + } + if let Some(code) = data.get("code").and_then(Value::as_str) { + writeln!(output, "Code: {code}").expect("write to string"); + } + if let Some(expires_at) = data.get("expires_at").and_then(Value::as_str) { + writeln!(output, "Expires: {}", format_timestamp(expires_at)).expect("write to string"); + } + if let Some(command) = data.get("poll_command").and_then(Value::as_str) { + writeln!(output, "Poll: {}", humanize_command(command)).expect("write to string"); + } + true +} + +fn render_request_plan(output: &mut String, data: &Value) -> bool { + if data.get("dry_run").and_then(Value::as_bool) != Some(true) { + return false; + } + + output.push_str("\nDry run\n"); + if data.get("valid").and_then(Value::as_bool) == Some(false) { + output.push_str("Request is not valid.\n"); + if let Some(error) = data.get("error") { + render_human_error(output, error); + } + return true; + } + + let request = data.get("request").unwrap_or(data); + let method = request.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = request.get("path").and_then(Value::as_str).unwrap_or("-"); + writeln!(output, "{method} {path}").expect("write to string"); + if let Some(query) = request.get("query").and_then(Value::as_array) + && !query.is_empty() + { + output.push_str("Query: "); + output.push_str(&name_value_pairs(query)); + output.push('\n'); + } + if let Some(auth) = request.get("auth") { + let required = auth + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + let attached = auth + .get("will_attach_stored_token") + .and_then(Value::as_bool) + .unwrap_or(false); + writeln!( + output, + "Auth: {}{}", + if required { "required" } else { "not required" }, + if attached { + ", stored token will be attached" + } else { + "" + } + ) + .expect("write to string"); + } + if let Some(body) = request.get("body") + && !body.is_null() + { + output.push_str("Body: "); + output.push_str(&human_compact_summary(body)); + output.push('\n'); + } + if let Some(pagination) = data.get("pagination") + && !pagination.is_null() + { + output.push_str("Pagination: "); + output.push_str(&human_compact_summary(pagination)); + output.push('\n'); + } + true +} + +fn render_auth_status(output: &mut String, data: &Value) -> bool { + if !data.get("authenticated").is_some_and(Value::is_boolean) + || data.get("auth").is_some() + || data.get("config_path").is_some() + { + return false; + } + + output.push_str("\nAuthentication\n"); + let authenticated = data + .get("authenticated") + .and_then(Value::as_bool) + .unwrap_or(false); + writeln!( + output, + "Status: {}", + if authenticated { + "authenticated" + } else { + "not logged in" + } + ) + .expect("write to string"); + if let Some(user) = data.get("user").and_then(Value::as_str) { + writeln!(output, "User: {user}").expect("write to string"); + } + if let Some(email) = data.get("email").and_then(Value::as_str) + && data.get("user").and_then(Value::as_str) != Some(email) + { + writeln!(output, "Email: {email}").expect("write to string"); + } + if let Some(wallet) = data.get("wallet_address").and_then(Value::as_str) { + writeln!(output, "Wallet: {wallet}").expect("write to string"); + } + if let Some(expires_at) = data.get("expires_at").and_then(Value::as_str) { + writeln!(output, "Token expires: {}", format_timestamp(expires_at)) + .expect("write to string"); + } + if let Some(seconds) = data.get("seconds_remaining").and_then(Value::as_i64) { + writeln!(output, "Time remaining: {}", format_duration(seconds)).expect("write to string"); + } + if data.get("refreshed").and_then(Value::as_bool) == Some(true) { + output.push_str("Token refreshed.\n"); + } + if let Some(request_id) = data.get("request_id").and_then(Value::as_str) { + writeln!(output, "Request ID: {request_id}").expect("write to string"); + } + true +} + +fn render_identity_status(output: &mut String, data: &Value) -> bool { + let Some(auth) = data.get("auth") else { + return false; + }; + if !auth.get("authenticated").is_some_and(Value::is_boolean) { + return false; + } + output.push_str("\nIdentity\n"); + let authenticated = auth + .get("authenticated") + .and_then(Value::as_bool) + .unwrap_or(false); + writeln!( + output, + "Status: {}", + if authenticated { + "authenticated" + } else { + "not logged in" + } + ) + .expect("write to string"); + if let Some(user) = auth.get("user").and_then(Value::as_str) { + writeln!(output, "User: {user}").expect("write to string"); + } + if let Some(user_id) = auth.get("user_id").and_then(Value::as_str) { + writeln!(output, "User ID: {user_id}").expect("write to string"); + } + if let Some(expires_at) = auth.get("expires_at").and_then(Value::as_str) { + writeln!(output, "Token expires: {}", format_timestamp(expires_at)) + .expect("write to string"); + } + if let Some(config_path) = data.get("config_path").and_then(Value::as_str) { + writeln!(output, "Config: {config_path}").expect("write to string"); + } + if data.get("offline").and_then(Value::as_bool) == Some(true) { + output.push_str("Network checks skipped.\n"); + } + true +} + +fn render_doctor(output: &mut String, data: &Value) -> bool { + let Some(checks) = data.get("checks").and_then(Value::as_array) else { + return false; + }; + output.push_str("\nDoctor\n"); + render_checks_table(output, checks); + if let Some(api_url) = data.get("api_url").and_then(Value::as_str) { + writeln!(output, "\nAPI: {api_url}").expect("write to string"); + } + output.push_str("Default output: human. Agents should pass --toon; scripts can pass --json.\n"); + true +} + +fn render_project_detail(output: &mut String, data: &Value) -> bool { + if data.get("project_id").is_none() || data.get("project_name").is_none() { + return false; + } + output.push_str("\nProject\n"); + write_string_field(output, "Name", data, "project_name"); + write_string_field(output, "ID", data, "project_id"); + write_string_field(output, "Slug", data, "slug"); + if let Some(private) = data.get("is_private").and_then(Value::as_bool) { + writeln!( + output, + "Visibility: {}", + if private { "private" } else { "public" } + ) + .expect("write to string"); + } + if let Some(dev) = data.get("is_dev").and_then(Value::as_bool) { + writeln!( + output, + "Mode: {}", + if dev { "development" } else { "production" } + ) + .expect("write to string"); + } + write_network_list_for_value(output, data); + write_optional_string_field(output, "Description", data, "project_description"); + write_optional_string_field(output, "GitHub", data, "github_url"); + write_timestamp_field(output, "Created", data, "created_at"); + write_timestamp_field(output, "Updated", data, "updated_at"); + if let Some(manager) = data + .get("protocol_manager_address") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + writeln!(output, "Protocol manager: {manager}").expect("write to string"); + } else { + output.push_str("Protocol manager: not set\n"); + } + write_count_field( + output, + "Submitted assertions", + data, + "submitted_assertion_ids", + ); + write_u64_field(output, "Saved by", data, "saved_count", Some("users")); + true +} + +fn render_incident_detail(output: &mut String, data: &Value) -> bool { + let Some(incident_id) = data.get("incident_id").and_then(Value::as_str) else { + return false; + }; + if data.get("invalidating_transactions").is_none() && data.get("transaction_count").is_none() { + return false; + } + + output.push_str("\nIncident\n"); + writeln!(output, "ID: {incident_id}").expect("write to string"); + write_optional_string_field(output, "Reference", data, "public_reference_id"); + write_u64_field(output, "Chain", data, "chain_id", None); + write_timestamp_field(output, "Window start", data, "window_start"); + write_string_field(output, "Environment", data, "environment"); + + if let Some(assertion) = data.get("assertion") { + output.push_str("\nAssertion\n"); + write_optional_string_field(output, "Title", assertion, "title"); + write_optional_string_field(output, "ID", assertion, "assertion_id"); + if let Some(description) = assertion + .get("description") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .filter(|value| !is_hex_blob(value)) + { + writeln!(output, "Description: {}", truncate(description, 96)) + .expect("write to string"); + } + } else { + write_optional_string_field(output, "Assertion ID", data, "assertion_id"); + } + + if let Some(adopter) = data.get("assertion_adopter") { + output.push_str("\nAssertion adopter\n"); + write_optional_string_field(output, "Name", adopter, "name"); + write_optional_string_field(output, "Address", adopter, "address"); + write_optional_string_field(output, "ID", adopter, "id"); + } else { + write_optional_string_field(output, "Assertion adopter ID", data, "assertion_adopter_id"); + } + + output.push_str("\nTrace summary\n"); + if let Some(value) = data.get("transaction_count").and_then(Value::as_u64) { + writeln!( + output, + "Invalidating transactions: {}", + plural_count(value, "transaction") + ) + .expect("write to string"); + } + write_u64_field(output, "Traces completed", data, "traces_completed", None); + write_u64_field(output, "Traces pending", data, "traces_pending", None); + + if let Some(transactions) = data + .get("invalidating_transactions") + .and_then(Value::as_array) + .filter(|transactions| !transactions.is_empty()) + { + let shown = transactions.len().min(5); + writeln!( + output, + "\nInvalidating transactions (first {shown} of {})", + transactions.len() + ) + .expect("write to string"); + writeln!( + output, + "{} {} {} {} Trace", + pad("#", 3), + pad("Time", 16), + pad("Tx hash", 20), + pad("Result", 11) + ) + .expect("write to string"); + for (index, tx) in transactions.iter().take(shown).enumerate() { + let time = tx + .get("incident_timestamp") + .and_then(Value::as_str) + .map_or_else(|| "-".to_string(), format_timestamp); + let hash = first_string_field(tx, &["transaction_hash", "hash", "tx_hash"]) + .map_or_else(|| "-".to_string(), |value| truncate(&value, 20)); + let result = match tx.get("landed_on_chain").and_then(Value::as_bool) { + Some(true) => "landed", + Some(false) => "invalidated", + None => "-", + }; + let trace = tx + .get("debug_traces") + .and_then(Value::as_array) + .and_then(|traces| traces.first()) + .and_then(|trace| trace.get("status")) + .and_then(Value::as_str) + .unwrap_or("-"); + writeln!( + output, + "{} {} {} {} {}", + pad(&(index + 1).to_string(), 3), + pad(&time, 16), + pad(&hash, 20), + pad(result, 11), + trace + ) + .expect("write to string"); + } + } + + true +} + +fn render_project_home(output: &mut String, envelope_data: &Value, data: &Value) -> bool { + let Some(member_projects) = data.get("member_projects").and_then(Value::as_array) else { + return false; + }; + let saved_projects = data + .get("saved_projects") + .and_then(Value::as_array) + .map_or(&[][..], Vec::as_slice); + let no_project_adopters = data + .get("no_project_adopters") + .and_then(Value::as_array) + .map_or(&[][..], Vec::as_slice); + + output.push_str("\nYour projects\n"); + writeln!( + output, + "Showing {} you belong to", + plural_count(member_projects.len(), "project") + ) + .expect("write to string"); + if let Some(meta) = envelope_data.get("_meta") { + render_collection_meta(output, meta); + } + output.push('\n'); + + if member_projects.is_empty() { + output.push_str("No projects found for your account.\n"); + } else { + render_projects_table(output, member_projects); + } + + writeln!( + output, + "\nSaved projects: {}", + plural_count(saved_projects.len(), "project") + ) + .expect("write to string"); + if !saved_projects.is_empty() { + render_projects_table(output, saved_projects); + } + writeln!( + output, + "Contracts without a project: {}", + plural_count(no_project_adopters.len(), "contract") + ) + .expect("write to string"); + true +} + +fn render_search_results(output: &mut String, data: &Value) -> bool { + let Some(projects) = data.get("projects").and_then(Value::as_array) else { + return false; + }; + let contracts = data + .get("contracts") + .and_then(Value::as_array) + .map_or(&[][..], Vec::as_slice); + let assertions = data + .get("assertions") + .and_then(Value::as_array) + .map_or(&[][..], Vec::as_slice); + + output.push_str("\nSearch results\n"); + writeln!(output, "Projects: {}", projects.len()).expect("write to string"); + writeln!(output, "Contracts: {}", contracts.len()).expect("write to string"); + writeln!(output, "Assertions: {}", assertions.len()).expect("write to string"); + + if projects.is_empty() && contracts.is_empty() && assertions.is_empty() { + output.push_str("\nNo search results found.\n"); + return true; + } + + if !projects.is_empty() { + output.push_str("\nProjects\n"); + render_generic_table(output, projects); + } + if !contracts.is_empty() { + output.push_str("\nContracts\n"); + render_search_contracts_table(output, contracts); + } + if !assertions.is_empty() { + output.push_str("\nAssertions\n"); + render_generic_table(output, assertions); + } + true +} + +fn render_search_contracts_table(output: &mut String, items: &[Value]) { + writeln!( + output, + "{:<32} {:<10} {:<22} Project", + "Contract", "Network", "Address" + ) + .expect("write to string"); + for item in items { + let data = item.get("data").unwrap_or(item); + let name = data + .get("contract_name") + .and_then(Value::as_str) + .unwrap_or("-"); + let network = data.get("network").and_then(Value::as_str).unwrap_or("-"); + let address = data.get("address").and_then(Value::as_str).unwrap_or("-"); + let project = data + .get("related_project_slug") + .or_else(|| data.get("related_project_id")) + .and_then(Value::as_str) + .unwrap_or("-"); + writeln!( + output, + "{:<32} {:<10} {:<22} {}", + pad(name, 32), + pad(network, 10), + pad(address, 22), + project + ) + .expect("write to string"); + } +} + +fn render_account_detail(output: &mut String, data: &Value) -> bool { + if data.get("email").is_none() || data.get("authMethod").is_none() { + return false; + } + output.push_str("\nAccount\n"); + write_string_field(output, "Email", data, "email"); + write_string_field(output, "User ID", data, "id"); + write_string_field(output, "Auth method", data, "authMethod"); + write_string_field(output, "Scope", data, "scope"); + write_bool_field(output, "Whitelisted", data, "whitelisted"); + write_bool_field(output, "Terms accepted", data, "terms_accepted"); + write_timestamp_field(output, "Terms accepted at", data, "terms_accepted_at"); + true +} + +fn render_deployment_state(output: &mut String, data: &Value) -> bool { + let Some(project) = data.get("project") else { + return false; + }; + if data.get("available_contracts").is_none() + || data.get("submitted_assertions").is_none() + || data.get("staging_assertions").is_none() + { + return false; + } + output.push_str("\nDeployments\n"); + if let Some(name) = project.get("project_name").and_then(Value::as_str) { + writeln!(output, "Project: {name}").expect("write to string"); + } + if let Some(id) = project.get("project_id").and_then(Value::as_str) { + writeln!(output, "Project ID: {id}").expect("write to string"); + } + write_network_list_for_value(output, project); + write_count_field(output, "Available contracts", data, "available_contracts"); + write_count_field(output, "Submitted assertions", data, "submitted_assertions"); + write_count_field(output, "Staging assertions", data, "staging_assertions"); + if let Some(meta) = data.get("_meta") { + render_collection_meta(output, meta); + } + true +} + +fn render_transfer_state(output: &mut String, data: &Value) -> bool { + let (Some(incoming), Some(outgoing)) = (data.get("incoming"), data.get("outgoing")) else { + return false; + }; + output.push_str("\nProtocol manager transfers\n"); + write_transfer_counts(output, "Incoming", incoming); + write_transfer_counts(output, "Outgoing", outgoing); + true +} + +fn render_integration_status(output: &mut String, data: &Value) -> bool { + if data.get("configured").is_none() || data.get("enabled").is_none() { + return false; + } + output.push_str("\nIntegration\n"); + write_bool_field(output, "Configured", data, "configured"); + write_bool_field(output, "Enabled", data, "enabled"); + write_optional_string_field(output, "Webhook URL", data, "webhook_url"); + write_timestamp_field(output, "Last notification", data, "last_notification_at"); + write_u64_field( + output, + "Notifications sent", + data, + "notification_count", + None, + ); + write_bool_field(output, "Test available", data, "test_available"); + true +} + +fn render_protocol_manager_status(output: &mut String, data: &Value) -> bool { + if data.get("has_pending_transfer").is_none() + || data.get("contracts_pending").is_none() + || data.get("contracts_total").is_none() + { + return false; + } + output.push_str("\nProtocol manager\n"); + write_bool_field(output, "Pending transfer", data, "has_pending_transfer"); + write_optional_string_field(output, "Current manager", data, "current_manager_address"); + write_optional_string_field(output, "New manager", data, "new_manager_address"); + write_u64_field(output, "Contracts pending", data, "contracts_pending", None); + write_u64_field(output, "Contracts total", data, "contracts_total", None); + true +} + +fn render_mutation_success(output: &mut String, envelope: &Value, data: &Value) -> bool { + if data.get("success").and_then(Value::as_bool) != Some(true) + || data + .as_object() + .is_some_and(|object| object.contains_key("message")) + { + return false; + } + let Some(request) = envelope.get("request") else { + return false; + }; + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let path = request.get("path").and_then(Value::as_str).unwrap_or(""); + output.push('\n'); + output.push_str(mutation_success_message(method, path)); + output.push('\n'); + true +} + +fn mutation_success_message(method: &str, path: &str) -> &'static str { + match (method, path) { + ("POST", "/projects/saved") => "Project saved", + ("DELETE", "/projects/saved") => "Project removed from saved projects", + _ if method == "DELETE" + && path.starts_with("/projects/") + && path.contains("/invitations/") => + { + "Invitation revoked" + } + _ if method == "POST" && path.starts_with("/projects/") && path.ends_with("/resend") => { + "Invitation resent" + } + _ if method == "PATCH" && path.starts_with("/projects/") && path.contains("/members/") => { + "Member role updated" + } + _ if method == "DELETE" && path.starts_with("/projects/") && path.contains("/members/") => { + "Member removed" + } + _ if method == "DELETE" && path.ends_with("/protocol-manager") => { + "Protocol manager cleared" + } + _ if method == "POST" && path.ends_with("/confirm-transfer") => { + "Protocol manager transfer confirmed" + } + _ if method == "DELETE" + && path.starts_with("/projects/") + && !path.contains("/integrations/") + && !path.contains("/invitations/") + && !path.contains("/members/") + && !path.contains("/protocol-manager") => + { + "Project deleted" + } + _ => "Request completed", + } +} + +fn render_body_template(output: &mut String, envelope: &Value, data: &Value) -> bool { + if !is_body_template_envelope(envelope) { + return false; + } + if let Some(variants) = data.get("body_variants").and_then(Value::as_array) { + output.push_str("\nBody variants\n"); + for variant in variants { + let name = variant + .get("name") + .and_then(Value::as_str) + .unwrap_or("variant"); + writeln!(output, "- {name}").expect("write to string"); + if let Some(body) = variant.get("body") { + render_human_value(output, body, 4); + } + } + return true; + } + + let Some(object) = data.as_object() else { + return false; + }; + if object.is_empty() + || !object + .values() + .all(|value| is_scalar(value) || value.is_object() || value.is_array()) + { + return false; + } + if !object.keys().any(|key| is_body_template_key(key)) { + return false; + } + output.push_str("\nBody template\n"); + render_human_value(output, data, 2); + true +} + +fn is_body_template_envelope(envelope: &Value) -> bool { + envelope + .get("next_actions") + .and_then(Value::as_array) + .is_some_and(|actions| { + actions.iter().filter_map(Value::as_str).any(|action| { + action.starts_with("Pass the template") + || action.starts_with("Choose one entry from data.body_variants") + }) + }) +} + +fn render_api_manifest(output: &mut String, data: &Value) -> bool { + if data.get("name").and_then(Value::as_str) != Some("pcl") || data.get("commands").is_none() { + return false; + } + output.push_str("\nPCL command surface\n"); + if let Some(description) = data.get("description").and_then(Value::as_str) { + writeln!(output, "{description}").expect("write to string"); + } + output.push_str("\nStart here:\n"); + for command in ["pcl --llms", "pcl workflows", "pcl schema list"] { + writeln!(output, " - {command}").expect("write to string"); + } + if let Some(commands) = data.get("commands").and_then(Value::as_array) { + writeln!( + output, + "\n{} workflow/API command groups available.", + commands.len() + ) + .expect("write to string"); + } + true +} + +fn render_llms_guide(output: &mut String, data: &Value) -> bool { + if data.get("purpose").is_none() || data.get("consumption_order").is_none() { + return false; + } + output.push_str("\nLLM guide\n"); + if let Some(purpose) = data.get("purpose").and_then(Value::as_str) { + writeln!(output, "{purpose}").expect("write to string"); + } + if let Some(order) = data.get("consumption_order").and_then(Value::as_array) { + output.push_str("\nRecommended order:\n"); + for command in order.iter().filter_map(Value::as_str).take(8) { + writeln!(output, " - {command}").expect("write to string"); + } + } + true +} + +fn render_workflow_detail(output: &mut String, data: &Value) -> bool { + if data.get("steps").is_none() || data.get("name").is_none() { + return false; + } + output.push('\n'); + if let Some(name) = data.get("name").and_then(Value::as_str) { + writeln!(output, "Workflow: {name}").expect("write to string"); + } + if let Some(description) = data.get("description").and_then(Value::as_str) { + writeln!(output, "{description}").expect("write to string"); + } + if let Some(steps) = data.get("steps").and_then(Value::as_array) { + output.push_str("\nSteps:\n"); + for (index, step) in steps.iter().enumerate() { + let command = step.get("command").and_then(Value::as_str).unwrap_or("-"); + let description = step.get("output").and_then(Value::as_str).unwrap_or(""); + writeln!( + output, + " {}. {}{}", + index + 1, + humanize_command(command), + if description.is_empty() { + String::new() + } else { + format!(" -> {description}") + } + ) + .expect("write to string"); + } + } + true +} + +fn render_schema_detail(output: &mut String, data: &Value) -> bool { + if data.get("workflow").is_none() + || !(data.get("actions").is_some() || data.get("action").is_some()) + { + return false; + } + output.push('\n'); + if let Some(workflow) = data.get("workflow").and_then(Value::as_str) { + writeln!(output, "Schema: {workflow}").expect("write to string"); + } + if let Some(command) = data.get("command").and_then(Value::as_str) { + writeln!(output, "Command: {}", humanize_command(command)).expect("write to string"); + } + if let Some(actions) = data.get("actions").and_then(Value::as_array) { + render_actions_table(output, actions); + } else if let Some(action) = data.get("action") { + render_action_detail(output, action); + } + true +} + +fn render_operation_detail(output: &mut String, data: &Value) -> bool { + if data.get("operation_id").is_none() + || data.get("method").is_none() + || data.get("path").is_none() + { + return false; + } + output.push_str("\nAPI operation\n"); + let method = data.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = data.get("path").and_then(Value::as_str).unwrap_or("-"); + writeln!(output, "{method} {path}").expect("write to string"); + if let Some(operation_id) = data.get("operation_id").and_then(Value::as_str) { + writeln!(output, "Operation: {operation_id}").expect("write to string"); + } + if let Some(summary) = data.get("summary").and_then(Value::as_str) { + writeln!(output, "Summary: {summary}").expect("write to string"); + } + if let Some(policy) = data.pointer("/raw_api_use/policy").and_then(Value::as_str) { + writeln!(output, "Raw API policy: {}", human_label(policy)).expect("write to string"); + } + if let Some(alternatives) = data.get("workflow_alternatives").and_then(Value::as_array) + && !alternatives.is_empty() + { + output.push_str("Prefer:\n"); + for alternative in alternatives { + if let Some(example) = alternative.get("example").and_then(Value::as_str) { + writeln!(output, " - {}", humanize_command(example)).expect("write to string"); + } + } + } + if let Some(command) = data.get("call_command").and_then(Value::as_str) { + writeln!(output, "Raw call: {}", humanize_command(command)).expect("write to string"); + } + true +} + +fn render_api_coverage(output: &mut String, data: &Value) -> bool { + let Some(total) = data.get("total_operations").and_then(Value::as_u64) else { + return false; + }; + output.push_str("\nAPI coverage\n"); + writeln!(output, "Operations: {total}").expect("write to string"); + for (label, field) in [ + ("No request-log hit", "no_hit_count"), + ("Hit without 2xx", "no_2xx_count"), + ("Write hit without 2xx", "write_no_2xx_count"), + ("Unmatched records", "unmatched_record_count"), + ] { + if let Some(count) = data.get(field).and_then(Value::as_u64) { + writeln!(output, "{label}: {count}").expect("write to string"); + } + } + if let Some(by_method) = data.get("by_method").and_then(Value::as_object) { + output.push_str("\nBy method:\n"); + for (method, stats) in by_method { + let total = stats.get("total").and_then(Value::as_u64).unwrap_or(0); + let hit = stats.get("hit").and_then(Value::as_u64).unwrap_or(0); + let ok = stats.get("ok").and_then(Value::as_u64).unwrap_or(0); + writeln!(output, " {method}: {ok}/{total} 2xx, {hit} hit").expect("write to string"); + } + } + true +} + +fn render_raw_api_response(output: &mut String, data: &Value) -> bool { + if data.get("request").is_none() || data.get("response").is_none() { + return false; + } + let request = data.get("request").unwrap_or(&Value::Null); + let response = data.get("response").unwrap_or(&Value::Null); + output.push_str("\nAPI response\n"); + if let (Some(method), Some(path)) = ( + request.get("method").and_then(Value::as_str), + request.get("path").and_then(Value::as_str), + ) { + writeln!(output, "{method} {path}").expect("write to string"); + } + if let Some(status) = response.get("status").and_then(Value::as_u64) { + writeln!(output, "HTTP {status}").expect("write to string"); + } + if let Some(request_id) = response.get("request_id").and_then(Value::as_str) { + writeln!(output, "Request ID: {request_id}").expect("write to string"); + } + if let Some(body) = response.get("body") { + if let Some(collection) = find_collection_in_value(body, "") { + output.push('\n'); + output.push_str(&collection.name); + output.push('\n'); + output.push_str(&collection_summary(&collection)); + output.push_str("\n\n"); + if collection.items.is_empty() { + writeln!(output, "No {} found.", collection.name.to_ascii_lowercase()) + .expect("write to string"); + } else { + render_collection_items(output, &collection); + } + } else { + output.push_str("Body: "); + output.push_str(&human_compact_summary(body)); + output.push('\n'); + } + } + if let Some(path) = data.get("output_path").and_then(Value::as_str) { + writeln!(output, "Wrote: {path}").expect("write to string"); + } + true +} + +fn render_export_result(output: &mut String, data: &Value) -> bool { + if data.get("export").and_then(Value::as_str) != Some("incidents") + && !(data.get("plan").is_some() && data.get("job_id").is_some()) + { + return false; + } + output.push_str("\nIncident export\n"); + if let Some(job_id) = data.get("job_id").and_then(Value::as_str) { + writeln!(output, "Job: {job_id}").expect("write to string"); + } + let source = data.get("plan").unwrap_or(data); + for (label, field) in [ + ("Output", "out"), + ("Errors", "errors"), + ("Checkpoint", "checkpoint"), + ] { + if let Some(path) = source.get(field).and_then(Value::as_str) { + writeln!(output, "{label}: {path}").expect("write to string"); + } + } + for (label, field) in [ + ("Pages fetched", "pages_fetched"), + ("Incidents written", "incidents_written"), + ("Errors written", "errors_written"), + ("Retries", "retries_attempted"), + ] { + if let Some(count) = data.get(field).and_then(Value::as_u64) { + writeln!(output, "{label}: {count}").expect("write to string"); + } + } + if let Some(command) = data.get("resume_command").and_then(Value::as_str) { + writeln!(output, "Resume: {}", humanize_command(command)).expect("write to string"); + } + true +} + +fn render_job_detail(output: &mut String, data: &Value) -> bool { + let job = data.get("job").unwrap_or(data); + if job.get("job_id").is_none() { + return false; + } + output.push_str("\nJob\n"); + for (label, field) in [ + ("ID", "job_id"), + ("Kind", "kind"), + ("Status", "status"), + ("Updated", "updated_at"), + ] { + if let Some(value) = job.get(field) { + writeln!(output, "{label}: {}", human_cell(value)).expect("write to string"); + } + } + if let Some(stats) = job.get("stats") { + output.push_str("Stats: "); + output.push_str(&human_compact_summary(stats)); + output.push('\n'); + } + if let Some(command) = data + .get("resume_command") + .or_else(|| job.get("resume_command")) + .and_then(Value::as_str) + { + writeln!(output, "Resume: {}", humanize_command(command)).expect("write to string"); + } + true +} + +fn render_path_or_toggle_result(output: &mut String, data: &Value) -> bool { + if data + .as_object() + .is_some_and(|object| object.values().any(Value::is_array)) + { + return false; + } + let path_fields = [ + ("Config", "config_path"), + ("Artifacts", "artifact_dir"), + ("Request log", "request_log"), + ("Jobs", "jobs_path"), + ]; + let mut rendered = false; + for (label, field) in path_fields { + if let Some(path) = data.get(field).and_then(Value::as_str) { + if !rendered { + output.push('\n'); + rendered = true; + } + writeln!(output, "{label}: {path}").expect("write to string"); + } + } + for (label, field) in [("Created", "created"), ("Deleted", "deleted")] { + if let Some(value) = data.get(field).and_then(Value::as_bool) { + if !rendered { + output.push('\n'); + rendered = true; + } + writeln!(output, "{label}: {}", yes_no(value)).expect("write to string"); + } + } + rendered +} + +fn write_string_field(output: &mut String, label: &str, data: &Value, field: &str) { + if let Some(value) = data.get(field).and_then(Value::as_str) { + writeln!(output, "{label}: {value}").expect("write to string"); + } +} + +fn write_optional_string_field(output: &mut String, label: &str, data: &Value, field: &str) { + match data.get(field) { + Some(Value::String(value)) if !value.is_empty() => { + writeln!(output, "{label}: {value}").expect("write to string"); + } + Some(Value::Null) | None => {} + Some(value) if is_scalar(value) => { + writeln!(output, "{label}: {}", scalar_string(value)).expect("write to string"); + } + Some(_) => {} + } +} + +fn write_timestamp_field(output: &mut String, label: &str, data: &Value, field: &str) { + if let Some(value) = data.get(field).and_then(Value::as_str) { + writeln!(output, "{label}: {}", format_timestamp(value)).expect("write to string"); + } +} + +fn write_bool_field(output: &mut String, label: &str, data: &Value, field: &str) { + if let Some(value) = data.get(field).and_then(Value::as_bool) { + writeln!(output, "{label}: {}", yes_no(value)).expect("write to string"); + } +} + +fn write_u64_field( + output: &mut String, + label: &str, + data: &Value, + field: &str, + unit: Option<&str>, +) { + if let Some(value) = data.get(field).and_then(Value::as_u64) { + if let Some(unit) = unit { + writeln!(output, "{label}: {value} {unit}").expect("write to string"); + } else { + writeln!(output, "{label}: {value}").expect("write to string"); + } + } +} + +fn write_count_field(output: &mut String, label: &str, data: &Value, field: &str) { + if let Some(values) = data.get(field).and_then(Value::as_array) { + writeln!( + output, + "{label}: {}", + plural_count(values.len(), count_field_unit(label, field)) + ) + .expect("write to string"); + } +} + +fn count_field_unit(label: &str, field: &str) -> &'static str { + match (label, field) { + ("Available contracts", _) => "contract", + ("Submitted assertions", _) | ("Staging assertions", _) => "assertion", + (_, "available_contracts") => "contract", + (_, "submitted_assertions" | "staging_assertions" | "submitted_assertion_ids") => { + "assertion" + } + _ => "item", + } +} + +fn write_network_list_for_value(output: &mut String, data: &Value) { + let names = data + .get("chain_names") + .or_else(|| data.get("project_networks")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(ToString::to_string)) + .collect::>() + }) + .unwrap_or_default(); + if names.is_empty() { + return; + } + writeln!(output, "Networks: {}", names.join(", ")).expect("write to string"); +} + +fn write_transfer_counts(output: &mut String, label: &str, value: &Value) { + let projects = value + .get("project_transfers") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let contracts = value + .get("contract_transfers") + .and_then(Value::as_array) + .map_or(0, Vec::len); + writeln!( + output, + "{label}: {}, {}", + plural_count(projects, "project transfer"), + plural_count(contracts, "contract transfer") + ) + .expect("write to string"); +} + +fn render_human_collection(output: &mut String, envelope: &Value) -> bool { + let Some(collection) = find_human_collection(envelope) else { + return false; + }; + + output.push('\n'); + output.push_str(&collection.name); + output.push('\n'); + output.push_str(&collection_summary(&collection)); + output.push('\n'); + if let Some(meta) = collection.meta { + render_collection_meta(output, meta); + } + output.push('\n'); + + if collection.items.is_empty() { + writeln!(output, "No {} found.", collection.name.to_ascii_lowercase()) + .expect("write to string"); + return true; + } + + render_collection_items(output, &collection); + + if let Some(pagination) = collection.pagination + && pagination + .get("hasMore") + .or_else(|| pagination.get("has_more")) + .and_then(Value::as_bool) + .unwrap_or(false) + { + let next_page = pagination + .get("page") + .and_then(Value::as_u64) + .map_or(2, |page| page.saturating_add(1)); + let limit = pagination + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(collection.items.len() as u64); + output.push('\n'); + writeln!( + output, + "More results available. Try --page {next_page} --limit {limit}." + ) + .expect("write to string"); + } + + true +} + +fn find_human_collection(envelope: &Value) -> Option> { + let data = envelope.get("data")?; + let request_path = envelope + .pointer("/request/path") + .and_then(Value::as_str) + .unwrap_or_default(); + + find_collection_in_value(data, request_path) +} + +fn find_collection_in_value<'a>( + data: &'a Value, + request_path: &str, +) -> Option> { + if let Some(inner) = data.get("data") + && let Some(collection) = find_collection_in_value(inner, request_path) + { + return Some(HumanCollection { + meta: data.get("_meta").or(collection.meta), + ..collection + }); + } + + if let Some(items) = data.as_array() { + return Some(HumanCollection { + field: infer_collection_field(request_path), + name: infer_collection_name("items", request_path, items), + items, + pagination: None, + meta: None, + }); + } + + if let Some(items) = data.get("items").and_then(Value::as_array) { + return Some(HumanCollection { + field: "items".to_string(), + name: infer_collection_name("items", request_path, items), + items, + pagination: data.get("pagination"), + meta: data.get("_meta"), + }); + } + + for field in [ + "incidents", + "assertions", + "contracts", + "releases", + "projects", + "deployments", + "events", + "operations", + "workflows", + "schemas", + "checks", + "records", + "jobs", + "artifacts", + "members", + "invitations", + "integrations", + "transfers", + "requests", + "no_hit", + "no_2xx", + "write_no_2xx", + "unmatched_records", + "body_variants", + "examples", + "product_surfaces", + ] { + if let Some(items) = data.get(field).and_then(Value::as_array) { + return Some(HumanCollection { + field: field.to_string(), + name: human_label(field), + items, + pagination: data.get("pagination"), + meta: data.get("_meta"), + }); + } + } + + None +} + +fn infer_collection_field(request_path: &str) -> String { + if request_path.contains("assertion_adopters") { + return "contracts".to_string(); + } + for field in [ + "incidents", + "projects", + "assertions", + "contracts", + "releases", + "deployments", + "events", + "members", + "invitations", + "transfers", + ] { + if request_path.contains(field) { + return field.to_string(); + } + } + "items".to_string() +} + +fn infer_collection_name(field: &str, request_path: &str, items: &[Value]) -> String { + if request_path.contains("assertion_adopters") { + return "Contracts".to_string(); + } + for name in [ + "incidents", + "assertions", + "contracts", + "releases", + "projects", + "deployments", + "events", + "operations", + "workflows", + "schemas", + "records", + "jobs", + "artifacts", + "requests", + ] { + if request_path.contains(name) { + return human_label(name); + } + } + if items.iter().any(has_incident_shape) { + return "Incidents".to_string(); + } + human_label(field) +} + +fn collection_summary(collection: &HumanCollection<'_>) -> String { + let shown = collection.items.len(); + if let Some(pagination) = collection.pagination { + let total = pagination + .get("total") + .and_then(Value::as_u64) + .unwrap_or(shown as u64); + let page = pagination.get("page").and_then(Value::as_u64); + let limit = pagination.get("limit").and_then(Value::as_u64); + let item_name = collection_item_name(&collection.name, total); + let mut summary = if total > shown as u64 { + format!("Showing {shown} of {total} {item_name}") + } else { + format!("Showing {shown} {item_name}") + }; + if let Some(page) = page { + write!(summary, " on page {page}").expect("write to string"); + } + if let Some(limit) = limit { + write!(summary, " (limit {limit})").expect("write to string"); + } + return summary; + } + let item_name = collection_item_name(&collection.name, shown as u64); + format!("Showing {shown} {item_name}") +} + +fn collection_item_name(name: &str, count: u64) -> String { + let lower = name.to_ascii_lowercase(); + if count != 1 { + return lower; + } + lower.strip_suffix("ies").map_or_else( + || lower.strip_suffix("s").unwrap_or(&lower).to_string(), + |stem| format!("{stem}y"), + ) +} + +fn render_collection_items(output: &mut String, collection: &HumanCollection<'_>) { + match collection.field.as_str() { + "checks" => render_checks_table(output, collection.items), + "operations" => render_operations_table(output, collection.items), + "workflows" => render_workflows_table(output, collection.items), + "schemas" => render_schemas_table(output, collection.items), + "records" | "requests" | "unmatched_records" => { + render_request_records_table(output, collection.items); + } + "jobs" => render_jobs_table(output, collection.items), + "artifacts" => render_artifacts_table(output, collection.items), + "members" => render_members_table(output, collection.items), + "invitations" => render_invitations_table(output, collection.items), + "projects" => render_projects_table(output, collection.items), + "releases" => render_releases_table(output, collection.items), + "events" => render_events_table(output, collection.items), + "no_hit" | "no_2xx" | "write_no_2xx" => render_coverage_table(output, collection.items), + "body_variants" => render_body_variant_table(output, collection.items), + _ if is_incident_collection(collection) => render_incident_table(output, collection.items), + _ => render_generic_table(output, collection.items), + } +} + +macro_rules! render_rows { + ($output:expr, $items:expr, $header:expr, $row:literal, |$item:ident| $($arg:expr),+ $(,)?) => {{ + writeln!($output, "{}", $header).expect("write to string"); + for $item in $items { + writeln!($output, $row, $($arg),+).expect("write to string"); + } + }}; +} + +fn str_field<'a>(item: &'a Value, field: &str) -> &'a str { + item.get(field).and_then(Value::as_str).unwrap_or("-") +} + +fn str_any<'a>(item: &'a Value, fields: &[&str], default: &'static str) -> &'a str { + fields + .iter() + .find_map(|field| item.get(*field).and_then(Value::as_str)) + .unwrap_or(default) +} + +fn render_checks_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<20} {:<10} Details", "Check", "Status"), + "{:<20} {:<10} {}", + |item| pad(str_field(item, "name"), 20), + pad(str_field(item, "status"), 10), + item.get("details") + .or_else(|| item.get("path")) + .map_or_else(String::new, human_compact_summary), + ); +} + +fn render_operations_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<7} {:<45} {:<36} Policy", "Method", "Path", "Operation"), + "{:<7} {:<45} {:<36} {}", + |item| str_field(item, "method"), + pad(str_field(item, "path"), 45), + pad(str_field(item, "operation_id"), 36), + human_label( + item.pointer("/raw_api_use/policy") + .and_then(Value::as_str) + .unwrap_or("-"), + ), + ); +} + +fn render_workflows_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<28} Steps Description", "Workflow"), + "{:<28} {:<5} {}", + |item| pad(str_field(item, "name"), 28), + item.get("steps") + .and_then(Value::as_array) + .map_or(0, Vec::len), + truncate( + item.get("description") + .and_then(Value::as_str) + .unwrap_or_default(), + 72, + ), + ); +} + +fn render_schemas_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<24} {:<7} Command", "Workflow", "Actions"), + "{:<24} {:<7} {}", + |item| pad(str_field(item, "workflow"), 24), + item.get("actions").and_then(Value::as_u64).unwrap_or(0), + truncate(&humanize_command(str_field(item, "command")), 96), + ); +} + +fn render_request_records_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!( + "{:<16} {:<7} {:<45} {:<6} Request ID", + "Time", "Method", "Path", "HTTP" + ), + "{:<16} {:<7} {:<45} {:<6} {}", + |item| { + pad( + &item + .get("timestamp") + .and_then(Value::as_str) + .map_or_else(String::new, format_timestamp), + 16, + ) + }, + str_field(item, "method"), + pad(str_field(item, "path"), 45), + item.get("status") + .and_then(Value::as_u64) + .map_or_else(|| "-".to_string(), |value| value.to_string()), + str_field(item, "request_id"), + ); +} + +fn render_jobs_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<38} {:<16} {:<12} Updated", "Job", "Kind", "Status"), + "{:<38} {:<16} {:<12} {}", + |item| pad(str_field(item, "job_id"), 38), + pad(str_field(item, "kind"), 16), + pad(str_field(item, "status"), 12), + item.get("updated_at") + .and_then(Value::as_str) + .map_or_else(String::new, format_timestamp), + ); +} + +fn render_artifacts_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<58} {:>10} Modified", "Path", "Bytes"), + "{:<58} {:>10} {}", + |item| pad(str_field(item, "path"), 58), + item.get("bytes") + .and_then(Value::as_u64) + .map_or_else(|| "-".to_string(), |value| value.to_string()), + item.get("modified") + .and_then(Value::as_u64) + .map_or_else(String::new, format_unix_timestamp), + ); +} + +fn render_projects_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!( + "{:<28} {:<22} {:<20} {:<10} ID", + "Project", "Slug", "Network", "Visibility" + ), + "{:<28} {:<22} {:<20} {:<10} {}", + |item| pad(str_any(item, &["project_name", "name"], "-"), 28), + pad(str_field(item, "slug"), 22), + pad(&first_project_network(item), 20), + item.get("is_private") + .and_then(Value::as_bool) + .map_or("-", |private| if private { "private" } else { "public" }), + str_any(item, &["project_id", "id"], "-"), + ); +} + +fn first_project_network(item: &Value) -> String { + item.get("chain_names") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .or_else(|| { + item.get("project_networks") + .and_then(Value::as_array) + .and_then(|values| values.first()) + }) + .map_or_else(|| "-".to_string(), human_scalar) +} + +fn render_members_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<34} {:<12} User ID", "Email", "Role"), + "{:<34} {:<12} {}", + |item| pad(str_field(item, "email"), 34), + pad(str_field(item, "role"), 12), + str_field(item, "user_id"), + ); +} + +fn render_invitations_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<34} {:<12} {:<16} ID", "Email", "Role", "Status"), + "{:<34} {:<12} {:<16} {}", + |item| { + pad( + str_any(item, &["email", "identifier", "invitee_identifier"], "-"), + 34, + ) + }, + pad(str_field(item, "role"), 12), + pad(str_any(item, &["status"], "pending"), 16), + str_any(item, &["id", "invitation_id"], "-"), + ); +} + +fn render_releases_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!( + "{:<36} {:<14} {:<16} Created", + "Release", "Environment", "Status" + ), + "{:<36} {:<14} {:<16} {}", + |item| pad(str_any(item, &["release_id", "id"], "-"), 36), + pad(str_field(item, "environment"), 14), + pad(str_field(item, "status"), 16), + item.get("created_at") + .or_else(|| item.get("createdAt")) + .and_then(Value::as_str) + .map_or_else(String::new, format_timestamp), + ); +} + +fn render_events_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!("{:<34} {:<14} {:<16} Type", "Event", "Environment", "Time"), + "{:<34} {:<14} {:<16} {}", + |item| pad(str_field(item, "id"), 34), + pad(str_field(item, "environment"), 14), + pad( + &item + .get("timestamp") + .or_else(|| item.get("created_at")) + .and_then(Value::as_str) + .map_or_else(String::new, format_timestamp), + 16, + ), + str_any(item, &["type", "event_type"], "-"), + ); +} + +fn render_coverage_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items.iter().take(20), + format!( + "{:<7} {:<45} {:<7} {:<7} Request ID", + "Method", "Path", "Hits", "2xx" + ), + "{:<7} {:<45} {:<7} {:<7} {}", + |item| str_field(item, "method"), + pad(str_field(item, "path"), 45), + item.get("hits").and_then(Value::as_u64).unwrap_or(0), + item.get("ok").and_then(Value::as_u64).unwrap_or(0), + str_field(item, "latest_request_id"), + ); + if items.len() > 20 { + writeln!(output, "... {} more", items.len() - 20).expect("write to string"); + } +} + +fn render_body_variant_table(output: &mut String, items: &[Value]) { + for item in items { + let name = item + .get("name") + .and_then(Value::as_str) + .unwrap_or("variant"); + writeln!(output, "- {name}").expect("write to string"); + if let Some(body) = item.get("body") { + render_human_value(output, body, 4); + } + } +} + +fn render_collection_meta(output: &mut String, meta: &Value) { + let fetched_at = meta + .get("fetchedAt") + .or_else(|| meta.get("fetched_at")) + .and_then(Value::as_str); + let sources = meta.get("sources").and_then(Value::as_array); + if fetched_at.is_none() && sources.is_none_or(Vec::is_empty) { + return; + } + + if let Some(fetched_at) = fetched_at { + output.push_str("Updated: "); + output.push_str(&format_timestamp(fetched_at)); + output.push('\n'); + } + if let Some(sources) = sources { + let source_names = sources + .iter() + .filter_map(Value::as_str) + .map(human_source_name) + .collect::>() + .join(", "); + if !source_names.is_empty() { + output.push_str("Source: "); + output.push_str(&source_names); + output.push('\n'); + } + } +} + +fn human_source_name(source: &str) -> String { + match source { + "offchain" => "Phylax platform index".to_string(), + "onchain" => "on-chain data".to_string(), + "cache" => "cache".to_string(), + other => human_label(other), + } +} + +fn is_incident_collection(collection: &HumanCollection<'_>) -> bool { + collection.name == "Incidents" || collection.items.iter().any(has_incident_shape) +} + +fn has_incident_shape(value: &Value) -> bool { + value.get("referenceId").is_some() + || value.get("reference_id").is_some() + || (value.get("timestamp").is_some() + && value.get("network").is_some() + && value.get("title").is_some()) +} + +fn render_incident_table(output: &mut String, items: &[Value]) { + writeln!( + output, + "{:<3} {:<16} {:<24} {:<29} ID", + "#", "Time", "Network", "Title" + ) + .expect("write to string"); + for (index, item) in items.iter().enumerate() { + let timestamp = item + .get("timestamp") + .and_then(Value::as_str) + .map_or_else(String::new, format_timestamp); + let network = format_network(item.get("network")); + let title = item + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled"); + let id = item.get("id").and_then(Value::as_str).unwrap_or("-"); + writeln!( + output, + "{:<3} {:<16} {:<24} {:<29} {}", + index + 1, + pad(×tamp, 16), + pad(&network, 24), + pad(title, 29), + id + ) + .expect("write to string"); + } +} + +fn render_generic_table(output: &mut String, items: &[Value]) { + let columns = generic_columns(items); + if columns.is_empty() { + render_human_value(output, &Value::Array(items.to_vec()), 0); + return; + } + + write!(output, "{:<3}", "#").expect("write to string"); + for column in &columns { + write!(output, " {:<22}", human_label(column)).expect("write to string"); + } + output.push('\n'); + + for (index, item) in items.iter().enumerate() { + write!(output, "{:<3}", index + 1).expect("write to string"); + for column in &columns { + let value = item.get(column).map_or_else(String::new, human_cell); + write!(output, " {:<22}", pad(&value, 22)).expect("write to string"); + } + output.push('\n'); + } +} + +fn generic_columns(items: &[Value]) -> Vec { + let mut columns = Vec::new(); + for preferred in [ + "name", + "title", + "id", + "status", + "environment", + "network", + "timestamp", + "createdAt", + "updatedAt", + ] { + if items.iter().any(|item| item.get(preferred).is_some()) { + columns.push(preferred.to_string()); + } + if columns.len() == 4 { + return columns; + } + } + + if columns.is_empty() + && let Some(object) = items.first().and_then(Value::as_object) + { + columns.extend(object.keys().take(4).cloned()); + } + columns +} + +fn human_cell(value: &Value) -> String { + match value { + Value::Object(object) if object.contains_key("name") => { + object + .get("name") + .and_then(Value::as_str) + .map_or_else(|| compact_json(value), ToString::to_string) + } + Value::Object(_) | Value::Array(_) => compact_json(value), + _ => human_scalar(value), + } +} + +fn human_action_str(value: &str) -> String { + if value.trim_start().starts_with("pcl ") { + humanize_command(value) + } else if matches!( + value, + "Use --toon for agent consumption or --json for strict JSON parsing" + | "Use --json for strict JSON parsing" + ) { + String::new() + } else if value == "Use --body-template when constructing mutation bodies" { + "Use --body-template to start from an example request body".to_string() + } else { + value.to_string() + } +} + +fn humanize_command(command: &str) -> String { + command + .replace(" --format toon", "") + .replace(" --toon", "") + .replace("--toon ", "") +} + +fn is_body_template_key(key: &str) -> bool { + matches!( + key, + "project_name" + | "project_description" + | "profile_image_url" + | "github_url" + | "chain_id" + | "is_private" + | "is_dev" + | "project_id" + | "identifier" + | "identifier_type" + | "role" + | "provider" + | "webhook_url" + | "routing_key" + | "enabled" + | "address" + | "signature" + | "nonce" + | "tx_hash" + | "contract_name" + | "assertions" + | "assertionsDir" + | "contracts" + | "environment" + | "mode" + | "new_manager_address" + | "ponder_transfer_id" + | "reason" + | "notify" + ) +} + +fn name_value_pairs(values: &[Value]) -> String { + values + .iter() + .map(|value| { + let name = value.get("name").and_then(Value::as_str).unwrap_or("?"); + let rendered = value + .get("value") + .map_or_else(|| "none".to_string(), scalar_string); + format!("{name}={rendered}") + }) + .collect::>() + .join(", ") +} + +fn render_actions_table(output: &mut String, actions: &[Value]) { + writeln!( + output, + "{:<24} {:<7} {:<8} Path", + "Action", "Auth", "Method" + ) + .expect("write to string"); + for action in actions { + let name = action.get("name").and_then(Value::as_str).unwrap_or("-"); + let auth = action + .get("auth") + .and_then(Value::as_bool) + .map_or("-", |value| if value { "yes" } else { "no" }); + let method = action.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = action.get("path").and_then(Value::as_str).unwrap_or("-"); + writeln!( + output, + "{:<24} {:<7} {:<8} {}", + pad(name, 24), + auth, + method, + path + ) + .expect("write to string"); + } +} + +fn render_action_detail(output: &mut String, action: &Value) { + let name = action.get("name").and_then(Value::as_str).unwrap_or("-"); + writeln!(output, "Action: {name}").expect("write to string"); + if let (Some(method), Some(path)) = ( + action.get("method").and_then(Value::as_str), + action.get("path").and_then(Value::as_str), + ) { + writeln!(output, "Request: {method} {path}").expect("write to string"); + } + if let Some(auth) = action.get("auth").and_then(Value::as_bool) { + writeln!( + output, + "Auth: {}", + if auth { "required" } else { "not required" } + ) + .expect("write to string"); + } + if let Some(example) = action.get("example").and_then(Value::as_str) { + writeln!(output, "Example: {}", humanize_command(example)).expect("write to string"); + } + if let Some(flags) = action.get("required_flags").and_then(Value::as_array) + && !flags.is_empty() + { + writeln!(output, "Required flags: {}", string_list(flags)).expect("write to string"); + } + if let Some(flags) = action.get("optional_flags").and_then(Value::as_array) + && !flags.is_empty() + { + writeln!(output, "Optional flags: {}", string_list(flags)).expect("write to string"); + } +} + +fn string_list(values: &[Value]) -> String { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +fn format_duration(seconds: i64) -> String { + if seconds < 0 { + return "expired".to_string(); + } + let days = seconds / 86_400; + let hours = (seconds % 86_400) / 3_600; + let minutes = (seconds % 3_600) / 60; + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {minutes}m") + } else { + format!("{minutes}m") + } +} + +fn render_human_summary(output: &mut String, data: &Value) { + let display_data = data.get("data").unwrap_or(data); + output.push('\n'); + if let Some(object) = display_data.as_object() { + for (key, value) in object { + if key.starts_with('_') { + continue; + } + output.push_str(&human_label(key)); + output.push_str(": "); + if is_scalar(value) { + output.push_str(&human_scalar(value)); + output.push('\n'); + } else { + output.push_str(&human_compact_summary(value)); + output.push('\n'); + } + } + } else { + render_human_value(output, display_data, 0); + } +} + +fn render_human_request_id(output: &mut String, envelope: &Value) { + let request_id = envelope + .pointer("/response/request_id") + .and_then(Value::as_str); + let status = envelope.pointer("/response/status").and_then(Value::as_u64); + if request_id.is_none() && status.is_none() { + return; + } + + output.push('\n'); + if let Some(request_id) = request_id { + output.push_str("Request ID: "); + output.push_str(request_id); + if let Some(status) = status { + write!(output, " (HTTP {status})").expect("write to string"); + } + output.push('\n'); + } else if let Some(status) = status { + writeln!(output, "HTTP status: {status}").expect("write to string"); + } +} + +fn human_compact_summary(value: &Value) -> String { + match value { + Value::Array(values) => plural_count(values.len(), "item"), + Value::Object(object) => { + if object.is_empty() { + return "empty object".to_string(); + } + object + .iter() + .filter(|(key, _)| !key.starts_with('_')) + .take(3) + .map(|(key, value)| { + if is_scalar(value) { + format!("{}={}", human_label(key), human_scalar(value)) + } else { + format!("{}={}", human_label(key), compact_json(value)) + } + }) + .collect::>() + .join(", ") + } + _ => human_scalar(value), + } +} + +fn format_network(value: Option<&Value>) -> String { + let Some(value) = value else { + return "-".to_string(); + }; + if let Some(name) = value.as_str() { + return name.to_string(); + } + let name = value + .get("name") + .and_then(Value::as_str) + .unwrap_or("Unknown network"); + if let Some(chain_id) = value.get("chainId").and_then(Value::as_u64) { + return format!("{name} ({chain_id})"); + } + if let Some(chain_id) = value.get("chain_id").and_then(Value::as_u64) { + return format!("{name} ({chain_id})"); + } + name.to_string() +} + +fn format_timestamp(value: &str) -> String { + if value.len() >= 16 && value.as_bytes().get(10) == Some(&b'T') { + return value[..16].replace('T', " "); + } + value.to_string() +} + +fn format_unix_timestamp(value: u64) -> String { + let Ok(seconds) = i64::try_from(value) else { + return value.to_string(); + }; + chrono::DateTime::from_timestamp(seconds, 0).map_or_else( + || value.to_string(), + |timestamp| timestamp.format("%Y-%m-%d %H:%M").to_string(), + ) +} + +fn human_label(value: &str) -> String { + let words = split_label_words(value); + let mut rendered = Vec::new(); + for (index, word) in words.iter().enumerate() { + let lower = word.to_ascii_lowercase(); + let text = match lower.as_str() { + "id" => "ID".to_string(), + "api" => "API".to_string(), + "http" => "HTTP".to_string(), + "url" => "URL".to_string(), + "json" => "JSON".to_string(), + "cli" => "CLI".to_string(), + "pcl" => "PCL".to_string(), + "uuid" => "UUID".to_string(), + "tx" => "tx".to_string(), + "github" => "GitHub".to_string(), + "authmethod" => "auth method".to_string(), + other if index == 0 => capitalize(other), + other => other.to_string(), + }; + rendered.push(text); + } + rendered.join(" ") +} + +fn split_label_words(value: &str) -> Vec { + let normalized = value.replace(['_', '-'], " "); + let mut words = Vec::new(); + for raw in normalized.split_whitespace() { + let mut current = String::new(); + let chars = raw.chars().collect::>(); + for (index, ch) in chars.iter().enumerate() { + if index > 0 + && ch.is_uppercase() + && chars + .get(index.saturating_sub(1)) + .is_some_and(|previous| previous.is_lowercase() || previous.is_ascii_digit()) + { + words.push(current); + current = String::new(); + } + current.push(*ch); + } + if !current.is_empty() { + words.push(current); + } + } + words +} + +fn capitalize(value: &str) -> String { + let mut chars = value.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::() + chars.as_str() + }) +} + +fn plural_count(count: impl std::fmt::Display, item: &str) -> String { + let count = count.to_string(); + if count == "1" { + format!("1 {item}") + } else { + format!("{count} {item}s") + } +} + +fn human_scalar(value: &Value) -> String { + match value { + Value::Bool(value) => yes_no(*value).to_string(), + Value::String(value) => { + if value.len() >= 16 && value.as_bytes().get(10) == Some(&b'T') { + format_timestamp(value) + } else { + value.clone() + } + } + _ => scalar_string(value), + } +} + +fn pad(value: &str, width: usize) -> String { + let value = truncate(value, width); + format!("{value: String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_string(); + } + if max_chars <= 3 { + return value.chars().take(max_chars).collect(); + } + let prefix: String = value.chars().take(max_chars - 3).collect(); + format!("{prefix}...") +} + +fn is_hex_blob(value: &str) -> bool { + let Some(hex) = value.strip_prefix("0x") else { + return false; + }; + hex.len() > 64 && hex.chars().all(|character| character.is_ascii_hexdigit()) +} + +fn render_human_value(output: &mut String, value: &Value, indent: usize) { + match value { + Value::Object(object) => { + for (key, value) in object { + write_indent(output, indent); + output.push_str(key); + output.push_str(": "); + if is_scalar(value) { + output.push_str(&scalar_string(value)); + output.push('\n'); + } else { + output.push('\n'); + render_human_value(output, value, indent + 2); + } + } + } + Value::Array(values) => { + for value in values { + write_indent(output, indent); + output.push_str("- "); + if is_scalar(value) { + output.push_str(&scalar_string(value)); + output.push('\n'); + } else { + output.push('\n'); + render_human_value(output, value, indent + 2); + } + } + } + _ => { + write_indent(output, indent); + output.push_str(&scalar_string(value)); + output.push('\n'); + } + } +} + +fn write_indent(output: &mut String, indent: usize) { + for _ in 0..indent { + output.push(' '); + } +} + +fn is_scalar(value: &Value) -> bool { + matches!( + value, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) +} + +fn scalar_string(value: &Value) -> String { + match value { + Value::Null => "none".to_string(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + Value::Array(_) | Value::Object(_) => compact_json(value), + } +} + +fn compact_json(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| value.to_string()) +} + +/// Render a JSON value as the CLI's compact TOON-style text output. +pub fn toon_string(value: &Value) -> String { + let mut output = toon_format::encode_default(value).unwrap_or_else(|_| { + serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) + }); + if !output.ends_with('\n') { + output.push('\n'); + } + output +} diff --git a/crates/pcl/core/src/api/templates.rs b/crates/pcl/core/src/api/templates.rs new file mode 100644 index 0000000..3117fb4 --- /dev/null +++ b/crates/pcl/core/src/api/templates.rs @@ -0,0 +1,307 @@ +use super::{ + AccessArgs, + ContractsArgs, + DeploymentsArgs, + IntegrationsArgs, + ProjectsArgs, + ProtocolManagerArgs, + ReleasesArgs, + TransfersArgs, + with_envelope_metadata, +}; +use serde_json::{ + Value, + json, +}; + +pub(super) fn template_envelope(data: Value) -> Value { + let next_actions = if data + .get("body_variants") + .and_then(Value::as_array) + .is_some_and(|variants| !variants.is_empty()) + { + vec![ + "Choose one entry from data.body_variants and pass only its body with --body-file ", + "Or pass fields from the chosen variant body with --field key=value", + ] + } else { + vec![ + "Pass the template with --body-file ", + "Or pass individual fields with --field key=value", + ] + }; + with_envelope_metadata(json!({ + "status": "ok", + "data": data, + "next_actions": next_actions, + })) +} + +pub(super) fn project_body_template(args: &ProjectsArgs) -> Value { + if args.update { + return body_template("project_update"); + } + if args.save || args.unsave { + return body_template("project_saved"); + } + if args.delete || args.resolve || args.widget || args.mine || args.saved { + return body_template("empty_object"); + } + body_template("project_create") +} + +pub(super) fn contracts_body_template(args: &ContractsArgs) -> Value { + if args.assign_project { + return body_template("contracts_assign_project"); + } + if args.unassigned || args.remove || args.remove_calldata || args.adopter_id.is_some() { + return body_template("empty_object"); + } + body_template("contracts") +} + +pub(super) fn release_body_template(args: &ReleasesArgs) -> Value { + if args.deploy { + return body_template("release_deploy"); + } + if args.remove { + return body_template("release_remove"); + } + if args.deploy_calldata + || args.remove_calldata + || args.backtest_progress + || args.retry_check + || args.release_id.is_some() + { + return body_template("empty_object"); + } + body_template("release") +} + +pub(super) fn deployment_body_template(args: &DeploymentsArgs) -> Value { + if !args.confirm { + return body_template("empty_object"); + } + body_template("deployment_confirmation") +} + +pub(super) fn access_body_template(args: &AccessArgs) -> Value { + if args.update_role { + return body_template("role_update"); + } + if args.invite { + return body_template("access_invite"); + } + if args.accept + || args.resend + || args.revoke + || args.remove + || args.members + || args.invitations + || args.pending + || args.preview + || args.my_role + { + return body_template("empty_object"); + } + body_template("access_invite") +} + +pub(super) fn integration_body_template(args: &IntegrationsArgs) -> Value { + if args.test || args.delete { + return body_template("empty_object"); + } + if let Some(provider) = args.provider { + return body_template(provider.path()); + } + json!({ + "body_variants": [ + { + "name": "slack", + "body": body_template("slack") + }, + { + "name": "pagerduty", + "body": body_template("pagerduty") + } + ] + }) +} + +pub(super) fn protocol_manager_body_template(args: &ProtocolManagerArgs) -> Value { + if args.set { + return body_template("protocol_manager_set"); + } + if args.confirm_transfer { + return body_template("protocol_manager_confirm"); + } + if args.clear + || args.nonce + || args.transfer_calldata + || args.accept_calldata + || args.pending_transfer + { + return body_template("empty_object"); + } + body_template("protocol_manager_set") +} + +pub(super) fn transfer_body_template(args: &TransfersArgs) -> Value { + if !args.reject { + return body_template("empty_object"); + } + body_template("transfer_reject") +} + +pub(super) fn body_template(kind: &str) -> Value { + match kind { + "project_create" => { + json!({ + "project_name": "", + "chain_id": 1, + "project_description": "", + "profile_image_url": "https://example.com/project.png", + "is_private": false + }) + } + "project_update" => { + json!({ + "project_name": "", + "project_description": "", + "github_url": "https://github.com/org/repo", + "profile_image_url": "https://example.com/project.png", + "is_dev": false, + "is_private": false, + "assertion_adopters": [] + }) + } + "project_saved" => json!({ "project_id": "" }), + "release" => { + json!({ + "environment": "staging", + "assertionsDir": "assertions", + "contracts": { + "": { + "address": "0x...", + "name": "", + "assertions": [ + { + "file": "Assertion.sol", + "args": [], + "bytecode": "0x...", + "flattenedSource": "", + "compilerVersion": "0.8.28", + "contractName": "", + "evmVersion": "paris", + "optimizerRuns": 200, + "optimizerEnabled": true, + "metadataBytecodeHash": "none", + "libraries": {} + } + ] + } + }, + "compilerArgs": [] + }) + } + "access_invite" => { + json!({ + "identifier": "user@example.com", + "identifier_type": "email", + "role": "viewer" + }) + } + "role_update" => json!({ "role": "viewer" }), + "release_deploy" => { + json!({ + "chainId": 1, + "txHash": "0x..." + }) + } + "release_remove" => { + json!({ + "chainId": 1, + "txHash": "0x..." + }) + } + "deployment_confirmation" => { + json!({ + "tx_hash": "0x...", + "chainId": 1, + "environment": "staging", + "assertions": [ + { + "assertion_id": "0x...", + "assertion_adopters": [ + { + "id": "" + } + ] + } + ] + }) + } + "slack" => { + json!({ + "webhook_url": "https://hooks.slack.com/services/...", + "enabled": true + }) + } + "pagerduty" => { + json!({ + "routing_key": "", + "enabled": true + }) + } + "protocol_manager_set" => { + json!({ + "address": "0x...", + "signature": "0x...", + "nonce": "" + }) + } + "protocol_manager_confirm" => { + json!({ + "body_variants": [ + { + "name": "direct", + "body": { + "mode": "direct", + "new_manager_address": "0x..." + } + }, + { + "name": "onchain", + "body": { + "mode": "onchain", + "new_manager_address": "0x...", + "chain_id": 1, + "tx_hash": "0x..." + } + } + ] + }) + } + "transfer_reject" => { + json!({ + "ponder_transfer_id": "" + }) + } + "contracts" => { + json!({ + "network": "1", + "address": "0x...", + "contract_name": "", + "project_id": "" + }) + } + "contracts_assign_project" => { + json!({ + "project_id": "", + "assertion_adopter_ids": [""] + }) + } + "empty_object" => json!({}), + _ => json!({}), + } +} diff --git a/crates/pcl/core/src/api/workflows.rs b/crates/pcl/core/src/api/workflows.rs new file mode 100644 index 0000000..f44410b --- /dev/null +++ b/crates/pcl/core/src/api/workflows.rs @@ -0,0 +1,1273 @@ +use super::{ + AccessArgs, + AccountArgs, + ApiCommandError, + AssertionsArgs, + ContractsArgs, + DeploymentsArgs, + EventsArgs, + HttpMethod, + IncidentsArgs, + IntegrationsArgs, + ProjectsArgs, + ProtocolManagerArgs, + ReleasesArgs, + SearchArgs, + TransfersArgs, + WorkflowRequest, + read_body, +}; +use serde_json::{ + Map, + Value, + json, +}; +use std::path::PathBuf; + +pub(super) fn search_request(args: &SearchArgs) -> Result { + if args.health { + return Ok(WorkflowRequest::get( + "/health", + false, + ["pcl search --system-status"], + )); + } + if args.system_status { + return Ok(WorkflowRequest::get( + "/system-status", + false, + ["pcl search --stats"], + )); + } + if args.stats { + return Ok(WorkflowRequest::get( + "/stats", + false, + ["pcl projects --limit 10"], + )); + } + if args.whitelist { + return Ok(WorkflowRequest::get( + "/whitelist", + true, + ["pcl projects --mine"], + )); + } + if args.verified_contract { + let address = required_arg(args.address.as_deref(), "--address")?; + let chain_id = args.chain_id.ok_or_else(|| { + ApiCommandError::InvalidWorkflowWithActions { + message: "--verified-contract requires --chain-id".to_string(), + next_actions: vec![ + "pcl search --verified-contract --address
--chain-id " + .to_string(), + "pcl search --help".to_string(), + ], + } + })?; + let mut request = WorkflowRequest::get( + "/web/verified-contract", + false, + ["pcl contracts --project "], + ); + push_query(&mut request.query, "address", Some(address)); + push_query(&mut request.query, "chainId", Some(chain_id)); + return Ok(request); + } + + let query = args + .query + .as_deref() + .or(args.term.as_deref()) + .filter(|query| !query.trim().is_empty()) + .ok_or_else(|| { + ApiCommandError::InvalidWorkflowWithActions { + message: "Search query is required unless you choose a specific search action" + .to_string(), + next_actions: vec![ + "pcl search ".to_string(), + "pcl search --query ".to_string(), + "pcl search --stats".to_string(), + "pcl search --help".to_string(), + ], + } + })?; + + let mut request = WorkflowRequest::get( + "/search", + false, + [ + "pcl projects --project ", + "pcl contracts --project ", + ], + ); + push_query(&mut request.query, "query", Some(query)); + Ok(request) +} + +pub(super) fn account_request(args: &AccountArgs) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + if args.accept_terms { + return Ok(workflow_with_body( + HttpMethod::Post, + "/web/auth/accept-terms", + true, + Some(body_or_empty(body)), + ["pcl account", "pcl projects --mine"], + )); + } + if args.logout { + return Ok(workflow_with_body( + HttpMethod::Post, + "/web/auth/logout", + true, + Some(body_or_empty(body)), + ["pcl auth logout"], + )); + } + Ok(WorkflowRequest::get( + "/web/auth/me", + true, + ["pcl account --accept-terms", "pcl projects --mine"], + )) +} + +pub(super) fn contracts_request(args: &ContractsArgs) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + if args.create { + return Ok(workflow_with_body( + HttpMethod::Post, + "/assertion_adopters", + true, + body, + ["pcl contracts --unassigned --manager "], + )); + } + if args.assign_project { + return Ok(workflow_with_body( + HttpMethod::Post, + "/assertion_adopters/assign-project", + true, + body, + ["pcl contracts --project "], + )); + } + if args.unassigned { + let manager = required_arg(args.manager.as_deref(), "--manager")?; + let mut request = WorkflowRequest::get( + "/assertion_adopters/no-project", + true, + ["pcl contracts --assign-project --body-template"], + ); + push_query(&mut request.query, "manager", Some(manager)); + return Ok(request); + } + if args.remove_calldata { + let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; + if args.assertion_ids.is_empty() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--assertion-id is required for --remove-calldata".to_string(), + }); + } + let mut request = WorkflowRequest::get( + format!("/assertion_adopters/{address}/remove-assertions-calldata"), + true, + ["pcl releases --project "], + ); + push_query(&mut request.query, "network", args.network.as_deref()); + push_query( + &mut request.query, + "environment", + args.environment.as_deref(), + ); + for assertion_id in &args.assertion_ids { + push_query(&mut request.query, "assertion_ids", Some(assertion_id)); + } + return Ok(request); + } + if args.remove { + let project = required_arg(args.project.as_deref(), "--project")?; + let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; + return Ok(workflow_with_body( + HttpMethod::Delete, + format!("/projects/{project}/{address}"), + true, + body, + vec![format!("pcl contracts --project {project}")], + )); + } + if let Some(project) = &args.project { + if let Some(adopter_id) = &args.adopter_id { + return Ok(WorkflowRequest::get( + format!("/views/projects/{project}/contracts/{adopter_id}"), + true, + vec![format!("pcl contracts --project {project}")], + )); + } + return Ok(WorkflowRequest::get( + format!("/views/projects/{project}/contracts"), + true, + vec![format!( + "pcl contracts --project {project} --adopter-id " + )], + )); + } + + Ok(WorkflowRequest::get( + "/assertion_adopters", + true, + ["pcl contracts --unassigned --manager "], + )) +} + +pub(super) fn releases_request(args: &ReleasesArgs) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + let project = required_project_arg(args.project.as_deref(), "releases", "--project")?; + if args.preview { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases/preview"), + true, + body, + vec![format!( + "pcl releases --project {project} --create --body-file release.json" + )], + )); + } + if args.create { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases"), + true, + body, + vec![format!("pcl releases --project {project}")], + )); + } + if args.deploy + || args.remove + || args.deploy_calldata + || args.remove_calldata + || args.backtest_progress + || args.retry_check + { + let release_id = required_arg(args.release_id.as_deref(), "--release-id")?; + if args.backtest_progress { + return Ok(WorkflowRequest::get( + format!("/projects/{project}/releases/{release_id}/backtest-progress"), + true, + vec![format!( + "pcl releases --project {project} --release-id {release_id}" + )], + )); + } + if args.retry_check { + let check_id = required_arg(args.check_id.as_deref(), "--check-id")?; + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases/{release_id}/checks/{check_id}/retry"), + true, + Some(body_or_empty(body)), + vec![format!( + "pcl releases --project {project} --release-id {release_id} --backtest-progress" + )], + )); + } + if args.deploy { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases/{release_id}/deploy"), + true, + body, + vec![format!( + "pcl releases --project {project} --release-id {release_id}" + )], + )); + } + if args.remove { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases/{release_id}/remove"), + true, + body, + vec![format!("pcl releases --project {project}")], + )); + } + if args.deploy_calldata { + let signer_address = required_arg(args.signer_address.as_deref(), "--signer-address")?; + let mut request = WorkflowRequest::get( + format!("/projects/{project}/releases/{release_id}/deploy-calldata"), + true, + vec![format!( + "pcl releases --project {project} --release-id {release_id} --deploy" + )], + ); + push_query(&mut request.query, "signerAddress", Some(signer_address)); + return Ok(request); + } + return Ok(WorkflowRequest::get( + format!("/projects/{project}/releases/{release_id}/remove-calldata"), + true, + vec![format!( + "pcl releases --project {project} --release-id {release_id} --remove" + )], + )); + } + let Some(release_id) = &args.release_id else { + return Ok(WorkflowRequest::get( + format!("/projects/{project}/releases"), + true, + vec![format!( + "pcl releases --project {project} --release-id " + )], + )); + }; + Ok(WorkflowRequest::get( + format!("/projects/{project}/releases/{release_id}"), + true, + vec![ + format!( + "pcl releases --project {project} --release-id {release_id} --deploy-calldata --signer-address " + ), + format!("pcl releases --project {project} --release-id {release_id} --remove-calldata"), + ], + )) +} + +pub(super) fn deployments_request( + args: &DeploymentsArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + let project = required_project_arg(args.project.as_deref(), "deployments", "--project")?; + if args.confirm { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/confirm-deployment"), + true, + body, + vec![format!("pcl deployments --project {project}")], + )); + } + Ok(WorkflowRequest::get( + format!("/views/projects/{project}/deployments"), + true, + vec![format!("pcl releases --project {project}")], + )) +} + +pub(super) fn access_request(args: &AccessArgs) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + if args.pending { + return Ok(WorkflowRequest::get( + "/invitations/pending", + true, + ["pcl access --token --accept"], + )); + } + if args.accept || args.preview { + let token = required_arg(args.token.as_deref(), "--token")?; + if args.accept { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/invitations/{token}/accept"), + true, + Some(body_or_empty(body)), + ["pcl projects --mine"], + )); + } + return Ok(WorkflowRequest::get( + format!("/invitations/{token}/preview"), + false, + vec![format!("pcl access --token {token} --accept")], + )); + } + if let Some(token) = &args.token { + return Ok(WorkflowRequest::get( + format!("/invitations/{token}/preview"), + false, + vec![format!("pcl access --token {token} --accept")], + )); + } + let project = required_project_arg(args.project.as_deref(), "access", "--project")?; + if args.my_role { + return Ok(WorkflowRequest::get( + format!("/projects/{project}/my-role"), + true, + vec![format!("pcl access --project {project} --members")], + )); + } + if args.invite { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/invitations"), + true, + body, + vec![format!("pcl access --project {project} --invitations")], + )); + } + if args.resend || args.revoke { + let invitation_id = required_arg(args.invitation_id.as_deref(), "--invitation-id")?; + if args.resend { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/invitations/{invitation_id}/resend"), + true, + Some(body_or_empty(body)), + vec![format!("pcl access --project {project} --invitations")], + )); + } + return Ok(workflow_with_body( + HttpMethod::Delete, + format!("/projects/{project}/invitations/{invitation_id}"), + true, + body, + vec![format!("pcl access --project {project} --invitations")], + )); + } + if args.update_role || args.remove { + let member_user_id = required_arg(args.member_user_id.as_deref(), "--member-user-id")?; + if args.update_role { + return Ok(workflow_with_body( + HttpMethod::Patch, + format!("/projects/{project}/members/{member_user_id}"), + true, + body, + vec![format!("pcl access --project {project} --members")], + )); + } + return Ok(workflow_with_body( + HttpMethod::Delete, + format!("/projects/{project}/members/{member_user_id}"), + true, + body, + vec![format!("pcl access --project {project} --members")], + )); + } + if args.invitations { + return Ok(WorkflowRequest::get( + format!("/projects/{project}/invitations"), + true, + vec![format!( + "pcl access --project {project} --invite --body-template" + )], + )); + } + Ok(WorkflowRequest::get( + format!("/projects/{project}/members"), + true, + vec![ + format!("pcl access --project {project} --my-role"), + format!("pcl access --project {project} --invitations"), + ], + )) +} + +pub(super) fn integrations_request( + args: &IntegrationsArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + let project = required_project_arg(args.project.as_deref(), "integrations", "--project")?; + let Some(provider) = args.provider else { + return Err(ApiCommandError::InvalidWorkflowWithActions { + message: "--provider is required".to_string(), + next_actions: vec![ + "pcl integrations --project --provider slack".to_string(), + "pcl integrations --project --provider pagerduty".to_string(), + "pcl integrations --help".to_string(), + ], + }); + }; + let provider = provider.path(); + let base = format!("/projects/{project}/integrations/{provider}"); + if args.configure { + return Ok(workflow_with_body( + HttpMethod::Post, + base, + true, + body, + vec![format!( + "pcl integrations --project {project} --provider {provider}" + )], + )); + } + if args.test { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("{base}/test"), + true, + Some(body_or_empty(body)), + vec![format!( + "pcl integrations --project {project} --provider {provider}" + )], + )); + } + if args.delete { + return Ok(workflow_with_body( + HttpMethod::Delete, + base, + true, + body, + vec![format!( + "pcl integrations --project {project} --provider {provider}" + )], + )); + } + Ok(WorkflowRequest::get( + base, + true, + vec![ + format!("pcl integrations --project {project} --provider {provider} --test"), + format!( + "pcl integrations --project {project} --provider {provider} --configure --body-template" + ), + ], + )) +} + +pub(super) fn protocol_manager_request( + args: &ProtocolManagerArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + let project = required_project_arg(args.project.as_deref(), "protocol-manager", "--project")?; + let base = format!("/projects/{project}/protocol-manager"); + if args.nonce { + let address = required_arg(args.address.as_deref(), "--address")?; + let mut request = WorkflowRequest::get( + format!("{base}/nonce"), + true, + vec![format!( + "pcl protocol-manager --project {project} --set --body-template" + )], + ); + push_query(&mut request.query, "address", Some(address)); + push_query(&mut request.query, "chain_id", args.chain_id); + return Ok(request); + } + if args.set { + return Ok(workflow_with_body( + HttpMethod::Post, + base, + true, + body, + vec![format!( + "pcl protocol-manager --project {project} --pending-transfer" + )], + )); + } + if args.clear { + return Ok(workflow_with_body( + HttpMethod::Delete, + base, + true, + body, + vec![format!( + "pcl protocol-manager --project {project} --nonce --address " + )], + )); + } + if args.transfer_calldata { + let new_manager = required_arg(args.new_manager.as_deref(), "--new-manager")?; + let mut request = WorkflowRequest::get( + format!("{base}/transfer-calldata"), + true, + vec![format!( + "pcl protocol-manager --project {project} --set --body-template" + )], + ); + push_query(&mut request.query, "new_manager", Some(new_manager)); + return Ok(request); + } + if args.accept_calldata { + return Ok(WorkflowRequest::get( + format!("{base}/accept-calldata"), + true, + vec![format!( + "pcl protocol-manager --project {project} --confirm-transfer --body-template" + )], + )); + } + if args.confirm_transfer { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("{base}/confirm-transfer"), + true, + body, + vec![format!( + "pcl protocol-manager --project {project} --pending-transfer" + )], + )); + } + Ok(WorkflowRequest::get( + format!("{base}/pending-transfer"), + true, + vec![ + format!("pcl protocol-manager --project {project} --nonce --address "), + format!( + "pcl protocol-manager --project {project} --transfer-calldata --new-manager " + ), + ], + )) +} + +pub(super) fn transfers_request(args: &TransfersArgs) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + if args.reject { + return Ok(workflow_with_body( + HttpMethod::Post, + "/transfers/reject", + true, + body, + ["pcl transfers --pending"], + )); + } + if let Some(transfer_id) = &args.transfer_id { + return Ok(WorkflowRequest::get( + format!("/views/transfers/{transfer_id}"), + true, + ["pcl transfers --pending"], + )); + } + Ok(WorkflowRequest::get( + "/views/transfers/pending", + true, + ["pcl transfers --transfer-id "], + )) +} + +pub(super) fn events_request(args: &EventsArgs) -> Result { + let project = required_project_arg(args.project.as_deref(), "events", "--project")?; + let mut request = if args.audit_log { + WorkflowRequest::get( + format!("/views/projects/{project}/audit-log"), + true, + vec![format!("pcl events --project {project}")], + ) + } else { + WorkflowRequest::get( + format!("/views/projects/{project}/events"), + true, + vec![format!("pcl events --project {project} --audit-log")], + ) + }; + push_query(&mut request.query, "page", args.page); + push_query(&mut request.query, "limit", args.limit); + push_query( + &mut request.query, + "environment", + args.environment.as_deref(), + ); + Ok(request) +} + +fn workflow_with_body( + method: HttpMethod, + path: impl Into, + require_auth: bool, + body: Option, + next_actions: impl IntoIterator>, +) -> WorkflowRequest { + WorkflowRequest { + method, + path: path.into(), + query: Vec::new(), + body, + require_auth, + next_actions: next_actions.into_iter().map(Into::into).collect(), + } +} + +fn body_or_empty(body: Option) -> String { + body.unwrap_or_else(|| "{}".to_string()) +} + +pub(super) fn request_body( + body: Option<&str>, + body_file: Option<&PathBuf>, + fields: &[String], +) -> Result, ApiCommandError> { + let body = read_body(body, body_file)?; + body_with_fields(body, fields) +} + +fn project_request_body(args: &ProjectsArgs) -> Result, ApiCommandError> { + let body = read_body(args.body.as_deref(), args.body_file.as_ref())?; + let mut object = match body { + Some(body) => serde_json::from_str::(&body)?, + None => Value::Object(Map::new()), + }; + let Value::Object(map) = &mut object else { + return Err(ApiCommandError::InvalidWorkflow { + message: "project body must be a JSON object".to_string(), + }); + }; + + insert_optional( + map, + "project_name", + args.project_name.clone().map(Value::String), + ); + insert_optional( + map, + "project_description", + args.project_description.clone().map(Value::String), + ); + insert_optional( + map, + "profile_image_url", + args.profile_image_url.clone().map(Value::String), + ); + insert_optional( + map, + "github_url", + args.github_url.clone().map(Value::String), + ); + insert_optional(map, "chain_id", args.chain_id.map(|value| json!(value))); + insert_optional(map, "is_private", args.is_private.map(|value| json!(value))); + insert_optional(map, "is_dev", args.is_dev.map(|value| json!(value))); + apply_fields(map, &args.field)?; + + if map.is_empty() { + Ok(None) + } else { + Ok(Some(Value::Object(map.clone()).to_string())) + } +} + +fn body_with_fields( + body: Option, + fields: &[String], +) -> Result, ApiCommandError> { + if fields.is_empty() { + return Ok(body); + } + let mut value = match body { + Some(body) => serde_json::from_str::(&body)?, + None => Value::Object(Map::new()), + }; + let Value::Object(map) = &mut value else { + return Err(ApiCommandError::InvalidWorkflow { + message: "--field requires the request body to be a JSON object".to_string(), + }); + }; + apply_fields(map, fields)?; + Ok(Some(Value::Object(map.clone()).to_string())) +} + +fn apply_fields(map: &mut Map, fields: &[String]) -> Result<(), ApiCommandError> { + for field in fields { + let (key, value) = field.split_once('=').ok_or_else(|| { + ApiCommandError::InvalidKeyValue { + kind: "field", + input: field.clone(), + } + })?; + map.insert(key.to_string(), parse_field_value(value)); + } + Ok(()) +} + +fn parse_field_value(value: &str) -> Value { + serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())) +} + +fn insert_optional(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + map.insert(key.to_string(), value); + } +} + +fn required_arg(value: Option<&str>, name: &str) -> Result { + value.map(ToString::to_string).ok_or_else(|| { + ApiCommandError::InvalidWorkflow { + message: format!("{name} is required"), + } + }) +} + +fn required_arg_with_actions( + value: Option<&str>, + name: &str, + next_actions: Vec, +) -> Result { + value.map(ToString::to_string).ok_or_else(|| { + ApiCommandError::InvalidWorkflowWithActions { + message: format!("{name} is required"), + next_actions, + } + }) +} + +fn required_project_arg( + value: Option<&str>, + command: &str, + flag: &str, +) -> Result { + required_arg_with_actions( + value, + flag, + vec![ + "pcl projects --mine".to_string(), + format!("pcl {command} {flag} "), + format!("pcl {command} --help"), + ], + ) +} + +pub(super) fn project_segment(path: &str) -> Option<(&'static str, &str, &str)> { + if let Some(rest) = path.strip_prefix("/projects/") { + let (segment, suffix) = split_first_segment(rest); + if matches!(segment, "saved" | "resolve") { + return None; + } + return Some(("/projects/", segment, suffix)); + } + if let Some(rest) = path.strip_prefix("/views/projects/") { + let (segment, suffix) = split_first_segment(rest); + if segment == "home" { + return None; + } + return Some(("/views/projects/", segment, suffix)); + } + None +} + +fn split_first_segment(path: &str) -> (&str, &str) { + path.split_once('/').map_or((path, ""), |(segment, _rest)| { + (segment, &path[segment.len()..]) + }) +} + +pub(super) fn incidents_request(args: &IncidentsArgs) -> Result { + if args.all && (args.incident_id.is_some() || args.stats || args.retry_trace) { + return Err(ApiCommandError::InvalidWorkflow { + message: "--all is only supported for incident list workflows".to_string(), + }); + } + if args.stats && args.project_id.is_none() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--stats requires --project-id".to_string(), + }); + } + if args.tx_id.is_some() && args.incident_id.is_none() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--tx-id requires --incident-id".to_string(), + }); + } + if args.retry_trace && args.tx_id.is_none() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--retry-trace requires --incident-id and --tx-id".to_string(), + }); + } + + let mut query = Vec::new(); + push_query(&mut query, "page", args.page); + push_query(&mut query, "limit", args.limit); + + if let Some(incident_id) = &args.incident_id { + if args.retry_trace { + let tx_id = required_arg(args.tx_id.as_deref(), "--tx-id")?; + return Ok(WorkflowRequest { + method: HttpMethod::Post, + path: format!("/incidents/{incident_id}/transactions/{tx_id}/trace/retry"), + query, + body: Some("{}".to_string()), + require_auth: true, + next_actions: vec![format!( + "pcl incidents --incident-id {incident_id} --tx-id {tx_id}" + )], + }); + } + let path = if let Some(tx_id) = &args.tx_id { + format!("/views/incidents/{incident_id}/transactions/{tx_id}/trace") + } else { + format!("/views/incidents/{incident_id}") + }; + let next_actions = vec![ + "pcl incidents --limit 5".to_string(), + format!("pcl api inspect get {}", path), + ]; + return Ok(WorkflowRequest::get_with_query( + path, + query, + true, + next_actions, + )); + } + + if let Some(project_id) = &args.project_id { + if args.stats { + let path = format!("/projects/{project_id}/incidents/stats"); + return Ok(WorkflowRequest::get_with_query( + path, + query, + true, + vec![format!( + "pcl incidents --project-id {project_id} --limit 10" + )], + )); + } + push_query(&mut query, "assertionId", args.assertion_id.as_deref()); + push_query( + &mut query, + "assertionAdopterId", + args.assertion_adopter_id.as_deref(), + ); + push_query(&mut query, "environment", args.environment.as_deref()); + push_query(&mut query, "fromDate", args.from_date.as_deref()); + push_query(&mut query, "toDate", args.to_date.as_deref()); + let path = format!("/views/projects/{project_id}/incidents"); + return Ok(WorkflowRequest::get_with_query( + path, + query, + true, + vec![ + format!("pcl assertions --project-id {project_id}"), + "pcl incidents --limit 5".to_string(), + ], + )); + } + + push_query(&mut query, "network", args.network); + push_query(&mut query, "sort", args.sort.as_deref()); + push_query(&mut query, "devMode", args.dev_mode.as_deref()); + Ok(WorkflowRequest::get_with_query( + "/views/public/incidents", + query, + false, + vec![ + "pcl incidents --project-id --limit 10".to_string(), + "pcl projects --limit 10".to_string(), + ], + )) +} + +pub(super) fn incidents_next_actions( + data: &Value, + args: &IncidentsArgs, + fallback: Vec, +) -> Vec { + if let Some(incident_id) = &args.incident_id { + if args.tx_id.is_none() + && let Some(tx_id) = data + .get("data") + .and_then(|data| data.get("invalidating_transactions")) + .and_then(Value::as_array) + .and_then(|transactions| transactions.first()) + .and_then(|transaction| { + first_string_field(transaction, &["transaction_hash", "id", "tx_id"]) + }) + { + return vec![ + format!("pcl incidents --incident-id {incident_id} --tx-id {tx_id}"), + "pcl incidents --limit 5".to_string(), + ]; + } + return fallback; + } + first_string_field(data, &["id", "incidentId", "incident_id"]).map_or(fallback, |incident_id| { + vec![ + format!("pcl incidents --incident-id {incident_id}"), + "pcl projects --limit 10".to_string(), + ] + }) +} + +pub(super) fn projects_next_actions(data: &Value, fallback: Vec) -> Vec { + if let Some(project_id) = data.get("project_id").and_then(Value::as_str) { + return vec![ + format!("pcl assertions --project-id {project_id}"), + format!("pcl incidents --project-id {project_id} --limit 10"), + ]; + } + first_string_field(data, &["project_id", "projectId", "id"]).map_or(fallback, |project_id| { + vec![ + format!("pcl projects --project-id {project_id}"), + format!("pcl assertions --project-id {project_id}"), + format!("pcl incidents --project-id {project_id} --limit 10"), + ] + }) +} + +pub(super) fn assertions_next_actions( + data: &Value, + args: &AssertionsArgs, + fallback: Vec, +) -> Vec { + let Some(project_id) = &args.project_id else { + return first_string_field( + data, + &["assertion_adopter_address", "adopter_address", "address"], + ) + .map_or(fallback, |address| { + vec![format!("pcl assertions --adopter-address {address}")] + }); + }; + + first_string_field(data, &["assertion_id", "assertionId", "id"]).map_or( + fallback, + |assertion_id| { + vec![ + format!("pcl assertions --project-id {project_id} --assertion-id {assertion_id}",), + format!("pcl incidents --project-id {project_id} --assertion-id {assertion_id}",), + ] + }, + ) +} + +pub(super) fn search_next_actions(data: &Value, fallback: Vec) -> Vec { + if let Some(project_id) = data + .get("projects") + .and_then(Value::as_array) + .and_then(|projects| projects.first()) + .and_then(|project| first_string_field(project, &["project_id", "projectId", "id", "slug"])) + { + return vec![ + format!("pcl projects --project-id {project_id}"), + format!("pcl contracts --project {project_id}"), + ]; + } + if let Some(project_id) = data + .get("contracts") + .and_then(Value::as_array) + .and_then(|contracts| contracts.first()) + .and_then(|contract| { + contract.get("data").map_or_else( + || first_string_field(contract, &["related_project_id", "related_project_slug"]), + |inner| first_string_field(inner, &["related_project_id", "related_project_slug"]), + ) + }) + { + return vec![ + format!("pcl projects --project-id {project_id}"), + format!("pcl contracts --project {project_id}"), + ]; + } + fallback +} + +pub(super) fn first_string_field(value: &Value, keys: &[&str]) -> Option { + match value { + Value::Object(object) => { + for key in keys { + if let Some(value) = object.get(*key).and_then(Value::as_str) { + return Some(value.to_string()); + } + } + object + .values() + .find_map(|value| first_string_field(value, keys)) + } + Value::Array(values) => { + values + .iter() + .find_map(|value| first_string_field(value, keys)) + } + _ => None, + } +} + +pub(super) fn projects_request(args: &ProjectsArgs) -> Result { + let mut query = Vec::new(); + push_query(&mut query, "page", args.page); + push_query(&mut query, "limit", args.limit); + push_query(&mut query, "search", args.search.as_deref()); + let body = project_request_body(args)?; + + if args.create { + return Ok(workflow_with_body( + HttpMethod::Post, + "/projects", + true, + body, + vec!["pcl projects --mine".to_string()], + )); + } + + if args.mine { + return Ok(WorkflowRequest::get_with_query( + "/views/projects/home", + query, + true, + vec![ + "pcl account".to_string(), + "pcl projects --saved --user-id ".to_string(), + ], + )); + } + if args.saved { + let user_id = required_arg(args.user_id.as_deref(), "--user-id")?; + push_query(&mut query, "user_id", Some(user_id)); + return Ok(WorkflowRequest::get_with_query( + "/projects/saved", + query, + true, + vec!["pcl projects --mine".to_string()], + )); + } + if args.project_id.is_none() + && (args.update || args.delete || args.save || args.unsave || args.resolve || args.widget) + { + required_project_arg(args.project_id.as_deref(), "projects", "--project-id")?; + } + if let Some(project_id) = &args.project_id { + if args.resolve { + return Ok(WorkflowRequest::get_with_query( + format!("/projects/resolve/{project_id}"), + query, + false, + vec![format!("pcl projects --project-id {project_id}")], + )); + } + if args.widget { + return Ok(WorkflowRequest::get( + format!("/projects/{project_id}/widget"), + true, + vec![format!("pcl projects --project-id {project_id}")], + )); + } + if args.save || args.unsave { + return Ok(workflow_with_body( + if args.save { + HttpMethod::Post + } else { + HttpMethod::Delete + }, + "/projects/saved", + true, + Some(json!({ "project_id": project_id }).to_string()), + vec![ + format!("pcl projects --project-id {project_id}"), + "pcl projects --mine".to_string(), + ], + )); + } + if args.update { + return Ok(workflow_with_body( + HttpMethod::Put, + format!("/projects/{project_id}"), + true, + body, + vec![format!("pcl projects --project-id {project_id}")], + )); + } + if args.delete { + return Ok(workflow_with_body( + HttpMethod::Delete, + format!("/projects/{project_id}"), + true, + body, + ["pcl projects --mine"], + )); + } + return Ok(WorkflowRequest::get_with_query( + format!("/projects/{project_id}"), + query, + true, + vec![ + format!("pcl assertions --project-id {project_id}"), + format!("pcl incidents --project-id {project_id} --limit 10"), + ], + )); + } + + Ok(WorkflowRequest::get_with_query( + "/views/projects", + query, + false, + [ + "pcl projects --project-id ", + "pcl incidents --limit 5", + ], + )) +} + +pub(super) fn assertions_request( + args: &AssertionsArgs, +) -> Result { + if args.submit || args.submitted { + return Err(ApiCommandError::InvalidWorkflow { + message: + "Submitted assertions have been removed from the API; use releases and registered assertions instead" + .to_string(), + }); + } + + if let Some(adopter_address) = &args.adopter_address { + let mut request = WorkflowRequest::get( + "/assertions", + false, + ["pcl contracts --project "], + ); + push_query(&mut request.query, "adopter_address", Some(adopter_address)); + push_query(&mut request.query, "network", args.network.as_deref()); + push_query( + &mut request.query, + "environment", + args.environment.as_deref(), + ); + push_query( + &mut request.query, + "include_onchain_only", + args.include_onchain_only, + ); + return Ok(request); + } + + let project_id = + required_project_arg(args.project_id.as_deref(), "assertions", "--project-id")?; + let mut query = Vec::new(); + push_query(&mut query, "page", args.page); + push_query(&mut query, "limit", args.limit); + push_query(&mut query, "assertionAdopterId", args.adopter_id.as_deref()); + push_query(&mut query, "environment", args.environment.as_deref()); + + if args.registered { + return Ok(WorkflowRequest::get( + format!("/projects/{project_id}/registered-assertions"), + true, + vec![format!("pcl assertions --project-id {project_id}")], + )); + } + if args.remove_info { + return Ok(WorkflowRequest::get( + format!("/projects/{project_id}/remove-assertions-info"), + true, + vec![format!( + "pcl assertions --project-id {project_id} --remove-calldata" + )], + )); + } + if args.remove_calldata { + return Ok(WorkflowRequest::get( + format!("/projects/{project_id}/remove-assertions-calldata"), + true, + vec![format!("pcl releases --project {project_id}")], + )); + } + + if let Some(assertion_id) = &args.assertion_id { + return Ok(WorkflowRequest::get_with_query( + format!("/views/projects/{project_id}/assertions/{assertion_id}"), + query, + true, + vec![format!( + "pcl incidents --project-id {project_id} --assertion-id {assertion_id}", + )], + )); + } + + Ok(WorkflowRequest::get_with_query( + format!("/views/projects/{project_id}/assertions"), + query, + true, + vec![ + format!("pcl incidents --project-id {project_id} --limit 10"), + format!("pcl assertions --project-id {project_id} --assertion-id "), + ], + )) +} + +fn push_query(query: &mut Vec<(String, String)>, name: &str, value: Option) { + if let Some(value) = value { + query.push((name.to_string(), value.to_string())); + } +}