solverforge-cli 2.0.3

CLI for scaffolding and managing SolverForge constraint solver projects
use std::fs;
use std::path::Path;

use crate::commands::generate_constraint::{parse_domain, validate_constraint_mod_source};
use crate::commands::generate_domain::{find_file_for_type, validate_domain_mod_source};
use crate::error::{CliError, CliResult};
use crate::managed_block;
use crate::output;

const ENTITY_TEMPLATE_PATH: &str = ".solverforge/templates/entity.rs.tmpl";
const SOLUTION_TEMPLATE_PATH: &str = ".solverforge/templates/solution.rs.tmpl";

pub fn run() -> CliResult {
    let domain_dir = Path::new("src/domain");
    if !domain_dir.exists() {
        return Err(CliError::NotInProject {
            missing: "src/domain/",
        });
    }

    let mut warnings = Vec::new();
    let mut errors = Vec::new();

    output::print_status("check", "src/domain/mod.rs");
    let domain_mod_path = domain_dir.join("mod.rs");
    let domain_mod_src = read_path(&domain_mod_path, &mut errors);
    let mut domain_mod_valid = false;
    if let Some(src) = domain_mod_src.as_deref() {
        if validate_managed_source(
            &domain_mod_path,
            src,
            &mut errors,
            validate_domain_mod_source,
        ) {
            domain_mod_valid = true;
            if let Ok(module_names) = validate_domain_mod_source(src) {
                for mod_name in module_names {
                    let file = domain_dir.join(format!("{mod_name}.rs"));
                    if !file.exists() {
                        errors.push(format!(
                            "Domain module '{}' declared in src/domain/mod.rs but file not found",
                            mod_name
                        ));
                    }
                }
            }
        }
    }

    let constraints_dir = Path::new("src/constraints");
    if constraints_dir.exists() {
        output::print_status("check", "src/constraints/mod.rs");
        let mod_path = constraints_dir.join("mod.rs");
        let constraints_mod_src = read_path(&mod_path, &mut errors);
        if let Some(src) = constraints_mod_src.as_deref() {
            if validate_managed_source(&mod_path, src, &mut errors, validate_constraint_mod_source)
            {
                if let Ok(module_names) = validate_constraint_mod_source(src) {
                    for mod_name in module_names {
                        let file = constraints_dir.join(format!("{mod_name}.rs"));
                        if !file.exists() {
                            errors.push(format!(
                                "Constraint module '{}' declared in src/constraints/mod.rs but file not found",
                                mod_name
                            ));
                        }
                    }
                }
            }
        }
    } else {
        warnings.push("src/constraints/ directory not found".to_string());
    }

    for (path, required_blocks) in [
        (ENTITY_TEMPLATE_PATH, managed_block::ENTITY_REQUIRED_BLOCKS),
        (
            SOLUTION_TEMPLATE_PATH,
            managed_block::SOLUTION_REQUIRED_BLOCKS,
        ),
    ] {
        let path = Path::new(path);
        if !path.exists() {
            continue;
        }
        output::print_status("check", &output::display_path(path));
        let src = read_path(path, &mut errors);
        if let Some(src) = src.as_deref() {
            validate_managed_source(path, src, &mut errors, |src| {
                managed_block::require_blocks(src, required_blocks)?;
                Ok(Vec::new())
            });
        }
    }

    if domain_mod_valid {
        output::print_status("check", "domain model");
        match parse_domain() {
            Ok(domain) => {
                let fact_fields = domain
                    .facts
                    .iter()
                    .map(|fact| fact.field_name.as_str())
                    .collect::<std::collections::BTreeSet<_>>();

                for entity in &domain.entities {
                    if entity.scalar_vars.is_empty() && entity.list_vars.is_empty() {
                        warnings.push(format!(
                            "Entity '{}' has no solvable fields — solver cannot optimize it",
                            entity.item_type
                        ));
                    }

                    for variable in &entity.scalar_vars {
                        if !variable.value_range_provider.is_empty()
                            && !fact_fields.contains(variable.value_range_provider.as_str())
                        {
                            errors.push(format!(
                            "Entity '{}.{}' references missing fact collection '{}' via value_range_provider",
                            entity.item_type, variable.field, variable.value_range_provider
                        ));
                        }
                    }

                    for variable in &entity.list_vars {
                        if variable.element_collection.is_empty() {
                            errors.push(format!(
                                "Entity '{}.{}' is missing element_collection",
                                entity.item_type, variable.field
                            ));
                        } else if !fact_fields.contains(variable.element_collection.as_str()) {
                            errors.push(format!(
                            "Entity '{}.{}' references missing fact collection '{}' via element_collection",
                            entity.item_type, variable.field, variable.element_collection
                        ));
                        }
                    }
                }

                if domain.solution_type.is_empty() {
                    errors.push("No planning solution found".to_string());
                } else {
                    match find_file_for_type(domain_dir, &domain.solution_type) {
                        Ok(path) => {
                            output::print_status("check", &output::display_path(&path));
                            if let Some(src) = read_path(&path, &mut errors) {
                                validate_managed_source(&path, &src, &mut errors, |src| {
                                    managed_block::require_blocks(
                                        src,
                                        managed_block::SOLUTION_REQUIRED_BLOCKS,
                                    )?;
                                    Ok(Vec::new())
                                });
                            }
                        }
                        Err(err) => errors.push(format!(
                            "planning solution '{}' source not found: {}",
                            domain.solution_type, err
                        )),
                    }
                }

                for entity in &domain.entities {
                    match find_file_for_type(domain_dir, &entity.item_type) {
                        Ok(path) => {
                            output::print_status("check", &output::display_path(&path));
                            if let Some(src) = read_path(&path, &mut errors) {
                                validate_managed_source(&path, &src, &mut errors, |src| {
                                    managed_block::require_blocks(
                                        src,
                                        managed_block::ENTITY_REQUIRED_BLOCKS,
                                    )?;
                                    Ok(Vec::new())
                                });
                            }
                        }
                        Err(err) => errors.push(format!(
                            "entity '{}' source not found: {}",
                            entity.item_type, err
                        )),
                    }
                }
            }
            Err(err) => errors.push(format!("Cannot parse domain model: {err}")),
        }
    }

    output::print_status("check", "solver.toml");
    if !Path::new("solver.toml").exists() {
        warnings.push("solver.toml not found — solver will use defaults".to_string());
    }

    println!();

    if errors.is_empty() && warnings.is_empty() {
        output::print_success("  All checks passed.");
        return Ok(());
    }

    for warning in &warnings {
        println!("  warning: {}", warning);
    }
    for error in &errors {
        println!("  error: {}", error);
    }
    println!();

    if !errors.is_empty() {
        return Err(CliError::general(format!(
            "{} error(s), {} warning(s)",
            errors.len(),
            warnings.len()
        )));
    }

    println!("  {} warning(s), 0 errors", warnings.len());
    Ok(())
}

fn read_path(path: &Path, errors: &mut Vec<String>) -> Option<String> {
    if !path.exists() {
        errors.push(format!("{} not found", output::display_path(path)));
        return None;
    }

    fs::read_to_string(path).map_or_else(
        |err| {
            errors.push(format!(
                "failed to read {}: {}",
                output::display_path(path),
                err
            ));
            None
        },
        Some,
    )
}

fn validate_managed_source<F>(
    path: &Path,
    src: &str,
    errors: &mut Vec<String>,
    validator: F,
) -> bool
where
    F: FnOnce(&str) -> Result<Vec<String>, String>,
{
    match validator(src) {
        Ok(_) => true,
        Err(err) => {
            errors.push(format!("{}: {}", output::display_path(path), err));
            false
        }
    }
}

#[cfg(test)]
#[path = "check_tests.rs"]
mod tests;