Skip to main content

flow_application/
nodes.rs

1//! Node catalog - the master abstract registry of node types.
2//!
3//! Mirrors the [`crate::templates`] / [`crate::template_hub`] split:
4//!
5//! - This module owns the **catalog parse** (the embedded
6//!   `node_catalog.json` - operator-supplied data, stand-in for a future
7//!   Hub API response) and the on-disk scheme IO at
8//!   `<flow_dir>/nodes/<slug>.json`.
9//! - [`crate::node_hub`] owns the Hub orchestration (install, update,
10//!   uninstall, install-status annotation).
11//!
12//! Every entry is installable like any third-party kind; nothing is
13//! pre-seeded at boot. Installed schemes land on disk under
14//! `<flow_dir>/nodes/`. The frontend consumes the merged set via the
15//! `nodes` Tauri commands.
16
17use std::path::Path;
18use std::sync::OnceLock;
19
20use flow_storage::{Store, StoreError as FsStoreError};
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24const CATALOG_JSON: &str = include_str!("../../../mocks/node_catalog.json");
25const SUFFIX: &str = ".json";
26
27#[derive(Debug, Error)]
28pub enum NodeError {
29    #[error("invalid node slug")]
30    InvalidSlug,
31    #[error("node `{0}` not found")]
32    NotFound(String),
33    #[error(transparent)]
34    Io(#[from] std::io::Error),
35    #[error(transparent)]
36    Json(#[from] serde_json::Error),
37    #[error(transparent)]
38    Store(#[from] FsStoreError),
39}
40
41/// One catalog entry as authored in `node_catalog.json`. The shape is also
42/// what we return from the Tauri layer (rename_all = camelCase) so the
43/// frontend can consume the same JSON the catalog ships with.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct NodeCatalogEntry {
47    pub slug: String,
48    pub version: String,
49    pub label: String,
50    #[serde(default)]
51    pub description: String,
52    #[serde(default)]
53    pub tags: Vec<String>,
54    #[serde(default)]
55    pub icon: String,
56    /// Stable sort key for the palette. Smaller = earlier. Catalog entries
57    /// without a key sort last.
58    #[serde(default = "default_sort_key")]
59    pub sort_key: i32,
60
61    /// Node type - one of `action | ai | agentic | utility | service`.
62    pub node_type: String,
63    /// ReactFlow node-type key the canvas mounts. Catalog entries that
64    /// reuse an existing bespoke canvas type name it here (e.g. `action`,
65    /// `zoweCommand`); generic ones use `"catalog"`.
66    pub canvas_type: String,
67    /// React component name for the canvas body. Reuses an existing
68    /// component name (e.g. `"ZoweCommandNode"`) or `"Generic"` for the
69    /// catalog-driven renderer.
70    pub renderer: String,
71    /// Inspector sub-form name (same convention as `renderer`).
72    pub inspector: String,
73
74    /// Required when `nodeType == "action"`. Identifies the adapter that
75    /// dispatches this node at run time.
76    #[serde(default)]
77    pub adapter: Option<String>,
78    #[serde(default)]
79    pub action_id: Option<String>,
80
81    /// Default values stamped onto `node.data` when a fresh node is
82    /// dropped on the canvas. Arbitrary JSON; the renderer reads what it
83    /// needs.
84    #[serde(default)]
85    pub defaults: serde_json::Value,
86
87    /// JSON Schema (Draft 2020-12 subset: object/properties/required/enum/
88    /// pattern/type/default/title) describing the node's data shape. Drives
89    /// the generic inspector form and the pre-run validator. Carried as
90    /// `serde_json::Value` so the catalog ships any well-formed schema
91    /// without the Rust type system having to mirror every keyword.
92    #[serde(default)]
93    pub schema: serde_json::Value,
94
95    /// Embedded reference data - the parsed Zowe command tree on the
96    /// `zowe.cli-tool` entry; the GitHub command catalog on
97    /// `github.cli-tool`; absent on every other entry.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub command_tree: Option<serde_json::Value>,
100
101    /// Global-settings dependencies the inspector surfaces as a banner
102    /// and the pre-run gate refuses on when missing.
103    #[serde(default)]
104    pub settings: Vec<SettingsRequirement>,
105
106    /// Canvas-card runtime extras the generic renderer opts into.
107    #[serde(default)]
108    pub runtime_bindings: RuntimeBindings,
109
110    /// DSL adapter/actionId stamped at lowering time, replacing per-kind
111    /// hardcoded values. Both fields are optional so AI / utility entries
112    /// (which don't carry an adapter) just leave them null.
113    #[serde(default)]
114    pub lowering: LoweringHint,
115
116    /// CLI tool this node shells out to. Present only on `cli-tool` action
117    /// entries (`zowe.cli-tool` / `github.cli-tool`); absent on every other
118    /// kind. Drives the Node Hub's "is this CLI installed, and does its
119    /// version meet the minimum?" check.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub cli_tool: Option<CliTool>,
122
123    /// Declarative API integration for `service` nodes. Present only on service
124    /// entries; absent on every other node type. The generic `service` adapter
125    /// reads it to call the external API at run time. The concrete service - its
126    /// base URL, auth, and operations - is **data authored in the catalog**, never
127    /// code, so the adapter stays vendor-neutral.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub service_integration: Option<ServiceIntegration>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct SettingsRequirement {
135    pub key: String,
136    /// `"config"` for fields in `Settings`, `"keyring"` for credentials.
137    pub scope: String,
138    #[serde(default)]
139    pub per_provider: bool,
140    #[serde(default)]
141    pub required: bool,
142    #[serde(default)]
143    pub missing_message: String,
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct RuntimeBindings {
149    #[serde(default)]
150    pub shows_execution_output: bool,
151    #[serde(default)]
152    pub shows_execution_status: bool,
153    /// `"local-llm"` or `"cloud-provider"` when the card should
154    /// render a model badge sourced from that inventory.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub model_inventory: Option<String>,
157    /// Present only on `zowe.cli-tool` / `github.cli-tool` - drives the
158    /// "zowe …" / "gh …" preview line.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub cli_preview: Option<CliPreview>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct CliPreview {
166    pub prefix: String,
167    pub positionals_key: String,
168    pub values_key: String,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub output_filter_key: Option<String>,
171}
172
173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct LoweringHint {
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub adapter: Option<String>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub action_id: Option<String>,
180}
181
182/// CLI tool a `cli-tool` action node depends on. `name` is the binary the Node
183/// Hub probes on the user's machine; `version` is the **minimum required**
184/// version (installed >= required => OK, lower => "update needed", absent =>
185/// "not installed"). Distinct from [`NodeCatalogEntry::version`], which is the
186/// node *scheme* version. `version_command` overrides the probe argument and
187/// defaults to `--version` at the call site.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct CliTool {
191    pub name: String,
192    pub version: String,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub version_command: Option<String>,
195}
196
197// The service-integration descriptor types live in `flow-execution` so the
198// generic `service` adapter and this catalog layer share one definition without
199// a dependency cycle. Re-exported here for the Tauri DTO + callers.
200pub use flow_execution::service::{ServiceAuth, ServiceIntegration, ServiceOperation};
201
202/// Map of catalog slug → [`ServiceIntegration`] for every embedded catalog entry
203/// that declares one. Built at startup and handed to the generic `service`
204/// adapter so it can execute the API a `service` node selects.
205pub fn service_integrations() -> std::collections::HashMap<String, ServiceIntegration> {
206    entries()
207        .iter()
208        .filter_map(|e| {
209            e.service_integration
210                .clone()
211                .map(|si| (e.slug.clone(), si))
212        })
213        .collect()
214}
215
216fn default_sort_key() -> i32 {
217    1_000
218}
219
220#[derive(Deserialize)]
221struct CatalogEnvelope {
222    #[serde(default, rename = "apiVersion")]
223    _api_version: String,
224    nodes: Vec<NodeCatalogEntry>,
225}
226
227/// Parse the embedded catalog exactly once. A malformed JSON file is a
228/// build-time error in spirit (the file ships in the binary), so a panic
229/// at first use is the right surface - same policy as `template_hub`.
230pub(crate) fn entries() -> &'static [NodeCatalogEntry] {
231    static CATALOG: OnceLock<Vec<NodeCatalogEntry>> = OnceLock::new();
232    CATALOG
233        .get_or_init(|| {
234            let env: CatalogEnvelope =
235                serde_json::from_str(CATALOG_JSON).expect("node_catalog.json is malformed");
236            env.nodes
237        })
238        .as_slice()
239}
240
241/// Browse list: catalog entries sorted by `sortKey`. Stable across boots
242/// so the palette order doesn't shuffle on the user.
243pub fn catalog() -> Vec<NodeCatalogEntry> {
244    let mut out: Vec<NodeCatalogEntry> = entries().to_vec();
245    out.sort_by(|a, b| a.sort_key.cmp(&b.sort_key).then_with(|| a.slug.cmp(&b.slug)));
246    out
247}
248
249/// Look up a catalog entry by slug.
250pub fn entry_by_slug(slug: &str) -> Option<NodeCatalogEntry> {
251    entries().iter().find(|e| e.slug == slug).cloned()
252}
253
254/// The canvas runtime's view of "what kinds exist": for every row in
255/// `node_library`, read the on-disk scheme. The scheme is the source of
256/// truth at runtime - the embedded catalog only seeds new installs.
257///
258/// Self-healing: a row whose scheme file is genuinely gone (`NotFound`) is
259/// an unusable orphan - typically left behind by an old slug rename - so the
260/// row is dropped rather than warned about on every boot. This prune only
261/// runs when `dir` is a readable directory: if the whole nodes dir is
262/// missing or unmounted, *every* scheme reads as `NotFound`, and mistaking
263/// that for "everything uninstalled" would wipe the registry. Any other read
264/// failure (a present-but-corrupt file) is logged and skipped - not fatal,
265/// not pruned - so a single bad scheme can't blank the palette or lose a row.
266pub fn list_installed_schemes(store: &Store, dir: &Path) -> Result<Vec<NodeCatalogEntry>, NodeError> {
267    let rows = store.list_node_library_rows()?;
268    // Guard the prune: only treat a missing scheme as an orphan when the dir
269    // itself is present. See the doc comment for why this matters.
270    let dir_present = dir.is_dir();
271    let mut out = Vec::with_capacity(rows.len());
272    for row in rows {
273        match read_scheme(dir, &row.slug) {
274            Ok(entry) => out.push(entry),
275            Err(NodeError::NotFound(_)) if dir_present => match store.delete_node_library_row(&row.slug) {
276                Ok(()) => tracing::info!(
277                    slug = %row.slug,
278                    "pruned orphaned node_library row (scheme file missing)"
279                ),
280                Err(e) => tracing::warn!(
281                    slug = %row.slug, error = ?e,
282                    "failed to prune orphaned node_library row"
283                ),
284            },
285            Err(e) => {
286                tracing::warn!(slug = %row.slug, error = ?e, "installed node scheme missing or unreadable");
287            }
288        }
289    }
290    out.sort_by(|a, b| a.sort_key.cmp(&b.sort_key).then_with(|| a.slug.cmp(&b.slug)));
291    Ok(out)
292}
293
294/// Read an installed scheme from `<dir>/<slug>.json`.
295pub fn read_scheme(dir: &Path, slug: &str) -> Result<NodeCatalogEntry, NodeError> {
296    validate_slug(slug)?;
297    let path = dir.join(format!("{slug}{SUFFIX}"));
298    if !path.exists() {
299        return Err(NodeError::NotFound(slug.to_string()));
300    }
301    let body = std::fs::read_to_string(&path)?;
302    Ok(serde_json::from_str(&body)?)
303}
304
305/// Write an installed scheme to `<dir>/<slug>.json`. Parent directory is
306/// created on demand.
307pub fn write_scheme(dir: &Path, entry: &NodeCatalogEntry) -> Result<(), NodeError> {
308    validate_slug(&entry.slug)?;
309    std::fs::create_dir_all(dir)?;
310    let path = dir.join(format!("{}{SUFFIX}", entry.slug));
311    let body = serde_json::to_string_pretty(entry)?;
312    std::fs::write(&path, body)?;
313    Ok(())
314}
315
316/// Remove an installed scheme from disk. Missing file is fine - the caller
317/// asked to delete it, the absence is the goal.
318pub fn delete_scheme(dir: &Path, slug: &str) -> Result<(), NodeError> {
319    validate_slug(slug)?;
320    let path = dir.join(format!("{slug}{SUFFIX}"));
321    if path.exists() {
322        std::fs::remove_file(&path)?;
323    }
324    Ok(())
325}
326
327/// Allowed: `[a-z0-9._-]+`. Underscore is in the set so DSL-kind slugs like
328/// `cloud_ai` survive (the DSL grammar uses snake_case kind names).
329/// Defense-in-depth against a buggy renderer passing `../foo` and reading or
330/// deleting files outside the nodes dir.
331pub(crate) fn validate_slug(slug: &str) -> Result<(), NodeError> {
332    if slug.is_empty() {
333        return Err(NodeError::InvalidSlug);
334    }
335    if !slug.chars().all(|c| {
336        c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.' || c == '_'
337    }) {
338        return Err(NodeError::InvalidSlug);
339    }
340    if slug.starts_with('-')
341        || slug.starts_with('.')
342        || slug.starts_with('_')
343        || slug.ends_with('-')
344        || slug.ends_with('.')
345        || slug.ends_with('_')
346    {
347        return Err(NodeError::InvalidSlug);
348    }
349    if slug.contains("..") {
350        return Err(NodeError::InvalidSlug);
351    }
352    Ok(())
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn tmp_dir() -> std::path::PathBuf {
360        let p = std::env::temp_dir().join(format!("flow-nodes-{}", uuid::Uuid::new_v4()));
361        std::fs::create_dir_all(&p).unwrap();
362        p
363    }
364
365    #[test]
366    fn catalog_parses_and_is_well_formed() {
367        for e in &catalog() {
368            assert!(!e.slug.is_empty());
369            assert!(!e.label.is_empty());
370            assert!(!e.version.is_empty());
371            assert!(!e.node_type.is_empty(), "{} missing nodeType", e.slug);
372            assert!(!e.canvas_type.is_empty(), "{} missing canvasType", e.slug);
373        }
374    }
375
376    #[test]
377    fn cli_tool_entries_carry_cli_tool_descriptor() {
378        // Every `cli-tool` action entry must declare the binary + minimum
379        // version the Node Hub probes for. Pins the catalog contract.
380        for e in &catalog() {
381            if e.action_id.as_deref() == Some("cli-tool") {
382                let tool = e
383                    .cli_tool
384                    .as_ref()
385                    .unwrap_or_else(|| panic!("{} missing cliTool", e.slug));
386                assert!(!tool.name.is_empty(), "{} cliTool.name empty", e.slug);
387                assert!(!tool.version.is_empty(), "{} cliTool.version empty", e.slug);
388            }
389        }
390    }
391
392    #[test]
393    fn catalog_sorted_by_sort_key() {
394        let listed = catalog();
395        let keys: Vec<i32> = listed.iter().map(|e| e.sort_key).collect();
396        let mut sorted = keys.clone();
397        sorted.sort();
398        assert_eq!(keys, sorted, "catalog must be sorted by sortKey");
399    }
400
401    #[test]
402    fn entry_by_slug_finds_known_entry() {
403        if let Some(first) = catalog().into_iter().next() {
404            let got = entry_by_slug(&first.slug).expect("present");
405            assert_eq!(got.slug, first.slug);
406        }
407        assert!(entry_by_slug("does-not-exist").is_none());
408    }
409
410    #[test]
411    fn validate_slug_accepts_namespaced_and_rejects_traversal() {
412        assert!(validate_slug("acme.foo").is_ok());
413        assert!(validate_slug("zowe.cli-tool").is_ok());
414        assert!(validate_slug("snake_case").is_ok());
415        assert!(matches!(
416            validate_slug("../escape"),
417            Err(NodeError::InvalidSlug)
418        ));
419        assert!(matches!(
420            validate_slug(".hidden"),
421            Err(NodeError::InvalidSlug)
422        ));
423        assert!(matches!(
424            validate_slug("_leading"),
425            Err(NodeError::InvalidSlug)
426        ));
427        assert!(matches!(
428            validate_slug("trailing-"),
429            Err(NodeError::InvalidSlug)
430        ));
431    }
432
433    #[test]
434    fn write_and_read_scheme_roundtrip() {
435        let dir = tmp_dir();
436        let entry = NodeCatalogEntry {
437            slug: "acme.foo".into(),
438            version: "1.0.0".into(),
439            label: "Acme Foo".into(),
440            description: "".into(),
441            tags: vec![],
442            icon: "".into(),
443            sort_key: 1000,
444            node_type: "action".into(),
445            canvas_type: "catalog".into(),
446            renderer: "Generic".into(),
447            inspector: "Generic".into(),
448            adapter: Some("shell".into()),
449            action_id: Some("run-command".into()),
450            defaults: serde_json::json!({}),
451            schema: serde_json::json!({}),
452            command_tree: None,
453            settings: vec![],
454            runtime_bindings: RuntimeBindings::default(),
455            lowering: LoweringHint::default(),
456            cli_tool: None,
457            service_integration: None,
458        };
459        write_scheme(&dir, &entry).unwrap();
460        let loaded = read_scheme(&dir, "acme.foo").unwrap();
461        assert_eq!(loaded.slug, entry.slug);
462        assert_eq!(loaded.canvas_type, "catalog");
463        let _ = std::fs::remove_dir_all(&dir);
464    }
465
466    #[test]
467    fn read_missing_returns_not_found() {
468        let dir = tmp_dir();
469        assert!(matches!(read_scheme(&dir, "nope"), Err(NodeError::NotFound(_))));
470    }
471
472    #[test]
473    fn delete_missing_is_ok() {
474        let dir = tmp_dir();
475        delete_scheme(&dir, "nope").unwrap();
476    }
477
478    #[test]
479    fn list_installed_schemes_returns_only_what_is_installed() {
480        use flow_storage::NodeLibraryRow;
481        let dir = tmp_dir();
482        let store = Store::open_in_memory().unwrap();
483        assert!(list_installed_schemes(&store, &dir).unwrap().is_empty());
484
485        let Some(slug) = catalog().into_iter().next().map(|e| e.slug) else {
486            return;
487        };
488        let entry = entry_by_slug(&slug).expect("present");
489        write_scheme(&dir, &entry).unwrap();
490        store
491            .upsert_node_library_row(&NodeLibraryRow {
492                slug: entry.slug.clone(),
493                version: entry.version.clone(),
494                installed_at: chrono::Utc::now(),
495            })
496            .unwrap();
497
498        let listed = list_installed_schemes(&store, &dir).unwrap();
499        assert_eq!(listed.len(), 1);
500        assert_eq!(listed[0].slug, slug);
501        let _ = std::fs::remove_dir_all(&dir);
502    }
503
504    #[test]
505    fn list_installed_schemes_prunes_orphan_rows_when_dir_present() {
506        use flow_storage::NodeLibraryRow;
507        let dir = tmp_dir(); // exists, so a missing scheme is a true orphan
508        let store = Store::open_in_memory().unwrap();
509        // Row points at a slug whose scheme file is not on disk.
510        store
511            .upsert_node_library_row(&NodeLibraryRow {
512                slug: "ghost".into(),
513                version: "1.0.0".into(),
514                installed_at: chrono::Utc::now(),
515            })
516            .unwrap();
517        let listed = list_installed_schemes(&store, &dir).unwrap();
518        assert!(listed.is_empty(), "orphan row contributes no scheme");
519        // Self-healed away, not merely skipped: it won't warn again next boot.
520        assert!(
521            store.get_node_library_row("ghost").unwrap().is_none(),
522            "row whose scheme file is missing should be pruned"
523        );
524        let _ = std::fs::remove_dir_all(&dir);
525    }
526
527    #[test]
528    fn list_installed_schemes_keeps_rows_when_dir_is_missing() {
529        use flow_storage::NodeLibraryRow;
530        // A path that does not exist: stands in for an unmounted/absent nodes
531        // dir, where every scheme reads as NotFound. We must NOT prune then.
532        let dir = std::env::temp_dir().join(format!("flow-nodes-absent-{}", uuid::Uuid::new_v4()));
533        assert!(!dir.exists());
534        let store = Store::open_in_memory().unwrap();
535        store
536            .upsert_node_library_row(&NodeLibraryRow {
537                slug: "ghost".into(),
538                version: "1.0.0".into(),
539                installed_at: chrono::Utc::now(),
540            })
541            .unwrap();
542        let listed = list_installed_schemes(&store, &dir).unwrap();
543        assert!(listed.is_empty(), "no readable schemes, so nothing listed");
544        assert!(
545            store.get_node_library_row("ghost").unwrap().is_some(),
546            "rows must survive a missing nodes dir - do not wipe the registry"
547        );
548    }
549}