solverforge-cli 1.1.2

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

use crate::commands::generate_constraint::parse_domain;
use crate::commands::generate_domain::{find_file_for_type, snake_to_pascal};
use crate::error::{CliError, CliResult};
use crate::output;
use crate::{app_spec, commands::generate_domain};

fn confirm_destroy(kind: &str, name: &str, skip_confirm: bool) -> CliResult<bool> {
    if skip_confirm {
        return Ok(true);
    }

    let prompt = format!("Remove {} '{}'?", kind, name);
    let confirmed = dialoguer::Confirm::new()
        .with_prompt(prompt)
        .default(false)
        .interact()
        .map_err(|e| CliError::general(format!("prompt failed: {}", e)))?;

    Ok(confirmed)
}

pub fn run_solution(skip_confirm: bool) -> CliResult {
    let domain = parse_domain().ok_or(CliError::NotInProject {
        missing: "src/domain/ (no planning solution found)",
    })?;

    if !confirm_destroy("solution", &domain.solution_type, skip_confirm)? {
        output::print_skip(&format!("solution {}", domain.solution_type));
        return Ok(());
    }

    let domain_dir = Path::new("src/domain");
    let solution_file = find_file_for_type(domain_dir, &domain.solution_type)?;

    let file_name = solution_file
        .file_stem()
        .and_then(|s| s.to_str())
        .ok_or_else(|| CliError::general("invalid solution file name"))?
        .to_string();

    fs::remove_file(&solution_file).map_err(|e| CliError::IoError {
        context: format!("failed to delete {}", solution_file.display()),
        source: e,
    })?;

    remove_from_domain_mod(&file_name)?;
    app_spec::sync_from_project()?;

    output::print_remove(&format!("src/domain/{}.rs", file_name));
    output::print_update("src/domain/mod.rs");
    Ok(())
}

pub fn run_entity(name: &str, skip_confirm: bool) -> CliResult {
    let domain = parse_domain().ok_or(CliError::NotInProject {
        missing: "src/domain/",
    })?;

    let snake = name.to_lowercase().replace('-', "_");
    let pascal = snake_to_pascal(&snake);

    let entity = domain
        .entities
        .iter()
        .find(|e| e.item_type == pascal)
        .ok_or_else(|| CliError::ResourceNotFound {
            kind: "entity",
            name: name.to_string(),
        })?;

    if !confirm_destroy("entity", &pascal, skip_confirm)? {
        output::print_skip(&format!("entity {}", pascal));
        return Ok(());
    }

    let domain_dir = Path::new("src/domain");
    let file_path = find_file_for_type(domain_dir, &pascal).or_else(|_| {
        let path = domain_dir.join(format!("{}.rs", snake));
        if path.exists() {
            Ok(path)
        } else {
            Err(CliError::ResourceNotFound {
                kind: "entity file",
                name: pascal.clone(),
            })
        }
    })?;

    let file_name = file_path
        .file_stem()
        .and_then(|s| s.to_str())
        .ok_or_else(|| CliError::general("invalid entity file name"))?
        .to_string();

    fs::remove_file(&file_path).map_err(|e| CliError::IoError {
        context: format!("failed to delete {}", file_path.display()),
        source: e,
    })?;

    remove_from_domain_mod(&file_name)?;
    unwire_collection_from_solution(&entity.field_name, &entity.item_type, &domain.solution_type)?;
    crate::commands::sf_config::remove_entity(&snake)?;
    app_spec::sync_from_project()?;

    output::print_remove(&format!("src/domain/{}.rs", file_name));
    output::print_update("src/domain/mod.rs");
    Ok(())
}

pub fn run_fact(name: &str, skip_confirm: bool) -> CliResult {
    let domain = parse_domain().ok_or(CliError::NotInProject {
        missing: "src/domain/",
    })?;

    let snake = name.to_lowercase().replace('-', "_");
    let pascal = snake_to_pascal(&snake);

    let fact = domain
        .facts
        .iter()
        .find(|f| f.item_type == pascal)
        .ok_or_else(|| CliError::ResourceNotFound {
            kind: "fact",
            name: name.to_string(),
        })?;

    if !confirm_destroy("fact", &pascal, skip_confirm)? {
        output::print_skip(&format!("fact {}", pascal));
        return Ok(());
    }

    let domain_dir = Path::new("src/domain");
    let file_path = find_file_for_type(domain_dir, &pascal).or_else(|_| {
        let path = domain_dir.join(format!("{}.rs", snake));
        if path.exists() {
            Ok(path)
        } else {
            Err(CliError::ResourceNotFound {
                kind: "fact file",
                name: pascal.clone(),
            })
        }
    })?;

    let file_name = file_path
        .file_stem()
        .and_then(|s| s.to_str())
        .ok_or_else(|| CliError::general("invalid fact file name"))?
        .to_string();

    fs::remove_file(&file_path).map_err(|e| CliError::IoError {
        context: format!("failed to delete {}", file_path.display()),
        source: e,
    })?;

    remove_from_domain_mod(&file_name)?;
    unwire_collection_from_solution(&fact.field_name, &fact.item_type, &domain.solution_type)?;
    crate::commands::sf_config::remove_fact(&snake)?;
    app_spec::sync_from_project()?;

    output::print_remove(&format!("src/domain/{}.rs", file_name));
    output::print_update("src/domain/mod.rs");
    Ok(())
}

pub fn run_variable(field: &str, entity: &str, skip_confirm: bool) -> CliResult {
    if !confirm_destroy("variable", field, skip_confirm)? {
        output::print_skip(&format!("variable {}", field));
        return Ok(());
    }

    generate_domain::destroy_variable(field, entity)
}

pub fn run_constraint(name: &str, skip_confirm: bool) -> CliResult {
    let snake = name.to_lowercase().replace('-', "_");
    let file_path = format!("src/constraints/{}.rs", snake);

    if !Path::new(&file_path).exists() {
        return Err(CliError::ResourceNotFound {
            kind: "constraint",
            name: name.to_string(),
        });
    }

    if !confirm_destroy("constraint", name, skip_confirm)? {
        output::print_skip(&format!("constraint {}", name));
        return Ok(());
    }

    fs::remove_file(&file_path).map_err(|e| CliError::IoError {
        context: format!("failed to delete {}", file_path),
        source: e,
    })?;

    remove_constraint_from_mod(&snake)?;
    crate::commands::sf_config::remove_constraint(&snake)?;
    app_spec::sync_from_project()?;

    output::print_remove(&file_path);
    output::print_update("src/constraints/mod.rs");
    Ok(())
}

fn remove_from_domain_mod(mod_name: &str) -> CliResult {
    let mod_path = Path::new("src/domain/mod.rs");
    if !mod_path.exists() {
        return Ok(());
    }

    let content = fs::read_to_string(mod_path).map_err(|e| CliError::IoError {
        context: "failed to read src/domain/mod.rs".to_string(),
        source: e,
    })?;

    let lines: Vec<&str> = content.lines().collect();
    let mut new_lines: Vec<String> = Vec::new();

    for line in lines {
        if line.trim() == format!("mod {};", mod_name)
            || line.trim().starts_with(&format!("pub use {}::", mod_name))
        {
            continue;
        }
        new_lines.push(line.to_string());
    }

    let new_content = new_lines.join("\n");
    fs::write(mod_path, new_content).map_err(|e| CliError::IoError {
        context: "failed to update src/domain/mod.rs".to_string(),
        source: e,
    })?;

    Ok(())
}

fn unwire_collection_from_solution(
    field_name: &str,
    type_name: &str,
    solution_type: &str,
) -> CliResult {
    let domain_dir = Path::new("src/domain");
    let solution_file = find_file_for_type(domain_dir, solution_type)?;

    let content = fs::read_to_string(&solution_file).map_err(|e| CliError::IoError {
        context: format!("failed to read {}", solution_file.display()),
        source: e,
    })?;

    let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
    let mut i = 0;

    while i < lines.len() {
        let line = &lines[i];

        if line.contains(&format!("{}: Vec<{}>", field_name, type_name)) {
            let mut start = i;
            while start > 0 && lines[start - 1].trim().starts_with('#') {
                start -= 1;
            }
            lines.drain(start..=i);
            i = start;
            continue;
        }

        if line.contains(&format!("{}: Vec::new()", field_name)) {
            lines.remove(i);
            continue;
        }

        if line.trim() == format!("use super::{};", type_name) {
            lines.remove(i);
            continue;
        }

        i += 1;
    }

    let new_content = lines.join("\n");
    fs::write(&solution_file, new_content).map_err(|e| CliError::IoError {
        context: format!("failed to update {}", solution_file.display()),
        source: e,
    })?;

    Ok(())
}

fn remove_constraint_from_mod(name: &str) -> CliResult {
    let mod_path = Path::new("src/constraints/mod.rs");
    if !mod_path.exists() {
        return Ok(());
    }

    let content = fs::read_to_string(mod_path).map_err(|e| CliError::IoError {
        context: "failed to read src/constraints/mod.rs".to_string(),
        source: e,
    })?;

    let lines: Vec<&str> = content.lines().collect();
    let mut new_lines: Vec<String> = Vec::new();

    for line in lines {
        if line.trim() == format!("mod {};", name) {
            continue;
        }

        if let Some(updated_line) = remove_constraint_call_from_line(line, name) {
            if updated_line.trim().is_empty() {
                continue;
            }
            new_lines.push(updated_line);
            continue;
        }
        new_lines.push(line.to_string());
    }

    let result = new_lines.join("\n");

    fs::write(mod_path, result).map_err(|e| CliError::IoError {
        context: "failed to update src/constraints/mod.rs".to_string(),
        source: e,
    })?;

    Ok(())
}

fn remove_constraint_call_from_line(line: &str, name: &str) -> Option<String> {
    let needle = format!("{name}::constraint()");
    if !line.contains(&needle) {
        return None;
    }

    let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
    let trimmed = line.trim();
    let had_trailing_comma = trimmed.ends_with(',');
    let without_trailing_comma = trimmed.trim_end_matches(',');
    let has_tuple_wrapper =
        without_trailing_comma.starts_with('(') && without_trailing_comma.ends_with(')');
    let inner = if has_tuple_wrapper {
        &without_trailing_comma[1..without_trailing_comma.len() - 1]
    } else {
        without_trailing_comma
    };

    let kept_parts: Vec<&str> = inner
        .split(',')
        .map(str::trim)
        .filter(|part| !part.is_empty() && *part != needle)
        .collect();

    if kept_parts.is_empty() {
        return Some(String::new());
    }

    let mut rebuilt = if has_tuple_wrapper {
        format!("({})", kept_parts.join(", "))
    } else {
        kept_parts.join(", ")
    };

    if had_trailing_comma {
        rebuilt.push(',');
    }

    Some(format!("{indent}{rebuilt}"))
}

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