greentic-component 0.5.2

High-level component loader and store for Greentic components
Documentation
#![cfg(feature = "cli")]

use std::fs;

use assert_cmd::cargo::cargo_bin_cmd;
use assert_fs::TempDir;
use serde_json::Value as JsonValue;

#[test]
fn updates_dev_flows_from_manifest_schema() {
    let temp = TempDir::new().expect("tempdir");
    let manifest = r#"
{
  "id": "component-demo",
  "name": "component-demo",
  "operations": [
    {
      "name": "handle_message",
      "input_schema": {
        "type": "object",
        "properties": {
          "title": { "type": "string", "default": "Hello world" },
          "threshold": { "type": "number", "default": 0.42 },
          "kind": { "enum": ["Text", "Number"], "default": "Text" },
          "internal": { "type": "string", "default": "skip-me", "x_flow_hidden": true }
        },
        "required": ["title", "threshold"]
      },
      "output_schema": {}
    }
  ],
  "default_operation": "handle_message",
  "config_schema": {
    "type": "object",
    "properties": {
      "title": {
        "type": "string",
        "description": "Greeting shown to users",
        "default": "Hello world"
      },
      "threshold": {
        "type": "number",
        "default": 0.42
      },
      "kind": {
        "enum": ["Text", "Number"],
        "description": "Answer type",
        "default": "Text"
      },
      "internal": {
        "type": "string",
        "x_flow_hidden": true,
        "default": "skip-me"
      }
    },
    "required": ["title", "threshold"]
  }
}
"#;
    fs::write(temp.path().join("component.manifest.json"), manifest).expect("write manifest");

    let mut cmd = cargo_bin_cmd!("greentic-component");
    cmd.current_dir(temp.path()).arg("flow").arg("update");
    cmd.assert().success();

    let manifest_after =
        fs::read_to_string(temp.path().join("component.manifest.json")).expect("manifest");
    let value: JsonValue = serde_json::from_str(&manifest_after).expect("json manifest");

    let default_flow = &value["dev_flows"]["default"];
    assert_eq!(default_flow["format"], "flow-ir-json");
    let default_graph = &default_flow["graph"];
    assert_eq!(default_graph["id"], "component-demo.default");
    assert_eq!(default_graph["kind"], "component-config");
    let default_template = default_graph["nodes"]["emit_config"]["template"]
        .as_str()
        .expect("default template");
    let default_payload: JsonValue =
        serde_json::from_str(default_template).expect("default template json");
    assert!(
        default_template.contains("NEXT_NODE_PLACEHOLDER")
            && !default_template.contains("COMPONENT_STEP")
            && !default_template.contains("\"tool\""),
        "template should be add-step ready"
    );
    assert_eq!(default_payload["node_id"], "component-demo");
    let exec_node = &default_payload["node"]["handle_message"];
    assert_eq!(exec_node["input"]["title"], "Hello world");
    assert_eq!(exec_node["input"]["threshold"], 0.42);
    assert!(
        default_payload["node"]["handle_message"]["input"]
            .get("kind")
            .is_none(),
        "optional fields should be omitted in default flow"
    );

    let custom_flow = &value["dev_flows"]["custom"];
    assert_eq!(custom_flow["format"], "flow-ir-json");
    let custom_graph = &custom_flow["graph"];
    assert_eq!(custom_graph["id"], "component-demo.custom");
    let question_fields = custom_graph["nodes"]["ask_config"]["questions"]["fields"]
        .as_array()
        .expect("question fields");
    let field_ids: Vec<String> = question_fields
        .iter()
        .filter_map(|entry| entry["id"].as_str().map(str::to_string))
        .collect();
    assert_eq!(field_ids, vec!["kind", "threshold", "title"]);
    let kind_field = question_fields
        .iter()
        .find(|entry| entry["id"] == "kind")
        .expect("kind field");
    let options = kind_field["options"].as_array().expect("enum options");
    assert_eq!(
        options
            .iter()
            .map(|value| value.as_str().unwrap().to_string())
            .collect::<Vec<_>>(),
        vec!["Text", "Number"]
    );
    let custom_template = custom_graph["nodes"]["emit_config"]["template"]
        .as_str()
        .expect("template string");
    assert!(
        custom_template.contains(r#""handle_message": {"#)
            && custom_template.contains(r#""node_id": "component-demo""#),
        "operation node should be emitted"
    );
    assert!(
        custom_template.contains(r#""title": "{{state.title}}""#),
        "string fields should be quoted state values"
    );
    assert!(
        custom_template.contains(r#""threshold": {{state.threshold}}"#),
        "number fields should be raw state values"
    );
    assert!(
        !custom_template.contains("internal"),
        "hidden fields should be skipped"
    );
}

#[test]
fn flow_update_is_idempotent() {
    let temp = TempDir::new().expect("tempdir");
    let manifest = r#"{"id":"component-demo","name":"component-demo","operations":[{"name":"handle_message","input_schema":{"type":"object","properties":{"input":{"type":"string","default":"hi"}},"required":["input"]},"output_schema":{}}],"config_schema":{"type":"object","properties":{},"required":[]}}"#;
    fs::write(temp.path().join("component.manifest.json"), manifest).expect("write manifest");

    let mut first = cargo_bin_cmd!("greentic-component");
    first.current_dir(temp.path()).arg("flow").arg("update");
    first.assert().success();
    let initial = fs::read_to_string(temp.path().join("component.manifest.json")).unwrap();

    let mut second = cargo_bin_cmd!("greentic-component");
    second.current_dir(temp.path()).arg("flow").arg("update");
    second.assert().success();
    let after = fs::read_to_string(temp.path().join("component.manifest.json")).unwrap();

    assert_eq!(initial, after, "running update twice should be stable");
}

#[test]
fn infers_schema_from_wit_when_missing() {
    let temp = TempDir::new().expect("tempdir");
    let manifest = r#"{"id":"component-demo","name":"component-demo","world":"demo:component/component@0.1.0","operations":[{"name":"handle_message","input_schema":{"type":"object","properties":{"input":{"type":"string","default":"hi"}},"required":["input"]},"output_schema":{}}]}"#;
    fs::write(temp.path().join("component.manifest.json"), manifest).expect("write manifest");
    let wit_dir = temp.path().join("wit");
    fs::create_dir_all(&wit_dir).expect("create wit dir");
    fs::write(
        wit_dir.join("world.wit"),
        r#"
package demo:component;

world component {
    import component: interface {
        @config
        record config {
            /// Demo title
            title: string,
            /// @default(5)
            max-items: u32,
        }
    }
}
"#,
    )
    .expect("write wit");

    let mut cmd = cargo_bin_cmd!("greentic-component");
    cmd.current_dir(temp.path()).arg("flow").arg("update");
    cmd.assert().success();

    let manifest_after =
        fs::read_to_string(temp.path().join("component.manifest.json")).expect("manifest");
    let manifest_json: JsonValue = serde_json::from_str(&manifest_after).unwrap();
    assert!(
        manifest_json.get("config_schema").is_some(),
        "inferred schema should be written by default"
    );
    assert!(
        manifest_json["dev_flows"].get("default").is_some(),
        "default dev flow should be generated"
    );
}

#[test]
fn fails_when_required_defaults_missing() {
    let temp = TempDir::new().expect("tempdir");
    let manifest = r#"{"id":"component-demo","name":"component-demo","operations":[{"name":"handle_message","input_schema":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]},"output_schema":{}}],"config_schema":{"type":"object","properties":{},"required":[]}}"#;
    fs::write(temp.path().join("component.manifest.json"), manifest).expect("write manifest");

    let mut cmd = cargo_bin_cmd!("greentic-component");
    cmd.current_dir(temp.path()).arg("flow").arg("update");
    cmd.assert().failure().stderr(predicates::str::contains(
        "Required field input has no default; cannot generate default dev_flow",
    ));
}