devist 0.1.1

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::fs;

use crate::git;
use crate::paths;
use crate::template::{self, Template};

#[derive(Subcommand)]
pub enum TemplateCmd {
    /// List installed templates
    List,
    /// Add a template repo (git clone into ~/.devist/templates/<name>)
    Add {
        /// Git URL of the template (or a repo containing multiple templates)
        url: String,
        /// Optional name override (defaults to repo name)
        #[arg(long)]
        name: Option<String>,
    },
    /// Update all templates (git pull)
    Sync,
    /// Remove a template directory
    Remove { name: String },
}

pub fn run(cmd: TemplateCmd) -> Result<()> {
    match cmd {
        TemplateCmd::List => list(),
        TemplateCmd::Add { url, name } => add(url, name),
        TemplateCmd::Sync => sync(),
        TemplateCmd::Remove { name } => remove(name),
    }
}

fn list() -> Result<()> {
    let templates = Template::list_all()?;

    if templates.is_empty() {
        println!("{}", style("No templates installed.").yellow());
        println!();
        println!(
            "  Add one with: {}",
            style("devist template add <git-url>").cyan()
        );
        return Ok(());
    }

    let root = paths::templates_dir()?;

    println!("{}", style("Installed templates").bold());
    println!();

    for t in &templates {
        let m = &t.manifest.meta;
        let version = if m.version.is_empty() {
            "-".to_string()
        } else {
            m.version.clone()
        };

        let source = t
            .path
            .strip_prefix(&root)
            .ok()
            .and_then(|rel| rel.components().next())
            .map(|c| c.as_os_str().to_string_lossy().to_string())
            .unwrap_or_else(|| "?".to_string());

        println!(
            "  {}  {}  {}",
            style(format!("{:20}", m.name)).cyan(),
            style(format!("{:8}", version)).dim(),
            style(format!("from {}", source)).dim()
        );
        if !m.description.is_empty() {
            println!("    {}", style(&m.description).dim());
        }
    }
    println!();
    Ok(())
}

fn add(url: String, name_override: Option<String>) -> Result<()> {
    let name = name_override.unwrap_or_else(|| template::name_from_url(&url));
    let target = paths::templates_dir()?.join(&name);

    if target.exists() {
        return Err(anyhow!(
            "Template directory already exists: {} (use `template remove {}` first or `template sync`)",
            target.display(),
            name
        ));
    }

    println!(
        "  {} {} {}",
        style("[FETCH]").cyan(),
        style(&url).dim(),
        style(format!("{}", target.display())).dim()
    );

    git::clone(&url, &target)?;

    let direct = target.join("devist.toml").exists();
    if direct {
        let t = Template::load(&target)?;
        println!(
            "  {} {} {}",
            style("[OK]").green(),
            style(&t.manifest.meta.name).cyan(),
            style(format!("({})", target.display())).dim()
        );
    } else {
        let mut count = 0;
        for entry in fs::read_dir(&target)? {
            let entry = entry?;
            if entry.path().is_dir() && entry.path().join("devist.toml").exists() {
                count += 1;
            }
        }
        if count == 0 {
            println!(
                "  {} no devist.toml found at root or in subdirectories",
                style("[WARN]").yellow()
            );
        } else {
            println!(
                "  {} found {} template(s) in {}",
                style("[OK]").green(),
                count,
                style(target.display()).dim()
            );
        }
    }
    Ok(())
}

fn sync() -> Result<()> {
    let root = paths::templates_dir()?;
    if !root.exists() {
        println!(
            "{}",
            style("No templates directory yet. Run `devist setup` first.").yellow()
        );
        return Ok(());
    }

    let mut any = false;
    for entry in fs::read_dir(&root)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        if !path.join(".git").exists() {
            continue;
        }
        any = true;

        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
        match git::pull(&path) {
            Ok(_) => println!("  {} {}", style("[PULL]").green(), name),
            Err(e) => println!("  {} {} {}", style("[FAIL]").red(), name, style(e).dim()),
        }
    }

    if !any {
        println!(
            "{}",
            style("Nothing to sync. No git-managed templates found.").yellow()
        );
    }
    Ok(())
}

fn remove(name: String) -> Result<()> {
    let target = paths::templates_dir()?.join(&name);
    if !target.exists() {
        return Err(anyhow!("Template not found: {}", name));
    }
    fs::remove_dir_all(&target)?;
    println!("  {} {}", style("[REMOVED]").green(), name);
    Ok(())
}