devist 0.2.0

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

use crate::paths;

#[derive(Debug, Deserialize)]
pub struct TemplateMeta {
    pub name: String,
    #[serde(default)]
    pub version: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    #[allow(dead_code)]
    pub author: String,
    #[serde(default)]
    #[allow(dead_code)]
    pub tags: Vec<String>,
}

#[derive(Debug, Deserialize, Default)]
pub struct VariableSpec {
    #[serde(default)]
    #[allow(dead_code)]
    pub prompt: String,
    #[serde(default)]
    pub default: String,
}

#[derive(Debug, Deserialize, Default)]
pub struct StackSpec {
    /// Type of backend stack: "supabase", "docker-compose", "none"
    #[serde(rename = "type", default = "default_stack_type")]
    #[allow(dead_code)]
    pub kind: String,
}

fn default_stack_type() -> String {
    "none".to_string()
}

#[derive(Debug, Deserialize, Default)]
pub struct CommandsSpec {
    #[serde(default)]
    pub install: Option<String>,
    #[serde(default)]
    pub dev: Option<String>,
    #[serde(default)]
    pub backend_start: Option<String>,
    #[serde(default)]
    pub backend_stop: Option<String>,
    #[serde(default)]
    #[allow(dead_code)]
    pub test: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct TemplateManifest {
    pub meta: TemplateMeta,
    #[serde(default)]
    pub variables: HashMap<String, VariableSpec>,
    #[serde(default)]
    #[allow(dead_code)]
    pub stack: StackSpec,
    #[serde(default)]
    pub commands: CommandsSpec,
}

#[derive(Debug)]
pub struct Template {
    pub manifest: TemplateManifest,
    pub path: PathBuf,
}

impl Template {
    pub fn load(dir: &Path) -> Result<Self> {
        let manifest_path = dir.join("devist.toml");
        let text = fs::read_to_string(&manifest_path).with_context(|| {
            format!(
                "Could not read template manifest: {}",
                manifest_path.display()
            )
        })?;
        let manifest: TemplateManifest = toml::from_str(&text)
            .with_context(|| format!("Invalid devist.toml: {}", manifest_path.display()))?;
        Ok(Template {
            manifest,
            path: dir.to_path_buf(),
        })
    }

    pub fn list_all() -> Result<Vec<Template>> {
        let root = paths::templates_dir()?;
        if !root.exists() {
            return Ok(Vec::new());
        }

        let mut found = Vec::new();
        for entry in fs::read_dir(&root)? {
            let entry = entry?;
            let path = entry.path();

            if !path.is_dir() {
                continue;
            }
            if is_hidden(&path) {
                continue;
            }

            if path.join("devist.toml").exists() {
                try_push(&path, &mut found);
                continue;
            }

            let inner = match fs::read_dir(&path) {
                Ok(it) => it,
                Err(_) => continue,
            };
            for sub in inner {
                let sub = match sub {
                    Ok(e) => e,
                    Err(_) => continue,
                };
                let sub_path = sub.path();
                if !sub_path.is_dir() || is_hidden(&sub_path) {
                    continue;
                }
                if sub_path.join("devist.toml").exists() {
                    try_push(&sub_path, &mut found);
                }
            }
        }

        found.sort_by(|a, b| a.manifest.meta.name.cmp(&b.manifest.meta.name));
        Ok(found)
    }
}

fn is_hidden(path: &Path) -> bool {
    path.file_name()
        .and_then(|n| n.to_str())
        .map(|n| n.starts_with('.'))
        .unwrap_or(false)
}

fn try_push(dir: &Path, out: &mut Vec<Template>) {
    match Template::load(dir) {
        Ok(t) => out.push(t),
        Err(e) => eprintln!("  Warning: skipping {}: {}", dir.display(), e),
    }
}

pub fn name_from_url(url: &str) -> String {
    let trimmed = url.trim().trim_end_matches('/').trim_end_matches(".git");

    let last = trimmed.rsplit(['/', ':']).next().unwrap_or(trimmed);

    last.to_string()
}