mecha10-core 0.6.5

Mecha10 core runtime and foundational types
Documentation
//! Detects whether a project needs a local Ollama server running.
//!
//! Mirrors [`crate::relay_topics`]'s approach: a node's `provider`/`llm_model` fields already
//! live in its own resolved config JSON (see `llm_command::config::OpenAIReasoningConfig`), so
//! this reads that raw JSON directly rather than depending on any node's Rust type - the same
//! reason `derive_relay_topics` doesn't need `RelayTopicsField` to answer "does anything declare
//! this". [`project_needs_ollama`] answers "is there any node whose active config has
//! `provider: \"local\"`" by recursively scanning `configs/nodes/`, the same tree
//! `derive_relay_topics` walks.
//!
//! This backs the CLI auto-starting an `ollama` Docker Compose service (profile `ollama`) only
//! for projects that actually use it - the same "conditional container" treatment `redis`
//! already gets, but gated on actual need rather than always-on.

use crate::relay_topics::{find_node_config_files, resolve_environment_section, NODES_PROJECT_DIR};
use crate::Result;
use serde_json::Value;
use std::path::Path;

/// Does any node under `project_root`'s `configs/nodes/` tree declare `"provider": "local"` in
/// its active (`MECHA10_ENV`, default `dev`) config section? Used to decide whether to start the
/// `ollama` Compose service and run the Ollama model-pull preflight at all.
pub fn project_needs_ollama(project_root: &Path) -> Result<bool> {
    let environment = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());

    for config_path in find_node_config_files(&project_root.join(NODES_PROJECT_DIR)) {
        let value = crate::relay_topics::read_json(&config_path)?;
        let resolved = resolve_environment_section(value, &environment);

        if resolved.get("provider").and_then(Value::as_str) == Some("local") {
            return Ok(true);
        }
    }

    Ok(false)
}

/// Extract installed model names from an Ollama `/api/tags` response body, e.g.
/// `{"models": [{"name": "llama3.1:8b"}, ...]}` -> `["llama3.1:8b"]`.
///
/// Returns an empty list if `body` doesn't have the expected shape (no `models` array, or a
/// model entry missing a string `name`). Shared by the CLI's standalone-mode model preflight and
/// `mecha10-node-runner`'s - each does its own `GET {base_url}/api/tags` and has different
/// unreachable-Ollama handling (the CLI skips its whole pull loop and warns once;
/// `node-runner` treats it as "not found" for the one model it's checking and proceeds to try
/// pulling it), so only this response-parsing step - the part that was byte-for-byte identical -
/// is shared rather than the whole fetch.
pub fn parse_installed_model_names(body: &Value) -> Vec<String> {
    body.get("models")
        .and_then(Value::as_array)
        .map(|models| {
            models
                .iter()
                .filter_map(|m| m.get("name").and_then(Value::as_str).map(str::to_string))
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serial_test::serial;
    use std::fs;
    use std::path::PathBuf;

    fn write_json(path: &Path, content: &str) {
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, content).unwrap();
    }

    fn nodes_dir(project_root: &Path) -> PathBuf {
        project_root.join("configs/nodes/@mecha10")
    }

    #[test]
    fn no_nodes_dir_means_no_ollama_needed() {
        let project = tempfile::tempdir().unwrap();

        assert!(!project_needs_ollama(project.path()).unwrap());
    }

    #[test]
    #[serial]
    fn a_node_with_provider_local_needs_ollama() {
        std::env::remove_var("MECHA10_ENV");

        let project = tempfile::tempdir().unwrap();
        write_json(
            &nodes_dir(project.path()).join("llm-command").join("config.json"),
            r#"{ "dev": { "provider": "local", "llm_model": "llama3.1" } }"#,
        );

        assert!(project_needs_ollama(project.path()).unwrap());
    }

    #[test]
    #[serial]
    fn a_node_with_provider_openai_does_not_need_ollama() {
        std::env::remove_var("MECHA10_ENV");

        let project = tempfile::tempdir().unwrap();
        write_json(
            &nodes_dir(project.path()).join("llm-command").join("config.json"),
            r#"{ "dev": { "provider": "openai", "llm_model": "gpt-4o-mini" } }"#,
        );

        assert!(!project_needs_ollama(project.path()).unwrap());
    }

    #[test]
    #[serial]
    fn only_the_active_environment_section_is_consulted() {
        std::env::set_var("MECHA10_ENV", "production");

        let project = tempfile::tempdir().unwrap();
        write_json(
            &nodes_dir(project.path()).join("llm-command").join("config.json"),
            r#"{
                "dev": { "provider": "local", "llm_model": "llama3.1" },
                "production": { "provider": "openai", "llm_model": "gpt-4o-mini" }
            }"#,
        );

        let needs_ollama = project_needs_ollama(project.path()).unwrap();
        std::env::remove_var("MECHA10_ENV");

        assert!(!needs_ollama);
    }

    #[test]
    fn parse_installed_model_names_extracts_every_name() {
        let body = serde_json::json!({
            "models": [
                { "name": "llama3.1:8b", "size": 123 },
                { "name": "mistral:7b" },
            ]
        });

        assert_eq!(
            parse_installed_model_names(&body),
            vec!["llama3.1:8b".to_string(), "mistral:7b".to_string()]
        );
    }

    #[test]
    fn parse_installed_model_names_skips_entries_missing_a_string_name() {
        let body = serde_json::json!({
            "models": [
                { "name": "llama3.1:8b" },
                { "size": 456 },
                { "name": 42 },
            ]
        });

        assert_eq!(parse_installed_model_names(&body), vec!["llama3.1:8b".to_string()]);
    }

    #[test]
    fn parse_installed_model_names_returns_empty_for_unexpected_shapes() {
        assert!(parse_installed_model_names(&serde_json::json!({})).is_empty());
        assert!(parse_installed_model_names(&serde_json::json!({ "models": "not an array" })).is_empty());
        assert!(parse_installed_model_names(&serde_json::json!(null)).is_empty());
    }
}