devist 0.18.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
use anyhow::{anyhow, Result};
use chrono::Local;
use console::style;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

use crate::registry::{Project, Registry};
use crate::render;
use crate::template::Template;

pub fn run(
    name: String,
    template_name: String,
    target_dir: Option<PathBuf>,
    vars_cli: Vec<String>,
) -> Result<()> {
    println!("{}", style("devist init").bold());
    println!();

    // 1. Find the template
    let templates = Template::list_all()?;
    let tpl = templates
        .iter()
        .find(|t| t.manifest.meta.name == template_name)
        .ok_or_else(|| {
            anyhow!(
                "Template not found: {}. Run `devist template list` to see installed.",
                template_name
            )
        })?;

    println!(
        "  {} {} {}",
        style("[TPL]").cyan(),
        style(&tpl.manifest.meta.name).bold(),
        style(format!("at {}", tpl.path.display())).dim()
    );

    // 2. Resolve target path
    let target = match target_dir {
        Some(p) => p,
        None => std::env::current_dir()?.join(&name),
    };
    if target.exists() {
        return Err(anyhow!("Target already exists: {}", target.display()));
    }
    println!(
        "  {} {}",
        style("[DIR]").cyan(),
        style(target.display()).bold()
    );

    // 3. Build variable map
    let vars = build_vars(tpl, &name, &vars_cli)?;
    if !vars.is_empty() {
        println!("  {} {} variables", style("[VAR]").cyan(), vars.len());
        for (k, v) in &vars {
            println!("       {} = {}", style(k).dim(), style(v).dim());
        }
    }

    // 4. Copy + render
    println!("  {} copying files...", style("[COPY]").cyan());
    let counts = copy_with_render(&tpl.path, &target, &vars)?;
    println!(
        "       {} files, {} rendered",
        counts.total, counts.rendered
    );

    // 5. Register
    let mut reg = Registry::load()?;
    reg.add(Project {
        name: name.clone(),
        path: target.clone(),
        template: template_name.clone(),
        created_at: Local::now().to_rfc3339(),
    });
    reg.save()?;
    println!(
        "  {} registered as {}",
        style("[REG]").green(),
        style(&name).bold()
    );

    println!();
    println!("{}", style("Done.").green());
    println!();
    println!("  Next:");
    println!("    cd {}", target.display());
    Ok(())
}

pub(crate) fn build_vars(
    tpl: &Template,
    project_name: &str,
    cli_overrides: &[String],
) -> Result<HashMap<String, String>> {
    let mut vars: HashMap<String, String> = HashMap::new();

    // Auto-provided
    vars.insert("project_name".to_string(), project_name.to_string());
    vars.insert("folder_name".to_string(), project_name.to_string());

    // Manifest defaults — entry API avoids type inference headaches
    for (key, spec) in &tpl.manifest.variables {
        vars.entry(key.clone())
            .or_insert_with(|| spec.default.clone());
    }

    // CLI overrides (--var key=value) — applied last, win over defaults
    for raw in cli_overrides {
        let (k, v) = raw
            .split_once('=')
            .ok_or_else(|| anyhow!("Invalid --var format (expected key=value): {}", raw))?;
        vars.insert(k.trim().to_string(), v.trim().to_string());
    }

    // Resolve any default values that themselves reference other vars
    let snapshot = vars.clone();
    for (_k, v) in vars.iter_mut() {
        if v.contains("{{") {
            *v = render::render(v, &snapshot)?;
        }
    }

    // Derive boolean flag vars from `agent_mode` so templates can use
    // `{{#if is_devist}}...{{/if}}` and `{{#if is_vibe}}...{{/if}}`.
    // Default to devist mode when unset or unknown.
    let mode = vars
        .get("agent_mode")
        .map(|s| s.trim().to_lowercase())
        .unwrap_or_else(|| "devist".to_string());
    match mode.as_str() {
        "devist" => {
            vars.insert("agent_mode".into(), "devist".into());
            vars.insert("is_devist".into(), "true".into());
            vars.insert("is_vibe".into(), String::new());
        }
        "vibe" => {
            vars.insert("agent_mode".into(), "vibe".into());
            vars.insert("is_devist".into(), String::new());
            vars.insert("is_vibe".into(), "true".into());
        }
        other => {
            return Err(anyhow!(
                "Unknown agent_mode `{}` (expected `devist` or `vibe`)",
                other
            ));
        }
    }

    Ok(vars)
}

struct CopyCounts {
    total: usize,
    rendered: usize,
}

fn copy_with_render(src: &Path, dst: &Path, vars: &HashMap<String, String>) -> Result<CopyCounts> {
    let mut total = 0;
    let mut rendered = 0;

    fs::create_dir_all(dst)?;

    for entry in WalkDir::new(src).min_depth(1) {
        let entry = entry?;
        let path = entry.path();
        let rel = path.strip_prefix(src)?;

        if should_skip(rel) {
            continue;
        }

        let dst_path = dst.join(rel);

        if entry.file_type().is_dir() {
            fs::create_dir_all(&dst_path)?;
            continue;
        }
        if !entry.file_type().is_file() {
            continue;
        }

        total += 1;

        // Files ending with .tmpl get rendered and the suffix dropped
        if path.extension().and_then(|e| e.to_str()) == Some("tmpl") {
            let body = fs::read_to_string(path)?;
            let rendered_body = render::render(&body, vars)?;
            let final_dst = dst_path.with_extension("");
            if let Some(parent) = final_dst.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(&final_dst, rendered_body)?;
            rendered += 1;
        } else {
            if let Some(parent) = dst_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(path, &dst_path)?;
        }
    }

    Ok(CopyCounts { total, rendered })
}

pub(crate) fn should_skip(rel: &Path) -> bool {
    let s = rel.to_string_lossy();

    if s == "devist.toml" {
        return true;
    }
    if s.contains(".git/") || s.contains(".git\\") || s == ".DS_Store" || s.ends_with("/.DS_Store")
    {
        return true;
    }
    false
}