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;
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, &config_path)?;
if resolved
.get("model")
.and_then(|m| m.get("provider"))
.and_then(Value::as_str)
== Some("local")
{
return Ok(true);
}
}
Ok(false)
}
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": { "model": { "name": "llama3.1", "provider": "local" } } }"#,
);
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": { "model": { "name": "gpt-4o-mini", "provider": "openai" } } }"#,
);
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": { "model": { "name": "llama3.1", "provider": "local" } },
"production": { "model": { "name": "gpt-4o-mini", "provider": "openai" } }
}"#,
);
let needs_ollama = project_needs_ollama(project.path()).unwrap();
std::env::remove_var("MECHA10_ENV");
assert!(!needs_ollama);
}
#[test]
#[serial]
fn errors_when_requested_environment_section_is_missing() {
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": { "model": { "name": "llama3.1", "provider": "local" } } }"#,
);
let err = project_needs_ollama(project.path()).unwrap_err();
std::env::remove_var("MECHA10_ENV");
assert!(err.to_string().contains("'production'"));
}
#[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());
}
}