use super::ToolSuggestDiscoverablePlugin;
use super::ToolSuggestPluginDiscoveryInput;
use crate::OPENAI_BUNDLED_MARKETPLACE_NAME;
use crate::PluginInstallRequest;
use crate::PluginsConfigInput;
use crate::PluginsManager;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::RemotePluginServiceConfig;
use crate::remote::fetch_and_cache_global_remote_plugin_catalog;
use crate::startup_sync::curated_plugins_repo_path;
use crate::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::test_support::load_plugins_config;
use crate::test_support::write_curated_plugin;
use crate::test_support::write_curated_plugin_sha_with;
use crate::test_support::write_file;
use crate::test_support::write_openai_curated_marketplace;
use codex_config::CONFIG_TOML_FILE;
use codex_login::CodexAuth;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashSet;
use std::path::Path;
use tempfile::tempdir;
use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_test::internal::MockWriter;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;

#[tokio::test]
async fn returns_fallback_plugins_without_installed_apps() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]);

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(
        discoverable_plugins
            .into_iter()
            .map(|plugin| plugin.id)
            .collect::<Vec<_>>(),
        vec![
            "openai-developers@openai-curated".to_string(),
            "slack@openai-curated".to_string(),
        ]
    );
}

#[tokio::test]
async fn returns_microsoft_fallback_plugins() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(
        &curated_root,
        &["teams", "sharepoint", "outlook-email", "outlook-calendar"],
    );
    install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await;

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(
        discoverable_plugins
            .into_iter()
            .map(|plugin| plugin.id)
            .collect::<Vec<_>>(),
        vec![
            "outlook-calendar@openai-curated".to_string(),
            "outlook-email@openai-curated".to_string(),
            "sharepoint@openai-curated".to_string(),
        ]
    );
}

#[tokio::test]
async fn omits_openai_curated_when_remote_enabled() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["slack"]);

    let bundled_marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
    let bundled_marketplace_root = codex_home
        .path()
        .join(format!(".tmp/marketplaces/{bundled_marketplace_name}"));
    write_file(
        &bundled_marketplace_root.join(".agents/plugins/marketplace.json"),
        &format!(
            r#"{{
  "name": "{bundled_marketplace_name}",
  "plugins": [
    {{"name": "chrome", "source": {{"source": "local", "path": "./plugins/chrome"}}}}
  ]
}}
"#
        ),
    );
    write_curated_plugin(&bundled_marketplace_root, "chrome");
    write_file(
        &codex_home.path().join(CONFIG_TOML_FILE),
        &format!(
            r#"[features]
plugins = true
remote_plugin = true

[marketplaces.{bundled_marketplace_name}]
source_type = "git"
source = "/tmp/{bundled_marketplace_name}"
"#
        ),
    );

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(
        discoverable_plugins
            .into_iter()
            .map(|plugin| plugin.id)
            .collect::<Vec<_>>(),
        vec!["chrome@openai-bundled".to_string()]
    );
}

#[tokio::test]
async fn deduplicates_configured_marketplace_plugin() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let plugin_name = "sample";
    let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
    let plugin_id = format!("{plugin_name}@{marketplace_name}");
    let marketplace_root = codex_home
        .path()
        .join(format!(".tmp/marketplaces/{marketplace_name}"));
    write_file(
        &marketplace_root.join(".agents/plugins/marketplace.json"),
        &format!(
            r#"{{
  "name": "{marketplace_name}",
  "plugins": [
    {{"name": "{plugin_name}", "source": {{"source": "local", "path": "./plugins/{plugin_name}"}}}}
  ]
}}
"#
        ),
    );
    write_curated_plugin(&marketplace_root, plugin_name);
    write_file(
        &codex_home.path().join(CONFIG_TOML_FILE),
        &format!(
            r#"[features]
plugins = true

[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
"#
        ),
    );

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[plugin_id.as_str()], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(discoverable_plugins.len(), 1);
    assert_eq!(discoverable_plugins[0].id, plugin_id);
}

#[tokio::test]
async fn ignores_missing_marketplace_plugin() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["installed", "slack"]);
    let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
    let marketplace_root = codex_home
        .path()
        .join(format!(".tmp/marketplaces/{marketplace_name}"));
    write_file(
        &marketplace_root.join(".agents/plugins/marketplace.json"),
        &format!(
            r#"{{
  "name": "{marketplace_name}",
  "plugins": [
    {{"name": "sample", "source": {{"source": "local", "path": "./plugins/sample"}}}}
  ]
}}
"#
        ),
    );
    write_file(
        &codex_home.path().join(CONFIG_TOML_FILE),
        &format!(
            r#"[features]
plugins = true

[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
"#
        ),
    );
    install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(discoverable_plugins.len(), 1);
    assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
}

#[tokio::test]
async fn normalizes_description() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["installed", "slack"]);
    write_file(
        &curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
        r#"{
  "name": "slack",
  "description": "  Plugin\n   with   extra   spacing  "
}"#,
    );
    install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(
        discoverable_plugins,
        vec![ToolSuggestDiscoverablePlugin {
            id: "slack@openai-curated".to_string(),
            remote_plugin_id: None,
            name: "slack".to_string(),
            description: Some("Plugin with extra spacing".to_string()),
            has_skills: true,
            mcp_server_names: vec!["sample-docs".to_string()],
            app_connector_ids: vec!["connector_calendar".to_string()],
        }]
    );
}

#[tokio::test]
async fn omits_installed_curated_plugins() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["slack"]);
    install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(discoverable_plugins, Vec::new());
}

#[tokio::test]
async fn omits_not_available_curated_plugins() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_file(
        &curated_root.join(".agents/plugins/marketplace.json"),
        r#"{
  "name": "openai-curated",
  "plugins": [
    {
      "name": "installed",
      "source": {
        "source": "local",
        "path": "./plugins/installed"
      }
    },
    {
      "name": "slack",
      "source": {
        "source": "local",
        "path": "./plugins/slack"
      }
    },
    {
      "name": "gmail",
      "source": {
        "source": "local",
        "path": "./plugins/gmail"
      },
      "policy": {
        "installation": "NOT_AVAILABLE"
      }
    }
  ]
}
"#,
    );
    write_curated_plugin(&curated_root, "installed");
    write_curated_plugin(&curated_root, "slack");
    write_curated_plugin(&curated_root, "gmail");
    install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(
        discoverable_plugins
            .into_iter()
            .map(|plugin| plugin.id)
            .collect::<Vec<_>>(),
        vec!["slack@openai-curated".to_string()]
    );
}

#[tokio::test]
async fn does_not_reload_marketplace_per_plugin() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["slack", "gmail", "openai-developers"]);
    install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;

    let too_long_prompt = "x".repeat(129);
    for plugin_name in ["gmail", "openai-developers"] {
        write_file(
            &curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")),
            &format!(
                r#"{{
  "name": "{plugin_name}",
  "description": "Plugin that includes skills, MCP servers, and app connectors",
  "interface": {{
    "defaultPrompt": "{too_long_prompt}"
  }}
}}"#
            ),
        );
    }

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let buffer: &'static std::sync::Mutex<Vec<u8>> =
        Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
    let subscriber = tracing_subscriber::fmt()
        .with_level(true)
        .with_ansi(false)
        .with_max_level(Level::WARN)
        .with_span_events(FmtSpan::NONE)
        .with_writer(MockWriter::new(buffer))
        .finish();
    let _guard = tracing::subscriber::set_default(subscriber);

    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(
        discoverable_plugins
            .iter()
            .map(|plugin| plugin.id.as_str())
            .collect::<Vec<_>>(),
        vec!["gmail@openai-curated", "openai-developers@openai-curated"]
    );

    let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone())
        .expect("utf8 logs")
        .replace('\\', "/");
    assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 8);
    assert_eq!(logs.matches("gmail/.codex-plugin/plugin.json").count(), 4);
    assert_eq!(
        logs.matches("openai-developers/.codex-plugin/plugin.json")
            .count(),
        4
    );
}

#[tokio::test]
async fn does_not_expand_local_plugins_by_installed_apps() {
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["sample", "slack", "hubspot"]);
    write_plugin_app(&curated_root, "sample", "sample", "connector_sample");
    install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(discoverable_plugins, Vec::new());
}

#[tokio::test]
async fn does_not_read_local_plugins_for_loaded_apps() {
    let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
    let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "sample"]);
    write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
    write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
    write_file(
        &curated_root.join("plugins/sample/.app.json"),
        "invalid json",
    );

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let buffer: &'static std::sync::Mutex<Vec<u8>> =
        Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
    let subscriber = tracing_subscriber::fmt()
        .with_level(true)
        .with_ansi(false)
        .with_max_level(Level::WARN)
        .with_span_events(FmtSpan::NONE)
        .with_writer(MockWriter::new(buffer))
        .finish();
    let _guard = tracing::subscriber::set_default(subscriber);

    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[hubspot_app_id]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(discoverable_plugins, Vec::new());
    let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone())
        .expect("utf8 logs")
        .replace('\\', "/");
    assert_eq!(logs.matches("plugins/sample/.app.json").count(), 0);
}

#[tokio::test]
async fn does_not_expand_local_sales_apps() {
    let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
    let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
    let test_app_id = "asdk_app_test_source";
    let codex_home = tempdir().expect("tempdir should succeed");
    let curated_root = curated_plugins_repo_path(codex_home.path());
    write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "test-source"]);
    write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
    write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
    write_plugin_app(&curated_root, "test-source", "test_source", test_app_id);

    let sales_marketplace_name = "oai-maintained-plugins";
    let sales_marketplace_root = codex_home
        .path()
        .join(format!(".tmp/marketplaces/{sales_marketplace_name}"));
    write_file(
        &sales_marketplace_root.join(".agents/plugins/marketplace.json"),
        &format!(
            r#"{{
  "name": "{sales_marketplace_name}",
  "plugins": [
    {{"name": "sales", "source": {{"source": "local", "path": "./plugins/sales"}}}}
  ]
}}
"#
        ),
    );
    write_curated_plugin(&sales_marketplace_root, "sales");
    write_file(
        &sales_marketplace_root.join("plugins/sales/.app.json"),
        &format!(
            r#"{{
  "apps": {{
    "hubspot": {{
      "id": "{hubspot_app_id}"
    }},
    "granola": {{
      "id": "{granola_app_id}"
    }}
  }}
}}
"#
        ),
    );
    write_file(
        &codex_home.path().join(CONFIG_TOML_FILE),
        &format!(
            r#"[features]
plugins = true

[marketplaces.{sales_marketplace_name}]
source_type = "git"
source = "/tmp/{sales_marketplace_name}"
"#
        ),
    );
    install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await;

    let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &[]),
        /*auth*/ None,
    )
    .await;

    assert_eq!(discoverable_plugins, Vec::new());
}

#[tokio::test]
async fn expands_cached_remote_plugins_by_loaded_apps() {
    let codex_home = tempdir().expect("tempdir should succeed");
    write_file(
        &codex_home.path().join(CONFIG_TOML_FILE),
        r#"[features]
plugins = true
remote_plugin = true
"#,
    );

    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/backend-api/ps/plugins/list"))
        .and(query_param("scope", "GLOBAL"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "plugins": [
                {
                    "id": "plugins~Plugin_remote_unlisted",
                    "name": "remote-unlisted",
                    "scope": "GLOBAL",
                    "installation_policy": "AVAILABLE",
                    "authentication_policy": "ON_USE",
                    "status": "AVAILABLE",
                    "release": {
                        "display_name": "Remote Unlisted",
                        "description": "Remote Unlisted long",
                        "app_ids": ["remote-unlisted-app"],
                        "interface": {
                            "short_description": "Remote Unlisted short",
                            "long_description": null,
                            "developer_name": null,
                            "category": null,
                            "capabilities": [],
                            "website_url": null,
                            "privacy_policy_url": null,
                            "terms_of_service_url": null,
                            "brand_color": null,
                            "default_prompt": null,
                            "composer_icon_url": null,
                            "logo_url": null,
                            "screenshot_urls": []
                        },
                        "skills": [
                            {
                                "name": "remote-unlisted",
                                "description": "Use unlisted remote plugin",
                                "interface": null
                            }
                        ]
                    }
                }
            ],
            "pagination": {
                "next_page_token": null
            }
        })))
        .expect(1)
        .mount(&server)
        .await;

    let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
    let mut plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
    plugins.chatgpt_base_url = format!("{}/backend-api", server.uri());
    let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
    fetch_and_cache_global_remote_plugin_catalog(
        codex_home.path(),
        &RemotePluginServiceConfig {
            chatgpt_base_url: plugins.chatgpt_base_url.clone(),
        },
        Some(&auth),
    )
    .await
    .expect("remote plugin catalog cache should write");

    for scope in ["GLOBAL", "WORKSPACE"] {
        Mock::given(method("GET"))
            .and(path("/backend-api/ps/plugins/installed"))
            .and(query_param("scope", scope))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "plugins": [],
                "pagination": {
                    "next_page_token": null
                }
            })))
            .expect(1)
            .mount(&server)
            .await;
    }
    plugins_manager
        .build_and_cache_remote_installed_plugin_marketplaces(
            &plugins,
            Some(&auth),
            &[REMOTE_GLOBAL_MARKETPLACE_NAME],
            /*on_effective_plugins_changed*/ None,
        )
        .await
        .expect("remote installed plugin cache should write");

    let discoverable_plugins = list_discoverable_plugins(
        &plugins_manager,
        discovery_input(plugins, &[], &[], &["remote-unlisted-app"]),
        Some(&auth),
    )
    .await;

    assert_eq!(
        discoverable_plugins,
        vec![ToolSuggestDiscoverablePlugin {
            id: "remote-unlisted@openai-curated-remote".to_string(),
            remote_plugin_id: Some("plugins~Plugin_remote_unlisted".to_string()),
            name: "Remote Unlisted".to_string(),
            description: Some("Remote Unlisted short".to_string()),
            has_skills: true,
            mcp_server_names: Vec::new(),
            app_connector_ids: vec!["remote-unlisted-app".to_string()],
        }]
    );
}

fn discovery_input(
    plugins: PluginsConfigInput,
    configured_plugin_ids: &[&str],
    disabled_plugin_ids: &[&str],
    loaded_plugin_app_connector_ids: &[&str],
) -> ToolSuggestPluginDiscoveryInput {
    ToolSuggestPluginDiscoveryInput {
        plugins,
        configured_plugin_ids: string_set(configured_plugin_ids),
        disabled_plugin_ids: string_set(disabled_plugin_ids),
        loaded_plugin_app_connector_ids: string_set(loaded_plugin_app_connector_ids),
    }
}

async fn list_discoverable_plugins(
    plugins_manager: &PluginsManager,
    input: ToolSuggestPluginDiscoveryInput,
    auth: Option<&CodexAuth>,
) -> Vec<ToolSuggestDiscoverablePlugin> {
    plugins_manager
        .list_tool_suggest_discoverable_plugins(&input, auth)
        .await
        .expect("discoverable plugins should load")
}

fn string_set(values: &[&str]) -> HashSet<String> {
    values.iter().map(ToString::to_string).collect()
}

async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) {
    write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA);
    PluginsManager::new(codex_home.to_path_buf())
        .install_plugin(PluginInstallRequest {
            plugin_name: plugin_name.to_string(),
            marketplace_path: AbsolutePathBuf::try_from(
                marketplace_root.join(".agents/plugins/marketplace.json"),
            )
            .expect("marketplace path"),
        })
        .await
        .expect("plugin should install");
}

fn write_plugin_app(root: &Path, plugin_name: &str, app_name: &str, app_id: &str) {
    write_file(
        &root.join(format!("plugins/{plugin_name}/.app.json")),
        &format!(
            r#"{{
  "apps": {{
    "{app_name}": {{
      "id": "{app_id}"
    }}
  }}
}}
"#
        ),
    );
}
