roboticus-api 0.11.4

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
//! Static checks that preserve connector–factory discipline (see `ARCHITECTURE.md`
//! at the repository root). Failing tests here usually mean a new code path
//! bypassed `run_pipeline` or duplication crept back into delegation SQL.

use std::path::PathBuf;

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn agent_connector_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/api/routes/agent")
}

#[test]
fn thin_agent_connectors_invoke_run_pipeline() {
    for file in [
        "handlers.rs",
        "streaming.rs",
        "channel_message.rs",
        "scheduled_tasks.rs",
    ] {
        let path = agent_connector_dir().join(file);
        let src = std::fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
        assert!(
            src.contains("run_pipeline"),
            "{} must call run_pipeline — connectors stay thin (ARCHITECTURE.md §1)",
            path.display()
        );
    }
}

/// Connector files must stay thin: parse → call → format. If a connector
/// grows beyond the threshold, business logic is leaking out of the pipeline.
/// This is a structural fitness function, not just a string-presence check.
#[test]
fn connector_files_are_structurally_thin() {
    // Max code lines (non-blank, non-comment) per connector.
    // handlers.rs and scheduled_tasks.rs are simple; channel_message.rs is
    // larger because it handles multimodal I/O and typing indicators (which
    // are legitimate connector concerns, not business logic).
    let limits = [
        ("handlers.rs", 80),
        ("streaming.rs", 500),       // streaming has SSE delivery mechanics
        ("channel_message.rs", 380), // multimodal I/O + typing + bot command dispatch + outcome formatting
        ("scheduled_tasks.rs", 120),
    ];
    for (file, max_lines) in limits {
        let path = agent_connector_dir().join(file);
        let src = std::fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
        let code_lines = src
            .lines()
            .filter(|line| {
                let trimmed = line.trim();
                !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with("///")
            })
            .count();
        assert!(
            code_lines <= max_lines,
            "{file} has {code_lines} code lines (max {max_lines}). \
             If this connector grew, check whether business logic leaked \
             out of the pipeline (ARCHITECTURE.md §1).",
        );
    }
}

/// No connector may contain its own `if`-branching on policy, routing,
/// or inference decisions. These patterns indicate business logic drift.
#[test]
fn connectors_do_not_contain_policy_decisions() {
    let forbidden_patterns = [
        (
            "classify_complexity",
            "complexity classification belongs in the pipeline",
        ),
        (
            "select_routed_model",
            "model selection belongs in the pipeline",
        ),
        ("guard_sets::", "guard chain setup belongs in the pipeline"),
        ("PolicyEngine", "policy evaluation belongs in the pipeline"),
    ];
    for file in ["handlers.rs", "channel_message.rs", "scheduled_tasks.rs"] {
        let path = agent_connector_dir().join(file);
        let src = std::fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
        for (pattern, reason) in &forbidden_patterns {
            assert!(
                !src.contains(pattern),
                "{file} contains '{pattern}' — {reason} (ARCHITECTURE.md §1)",
            );
        }
    }
}

#[test]
fn architecture_md_documents_off_pipeline_exemption() {
    let arch = repo_root().join("ARCHITECTURE.md");
    let text =
        std::fs::read_to_string(&arch).unwrap_or_else(|e| panic!("read {}: {e}", arch.display()));
    assert!(
        text.contains("Off-pipeline") && text.contains("/api/interview"),
        "ARCHITECTURE.md must document off-pipeline exemptions and the interview routes"
    );
}

#[test]
fn retire_unused_subagents_uses_shared_disable_primitive() {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/api/routes/subagents.rs");
    let src =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    assert!(
        src.contains("disable_subagents_by_name"),
        "retirement must call roboticus_db::agents::disable_subagents_by_name so soft-disable stays one code path"
    );
}

#[test]
fn db_agents_delegation_sql_uses_core_helper() {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../roboticus-db/src/agents.rs");
    let src =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    assert!(
        src.contains("delegation_output_tool_name_sql_in_predicate"),
        "roboticus-db agents.rs must build delegation SQL from roboticus_core::delegation_tools"
    );
}

// ── Pipeline dependency direction ────────────────────────────────────────

/// The pipeline crate must NEVER depend back on roboticus-api.
/// This prevents circular dependencies and enforces the layering:
/// API → pipeline (not pipeline → API).
#[test]
fn pipeline_crate_does_not_depend_on_api() {
    let cargo_toml = repo_root().join("crates/roboticus-pipeline/Cargo.toml");
    let text = std::fs::read_to_string(&cargo_toml)
        .unwrap_or_else(|e| panic!("read {}: {e}", cargo_toml.display()));
    assert!(
        !text.contains("roboticus-api"),
        "roboticus-pipeline/Cargo.toml must NOT depend on roboticus-api — \
         the pipeline crate is a lower layer consumed by the API crate"
    );
}

/// No `.rs` file in the pipeline crate should reference `AppState`.
/// The pipeline operates through capability traits, not concrete state types.
#[test]
fn pipeline_crate_has_no_appstate_references() {
    let pipeline_src = repo_root().join("crates/roboticus-pipeline/src");
    for entry in walkdir(&pipeline_src) {
        if !entry.ends_with(".rs") {
            continue;
        }
        let src = std::fs::read_to_string(&entry)
            .unwrap_or_else(|e| panic!("read {}: {e}", entry.display()));
        assert!(
            !src.contains("AppState"),
            "{} mentions AppState — pipeline crate must use capability traits, \
             not the concrete application state type",
            entry.display()
        );
    }
}

// ── Stage dependency scoping ─────────────────────────────────────────────

/// Core module files must not contain `&AppState`.
/// All core inference functions use trait-based deps.
#[test]
fn core_module_has_no_appstate() {
    let core_dir = agent_connector_dir().join("core");
    for entry in walkdir(&core_dir) {
        if !entry.ends_with(".rs") {
            continue;
        }
        let src = std::fs::read_to_string(&entry)
            .unwrap_or_else(|e| panic!("read {}: {e}", entry.display()));
        assert!(
            !src.contains("&AppState"),
            "{} contains &AppState — core module functions must use \
             CoreInferenceDeps or narrower trait refs",
            entry.display()
        );
    }
}

/// Pipeline context stage files must not contain `&AppState`.
/// All stage functions use trait-based dep structs.
#[test]
fn pipeline_context_stages_have_no_appstate() {
    let context_dir = agent_connector_dir().join("pipeline/context");
    for entry in walkdir(&context_dir) {
        if !entry.ends_with(".rs") {
            continue;
        }
        let src = std::fs::read_to_string(&entry)
            .unwrap_or_else(|e| panic!("read {}: {e}", entry.display()));
        assert!(
            !src.contains("&AppState"),
            "{} contains &AppState — pipeline context stages must use \
             trait-scoped dep structs (SessionDeps, CoreInferenceDeps, etc.)",
            entry.display()
        );
    }
}

/// The pipeline orchestrator (run.rs) must not contain `&AppState` in
/// production code. Test-only code is exempt.
#[test]
fn orchestrator_production_code_has_no_appstate() {
    let path = agent_connector_dir().join("pipeline/run.rs");
    let src =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    // Split at #[cfg(test)] — production code is everything before it.
    let production = src.split("#[cfg(test)]").next().unwrap_or(&src);
    assert!(
        !production.contains("&AppState"),
        "pipeline/run.rs production code contains &AppState — the orchestrator \
         must operate through PipelineDeps / trait-scoped dep structs only"
    );
}

/// `PipelineCore` must not expose `hmac_secret`.
/// HMAC access is scoped through `PipelineSecurity`.
#[test]
fn pipeline_core_does_not_expose_hmac() {
    let path = repo_root().join("crates/roboticus-pipeline/src/capabilities.rs");
    let src =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    // Find the PipelineCore trait body
    let core_start = src
        .find("pub trait PipelineCore")
        .expect("PipelineCore trait");
    let core_end = src[core_start..]
        .find("\n}")
        .map(|i| core_start + i)
        .unwrap_or(src.len());
    let core_body = &src[core_start..core_end];
    assert!(
        !core_body.contains("hmac_secret"),
        "PipelineCore must not expose hmac_secret — use PipelineSecurity trait"
    );
}

/// Connectors must construct `PipelineDeps` via `pipeline_deps()`, not
/// inline `CoreInferenceDeps` or other stage-specific dep structs.
#[test]
fn connectors_use_pipeline_deps_not_stage_deps() {
    for file in ["handlers.rs", "channel_message.rs", "scheduled_tasks.rs"] {
        let path = agent_connector_dir().join(file);
        let src = std::fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
        assert!(
            !src.contains("CoreInferenceDeps"),
            "{file} constructs CoreInferenceDeps directly — connectors should \
             use state.pipeline_deps() and let the pipeline narrow internally"
        );
    }
}

/// Helper: recursively list `.rs` files under a directory.
fn walkdir(dir: &std::path::Path) -> Vec<PathBuf> {
    let mut files = Vec::new();
    if let Ok(entries) = std::fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                files.extend(walkdir(&path));
            } else if path.extension().is_some_and(|e| e == "rs") {
                files.push(path);
            }
        }
    }
    files
}