feat(sdk,plugins): API publique d'accès fichiers/workspace + analyse structure (#124,#129)

This commit is contained in:
2026-08-02 13:34:54 +02:00
parent 033e9a86d5
commit dce61ae1aa
27 changed files with 5895 additions and 94 deletions

View File

@ -397,6 +397,21 @@ pub fn run() {
plugins::plugin_set_enabled,
plugins::plugin_uninstall,
plugins::plugin_list_runtime_contributions,
plugins::plugin_workspace_read_text,
plugins::plugin_workspace_read_binary,
plugins::plugin_workspace_write_text,
plugins::plugin_workspace_write_binary,
plugins::plugin_workspace_list_dir,
plugins::plugin_workspace_stat,
plugins::plugin_query_project_structure,
plugins::plugin_config_read_document,
plugins::plugin_config_update_document,
plugins::plugin_task_run_command,
plugins::plugin_task_get_status,
plugins::plugin_toolchain_diagnose,
plugins::plugin_events_subscribe,
plugins::plugin_events_poll,
plugins::plugin_events_unsubscribe,
plugins::plugin_open_plugins_folder,
commands::get_server_exposure_settings,
commands::save_server_exposure_settings,
@ -415,6 +430,28 @@ pub fn run() {
.expect("error while running IdeA Tauri application");
}
#[cfg(test)]
fn plugin_workspace_invoke_handler<R: tauri::Runtime>(
) -> impl Fn(tauri::ipc::Invoke<R>) -> bool + Send + Sync + 'static {
tauri::generate_handler![
plugins::plugin_workspace_read_text,
plugins::plugin_workspace_read_binary,
plugins::plugin_workspace_write_text,
plugins::plugin_workspace_write_binary,
plugins::plugin_workspace_list_dir,
plugins::plugin_workspace_stat,
plugins::plugin_query_project_structure,
plugins::plugin_config_read_document,
plugins::plugin_config_update_document,
plugins::plugin_task_run_command,
plugins::plugin_task_get_status,
plugins::plugin_toolchain_diagnose,
plugins::plugin_events_subscribe,
plugins::plugin_events_poll,
plugins::plugin_events_unsubscribe,
]
}
async fn app_exit_work_guard_state(
handle: &tauri::AppHandle,
) -> Result<application::AppExitWorkGuardState, AppError> {
@ -713,6 +750,7 @@ fn persisted_monitor_is_available(
#[cfg(test)]
mod tests {
use super::plugin_workspace_invoke_handler;
use super::{
apply_main_close_decision, confirm_next_main_window_close, consume_exit_guard_confirmation,
decide_main_close_action, persisted_view_identity_from_label, persisted_window_identity,
@ -720,7 +758,10 @@ mod tests {
};
use super::{should_close_with_main_window, PersistedWindowKind};
use application::AppExitWorkGuardState;
use serde_json::json;
use std::cell::Cell;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY};
#[test]
fn main_close_without_work_allows_shutdown_without_preventing_close() {
@ -880,4 +921,289 @@ mod tests {
);
assert!(persisted_window_identity("view-tickets-not-a-project").is_none());
}
#[test]
fn dto_plugins_workspace_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-workspace-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(124).to_string();
for command in [
"plugin_workspace_read_text",
"plugin_workspace_read_binary",
"plugin_workspace_list_dir",
"plugin_workspace_stat",
"plugin_query_project_structure",
] {
let err = invoke_plugin_command(
&webview,
command,
json!({
"input": {
"projectId": missing_project.clone(),
"path": "src/main.rs",
"maxDepth": 2,
"maxEntries": 10
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND", "{command}");
}
for (command, input) in [
(
"plugin_workspace_write_text",
json!({
"projectId": missing_project.clone(),
"path": "generated.txt",
"content": "hello\n"
}),
),
(
"plugin_workspace_write_binary",
json!({
"projectId": missing_project.clone(),
"path": "generated.bin",
"bytes": [1, 2, 3]
}),
),
] {
let err = invoke_plugin_command(&webview, command, json!({ "input": input }))
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND", "{command}");
}
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_config_document_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-config-document-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(130).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_config_read_document",
json!({
"input": {
"projectId": missing_project.clone(),
"path": "config/settings.json",
"format": "json"
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
let err = invoke_plugin_command(
&webview,
"plugin_config_update_document",
json!({
"input": {
"projectId": missing_project,
"path": "config/settings.json",
"format": "json",
"mode": "mergePatch",
"value": {"enabled": true}
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_command_task_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-task-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(125).to_string();
let owner = uuid::Uuid::from_u128(126).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_task_run_command",
json!({
"input": {
"projectId": missing_project,
"ownerAgentId": owner,
"label": "cargo test",
"command": "cargo",
"args": ["test"],
"cwd": ".",
"env": [["RUST_LOG", "debug"]],
"recordOnly": true
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
let task_id = uuid::Uuid::from_u128(127).to_string();
let value = invoke_plugin_command(
&webview,
"plugin_task_get_status",
json!({
"input": {
"taskId": task_id
}
}),
)
.expect("unknown task is a successful empty status");
assert_eq!(value, serde_json::Value::Null);
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_toolchain_diagnostic_command_is_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-toolchain-diagnostic-command");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(126).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_toolchain_diagnose",
json!({
"input": {
"projectId": missing_project,
"cwd": ".",
"tools": [{
"id": "rust",
"executable": "cargo",
"versionArgs": ["--version"],
"required": true
}],
"env": [{
"name": "RUSTUP_HOME",
"required": false
}],
"files": [{
"path": "Cargo.toml",
"required": true,
"kind": "file"
}]
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_event_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-event-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(127).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_events_subscribe",
json!({
"input": {
"projectId": missing_project,
"eventTypes": ["workspaceFileChanged", "backgroundTaskChanged"],
"capacity": 10
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
let subscription_id = uuid::Uuid::from_u128(128).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_events_poll",
json!({
"input": {
"subscriptionId": subscription_id.clone(),
"maxEvents": 10
}
}),
)
.expect_err("unknown subscription must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
let disposed = invoke_plugin_command(
&webview,
"plugin_events_unsubscribe",
json!({
"input": {
"subscriptionId": subscription_id
}
}),
)
.expect("unsubscribe is idempotent");
assert_eq!(disposed["retention"], "disposed");
std::fs::remove_dir_all(app_data).ok();
}
fn invoke_plugin_command<W: AsRef<tauri::Webview<tauri::test::MockRuntime>>>(
webview: &W,
command: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, serde_json::Value> {
get_ipc_response(
webview,
tauri::webview::InvokeRequest {
cmd: command.to_owned(),
callback: tauri::ipc::CallbackFn(0),
error: tauri::ipc::CallbackFn(1),
url: "tauri://localhost".parse().unwrap(),
body: tauri::ipc::InvokeBody::Json(body),
headers: Default::default(),
invoke_key: INVOKE_KEY.to_owned(),
},
)
.map(|body| body.deserialize::<serde_json::Value>().unwrap())
}
fn test_app_data_dir(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("idea-{label}-{}-{nanos}", std::process::id()))
}
}