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