steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Guard: every `pub async fn` inside `steam-user/src/services/*.rs` must
//! either carry `#[steam_endpoint(...)]` or be marked as a delegate with a
//! `// delegates to ...` comment.
//!
//! This catches accidental drift — e.g. a new endpoint added without
//! annotation, or `#[steam_endpoint]` removed during refactor — before it
//! reaches CI/main. The check runs on the actual source files, not on the
//! compiled registry, so it also flags methods whose macro expansion is
//! suppressed by `#[cfg(...)]`.
//!
//! Failure mode: prints a list like
//!
//! ```text
//! services/foo.rs:42 fn bar — missing #[steam_endpoint(...)] and no `// delegates to` comment
//! ```

use std::{fs, path::PathBuf};

use syn::{spanned::Spanned, ImplItem, Item, Visibility};

/// Universal "I intentionally did not annotate this" marker. Used in
/// comments for delegates, composites, and dynamic-URL helpers — anything
/// that legitimately can't carry `#[steam_endpoint(...)]`. The reason
/// (delegates / composite / dynamic / ...) goes elsewhere in the comment;
/// only this exact substring is required.
const SKIP_MARKER: &str = "no #[steam_endpoint]";

fn services_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src").join("services")
}

#[test]
fn every_pub_async_fn_is_annotated_or_marked_delegate() {
    let dir = services_dir();
    let entries = fs::read_dir(&dir).expect("services dir readable");

    let mut failures: Vec<String> = Vec::new();
    let mut files_checked = 0usize;
    let mut fns_checked = 0usize;

    for entry in entries {
        let path = entry.expect("dir entry").path();
        if path.extension().and_then(|s| s.to_str()) != Some("rs") {
            continue;
        }
        // Don't recurse into per-service subdirectories — those are private
        // helpers, not Steam endpoints.
        if !path.is_file() {
            continue;
        }

        files_checked += 1;
        let src = fs::read_to_string(&path).expect("read service file");
        let lines: Vec<&str> = src.lines().collect();
        let file_ast = syn::parse_file(&src).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));

        let rel = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");

        for item in &file_ast.items {
            let Item::Impl(item_impl) = item else { continue };
            for impl_item in &item_impl.items {
                let ImplItem::Fn(fn_item) = impl_item else { continue };

                let is_pub = matches!(fn_item.vis, Visibility::Public(_));
                let is_async = fn_item.sig.asyncness.is_some();
                if !(is_pub && is_async) {
                    continue;
                }

                fns_checked += 1;

                let has_endpoint_attr = fn_item
                    .attrs
                    .iter()
                    .any(|a| a.path().is_ident("steam_endpoint"));
                if has_endpoint_attr {
                    continue;
                }

                // Look back at the source lines immediately preceding the fn
                // signature for a `// delegates to ...` comment. We scan up
                // to 8 lines back to allow for doc-comment blocks between the
                // delegate marker and the fn keyword.
                let fn_line_1based = fn_item.sig.fn_token.span().start().line;
                if fn_line_1based == 0 {
                    failures.push(format!(
                        "services/{rel}: fn `{}` — span info missing (proc-macro2 span-locations feature?)",
                        fn_item.sig.ident
                    ));
                    continue;
                }
                let fn_line_idx = fn_line_1based - 1;

                let look_back_window = fn_line_idx.saturating_sub(8)..fn_line_idx;
                let has_skip_comment = lines[look_back_window].iter().any(|line| {
                    let trimmed = line.trim_start();
                    trimmed.starts_with("//") && trimmed.contains(SKIP_MARKER)
                });

                if !has_skip_comment {
                    failures.push(format!(
                        "services/{rel}:{fn_line_1based} fn `{}` — missing #[steam_endpoint(...)] and no `// ...{SKIP_MARKER}` comment in the 8 lines above",
                        fn_item.sig.ident,
                    ));
                }
            }
        }
    }

    assert!(
        files_checked > 0,
        "no .rs files found in {} — guard would silently pass",
        services_dir().display(),
    );
    assert!(
        fns_checked >= 100,
        "only {fns_checked} pub async fn discovered — parser probably regressed",
    );

    if !failures.is_empty() {
        let count = failures.len();
        let body = failures.join("\n  ");
        panic!("{count} pub async fn(s) missing endpoint metadata:\n  {body}");
    }
}