Skip to main content

linkrs_server/http/
openapi.rs

1//! OpenAPI document assembly and drift guards.
2//!
3//! This module collects every `#[utoipa::path]` annotation into a single
4//! document. Referenced schemas are pulled in automatically by the derive,
5//! so this module only lists paths and tags. Two tests pin the contract:
6//! the serialized snapshot under `frontend/openapi.json` must match, and
7//! every route registered in `router.rs` / `web.rs` / `web/handlers/*.rs`
8//! must have a matching annotation.
9//!
10//! Document split: `CoreDoc` always applies and covers the unconditional
11//! `/v1` handlers plus all `/api` web handlers. `VectorDoc` and
12//! `FulltextDoc` are feature gated and merged on top when the corresponding
13//! cargo feature is enabled.
14//!
15//! Standard commands (run from the workspace root):
16//! ```text
17//! LINKRS_REFRESH_OPENAPI=1 cargo test -p linkrs-server --features vector,fulltext openapi_snapshot_matches
18//! cargo test -p linkrs-server --features vector,fulltext
19//! ```
20
21use std::sync::OnceLock;
22
23use utoipa::OpenApi;
24
25#[derive(OpenApi)]
26#[openapi(
27    info(title = "Linkrs API", version = "1.0.0"),
28    paths(
29        crate::http::handlers::health::check,
30        crate::http::handlers::metrics::metrics,
31        crate::http::handlers::auth::login,
32        crate::http::handlers::auth::logout,
33        crate::http::handlers::auth::me,
34        crate::http::handlers::users::list,
35        crate::http::handlers::users::create,
36        crate::http::handlers::users::drop_user,
37        crate::http::handlers::users::reset_password,
38        crate::http::handlers::users::enable,
39        crate::http::handlers::users::disable,
40        crate::http::handlers::users::grant,
41        crate::http::handlers::users::revoke,
42        crate::http::handlers::session::create,
43        crate::http::handlers::session::list_sessions,
44        crate::http::handlers::session::get_session,
45        crate::http::handlers::session::delete_session,
46        crate::http::handlers::query::execute,
47        crate::http::handlers::query::validate,
48        crate::http::handlers::query::explain,
49        crate::http::handlers::query::execute_batch,
50        crate::http::handlers::transaction::begin,
51        crate::http::handlers::transaction::list_transactions,
52        crate::http::handlers::transaction::metrics,
53        crate::http::handlers::transaction::commit,
54        crate::http::handlers::transaction::rollback,
55        crate::http::handlers::transaction::kill_transaction,
56        crate::http::handlers::transaction::retry_transaction_outbox,
57        crate::http::handlers::transaction::create_savepoint,
58        crate::http::handlers::transaction::get_savepoints,
59        crate::http::handlers::transaction::rollback_to_savepoint,
60        crate::http::handlers::transaction::release_savepoint,
61        crate::http::handlers::batch::create,
62        crate::http::handlers::batch::status,
63        crate::http::handlers::batch::add_items,
64        crate::http::handlers::batch::execute,
65        crate::http::handlers::batch::cancel,
66        crate::http::handlers::batch::delete,
67        crate::http::handlers::import::import_file,
68        crate::http::handlers::import::import_status,
69        crate::http::handlers::export::export_data,
70        crate::http::handlers::statistics::query::session,
71        crate::http::handlers::statistics::query::queries,
72        crate::http::handlers::statistics::query::query_profile_detail,
73        crate::http::handlers::statistics::overview::overview,
74        crate::http::handlers::statistics::database::database,
75        crate::http::handlers::statistics::system::system,
76        crate::http::handlers::statistics::query::search,
77        crate::http::handlers::statistics::freeze::freeze_stats,
78        crate::http::handlers::statistics::freeze::trigger_freeze,
79        crate::http::handlers::statistics::migration::migration,
80        crate::http::handlers::config::get,
81        crate::http::handlers::config::update,
82        crate::http::handlers::config::get_key,
83        crate::http::handlers::config::update_key,
84        crate::http::handlers::config::reset_key,
85        crate::http::handlers::function::register,
86        crate::http::handlers::function::list,
87        crate::http::handlers::function::info,
88        crate::http::handlers::function::unregister,
89        crate::http::handlers::stream::execute_stream,
90        crate::http::handlers::cursor::open_cursor,
91        crate::http::handlers::cursor::fetch_cursor,
92        crate::http::handlers::cursor::close_cursor,
93        crate::http::handlers::sync::status,
94        crate::http::handlers::sync::retry_outbox,
95        crate::http::handlers::sync::diagnostics,
96        crate::http::handlers::sync::dead_letters,
97        crate::http::handlers::sync::requeue,
98        crate::http::handlers::sync::degraded_ranges,
99        crate::http::handlers::sync::degraded_clear,
100        crate::http::handlers::sync::retention_run,
101        crate::http::handlers::sync::retention_status,
102        crate::http::handlers::schema::space::create_space,
103        crate::http::handlers::schema::space::list_spaces,
104        crate::http::handlers::schema::space::get_space,
105        crate::http::handlers::schema::space::drop_space,
106        crate::http::handlers::schema::tag::create_tag,
107        crate::http::handlers::schema::tag::list_tags,
108        crate::http::handlers::schema::edge_type::create_edge_type,
109        crate::http::handlers::schema::edge_type::list_edge_types,
110        crate::http::handlers::schema::version::get_version_history,
111        crate::http::handlers::schema::version::get_schema_changes,
112        crate::http::handlers::schema::version::detect_breaking_changes,
113        crate::http::handlers::schema::migration::create_migration_plan,
114        crate::http::handlers::schema::migration::execute_migration,
115        crate::http::handlers::schema::migration::rollback_migration,
116        crate::http::handlers::schema::migration::dry_run_migration,
117        crate::http::handlers::schema::migration::migration_history,
118        crate::http::handlers::schema::migration::migration_status,
119        crate::http::handlers::migration_progress::migration_progress_stream,
120        crate::web::handlers::metadata::add_history,
121        crate::web::handlers::metadata::list_history,
122        crate::web::handlers::metadata::delete_history,
123        crate::web::handlers::metadata::clear_history,
124        crate::web::handlers::metadata::add_favorite,
125        crate::web::handlers::metadata::list_favorites,
126        crate::web::handlers::metadata::get_favorite,
127        crate::web::handlers::metadata::update_favorite,
128        crate::web::handlers::metadata::delete_favorite,
129        crate::web::handlers::metadata::clear_favorites,
130        crate::web::handlers::schema_ext::space::list_spaces,
131        crate::web::handlers::schema_ext::space::get_space_details,
132        crate::web::handlers::schema_ext::space::get_space_statistics,
133        crate::web::handlers::schema_ext::tag::list_tags,
134        crate::web::handlers::schema_ext::tag::create_tag,
135        crate::web::handlers::schema_ext::tag::get_tag,
136        crate::web::handlers::schema_ext::tag::update_tag,
137        crate::web::handlers::schema_ext::tag::delete_tag,
138        crate::web::handlers::schema_ext::edge_type::list_edge_types,
139        crate::web::handlers::schema_ext::edge_type::create_edge_type,
140        crate::web::handlers::schema_ext::edge_type::get_edge_type,
141        crate::web::handlers::schema_ext::edge_type::update_edge_type,
142        crate::web::handlers::schema_ext::edge_type::delete_edge_type,
143        crate::web::handlers::schema_ext::index::list_indexes,
144        crate::web::handlers::schema_ext::index::create_index,
145        crate::web::handlers::schema_ext::index::get_index,
146        crate::web::handlers::schema_ext::index::delete_index,
147        crate::web::handlers::schema_ext::index::rebuild_index,
148        crate::web::handlers::data_browser::list_vertices_by_tag,
149        crate::web::handlers::data_browser::list_edges_by_type,
150        crate::web::handlers::graph_data::get_vertex,
151        crate::web::handlers::graph_data::get_edge,
152        crate::web::handlers::graph_data::get_neighbors,
153    ),
154    tags(
155        (name = "WebHistory", description = "Web query history and favorites"),
156        (name = "WebSchema", description = "Web extended schema management"),
157        (name = "WebData", description = "Web data browsing"),
158        (name = "WebGraph", description = "Web graph data queries"),
159        (name = "Health", description = "Health checks"),
160        (name = "Auth", description = "Authentication"),
161        (name = "Session", description = "Session management"),
162        (name = "Query", description = "Query execution"),
163        (name = "Transaction", description = "Transaction management"),
164        (name = "Batch", description = "Batch operations"),
165        (name = "Import", description = "Data import"),
166        (name = "Export", description = "Data export"),
167        (name = "Statistics", description = "Statistics and monitoring"),
168        (name = "Config", description = "Configuration management"),
169        (name = "Function", description = "Custom functions"),
170        (name = "Stream", description = "Streaming queries"),
171        (name = "Sync", description = "Synchronization management"),
172        (name = "Schema", description = "Schema management"),
173        (name = "Migration", description = "Schema migration"),
174    )
175)]
176struct CoreDoc;
177
178#[cfg(feature = "vector")]
179#[derive(OpenApi)]
180#[openapi(
181    info(title = "Linkrs API", version = "1.0.0"),
182    paths(
183        crate::http::handlers::vector::create_index,
184        crate::http::handlers::vector::list_indexes,
185        crate::http::handlers::vector::get_index_info,
186        crate::http::handlers::vector::drop_index,
187        crate::http::handlers::vector::search,
188        crate::http::handlers::vector::get_vector,
189        crate::http::handlers::vector::count,
190        crate::http::handlers::vector::set_payload,
191        crate::http::handlers::vector::set_payload_fields,
192        crate::http::handlers::vector::delete_payload,
193        crate::http::handlers::vector::scroll,
194        crate::http::handlers::rebuild::rebuild_vector,
195        crate::http::handlers::rebuild::vector_rebuild_status,
196        crate::http::handlers::rebuild::clear_vector,
197    ),
198    components(schemas(
199        crate::core::vector::VectorFilter,
200        crate::core::vector::MinShouldCondition,
201        crate::core::vector::FilterCondition,
202        crate::core::vector::ConditionType,
203        crate::core::vector::RangeCondition,
204        crate::core::vector::GeoRadius,
205        crate::core::vector::GeoBoundingBox,
206        crate::core::vector::ValuesCountCondition,
207    )),
208    tags((name = "Vector", description = "Vector search and index management"))
209)]
210struct VectorDoc;
211
212#[cfg(feature = "fulltext")]
213#[derive(OpenApi)]
214#[openapi(
215    info(title = "Linkrs API", version = "1.0.0"),
216    paths(
217        crate::http::handlers::rebuild::rebuild_fulltext,
218        crate::http::handlers::rebuild::fulltext_rebuild_status,
219        crate::http::handlers::rebuild::clear_fulltext,
220        crate::http::handlers::rebuild::inconsistent_fulltext,
221    ),
222    tags((name = "Fulltext", description = "Fulltext index management"))
223)]
224struct FulltextDoc;
225
226/// Serialize the OpenAPI document, cached after the first call.
227pub fn openapi_json() -> String {
228    static CACHE: OnceLock<String> = OnceLock::new();
229    CACHE
230        .get_or_init(|| {
231            #[allow(unused_mut)]
232            let mut doc = CoreDoc::openapi();
233            #[cfg(feature = "vector")]
234            {
235                doc = doc.merge_from(VectorDoc::openapi());
236            }
237            #[cfg(feature = "fulltext")]
238            {
239                doc = doc.merge_from(FulltextDoc::openapi());
240            }
241            serde_json::to_string_pretty(&doc).expect("OpenAPI document must serialize")
242        })
243        .clone()
244}
245
246#[cfg(all(feature = "vector", feature = "fulltext"))]
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use std::collections::BTreeSet;
251    use std::path::{Path, PathBuf};
252
253    // ---- contract configuration (repo-specific) ----
254
255    const SNAPSHOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../frontend/openapi.json");
256    const REFRESH_ENV: &str = "LINKRS_REFRESH_OPENAPI";
257
258    fn source_root() -> PathBuf {
259        Path::new(env!("CARGO_MANIFEST_DIR")).join("src")
260    }
261
262    /// Mount prefix for `.route()` registrations in the file at `rel`
263    /// (relative to the crate `src` root); `None` skips the file for route
264    /// collection. Axum routes are relative: `/v1` handlers live in
265    /// `http/router.rs`, web handlers in `web/handlers/*.rs` nest under
266    /// `/api` with one prefix per module (see `web.rs`).
267    fn route_prefix(rel: &str) -> Option<String> {
268        match rel {
269            "http/router.rs" => Some("/v1".to_string()),
270            "web/handlers/metadata.rs" => Some("/api/v1/queries".to_string()),
271            "web/handlers/schema_ext/routes.rs" => Some("/api/v1/schema".to_string()),
272            "web/handlers/data_browser.rs" => Some("/api/v1/data".to_string()),
273            "web/handlers/graph_data.rs" => Some("/api/v1/graph".to_string()),
274            _ => None,
275        }
276    }
277
278    /// Debug-only documentation endpoint, not part of the contract.
279    fn is_ignored_path(path: &str) -> bool {
280        path.contains("/api-docs")
281    }
282
283    fn feature_enabled(name: &str) -> bool {
284        match name {
285            "vector" => cfg!(feature = "vector"),
286            "fulltext" => cfg!(feature = "fulltext"),
287            _ => panic!("unexpected feature gate in scanned route sources: {name}"),
288        }
289    }
290
291    #[test]
292    fn openapi_snapshot_matches() {
293        let doc = openapi_json();
294        if std::env::var(REFRESH_ENV).as_deref() == Ok("1") {
295            std::fs::write(SNAPSHOT, format!("{doc}\n")).expect("snapshot must be writable");
296            return;
297        }
298        // The canonical snapshot is generated with all features enabled.
299        // Standard command: cargo test -p linkrs-server --features vector,fulltext
300        let expected = std::fs::read_to_string(SNAPSHOT).expect("openapi snapshot must exist");
301        assert_eq!(
302            doc.trim_end(),
303            expected.trim_end(),
304            "snapshot drifted; refresh with {REFRESH_ENV}=1"
305        );
306    }
307
308    #[test]
309    fn routes_match_openapi_paths() {
310        let routed = collect_routed();
311        let annotated = collect_annotated();
312        let documented = collect_documented();
313
314        assert_sets_equal(
315            &routed,
316            &annotated,
317            "router registrations vs #[utoipa::path] annotations",
318        );
319        assert_sets_equal(
320            &annotated,
321            &documented,
322            "#[utoipa::path] annotations vs ApiDoc registration",
323        );
324        assert!(!routed.is_empty(), "expected a non-empty route set");
325    }
326
327    /// Each operation needs a document-unique `operationId`: `openapi-typescript`
328    /// keys its `operations` map by it, so collisions silently overwrite an
329    /// operation and mis-type call sites that use `openapi-fetch`.
330    #[test]
331    fn operation_ids_are_unique() {
332        use std::collections::HashSet;
333        let doc: serde_json::Value =
334            serde_json::from_str(&openapi_json()).expect("document must parse");
335        let mut seen = HashSet::new();
336        for (_path, item) in doc["paths"]
337            .as_object()
338            .expect("document must contain paths")
339        {
340            if let Some(ops) = item.as_object() {
341                for (_method, op) in ops {
342                    if let Some(id) = op.get("operationId").and_then(|id| id.as_str()) {
343                        assert!(seen.insert(id.to_string()), "duplicate operationId: {id}");
344                    }
345                }
346            }
347        }
348    }
349
350    // ---- unified guard engine (shared across repos; keep verbatim) ----
351
352    const HTTP_METHODS: [&str; 8] = [
353        "get", "put", "post", "patch", "delete", "head", "options", "trace",
354    ];
355
356    fn assert_sets_equal(
357        left: &BTreeSet<(String, String)>,
358        right: &BTreeSet<(String, String)>,
359        label: &str,
360    ) {
361        let only_left: Vec<_> = left.difference(right).collect();
362        let only_right: Vec<_> = right.difference(left).collect();
363        assert!(
364            only_left.is_empty() && only_right.is_empty(),
365            "{label} drift\nonly on the left: {only_left:?}\nonly on the right: {only_right:?}"
366        );
367    }
368
369    fn walk_rs(dir: &Path) -> Vec<PathBuf> {
370        let mut out = Vec::new();
371        for entry in std::fs::read_dir(dir).expect("read source dir") {
372            let path = entry.expect("dir entry").path();
373            if path.is_dir() {
374                out.extend(walk_rs(&path));
375            } else if path.extension().is_some_and(|e| e == "rs") {
376                out.push(path);
377            }
378        }
379        out
380    }
381
382    fn source_files() -> Vec<(String, String)> {
383        let root = source_root();
384        walk_rs(&root)
385            .into_iter()
386            .map(|path| {
387                let rel = path
388                    .strip_prefix(&root)
389                    .expect("source under src root")
390                    .to_string_lossy()
391                    .replace('\\', "/");
392                let text = std::fs::read_to_string(&path).expect("read source file");
393                (rel, text)
394            })
395            .collect()
396    }
397
398    fn collect_routed() -> BTreeSet<(String, String)> {
399        let mut out = BTreeSet::new();
400        for (rel, text) in source_files() {
401            let Some(prefix) = route_prefix(&rel) else {
402                continue;
403            };
404            for (method, path) in parse_route_registrations(&text) {
405                let full = format!("{prefix}{path}");
406                if !is_ignored_path(&full) {
407                    out.insert((method, full));
408                }
409            }
410        }
411        out
412    }
413
414    fn collect_annotated() -> BTreeSet<(String, String)> {
415        let mut out = BTreeSet::new();
416        for (_rel, text) in source_files() {
417            for (method, path) in parse_utoipa_annotations(&text) {
418                out.insert((method, path));
419            }
420        }
421        out
422    }
423
424    fn collect_documented() -> BTreeSet<(String, String)> {
425        let doc: serde_json::Value =
426            serde_json::from_str(&openapi_json()).expect("document must parse");
427        let mut out = BTreeSet::new();
428        if let Some(paths) = doc.get("paths").and_then(|p| p.as_object()) {
429            for (path, item) in paths {
430                if let Some(ops) = item.as_object() {
431                    for method in ops.keys() {
432                        out.insert((method.to_uppercase(), path.clone()));
433                    }
434                }
435            }
436        }
437        out
438    }
439
440    /// Parse `#[utoipa::path(...)]` attributes into (method, full path).
441    fn parse_utoipa_annotations(text: &str) -> Vec<(String, String)> {
442        let mut out = Vec::new();
443        let mut lines = text.lines().peekable();
444        let mut buf: Vec<String> = Vec::new();
445        while let Some(line) = lines.next() {
446            let trimmed = line.trim();
447            if let Some(tail) = trimmed.strip_prefix("#[utoipa::path(") {
448                buf.clear();
449                if tail != "(" {
450                    buf.push(tail.to_string());
451                }
452                for line in lines.by_ref().take(60) {
453                    let trimmed = line.trim();
454                    if let Some(closer) = trimmed.strip_prefix(")]") {
455                        buf.push(trimmed.to_string());
456                        let _ = closer;
457                        break;
458                    }
459                    buf.push(trimmed.to_string());
460                }
461                if let Some((method, path)) = annotation_head(&buf) {
462                    out.push((method, path));
463                }
464            }
465        }
466        out
467    }
468
469    fn annotation_head(buf: &[String]) -> Option<(String, String)> {
470        let method = buf.iter().find_map(|line| {
471            HTTP_METHODS
472                .iter()
473                .find(|m| line.starts_with(&format!("{m},")))
474                .map(|m| m.to_uppercase())
475        })?;
476        let path = buf
477            .iter()
478            .find_map(|line| {
479                line.find("path = \"")
480                    .map(|i| &line[i + "path = \"".len()..])
481            })
482            .and_then(|rest| rest.split('"').next())
483            .map(str::to_string)?;
484        Some((method, path))
485    }
486
487    /// Parse axum `.route("<path>", get(..).post(..))` registrations into
488    /// (method, path) pairs. Line-based: rustfmt keeps these tables stable.
489    /// Top-level `#[cfg(...)]` attributes gate whole functions so
490    /// feature-disabled route bodies are ignored.
491    fn parse_route_registrations(text: &str) -> Vec<(String, String)> {
492        let mut out = Vec::new();
493        let mut pending_attr: Option<String> = None;
494        let mut fn_active = true;
495        let mut entry: Option<(Option<String>, Vec<String>)> = None;
496
497        let flush = |out: &mut Vec<(String, String)>,
498                     entry: &mut Option<(Option<String>, Vec<String>)>| {
499            if let Some((Some(path), methods)) = entry.take() {
500                for method in methods {
501                    out.push((method, path.clone()));
502                }
503            } else {
504                entry.take();
505            }
506        };
507
508        for line in text.lines() {
509            let trimmed = line.trim();
510            let top_level = !trimmed.is_empty() && !line.starts_with(char::is_whitespace);
511            if top_level {
512                if let Some(expr) = trimmed
513                    .strip_prefix("#[cfg(")
514                    .and_then(|rest| rest.strip_suffix(")]"))
515                {
516                    pending_attr = Some(expr.to_string());
517                    continue;
518                }
519                if trimmed.starts_with("#[") || trimmed.starts_with("//") {
520                    continue;
521                }
522                let is_fn = trimmed.starts_with("fn ")
523                    || trimmed.starts_with("pub fn ")
524                    || trimmed.starts_with("pub(crate) fn ")
525                    || trimmed.starts_with("async fn ")
526                    || trimmed.starts_with("pub async fn ")
527                    || trimmed.starts_with("pub(crate) async fn ");
528                if is_fn {
529                    fn_active = pending_attr.take().map_or(true, |expr| eval_cfg(&expr));
530                    entry = None;
531                    continue;
532                }
533                pending_attr = None;
534            }
535            if !fn_active {
536                continue;
537            }
538
539            if let Some(pos) = line.find(".route(") {
540                flush(&mut out, &mut entry);
541                let tail = &line[pos + ".route(".len()..];
542                entry = Some((first_string_literal(tail), method_tokens(tail)));
543            } else if let Some((path, methods)) = entry.as_mut() {
544                if path.is_none() {
545                    *path = first_string_literal(trimmed);
546                }
547                methods.extend(method_tokens(trimmed));
548            }
549
550            let ends_entry = trimmed.starts_with(".nest(")
551                || trimmed.starts_with(".layer(")
552                || trimmed.starts_with(".route_layer(")
553                || trimmed.starts_with(".merge(")
554                || trimmed.starts_with(".fallback(")
555                || trimmed.starts_with(".with_state(")
556                || trimmed == ")"
557                || trimmed == "),"
558                || trimmed.ends_with(");");
559            if ends_entry {
560                flush(&mut out, &mut entry);
561            }
562        }
563        flush(&mut out, &mut entry);
564        out
565    }
566
567    fn first_string_literal(s: &str) -> Option<String> {
568        let start = s.find('"')?;
569        let rest = &s[start + 1..];
570        let end = rest.find('"')?;
571        Some(rest[..end].to_string())
572    }
573
574    fn method_tokens(line: &str) -> Vec<String> {
575        let cleaned = strip_string_literals(line);
576        let b = cleaned.as_bytes();
577        let mut out = Vec::new();
578        let mut i = 0;
579        while i < b.len() {
580            if !(b[i].is_ascii_alphabetic() || b[i] == b'_') {
581                i += 1;
582                continue;
583            }
584            let start = i;
585            while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
586                i += 1;
587            }
588            let word = &cleaned[start..i];
589            let mut j = i;
590            while j < b.len() && (b[j] as char).is_whitespace() {
591                j += 1;
592            }
593            if j < b.len() && b[j] == b'(' && HTTP_METHODS.contains(&word) {
594                out.push(word.to_uppercase());
595            }
596        }
597        out
598    }
599
600    fn strip_string_literals(line: &str) -> String {
601        let mut out = String::with_capacity(line.len());
602        let mut in_string = false;
603        for c in line.chars() {
604            if c == '"' {
605                in_string = !in_string;
606                out.push(' ');
607            } else if in_string {
608                out.push(' ');
609            } else {
610                out.push(c);
611            }
612        }
613        out
614    }
615
616    fn eval_cfg(expr: &str) -> bool {
617        let expr = expr.trim();
618        if let Some(inner) = expr.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) {
619            return !eval_cfg(inner);
620        }
621        if let Some(inner) = expr.strip_prefix("all(").and_then(|s| s.strip_suffix(')')) {
622            return split_cfg_items(inner).iter().all(|item| eval_cfg(item));
623        }
624        if let Some(inner) = expr.strip_prefix("any(").and_then(|s| s.strip_suffix(')')) {
625            return split_cfg_items(inner).iter().any(|item| eval_cfg(item));
626        }
627        match expr {
628            "debug_assertions" => cfg!(debug_assertions),
629            "test" => false,
630            other => {
631                if let Some(name) = other
632                    .strip_prefix("feature")
633                    .and_then(|s| s.trim().strip_prefix('='))
634                    .and_then(|s| s.trim().split('"').nth(1))
635                {
636                    feature_enabled(name)
637                } else {
638                    panic!("unsupported cfg predicate: {expr}")
639                }
640            }
641        }
642    }
643
644    fn split_cfg_items(expr: &str) -> Vec<&str> {
645        let mut depth = 0usize;
646        let mut items = Vec::new();
647        let mut start = 0;
648        for (i, c) in expr.char_indices() {
649            match c {
650                '(' => depth += 1,
651                ')' => depth -= 1,
652                ',' if depth == 0 => {
653                    items.push(expr[start..i].trim());
654                    start = i + 1;
655                }
656                _ => {}
657            }
658        }
659        let last = expr[start..].trim();
660        if !last.is_empty() {
661            items.push(last);
662        }
663        items
664    }
665}