solverforge-cli 1.1.3

CLI for scaffolding and managing SolverForge constraint solver projects
const fs = require('fs');
const os = require('os');
const path = require('path');
const net = require('net');
const { spawn, spawnSync } = require('child_process');

const repoRoot = path.resolve(__dirname, '..', '..');
const cliManifest = path.join(repoRoot, 'Cargo.toml');
const artifactRoot = process.env.SF_E2E_ARTIFACT_ROOT || path.join(repoRoot, 'target', 'test-artifacts', 'playwright');
const manifestPath = path.join(artifactRoot, 'manifest.json');
const statePath = path.join(artifactRoot, 'state.json');

const seededMixedDataModule = `/* Seeded demo data for Playwright end-to-end tests. */

use std::str::FromStr;

use crate::domain::{Container, Item, Plan, Resource, Task};

#[derive(Debug, Clone, Copy)]
pub enum DemoData {
    Small,
    Standard,
}

impl FromStr for DemoData {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_uppercase().as_str() {
            "SMALL" => Ok(DemoData::Small),
            "STANDARD" => Ok(DemoData::Standard),
            _ => Err(()),
        }
    }
}

pub fn generate(demo: DemoData) -> Plan {
    match demo {
        DemoData::Small => generate_plan(2, 3, 2, 4),
        DemoData::Standard => generate_plan(3, 6, 3, 8),
    }
}

fn generate_plan(n_resources: usize, n_tasks: usize, n_containers: usize, n_items: usize) -> Plan {
    let resources = (0..n_resources)
        .map(|idx| Resource::new(format!("resource-{idx}"), format!("resource-{idx}")))
        .collect::<Vec<_>>();
    let tasks = (0..n_tasks)
        .map(|idx| Task::new(format!("task-{idx}")))
        .collect::<Vec<_>>();
    let items = (0..n_items)
        .map(|idx| Item::new(format!("item-{idx}"), format!("item-{idx}")))
        .collect::<Vec<_>>();
    let containers = (0..n_containers)
        .map(|idx| Container::new(format!("container-{idx}")))
        .collect::<Vec<_>>();

    Plan::new(resources, tasks, items, containers)
}
`;

const seededStandardDataModule = `/* Seeded standard-variable demo data for Playwright end-to-end tests. */

use std::str::FromStr;

use crate::domain::{Plan, Resource, Task};

#[derive(Debug, Clone, Copy)]
pub enum DemoData {
    Small,
    Standard,
}

impl FromStr for DemoData {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_uppercase().as_str() {
            "SMALL" => Ok(DemoData::Small),
            "STANDARD" => Ok(DemoData::Standard),
            _ => Err(()),
        }
    }
}

pub fn generate(demo: DemoData) -> Plan {
    match demo {
        DemoData::Small => generate_plan(4, 16),
        DemoData::Standard => generate_plan(8, 48),
    }
}

fn generate_plan(n_resources: usize, n_tasks: usize) -> Plan {
    let resources = (0..n_resources)
        .map(|idx| Resource::new(format!("resource-{idx}"), format!("resource-{idx}")))
        .collect::<Vec<_>>();
    let tasks = (0..n_tasks)
        .map(|idx| Task::new(format!("task-{idx}")))
        .collect::<Vec<_>>();

    Plan::new(resources, tasks)
}
`;

function phase(suite, title) {
  console.log(`=== PHASE: ${suite} :: ${title} ===`);
}

function ensureDir(dir) {
  fs.mkdirSync(dir, { recursive: true });
}

function workspaceRoot() {
  return path.dirname(repoRoot);
}

function tomlPath(value) {
  return value.replace(/\\/g, '/');
}

function usePublishedDeps() {
  const value = process.env.SF_USE_PUBLISHED_DEPS;
  return value && ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
}

function resolveBuiltExecutable(buildStdout) {
  let executable = null;
  for (const line of buildStdout.split(/\r?\n/)) {
    if (!line.trim()) {
      continue;
    }
    try {
      const message = JSON.parse(line);
      if (message.reason !== 'compiler-artifact') {
        continue;
      }
      if (!Array.isArray(message.target?.kind) || !message.target.kind.includes('bin')) {
        continue;
      }
      if (typeof message.executable === 'string' && message.executable.length > 0) {
        executable = message.executable;
      }
    } catch (_err) {}
  }
  if (!executable) {
    throw new Error('cargo build succeeded but did not report a binary executable');
  }
  return executable;
}

function resolveLocalSolverforgePaths() {
  if (usePublishedDeps()) {
    return null;
  }

  const runtimePath = path.join(workspaceRoot(), 'solverforge-rs', 'crates', 'solverforge');
  const uiPath = path.join(workspaceRoot(), 'solverforge-ui');
  const mapsPath = path.join(workspaceRoot(), 'solverforge-maps');
  if (!fs.existsSync(runtimePath) || !fs.existsSync(uiPath)) {
    return null;
  }

  return {
    runtimePath,
    uiPath,
    mapsPath: fs.existsSync(mapsPath) ? mapsPath : null,
  };
}

function pinGeneratedProjectToLocalSolverforge(projectDir) {
  const localPaths = resolveLocalSolverforgePaths();
  if (!localPaths) {
    return false;
  }

  const cargoTomlPath = path.join(projectDir, 'Cargo.toml');
  const cargoToml = fs.readFileSync(cargoTomlPath, 'utf8');
  const rewritten = cargoToml
    .split('\n')
    .map((line) => {
      const trimmed = line.trimStart();
      if (trimmed.startsWith('solverforge = ')) {
        return `solverforge = { path = "${tomlPath(localPaths.runtimePath)}", features = ["serde", "console", "verbose-logging"] }`;
      }
      if (trimmed.startsWith('solverforge-ui = ')) {
        return `solverforge-ui = { path = "${tomlPath(localPaths.uiPath)}" }`;
      }
      if (trimmed.startsWith('solverforge-maps = ') && localPaths.mapsPath) {
        return `solverforge-maps = { path = "${tomlPath(localPaths.mapsPath)}" }`;
      }
      return line;
    })
    .join('\n');
  fs.writeFileSync(cargoTomlPath, `${rewritten}\n`);
  return true;
}

function runCommand({ suite, title, cwd, args, logPath, env = {} }) {
  phase(suite, title);
  ensureDir(path.dirname(logPath));
  const result = spawnSync('cargo', ['run', '--quiet', '--manifest-path', cliManifest, '--bin', 'solverforge', '--', ...args], {
    cwd,
    env: { ...process.env, ...env },
    encoding: 'utf8',
  });
  fs.writeFileSync(
    logPath,
    `=== COMMAND ===\ncargo run --quiet --manifest-path ${cliManifest} --bin solverforge -- ${args.join(' ')}\n` +
      `=== STDOUT ===\n${result.stdout || ''}\n=== STDERR ===\n${result.stderr || ''}\n`
  );
  if (result.status !== 0) {
    throw new Error(`${title} failed.\nSee ${logPath}\n${result.stderr || result.stdout || ''}`);
  }
}

function allocatePort() {
  return new Promise((resolve, reject) => {
    const server = net.createServer();
    server.listen(0, '127.0.0.1', () => {
      const { port } = server.address();
      server.close(() => resolve(port));
    });
    server.on('error', reject);
  });
}

async function waitForHealth({ baseUrl, suite, child, stdoutPath, stderrPath }) {
  phase(suite, 'Wait for readiness');
  let spawnError = null;
  child.once('error', (error) => {
    spawnError = error;
  });
  const started = Date.now();
  while (Date.now() - started < 40000) {
    if (spawnError) {
      throw new Error(
        `Server failed to launch: ${spawnError.message}. stdout: ${stdoutPath} stderr: ${stderrPath}`
      );
    }
    if (child.exitCode !== null || child.signalCode !== null) {
      throw new Error(
        `Server exited before readiness (exitCode=${child.exitCode}, signal=${child.signalCode}). stdout: ${stdoutPath} stderr: ${stderrPath}`
      );
    }
    try {
      const response = await fetch(`${baseUrl}/health`);
      if (response.ok) {
        return;
      }
    } catch (_err) {}
    await new Promise((resolve) => setTimeout(resolve, 250));
  }
  throw new Error(`Server at ${baseUrl} did not become healthy in time`);
}

async function scaffoldScenario(name, generatorCommands) {
  const suite = `playwright:${name}`;
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), `solverforge-${name}-`));
  const projectName = `${name}-app`;
  const projectDir = path.join(tmpRoot, projectName);
  const scenarioArtifactDir = path.join(artifactRoot, name);
  ensureDir(scenarioArtifactDir);

  runCommand({
    suite,
    title: 'Scaffold neutral app',
    cwd: tmpRoot,
    args: ['new', projectName, '--skip-git', '--skip-readme', '--quiet'],
    logPath: path.join(scenarioArtifactDir, '01-scaffold.log'),
  });
  const pinnedToLocal = pinGeneratedProjectToLocalSolverforge(projectDir);
  if (!pinnedToLocal) {
    phase(suite, 'Using published SolverForge crate targets');
  }

  generatorCommands.forEach((args, index) => {
    runCommand({
      suite,
      title: `Generator command ${index + 1}`,
      cwd: projectDir,
      args,
      logPath: path.join(scenarioArtifactDir, `02-generate-${index + 1}.log`),
    });
  });

  if (name === 'mixed-pipeline') {
    phase(suite, 'Seed non-empty mixed demo data');
    fs.writeFileSync(path.join(projectDir, 'src', 'data', 'mod.rs'), seededMixedDataModule);
  }
  if (name === 'standard-solver') {
    phase(suite, 'Seed non-empty standard demo data');
    fs.writeFileSync(path.join(projectDir, 'src', 'data', 'mod.rs'), seededStandardDataModule);
  }

  phase(suite, 'Build generated app');
  const cargoBuild = spawnSync('cargo', ['build', '--message-format=json-render-diagnostics'], {
    cwd: projectDir,
    encoding: 'utf8',
  });
  let executablePath = null;
  let executableResolveError = null;
  if (cargoBuild.status === 0) {
    try {
      executablePath = resolveBuiltExecutable(cargoBuild.stdout || '');
    } catch (error) {
      executableResolveError = error;
    }
  }
  fs.writeFileSync(
    path.join(scenarioArtifactDir, '03-cargo-build.log'),
    `=== EXECUTABLE ===\n${executablePath || ''}\n=== STDOUT ===\n${cargoBuild.stdout || ''}\n=== STDERR ===\n${cargoBuild.stderr || ''}\n`
  );
  if (cargoBuild.status !== 0) {
    throw new Error(`cargo build failed for ${name}`);
  }
  if (executableResolveError) {
    throw new Error(
      `${executableResolveError.message}. See ${path.join(scenarioArtifactDir, '03-cargo-build.log')}`
    );
  }

  phase(suite, 'Boot generated server');
  const port = await allocatePort();
  const stdoutPath = path.join(scenarioArtifactDir, 'server.stdout.log');
  const stderrPath = path.join(scenarioArtifactDir, 'server.stderr.log');
  const stdout = fs.openSync(stdoutPath, 'w');
  const stderr = fs.openSync(stderrPath, 'w');
  const child = spawn(executablePath, [], {
    cwd: projectDir,
    env: { ...process.env, PORT: String(port) },
    stdio: ['ignore', stdout, stderr],
  });
  fs.closeSync(stdout);
  fs.closeSync(stderr);
  const baseUrl = `http://127.0.0.1:${port}`;
  await waitForHealth({ baseUrl, suite, child, stdoutPath, stderrPath });

  return {
    suite,
    name,
    tmpRoot,
    projectDir,
    artifactDir: scenarioArtifactDir,
    pid: child.pid,
    port,
    baseUrl,
  };
}

function writeManifest(manifest) {
  ensureDir(artifactRoot);
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
}

function writeState(state) {
  ensureDir(artifactRoot);
  fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
}

function readManifest() {
  return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}

function readState() {
  return JSON.parse(fs.readFileSync(statePath, 'utf8'));
}

module.exports = {
  artifactRoot,
  manifestPath,
  phase,
  readManifest,
  readState,
  scaffoldScenario,
  writeManifest,
  writeState,
};