feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
35
crates/app-tauri/Cargo.toml
Normal file
@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "app-tauri"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "IdeA — Tauri v2 shell: composition root (DI), IPC commands/events, PTY↔Channel bridge."
|
||||
|
||||
# The library carries all the wiring so it is unit-testable; the binary is a
|
||||
# thin entry point.
|
||||
[lib]
|
||||
name = "app_tauri_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "app-tauri"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
infrastructure = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { workspace = true }
|
||||
3
crates/app-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
7
crates/app-tauri/capabilities/default.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability set for the IdeA main window.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "dialog:allow-open"]
|
||||
}
|
||||
1
crates/app-tauri/gen/schemas/acl-manifests.json
Normal file
1
crates/app-tauri/gen/schemas/capabilities.json
Normal file
@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability set for the IdeA main window.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
|
||||
2358
crates/app-tauri/gen/schemas/desktop-schema.json
Normal file
2358
crates/app-tauri/gen/schemas/linux-schema.json
Normal file
BIN
crates/app-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
crates/app-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
crates/app-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
crates/app-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
crates/app-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
crates/app-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
crates/app-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
crates/app-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
crates/app-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
crates/app-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
crates/app-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/app-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
crates/app-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/app-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
BIN
crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
BIN
crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
BIN
crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
crates/app-tauri/icons/icon.icns
Normal file
BIN
crates/app-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
crates/app-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 729 B |
BIN
crates/app-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
1121
crates/app-tauri/src/commands.rs
Normal file
1332
crates/app-tauri/src/dto.rs
Normal file
186
crates/app-tauri/src/events.rs
Normal file
@ -0,0 +1,186 @@
|
||||
//! `TauriEventRelay` — bridges the domain [`EventBus`] to Tauri events
|
||||
//! (backend → frontend push channel, ARCHITECTURE §2 "Events").
|
||||
//!
|
||||
//! The relay subscribes to the bus and re-emits each [`DomainEvent`] as a Tauri
|
||||
//! event named [`DOMAIN_EVENT`], carrying a serialisable [`DomainEventDto`]
|
||||
//! payload (the domain event itself is deliberately not `Serialize`; the wire
|
||||
//! format is owned here, the infrastructure/presentation layer).
|
||||
//!
|
||||
//! High-frequency `PtyOutput` is intentionally *not* relayed through this global
|
||||
//! event; it goes through per-session [`crate::pty::PtyBridge`] channels instead.
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use infrastructure::TokioBroadcastEventBus;
|
||||
|
||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||
pub const DOMAIN_EVENT: &str = "domain://event";
|
||||
|
||||
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
|
||||
///
|
||||
/// `type` is the discriminant; payload fields are flattened per variant. This is
|
||||
/// the single owner of the event wire format on the backend side.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum DomainEventDto {
|
||||
/// A project was created.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ProjectCreated {
|
||||
/// Project id (UUID string).
|
||||
project_id: String,
|
||||
},
|
||||
/// An agent was launched.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentLaunched {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// Session id.
|
||||
session_id: String,
|
||||
},
|
||||
/// An agent exited.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentExited {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// Exit code.
|
||||
code: i32,
|
||||
},
|
||||
/// A template was updated.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
TemplateUpdated {
|
||||
/// Template id.
|
||||
template_id: String,
|
||||
/// New version.
|
||||
version: u64,
|
||||
},
|
||||
/// A synchronized agent drifted from its template.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentDriftDetected {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// Current version.
|
||||
from: u64,
|
||||
/// Available version.
|
||||
to: u64,
|
||||
},
|
||||
/// A synchronized agent was brought up to date.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentSynced {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// Version synced to.
|
||||
to: u64,
|
||||
},
|
||||
/// A tab's layout changed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
LayoutChanged {
|
||||
/// Project id.
|
||||
project_id: String,
|
||||
},
|
||||
/// A remote connection was established.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RemoteConnected {
|
||||
/// Project id.
|
||||
project_id: String,
|
||||
},
|
||||
/// Git state changed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
GitStateChanged {
|
||||
/// Project id.
|
||||
project_id: String,
|
||||
},
|
||||
/// Raw PTY output (normally routed to a per-session channel, not here).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PtyOutput {
|
||||
/// Session id.
|
||||
session_id: String,
|
||||
/// Output bytes.
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&DomainEvent> for DomainEventDto {
|
||||
fn from(e: &DomainEvent) -> Self {
|
||||
match e {
|
||||
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
||||
project_id: project_id.to_string(),
|
||||
},
|
||||
DomainEvent::AgentLaunched {
|
||||
agent_id,
|
||||
session_id,
|
||||
} => Self::AgentLaunched {
|
||||
agent_id: agent_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
},
|
||||
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
|
||||
agent_id: agent_id.to_string(),
|
||||
code: *code,
|
||||
},
|
||||
DomainEvent::TemplateUpdated {
|
||||
template_id,
|
||||
version,
|
||||
} => Self::TemplateUpdated {
|
||||
template_id: template_id.to_string(),
|
||||
version: version.get(),
|
||||
},
|
||||
DomainEvent::AgentDriftDetected {
|
||||
agent_id,
|
||||
from,
|
||||
to,
|
||||
} => Self::AgentDriftDetected {
|
||||
agent_id: agent_id.to_string(),
|
||||
from: from.get(),
|
||||
to: to.get(),
|
||||
},
|
||||
DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced {
|
||||
agent_id: agent_id.to_string(),
|
||||
to: to.get(),
|
||||
},
|
||||
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
|
||||
project_id: project_id.to_string(),
|
||||
},
|
||||
DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected {
|
||||
project_id: project_id.to_string(),
|
||||
},
|
||||
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
|
||||
project_id: project_id.to_string(),
|
||||
},
|
||||
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
|
||||
session_id: session_id.to_string(),
|
||||
bytes: bytes.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes the relay to the bus and spawns a background task that forwards
|
||||
/// every [`DomainEvent`] to the frontend as a [`DOMAIN_EVENT`] Tauri event.
|
||||
///
|
||||
/// Uses the bus's raw async broadcast receiver so the relay runs cooperatively
|
||||
/// on the Tokio runtime (no blocking thread). Returns immediately; the spawned
|
||||
/// task lives for the duration of the app.
|
||||
pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
let mut rx = bus.raw_receiver();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
// Skip high-frequency PTY output on the global channel.
|
||||
if matches!(event, DomainEvent::PtyOutput { .. }) {
|
||||
continue;
|
||||
}
|
||||
let dto = DomainEventDto::from(&event);
|
||||
let _ = app.emit(DOMAIN_EVENT, dto);
|
||||
}
|
||||
// The bus dropped some events for this slow receiver; keep going.
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
// The bus was dropped (app shutting down); stop the relay.
|
||||
Err(RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
103
crates/app-tauri/src/lib.rs
Normal file
@ -0,0 +1,103 @@
|
||||
//! # IdeA — `app-tauri` (presentation / driving adapter + composition root)
|
||||
//!
|
||||
//! This crate is the **only** place that knows every other crate. It:
|
||||
//! - builds the concrete adapters and injects them into use cases
|
||||
//! ([`state::AppState`], the composition root),
|
||||
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
|
||||
//! - relays domain events to the frontend ([`events::TauriEventRelay`]),
|
||||
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3.
|
||||
//!
|
||||
//! The wiring lives in the library (testable) and `main.rs` is a thin shim.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod commands;
|
||||
pub mod dto;
|
||||
pub mod events;
|
||||
pub mod pty;
|
||||
pub mod state;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
/// Builds and runs the Tauri application.
|
||||
///
|
||||
/// Sets up the composition root (resolving the app-data directory via the Tauri
|
||||
/// path API), registers commands, spawns the event relay, and starts the main
|
||||
/// window.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the Tauri application fails to build or run (no window/webview), or
|
||||
/// if the app-data directory cannot be resolved.
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
|
||||
// and build the composition root once the app handle exists, so the
|
||||
// stores receive a concrete path without ever touching Tauri.
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to resolve the app data directory");
|
||||
let app_state = AppState::build(app_data_dir);
|
||||
|
||||
// Wire the domain event bus → Tauri events relay.
|
||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||
|
||||
app.manage(app_state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::health,
|
||||
commands::create_project,
|
||||
commands::open_project,
|
||||
commands::close_project,
|
||||
commands::list_projects,
|
||||
commands::open_terminal,
|
||||
commands::write_terminal,
|
||||
commands::resize_terminal,
|
||||
commands::close_terminal,
|
||||
commands::load_layout,
|
||||
commands::mutate_layout,
|
||||
commands::list_layouts,
|
||||
commands::create_layout,
|
||||
commands::rename_layout,
|
||||
commands::delete_layout,
|
||||
commands::set_active_layout,
|
||||
commands::first_run_state,
|
||||
commands::reference_profiles,
|
||||
commands::detect_profiles,
|
||||
commands::list_profiles,
|
||||
commands::save_profile,
|
||||
commands::delete_profile,
|
||||
commands::configure_profiles,
|
||||
commands::create_agent,
|
||||
commands::list_agents,
|
||||
commands::read_agent_context,
|
||||
commands::update_agent_context,
|
||||
commands::delete_agent,
|
||||
commands::launch_agent,
|
||||
commands::create_template,
|
||||
commands::update_template,
|
||||
commands::list_templates,
|
||||
commands::delete_template,
|
||||
commands::create_agent_from_template,
|
||||
commands::detect_agent_drift,
|
||||
commands::sync_agent_with_template,
|
||||
commands::git_status,
|
||||
commands::git_stage,
|
||||
commands::git_unstage,
|
||||
commands::git_commit,
|
||||
commands::git_branches,
|
||||
commands::git_checkout,
|
||||
commands::git_log,
|
||||
commands::git_init,
|
||||
commands::git_graph,
|
||||
commands::move_tab_to_new_window,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running IdeA Tauri application");
|
||||
}
|
||||
16
crates/app-tauri/src/main.rs
Normal file
@ -0,0 +1,16 @@
|
||||
// Prevents an extra console window on Windows in release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// WebKitGTK's DMABUF renderer causes a blank/white window on many Linux
|
||||
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
|
||||
// the webview initializes, unless the user has explicitly set the variable.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
}
|
||||
|
||||
app_tauri_lib::run();
|
||||
}
|
||||
84
crates/app-tauri/src/pty.rs
Normal file
@ -0,0 +1,84 @@
|
||||
//! Generic **PTY ↔ Tauri Channel** bridge infrastructure.
|
||||
//!
|
||||
//! ARCHITECTURE §2 decides that high-frequency PTY byte streams travel over
|
||||
//! per-session [`tauri::ipc::Channel`]s rather than global events, for
|
||||
//! throughput and isolation. This module provides the transport-side plumbing
|
||||
//! that L3 will plug a real `PtyPort` into; here there is **no real PTY** yet —
|
||||
//! only the registry + the abstraction that routes byte chunks to the right
|
||||
//! frontend channel.
|
||||
//!
|
||||
//! Design:
|
||||
//! - The frontend opens a terminal and passes a [`tauri::ipc::Channel`] for that
|
||||
//! session. The backend registers it in [`PtyBridge`] keyed by `SessionId`.
|
||||
//! - Whatever produces output (the PTY adapter in L3) calls
|
||||
//! [`PtyBridge::send_output`], which forwards the bytes on the matching
|
||||
//! channel. Bytes are sent as-is; the frontend xterm wrapper consumes them.
|
||||
//! - [`PtyBridge::unregister`] tears the channel down on terminal close.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
|
||||
/// A chunk of PTY output bytes destined for a specific session's channel.
|
||||
///
|
||||
/// Sent as a raw byte vector; serde encodes it for the IPC channel. Kept as a
|
||||
/// distinct type so the wire shape can evolve (e.g. add a sequence number for
|
||||
/// backpressure handling) without touching call sites.
|
||||
pub type PtyChunk = Vec<u8>;
|
||||
|
||||
/// Registry mapping live terminal sessions to their output [`Channel`].
|
||||
///
|
||||
/// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`].
|
||||
#[derive(Default)]
|
||||
pub struct PtyBridge {
|
||||
channels: Mutex<HashMap<SessionId, Channel<PtyChunk>>>,
|
||||
}
|
||||
|
||||
impl PtyBridge {
|
||||
/// Creates an empty bridge.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the output channel for a session (called when a terminal is
|
||||
/// opened, from a `#[tauri::command]` that receives the `Channel` argument).
|
||||
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) {
|
||||
if let Ok(mut map) = self.channels.lock() {
|
||||
map.insert(session, channel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a session's channel (terminal closed).
|
||||
pub fn unregister(&self, session: &SessionId) {
|
||||
if let Ok(mut map) = self.channels.lock() {
|
||||
map.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards a chunk of output bytes to a session's channel.
|
||||
///
|
||||
/// Returns `true` if the chunk was delivered, `false` if no channel is
|
||||
/// registered for the session (e.g. already closed). In L3 the PTY adapter's
|
||||
/// output stream drives this.
|
||||
pub fn send_output(&self, session: &SessionId, chunk: PtyChunk) -> bool {
|
||||
let Ok(map) = self.channels.lock() else {
|
||||
return false;
|
||||
};
|
||||
match map.get(session) {
|
||||
Some(channel) => channel.send(chunk).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of currently-registered sessions (handy for tests/diagnostics).
|
||||
#[must_use]
|
||||
pub fn active_sessions(&self) -> usize {
|
||||
self.channels.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
428
crates/app-tauri/src/state.rs
Normal file
@ -0,0 +1,428 @@
|
||||
//! Managed application state — the product of the **composition root**.
|
||||
//!
|
||||
//! The composition root ([`build_app_state`]) is the *single* place that
|
||||
//! constructs concrete adapters (`new ConcreteAdapter`) and injects them as
|
||||
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
|
||||
//! are then exposed through `tauri::State<AppState>` to the command handlers.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{
|
||||
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent,
|
||||
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListTemplates,
|
||||
LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, ReadAgentContext,
|
||||
ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
|
||||
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, TemplateStore,
|
||||
};
|
||||
|
||||
use infrastructure::{
|
||||
CliAgentRuntime, FsProfileStore, FsProjectStore, FsTemplateStore, Git2Repository,
|
||||
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
|
||||
TokioBroadcastEventBus, UuidGenerator,
|
||||
};
|
||||
|
||||
use crate::pty::PtyBridge;
|
||||
|
||||
/// Everything the IPC layer needs at runtime, managed by Tauri.
|
||||
///
|
||||
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
||||
/// adapters are owned here and never leak past the composition root as concrete
|
||||
/// types — downstream code only sees the `Arc<dyn Port>` held inside the use
|
||||
/// cases.
|
||||
pub struct AppState {
|
||||
/// Trivial health use case validating the end-to-end wiring.
|
||||
pub health: Arc<HealthUseCase>,
|
||||
/// Create a project (init `.ideai/`, register it).
|
||||
pub create_project: Arc<CreateProject>,
|
||||
/// Open a project (load meta + manifest).
|
||||
pub open_project: Arc<OpenProject>,
|
||||
/// Close a project (persist state).
|
||||
pub close_project: Arc<CloseProject>,
|
||||
/// Close a tab.
|
||||
pub close_tab: Arc<CloseTab>,
|
||||
/// List known projects.
|
||||
pub list_projects: Arc<ListProjects>,
|
||||
/// Open a terminal (spawn PTY, register session).
|
||||
pub open_terminal: Arc<OpenTerminal>,
|
||||
/// Write keystrokes to a terminal.
|
||||
pub write_terminal: Arc<WriteToTerminal>,
|
||||
/// Resize a terminal.
|
||||
pub resize_terminal: Arc<ResizeTerminal>,
|
||||
/// Close a terminal (kill PTY).
|
||||
pub close_terminal: Arc<CloseTerminal>,
|
||||
/// Load a project's persisted layout tree.
|
||||
pub load_layout: Arc<LoadLayout>,
|
||||
/// Mutate + persist a project's layout tree.
|
||||
pub mutate_layout: Arc<MutateLayout>,
|
||||
/// List all named layouts for a project (#4).
|
||||
pub list_layouts: Arc<ListLayouts>,
|
||||
/// Create a new named layout (#4).
|
||||
pub create_layout: Arc<CreateLayout>,
|
||||
/// Rename a named layout (#4).
|
||||
pub rename_layout: Arc<RenameLayout>,
|
||||
/// Delete a named layout (#4).
|
||||
pub delete_layout: Arc<DeleteLayout>,
|
||||
/// Set the active named layout (#4).
|
||||
pub set_active_layout: Arc<SetActiveLayout>,
|
||||
/// Detect which candidate profiles' CLIs are installed (first-run).
|
||||
pub detect_profiles: Arc<DetectProfiles>,
|
||||
/// List configured profiles.
|
||||
pub list_profiles: Arc<ListProfiles>,
|
||||
/// Save (upsert) a profile.
|
||||
pub save_profile: Arc<SaveProfile>,
|
||||
/// Delete a profile.
|
||||
pub delete_profile: Arc<DeleteProfile>,
|
||||
/// Persist the batch of chosen profiles (closes the first run).
|
||||
pub configure_profiles: Arc<ConfigureProfiles>,
|
||||
/// Expose the pre-filled reference catalogue.
|
||||
pub reference_profiles: Arc<ReferenceProfiles>,
|
||||
/// Whether the first-run wizard should show + the reference catalogue.
|
||||
pub first_run_state: Arc<FirstRunState>,
|
||||
/// The local PTY adapter, kept port-typed so the presentation layer can
|
||||
/// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport).
|
||||
pub pty_port: Arc<dyn PtyPort>,
|
||||
/// Active-terminal registry shared by the terminal use cases.
|
||||
pub terminal_sessions: Arc<TerminalSessions>,
|
||||
/// The domain event bus (also handed to the event relay).
|
||||
pub event_bus: Arc<TokioBroadcastEventBus>,
|
||||
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
||||
pub pty_bridge: Arc<PtyBridge>,
|
||||
// --- Agents (L6) ---
|
||||
/// Create a project agent from scratch.
|
||||
pub create_agent: Arc<CreateAgentFromScratch>,
|
||||
/// List a project's agents.
|
||||
pub list_agents: Arc<ListAgents>,
|
||||
/// Read an agent's Markdown context.
|
||||
pub read_agent_context: Arc<ReadAgentContext>,
|
||||
/// Overwrite an agent's Markdown context.
|
||||
pub update_agent_context: Arc<UpdateAgentContext>,
|
||||
/// Delete an agent from the manifest.
|
||||
pub delete_agent: Arc<DeleteAgent>,
|
||||
/// Launch an agent (spawn PTY, apply injection strategy).
|
||||
pub launch_agent: Arc<LaunchAgent>,
|
||||
/// Project registry — used by agent commands to resolve a `Project` from an id.
|
||||
pub project_store: Arc<dyn ProjectStore>,
|
||||
// --- Windows (L10) ---
|
||||
/// Detach a tab into a new OS window (persists the workspace topology).
|
||||
pub move_tab: Arc<MoveTabToNewWindow>,
|
||||
// --- Templates & sync (L7) ---
|
||||
/// Create a template in the global store.
|
||||
pub create_template: Arc<CreateTemplate>,
|
||||
/// Update a template's content (bumps version).
|
||||
pub update_template: Arc<UpdateTemplate>,
|
||||
/// List all templates in the global store.
|
||||
pub list_templates: Arc<ListTemplates>,
|
||||
/// Delete a template from the global store.
|
||||
pub delete_template: Arc<DeleteTemplate>,
|
||||
/// Create an agent from a template.
|
||||
pub create_agent_from_template: Arc<CreateAgentFromTemplate>,
|
||||
/// Detect which synchronized agents are behind their template.
|
||||
pub detect_agent_drift: Arc<DetectAgentDrift>,
|
||||
/// Apply a template update to a synchronized agent.
|
||||
pub sync_agent_with_template: Arc<SyncAgentWithTemplate>,
|
||||
// --- Git (L8) ---
|
||||
/// Report the working-tree status of a repository.
|
||||
pub git_status: Arc<GitStatus>,
|
||||
/// Stage a path.
|
||||
pub git_stage: Arc<GitStage>,
|
||||
/// Unstage a path.
|
||||
pub git_unstage: Arc<GitUnstage>,
|
||||
/// Create a commit.
|
||||
pub git_commit: Arc<GitCommit>,
|
||||
/// List branches.
|
||||
pub git_branches: Arc<GitBranches>,
|
||||
/// Check out a branch.
|
||||
pub git_checkout: Arc<GitCheckout>,
|
||||
/// Return the recent commit log.
|
||||
pub git_log: Arc<GitLog>,
|
||||
/// Initialise a repository.
|
||||
pub git_init: Arc<GitInit>,
|
||||
/// Return the commit graph for all local branches.
|
||||
pub git_graph: Arc<GitGraph>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// **Composition root.** Builds all adapters and use cases.
|
||||
///
|
||||
/// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE
|
||||
/// §9.2), resolved by the caller via the Tauri path API and injected here so
|
||||
/// the stores never touch Tauri themselves (Dependency Inversion).
|
||||
///
|
||||
/// This is the only function that constructs concrete adapters; every other
|
||||
/// layer depends on ports. Adapters added in later lots (PTY, git, remote)
|
||||
/// are wired in here.
|
||||
#[must_use]
|
||||
pub fn build(app_data_dir: PathBuf) -> Self {
|
||||
// --- Concrete adapters (driven adapters) ---
|
||||
let event_bus = Arc::new(TokioBroadcastEventBus::new());
|
||||
let clock = Arc::new(SystemClock::new());
|
||||
let ids = Arc::new(UuidGenerator::new());
|
||||
let fs = Arc::new(LocalFileSystem::new());
|
||||
let store = Arc::new(FsProjectStore::new(
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
|
||||
// Port-typed handles for injection.
|
||||
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
||||
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||
|
||||
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
||||
let health = Arc::new(HealthUseCase::new(
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
|
||||
let create_project = Arc::new(CreateProject::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let open_project = Arc::new(OpenProject::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
));
|
||||
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
|
||||
let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port)));
|
||||
let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port)));
|
||||
|
||||
// --- PTY adapter + terminal use cases (L3) ---
|
||||
let pty = Arc::new(PortablePtyAdapter::new());
|
||||
let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
|
||||
let terminal_sessions = Arc::new(TerminalSessions::new());
|
||||
|
||||
let open_terminal = Arc::new(OpenTerminal::new(
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let write_terminal = Arc::new(WriteToTerminal::new(
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
));
|
||||
let resize_terminal = Arc::new(ResizeTerminal::new(
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
));
|
||||
let close_terminal = Arc::new(CloseTerminal::new(
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
));
|
||||
|
||||
// --- Layout use cases (L4 + #4) ---
|
||||
let load_layout = Arc::new(LoadLayout::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
));
|
||||
let mutate_layout = Arc::new(MutateLayout::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let list_layouts = Arc::new(ListLayouts::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
));
|
||||
let create_layout = Arc::new(CreateLayout::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let rename_layout = Arc::new(RenameLayout::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let delete_layout = Arc::new(DeleteLayout::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let set_active_layout = Arc::new(SetActiveLayout::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
|
||||
// --- Profiles & AI runtime (L5) ---
|
||||
// One generic, profile-driven runtime adapter (Open/Closed): it holds the
|
||||
// process spawner used for detection. The profile store persists
|
||||
// `profiles.json` in the same machine-local app-data dir as the project
|
||||
// registry.
|
||||
let spawner = Arc::new(LocalProcessSpawner::new());
|
||||
let spawner_port = Arc::clone(&spawner) as Arc<dyn ProcessSpawner>;
|
||||
let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port)));
|
||||
let runtime_port = Arc::clone(&runtime) as Arc<dyn AgentRuntime>;
|
||||
|
||||
let profile_store = Arc::new(FsProfileStore::new(
|
||||
Arc::clone(&fs_port),
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
let profile_store_port = Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
|
||||
|
||||
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
|
||||
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
|
||||
let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port)));
|
||||
let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port)));
|
||||
let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
|
||||
let reference_profiles = Arc::new(ReferenceProfiles::new());
|
||||
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
|
||||
|
||||
let pty_bridge = Arc::new(PtyBridge::new());
|
||||
|
||||
// --- Agent context store + use cases (L6) ---
|
||||
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
|
||||
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
|
||||
|
||||
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let list_agents = Arc::new(ListAgents::new(Arc::clone(&contexts_port)));
|
||||
let read_agent_context = Arc::new(ReadAgentContext::new(Arc::clone(&contexts_port)));
|
||||
let update_agent_context = Arc::new(UpdateAgentContext::new(Arc::clone(&contexts_port)));
|
||||
let delete_agent = Arc::new(DeleteAgent::new(
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
|
||||
// use cases — indispensable for the PtyBridge to work correctly.
|
||||
let launch_agent = Arc::new(LaunchAgent::new(
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&runtime_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
|
||||
let project_store = Arc::clone(&store_port);
|
||||
|
||||
// --- Template store + use cases (L7) ---
|
||||
let template_store = Arc::new(FsTemplateStore::new(
|
||||
Arc::clone(&fs_port),
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
let template_store_port = Arc::clone(&template_store) as Arc<dyn TemplateStore>;
|
||||
|
||||
let create_template = Arc::new(CreateTemplate::new(
|
||||
Arc::clone(&template_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
let update_template = Arc::new(UpdateTemplate::new(
|
||||
Arc::clone(&template_store_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port)));
|
||||
let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port)));
|
||||
let create_agent_from_template = Arc::new(CreateAgentFromTemplate::new(
|
||||
Arc::clone(&template_store_port),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let detect_agent_drift = Arc::new(DetectAgentDrift::new(
|
||||
Arc::clone(&template_store_port),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let sync_agent_with_template = Arc::new(SyncAgentWithTemplate::new(
|
||||
Arc::clone(&template_store_port),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
|
||||
// --- Git adapter + use cases (L8) ---
|
||||
let git = Arc::new(Git2Repository::new());
|
||||
let git_port = Arc::clone(&git) as Arc<dyn GitPort>;
|
||||
|
||||
let git_status = Arc::new(GitStatus::new(Arc::clone(&git_port)));
|
||||
let git_stage = Arc::new(GitStage::new(Arc::clone(&git_port)));
|
||||
let git_unstage = Arc::new(GitUnstage::new(Arc::clone(&git_port)));
|
||||
let git_commit = Arc::new(GitCommit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
|
||||
let git_branches = Arc::new(GitBranches::new(Arc::clone(&git_port)));
|
||||
let git_checkout = Arc::new(GitCheckout::new(
|
||||
Arc::clone(&git_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let git_log = Arc::new(GitLog::new(Arc::clone(&git_port)));
|
||||
let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
|
||||
let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));
|
||||
|
||||
// --- Windows (L10) ---
|
||||
let move_tab = Arc::new(MoveTabToNewWindow::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
|
||||
Self {
|
||||
health,
|
||||
create_project,
|
||||
open_project,
|
||||
close_project,
|
||||
close_tab,
|
||||
list_projects,
|
||||
open_terminal,
|
||||
write_terminal,
|
||||
resize_terminal,
|
||||
close_terminal,
|
||||
load_layout,
|
||||
mutate_layout,
|
||||
list_layouts,
|
||||
create_layout,
|
||||
rename_layout,
|
||||
delete_layout,
|
||||
set_active_layout,
|
||||
detect_profiles,
|
||||
list_profiles,
|
||||
save_profile,
|
||||
delete_profile,
|
||||
configure_profiles,
|
||||
reference_profiles,
|
||||
first_run_state,
|
||||
pty_port,
|
||||
terminal_sessions,
|
||||
event_bus,
|
||||
pty_bridge,
|
||||
create_agent,
|
||||
list_agents,
|
||||
read_agent_context,
|
||||
update_agent_context,
|
||||
delete_agent,
|
||||
launch_agent,
|
||||
project_store,
|
||||
create_template,
|
||||
update_template,
|
||||
list_templates,
|
||||
delete_template,
|
||||
create_agent_from_template,
|
||||
detect_agent_drift,
|
||||
sync_agent_with_template,
|
||||
git_status,
|
||||
git_stage,
|
||||
git_unstage,
|
||||
git_commit,
|
||||
git_branches,
|
||||
git_checkout,
|
||||
git_log,
|
||||
git_init,
|
||||
git_graph,
|
||||
move_tab,
|
||||
}
|
||||
}
|
||||
}
|
||||
29
crates/app-tauri/tauri.conf.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "IdeA",
|
||||
"version": "0.1.0",
|
||||
"identifier": "app.idea.ide",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm --prefix ../frontend run dev"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "IdeA",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage", "nsis"],
|
||||
"icon": ["icons/icon.png"]
|
||||
}
|
||||
}
|
||||
326
crates/app-tauri/tests/dto.rs
Normal file
@ -0,0 +1,326 @@
|
||||
//! L1 tests for the IPC DTO (de)serialisation contract: camelCase on the wire,
|
||||
//! stable `ErrorDto.code`, and the `DomainEvent -> DomainEventDto` mapping with
|
||||
//! its tagged, camelCase JSON shape.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
|
||||
LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto,
|
||||
WriteTerminalRequestDto,
|
||||
};
|
||||
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
||||
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
||||
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
||||
|
||||
use application::{AppError, HealthInput};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, SessionId};
|
||||
use domain::ProjectId;
|
||||
use domain::TemplateVersion;
|
||||
use domain::TemplateId;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn health_response_serialises_camel_case() {
|
||||
let dto = HealthResponseDto {
|
||||
version: "1.2.3".into(),
|
||||
alive: true,
|
||||
time_millis: 42,
|
||||
correlation_id: "abc".into(),
|
||||
note: Some("hi".into()),
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"version": "1.2.3",
|
||||
"alive": true,
|
||||
"timeMillis": 42,
|
||||
"correlationId": "abc",
|
||||
"note": "hi",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_request_deserialises_camel_case_and_defaults() {
|
||||
let dto: HealthRequestDto = serde_json::from_value(json!({ "note": "x" })).unwrap();
|
||||
assert_eq!(HealthInput::from(dto).note.as_deref(), Some("x"));
|
||||
|
||||
// Missing note defaults to None.
|
||||
let empty: HealthRequestDto = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(HealthInput::from(empty).note, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_dto_carries_stable_code_and_message() {
|
||||
let dto = ErrorDto::from(AppError::NotFound("project".into()));
|
||||
assert_eq!(dto.code, "NOT_FOUND");
|
||||
assert!(dto.message.contains("project"));
|
||||
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["code"], "NOT_FOUND");
|
||||
assert!(v.get("message").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_relay_name_is_frozen() {
|
||||
assert_eq!(DOMAIN_EVENT, "domain://event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_dto_is_tagged_and_camel_case() {
|
||||
let pid = ProjectId::from_uuid(Uuid::nil());
|
||||
let dto = DomainEventDto::from(&DomainEvent::ProjectCreated { project_id: pid });
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({ "type": "projectCreated", "projectId": pid.to_string() })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_dto_maps_agent_launched() {
|
||||
let aid = AgentId::from_uuid(Uuid::nil());
|
||||
let sid = SessionId::from_uuid(Uuid::nil());
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLaunched {
|
||||
agent_id: aid,
|
||||
session_id: sid,
|
||||
});
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["type"], "agentLaunched");
|
||||
assert_eq!(v["agentId"], aid.to_string());
|
||||
assert_eq!(v["sessionId"], sid.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_dto_maps_template_updated_version() {
|
||||
let tid = TemplateId::from_uuid(Uuid::nil());
|
||||
let dto = DomainEventDto::from(&DomainEvent::TemplateUpdated {
|
||||
template_id: tid,
|
||||
version: TemplateVersion::INITIAL,
|
||||
});
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["type"], "templateUpdated");
|
||||
assert_eq!(v["templateId"], tid.to_string());
|
||||
assert!(v["version"].is_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_dto_maps_pty_output_bytes() {
|
||||
let sid = SessionId::from_uuid(Uuid::nil());
|
||||
let dto = DomainEventDto::from(&DomainEvent::PtyOutput {
|
||||
session_id: sid,
|
||||
bytes: vec![1, 2, 3],
|
||||
});
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["type"], "ptyOutput");
|
||||
assert_eq!(v["bytes"], json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal DTOs (L3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn open_terminal_request_deserialises_camel_case_with_defaults() {
|
||||
// Minimal payload: command/args omitted → None / empty.
|
||||
let dto: OpenTerminalRequestDto =
|
||||
serde_json::from_value(json!({ "cwd": "/p", "rows": 24, "cols": 80 })).unwrap();
|
||||
let input = OpenTerminalInput::from(dto);
|
||||
assert_eq!(input.cwd, "/p");
|
||||
assert_eq!(input.rows, 24);
|
||||
assert_eq!(input.cols, 80);
|
||||
assert_eq!(input.command, None);
|
||||
assert!(input.args.is_empty());
|
||||
assert_eq!(input.node_id, None);
|
||||
|
||||
// Full payload with explicit command + args.
|
||||
let full: OpenTerminalRequestDto = serde_json::from_value(json!({
|
||||
"cwd": "/q", "rows": 10, "cols": 20,
|
||||
"command": "/bin/zsh", "args": ["-l"],
|
||||
}))
|
||||
.unwrap();
|
||||
let input = OpenTerminalInput::from(full);
|
||||
assert_eq!(input.command.as_deref(), Some("/bin/zsh"));
|
||||
assert_eq!(input.args, vec!["-l".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_terminal_request_deserialises_session_id_and_data() {
|
||||
let sid = SessionId::from_uuid(Uuid::nil());
|
||||
let dto: WriteTerminalRequestDto = serde_json::from_value(json!({
|
||||
"sessionId": sid.to_string(),
|
||||
"data": [104, 105],
|
||||
}))
|
||||
.unwrap();
|
||||
let input = dto.into_input().expect("valid session id");
|
||||
assert_eq!(input.session_id, sid);
|
||||
assert_eq!(input.data, vec![104u8, 105]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_terminal_request_rejects_bad_session_id() {
|
||||
let dto: WriteTerminalRequestDto =
|
||||
serde_json::from_value(json!({ "sessionId": "not-a-uuid", "data": [] })).unwrap();
|
||||
let err = dto.into_input().expect_err("malformed id rejected");
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_terminal_request_deserialises_camel_case() {
|
||||
let sid = SessionId::from_uuid(Uuid::nil());
|
||||
let dto: ResizeTerminalRequestDto = serde_json::from_value(json!({
|
||||
"sessionId": sid.to_string(), "rows": 40, "cols": 120,
|
||||
}))
|
||||
.unwrap();
|
||||
let input = dto.into_input().expect("valid");
|
||||
assert_eq!(input.session_id, sid);
|
||||
assert_eq!(input.rows, 40);
|
||||
assert_eq!(input.cols, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_closed_dto_serialises_code_camel_case() {
|
||||
let dto = TerminalClosedDto::from(CloseTerminalOutput { code: Some(0) });
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v, json!({ "code": 0 }));
|
||||
|
||||
// Signalled (None) round-trips as null.
|
||||
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None });
|
||||
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_id_accepts_uuid_and_rejects_garbage() {
|
||||
let sid = SessionId::from_uuid(Uuid::nil());
|
||||
assert_eq!(parse_session_id(&sid.to_string()).unwrap(), sid);
|
||||
assert_eq!(parse_session_id("nope").unwrap_err().code, "INVALID");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout (L4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_dto_serialises_camelcase_tagged_tree() {
|
||||
// A single-leaf tree → transparent over LayoutTree, tagged `{type,node}`.
|
||||
let tree = LayoutTree::single(LeafCell {
|
||||
id: nid(1),
|
||||
session: None,
|
||||
agent: None,
|
||||
});
|
||||
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||
layout_id: domain::LayoutId::new_random(),
|
||||
layout: tree,
|
||||
});
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["root"]["type"], "leaf", "enum tagged on `type`");
|
||||
assert_eq!(v["root"]["node"]["id"], nid(1).to_string());
|
||||
// Empty session is skipped (skip_serializing_if).
|
||||
assert!(v["root"]["node"].get("session").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_operation_dto_split_deserialises_camelcase() {
|
||||
let json = json!({
|
||||
"type": "split",
|
||||
"target": nid(1).to_string(),
|
||||
"direction": "row",
|
||||
"newLeaf": nid(2).to_string(),
|
||||
"container": nid(9).to_string(),
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||
match dto.into_operation().unwrap() {
|
||||
LayoutOperation::Split {
|
||||
target,
|
||||
direction,
|
||||
new_leaf,
|
||||
container,
|
||||
} => {
|
||||
assert_eq!(target, nid(1));
|
||||
assert_eq!(direction, Direction::Row);
|
||||
assert_eq!(new_leaf, nid(2));
|
||||
assert_eq!(container, nid(9));
|
||||
}
|
||||
other => panic!("expected Split, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_operation_dto_set_session_accepts_null_session() {
|
||||
// `session: null` → detach (None).
|
||||
let json = json!({
|
||||
"type": "setSession",
|
||||
"target": nid(1).to_string(),
|
||||
"session": null,
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||
match dto.into_operation().unwrap() {
|
||||
LayoutOperation::SetSession { target, session } => {
|
||||
assert_eq!(target, nid(1));
|
||||
assert!(session.is_none());
|
||||
}
|
||||
other => panic!("expected SetSession, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_operation_dto_resize_carries_weights() {
|
||||
let json = json!({
|
||||
"type": "resize",
|
||||
"container": nid(9).to_string(),
|
||||
"weights": [3.0, 1.0],
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||
match dto.into_operation().unwrap() {
|
||||
LayoutOperation::Resize { container, weights } => {
|
||||
assert_eq!(container, nid(9));
|
||||
assert_eq!(weights, vec![3.0, 1.0]);
|
||||
}
|
||||
other => panic!("expected Resize, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_operation_dto_rejects_malformed_uuid_as_invalid() {
|
||||
let json = json!({
|
||||
"type": "merge",
|
||||
"container": "not-a-uuid",
|
||||
"keepIndex": 0,
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||
let err = dto.into_operation().expect_err("malformed uuid rejected");
|
||||
assert_eq!(err.code, "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_node_id_accepts_uuid_and_rejects_garbage() {
|
||||
assert_eq!(parse_node_id(&nid(7).to_string()).unwrap(), nid(7));
|
||||
assert_eq!(parse_node_id("garbage").unwrap_err().code, "INVALID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_dto_round_trips_a_split_tree_shape() {
|
||||
// Build a split tree, serialise via the DTO, and confirm the tagged shape
|
||||
// re-parses into an equivalent LayoutTree.
|
||||
let tree = LayoutTree::single(LeafCell {
|
||||
id: nid(1),
|
||||
session: None,
|
||||
agent: None,
|
||||
})
|
||||
.split(nid(1), Direction::Column, LeafCell { id: nid(2), session: None, agent: None }, nid(9))
|
||||
.unwrap();
|
||||
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||
layout_id: domain::LayoutId::new_random(),
|
||||
layout: tree.clone(),
|
||||
});
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let back: LayoutTree = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, tree, "DTO serialisation round-trips the tree");
|
||||
assert!(matches!(back.root, LayoutNode::Split(_)));
|
||||
}
|
||||
184
crates/app-tauri/tests/dto_agents.rs
Normal file
@ -0,0 +1,184 @@
|
||||
//! L6 tests for the agent DTO (de)serialisation contract: camelCase on the
|
||||
//! wire, embedded [`Agent`] shape preserved, `parse_agent_id` error behaviour,
|
||||
//! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`].
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_agent_id, AgentDto, AgentListDto, CreateAgentRequestDto, LaunchAgentRequestDto,
|
||||
TerminalSessionDto, UpdateAgentContextRequestDto,
|
||||
};
|
||||
use application::{CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput};
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
|
||||
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::{Agent, AgentOrigin, ProjectPath};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper: build a minimal validated [`Agent`].
|
||||
fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
|
||||
Agent::new(
|
||||
AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
|
||||
"My Agent",
|
||||
"agents/my-agent.md",
|
||||
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.expect("valid agent")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentDto / AgentListDto serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_dto_serialises_camelcase() {
|
||||
let agent = make_agent(1, 2);
|
||||
let dto = AgentDto(agent.clone());
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["id"], agent.id.to_string());
|
||||
assert_eq!(v["name"], "My Agent");
|
||||
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key");
|
||||
assert_eq!(v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string());
|
||||
assert_eq!(v["synchronized"], false);
|
||||
// origin: tagged `{ "type": "scratch" }`
|
||||
assert_eq!(v["origin"]["type"], "scratch");
|
||||
// no snake_case leak
|
||||
assert!(v.get("context_path").is_none());
|
||||
assert!(v.get("profile_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_list_dto_is_transparent_array() {
|
||||
let out = ListAgentsOutput {
|
||||
agents: vec![make_agent(1, 2), make_agent(3, 4)],
|
||||
};
|
||||
let dto = AgentListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["name"], "My Agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_agent_output_maps_to_agent_dto() {
|
||||
let agent = make_agent(5, 6);
|
||||
let out = CreateAgentOutput { agent: agent.clone() };
|
||||
let dto = AgentDto::from(out);
|
||||
assert_eq!(dto.0.id, agent.id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request DTO deserialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_agent_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(10).to_string();
|
||||
let profile_id = Uuid::from_u128(20).to_string();
|
||||
let raw = json!({
|
||||
"projectId": project_id,
|
||||
"name": "Backend Dev",
|
||||
"profileId": profile_id,
|
||||
"initialContent": "# Hello"
|
||||
});
|
||||
let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.name, "Backend Dev");
|
||||
assert_eq!(dto.profile_id, profile_id);
|
||||
assert_eq!(dto.initial_content.as_deref(), Some("# Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_agent_request_initial_content_defaults_to_none() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::nil().to_string(),
|
||||
"name": "Agent",
|
||||
"profileId": Uuid::nil().to_string()
|
||||
});
|
||||
let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert!(dto.initial_content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_agent_context_request_deserialises_camelcase() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::nil().to_string(),
|
||||
"agentId": Uuid::nil().to_string(),
|
||||
"content": "# Updated"
|
||||
});
|
||||
let dto: UpdateAgentContextRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.content, "# Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(1).to_string();
|
||||
let agent_id = Uuid::from_u128(2).to_string();
|
||||
let raw = json!({
|
||||
"projectId": project_id,
|
||||
"agentId": agent_id,
|
||||
"rows": 24,
|
||||
"cols": 80
|
||||
});
|
||||
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.rows, 24);
|
||||
assert_eq!(dto.cols, 80);
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.agent_id, agent_id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parse_agent_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_agent_id_accepts_uuid() {
|
||||
let id = Uuid::from_u128(99);
|
||||
assert_eq!(
|
||||
parse_agent_id(&id.to_string()).unwrap(),
|
||||
AgentId::from_uuid(id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_agent_id_rejects_garbage() {
|
||||
let err = parse_agent_id("not-a-uuid").expect_err("garbage rejected");
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From<LaunchAgentOutput> for TerminalSessionDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn launch_agent_output_maps_to_terminal_session_dto() {
|
||||
let session_id = SessionId::from_uuid(Uuid::from_u128(7));
|
||||
let node_id = NodeId::from_uuid(Uuid::from_u128(8));
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(9));
|
||||
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
|
||||
let size = PtySize::new(24, 80).unwrap();
|
||||
|
||||
let mut session = TerminalSession::starting(
|
||||
session_id,
|
||||
node_id,
|
||||
cwd,
|
||||
SessionKind::Agent { agent_id },
|
||||
size,
|
||||
);
|
||||
session.status = SessionStatus::Running;
|
||||
|
||||
let out = LaunchAgentOutput { session };
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
|
||||
assert_eq!(dto.session_id, session_id.to_string());
|
||||
assert_eq!(dto.cwd, "/tmp/project");
|
||||
assert_eq!(dto.rows, 24);
|
||||
assert_eq!(dto.cols, 80);
|
||||
|
||||
// Also verify camelCase serialisation.
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["sessionId"], session_id.to_string());
|
||||
assert_eq!(v["cwd"], "/tmp/project");
|
||||
assert!(v.get("session_id").is_none(), "no snake_case leak");
|
||||
}
|
||||
262
crates/app-tauri/tests/dto_git.rs
Normal file
@ -0,0 +1,262 @@
|
||||
//! L8 tests for the Git DTO (de)serialisation contract: camelCase on the
|
||||
//! wire, transparent list shapes, `From<…Output>` conversions, and request
|
||||
//! DTO deserialisation.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
|
||||
};
|
||||
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput};
|
||||
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitFileStatusDto serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn git_file_status_dto_serialises_camelcase() {
|
||||
let dto = GitFileStatusDto {
|
||||
path: "src/main.rs".to_owned(),
|
||||
staged: true,
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["path"], "src/main.rs");
|
||||
assert_eq!(v["staged"], true);
|
||||
// no snake_case leak — both fields are single words so no renaming needed,
|
||||
// but verify no extra/unexpected keys.
|
||||
assert!(v.get("path").is_some());
|
||||
assert!(v.get("staged").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_file_status_from_domain_type() {
|
||||
let domain = GitFileStatus {
|
||||
path: "README.md".to_owned(),
|
||||
staged: false,
|
||||
};
|
||||
let dto = GitFileStatusDto::from(domain);
|
||||
assert_eq!(dto.path, "README.md");
|
||||
assert!(!dto.staged);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitStatusListDto — transparent array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn git_status_list_dto_is_transparent_array() {
|
||||
let out = GitStatusOutput {
|
||||
entries: vec![
|
||||
GitFileStatus {
|
||||
path: "a.rs".to_owned(),
|
||||
staged: true,
|
||||
},
|
||||
GitFileStatus {
|
||||
path: "b.rs".to_owned(),
|
||||
staged: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
let dto = GitStatusListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["path"], "a.rs");
|
||||
assert_eq!(arr[0]["staged"], true);
|
||||
assert_eq!(arr[1]["path"], "b.rs");
|
||||
assert_eq!(arr[1]["staged"], false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitCommitDto serialisation & From impls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn git_commit_dto_serialises_camelcase() {
|
||||
let dto = GitCommitDto {
|
||||
hash: "abc123".to_owned(),
|
||||
summary: "feat: add git support".to_owned(),
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["hash"], "abc123");
|
||||
assert_eq!(v["summary"], "feat: add git support");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_dto_from_commit_info() {
|
||||
let info = GitCommitInfo {
|
||||
hash: "deadbeef".to_owned(),
|
||||
summary: "fix: something".to_owned(),
|
||||
};
|
||||
let dto = GitCommitDto::from(info);
|
||||
assert_eq!(dto.hash, "deadbeef");
|
||||
assert_eq!(dto.summary, "fix: something");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_dto_from_commit_output() {
|
||||
let out = GitCommitOutput {
|
||||
commit: GitCommitInfo {
|
||||
hash: "cafebabe".to_owned(),
|
||||
summary: "chore: cleanup".to_owned(),
|
||||
},
|
||||
};
|
||||
let dto = GitCommitDto::from(out);
|
||||
assert_eq!(dto.hash, "cafebabe");
|
||||
assert_eq!(dto.summary, "chore: cleanup");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitCommitListDto — transparent array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn git_commit_list_dto_is_transparent_array() {
|
||||
let out = GitLogOutput {
|
||||
commits: vec![
|
||||
GitCommitInfo {
|
||||
hash: "aaa".to_owned(),
|
||||
summary: "first".to_owned(),
|
||||
},
|
||||
GitCommitInfo {
|
||||
hash: "bbb".to_owned(),
|
||||
summary: "second".to_owned(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let dto = GitCommitListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["hash"], "aaa");
|
||||
assert_eq!(arr[0]["summary"], "first");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitBranchesDto serialisation (incl. current: null)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn git_branches_dto_serialises_with_current() {
|
||||
let out = GitBranchesOutput {
|
||||
branches: vec!["main".to_owned(), "feature/x".to_owned()],
|
||||
current: Some("main".to_owned()),
|
||||
};
|
||||
let dto = GitBranchesDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["branches"][0], "main");
|
||||
assert_eq!(v["branches"][1], "feature/x");
|
||||
assert_eq!(v["current"], "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branches_dto_serialises_null_current() {
|
||||
let out = GitBranchesOutput {
|
||||
branches: vec![],
|
||||
current: None,
|
||||
};
|
||||
let dto = GitBranchesDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert!(v["current"].is_null(), "current must be null when None");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GraphCommitDto serialisation & From impls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn graph_commit_dto_serialises_camelcase() {
|
||||
let commit = GraphCommit {
|
||||
hash: "abc123".to_owned(),
|
||||
summary: "feat: graph".to_owned(),
|
||||
parents: vec!["deadbeef".to_owned()],
|
||||
refs: vec!["main".to_owned(), "tag: v1.0".to_owned()],
|
||||
author: "Alice".to_owned(),
|
||||
timestamp: 1_700_000_000,
|
||||
};
|
||||
let dto = GraphCommitDto::from(commit);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["hash"], "abc123");
|
||||
assert_eq!(v["summary"], "feat: graph");
|
||||
assert_eq!(v["parents"][0], "deadbeef");
|
||||
assert_eq!(v["refs"][0], "main");
|
||||
assert_eq!(v["refs"][1], "tag: v1.0");
|
||||
assert_eq!(v["author"], "Alice");
|
||||
assert_eq!(v["timestamp"], 1_700_000_000_i64);
|
||||
// No snake_case leaks for multi-word fields — none here but verify shape.
|
||||
assert!(v.get("hash").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_commit_list_dto_is_transparent_array() {
|
||||
let out = GitGraphOutput {
|
||||
commits: vec![
|
||||
GraphCommit {
|
||||
hash: "aaa".to_owned(),
|
||||
summary: "first".to_owned(),
|
||||
parents: vec![],
|
||||
refs: vec!["main".to_owned()],
|
||||
author: "Bob".to_owned(),
|
||||
timestamp: 1000,
|
||||
},
|
||||
GraphCommit {
|
||||
hash: "bbb".to_owned(),
|
||||
summary: "second".to_owned(),
|
||||
parents: vec!["aaa".to_owned()],
|
||||
refs: vec![],
|
||||
author: "Bob".to_owned(),
|
||||
timestamp: 999,
|
||||
},
|
||||
],
|
||||
};
|
||||
let dto = GraphCommitListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["hash"], "aaa");
|
||||
assert_eq!(arr[0]["refs"][0], "main");
|
||||
assert_eq!(arr[1]["hash"], "bbb");
|
||||
assert!(arr[1]["parents"][0] == "aaa");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request DTO deserialisation (camelCase)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn git_stage_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(1).to_string();
|
||||
let raw = json!({
|
||||
"projectId": project_id,
|
||||
"path": "src/lib.rs"
|
||||
});
|
||||
let dto: GitStageRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.path, "src/lib.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(2).to_string();
|
||||
let raw = json!({
|
||||
"projectId": project_id,
|
||||
"message": "feat: initial commit"
|
||||
});
|
||||
let dto: GitCommitRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.message, "feat: initial commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_checkout_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(3).to_string();
|
||||
let raw = json!({
|
||||
"projectId": project_id,
|
||||
"branch": "feature/awesome"
|
||||
});
|
||||
let dto: GitCheckoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.branch, "feature/awesome");
|
||||
}
|
||||
247
crates/app-tauri/tests/dto_layouts.rs
Normal file
@ -0,0 +1,247 @@
|
||||
//! Tests for the layout-management (#4) and per-cell-agent (#3) DTOs:
|
||||
//! camelCase wire shape, `parse_layout_id` error behaviour, `ListLayoutsDto` /
|
||||
//! `CreateLayoutResultDto` / `DeleteLayoutResultDto` mappings, and
|
||||
//! `setCellAgent` operation deserialisation.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
|
||||
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto,
|
||||
SetActiveLayoutRequestDto,
|
||||
};
|
||||
use application::{
|
||||
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
|
||||
};
|
||||
use domain::ids::{AgentId, LayoutId, NodeId};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn lid(n: u128) -> LayoutId {
|
||||
LayoutId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LayoutInfoDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn layout_info_dto_serialises_camelcase() {
|
||||
let info = LayoutInfo {
|
||||
id: lid(1),
|
||||
name: "Default".to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
};
|
||||
let dto = LayoutInfoDto::from(info);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["id"], lid(1).to_string());
|
||||
assert_eq!(v["name"], "Default");
|
||||
assert_eq!(v["kind"], "terminal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_info_dto_git_graph_kind() {
|
||||
let info = LayoutInfo {
|
||||
id: lid(2),
|
||||
name: "Graph".to_owned(),
|
||||
kind: LayoutKind::GitGraph,
|
||||
};
|
||||
let dto = LayoutInfoDto::from(info);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["kind"], "gitGraph");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListLayoutsDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn list_layouts_dto_from_output() {
|
||||
let out = ListLayoutsOutput {
|
||||
layouts: vec![
|
||||
LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal },
|
||||
LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph },
|
||||
],
|
||||
active_id: lid(1),
|
||||
};
|
||||
let dto = ListLayoutsDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
let layouts = v["layouts"].as_array().unwrap();
|
||||
assert_eq!(layouts.len(), 2);
|
||||
assert_eq!(layouts[0]["name"], "Default");
|
||||
assert_eq!(layouts[0]["kind"], "terminal");
|
||||
assert_eq!(layouts[1]["name"], "Backend");
|
||||
assert_eq!(layouts[1]["kind"], "gitGraph");
|
||||
assert_eq!(v["activeId"], lid(1).to_string(), "camelCase activeId");
|
||||
assert!(v.get("active_id").is_none(), "no snake_case leak");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateLayoutResultDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_layout_result_dto_from_output() {
|
||||
let out = CreateLayoutOutput { layout_id: lid(42) };
|
||||
let dto = CreateLayoutResultDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["layoutId"], lid(42).to_string(), "camelCase layoutId");
|
||||
assert!(v.get("layout_id").is_none(), "no snake_case leak");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteLayoutResultDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn delete_layout_result_dto_from_output() {
|
||||
let out = DeleteLayoutOutput { active_id: lid(1) };
|
||||
let dto = DeleteLayoutResultDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["activeId"], lid(1).to_string(), "camelCase activeId");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request DTO deserialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_layout_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(10).to_string();
|
||||
let raw = json!({ "projectId": project_id, "name": "Backend" });
|
||||
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.name, "Backend");
|
||||
assert!(dto.kind.is_none(), "kind defaults to None when absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_layout_request_with_git_graph_kind() {
|
||||
let project_id = Uuid::from_u128(11).to_string();
|
||||
let raw = json!({ "projectId": project_id, "name": "Graph", "kind": "gitGraph" });
|
||||
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.kind.as_deref(), Some("gitGraph"));
|
||||
let kind = dto.parse_kind().unwrap();
|
||||
assert_eq!(kind, application::LayoutKind::GitGraph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_layout_request_unknown_kind_is_invalid() {
|
||||
let project_id = Uuid::from_u128(12).to_string();
|
||||
let raw = json!({ "projectId": project_id, "name": "X", "kind": "unknown" });
|
||||
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
let err = dto.parse_kind().unwrap_err();
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_layout_request_deserialises_camelcase() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::nil().to_string(),
|
||||
"layoutId": Uuid::nil().to_string(),
|
||||
"name": "Renamed"
|
||||
});
|
||||
let dto: RenameLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.name, "Renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_layout_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(1).to_string();
|
||||
let layout_id = Uuid::from_u128(2).to_string();
|
||||
let raw = json!({ "projectId": project_id, "layoutId": layout_id });
|
||||
let dto: DeleteLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.layout_id, layout_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_active_layout_request_deserialises_camelcase() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::nil().to_string(),
|
||||
"layoutId": Uuid::nil().to_string()
|
||||
});
|
||||
let _dto: SetActiveLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parse_layout_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_layout_id_accepts_uuid() {
|
||||
let id = Uuid::from_u128(5);
|
||||
assert_eq!(
|
||||
parse_layout_id(&id.to_string()).unwrap(),
|
||||
LayoutId::from_uuid(id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_layout_id_rejects_garbage() {
|
||||
let err = parse_layout_id("not-a-uuid").expect_err("garbage rejected");
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCellAgent operation deserialisation (#3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn set_cell_agent_op_deserialises_with_agent() {
|
||||
let target = nid(1);
|
||||
let agent = aid(99);
|
||||
let raw = json!({
|
||||
"type": "setCellAgent",
|
||||
"target": target.to_string(),
|
||||
"agent": agent.to_string()
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetCellAgent { target: t, agent: a } => {
|
||||
assert_eq!(t, target);
|
||||
assert_eq!(a, Some(agent));
|
||||
}
|
||||
_ => panic!("expected SetCellAgent"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cell_agent_op_deserialises_with_null_agent() {
|
||||
let target = nid(1);
|
||||
let raw = json!({
|
||||
"type": "setCellAgent",
|
||||
"target": target.to_string(),
|
||||
"agent": null
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetCellAgent { target: t, agent: None } => {
|
||||
assert_eq!(t, target);
|
||||
}
|
||||
_ => panic!("expected SetCellAgent with None agent"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() {
|
||||
let target = nid(2);
|
||||
// omitting `agent` should default to None
|
||||
let raw = json!({
|
||||
"type": "setCellAgent",
|
||||
"target": target.to_string()
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetCellAgent { agent: None, .. } => {}
|
||||
_ => panic!("expected SetCellAgent with None agent"),
|
||||
}
|
||||
}
|
||||
133
crates/app-tauri/tests/dto_profiles.rs
Normal file
@ -0,0 +1,133 @@
|
||||
//! L5 tests for the profile/first-run DTO (de)serialisation contract: camelCase
|
||||
//! on the wire, embedded [`AgentProfile`] shape preserved, and `parse_profile_id`
|
||||
//! error behaviour.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
|
||||
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
||||
};
|
||||
use application::{
|
||||
ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput,
|
||||
ProfileAvailability, SaveProfileInput,
|
||||
};
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(id)),
|
||||
name,
|
||||
command,
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(format!("{command} --version")),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_list_dto_serialises_camelcase_profiles() {
|
||||
let dto: ProfileListDto = vec![profile(1, "Claude", "claude")].into();
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["command"], "claude");
|
||||
assert_eq!(arr[0]["cwdTemplate"], "{projectRoot}");
|
||||
assert_eq!(arr[0]["contextInjection"]["strategy"], "conventionFile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_request_deserialises_candidates() {
|
||||
let raw = json!({
|
||||
"candidates": [{
|
||||
"id": Uuid::from_u128(1).to_string(),
|
||||
"name": "Claude",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "stdin" },
|
||||
"detect": "claude --version",
|
||||
"cwdTemplate": "{projectRoot}"
|
||||
}]
|
||||
});
|
||||
let dto: DetectProfilesRequestDto = serde_json::from_value(raw).unwrap();
|
||||
let input: DetectProfilesInput = dto.into();
|
||||
assert_eq!(input.candidates.len(), 1);
|
||||
assert_eq!(input.candidates[0].command, "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_response_serialises_available_flag_camelcase() {
|
||||
let out = DetectProfilesOutput {
|
||||
results: vec![ProfileAvailability {
|
||||
profile: profile(1, "Claude", "claude"),
|
||||
available: true,
|
||||
}],
|
||||
};
|
||||
let dto: DetectProfilesResponseDto = out.into();
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().unwrap();
|
||||
assert_eq!(arr[0]["available"], true);
|
||||
assert_eq!(arr[0]["profile"]["command"], "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_request_deserialises_profile() {
|
||||
let raw = json!({
|
||||
"profile": {
|
||||
"id": Uuid::from_u128(2).to_string(),
|
||||
"name": "Codex",
|
||||
"command": "codex",
|
||||
"args": ["--foo"],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "AGENTS.md" },
|
||||
"detect": null,
|
||||
"cwdTemplate": ""
|
||||
}
|
||||
});
|
||||
let dto: SaveProfileRequestDto = serde_json::from_value(raw).unwrap();
|
||||
let input: SaveProfileInput = dto.into();
|
||||
assert_eq!(input.profile.command, "codex");
|
||||
assert_eq!(input.profile.args, vec!["--foo"]);
|
||||
assert!(input.profile.detect.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_request_deserialises_profiles() {
|
||||
let raw = json!({ "profiles": [] });
|
||||
let dto: ConfigureProfilesRequestDto = serde_json::from_value(raw).unwrap();
|
||||
let input: ConfigureProfilesInput = dto.into();
|
||||
assert!(input.profiles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_state_dto_serialises_camelcase() {
|
||||
let out = FirstRunStateOutput {
|
||||
is_first_run: true,
|
||||
reference_profiles: vec![profile(1, "Claude", "claude")],
|
||||
};
|
||||
let dto: FirstRunStateDto = out.into();
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["isFirstRun"], true);
|
||||
assert!(v.get("is_first_run").is_none(), "no snake_case leak");
|
||||
assert_eq!(v["referenceProfiles"][0]["command"], "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_profile_id_accepts_uuid_and_rejects_garbage() {
|
||||
let id = Uuid::from_u128(7);
|
||||
assert_eq!(
|
||||
parse_profile_id(&id.to_string()).unwrap(),
|
||||
ProfileId::from_uuid(id)
|
||||
);
|
||||
let err = parse_profile_id("not-a-uuid").expect_err("garbage rejected");
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_delete_profile_builds_input() {
|
||||
let id = Uuid::from_u128(9);
|
||||
let input = parse_delete_profile(&id.to_string()).unwrap();
|
||||
assert_eq!(input.id, ProfileId::from_uuid(id));
|
||||
}
|
||||
275
crates/app-tauri/tests/dto_templates.rs
Normal file
@ -0,0 +1,275 @@
|
||||
//! L7 tests for template & sync DTO (de)serialisation contract: camelCase on
|
||||
//! the wire, `TemplateDto`/`TemplateListDto` shapes, `AgentDriftDto`/
|
||||
//! `AgentDriftListDto`, `SyncResultDto`, request DTO deserialisation, and
|
||||
//! `parse_template_id` error behaviour. No Tauri runtime required.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_template_id, AgentDriftDto, AgentDriftListDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
UpdateTemplateRequestDto,
|
||||
};
|
||||
use application::{
|
||||
AgentDrift, CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput,
|
||||
SyncAgentWithTemplateOutput, UpdateTemplateOutput,
|
||||
};
|
||||
use domain::ids::{AgentId, ProfileId, TemplateId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::template::{AgentTemplate, TemplateVersion};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_template(template_uuid: u128, profile_uuid: u128) -> AgentTemplate {
|
||||
AgentTemplate::new(
|
||||
TemplateId::from_uuid(Uuid::from_u128(template_uuid)),
|
||||
"My Template",
|
||||
MarkdownDoc::new("# Hello".to_owned()),
|
||||
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
|
||||
)
|
||||
.expect("valid template")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TemplateDto serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_dto_serialises_camelcase() {
|
||||
let tmpl = make_template(1, 2);
|
||||
let dto = TemplateDto(tmpl.clone());
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["id"], tmpl.id.to_string());
|
||||
assert_eq!(v["name"], "My Template");
|
||||
// contentMd is the camelCase field on AgentTemplate; MarkdownDoc is transparent → plain string
|
||||
assert_eq!(v["contentMd"], "# Hello");
|
||||
// version is a transparent number
|
||||
assert_eq!(v["version"], 1u64);
|
||||
assert_eq!(
|
||||
v["defaultProfileId"],
|
||||
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
|
||||
);
|
||||
// no snake_case leak
|
||||
assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd");
|
||||
assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_dto_version_is_number() {
|
||||
let tmpl = make_template(3, 4);
|
||||
let dto = TemplateDto(tmpl);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert!(v["version"].is_number(), "version should be a JSON number");
|
||||
assert_eq!(v["version"].as_u64().unwrap(), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TemplateListDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn template_list_dto_is_transparent_array() {
|
||||
let out = ListTemplatesOutput {
|
||||
templates: vec![make_template(1, 2), make_template(3, 4)],
|
||||
};
|
||||
let dto = TemplateListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["name"], "My Template");
|
||||
assert_eq!(arr[1]["name"], "My Template");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_list_dto_empty() {
|
||||
let out = ListTemplatesOutput { templates: vec![] };
|
||||
let dto = TemplateListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert!(v.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From<CreateTemplateOutput> / From<UpdateTemplateOutput>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_template_output_maps_to_template_dto() {
|
||||
let tmpl = make_template(5, 6);
|
||||
let out = CreateTemplateOutput { template: tmpl.clone() };
|
||||
let dto = TemplateDto::from(out);
|
||||
assert_eq!(dto.0.id, tmpl.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_template_output_maps_to_template_dto() {
|
||||
let tmpl = make_template(7, 8);
|
||||
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
|
||||
let out = UpdateTemplateOutput { template: bumped.clone() };
|
||||
let dto = TemplateDto::from(out);
|
||||
assert_eq!(dto.0.version, TemplateVersion(2));
|
||||
assert_eq!(dto.0.id, bumped.id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentDriftDto / AgentDriftListDto serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_drift_dto_serialises_camelcase() {
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(10));
|
||||
let drift = AgentDrift {
|
||||
agent_id,
|
||||
from: TemplateVersion(1),
|
||||
to: TemplateVersion(3),
|
||||
};
|
||||
let dto = AgentDriftDto::from(drift);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["agentId"], agent_id.to_string());
|
||||
assert_eq!(v["from"], 1u64);
|
||||
assert_eq!(v["to"], 3u64);
|
||||
// no snake_case leak
|
||||
assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_drift_list_dto_is_transparent_array() {
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(11));
|
||||
let out = DetectAgentDriftOutput {
|
||||
drifts: vec![AgentDrift {
|
||||
agent_id,
|
||||
from: TemplateVersion(2),
|
||||
to: TemplateVersion(5),
|
||||
}],
|
||||
};
|
||||
let dto = AgentDriftListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["from"], 2u64);
|
||||
assert_eq!(arr[0]["to"], 5u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_drift_list_dto_empty() {
|
||||
let out = DetectAgentDriftOutput { drifts: vec![] };
|
||||
let dto = AgentDriftListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert!(v.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SyncResultDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sync_result_dto_synced_with_version() {
|
||||
let out = SyncAgentWithTemplateOutput {
|
||||
synced: true,
|
||||
version: Some(TemplateVersion(4)),
|
||||
};
|
||||
let dto = SyncResultDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["synced"], true);
|
||||
assert_eq!(v["version"], 4u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_result_dto_not_synced_version_null() {
|
||||
let out = SyncAgentWithTemplateOutput {
|
||||
synced: false,
|
||||
version: None,
|
||||
};
|
||||
let dto = SyncResultDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["synced"], false);
|
||||
assert!(v["version"].is_null(), "version should be null when None");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request DTO deserialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_template_request_deserialises_camelcase() {
|
||||
let profile_id = Uuid::from_u128(20).to_string();
|
||||
let raw = json!({
|
||||
"name": "Backend Template",
|
||||
"content": "# My context",
|
||||
"defaultProfileId": profile_id
|
||||
});
|
||||
let dto: CreateTemplateRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.name, "Backend Template");
|
||||
assert_eq!(dto.content, "# My context");
|
||||
assert_eq!(dto.default_profile_id, profile_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_template_request_deserialises_camelcase() {
|
||||
let template_id = Uuid::from_u128(30).to_string();
|
||||
let raw = json!({
|
||||
"templateId": template_id,
|
||||
"content": "# Updated content"
|
||||
});
|
||||
let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.template_id, template_id);
|
||||
assert_eq!(dto.content, "# Updated content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_agent_from_template_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(40).to_string();
|
||||
let template_id = Uuid::from_u128(41).to_string();
|
||||
let raw = json!({
|
||||
"projectId": project_id,
|
||||
"templateId": template_id,
|
||||
"name": "My Agent",
|
||||
"synchronized": true
|
||||
});
|
||||
let dto: CreateAgentFromTemplateRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.template_id, template_id);
|
||||
assert_eq!(dto.name.as_deref(), Some("My Agent"));
|
||||
assert!(dto.synchronized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_agent_from_template_request_name_defaults_to_none() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::nil().to_string(),
|
||||
"templateId": Uuid::nil().to_string(),
|
||||
"synchronized": false
|
||||
});
|
||||
let dto: CreateAgentFromTemplateRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert!(dto.name.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parse_template_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_template_id_accepts_uuid() {
|
||||
let id = Uuid::from_u128(99);
|
||||
assert_eq!(
|
||||
parse_template_id(&id.to_string()).unwrap(),
|
||||
TemplateId::from_uuid(id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_id_rejects_garbage() {
|
||||
let err = parse_template_id("INVALID").expect_err("garbage rejected");
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_id_rejects_empty() {
|
||||
let err = parse_template_id("").expect_err("empty string rejected");
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
28
crates/app-tauri/tests/dto_window.rs
Normal file
@ -0,0 +1,28 @@
|
||||
//! L10 DTO tests: `move_tab_to_new_window` result mapping + tab-id parsing.
|
||||
|
||||
use app_tauri_lib::dto::{parse_tab_id, MoveTabResultDto};
|
||||
use application::MoveTabToNewWindowOutput;
|
||||
use domain::ids::WindowId;
|
||||
use domain::layout::Workspace;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn move_tab_result_serializes_new_window_id_camel_case() {
|
||||
let out = MoveTabToNewWindowOutput {
|
||||
new_window_id: WindowId::from_uuid(Uuid::from_u128(42)),
|
||||
workspace: Workspace::default(),
|
||||
};
|
||||
let dto = MoveTabResultDto::from(out);
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
assert!(json.contains("\"newWindowId\""), "json was {json}");
|
||||
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tab_id_accepts_uuid_and_rejects_garbage() {
|
||||
let uuid = Uuid::from_u128(7).to_string();
|
||||
assert!(parse_tab_id(&uuid).is_ok());
|
||||
|
||||
let err = parse_tab_id("not-a-uuid").unwrap_err();
|
||||
assert_eq!(err.code, "INVALID");
|
||||
}
|
||||
90
crates/app-tauri/tests/pty_bridge.rs
Normal file
@ -0,0 +1,90 @@
|
||||
//! L1 tests for [`PtyBridge`] — the PTY↔Channel registry — exercised with a
|
||||
//! real [`tauri::ipc::Channel`] built from a capturing closure (no Tauri runtime
|
||||
//! and no real PTY needed).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||
|
||||
use app_tauri_lib::pty::PtyBridge;
|
||||
use domain::ids::SessionId;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Builds a `Channel<Vec<u8>>` whose sent chunks are recorded into `sink`.
|
||||
///
|
||||
/// `Vec<u8>` is `Serialize`, so chunks arrive as a JSON array string in an
|
||||
/// `InvokeResponseBody::Json`; we parse them back to bytes for assertions.
|
||||
fn capturing_channel(sink: Arc<Mutex<Vec<Vec<u8>>>>) -> Channel<Vec<u8>> {
|
||||
Channel::new(move |body: InvokeResponseBody| {
|
||||
let bytes: Vec<u8> = match body {
|
||||
InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
|
||||
InvokeResponseBody::Raw(b) => b,
|
||||
};
|
||||
sink.lock().unwrap().push(bytes);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn sid() -> SessionId {
|
||||
SessionId::from_uuid(Uuid::new_v4())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_increases_active_sessions() {
|
||||
let bridge = PtyBridge::new();
|
||||
assert_eq!(bridge.active_sessions(), 0);
|
||||
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
bridge.register(sid(), capturing_channel(sink));
|
||||
assert_eq!(bridge.active_sessions(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_output_delivers_bytes_to_registered_channel() {
|
||||
let bridge = PtyBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
|
||||
let delivered = bridge.send_output(&session, vec![104, 105]);
|
||||
assert!(delivered, "send_output should return true for a live session");
|
||||
|
||||
let captured = sink.lock().unwrap();
|
||||
assert_eq!(captured.as_slice(), &[vec![104, 105]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_output_to_unknown_session_returns_false() {
|
||||
let bridge = PtyBridge::new();
|
||||
assert!(!bridge.send_output(&sid(), vec![0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_removes_session_and_stops_delivery() {
|
||||
let bridge = PtyBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
assert_eq!(bridge.active_sessions(), 1);
|
||||
|
||||
bridge.unregister(&session);
|
||||
assert_eq!(bridge.active_sessions(), 0);
|
||||
assert!(!bridge.send_output(&session, vec![1]));
|
||||
assert!(sink.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_same_session_twice_replaces_channel() {
|
||||
let bridge = PtyBridge::new();
|
||||
let session = sid();
|
||||
let first = Arc::new(Mutex::new(Vec::new()));
|
||||
let second = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
bridge.register(session, capturing_channel(Arc::clone(&first)));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&second)));
|
||||
assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added");
|
||||
|
||||
bridge.send_output(&session, vec![9]);
|
||||
assert!(first.lock().unwrap().is_empty(), "old channel no longer used");
|
||||
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
|
||||
}
|
||||