osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
#[cfg(unix)]
#[test]
fn timeout_dispatch_covers_timeout_fields_and_process_cleanup_unit() {
    let root = make_temp_dir("osp-cli-plugin-manager-dispatch-timeout");
    let plugins_dir = root.join("plugins");
    std::fs::create_dir_all(&plugins_dir).expect("plugin dir should be created");

    write_sleepy_test_plugin(&plugins_dir, "hang", false);
    let manager = PluginManager::new(vec![plugins_dir.clone()])
        .with_process_timeout(Duration::from_millis(50));

    let err = manager
        .dispatch("hang", &[], &PluginDispatchContext::default())
        .expect_err("hung plugin should time out");

    match err {
        PluginDispatchError::TimedOut {
            plugin_id, timeout, ..
        } => {
            assert_eq!(plugin_id, "hang");
            assert_eq!(timeout, Duration::from_millis(50));
        }
        other => panic!("expected timeout error, got {other}"),
    }

    let leak_root = make_temp_dir("osp-cli-plugin-manager-timeout-process-group");
    let leak_plugins_dir = leak_root.join("plugins");
    let marker = leak_root.join("leaked-child.txt");
    std::fs::create_dir_all(&leak_plugins_dir).expect("plugin dir should be created");

    write_timeout_leak_test_plugin(&leak_plugins_dir, "hang", &marker);
    let leak_manager = PluginManager::new(vec![leak_plugins_dir.clone()])
        .with_process_timeout(Duration::from_millis(50));

    let err = leak_manager
        .dispatch("hang", &[], &PluginDispatchContext::default())
        .expect_err("hung plugin should time out");
    assert!(matches!(err, PluginDispatchError::TimedOut { .. }));

    std::thread::sleep(Duration::from_millis(350));
    assert!(
        !marker.exists(),
        "timed-out plugin left a background child behind"
    );
}

#[cfg(unix)]
#[test]
fn dispatch_drains_large_plugin_output_without_false_timeout_unit() {
    let root = make_temp_dir("osp-cli-plugin-manager-large-output");
    let plugins_dir = root.join("plugins");
    std::fs::create_dir_all(&plugins_dir).expect("plugin dir should be created");

    write_large_output_test_plugin(&plugins_dir, "loud");
    let manager = PluginManager::new(vec![plugins_dir.clone()])
        .with_process_timeout(Duration::from_millis(500));

    let response = manager
        .dispatch("loud", &[], &PluginDispatchContext::default())
        .expect("large-output plugin should complete without timing out");

    assert!(response.ok);
    assert!(
        response
            .data
            .as_object()
            .and_then(|data| data.get("blob"))
            .and_then(|value| value.as_str())
            .is_some_and(|blob| blob.len() >= 131_072)
    );
}

#[test]
fn plugin_dispatch_context_and_error_formats_cover_local_helper_paths_unit() {
    let context = PluginDispatchContext::new(crate::core::runtime::RuntimeHints::default())
        .with_shared_env([("OSP_FORMAT", "json")])
        .with_plugin_env(std::collections::HashMap::from([(
            "alpha".to_string(),
            vec![("OSP_PLUGIN_FLAG".to_string(), "1".to_string())],
        )]));

    let pairs = context.env_pairs_for("alpha").collect::<Vec<_>>();
    assert_eq!(
        pairs,
        vec![("OSP_FORMAT", "json"), ("OSP_PLUGIN_FLAG", "1")]
    );
    assert_eq!(
        context.env_pairs_for("missing").collect::<Vec<_>>(),
        vec![("OSP_FORMAT", "json")]
    );

    let timeout_plain = PluginDispatchError::TimedOut {
        plugin_id: "alpha".to_string(),
        timeout: Duration::from_millis(25),
        stderr: String::new(),
    };
    assert!(
        timeout_plain
            .to_string()
            .contains("plugin alpha timed out after 25 ms")
    );

    let timeout_stderr = PluginDispatchError::TimedOut {
        plugin_id: "alpha".to_string(),
        timeout: Duration::from_millis(25),
        stderr: "stuck".to_string(),
    };
    assert!(timeout_stderr.to_string().contains("stuck"));

    let nonzero_plain = PluginDispatchError::NonZeroExit {
        plugin_id: "beta".to_string(),
        status_code: 9,
        stderr: String::new(),
    };
    assert_eq!(
        nonzero_plain.to_string(),
        "plugin beta exited with status 9"
    );

    let nonzero_stderr = PluginDispatchError::NonZeroExit {
        plugin_id: "beta".to_string(),
        status_code: 9,
        stderr: "boom".to_string(),
    };
    assert!(nonzero_stderr.to_string().contains("boom"));

    let ambiguous = PluginDispatchError::CommandAmbiguous {
        command: "shared".to_string(),
        providers: vec!["alpha".to_string(), "beta".to_string()],
    };
    assert!(ambiguous.to_string().contains("multiple plugins"));

    let provider_missing = PluginDispatchError::ProviderNotFound {
        command: "shared".to_string(),
        requested_provider: "gamma".to_string(),
        providers: vec!["alpha".to_string(), "beta".to_string()],
    };
    assert!(provider_missing.to_string().contains("available providers"));

    let execute_failed = PluginDispatchError::ExecuteFailed {
        plugin_id: "alpha".to_string(),
        source: std::io::Error::other("spawn failed"),
    };
    assert_eq!(
        execute_failed.source().map(|err| err.to_string()),
        Some("spawn failed".to_string())
    );

    let invalid_json = PluginDispatchError::InvalidJsonResponse {
        plugin_id: "alpha".to_string(),
        source: serde_json::from_str::<serde_json::Value>("not-json").expect_err("invalid"),
    };
    assert!(invalid_json.to_string().contains("invalid JSON response"));
    assert!(invalid_json.source().is_some());

    let invalid_payload = PluginDispatchError::InvalidResponsePayload {
        plugin_id: "alpha".to_string(),
        reason: "missing data".to_string(),
    };
    assert!(invalid_payload.to_string().contains("missing data"));
    assert!(invalid_payload.source().is_none());
}

#[cfg(unix)]
#[test]
fn describe_plugin_and_provider_error_paths_cover_missing_nonzero_invalid_and_execute_failed_unit() {
    let _lock = env_lock()
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    let root = make_temp_dir("osp-cli-plugin-manager-missing-describe");
    let missing = root.join("osp-missing");

    let err = describe_plugin(&missing, Duration::from_millis(50))
        .expect_err("missing executable should fail");
    assert!(err.to_string().contains("failed to execute --describe"));
    use std::os::unix::fs::PermissionsExt;
    let nonzero = root.join("osp-nonzero");
    let invalid_json = root.join("osp-invalid-json");
    let invalid_payload = root.join("osp-invalid-payload");

    std::fs::write(
        &nonzero,
        "#!/bin/sh\nPATH=/usr/bin:/bin\nif [ \"$1\" = \"--describe\" ]; then echo nope >&2; exit 7; fi\n",
    )
    .expect("fixture should be written");
    std::fs::write(
        &invalid_json,
        "#!/bin/sh\nPATH=/usr/bin:/bin\nif [ \"$1\" = \"--describe\" ]; then printf 'not-json\\n'; exit 0; fi\n",
    )
    .expect("fixture should be written");
    std::fs::write(
        &invalid_payload,
        "#!/bin/sh\nPATH=/usr/bin:/bin\nif [ \"$1\" = \"--describe\" ]; then cat <<'JSON'\n{\"protocol_version\":1,\"plugin_id\":\"\",\"plugin_version\":\"0.1.0\",\"commands\":[]}\nJSON\nexit 0\nfi\n",
    )
    .expect("fixture should be written");

    for path in [&nonzero, &invalid_json, &invalid_payload] {
        let mut perms = std::fs::metadata(path).expect("metadata").permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(path, perms).expect("chmod");
    }

    let err = describe_plugin(&nonzero, Duration::from_millis(50))
        .expect_err("non-zero describe should fail");
    assert!(err.to_string().contains("--describe failed with status"));
    assert!(err.to_string().contains("nope"));

    let err = describe_plugin(&invalid_json, Duration::from_millis(50))
        .expect_err("invalid json should fail");
    assert!(err.to_string().contains("invalid describe JSON"));

    let err = describe_plugin(&invalid_payload, Duration::from_millis(50))
        .expect_err("invalid payload should fail");
    assert!(err.to_string().contains("invalid describe payload"));

    let provider = DiscoveredPlugin {
        plugin_id: "missing".to_string(),
        plugin_version: None,
        executable: root.join("osp-missing-run"),
        source: PluginSource::Explicit,
        commands: vec!["missing".to_string()],
        describe_commands: Vec::new(),
        command_specs: Vec::new(),
        issue: None,
        default_enabled: true,
    };
    let err = run_provider(
        &provider,
        "missing",
        &[],
        &PluginDispatchContext::default(),
        Duration::from_millis(50),
    )
    .expect_err("missing executable should fail");
    assert!(matches!(
        err,
        PluginDispatchError::ExecuteFailed { plugin_id, .. } if plugin_id == "missing"
    ));

}