zebflow 0.8.3

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
//! Hierarchical help system — auto-discovered from `src/platform/help/**/*.md`.
//!
//! `get_help(path)` is the single entry point for all help content.
//! `pipeline/nodes` and `pipeline/nodes/{kind}` are dynamically generated from Rust node definitions.
//! All other paths are served from the static `HELP` array built by `build.rs`.

// GENERATED — do not edit this file; see build.rs
include!(concat!(env!("OUT_DIR"), "/help_generated.rs"));

/// One help document node, compiled into the binary.
pub struct HelpNode {
    /// Hierarchical path, e.g. "web/hooks", "pipeline/dsl"
    pub path: &'static str,
    /// Title parsed from first `# ` heading in the .md file.
    pub title: &'static str,
    /// First paragraph, ≤150 chars. Used in index listings.
    pub excerpt: &'static str,
    /// "YYYY-MM-DD" from `git log`, or "unknown" if unavailable.
    pub last_updated: &'static str,
    /// Full file content via `include_str!`.
    pub content: &'static str,
}

pub fn top_level_sections() -> Vec<(String, String, String)> {
    let mut out = Vec::new();
    let mut seen = std::collections::HashSet::new();
    for node in HELP.iter() {
        let top = node.path.split('/').next().unwrap_or("").trim();
        if top.is_empty() || !seen.insert(top.to_string()) {
            continue;
        }
        let title = HELP
            .iter()
            .find(|n| n.path == format!("{}/index", top) || n.path == top)
            .map(|n| n.title.to_string())
            .unwrap_or_else(|| top.to_string());
        let content = get_help(top).unwrap_or_else(|_| format!("# {title}\n"));
        out.push((top.to_string(), title, content));
    }
    out
}

fn official_node_definitions_for_help() -> Vec<crate::pipeline::NodeDefinition> {
    let mut defs = crate::pipeline::nodes::builtin_node_definitions();
    defs.extend(
        crate::platform::services::node_registry::NodeRegistryService::embedded_official_definitions(),
    );
    defs.sort_by(|a, b| a.kind.cmp(&b.kind));
    defs
}

fn official_nodes_markdown_reference_for_help() -> String {
    let mut s = String::from(
        "## Node kinds (live — native + embedded official composites)\n\n\
         This block matches the pipeline editor / project node API for platform-bundled nodes: titles, descriptions, pins, DSL flags, and input/output schemas.\n\n\
         - **Full catalog:** `help_nodes` with no `kind` (same as this section).\n\
         - **One kind:** `help_nodes` with `kind=\"n.script\"` (or `script`, `trigger.webhook`, composite kinds, etc.).\n\n\
         ---\n\n",
    );
    for def in official_node_definitions_for_help() {
        s.push_str(&crate::pipeline::nodes::format_node_definition_markdown(
            &def,
        ));
        s.push_str("---\n\n");
    }
    s
}

// ── Public API ────────────────────────────────────────────────────────────────

/// Primary help lookup. Returns the rendered content for `path`.
///
/// Special paths (not in HELP array):
/// - `""` → root index table
/// - `"pipeline/nodes"` → dynamic node catalog
/// - `"pipeline/nodes/{kind}"` → one node docs
///
/// Branch nodes (e.g. "web") get their content appended with a children index.
pub fn get_help(path: &str) -> Result<String, String> {
    let path = path.trim_matches('/').trim();

    // Root index
    if path.is_empty() {
        return Ok(help_root_index());
    }

    // Dynamic: full node catalog
    if path == "pipeline/nodes" {
        return Ok(official_nodes_markdown_reference_for_help());
    }

    // Dynamic: single node
    if let Some(kind) = path.strip_prefix("pipeline/nodes/") {
        return match official_node_definitions_for_help()
            .into_iter()
            .find(|def| crate::pipeline::nodes::kind_query_matches_def(def, kind))
        {
            Some(def) => Ok(crate::pipeline::nodes::format_node_definition_markdown(
                &def,
            )),
            None => Err(format!(
                "Node '{}' not found. Call help(\"pipeline/nodes\") for the full catalog.",
                kind
            )),
        };
    }

    // Static HELP array lookup — also try "{path}/index" for directory-style nodes
    let node = HELP
        .iter()
        .find(|n| n.path == path)
        .or_else(|| HELP.iter().find(|n| n.path == &format!("{}/index", path)));

    if let Some(node) = node {
        let mut out = node.content.to_string();
        // Append children index if this is a branch node
        let children = children_of(path);
        if !children.is_empty() {
            out.push_str("\n\n---\n\n## Sub-topics\n\n");
            out.push_str(&format_children_index(path, &children));
        }
        return Ok(out);
    }

    // Special: pipeline/examples — auto-index
    if path == "pipeline/examples" {
        let examples: Vec<&HelpNode> = HELP
            .iter()
            .filter(|n| n.path.starts_with("pipeline/examples/"))
            .collect();
        if !examples.is_empty() {
            let mut out = "# Pipeline Examples\n\nCall `help(\"pipeline/examples/{slug}\")` to load a full recipe.\n\n".to_string();
            out.push_str("| Example | Description |\n|---------|-------------|\n");
            for ex in examples {
                let slug = ex.path.trim_start_matches("pipeline/examples/");
                out.push_str(&format!(
                    "| `help(\"pipeline/examples/{}\")` | {} |\n",
                    slug, ex.title
                ));
            }
            return Ok(out);
        }
    }

    // Not found — suggest closest paths
    let closest = closest_paths(path);
    let suggestions = if closest.is_empty() {
        "Call help() with no argument for the full index.".to_string()
    } else {
        format!(
            "Did you mean one of:\n{}",
            closest
                .iter()
                .map(|p| format!("  help(\"{}\")", p))
                .collect::<Vec<_>>()
                .join("\n")
        )
    };
    Err(format!("Help topic '{}' not found. {}", path, suggestions))
}

/// Returns the full content string for a path, or None if not found.
/// Used for the MCP ServerInfo instructions.
pub fn get_help_content(path: &str) -> Option<String> {
    get_help(path).ok()
}

/// Root index table — full two-level hierarchy from HELP array.
pub fn help_root_index() -> String {
    let mut out = String::from(
        "# Zebflow Help Index\n\n\
         Call `help(topic)` for documentation.\n\n\
         | Topic | Title | Last updated |\n\
         |-------|-------|--------------|\n",
    );

    // Collect ordered top-level sections (preserving first-seen order)
    let mut top_levels: Vec<&str> = Vec::new();
    let mut seen_tops = std::collections::HashSet::new();
    for node in HELP.iter() {
        let top = node.path.splitn(2, '/').next().unwrap_or("");
        if !top.is_empty() && seen_tops.insert(top) {
            top_levels.push(top);
        }
    }

    for top in top_levels {
        // Top-level section header (from /index file if it exists)
        if let Some(idx) = HELP
            .iter()
            .find(|n| n.path == &format!("{}/index", top) || n.path == top)
        {
            let display = if idx.path.ends_with("/index") {
                top
            } else {
                idx.path
            };
            out.push_str(&format!(
                "| **`help(\"{}\")`** | **{}** | {} |\n",
                display, idx.title, idx.last_updated
            ));
        }

        // Children: depth-2 entries under this top, excluding /index and examples sub-items
        let prefix = format!("{}/", top);
        let mut children: Vec<&HelpNode> = HELP
            .iter()
            .filter(|n| {
                n.path.starts_with(&prefix)
                    && !n.path.ends_with("/index")
                    && !n.path[prefix.len()..].contains('/')
            })
            .collect();
        children.sort_by_key(|n| n.path);

        for child in children {
            out.push_str(&format!(
                "| `help(\"{}\")` | {} | {} |\n",
                child.path, child.title, child.last_updated
            ));
        }

        // Inject dynamic pipeline entries after pipeline children
        if top == "pipeline" {
            out.push_str(
                "| `help(\"pipeline/nodes\")` | **Node Catalog** (live from Rust) | live |\n",
            );
            out.push_str("| `help(\"pipeline/examples\")` | Pipeline Examples Index | — |\n");
        }
    }

    out.push_str(
        "\n`help_search(query)` — search across all help docs + node catalog (partial, scored)\n\n\
         Tip: `help(\"pipeline/nodes\")` → full node catalog with all DSL flags and schemas.",
    );
    out
}

/// All searchable content: static HELP files + dynamic node catalog.
/// Used by help_search — node catalog is included so agents can find nodes by name/flag/description.
pub fn all_searchable_content() -> Vec<(String, String, String)> {
    let mut all: Vec<(String, String, String)> = HELP
        .iter()
        .map(|n| {
            (
                n.path.to_string(),
                n.title.to_string(),
                n.content.to_string(),
            )
        })
        .collect();

    // One entry per official node — kind, title, description, and all DSL flag text
    for def in official_node_definitions_for_help() {
        let path = format!("pipeline/nodes/{}", def.kind);
        let title = format!("{} — {}", def.kind, def.title);
        let mut content = format!(
            "kind: {}\ntitle: {}\n{}\n",
            def.kind, def.title, def.description
        );
        for flag in &def.dsl_flags {
            content.push_str(&format!(
                "flag: {} key: {} required: {} {}\n",
                flag.flag, flag.config_key, flag.required, flag.description
            ));
        }
        all.push((path, title, content));
    }

    all
}

/// Format all help content for inclusion in the assistant system prompt.
pub fn format_for_system_prompt() -> String {
    HELP.iter()
        .map(|n| format!("### {}\n{}\n", n.title, n.content))
        .collect::<Vec<_>>()
        .join("\n---\n\n")
}

// ── Internal helpers ──────────────────────────────────────────────────────────

/// Children of a path — nodes whose path starts with `{parent}/`, has exactly one more segment,
/// and is not the index file of the parent itself.
fn children_of(parent: &str) -> Vec<&'static HelpNode> {
    let prefix = format!("{}/", parent);
    HELP.iter()
        .filter(|n| {
            n.path.starts_with(&prefix) && {
                let rest = &n.path[prefix.len()..];
                !rest.contains('/') && rest != "index"
            }
        })
        .collect()
}

fn format_children_index(parent: &str, children: &[&HelpNode]) -> String {
    let mut out = String::new();
    for child in children {
        let slug = &child.path[parent.len() + 1..];
        out.push_str(&format!(
            "- `help(\"{}\")` — **{}** — {}\n",
            child.path, child.title, child.excerpt
        ));
        let _ = slug; // used above via child.path
    }
    // Add pipeline/nodes special entry if parent is "pipeline"
    if parent == "pipeline" {
        out.push_str("- `help(\"pipeline/nodes\")` — **Node Catalog** — All built-in nodes (live from Rust)\n");
        out.push_str("- `help(\"pipeline/examples\")` — **Examples Index** — Full DSL recipes\n");
    }
    out
}

fn closest_paths(query: &str) -> Vec<&'static str> {
    let q = query.to_lowercase();
    let mut hits: Vec<&'static str> = HELP
        .iter()
        .filter(|n| n.path.contains(&*q) || n.title.to_lowercase().contains(&*q))
        .map(|n| n.path)
        .take(5)
        .collect();
    if hits.is_empty() {
        // Return top-level paths as fallback
        hits = HELP
            .iter()
            .filter(|n| !n.path.contains('/'))
            .map(|n| n.path)
            .take(5)
            .collect();
    }
    hits
}