main-spex 0.20.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use clap::ValueEnum;

use anstream::println;
use eros::{Context, bail};
use owo_colors::OwoColorize;

use crate::config::{CLI_VERSION, Layout, SPEX_CONFIG_FILE, SpexConfig, spex_yaml_edit};
use crate::error::{Result, could_not_read_dir};
use crate::spec::SPECS_DIR;
use crate::support::fs::{create_dir_all, current_dir, dir_name, normalize, write_file};

use super::agent_files::verify_agent_files;
use super::scaffold::scaffold;

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

const PLANS_DIR_NAME: &str = "plans";
const CONTRACTS_DIR_NAME: &str = "contracts";

const CODE_WORKSPACE_EXTENSION: &str = "code-workspace";
const MULTI_PACKAGE_CONFIG_TEMPLATE: &str = include_str!("../../../templates/spex.yaml");
const SINGLE_PACKAGE_CONFIG_TEMPLATE: &str =
    include_str!("../../../templates/spex.single-repo.yaml");
const MONOPACKAGE_CONFIG_TEMPLATE: &str = include_str!("../../../templates/spex.monorepo.yaml");
const CODE_WORKSPACE_TEMPLATE: &str =
    include_str!("../../../templates/project-name.code-workspace");

pub fn run() -> Result<ExitCode> {
    let current_dir = current_dir()?;

    let project_root = project_root(&current_dir)?;

    let default_name = dir_name(&project_root)
        .ok_or_else(|| eros::error!("could not read the project root name"))?;

    let layout = confirm_layout()?;
    let project_name = confirm_project_name(default_name)?;

    let spex_dir = locate_spex_dir(&current_dir, &project_root, layout, &project_name)?;
    verify_empty(&spex_dir)?;
    let agent_root = normalize(&spex_dir.join(layout.agent_root()));
    verify_agent_files(&agent_root)?;

    create_dir_all(&spex_dir)?;
    write_config(&spex_dir, layout, &project_name)?;
    if layout.is_multi_repo() {
        write_code_workspace(&spex_dir, &project_name)?;
    }
    copy_templates(&spex_dir, &agent_root)?;

    println!(
        "{}",
        "spex initialized, open project in your editor and run agent skill `/spex-setup` to complete setup"
            .green()
    );
    Ok(ExitCode::SUCCESS)
}

fn copy_templates(spex_dir: &Path, agent_root: &Path) -> Result<()> {
    scaffold(spex_dir, agent_root)?;

    let specs = spex_dir.join(SPECS_DIR);
    create_dir_all(&specs)?;

    let plans = spex_dir.join(PLANS_DIR_NAME);
    create_dir_all(&plans)?;

    let contracts = spex_dir.join(CONTRACTS_DIR_NAME);
    create_dir_all(&contracts)?;

    Ok(())
}

fn write_config(spex_dir: &Path, layout: Layout, project_name: &str) -> Result<()> {
    let config_path = spex_dir.join(SPEX_CONFIG_FILE);
    write_file(&config_path, config_from_template(layout, project_name))?;
    Ok(())
}

fn config_template(layout: Layout) -> &'static str {
    match layout {
        Layout::MultiRepo => MULTI_PACKAGE_CONFIG_TEMPLATE,
        Layout::SingleRepo => SINGLE_PACKAGE_CONFIG_TEMPLATE,
        Layout::Monorepo => MONOPACKAGE_CONFIG_TEMPLATE,
    }
}

fn package_name(layout: Layout, project_name: &str) -> String {
    match layout {
        Layout::MultiRepo => layout.spex_dir_name(project_name),
        Layout::SingleRepo | Layout::Monorepo => project_name.to_string(),
    }
}

fn config_from_template(layout: Layout, project_name: &str) -> String {
    let named = config_template(layout)
        .replace(
            "project_name: unknown",
            &format!("project_name: {project_name}"),
        )
        .replace("layout: unknown", &format!("layout: {layout}"))
        .replace(
            "agent_root: unknown",
            &format!("agent_root: {}", layout.agent_root()),
        )
        .replace(
            "package_name: unknown",
            &format!("package_name: {}", package_name(layout, project_name)),
        )
        .replace(
            "package_short_name: unknown",
            &format!("package_short_name: {project_name}"),
        );
    spex_yaml_edit::set_cli_version(&named, CLI_VERSION)
}

fn write_code_workspace(spex_dir: &Path, project_name: &str) -> Result<()> {
    let file_name = format!("{project_name}.{CODE_WORKSPACE_EXTENSION}");
    let path = spex_dir.join(&file_name);
    write_file(&path, CODE_WORKSPACE_TEMPLATE)?;
    Ok(())
}

fn is_inside_spex_dir(current_dir: &Path) -> Result<bool> {
    let is_named_like_spex_dir = dir_name(current_dir).is_some_and(SpexConfig::is_spex_dir_name);
    Ok(is_named_like_spex_dir
        && (current_dir.join(SPEX_CONFIG_FILE).is_file() || is_empty_dir(current_dir)?))
}

fn project_root(current_dir: &Path) -> Result<PathBuf> {
    if is_inside_spex_dir(current_dir)? {
        let parent = current_dir.parent().ok_or_else(|| {
            eros::error!("expected the spex directory to live inside a project root")
        })?;
        return Ok(parent.to_path_buf());
    }
    Ok(current_dir.to_path_buf())
}

fn locate_spex_dir(
    current_dir: &Path,
    project_root: &Path,
    layout: Layout,
    project_name: &str,
) -> Result<PathBuf> {
    if is_inside_spex_dir(current_dir)? {
        return Ok(current_dir.to_path_buf());
    }

    Ok(project_root.join(layout.spex_dir_name(project_name)))
}

fn is_empty_dir(dir: &Path) -> Result<bool> {
    let mut entries = fs::read_dir(dir).with_context(|| could_not_read_dir(dir))?;
    Ok(entries.next().is_none())
}

fn verify_empty(spex_dir: &Path) -> Result<()> {
    if !spex_dir.exists() || is_empty_dir(spex_dir)? {
        return Ok(());
    }

    bail!(
        "`{}` is not empty — `spex init` sets up a fresh project in an empty directory, use `spex update` for an initialized one",
        spex_dir.display()
    );
}

fn confirm_layout() -> Result<Layout> {
    let layouts = Layout::value_variants();
    println!("Layout:");
    for (index, layout) in layouts.iter().enumerate() {
        println!("  {}) {layout}", index + 1);
    }
    let default_number = layouts
        .iter()
        .position(|layout| *layout == Layout::default())
        .map_or(1, |index| index + 1);
    let input = prompt(&format!("Choose [{default_number}]: "))?;
    parse_layout(input.trim())
}

fn parse_layout(answer: &str) -> Result<Layout> {
    if answer.is_empty() {
        return Ok(Layout::default());
    }
    let layouts = Layout::value_variants();
    let by_number = answer
        .parse::<usize>()
        .ok()
        .and_then(|number| number.checked_sub(1))
        .and_then(|index| layouts.get(index));
    by_number
        .copied()
        .or_else(|| Layout::from_str(answer, true).ok())
        .ok_or_else(|| {
            eros::error!(
                "unknown layout `{answer}` — enter a number from 1 to {} or one of: {}",
                layouts.len(),
                Layout::choices()
            )
        })
}

fn confirm_project_name(default_name: &str) -> Result<String> {
    let default_name = to_kebab_case(default_name);
    let input = prompt(&format!("Project name [{default_name}]: "))?;
    let name = input.trim();
    Ok(if name.is_empty() {
        default_name
    } else {
        to_kebab_case(name)
    })
}

fn to_kebab_case(name: &str) -> String {
    let mut result = String::with_capacity(name.len());
    let mut pending_separator = false;
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            if pending_separator && !result.is_empty() {
                result.push('-');
            }
            pending_separator = false;
            result.push(ch.to_ascii_lowercase());
        } else if matches!(ch, ' ' | '_' | '-') {
            pending_separator = true;
        }
    }
    result
}

fn prompt(message: &str) -> Result<String> {
    print!("{message}");
    io::stdout().flush().context("could not flush stdout")?;

    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .context("could not read from stdin")?;
    Ok(input)
}