vimanam 2.0.0

OpenAPI/Swagger to Markdown documentation generator with grouping, filtering, and detail levels for docs and LLM context
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! Format-neutral contract surfaces and adapter-classified change records.
//!
//! OpenAPI is the first adapter. GraphQL and protobuf native locations are
//! representable without making HTTP concepts part of neutral identity.

pub mod json;
pub mod openapi;
pub mod protobuf;
use crate::diff::json::{canonical_json, sha256_hex};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

/// Source contract language (not its document version).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Format {
    /// OpenAPI or Swagger.
    Openapi,
    /// GraphQL SDL or introspection.
    Graphql,
    /// Protocol Buffers or descriptors.
    Protobuf,
}

/// A native coordinate. Pointers in OpenAPI address normalized schemas when
/// `resolved` is true; they must not be interpreted as input-file pointers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "format", rename_all = "snake_case")]
pub enum NativeLocation {
    /// OpenAPI HTTP or reusable-schema coordinate.
    Openapi {
        /// HTTP method, when operation-scoped.
        method: Option<String>,
        /// Exact template without basePath.
        path: Option<String>,
        /// Parameter location, when applicable.
        parameter_in: Option<String>,
        /// Response status, when applicable.
        status: Option<String>,
        /// Normalized schema or surface coordinate.
        pointer: String,
        /// Whether refs were expanded.
        resolved: bool,
    },
    /// GraphQL schema coordinate, including Type.field(arg:).
    Graphql {
        /// Schema coordinate.
        coordinate: String,
    },
    /// Protobuf symbol and optional wire identity.
    Protobuf {
        /// Fully qualified symbol.
        full_name: String,
        /// Field or enum number, if applicable.
        number: Option<i32>,
    },
}

/// Role of a contract entity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EntityKind {
    /// Service or GraphQL root.
    Service,
    /// HTTP operation, root field or RPC.
    Operation,
    /// Named type.
    Type,
    /// Object/input/message field.
    Field,
    /// Operation/field input argument.
    Argument,
    /// Result variant (HTTP status, RPC output).
    Result,
    /// Enum value.
    Value,
}

/// A node in the neutral contract surface. Parent links form a forest;
/// references represent shared types and recursive edges without expansion.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Entity {
    /// Stable identity, independent of annotations and document order.
    pub id: String,
    /// Entity category.
    pub kind: EntityKind,
    /// Native display name.
    pub name: String,
    /// Containing entity, absent for roots.
    pub parent: Option<String>,
    /// Service memberships; operations may have several.
    pub services: Vec<String>,
    /// Source coordinate.
    pub native: NativeLocation,
    /// Contract-language-specific attributes, including schema constraints.
    pub attributes: Value,
    /// Referenced named type IDs (including cyclic references).
    pub references: Vec<String>,
}

/// Complete neutral surface for a single source document.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Contract {
    /// Source language.
    pub format: Format,
    /// Declared API title.
    pub title: String,
    /// Declared API version.
    pub version: String,
    /// Nodes in deterministic native order.
    pub entities: Vec<Entity>,
}

/// Format-neutral change vocabulary. Native detail covers refinements that do
/// not fit these categories without inventing cross-format equivalences.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ChangeKind {
    /// A service appeared.
    ServiceAdded,
    /// A service disappeared.
    ServiceRemoved,
    /// An operation appeared.
    OperationAdded,
    /// An operation disappeared.
    OperationRemoved,
    /// An operation's declared name changed.
    OperationRenamed,
    /// A type appeared.
    TypeAdded,
    /// A type disappeared.
    TypeRemoved,
    /// A type category changed.
    TypeKindChanged,
    /// An argument appeared.
    ArgumentAdded,
    /// An argument disappeared.
    ArgumentRemoved,
    /// Requiredness changed.
    ArgumentRequiredChanged,
    /// An argument type changed.
    ArgumentTypeChanged,
    /// A field appeared.
    FieldAdded,
    /// A field disappeared.
    FieldRemoved,
    /// A field type changed.
    FieldTypeChanged,
    /// Requiredness changed.
    FieldRequiredChanged,
    /// Nullability changed.
    NullabilityChanged,
    /// An enum value appeared.
    EnumValueAdded,
    /// An enum value disappeared.
    EnumValueRemoved,
    /// Deprecation changed.
    DeprecatedChanged,
    /// A default changed.
    DefaultChanged,
    /// Pagination shape/controls changed.
    PaginationChanged,
    /// Wire or transport identity changed.
    WireIdentityChanged,
    /// A result variant appeared.
    ResultAdded,
    /// A result variant disappeared.
    ResultRemoved,
    /// Another constraint changed.
    ConstraintChanged,
}

/// Adapter verdict, deliberately independent of neutral kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Severity {
    /// Existing clients can break.
    Breaking,
    /// Additive or relaxing.
    NonBreaking,
    /// Usage-dependent or inferred.
    Review,
}

/// One neutral fact with its adapter verdict and native explanation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Change {
    /// Content-derived vc2 identifier.
    pub id: String,
    /// Stable ID of the changed entity or containing schema root.
    pub subject: String,
    /// Neutral fact.
    pub kind: ChangeKind,
    /// Adapter verdict.
    pub severity: Severity,
    /// Compatibility dimensions (e.g. source, wire, wire_json).
    pub impacts: Vec<String>,
    /// Native location, retaining format-specific attribution.
    pub native: NativeLocation,
    /// Adapter's native change kind.
    pub native_kind: String,
    /// Lossless adapter-specific change details; absence is explicit.
    pub details: Value,
}

impl Change {
    /// Recompute the stable ID. Severity, impacts and native display details
    /// are excluded; subject, neutral kind and fact details are hashed.
    pub fn refresh_id(&mut self) {
        self.id = change_id(self);
    }
}

/// Stable entity ID: SHA-256 of canonical {v:2, format, kind, coordinate}.
pub fn entity_id(format: Format, kind: EntityKind, coordinate: &str) -> String {
    format!(
        "ve2_{}",
        sha256_hex(
            canonical_json(&json!({"v":2,"format":format,"kind":kind,"coordinate":coordinate}))
                .as_bytes()
        )
    )
}

/// Stable neutral change ID: SHA-256 of canonical {v:2, subject, kind, details}.
pub fn change_id(change: &Change) -> String {
    format!(
        "vc2_{}",
        sha256_hex(
            canonical_json(
                &json!({"v":2,"subject":change.subject,"kind":change.kind,"details":change.details})
            )
            .as_bytes()
        )
    )
}

/// An adapter owns compatibility classification. Neutral kinds alone cannot
/// decide directional nullability, source compatibility or wire compatibility.
pub trait Adapter {
    /// Normalize attributes before comparison; default compares all values.
    fn normalize_attributes(&self, subject: &Entity) -> Value {
        subject.attributes.clone()
    }

    /// Refine a native location for an attribute pointer.
    fn change_location(&self, subject: &Entity, _pointer: &str) -> NativeLocation {
        subject.native.clone()
    }

    /// Map an adapter-specific attribute difference to a neutral kind.
    /// Default preserves an opaque constraint fact for custom adapters.
    fn attribute_kind(
        &self,
        _subject: &Entity,
        _pointer: &str,
        _change: &crate::diff::ValueChangeKind,
    ) -> ChangeKind {
        ChangeKind::ConstraintChanged
    }

    /// Map a structural edge or native identity difference to a neutral fact.
    /// Returning `None` ignores an adapter-specific representation detail.
    fn structural_kind(&self, _subject: &Entity, _field: &str) -> Option<ChangeKind> {
        Some(ChangeKind::ConstraintChanged)
    }

    /// Classify a fact about this subject (including input/output context).
    fn classify(
        &self,
        subject: &Entity,
        kind: ChangeKind,
        details: &Value,
    ) -> (Severity, Vec<String>);
}

/// Neutral comparison for adapter-created surfaces. Adapters can add composite
/// facts (pagination, renames) after this basic identity/attribute comparison.
/// Changed attributes retain explicit presence and are classified by the adapter.
pub fn compare(
    old: &Contract,
    new: &Contract,
    adapter: &impl Adapter,
) -> anyhow::Result<Vec<Change>> {
    anyhow::ensure!(
        old.format == new.format,
        "Cannot compare different contract formats"
    );
    let index = |contract: &Contract| -> anyhow::Result<std::collections::HashMap<String, usize>> {
        let mut map = std::collections::HashMap::with_capacity(contract.entities.len());
        for (i, entity) in contract.entities.iter().enumerate() {
            anyhow::ensure!(
                map.insert(entity.id.clone(), i).is_none(),
                "Duplicate contract entity ID: {}",
                entity.id
            );
        }
        Ok(map)
    };
    let old_index = index(old)?;
    let new_index = index(new)?;
    let mut changes = Vec::new();
    for entity in &old.entities {
        if let Some(&position) = new_index.get(&entity.id) {
            let next = &new.entities[position];
            let mut values = Vec::new();
            crate::diff::diff_values(
                &adapter.normalize_attributes(entity),
                &adapter.normalize_attributes(next),
                &mut String::new(),
                &mut values,
            );
            for value in values {
                use crate::diff::ValueChangeKind as V;
                let (before, after) = match &value.kind {
                    V::Added(v) => (json!({"present":false}), json!({"present":true,"value":v})),
                    V::Removed(v) => (json!({"present":true,"value":v}), json!({"present":false})),
                    V::Changed { old, new } => (
                        json!({"present":true,"value":old}),
                        json!({"present":true,"value":new}),
                    ),
                };
                let kind = adapter.attribute_kind(entity, &value.pointer, &value.kind);
                let details = json!({"pointer":value.pointer,"before":before,"after":after});
                push(&mut changes, entity, kind, details, adapter);
            }
            for (field, before, after) in structural_values(entity, next) {
                if before != after
                    && let Some(kind) = adapter.structural_kind(entity, field)
                {
                    push(
                        &mut changes,
                        entity,
                        kind,
                        json!({"structural":field,"before":before,"after":after}),
                        adapter,
                    );
                }
            }
        } else {
            push(
                &mut changes,
                entity,
                removed(entity.kind),
                json!({}),
                adapter,
            );
        }
    }
    for entity in &new.entities {
        if !old_index.contains_key(&entity.id) {
            push(&mut changes, entity, added(entity.kind), json!({}), adapter);
        }
    }
    Ok(changes)
}

fn structural_values(old: &Entity, new: &Entity) -> Vec<(&'static str, Value, Value)> {
    let mut old_services = old.services.clone();
    let mut new_services = new.services.clone();
    old_services.sort();
    new_services.sort();
    let mut old_references = old.references.clone();
    let mut new_references = new.references.clone();
    old_references.sort();
    new_references.sort();
    vec![
        ("name", json!(old.name), json!(new.name)),
        ("parent", json!(old.parent), json!(new.parent)),
        ("services", json!(old_services), json!(new_services)),
        ("references", json!(old_references), json!(new_references)),
        ("native", json!(old.native), json!(new.native)),
    ]
}

fn push(
    changes: &mut Vec<Change>,
    entity: &Entity,
    kind: ChangeKind,
    details: Value,
    adapter: &impl Adapter,
) {
    let (severity, impacts) = adapter.classify(entity, kind, &details);
    let native = adapter.change_location(
        entity,
        details.get("pointer").and_then(Value::as_str).unwrap_or(""),
    );
    let mut change = Change {
        id: String::new(),
        subject: entity.id.clone(),
        kind,
        severity,
        impacts,
        native,
        native_kind: "surface_changed".into(),
        details,
    };
    change.refresh_id();
    changes.push(change);
}
fn added(kind: EntityKind) -> ChangeKind {
    match kind {
        EntityKind::Service => ChangeKind::ServiceAdded,
        EntityKind::Operation => ChangeKind::OperationAdded,
        EntityKind::Type => ChangeKind::TypeAdded,
        EntityKind::Field => ChangeKind::FieldAdded,
        EntityKind::Argument => ChangeKind::ArgumentAdded,
        EntityKind::Result => ChangeKind::ResultAdded,
        EntityKind::Value => ChangeKind::EnumValueAdded,
    }
}
fn removed(kind: EntityKind) -> ChangeKind {
    match kind {
        EntityKind::Service => ChangeKind::ServiceRemoved,
        EntityKind::Operation => ChangeKind::OperationRemoved,
        EntityKind::Type => ChangeKind::TypeRemoved,
        EntityKind::Field => ChangeKind::FieldRemoved,
        EntityKind::Argument => ChangeKind::ArgumentRemoved,
        EntityKind::Result => ChangeKind::ResultRemoved,
        EntityKind::Value => ChangeKind::EnumValueRemoved,
    }
}

// Strip descriptive annotations only at schema objects, not named property keys.
pub(super) fn semantic_attributes(value: &Value) -> Value {
    const SCHEMA_MAPS: &[&str] = &["properties", "patternProperties", "definitions", "$defs"];
    const SCHEMA_CHILDREN: &[&str] = &[
        "schema",
        "items",
        "not",
        "additionalProperties",
        "if",
        "then",
        "else",
    ];
    const SCHEMA_ARRAYS: &[&str] = &["allOf", "oneOf", "anyOf"];
    let Some(map) = value.as_object() else {
        return value.clone();
    };
    let mut output = serde_json::Map::new();
    for (key, child) in map {
        if ["description", "title", "example", "examples"].contains(&key.as_str())
            || key.starts_with("x-")
        {
            continue;
        }
        let normalized = if key == "required" || key == "enum" {
            if let Some(items) = child.as_array() {
                let mut items = items.clone();
                items.sort_by_key(crate::diff::json::canonical_json);
                Value::Array(items)
            } else {
                child.clone()
            }
        } else if SCHEMA_MAPS.contains(&key.as_str()) {
            child
                .as_object()
                .map(|members| {
                    Value::Object(
                        members
                            .iter()
                            .map(|(name, schema)| (name.clone(), semantic_attributes(schema)))
                            .collect(),
                    )
                })
                .unwrap_or_else(|| child.clone())
        } else if SCHEMA_CHILDREN.contains(&key.as_str()) {
            semantic_attributes(child)
        } else if SCHEMA_ARRAYS.contains(&key.as_str()) {
            child
                .as_array()
                .map(|items| Value::Array(items.iter().map(semantic_attributes).collect()))
                .unwrap_or_else(|| child.clone())
        } else {
            child.clone()
        };
        output.insert(key.clone(), normalized);
    }
    Value::Object(output)
}