diff --git a/src/cli/diff.rs b/src/cli/diff.rs index 5f3fa3e..592eb11 100644 --- a/src/cli/diff.rs +++ b/src/cli/diff.rs @@ -79,6 +79,26 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro let has_breaking = result.summary.has_breaking; + // CI gate: check breaking changes before any output to avoid + // contradictory success-JSON-then-error in robot mode. + if args.fail_on.as_deref() == Some("breaking") && has_breaking { + if robot_mode { + let err = SwaggerCliError::Usage( + "Breaking changes detected (use --fail-on to control this check)".into(), + ); + robot::robot_error( + err.code(), + &err.to_string(), + err.suggestion(), + "diff", + duration, + ); + } + return Err(SwaggerCliError::Usage( + "Breaking changes detected (use --fail-on to control this check)".into(), + )); + } + if robot_mode { let output = DiffOutput { left: args.left.clone(), @@ -145,13 +165,6 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro } } - // CI gate: exit non-zero on breaking changes when requested - if args.fail_on.as_deref() == Some("breaking") && has_breaking { - return Err(SwaggerCliError::Usage( - "Breaking changes detected (use --fail-on to control this check)".into(), - )); - } - Ok(()) } diff --git a/src/cli/fetch.rs b/src/cli/fetch.rs index d6bca6b..e5c09c7 100644 --- a/src/cli/fetch.rs +++ b/src/cli/fetch.rs @@ -290,8 +290,7 @@ async fn fetch_inner( Format::Yaml => "yaml", }; - let json_bytes = normalize_to_json(&raw_bytes, format)?; - let mut value: serde_json::Value = serde_json::from_slice(&json_bytes)?; + let (_json_bytes, mut value) = normalize_to_json(&raw_bytes, format)?; // External ref resolution (optional) if args.resolve_external_refs { @@ -322,8 +321,11 @@ async fn fetch_inner( // Re-serialize the (possibly bundled) value to get the final json_bytes let json_bytes = serde_json::to_vec(&value)?; - // Compute content hash for indexing - let content_hash = compute_hash(&raw_bytes); + // Compute content hash from the final json_bytes (post-resolution), not + // the original raw_bytes. When external refs are resolved, the stored + // content differs from the original fetch, so the hash must match what + // is actually written to the cache. + let content_hash = compute_hash(&json_bytes); // Determine generation: if overwriting, increment previous generation let previous_generation = if args.force && cm.alias_exists(&args.alias) { diff --git a/src/cli/sync_cmd.rs b/src/cli/sync_cmd.rs index e14f727..51efedd 100644 --- a/src/cli/sync_cmd.rs +++ b/src/cli/sync_cmd.rs @@ -143,6 +143,8 @@ struct AliasSyncResult { remote_version: Option, #[serde(skip_serializing_if = "Option::is_none")] changes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, duration_ms: u64, } @@ -201,13 +203,15 @@ fn remove_checkpoint(cache_path: &std::path::Path) { // Index diffing // --------------------------------------------------------------------------- -/// Build a comparable key for an endpoint: (path, method). +/// Build a comparable key for an endpoint: (path, METHOD). +/// Method is uppercased for consistent comparison regardless of indexer casing. fn endpoint_key(ep: &crate::core::spec::IndexedEndpoint) -> (String, String) { - (ep.path.clone(), ep.method.clone()) + (ep.path.clone(), ep.method.to_uppercase()) } /// Build a fingerprint of an endpoint for modification detection. -/// Includes summary, parameters, deprecated status, and request body info. +/// Includes all semantically meaningful fields so that changes to security, +/// tags, operation_id, etc. are detected during sync. fn endpoint_fingerprint(ep: &crate::core::spec::IndexedEndpoint) -> String { let params: Vec = ep .parameters @@ -216,12 +220,16 @@ fn endpoint_fingerprint(ep: &crate::core::spec::IndexedEndpoint) -> String { .collect(); format!( - "{}|{}|{}|{}|{}", + "{}|{}|{}|{}|{}|{}|{}|{}|{}", ep.summary.as_deref().unwrap_or(""), ep.deprecated, params.join(","), ep.request_body_required, ep.request_body_content_types.join(","), + ep.security_schemes.join(","), + ep.security_required, + ep.tags.join(","), + ep.operation_id.as_deref().unwrap_or(""), ) } @@ -378,6 +386,7 @@ async fn sync_one_alias( local_version: None, remote_version: None, changes: None, + details: None, duration_ms: start.elapsed().as_millis().min(u64::MAX as u128) as u64, }, } @@ -463,6 +472,7 @@ async fn sync_one_alias_inner( local_version: Some(meta.spec_version.clone()), remote_version: None, changes: None, + details: None, duration_ms: elapsed_ms(), }), ConditionalFetchResult::Modified(result) => { @@ -478,6 +488,7 @@ async fn sync_one_alias_inner( local_version: Some(meta.spec_version.clone()), remote_version: None, changes: None, + details: None, duration_ms: elapsed_ms(), }); } @@ -488,11 +499,10 @@ async fn sync_one_alias_inner( Format::Yaml => "yaml", }; - let json_bytes = normalize_to_json(&result.bytes, format)?; - let value: serde_json::Value = serde_json::from_slice(&json_bytes)?; + let (json_bytes, value) = normalize_to_json(&result.bytes, format)?; let new_index = build_index(&value, &new_content_hash, meta.generation + 1)?; - let (summary, _details) = compute_diff(&old_index, &new_index); + let (summary, details) = compute_diff(&old_index, &new_index); let has_changes = summary.endpoints_added > 0 || summary.endpoints_removed > 0 @@ -533,6 +543,7 @@ async fn sync_one_alias_inner( local_version: Some(meta.spec_version.clone()), remote_version: Some(new_index.info.version.clone()), changes: if include_details { Some(summary) } else { None }, + details: if include_details { Some(details) } else { None }, duration_ms: elapsed_ms(), }) } @@ -573,6 +584,7 @@ async fn sync_inner( let cfg = Config::load(&config_path(config_override))?; let mut builder = AsyncHttpClient::builder() .allow_insecure_http(url.starts_with("http://")) + .allowed_private_hosts(args.allow_private_host.clone()) .network_policy(network_policy); if let Some(profile_name) = &args.auth { @@ -637,8 +649,7 @@ async fn sync_inner( Format::Yaml => "yaml", }; - let json_bytes = normalize_to_json(&result.bytes, format)?; - let value: serde_json::Value = serde_json::from_slice(&json_bytes)?; + let (json_bytes, value) = normalize_to_json(&result.bytes, format)?; let new_index = build_index(&value, &new_content_hash, meta.generation + 1)?; // 6. Compute diff @@ -821,12 +832,6 @@ async fn sync_all_inner( }; let total = aliases.len(); - let _skipped_from_resume = if args.resume { - total - to_sync.len() - } else { - 0 - }; - // Handle empty aliases if total == 0 { let output = SyncAllOutput { @@ -890,6 +895,7 @@ async fn sync_all_inner( local_version: None, remote_version: None, changes: None, + details: None, duration_ms: 0, }; } @@ -911,6 +917,7 @@ async fn sync_all_inner( local_version: None, remote_version: None, changes: None, + details: None, duration_ms: 0, }; } @@ -972,6 +979,7 @@ async fn sync_all_inner( local_version: None, remote_version: None, changes: None, + details: None, duration_ms: 0, }); } @@ -985,6 +993,7 @@ async fn sync_all_inner( local_version: None, remote_version: None, changes: None, + details: None, duration_ms: 0, }); }