Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/solverforge-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
25 changes: 5 additions & 20 deletions crates/solverforge-cli/src/commands/destroy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -284,34 +287,16 @@ 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;

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(',');
}
}
}
in_tuple = false;
new_lines.push(line);
} else if in_tuple && line.contains(&format!("{}::", name)) {
removed_item = true;
if line.contains(&format!("{}::", name)) {
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle flat-shape tuples when removing constraints

In flat-shape constraint modules like templates/list/vehicle-routing/src/constraints/mod.rs, generate constraint appends foo::constraint() into the same tuple line as the existing constraints. This branch drops any line containing foo::, so destroy constraint foo deletes the entire tuple expression instead of just that element, leaving create_constraints() with no return value and breaking compilation for projects using that layout.

Useful? React with 👍 / 👎.

} else {
new_lines.push(line);
}
new_lines.push(line);
}

let result = new_lines.join("\n");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions crates/solverforge-cli/src/commands/generate_domain/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions crates/solverforge-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ pub mod info;
pub mod new;
pub mod routes;
pub mod server;
pub mod sf_config;
pub mod test;
165 changes: 165 additions & 0 deletions crates/solverforge-cli/src/commands/sf_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// 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::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<String>,
Comment on lines +21 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unknown top-level keys in sf-config.json

SfConfig only models a fixed set of top-level fields, and save() serializes that struct back verbatim. Because Serde ignores unknown keys on input, any existing static/sf-config.json that contains additional UI settings will silently lose them the next time generate/destroy updates constraints, entities, or facts, even though those commands only intended to touch one list.

Useful? React with 👍 / 👎.

#[serde(default)]
entities: Vec<EntityEntry>,
#[serde(default)]
facts: Vec<EntityEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
view: Option<Value>,
}

const CONFIG_PATH: &str = "static/sf-config.json";

fn load() -> Option<SfConfig> {
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 std::sync::{Mutex, OnceLock};

static CWD_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

#[test]
fn preserves_arbitrary_view_shape_when_updating_constraints() {
let _guard = CWD_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("cwd lock poisoned");

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");

assert!(saved.contains("\"capacity_limit\""));
assert!(saved.contains("\"assignment_board\""));
assert!(saved.contains("\"columns\""));
assert!(saved.contains("\"accent\": \"red\""));
}
}
3 changes: 3 additions & 0 deletions crates/solverforge-console/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
8 changes: 7 additions & 1 deletion crates/solverforge-console/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion crates/solverforge-solver/src/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ fn build_local_search<S>(
) -> BasicLocalSearch<S>
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| {
Expand Down
13 changes: 9 additions & 4 deletions crates/solverforge-solver/src/builder/acceptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -121,7 +121,7 @@ impl AcceptorBuilder {
/// Builds a concrete [`AnyAcceptor`] from configuration.
pub fn build<S: PlanningSolution>(config: &AcceptorConfig) -> AnyAcceptor<S>
where
S::Score: Score,
S::Score: Score + ParseableScore,
{
match config {
AcceptorConfig::HillClimbing => AnyAcceptor::HillClimbing(HillClimbingAcceptor::new()),
Expand All @@ -138,7 +138,12 @@ impl AcceptorBuilder {
let starting_temp = sa_config
.starting_temperature
.as_ref()
.and_then(|s| s.parse::<f64>().ok())
.map(|s| {
let score = S::Score::parse(s).unwrap_or_else(|e| {
panic!("Invalid starting_temperature '{}': {}", s, e)
});
score.to_scalar().abs()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept scalar SA temperatures in config-driven builders

This now parses starting_temperature with S::Score::parse, which rejects the scalar values that the acceptor already supports (SimulatedAnnealingAcceptor::new still takes an f64). A solver.toml that previously used 1.5 or 100.0 will now panic at startup unless the string happens to match the score type’s textual format, so existing simulated-annealing configs regress as soon as they use a numeric temperature or any fractional value for SoftScore/HardSoftScore.

Useful? React with 👍 / 👎.

})
.unwrap_or(0.0);
AnyAcceptor::SimulatedAnnealing(SimulatedAnnealingAcceptor::new(
starting_temp,
Expand Down Expand Up @@ -216,7 +221,7 @@ 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<TestSolution> = AcceptorBuilder::build(&config);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/solverforge-solver/src/list_solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ fn build_list_local_search<S, V, DM, IDM>(
) -> ListLocalSearch<S, V, DM, IDM>
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<S> + Clone,
IDM: CrossEntityDistanceMeter<S> + Clone,
Expand Down
Loading