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()))
}
}

View File

@ -5,8 +5,16 @@ use std::path::{Path, PathBuf};
use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
use backend::dto::{
ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto,
PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto,
ErrorDto, PluginAdminDto, PluginConfigDocumentDto, PluginConfigDocumentReadDto,
PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto,
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
PluginRuntimeContributionCatalogDto, PluginTaskDto, PluginTaskStatusDto,
PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto,
PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto,
PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto,
PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
};
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
use domain::{PluginId, RelativePath};
@ -156,6 +164,198 @@ pub async fn plugin_list_runtime_contributions(
.map_err(ErrorDto::from)
}
/// Reads a UTF-8 workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_read_text(
input: PluginWorkspacePathDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceTextFileDto, ErrorDto> {
state
.plugin_workspace_access
.read_text(input.into())
.await
.map_err(ErrorDto::from)
}
/// Reads a binary workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_read_binary(
input: PluginWorkspacePathDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceBinaryFileDto, ErrorDto> {
state
.plugin_workspace_access
.read_binary(input.into())
.await
.map_err(ErrorDto::from)
}
/// Writes a UTF-8 workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_write_text(
input: PluginWorkspaceWriteTextDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.plugin_workspace_access
.write_text(input.into())
.await
.map_err(ErrorDto::from)
}
/// Writes a binary workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_write_binary(
input: PluginWorkspaceWriteBinaryDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.plugin_workspace_access
.write_binary(input.into())
.await
.map_err(ErrorDto::from)
}
/// Lists a workspace directory for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_list_dir(
input: PluginWorkspacePathDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceDirectoryListingDto, ErrorDto> {
state
.plugin_workspace_access
.list_dir(input.into())
.await
.map_err(ErrorDto::from)
}
/// Stats a workspace path for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_stat(
input: PluginWorkspacePathDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceStatDto, ErrorDto> {
state
.plugin_workspace_access
.stat(input.into())
.await
.map_err(ErrorDto::from)
}
/// Queries a bounded generic project structure for the public plugin API.
#[tauri::command]
pub async fn plugin_query_project_structure(
input: PluginProjectStructureQueryDto,
state: State<'_, AppState>,
) -> Result<PluginProjectStructureDto, ErrorDto> {
state
.query_project_structure
.execute(input.into())
.await
.map_err(ErrorDto::from)
}
/// Reads a structured configuration document for the public plugin API.
#[tauri::command]
pub async fn plugin_config_read_document(
input: PluginConfigDocumentReadDto,
state: State<'_, AppState>,
) -> Result<PluginConfigDocumentDto, ErrorDto> {
state
.plugin_config_documents
.read(input.into())
.await
.map_err(ErrorDto::from)
}
/// Updates a structured configuration document for the public plugin API.
#[tauri::command]
pub async fn plugin_config_update_document(
input: PluginConfigDocumentUpdateDto,
state: State<'_, AppState>,
) -> Result<PluginConfigDocumentWriteResultDto, ErrorDto> {
state
.plugin_config_documents
.update(input.into())
.await
.map_err(ErrorDto::from)
}
/// Launches a command-backed background task for the public plugin API.
#[tauri::command]
pub async fn plugin_task_run_command(
input: PluginRunCommandDto,
state: State<'_, AppState>,
) -> Result<PluginTaskDto, ErrorDto> {
state
.plugin_command_tasks
.run_command(input.into())
.await
.map(PluginTaskDto::from)
.map_err(ErrorDto::from)
}
/// Reads one command task status for the public plugin API.
#[tauri::command]
pub async fn plugin_task_get_status(
input: PluginTaskStatusDto,
state: State<'_, AppState>,
) -> Result<Option<PluginTaskDto>, ErrorDto> {
state
.plugin_command_tasks
.get_status(input.into())
.await
.map(|task| task.map(PluginTaskDto::from))
.map_err(ErrorDto::from)
}
/// Diagnoses generic external toolchain prerequisites for the public plugin API.
#[tauri::command]
pub async fn plugin_toolchain_diagnose(
input: PluginToolchainDiagnosticRequestDto,
state: State<'_, AppState>,
) -> Result<PluginToolchainDiagnosticDto, ErrorDto> {
state
.plugin_toolchain_diagnostics
.diagnose(input.into())
.await
.map_err(ErrorDto::from)
}
/// Subscribes to stable public plugin events.
#[tauri::command]
pub async fn plugin_events_subscribe(
input: PluginEventSubscribeDto,
state: State<'_, AppState>,
) -> Result<PluginEventSubscriptionDto, ErrorDto> {
state
.plugin_event_subscriptions
.subscribe(input.into())
.await
.map_err(ErrorDto::from)
}
/// Drains retained public plugin events for one subscription.
#[tauri::command]
pub fn plugin_events_poll(
input: PluginEventPollDto,
state: State<'_, AppState>,
) -> Result<PluginEventBatchDto, ErrorDto> {
state
.plugin_event_subscriptions
.poll(input.into())
.map_err(ErrorDto::from)
}
/// Disposes a public plugin event subscription.
#[tauri::command]
pub fn plugin_events_unsubscribe(
input: PluginEventUnsubscribeDto,
state: State<'_, AppState>,
) -> PluginEventSubscriptionDto {
state.plugin_event_subscriptions.unsubscribe(input.into())
}
/// Opens the plugin store folder, or one plugin folder when an id is provided.
#[tauri::command]
pub fn plugin_open_plugins_folder(