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