diff --git a/Cargo.lock b/Cargo.lock index 5d9daa66..d4edc908 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -669,6 +669,8 @@ dependencies = [ "dialoguer", "include_dir", "owo-colors", + "serde", + "serde_json", "strsim", "tempfile", "toml", diff --git a/crates/solverforge-cli/Cargo.toml b/crates/solverforge-cli/Cargo.toml index facd5cae..a56d273d 100644 --- a/crates/solverforge-cli/Cargo.toml +++ b/crates/solverforge-cli/Cargo.toml @@ -17,6 +17,8 @@ clap_complete = { workspace = true } dialoguer = { workspace = true } include_dir = { workspace = true } owo-colors = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" strsim = { workspace = true } toml = { workspace = true } diff --git a/crates/solverforge-cli/src/commands/destroy.rs b/crates/solverforge-cli/src/commands/destroy.rs index cefa3fb0..8ccdc495 100644 --- a/crates/solverforge-cli/src/commands/destroy.rs +++ b/crates/solverforge-cli/src/commands/destroy.rs @@ -100,6 +100,7 @@ pub fn run_entity(name: &str, skip_confirm: bool) -> CliResult { 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)?; output::print_remove(&format!("src/domain/{}.rs", file_name)); output::print_update("src/domain/mod.rs"); @@ -154,6 +155,7 @@ pub fn run_fact(name: &str, skip_confirm: bool) -> CliResult { 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)?; output::print_remove(&format!("src/domain/{}.rs", file_name)); output::print_update("src/domain/mod.rs"); @@ -182,6 +184,7 @@ pub fn run_constraint(name: &str, skip_confirm: bool) -> CliResult { })?; remove_constraint_from_mod(&snake)?; + crate::commands::sf_config::remove_constraint(&snake)?; output::print_remove(&file_path); output::print_update("src/constraints/mod.rs"); @@ -200,7 +203,7 @@ fn remove_from_domain_mod(mod_name: &str) -> CliResult { })?; let lines: Vec<&str> = content.lines().collect(); - let mut new_lines = Vec::new(); + let mut new_lines: Vec = Vec::new(); for line in lines { if line.trim() == format!("mod {};", mod_name) @@ -208,7 +211,7 @@ fn remove_from_domain_mod(mod_name: &str) -> CliResult { { continue; } - new_lines.push(line); + new_lines.push(line.to_string()); } let new_content = new_lines.join("\n"); @@ -283,35 +286,21 @@ fn remove_constraint_from_mod(name: &str) -> CliResult { })?; let lines: Vec<&str> = content.lines().collect(); - let mut new_lines = Vec::new(); - let mut in_tuple = false; - let mut removed_item = false; + let mut new_lines: Vec = Vec::new(); for line in lines { if line.trim() == format!("mod {};", name) { continue; } - if line.contains("pub fn all() ->") || line.contains("impl Constraint") { - in_tuple = true; - new_lines.push(line); - } else if in_tuple && line.contains(')') { - if removed_item && !new_lines.is_empty() { - let last_idx = new_lines.len() - 1; - if let Some(last) = new_lines.get_mut(last_idx) { - if last.trim().ends_with(',') { - *last = last.trim().trim_end_matches(','); - } - } + if let Some(updated_line) = remove_constraint_call_from_line(line, name) { + if updated_line.trim().is_empty() { + continue; } - in_tuple = false; - new_lines.push(line); - } else if in_tuple && line.contains(&format!("{}::", name)) { - removed_item = true; + new_lines.push(updated_line); continue; - } else { - new_lines.push(line); } + new_lines.push(line.to_string()); } let result = new_lines.join("\n"); @@ -323,3 +312,68 @@ fn remove_constraint_from_mod(name: &str) -> CliResult { Ok(()) } + +fn remove_constraint_call_from_line(line: &str, name: &str) -> Option { + 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)] +mod tests { + use super::remove_constraint_call_from_line; + + #[test] + fn removes_constraint_from_multiline_tuple_entry() { + let line = " all_assigned::constraint(),"; + let updated = remove_constraint_call_from_line(line, "all_assigned") + .expect("line should be rewritten"); + assert!(updated.is_empty()); + } + + #[test] + fn removes_constraint_from_flat_tuple_line() { + let line = " (capacity::constraint(), extra::constraint(), distance::constraint())"; + let updated = + remove_constraint_call_from_line(line, "extra").expect("line should be rewritten"); + assert_eq!( + updated, + " (capacity::constraint(), distance::constraint())" + ); + } +} diff --git a/crates/solverforge-cli/src/commands/generate_constraint/run.rs b/crates/solverforge-cli/src/commands/generate_constraint/run.rs index 1d246fe5..56d2adf6 100644 --- a/crates/solverforge-cli/src/commands/generate_constraint/run.rs +++ b/crates/solverforge-cli/src/commands/generate_constraint/run.rs @@ -108,6 +108,8 @@ pub fn run( source: e, })?; + crate::commands::sf_config::add_constraint(name)?; + output::print_create(&format!("src/constraints/{}.rs", name)); print_diff_verbose("", &skeleton); output::print_update("src/constraints/mod.rs"); diff --git a/crates/solverforge-cli/src/commands/generate_domain/run.rs b/crates/solverforge-cli/src/commands/generate_domain/run.rs index ef5bfb0e..e6d452be 100644 --- a/crates/solverforge-cli/src/commands/generate_domain/run.rs +++ b/crates/solverforge-cli/src/commands/generate_domain/run.rs @@ -54,6 +54,8 @@ pub fn run_entity( update_domain_mod(name, &pascal)?; wire_collection_into_solution(name, &pascal, "planning_entity_collection")?; + let plural = super::utils::pluralize(name); + crate::commands::sf_config::add_entity(name, &pascal, &plural)?; output::print_create(file_path.to_str().unwrap()); print_diff_verbose("", &src); @@ -94,6 +96,8 @@ pub fn run_fact(name: &str, fields: &[String], force: bool, pretend: bool) -> Cl update_domain_mod(name, &pascal)?; wire_collection_into_solution(name, &pascal, "problem_fact_collection")?; + let plural = super::utils::pluralize(name); + crate::commands::sf_config::add_fact(name, &pascal, &plural)?; output::print_create(file_path.to_str().unwrap()); print_diff_verbose("", &src); diff --git a/crates/solverforge-cli/src/commands/generate_domain/tests.rs b/crates/solverforge-cli/src/commands/generate_domain/tests.rs index d5689c61..cbd61f45 100644 --- a/crates/solverforge-cli/src/commands/generate_domain/tests.rs +++ b/crates/solverforge-cli/src/commands/generate_domain/tests.rs @@ -4,7 +4,7 @@ use super::{ utils::{pluralize, snake_to_pascal, validate_score_type}, wiring::{add_import, replace_score_type}, }; -use std::sync::{Mutex, OnceLock}; +use crate::test_support; #[test] fn test_snake_to_pascal() { @@ -151,12 +151,7 @@ fn test_generate_data_loader_stub_is_compile_safe() { #[test] fn test_remove_default_scaffold_rewrites_data_module_without_todo() { - static CWD_LOCK: OnceLock> = OnceLock::new(); - - let _guard = CWD_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .expect("cwd lock poisoned"); + let guard = test_support::lock_cwd(); let tmp = tempfile::tempdir().expect("failed to create temp dir"); let original_dir = std::env::current_dir().expect("failed to get current dir"); @@ -194,6 +189,7 @@ fn test_remove_default_scaffold_rewrites_data_module_without_todo() { std::env::set_current_dir(tmp.path()).expect("failed to enter temp dir"); let result = remove_default_scaffold(); std::env::set_current_dir(original_dir).expect("failed to restore current dir"); + drop(guard); result.expect("remove_default_scaffold should succeed"); diff --git a/crates/solverforge-cli/src/commands/mod.rs b/crates/solverforge-cli/src/commands/mod.rs index 8b9157fd..2cb9a20b 100644 --- a/crates/solverforge-cli/src/commands/mod.rs +++ b/crates/solverforge-cli/src/commands/mod.rs @@ -9,4 +9,5 @@ pub mod info; pub mod new; pub mod routes; pub mod server; +pub mod sf_config; pub mod test; diff --git a/crates/solverforge-cli/src/commands/sf_config.rs b/crates/solverforge-cli/src/commands/sf_config.rs new file mode 100644 index 00000000..0aded4b3 --- /dev/null +++ b/crates/solverforge-cli/src/commands/sf_config.rs @@ -0,0 +1,195 @@ +// JSON-based sf-config.json reader/writer for UI wiring. +// Reads/modifies/writes static/sf-config.json. +// If the file doesn't exist, operations return Ok(()) silently. + +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::error::CliResult; + +#[derive(Debug, Deserialize, Serialize)] +struct EntityEntry { + name: String, + label: String, + plural: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct SfConfig { + title: String, + subtitle: String, + #[serde(default)] + constraints: Vec, + #[serde(default)] + entities: Vec, + #[serde(default)] + facts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + view: Option, + #[serde(flatten)] + extra: Map, +} + +const CONFIG_PATH: &str = "static/sf-config.json"; + +fn load() -> Option { + let path = Path::new(CONFIG_PATH); + if !path.exists() { + return None; + } + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +fn save(config: &SfConfig) -> CliResult { + let json = serde_json::to_string_pretty(config).map_err(|e| { + crate::error::CliError::general(format!("sf-config serialize error: {}", e)) + })?; + fs::write(CONFIG_PATH, json).map_err(|e| crate::error::CliError::IoError { + context: "failed to write sf-config.json".to_string(), + source: e, + })?; + Ok(()) +} + +pub fn add_constraint(name: &str) -> CliResult { + let Some(mut config) = load() else { + return Ok(()); + }; + if !config.constraints.contains(&name.to_string()) { + config.constraints.push(name.to_string()); + save(&config)?; + } + Ok(()) +} + +pub fn remove_constraint(name: &str) -> CliResult { + let Some(mut config) = load() else { + return Ok(()); + }; + config.constraints.retain(|c| c != name); + save(&config) +} + +pub fn add_entity(name: &str, label: &str, plural: &str) -> CliResult { + let Some(mut config) = load() else { + return Ok(()); + }; + if !config.entities.iter().any(|e| e.name == name) { + config.entities.push(EntityEntry { + name: name.to_string(), + label: label.to_string(), + plural: plural.to_string(), + }); + save(&config)?; + } + Ok(()) +} + +pub fn remove_entity(name: &str) -> CliResult { + let Some(mut config) = load() else { + return Ok(()); + }; + config.entities.retain(|e| e.name != name); + save(&config) +} + +pub fn add_fact(name: &str, label: &str, plural: &str) -> CliResult { + let Some(mut config) = load() else { + return Ok(()); + }; + if !config.facts.iter().any(|f| f.name == name) { + config.facts.push(EntityEntry { + name: name.to_string(), + label: label.to_string(), + plural: plural.to_string(), + }); + save(&config)?; + } + Ok(()) +} + +pub fn remove_fact(name: &str) -> CliResult { + let Some(mut config) = load() else { + return Ok(()); + }; + config.facts.retain(|f| f.name != name); + save(&config) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support; + + #[test] + fn preserves_arbitrary_view_shape_when_updating_constraints() { + let guard = test_support::lock_cwd(); + + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let original_dir = std::env::current_dir().expect("failed to get current dir"); + + std::fs::create_dir_all(tmp.path().join("static")).expect("failed to create static dir"); + std::fs::write( + tmp.path().join("static").join("sf-config.json"), + r#"{ + "title": "demo", + "subtitle": "demo", + "constraints": ["all_assigned"], + "view": { + "type": "assignment_board", + "columns": [{"id": "todo"}], + "meta": {"accent": "red"} + } +}"#, + ) + .expect("failed to write sf-config"); + + std::env::set_current_dir(tmp.path()).expect("failed to enter temp dir"); + add_constraint("capacity_limit").expect("add_constraint should succeed"); + let saved = std::fs::read_to_string(tmp.path().join("static").join("sf-config.json")) + .expect("failed to read saved sf-config"); + std::env::set_current_dir(original_dir).expect("failed to restore current dir"); + drop(guard); + + assert!(saved.contains("\"capacity_limit\"")); + assert!(saved.contains("\"assignment_board\"")); + assert!(saved.contains("\"columns\"")); + assert!(saved.contains("\"accent\": \"red\"")); + } + + #[test] + fn preserves_unknown_top_level_keys_when_updating_constraints() { + let guard = test_support::lock_cwd(); + + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let original_dir = std::env::current_dir().expect("failed to get current dir"); + + std::fs::create_dir_all(tmp.path().join("static")).expect("failed to create static dir"); + std::fs::write( + tmp.path().join("static").join("sf-config.json"), + r#"{ + "title": "demo", + "subtitle": "demo", + "constraints": ["all_assigned"], + "theme": {"accent": "red"}, + "layout": "rail" +}"#, + ) + .expect("failed to write sf-config"); + + std::env::set_current_dir(tmp.path()).expect("failed to enter temp dir"); + add_constraint("capacity_limit").expect("add_constraint should succeed"); + let saved = std::fs::read_to_string(tmp.path().join("static").join("sf-config.json")) + .expect("failed to read saved sf-config"); + std::env::set_current_dir(original_dir).expect("failed to restore current dir"); + drop(guard); + + assert!(saved.contains("\"capacity_limit\"")); + assert!(saved.contains("\"theme\"")); + assert!(saved.contains("\"layout\": \"rail\"")); + } +} diff --git a/crates/solverforge-cli/src/main.rs b/crates/solverforge-cli/src/main.rs index 71bfe10b..e58f7a8b 100644 --- a/crates/solverforge-cli/src/main.rs +++ b/crates/solverforge-cli/src/main.rs @@ -6,6 +6,8 @@ mod error; mod output; mod rc; mod template; +#[cfg(test)] +mod test_support; use error::CliResult; diff --git a/crates/solverforge-cli/src/test_support.rs b/crates/solverforge-cli/src/test_support.rs new file mode 100644 index 00000000..fd202860 --- /dev/null +++ b/crates/solverforge-cli/src/test_support.rs @@ -0,0 +1,10 @@ +use std::sync::{Mutex, MutexGuard, OnceLock}; + +static CWD_LOCK: OnceLock> = OnceLock::new(); + +pub(crate) fn lock_cwd() -> MutexGuard<'static, ()> { + CWD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("cwd lock poisoned") +} diff --git a/crates/solverforge-console/Cargo.toml b/crates/solverforge-console/Cargo.toml index 51e5d6e9..b017e381 100644 --- a/crates/solverforge-console/Cargo.toml +++ b/crates/solverforge-console/Cargo.toml @@ -10,3 +10,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } owo-colors = { workspace = true } num-format = "0.4" + +[features] +verbose-logging = [] diff --git a/crates/solverforge-console/src/lib.rs b/crates/solverforge-console/src/lib.rs index a1f3a10b..4bd72f29 100644 --- a/crates/solverforge-console/src/lib.rs +++ b/crates/solverforge-console/src/lib.rs @@ -32,9 +32,15 @@ pub fn init() { INIT.get_or_init(|| { banner::print_banner(); + #[cfg(feature = "verbose-logging")] + let solver_level = "solverforge_solver=debug"; + #[cfg(not(feature = "verbose-logging"))] + let solver_level = "solverforge_solver=info"; + let filter = EnvFilter::builder() - .with_default_directive("solverforge_solver=info".parse().unwrap()) + .with_default_directive(solver_level.parse().unwrap()) .from_env_lossy() + .add_directive(solver_level.parse().unwrap()) .add_directive("solverforge_dynamic=info".parse().unwrap()); let _ = tracing_subscriber::registry() diff --git a/crates/solverforge-solver/src/basic.rs b/crates/solverforge-solver/src/basic.rs index be908f4c..db77722c 100644 --- a/crates/solverforge-solver/src/basic.rs +++ b/crates/solverforge-solver/src/basic.rs @@ -178,7 +178,7 @@ fn build_local_search( ) -> BasicLocalSearch where S: PlanningSolution, - S::Score: Score, + S::Score: Score + ParseableScore, { // Find first local search phase config let ls_config = config.phases.iter().find_map(|p| { diff --git a/crates/solverforge-solver/src/builder/acceptor.rs b/crates/solverforge-solver/src/builder/acceptor.rs index 0d8957b1..b32446c0 100644 --- a/crates/solverforge-solver/src/builder/acceptor.rs +++ b/crates/solverforge-solver/src/builder/acceptor.rs @@ -4,7 +4,7 @@ use std::fmt::Debug; use solverforge_config::AcceptorConfig; use solverforge_core::domain::PlanningSolution; -use solverforge_core::score::Score; +use solverforge_core::score::{ParseableScore, Score}; use crate::phase::localsearch::{ Acceptor, GreatDelugeAcceptor, HillClimbingAcceptor, LateAcceptanceAcceptor, @@ -121,7 +121,7 @@ impl AcceptorBuilder { /// Builds a concrete [`AnyAcceptor`] from configuration. pub fn build(config: &AcceptorConfig) -> AnyAcceptor where - S::Score: Score, + S::Score: Score + ParseableScore, { match config { AcceptorConfig::HillClimbing => AnyAcceptor::HillClimbing(HillClimbingAcceptor::new()), @@ -138,7 +138,14 @@ impl AcceptorBuilder { let starting_temp = sa_config .starting_temperature .as_ref() - .and_then(|s| s.parse::().ok()) + .map(|s| { + s.parse::() + .ok() + .or_else(|| S::Score::parse(s).ok().map(|score| score.to_scalar().abs())) + .unwrap_or_else(|| { + panic!("Invalid starting_temperature '{}': expected scalar or score string", s) + }) + }) .unwrap_or(0.0); AnyAcceptor::SimulatedAnnealing(SimulatedAnnealingAcceptor::new( starting_temp, @@ -216,7 +223,15 @@ mod tests { #[test] fn test_acceptor_builder_simulated_annealing() { let config = AcceptorConfig::SimulatedAnnealing(SimulatedAnnealingConfig { - starting_temperature: Some("1.5".to_string()), + starting_temperature: Some("2".to_string()), + }); + let _acceptor: AnyAcceptor = AcceptorBuilder::build(&config); + } + + #[test] + fn test_acceptor_builder_simulated_annealing_accepts_fractional_scalar() { + let config = AcceptorConfig::SimulatedAnnealing(SimulatedAnnealingConfig { + starting_temperature: Some("2.5".to_string()), }); let _acceptor: AnyAcceptor = AcceptorBuilder::build(&config); } diff --git a/crates/solverforge-solver/src/list_solver.rs b/crates/solverforge-solver/src/list_solver.rs index b2696ef1..d20ae996 100644 --- a/crates/solverforge-solver/src/list_solver.rs +++ b/crates/solverforge-solver/src/list_solver.rs @@ -451,7 +451,7 @@ fn build_list_local_search( ) -> ListLocalSearch where S: PlanningSolution, - S::Score: Score, + S::Score: Score + ParseableScore, V: Clone + Copy + PartialEq + Eq + std::hash::Hash + Send + Sync + fmt::Debug + 'static, DM: CrossEntityDistanceMeter + Clone, IDM: CrossEntityDistanceMeter + Clone,