devist 0.15.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 clap::Subcommand;
use console::style;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;

use crate::commands::init;
use crate::registry::Registry;
use crate::render;
use crate::state::State;
use crate::template::Template;

#[derive(Subcommand)]
pub enum ProjectCmd {
    /// List all registered projects
    List,
    /// Remove a project from the registry (does not delete files)
    Forget { name: String },
    /// Add files from the latest template version that are missing in
    /// this project. Never overwrites existing files.
    Sync {
        /// Project name
        name: String,
        /// Override variables: --var key=value (only used to render newly added files)
        #[arg(long = "var", value_name = "KEY=VALUE")]
        vars: Vec<String>,
        /// Show what would change without writing anything
        #[arg(long)]
        dry_run: bool,
    },
}

pub fn run(cmd: ProjectCmd) -> Result<()> {
    match cmd {
        ProjectCmd::List => list(),
        ProjectCmd::Forget { name } => forget(name),
        ProjectCmd::Sync {
            name,
            vars,
            dry_run,
        } => sync(name, vars, dry_run),
    }
}

fn list() -> Result<()> {
    let reg = Registry::load()?;
    let state = State::load()?;
    let active = state.active_project.as_deref();

    if reg.projects.is_empty() {
        println!("{}", style("No projects registered.").yellow());
        println!();
        println!(
            "  Create one with: {}",
            style("devist init <name> --template=<t>").cyan()
        );
        return Ok(());
    }

    println!("{}", style("Registered projects").bold());
    println!();

    for p in &reg.projects {
        let active_marker = if Some(p.name.as_str()) == active {
            style("").green().to_string()
        } else {
            "  ".to_string()
        };

        println!(
            "{}{}  {}",
            active_marker,
            style(format!("{:20}", p.name)).cyan(),
            style(format!("[{}]", p.template)).dim()
        );
        println!("    {}", style(p.path.display()).dim());
        println!("    {}", style(format!("created {}", p.created_at)).dim());
    }
    println!();
    if active.is_some() {
        println!("  {} = active backend", style("").green());
    }
    Ok(())
}

fn forget(name: String) -> Result<()> {
    let mut reg = Registry::load()?;
    if !reg.remove(&name) {
        return Err(anyhow!("Project not found in registry: {}", name));
    }
    reg.save()?;

    // Also clear state if this was the active project
    let mut state = State::load()?;
    if state.active_project.as_deref() == Some(&name) {
        state.set_active(None);
        state.save()?;
    }

    println!("  {} {}", style("[FORGOTTEN]").green(), name);
    println!("  {}", style("(project files on disk are untouched)").dim());
    Ok(())
}

fn sync(name: String, var_overrides: Vec<String>, dry_run: bool) -> Result<()> {
    let reg = Registry::load()?;
    let project = reg
        .find(&name)
        .ok_or_else(|| anyhow!("Project not found in registry: {}", name))?
        .clone();

    if !project.path.exists() {
        return Err(anyhow!(
            "Project directory missing: {}",
            project.path.display()
        ));
    }

    let templates = Template::list_all()?;
    let tpl = templates
        .iter()
        .find(|t| t.manifest.meta.name == project.template)
        .ok_or_else(|| {
            anyhow!(
                "Template `{}` not installed. Run `devist template add ...` first.",
                project.template
            )
        })?;

    let vars = init::build_vars(tpl, &project.name, &var_overrides)?;

    println!("{}", style("devist project sync").bold());
    println!();
    println!(
        "  {} {} {}",
        style("[PROJ]").cyan(),
        style(&project.name).bold(),
        style(format!("[{}]", project.template)).dim()
    );
    println!(
        "  {} {}",
        style("[TPL]").cyan(),
        style(tpl.path.display()).dim()
    );
    if dry_run {
        println!(
            "  {} dry-run — no files will be written",
            style("[DRY]").yellow()
        );
    }
    println!();

    let counts = walk_and_add(&tpl.path, &project.path, &vars, dry_run)?;

    println!();
    println!(
        "{}",
        style(format!(
            "Done. {} added, {} skipped (already exist).",
            counts.added, counts.skipped
        ))
        .green()
    );
    Ok(())
}

struct SyncCounts {
    added: usize,
    skipped: usize,
}

fn walk_and_add(
    src: &Path,
    dst: &Path,
    vars: &HashMap<String, String>,
    dry_run: bool,
) -> Result<SyncCounts> {
    let mut added = 0usize;
    let mut skipped = 0usize;

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

        if init::should_skip(rel) {
            continue;
        }

        if !entry.file_type().is_file() {
            continue;
        }

        let is_template = path.extension().and_then(|e| e.to_str()) == Some("tmpl");
        let dst_rel = if is_template {
            // strip .tmpl from the final component
            let mut p = rel.to_path_buf();
            p.set_extension("");
            p
        } else {
            rel.to_path_buf()
        };
        let dst_path = dst.join(&dst_rel);

        if dst_path.exists() {
            skipped += 1;
            continue;
        }

        // ADD
        if dry_run {
            println!("  {} {}", style("[+]").green(), dst_rel.display());
        } else if is_template {
            let body = fs::read_to_string(path)?;
            let rendered_body = render::render(&body, vars)?;
            if let Some(parent) = dst_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(&dst_path, rendered_body)?;
            println!("  {} {}", style("[+]").green(), dst_rel.display());
        } else {
            if let Some(parent) = dst_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(path, &dst_path)?;
            println!("  {} {}", style("[+]").green(), dst_rel.display());
        }
        added += 1;
    }

    Ok(SyncCounts { added, skipped })
}