ironflow-cli 0.1.24

CLI tool to drive the Ironflow workflow engine
Documentation
//! `ironflow-cli template` subcommands.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::Result;
use clap::{Args, Subcommand};

use ironflow_templates::fetch::fetch_repo;
use ironflow_templates::install::install_template;
use ironflow_templates::registry::{discover_templates, find_template};

/// Manage workflow templates.
#[derive(Debug, Args)]
pub struct TemplateArgs {
    /// Subcommand to execute.
    #[command(subcommand)]
    pub command: TemplateCommands,
}

/// Template subcommands.
#[derive(Debug, Subcommand)]
pub enum TemplateCommands {
    /// List available templates in a repository (local path or remote Git URL).
    List {
        /// Local path or Git URL containing templates.
        source: String,
    },
    /// Show details about a specific template.
    Info {
        /// Local path or Git URL containing templates.
        source: String,
        /// Template name.
        name: String,
    },
    /// Add a template from a repository into your project.
    Add {
        /// Local path or Git URL containing templates.
        source: String,
        /// Template name to install.
        name: String,
        /// Output directory (default: `src/workflows/<name>`).
        #[arg(long, short)]
        output: Option<PathBuf>,
    },
    /// Create a new empty template scaffold.
    Create {
        /// Template name.
        name: String,
        /// Output directory (default: `./<name>`).
        #[arg(long, short)]
        output: Option<PathBuf>,
    },
}

/// Execute a template subcommand.
///
/// # Errors
///
/// Returns an error if fetching, parsing, or installing fails.
pub fn execute(args: &TemplateArgs) -> Result<()> {
    match &args.command {
        TemplateCommands::List { source } => cmd_list(source),
        TemplateCommands::Info { source, name } => cmd_info(source, name),
        TemplateCommands::Add {
            source,
            name,
            output,
        } => cmd_add(source, name, output.as_deref()),
        TemplateCommands::Create { name, output } => cmd_create(name, output.as_deref()),
    }
}

/// Resolve a source string to a local path.
///
/// If the source looks like a URL (contains `://`), clone it into a
/// temporary directory and return the path. Otherwise treat it as a
/// local path.
fn resolve_source(source: &str) -> Result<ResolvedSource> {
    if source.contains("://") {
        let tmp = fetch_repo(source)?;
        Ok(ResolvedSource::Cloned(tmp))
    } else {
        let path = PathBuf::from(source);
        if !path.exists() {
            anyhow::bail!("path does not exist: {source}");
        }
        Ok(ResolvedSource::Local(path))
    }
}

enum ResolvedSource {
    Local(PathBuf),
    Cloned(tempfile::TempDir),
}

impl ResolvedSource {
    fn path(&self) -> &Path {
        match self {
            ResolvedSource::Local(p) => p,
            ResolvedSource::Cloned(tmp) => tmp.path(),
        }
    }
}

fn cmd_list(source: &str) -> Result<()> {
    let resolved = resolve_source(source)?;
    let templates = discover_templates(resolved.path())?;

    if templates.is_empty() {
        println!("No templates found in {source}");
        return Ok(());
    }

    println!("Available templates:");
    println!();
    for (name, (manifest, _dir)) in &templates {
        println!("  {name} (v{})", manifest.template.version);
        println!("    {}", manifest.template.description);
        if let Some(cat) = &manifest.template.category {
            println!("    category: {cat}");
        }
        println!();
    }

    Ok(())
}

fn cmd_info(source: &str, name: &str) -> Result<()> {
    let resolved = resolve_source(source)?;
    let (manifest, _dir) = find_template(resolved.path(), name)?;

    println!("Template: {}", manifest.template.name);
    println!("Version:  {}", manifest.template.version);
    println!("Description: {}", manifest.template.description);

    if !manifest.template.authors.is_empty() {
        println!("Authors: {}", manifest.template.authors.join(", "));
    }
    if let Some(license) = &manifest.template.license {
        println!("License: {license}");
    }
    if let Some(cat) = &manifest.template.category {
        println!("Category: {cat}");
    }

    if !manifest.dependencies.is_empty() {
        println!();
        println!("Dependencies:");
        let mut deps: Vec<_> = manifest.dependencies.keys().collect();
        deps.sort();
        for dep in deps {
            println!("  - {dep}");
        }
    }

    Ok(())
}

fn git_user_name() -> String {
    Command::new("git")
        .args(["config", "user.name"])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "Author".to_string())
}

fn cmd_create(name: &str, output: Option<&Path>) -> Result<()> {
    let dest = output
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(name));

    if dest.exists() {
        anyhow::bail!("directory '{}' already exists", dest.display());
    }

    let src_dir = dest.join("src");
    fs::create_dir_all(&src_dir)?;

    let author = git_user_name();
    let template_toml = format!(
        r#"[template]
name = "{name}"
version = "0.1.0"
description = "A workflow template"
authors = ["{author}"]

[dependencies]
"#
    );
    fs::write(dest.join("template.toml"), template_toml)?;

    let handler = format!(
        r#"use ironflow_engine::context::WorkflowContext;
use ironflow_engine::handler::{{HandlerFuture, WorkflowHandler}};

pub struct {handler_name};

impl WorkflowHandler for {handler_name} {{
    fn name(&self) -> &str {{
        "{name}"
    }}

    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {{
        Box::pin(async move {{
            ctx.shell("hello", "echo 'Hello from {name}!'").await?;
            Ok(())
        }})
    }}
}}
"#,
        handler_name = to_pascal_case(name)
    );
    fs::write(src_dir.join("handler.rs"), handler)?;

    println!("Template '{name}' created at {}", dest.display());

    Ok(())
}

fn to_pascal_case(s: &str) -> String {
    s.split(['-', '_'])
        .filter(|part| !part.is_empty())
        .map(|part| {
            let mut chars = part.chars();
            match chars.next() {
                None => String::new(),
                Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
            }
        })
        .collect()
}

fn cmd_add(source: &str, name: &str, output: Option<&Path>) -> Result<()> {
    let resolved = resolve_source(source)?;
    let (manifest, template_dir) = find_template(resolved.path(), name)?;

    let destination = match output {
        Some(path) => path.to_path_buf(),
        None => PathBuf::from(format!("src/workflows/{name}")),
    };

    let result = install_template(&manifest, &template_dir, &destination)?;

    println!(
        "Template '{name}' installed to {}",
        result.destination.display()
    );

    if !result.dependencies.is_empty() {
        println!();
        println!("Add these dependencies to your Cargo.toml:");
        println!();
        for dep in &result.dependencies {
            println!("  {dep}");
        }
    }

    Ok(())
}