Skip to main content

ironflow_cli/commands/
template.rs

1//! `ironflow-cli template` subcommands.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use anyhow::Result;
8use clap::{Args, Subcommand};
9
10use ironflow_templates::fetch::fetch_repo;
11use ironflow_templates::install::install_template;
12use ironflow_templates::registry::{discover_templates, find_template};
13
14/// Manage workflow templates.
15#[derive(Debug, Args)]
16pub struct TemplateArgs {
17    /// Subcommand to execute.
18    #[command(subcommand)]
19    pub command: TemplateCommands,
20}
21
22/// Template subcommands.
23#[derive(Debug, Subcommand)]
24pub enum TemplateCommands {
25    /// List available templates in a repository (local path or remote Git URL).
26    List {
27        /// Local path or Git URL containing templates.
28        source: String,
29    },
30    /// Show details about a specific template.
31    Info {
32        /// Local path or Git URL containing templates.
33        source: String,
34        /// Template name.
35        name: String,
36    },
37    /// Add a template from a repository into your project.
38    Add {
39        /// Local path or Git URL containing templates.
40        source: String,
41        /// Template name to install.
42        name: String,
43        /// Output directory (default: `src/workflows/<name>`).
44        #[arg(long, short)]
45        output: Option<PathBuf>,
46    },
47    /// Create a new empty template scaffold.
48    Create {
49        /// Template name.
50        name: String,
51        /// Output directory (default: `./<name>`).
52        #[arg(long, short)]
53        output: Option<PathBuf>,
54    },
55}
56
57/// Execute a template subcommand.
58///
59/// # Errors
60///
61/// Returns an error if fetching, parsing, or installing fails.
62pub fn execute(args: &TemplateArgs) -> Result<()> {
63    match &args.command {
64        TemplateCommands::List { source } => cmd_list(source),
65        TemplateCommands::Info { source, name } => cmd_info(source, name),
66        TemplateCommands::Add {
67            source,
68            name,
69            output,
70        } => cmd_add(source, name, output.as_deref()),
71        TemplateCommands::Create { name, output } => cmd_create(name, output.as_deref()),
72    }
73}
74
75/// Resolve a source string to a local path.
76///
77/// If the source looks like a URL (contains `://`), clone it into a
78/// temporary directory and return the path. Otherwise treat it as a
79/// local path.
80fn resolve_source(source: &str) -> Result<ResolvedSource> {
81    if source.contains("://") {
82        let tmp = fetch_repo(source)?;
83        Ok(ResolvedSource::Cloned(tmp))
84    } else {
85        let path = PathBuf::from(source);
86        if !path.exists() {
87            anyhow::bail!("path does not exist: {source}");
88        }
89        Ok(ResolvedSource::Local(path))
90    }
91}
92
93enum ResolvedSource {
94    Local(PathBuf),
95    Cloned(tempfile::TempDir),
96}
97
98impl ResolvedSource {
99    fn path(&self) -> &Path {
100        match self {
101            ResolvedSource::Local(p) => p,
102            ResolvedSource::Cloned(tmp) => tmp.path(),
103        }
104    }
105}
106
107fn cmd_list(source: &str) -> Result<()> {
108    let resolved = resolve_source(source)?;
109    let templates = discover_templates(resolved.path())?;
110
111    if templates.is_empty() {
112        println!("No templates found in {source}");
113        return Ok(());
114    }
115
116    println!("Available templates:");
117    println!();
118    for (name, (manifest, _dir)) in &templates {
119        println!("  {name} (v{})", manifest.template.version);
120        println!("    {}", manifest.template.description);
121        if let Some(cat) = &manifest.template.category {
122            println!("    category: {cat}");
123        }
124        println!();
125    }
126
127    Ok(())
128}
129
130fn cmd_info(source: &str, name: &str) -> Result<()> {
131    let resolved = resolve_source(source)?;
132    let (manifest, _dir) = find_template(resolved.path(), name)?;
133
134    println!("Template: {}", manifest.template.name);
135    println!("Version:  {}", manifest.template.version);
136    println!("Description: {}", manifest.template.description);
137
138    if !manifest.template.authors.is_empty() {
139        println!("Authors: {}", manifest.template.authors.join(", "));
140    }
141    if let Some(license) = &manifest.template.license {
142        println!("License: {license}");
143    }
144    if let Some(cat) = &manifest.template.category {
145        println!("Category: {cat}");
146    }
147
148    if !manifest.dependencies.is_empty() {
149        println!();
150        println!("Dependencies:");
151        let mut deps: Vec<_> = manifest.dependencies.keys().collect();
152        deps.sort();
153        for dep in deps {
154            println!("  - {dep}");
155        }
156    }
157
158    Ok(())
159}
160
161fn git_user_name() -> String {
162    Command::new("git")
163        .args(["config", "user.name"])
164        .output()
165        .ok()
166        .filter(|o| o.status.success())
167        .and_then(|o| String::from_utf8(o.stdout).ok())
168        .map(|s| s.trim().to_string())
169        .filter(|s| !s.is_empty())
170        .unwrap_or_else(|| "Author".to_string())
171}
172
173fn cmd_create(name: &str, output: Option<&Path>) -> Result<()> {
174    let dest = output
175        .map(PathBuf::from)
176        .unwrap_or_else(|| PathBuf::from(name));
177
178    if dest.exists() {
179        anyhow::bail!("directory '{}' already exists", dest.display());
180    }
181
182    let src_dir = dest.join("src");
183    fs::create_dir_all(&src_dir)?;
184
185    let author = git_user_name();
186    let template_toml = format!(
187        r#"[template]
188name = "{name}"
189version = "0.1.0"
190description = "A workflow template"
191authors = ["{author}"]
192
193[dependencies]
194"#
195    );
196    fs::write(dest.join("template.toml"), template_toml)?;
197
198    let handler = format!(
199        r#"use ironflow_engine::context::WorkflowContext;
200use ironflow_engine::handler::{{HandlerFuture, WorkflowHandler}};
201
202pub struct {handler_name};
203
204impl WorkflowHandler for {handler_name} {{
205    fn name(&self) -> &str {{
206        "{name}"
207    }}
208
209    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {{
210        Box::pin(async move {{
211            ctx.shell("hello", "echo 'Hello from {name}!'").await?;
212            Ok(())
213        }})
214    }}
215}}
216"#,
217        handler_name = to_pascal_case(name)
218    );
219    fs::write(src_dir.join("handler.rs"), handler)?;
220
221    println!("Template '{name}' created at {}", dest.display());
222
223    Ok(())
224}
225
226fn to_pascal_case(s: &str) -> String {
227    s.split(['-', '_'])
228        .filter(|part| !part.is_empty())
229        .map(|part| {
230            let mut chars = part.chars();
231            match chars.next() {
232                None => String::new(),
233                Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
234            }
235        })
236        .collect()
237}
238
239fn cmd_add(source: &str, name: &str, output: Option<&Path>) -> Result<()> {
240    let resolved = resolve_source(source)?;
241    let (manifest, template_dir) = find_template(resolved.path(), name)?;
242
243    let destination = match output {
244        Some(path) => path.to_path_buf(),
245        None => PathBuf::from(format!("src/workflows/{name}")),
246    };
247
248    let result = install_template(&manifest, &template_dir, &destination)?;
249
250    println!(
251        "Template '{name}' installed to {}",
252        result.destination.display()
253    );
254
255    if !result.dependencies.is_empty() {
256        println!();
257        println!("Add these dependencies to your Cargo.toml:");
258        println!();
259        for dep in &result.dependencies {
260            println!("  {dep}");
261        }
262    }
263
264    Ok(())
265}