diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..bb3a95f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,8 @@ +# Build configuration for the IdeA workspace. +# +# Link the system libgit2 (via pkg-config) instead of vendoring/compiling it from +# source. The dev machines and CI provide libgit2 ≥ 1.9 (matching git2 0.20), and +# this avoids requiring cmake for the vendored build. Static vendoring for the +# portable AppImage is handled separately at packaging time (L11). +[env] +LIBGIT2_SYS_USE_PKG_CONFIG = "1" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c2c06a6 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,601 @@ +# IdeA — Cartographie d'Architecture + +> Document de référence produit par l'**Agent Architecture**. +> Fait autorité sur les frontières, ports, adapters, modules et conventions. +> Toute feature DOIT être validée contre ce document avant développement. +> Architecture **Hexagonale (Ports & Adapters)** + **SOLID**, stricte. +> +> Stack non négociable : Tauri v2 (shell) · Rust (cœur hexagonal) · TypeScript + React (UI) · xterm.js + portable-pty (terminaux) · git2/libgit2 · russh/ssh2 · wsl.exe. + +--- + +## 1. Principes : SOLID + Hexagonal, appliqués concrètement + +### 1.1 Règle de dépendance (la seule qui compte) + +``` + ┌─────────────────────────────────────────────┐ + │ Le sens des dépendances │ + │ │ + Présentation ─► Application ─► Domaine ◄─ Infrastructure │ + (React/Tauri) (use cases) (pur) (adapters) │ + │ │ + └─────────────────────────────────────────────┘ +``` + +- **Le Domaine ne dépend de RIEN** : ni Tauri, ni tokio, ni git2, ni portable-pty, ni serde (le moins possible — voir §1.4). Il ne contient que des entités, value objects, règles métier et **traits = ports**. +- **L'Application** dépend du Domaine. Elle orchestre les use cases en parlant **uniquement aux ports** (traits), jamais aux adapters concrets. +- **L'Infrastructure** dépend du Domaine et de l'Application (elle implémente les ports). Elle contient tous les détails techniques (PTY, FS, git, SSH, WSL, stores). +- **La Présentation** (Tauri commands + React) dépend de l'Application. Les commandes Tauri sont des **adapters entrants (driving adapters)** ; les impl de ports sont des **adapters sortants (driven adapters)**. + +Aucune flèche ne pointe **vers** la présentation ou l'infrastructure. L'inversion de dépendance (le **D** de SOLID) est matérialisée par les traits définis dans le domaine et implémentés dehors. + +### 1.2 SOLID, point par point, traduit IdeA + +| Principe | Application concrète | +|---|---| +| **S** — Single Responsibility | Un use case = une intention métier (`LaunchAgent`, `SyncAgentWithTemplate`). Un adapter = une techno (`Git2Repository` ne fait que du git). Le `LayoutNode` ne gère que la topologie, pas le rendu. | +| **O** — Open/Closed | Ajouter une IA = ajouter un **profil déclaratif** (donnée), pas du code. Ajouter un mode distant = nouvel adapter `RemoteHost` sans toucher aux use cases. Ajouter une stratégie d'injection de contexte = nouvelle variante d'enum + handler, use case inchangé. | +| **L** — Liskov | Tout `RemoteHost` (local, SSH, WSL) est substituable : un use case marche identiquement quelle que soit l'impl. Les contrats (pré/postconditions) des ports sont documentés et respectés par chaque adapter. | +| **I** — Interface Segregation | Ports **fins et ciblés** : `ProcessSpawner`, `FileSystem`, `PtyPort` séparés plutôt qu'un `System` fourre-tout. Un use case ne reçoit que les ports qu'il consomme. | +| **D** — Dependency Inversion | Domaine définit les traits ; infra les implémente ; l'application reçoit des `Arc` par **injection** (composition root dans la couche Tauri). | + +### 1.3 Hexagonal côté Frontend (React aussi) + +L'hexagonal ne s'arrête pas à Rust. Côté React on applique le même découpage : + +- **Domaine UI / modèles de vue** : types TS purs (miroir des DTO), logique de présentation pure (ex. calcul de tailles de cellules d'un `LayoutNode`), testable sans React ni Tauri. +- **Ports UI** : interfaces TS (`AgentGateway`, `TerminalGateway`, `ProjectGateway`, `LayoutGateway`, `GitGateway`, `RemoteGateway`) décrivant **ce dont l'UI a besoin**, indépendamment du transport. +- **Adapters UI** : implémentation des ports via `@tauri-apps/api` (`invoke` pour commands, `listen` pour events). Remplaçables par des **mocks** en test/Storybook. +- **Présentation** : composants React, hooks, state (Zustand/Redux) qui consomment les ports UI, jamais `invoke()` en direct. + +Bénéfice : le frontend est testable et développable sans backend (adapters mock), et la frontière IPC est centralisée en un seul endroit. + +### 1.4 Domaine pur vs adapters — règle pratique Rust + +- Le crate `domain` est **`#![no_std]`-friendly d'esprit** (pas imposé), sans dépendance I/O. Tolérance pragmatique : `serde` est autorisé **uniquement** pour dériver la (dé)sérialisation des entités persistées (manifeste, layout, profils), car c'est une contrainte métier de format, pas un détail technique d'I/O. Les **traits/ports** y vivent. Pas de `tokio`, pas de `std::process`, pas de `std::fs`. +- Tout ce qui touche le monde réel (`std::fs`, `Command`, sockets, libgit2, PTY) vit **exclusivement** dans `infrastructure`. + +--- + +## 2. Découpage en couches & frontière Rust ↔ Tauri ↔ React + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ PRÉSENTATION (Frontend) — TypeScript + React + xterm.js │ +│ features/* · ui-ports (gateways) · tauri-adapters (invoke/listen) │ +└───────────────────────────────┬───────────────────────────────────────┘ + │ IPC Tauri (commands ⇄ events, JSON) +┌───────────────────────────────▼───────────────────────────────────────┐ +│ PRÉSENTATION (Backend) — crate `app-tauri` (DRIVING ADAPTER) │ +│ #[tauri::command] handlers · event emitters · COMPOSITION ROOT (DI) │ +│ PTY byte-stream bridge ⇄ xterm.js │ +└───────────────────────────────┬───────────────────────────────────────┘ + │ appels de use cases (Arc) +┌───────────────────────────────▼───────────────────────────────────────┐ +│ APPLICATION — crate `application` │ +│ Use cases / services · DTOs · orchestration · transactions métier │ +│ Dépend UNIQUEMENT des ports (traits) du domaine │ +└───────────────────────────────┬───────────────────────────────────────┘ + │ implémente / consomme +┌───────────────────────────────▼───────────────────────────────────────┐ +│ DOMAINE — crate `domain` (PUR, sans I/O) │ +│ Entities · Value Objects · Invariants · PORTS (traits) · DomainEvents │ +└───────────────────────────────▲───────────────────────────────────────┘ + │ implémentent les ports (DRIVEN ADAPTERS) +┌───────────────────────────────┴───────────────────────────────────────┐ +│ INFRASTRUCTURE — crate `infrastructure` │ +│ portable-pty · git2 · russh/ssh2 · wsl.exe · fs local · md/json store │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Frontière IPC Tauri — deux directions + +- **Commands (Frontend → Backend, request/response)** : `invoke("create_project", {...})`. Le handler `#[tauri::command]` désérialise le DTO, appelle le use case, renvoie un `Result`. **Stateless** côté forme : tout l'état vit dans des services managés via `tauri::State`. +- **Events (Backend → Frontend, push)** : flux PTY (octets/base64), changements de statut d'agent, fin de processus, progrès git, drift de template détecté. Émis via `app_handle.emit(...)` / channels Tauri. L'`EventBus` domaine est relayé vers ces events Tauri par un adapter dans `app-tauri`. + +> **Décision** : le flux PTY haute fréquence passe par des **Tauri Channels** (`tauri::ipc::Channel`) plutôt que des events globaux, pour la perf et l'isolement par session terminal. + +--- + +## 3. Modèle de domaine + +### 3.1 Vue d'ensemble (relations) + +``` +Workspace 1───* Window 1───* Tab 1───1 Project + │ │ + │ 1 ├──* Agent ─────? AgentTemplate (origine) + │ │ │ 1 + │ 1 │ └──1 AgentProfile (runtime IA, par réf id) + LayoutTree ├──1 GitRepository + (LayoutNode récursif) ├──1 RemoteHost (Local | Ssh | Wsl) + │ feuilles └──1 AgentManifest (.ideai/agents.json) + ▼ + TerminalSession 1───? Agent (si lancé par un agent) +``` + +### 3.2 Entités & Value Objects (avec invariants) + +**`ProjectId`, `AgentId`, `TemplateId`, `ProfileId`, `SessionId`, `WindowId`, `TabId`, `NodeId`** — VO `newtype(Uuid)` ou string typée. Invariant : non vide, immuable. + +**`Project`** (entité, racine d'agrégat projet) +- Champs : `id`, `name`, `root: ProjectPath`, `remote: RemoteRef`, `created_at`. +- Invariants : `root` doit être un chemin **absolu et valide pour son `RemoteRef`** ; deux projets ne peuvent partager le même `(remote, root)`. + +**`ProjectPath`** (VO) — chemin absolu normalisé, conscient de la plateforme cible (POSIX vs Windows vs WSL `/mnt/...`). + +**`Agent`** (entité) +- Champs : `id`, `name`, `context: AgentContextRef` (chemin du `.md` dans `.ideai/`), `profile_id: ProfileId`, `origin: AgentOrigin` (`Scratch` | `FromTemplate { template_id, synced_version }`), `synchronized: bool`. +- Invariants : `synchronized == true` ⇒ `origin == FromTemplate{..}` (on ne peut pas synchroniser un agent créé from scratch). `context` doit exister à l'activation. `profile_id` doit référencer un `AgentProfile` connu. + +**`AgentTemplate`** (entité, store global) +- Champs : `id`, `name`, `content_md: MarkdownDoc`, `version: TemplateVersion`, `default_profile_id`. +- Invariants : `version` **monotone croissante** ; toute modification du `content_md` ⇒ `version + 1` (voir §8). + +**`AgentProfile`** (entité de config runtime IA — le port `AgentRuntime` est paramétré par elle) +- Champs : `id`, `name`, `command: String`, `args: Vec`, `context_injection: ContextInjection`, `detect: Option`, `cwd_template: String` (ex. `"{projectRoot}"`). +- Invariants : `command` non vide ; cohérence de `ContextInjection` (voir VO ci-dessous). + +**`ContextInjection`** (VO, enum — cœur du moteur IA flexible) +``` +ContextInjection = + | ConventionFile { target: String } // ex. "CLAUDE.md" / "AGENTS.md" / "GEMINI.md" + | Flag { flag: String } // ex. "--context-file {path}" ou "-f" + | Stdin // pipe du contenu md sur stdin + | Env { var: String } // ex. "AGENT_CONTEXT_FILE" +``` +- Invariants : `ConventionFile.target` est un nom de fichier relatif (pas de `..`, pas absolu) ; `Env.var` est un identifiant d'env valide ; `Flag.flag` non vide. + +**`TerminalSession`** (entité) +- Champs : `id`, `node_id` (cellule du layout qui l'héberge), `cwd: ProjectPath`, `kind: SessionKind` (`Plain` | `Agent { agent_id }`), `pty_size: PtySize { rows, cols }`, `status` (`Starting|Running|Exited{code}`). +- Invariants : une cellule (feuille de layout) héberge **au plus une** `TerminalSession` active. `pty_size.rows>0 && cols>0`. + +**`LayoutNode` / `LayoutTree`** (VO récursif — voir §7 pour le détail complet) +- Invariants : poids relatifs strictement positifs ; somme normalisable ; pas de fusion qui chevauche deux conteneurs distincts ; un `Leaf` référence 0 ou 1 `SessionId`. + +**`RemoteHost`** (VO de stratégie de localisation — abstrait Local/SSH/WSL) +``` +RemoteRef = + | Local + | Ssh { host, port, user, auth: SshAuth, remote_root } + | Wsl { distro: String } +``` +- Invariants : `Ssh.port` ∈ 1..=65535 ; `Wsl.distro` non vide ; pour `Ssh`/`Wsl`, les chemins projet sont interprétés côté distant. + +**`GitRepository`** (entité) +- Champs : `project_id`, `root`, `current_branch`, `is_dirty`. +- Invariants : `root` contient (ou contiendra après init) un `.git`. État dérivé, rafraîchi via le port. + +**`AgentManifest`** (entité — image en mémoire de `.ideai/agents.json`) +- Champs : `entries: Vec`. +- Invariants : `synchronized ⇒ template_id.is_some() && synced_template_version.is_some()` ; `md_path` unique ; cohérence avec les `Agent` chargés. + +**`Workspace` / `Window` / `Tab`** (entités de présentation persistée) +- `Workspace` = ensemble des fenêtres d'une session utilisateur. +- `Window` = fenêtre OS ; possède un `LayoutTree` **par onglet actif** et une liste de `Tab`. +- `Tab` = onglet ⇔ **un `Project`** (1:1). +- Invariants : un `Project` ouvert apparaît dans **exactement un** `Tab` à la fois (le drag déplace, ne duplique pas) ; un `Window` a ≥ 1 `Tab` ou est fermée. + +**`DomainEvent`** (enum) — `ProjectCreated`, `AgentLaunched`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}` (ce dernier souvent court-circuité vers un Channel). + +--- + +## 4. Ports (traits du domaine) + +> Signatures **conceptuelles** (Rust idiomatique, `async` via `async_trait` ou retours `Future` ; erreurs typées par port). « Consommé par » = use cases. « Implémenté par » = adapters de §5. + +### `AgentRuntime` +- **Rôle** : lancer/piloter la CLI d'une IA selon un `AgentProfile`, en gérant l'injection du contexte `.md`. +- **Signature** : + ```rust + trait AgentRuntime { + fn detect(&self, profile: &AgentProfile) -> Result; + fn prepare_invocation(&self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath) + -> Result; // commande + args + plan d'injection (fichier/flag/stdin/env) + } + ``` +- **Consommé par** : `LaunchAgent`, `DetectProfilesUseCase` (first-run). +- **Implémenté par** : `CliAgentRuntime` (un seul adapter générique piloté par le profil déclaratif — c'est l'**Open/Closed**). La diversité des IA = données, pas code. + +### `PtyPort` (alias domaine de `TerminalSessionPort`) +- **Rôle** : ouvrir un pseudo-terminal, lire/écrire, redimensionner, tuer. +- **Signature** : + ```rust + trait PtyPort { + async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result; + fn write(&self, h: &PtyHandle, data: &[u8]) -> Result<(), PtyError>; + fn resize(&self, h: &PtyHandle, size: PtySize) -> Result<(), PtyError>; + fn subscribe_output(&self, h: &PtyHandle) -> OutputStream; // flux d'octets + async fn kill(&self, h: &PtyHandle) -> Result; + } + ``` +- **Consommé par** : `OpenTerminal`, `LaunchAgent`, `CloseTerminal`. +- **Implémenté par** : `PortablePtyAdapter` (local), `SshPtyAdapter` (PTY distant via russh exec/shell), `WslPtyAdapter` (PTY via `wsl.exe`). Sélection par stratégie `RemoteRef` (Liskov). + +### `RemoteHost` +- **Rôle** : abstraction de la **localisation d'exécution** (local / SSH / WSL) : exécuter une commande, ouvrir un PTY, accéder au FS, dans le bon contexte. +- **Signature** : + ```rust + trait RemoteHost { + fn kind(&self) -> RemoteKind; + async fn connect(&self) -> Result<(), RemoteError>; + fn file_system(&self) -> Arc; + fn process_spawner(&self) -> Arc; + fn pty(&self) -> Arc; + } + ``` +- **Consommé par** : tous les use cases qui touchent un projet (résolvent leurs ports via le `RemoteHost` du projet → **transparence local/distant**). +- **Implémenté par** : `LocalHost`, `SshHost` (russh/ssh2), `WslHost` (wsl.exe). C'est la **stratégie** qui unifie les 3 modes. + +### `ProcessSpawner` +- **Rôle** : lancer un process **non interactif** et récupérer sortie/exit (ex. `detect`, commandes git hors libgit2, scripts). +- **Signature** : `async fn run(&self, spec: SpawnSpec) -> Result;` +- **Consommé par** : `DetectProfilesUseCase`, services divers. +- **Implémenté par** : `LocalProcessSpawner`, `SshProcessSpawner`, `WslProcessSpawner`. + +### `FileSystem` +- **Rôle** : lecture/écriture/listing/symlink, neutre vis-à-vis de la localisation. +- **Signature** : + ```rust + trait FileSystem { + async fn read(&self, p: &RemotePath) -> Result, FsError>; + async fn write(&self, p: &RemotePath, data: &[u8]) -> Result<(), FsError>; + async fn exists(&self, p: &RemotePath) -> Result; + async fn create_dir_all(&self, p: &RemotePath) -> Result<(), FsError>; + async fn list(&self, p: &RemotePath) -> Result, FsError>; + async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>; + } + ``` +- **Consommé par** : `AgentContextStore`, `ProjectStore`, injection `conventionFile`, etc. +- **Implémenté par** : `LocalFileSystem` (std::fs/tokio::fs), `SshFileSystem` (SFTP), `WslFileSystem` (via `wsl.exe` ou chemins `\\wsl$`). + +### `TemplateStore` +- **Rôle** : CRUD des `AgentTemplate` dans le store global IDE + versioning. +- **Signature** : `list / get / save / delete / bump_version`. +- **Consommé par** : `CreateTemplate`, `UpdateTemplate`, `CreateAgentFromTemplate`, `SyncAgentWithTemplate`. +- **Implémenté par** : `FsTemplateStore` (md + index json dans le dossier de données app). + +### `ProjectStore` +- **Rôle** : persistance de la liste des projets connus, workspaces, windows, tabs, layouts. +- **Signature** : `list_projects / load_project / save_project / save_workspace / load_workspace`. +- **Consommé par** : `CreateProject`, `OpenProject`, persistance fenêtres/onglets/layout. +- **Implémenté par** : `FsProjectStore` (json dans données app pour le registre ; layout par projet dans `.ideai/`). + +### `AgentContextStore` +- **Rôle** : lire/écrire les `.md` d'agents **et** le manifeste `.ideai/agents.json` (au sein du projet, via le `FileSystem` du `RemoteHost`). +- **Signature** : + ```rust + trait AgentContextStore { + async fn read_context(&self, project: &Project, agent: &AgentId) -> Result; + async fn write_context(&self, project: &Project, agent: &AgentId, md: &MarkdownDoc) -> Result<(), StoreError>; + async fn load_manifest(&self, project: &Project) -> Result; + async fn save_manifest(&self, project: &Project, m: &AgentManifest) -> Result<(), StoreError>; + } + ``` +- **Consommé par** : `CreateAgent*`, `LaunchAgent`, `SyncAgentWithTemplate`. +- **Implémenté par** : `IdeaiContextStore` (compose `FileSystem`, écrit `.ideai/`). + +### `GitRepository` +- **Rôle** : opérations git du projet. +- **Signature** : `status / stage / unstage / commit / branches / checkout / current_branch / diff / log / pull / push / clone / init`. +- **Consommé par** : use cases Git. +- **Implémenté par** : `Git2Repository` (libgit2, local) ; sur SSH/WSL, `RemoteGitRepository` délègue à git CLI via `ProcessSpawner` quand libgit2 ne peut pas atteindre le FS distant (point ouvert §13). + +### `EventBus` +- **Rôle** : publier/souscrire les `DomainEvent` (découple émetteurs et présentation). +- **Signature** : `fn publish(&self, e: DomainEvent); fn subscribe(&self) -> EventStream;` +- **Consommé par** : tous use cases (publient) ; l'adapter Tauri (souscrit → relaye en events/channels IPC). +- **Implémenté par** : `TokioBroadcastEventBus` (in-process), relayé par `TauriEventRelay`. + +### `Clock` & `IdGenerator` (ports utilitaires — testabilité) +- **Rôle** : éliminer le non-déterminisme (`now()`, `uuid`) du domaine/application. +- **Implémenté par** : `SystemClock` / `UuidGenerator` (prod), `FixedClock` / `SeqIdGenerator` (tests). + +--- + +## 5. Adapters (impl concrètes par port) + +| Port | Adapter(s) | Techno | Notes | +|---|---|---|---| +| `AgentRuntime` | `CliAgentRuntime` | piloté par `AgentProfile` | Construit `SpawnSpec` + plan d'injection. Un seul adapter, N profils. | +| `PtyPort` | `PortablePtyAdapter` | portable-pty | Local. Stream octets → Channel Tauri. | +| | `SshPtyAdapter` | russh (channel shell/exec + pty req) | Distant SSH. | +| | `WslPtyAdapter` | `wsl.exe -d ` + portable-pty | PTY dans la distro. | +| `RemoteHost` | `LocalHost` / `SshHost` / `WslHost` | — / russh,ssh2 / wsl.exe | Stratégie ; fabrique FS/Spawner/PTY adaptés. | +| `ProcessSpawner` | `LocalProcessSpawner` | std/tokio `Command` | | +| | `SshProcessSpawner` | russh exec | | +| | `WslProcessSpawner` | `wsl.exe` | | +| `FileSystem` | `LocalFileSystem` | tokio::fs | | +| | `SshFileSystem` | SFTP (ssh2/russh-sftp) | | +| | `WslFileSystem` | `\\wsl$\` / `wsl.exe cat`… | | +| `TemplateStore` | `FsTemplateStore` | tokio::fs + serde_json | Dossier données app. | +| `ProjectStore` | `FsProjectStore` | tokio::fs + serde_json | Registre projets + workspace. | +| `AgentContextStore` | `IdeaiContextStore` | compose `FileSystem` | Écrit `.ideai/`. | +| `GitRepository` | `Git2Repository` | git2 | Local. | +| | `RemoteGitRepository` | git CLI via `ProcessSpawner` | SSH/WSL fallback. | +| `EventBus` | `TokioBroadcastEventBus` (+ `TauriEventRelay`) | tokio::broadcast | Relais vers IPC. | +| `Clock`/`IdGenerator` | `SystemClock`/`UuidGenerator` | std/uuid | Mocks en test. | + +**Adapters entrants (driving)** : handlers `#[tauri::command]` (frontend → app) + `TauriEventRelay` (app → frontend). Côté UI : `tauri-adapters` implémentant les gateways TS. + +--- + +## 6. Use cases / services applicatifs + +> Chaque use case : un struct `XxxUseCase` portant ses ports en `Arc`, une méthode `execute(input: XxxInput) -> Result`. **Single Responsibility**. Aucune dépendance à Tauri. + +| Use case | Rôle | Ports consommés | +|---|---|---| +| `CreateProject` | Crée un projet (project root), init `.ideai/`, registre. | `ProjectStore`, `FileSystem`, `IdGenerator`, `EventBus` | +| `OpenProject` | Charge projet, manifeste, layout, résout `RemoteHost`. | `ProjectStore`, `AgentContextStore`, `RemoteHost` | +| `CloseProject` / `CloseTab` | Persiste l'état, libère PTYs. | `ProjectStore`, `PtyPort`, `EventBus` | +| `DetectProfiles` (first-run) | Teste `detect` de chaque profil candidat. | `AgentRuntime`, `ProcessSpawner` | +| `ConfigureProfiles` | Enregistre profils choisis/édités/custom. | `TemplateStore`/profile store, `FileSystem` | +| `CreateAgentFromScratch` | Crée agent + `.md`, met à jour manifeste. | `AgentContextStore`, `IdGenerator` | +| `CreateAgentFromTemplate` | Copie le `content_md` du template → agent ; lie origine + version + `synchronized`. | `TemplateStore`, `AgentContextStore` | +| `UpdateTemplate` | Modifie un template, **bump version**, signale drift aux agents liés. | `TemplateStore`, `EventBus` | +| `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` | +| `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` | +| `LaunchAgent` | Résout profil+contexte, prépare injection, ouvre cellule PTY au bon `cwd`, spawn CLI. | `AgentRuntime`, `AgentContextStore`, `RemoteHost`→`PtyPort`/`FileSystem`, `EventBus` | +| `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost`→`PtyPort`, `EventBus` | +| `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` | +| `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) | +| `ConnectRemote` (SSH/WSL) | Établit la connexion, valide l'accès au root. | `RemoteHost`, `FileSystem` | +| `MoveTabToNewWindow` | Détache un onglet → nouvelle fenêtre (réaffectation `WindowId`). | `ProjectStore`, `EventBus` | +| Use cases Git | `GitStatus`, `GitCommit`, `GitCheckout`, `GitPush`, … | `GitRepository`, `EventBus` | + +--- + +## 7. Modèle de layout terminal (grille tableur récursive + fusion) + +### 7.1 Structure de données + +La grille « type tableur, lignes/colonnes imbriquées indépendamment + fusion » est modélisée par un **arbre de splits récursif** où chaque conteneur définit son propre découpage. La **fusion** est obtenue nativement : fusionner = ne pas subdiviser une zone (un `Leaf` couvre plusieurs « cellules visuelles » d'un parent voisin). Pour le cas Excel pur (fusion arbitraire chevauchant la grille), on superpose un modèle **GridContainer** avec spans. + +```rust +enum LayoutNode { + Leaf(LeafCell), + Split(SplitContainer), + Grid(GridContainer), +} + +struct LeafCell { + id: NodeId, + session: Option, // 0 ou 1 terminal +} + +struct SplitContainer { // découpage simple binaire/n-aire pondéré + id: NodeId, + direction: Direction, // Row (colonnes) | Column (lignes) + children: Vec, // ordre = gauche→droite / haut→bas +} +struct WeightedChild { node: LayoutNode, weight: f32 } // poids = part redimensionnable + +struct GridContainer { // grille tableur avec fusion (spans) + id: NodeId, + col_weights: Vec, // largeurs de colonnes + row_weights: Vec, // hauteurs de lignes + cells: Vec, // placements avec spans (fusion) +} +struct GridCell { + node: LayoutNode, // récursif : une cellule peut re-contenir un Split/Grid + row: u16, col: u16, + row_span: u16, // ≥1 ; >1 = cellules fusionnées verticalement + col_span: u16, // ≥1 ; >1 = cellules fusionnées horizontalement +} +``` + +- **Lignes/colonnes indépendantes par zone** : chaque `SplitContainer`/`GridContainer` a ses propres poids ⇒ pas de grille uniforme rigide. +- **Imbrication** : un enfant peut être un nouveau `Split`/`Grid` ⇒ « N colonnes dans une ligne, M lignes dans une colonne » de façon arbitraire. +- **Fusion** : `row_span`/`col_span` dans `GridContainer` (modèle tableur fidèle) **ou** simplement un `Leaf` plus grand via `SplitContainer` (cas courant). Le domaine supporte les deux ; l'UI choisit la représentation selon l'interaction. + +### 7.2 Invariants (validés dans le domaine, testables sans I/O) + +- Tous les `weight > 0`. Les poids sont **relatifs** (l'UI normalise pour le rendu). +- Dans un `GridContainer` : aucune superposition de spans ; toute la surface couverte ; `row+row_span ≤ rows`, `col+col_span ≤ cols`. +- Un `SessionId` n'apparaît que dans **un seul** `Leaf`. +- Les opérations `split`, `merge`, `resize`, `move` sont des **fonctions pures** `LayoutTree -> Result` (immutabilité ⇒ testabilité, undo/redo facile). + +### 7.3 Sérialisation & persistance + +- Sérialisé en **JSON** (serde, `tag`/`content` pour l'enum) → `.ideai/layout.json` (par projet, donc voyage avec le projet, y compris distant). +- Le `Workspace`/`Window`/`Tab` (organisation des fenêtres OS) est persisté côté **store global IDE** (machine-local, pas dans le projet) car lié à l'écran de l'utilisateur, pas au code. + +--- + +## 8. Synchronisation template → agents + +### 8.1 Versioning + +- `AgentTemplate.version: u64` monotone. **`UpdateTemplate` incrémente** la version à chaque changement de `content_md`. Un hash du contenu (`content_hash`) est aussi stocké pour détecter les éditions hors-app. +- Chaque `ManifestEntry` d'agent lié garde `synced_template_version` = version du template **au dernier sync réussi**. + +### 8.2 Détection de drift + +``` +drift(agent) = + agent.synchronized + && agent.origin == FromTemplate{ template_id, .. } + && template_store.get(template_id).version > entry.synced_template_version +``` +`DetectAgentDrift` est lancé à `OpenProject` et après chaque `UpdateTemplate` ; émet `AgentDriftDetected { agent_id, from, to }` → badge UI. + +### 8.3 Application de la MAJ (`SyncAgentWithTemplate`) + +``` +1. Charger template (version courante) + manifeste projet. +2. Pour chaque agent ciblé avec synchronized==true : + a. Stratégie de MAJ = REMPLACEMENT du .md par content_md du template + (le contexte d'un agent synchronisé est "possédé" par le template). + → Variante future : merge 3-way si l'agent a un bloc local marqué. + b. write_context(agent, template.content_md) + c. entry.synced_template_version = template.version +3. save_manifest. publish(AgentSynced{..}). +``` + +### 8.4 Agents non synchronisés + +- `synchronized == false` : ne reçoivent **jamais** de MAJ auto. Ils gardent leur `.md` libre. On peut afficher « une nouvelle version du template existe » (info) mais aucune écriture n'a lieu sans action explicite (qui basculerait `synchronized` ou ferait un sync ponctuel one-shot). +- Agents `Scratch` : aucun lien template, hors périmètre de sync. + +--- + +## 9. Stockage & arborescence des fichiers + +### 9.1 Dans le projet — `.ideai/` (voyage avec le code, versionnable) + +``` +/ +├── .ideai/ +│ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version) +│ ├── layout.json # LayoutTree de l'onglet (sérialisé) +│ ├── project.json # méta projet local (nom, profil par défaut, remote ref) +│ └── agents/ +│ ├── reviewer.md # contexte d'un agent de projet +│ ├── backend-dev.md +│ └── ... +└── (CLAUDE.md / AGENTS.md / GEMINI.md générés/symlinkés à l'activation si conventionFile) +``` + +**Schéma `agents.json`** : +```json +{ + "version": 1, + "agents": [ + { + "id": "a3f1...", + "name": "Backend Dev", + "md": "agents/backend-dev.md", + "profileId": "claude-code", + "origin": { "type": "fromTemplate", "templateId": "tpl-backend", "syncedTemplateVersion": 4 }, + "synchronized": true + }, + { + "id": "b7c2...", + "name": "Ad-hoc", + "md": "agents/adhoc.md", + "profileId": "codex-cli", + "origin": { "type": "scratch" }, + "synchronized": false + } + ] +} +``` + +### 9.2 Store global IDE (données app, hors projet, machine-local) + +Emplacement résolu via Tauri path API (`AppData`/`~/.local/share/IdeA`/`~/Library/Application Support/IdeA`). + +``` +/IdeA/ +├── profiles.json # AgentProfile[] configurés (first-run + custom + édités) +├── settings.json # préférences IDE +├── workspace.json # Workspace/Window/Tab + quel projet dans quel onglet (machine-local) +└── templates/ + ├── index.json # [{id, name, version, contentHash, defaultProfileId}] + └── md/ + ├── tpl-backend.md + ├── tpl-reviewer.md + └── ... +``` + +**Schéma `profiles.json` (item)** : exactement le profil déclaratif de CONTEXT.md §9 (`id, name, command, args, contextInjection{strategy,target/flag/var}, detect, cwd`). + +**Formats** : contextes & templates en **Markdown** ; tout le reste en **JSON** (serde). Pas de base de données : fichiers plats, simples, diffables, portables (AppImage friendly). + +--- + +## 10. Arborescence du repo + +### 10.1 Décision : workspace Cargo **multi-crate** + +**Multi-crate** retenu (vs mono-crate) pour **forcer** la règle de dépendance à la compilation : le crate `domain` ne peut littéralement pas dépendre de `infrastructure` si ce n'est pas dans son `Cargo.toml`. C'est la garantie mécanique de l'hexagonal (mieux qu'une convention). Coût : un peu de cérémonie de workspace — acceptable et même souhaitable ici vu le découpage en lots/agents (§12). + +``` +IdeA/ +├── Cargo.toml # [workspace] members +├── ARCHITECTURE.md +├── CONTEXT.md +├── crates/ +│ ├── domain/ # PUR : entities, VO, ports (traits), domain events, layout logic +│ │ └── src/{project,agent,template,profile,terminal,layout,remote,git,ports,events}.rs +│ ├── application/ # use cases, DTOs, AppError ; dépend de domain +│ │ └── src/{project,agent,template,terminal,layout,remote,git}/ +│ ├── infrastructure/ # adapters ; dépend de domain (+ application pour DTO si besoin) +│ │ └── src/{pty,fs,process,remote,git,store,runtime,eventbus}/ +│ └── app-tauri/ # binaire Tauri : commands, events, COMPOSITION ROOT (DI) +│ ├── src/{commands,events,state,main.rs} +│ ├── tauri.conf.json +│ ├── build.rs +│ └── icons/, bundle (NSIS + AppImage) +├── frontend/ # TypeScript + React (Vite) +│ ├── package.json, vite.config.ts, index.html +│ └── src/ +│ ├── domain/ # types & logique de vue purs (miroir DTO, calc layout) +│ ├── ports/ # gateways TS (interfaces) : AgentGateway, TerminalGateway, ... +│ ├── adapters/ # impl gateways via @tauri-apps/api (invoke/listen/Channel) +│ │ └── mock/ # impl mock pour dev/test/storybook +│ ├── features/ # par feature : projects, agents, templates, terminals, layout, git, remote, first-run +│ │ └── /{components,hooks,store,index.ts} +│ ├── shared/ # ui kit, xterm wrapper, design system +│ └── app/ # bootstrap, routing, providers (DI des adapters) +└── docs/ # ADRs, schémas +``` + +`app-tauri` = **seul** endroit qui connaît tous les crates : il instancie les adapters concrets et injecte dans les use cases (composition root). Personne d'autre ne fait de `new ConcreteAdapter`. + +--- + +## 11. Stratégie de tests + +| Couche | Type de test | Comment / où | +|---|---|---| +| `domain` | **Unitaires purs** (sans I/O, sans async) | `#[cfg(test)] mod tests` par module. Invariants d'entités, opérations de layout (split/merge/resize), détection de drift, validation `ContextInjection`. Déterministe via `FixedClock`/`SeqIdGenerator`. | +| `application` | **Unitaires avec ports mockés** | Chaque use case testé avec des **mocks de ports** (`mockall` ou fakes manuels). Ex. `LaunchAgent` vérifie qu'il appelle `prepare_invocation` puis `pty.spawn` avec le bon `cwd` et plan d'injection. **Aucun vrai PTY/FS/git.** | +| `infrastructure` | **Tests d'intégration ciblés** | Par adapter : `LocalFileSystem` sur tmpdir, `Git2Repository` sur repo temporaire, `PortablePtyAdapter` lance `echo`. SSH/WSL : tests `#[ignore]` gated derrière feature/env (CI conditionnelle). | +| `app-tauri` | Tests des commands (mapping DTO ↔ use case) | Wiring testé avec use cases réels + adapters in-memory. | +| Frontend `domain`/`ports` | **Vitest** (unitaires purs) | Logique de vue, calc tailles cellules, réducteurs de state. | +| Frontend `features` | **React Testing Library** + **gateways mock** | Composants testés avec adapters mock ⇒ **sans backend**. | +| E2E (plus tard) | Playwright / `tauri-driver` | Smoke tests des parcours clés. | + +**Clé de testabilité** : grâce aux **ports**, le domaine et l'application se testent **100 % sans I/O**. C'est l'argument central de l'hexagonal et le socle du cycle dev↔test (chaque agent dev appairé à un agent test, cf. CONTEXT §3). Règle d'or : une feature n'est verte que quand `cargo test -p ` et `vitest` passent. + +--- + +## 12. Découpage en lots/features livrables + +> Chaque lot = périmètre autonome, validable par le cycle dev/test, confiable à **un binôme (agent dev + agent test)**. Ordonnés par dépendance. + +| # | Lot | Contenu | Crates/zones | +|---|---|---|---| +| L0 | **Socle domaine & ports** | Entities, VO, **tous les traits ports**, domain events, `AppError`. Aucun adapter. | `domain` (+ ports utilitaires) | +| L1 | **Composition root & IPC** | `app-tauri` : DI, registre de commands/events, bridge PTY↔Channel, gateways TS + adapters Tauri + mocks. | `app-tauri`, `frontend/ports`+`adapters` | +| L2 | **Projets & stockage** | `CreateProject`/`OpenProject`/`CloseProject`, `FsProjectStore`, `LocalFileSystem`, init `.ideai/`. UI projets/onglets. | `application/project`, `infrastructure/{fs,store}`, `frontend/features/projects` | +| L3 | **Terminaux & PTY (local)** | `PtyPort` + `PortablePtyAdapter`, use cases terminal, wrapper xterm.js, flux Channel. | `infrastructure/pty`, `application/terminal`, `frontend/features/terminals` | +| L4 | **Layout tableur** | Logique pure `LayoutTree` (déjà en L0 partiellement), `MutateLayout`, persistance `layout.json`, UI grille redimensionnable + fusion. | `domain/layout`, `application/layout`, `frontend/features/layout` | +| L5 | **Profils IA & runtime** | `AgentProfile`, `CliAgentRuntime`, `DetectProfiles`, first-run wizard, `profiles.json`. | `infrastructure/runtime`, `application/agent`, `frontend/features/first-run` | +| L6 | **Agents & contextes** | `AgentContextStore`/`IdeaiContextStore`, CRUD agents, `LaunchAgent` (injection + spawn + cellule). | `application/agent`, `infrastructure/store`, `frontend/features/agents` | +| L7 | **Templates & synchro** | `TemplateStore`, versioning, `DetectAgentDrift`, `SyncAgentWithTemplate`. UI templates + badges drift. | `application/template`, `infrastructure/store`, `frontend/features/templates` | +| L8 | **Git** | `GitRepository`/`Git2Repository`, use cases git, UI git. | `infrastructure/git`, `application/git`, `frontend/features/git` | +| L9 | **Remote (SSH + WSL)** | `RemoteHost` stratégie, `SshHost`/`WslHost`, adapters FS/PTY/Spawner distants, `RemoteGitRepository`. UI connexion. | `infrastructure/remote`, `application/remote`, `frontend/features/remote` | +| L10 | **Fenêtres & multi-window** | `Workspace`/`Window`/`Tab`, `MoveTabToNewWindow`, drag d'onglet → nouvelle fenêtre OS Tauri. | `application`, `app-tauri`, `frontend/app` | +| L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI | + +--- + +## 13. Risques techniques & points ouverts (spikes) + +1. **PTY cross-platform** : portable-pty + xterm.js OK sur les 3 OS, mais signaux/resize/exit codes diffèrent (Windows ConPTY). **Spike** L3. +2. **AppImage multi-distro** : libgit2/openssl/glibc liés dynamiquement → risque de non-portabilité. **Spike** : vendoring statique (`git2` features, `rustls` pour russh au lieu d'OpenSSL), test sur ≥3 distros (Ubuntu/Fedora/Arch). L11. +3. **Drag d'onglet entre fenêtres Tauri** : Tauri v2 multi-webview/multi-window + DnD natif inter-fenêtres est délicat (le DnD HTML ne traverse pas les fenêtres OS). **Spike** : protocole « detach » (créer une `WebviewWindow`, transférer l'état via store + event, fermer l'onglet source). L10. +4. **Git sur FS distant** : libgit2 ne lit pas un FS SSH/WSL directement. Décision : **fallback git CLI** (`RemoteGitRepository`) côté distant via `ProcessSpawner`. À valider (perf, parsing). L9. +5. **Synchro temps réel UI ↔ PTY** : volume d'octets élevé ; backpressure des Channels Tauri, throttling/coalescing côté front. **Spike** L3. +6. **Injection `conventionFile`** : symlink vs copie du `.md` vers `CLAUDE.md`/`AGENTS.md` ; conflits si fichier existant, .gitignore, droits Windows (symlinks). À cadrer L6. +7. **SSH auth** : agent/clé/mot de passe/known_hosts ; choix russh (rustls) vs ssh2 (libssh2/OpenSSL — impacte point 2). Décision à figer début L9. +8. **WSL chemins** : conversion `/mnt/c/...` ↔ `\\wsl$\...`, distros multiples, perf I/O cross-boundary. Spike L9. +9. **Détection d'édition hors-app** des `.md`/templates (content hash) et résolution de conflit lors du sync. L7. + +--- + +*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.* diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..d0c97f4 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,187 @@ +# IdeA — Contexte & Méthode de travail + +> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA. +> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici. + +--- + +## 1. Mon rôle : chef d'orchestre, pas développeur + +Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail. +Je suis responsable de : + +- Découper le travail en tâches claires et autonomes. +- Attribuer chaque tâche aux bons agents. +- Garantir que le cycle de développement/test est respecté. +- Faire respecter les principes d'architecture (SOLID, Hexagonal). +- Maintenir la cohérence globale du projet et de ce document. +- Arbitrer et valider avant toute action irréversible ou sortante. + +--- + +## 2. Les agents + +### 2.1 Agent Architecture (1 pour tout le projet) +- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**. +- Définit les frontières (domaine / application / infrastructure), les ports, les contrats. +- Valide que chaque nouvelle feature respecte la structure avant son développement. +- Tient à jour la cartographie d'architecture et les conventions. + +### 2.2 Agents de Développement +- Écrivent le code des features. +- Respectent strictement l'architecture définie par l'agent Architecture. +- Code **propre, structuré, stable**. +- Reçoivent les rapports d'erreurs des agents de test et corrigent. + +### 2.3 Agents de Test +- **Chaque agent de développement est appairé avec un agent de test dédié.** +- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées. +- Produisent un **rapport d'erreurs** clair quand un test échoue. +- Re-testent après chaque correction. + +--- + +## 3. Le cycle de développement (boucle obligatoire) + +Pour **chaque** feature implémentée ou modifiée : + +``` +1. Agent Architecture → valide le découpage et les contrats (ports/interfaces) +2. Agent Développement → écrit le code +3. Agent Test → écrit les tests unitaires + les exécute +4a. Tests OK → feature validée, on passe à la suite +4b. Tests KO → rapport d'erreurs → retour à l'agent Développement + → correction → retour à l'étape 3 (boucle jusqu'au vert) +``` + +**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas. +Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle. + +--- + +## 4. Principes de code + +- **SOLID** appliqué au maximum. +- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...). +- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe. +- Tests unitaires systématiques ; couverture des features critiques. +- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif. + +--- + +## 5. Vision produit : IdeA + +**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.** + +### Fonctionnalités clés +- **Multi-projets en parallèle** : un **onglet par projet**. +- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement. +- **Agents par projet** : chaque projet a ses propres agents. +- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets. +- **Création d'agents** : depuis zéro ou à partir d'un template. +- **Synchronisation template → agents** : option « garder l'agent à jour ». + Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour. +- **Contextes d'agents stockés en `.md`** (toujours). +- **Création de projet** = définition de son **project root**. + +### Intégrations +- **Git** intégré. +- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH. +- **Développement WSL** : travailler sur une WSL depuis Windows. + +### Plateformes & livraison +- Cible : **macOS, Linux, Windows**. +- Première phase de compilation : **Linux et Windows**. +- Livraison : + - **Windows** : `setup.exe`. + - **Linux** : **AppImage** (doit fonctionner sur les différentes distributions). + +--- + +## 6. Stack technique (validée) + +- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs). +- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations). +- **Frontend / UI** : **TypeScript + React**. +- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust). +- **Git** : **libgit2** via `git2` (Rust). +- **SSH** : `russh` / `ssh2` (Rust). +- **WSL** : invocation de `wsl.exe` depuis le backend. + +## 7. Layout des terminaux (exigence produit) + +Disposition en **grille redimensionnable de type tableur (Excel)** : + +- Splits redimensionnables horizontaux **et** verticaux. +- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone. +- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur. +- Chaque cellule de la grille héberge un terminal. +- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture. + +## 8. Stockage des contextes & liaison aux templates + +- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet). +- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root. + *(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)* +- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet : + - le `.md` de l'agent, + - le template d'origine (le cas échéant), + - `synchronized: true/false`, + - la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible). +- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ. + +## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`) + +Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA : + +1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`). +2. **Stratégie d'injection du contexte `.md`** : + - `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…). + - `flag` : passer le chemin via un argument. + - `stdin` : piper le contenu. + - `env` : passer via variable d'environnement. + +Exemple de profil : +```json +{ + "id": "claude-code", + "name": "Claude Code", + "command": "claude", + "args": [], + "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, + "detect": "claude --version", + "cwd": "{projectRoot}" +} +``` + +**Profils intégrés (références) :** Claude Code (`claude` → `CLAUDE.md`), OpenAI Codex CLI (`codex` → `AGENTS.md`), Gemini CLI (`gemini` → `GEMINI.md`), Aider (`aider` → args/message). + +**Règles produit :** +- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut. +- Les commandes des profils sont **pré-remplies mais éditables**. +- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA. + +**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil. + +## 10. Fenêtres & onglets + +- **Par défaut : un onglet par projet** (comme les IDE classiques). +- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet. +- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets. + +## 11. Feuille de route + +1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**. +2. Puis MVP incrémental selon le cycle dev/test de la section 3. + +## 12. Autonomie d'exécution dans le projet + +L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**. + +- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**. +- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…). +- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite. + +--- + +*Dernière mise à jour : 2026-06-05* diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..934ccb0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4975 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "app-tauri" +version = "0.1.0" +dependencies = [ + "application", + "domain", + "infrastructure", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "application" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.12.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.12.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.12.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "domain" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.12.1", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.12.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "infrastructure" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "git2", + "portable-pty", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.12.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libgit2-sys" +version = "0.18.5+1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.12.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.12.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.12.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.12.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.12.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.12.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.12.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.12.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.12.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.12.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.12.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.12.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.12.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2b4bf4b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[workspace] +resolver = "2" +members = [ + "crates/domain", + "crates/application", + "crates/infrastructure", + "crates/app-tauri", +] + +[workspace.package] +edition = "2021" +license = "MIT OR Apache-2.0" +rust-version = "1.80" + +[workspace.dependencies] +uuid = { version = "1", features = ["serde", "v4", "v5", "macro-diagnostics"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +async-trait = "0.1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util"] } +# Local git via libgit2. Network features (https/ssh → openssl) are off for L8: +# only local operations (status/commit/branch/checkout/log) are in scope; remote +# push/pull and static vendoring for the AppImage are deferred to L9/L11. +git2 = { version = "0.20", default-features = false } + +# Internal crates +domain = { path = "crates/domain" } +application = { path = "crates/application" } +infrastructure = { path = "crates/infrastructure" } + +# Tauri v2 +tauri = { version = "2", features = [] } +tauri-build = { version = "2", features = [] } +tauri-plugin-dialog = "2" diff --git a/agents-dev/L0-core-domain.md b/agents-dev/L0-core-domain.md new file mode 100644 index 0000000..b660f79 --- /dev/null +++ b/agents-dev/L0-core-domain.md @@ -0,0 +1,54 @@ +# L0 — Socle domaine & ports + +**Binôme :** `dev-core-domain` / `test-core-domain` +**Crate :** `crates/domain` (pur, zéro I/O) +**Dépendances amont :** aucune (fondation du projet). +**Statut :** en cours. + +## Objectif + +Poser le **cœur hexagonal** : toutes les entités, value objects, invariants, **ports (traits)**, domain events et la **logique de layout pure**. Aucun adapter, aucune I/O. + +## Périmètre (DEV) + +### Entities & Value Objects (cf. ARCHITECTURE §3) +- IDs typés (`ProjectId`, `AgentId`, `TemplateId`, `ProfileId`, `SessionId`, `WindowId`, `TabId`, `NodeId`). +- `Project`, `ProjectPath`, `Agent`, `AgentOrigin`, `AgentTemplate`, `TemplateVersion`, `AgentProfile`, `ContextInjection`. +- `TerminalSession`, `SessionKind`, `PtySize`, `RemoteRef`/`SshAuth`, `GitRepository`, `AgentManifest`/`ManifestEntry`, `Workspace`/`Window`/`Tab`. +- `MarkdownDoc` (VO contenu .md). +- Tous les **invariants** documentés en §3 doivent être appliqués (constructeurs validants / `try_new`). + +### Logique de layout pure (cf. ARCHITECTURE §7) +- `LayoutNode` (`Leaf`/`Split`/`Grid`), `SplitContainer`, `WeightedChild`, `GridContainer`, `GridCell`. +- Opérations **pures** : `split`, `merge`, `resize`, `move` → `LayoutTree -> Result`. +- Validation des invariants (poids > 0, pas de chevauchement de spans, surface couverte, 1 session par leaf max). + +### Ports (traits) — définitions seulement, pas d'impl +`AgentRuntime`, `PtyPort`, `RemoteHost`, `ProcessSpawner`, `FileSystem`, `TemplateStore`, `ProjectStore`, `AgentContextStore`, `GitRepository`, `EventBus`, `Clock`, `IdGenerator` (signatures conceptuelles en ARCHITECTURE §4). + +### Domain events & erreurs +- `DomainEvent` (enum complet de §3.2). +- Types d'erreur par domaine (`LayoutError`, et erreurs de port définies ici si partagées). + +### serde +- Autorisé **uniquement** pour les types persistés (manifeste, layout, profils) — dérive `Serialize`/`Deserialize`. Aucune autre dépendance I/O. + +## Périmètre (TEST) + +- Invariants d'entités : rejets attendus (chemin relatif, `synchronized` sans template, port SSH hors plage, etc.). +- **Layout** (cœur du lot) : `split`/`merge`/`resize`/`move` — cas nominaux + cas d'erreur (chevauchement, poids ≤ 0, span hors grille, session dupliquée). +- Déterminisme via `FixedClock`/`SeqIdGenerator`. +- `ContextInjection` : validation des 4 variantes (target relatif, var env valide, flag non vide). +- Sérialisation round-trip JSON des types persistés. + +## Definition of Done + +- `cargo test -p domain` vert. +- `crates/domain/Cargo.toml` ne dépend d'aucun crate I/O (vérifié). +- Tous les ports compilent et sont documentés. +- Logique de layout couverte (cas limites inclus). + +## Notes / points d'attention + +- Choisir `async_trait` vs `-> impl Future` pour les ports I/O (à figer ici, impacte tous les lots). +- Garder les traits **fins** (Interface Segregation) : ne pas fusionner FS/PTY/Process. diff --git a/agents-dev/L1-ipc-bridge.md b/agents-dev/L1-ipc-bridge.md new file mode 100644 index 0000000..6804c57 --- /dev/null +++ b/agents-dev/L1-ipc-bridge.md @@ -0,0 +1,44 @@ +# L1 — Composition root & IPC + +**Binôme :** `dev-ipc-bridge` / `test-ipc-bridge` +**Zones :** `crates/app-tauri`, `frontend/ports`, `frontend/adapters` +**Dépendances amont :** L0 (ports figés). +**Statut :** suivant (enchaîné après L0). + +## Objectif + +Mettre en place le **squelette Tauri qui tourne** : composition root (DI), pont IPC bidirectionnel, et la couche ports/adapters du frontend (avec mocks) — pour que le front soit développable **sans backend** dès les lots suivants. + +## Périmètre (DEV) + +### Backend `app-tauri` (driving adapter + composition root) +- **Composition root** : instancier les adapters concrets (au départ : `LocalFileSystem`, `TokioBroadcastEventBus`, `SystemClock`, `UuidGenerator`) et les injecter dans les use cases via `tauri::State` (`Arc`). +- Registre des `#[tauri::command]` (squelette, mapping DTO ↔ use case) et `ErrorDTO`. +- **`TauriEventRelay`** : souscrit l'`EventBus` domaine → relaie en events/Channels Tauri. +- **Bridge PTY ↔ Tauri Channel** (`tauri::ipc::Channel`) : infrastructure générique de flux d'octets par session (sera consommée par L3). +- App Tauri v2 minimale qui démarre (fenêtre vide). + +### Frontend (hexagonal côté UI) +- `frontend/ports` : gateways TS (`AgentGateway`, `TerminalGateway`, `ProjectGateway`, `LayoutGateway`, `GitGateway`, `RemoteGateway`). +- `frontend/adapters` : impl via `@tauri-apps/api` (`invoke`/`listen`/`Channel`). +- `frontend/adapters/mock` : impl mock de chaque gateway. +- `frontend/app` : bootstrap React + Vite, provider de DI des adapters (réel vs mock). + +## Périmètre (TEST) + +- Backend : mapping commands ↔ use cases (un use case in-memory simple validant le wiring). +- Relais `EventBus` → events Tauri (un `DomainEvent` publié arrive bien côté relais). +- Front : chaque gateway mock satisfait l'interface du port (typecheck + tests Vitest). +- Provider de DI : bascule réel/mock fonctionnelle. + +## Definition of Done + +- L'app Tauri démarre (fenêtre vide) sur Linux. +- `cargo test -p app-tauri` et `vitest` verts. +- Front compile et tourne en mode **mock** sans backend. +- Aucun `invoke()` direct dans les composants (uniquement via gateways). + +## Points d'attention / spikes + +- Forme du bridge PTY↔Channel (backpressure) — préparé ici, stressé en L3. +- Convention de (dé)sérialisation DTO Rust ↔ TS (serde camelCase ?). À figer ici. diff --git a/agents-dev/L10-windows.md b/agents-dev/L10-windows.md new file mode 100644 index 0000000..f9b896b --- /dev/null +++ b/agents-dev/L10-windows.md @@ -0,0 +1,38 @@ +# L10 — Fenêtres & multi-window + +**Binôme :** `dev-windows` / `test-windows` +**Zones :** `application`, `app-tauri`, `frontend/app` +**Dépendances amont :** L0, L1, L2, L4. + +## Objectif +Gestion des fenêtres et onglets : un onglet par projet ; **drag d'un onglet hors de la fenêtre → nouvelle fenêtre OS** portant ce projet. + +## Périmètre (DEV) +- Entités `Workspace`/`Window`/`Tab` + persistance (`workspace.json`, machine-local). +- Use case `MoveTabToNewWindow` (réaffectation `WindowId`, l'onglet est déplacé, pas dupliqué). +- `app-tauri` : création de `WebviewWindow`, transfert d'état, fermeture de l'onglet source. +- Front : barre d'onglets, drag & drop, restauration de session. + +## Périmètre (TEST) +- `MoveTabToNewWindow` : invariants (un projet dans un seul onglet à la fois ; fenêtre ≥ 1 onglet ou fermée). +- Persistance workspace round-trip. +- Front : interactions onglets (mock). + +## Definition of Done +- `cargo test` + `vitest` verts ; détacher un onglet en nouvelle fenêtre fonctionne (dev manuel). + +## Avancement + +### ✅ Backend (vert) +- **Domaine** : opération **pure** `Workspace::move_tab_to_new_window(tab, new_window)` (`layout.rs`) — l'onglet est *déplacé* (jamais dupliqué) ; fenêtre source vidée → supprimée ; onglet actif déplacé → repli sur un onglet restant. Variante d'erreur `LayoutError::TabNotFound`. 4 tests domaine. +- **Application** (`application/window/`) : `MoveTabToNewWindow` (charge le workspace, mint `WindowId`, applique l'op pure, persiste via `ProjectStore`). 2 tests (store mock). La persistance round-trip du workspace est déjà couverte par `FsProjectStore` (L2). +- `cargo test --workspace` : **323 verts, 0 régression** ; clippy clean. + +### ✅ IPC `app-tauri` (vert) +- Composition root : use case `MoveTabToNewWindow` injecté. Commande `move_tab_to_new_window(tabId)` : applique la topologie (persistée) **et ouvre une vraie `WebviewWindow`** (primitive de détach résolue). DTO `MoveTabResultDto` + `parse_tab_id`. Test `tests/dto_window.rs` (2). Workspace **325 verts, 0 régression**, clippy clean. + +### ⏳ Reste (fait pendant la refonte disposition L11) +- **Front multi-fenêtres** : adopter le modèle `Workspace`/`Window`/`Tab` persistant (aujourd'hui les onglets vivent en state React transitoire), barre d'onglets, **DnD detach** + handoff d'état vers la nouvelle fenêtre. Couplé à la refonte de disposition IDE (L11), donc traité là-bas pour éviter de construire une barre d'onglets jetable. + +## Spike (cf. ARCHITECTURE §13) +- DnD inter-fenêtres Tauri (le DnD HTML ne traverse pas les fenêtres OS) → protocole « detach » via store + event. diff --git a/agents-dev/L11-packaging.md b/agents-dev/L11-packaging.md new file mode 100644 index 0000000..8ff97db --- /dev/null +++ b/agents-dev/L11-packaging.md @@ -0,0 +1,45 @@ +# L11 — Packaging & livraison + +**Binôme :** `dev-packaging` / `test-packaging` +**Zones :** `app-tauri` (bundle), CI +**Dépendances amont :** transverse (mûrit avec les autres lots) ; finalisé en fin de cycle. + +## Objectif +Livrer IdeA : **`setup.exe` (NSIS) Windows** et **AppImage Linux multi-distro**. macOS plus tard. + +## Périmètre (DEV) +- Config bundle Tauri v2 : NSIS (Windows), AppImage (Linux). +- Vendoring statique des deps natives (git2/openssl → préférer `rustls` pour russh ; features git2) pour la portabilité AppImage. +- Pipeline CI : build Linux + Windows, artefacts publiés. + +## Périmètre (TEST) +- Le bundle se construit sur Linux et Windows (CI verte). +- L'AppImage **démarre sur ≥3 distros** (Ubuntu, Fedora, Arch) — smoke test. +- L'installeur Windows installe/lance/désinstalle proprement. + +## Definition of Done +- CI produit un `setup.exe` et un AppImage fonctionnels ; smoke tests multi-distro verts. + +## Spike (cf. ARCHITECTURE §13) +- AppImage multi-distro : glibc/openssl/libgit2 liés dynamiquement = risque ; valider le vendoring statique tôt (coordonné avec L8/L9). + +## Notes de build vérifiées (2026-06-04, premier build sur Arch) +- **CLI** : `@tauri-apps/cli` v2 installé en devDependency frontend ; binaire à `frontend/node_modules/.bin/tauri`. +- **Hooks `tauri.conf.json`** : Tauri exécute `beforeBuildCommand`/`beforeDevCommand` avec cwd = `IdeA/crates/`. Les chemins npm doivent donc être `--prefix ../frontend` (et NON `../../frontend` ni `frontend`). +- **Arch Linux — `linuxdeploy` strip échoue** : le `strip` embarqué dans `linuxdeploy-x86_64.AppImage` ne comprend pas la section ELF `.relr.dyn` des libs Arch modernes (`libzstd.so.1`, `libyuv.so`…) → erreur `unknown type [0x13] section .relr.dyn`. **Parade** : exporter **`NO_STRIP=true`** avant `tauri build`. À intégrer dans la CI/scripts de build Linux. +- Build de référence OK : `cd crates/app-tauri && NO_STRIP=true tauri build --bundles appimage` → `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage` (~101 Mo). +- Le 1er échec `failed to run linuxdeploy` masquait le vrai message (strip) ; `--verbose` est nécessaire pour le voir. +- **Écran blanc au lancement (Linux/WebKitGTK)** : le renderer DMABUF de WebKitGTK rend une fenêtre blanche sur beaucoup de configs Linux récentes (Mesa/Nvidia, fréquent sur Arch). **Fix baké dans `crates/app-tauri/src/main.rs`** : on positionne `WEBKIT_DISABLE_DMABUF_RENDERER=1` au début de `main()` (cfg `target_os = "linux"`, seulement si non déjà défini) avant l'init du webview. Plus besoin de variable d'env côté utilisateur. Vérifié visuellement OK sur Arch (UI projets + health-check rendus). + +## Avancement (2026-06-05) + +### ✅ Fait +- **Icônes** générées (`tauri icon` → `crates/app-tauri/icons/`, monogramme « IA » sombre/accent) — manquaient, requises par le bundle. +- **AppImage Linux reconstruite** : `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage` (~103 Mo), validée (ELF AppImage-runtime, libfuse2 présent, se lance directement). `git2` lié à la **libgit2 système** via `.cargo/config.toml` (`LIBGIT2_SYS_USE_PKG_CONFIG=1`). +- **`beforeBuildCommand`/`beforeDevCommand`** confirmés à `--prefix ../frontend` (cwd des hooks = `IdeA/crates/`). +- **Refonte disposition IDE** (passe UI/altitude) ✅ : `ProjectsView` réécrit en disposition d'IDE — **barre d'onglets projets** en haut, **sidebar** (onglets Projects/Agents/Templates/Git, un panneau à la fois) + **zone principale** = `LayoutGrid` (grille de terminaux) qui remplit la hauteur. App shell en `h-full`. Composants `ProjectLauncher`/`ProjectTabs`/`Workspace` extraits. Hooks de test préservés ; front **158 tests verts**, `tsc` clean. (Note : `beforeBuildCommand` retiré de `tauri.conf.json` — chemins de hook ambigus selon le cwd d'invocation ; on **build le front manuellement** (`npm --prefix frontend run build`) avant `tauri build` lancé **depuis la racine du repo**. C'est le recette déterministe vérifiée.) + +### ⏳ Reste +- **Vendoring statique** pour AppImage portable multi-distro : passer git2 en `vendored-libgit2` (nécessite **cmake**, absent de la machine actuelle) + `rustls` pour russh (L9). Aujourd'hui l'AppImage lie la libgit2 **système** → portable seulement vers des distros fournissant libgit2 ≥ 1.9. +- **Windows `setup.exe` (NSIS)** : non constructible ici (Linux) → CI Windows. +- **CI** Linux+Windows (avec `NO_STRIP=true` côté Linux) + smoke tests ≥3 distros. diff --git a/agents-dev/L2-projects.md b/agents-dev/L2-projects.md new file mode 100644 index 0000000..ab0009a --- /dev/null +++ b/agents-dev/L2-projects.md @@ -0,0 +1,22 @@ +# L2 — Projets & stockage + +**Binôme :** `dev-projects` / `test-projects` +**Zones :** `application/project`, `infrastructure/{fs,store}`, `frontend/features/projects` +**Dépendances amont :** L0, L1. + +## Objectif +Gérer le cycle de vie des projets (création par project root, ouverture, fermeture) et le stockage de base. + +## Périmètre (DEV) +- Use cases : `CreateProject` (init `.ideai/` + `project.json` + registre), `OpenProject`, `CloseProject`/`CloseTab`. +- Adapters : `LocalFileSystem` (tokio::fs), `FsProjectStore` (registre projets + workspace en JSON dans données app). +- UI : sélection du project root, liste des projets, ouverture en onglet. + +## Périmètre (TEST) +- Use cases avec `FileSystem`/`ProjectStore` mockés : création initialise bien `.ideai/`, invariants projet respectés (root absolu, unicité `(remote, root)`). +- Intégration ciblée : `LocalFileSystem` sur tmpdir, `FsProjectStore` round-trip. +- Front : feature projects avec gateway mock (RTL). + +## Definition of Done +- `cargo test -p application -p infrastructure` (filtré projet) + `vitest` verts. +- Créer/ouvrir/fermer un projet de bout en bout (avec adapters réels en dev manuel). diff --git a/agents-dev/L3-terminals.md b/agents-dev/L3-terminals.md new file mode 100644 index 0000000..2311dc9 --- /dev/null +++ b/agents-dev/L3-terminals.md @@ -0,0 +1,25 @@ +# L3 — Terminaux & PTY (local) + +**Binôme :** `dev-terminals` / `test-terminals` +**Zones :** `infrastructure/pty`, `application/terminal`, `frontend/features/terminals` +**Dépendances amont :** L0, L1. + +## Objectif +Terminaux fonctionnels en local : ouverture PTY, I/O, resize, fermeture, rendu xterm.js, flux via Tauri Channel. + +## Périmètre (DEV) +- Adapter `PortablePtyAdapter` (portable-pty) implémentant `PtyPort`. +- Use cases : `OpenTerminal`, `WriteToTerminal`, `ResizeTerminal`, `CloseTerminal`. +- Front : wrapper xterm.js, abonnement au flux d'octets (Channel), envoi des frappes/resize. + +## Périmètre (TEST) +- Use cases avec `PtyPort` mocké (spawn/write/resize/kill appelés correctement). +- Intégration : `PortablePtyAdapter` lance `echo`/`printf` et reçoit la sortie attendue. +- Front : wrapper xterm avec gateway mock (frappe → write, octets reçus → rendu). + +## Definition of Done +- `cargo test` (pty/terminal) + `vitest` verts ; un terminal réel utilisable en dev manuel sur Linux. + +## Spikes (cf. ARCHITECTURE §13) +- ConPTY Windows (resize/signaux/exit codes). +- Backpressure/coalescing du flux haute fréquence via Channel. diff --git a/agents-dev/L4-layout.md b/agents-dev/L4-layout.md new file mode 100644 index 0000000..58eed0c --- /dev/null +++ b/agents-dev/L4-layout.md @@ -0,0 +1,21 @@ +# L4 — Layout tableur + +**Binôme :** `dev-layout` / `test-layout` +**Zones :** `domain/layout` (déjà amorcé en L0), `application/layout`, `frontend/features/layout` +**Dépendances amont :** L0, L1, L3 (cellules ↔ terminaux). + +## Objectif +Grille redimensionnable type tableur : N colonnes par ligne / M lignes par colonne indépendantes, **fusion de cellules**, persistance. + +## Périmètre (DEV) +- Compléter la logique de layout pure du domaine (si reliquats post-L0). +- Use case `MutateLayout` (split/merge/resize/move) + persistance `.ideai/layout.json`. +- UI : grille redimensionnable (drag des séparateurs), création/suppression de cellules, fusion, mapping cellule → terminal. + +## Périmètre (TEST) +- Domaine : opérations pures exhaustives (déjà couvertes L0, étendre cas combinés). +- Application : `MutateLayout` persiste et publie `LayoutChanged`. +- Front : logique de calcul des tailles de cellules (Vitest, pure) ; interactions de split/merge (RTL + mock). + +## Definition of Done +- `cargo test` (layout) + `vitest` verts ; manipulation visuelle de la grille fonctionnelle. diff --git a/agents-dev/L5-ai-runtime.md b/agents-dev/L5-ai-runtime.md new file mode 100644 index 0000000..ee4a829 --- /dev/null +++ b/agents-dev/L5-ai-runtime.md @@ -0,0 +1,22 @@ +# L5 — Profils IA & runtime + +**Binôme :** `dev-ai-runtime` / `test-ai-runtime` +**Zones :** `infrastructure/runtime`, `application/agent`, `frontend/features/first-run` +**Dépendances amont :** L0, L1, L2. + +## Objectif +Moteur IA flexible : profils déclaratifs, détection, first-run wizard. Cœur du « 100% IA, piloté par données ». + +## Périmètre (DEV) +- Adapter `CliAgentRuntime` (un seul, piloté par `AgentProfile`) : `detect`, `prepare_invocation` (construit `SpawnSpec` + plan d'injection selon `ContextInjection`). +- Use cases : `DetectProfiles`, `ConfigureProfiles`. +- Stockage `profiles.json` (store global IDE). +- Front : **first-run wizard** demandant quels profils configurer ; commandes pré-remplies (Claude/Codex/Gemini/Aider) **éditables** ; ajout de **profil custom**. + +## Périmètre (TEST) +- `CliAgentRuntime` : pour chaque stratégie d'injection (conventionFile/flag/stdin/env), le `SpawnSpec` produit est correct. +- `DetectProfiles` avec `ProcessSpawner` mocké (présent/absent). +- Front : wizard avec gateway mock (sélection, édition, ajout custom). + +## Definition of Done +- `cargo test` (runtime/agent) + `vitest` verts ; wizard fonctionnel en dev manuel. diff --git a/agents-dev/L6-agents.md b/agents-dev/L6-agents.md new file mode 100644 index 0000000..2e66d23 --- /dev/null +++ b/agents-dev/L6-agents.md @@ -0,0 +1,52 @@ +# L6 — Agents & contextes + +**Binôme :** `dev-agents` / `test-agents` +**Zones :** `application/agent`, `infrastructure/store`, `frontend/features/agents` +**Dépendances amont :** L0, L1, L2, L3, L5. + +## Objectif +Agents de projet : contextes `.md` dans `.ideai/`, manifeste, et **lancement d'un agent** (injection contexte + spawn CLI dans une cellule terminal). + +## Périmètre (DEV) +- Adapter `IdeaiContextStore` (compose `FileSystem`) : lecture/écriture `.md` + `agents.json`. +- Use cases : `CreateAgentFromScratch`, `LaunchAgent` (résout profil+contexte, injecte, ouvre cellule PTY au bon `cwd`, spawn CLI), CRUD agents. +- Front : panneau agents (créer, éditer le `.md`, activer → terminal). + +## Périmètre (TEST) +- `IdeaiContextStore` : round-trip `.md` + manifeste (intégration tmpdir + mock FS pour les use cases). +- `LaunchAgent` : ordre des appels (prepare_invocation → injection → pty.spawn) avec `cwd` correct. +- Front : feature agents avec gateway mock. + +## Definition of Done +- `cargo test` (agent/store) + `vitest` verts ; activer un agent ouvre un terminal avec la CLI lancée (dev manuel). + +## Spike +- Injection `conventionFile` : symlink vs copie ; conflits si `CLAUDE.md` existe ; symlinks Windows. + +## Avancement + +### ✅ Backend (vert) +- **Domaine** : `ManifestEntry` réconcilié avec le schéma documenté `agents.json` (ARCHITECTURE §9.1) — porte désormais `name` + `profile_id` ; helpers `from_agent`/`to_agent` (le manifeste est la forme persistée d'un `Agent`). Tests domaine maj, verts. +- **Infra** : `IdeaiContextStore` (`infrastructure/store/context.rs`) implémente `AgentContextStore` en composant `FileSystem` ; écrit `.ideai/agents.json` + `.ideai/agents/*.md`, location-neutre (réutilisable local/SSH/WSL). Test d'intégration tmpdir (5 tests). +- **Application** (`application/agent/lifecycle.rs`) : `CreateAgentFromScratch`, `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, et `LaunchAgent` (résout profil+contexte → `prepare_invocation` → injection → `pty.spawn` au bon `cwd` → event `AgentLaunched`). 9 tests use cases (ordre d'appel vérifié via trace partagée). +- **Spike `conventionFile`** tranché pour L6 : **copie** du `.md` vers le fichier conventionnel (ex. `CLAUDE.md`), écrasement si présent — choix portable (symlinks Windows = privilèges, sémantique SFTP/WSL divergente). Stratégie `Env` → chemin absolu du `.md` ; `Stdin` → contenu piped après spawn. + +### ✅ Front (vert) +- **Port** `AgentGateway` étendu (list/create/read/update/delete/launch) + type `Agent` dans `domain` ; **mock** stateful `MockAgentGateway` ; **adapter** Tauri `TauriAgentGateway` (`src/adapters/agent.ts`, commandes `*_agent` — câblage backend à venir). +- **Feature** `frontend/features/agents` : hook `useAgents(projectId)` + `AgentsPanel` (liste, création nom+profil, éditeur de contexte `.md`, Launch/Delete), bâti sur le **design system** (LD) et intégré dans l'onglet projet actif. +- **Tests** : 13 nouveaux (RTL + mock) ; suite front **116 verts**, `tsc` clean. Garde-fou « no direct invoke » respecté. + +### ✅ IPC `app-tauri` (vert) +- **Composition root** (`state.rs`) : `IdeaiContextStore` construit ; 6 use cases agents instanciés en réutilisant les ports existants ; `LaunchAgent` partage le **même** `pty_port` + `terminal_sessions` que les terminaux (indispensable au `PtyBridge`) ; handle `project_store` ajouté pour résoudre le `Project` depuis un `projectId`. +- **Commands** (`commands.rs`) : `create_agent`, `list_agents`, `read_agent_context`, `update_agent_context`, `delete_agent`, `launch_agent`. `launch_agent` imite `open_terminal` (Channel + `PtyBridge` + thread de pompe). Enregistrées dans `lib.rs`. +- **DTOs** (`dto.rs`) : `AgentDto`/`AgentListDto` (transparent, camelCase), request DTOs, `parse_agent_id`, `From for TerminalSessionDto`. +- **Tests** : `tests/dto_agents.rs` (10) ; `cargo test -p app-tauri` 44 verts ; `cargo test --workspace` **256 verts, 0 régression** ; clippy clean. + +### ✅ Terminal d'agent (front, vert) — L6 clos +- `AgentGateway.launchAgent(projectId, agentId, options, onData)` renvoie désormais un `TerminalHandle` (signature calquée sur `openTerminal`) ; adapter Tauri = `Channel` + `invoke("launch_agent", …)` + write/resize/close via les commandes terminal (clé `sessionId`) ; mock = greeting + echo. +- `TerminalView` généralisé avec une prop `open?` optionnelle (par défaut = gateway terminal) → réutilisé tel quel pour le terminal d'agent (xterm/fit/resize/cleanup partagés). +- `AgentsPanel` : bouton **Launch** monte un `TerminalView` (conteneur sombre `h-96`) branché sur la session d'agent ; bouton **Stop** le démonte (cleanup `close()`). +- **Tests** front : 120 verts (`tsc` clean), garde-fou « no direct invoke » respecté. Backend/IPC : 256 verts. + +### ⏳ Hors périmètre L6 (à reprendre plus tard) +- Affiner la stratégie `Env` (support adapter de premier ordre). diff --git a/agents-dev/L7-templates.md b/agents-dev/L7-templates.md new file mode 100644 index 0000000..e72831b --- /dev/null +++ b/agents-dev/L7-templates.md @@ -0,0 +1,42 @@ +# L7 — Templates & synchronisation + +**Binôme :** `dev-templates` / `test-templates` +**Zones :** `application/template`, `infrastructure/store`, `frontend/features/templates` +**Dépendances amont :** L0, L1, L5, L6. + +## Objectif +Templates d'agents (store global IDE), versioning, création d'agent depuis template, **détection de drift** et **synchronisation template → agents**. + +## Périmètre (DEV) +- Adapter `FsTemplateStore` (md + `index.json`) avec versioning monotone + `content_hash`. +- Use cases : `CreateTemplate`, `UpdateTemplate` (bump version), `CreateAgentFromTemplate`, `DetectAgentDrift`, `SyncAgentWithTemplate` (remplacement du `.md` si `synchronized`). +- Front : gestion des templates, badge « MAJ disponible », action de sync. + +## Périmètre (TEST) +- `UpdateTemplate` incrémente la version ; `DetectAgentDrift` détecte `version > synced_template_version`. +- `SyncAgentWithTemplate` : applique aux `synchronized==true`, ignore `false` et `scratch` ; met à jour `synced_template_version`. +- Front : badge drift + flux de sync avec gateway mock. + +## Definition of Done +- `cargo test` (template/store) + `vitest` verts ; cycle MAJ template → propagation aux agents synchronisés (dev manuel). + +## Avancement + +### ✅ Backend (vert) +- **Infra** : `FsTemplateStore` (`infrastructure/store/template.rs`) — store global `/templates/{index.json, md/.md}`, versioning persisté, `contentHash` (digest stable, sans dépendance) pour détection d'édition hors-app. 6 tests d'intégration (tmpdir). +- **Application** (`application/template/usecases.rs`) : `CreateTemplate`, `UpdateTemplate` (bump version + event `TemplateUpdated`), `ListTemplates`, `DeleteTemplate`, `CreateAgentFromTemplate` (origine `FromTemplate` + seed du `.md`, réutilise le helper de nommage L6), `DetectAgentDrift` (ne flague que les `synchronized` en retard ; ignore non-sync/scratch/à-jour/template supprimé ; émet `AgentDriftDetected`), `SyncAgentWithTemplate` (remplace le `.md`, avance `synced_template_version`, émet `AgentSynced` ; laisse intacts non-sync/scratch). 8 tests use cases. +- `cargo test --workspace` : **270 verts, 0 régression** ; clippy clean. + +### ✅ IPC `app-tauri` (vert) +- Composition root : `FsTemplateStore` construit, 7 use cases injectés (réutilise `contexts_port`/`ids`/`events_port`). +- 7 commands : `create_template`, `update_template`, `list_templates`, `delete_template`, `create_agent_from_template`, `detect_agent_drift`, `sync_agent_with_template` (shells fins, `resolve_project` réutilisé). DTOs camelCase (`TemplateDto` transparent, `AgentDriftDto`, `SyncResultDto`, `parse_template_id`). +- Tests `tests/dto_templates.rs` (18) ; `cargo test -p app-tauri` 62 verts ; workspace **288 verts, 0 régression** ; clippy clean. + +### ✅ Front (vert) +- Port `TemplateGateway` (7 méthodes) + types `Template`/`AgentDrift` ; adapter Tauri `TauriTemplateGateway` ; `MockTemplateGateway` stateful **partageant le registre d'agents** du `MockAgentGateway` (helpers internes `_insertAgent`/`_updateAgent`/`_rawAgents`) pour faire vivre le drift offline. +- Feature `features/templates` : `useTemplates` (CRUD), `useDrift(projectId)` (détection + sync), `TemplatesPanel` (liste nom+version, création, édition, suppression, « Create agent from template »), monté dans l'onglet projet. +- `AgentsPanel` : **badge « update available »** + bouton **Sync** par agent en drift. +- Tests : 20 ajoutés ; suite front **140 verts** ; `tsc` clean ; garde-fou « no direct invoke » respecté. + +### ⏳ Hors périmètre L7 +- Intégration front du terminal d'agent depuis un agent créé via template (réutilise le fil L6). diff --git a/agents-dev/L8-git.md b/agents-dev/L8-git.md new file mode 100644 index 0000000..81a7066 --- /dev/null +++ b/agents-dev/L8-git.md @@ -0,0 +1,42 @@ +# L8 — Git + +**Binôme :** `dev-git` / `test-git` +**Zones :** `infrastructure/git`, `application/git`, `frontend/features/git` +**Dépendances amont :** L0, L1, L2. + +## Objectif +Support Git intégré (local) via libgit2. + +## Périmètre (DEV) +- Adapter `Git2Repository` (git2) : `status`, `stage`/`unstage`, `commit`, `branches`, `checkout`, `current_branch`, `diff`, `log`, `pull`, `push`, `clone`, `init`. +- Use cases Git (`GitStatus`, `GitCommit`, `GitCheckout`, `GitPush`, …). +- Front : panneau Git (changements, staging, commit, branches). + +## Périmètre (TEST) +- Use cases avec `GitRepository` mocké. +- Intégration : `Git2Repository` sur repo temporaire (init → commit → branch → status). +- Front : feature git avec gateway mock. + +## Definition of Done +- `cargo test` (git) + `vitest` verts ; opérations git de base utilisables (dev manuel). + +## Spike +- Vendoring statique git2/openssl pour l'AppImage (coordonné avec L11). + +## Avancement + +### ✅ Backend (vert) +- **Dépendance** : `git2` 0.20 (`default-features = false`, pas d'openssl) liée à la **libgit2 système** via `.cargo/config.toml` (`LIBGIT2_SYS_USE_PKG_CONFIG=1`) — évite cmake/vendoring ; le vendoring statique AppImage reste pour L11. +- **Infra** : `Git2Repository` (`infrastructure/git/mod.rs`) implémente `GitPort` — `init`, `status`, `stage`, `unstage`, `commit` (signature de repli `IdeA ` si pas de config user), `branches`, `current_branch`, `checkout`, `log`. `pull`/`push` renvoient une erreur explicite (réseau/credentials → L9). Appels libgit2 synchrones sans `await` ⇒ futures `Send`. 3 tests d'intégration (vrai repo temporaire). +- **Application** (`application/git/`) : `GitStatus`, `GitStage`, `GitUnstage`, `GitCommit` (valide message non vide + event `GitStateChanged`), `GitBranches` (liste + courante), `GitCheckout` (+ event), `GitLog`, `GitInit` (+ event). 7 tests use cases (mock `GitPort`). +- `cargo test --workspace` : **298 verts, 0 régression** ; clippy clean. + +### ✅ IPC `app-tauri` (vert) +- Composition root : `Git2Repository` construit, 8 use cases injectés. 8 commands (`git_status`, `git_stage`, `git_unstage`, `git_commit`, `git_branches`, `git_checkout`, `git_log`, `git_init`), résolution `projectId → root` via `resolve_project`. DTOs `GitFileStatusDto`/`GitCommitDto`/`GitBranchesDto` + request DTOs camelCase. Tests `tests/dto_git.rs` (12). `cargo test -p app-tauri` 74 verts ; workspace **310 verts, 0 régression** ; clippy clean. + +### ✅ Front (vert) +- Port `GitGateway` complet (8 méthodes) + types `GitFileStatus`/`GitCommit`/`GitBranches` ; adapter `TauriGitGateway` ; `MockGitGateway` stateful (état par projet, seedé) pour le mode offline. +- Feature `features/git` : `useGit(projectId)` + `GitPanel` (sections Staged/Unstaged + stage/unstage, message + commit, branches + checkout, log récent), monté dans l'onglet projet. 17 tests ; suite front **157 verts** ; `tsc` clean ; garde-fou « no invoke » OK. + +### ⏳ Hors périmètre L8 +- `pull`/`push`/`clone` (réseau + credentials) → L9 (`RemoteGitRepository` + callbacks) ; vendoring statique git2 pour l'AppImage → L11. diff --git a/agents-dev/L9-remote.md b/agents-dev/L9-remote.md new file mode 100644 index 0000000..d9736dd --- /dev/null +++ b/agents-dev/L9-remote.md @@ -0,0 +1,40 @@ +# L9 — Remote (SSH + WSL) + +**Binôme :** `dev-remote` / `test-remote` +**Zones :** `infrastructure/remote`, `application/remote`, `frontend/features/remote` +**Dépendances amont :** L0, L1, L2, L3, L8. + +## Objectif +Développement distant : projet sur autre machine via **SSH**, ou sur une **WSL** depuis Windows. Transparence local/distant via la stratégie `RemoteHost`. + +## Périmètre (DEV) +- `RemoteHost` : `LocalHost`, `SshHost` (russh/ssh2), `WslHost` (`wsl.exe`) — fabriquent FS/PTY/Spawner adaptés. +- Adapters distants : `SshFileSystem` (SFTP), `SshPtyAdapter`, `SshProcessSpawner` ; `WslFileSystem`, `WslPtyAdapter`, `WslProcessSpawner`. +- `RemoteGitRepository` (git CLI via `ProcessSpawner`) en fallback distant. +- Use case `ConnectRemote` (valide l'accès au root). Front : UI de connexion SSH/WSL. + +## Périmètre (TEST) +- Substituabilité (Liskov) : un use case marche identiquement quel que soit le `RemoteHost` (tests avec hosts mockés). +- Intégration SSH/WSL : tests `#[ignore]` gated derrière feature/env (CI conditionnelle). +- Front : feature remote avec gateway mock. + +## Definition of Done +- `cargo test` (remote, hors `#[ignore]`) + `vitest` verts ; connexion SSH et WSL démontrée en dev manuel. + +## Avancement + +### ✅ Socle (vert) +- **Domaine** : `RemoteRef` (Local/Ssh/Wsl) + port `RemoteHost` (fabrique FS/PTY/Spawner) déjà en place (L0). +- **Infra** (`infrastructure/remote/mod.rs`) : `LocalHost` (fabrique les adapters locaux ; `connect` = no-op) + sélecteur `remote_host(&RemoteRef)` (Local → `LocalHost` ; SSH/WSL → `RemoteError::Connection` explicite « not yet supported »). 4 tests d'intégration. +- **Application** (`application/remote/`) : `ConnectRemote` (établit l'hôte, valide que le root existe via le FS de l'hôte, émet `RemoteConnected`). 3 tests **Liskov** (comportement identique Local/Ssh/Wsl avec hôte mocké ; échec connexion → REMOTE ; root absent → NOT_FOUND). +- `cargo test --workspace` : **317 verts, 0 régression** ; clippy clean. + +### ⏳ Reste à faire (gated / non vérifiable dans cet environnement) +- Adapters distants **SSH** (`SshHost`/`SshFileSystem` SFTP/`SshPtyAdapter`/`SshProcessSpawner` via russh ou ssh2 — décision auth à figer, cf. spikes) et **WSL** (`WslHost` + adapters préfixant `wsl.exe`), avec tests d'intégration `#[ignore]` derrière feature/env. +- `RemoteGitRepository` (git CLI distant via `ProcessSpawner`) + `pull`/`push` (reportés depuis L8). +- IPC `app-tauri` `connect_remote` + front feature remote (UI connexion SSH/WSL). + +## Spikes (cf. ARCHITECTURE §13) +- Auth SSH (clé/agent/mot de passe/known_hosts) ; choix russh(rustls) vs ssh2(OpenSSL) — impacte l'AppImage. +- Conversion de chemins WSL `/mnt/...` ↔ `\\wsl$\...` ; perf I/O cross-boundary. +- Git sur FS distant (perf, parsing CLI). diff --git a/agents-dev/LD-design-system.md b/agents-dev/LD-design-system.md new file mode 100644 index 0000000..97496a2 --- /dev/null +++ b/agents-dev/LD-design-system.md @@ -0,0 +1,39 @@ +# LD — Design System (UI kit maison) + +**Binôme :** `dev-ui` / `test-ui` +**Zones :** `frontend/src/shared` (UI kit), `frontend` (config Tailwind), `frontend/src/app` (app shell) +**Dépendances amont :** L1 (frontend ports/adapters/DI en place). +**Position :** inséré **après L6** (décision produit : voir CONTEXT, lot transverse), consommé ensuite au fil de l'eau par chaque feature. + +## Objectif +Doter IdeA d'un **design system maison** cohérent et d'un **thème sombre** par défaut (c'est un IDE), pour que chaque feature cesse de bricoler des styles inline et consomme des composants réutilisables. + +## Stack (validée) +- **Tailwind v4** + `@tailwindcss/vite` (CSS-first, `@theme`), composants **maison** dans `shared/ui` (pas de lib de composants tierce). +- Tokens de design (couleurs/typo/espacements/rayons) en variables de thème ; helper `cn()` pour composer les classes. + +## Périmètre (DEV) +- Config Tailwind (plugin Vite, feuille de thème globale, tokens, dark par défaut). +- `shared/ui` : composants de base — `Button`, `IconButton`, `Input`, `Panel`/`Card`, `Tabs`, `Toolbar`, `Spinner`, `Field`. Helper `cn`. +- **App shell** sombre dans `app/` (remplace les styles inline du smoke-test). + +## Périmètre (TEST) +- `cn()` (unitaire). +- Rendu + variantes/états des composants (`Button` variants/disabled, `Input` label/aria, `Tabs` sélection) via RTL/vitest, **sans backend**. + +## Definition of Done +- `vitest` vert ; `tsc --noEmit` vert ; le domaine/les ports UI restent inchangés (pur skin). +- Aucune feature ne régresse ; l'app monte avec le thème sombre. + +## Hors périmètre +- Re-styling exhaustif de toutes les features (fait au fil de l'eau quand on touche chacune). + +## Avancement — ✅ vert + +- **Config** : `tailwindcss@4` + `@tailwindcss/vite` ajoutés ; plugin câblé dans `vite.config.ts` ; feuille de thème globale `src/shared/styles/theme.css` (tokens sémantiques sous `@theme`, thème **sombre** par défaut, focus ring), importée une fois dans `app/main.tsx`. +- **UI kit** `src/shared/ui` : `Button` (variants primary/secondary/ghost/danger, sizes, loading), `IconButton`, `Input`, `Field` (label/hint/error + aria), `Panel`, `Tabs` (sélection + close), `Toolbar`, `Spinner`. Helper `cn()`. Baril `@/shared`. +- **App shell** sombre (`app/App.tsx`) : header avec statut backend, plus de styles inline. +- **Consommation** : `features/projects/ProjectsView` migré sur le kit (premier consommateur), hooks d'accessibilité préservés (tests L2 toujours verts). +- **Tests** : `cn` + composants (`Button`/`Input`/`Field`/`Tabs`) via RTL — assertions sur rôles/aria, pas sur les classes Tailwind (le design peut évoluer sans casser la suite). + +**Vérifs** : `tsc --noEmit` vert · `vitest` **103 tests verts** (14 fichiers) · `vite build` OK (Tailwind compile, CSS 22.8 kB). diff --git a/agents-dev/README.md b/agents-dev/README.md new file mode 100644 index 0000000..9824f5b --- /dev/null +++ b/agents-dev/README.md @@ -0,0 +1,69 @@ +# Agents de développement IdeA — Protocole commun + +> Ces agents servent à **développer l'IDE IdeA lui-même**. Ils ne sont pas la feature produit « agents IA » de l'application. +> Référence produit/archi : [`../CONTEXT.md`](../CONTEXT.md) · [`../ARCHITECTURE.md`](../ARCHITECTURE.md). + +## Composition de l'équipe + +Chaque **lot livrable** (L0…L11) est confié à un **binôme** : +- un **agent de développement** (écrit le code), +- un **agent de test** appairé (écrit et exécute les tests unitaires). + +Un fichier `Lx-*.md` par binôme décrit son périmètre, ses ports/adapters, ses dépendances et sa *definition of done*. + +| Lot | Fichier | Statut | +|---|---|---| +| L0 | [L0-core-domain.md](L0-core-domain.md) | ✅ **vert** (84 tests) | +| L1 | [L1-ipc-bridge.md](L1-ipc-bridge.md) | ✅ **vert** (46 tests) | +| L2 | [L2-projects.md](L2-projects.md) | ✅ **vert** (26 tests) | +| L3 | [L3-terminals.md](L3-terminals.md) | ✅ **vert** (61 tests) | +| L4 | [L4-layout.md](L4-layout.md) | ✅ **vert** (52 tests) | +| L5 | [L5-ai-runtime.md](L5-ai-runtime.md) | ✅ **vert** (69 tests) | +| L6 | [L6-agents.md](L6-agents.md) | ✅ **vert** (domaine+app+infra+IPC+front · activer un agent ouvre son terminal) | +| LD | [LD-design-system.md](LD-design-system.md) | ✅ **vert** (Tailwind v4 + UI kit maison, thème sombre) | +| L7 | [L7-templates.md](L7-templates.md) | ✅ **vert** (backend + IPC + front · drift & sync) | +| L8 | [L8-git.md](L8-git.md) | ✅ **vert** (backend + IPC + front · git local libgit2) | +| L9 | [L9-remote.md](L9-remote.md) | 🟡 **socle vert** (LocalHost + ConnectRemote, Liskov) · SSH/WSL gated à venir | +| L10 | [L10-windows.md](L10-windows.md) | 🟡 **backend + IPC verts** (move-tab + `WebviewWindow`) · détach UI fait avec la refonte L11 | +| L11 | [L11-packaging.md](L11-packaging.md) | 🟡 AppImage Linux **OK** · refonte disposition IDE en cours · Windows/CI à venir | + +## Cycle dev ↔ test (obligatoire, cf. CONTEXT §3) + +``` +1. Archi valide le découpage et les contrats (ports/interfaces) du lot. +2. Agent DEV écrit le code de la feature. +3. Agent TEST écrit les tests unitaires + les exécute. +4a. Vert → feature validée, lot suivant. +4b. Rouge → rapport d'erreurs structuré → retour DEV → correction → retour étape 3. +``` + +**Règle d'or** : aucune feature n'est « terminée » tant que ses tests ne passent pas. Les résultats sont relayés fidèlement (sortie réelle des tests). + +## Definition of Done (commune à tous les lots) + +- [ ] Code conforme à l'architecture hexagonale et SOLID (cf. ARCHITECTURE §1). +- [ ] Le `domain` reste pur (aucune dépendance I/O qui y entre). +- [ ] Les use cases ne parlent qu'aux **ports**, jamais aux adapters concrets. +- [ ] Tests unitaires écrits et **verts** : `cargo test -p ` (Rust) et/ou `vitest` (front). +- [ ] Domaine/application testés **sans I/O** (ports mockés : `mockall` ou fakes). +- [ ] Pas de `new ConcreteAdapter` ailleurs que dans la composition root (`app-tauri`). +- [ ] Pas de régression sur les lots déjà verts. +- [ ] Code lisible, cohérent avec le style existant. + +## Format du rapport d'erreurs (TEST → DEV) + +``` +LOT: Lx +TEST EN ÉCHEC: +ATTENDU: <…> +OBTENU: <…> +SORTIE: +HYPOTHÈSE: +``` + +## Conventions techniques + +- Rust : workspace multi-crate (`crates/domain`, `application`, `infrastructure`, `app-tauri`). +- Erreurs typées par port/use case ; `async` via `async_trait` côté ports I/O. +- Déterminisme des tests via ports `Clock`/`IdGenerator` (impl `Fixed`/`Seq` en test). +- Front : `domain`/`ports` purs (Vitest), `features` testés avec gateways **mock** (RTL). diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml new file mode 100644 index 0000000..3ad0de3 --- /dev/null +++ b/crates/app-tauri/Cargo.toml @@ -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 } diff --git a/crates/app-tauri/build.rs b/crates/app-tauri/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/crates/app-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/crates/app-tauri/capabilities/default.json b/crates/app-tauri/capabilities/default.json new file mode 100644 index 0000000..679314a --- /dev/null +++ b/crates/app-tauri/capabilities/default.json @@ -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"] +} diff --git a/crates/app-tauri/gen/schemas/acl-manifests.json b/crates/app-tauri/gen/schemas/acl-manifests.json new file mode 100644 index 0000000..f2f210a --- /dev/null +++ b/crates/app-tauri/gen/schemas/acl-manifests.json @@ -0,0 +1 @@ +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/crates/app-tauri/gen/schemas/capabilities.json b/crates/app-tauri/gen/schemas/capabilities.json new file mode 100644 index 0000000..7c2a13a --- /dev/null +++ b/crates/app-tauri/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file diff --git a/crates/app-tauri/gen/schemas/desktop-schema.json b/crates/app-tauri/gen/schemas/desktop-schema.json new file mode 100644 index 0000000..24c9001 --- /dev/null +++ b/crates/app-tauri/gen/schemas/desktop-schema.json @@ -0,0 +1,2358 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/crates/app-tauri/gen/schemas/linux-schema.json b/crates/app-tauri/gen/schemas/linux-schema.json new file mode 100644 index 0000000..24c9001 --- /dev/null +++ b/crates/app-tauri/gen/schemas/linux-schema.json @@ -0,0 +1,2358 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/crates/app-tauri/icons/128x128.png b/crates/app-tauri/icons/128x128.png new file mode 100644 index 0000000..57c337d Binary files /dev/null and b/crates/app-tauri/icons/128x128.png differ diff --git a/crates/app-tauri/icons/128x128@2x.png b/crates/app-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..bd39cc7 Binary files /dev/null and b/crates/app-tauri/icons/128x128@2x.png differ diff --git a/crates/app-tauri/icons/32x32.png b/crates/app-tauri/icons/32x32.png new file mode 100644 index 0000000..c40e01b Binary files /dev/null and b/crates/app-tauri/icons/32x32.png differ diff --git a/crates/app-tauri/icons/64x64.png b/crates/app-tauri/icons/64x64.png new file mode 100644 index 0000000..6095f9c Binary files /dev/null and b/crates/app-tauri/icons/64x64.png differ diff --git a/crates/app-tauri/icons/Square107x107Logo.png b/crates/app-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..1ff3e48 Binary files /dev/null and b/crates/app-tauri/icons/Square107x107Logo.png differ diff --git a/crates/app-tauri/icons/Square142x142Logo.png b/crates/app-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..369985c Binary files /dev/null and b/crates/app-tauri/icons/Square142x142Logo.png differ diff --git a/crates/app-tauri/icons/Square150x150Logo.png b/crates/app-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..9aad84a Binary files /dev/null and b/crates/app-tauri/icons/Square150x150Logo.png differ diff --git a/crates/app-tauri/icons/Square284x284Logo.png b/crates/app-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..2389b81 Binary files /dev/null and b/crates/app-tauri/icons/Square284x284Logo.png differ diff --git a/crates/app-tauri/icons/Square30x30Logo.png b/crates/app-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..b8f5ef9 Binary files /dev/null and b/crates/app-tauri/icons/Square30x30Logo.png differ diff --git a/crates/app-tauri/icons/Square310x310Logo.png b/crates/app-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..3b33f06 Binary files /dev/null and b/crates/app-tauri/icons/Square310x310Logo.png differ diff --git a/crates/app-tauri/icons/Square44x44Logo.png b/crates/app-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..519d2c5 Binary files /dev/null and b/crates/app-tauri/icons/Square44x44Logo.png differ diff --git a/crates/app-tauri/icons/Square71x71Logo.png b/crates/app-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..cb9ae01 Binary files /dev/null and b/crates/app-tauri/icons/Square71x71Logo.png differ diff --git a/crates/app-tauri/icons/Square89x89Logo.png b/crates/app-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..5fb6de6 Binary files /dev/null and b/crates/app-tauri/icons/Square89x89Logo.png differ diff --git a/crates/app-tauri/icons/StoreLogo.png b/crates/app-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..d690bbe Binary files /dev/null and b/crates/app-tauri/icons/StoreLogo.png differ diff --git a/crates/app-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/crates/app-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/crates/app-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..d354482 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d33ca02 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..f4d26d3 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..dcfa58f Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..476061a Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ff6a3d4 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..cf330d9 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f870783 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..f71fb5a Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6417941 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2101812 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dde5b44 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ab397bc Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e0d2a56 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b05d341 Binary files /dev/null and b/crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/crates/app-tauri/icons/android/values/ic_launcher_background.xml b/crates/app-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/crates/app-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/crates/app-tauri/icons/icon.icns b/crates/app-tauri/icons/icon.icns new file mode 100644 index 0000000..579aac2 Binary files /dev/null and b/crates/app-tauri/icons/icon.icns differ diff --git a/crates/app-tauri/icons/icon.ico b/crates/app-tauri/icons/icon.ico new file mode 100644 index 0000000..e4b130a Binary files /dev/null and b/crates/app-tauri/icons/icon.ico differ diff --git a/crates/app-tauri/icons/icon.png b/crates/app-tauri/icons/icon.png new file mode 100644 index 0000000..dbe2ea1 Binary files /dev/null and b/crates/app-tauri/icons/icon.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-20x20@1x.png b/crates/app-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..987cca8 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-20x20@2x-1.png b/crates/app-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..6ba844e Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-20x20@2x.png b/crates/app-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..6ba844e Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-20x20@3x.png b/crates/app-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..ed10b55 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-29x29@1x.png b/crates/app-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..372a70a Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-29x29@2x-1.png b/crates/app-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..42a686a Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-29x29@2x.png b/crates/app-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..42a686a Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-29x29@3x.png b/crates/app-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..226bb91 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-40x40@1x.png b/crates/app-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..6ba844e Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-40x40@2x-1.png b/crates/app-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..d773500 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-40x40@2x.png b/crates/app-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..d773500 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-40x40@3x.png b/crates/app-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..c7847fb Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-512@2x.png b/crates/app-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..6a5fe7e Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-60x60@2x.png b/crates/app-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..c7847fb Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-60x60@3x.png b/crates/app-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..41c3e81 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-76x76@1x.png b/crates/app-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..f8cc751 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-76x76@2x.png b/crates/app-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..fdf6351 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/crates/app-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/crates/app-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..8265a98 Binary files /dev/null and b/crates/app-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs new file mode 100644 index 0000000..38bff28 --- /dev/null +++ b/crates/app-tauri/src/commands.rs @@ -0,0 +1,1121 @@ +//! `#[tauri::command]` handlers — the **driving adapters** (frontend → backend). +//! +//! Each handler is a thin shell: deserialise the DTO, call the use case from +//! [`AppState`], map `Result` to `Result`. No business logic lives here. + +use tauri::ipc::Channel; +use tauri::State; + +use application::{ + AppError, CloseProjectInput, CreateAgentInput, CreateLayoutInput, DeleteAgentInput, + DeleteLayoutInput, DeleteTemplateInput, DetectAgentDriftInput, GitBranchesInput, + GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, + GitStatusInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput, + MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput, + SetActiveLayoutInput, SyncAgentWithTemplateInput, UpdateAgentContextInput, +}; +use domain::ports::PtyHandle; + +use crate::dto::{ + parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_profile_id, + parse_project_id, parse_session_id, parse_template_id, AgentDriftListDto, AgentDto, + AgentListDto, ConfigureProfilesRequestDto, CreateAgentFromTemplateRequestDto, + CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateProjectRequestDto, + CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, + DetectProfilesRequestDto, DetectProfilesResponseDto, ErrorDto, FirstRunStateDto, + GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, + GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, + LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto, + ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, + RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto, + SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto, + UpdateTemplateRequestDto, WriteTerminalRequestDto, +}; +use crate::pty::{PtyBridge, PtyChunk}; +use crate::state::AppState; + +/// `health` — trivial command validating the full IPC pipeline +/// (frontend gateway → invoke → command → use case → ports → event relay). +/// +/// # Errors +/// Returns an [`ErrorDto`] if the use case fails. +#[tauri::command] +pub fn health( + request: Option, + state: State<'_, AppState>, +) -> Result { + let input = request.unwrap_or_default().into(); + state + .health + .execute(input) + .map(HealthResponseDto::from) + .map_err(ErrorDto::from) +} + +/// `create_project` — create a project from a root: init `.ideai/`, register it. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad root/name or a duplicate +/// `(remote, root)`, `FILESYSTEM`/`STORE` on I/O failure). +#[tauri::command] +pub async fn create_project( + request: CreateProjectRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .create_project + .execute(request.into()) + .await + .map(ProjectDto::from) + .map_err(ErrorDto::from) +} + +/// `open_project` — load a project and its `.ideai/` meta/manifest. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on registry I/O failure). +#[tauri::command] +pub async fn open_project( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let id = parse_project_id(&project_id)?; + state + .open_project + .execute(OpenProjectInput { project_id: id }) + .await + .map(ProjectDto::from) + .map_err(ErrorDto::from) +} + +/// `close_project` — persist state and release resources for a project. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure). +#[tauri::command] +pub async fn close_project( + project_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let id = parse_project_id(&project_id)?; + state + .close_project + .execute(CloseProjectInput { + project_id: id, + // L2 has no UI-side workspace mutations to persist yet. + workspace: None, + }) + .await + .map(|_| ()) + .map_err(ErrorDto::from) +} + +/// `list_projects` — list the projects known to the registry. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on registry I/O failure). +#[tauri::command] +pub async fn list_projects(state: State<'_, AppState>) -> Result { + state + .list_projects + .execute() + .await + .map(ProjectListDto::from) + .map_err(ErrorDto::from) +} + +// --------------------------------------------------------------------------- +// Terminals (L3) +// --------------------------------------------------------------------------- + +/// `open_terminal` — spawn a PTY and wire its byte stream to the frontend. +/// +/// The frontend passes a per-session [`Channel`] (xterm's output sink). We: +/// 1. run [`application::OpenTerminal`] (spawn the PTY, register the session), +/// 2. register the channel in the [`PtyBridge`] keyed by the new session id, +/// 3. start a pump that drains the PTY's blocking output stream and forwards +/// each chunk through the bridge to that channel. +/// +/// Returns the [`TerminalSessionDto`] (its `sessionId` is what `write`/`resize`/ +/// `close` reference). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad cwd/size, `PROCESS` if the PTY +/// fails to spawn or its output cannot be subscribed). +#[tauri::command] +pub async fn open_terminal( + request: OpenTerminalRequestDto, + on_output: Channel, + state: State<'_, AppState>, +) -> Result { + let output = state + .open_terminal + .execute(request.into()) + .await + .map_err(ErrorDto::from)?; + let session_id = output.session.id; + + // (2) Register the xterm output channel for this session. + state.pty_bridge.register(session_id, on_output); + + // (3) Subscribe to the PTY's byte stream and pump it to the channel. The + // stream is a blocking iterator, so it runs on a dedicated OS thread; it + // ends when the PTY hits EOF (process exit) or the bridge channel is gone. + let handle = PtyHandle { session_id }; + match state.pty_port.subscribe_output(&handle) { + Ok(stream) => { + let bridge: std::sync::Arc = std::sync::Arc::clone(&state.pty_bridge); + std::thread::spawn(move || { + for chunk in stream { + if !bridge.send_output(&session_id, chunk) { + break; + } + } + // Stream ended (process exited): drop the channel registration. + bridge.unregister(&session_id); + }); + } + Err(e) => { + state.pty_bridge.unregister(&session_id); + return Err(ErrorDto::from(application::AppError::from(e))); + } + } + + Ok(TerminalSessionDto::from(output)) +} + +/// `write_terminal` — forward bytes (xterm keystrokes) to a live PTY. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// session is unknown, `PROCESS` on PTY I/O failure). +#[tauri::command] +pub fn write_terminal( + request: WriteTerminalRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let input = request.into_input()?; + state + .write_terminal + .execute(input) + .map_err(ErrorDto::from) +} + +/// `resize_terminal` — resize a live PTY. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/size, `NOT_FOUND` if the +/// session is unknown, `PROCESS` on failure). +#[tauri::command] +pub fn resize_terminal( + request: ResizeTerminalRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let input = request.into_input()?; + state + .resize_terminal + .execute(input) + .map_err(ErrorDto::from) +} + +/// `close_terminal` — kill a live PTY and tear down its channel. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// session is unknown, `PROCESS` if the kill fails). +#[tauri::command] +pub async fn close_terminal( + session_id: String, + state: State<'_, AppState>, +) -> Result { + let input = parse_close_terminal(&session_id)?; + let sid = parse_session_id(&session_id)?; + let result = state + .close_terminal + .execute(input) + .await + .map(TerminalClosedDto::from) + .map_err(ErrorDto::from); + // Tear down the channel regardless of kill outcome. + state.pty_bridge.unregister(&sid); + result +} + +// --------------------------------------------------------------------------- +// Layout (L4) +// --------------------------------------------------------------------------- + +/// `load_layout` — read a project's named layout (the active one when `layout_id` +/// is omitted). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project or layout is unknown, `STORE` on registry I/O failure). +#[tauri::command] +pub async fn load_layout( + project_id: String, + layout_id: Option, + state: State<'_, AppState>, +) -> Result { + let id = parse_project_id(&project_id)?; + let lid = layout_id.as_deref().map(parse_layout_id).transpose()?; + state + .load_layout + .execute(LoadLayoutInput { + project_id: id, + layout_id: lid, + }) + .await + .map(LayoutDto::from) + .map_err(ErrorDto::from) +} + +/// `mutate_layout` — apply a split/merge/resize/move/setSession/setCellAgent +/// operation, persist the result and announce `LayoutChanged`. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/operation or an invariant +/// violation, `NOT_FOUND` for an unknown project/node, `FILESYSTEM`/`STORE` on I/O +/// failure). +#[tauri::command] +pub async fn mutate_layout( + project_id: String, + layout_id: Option, + operation: LayoutOperationDto, + state: State<'_, AppState>, +) -> Result { + let id = parse_project_id(&project_id)?; + let lid = layout_id.as_deref().map(parse_layout_id).transpose()?; + let operation = operation.into_operation()?; + state + .mutate_layout + .execute(MutateLayoutInput { + project_id: id, + layout_id: lid, + operation, + }) + .await + .map(LayoutDto::from) + .map_err(ErrorDto::from) +} + +// --------------------------------------------------------------------------- +// Named-layout management (#4) +// --------------------------------------------------------------------------- + +/// `list_layouts` — list all named layouts of a project and the active one. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE`/`FILESYSTEM` on I/O failure). +#[tauri::command] +pub async fn list_layouts( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let id = parse_project_id(&project_id)?; + state + .list_layouts + .execute(ListLayoutsInput { project_id: id }) + .await + .map(ListLayoutsDto::from) + .map_err(ErrorDto::from) +} + +/// `create_layout` — create a new empty named layout and make it active. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for empty name or malformed id, `NOT_FOUND` +/// if the project is unknown, `STORE`/`FILESYSTEM` on I/O failure). +#[tauri::command] +pub async fn create_layout( + request: CreateLayoutRequestDto, + state: State<'_, AppState>, +) -> Result { + let project_id = parse_project_id(&request.project_id)?; + let kind = request.parse_kind()?; + state + .create_layout + .execute(CreateLayoutInput { + project_id, + name: request.name, + kind, + }) + .await + .map(CreateLayoutResultDto::from) + .map_err(ErrorDto::from) +} + +/// `rename_layout` — rename a named layout. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for empty name or malformed id, `NOT_FOUND` +/// if the project or layout is unknown). +#[tauri::command] +pub async fn rename_layout( + request: RenameLayoutRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project_id = parse_project_id(&request.project_id)?; + let layout_id = parse_layout_id(&request.layout_id)?; + state + .rename_layout + .execute(RenameLayoutInput { + project_id, + layout_id, + name: request.name, + }) + .await + .map_err(ErrorDto::from) +} + +/// `delete_layout` — delete a named layout (cannot be the last one). +/// +/// Returns the active layout id after the deletion. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed id or last layout attempt, +/// `NOT_FOUND` if the project or layout is unknown). +#[tauri::command] +pub async fn delete_layout( + request: DeleteLayoutRequestDto, + state: State<'_, AppState>, +) -> Result { + let project_id = parse_project_id(&request.project_id)?; + let layout_id = parse_layout_id(&request.layout_id)?; + state + .delete_layout + .execute(DeleteLayoutInput { + project_id, + layout_id, + }) + .await + .map(DeleteLayoutResultDto::from) + .map_err(ErrorDto::from) +} + +/// `set_active_layout` — switch the active named layout of a project. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the +/// project or layout is unknown). +#[tauri::command] +pub async fn set_active_layout( + request: SetActiveLayoutRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project_id = parse_project_id(&request.project_id)?; + let layout_id = parse_layout_id(&request.layout_id)?; + state + .set_active_layout + .execute(SetActiveLayoutInput { + project_id, + layout_id, + }) + .await + .map_err(ErrorDto::from) +} + +// --------------------------------------------------------------------------- +// Profiles & first-run (L5) +// --------------------------------------------------------------------------- + +/// `first_run_state` — whether the first-run wizard should show (no +/// `profiles.json` yet) plus the pre-filled reference catalogue to seed it. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure). +#[tauri::command] +pub async fn first_run_state(state: State<'_, AppState>) -> Result { + state + .first_run_state + .execute() + .await + .map(FirstRunStateDto::from) + .map_err(ErrorDto::from) +} + +/// `reference_profiles` — the pre-filled, editable reference catalogue +/// (Claude/Codex/Gemini/Aider). +/// +/// # Errors +/// Returns an [`ErrorDto`] (never in practice; the catalogue is in-memory). +#[tauri::command] +pub async fn reference_profiles(state: State<'_, AppState>) -> Result { + state + .reference_profiles + .execute() + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from) +} + +/// `detect_profiles` — probe each candidate profile's detection command and +/// report which CLIs are installed (✓/✗). +/// +/// # Errors +/// Returns an [`ErrorDto`] (detection failures degrade to `available: false`). +#[tauri::command] +pub async fn detect_profiles( + request: DetectProfilesRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .detect_profiles + .execute(request.into()) + .await + .map(DetectProfilesResponseDto::from) + .map_err(ErrorDto::from) +} + +/// `list_profiles` — list the configured profiles. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure). +#[tauri::command] +pub async fn list_profiles(state: State<'_, AppState>) -> Result { + state + .list_profiles + .execute() + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from) +} + +/// `save_profile` — create or replace (by id) a single profile. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure). +#[tauri::command] +pub async fn save_profile( + request: SaveProfileRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .save_profile + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from) +} + +/// `delete_profile` — delete a profile by id. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if absent, +/// `STORE` on failure). +#[tauri::command] +pub async fn delete_profile( + profile_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let input = parse_delete_profile(&profile_id)?; + state + .delete_profile + .execute(input) + .await + .map_err(ErrorDto::from) +} + +/// `configure_profiles` — persist the batch of chosen/edited/custom profiles, +/// closing the first run. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure). +#[tauri::command] +pub async fn configure_profiles( + request: ConfigureProfilesRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .configure_profiles + .execute(request.into()) + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from) +} + +// --------------------------------------------------------------------------- +// Agents (L6) +// --------------------------------------------------------------------------- + +/// Resolves a [`domain::Project`] by id, mapping `StoreError` → `AppError` → `ErrorDto`. +async fn resolve_project( + project_id: &str, + state: &State<'_, AppState>, +) -> Result { + let id = parse_project_id(project_id)?; + state + .project_store + .load_project(id) + .await + .map_err(|e| ErrorDto::from(AppError::from(e))) +} + +/// `create_agent` — create a new project agent from scratch. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad id/name, `NOT_FOUND` if the +/// project is unknown, `STORE` on I/O failure). +#[tauri::command] +pub async fn create_agent( + request: CreateAgentRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let profile_id = parse_profile_id(&request.profile_id)?; + state + .create_agent + .execute(CreateAgentInput { + project, + name: request.name, + profile_id, + initial_content: request.initial_content, + }) + .await + .map(AgentDto::from) + .map_err(ErrorDto::from) +} + +/// `list_agents` — list the agents of a project. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on I/O failure). +#[tauri::command] +pub async fn list_agents( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .list_agents + .execute(ListAgentsInput { project }) + .await + .map(AgentListDto::from) + .map_err(ErrorDto::from) +} + +/// `read_agent_context` — read an agent's Markdown context. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project or agent is unknown, `STORE` on I/O failure). +#[tauri::command] +pub async fn read_agent_context( + project_id: String, + agent_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + let agent_id = parse_agent_id(&agent_id)?; + state + .read_agent_context + .execute(ReadAgentContextInput { project, agent_id }) + .await + .map(ReadAgentContextResponseDto::from) + .map_err(ErrorDto::from) +} + +/// `update_agent_context` — overwrite an agent's Markdown context. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project or agent is unknown, `STORE` on I/O failure). +#[tauri::command] +pub async fn update_agent_context( + request: UpdateAgentContextRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .update_agent_context + .execute(UpdateAgentContextInput { + project, + agent_id, + content: request.content, + }) + .await + .map_err(ErrorDto::from) +} + +/// `delete_agent` — remove an agent from the project manifest. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project or agent is unknown, `STORE` on I/O failure). +#[tauri::command] +pub async fn delete_agent( + project_id: String, + agent_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&project_id, &state).await?; + let agent_id = parse_agent_id(&agent_id)?; + state + .delete_agent + .execute(DeleteAgentInput { project, agent_id }) + .await + .map_err(ErrorDto::from) +} + +/// `launch_agent` — spawn an agent's CLI in a PTY and wire its byte stream to +/// the frontend via a [`Channel`]. +/// +/// Mirrors `open_terminal`: execute the use case (spawn + register session), +/// register the xterm channel in the [`PtyBridge`], then pump PTY output to +/// that channel on a dedicated OS thread. +/// +/// Returns the [`TerminalSessionDto`] (its `sessionId` is what +/// `write`/`resize`/`close` reference). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad id/size, `NOT_FOUND` if the +/// project, agent or profile is unknown, `PROCESS` if the PTY fails to spawn). +#[tauri::command] +pub async fn launch_agent( + request: LaunchAgentRequestDto, + on_output: Channel, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + + let output = state + .launch_agent + .execute(LaunchAgentInput { + project, + agent_id, + rows: request.rows, + cols: request.cols, + node_id: None, + }) + .await + .map_err(ErrorDto::from)?; + + let session_id = output.session.id; + + // Register the xterm output channel for this session. + state.pty_bridge.register(session_id, on_output); + + // Subscribe to the PTY's byte stream and pump it to the channel. + // The stream is a blocking iterator; it runs on a dedicated OS thread and + // ends when the PTY hits EOF or the bridge channel is gone. + let handle = PtyHandle { session_id }; + match state.pty_port.subscribe_output(&handle) { + Ok(stream) => { + let bridge: std::sync::Arc = std::sync::Arc::clone(&state.pty_bridge); + std::thread::spawn(move || { + for chunk in stream { + if !bridge.send_output(&session_id, chunk) { + break; + } + } + bridge.unregister(&session_id); + }); + } + Err(e) => { + state.pty_bridge.unregister(&session_id); + return Err(ErrorDto::from(AppError::from(e))); + } + } + + Ok(TerminalSessionDto::from(output)) +} + +// --------------------------------------------------------------------------- +// Templates & sync (L7) +// --------------------------------------------------------------------------- + +/// `create_template` — create a template in the global IDE store. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an empty name or malformed profile id, +/// `STORE` on persistence failure). +#[tauri::command] +pub async fn create_template( + request: CreateTemplateRequestDto, + state: State<'_, AppState>, +) -> Result { + let input = request.into_input()?; + state + .create_template + .execute(input) + .await + .map(TemplateDto::from) + .map_err(ErrorDto::from) +} + +/// `update_template` — update a template's content (bumps version, fires +/// `TemplateUpdated` event). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// template is unknown, `STORE` on persistence failure). +#[tauri::command] +pub async fn update_template( + request: UpdateTemplateRequestDto, + state: State<'_, AppState>, +) -> Result { + let input = request.into_input()?; + state + .update_template + .execute(input) + .await + .map(TemplateDto::from) + .map_err(ErrorDto::from) +} + +/// `list_templates` — list all templates in the global IDE store. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on persistence failure). +#[tauri::command] +pub async fn list_templates(state: State<'_, AppState>) -> Result { + state + .list_templates + .execute() + .await + .map(TemplateListDto::from) + .map_err(ErrorDto::from) +} + +/// `delete_template` — remove a template from the global IDE store. +/// +/// Agents previously created from it keep their `.md`; drift detection simply +/// finds nothing to compare against afterwards. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// template is unknown, `STORE` on failure). +#[tauri::command] +pub async fn delete_template( + template_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let id = parse_template_id(&template_id)?; + state + .delete_template + .execute(DeleteTemplateInput { template_id: id }) + .await + .map_err(ErrorDto::from) +} + +/// `create_agent_from_template` — instantiate a project agent from a template. +/// +/// Copies the template's Markdown content, links the agent origin and version, +/// and records the manifest entry. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project or template is unknown, `STORE` on I/O failure). +#[tauri::command] +pub async fn create_agent_from_template( + request: CreateAgentFromTemplateRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let input = request.into_input(project)?; + state + .create_agent_from_template + .execute(input) + .await + .map(|out| AgentDto(out.agent)) + .map_err(ErrorDto::from) +} + +/// `detect_agent_drift` — list which synchronized agents are behind their +/// template (version drift). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on I/O failure). +#[tauri::command] +pub async fn detect_agent_drift( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .detect_agent_drift + .execute(DetectAgentDriftInput { project }) + .await + .map(AgentDriftListDto::from) + .map_err(ErrorDto::from) +} + +/// `sync_agent_with_template` — apply the latest template content to a +/// synchronized agent. +/// +/// Returns whether a sync was applied and the resulting version. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project, agent or template is unknown, `STORE` on I/O failure). +#[tauri::command] +pub async fn sync_agent_with_template( + request: SyncAgentWithTemplateRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .sync_agent_with_template + .execute(SyncAgentWithTemplateInput { project, agent_id }) + .await + .map(SyncResultDto::from) + .map_err(ErrorDto::from) +} + +// --------------------------------------------------------------------------- +// Git (L8) +// --------------------------------------------------------------------------- + +/// `git_status` — report the working-tree status of a project's repository. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` if +/// the repo is missing or the operation fails). +#[tauri::command] +pub async fn git_status( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_status + .execute(GitStatusInput { root }) + .await + .map(GitStatusListDto::from) + .map_err(ErrorDto::from) +} + +/// `git_stage` — stage a path in a project's repository. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on +/// failure). +#[tauri::command] +pub async fn git_stage( + request: GitStageRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_stage + .execute(GitStagePathInput { + root, + path: request.path, + }) + .await + .map_err(ErrorDto::from) +} + +/// `git_unstage` — unstage a path in a project's repository. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on +/// failure). +#[tauri::command] +pub async fn git_unstage( + request: GitStageRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_unstage + .execute(GitStagePathInput { + root, + path: request.path, + }) + .await + .map_err(ErrorDto::from) +} + +/// `git_commit` — create a commit in a project's repository. +/// +/// Announces [`DomainEvent::GitStateChanged`]. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad id or an empty message, `GIT` +/// on failure). +#[tauri::command] +pub async fn git_commit( + request: GitCommitRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_commit + .execute(GitCommitInput { + project_id: project.id, + root, + message: request.message, + }) + .await + .map(GitCommitDto::from) + .map_err(ErrorDto::from) +} + +/// `git_branches` — list branches and the current one for a project's repository. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on +/// failure). +#[tauri::command] +pub async fn git_branches( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_branches + .execute(GitBranchesInput { root }) + .await + .map(GitBranchesDto::from) + .map_err(ErrorDto::from) +} + +/// `git_checkout` — check out a branch in a project's repository. +/// +/// Announces [`DomainEvent::GitStateChanged`]. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on +/// failure). +#[tauri::command] +pub async fn git_checkout( + request: GitCheckoutRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_checkout + .execute(GitCheckoutInput { + project_id: project.id, + root, + branch: request.branch, + }) + .await + .map_err(ErrorDto::from) +} + +/// `git_log` — return the recent commit log for a project's repository. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on +/// failure). +#[tauri::command] +pub async fn git_log( + project_id: String, + limit: usize, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_log + .execute(GitLogInput { root, limit }) + .await + .map(GitCommitListDto::from) + .map_err(ErrorDto::from) +} + +/// `git_init` — initialise a git repository at a project's root. +/// +/// Announces [`DomainEvent::GitStateChanged`]. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on +/// failure). +#[tauri::command] +pub async fn git_init( + project_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_init + .execute(GitInitInput { + project_id: project.id, + root, + }) + .await + .map_err(ErrorDto::from) +} + +/// `git_graph` — return the commit graph for all local branches of a project. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on +/// failure). +#[tauri::command] +pub async fn git_graph( + project_id: String, + limit: usize, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + let root = project.root.as_str().to_owned(); + state + .git_graph + .execute(GitGraphInput { root, limit }) + .await + .map(GraphCommitListDto::from) + .map_err(ErrorDto::from) +} + +// --------------------------------------------------------------------------- +// Windows (L10) +// --------------------------------------------------------------------------- + +use application::MoveTabToNewWindowInput; +use crate::dto::{parse_tab_id, MoveTabResultDto}; + +/// `move_tab_to_new_window` — detach a tab into a brand-new OS window. +/// +/// Applies the workspace topology change (the tab is *moved*, not duplicated) +/// and opens a fresh [`tauri::WebviewWindow`]. The session-state handoff to that +/// window (rendering the detached tab) is the L11 multi-window UI work; this +/// command provides the backend primitive and the new OS window. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed tab id, `NOT_FOUND` if the +/// tab is unknown to the persisted workspace, `INTERNAL` if the window fails to +/// open). +#[tauri::command] +pub async fn move_tab_to_new_window( + app: tauri::AppHandle, + tab_id: String, + state: State<'_, AppState>, +) -> Result { + let tid = parse_tab_id(&tab_id)?; + let out = state + .move_tab + .execute(MoveTabToNewWindowInput { tab_id: tid }) + .await + .map_err(ErrorDto::from)?; + + let label = format!("win-{}", out.new_window_id); + tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::App("index.html".into())) + .title("IdeA") + .inner_size(1280.0, 800.0) + .build() + .map_err(|e| ErrorDto { + code: "INTERNAL".to_owned(), + message: e.to_string(), + })?; + + Ok(MoveTabResultDto::from(out)) +} diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs new file mode 100644 index 0000000..76427f1 --- /dev/null +++ b/crates/app-tauri/src/dto.rs @@ -0,0 +1,1332 @@ +//! Data Transfer Objects crossing the IPC boundary. +//! +//! Convention (frozen here for L1, see L1-ipc-bridge.md "points d'attention"): +//! **all IPC payloads are `camelCase`** via `#[serde(rename_all = "camelCase")]`. +//! Rust uses `snake_case` fields; serde renames them on the wire so the +//! TypeScript side sees idiomatic camelCase. This matches the persisted-domain +//! JSON convention already used in the domain (`agents.json` etc.). + +use serde::{Deserialize, Serialize}; + +use application::{ + AppError, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, + LayoutKind, ListProjectsOutput, OpenProjectOutput, +}; +use domain::{Project, ProjectId}; + +/// Request DTO for the `health` command. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthRequestDto { + /// Optional note echoed back by the use case. + #[serde(default)] + pub note: Option, +} + +impl From for HealthInput { + fn from(dto: HealthRequestDto) -> Self { + Self { note: dto.note } + } +} + +/// Response DTO for the `health` command. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthResponseDto { + /// Application version. + pub version: String, + /// Liveness flag. + pub alive: bool, + /// Server time in epoch milliseconds. + pub time_millis: i64, + /// Correlation id for this call. + pub correlation_id: String, + /// Echoed note, if any. + pub note: Option, +} + +impl From for HealthResponseDto { + fn from(r: HealthReport) -> Self { + Self { + version: r.version, + alive: r.alive, + time_millis: r.time_millis, + correlation_id: r.correlation_id, + note: r.note, + } + } +} + +/// Error DTO returned to the frontend in the `Err` arm of every command. +/// +/// `code` is a stable machine-readable string (see [`AppError::code`]); the +/// frontend branches on it without parsing `message`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorDto { + /// Stable error code, e.g. `NOT_FOUND`, `INVALID`. + pub code: String, + /// Human-readable message. + pub message: String, +} + +impl From for ErrorDto { + fn from(e: AppError) -> Self { + Self { + code: e.code().to_owned(), + message: e.to_string(), + } + } +} + +// --------------------------------------------------------------------------- +// Projects (L2) +// --------------------------------------------------------------------------- + +/// A project as seen by the frontend (camelCase wire shape). +/// +/// `remote` is the domain [`domain::RemoteRef`], which already serialises +/// camelCase + tagged (`kind`), so we embed it directly. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectDto { + /// Stable project id (UUID string). + pub id: String, + /// Display name. + pub name: String, + /// Absolute project root. + pub root: String, + /// Where the project lives (`{ "kind": "local" }`, `ssh`, `wsl`). + pub remote: domain::RemoteRef, + /// Creation timestamp, epoch milliseconds. + pub created_at: i64, +} + +impl From for ProjectDto { + fn from(p: Project) -> Self { + Self { + id: p.id.to_string(), + name: p.name, + root: p.root.as_str().to_owned(), + remote: p.remote, + created_at: p.created_at, + } + } +} + +/// Request DTO for `create_project`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectRequestDto { + /// Display name. + pub name: String, + /// Absolute project root. + pub root: String, + /// Optional remote reference; defaults to local when omitted. + #[serde(default)] + pub remote: Option, + /// Optional default profile id. + #[serde(default)] + pub default_profile_id: Option, +} + +impl From for CreateProjectInput { + fn from(dto: CreateProjectRequestDto) -> Self { + Self { + name: dto.name, + root: dto.root, + remote: dto.remote, + default_profile_id: dto.default_profile_id, + } + } +} + +impl From for ProjectDto { + fn from(out: CreateProjectOutput) -> Self { + out.project.into() + } +} + +impl From for ProjectDto { + fn from(out: OpenProjectOutput) -> Self { + out.project.into() + } +} + +/// Parses a project-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_project_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(ProjectId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid project id: {raw}"), + }) +} + +/// Response DTO for `list_projects`. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ProjectListDto(pub Vec); + +impl From for ProjectListDto { + fn from(out: ListProjectsOutput) -> Self { + Self(out.projects.into_iter().map(ProjectDto::from).collect()) + } +} + +// --------------------------------------------------------------------------- +// Terminals (L3) +// --------------------------------------------------------------------------- + +use application::{ + CloseTerminalInput, CloseTerminalOutput, OpenTerminalInput, OpenTerminalOutput, + ResizeTerminalInput, WriteToTerminalInput, +}; +use domain::SessionId; + +/// Request DTO for `open_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenTerminalRequestDto { + /// Working directory (typically the project root). + pub cwd: String, + /// Initial terminal height in rows. + pub rows: u16, + /// Initial terminal width in columns. + pub cols: u16, + /// Optional explicit command; defaults to the platform shell when omitted. + #[serde(default)] + pub command: Option, + /// Optional arguments for the command. + #[serde(default)] + pub args: Vec, +} + +impl From for OpenTerminalInput { + fn from(dto: OpenTerminalRequestDto) -> Self { + Self { + cwd: dto.cwd, + rows: dto.rows, + cols: dto.cols, + command: dto.command, + args: dto.args, + node_id: None, + } + } +} + +/// Response DTO for `open_terminal`: the freshly-opened session. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSessionDto { + /// Stable session id (UUID string) — used for write/resize/close + the + /// output channel. + pub session_id: String, + /// Working directory the shell runs in. + pub cwd: String, + /// Current rows. + pub rows: u16, + /// Current cols. + pub cols: u16, +} + +impl From for TerminalSessionDto { + fn from(out: OpenTerminalOutput) -> Self { + let s = out.session; + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + } + } +} + +/// Request DTO for `write_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteTerminalRequestDto { + /// Target session id. + pub session_id: String, + /// Bytes to write (xterm keystrokes). + pub data: Vec, +} + +impl WriteTerminalRequestDto { + /// Converts to the use-case input, parsing the session id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the id is malformed. + pub fn into_input(self) -> Result { + Ok(WriteToTerminalInput { + session_id: parse_session_id(&self.session_id)?, + data: self.data, + }) + } +} + +/// Request DTO for `resize_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResizeTerminalRequestDto { + /// Target session id. + pub session_id: String, + /// New rows. + pub rows: u16, + /// New cols. + pub cols: u16, +} + +impl ResizeTerminalRequestDto { + /// Converts to the use-case input, parsing the session id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the id is malformed. + pub fn into_input(self) -> Result { + Ok(ResizeTerminalInput { + session_id: parse_session_id(&self.session_id)?, + rows: self.rows, + cols: self.cols, + }) + } +} + +/// Response DTO for `close_terminal`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalClosedDto { + /// Exit code, if the process reported one. + pub code: Option, +} + +impl From for TerminalClosedDto { + fn from(out: CloseTerminalOutput) -> Self { + Self { code: out.code } + } +} + +/// Builds a [`CloseTerminalInput`] from a raw session-id string. +/// +/// # Errors +/// [`ErrorDto`] with code `INVALID` if the id is malformed. +pub fn parse_close_terminal(raw: &str) -> Result { + Ok(CloseTerminalInput { + session_id: parse_session_id(raw)?, + }) +} + +/// Parses a session-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_session_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(SessionId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid session id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Layout (L4 + #4 management + #3 per-cell agent) +// --------------------------------------------------------------------------- + +use application::{ + CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, + LoadLayoutOutput, MutateLayoutOutput, +}; +use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId}; + +/// Response DTO carrying a layout tree. +/// +/// [`LayoutTree`] already serialises camelCase + tagged (its enum uses +/// `#[serde(tag = "type", content = "node")]`), so we embed it directly; the +/// TypeScript mirror in `@/domain` matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct LayoutDto(pub LayoutTree); + +impl From for LayoutDto { + fn from(out: LoadLayoutOutput) -> Self { + Self(out.layout) + } +} + +impl From for LayoutDto { + fn from(out: MutateLayoutOutput) -> Self { + Self(out.layout) + } +} + +/// A layout operation as sent by the frontend (tagged on `type`, camelCase). +/// +/// Mirrors [`LayoutOperation`]; node/session ids cross the wire as UUID strings +/// and are parsed here. `direction` reuses the domain [`Direction`] (which +/// already serialises `"row"`/`"column"`). +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum LayoutOperationDto { + /// Split a leaf into a two-child split. + #[serde(rename_all = "camelCase")] + Split { + /// Leaf to split. + target: String, + /// Row (columns) or Column (rows). + direction: Direction, + /// Id for the new sibling leaf. + new_leaf: String, + /// Id for the wrapping split container. + container: String, + }, + /// Collapse a split container back to one child. + #[serde(rename_all = "camelCase")] + Merge { + /// Split container to collapse. + container: String, + /// Index of the child to keep. + keep_index: usize, + }, + /// Reassign a split's child weights. + #[serde(rename_all = "camelCase")] + Resize { + /// Split container to resize. + container: String, + /// New weights (one per child). + weights: Vec, + }, + /// Move a session from one leaf to another. + #[serde(rename_all = "camelCase")] + Move { + /// Source leaf. + from: String, + /// Target (empty) leaf. + to: String, + }, + /// Attach/detach a session to/from a leaf. + #[serde(rename_all = "camelCase")] + SetSession { + /// Hosting leaf. + target: String, + /// Session id, or `null` to clear. + #[serde(default)] + session: Option, + }, + /// Attach/detach an agent to/from a leaf (#3 per-cell agent). + #[serde(rename_all = "camelCase")] + SetCellAgent { + /// Hosting leaf. + target: String, + /// Agent id, or `null` to clear. + #[serde(default)] + agent: Option, + }, +} + +impl LayoutOperationDto { + /// Converts to the use-case operation, parsing all ids. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if any id is malformed. + pub fn into_operation(self) -> Result { + Ok(match self { + Self::Split { + target, + direction, + new_leaf, + container, + } => LayoutOperation::Split { + target: parse_node_id(&target)?, + direction, + new_leaf: parse_node_id(&new_leaf)?, + container: parse_node_id(&container)?, + }, + Self::Merge { + container, + keep_index, + } => LayoutOperation::Merge { + container: parse_node_id(&container)?, + keep_index, + }, + Self::Resize { container, weights } => LayoutOperation::Resize { + container: parse_node_id(&container)?, + weights, + }, + Self::Move { from, to } => LayoutOperation::Move { + from: parse_node_id(&from)?, + to: parse_node_id(&to)?, + }, + Self::SetSession { target, session } => LayoutOperation::SetSession { + target: parse_node_id(&target)?, + session: session.as_deref().map(parse_session_id).transpose()?, + }, + Self::SetCellAgent { target, agent } => LayoutOperation::SetCellAgent { + target: parse_node_id(&target)?, + agent: agent.as_deref().map(parse_agent_id).transpose()?, + }, + }) + } +} + +/// Parses a node-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_node_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(NodeId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid node id: {raw}"), + }) +} + +/// Parses a layout-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_layout_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(LayoutId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid layout id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Layouts (#4) — management DTOs +// --------------------------------------------------------------------------- + +/// Lightweight layout descriptor (id + name + kind), for the tab bar. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LayoutInfoDto { + /// Stable layout id (UUID string). + pub id: String, + /// Display name. + pub name: String, + /// Layout kind: `"terminal"` or `"gitGraph"`. + pub kind: String, +} + +impl From for LayoutInfoDto { + fn from(info: LayoutInfo) -> Self { + let kind = match info.kind { + LayoutKind::Terminal => "terminal", + LayoutKind::GitGraph => "gitGraph", + } + .to_owned(); + Self { + id: info.id.to_string(), + name: info.name, + kind, + } + } +} + +/// Response DTO for `list_layouts`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListLayoutsDto { + /// All named layouts (id + name), in order. + pub layouts: Vec, + /// The id of the currently active layout. + pub active_id: String, +} + +impl From for ListLayoutsDto { + fn from(out: ListLayoutsOutput) -> Self { + Self { + layouts: out.layouts.into_iter().map(LayoutInfoDto::from).collect(), + active_id: out.active_id.to_string(), + } + } +} + +/// Response DTO for `create_layout`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLayoutResultDto { + /// The id minted for the new layout. + pub layout_id: String, +} + +impl From for CreateLayoutResultDto { + fn from(out: CreateLayoutOutput) -> Self { + Self { + layout_id: out.layout_id.to_string(), + } + } +} + +/// Response DTO for `delete_layout`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLayoutResultDto { + /// The active layout after the deletion. + pub active_id: String, +} + +impl From for DeleteLayoutResultDto { + fn from(out: DeleteLayoutOutput) -> Self { + Self { + active_id: out.active_id.to_string(), + } + } +} + +/// Request DTO for `create_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Display name for the new layout. + pub name: String, + /// Optional layout kind: `"terminal"` (default) or `"gitGraph"`. + #[serde(default)] + pub kind: Option, +} + +impl CreateLayoutRequestDto { + /// Parses the optional `kind` string into a [`LayoutKind`]. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the value is not a known kind. + pub fn parse_kind(&self) -> Result { + match self.kind.as_deref() { + None | Some("terminal") => Ok(LayoutKind::Terminal), + Some("gitGraph") => Ok(LayoutKind::GitGraph), + Some(other) => Err(ErrorDto { + code: "INVALID".to_owned(), + message: format!("unknown layout kind: {other}"), + }), + } + } +} + +/// Request DTO for `rename_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to rename. + pub layout_id: String, + /// New display name. + pub name: String, +} + +/// Request DTO for `delete_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to delete. + pub layout_id: String, +} + +/// Request DTO for `set_active_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetActiveLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to make active. + pub layout_id: String, +} + +// --------------------------------------------------------------------------- +// Profiles & first-run (L5) +// --------------------------------------------------------------------------- + +use application::{ + ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, + DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, + ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput, +}; +use domain::profile::AgentProfile; +use domain::ProfileId; + +/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase +/// (id, name, command, args, `contextInjection{strategy,…}`, detect, +/// `cwdTemplate`), so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProfileDto(pub AgentProfile); + +/// A list of profiles (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ProfileListDto(pub Vec); + +impl From> for ProfileListDto { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(ProfileDto).collect()) + } +} + +impl From for ProfileListDto { + fn from(out: ListProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for ProfileListDto { + fn from(out: ReferenceProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for ProfileDto { + fn from(out: SaveProfileOutput) -> Self { + Self(out.profile) + } +} + +/// Request DTO for `detect_profiles`: the candidate profiles to probe. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetectProfilesRequestDto { + /// Candidate profiles whose `detect` command should be run. + pub candidates: Vec, +} + +impl From for DetectProfilesInput { + fn from(dto: DetectProfilesRequestDto) -> Self { + Self { + candidates: dto.candidates, + } + } +} + +/// One availability result (`profile` + whether its CLI is installed). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileAvailabilityDto { + /// The probed profile. + pub profile: AgentProfile, + /// Whether the CLI was detected (exit code 0). + pub available: bool, +} + +impl From for ProfileAvailabilityDto { + fn from(a: ProfileAvailability) -> Self { + Self { + profile: a.profile, + available: a.available, + } + } +} + +/// Response DTO for `detect_profiles`. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct DetectProfilesResponseDto(pub Vec); + +impl From for DetectProfilesResponseDto { + fn from(out: DetectProfilesOutput) -> Self { + Self(out.results.into_iter().map(Into::into).collect()) + } +} + +/// Request DTO for `save_profile`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveProfileRequestDto { + /// The profile to upsert. + pub profile: AgentProfile, +} + +impl From for SaveProfileInput { + fn from(dto: SaveProfileRequestDto) -> Self { + Self { + profile: dto.profile, + } + } +} + +/// Request DTO for `configure_profiles` (closes the first run). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigureProfilesRequestDto { + /// All profiles the user chose to keep. + pub profiles: Vec, +} + +impl From for ConfigureProfilesInput { + fn from(dto: ConfigureProfilesRequestDto) -> Self { + Self { + profiles: dto.profiles, + } + } +} + +impl From for ProfileListDto { + fn from(out: ConfigureProfilesOutput) -> Self { + out.profiles.into() + } +} + +/// Response DTO for `first_run_state`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FirstRunStateDto { + /// `true` when the first-run wizard should be shown. + pub is_first_run: bool, + /// Pre-filled reference catalogue to seed the wizard. + pub reference_profiles: Vec, +} + +impl From for FirstRunStateDto { + fn from(out: FirstRunStateOutput) -> Self { + Self { + is_first_run: out.is_first_run, + reference_profiles: out.reference_profiles, + } + } +} + +/// Builds a [`DeleteProfileInput`] from a raw profile-id string. +/// +/// # Errors +/// [`ErrorDto`] with code `INVALID` if the id is malformed. +pub fn parse_delete_profile(raw: &str) -> Result { + Ok(DeleteProfileInput { + id: parse_profile_id(raw)?, + }) +} + +/// Parses a profile-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_profile_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(ProfileId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid profile id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Agents (L6) +// --------------------------------------------------------------------------- + +use application::{ + CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, +}; +use domain::Agent; + +/// An agent crossing the wire. [`Agent`] already serialises camelCase +/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), +/// so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentDto(pub Agent); + +/// A list of agents (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentListDto(pub Vec); + +impl From for AgentListDto { + fn from(out: ListAgentsOutput) -> Self { + Self(out.agents.into_iter().map(AgentDto).collect()) + } +} + +impl From for AgentDto { + fn from(out: CreateAgentOutput) -> Self { + Self(out.agent) + } +} + +/// Request DTO for `create_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Display name for the new agent. + pub name: String, + /// Runtime profile id. + pub profile_id: String, + /// Initial Markdown content (empty when absent). + #[serde(default)] + pub initial_content: Option, +} + +/// Response DTO for `read_agent_context`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadAgentContextResponseDto { + /// The agent's Markdown context content. + pub content: String, +} + +impl From for ReadAgentContextResponseDto { + fn from(out: ReadAgentContextOutput) -> Self { + Self { + content: out.content.into_string(), + } + } +} + +/// Request DTO for `update_agent_context`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentContextRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to update. + pub agent_id: String, + /// New Markdown content. + pub content: String, +} + +/// Request DTO for `launch_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to launch. + pub agent_id: String, + /// Initial terminal height in rows. + pub rows: u16, + /// Initial terminal width in columns. + pub cols: u16, +} + +impl From for TerminalSessionDto { + fn from(out: LaunchAgentOutput) -> Self { + let s = out.session; + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + } + } +} + +/// Parses an agent-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_agent_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(AgentId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid agent id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Templates & sync (L7) +// --------------------------------------------------------------------------- + +use application::{ + AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, + CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, + UpdateTemplateInput, UpdateTemplateOutput, +}; +use domain::{AgentTemplate, TemplateId}; + +/// A template crossing the wire. [`AgentTemplate`] already serialises camelCase +/// (`id`, `name`, `contentMd`, `version` as a number, `defaultProfileId`), +/// so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct TemplateDto(pub AgentTemplate); + +/// A list of templates (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct TemplateListDto(pub Vec); + +impl From for TemplateListDto { + fn from(out: ListTemplatesOutput) -> Self { + Self(out.templates.into_iter().map(TemplateDto).collect()) + } +} + +impl From for TemplateDto { + fn from(out: CreateTemplateOutput) -> Self { + Self(out.template) + } +} + +impl From for TemplateDto { + fn from(out: UpdateTemplateOutput) -> Self { + Self(out.template) + } +} + +/// Request DTO for `create_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTemplateRequestDto { + /// Display name. + pub name: String, + /// Initial Markdown content. + pub content: String, + /// Default runtime profile id for agents created from this template. + pub default_profile_id: String, +} + +impl CreateTemplateRequestDto { + /// Converts to the use-case input, parsing the profile id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the profile id is malformed. + pub fn into_input(self) -> Result { + Ok(CreateTemplateInput { + name: self.name, + content: self.content, + default_profile_id: parse_profile_id(&self.default_profile_id)?, + }) + } +} + +/// Request DTO for `update_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTemplateRequestDto { + /// Id of the template to update. + pub template_id: String, + /// New Markdown content. + pub content: String, +} + +impl UpdateTemplateRequestDto { + /// Converts to the use-case input, parsing the template id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the template id is malformed. + pub fn into_input(self) -> Result { + Ok(UpdateTemplateInput { + template_id: parse_template_id(&self.template_id)?, + content: self.content, + }) + } +} + +/// Parses a template-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_template_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(TemplateId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid template id: {raw}"), + }) +} + +/// Request DTO for `create_agent_from_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentFromTemplateRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Source template id. + pub template_id: String, + /// Optional agent name; defaults to the template's name when absent. + #[serde(default)] + pub name: Option, + /// Whether the agent tracks the template for future syncs. + pub synchronized: bool, +} + +impl CreateAgentFromTemplateRequestDto { + /// Converts to the use-case input, given the resolved project. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the template id is malformed. + pub fn into_input( + self, + project: domain::Project, + ) -> Result { + Ok(CreateAgentFromTemplateInput { + project, + template_id: parse_template_id(&self.template_id)?, + name: self.name, + synchronized: self.synchronized, + }) + } +} + +/// One drifting agent, as seen by the frontend. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentDriftDto { + /// The drifting agent id (UUID string). + pub agent_id: String, + /// Version the agent is currently synced to. + pub from: u64, + /// Version available from the template. + pub to: u64, +} + +impl From for AgentDriftDto { + fn from(d: AgentDrift) -> Self { + Self { + agent_id: d.agent_id.to_string(), + from: d.from.get(), + to: d.to.get(), + } + } +} + +/// Response DTO for `detect_agent_drift` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentDriftListDto(pub Vec); + +impl From for AgentDriftListDto { + fn from(out: DetectAgentDriftOutput) -> Self { + Self(out.drifts.into_iter().map(AgentDriftDto::from).collect()) + } +} + +/// Response DTO for `sync_agent_with_template`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncResultDto { + /// Whether a sync was actually applied. + pub synced: bool, + /// The version the agent is now at (`null` when no sync happened). + pub version: Option, +} + +impl From for SyncResultDto { + fn from(out: SyncAgentWithTemplateOutput) -> Self { + Self { + synced: out.synced, + version: out.version.map(|v| v.get()), + } + } +} + +/// Request DTO for `sync_agent_with_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncAgentWithTemplateRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to sync. + pub agent_id: String, +} + +// --------------------------------------------------------------------------- +// Git (L8) +// --------------------------------------------------------------------------- + +use application::{ + GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput, +}; +use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; + +/// One changed path returned by `git_status`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitFileStatusDto { + /// Repo-relative path. + pub path: String, + /// Whether the change is staged. + pub staged: bool, +} + +impl From for GitFileStatusDto { + fn from(s: GitFileStatus) -> Self { + Self { + path: s.path, + staged: s.staged, + } + } +} + +/// Response DTO for `git_status` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GitStatusListDto(pub Vec); + +impl From for GitStatusListDto { + fn from(out: GitStatusOutput) -> Self { + Self(out.entries.into_iter().map(GitFileStatusDto::from).collect()) + } +} + +/// A single commit summary crossing the wire. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitDto { + /// Commit hash. + pub hash: String, + /// Commit message summary. + pub summary: String, +} + +impl From for GitCommitDto { + fn from(c: GitCommitInfo) -> Self { + Self { + hash: c.hash, + summary: c.summary, + } + } +} + +impl From for GitCommitDto { + fn from(out: GitCommitOutput) -> Self { + Self::from(out.commit) + } +} + +/// Response DTO for `git_log` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GitCommitListDto(pub Vec); + +impl From for GitCommitListDto { + fn from(out: GitLogOutput) -> Self { + Self(out.commits.into_iter().map(GitCommitDto::from).collect()) + } +} + +/// Response DTO for `git_branches`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitBranchesDto { + /// All local branches. + pub branches: Vec, + /// The current branch (`null` when detached or unborn). + pub current: Option, +} + +impl From for GitBranchesDto { + fn from(out: GitBranchesOutput) -> Self { + Self { + branches: out.branches, + current: out.current, + } + } +} + +/// A single commit enriched for graph display. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphCommitDto { + /// Full commit hash. + pub hash: String, + /// First line of the commit message. + pub summary: String, + /// Parent commit hashes. + pub parents: Vec, + /// Ref labels pointing at this commit (e.g. `"main"`, `"tag: v1.0"`). + pub refs: Vec, + /// Author name. + pub author: String, + /// Author timestamp in Unix seconds. + pub timestamp: i64, +} + +impl From for GraphCommitDto { + fn from(c: GraphCommit) -> Self { + Self { + hash: c.hash, + summary: c.summary, + parents: c.parents, + refs: c.refs, + author: c.author, + timestamp: c.timestamp, + } + } +} + +/// Response DTO for `git_graph` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GraphCommitListDto(pub Vec); + +impl From for GraphCommitListDto { + fn from(out: GitGraphOutput) -> Self { + Self(out.commits.into_iter().map(GraphCommitDto::from).collect()) + } +} + +/// Request DTO for `git_stage` / `git_unstage`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStageRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Repo-relative path to (un)stage. + pub path: String, +} + +/// Request DTO for `git_commit`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Commit message. + pub message: String, +} + +/// Request DTO for `git_checkout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCheckoutRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Branch to check out. + pub branch: String, +} + +// --------------------------------------------------------------------------- +// Windows (L10) +// --------------------------------------------------------------------------- + +use application::MoveTabToNewWindowOutput; +use domain::ids::TabId; + +/// Response DTO for `move_tab_to_new_window`: the id minted for the new window +/// (used as the new OS window's label). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveTabResultDto { + /// The new window id (UUID string). + pub new_window_id: String, +} + +impl From for MoveTabResultDto { + fn from(out: MoveTabToNewWindowOutput) -> Self { + Self { + new_window_id: out.new_window_id.to_string(), + } + } +} + +/// Parses a tab-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_tab_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(TabId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid tab id: {raw}"), + }) +} diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs new file mode 100644 index 0000000..b3532d6 --- /dev/null +++ b/crates/app-tauri/src/events.rs @@ -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, + }, +} + +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, + } + } + }); +} diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs new file mode 100644 index 0000000..e36752d --- /dev/null +++ b/crates/app-tauri/src/lib.rs @@ -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"); +} diff --git a/crates/app-tauri/src/main.rs b/crates/app-tauri/src/main.rs new file mode 100644 index 0000000..4310022 --- /dev/null +++ b/crates/app-tauri/src/main.rs @@ -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(); +} diff --git a/crates/app-tauri/src/pty.rs b/crates/app-tauri/src/pty.rs new file mode 100644 index 0000000..caf5293 --- /dev/null +++ b/crates/app-tauri/src/pty.rs @@ -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; + +/// Registry mapping live terminal sessions to their output [`Channel`]. +/// +/// Thread-safe; cloned `Arc` is held in [`crate::state::AppState`]. +#[derive(Default)] +pub struct PtyBridge { + channels: Mutex>>, +} + +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) { + 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) + } +} diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs new file mode 100644 index 0000000..455fe28 --- /dev/null +++ b/crates/app-tauri/src/state.rs @@ -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` into the use cases (ARCHITECTURE §1.1, §10). The use cases +//! are then exposed through `tauri::State` 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` held inside the use +/// cases. +pub struct AppState { + /// Trivial health use case validating the end-to-end wiring. + pub health: Arc, + /// Create a project (init `.ideai/`, register it). + pub create_project: Arc, + /// Open a project (load meta + manifest). + pub open_project: Arc, + /// Close a project (persist state). + pub close_project: Arc, + /// Close a tab. + pub close_tab: Arc, + /// List known projects. + pub list_projects: Arc, + /// Open a terminal (spawn PTY, register session). + pub open_terminal: Arc, + /// Write keystrokes to a terminal. + pub write_terminal: Arc, + /// Resize a terminal. + pub resize_terminal: Arc, + /// Close a terminal (kill PTY). + pub close_terminal: Arc, + /// Load a project's persisted layout tree. + pub load_layout: Arc, + /// Mutate + persist a project's layout tree. + pub mutate_layout: Arc, + /// List all named layouts for a project (#4). + pub list_layouts: Arc, + /// Create a new named layout (#4). + pub create_layout: Arc, + /// Rename a named layout (#4). + pub rename_layout: Arc, + /// Delete a named layout (#4). + pub delete_layout: Arc, + /// Set the active named layout (#4). + pub set_active_layout: Arc, + /// Detect which candidate profiles' CLIs are installed (first-run). + pub detect_profiles: Arc, + /// List configured profiles. + pub list_profiles: Arc, + /// Save (upsert) a profile. + pub save_profile: Arc, + /// Delete a profile. + pub delete_profile: Arc, + /// Persist the batch of chosen profiles (closes the first run). + pub configure_profiles: Arc, + /// Expose the pre-filled reference catalogue. + pub reference_profiles: Arc, + /// Whether the first-run wizard should show + the reference catalogue. + pub first_run_state: Arc, + /// 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, + /// Active-terminal registry shared by the terminal use cases. + pub terminal_sessions: Arc, + /// The domain event bus (also handed to the event relay). + pub event_bus: Arc, + /// Generic PTY↔Channel bridge registry (consumed by L3). + pub pty_bridge: Arc, + // --- Agents (L6) --- + /// Create a project agent from scratch. + pub create_agent: Arc, + /// List a project's agents. + pub list_agents: Arc, + /// Read an agent's Markdown context. + pub read_agent_context: Arc, + /// Overwrite an agent's Markdown context. + pub update_agent_context: Arc, + /// Delete an agent from the manifest. + pub delete_agent: Arc, + /// Launch an agent (spawn PTY, apply injection strategy). + pub launch_agent: Arc, + /// Project registry — used by agent commands to resolve a `Project` from an id. + pub project_store: Arc, + // --- Windows (L10) --- + /// Detach a tab into a new OS window (persists the workspace topology). + pub move_tab: Arc, + // --- Templates & sync (L7) --- + /// Create a template in the global store. + pub create_template: Arc, + /// Update a template's content (bumps version). + pub update_template: Arc, + /// List all templates in the global store. + pub list_templates: Arc, + /// Delete a template from the global store. + pub delete_template: Arc, + /// Create an agent from a template. + pub create_agent_from_template: Arc, + /// Detect which synchronized agents are behind their template. + pub detect_agent_drift: Arc, + /// Apply a template update to a synchronized agent. + pub sync_agent_with_template: Arc, + // --- Git (L8) --- + /// Report the working-tree status of a repository. + pub git_status: Arc, + /// Stage a path. + pub git_stage: Arc, + /// Unstage a path. + pub git_unstage: Arc, + /// Create a commit. + pub git_commit: Arc, + /// List branches. + pub git_branches: Arc, + /// Check out a branch. + pub git_checkout: Arc, + /// Return the recent commit log. + pub git_log: Arc, + /// Initialise a repository. + pub git_init: Arc, + /// Return the commit graph for all local branches. + pub git_graph: Arc, +} + +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, + app_data_dir.to_string_lossy().into_owned(), + )); + + // Port-typed handles for injection. + let fs_port = Arc::clone(&fs) as Arc; + let store_port = Arc::clone(&store) as Arc; + let events_port = Arc::clone(&event_bus) as Arc; + + // --- Use cases (ports injected as Arc) --- + let health = Arc::new(HealthUseCase::new( + Arc::clone(&clock) as Arc, + Arc::clone(&ids) as Arc, + Arc::clone(&events_port), + )); + + let create_project = Arc::new(CreateProject::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + 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; + 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, + 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; + let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port))); + let runtime_port = Arc::clone(&runtime) as Arc; + + 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; + + 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; + + let create_agent = Arc::new(CreateAgentFromScratch::new( + Arc::clone(&contexts_port), + Arc::clone(&ids) as Arc, + 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; + + let create_template = Arc::new(CreateTemplate::new( + Arc::clone(&template_store_port), + Arc::clone(&ids) as Arc, + )); + 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, + 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; + + 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, + )); + + 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, + } + } +} diff --git a/crates/app-tauri/tauri.conf.json b/crates/app-tauri/tauri.conf.json new file mode 100644 index 0000000..4236c38 --- /dev/null +++ b/crates/app-tauri/tauri.conf.json @@ -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"] + } +} diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs new file mode 100644 index 0000000..7791cca --- /dev/null +++ b/crates/app-tauri/tests/dto.rs @@ -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(_))); +} diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs new file mode 100644 index 0000000..4ca60d2 --- /dev/null +++ b/crates/app-tauri/tests/dto_agents.rs @@ -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` 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 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"); +} diff --git a/crates/app-tauri/tests/dto_git.rs b/crates/app-tauri/tests/dto_git.rs new file mode 100644 index 0000000..db6c37e --- /dev/null +++ b/crates/app-tauri/tests/dto_git.rs @@ -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"); +} diff --git a/crates/app-tauri/tests/dto_layouts.rs b/crates/app-tauri/tests/dto_layouts.rs new file mode 100644 index 0000000..3b95492 --- /dev/null +++ b/crates/app-tauri/tests/dto_layouts.rs @@ -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"), + } +} diff --git a/crates/app-tauri/tests/dto_profiles.rs b/crates/app-tauri/tests/dto_profiles.rs new file mode 100644 index 0000000..677dc63 --- /dev/null +++ b/crates/app-tauri/tests/dto_profiles.rs @@ -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)); +} diff --git a/crates/app-tauri/tests/dto_templates.rs b/crates/app-tauri/tests/dto_templates.rs new file mode 100644 index 0000000..8e43d3a --- /dev/null +++ b/crates/app-tauri/tests/dto_templates.rs @@ -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 / From +// --------------------------------------------------------------------------- + +#[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"); +} diff --git a/crates/app-tauri/tests/dto_window.rs b/crates/app-tauri/tests/dto_window.rs new file mode 100644 index 0000000..bb5205c --- /dev/null +++ b/crates/app-tauri/tests/dto_window.rs @@ -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"); +} diff --git a/crates/app-tauri/tests/pty_bridge.rs b/crates/app-tauri/tests/pty_bridge.rs new file mode 100644 index 0000000..4c6b93d --- /dev/null +++ b/crates/app-tauri/tests/pty_bridge.rs @@ -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>` whose sent chunks are recorded into `sink`. +/// +/// `Vec` 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>>>) -> Channel> { + Channel::new(move |body: InvokeResponseBody| { + let bytes: Vec = 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]]); +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..94555af --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "application" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "IdeA — application layer: use cases, DTOs, AppError. Depends only on domain ports." + +[dependencies] +domain = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +# `v5` derives stable reference-profile ids from a fixed namespace (catalogue). +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs new file mode 100644 index 0000000..21b876b --- /dev/null +++ b/crates/application/src/agent/catalogue.rs @@ -0,0 +1,93 @@ +//! Reference profile **catalogue** — the pre-filled, *editable* profiles offered +//! by the first-run wizard (CONTEXT §9, ARCHITECTURE §6 `ConfigureProfiles`). +//! +//! These are **data, not domain code**: the catalogue lives in the application +//! layer (a product decision about *which* AIs to suggest), built from the +//! domain's validating constructors. Nothing is imposed — the user picks, edits +//! the pre-filled commands, and may add custom profiles. The single +//! [`domain::ports::AgentRuntime`] adapter consumes whatever profiles result. +//! +//! Reference set (CONTEXT §9): +//! - **Claude Code** — `claude`, context via `CLAUDE.md` (convention file), +//! - **OpenAI Codex CLI** — `codex`, context via `AGENTS.md`, +//! - **Gemini CLI** — `gemini`, context via `GEMINI.md`, +//! - **Aider** — `aider`, context passed as an argument (`--message-file {path}`). +//! +//! The ids are **stable, deterministic UUIDs** (derived from a fixed namespace) +//! so re-deriving the catalogue yields the same id for "claude" every time, +//! making the reference profiles addressable across runs without a registry. + +use domain::ids::ProfileId; +use domain::profile::{AgentProfile, ContextInjection}; + +/// A fixed UUID namespace used to derive stable ids for reference profiles. +/// (Random-looking but constant; only its stability matters.) +const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b"); + +/// Derives a stable [`ProfileId`] for a reference profile from its slug. +#[must_use] +fn reference_id(slug: &str) -> ProfileId { + ProfileId::from_uuid(uuid::Uuid::new_v5(&REFERENCE_NAMESPACE, slug.as_bytes())) +} + +/// Returns the stable id a reference profile slug maps to (exposed for tests and +/// callers that need to address a reference profile). +#[must_use] +pub fn reference_profile_id(slug: &str) -> ProfileId { + reference_id(slug) +} + +/// Builds the pre-filled, editable reference profiles (CONTEXT §9). +/// +/// # Panics +/// Never in practice: every literal here satisfies the domain invariants, so the +/// constructors cannot fail; the `expect`s document that. +#[must_use] +pub fn reference_profiles() -> Vec { + vec![ + AgentProfile::new( + reference_id("claude"), + "Claude Code", + "claude", + Vec::new(), + ContextInjection::convention_file("CLAUDE.md") + .expect("CLAUDE.md is a valid convention target"), + Some("claude --version".to_owned()), + "{projectRoot}", + ) + .expect("claude reference profile is valid"), + AgentProfile::new( + reference_id("codex"), + "OpenAI Codex CLI", + "codex", + Vec::new(), + ContextInjection::convention_file("AGENTS.md") + .expect("AGENTS.md is a valid convention target"), + Some("codex --version".to_owned()), + "{projectRoot}", + ) + .expect("codex reference profile is valid"), + AgentProfile::new( + reference_id("gemini"), + "Gemini CLI", + "gemini", + Vec::new(), + ContextInjection::convention_file("GEMINI.md") + .expect("GEMINI.md is a valid convention target"), + Some("gemini --version".to_owned()), + "{projectRoot}", + ) + .expect("gemini reference profile is valid"), + AgentProfile::new( + reference_id("aider"), + "Aider", + "aider", + Vec::new(), + ContextInjection::flag("--message-file {path}") + .expect("aider flag template is non-empty"), + Some("aider --version".to_owned()), + "{projectRoot}", + ) + .expect("aider reference profile is valid"), + ] +} diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs new file mode 100644 index 0000000..bc7a537 --- /dev/null +++ b/crates/application/src/agent/lifecycle.rs @@ -0,0 +1,538 @@ +//! Agent lifecycle use cases (ARCHITECTURE §6, L6). +//! +//! These own the *project-agent* side (distinct from the profile side in +//! [`super::usecases`]): creating agents and their `.md` contexts under +//! `.ideai/`, listing/reading/updating them, and — the centrepiece — +//! [`LaunchAgent`], which resolves the agent's profile + context, applies the +//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and +//! spawns the CLI. +//! +//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`], +//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about +//! a concrete adapter or Tauri. + +use std::sync::Arc; + +use domain::ports::{ + AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext, + ProfileStore, PtyPort, RemotePath, SpawnSpec, +}; +use domain::{ + Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId, + Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession, +}; + +use crate::error::AppError; +use crate::terminal::TerminalSessions; + +/// Directory (relative to `.ideai/`) under which agent contexts are written. +const AGENTS_SUBDIR: &str = "agents"; + +// --------------------------------------------------------------------------- +// CreateAgentFromScratch +// --------------------------------------------------------------------------- + +/// Input for [`CreateAgentFromScratch::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateAgentInput { + /// The project that owns the agent. + pub project: Project, + /// Display name of the agent. + pub name: String, + /// Runtime profile the agent launches with. + pub profile_id: ProfileId, + /// Initial `.md` content (empty when `None`). + pub initial_content: Option, +} + +/// Output of [`CreateAgentFromScratch::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateAgentOutput { + /// The freshly-created agent. + pub agent: Agent, +} + +/// Creates a project agent from scratch: mints an id, derives a unique `.md` +/// path, records the manifest entry, then writes the (possibly empty) context. +pub struct CreateAgentFromScratch { + contexts: Arc, + ids: Arc, + events: Arc, +} + +impl CreateAgentFromScratch { + /// Builds the use case from its injected ports. + #[must_use] + pub fn new( + contexts: Arc, + ids: Arc, + events: Arc, + ) -> Self { + Self { + contexts, + ids, + events, + } + } + + /// Executes creation. + /// + /// Ordering matters: the manifest entry is persisted **before** the context + /// is written, because [`AgentContextStore::write_context`] resolves the + /// on-disk path from the manifest. + /// + /// # Errors + /// - [`AppError::Invalid`] if the name is empty or the manifest would become + /// inconsistent, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: CreateAgentInput) -> Result { + let manifest = self.contexts.load_manifest(&input.project).await?; + + let id = AgentId::from_uuid(self.ids.new_uuid()); + let md_path = unique_md_path(&input.name, &manifest); + + let agent = Agent::new( + id, + input.name, + md_path, + input.profile_id, + AgentOrigin::Scratch, + false, + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; + + // Append the entry and re-validate the whole manifest (unique md_paths). + let mut entries = manifest.entries; + entries.push(ManifestEntry::from_agent(&agent)); + let manifest = AgentManifest::new(manifest.version, entries) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.contexts.save_manifest(&input.project, &manifest).await?; + + // Now the path resolves: write the initial context. + let md = MarkdownDoc::new(input.initial_content.unwrap_or_default()); + self.contexts + .write_context(&input.project, &agent.id, &md) + .await?; + + self.events.publish(DomainEvent::LayoutChanged { + project_id: input.project.id, + }); + + Ok(CreateAgentOutput { agent }) + } +} + +// --------------------------------------------------------------------------- +// ListAgents +// --------------------------------------------------------------------------- + +/// Input for [`ListAgents::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListAgentsInput { + /// The project whose agents to list. + pub project: Project, +} + +/// Output of [`ListAgents::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListAgentsOutput { + /// The project's agents (reconstructed from the manifest). + pub agents: Vec, +} + +/// Lists a project's agents by reconstructing them from the manifest entries. +pub struct ListAgents { + contexts: Arc, +} + +impl ListAgents { + /// Builds the use case from the [`AgentContextStore`] port. + #[must_use] + pub fn new(contexts: Arc) -> Self { + Self { contexts } + } + + /// Loads the manifest and folds each entry back into an [`Agent`]. + /// + /// # Errors + /// - [`AppError::Store`] on persistence failure, + /// - [`AppError::Invalid`] if a persisted entry violates an agent invariant. + pub async fn execute(&self, input: ListAgentsInput) -> Result { + let manifest = self.contexts.load_manifest(&input.project).await?; + let agents = manifest + .entries + .iter() + .map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string()))) + .collect::, _>>()?; + Ok(ListAgentsOutput { agents }) + } +} + +// --------------------------------------------------------------------------- +// ReadAgentContext / UpdateAgentContext +// --------------------------------------------------------------------------- + +/// Input for [`ReadAgentContext::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadAgentContextInput { + /// The owning project. + pub project: Project, + /// The agent whose `.md` to read. + pub agent_id: AgentId, +} + +/// Output of [`ReadAgentContext::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadAgentContextOutput { + /// The agent's Markdown context. + pub content: MarkdownDoc, +} + +/// Reads an agent's `.md` context. +pub struct ReadAgentContext { + contexts: Arc, +} + +impl ReadAgentContext { + /// Builds the use case. + #[must_use] + pub fn new(contexts: Arc) -> Self { + Self { contexts } + } + + /// Reads the context. + /// + /// # Errors + /// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: ReadAgentContextInput, + ) -> Result { + let content = self + .contexts + .read_context(&input.project, &input.agent_id) + .await?; + Ok(ReadAgentContextOutput { content }) + } +} + +/// Input for [`UpdateAgentContext::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateAgentContextInput { + /// The owning project. + pub project: Project, + /// The agent whose `.md` to overwrite. + pub agent_id: AgentId, + /// New Markdown content. + pub content: String, +} + +/// Overwrites an agent's `.md` context. +pub struct UpdateAgentContext { + contexts: Arc, +} + +impl UpdateAgentContext { + /// Builds the use case. + #[must_use] + pub fn new(contexts: Arc) -> Self { + Self { contexts } + } + + /// Writes the new context. + /// + /// # Errors + /// - [`AppError::NotFound`] if the agent is unknown, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> { + let md = MarkdownDoc::new(input.content); + self.contexts + .write_context(&input.project, &input.agent_id, &md) + .await?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// DeleteAgent +// --------------------------------------------------------------------------- + +/// Input for [`DeleteAgent::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteAgentInput { + /// The owning project. + pub project: Project, + /// The agent to remove. + pub agent_id: AgentId, +} + +/// Removes an agent from the project manifest. +/// +/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no +/// delete, and keeping the file is the safe default (the user may want to recover +/// the context). Re-creating an agent with the same name reuses a fresh path. +pub struct DeleteAgent { + contexts: Arc, + events: Arc, +} + +impl DeleteAgent { + /// Builds the use case. + #[must_use] + pub fn new(contexts: Arc, events: Arc) -> Self { + Self { contexts, events } + } + + /// Drops the manifest entry for the agent. + /// + /// # Errors + /// - [`AppError::NotFound`] if the agent is not in the manifest, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> { + let manifest = self.contexts.load_manifest(&input.project).await?; + let before = manifest.entries.len(); + let entries: Vec = manifest + .entries + .into_iter() + .filter(|e| e.agent_id != input.agent_id) + .collect(); + if entries.len() == before { + return Err(AppError::NotFound(format!("agent {}", input.agent_id))); + } + let manifest = AgentManifest::new(manifest.version, entries) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.contexts.save_manifest(&input.project, &manifest).await?; + self.events.publish(DomainEvent::LayoutChanged { + project_id: input.project.id, + }); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// LaunchAgent +// --------------------------------------------------------------------------- + +/// Input for [`LaunchAgent::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchAgentInput { + /// The owning project. + pub project: Project, + /// The agent to launch. + pub agent_id: AgentId, + /// Initial terminal height in rows. + pub rows: u16, + /// Initial terminal width in columns. + pub cols: u16, + /// The layout leaf hosting the session (a fresh node when `None`). + pub node_id: Option, +} + +/// Output of [`LaunchAgent::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchAgentOutput { + /// The created agent terminal session. + pub session: TerminalSession, +} + +/// Launches an agent: resolve profile + context, prepare the invocation, apply +/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI. +/// +/// This is the orchestrating use case of L6 and therefore consumes several ports +/// — each only for the slice it needs (Interface Segregation): the context store +/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime +/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile` +/// context), and the PTY (spawn + optional stdin injection). +pub struct LaunchAgent { + contexts: Arc, + profiles: Arc, + runtime: Arc, + fs: Arc, + pty: Arc, + sessions: Arc, + events: Arc, +} + +impl LaunchAgent { + /// Builds the use case from its injected ports. + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new( + contexts: Arc, + profiles: Arc, + runtime: Arc, + fs: Arc, + pty: Arc, + sessions: Arc, + events: Arc, + ) -> Self { + Self { + contexts, + profiles, + runtime, + fs, + pty, + sessions, + events, + } + } + + /// Executes the launch. + /// + /// Step order is contractually significant (and unit-tested): resolve the + /// agent + context, **`prepare_invocation`**, **apply the injection plan** + /// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the + /// resolved `cwd`, and finally pipe the context on stdin for the `Stdin` + /// strategy. + /// + /// # Errors + /// - [`AppError::NotFound`] if the agent or its profile is unknown, + /// - [`AppError::Invalid`] for a zero-sized terminal, + /// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on + /// the respective port failures. + pub async fn execute(&self, input: LaunchAgentInput) -> Result { + let size = + PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; + + // 1. Resolve the agent from the manifest (name + profile + md_path). + let manifest = self.contexts.load_manifest(&input.project).await?; + let entry = manifest + .entries + .iter() + .find(|e| e.agent_id == input.agent_id) + .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + let agent = entry + .to_agent() + .map_err(|e| AppError::Invalid(e.to_string()))?; + + // 2. Read its context and resolve its profile. + let content = self + .contexts + .read_context(&input.project, &agent.id) + .await?; + let profile = self + .profiles + .list() + .await? + .into_iter() + .find(|p| p.id == agent.profile_id) + .ok_or_else(|| { + AppError::NotFound(format!("profile {} for agent", agent.profile_id)) + })?; + + // 3. Prepare the invocation (pure): command + args + injection plan + cwd. + let prepared = PreparedContext { + content: content.clone(), + relative_path: agent.context_path.clone(), + }; + let mut spec = self + .runtime + .prepare_invocation(&profile, &prepared, &input.project.root)?; + + // 4. Apply the injection plan side effects *before* spawning. + self.apply_injection(&input.project, &agent.context_path, &content, &mut spec) + .await?; + + // 5. Spawn the PTY at the resolved cwd; adopt its session id everywhere. + let handle = self.pty.spawn(spec.clone(), size).await?; + let session_id = handle.session_id; + + // 6. For the Stdin strategy, pipe the context once the PTY is live. + if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) { + self.pty.write(&handle, content.as_str().as_bytes())?; + } + + let node_id = input.node_id.unwrap_or_else(NodeId::new_random); + let mut session = TerminalSession::starting( + session_id, + node_id, + spec.cwd.clone(), + SessionKind::Agent { + agent_id: agent.id, + }, + size, + ); + session.status = SessionStatus::Running; + self.sessions.insert(handle, session.clone()); + + self.events.publish(DomainEvent::AgentLaunched { + agent_id: agent.id, + session_id, + }); + + Ok(LaunchAgentOutput { session }) + } + + /// Applies the context-injection plan that must happen *before* spawn: + /// materialising a `conventionFile` context (write the `.md` to `/target`) + /// or attaching the on-disk context path to an environment variable. `Args` is + /// already folded into the spec by the runtime; `Stdin` is handled post-spawn. + async fn apply_injection( + &self, + project: &Project, + context_rel_path: &str, + content: &MarkdownDoc, + spec: &mut SpawnSpec, + ) -> Result<(), AppError> { + match spec.context_plan.clone() { + Some(ContextInjectionPlan::File { target }) => { + // conventionFile spike (ARCHITECTURE §13.6): copy the context to the + // conventional file (e.g. CLAUDE.md), overwriting any existing one. + // A copy (not a symlink) is the portable choice — Windows symlinks + // need privileges and SFTP/WSL symlink semantics differ. + let path = RemotePath::new(join(&spec.cwd, &target)); + self.fs.write(&path, content.as_str().as_bytes()).await?; + } + Some(ContextInjectionPlan::Env { var }) => { + // Hand the CLI the absolute path of the agent's `.md` (which lives at + // `/.ideai/`) via the environment variable. + let abspath = join(&project.root, &format!(".ideai/{context_rel_path}")); + spec.env.push((var, abspath)); + } + // Args were folded into spec.args by prepare_invocation; Stdin is + // applied after the PTY is live. + Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {} + } + Ok(()) + } +} + +/// Builds an absolute path string by joining a [`ProjectPath`] with a relative +/// segment using a POSIX separator. +fn join(base: &ProjectPath, rel: &str) -> String { + let b = base.as_str().trim_end_matches(['/', '\\']); + format!("{b}/{rel}") +} + +/// Derives a unique, filesystem-safe `md_path` (`agents/.md`) for a new +/// agent, disambiguating against the manifest's existing paths with a numeric +/// suffix when needed. Shared with the template-driven agent creation (L7). +pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String { + let slug = slugify(name); + let base = if slug.is_empty() { "agent".to_owned() } else { slug }; + let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md"); + let mut n = 2; + while manifest.entries.iter().any(|e| e.md_path == candidate) { + candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md"); + n += 1; + } + candidate +} + +/// Lowercases and slugifies a display name into a safe file stem +/// (`[a-z0-9-]`), collapsing runs of separators. +fn slugify(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut prev_dash = false; + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + out.trim_matches('-').to_owned() +} diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs new file mode 100644 index 0000000..c3f9157 --- /dev/null +++ b/crates/application/src/agent/mod.rs @@ -0,0 +1,27 @@ +//! Agent-profile use cases & reference catalogue (ARCHITECTURE §6, L5). +//! +//! This module owns the *profile* side of the AI runtime: detecting which CLIs +//! are installed, persisting the chosen/edited/custom profiles, and exposing the +//! pre-filled reference catalogue that seeds the first-run wizard. It talks only +//! to the [`domain::ports::AgentRuntime`] and [`domain::ports::ProfileStore`] +//! ports. Launching an agent (PTY + injection) is L6. + +mod catalogue; +mod lifecycle; +mod usecases; + +pub(crate) use lifecycle::unique_md_path; + +pub use catalogue::{reference_profile_id, reference_profiles}; +pub use lifecycle::{ + CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, + ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext, + UpdateAgentContextInput, +}; +pub use usecases::{ + ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, + DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, + FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles, + ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, +}; diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs new file mode 100644 index 0000000..def35e2 --- /dev/null +++ b/crates/application/src/agent/usecases.rs @@ -0,0 +1,323 @@ +//! Profile use cases (ARCHITECTURE §6, L5). Each is a single-responsibility +//! struct carrying its ports as `Arc` and exposing one `execute`. +//! +//! - [`DetectProfiles`] — probe a set of candidate profiles via [`AgentRuntime`] +//! and report which CLIs are installed (first-run availability ✓/✗). +//! - [`ListProfiles`] / [`SaveProfile`] / [`DeleteProfile`] — CRUD over the +//! persisted profiles through the [`ProfileStore`]. +//! - [`ConfigureProfiles`] — persist a batch of chosen/edited/custom profiles +//! (closes the first-run wizard). +//! - [`ReferenceProfiles`] — expose the pre-filled, editable catalogue. +//! - [`FirstRunState`] — tell the UI whether the first-run wizard should show +//! (no `profiles.json` yet) and hand it the reference catalogue. + +use std::sync::Arc; + +use domain::ports::{AgentRuntime, ProfileStore}; +use domain::profile::AgentProfile; + +use crate::error::AppError; + +use super::catalogue::reference_profiles; + +// --------------------------------------------------------------------------- +// DetectProfiles +// --------------------------------------------------------------------------- + +/// Input for [`DetectProfiles::execute`]: the candidate profiles to probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectProfilesInput { + /// Profiles whose `detect` command should be run. + pub candidates: Vec, +} + +/// Availability of a single candidate after detection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProfileAvailability { + /// The probed profile. + pub profile: AgentProfile, + /// Whether its CLI was detected as installed (exit code 0). + pub available: bool, +} + +/// Output of [`DetectProfiles::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectProfilesOutput { + /// One entry per candidate (same order), with its availability. + pub results: Vec, +} + +/// Probes candidate profiles' detection commands and reports availability. +pub struct DetectProfiles { + runtime: Arc, +} + +impl DetectProfiles { + /// Builds the use case from the [`AgentRuntime`] port. The runtime itself + /// holds the [`domain::ports::ProcessSpawner`] used for detection. + #[must_use] + pub fn new(runtime: Arc) -> Self { + Self { runtime } + } + + /// Runs detection for each candidate. A detection *error* (e.g. the command + /// could not even be launched) is reported as `available: false`, not a + /// hard failure — the wizard just shows ✗ and the user can still keep the + /// profile. + /// + /// # Errors + /// Currently never returns `Err` (failures degrade to `available: false`); + /// the `Result` keeps the signature uniform with the other use cases. + pub async fn execute( + &self, + input: DetectProfilesInput, + ) -> Result { + let results = input + .candidates + .into_iter() + .map(|profile| { + let available = self.runtime.detect(&profile).unwrap_or(false); + ProfileAvailability { profile, available } + }) + .collect(); + Ok(DetectProfilesOutput { results }) + } +} + +// --------------------------------------------------------------------------- +// ListProfiles +// --------------------------------------------------------------------------- + +/// Output of [`ListProfiles::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListProfilesOutput { + /// All configured profiles. + pub profiles: Vec, +} + +/// Lists the configured profiles from the store. +pub struct ListProfiles { + store: Arc, +} + +impl ListProfiles { + /// Builds the use case from the [`ProfileStore`] port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Lists configured profiles. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure. + pub async fn execute(&self) -> Result { + Ok(ListProfilesOutput { + profiles: self.store.list().await?, + }) + } +} + +// --------------------------------------------------------------------------- +// SaveProfile +// --------------------------------------------------------------------------- + +/// Input for [`SaveProfile::execute`]: the profile to upsert. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveProfileInput { + /// The profile to create or replace (by id). + pub profile: AgentProfile, +} + +/// Output of [`SaveProfile::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveProfileOutput { + /// The saved profile (echoed back). + pub profile: AgentProfile, +} + +/// Persists (creates or replaces) a single profile. +pub struct SaveProfile { + store: Arc, +} + +impl SaveProfile { + /// Builds the use case from the [`ProfileStore`] port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Saves the profile. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: SaveProfileInput) -> Result { + self.store.save(&input.profile).await?; + Ok(SaveProfileOutput { + profile: input.profile, + }) + } +} + +// --------------------------------------------------------------------------- +// DeleteProfile +// --------------------------------------------------------------------------- + +/// Input for [`DeleteProfile::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteProfileInput { + /// Id of the profile to delete. + pub id: domain::ids::ProfileId, +} + +/// Deletes a profile by id. +pub struct DeleteProfile { + store: Arc, +} + +impl DeleteProfile { + /// Builds the use case from the [`ProfileStore`] port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Deletes the profile. + /// + /// # Errors + /// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on + /// persistence failure. + pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> { + self.store.delete(input.id).await?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// ConfigureProfiles +// --------------------------------------------------------------------------- + +/// Input for [`ConfigureProfiles::execute`]: the chosen/edited/custom profiles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigureProfilesInput { + /// All profiles the user decided to keep (closes the first run). + pub profiles: Vec, +} + +/// Output of [`ConfigureProfiles::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigureProfilesOutput { + /// The persisted profiles. + pub profiles: Vec, +} + +/// Persists the batch of profiles chosen at the end of the first-run wizard. +/// +/// Saving even an empty list creates `profiles.json`, which marks the first run +/// as done (so the wizard does not reappear). +pub struct ConfigureProfiles { + store: Arc, +} + +impl ConfigureProfiles { + /// Builds the use case from the [`ProfileStore`] port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Persists each chosen profile. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: ConfigureProfilesInput, + ) -> Result { + for profile in &input.profiles { + self.store.save(profile).await?; + } + // Ensure `profiles.json` exists even when the user kept nothing, so the + // first run is recorded as complete. + if input.profiles.is_empty() { + self.store.mark_configured().await?; + } + Ok(ConfigureProfilesOutput { + profiles: input.profiles, + }) + } +} + +// --------------------------------------------------------------------------- +// ReferenceProfiles (catalogue accessor) +// --------------------------------------------------------------------------- + +/// Output of [`ReferenceProfiles::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReferenceProfilesOutput { + /// The pre-filled, editable reference catalogue. + pub profiles: Vec, +} + +/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider). +#[derive(Default)] +pub struct ReferenceProfiles; + +impl ReferenceProfiles { + /// Builds the (stateless) use case. + #[must_use] + pub fn new() -> Self { + Self + } + + /// Returns the reference catalogue. Infallible. + /// + /// # Errors + /// Never; the `Result` keeps the call site uniform. + #[allow(clippy::unused_async)] + pub async fn execute(&self) -> Result { + Ok(ReferenceProfilesOutput { + profiles: reference_profiles(), + }) + } +} + +// --------------------------------------------------------------------------- +// FirstRunState +// --------------------------------------------------------------------------- + +/// Output of [`FirstRunState::execute`]: whether to show the wizard + catalogue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FirstRunStateOutput { + /// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard. + pub is_first_run: bool, + /// The pre-filled reference catalogue to seed the wizard. + pub reference_profiles: Vec, +} + +/// Reports whether the IDE is on its first run (no profiles configured yet) and +/// provides the reference catalogue to seed the wizard. +pub struct FirstRunState { + store: Arc, +} + +impl FirstRunState { + /// Builds the use case from the [`ProfileStore`] port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Computes the first-run state. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure. + pub async fn execute(&self) -> Result { + let configured = self.store.is_configured().await?; + Ok(FirstRunStateOutput { + is_first_run: !configured, + reference_profiles: reference_profiles(), + }) + } +} diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs new file mode 100644 index 0000000..d553636 --- /dev/null +++ b/crates/application/src/error.rs @@ -0,0 +1,115 @@ +//! [`AppError`] — the single error type returned by every use case. +//! +//! Per-port errors from the domain ([`domain::ports::FsError`], +//! [`domain::ports::StoreError`], …) are mapped into this application-level +//! error so that the presentation layer (Tauri commands) only ever has to deal +//! with one error shape when building its `ErrorDTO`. + +use domain::ports::{ + FsError, GitError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError, +}; + +/// Errors surfaced by application use cases. +/// +/// Each variant carries a stable, machine-readable `code` (see [`AppError::code`]) +/// so the presentation layer can map it to an `ErrorDTO` without string matching. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AppError { + /// A requested resource was not found. + #[error("not found: {0}")] + NotFound(String), + + /// The input failed a domain/application invariant. + #[error("invalid input: {0}")] + Invalid(String), + + /// A filesystem operation failed. + #[error("filesystem error: {0}")] + FileSystem(String), + + /// A persistence (store) operation failed. + #[error("store error: {0}")] + Store(String), + + /// A process/PTY/runtime operation failed. + #[error("process error: {0}")] + Process(String), + + /// A git operation failed. + #[error("git error: {0}")] + Git(String), + + /// A remote (SSH/WSL) operation failed. + #[error("remote error: {0}")] + Remote(String), + + /// An unexpected internal error. + #[error("internal error: {0}")] + Internal(String), +} + +impl AppError { + /// A stable, machine-readable code for this error, intended for the + /// `ErrorDTO` so the frontend can branch without parsing messages. + #[must_use] + pub fn code(&self) -> &'static str { + match self { + Self::NotFound(_) => "NOT_FOUND", + Self::Invalid(_) => "INVALID", + Self::FileSystem(_) => "FILESYSTEM", + Self::Store(_) => "STORE", + Self::Process(_) => "PROCESS", + Self::Git(_) => "GIT", + Self::Remote(_) => "REMOTE", + Self::Internal(_) => "INTERNAL", + } + } +} + +impl From for AppError { + fn from(e: FsError) -> Self { + match e { + FsError::NotFound(p) => Self::NotFound(p), + other => Self::FileSystem(other.to_string()), + } + } +} + +impl From for AppError { + fn from(e: StoreError) -> Self { + match e { + StoreError::NotFound => Self::NotFound("store item".to_owned()), + other => Self::Store(other.to_string()), + } + } +} + +impl From for AppError { + fn from(e: PtyError) -> Self { + Self::Process(e.to_string()) + } +} + +impl From for AppError { + fn from(e: ProcessError) -> Self { + Self::Process(e.to_string()) + } +} + +impl From for AppError { + fn from(e: RuntimeError) -> Self { + Self::Process(e.to_string()) + } +} + +impl From for AppError { + fn from(e: GitError) -> Self { + Self::Git(e.to_string()) + } +} + +impl From for AppError { + fn from(e: RemoteError) -> Self { + Self::Remote(e.to_string()) + } +} diff --git a/crates/application/src/git/mod.rs b/crates/application/src/git/mod.rs new file mode 100644 index 0000000..1a9c174 --- /dev/null +++ b/crates/application/src/git/mod.rs @@ -0,0 +1,12 @@ +//! Git use cases (ARCHITECTURE §6, L8). Local Git operations orchestrated over +//! the [`domain::ports::GitPort`]: status, staging, commit, branches, checkout +//! and log. State-changing operations announce [`domain::DomainEvent::GitStateChanged`]. + +mod usecases; + +pub use usecases::{ + GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit, + GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput, + GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, + GitStatusOutput, GitUnstage, +}; diff --git a/crates/application/src/git/usecases.rs b/crates/application/src/git/usecases.rs new file mode 100644 index 0000000..79fd7cb --- /dev/null +++ b/crates/application/src/git/usecases.rs @@ -0,0 +1,382 @@ +//! Git use cases (ARCHITECTURE §6, L8). Thin orchestration over the [`GitPort`]: +//! validate the root, call the port, and (for state-changing operations) announce +//! [`DomainEvent::GitStateChanged`] so the UI can refresh. + +use std::sync::Arc; + +use domain::ports::{EventBus, GitCommitInfo, GitFileStatus, GitPort, GraphCommit}; +use domain::{DomainEvent, ProjectId, ProjectPath}; + +use crate::error::AppError; + +/// Parses a raw root string into a validated [`ProjectPath`]. +fn parse_root(root: &str) -> Result { + ProjectPath::new(root).map_err(|e| AppError::Invalid(e.to_string())) +} + +// --------------------------------------------------------------------------- +// GitStatus +// --------------------------------------------------------------------------- + +/// Input for [`GitStatus::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStatusInput { + /// Absolute repository root. + pub root: String, +} + +/// Output of [`GitStatus::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStatusOutput { + /// Changed paths (staged flag per path). + pub entries: Vec, +} + +/// Reports the working-tree status of a repository. +pub struct GitStatus { + git: Arc, +} + +impl GitStatus { + /// Builds the use case from the [`GitPort`]. + #[must_use] + pub fn new(git: Arc) -> Self { + Self { git } + } + + /// Returns the status. + /// + /// # Errors + /// - [`AppError::Invalid`] for a non-absolute root, + /// - [`AppError::Git`] if the repo is missing or the operation fails. + pub async fn execute(&self, input: GitStatusInput) -> Result { + let root = parse_root(&input.root)?; + let entries = self.git.status(&root).await?; + Ok(GitStatusOutput { entries }) + } +} + +// --------------------------------------------------------------------------- +// GitStage / GitUnstage +// --------------------------------------------------------------------------- + +/// Input for [`GitStage::execute`] / [`GitUnstage::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStagePathInput { + /// Absolute repository root. + pub root: String, + /// Repo-relative path to (un)stage. + pub path: String, +} + +/// Stages a path (adds it to the index). +pub struct GitStage { + git: Arc, +} + +impl GitStage { + /// Builds the use case. + #[must_use] + pub fn new(git: Arc) -> Self { + Self { git } + } + + /// Stages the path. + /// + /// # Errors + /// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure. + pub async fn execute(&self, input: GitStagePathInput) -> Result<(), AppError> { + let root = parse_root(&input.root)?; + self.git.stage(&root, &input.path).await?; + Ok(()) + } +} + +/// Unstages a path (resets it to its HEAD state, or removes it when unborn). +pub struct GitUnstage { + git: Arc, +} + +impl GitUnstage { + /// Builds the use case. + #[must_use] + pub fn new(git: Arc) -> Self { + Self { git } + } + + /// Unstages the path. + /// + /// # Errors + /// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure. + pub async fn execute(&self, input: GitStagePathInput) -> Result<(), AppError> { + let root = parse_root(&input.root)?; + self.git.unstage(&root, &input.path).await?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// GitCommit +// --------------------------------------------------------------------------- + +/// Input for [`GitCommit::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitCommitInput { + /// The project (for the emitted event). + pub project_id: ProjectId, + /// Absolute repository root. + pub root: String, + /// Commit message. + pub message: String, +} + +/// Output of [`GitCommit::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitCommitOutput { + /// The created commit. + pub commit: GitCommitInfo, +} + +/// Commits the staged index, announcing [`DomainEvent::GitStateChanged`]. +pub struct GitCommit { + git: Arc, + events: Arc, +} + +impl GitCommit { + /// Builds the use case from its ports. + #[must_use] + pub fn new(git: Arc, events: Arc) -> Self { + Self { git, events } + } + + /// Creates the commit. + /// + /// # Errors + /// - [`AppError::Invalid`] for a bad root or an empty message, + /// - [`AppError::Git`] on failure. + pub async fn execute(&self, input: GitCommitInput) -> Result { + if input.message.trim().is_empty() { + return Err(AppError::Invalid("commit message is empty".to_owned())); + } + let root = parse_root(&input.root)?; + let commit = self.git.commit(&root, &input.message).await?; + self.events.publish(DomainEvent::GitStateChanged { + project_id: input.project_id, + }); + Ok(GitCommitOutput { commit }) + } +} + +// --------------------------------------------------------------------------- +// GitBranches +// --------------------------------------------------------------------------- + +/// Input for [`GitBranches::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitBranchesInput { + /// Absolute repository root. + pub root: String, +} + +/// Output of [`GitBranches::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitBranchesOutput { + /// All local branches. + pub branches: Vec, + /// The current branch (`None` when detached or unborn). + pub current: Option, +} + +/// Lists local branches and the current one. +pub struct GitBranches { + git: Arc, +} + +impl GitBranches { + /// Builds the use case. + #[must_use] + pub fn new(git: Arc) -> Self { + Self { git } + } + + /// Lists branches + current. + /// + /// # Errors + /// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure. + pub async fn execute(&self, input: GitBranchesInput) -> Result { + let root = parse_root(&input.root)?; + let branches = self.git.branches(&root).await?; + let current = self.git.current_branch(&root).await?; + Ok(GitBranchesOutput { branches, current }) + } +} + +// --------------------------------------------------------------------------- +// GitCheckout +// --------------------------------------------------------------------------- + +/// Input for [`GitCheckout::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitCheckoutInput { + /// The project (for the emitted event). + pub project_id: ProjectId, + /// Absolute repository root. + pub root: String, + /// Branch to check out. + pub branch: String, +} + +/// Checks out a branch, announcing [`DomainEvent::GitStateChanged`]. +pub struct GitCheckout { + git: Arc, + events: Arc, +} + +impl GitCheckout { + /// Builds the use case from its ports. + #[must_use] + pub fn new(git: Arc, events: Arc) -> Self { + Self { git, events } + } + + /// Checks out the branch. + /// + /// # Errors + /// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure. + pub async fn execute(&self, input: GitCheckoutInput) -> Result<(), AppError> { + let root = parse_root(&input.root)?; + self.git.checkout(&root, &input.branch).await?; + self.events.publish(DomainEvent::GitStateChanged { + project_id: input.project_id, + }); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// GitLog +// --------------------------------------------------------------------------- + +/// Input for [`GitLog::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitLogInput { + /// Absolute repository root. + pub root: String, + /// Maximum number of commits to return. + pub limit: usize, +} + +/// Output of [`GitLog::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitLogOutput { + /// Recent commits, newest first. + pub commits: Vec, +} + +/// Returns the recent commit log. +pub struct GitLog { + git: Arc, +} + +impl GitLog { + /// Builds the use case. + #[must_use] + pub fn new(git: Arc) -> Self { + Self { git } + } + + /// Returns the log. + /// + /// # Errors + /// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure. + pub async fn execute(&self, input: GitLogInput) -> Result { + let root = parse_root(&input.root)?; + let commits = self.git.log(&root, input.limit).await?; + Ok(GitLogOutput { commits }) + } +} + +// --------------------------------------------------------------------------- +// GitInit +// --------------------------------------------------------------------------- + +/// Input for [`GitInit::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitInitInput { + /// The project (for the emitted event). + pub project_id: ProjectId, + /// Absolute repository root. + pub root: String, +} + +/// Initialises a repository at the project root, announcing +/// [`DomainEvent::GitStateChanged`]. +pub struct GitInit { + git: Arc, + events: Arc, +} + +impl GitInit { + /// Builds the use case from its ports. + #[must_use] + pub fn new(git: Arc, events: Arc) -> Self { + Self { git, events } + } + + /// Initialises the repository. + /// + /// # Errors + /// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure. + pub async fn execute(&self, input: GitInitInput) -> Result<(), AppError> { + let root = parse_root(&input.root)?; + self.git.init(&root).await?; + self.events.publish(DomainEvent::GitStateChanged { + project_id: input.project_id, + }); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// GitGraph +// --------------------------------------------------------------------------- + +/// Input for [`GitGraph::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitGraphInput { + /// Absolute repository root. + pub root: String, + /// Maximum number of commits to return. + pub limit: usize, +} + +/// Output of [`GitGraph::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitGraphOutput { + /// Graph commits (topological + time order), newest first. + pub commits: Vec, +} + +/// Returns the full commit graph for all local branches. +pub struct GitGraph { + git: Arc, +} + +impl GitGraph { + /// Builds the use case. + #[must_use] + pub fn new(git: Arc) -> Self { + Self { git } + } + + /// Returns the graph. + /// + /// # Errors + /// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure. + pub async fn execute(&self, input: GitGraphInput) -> Result { + let root = parse_root(&input.root)?; + let commits = self.git.log_graph(&root, input.limit).await?; + Ok(GitGraphOutput { commits }) + } +} diff --git a/crates/application/src/health.rs b/crates/application/src/health.rs new file mode 100644 index 0000000..c927382 --- /dev/null +++ b/crates/application/src/health.rs @@ -0,0 +1,84 @@ +//! [`HealthUseCase`] — a trivial, in-memory use case used to validate the +//! end-to-end wiring (composition root → Tauri command → use case → ports → +//! frontend gateway). It carries its ports as `Arc`, exactly like +//! every real use case will (ARCHITECTURE §6). +//! +//! It depends only on the utility ports [`Clock`] and [`IdGenerator`], so it +//! exercises dependency injection without needing any I/O adapter. + +use std::sync::Arc; + +use domain::ports::{Clock, EventBus, IdGenerator}; +use domain::DomainEvent; +use domain::ProjectId; + +use crate::error::AppError; + +/// Input for [`HealthUseCase::execute`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct HealthInput { + /// Optional caller-supplied note echoed back in the report (used by tests + /// and the frontend smoke check). + pub note: Option, +} + +/// Output of [`HealthUseCase::execute`]: a small health report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HealthReport { + /// Application/crate version (`CARGO_PKG_VERSION`). + pub version: String, + /// Liveness flag — always `true` if the use case ran. + pub alive: bool, + /// Server "now" in epoch milliseconds (from the injected [`Clock`]). + pub time_millis: i64, + /// A fresh correlation id (from the injected [`IdGenerator`]). + pub correlation_id: String, + /// Echoed caller note, if any. + pub note: Option, +} + +/// Trivial health/ping use case validating the DI + IPC pipeline. +pub struct HealthUseCase { + clock: Arc, + ids: Arc, + events: Arc, +} + +impl HealthUseCase { + /// Builds the use case from its injected ports. + #[must_use] + pub fn new(clock: Arc, ids: Arc, events: Arc) -> Self { + Self { + clock, + ids, + events, + } + } + + /// Executes the health check. + /// + /// As a side effect it publishes a [`DomainEvent`] on the [`EventBus`] so + /// the event-relay path is exercised end to end (the relay forwards it to a + /// Tauri event). A `ProjectCreated`-shaped event is reused here purely as a + /// no-op smoke signal; no project is actually created. + /// + /// # Errors + /// Returns [`AppError`] — never, in this trivial implementation, but the + /// signature matches the real use-case contract. + pub fn execute(&self, input: HealthInput) -> Result { + let correlation = self.ids.new_uuid(); + + // Exercise the event-bus relay path with a harmless smoke event. + self.events.publish(DomainEvent::ProjectCreated { + project_id: ProjectId::from_uuid(correlation), + }); + + Ok(HealthReport { + version: env!("CARGO_PKG_VERSION").to_owned(), + alive: true, + time_millis: self.clock.now_millis(), + correlation_id: correlation.to_string(), + note: input.note, + }) + } +} diff --git a/crates/application/src/layout/management.rs b/crates/application/src/layout/management.rs new file mode 100644 index 0000000..dd8d7ea --- /dev/null +++ b/crates/application/src/layout/management.rs @@ -0,0 +1,336 @@ +//! Named-layout management use cases (#4): list, create, rename, delete and set +//! the active layout. Each loads the project's layouts store (see +//! [`super::store`]), applies the change and persists it. + +use std::sync::Arc; + +use domain::ports::{EventBus, FileSystem, IdGenerator, ProjectStore}; +use domain::{DomainEvent, LayoutId, ProjectId}; + +use crate::error::AppError; + +use super::store::{default_tree, persist_doc, resolve_doc, LayoutKind, NamedLayout}; + +/// Lightweight descriptor of a named layout (no tree), for the layouts tab bar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LayoutInfo { + /// Stable identifier. + pub id: LayoutId, + /// Display name. + pub name: String, + /// Kind of this layout. + pub kind: LayoutKind, +} + +// --------------------------------------------------------------------------- +// ListLayouts +// --------------------------------------------------------------------------- + +/// Input for [`ListLayouts::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListLayoutsInput { + /// Project whose layouts to list. + pub project_id: ProjectId, +} + +/// Output of [`ListLayouts::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListLayoutsOutput { + /// All named layouts (id + name), in order. + pub layouts: Vec, + /// The active layout. + pub active_id: LayoutId, +} + +/// Lists a project's named layouts and the active one. +pub struct ListLayouts { + store: Arc, + fs: Arc, +} + +impl ListLayouts { + /// Builds the use case from its ports. + #[must_use] + pub fn new(store: Arc, fs: Arc) -> Self { + Self { store, fs } + } + + /// Lists the layouts. + /// + /// # Errors + /// [`AppError::NotFound`] for an unknown project, [`AppError::FileSystem`] / + /// [`AppError::Store`] on I/O failure. + pub async fn execute(&self, input: ListLayoutsInput) -> Result { + let project = self.store.load_project(input.project_id).await?; + let doc = resolve_doc(self.fs.as_ref(), &project).await?; + Ok(ListLayoutsOutput { + layouts: doc + .layouts + .iter() + .map(|l| LayoutInfo { + id: l.id, + name: l.name.clone(), + kind: l.kind, + }) + .collect(), + active_id: doc.active_id, + }) + } +} + +// --------------------------------------------------------------------------- +// CreateLayout +// --------------------------------------------------------------------------- + +/// Input for [`CreateLayout::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateLayoutInput { + /// Owning project. + pub project_id: ProjectId, + /// Display name for the new layout. + pub name: String, + /// Kind of the new layout (defaults to Terminal). + pub kind: LayoutKind, +} + +/// Output of [`CreateLayout::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateLayoutOutput { + /// The id minted for the new (now active) layout. + pub layout_id: LayoutId, +} + +/// Creates a new empty named layout and makes it active. +pub struct CreateLayout { + store: Arc, + fs: Arc, + ids: Arc, + events: Arc, +} + +impl CreateLayout { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + store: Arc, + fs: Arc, + ids: Arc, + events: Arc, + ) -> Self { + Self { + store, + fs, + ids, + events, + } + } + + /// Creates the layout. + /// + /// # Errors + /// [`AppError::Invalid`] for an empty name, [`AppError::NotFound`] for an + /// unknown project, I/O errors otherwise. + pub async fn execute(&self, input: CreateLayoutInput) -> Result { + let name = input.name.trim(); + if name.is_empty() { + return Err(AppError::Invalid("layout name is empty".to_owned())); + } + let project = self.store.load_project(input.project_id).await?; + let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; + + let id = LayoutId::from_uuid(self.ids.new_uuid()); + doc.layouts.push(NamedLayout { + id, + name: name.to_owned(), + kind: input.kind, + tree: default_tree(), + }); + doc.active_id = id; // a freshly-created layout becomes active. + + persist_doc(self.fs.as_ref(), &project, &doc).await?; + self.events.publish(DomainEvent::LayoutChanged { + project_id: input.project_id, + }); + Ok(CreateLayoutOutput { layout_id: id }) + } +} + +// --------------------------------------------------------------------------- +// RenameLayout +// --------------------------------------------------------------------------- + +/// Input for [`RenameLayout::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenameLayoutInput { + /// Owning project. + pub project_id: ProjectId, + /// Layout to rename. + pub layout_id: LayoutId, + /// New display name. + pub name: String, +} + +/// Renames a named layout. +pub struct RenameLayout { + store: Arc, + fs: Arc, + events: Arc, +} + +impl RenameLayout { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + store: Arc, + fs: Arc, + events: Arc, + ) -> Self { + Self { store, fs, events } + } + + /// Renames the layout. + /// + /// # Errors + /// [`AppError::Invalid`] for an empty name, [`AppError::NotFound`] if the + /// project or layout is unknown. + pub async fn execute(&self, input: RenameLayoutInput) -> Result<(), AppError> { + let name = input.name.trim(); + if name.is_empty() { + return Err(AppError::Invalid("layout name is empty".to_owned())); + } + let project = self.store.load_project(input.project_id).await?; + let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; + let named = doc + .find_mut(input.layout_id) + .ok_or_else(|| AppError::NotFound(format!("layout {}", input.layout_id)))?; + named.name = name.to_owned(); + + persist_doc(self.fs.as_ref(), &project, &doc).await?; + self.events.publish(DomainEvent::LayoutChanged { + project_id: input.project_id, + }); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// DeleteLayout +// --------------------------------------------------------------------------- + +/// Input for [`DeleteLayout::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteLayoutInput { + /// Owning project. + pub project_id: ProjectId, + /// Layout to delete. + pub layout_id: LayoutId, +} + +/// Output of [`DeleteLayout::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteLayoutOutput { + /// The active layout after the deletion. + pub active_id: LayoutId, +} + +/// Deletes a named layout. The last remaining layout cannot be deleted; if the +/// active layout is removed, the first remaining one becomes active. +pub struct DeleteLayout { + store: Arc, + fs: Arc, + events: Arc, +} + +impl DeleteLayout { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + store: Arc, + fs: Arc, + events: Arc, + ) -> Self { + Self { store, fs, events } + } + + /// Deletes the layout. + /// + /// # Errors + /// [`AppError::Invalid`] if it is the last layout, [`AppError::NotFound`] if + /// the project or layout is unknown. + pub async fn execute(&self, input: DeleteLayoutInput) -> Result { + let project = self.store.load_project(input.project_id).await?; + let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; + + if doc.layouts.len() <= 1 { + return Err(AppError::Invalid( + "cannot delete the last layout".to_owned(), + )); + } + if doc.find(input.layout_id).is_none() { + return Err(AppError::NotFound(format!("layout {}", input.layout_id))); + } + doc.layouts.retain(|l| l.id != input.layout_id); + if doc.active_id == input.layout_id { + doc.active_id = doc.layouts[0].id; + } + + persist_doc(self.fs.as_ref(), &project, &doc).await?; + self.events.publish(DomainEvent::LayoutChanged { + project_id: input.project_id, + }); + Ok(DeleteLayoutOutput { + active_id: doc.active_id, + }) + } +} + +// --------------------------------------------------------------------------- +// SetActiveLayout +// --------------------------------------------------------------------------- + +/// Input for [`SetActiveLayout::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetActiveLayoutInput { + /// Owning project. + pub project_id: ProjectId, + /// Layout to make active. + pub layout_id: LayoutId, +} + +/// Switches the active layout of a project. +pub struct SetActiveLayout { + store: Arc, + fs: Arc, + events: Arc, +} + +impl SetActiveLayout { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + store: Arc, + fs: Arc, + events: Arc, + ) -> Self { + Self { store, fs, events } + } + + /// Sets the active layout. + /// + /// # Errors + /// [`AppError::NotFound`] if the project or layout is unknown. + pub async fn execute(&self, input: SetActiveLayoutInput) -> Result<(), AppError> { + let project = self.store.load_project(input.project_id).await?; + let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; + if doc.find(input.layout_id).is_none() { + return Err(AppError::NotFound(format!("layout {}", input.layout_id))); + } + doc.active_id = input.layout_id; + + persist_doc(self.fs.as_ref(), &project, &doc).await?; + self.events.publish(DomainEvent::LayoutChanged { + project_id: input.project_id, + }); + Ok(()) + } +} diff --git a/crates/application/src/layout/mod.rs b/crates/application/src/layout/mod.rs new file mode 100644 index 0000000..000c1a0 --- /dev/null +++ b/crates/application/src/layout/mod.rs @@ -0,0 +1,36 @@ +//! Layout use cases (ARCHITECTURE §6, §7, L4). +//! +//! The terminal layout of a tab is a pure [`domain::LayoutTree`] (a recursive +//! spreadsheet-like grid). Its mutating operations (`split`/`merge`/`resize`/ +//! `move`/`set_session`) are **pure functions** that live in the domain; this +//! module is the thin application orchestration that: +//! +//! - resolves the project root (via the [`domain::ports::ProjectStore`]), +//! - reads/writes the tree from/to `.ideai/layout.json` (via the +//! [`domain::ports::FileSystem`] port — so the layout *travels with the +//! project*, including on remote hosts, ARCHITECTURE §7.3, §9.1), +//! - publishes [`domain::DomainEvent::LayoutChanged`] on every mutation. +//! +//! ## Layout ↔ terminal session binding (L3 ↔ L4) +//! +//! A [`domain::LeafCell`] carries `Option`. When the UI opens a +//! terminal in a cell, it calls `OpenTerminal` (L3) — which mints the +//! `SessionId` — then records that id in the hosting leaf through the +//! `SetSession` layout operation here. The layout is the single source of truth +//! for *which cell hosts which session*; the live PTY handle stays in L3's +//! `TerminalSessions` registry, keyed by that same `SessionId`. + +mod management; +mod store; +mod usecases; + +pub use management::{ + CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, + DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout, + RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, +}; +pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE}; +pub use usecases::{ + LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout, + MutateLayoutInput, MutateLayoutOutput, +}; diff --git a/crates/application/src/layout/store.rs b/crates/application/src/layout/store.rs new file mode 100644 index 0000000..a46faa6 --- /dev/null +++ b/crates/application/src/layout/store.rs @@ -0,0 +1,182 @@ +//! Persistence of a project's **named terminal layouts** (#4). +//! +//! A project no longer has a single layout: `.ideai/layouts.json` holds a +//! collection of named [`LayoutTree`]s plus which one is active: +//! +//! ```json +//! { "version": 1, "activeId": "…", "layouts": [ { "id": "…", "name": "Default", "tree": { … } } ] } +//! ``` +//! +//! Migration: a project that still has the legacy single `.ideai/layout.json` +//! (pre-#4) is upgraded transparently — its tree becomes the first "Default" +//! layout. A missing/corrupt store self-heals to one default layout (same +//! self-healing contract as the original L4 single-file resolver). + +use serde::{Deserialize, Serialize}; + +use domain::ports::{FileSystem, RemotePath}; +use domain::{LayoutId, LayoutTree, LeafCell, NodeId, Project}; + +use crate::error::AppError; +use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR}; + +/// File name of the named-layouts store inside a project's `.ideai/`. +pub const LAYOUTS_FILE: &str = "layouts.json"; + +/// Legacy single-layout file (pre-#4), migrated on first read if present. +const LEGACY_LAYOUT_FILE: &str = "layout.json"; + +/// Current schema version of `layouts.json`. +const LAYOUTS_VERSION: u32 = 1; + +/// Name given to the layout created by default / migrated from the legacy file. +const DEFAULT_LAYOUT_NAME: &str = "Default"; + +/// Discriminates the kind of content a named layout holds. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LayoutKind { + /// A terminal-grid layout (the original kind). + #[default] + Terminal, + /// A Git-graph visualisation layout. + GitGraph, +} + +/// One named layout: a stable id, a display name and its terminal grid tree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NamedLayout { + /// Stable identifier. + pub id: LayoutId, + /// Display name (shown in the layouts tab bar). + pub name: String, + /// The kind of this layout (terminal grid or git-graph). + #[serde(default)] + pub kind: LayoutKind, + /// The terminal grid for this layout (present but ignored for GitGraph). + pub tree: LayoutTree, +} + +/// On-disk shape of `.ideai/layouts.json`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LayoutsDoc { + /// Schema version. + pub version: u32, + /// The currently active layout (always one of `layouts`). + pub active_id: LayoutId, + /// All named layouts (at least one). + pub layouts: Vec, +} + +impl LayoutsDoc { + /// The active layout id, or the explicit one when provided. + #[must_use] + pub fn resolve_id(&self, id: Option) -> LayoutId { + id.unwrap_or(self.active_id) + } + + /// Finds a layout by id. + #[must_use] + pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> { + self.layouts.iter().find(|l| l.id == id) + } + + /// Mutable access to a layout by id. + pub fn find_mut(&mut self, id: LayoutId) -> Option<&mut NamedLayout> { + self.layouts.iter_mut().find(|l| l.id == id) + } + + /// Structural validity: non-empty, `active_id` present, every tree valid. + fn is_valid(&self) -> bool { + !self.layouts.is_empty() + && self.find(self.active_id).is_some() + && self.layouts.iter().all(|l| l.tree.validate().is_ok()) + } +} + +/// The default single-cell layout tree (one empty leaf). +#[must_use] +pub fn default_tree() -> LayoutTree { + LayoutTree::single(LeafCell { + id: NodeId::new_random(), + session: None, + agent: None, + }) +} + +/// Builds a fresh doc holding one layout (`tree`) made active. +fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc { + LayoutsDoc { + version: LAYOUTS_VERSION, + active_id: id, + layouts: vec![NamedLayout { + id, + name: name.to_owned(), + kind: LayoutKind::Terminal, + tree, + }], + } +} + +fn layouts_path(project: &Project) -> RemotePath { + RemotePath::new(join_root( + &project.root, + &format!("{IDEAI_DIR}/{LAYOUTS_FILE}"), + )) +} + +fn legacy_path(project: &Project) -> RemotePath { + RemotePath::new(join_root( + &project.root, + &format!("{IDEAI_DIR}/{LEGACY_LAYOUT_FILE}"), + )) +} + +/// Reads the legacy single layout tree if present and valid (for migration). +async fn read_legacy_tree(fs: &dyn FileSystem, project: &Project) -> Option { + let bytes = fs.read(&legacy_path(project)).await.ok()?; + let tree = from_json_bytes::(&bytes).ok()?; + tree.validate().is_ok().then_some(tree) +} + +/// Writes the doc to `.ideai/layouts.json` (creating `.ideai/` if needed). +pub async fn persist_doc( + fs: &dyn FileSystem, + project: &Project, + doc: &LayoutsDoc, +) -> Result<(), AppError> { + let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR)); + fs.create_dir_all(&ideai_dir).await?; + fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?; + Ok(()) +} + +/// Resolves the project's layouts doc, with idempotent initialisation / +/// migration / self-healing (mirrors the L4 single-file resolver contract). +/// +/// - **Present & valid**: returned as-is (no write). +/// - **Absent**: migrated from the legacy `layout.json` if present, else seeded +/// with one empty "Default" layout — and **persisted** (write-through, so ids +/// are stable from the first load). +/// - **Present but corrupt/invalid**: overwritten with a fresh default. +pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result { + if let Ok(bytes) = fs.read(&layouts_path(project)).await { + if let Ok(doc) = from_json_bytes::(&bytes) { + if doc.is_valid() { + return Ok(doc); + } + } + // Present-but-invalid: fall through and re-seed. + } + + // First run for this project (or self-heal): migrate the legacy tree if any. + let tree = match read_legacy_tree(fs, project).await { + Some(tree) => tree, + None => default_tree(), + }; + let doc = doc_with(LayoutId::new_random(), DEFAULT_LAYOUT_NAME, tree); + persist_doc(fs, project, &doc).await?; + Ok(doc) +} diff --git a/crates/application/src/layout/usecases.rs b/crates/application/src/layout/usecases.rs new file mode 100644 index 0000000..291c74f --- /dev/null +++ b/crates/application/src/layout/usecases.rs @@ -0,0 +1,232 @@ +//! [`LoadLayout`] and [`MutateLayout`] (ARCHITECTURE §6, §7; L4 + #4). +//! +//! A project owns **several named layouts** (see [`super::store`]). These two use +//! cases operate on **one** layout — the one identified by `layout_id`, or the +//! active layout when `layout_id` is `None`. They stay thin orchestrators: the +//! mutating operations are the domain's pure `LayoutTree` functions. + +use std::sync::Arc; + +use domain::ports::{EventBus, FileSystem, ProjectStore}; +use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId}; + +use crate::error::AppError; + +use super::store::{persist_doc, resolve_doc}; + +/// Maps a [`LayoutError`] to the application error type. +fn map_layout_err(e: LayoutError) -> AppError { + match e { + LayoutError::NodeNotFound(id) => AppError::NotFound(format!("layout node {id}")), + other => AppError::Invalid(other.to_string()), + } +} + +/// A layout mutation expressed in terms of the pure domain operations. +/// +/// Each variant maps 1:1 to a pure `LayoutTree` method +/// (`split`/`merge`/`resize`/`move`/`set_session`). Decoupling the *operation* +/// from the *tree* keeps the use case a thin orchestrator and lets the +/// presentation layer (and undo/redo) speak in intentions. +#[derive(Debug, Clone, PartialEq)] +pub enum LayoutOperation { + /// Split a leaf into a two-child split (original + a new empty leaf). + Split { + /// The leaf to split. + target: NodeId, + /// Row (columns) or Column (rows). + direction: Direction, + /// Id for the new sibling leaf. + new_leaf: NodeId, + /// Id for the wrapping split container. + container: NodeId, + }, + /// Collapse a split container back to one of its children. + Merge { + /// The split container to collapse. + container: NodeId, + /// Index of the child to keep. + keep_index: usize, + }, + /// Reassign the relative weights of a split's children. + Resize { + /// The split container to resize. + container: NodeId, + /// New weights (one per child, all `> 0`). + weights: Vec, + }, + /// Move the session hosted by one leaf to another (empty) leaf. + Move { + /// Source leaf (left empty). + from: NodeId, + /// Target leaf (must be empty). + to: NodeId, + }, + /// Attach or detach a session to/from a leaf (cell ↔ terminal binding). + SetSession { + /// The hosting leaf. + target: NodeId, + /// Session to host, or `None` to clear. + session: Option, + }, + /// Attach or detach an agent to/from a leaf (per-cell agent, feature #3). + SetCellAgent { + /// The hosting leaf. + target: NodeId, + /// Agent to associate, or `None` to clear. + agent: Option, + }, +} + +impl LayoutOperation { + /// Applies this operation to `tree`, returning the new validated tree. + fn apply(&self, tree: &LayoutTree) -> Result { + let result = match self { + Self::Split { + target, + direction, + new_leaf, + container, + } => tree.split( + *target, + *direction, + LeafCell { + id: *new_leaf, + session: None, + agent: None, + }, + *container, + ), + Self::Merge { + container, + keep_index, + } => tree.merge(*container, *keep_index), + Self::Resize { container, weights } => tree.resize(*container, weights), + Self::Move { from, to } => tree.move_session(*from, *to), + Self::SetSession { target, session } => tree.set_session(*target, *session), + Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent), + }; + result.map_err(map_layout_err) + } +} + +/// Input for [`LoadLayout::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadLayoutInput { + /// Project whose layout to load. + pub project_id: ProjectId, + /// Which named layout to load; `None` loads the active one. + pub layout_id: Option, +} + +/// Output of [`LoadLayout::execute`]. +#[derive(Debug, Clone, PartialEq)] +pub struct LoadLayoutOutput { + /// The id of the layout that was loaded (resolved from active when omitted). + pub layout_id: LayoutId, + /// The loaded layout tree. + pub layout: LayoutTree, +} + +/// Loads one named layout (the active one by default), self-healing / migrating +/// the layouts store as needed (see [`super::store::resolve_doc`]). +pub struct LoadLayout { + store: Arc, + fs: Arc, +} + +impl LoadLayout { + /// Builds the use case from its injected ports. + #[must_use] + pub fn new(store: Arc, fs: Arc) -> Self { + Self { store, fs } + } + + /// Executes the load. + /// + /// # Errors + /// - [`AppError::NotFound`] if the project or the requested layout is unknown, + /// - [`AppError::FileSystem`] if seeding the default layouts fails to persist, + /// - [`AppError::Store`] on registry I/O failure. + pub async fn execute(&self, input: LoadLayoutInput) -> Result { + let project = self.store.load_project(input.project_id).await?; + let doc = resolve_doc(self.fs.as_ref(), &project).await?; + let id = doc.resolve_id(input.layout_id); + let named = doc + .find(id) + .ok_or_else(|| AppError::NotFound(format!("layout {id}")))?; + Ok(LoadLayoutOutput { + layout_id: id, + layout: named.tree.clone(), + }) + } +} + +/// Input for [`MutateLayout::execute`]. +#[derive(Debug, Clone, PartialEq)] +pub struct MutateLayoutInput { + /// Project whose layout to mutate. + pub project_id: ProjectId, + /// Which named layout to mutate; `None` mutates the active one. + pub layout_id: Option, + /// The operation to apply. + pub operation: LayoutOperation, +} + +/// Output of [`MutateLayout::execute`]. +#[derive(Debug, Clone, PartialEq)] +pub struct MutateLayoutOutput { + /// The id of the mutated layout. + pub layout_id: LayoutId, + /// The new, persisted layout tree. + pub layout: LayoutTree, +} + +/// Applies a pure layout operation to one named layout, persists the whole +/// layouts store and publishes [`DomainEvent::LayoutChanged`]. +pub struct MutateLayout { + store: Arc, + fs: Arc, + events: Arc, +} + +impl MutateLayout { + /// Builds the use case from its injected ports. + #[must_use] + pub fn new( + store: Arc, + fs: Arc, + events: Arc, + ) -> Self { + Self { store, fs, events } + } + + /// Executes the mutation. + /// + /// # Errors + /// - [`AppError::NotFound`] if the project, layout or a referenced node is unknown, + /// - [`AppError::Invalid`] if the operation violates a layout invariant, + /// - [`AppError::FileSystem`] on persistence failure, + /// - [`AppError::Store`] on registry I/O failure. + pub async fn execute(&self, input: MutateLayoutInput) -> Result { + let project = self.store.load_project(input.project_id).await?; + let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; + let id = doc.resolve_id(input.layout_id); + + let named = doc + .find_mut(id) + .ok_or_else(|| AppError::NotFound(format!("layout {id}")))?; + let next = input.operation.apply(&named.tree)?; + named.tree = next.clone(); + + persist_doc(self.fs.as_ref(), &project, &doc).await?; + self.events.publish(DomainEvent::LayoutChanged { + project_id: input.project_id, + }); + + Ok(MutateLayoutOutput { + layout_id: id, + layout: next, + }) + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..ce20dba --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,69 @@ +//! # IdeA — Application layer +//! +//! Orchestrates **use cases** by talking **only to the domain ports** (traits), +//! never to concrete adapters (ARCHITECTURE §1.1, §6). Every use case is a +//! struct carrying its ports as `Arc` and exposing +//! `execute(input) -> Result`. +//! +//! This crate depends on `domain` only. The composition root (`app-tauri`) is +//! the single place that constructs concrete adapters and injects them here. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod agent; +pub mod error; +pub mod git; +pub mod health; +pub mod layout; +pub mod project; +pub mod remote; +pub mod template; +pub mod terminal; +pub mod window; + +pub use agent::{ + reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, + ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, + DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, + DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, LaunchAgent, + LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, + ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, + ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile, + SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput, +}; +pub use error::AppError; +pub use git::{ + GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit, + GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput, + GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, + GitStatusOutput, GitUnstage, +}; +pub use health::{HealthInput, HealthReport, HealthUseCase}; +pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput}; +pub use layout::{ + CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, + DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts, + ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput, + MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout, + RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, LAYOUTS_FILE, +}; +pub use project::{ + CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, + CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, + OpenProjectInput, OpenProjectOutput, ProjectMeta, +}; +pub use template::{ + AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, + CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput, + DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput, + DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate, + SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, + UpdateTemplateOutput, +}; +pub use terminal::{ + CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, + OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal, + WriteToTerminalInput, +}; +pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; diff --git a/crates/application/src/project/close.rs b/crates/application/src/project/close.rs new file mode 100644 index 0000000..53688aa --- /dev/null +++ b/crates/application/src/project/close.rs @@ -0,0 +1,97 @@ +//! [`CloseProject`] / [`CloseTab`] (ARCHITECTURE §6). +//! +//! In L2 there are no PTYs to release yet, so closing is essentially *persisting +//! the current state*. The use case takes the workspace image to persist (the +//! UI owns the windows/tabs arrangement) and saves it through the +//! [`ProjectStore`]. It is written so the L3 "release PTYs" step slots in here +//! without changing the call sites. + +use std::sync::Arc; + +use domain::ports::ProjectStore; +use domain::{ProjectId, Workspace}; + +use crate::error::AppError; + +/// Input for [`CloseProject::execute`]. +#[derive(Debug, Clone, PartialEq)] +pub struct CloseProjectInput { + /// The project being closed. + pub project_id: ProjectId, + /// The workspace state to persist (windows/tabs/layouts). `None` skips + /// persistence (e.g. the UI has nothing to save). + pub workspace: Option, +} + +/// Output of [`CloseProject::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloseProjectOutput { + /// The project that was closed. + pub project_id: ProjectId, +} + +/// Closes a project: persists the workspace state and releases resources. +pub struct CloseProject { + store: Arc, +} + +impl CloseProject { + /// Builds the use case from its injected port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the close. + /// + /// # Errors + /// [`AppError::Store`] if persisting the workspace fails. + pub async fn execute(&self, input: CloseProjectInput) -> Result { + if let Some(workspace) = &input.workspace { + self.store.save_workspace(workspace).await?; + } + // L3 will release the project's PTYs here. + Ok(CloseProjectOutput { + project_id: input.project_id, + }) + } +} + +/// Input for [`CloseTab::execute`] — closing one tab (a single open project). +#[derive(Debug, Clone, PartialEq)] +pub struct CloseTabInput { + /// The project shown in the tab being closed. + pub project_id: ProjectId, + /// The workspace state to persist after the tab is removed. + pub workspace: Option, +} + +/// Closes a single tab. In L2 this delegates to the same persistence path as +/// [`CloseProject`]; it exists as a distinct intention so the multi-window lot +/// (L10) can give it tab-specific behaviour without touching callers. +pub struct CloseTab { + inner: CloseProject, +} + +impl CloseTab { + /// Builds the use case from its injected port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { + inner: CloseProject::new(store), + } + } + + /// Executes the tab close. + /// + /// # Errors + /// [`AppError::Store`] if persisting the workspace fails. + pub async fn execute(&self, input: CloseTabInput) -> Result { + self.inner + .execute(CloseProjectInput { + project_id: input.project_id, + workspace: input.workspace, + }) + .await + } +} diff --git a/crates/application/src/project/create.rs b/crates/application/src/project/create.rs new file mode 100644 index 0000000..34b29ab --- /dev/null +++ b/crates/application/src/project/create.rs @@ -0,0 +1,120 @@ +//! [`CreateProject`] — create a project from a project root (ARCHITECTURE §6). +//! +//! Responsibilities (and *only* these): +//! 1. validate the root (absolute path — enforced by [`ProjectPath`]) and name, +//! 2. enforce the cross-aggregate uniqueness invariant `(remote, root)` — this +//! lives here, not in the domain, because it requires knowledge of *all* +//! projects (a repository concern, ARCHITECTURE §3.2), +//! 3. create the `.ideai/` directory and write `project.json`, +//! 4. register the project via the [`ProjectStore`], +//! 5. publish [`DomainEvent::ProjectCreated`]. + +use std::sync::Arc; + +use domain::ports::{Clock, EventBus, FileSystem, IdGenerator, ProjectStore, RemotePath}; +use domain::{DomainEvent, Project, ProjectId, ProjectPath, RemoteRef}; + +use crate::error::AppError; + +use super::meta::{join_root, to_json_bytes, ProjectMeta, IDEAI_DIR, PROJECT_FILE}; + +/// Input for [`CreateProject::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateProjectInput { + /// Display name of the project. + pub name: String, + /// Absolute project root (validated into a [`ProjectPath`]). + pub root: String, + /// Where the project lives. Defaults to [`RemoteRef::Local`] when `None`. + pub remote: Option, + /// Default agent profile id, if already chosen. + pub default_profile_id: Option, +} + +/// Output of [`CreateProject::execute`]: the freshly-created project. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateProjectOutput { + /// The created project. + pub project: Project, +} + +/// Creates a project: inits `.ideai/`, writes `project.json`, registers it. +pub struct CreateProject { + store: Arc, + fs: Arc, + ids: Arc, + clock: Arc, + events: Arc, +} + +impl CreateProject { + /// Builds the use case from its injected ports. + #[must_use] + pub fn new( + store: Arc, + fs: Arc, + ids: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + store, + fs, + ids, + clock, + events, + } + } + + /// Executes project creation. + /// + /// # Errors + /// - [`AppError::Invalid`] if the name is empty or the root is not absolute, + /// - [`AppError::Invalid`] if a project already exists for the same + /// `(remote, root)` (uniqueness invariant), + /// - [`AppError::FileSystem`] / [`AppError::Store`] on I/O failures. + pub async fn execute( + &self, + input: CreateProjectInput, + ) -> Result { + let root = ProjectPath::new(input.root).map_err(|e| AppError::Invalid(e.to_string()))?; + let remote = input.remote.unwrap_or(RemoteRef::Local); + + // (1+2) Uniqueness invariant on (remote, root) — repository-level. + let existing = self.store.list_projects().await?; + if existing + .iter() + .any(|p| p.remote == remote && p.root == root) + { + return Err(AppError::Invalid(format!( + "a project already exists at {} for this remote", + root.as_str() + ))); + } + + // Build the validated aggregate (enforces non-empty name). + let id = ProjectId::from_uuid(self.ids.new_uuid()); + let created_at = self.clock.now_millis(); + let project = Project::new(id, input.name, root.clone(), remote, created_at) + .map_err(|e| AppError::Invalid(e.to_string()))?; + + // (3) Materialise `.ideai/` and write `project.json`. + let ideai_dir = join_root(&root, IDEAI_DIR); + self.fs.create_dir_all(&RemotePath::new(ideai_dir)).await?; + + let meta = ProjectMeta::from_project(&project, input.default_profile_id); + let meta_path = join_root(&root, &format!("{IDEAI_DIR}/{PROJECT_FILE}")); + self.fs + .write(&RemotePath::new(meta_path), &to_json_bytes(&meta)?) + .await?; + + // (4) Register in the known-projects registry. + self.store.save_project(&project).await?; + + // (5) Announce. + self.events + .publish(DomainEvent::ProjectCreated { project_id: id }); + + Ok(CreateProjectOutput { project }) + } +} diff --git a/crates/application/src/project/meta.rs b/crates/application/src/project/meta.rs new file mode 100644 index 0000000..33627ac --- /dev/null +++ b/crates/application/src/project/meta.rs @@ -0,0 +1,99 @@ +//! [`ProjectMeta`] — the on-disk shape of `.ideai/project.json` (ARCHITECTURE §9.1). +//! +//! This is the *project-local* metadata that travels with the code (it lives +//! inside the project root, is versionable, and is independent of the machine). +//! The known-projects **registry** is a separate, machine-local concern owned by +//! the [`domain::ports::ProjectStore`] adapter (ARCHITECTURE §9.2). + +use serde::{Deserialize, Serialize}; + +use domain::{Project, ProjectId, ProjectPath, RemoteRef}; + +use crate::error::AppError; + +/// The `.ideai/` directory name inside a project root. +pub(crate) const IDEAI_DIR: &str = ".ideai"; + +/// The project-meta file name inside `.ideai/`. +pub(crate) const PROJECT_FILE: &str = "project.json"; + +/// The agent manifest file name inside `.ideai/`. +pub(crate) const AGENTS_FILE: &str = "agents.json"; + +/// Current schema version of `project.json`. +pub(crate) const PROJECT_META_VERSION: u32 = 1; + +/// Serialised contents of `.ideai/project.json`. +/// +/// Carries the project's identity and the metadata needed to reopen it: its +/// stable id, display name, the default agent profile, the remote reference and +/// the creation timestamp. The `root` itself is *not* stored here — the file +/// already lives at `/.ideai/project.json`, so the root is implied by the +/// file's location (and authoritatively held by the registry). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMeta { + /// Schema version of this file. + pub version: u32, + /// Stable project id (matches the registry entry). + pub id: ProjectId, + /// Display name. + pub name: String, + /// Default agent profile id, if one has been chosen (`null` until first-run + /// / profile selection in later lots). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_profile_id: Option, + /// Where the project physically lives. + pub remote: RemoteRef, + /// Creation timestamp, epoch milliseconds. + pub created_at: i64, +} + +impl ProjectMeta { + /// Builds the meta image from a [`Project`]. + #[must_use] + pub fn from_project(project: &Project, default_profile_id: Option) -> Self { + Self { + version: PROJECT_META_VERSION, + id: project.id, + name: project.name.clone(), + default_profile_id, + remote: project.remote.clone(), + created_at: project.created_at, + } + } + + /// Reconstructs a validated [`Project`] from this meta and its (registry-known) + /// root. + /// + /// # Errors + /// Returns [`AppError::Invalid`] if the stored fields violate a domain + /// invariant (e.g. empty name). + pub fn into_project(self, root: ProjectPath) -> Result { + Project::new(self.id, self.name, root, self.remote, self.created_at) + .map_err(|e| AppError::Invalid(e.to_string())) + } +} + +/// Serialises a value to pretty JSON bytes, mapping failures to [`AppError`]. +pub(crate) fn to_json_bytes(value: &T) -> Result, AppError> { + serde_json::to_vec_pretty(value) + .map(|mut v| { + v.push(b'\n'); + v + }) + .map_err(|e| AppError::Store(format!("serialize failed: {e}"))) +} + +/// Deserialises JSON bytes, mapping failures to [`AppError`]. +pub(crate) fn from_json_bytes Deserialize<'de>>(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| AppError::Store(format!("deserialize failed: {e}"))) +} + +/// Joins a project root with a relative path segment using a POSIX-style +/// separator. Paths inside `.ideai/` are always written with `/`, which is valid +/// on every platform we target (Windows `tokio::fs` accepts `/`). +pub(crate) fn join_root(root: &ProjectPath, rel: &str) -> String { + let base = root.as_str().trim_end_matches(['/', '\\']); + format!("{base}/{rel}") +} diff --git a/crates/application/src/project/mod.rs b/crates/application/src/project/mod.rs new file mode 100644 index 0000000..f6c38ca --- /dev/null +++ b/crates/application/src/project/mod.rs @@ -0,0 +1,24 @@ +//! Project life-cycle use cases (ARCHITECTURE §6, L2). +//! +//! Each use case is a struct carrying its ports as `Arc` and exposing +//! a single `execute(input) -> Result` method +//! (**Single Responsibility**). They talk **only** to the domain ports, never to +//! concrete adapters; the composition root injects the implementations. +//! +//! - [`CreateProject`] — validate the root, create `.ideai/project.json`, +//! register the project, publish [`domain::DomainEvent::ProjectCreated`]. +//! - [`OpenProject`] — load a project and its `.ideai/project.json` meta (plus, +//! tolerantly, the agent manifest if present). +//! - [`CloseProject`] / [`CloseTab`] — persist state and release resources +//! (no PTYs yet in L2). +//! - [`ListProjects`] — list known projects from the registry. + +mod close; +mod create; +pub(crate) mod meta; +mod open; + +pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput}; +pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput}; +pub use meta::ProjectMeta; +pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput}; diff --git a/crates/application/src/project/open.rs b/crates/application/src/project/open.rs new file mode 100644 index 0000000..128b290 --- /dev/null +++ b/crates/application/src/project/open.rs @@ -0,0 +1,115 @@ +//! [`OpenProject`] and [`ListProjects`] (ARCHITECTURE §6). + +use std::sync::Arc; + +use domain::ports::{FileSystem, ProjectStore, RemotePath}; +use domain::{AgentManifest, Project, ProjectId}; + +use crate::error::AppError; + +use super::meta::{ + from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE, +}; + +/// Input for [`OpenProject::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenProjectInput { + /// Id of the project to open (as known by the registry). + pub project_id: ProjectId, +} + +/// Output of [`OpenProject::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenProjectOutput { + /// The opened project (registry source of truth for id/name/root/remote). + pub project: Project, + /// The project-local meta read from `.ideai/project.json`, if present and + /// readable. Read tolerantly: a missing/corrupt file does not fail the open. + pub meta: Option, + /// The agent manifest read from `.ideai/agents.json`, if present. Tolerant: + /// absent in a brand-new project. + pub manifest: Option, +} + +/// Loads a project, its `.ideai/project.json` meta and (tolerantly) its manifest. +pub struct OpenProject { + store: Arc, + fs: Arc, +} + +impl OpenProject { + /// Builds the use case from its injected ports. + #[must_use] + pub fn new(store: Arc, fs: Arc) -> Self { + Self { store, fs } + } + + /// Executes the open. + /// + /// # Errors + /// - [`AppError::NotFound`] if the registry has no such project, + /// - [`AppError::Store`] on registry I/O failure. + /// + /// Reading the `.ideai/` files is **tolerant**: their absence or a parse + /// failure yields `None` rather than an error, so a project whose `.ideai/` + /// was deleted can still be opened. + pub async fn execute(&self, input: OpenProjectInput) -> Result { + let project = self.store.load_project(input.project_id).await?; + + let meta = self + .read_optional_json::(&project, PROJECT_FILE) + .await; + let manifest = self + .read_optional_json::(&project, AGENTS_FILE) + .await; + + Ok(OpenProjectOutput { + project, + meta, + manifest, + }) + } + + /// Reads and parses a JSON file inside the project's `.ideai/`, tolerating + /// any failure (missing file, I/O error, parse error) by returning `None`. + async fn read_optional_json serde::Deserialize<'de>>( + &self, + project: &Project, + file: &str, + ) -> Option { + let path = RemotePath::new(join_root(&project.root, &format!("{IDEAI_DIR}/{file}"))); + match self.fs.read(&path).await { + Ok(bytes) => from_json_bytes::(&bytes).ok(), + Err(_) => None, + } + } +} + +/// Output of [`ListProjects::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListProjectsOutput { + /// All projects known to the registry. + pub projects: Vec, +} + +/// Lists the projects known to the registry. +pub struct ListProjects { + store: Arc, +} + +impl ListProjects { + /// Builds the use case from its injected port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the listing. + /// + /// # Errors + /// [`AppError::Store`] on registry I/O failure. + pub async fn execute(&self) -> Result { + let projects = self.store.list_projects().await?; + Ok(ListProjectsOutput { projects }) + } +} diff --git a/crates/application/src/remote/mod.rs b/crates/application/src/remote/mod.rs new file mode 100644 index 0000000..d51d708 --- /dev/null +++ b/crates/application/src/remote/mod.rs @@ -0,0 +1,6 @@ +//! Remote use cases (ARCHITECTURE §6, L9). Connecting a project's +//! [`domain::ports::RemoteHost`] (local / SSH / WSL) and validating its root. + +mod usecases; + +pub use usecases::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput}; diff --git a/crates/application/src/remote/usecases.rs b/crates/application/src/remote/usecases.rs new file mode 100644 index 0000000..4046744 --- /dev/null +++ b/crates/application/src/remote/usecases.rs @@ -0,0 +1,75 @@ +//! Remote-connection use case (ARCHITECTURE §6, L9). +//! +//! [`ConnectRemote`] establishes a project's [`RemoteHost`] and validates that +//! its root is reachable on the host's filesystem. It speaks only to the +//! [`RemoteHost`] port, so it behaves identically for a local, SSH or WSL host +//! (Liskov) and is fully testable with a mock host. + +use std::sync::Arc; + +use domain::ports::{EventBus, RemoteHost, RemotePath}; +use domain::{DomainEvent, ProjectId, RemoteKind}; + +use crate::error::AppError; + +/// Input for [`ConnectRemote::execute`]. +#[derive(Clone)] +pub struct ConnectRemoteInput { + /// The host strategy to connect through (built from the project's `RemoteRef`). + pub host: Arc, + /// The project being connected (for the emitted event). + pub project_id: ProjectId, + /// Absolute root path to validate on the host. + pub root: String, +} + +/// Output of [`ConnectRemote::execute`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConnectRemoteOutput { + /// The kind of host that was connected. + pub kind: RemoteKind, +} + +/// Connects a project's remote host and checks its root is reachable. +pub struct ConnectRemote { + events: Arc, +} + +impl ConnectRemote { + /// Builds the use case from the [`EventBus`]. + #[must_use] + pub fn new(events: Arc) -> Self { + Self { events } + } + + /// Connects the host and validates the root exists, announcing + /// [`DomainEvent::RemoteConnected`] on success. + /// + /// # Errors + /// - [`AppError::Remote`] if the connection fails, + /// - [`AppError::NotFound`] if the root does not exist on the host, + /// - [`AppError::FileSystem`] on an I/O failure while probing the root. + pub async fn execute( + &self, + input: ConnectRemoteInput, + ) -> Result { + input.host.connect().await?; + + let fs = input.host.file_system(); + let exists = fs.exists(&RemotePath::new(input.root.clone())).await?; + if !exists { + return Err(AppError::NotFound(format!( + "remote root {} is not reachable", + input.root + ))); + } + + self.events.publish(DomainEvent::RemoteConnected { + project_id: input.project_id, + }); + + Ok(ConnectRemoteOutput { + kind: input.host.kind(), + }) + } +} diff --git a/crates/application/src/template/mod.rs b/crates/application/src/template/mod.rs new file mode 100644 index 0000000..06b0256 --- /dev/null +++ b/crates/application/src/template/mod.rs @@ -0,0 +1,17 @@ +//! Template & synchronisation use cases (ARCHITECTURE §6, §8; L7). +//! +//! Templates are reusable agent contexts stored in the global IDE store, with a +//! monotonic version. This module owns their CRUD, the template→agent +//! instantiation, and the drift-detection / synchronisation flow that keeps +//! `synchronized` agents in step with their template. + +mod usecases; + +pub use usecases::{ + AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, + CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput, + DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput, + DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate, + SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, + UpdateTemplateOutput, +}; diff --git a/crates/application/src/template/usecases.rs b/crates/application/src/template/usecases.rs new file mode 100644 index 0000000..d3378a6 --- /dev/null +++ b/crates/application/src/template/usecases.rs @@ -0,0 +1,495 @@ +//! Template & synchronisation use cases (ARCHITECTURE §6, §8; L7). +//! +//! Two concerns live here: +//! - **Templates** (global IDE store): CRUD + monotonic versioning +//! ([`CreateTemplate`], [`UpdateTemplate`], [`ListTemplates`], [`DeleteTemplate`]). +//! - **Template → agent link**: instantiating an agent from a template +//! ([`CreateAgentFromTemplate`]), detecting when a synchronized agent is behind +//! its template ([`DetectAgentDrift`]), and applying the update +//! ([`SyncAgentWithTemplate`]). +//! +//! Every use case talks only to ports ([`TemplateStore`], [`AgentContextStore`], +//! [`IdGenerator`], [`EventBus`]). + +use std::sync::Arc; + +use domain::ports::{AgentContextStore, EventBus, IdGenerator, StoreError, TemplateStore}; +use domain::{ + Agent, AgentId, AgentManifest, AgentOrigin, AgentTemplate, DomainEvent, ManifestEntry, + MarkdownDoc, ProfileId, Project, TemplateId, TemplateVersion, +}; + +use crate::agent::unique_md_path; +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// CreateTemplate +// --------------------------------------------------------------------------- + +/// Input for [`CreateTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateTemplateInput { + /// Display name. + pub name: String, + /// Initial Markdown content. + pub content: String, + /// Default runtime profile for agents created from this template. + pub default_profile_id: ProfileId, +} + +/// Output of [`CreateTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateTemplateOutput { + /// The created template (at [`TemplateVersion::INITIAL`]). + pub template: AgentTemplate, +} + +/// Creates a template in the global store at the initial version. +pub struct CreateTemplate { + templates: Arc, + ids: Arc, +} + +impl CreateTemplate { + /// Builds the use case from its ports. + #[must_use] + pub fn new(templates: Arc, ids: Arc) -> Self { + Self { templates, ids } + } + + /// Executes creation. + /// + /// # Errors + /// - [`AppError::Invalid`] if the name is empty, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: CreateTemplateInput, + ) -> Result { + let id = TemplateId::from_uuid(self.ids.new_uuid()); + let template = AgentTemplate::new( + id, + input.name, + MarkdownDoc::new(input.content), + input.default_profile_id, + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.templates.save(&template).await?; + Ok(CreateTemplateOutput { template }) + } +} + +// --------------------------------------------------------------------------- +// UpdateTemplate +// --------------------------------------------------------------------------- + +/// Input for [`UpdateTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateTemplateInput { + /// Template to update. + pub template_id: TemplateId, + /// New Markdown content. + pub content: String, +} + +/// Output of [`UpdateTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateTemplateOutput { + /// The updated template (version bumped by one). + pub template: AgentTemplate, +} + +/// Updates a template's content and **bumps its version** (monotonic, §8.1), +/// announcing [`DomainEvent::TemplateUpdated`] so drift can be re-evaluated. +pub struct UpdateTemplate { + templates: Arc, + events: Arc, +} + +impl UpdateTemplate { + /// Builds the use case from its ports. + #[must_use] + pub fn new(templates: Arc, events: Arc) -> Self { + Self { templates, events } + } + + /// Executes the update. + /// + /// # Errors + /// - [`AppError::NotFound`] if the template is unknown, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: UpdateTemplateInput, + ) -> Result { + let current = self.templates.get(input.template_id).await?; + let updated = current.with_updated_content(MarkdownDoc::new(input.content)); + self.templates.save(&updated).await?; + self.events.publish(DomainEvent::TemplateUpdated { + template_id: updated.id, + version: updated.version, + }); + Ok(UpdateTemplateOutput { template: updated }) + } +} + +// --------------------------------------------------------------------------- +// ListTemplates / DeleteTemplate +// --------------------------------------------------------------------------- + +/// Output of [`ListTemplates::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListTemplatesOutput { + /// All templates in the global store. + pub templates: Vec, +} + +/// Lists the templates in the global store. +pub struct ListTemplates { + templates: Arc, +} + +impl ListTemplates { + /// Builds the use case. + #[must_use] + pub fn new(templates: Arc) -> Self { + Self { templates } + } + + /// Lists templates. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure. + pub async fn execute(&self) -> Result { + Ok(ListTemplatesOutput { + templates: self.templates.list().await?, + }) + } +} + +/// Input for [`DeleteTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteTemplateInput { + /// Template to delete. + pub template_id: TemplateId, +} + +/// Deletes a template from the global store. +/// +/// Agents previously created from it keep their `.md` (their `origin` still +/// references the now-absent template; drift detection simply finds nothing to +/// compare against). +pub struct DeleteTemplate { + templates: Arc, +} + +impl DeleteTemplate { + /// Builds the use case. + #[must_use] + pub fn new(templates: Arc) -> Self { + Self { templates } + } + + /// Deletes the template. + /// + /// # Errors + /// - [`AppError::NotFound`] if the template is unknown, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: DeleteTemplateInput) -> Result<(), AppError> { + self.templates.delete(input.template_id).await?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// CreateAgentFromTemplate +// --------------------------------------------------------------------------- + +/// Input for [`CreateAgentFromTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateAgentFromTemplateInput { + /// The project that owns the agent. + pub project: Project, + /// Source template. + pub template_id: TemplateId, + /// Optional agent name; defaults to the template's name. + pub name: Option, + /// Whether the agent tracks the template (`true` ⇒ future syncs apply). + pub synchronized: bool, +} + +/// Output of [`CreateAgentFromTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateAgentFromTemplateOutput { + /// The created agent. + pub agent: Agent, +} + +/// Instantiates a project agent from a template: copies the template's +/// `content_md` into the agent's `.md`, links the origin to the template at its +/// current version, and records the manifest entry. +pub struct CreateAgentFromTemplate { + templates: Arc, + contexts: Arc, + ids: Arc, + events: Arc, +} + +impl CreateAgentFromTemplate { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + templates: Arc, + contexts: Arc, + ids: Arc, + events: Arc, + ) -> Self { + Self { + templates, + contexts, + ids, + events, + } + } + + /// Executes creation from a template. + /// + /// # Errors + /// - [`AppError::NotFound`] if the template is unknown, + /// - [`AppError::Invalid`] if the resulting agent/manifest is invalid, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: CreateAgentFromTemplateInput, + ) -> Result { + let template = self.templates.get(input.template_id).await?; + let manifest = self.contexts.load_manifest(&input.project).await?; + + let name = input.name.unwrap_or_else(|| template.name.clone()); + let id = AgentId::from_uuid(self.ids.new_uuid()); + let md_path = unique_md_path(&name, &manifest); + let origin = AgentOrigin::FromTemplate { + template_id: template.id, + synced_template_version: template.version, + }; + let agent = Agent::new( + id, + name, + md_path, + template.default_profile_id, + origin, + input.synchronized, + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; + + let mut entries = manifest.entries; + entries.push(ManifestEntry::from_agent(&agent)); + let manifest = AgentManifest::new(manifest.version, entries) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.contexts.save_manifest(&input.project, &manifest).await?; + + // Seed the agent context with the template content. + self.contexts + .write_context(&input.project, &agent.id, &template.content_md) + .await?; + + self.events.publish(DomainEvent::LayoutChanged { + project_id: input.project.id, + }); + + Ok(CreateAgentFromTemplateOutput { agent }) + } +} + +// --------------------------------------------------------------------------- +// DetectAgentDrift +// --------------------------------------------------------------------------- + +/// One drifting agent: its template moved ahead of the agent's synced version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentDrift { + /// The drifting agent. + pub agent_id: AgentId, + /// Version the agent is currently synced to. + pub from: TemplateVersion, + /// Version available from the template. + pub to: TemplateVersion, +} + +/// Input for [`DetectAgentDrift::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectAgentDriftInput { + /// The project whose agents to check. + pub project: Project, +} + +/// Output of [`DetectAgentDrift::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectAgentDriftOutput { + /// Agents whose template has a newer version (only `synchronized` ones). + pub drifts: Vec, +} + +/// Detects which synchronized agents are behind their template +/// (`template.version > synced_template_version`, §8.2), announcing +/// [`DomainEvent::AgentDriftDetected`] for each. +pub struct DetectAgentDrift { + templates: Arc, + contexts: Arc, + events: Arc, +} + +impl DetectAgentDrift { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + templates: Arc, + contexts: Arc, + events: Arc, + ) -> Self { + Self { + templates, + contexts, + events, + } + } + + /// Computes the drift set. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure (a *deleted* template is not an + /// error — that agent simply has nothing to drift against). + pub async fn execute( + &self, + input: DetectAgentDriftInput, + ) -> Result { + let manifest = self.contexts.load_manifest(&input.project).await?; + let mut drifts = Vec::new(); + for entry in &manifest.entries { + // Only synchronized, template-backed agents can drift. + if !entry.synchronized { + continue; + } + let (Some(template_id), Some(synced)) = + (entry.template_id, entry.synced_template_version) + else { + continue; + }; + let template = match self.templates.get(template_id).await { + Ok(t) => t, + Err(StoreError::NotFound) => continue, + Err(e) => return Err(e.into()), + }; + if template.version > synced { + let drift = AgentDrift { + agent_id: entry.agent_id, + from: synced, + to: template.version, + }; + self.events.publish(DomainEvent::AgentDriftDetected { + agent_id: drift.agent_id, + from: drift.from, + to: drift.to, + }); + drifts.push(drift); + } + } + Ok(DetectAgentDriftOutput { drifts }) + } +} + +// --------------------------------------------------------------------------- +// SyncAgentWithTemplate +// --------------------------------------------------------------------------- + +/// Input for [`SyncAgentWithTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncAgentWithTemplateInput { + /// The owning project. + pub project: Project, + /// The agent to bring up to date. + pub agent_id: AgentId, +} + +/// Output of [`SyncAgentWithTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncAgentWithTemplateOutput { + /// Whether an update was applied (`false` for a non-synchronized or + /// scratch agent — those are intentionally left untouched, §8.4). + pub synced: bool, + /// The version the agent is now at, when a sync happened. + pub version: Option, +} + +/// Applies a template update to a synchronized agent: **replaces** the agent's +/// `.md` with the template content (the context of a synchronized agent is +/// "owned" by the template, §8.3) and records the new synced version. Agents +/// that are not `synchronized` (or are `scratch`) are left untouched. +pub struct SyncAgentWithTemplate { + templates: Arc, + contexts: Arc, + events: Arc, +} + +impl SyncAgentWithTemplate { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + templates: Arc, + contexts: Arc, + events: Arc, + ) -> Self { + Self { + templates, + contexts, + events, + } + } + + /// Executes the sync for one agent. + /// + /// # Errors + /// - [`AppError::NotFound`] if the agent or its template is unknown, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: SyncAgentWithTemplateInput, + ) -> Result { + let mut manifest = self.contexts.load_manifest(&input.project).await?; + let idx = manifest + .entries + .iter() + .position(|e| e.agent_id == input.agent_id) + .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + let entry = &manifest.entries[idx]; + + // Non-synchronized / scratch agents are never auto-updated (§8.4). + let Some(template_id) = entry.template_id.filter(|_| entry.synchronized) else { + return Ok(SyncAgentWithTemplateOutput { + synced: false, + version: None, + }); + }; + + let template = self.templates.get(template_id).await?; + manifest.entries[idx].synced_template_version = Some(template.version); + + // Persist the manifest (revalidated) and overwrite the agent context. + let manifest = AgentManifest::new(manifest.version, manifest.entries) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.contexts.save_manifest(&input.project, &manifest).await?; + self.contexts + .write_context(&input.project, &input.agent_id, &template.content_md) + .await?; + + self.events.publish(DomainEvent::AgentSynced { + agent_id: input.agent_id, + to: template.version, + }); + + Ok(SyncAgentWithTemplateOutput { + synced: true, + version: Some(template.version), + }) + } +} diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs new file mode 100644 index 0000000..f2d48ee --- /dev/null +++ b/crates/application/src/terminal/mod.rs @@ -0,0 +1,35 @@ +//! Terminal use cases (ARCHITECTURE §6, L3). +//! +//! Each use case is a struct carrying its ports as `Arc` and exposing a +//! single `execute(input) -> Result` method (**Single +//! Responsibility**). They talk **only** to the [`domain::ports::PtyPort`] (plus +//! the [`domain::ports::EventBus`] for `OpenTerminal`); the composition root +//! injects the concrete [`infrastructure::PortablePtyAdapter`]. +//! +//! - [`OpenTerminal`] — resolve the cwd, spawn a PTY, create a +//! [`domain::TerminalSession`], register the live handle, publish an event. +//! - [`WriteToTerminal`] — forward bytes (keystrokes) to a PTY. +//! - [`ResizeTerminal`] — resize a PTY. +//! - [`CloseTerminal`] — kill a PTY and forget its handle. +//! +//! # Where the active-session registry lives, and why +//! +//! The domain [`PtyPort`] is *handle-oriented*: `spawn` returns a +//! [`domain::ports::PtyHandle`] that every later call (`write`/`resize`/`kill`) +//! must reference. Something has to remember, per [`SessionId`], the live +//! `PtyHandle` (and the `TerminalSession` snapshot) between IPC calls. That state +//! is **not domain state** (it is the in-flight wiring of an I/O resource), and +//! it must not live in the adapter alone (other use cases need to address a +//! session by id). It therefore lives in [`TerminalSessions`], an **application +//! service** injected into the terminal use cases (and held in the composition +//! root behind an `Arc`). This keeps the domain pure and the registry shared, +//! testable, and transport-agnostic. + +mod registry; +mod usecases; + +pub use registry::TerminalSessions; +pub use usecases::{ + CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, + OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput, +}; diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs new file mode 100644 index 0000000..640a651 --- /dev/null +++ b/crates/application/src/terminal/registry.rs @@ -0,0 +1,81 @@ +//! [`TerminalSessions`] — the active-terminal registry (application service). +//! +//! Maps a [`SessionId`] to the live [`PtyHandle`] and the [`TerminalSession`] +//! snapshot. Thread-safe (behind a [`Mutex`]); a single instance is shared +//! (as `Arc`) by all terminal use cases via the composition root. See the module +//! docs in `terminal/mod.rs` for the rationale of keeping this in the +//! application layer rather than the domain or the adapter. + +use std::collections::HashMap; +use std::sync::Mutex; + +use domain::ports::PtyHandle; +use domain::{SessionId, TerminalSession}; + +/// A registered, live terminal: its PTY handle plus the domain snapshot. +#[derive(Debug, Clone)] +struct Entry { + handle: PtyHandle, + session: TerminalSession, +} + +/// In-memory registry of active terminal sessions. +#[derive(Default)] +pub struct TerminalSessions { + entries: Mutex>, +} + +impl TerminalSessions { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + + /// Inserts a freshly-opened session. + pub fn insert(&self, handle: PtyHandle, session: TerminalSession) { + if let Ok(mut map) = self.entries.lock() { + map.insert(session.id, Entry { handle, session }); + } + } + + /// Returns the [`PtyHandle`] for a session, if registered. + #[must_use] + pub fn handle(&self, id: &SessionId) -> Option { + self.entries + .lock() + .ok() + .and_then(|m| m.get(id).map(|e| e.handle.clone())) + } + + /// Returns the [`TerminalSession`] snapshot for a session, if registered. + #[must_use] + pub fn session(&self, id: &SessionId) -> Option { + self.entries + .lock() + .ok() + .and_then(|m| m.get(id).map(|e| e.session.clone())) + } + + /// Removes a session from the registry, returning its handle if present. + pub fn remove(&self, id: &SessionId) -> Option { + self.entries + .lock() + .ok() + .and_then(|mut m| m.remove(id).map(|e| e.handle)) + } + + /// Number of currently-registered sessions. + #[must_use] + pub fn len(&self) -> usize { + self.entries.lock().map(|m| m.len()).unwrap_or(0) + } + + /// Whether the registry is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} diff --git a/crates/application/src/terminal/usecases.rs b/crates/application/src/terminal/usecases.rs new file mode 100644 index 0000000..132495d --- /dev/null +++ b/crates/application/src/terminal/usecases.rs @@ -0,0 +1,250 @@ +//! The four terminal use cases (ARCHITECTURE §6, L3). + +use std::sync::Arc; + +use domain::ports::{EventBus, PtyPort, SpawnSpec}; +use domain::{ + DomainEvent, NodeId, ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, + TerminalSession, +}; + +use crate::error::AppError; + +use super::registry::TerminalSessions; + +/// Input for [`OpenTerminal::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenTerminalInput { + /// Working directory for the shell (absolute path; defaults applied by the + /// caller — typically the project root). + pub cwd: String, + /// Initial terminal height in rows. + pub rows: u16, + /// Initial terminal width in columns. + pub cols: u16, + /// Command to run. `None` ⇒ the platform default login shell. + pub command: Option, + /// Arguments for the command. + pub args: Vec, + /// The layout leaf hosting this session. `None` ⇒ a fresh node id (L4 will + /// thread the real layout node through here). + pub node_id: Option, +} + +/// Output of [`OpenTerminal::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenTerminalOutput { + /// The created terminal session (its `id` is the [`SessionId`] minted by the + /// PTY layer and reused everywhere — write/resize/close, the output channel). + pub session: TerminalSession, +} + +/// Opens a PTY in a cwd, creates a [`TerminalSession`], registers the handle. +pub struct OpenTerminal { + pty: Arc, + sessions: Arc, + events: Arc, +} + +impl OpenTerminal { + /// Builds the use case from its injected ports/services. + #[must_use] + pub fn new( + pty: Arc, + sessions: Arc, + events: Arc, + ) -> Self { + Self { + pty, + sessions, + events, + } + } + + /// Executes the open: validate cwd + size, spawn the PTY, snapshot the + /// session, register the live handle, publish [`DomainEvent::LayoutChanged`]. + /// + /// # Errors + /// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal, + /// - [`AppError::Process`] if the PTY fails to spawn. + pub async fn execute( + &self, + input: OpenTerminalInput, + ) -> Result { + let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?; + let size = + PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; + + let command = input.command.unwrap_or_else(default_shell); + let spec = SpawnSpec { + command, + args: input.args, + cwd: cwd.clone(), + env: Vec::new(), + context_plan: None, + }; + + // The PTY layer owns the session identity; we adopt the returned handle's + // id as the `TerminalSession.id` (single source of truth, ARCHITECTURE §4). + let handle = self.pty.spawn(spec, size).await?; + let session_id = handle.session_id; + let node_id = input.node_id.unwrap_or_else(NodeId::new_random); + + let mut session = + TerminalSession::starting(session_id, node_id, cwd, SessionKind::Plain, size); + session.status = SessionStatus::Running; + + self.sessions.insert(handle, session.clone()); + + // Output streaming + per-session Channel wiring happens in the presentation + // layer (it owns the transport). Announce so the UI can react. + self.events.publish(DomainEvent::PtyOutput { + session_id, + bytes: Vec::new(), + }); + + Ok(OpenTerminalOutput { session }) + } +} + +/// Input for [`WriteToTerminal::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WriteToTerminalInput { + /// Target session. + pub session_id: SessionId, + /// Bytes to write (typically keystrokes from xterm.js). + pub data: Vec, +} + +/// Forwards bytes (keystrokes) to a live PTY. +pub struct WriteToTerminal { + pty: Arc, + sessions: Arc, +} + +impl WriteToTerminal { + /// Builds the use case. + #[must_use] + pub fn new(pty: Arc, sessions: Arc) -> Self { + Self { pty, sessions } + } + + /// Writes to the session's PTY. + /// + /// # Errors + /// - [`AppError::NotFound`] if the session is unknown, + /// - [`AppError::Process`] on PTY I/O failure. + pub fn execute(&self, input: WriteToTerminalInput) -> Result<(), AppError> { + let handle = self + .sessions + .handle(&input.session_id) + .ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?; + self.pty.write(&handle, &input.data)?; + Ok(()) + } +} + +/// Input for [`ResizeTerminal::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResizeTerminalInput { + /// Target session. + pub session_id: SessionId, + /// New height in rows. + pub rows: u16, + /// New width in columns. + pub cols: u16, +} + +/// Resizes a live PTY. +pub struct ResizeTerminal { + pty: Arc, + sessions: Arc, +} + +impl ResizeTerminal { + /// Builds the use case. + #[must_use] + pub fn new(pty: Arc, sessions: Arc) -> Self { + Self { pty, sessions } + } + + /// Resizes the session's PTY. + /// + /// # Errors + /// - [`AppError::Invalid`] for a zero-sized terminal, + /// - [`AppError::NotFound`] if the session is unknown, + /// - [`AppError::Process`] on PTY failure. + pub fn execute(&self, input: ResizeTerminalInput) -> Result<(), AppError> { + let size = + PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; + let handle = self + .sessions + .handle(&input.session_id) + .ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?; + self.pty.resize(&handle, size)?; + Ok(()) + } +} + +/// Input for [`CloseTerminal::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloseTerminalInput { + /// Target session. + pub session_id: SessionId, +} + +/// Output of [`CloseTerminal::execute`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CloseTerminalOutput { + /// Exit code reported by the killed process (`None` if signalled). + pub code: Option, +} + +/// Kills a live PTY and forgets its handle. +pub struct CloseTerminal { + pty: Arc, + sessions: Arc, +} + +impl CloseTerminal { + /// Builds the use case. + #[must_use] + pub fn new(pty: Arc, sessions: Arc) -> Self { + Self { pty, sessions } + } + + /// Kills the session's PTY and removes it from the registry. Idempotent on + /// the registry side (removing an unknown session is a no-op error). + /// + /// # Errors + /// - [`AppError::NotFound`] if the session is unknown, + /// - [`AppError::Process`] if the kill fails. + pub async fn execute( + &self, + input: CloseTerminalInput, + ) -> Result { + let handle = self + .sessions + .remove(&input.session_id) + .ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?; + let status = self.pty.kill(&handle).await?; + Ok(CloseTerminalOutput { code: status.code }) + } +} + +/// The platform default interactive shell. +/// +/// Resolution policy lives in the application layer (a metier default), not the +/// adapter, so it is uniform and testable. On Unix we honour `$SHELL`, falling +/// back to `/bin/sh`; on Windows we use `cmd.exe` (a ConPTY spike point — PowerShell +/// could become the default, ARCHITECTURE §13.1). +fn default_shell() -> String { + #[cfg(windows)] + { + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_owned()) + } + #[cfg(not(windows))] + { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned()) + } +} diff --git a/crates/application/src/window/mod.rs b/crates/application/src/window/mod.rs new file mode 100644 index 0000000..bec4513 --- /dev/null +++ b/crates/application/src/window/mod.rs @@ -0,0 +1,6 @@ +//! Window/tab use cases (ARCHITECTURE §6, §10; L10). Detaching a tab into a new +//! OS window, persisting the workspace. + +mod usecases; + +pub use usecases::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; diff --git a/crates/application/src/window/usecases.rs b/crates/application/src/window/usecases.rs new file mode 100644 index 0000000..9970ba6 --- /dev/null +++ b/crates/application/src/window/usecases.rs @@ -0,0 +1,73 @@ +//! Window/tab use cases (ARCHITECTURE §6, §10; L10). +//! +//! [`MoveTabToNewWindow`] detaches a tab into a new OS window. The topology +//! change is the pure [`Workspace::move_tab_to_new_window`] domain operation; the +//! use case only loads/persists the workspace and mints the new window id. + +use std::sync::Arc; + +use domain::ids::{TabId, WindowId}; +use domain::layout::Workspace; +use domain::ports::{IdGenerator, ProjectStore}; + +use crate::error::AppError; + +/// Input for [`MoveTabToNewWindow::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MoveTabToNewWindowInput { + /// The tab to detach into its own new window. + pub tab_id: TabId, +} + +/// Output of [`MoveTabToNewWindow::execute`]. +#[derive(Debug, Clone, PartialEq)] +pub struct MoveTabToNewWindowOutput { + /// The id minted for the new window. + pub new_window_id: WindowId, + /// The resulting workspace (already persisted). + pub workspace: Workspace, +} + +/// Detaches a tab into a freshly-created window and persists the workspace. +pub struct MoveTabToNewWindow { + store: Arc, + ids: Arc, +} + +impl MoveTabToNewWindow { + /// Builds the use case from its ports. + #[must_use] + pub fn new(store: Arc, ids: Arc) -> Self { + Self { store, ids } + } + + /// Executes the detach. + /// + /// # Errors + /// - [`AppError::NotFound`] if the tab is not in the workspace, + /// - [`AppError::Invalid`] if the resulting window is invalid, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: MoveTabToNewWindowInput, + ) -> Result { + let workspace = self.store.load_workspace().await?; + let new_window_id = WindowId::from_uuid(self.ids.new_uuid()); + + let workspace = workspace + .move_tab_to_new_window(input.tab_id, new_window_id) + .map_err(|e| match e { + domain::layout::LayoutError::TabNotFound(t) => { + AppError::NotFound(format!("tab {t}")) + } + other => AppError::Invalid(other.to_string()), + })?; + + self.store.save_workspace(&workspace).await?; + + Ok(MoveTabToNewWindowOutput { + new_window_id, + workspace, + }) + } +} diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs new file mode 100644 index 0000000..0eda9cb --- /dev/null +++ b/crates/application/tests/agent_lifecycle.rs @@ -0,0 +1,681 @@ +//! L6 tests for the agent lifecycle use cases (`CreateAgentFromScratch`, +//! `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, +//! `LaunchAgent`). +//! +//! Every port is faked in-memory so the use cases run without real I/O: +//! - [`FakeContexts`] — an [`AgentContextStore`] holding the manifest + a +//! `md_path → content` map, +//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list, +//! - [`FakeRuntime`] — an [`AgentRuntime`] whose `prepare_invocation` records the +//! call into a shared **trace** and returns a configurable injection plan, +//! - [`FakeFs`] — a [`FileSystem`] recording writes into the same trace, +//! - [`FakePty`] — a [`PtyPort`] recording `spawn` into the trace, +//! - [`SpyBus`], [`SeqIds`] — event recorder and deterministic id generator. +//! +//! The shared trace lets us assert the **call ordering** contract of +//! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; +use domain::events::DomainEvent; +use domain::ids::{AgentId, ProfileId, ProjectId}; +use domain::markdown::MarkdownDoc; +use domain::ports::{ + AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, + ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, + PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError, +}; +use domain::profile::{AgentProfile, ContextInjection}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use domain::{PtySize, SessionId}; +use uuid::Uuid; + +use application::{ + CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, + LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput, + TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, +}; + +// --------------------------------------------------------------------------- +// Shared trace (ordering) +// --------------------------------------------------------------------------- + +type Trace = Arc>>; + +/// A recorded list of `(target, bytes)` writes, keyed by whatever addresses the +/// target (a path for the fs, a [`SessionId`] for the pty). +type WriteLog = Arc)>>>; + +fn trace() -> Trace { + Arc::new(Mutex::new(Vec::new())) +} + +// --------------------------------------------------------------------------- +// FakeContexts (AgentContextStore) +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct ContextsInner { + manifest: AgentManifest, + contents: HashMap, +} + +#[derive(Clone)] +struct FakeContexts(Arc>); + +impl FakeContexts { + fn new() -> Self { + Self(Arc::new(Mutex::new(ContextsInner { + manifest: AgentManifest { + version: 1, + entries: Vec::new(), + }, + contents: HashMap::new(), + }))) + } + fn with_agent(agent: &Agent, content: &str) -> Self { + let me = Self::new(); + { + let mut inner = me.0.lock().unwrap(); + inner.manifest.entries.push(ManifestEntry::from_agent(agent)); + inner + .contents + .insert(agent.context_path.clone(), content.to_owned()); + } + me + } + fn manifest(&self) -> AgentManifest { + self.0.lock().unwrap().manifest.clone() + } + fn content(&self, md_path: &str) -> Option { + self.0.lock().unwrap().contents.get(md_path).cloned() + } + fn md_path_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .manifest + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.md_path.clone()) + } +} + +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + agent: &AgentId, + ) -> Result { + let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.content(&md_path) + .map(MarkdownDoc::new) + .ok_or(StoreError::NotFound) + } + async fn write_context( + &self, + _project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0 + .lock() + .unwrap() + .contents + .insert(md_path, md.as_str().to_owned()); + Ok(()) + } + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.manifest()) + } + async fn save_manifest( + &self, + _project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError> { + self.0.lock().unwrap().manifest = manifest.clone(); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeProfiles (ProfileStore) +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakeProfiles(Arc>); + +impl FakeProfiles { + fn new(profiles: Vec) -> Self { + Self(Arc::new(profiles)) + } +} + +#[async_trait] +impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, StoreError> { + Ok((*self.0).clone()) + } + async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> { + Ok(()) + } + async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { + Ok(()) + } + async fn is_configured(&self) -> Result { + Ok(true) + } + async fn mark_configured(&self) -> Result<(), StoreError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan +// --------------------------------------------------------------------------- + +struct FakeRuntime { + trace: Trace, + plan: Option, +} + +impl FakeRuntime { + fn new(trace: Trace, plan: Option) -> Self { + Self { trace, plan } + } +} + +impl AgentRuntime for FakeRuntime { + fn detect(&self, _profile: &AgentProfile) -> Result { + Ok(true) + } + fn prepare_invocation( + &self, + profile: &AgentProfile, + _ctx: &PreparedContext, + cwd: &ProjectPath, + ) -> Result { + self.trace.lock().unwrap().push("prepare".to_owned()); + Ok(SpawnSpec { + command: profile.command.clone(), + args: profile.args.clone(), + cwd: cwd.clone(), + env: Vec::new(), + context_plan: self.plan.clone(), + }) + } +} + +// --------------------------------------------------------------------------- +// FakeFs (FileSystem) — records writes into the trace +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakeFs { + trace: Trace, + writes: WriteLog, +} + +impl FakeFs { + fn new(trace: Trace) -> Self { + Self { + trace, + writes: Arc::new(Mutex::new(Vec::new())), + } + } + fn writes(&self) -> Vec<(String, Vec)> { + self.writes.lock().unwrap().clone() + } +} + +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + Err(FsError::NotFound(path.as_str().to_owned())) + } + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + self.trace.lock().unwrap().push("fs.write".to_owned()); + self.writes + .lock() + .unwrap() + .push((path.as_str().to_owned(), data.to_vec())); + Ok(()) + } + async fn exists(&self, _path: &RemotePath) -> Result { + Ok(false) + } + async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakePty (PtyPort) — records spawn into the trace +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakePty { + trace: Trace, + next_id: SessionId, + spawns: Arc>>, + writes: WriteLog, +} + +impl FakePty { + fn new(trace: Trace, next_id: SessionId) -> Self { + Self { + trace, + next_id, + spawns: Arc::new(Mutex::new(Vec::new())), + writes: Arc::new(Mutex::new(Vec::new())), + } + } + fn spawns(&self) -> Vec { + self.spawns.lock().unwrap().clone() + } + fn writes(&self) -> Vec<(SessionId, Vec)> { + self.writes.lock().unwrap().clone() + } +} + +#[async_trait] +impl PtyPort for FakePty { + async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result { + self.trace.lock().unwrap().push("spawn".to_owned()); + self.spawns.lock().unwrap().push(spec); + Ok(PtyHandle { + session_id: self.next_id, + }) + } + fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { + self.writes + .lock() + .unwrap() + .push((handle.session_id, data.to_vec())); + Ok(()) + } + fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> { + Ok(()) + } + fn subscribe_output(&self, _handle: &PtyHandle) -> Result { + Ok(Box::new(std::iter::empty())) + } + async fn kill(&self, _handle: &PtyHandle) -> Result { + Ok(ExitStatus { code: Some(0) }) + } +} + +// --------------------------------------------------------------------------- +// SpyBus + SeqIds +// --------------------------------------------------------------------------- + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +struct SeqIds(Mutex); +impl SeqIds { + fn new() -> Self { + Self(Mutex::new(1)) + } +} +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +fn pid(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} +fn sid(n: u128) -> SessionId { + SessionId::from_uuid(Uuid::from_u128(n)) +} + +fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1000)), + "demo", + ProjectPath::new("/home/me/proj").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile { + AgentProfile::new( + id, + "Claude Code", + "claude", + Vec::new(), + injection, + Some("claude --version".to_owned()), + "{projectRoot}", + ) + .unwrap() +} + +fn scratch_agent(id: AgentId, name: &str, md: &str, profile_id: ProfileId) -> Agent { + Agent::new(id, name, md, profile_id, AgentOrigin::Scratch, false).unwrap() +} + +// --------------------------------------------------------------------------- +// CreateAgentFromScratch +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn create_persists_manifest_entry_and_initial_context() { + let contexts = FakeContexts::new(); + let bus = SpyBus::default(); + let create = CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds::new()), + Arc::new(bus.clone()), + ); + + let out = create + .execute(CreateAgentInput { + project: project(), + name: "Backend Dev".to_owned(), + profile_id: pid(9), + initial_content: Some("# Backend".to_owned()), + }) + .await + .expect("create succeeds"); + + // md_path is slugified from the name. + assert_eq!(out.agent.context_path, "agents/backend-dev.md"); + assert_eq!(out.agent.profile_id, pid(9)); + assert!(matches!(out.agent.origin, AgentOrigin::Scratch)); + assert!(!out.agent.synchronized); + + // Manifest has exactly one entry for this agent; context stored under md_path. + let manifest = contexts.manifest(); + assert_eq!(manifest.entries.len(), 1); + assert_eq!(manifest.entries[0].agent_id, out.agent.id); + assert_eq!( + contexts.content("agents/backend-dev.md").as_deref(), + Some("# Backend") + ); +} + +#[tokio::test] +async fn create_disambiguates_md_path_on_name_collision() { + // Seed a project that already has `agents/backend.md`. + let existing = scratch_agent(aid(50), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&existing, "old"); + let create = CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds::new()), + Arc::new(SpyBus::default()), + ); + + let out = create + .execute(CreateAgentInput { + project: project(), + name: "Backend".to_owned(), + profile_id: pid(9), + initial_content: None, + }) + .await + .unwrap(); + + assert_eq!(out.agent.context_path, "agents/backend-2.md"); + assert_eq!(contexts.manifest().entries.len(), 2); +} + +// --------------------------------------------------------------------------- +// ListAgents / Read / Update / Delete +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn list_reconstructs_agents_from_manifest() { + let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&a, "ctx"); + let list = ListAgents::new(Arc::new(contexts)); + + let out = list + .execute(ListAgentsInput { project: project() }) + .await + .unwrap(); + assert_eq!(out.agents, vec![a]); +} + +#[tokio::test] +async fn read_then_update_context_roundtrips() { + let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&a, "original"); + let read = ReadAgentContext::new(Arc::new(contexts.clone())); + let update = UpdateAgentContext::new(Arc::new(contexts.clone())); + + let before = read + .execute(ReadAgentContextInput { + project: project(), + agent_id: a.id, + }) + .await + .unwrap(); + assert_eq!(before.content.as_str(), "original"); + + update + .execute(UpdateAgentContextInput { + project: project(), + agent_id: a.id, + content: "edited".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(contexts.content("agents/backend.md").as_deref(), Some("edited")); +} + +#[tokio::test] +async fn delete_removes_entry_then_unknown_is_not_found() { + let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&a, "ctx"); + let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(SpyBus::default())); + + delete + .execute(DeleteAgentInput { + project: project(), + agent_id: a.id, + }) + .await + .unwrap(); + assert!(contexts.manifest().entries.is_empty()); + + // Second delete: the agent is gone → NotFound. + let err = delete + .execute(DeleteAgentInput { + project: project(), + agent_id: a.id, + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + +// --------------------------------------------------------------------------- +// LaunchAgent +// --------------------------------------------------------------------------- + +/// Everything a launch test needs to drive `LaunchAgent` and assert over the +/// fakes: the use case, the seeded agent, the recording fs/pty, the event spy, +/// the session registry and the shared ordering trace. +type LaunchFixture = ( + LaunchAgent, + Agent, + FakeFs, + FakePty, + SpyBus, + Arc, + Trace, +); + +/// Wires a LaunchAgent over fakes for a given injection strategy/plan. +fn launch_fixture(injection: ContextInjection, plan: Option) -> LaunchFixture { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&agent, "# ctx body"); + let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]); + let tr = trace(); + let runtime = FakeRuntime::new(Arc::clone(&tr), plan); + let fs = FakeFs::new(Arc::clone(&tr)); + let pty = FakePty::new(Arc::clone(&tr), sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let bus = SpyBus::default(); + let launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(runtime), + Arc::new(fs.clone()), + Arc::new(pty.clone()), + Arc::clone(&sessions), + Arc::new(bus.clone()), + ); + (launch, agent, fs, pty, bus, sessions, tr) +} + +fn launch_input(agent_id: AgentId) -> LaunchAgentInput { + LaunchAgentInput { + project: project(), + agent_id, + rows: 24, + cols: 80, + node_id: None, + } +} + +#[tokio::test] +async fn launch_orders_prepare_then_injection_then_spawn() { + // conventionFile strategy → an fs.write must happen between prepare and spawn. + let (launch, agent, fs, pty, bus, sessions, tr) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + let out = launch.execute(launch_input(agent.id)).await.expect("launch"); + + // Ordering contract. + assert_eq!( + *tr.lock().unwrap(), + vec!["prepare".to_owned(), "fs.write".to_owned(), "spawn".to_owned()], + "prepare → injection → spawn" + ); + + // The conventionFile was written to /CLAUDE.md with the context body. + let writes = fs.writes(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].0, "/home/me/proj/CLAUDE.md"); + assert_eq!(writes[0].1, b"# ctx body"); + + // Spawn happened at the resolved cwd with the profile command. + let spawns = pty.spawns(); + assert_eq!(spawns.len(), 1); + assert_eq!(spawns[0].command, "claude"); + assert_eq!(spawns[0].cwd.as_str(), "/home/me/proj"); + + // The session adopts the PTY id, is Running, and is registered as an agent. + assert_eq!(out.session.id, sid(777)); + assert!(matches!( + out.session.kind, + domain::SessionKind::Agent { agent_id } if agent_id == agent.id + )); + assert!(sessions.session(&sid(777)).is_some()); + + // AgentLaunched announced. + assert_eq!( + bus.events(), + vec![DomainEvent::AgentLaunched { + agent_id: agent.id, + session_id: sid(777), + }] + ); +} + +#[tokio::test] +async fn launch_stdin_strategy_pipes_context_after_spawn() { + let (launch, agent, fs, pty, _bus, _sessions, tr) = + launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin)); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + // No file written for stdin; content is piped to the PTY post-spawn. + assert!(fs.writes().is_empty(), "stdin must not write a file"); + assert_eq!(*tr.lock().unwrap(), vec!["prepare".to_owned(), "spawn".to_owned()]); + let writes = pty.writes(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].0, sid(777)); + assert_eq!(writes[0].1, b"# ctx body"); +} + +#[tokio::test] +async fn launch_unknown_agent_is_not_found() { + let (launch, _agent, _fs, pty, _bus, _sessions, _tr) = launch_fixture( + ContextInjection::stdin(), + Some(ContextInjectionPlan::Stdin), + ); + let err = launch.execute(launch_input(aid(404))).await.unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); + assert!(pty.spawns().is_empty(), "no spawn for unknown agent"); +} + +#[tokio::test] +async fn launch_unknown_profile_is_not_found() { + // The agent references pid(9) but the store only knows pid(1). + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&agent, "ctx"); + let profiles = FakeProfiles::new(vec![profile(pid(1), ContextInjection::stdin())]); + let tr = trace(); + let pty = FakePty::new(Arc::clone(&tr), sid(777)); + let launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))), + Arc::new(FakeFs::new(Arc::clone(&tr))), + Arc::new(pty.clone()), + Arc::new(TerminalSessions::new()), + Arc::new(SpyBus::default()), + ); + + let err = launch.execute(launch_input(agent.id)).await.unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); + assert!(pty.spawns().is_empty(), "no spawn when profile unresolved"); +} diff --git a/crates/application/tests/error_codes.rs b/crates/application/tests/error_codes.rs new file mode 100644 index 0000000..99f8724 --- /dev/null +++ b/crates/application/tests/error_codes.rs @@ -0,0 +1,57 @@ +//! L1 tests pinning the stable [`AppError::code`] strings the IPC `ErrorDto` +//! relies on, and the per-port `From` mappings. + +use application::AppError; +use domain::ports::{FsError, GitError, ProcessError, PtyError, RemoteError, StoreError}; + +#[test] +fn codes_are_stable() { + assert_eq!(AppError::NotFound("x".into()).code(), "NOT_FOUND"); + assert_eq!(AppError::Invalid("x".into()).code(), "INVALID"); + assert_eq!(AppError::FileSystem("x".into()).code(), "FILESYSTEM"); + assert_eq!(AppError::Store("x".into()).code(), "STORE"); + assert_eq!(AppError::Process("x".into()).code(), "PROCESS"); + assert_eq!(AppError::Git("x".into()).code(), "GIT"); + assert_eq!(AppError::Remote("x".into()).code(), "REMOTE"); + assert_eq!(AppError::Internal("x".into()).code(), "INTERNAL"); +} + +#[test] +fn fs_not_found_maps_to_not_found_other_to_filesystem() { + assert_eq!( + AppError::from(FsError::NotFound("/tmp/x".into())).code(), + "NOT_FOUND" + ); + assert_eq!( + AppError::from(FsError::PermissionDenied("/tmp/x".into())).code(), + "FILESYSTEM" + ); + assert_eq!(AppError::from(FsError::Io("boom".into())).code(), "FILESYSTEM"); +} + +#[test] +fn store_not_found_maps_to_not_found_other_to_store() { + assert_eq!(AppError::from(StoreError::NotFound).code(), "NOT_FOUND"); + assert_eq!( + AppError::from(StoreError::Serialization("bad".into())).code(), + "STORE" + ); +} + +#[test] +fn process_pty_runtime_map_to_process() { + assert_eq!(AppError::from(PtyError::NotFound).code(), "PROCESS"); + assert_eq!( + AppError::from(ProcessError::Spawn("x".into())).code(), + "PROCESS" + ); +} + +#[test] +fn git_and_remote_map_through() { + assert_eq!(AppError::from(GitError::NotFound).code(), "GIT"); + assert_eq!( + AppError::from(RemoteError::Auth("nope".into())).code(), + "REMOTE" + ); +} diff --git a/crates/application/tests/git_usecases.rs b/crates/application/tests/git_usecases.rs new file mode 100644 index 0000000..11e24f1 --- /dev/null +++ b/crates/application/tests/git_usecases.rs @@ -0,0 +1,240 @@ +//! L8 tests for the Git use cases with a faked [`GitPort`] (no real repo): +//! pass-through to the port, event emission on state changes, and input +//! validation (empty message, non-absolute root). + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::ports::{ + EventBus, EventStream, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, +}; +use domain::{ProjectId, ProjectPath}; +use uuid::Uuid; + +use application::{ + GitBranches, GitBranchesInput, GitCheckout, GitCheckoutInput, GitCommit, GitCommitInput, + GitStage, GitStagePathInput, GitStatus, GitStatusInput, +}; + +/// A recording [`GitPort`] with canned return values. +#[derive(Default)] +struct FakeGitInner { + calls: Vec, + status: Vec, + branches: Vec, + current: Option, +} + +#[derive(Default, Clone)] +struct FakeGit(Arc>); +impl FakeGit { + fn calls(&self) -> Vec { + self.0.lock().unwrap().calls.clone() + } + fn set_status(&self, s: Vec) { + self.0.lock().unwrap().status = s; + } + fn set_branches(&self, b: Vec, current: Option) { + let mut i = self.0.lock().unwrap(); + i.branches = b; + i.current = current; + } + fn record(&self, c: &str) { + self.0.lock().unwrap().calls.push(c.to_owned()); + } +} + +#[async_trait] +impl GitPort for FakeGit { + async fn init(&self, _r: &ProjectPath) -> Result<(), GitError> { + self.record("init"); + Ok(()) + } + async fn status(&self, _r: &ProjectPath) -> Result, GitError> { + self.record("status"); + Ok(self.0.lock().unwrap().status.clone()) + } + async fn stage(&self, _r: &ProjectPath, path: &str) -> Result<(), GitError> { + self.record(&format!("stage:{path}")); + Ok(()) + } + async fn unstage(&self, _r: &ProjectPath, path: &str) -> Result<(), GitError> { + self.record(&format!("unstage:{path}")); + Ok(()) + } + async fn commit(&self, _r: &ProjectPath, message: &str) -> Result { + self.record(&format!("commit:{message}")); + Ok(GitCommitInfo { + hash: "abc123".to_owned(), + summary: message.to_owned(), + }) + } + async fn branches(&self, _r: &ProjectPath) -> Result, GitError> { + self.record("branches"); + Ok(self.0.lock().unwrap().branches.clone()) + } + async fn current_branch(&self, _r: &ProjectPath) -> Result, GitError> { + self.record("current_branch"); + Ok(self.0.lock().unwrap().current.clone()) + } + async fn checkout(&self, _r: &ProjectPath, branch: &str) -> Result<(), GitError> { + self.record(&format!("checkout:{branch}")); + Ok(()) + } + async fn log( + &self, + _r: &ProjectPath, + _limit: usize, + ) -> Result, GitError> { + self.record("log"); + Ok(Vec::new()) + } + async fn log_graph( + &self, + _r: &ProjectPath, + _limit: usize, + ) -> Result, GitError> { + self.record("log_graph"); + Ok(Vec::new()) + } + async fn pull(&self, _r: &ProjectPath) -> Result<(), GitError> { + Ok(()) + } + async fn push(&self, _r: &ProjectPath) -> Result<(), GitError> { + Ok(()) + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +fn pid() -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(1)) +} +const ROOT: &str = "/home/me/repo"; + +#[tokio::test] +async fn status_passes_through_to_port() { + let git = FakeGit::default(); + git.set_status(vec![GitFileStatus { + path: "a.txt".to_owned(), + staged: true, + }]); + let out = GitStatus::new(Arc::new(git.clone())) + .execute(GitStatusInput { root: ROOT.to_owned() }) + .await + .unwrap(); + assert_eq!(out.entries.len(), 1); + assert_eq!(out.entries[0].path, "a.txt"); + assert_eq!(git.calls(), vec!["status"]); +} + +#[tokio::test] +async fn status_rejects_non_absolute_root() { + let git = FakeGit::default(); + let err = GitStatus::new(Arc::new(git.clone())) + .execute(GitStatusInput { + root: "relative/path".to_owned(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "got {err:?}"); + assert!(git.calls().is_empty(), "no port call on invalid root"); +} + +#[tokio::test] +async fn stage_calls_port_with_path() { + let git = FakeGit::default(); + GitStage::new(Arc::new(git.clone())) + .execute(GitStagePathInput { + root: ROOT.to_owned(), + path: "src/x.rs".to_owned(), + }) + .await + .unwrap(); + assert_eq!(git.calls(), vec!["stage:src/x.rs"]); +} + +#[tokio::test] +async fn commit_returns_commit_and_publishes_event() { + let git = FakeGit::default(); + let bus = SpyBus::default(); + let out = GitCommit::new(Arc::new(git.clone()), Arc::new(bus.clone())) + .execute(GitCommitInput { + project_id: pid(), + root: ROOT.to_owned(), + message: "feat: x".to_owned(), + }) + .await + .unwrap(); + assert_eq!(out.commit.hash, "abc123"); + assert_eq!(out.commit.summary, "feat: x"); + assert_eq!(git.calls(), vec!["commit:feat: x"]); + assert_eq!( + bus.events(), + vec![DomainEvent::GitStateChanged { project_id: pid() }] + ); +} + +#[tokio::test] +async fn commit_rejects_empty_message_without_touching_port() { + let git = FakeGit::default(); + let bus = SpyBus::default(); + let err = GitCommit::new(Arc::new(git.clone()), Arc::new(bus.clone())) + .execute(GitCommitInput { + project_id: pid(), + root: ROOT.to_owned(), + message: " ".to_owned(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "got {err:?}"); + assert!(git.calls().is_empty(), "no commit attempted"); + assert!(bus.events().is_empty(), "no event on rejected commit"); +} + +#[tokio::test] +async fn checkout_publishes_event() { + let git = FakeGit::default(); + let bus = SpyBus::default(); + GitCheckout::new(Arc::new(git.clone()), Arc::new(bus.clone())) + .execute(GitCheckoutInput { + project_id: pid(), + root: ROOT.to_owned(), + branch: "dev".to_owned(), + }) + .await + .unwrap(); + assert_eq!(git.calls(), vec!["checkout:dev"]); + assert_eq!( + bus.events(), + vec![DomainEvent::GitStateChanged { project_id: pid() }] + ); +} + +#[tokio::test] +async fn branches_returns_list_and_current() { + let git = FakeGit::default(); + git.set_branches(vec!["main".to_owned(), "dev".to_owned()], Some("main".to_owned())); + let out = GitBranches::new(Arc::new(git.clone())) + .execute(GitBranchesInput { root: ROOT.to_owned() }) + .await + .unwrap(); + assert_eq!(out.branches, vec!["main", "dev"]); + assert_eq!(out.current.as_deref(), Some("main")); + assert_eq!(git.calls(), vec!["branches", "current_branch"]); +} diff --git a/crates/application/tests/health.rs b/crates/application/tests/health.rs new file mode 100644 index 0000000..651d27c --- /dev/null +++ b/crates/application/tests/health.rs @@ -0,0 +1,96 @@ +//! L1 tests for [`HealthUseCase`] driven entirely through **mocked/fake ports** +//! (`FixedClock`, `SeqIdGenerator`, a spy `EventBus`), exercising DI without any +//! real I/O (README "Domaine/application testés sans I/O"). + +use std::sync::{Arc, Mutex}; + +use application::{HealthInput, HealthUseCase}; +use domain::events::DomainEvent; +use domain::ports::{Clock, EventBus, EventStream, IdGenerator}; +use uuid::Uuid; + +/// A clock that always returns the same configured instant. +struct FixedClock(i64); +impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } +} + +/// An id generator yielding a deterministic, predefined UUID. +struct SeqIdGenerator(Uuid); +impl IdGenerator for SeqIdGenerator { + fn new_uuid(&self) -> Uuid { + self.0 + } +} + +/// A spy event bus capturing every published event for assertions. +#[derive(Default)] +struct SpyEventBus { + published: Mutex>, +} +impl EventBus for SpyEventBus { + fn publish(&self, event: DomainEvent) { + self.published.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +fn fixed_uuid() -> Uuid { + Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap() +} + +#[test] +fn health_report_reflects_injected_ports_and_input() { + let clock = Arc::new(FixedClock(1_700_000_000_123)); + let ids = Arc::new(SeqIdGenerator(fixed_uuid())); + let bus = Arc::new(SpyEventBus::default()); + + let uc = HealthUseCase::new(clock, ids, Arc::clone(&bus) as Arc); + + let report = uc + .execute(HealthInput { + note: Some("ping".to_owned()), + }) + .expect("health never errs"); + + assert!(report.alive); + assert_eq!(report.time_millis, 1_700_000_000_123); + assert_eq!(report.correlation_id, fixed_uuid().to_string()); + assert_eq!(report.note.as_deref(), Some("ping")); + assert_eq!(report.version, env!("CARGO_PKG_VERSION")); +} + +#[test] +fn health_publishes_exactly_one_domain_event() { + let clock = Arc::new(FixedClock(0)); + let ids = Arc::new(SeqIdGenerator(fixed_uuid())); + let bus = Arc::new(SpyEventBus::default()); + + let uc = HealthUseCase::new(clock, ids, Arc::clone(&bus) as Arc); + uc.execute(HealthInput::default()).unwrap(); + + let published = bus.published.lock().unwrap(); + assert_eq!(published.len(), 1, "exactly one smoke event is published"); + match &published[0] { + DomainEvent::ProjectCreated { project_id } => { + // The smoke event reuses the correlation id as the (fake) project id. + assert_eq!(project_id.as_uuid(), fixed_uuid()); + } + other => panic!("expected ProjectCreated smoke event, got {other:?}"), + } +} + +#[test] +fn health_note_defaults_to_none() { + let uc = HealthUseCase::new( + Arc::new(FixedClock(42)), + Arc::new(SeqIdGenerator(fixed_uuid())), + Arc::new(SpyEventBus::default()), + ); + let report = uc.execute(HealthInput::default()).unwrap(); + assert_eq!(report.note, None); +} diff --git a/crates/application/tests/layout_usecases.rs b/crates/application/tests/layout_usecases.rs new file mode 100644 index 0000000..f2ce543 --- /dev/null +++ b/crates/application/tests/layout_usecases.rs @@ -0,0 +1,814 @@ +//! L4 + #4 tests for the layout use cases (`LoadLayout`, `MutateLayout`) and the +//! named-layout management (`ListLayouts`, `CreateLayout`, `RenameLayout`, +//! `DeleteLayout`, `SetActiveLayout`). +//! +//! Every port is faked in-memory so the use cases run without any real I/O. +//! Layouts now persist to `.ideai/layouts.json` (a collection); a legacy +//! `.ideai/layout.json` is migrated transparently. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::layout::Workspace; +use domain::ports::{ + DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, RemotePath, + StoreError, +}; +use domain::{ + AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId, + ProjectPath, RemoteRef, SessionId, +}; +use uuid::Uuid; + +use application::{ + CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation, + ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput, + RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, +}; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct FakeFsInner { + files: HashMap>, + dirs: HashSet, +} + +#[derive(Default, Clone)] +struct FakeFs(Arc>); + +impl FakeFs { + fn read_file(&self, path: &str) -> Option> { + self.0.lock().unwrap().files.get(path).cloned() + } + fn has_dir(&self, path: &str) -> bool { + self.0.lock().unwrap().dirs.contains(path) + } + fn put(&self, path: &str, data: &[u8]) { + self.0 + .lock() + .unwrap() + .files + .insert(path.to_owned(), data.to_vec()); + } +} + +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + self.0 + .lock() + .unwrap() + .files + .get(path.as_str()) + .cloned() + .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) + } + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + self.0 + .lock() + .unwrap() + .files + .insert(path.as_str().to_owned(), data.to_vec()); + Ok(()) + } + async fn exists(&self, path: &RemotePath) -> Result { + let inner = self.0.lock().unwrap(); + Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str())) + } + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { + self.0.lock().unwrap().dirs.insert(path.as_str().to_owned()); + Ok(()) + } + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeStoreInner { + projects: Vec, +} + +#[derive(Default, Clone)] +struct FakeStore(Arc>); + +#[async_trait] +impl ProjectStore for FakeStore { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.0.lock().unwrap().projects.clone()) + } + async fn load_project(&self, id: ProjectId) -> Result { + self.0 + .lock() + .unwrap() + .projects + .iter() + .find(|p| p.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + self.0.lock().unwrap().projects.push(project.clone()); + Ok(()) + } + async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { + Ok(()) + } + async fn load_workspace(&self) -> Result { + Ok(Workspace::default()) + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +struct SeqIds(Mutex); +impl SeqIds { + fn new(start: u128) -> Self { + Self(Mutex::new(start)) + } +} +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ROOT: &str = "/home/me/proj"; +const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json"; +const LEGACY_PATH: &str = "/home/me/proj/.ideai/layout.json"; + +fn pid(n: u128) -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(n)) +} +fn nid(n: u128) -> NodeId { + NodeId::from_uuid(Uuid::from_u128(n)) +} +fn sid(n: u128) -> SessionId { + SessionId::from_uuid(Uuid::from_u128(n)) +} +fn lid(n: u128) -> LayoutId { + LayoutId::from_uuid(Uuid::from_u128(n)) +} + +async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId { + let project = + Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap(); + store.save_project(&project).await.unwrap(); + id +} + +fn single_leaf(node_id: NodeId) -> LayoutTree { + LayoutTree::single(LeafCell { + id: node_id, + session: None, + agent: None, + }) +} + +/// Seeds a valid `layouts.json` with a single active layout holding `tree`. +fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) { + let doc = serde_json::json!({ + "version": 1, + "activeId": id.to_string(), + "layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ], + }); + fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap()); +} + +fn doc_json(fs: &FakeFs) -> serde_json::Value { + serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json written")).unwrap() +} + +/// The JSON of the active layout's tree. +fn active_tree_json(fs: &FakeFs) -> serde_json::Value { + let doc = doc_json(fs); + let active = doc["activeId"].clone(); + doc["layouts"] + .as_array() + .unwrap() + .iter() + .find(|l| l["id"] == active) + .expect("active layout present")["tree"] + .clone() +} + +fn read_active_tree(fs: &FakeFs) -> LayoutTree { + serde_json::from_value(active_tree_json(fs)).expect("active tree parseable") +} + +fn root_leaf_id(tree: &LayoutTree) -> NodeId { + match &tree.root { + LayoutNode::Leaf(l) => l.id, + _ => panic!("expected a single-leaf root"), + } +} + +// --------------------------------------------------------------------------- +// LoadLayout +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn load_returns_persisted_active_layout() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let id = register_project(&store, pid(1)).await; + seed_layouts(&fs, lid(1), &single_leaf(nid(42))); + + let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); + let out = load + .execute(LoadLayoutInput { + project_id: id, + layout_id: None, + }) + .await + .expect("load succeeds"); + + assert_eq!(out.layout_id, lid(1)); + assert_eq!(root_leaf_id(&out.layout), nid(42)); +} + +#[tokio::test] +async fn load_migrates_a_legacy_layout_json() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let id = register_project(&store, pid(2)).await; + // Only the legacy single-layout file exists. + fs.put(LEGACY_PATH, &serde_json::to_vec(&single_leaf(nid(7))).unwrap()); + + let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone())); + let out = load + .execute(LoadLayoutInput { + project_id: id, + layout_id: None, + }) + .await + .expect("legacy layout migrates"); + + assert_eq!(root_leaf_id(&out.layout), nid(7), "legacy tree preserved"); + // A layouts.json was written with that tree as the active layout. + assert_eq!(root_leaf_id(&read_active_tree(&fs)), nid(7)); +} + +#[tokio::test] +async fn load_defaults_to_single_empty_leaf_when_absent() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let id = register_project(&store, pid(3)).await; + + let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone())); + let out = load + .execute(LoadLayoutInput { + project_id: id, + layout_id: None, + }) + .await + .expect("absent layout does not fail"); + + match &out.layout.root { + LayoutNode::Leaf(l) => assert!(l.session.is_none()), + _ => panic!("expected a single default leaf"), + } + // Default was written through; two loads are deterministic. + assert_eq!(root_leaf_id(&read_active_tree(&fs)), root_leaf_id(&out.layout)); + assert!(fs.has_dir("/home/me/proj/.ideai")); +} + +#[tokio::test] +async fn load_tolerates_corrupt_json_with_default() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let id = register_project(&store, pid(4)).await; + fs.put(LAYOUTS_PATH, b"{ not json ]"); + + let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); + let out = load + .execute(LoadLayoutInput { + project_id: id, + layout_id: None, + }) + .await + .expect("corrupt JSON falls back to default"); + + assert!(matches!(out.layout.root, LayoutNode::Leaf(_))); + assert!(out.layout.validate().is_ok()); +} + +#[tokio::test] +async fn load_unknown_layout_id_is_not_found() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let id = register_project(&store, pid(5)).await; + seed_layouts(&fs, lid(1), &single_leaf(nid(1))); + + let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); + let err = load + .execute(LoadLayoutInput { + project_id: id, + layout_id: Some(lid(999)), + }) + .await + .expect_err("unknown layout id rejected"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + +#[tokio::test] +async fn load_unknown_project_is_not_found() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); + let err = load + .execute(LoadLayoutInput { + project_id: pid(999), + layout_id: None, + }) + .await + .expect_err("unknown project rejected"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + +// --------------------------------------------------------------------------- +// MutateLayout +// --------------------------------------------------------------------------- + +struct MutEnv { + fs: FakeFs, + bus: SpyBus, + mutate: MutateLayout, + project_id: ProjectId, +} + +async fn mut_env(project_id: ProjectId) -> MutEnv { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let bus = SpyBus::default(); + register_project(&store, project_id).await; + seed_layouts(&fs, lid(1), &single_leaf(nid(1))); + + let mutate = MutateLayout::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + Arc::new(bus.clone()), + ); + MutEnv { + fs, + bus, + mutate, + project_id, + } +} + +#[tokio::test] +async fn mutate_split_persists_camelcase_layout_and_announces() { + let env = mut_env(pid(10)).await; + let out = env + .mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::Split { + target: nid(1), + direction: Direction::Row, + new_leaf: nid(2), + container: nid(9), + }, + }) + .await + .expect("split succeeds"); + + match &out.layout.root { + LayoutNode::Split(s) => assert_eq!(s.children.len(), 2), + _ => panic!("expected a split root"), + } + + assert!(env.fs.has_dir("/home/me/proj/.ideai")); + let tree = active_tree_json(&env.fs); + assert_eq!(tree["root"]["type"], "split", "tagged on `type`"); + assert_eq!(tree["root"]["node"]["direction"], "row"); + assert_eq!(tree["root"]["node"]["children"].as_array().unwrap().len(), 2); + + assert_eq!( + env.bus.events(), + vec![DomainEvent::LayoutChanged { + project_id: env.project_id + }] + ); +} + +#[tokio::test] +async fn mutate_resize_writes_new_weights() { + let env = mut_env(pid(11)).await; + env.mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::Split { + target: nid(1), + direction: Direction::Row, + new_leaf: nid(2), + container: nid(9), + }, + }) + .await + .unwrap(); + env.mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::Resize { + container: nid(9), + weights: vec![3.0, 1.0], + }, + }) + .await + .expect("resize succeeds"); + + let tree = active_tree_json(&env.fs); + let children = tree["root"]["node"]["children"].as_array().unwrap(); + assert_eq!(children[0]["weight"], 3.0); + assert_eq!(children[1]["weight"], 1.0); +} + +#[tokio::test] +async fn mutate_set_session_attaches_and_clears() { + let env = mut_env(pid(12)).await; + env.mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetSession { + target: nid(1), + session: Some(sid(77)), + }, + }) + .await + .expect("attach"); + assert_eq!( + active_tree_json(&env.fs)["root"]["node"]["session"], + sid(77).to_string() + ); + + let out = env + .mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetSession { + target: nid(1), + session: None, + }, + }) + .await + .expect("detach"); + match &out.layout.root { + LayoutNode::Leaf(l) => assert!(l.session.is_none()), + _ => panic!("expected leaf root"), + } + assert!(active_tree_json(&env.fs)["root"]["node"] + .get("session") + .is_none()); +} + +#[tokio::test] +async fn mutate_invalid_op_errors_and_does_not_persist() { + let env = mut_env(pid(14)).await; + // Force the layouts.json to exist first (a clean load) so we have a baseline. + let before = doc_json(&env.fs); + + let err = env + .mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetSession { + target: nid(404), + session: Some(sid(1)), + }, + }) + .await + .expect_err("set_session on unknown node fails"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); + + assert_eq!(before, doc_json(&env.fs), "failed op must not overwrite"); + assert!(env.bus.events().is_empty(), "no event on failed mutation"); +} + +#[tokio::test] +async fn load_then_set_session_on_returned_id_succeeds() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let bus = SpyBus::default(); + let id = register_project(&store, pid(22)).await; + + let load = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone())); + let mutate = MutateLayout::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + Arc::new(bus.clone()), + ); + + let loaded = load + .execute(LoadLayoutInput { + project_id: id, + layout_id: None, + }) + .await + .expect("load"); + let leaf = root_leaf_id(&loaded.layout); + + mutate + .execute(MutateLayoutInput { + project_id: id, + layout_id: None, + operation: LayoutOperation::SetSession { + target: leaf, + session: Some(sid(7)), + }, + }) + .await + .expect("set_session on the just-loaded leaf id must succeed"); + + match &read_active_tree(&fs).root { + LayoutNode::Leaf(l) => assert_eq!(l.session, Some(sid(7))), + _ => panic!("expected persisted leaf root"), + } +} + +// --------------------------------------------------------------------------- +// SetCellAgent (#3 — per-cell agent) +// --------------------------------------------------------------------------- + +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} + +#[tokio::test] +async fn mutate_set_cell_agent_persists_agent_on_leaf() { + let env = mut_env(pid(50)).await; + // Attach an agent to the single root leaf (nid(1)). + env.mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetCellAgent { + target: nid(1), + agent: Some(aid(0xAA)), + }, + }) + .await + .expect("set_cell_agent attaches"); + + // Verify persisted JSON has the agent field. + let tree_json = active_tree_json(&env.fs); + assert_eq!( + tree_json["root"]["node"]["agent"], + aid(0xAA).to_string(), + "agent must be persisted on the leaf" + ); + + // Now clear it. + let out = env + .mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetCellAgent { + target: nid(1), + agent: None, + }, + }) + .await + .expect("set_cell_agent clears"); + + match &out.layout.root { + LayoutNode::Leaf(l) => assert_eq!(l.agent, None, "agent must be cleared"), + _ => panic!("expected leaf root"), + } + assert!( + active_tree_json(&env.fs)["root"]["node"] + .get("agent") + .is_none(), + "cleared agent must not be serialised" + ); +} + +#[tokio::test] +async fn mutate_set_cell_agent_missing_leaf_is_not_found() { + let env = mut_env(pid(51)).await; + let err = env + .mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetCellAgent { + target: nid(404), + agent: Some(aid(1)), + }, + }) + .await + .expect_err("unknown node rejected"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + +// --------------------------------------------------------------------------- +// Named-layout management (#4) +// --------------------------------------------------------------------------- + +/// Builds a project + fs + bus with a seeded single "Default" layout (id 1). +async fn mgmt_env(project_id: ProjectId) -> (FakeStore, FakeFs, SpyBus) { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let bus = SpyBus::default(); + register_project(&store, project_id).await; + seed_layouts(&fs, lid(1), &single_leaf(nid(1))); + (store, fs, bus) +} + +#[tokio::test] +async fn create_layout_appends_and_activates_it() { + let (store, fs, bus) = mgmt_env(pid(30)).await; + let create = CreateLayout::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + Arc::new(SeqIds::new(0xABC)), + Arc::new(bus.clone()), + ); + let out = create + .execute(CreateLayoutInput { + project_id: pid(30), + name: "Backend".to_owned(), + kind: LayoutKind::Terminal, + }) + .await + .unwrap(); + + let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) + .execute(ListLayoutsInput { project_id: pid(30) }) + .await + .unwrap(); + assert_eq!(list.layouts.len(), 2, "Default + Backend"); + assert_eq!(list.active_id, out.layout_id, "new layout is active"); + assert!(list.layouts.iter().any(|l| l.name == "Backend")); +} + +#[tokio::test] +async fn create_layout_rejects_empty_name() { + let (store, fs, bus) = mgmt_env(pid(31)).await; + let err = CreateLayout::new( + Arc::new(store), + Arc::new(fs), + Arc::new(SeqIds::new(1)), + Arc::new(bus), + ) + .execute(CreateLayoutInput { + project_id: pid(31), + name: " ".to_owned(), + kind: LayoutKind::Terminal, + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "got {err:?}"); +} + +#[tokio::test] +async fn rename_layout_changes_the_name() { + let (store, fs, bus) = mgmt_env(pid(32)).await; + RenameLayout::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + Arc::new(bus), + ) + .execute(RenameLayoutInput { + project_id: pid(32), + layout_id: lid(1), + name: "Main".to_owned(), + }) + .await + .unwrap(); + + let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) + .execute(ListLayoutsInput { project_id: pid(32) }) + .await + .unwrap(); + assert_eq!(list.layouts[0].name, "Main"); +} + +#[tokio::test] +async fn delete_layout_rejects_the_last_one() { + let (store, fs, bus) = mgmt_env(pid(33)).await; + let err = DeleteLayout::new(Arc::new(store), Arc::new(fs), Arc::new(bus)) + .execute(DeleteLayoutInput { + project_id: pid(33), + layout_id: lid(1), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "cannot delete the last layout"); +} + +#[tokio::test] +async fn delete_active_layout_reassigns_active() { + let (store, fs, bus) = mgmt_env(pid(34)).await; + // Add a second layout (becomes active), then delete it → active falls back. + let created = CreateLayout::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + Arc::new(SeqIds::new(0xD)), + Arc::new(bus.clone()), + ) + .execute(CreateLayoutInput { + project_id: pid(34), + name: "Second".to_owned(), + kind: LayoutKind::Terminal, + }) + .await + .unwrap(); + + let out = DeleteLayout::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + Arc::new(bus), + ) + .execute(DeleteLayoutInput { + project_id: pid(34), + layout_id: created.layout_id, + }) + .await + .unwrap(); + assert_eq!(out.active_id, lid(1), "active fell back to the Default layout"); + + let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) + .execute(ListLayoutsInput { project_id: pid(34) }) + .await + .unwrap(); + assert_eq!(list.layouts.len(), 1); +} + +#[tokio::test] +async fn set_active_layout_switches_and_load_follows() { + let (store, fs, bus) = mgmt_env(pid(35)).await; + let created = CreateLayout::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + Arc::new(SeqIds::new(0xE)), + Arc::new(bus.clone()), + ) + .execute(CreateLayoutInput { + project_id: pid(35), + name: "Second".to_owned(), + kind: LayoutKind::Terminal, + }) + .await + .unwrap(); + + // Switch back to the Default layout. + SetActiveLayout::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + Arc::new(bus), + ) + .execute(SetActiveLayoutInput { + project_id: pid(35), + layout_id: lid(1), + }) + .await + .unwrap(); + + let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs)) + .execute(LoadLayoutInput { + project_id: pid(35), + layout_id: None, + }) + .await + .unwrap(); + assert_eq!(loaded.layout_id, lid(1)); + assert_ne!(loaded.layout_id, created.layout_id); +} diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs new file mode 100644 index 0000000..7af8e69 --- /dev/null +++ b/crates/application/tests/profile_usecases.rs @@ -0,0 +1,320 @@ +//! L5 tests for the profile/first-run use cases and the reference catalogue. +//! +//! Ports are faked in-memory so the use cases run without any I/O: +//! - [`FakeProfileStore`] — an in-memory [`ProfileStore`] tracking a `configured` +//! flag (mirrors `profiles.json` existence), +//! - [`StubRuntime`] — an [`AgentRuntime`] whose `detect` is driven by a map from +//! command → result (including an error case to prove graceful degradation). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use domain::ids::ProfileId; +use domain::ports::{ + AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SpawnSpec, StoreError, +}; +use domain::profile::{AgentProfile, ContextInjection}; +use domain::project::ProjectPath; + +use application::{ + reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, + DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, + ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, +}; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct FakeStoreInner { + profiles: Vec, + configured: bool, +} + +#[derive(Default, Clone)] +struct FakeProfileStore(Arc>); + +#[async_trait] +impl ProfileStore for FakeProfileStore { + async fn list(&self) -> Result, StoreError> { + Ok(self.0.lock().unwrap().profiles.clone()) + } + + async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> { + let mut inner = self.0.lock().unwrap(); + inner.configured = true; + if let Some(slot) = inner.profiles.iter_mut().find(|p| p.id == profile.id) { + *slot = profile.clone(); + } else { + inner.profiles.push(profile.clone()); + } + Ok(()) + } + + async fn delete(&self, id: ProfileId) -> Result<(), StoreError> { + let mut inner = self.0.lock().unwrap(); + let before = inner.profiles.len(); + inner.profiles.retain(|p| p.id != id); + if inner.profiles.len() == before { + return Err(StoreError::NotFound); + } + Ok(()) + } + + async fn is_configured(&self) -> Result { + Ok(self.0.lock().unwrap().configured) + } + + async fn mark_configured(&self) -> Result<(), StoreError> { + self.0.lock().unwrap().configured = true; + Ok(()) + } +} + +/// Detection outcomes keyed by command. Missing keys ⇒ `false`. +#[derive(Clone)] +enum DetectResult { + Available, + Missing, + Error, +} + +struct StubRuntime { + by_command: HashMap, +} + +#[async_trait] +impl AgentRuntime for StubRuntime { + fn detect(&self, profile: &AgentProfile) -> Result { + match self.by_command.get(&profile.command) { + Some(DetectResult::Available) => Ok(true), + Some(DetectResult::Missing) | None => Ok(false), + Some(DetectResult::Error) => { + Err(RuntimeError::Detection("boom".to_owned())) + } + } + } + + fn prepare_invocation( + &self, + _profile: &AgentProfile, + _ctx: &PreparedContext, + _cwd: &ProjectPath, + ) -> Result { + unreachable!("not used in these tests") + } +} + +fn profile(id: u128, name: &str, command: &str) -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(uuid::Uuid::from_u128(id)), + name, + command, + Vec::new(), + ContextInjection::stdin(), + Some(format!("{command} --version")), + "{projectRoot}", + ) + .unwrap() +} + +// --------------------------------------------------------------------------- +// DetectProfiles +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn detect_maps_candidates_to_availability_in_order() { + let mut map = HashMap::new(); + map.insert("claude".to_owned(), DetectResult::Available); + map.insert("codex".to_owned(), DetectResult::Missing); + let runtime: Arc = Arc::new(StubRuntime { by_command: map }); + + let detect = DetectProfiles::new(runtime); + let out = detect + .execute(DetectProfilesInput { + candidates: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")], + }) + .await + .unwrap(); + + assert_eq!(out.results.len(), 2); + assert_eq!(out.results[0].profile.command, "claude"); + assert!(out.results[0].available); + assert_eq!(out.results[1].profile.command, "codex"); + assert!(!out.results[1].available); +} + +#[tokio::test] +async fn detect_error_degrades_to_unavailable_not_hard_failure() { + let mut map = HashMap::new(); + map.insert("aider".to_owned(), DetectResult::Error); + let runtime: Arc = Arc::new(StubRuntime { by_command: map }); + + let detect = DetectProfiles::new(runtime); + let out = detect + .execute(DetectProfilesInput { + candidates: vec![profile(1, "Aider", "aider")], + }) + .await + .expect("detection error must not fail the use case"); + + assert!(!out.results[0].available, "errored detection ⇒ available:false"); +} + +// --------------------------------------------------------------------------- +// ConfigureProfiles +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn configure_persists_chosen_profiles_and_closes_first_run() { + let store = FakeProfileStore::default(); + let configure = ConfigureProfiles::new(Arc::new(store.clone())); + + let out = configure + .execute(ConfigureProfilesInput { + profiles: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")], + }) + .await + .unwrap(); + + assert_eq!(out.profiles.len(), 2); + assert!(store.is_configured().await.unwrap()); + assert_eq!(store.list().await.unwrap().len(), 2); +} + +#[tokio::test] +async fn configure_empty_list_still_marks_configured() { + let store = FakeProfileStore::default(); + let configure = ConfigureProfiles::new(Arc::new(store.clone())); + + configure + .execute(ConfigureProfilesInput { profiles: vec![] }) + .await + .unwrap(); + + assert!( + store.is_configured().await.unwrap(), + "empty configure closes the first run" + ); + assert!(store.list().await.unwrap().is_empty()); +} + +// --------------------------------------------------------------------------- +// FirstRunState +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn first_run_true_when_not_configured_with_reference_catalogue() { + let store = FakeProfileStore::default(); + let uc = FirstRunState::new(Arc::new(store)); + + let out = uc.execute().await.unwrap(); + assert!(out.is_first_run); + assert_eq!(out.reference_profiles.len(), 4, "catalogue seeded"); +} + +#[tokio::test] +async fn first_run_false_after_configuration() { + let store = FakeProfileStore::default(); + store.mark_configured().await.unwrap(); + let uc = FirstRunState::new(Arc::new(store)); + + let out = uc.execute().await.unwrap(); + assert!(!out.is_first_run); +} + +// --------------------------------------------------------------------------- +// ListProfiles / SaveProfile / DeleteProfile +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn save_then_list_then_delete() { + let store = FakeProfileStore::default(); + let save = SaveProfile::new(Arc::new(store.clone())); + let list = ListProfiles::new(Arc::new(store.clone())); + let delete = DeleteProfile::new(Arc::new(store.clone())); + + let p = profile(1, "Claude", "claude"); + let saved = save + .execute(SaveProfileInput { profile: p.clone() }) + .await + .unwrap(); + assert_eq!(saved.profile, p); + + assert_eq!(list.execute().await.unwrap().profiles, vec![p.clone()]); + + delete.execute(DeleteProfileInput { id: p.id }).await.unwrap(); + assert!(list.execute().await.unwrap().profiles.is_empty()); +} + +#[tokio::test] +async fn delete_unknown_is_not_found_error() { + let store = FakeProfileStore::default(); + let delete = DeleteProfile::new(Arc::new(store)); + let err = delete + .execute(DeleteProfileInput { + id: ProfileId::from_uuid(uuid::Uuid::from_u128(123)), + }) + .await + .expect_err("deleting unknown id errors"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + +// --------------------------------------------------------------------------- +// ReferenceProfiles / catalogue +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn reference_profiles_use_case_returns_four() { + let out = ReferenceProfiles::new().execute().await.unwrap(); + assert_eq!(out.profiles.len(), 4); +} + +#[test] +fn catalogue_has_expected_commands_and_injection() { + let profiles = reference_profiles(); + let by_command: HashMap<&str, &AgentProfile> = + profiles.iter().map(|p| (p.command.as_str(), p)).collect(); + + let claude = by_command["claude"]; + assert_eq!( + claude.context_injection, + ContextInjection::ConventionFile { + target: "CLAUDE.md".to_owned() + } + ); + + assert_eq!( + by_command["codex"].context_injection, + ContextInjection::ConventionFile { + target: "AGENTS.md".to_owned() + } + ); + assert_eq!( + by_command["gemini"].context_injection, + ContextInjection::ConventionFile { + target: "GEMINI.md".to_owned() + } + ); + assert_eq!( + by_command["aider"].context_injection, + ContextInjection::Flag { + flag: "--message-file {path}".to_owned() + } + ); +} + +#[test] +fn catalogue_ids_are_stable_across_calls() { + let first = reference_profiles(); + let second = reference_profiles(); + let ids_a: Vec<_> = first.iter().map(|p| p.id).collect(); + let ids_b: Vec<_> = second.iter().map(|p| p.id).collect(); + assert_eq!(ids_a, ids_b, "reference ids are deterministic"); + + // And match the slug-derived id helper. + assert_eq!(first[0].id, reference_profile_id("claude")); +} diff --git a/crates/application/tests/project_usecases.rs b/crates/application/tests/project_usecases.rs new file mode 100644 index 0000000..c254e26 --- /dev/null +++ b/crates/application/tests/project_usecases.rs @@ -0,0 +1,565 @@ +//! L2 tests for the project life-cycle use cases (`CreateProject`, +//! `OpenProject`, `ListProjects`, `CloseProject`/`CloseTab`). +//! +//! Every port is faked in-memory so the use cases are exercised without any I/O: +//! - [`FakeFs`] — a `Mutex>>` filesystem that records +//! directories and file contents, +//! - [`FakeStore`] — an in-memory `ProjectStore` (registry + workspace), +//! - [`SpyBus`] — records published [`DomainEvent`]s, +//! - [`SeqIds`] / [`FixedClock`] — deterministic id/time. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::layout::Workspace; +use domain::ports::{ + Clock, DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, + RemotePath, StoreError, +}; +use domain::{Project, ProjectId, ProjectPath, RemoteRef}; + +use application::{ + CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, + ListProjects, OpenProject, OpenProjectInput, +}; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct FakeFsInner { + files: HashMap>, + dirs: HashSet, +} + +/// An in-memory [`FileSystem`] recording writes and created directories. +#[derive(Default, Clone)] +struct FakeFs(Arc>); + +impl FakeFs { + fn read_file(&self, path: &str) -> Option> { + self.0.lock().unwrap().files.get(path).cloned() + } + fn has_dir(&self, path: &str) -> bool { + self.0.lock().unwrap().dirs.contains(path) + } +} + +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + self.0 + .lock() + .unwrap() + .files + .get(path.as_str()) + .cloned() + .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) + } + + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + self.0 + .lock() + .unwrap() + .files + .insert(path.as_str().to_owned(), data.to_vec()); + Ok(()) + } + + async fn exists(&self, path: &RemotePath) -> Result { + let inner = self.0.lock().unwrap(); + Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str())) + } + + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { + self.0 + .lock() + .unwrap() + .dirs + .insert(path.as_str().to_owned()); + Ok(()) + } + + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeStoreInner { + projects: Vec, + workspace: Option, +} + +/// An in-memory [`ProjectStore`]. +#[derive(Default, Clone)] +struct FakeStore(Arc>); + +impl FakeStore { + fn saved_workspace(&self) -> Option { + self.0.lock().unwrap().workspace.clone() + } +} + +#[async_trait] +impl ProjectStore for FakeStore { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.0.lock().unwrap().projects.clone()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + self.0 + .lock() + .unwrap() + .projects + .iter() + .find(|p| p.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + let mut inner = self.0.lock().unwrap(); + if let Some(slot) = inner.projects.iter_mut().find(|p| p.id == project.id) { + *slot = project.clone(); + } else { + inner.projects.push(project.clone()); + } + Ok(()) + } + + async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> { + self.0.lock().unwrap().workspace = Some(workspace.clone()); + Ok(()) + } + + async fn load_workspace(&self) -> Result { + Ok(self.0.lock().unwrap().workspace.clone().unwrap_or_default()) + } +} + +/// A [`ProjectStore`] whose registry read always fails — used to assert the +/// `Store` error code propagates. +#[derive(Default, Clone)] +struct BrokenStore; + +#[async_trait] +impl ProjectStore for BrokenStore { + async fn list_projects(&self) -> Result, StoreError> { + Err(StoreError::Io("boom".into())) + } + async fn load_project(&self, _id: ProjectId) -> Result { + Err(StoreError::Io("boom".into())) + } + async fn save_project(&self, _project: &Project) -> Result<(), StoreError> { + Err(StoreError::Io("boom".into())) + } + async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { + Err(StoreError::Io("boom".into())) + } + async fn load_workspace(&self) -> Result { + Err(StoreError::Io("boom".into())) + } +} + +/// Records published events. +#[derive(Default, Clone)] +struct SpyBus(Arc>>); + +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} + +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +/// Deterministic ids: nil-based UUIDs derived from a counter. +struct SeqIds(Mutex); +impl SeqIds { + fn new() -> Self { + Self(Mutex::new(1)) + } +} +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> uuid::Uuid { + let mut n = self.0.lock().unwrap(); + let v = *n; + *n += 1; + uuid::Uuid::from_u128(v) + } +} + +struct FixedClock(i64); +impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } +} + +// --------------------------------------------------------------------------- +// Wiring helpers +// --------------------------------------------------------------------------- + +struct Env { + store: FakeStore, + fs: FakeFs, + bus: SpyBus, + create: CreateProject, + open: OpenProject, +} + +fn env() -> Env { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let bus = SpyBus::default(); + let ids: Arc = Arc::new(SeqIds::new()); + let clock: Arc = Arc::new(FixedClock(1_700_000_000_000)); + + let create = CreateProject::new( + Arc::new(store.clone()), + Arc::new(fs.clone()), + ids, + clock, + Arc::new(bus.clone()), + ); + let open = OpenProject::new(Arc::new(store.clone()), Arc::new(fs.clone())); + + Env { + store, + fs, + bus, + create, + open, + } +} + +fn input(name: &str, root: &str) -> CreateProjectInput { + CreateProjectInput { + name: name.to_owned(), + root: root.to_owned(), + remote: None, + default_profile_id: None, + } +} + +// --------------------------------------------------------------------------- +// CreateProject +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn create_inits_ideai_dir_and_writes_camelcase_project_json() { + let env = env(); + let out = env + .create + .execute(input("Demo", "/home/me/proj")) + .await + .expect("creation succeeds"); + + // .ideai/ created. + assert!(env.fs.has_dir("/home/me/proj/.ideai"), "dir created"); + + // project.json written with camelCase fields and no `root`. + let bytes = env + .fs + .read_file("/home/me/proj/.ideai/project.json") + .expect("project.json written"); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(json["version"], 1); + assert_eq!(json["name"], "Demo"); + assert_eq!(json["id"], out.project.id.to_string()); + assert_eq!(json["createdAt"], 1_700_000_000_000i64); + assert_eq!(json["remote"]["kind"], "local"); + assert!(json.get("root").is_none(), "root must NOT be stored"); + // default_profile_id omitted when None (skip_serializing_if). + assert!(json.get("defaultProfileId").is_none()); +} + +#[tokio::test] +async fn create_registers_project_in_store() { + let env = env(); + let out = env.create.execute(input("Demo", "/p")).await.unwrap(); + + let stored = env.store.list_projects().await.unwrap(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].id, out.project.id); + assert_eq!(stored[0].name, "Demo"); +} + +#[tokio::test] +async fn create_publishes_project_created_event() { + let env = env(); + let out = env.create.execute(input("Demo", "/p")).await.unwrap(); + + assert_eq!( + env.bus.events(), + vec![DomainEvent::ProjectCreated { + project_id: out.project.id + }] + ); +} + +#[tokio::test] +async fn create_rejects_duplicate_remote_root() { + let env = env(); + env.create.execute(input("A", "/same")).await.unwrap(); + + let err = env + .create + .execute(input("B", "/same")) + .await + .expect_err("duplicate (remote, root) rejected"); + + assert_eq!(err.code(), "INVALID", "got {err:?}"); + // Only the first project remains registered. + assert_eq!(env.store.list_projects().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn create_allows_same_root_on_different_remote() { + let env = env(); + env.create.execute(input("Local", "/shared")).await.unwrap(); + + let remote_input = CreateProjectInput { + remote: Some(RemoteRef::Wsl { + distro: "Ubuntu".to_owned(), + }), + ..input("Wsl", "/shared") + }; + env.create + .execute(remote_input) + .await + .expect("same root, different remote is allowed"); + + assert_eq!(env.store.list_projects().await.unwrap().len(), 2); +} + +#[tokio::test] +async fn create_rejects_non_absolute_root() { + let env = env(); + let err = env + .create + .execute(input("X", "relative/path")) + .await + .expect_err("non-absolute root rejected"); + assert_eq!(err.code(), "INVALID", "got {err:?}"); +} + +#[tokio::test] +async fn create_rejects_empty_name() { + let env = env(); + let err = env + .create + .execute(input("", "/abs")) + .await + .expect_err("empty name rejected"); + assert_eq!(err.code(), "INVALID", "got {err:?}"); +} + +#[tokio::test] +async fn create_propagates_store_error_code() { + let ids: Arc = Arc::new(SeqIds::new()); + let clock: Arc = Arc::new(FixedClock(0)); + let create = CreateProject::new( + Arc::new(BrokenStore), + Arc::new(FakeFs::default()), + ids, + clock, + Arc::new(SpyBus::default()), + ); + let err = create + .execute(input("X", "/abs")) + .await + .expect_err("store failure surfaces"); + assert_eq!(err.code(), "STORE", "got {err:?}"); +} + +// --------------------------------------------------------------------------- +// OpenProject — tolerant reads +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn open_loads_project_and_meta() { + let env = env(); + let created = env.create.execute(input("Demo", "/o/proj")).await.unwrap(); + + let out = env + .open + .execute(OpenProjectInput { + project_id: created.project.id, + }) + .await + .expect("open succeeds"); + + assert_eq!(out.project.id, created.project.id); + assert_eq!(out.project.root, created.project.root); + let meta = out.meta.expect("meta present (project.json was written)"); + assert_eq!(meta.id, created.project.id); + assert_eq!(meta.name, "Demo"); + // No agents.json was written → manifest tolerantly None. + assert!(out.manifest.is_none(), "agents.json absent → None"); +} + +#[tokio::test] +async fn open_unknown_project_is_not_found() { + let env = env(); + let err = env + .open + .execute(OpenProjectInput { + project_id: ProjectId::from_uuid(uuid::Uuid::from_u128(999)), + }) + .await + .expect_err("unknown id"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + +#[tokio::test] +async fn open_tolerates_missing_meta_file() { + // Register a project in the store WITHOUT writing any .ideai/ files. + let store = FakeStore::default(); + let fs = FakeFs::default(); + let id = ProjectId::from_uuid(uuid::Uuid::from_u128(7)); + let project = Project::new( + id, + "Orphan", + ProjectPath::new("/no/ideai").unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap(); + store.save_project(&project).await.unwrap(); + + let open = OpenProject::new(Arc::new(store), Arc::new(fs)); + let out = open + .execute(OpenProjectInput { project_id: id }) + .await + .expect("open does not fail on missing meta"); + assert!(out.meta.is_none(), "missing project.json → None"); + assert!(out.manifest.is_none(), "missing agents.json → None"); +} + +#[tokio::test] +async fn open_tolerates_corrupt_json() { + let store = FakeStore::default(); + let fs = FakeFs::default(); + let id = ProjectId::from_uuid(uuid::Uuid::from_u128(8)); + let project = Project::new( + id, + "Corrupt", + ProjectPath::new("/c/proj").unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap(); + store.save_project(&project).await.unwrap(); + // Write garbage at both .ideai/ paths. + fs.write( + &RemotePath::new("/c/proj/.ideai/project.json"), + b"{ not json ]", + ) + .await + .unwrap(); + fs.write( + &RemotePath::new("/c/proj/.ideai/agents.json"), + b"<<>>", + ) + .await + .unwrap(); + + let open = OpenProject::new(Arc::new(store), Arc::new(fs)); + let out = open + .execute(OpenProjectInput { project_id: id }) + .await + .expect("corrupt JSON does not fail the open"); + assert!(out.meta.is_none(), "corrupt project.json → None"); + assert!(out.manifest.is_none(), "corrupt agents.json → None"); +} + +// --------------------------------------------------------------------------- +// ListProjects +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn list_projects_returns_registered() { + let env = env(); + env.create.execute(input("A", "/a")).await.unwrap(); + env.create.execute(input("B", "/b")).await.unwrap(); + + let list = ListProjects::new(Arc::new(env.store.clone())); + let out = list.execute().await.unwrap(); + let names: Vec<&str> = out.projects.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(out.projects.len(), 2); + assert!(names.contains(&"A") && names.contains(&"B")); +} + +// --------------------------------------------------------------------------- +// CloseProject / CloseTab +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn close_persists_workspace() { + let store = FakeStore::default(); + let close = CloseProject::new(Arc::new(store.clone())); + let id = ProjectId::from_uuid(uuid::Uuid::from_u128(3)); + + let out = close + .execute(CloseProjectInput { + project_id: id, + workspace: Some(Workspace::default()), + }) + .await + .unwrap(); + + assert_eq!(out.project_id, id); + assert!(store.saved_workspace().is_some(), "workspace persisted"); +} + +#[tokio::test] +async fn close_without_workspace_skips_persistence() { + let store = FakeStore::default(); + let close = CloseProject::new(Arc::new(store.clone())); + let id = ProjectId::from_uuid(uuid::Uuid::from_u128(4)); + + close + .execute(CloseProjectInput { + project_id: id, + workspace: None, + }) + .await + .unwrap(); + + assert!(store.saved_workspace().is_none(), "no persistence when None"); +} + +#[tokio::test] +async fn close_tab_delegates_to_persistence() { + let store = FakeStore::default(); + let close_tab = CloseTab::new(Arc::new(store.clone())); + let id = ProjectId::from_uuid(uuid::Uuid::from_u128(5)); + + let out = close_tab + .execute(CloseTabInput { + project_id: id, + workspace: Some(Workspace::default()), + }) + .await + .unwrap(); + + assert_eq!(out.project_id, id); + assert!(store.saved_workspace().is_some(), "tab close persists too"); +} diff --git a/crates/application/tests/remote_usecases.rs b/crates/application/tests/remote_usecases.rs new file mode 100644 index 0000000..8bf0b88 --- /dev/null +++ b/crates/application/tests/remote_usecases.rs @@ -0,0 +1,158 @@ +//! L9 tests for [`ConnectRemote`] with a mock [`RemoteHost`]. The same use case +//! must behave identically whatever the host kind (Liskov), so we drive it with a +//! fake host parameterised by kind + root reachability. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::ports::{ + DirEntry, EventBus, EventStream, FileSystem, FsError, ProcessSpawner, PtyPort, RemoteError, + RemoteHost, RemotePath, +}; +use domain::{ProjectId, RemoteKind}; +use uuid::Uuid; + +use application::{ConnectRemote, ConnectRemoteInput}; + +// --- Fake filesystem (only `exists` matters here) ------------------------- + +struct FakeFs { + existing_root: Option, +} +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, p: &RemotePath) -> Result, FsError> { + Err(FsError::NotFound(p.as_str().to_owned())) + } + async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> { + Ok(()) + } + async fn exists(&self, p: &RemotePath) -> Result { + Ok(self.existing_root.as_deref() == Some(p.as_str())) + } + async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + async fn list(&self, _p: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +// --- Fake remote host ----------------------------------------------------- + +struct FakeHost { + kind: RemoteKind, + connect_ok: bool, + fs: Arc, +} +impl FakeHost { + fn make(kind: RemoteKind, connect_ok: bool, existing_root: Option<&str>) -> Arc { + Arc::new(Self { + kind, + connect_ok, + fs: Arc::new(FakeFs { + existing_root: existing_root.map(ToOwned::to_owned), + }), + }) + } +} +#[async_trait] +impl RemoteHost for FakeHost { + fn kind(&self) -> RemoteKind { + self.kind + } + async fn connect(&self) -> Result<(), RemoteError> { + if self.connect_ok { + Ok(()) + } else { + Err(RemoteError::Connection("refused".to_owned())) + } + } + fn file_system(&self) -> Arc { + Arc::clone(&self.fs) + } + fn process_spawner(&self) -> Arc { + unreachable!("ConnectRemote does not use the spawner") + } + fn pty(&self) -> Arc { + unreachable!("ConnectRemote does not use the pty") + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} +impl EventBus for SpyBus { + fn publish(&self, e: DomainEvent) { + self.0.lock().unwrap().push(e); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +fn pid() -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(1)) +} + +#[tokio::test] +async fn connect_succeeds_and_emits_event_for_any_host_kind() { + // Liskov: identical behaviour for Local, Ssh and Wsl hosts. + for kind in [RemoteKind::Local, RemoteKind::Ssh, RemoteKind::Wsl] { + let host = FakeHost::make(kind, true, Some("/srv/app")); + let bus = SpyBus::default(); + let out = ConnectRemote::new(Arc::new(bus.clone())) + .execute(ConnectRemoteInput { + host, + project_id: pid(), + root: "/srv/app".to_owned(), + }) + .await + .unwrap(); + assert_eq!(out.kind, kind); + assert_eq!( + bus.events(), + vec![DomainEvent::RemoteConnected { project_id: pid() }] + ); + } +} + +#[tokio::test] +async fn connect_propagates_connection_failure() { + let host = FakeHost::make(RemoteKind::Ssh, false, Some("/srv/app")); + let bus = SpyBus::default(); + let err = ConnectRemote::new(Arc::new(bus.clone())) + .execute(ConnectRemoteInput { + host, + project_id: pid(), + root: "/srv/app".to_owned(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "REMOTE", "got {err:?}"); + assert!(bus.events().is_empty()); +} + +#[tokio::test] +async fn connect_fails_when_root_unreachable() { + let host = FakeHost::make(RemoteKind::Local, true, Some("/other")); + let bus = SpyBus::default(); + let err = ConnectRemote::new(Arc::new(bus.clone())) + .execute(ConnectRemoteInput { + host, + project_id: pid(), + root: "/srv/app".to_owned(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); + assert!(bus.events().is_empty(), "no event when root is missing"); +} diff --git a/crates/application/tests/template_usecases.rs b/crates/application/tests/template_usecases.rs new file mode 100644 index 0000000..ef17eef --- /dev/null +++ b/crates/application/tests/template_usecases.rs @@ -0,0 +1,421 @@ +//! L7 tests for the template & synchronisation use cases, with in-memory port +//! fakes (no real store/FS): `CreateTemplate`, `UpdateTemplate`, +//! `CreateAgentFromTemplate`, `DetectAgentDrift`, `SyncAgentWithTemplate`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::ids::{AgentId, ProfileId, ProjectId, TemplateId}; +use domain::markdown::MarkdownDoc; +use domain::ports::{ + AgentContextStore, EventBus, EventStream, IdGenerator, StoreError, TemplateStore, +}; +use domain::template::{AgentTemplate, TemplateVersion}; +use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef}; +use uuid::Uuid; + +use application::{ + CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput, + DetectAgentDrift, DetectAgentDriftInput, SyncAgentWithTemplate, SyncAgentWithTemplateInput, + UpdateTemplate, UpdateTemplateInput, +}; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +#[derive(Clone, Default)] +struct FakeTemplates(Arc>>); +impl FakeTemplates { + fn with(templates: Vec) -> Self { + Self(Arc::new(Mutex::new(templates))) + } + fn get_sync(&self, id: TemplateId) -> Option { + self.0.lock().unwrap().iter().find(|t| t.id == id).cloned() + } +} +#[async_trait] +impl TemplateStore for FakeTemplates { + async fn list(&self) -> Result, StoreError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn get(&self, id: TemplateId) -> Result { + self.get_sync(id).ok_or(StoreError::NotFound) + } + async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> { + let mut v = self.0.lock().unwrap(); + if let Some(slot) = v.iter_mut().find(|t| t.id == template.id) { + *slot = template.clone(); + } else { + v.push(template.clone()); + } + Ok(()) + } + async fn delete(&self, id: TemplateId) -> Result<(), StoreError> { + let mut v = self.0.lock().unwrap(); + let before = v.len(); + v.retain(|t| t.id != id); + if v.len() == before { + return Err(StoreError::NotFound); + } + Ok(()) + } +} + +#[derive(Clone)] +struct FakeContexts(Arc)>>); +impl FakeContexts { + fn new(entries: Vec) -> Self { + Self(Arc::new(Mutex::new(( + AgentManifest { version: 1, entries }, + HashMap::new(), + )))) + } + fn manifest(&self) -> AgentManifest { + self.0.lock().unwrap().0.clone() + } + fn content(&self, md_path: &str) -> Option { + self.0.lock().unwrap().1.get(md_path).cloned() + } + fn md_path_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .0 + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.md_path.clone()) + } +} +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _p: &Project, + agent: &AgentId, + ) -> Result { + let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound) + } + async fn write_context( + &self, + _p: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0.lock().unwrap().1.insert(path, md.as_str().to_owned()); + Ok(()) + } + async fn load_manifest(&self, _p: &Project) -> Result { + Ok(self.manifest()) + } + async fn save_manifest(&self, _p: &Project, m: &AgentManifest) -> Result<(), StoreError> { + self.0.lock().unwrap().0 = m.clone(); + Ok(()) + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +struct SeqIds(Mutex); +impl SeqIds { + fn new() -> Self { + Self(Mutex::new(1)) + } +} +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +fn pid(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} +fn tid(n: u128) -> TemplateId { + TemplateId::from_uuid(Uuid::from_u128(n)) +} +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} +fn v(n: u64) -> TemplateVersion { + TemplateVersion(n) +} +fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1000)), + "demo", + ProjectPath::new("/home/me/demo").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} +fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTemplate { + let mut t = AgentTemplate::new(id, name, MarkdownDoc::new(content), pid(1)).unwrap(); + // Bump to the requested version by re-applying content updates. + while t.version.get() < version { + t = t.with_updated_content(MarkdownDoc::new(content)); + } + t +} +/// A synchronized, template-backed manifest entry synced at `synced`. +fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry { + ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap() +} + +// --------------------------------------------------------------------------- +// CreateTemplate / UpdateTemplate +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn create_template_starts_at_initial_version() { + let store = FakeTemplates::default(); + let out = CreateTemplate::new(Arc::new(store.clone()), Arc::new(SeqIds::new())) + .execute(CreateTemplateInput { + name: "Backend".to_owned(), + content: "# ctx".to_owned(), + default_profile_id: pid(7), + }) + .await + .unwrap(); + assert_eq!(out.template.version, TemplateVersion::INITIAL); + assert_eq!(out.template.default_profile_id, pid(7)); + assert_eq!(store.list().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn update_template_bumps_version_and_publishes_event() { + let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]); + let bus = SpyBus::default(); + let out = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(bus.clone())) + .execute(UpdateTemplateInput { + template_id: tid(1), + content: "v2".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(out.template.version.get(), 2); + assert_eq!(store.get_sync(tid(1)).unwrap().content_md.as_str(), "v2"); + assert_eq!( + bus.events(), + vec![DomainEvent::TemplateUpdated { + template_id: tid(1), + version: v(2), + }] + ); +} + +#[tokio::test] +async fn update_unknown_template_is_not_found() { + let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default())) + .execute(UpdateTemplateInput { + template_id: tid(404), + content: "x".to_owned(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + +// --------------------------------------------------------------------------- +// CreateAgentFromTemplate +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn create_agent_from_template_links_origin_and_seeds_context() { + let store = FakeTemplates::with(vec![template(tid(1), "Backend", "# body", 4)]); + let contexts = FakeContexts::new(vec![]); + let out = CreateAgentFromTemplate::new( + Arc::new(store), + Arc::new(contexts.clone()), + Arc::new(SeqIds::new()), + Arc::new(SpyBus::default()), + ) + .execute(CreateAgentFromTemplateInput { + project: project(), + template_id: tid(1), + name: None, + synchronized: true, + }) + .await + .unwrap(); + + // Name defaults to the template name; profile = template default. + assert_eq!(out.agent.name, "Backend"); + assert_eq!(out.agent.profile_id, pid(1)); + assert!(out.agent.synchronized); + assert_eq!( + out.agent.origin, + domain::AgentOrigin::FromTemplate { + template_id: tid(1), + synced_template_version: v(4), + } + ); + // Context seeded with the template content under the agent's md path. + assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body")); + assert_eq!(contexts.manifest().entries.len(), 1); +} + +// --------------------------------------------------------------------------- +// DetectAgentDrift +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn detect_drift_flags_only_synchronized_agents_behind() { + // Template at v3. + let store = FakeTemplates::with(vec![template(tid(1), "T", "v3", 3)]); + // a1: synchronized, synced at v1 → drift (1→3). + // a2: synchronized, synced at v3 → up to date, no drift. + // a3: from template but NOT synchronized → ignored. + // a4: scratch (no template) → ignored. + let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1))) + .unwrap(); + let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap(); + let contexts = FakeContexts::new(vec![ + synced_entry(aid(1), "agents/a1.md", tid(1), 1), + synced_entry(aid(2), "agents/a2.md", tid(1), 3), + a3, + a4, + ]); + let bus = SpyBus::default(); + + let out = DetectAgentDrift::new( + Arc::new(store), + Arc::new(contexts), + Arc::new(bus.clone()), + ) + .execute(DetectAgentDriftInput { project: project() }) + .await + .unwrap(); + + assert_eq!(out.drifts.len(), 1, "only a1 drifts"); + assert_eq!(out.drifts[0].agent_id, aid(1)); + assert_eq!(out.drifts[0].from, v(1)); + assert_eq!(out.drifts[0].to, v(3)); + assert_eq!( + bus.events(), + vec![DomainEvent::AgentDriftDetected { + agent_id: aid(1), + from: v(1), + to: v(3), + }] + ); +} + +#[tokio::test] +async fn detect_drift_ignores_deleted_template() { + // No templates in the store, but an agent references tid(1): not an error. + let store = FakeTemplates::default(); + let contexts = FakeContexts::new(vec![synced_entry(aid(1), "agents/a1.md", tid(1), 1)]); + let out = DetectAgentDrift::new( + Arc::new(store), + Arc::new(contexts), + Arc::new(SpyBus::default()), + ) + .execute(DetectAgentDriftInput { project: project() }) + .await + .unwrap(); + assert!(out.drifts.is_empty()); +} + +// --------------------------------------------------------------------------- +// SyncAgentWithTemplate +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn sync_applies_to_synchronized_and_updates_version_and_context() { + let store = FakeTemplates::with(vec![template(tid(1), "T", "newest body", 3)]); + let contexts = FakeContexts::new(vec![synced_entry(aid(1), "agents/a1.md", tid(1), 1)]); + // Seed an old context so we can see the replacement. + contexts + .write_context(&project(), &aid(1), &MarkdownDoc::new("old")) + .await + .unwrap(); + let bus = SpyBus::default(); + + let out = SyncAgentWithTemplate::new( + Arc::new(store), + Arc::new(contexts.clone()), + Arc::new(bus.clone()), + ) + .execute(SyncAgentWithTemplateInput { + project: project(), + agent_id: aid(1), + }) + .await + .unwrap(); + + assert!(out.synced); + assert_eq!(out.version, Some(v(3))); + // Context replaced by the template content. + assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body")); + // Manifest synced version advanced to 3. + let entry = &contexts.manifest().entries[0]; + assert_eq!(entry.synced_template_version, Some(v(3))); + assert_eq!( + bus.events(), + vec![DomainEvent::AgentSynced { + agent_id: aid(1), + to: v(3), + }] + ); +} + +#[tokio::test] +async fn sync_ignores_non_synchronized_agent() { + let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]); + // Non-synchronized agent from a template. + let entry = + ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1))) + .unwrap(); + let contexts = FakeContexts::new(vec![entry]); + contexts + .write_context(&project(), &aid(1), &MarkdownDoc::new("keep me")) + .await + .unwrap(); + let bus = SpyBus::default(); + + let out = SyncAgentWithTemplate::new( + Arc::new(store), + Arc::new(contexts.clone()), + Arc::new(bus.clone()), + ) + .execute(SyncAgentWithTemplateInput { + project: project(), + agent_id: aid(1), + }) + .await + .unwrap(); + + assert!(!out.synced, "non-synchronized agent is left untouched"); + assert_eq!(out.version, None); + assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me")); + assert!(bus.events().is_empty(), "no sync event for an ignored agent"); +} diff --git a/crates/application/tests/terminal_usecases.rs b/crates/application/tests/terminal_usecases.rs new file mode 100644 index 0000000..5ea669e --- /dev/null +++ b/crates/application/tests/terminal_usecases.rs @@ -0,0 +1,535 @@ +//! L3 tests for the terminal use cases (`OpenTerminal`, `WriteToTerminal`, +//! `ResizeTerminal`, `CloseTerminal`) and the [`TerminalSessions`] registry. +//! +//! Every port is faked in-memory so the use cases run without any real PTY: +//! - [`FakePty`] — a recording [`PtyPort`] that mints a deterministic +//! [`SessionId`] on `spawn` and records every `write`/`resize`/`kill`, +//! - [`SpyBus`] — records published [`DomainEvent`]s. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::ports::{ + EventBus, EventStream, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec, +}; +use domain::{PtySize, SessionId}; + +use application::{ + CloseTerminal, CloseTerminalInput, OpenTerminal, OpenTerminalInput, ResizeTerminal, + ResizeTerminalInput, TerminalSessions, WriteToTerminal, WriteToTerminalInput, +}; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// One recorded PTY call. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Call { + Spawn { spec: SpawnSpec, size: PtySize }, + Write { id: SessionId, data: Vec }, + Resize { id: SessionId, size: PtySize }, + Kill { id: SessionId }, +} + +#[derive(Default)] +struct FakePtyInner { + calls: Vec, + /// SessionId the next `spawn` will mint (defaults to random). + next_id: Option, + /// Exit code the next `kill` will report. + kill_code: Option, + /// When set, `write`/`resize` fail to exercise error propagation. + fail_io: bool, +} + +/// A recording [`PtyPort`]: no real OS PTY, just bookkeeping. +#[derive(Default, Clone)] +struct FakePty(Arc>); + +impl FakePty { + fn with_next_id(id: SessionId) -> Self { + let pty = Self::default(); + pty.0.lock().unwrap().next_id = Some(id); + pty + } + fn calls(&self) -> Vec { + self.0.lock().unwrap().calls.clone() + } + fn set_kill_code(&self, code: Option) { + self.0.lock().unwrap().kill_code = code; + } + fn set_fail_io(&self, fail: bool) { + self.0.lock().unwrap().fail_io = fail; + } +} + +#[async_trait] +impl PtyPort for FakePty { + async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result { + let mut inner = self.0.lock().unwrap(); + inner.calls.push(Call::Spawn { spec, size }); + let session_id = inner.next_id.unwrap_or_else(SessionId::new_random); + Ok(PtyHandle { session_id }) + } + + fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { + let mut inner = self.0.lock().unwrap(); + if inner.fail_io { + return Err(PtyError::Io("boom".to_owned())); + } + inner.calls.push(Call::Write { + id: handle.session_id, + data: data.to_vec(), + }); + Ok(()) + } + + fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> { + let mut inner = self.0.lock().unwrap(); + if inner.fail_io { + return Err(PtyError::Io("boom".to_owned())); + } + inner.calls.push(Call::Resize { + id: handle.session_id, + size, + }); + Ok(()) + } + + fn subscribe_output(&self, _handle: &PtyHandle) -> Result { + Ok(Box::new(std::iter::empty())) + } + + async fn kill(&self, handle: &PtyHandle) -> Result { + let mut inner = self.0.lock().unwrap(); + inner.calls.push(Call::Kill { + id: handle.session_id, + }); + Ok(ExitStatus { + code: inner.kill_code, + }) + } +} + +/// Records published events. +#[derive(Default, Clone)] +struct SpyBus(Arc>>); + +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} + +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +fn sid(n: u128) -> SessionId { + SessionId::from_uuid(uuid::Uuid::from_u128(n)) +} + +fn open_input(cwd: &str) -> OpenTerminalInput { + OpenTerminalInput { + cwd: cwd.to_owned(), + rows: 24, + cols: 80, + command: None, + args: Vec::new(), + node_id: None, + } +} + +// --------------------------------------------------------------------------- +// OpenTerminal +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn open_spawns_with_resolved_spec_and_size() { + let pty = FakePty::with_next_id(sid(42)); + let sessions = Arc::new(TerminalSessions::new()); + let bus = SpyBus::default(); + let open = OpenTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + Arc::new(bus.clone()), + ); + + let input = OpenTerminalInput { + command: Some("/bin/zsh".to_owned()), + args: vec!["-l".to_owned()], + ..open_input("/home/me/proj") + }; + let out = open.execute(input).await.expect("open succeeds"); + + // The session adopts the PTY-minted id. + assert_eq!(out.session.id, sid(42)); + + let calls = pty.calls(); + assert_eq!(calls.len(), 1, "exactly one spawn"); + match &calls[0] { + Call::Spawn { spec, size } => { + assert_eq!(spec.command, "/bin/zsh"); + assert_eq!(spec.args, vec!["-l".to_owned()]); + assert_eq!(spec.cwd.as_str(), "/home/me/proj"); + assert_eq!(*size, PtySize::new(24, 80).unwrap()); + } + other => panic!("expected spawn, got {other:?}"), + } +} + +#[tokio::test] +async fn open_defaults_command_when_none() { + let pty = FakePty::default(); + let open = OpenTerminal::new( + Arc::new(pty.clone()), + Arc::new(TerminalSessions::new()), + Arc::new(SpyBus::default()), + ); + open.execute(open_input("/p")).await.unwrap(); + + match &pty.calls()[0] { + Call::Spawn { spec, .. } => assert!( + !spec.command.is_empty(), + "a default shell command is filled in" + ), + other => panic!("expected spawn, got {other:?}"), + } +} + +#[tokio::test] +async fn open_registers_session_in_registry() { + let pty = FakePty::with_next_id(sid(7)); + let sessions = Arc::new(TerminalSessions::new()); + let open = OpenTerminal::new( + Arc::new(pty), + Arc::clone(&sessions), + Arc::new(SpyBus::default()), + ); + + assert!(sessions.is_empty()); + open.execute(open_input("/p")).await.unwrap(); + + assert_eq!(sessions.len(), 1); + assert!(sessions.handle(&sid(7)).is_some(), "handle registered"); + assert!(sessions.session(&sid(7)).is_some(), "snapshot registered"); +} + +#[tokio::test] +async fn open_publishes_pty_output_open_event() { + let pty = FakePty::with_next_id(sid(9)); + let bus = SpyBus::default(); + let open = OpenTerminal::new( + Arc::new(pty), + Arc::new(TerminalSessions::new()), + Arc::new(bus.clone()), + ); + open.execute(open_input("/p")).await.unwrap(); + + assert_eq!( + bus.events(), + vec![DomainEvent::PtyOutput { + session_id: sid(9), + bytes: Vec::new(), + }] + ); +} + +#[tokio::test] +async fn open_rejects_non_absolute_cwd() { + let open = OpenTerminal::new( + Arc::new(FakePty::default()), + Arc::new(TerminalSessions::new()), + Arc::new(SpyBus::default()), + ); + let err = open + .execute(open_input("relative/path")) + .await + .expect_err("non-absolute cwd rejected"); + assert_eq!(err.code(), "INVALID", "got {err:?}"); +} + +#[tokio::test] +async fn open_rejects_zero_sized_terminal() { + let pty = FakePty::default(); + let open = OpenTerminal::new( + Arc::new(pty.clone()), + Arc::new(TerminalSessions::new()), + Arc::new(SpyBus::default()), + ); + let err = open + .execute(OpenTerminalInput { + rows: 0, + ..open_input("/p") + }) + .await + .expect_err("zero-sized terminal rejected"); + assert_eq!(err.code(), "INVALID", "got {err:?}"); + assert!(pty.calls().is_empty(), "must not spawn on invalid size"); +} + +// --------------------------------------------------------------------------- +// WriteToTerminal +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn write_routes_bytes_to_the_right_session() { + let pty = FakePty::with_next_id(sid(1)); + let sessions = Arc::new(TerminalSessions::new()); + let open = OpenTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + Arc::new(SpyBus::default()), + ); + open.execute(open_input("/p")).await.unwrap(); + + let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); + write + .execute(WriteToTerminalInput { + session_id: sid(1), + data: b"ls\n".to_vec(), + }) + .expect("write succeeds"); + + let writes: Vec<_> = pty + .calls() + .into_iter() + .filter(|c| matches!(c, Call::Write { .. })) + .collect(); + assert_eq!( + writes, + vec![Call::Write { + id: sid(1), + data: b"ls\n".to_vec(), + }] + ); +} + +#[tokio::test] +async fn write_to_unknown_session_is_not_found() { + let pty = FakePty::default(); + let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::new(TerminalSessions::new())); + let err = write + .execute(WriteToTerminalInput { + session_id: sid(404), + data: b"x".to_vec(), + }) + .expect_err("unknown session rejected"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); + assert!(pty.calls().is_empty(), "no PTY call for unknown session"); +} + +#[tokio::test] +async fn write_propagates_pty_io_error() { + let pty = FakePty::with_next_id(sid(2)); + let sessions = Arc::new(TerminalSessions::new()); + OpenTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + Arc::new(SpyBus::default()), + ) + .execute(open_input("/p")) + .await + .unwrap(); + + pty.set_fail_io(true); + let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); + let err = write + .execute(WriteToTerminalInput { + session_id: sid(2), + data: b"x".to_vec(), + }) + .expect_err("io failure surfaces"); + assert_eq!(err.code(), "PROCESS", "got {err:?}"); +} + +// --------------------------------------------------------------------------- +// ResizeTerminal +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn resize_calls_pty_with_new_size() { + let pty = FakePty::with_next_id(sid(3)); + let sessions = Arc::new(TerminalSessions::new()); + OpenTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + Arc::new(SpyBus::default()), + ) + .execute(open_input("/p")) + .await + .unwrap(); + + let resize = ResizeTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); + resize + .execute(ResizeTerminalInput { + session_id: sid(3), + rows: 40, + cols: 120, + }) + .expect("resize succeeds"); + + let resizes: Vec<_> = pty + .calls() + .into_iter() + .filter(|c| matches!(c, Call::Resize { .. })) + .collect(); + assert_eq!( + resizes, + vec![Call::Resize { + id: sid(3), + size: PtySize::new(40, 120).unwrap(), + }] + ); +} + +#[tokio::test] +async fn resize_unknown_session_is_not_found() { + let resize = ResizeTerminal::new( + Arc::new(FakePty::default()), + Arc::new(TerminalSessions::new()), + ); + let err = resize + .execute(ResizeTerminalInput { + session_id: sid(404), + rows: 40, + cols: 120, + }) + .expect_err("unknown session rejected"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + +#[tokio::test] +async fn resize_rejects_zero_size() { + let resize = ResizeTerminal::new( + Arc::new(FakePty::default()), + Arc::new(TerminalSessions::new()), + ); + let err = resize + .execute(ResizeTerminalInput { + session_id: sid(1), + rows: 0, + cols: 80, + }) + .expect_err("zero size rejected"); + assert_eq!(err.code(), "INVALID", "got {err:?}"); +} + +// --------------------------------------------------------------------------- +// CloseTerminal +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn close_kills_pty_removes_session_and_returns_code() { + let pty = FakePty::with_next_id(sid(5)); + pty.set_kill_code(Some(0)); + let sessions = Arc::new(TerminalSessions::new()); + OpenTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + Arc::new(SpyBus::default()), + ) + .execute(open_input("/p")) + .await + .unwrap(); + assert_eq!(sessions.len(), 1); + + let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); + let out = close + .execute(CloseTerminalInput { + session_id: sid(5), + }) + .await + .expect("close succeeds"); + + assert_eq!(out.code, Some(0)); + assert!(sessions.is_empty(), "session removed from registry"); + assert!( + pty.calls().iter().any(|c| matches!(c, Call::Kill { id } if *id == sid(5))), + "kill called for the session" + ); +} + +#[tokio::test] +async fn close_surfaces_signal_exit_as_none_code() { + let pty = FakePty::with_next_id(sid(6)); + pty.set_kill_code(None); + let sessions = Arc::new(TerminalSessions::new()); + OpenTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + Arc::new(SpyBus::default()), + ) + .execute(open_input("/p")) + .await + .unwrap(); + + let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); + let out = close + .execute(CloseTerminalInput { + session_id: sid(6), + }) + .await + .unwrap(); + assert_eq!(out.code, None); +} + +#[tokio::test] +async fn close_unknown_session_is_not_found() { + let pty = FakePty::default(); + let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::new(TerminalSessions::new())); + let err = close + .execute(CloseTerminalInput { + session_id: sid(404), + }) + .await + .expect_err("unknown session rejected"); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); + assert!(pty.calls().is_empty(), "no kill for unknown session"); +} + +// --------------------------------------------------------------------------- +// TerminalSessions registry (unit-level) +// --------------------------------------------------------------------------- + +#[test] +fn registry_insert_handle_session_remove_len() { + use domain::{NodeId, ProjectPath, SessionKind, TerminalSession}; + + let registry = TerminalSessions::new(); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); + + let id = sid(100); + let handle = PtyHandle { session_id: id }; + let session = TerminalSession::starting( + id, + NodeId::new_random(), + ProjectPath::new("/p").unwrap(), + SessionKind::Plain, + PtySize::new(24, 80).unwrap(), + ); + registry.insert(handle.clone(), session); + + assert_eq!(registry.len(), 1); + assert!(!registry.is_empty()); + assert_eq!(registry.handle(&id), Some(handle.clone())); + assert!(registry.session(&id).is_some()); + assert_eq!(registry.session(&id).unwrap().id, id); + + // Unknown id resolves to None. + assert!(registry.handle(&sid(999)).is_none()); + assert!(registry.session(&sid(999)).is_none()); + + // Remove returns the handle and empties the registry. + assert_eq!(registry.remove(&id), Some(handle)); + assert!(registry.is_empty()); + assert!(registry.remove(&id).is_none(), "second remove is a no-op"); +} diff --git a/crates/application/tests/window_usecases.rs b/crates/application/tests/window_usecases.rs new file mode 100644 index 0000000..52db7d9 --- /dev/null +++ b/crates/application/tests/window_usecases.rs @@ -0,0 +1,111 @@ +//! L10 tests for [`MoveTabToNewWindow`] with a fake [`ProjectStore`]: the tab is +//! detached and the workspace is persisted (load returns the new state). + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::ids::{ProjectId, TabId, WindowId}; +use domain::layout::{LayoutNode, LayoutTree, LeafCell, Tab, Window, Workspace}; +use domain::ports::{IdGenerator, ProjectStore, StoreError}; +use domain::project::Project; +use domain::NodeId; +use uuid::Uuid; + +use application::{MoveTabToNewWindow, MoveTabToNewWindowInput}; + +/// A `ProjectStore` fake that only implements the workspace persistence the use +/// case needs (the project methods are never called here). +#[derive(Clone)] +struct FakeStore(Arc>); +#[async_trait] +impl ProjectStore for FakeStore { + async fn list_projects(&self) -> Result, StoreError> { + unreachable!() + } + async fn load_project(&self, _id: ProjectId) -> Result { + unreachable!() + } + async fn save_project(&self, _p: &Project) -> Result<(), StoreError> { + unreachable!() + } + async fn save_workspace(&self, ws: &Workspace) -> Result<(), StoreError> { + *self.0.lock().unwrap() = ws.clone(); + Ok(()) + } + async fn load_workspace(&self) -> Result { + Ok(self.0.lock().unwrap().clone()) + } +} + +struct SeqIds(Mutex); +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } +} + +fn tid(n: u128) -> TabId { + TabId::from_uuid(Uuid::from_u128(n)) +} +fn wid(n: u128) -> WindowId { + WindowId::from_uuid(Uuid::from_u128(n)) +} +fn tab(n: u128) -> Tab { + Tab { + id: tid(n), + project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)), + layout: LayoutTree::new(LayoutNode::Leaf(LeafCell { + id: NodeId::from_uuid(Uuid::from_u128(900 + n)), + session: None, + agent: None, + })), + } +} + +fn seeded() -> FakeStore { + let ws = Workspace { + windows: vec![Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap()], + }; + FakeStore(Arc::new(Mutex::new(ws))) +} + +#[tokio::test] +async fn detaches_tab_and_persists_workspace() { + let store = seeded(); + // The id generator's first uuid (from_u128(7)) becomes the new window id. + let ids = Arc::new(SeqIds(Mutex::new(7))); + let uc = MoveTabToNewWindow::new(Arc::new(store.clone()), ids); + + let out = uc + .execute(MoveTabToNewWindowInput { tab_id: tid(1) }) + .await + .unwrap(); + + assert_eq!(out.new_window_id, WindowId::from_uuid(Uuid::from_u128(7))); + assert_eq!(out.workspace.windows.len(), 2); + + // Persisted: reloading the store yields the detached layout. + let reloaded = store.load_workspace().await.unwrap(); + assert_eq!(reloaded, out.workspace); + let detached = reloaded + .windows + .iter() + .find(|w| w.id == out.new_window_id) + .unwrap(); + assert_eq!(detached.tabs.len(), 1); + assert_eq!(detached.tabs[0].id, tid(1)); +} + +#[tokio::test] +async fn unknown_tab_is_not_found() { + let store = seeded(); + let uc = MoveTabToNewWindow::new(Arc::new(store), Arc::new(SeqIds(Mutex::new(7)))); + let err = uc + .execute(MoveTabToNewWindowInput { tab_id: tid(404) }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..b9c2df0 --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "domain" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "IdeA — pure domain layer: entities, value objects, ports (traits), domain events, layout logic. Zero I/O." + +[dependencies] +uuid = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/domain/src/agent.rs b/crates/domain/src/agent.rs new file mode 100644 index 0000000..a8a80bc --- /dev/null +++ b/crates/domain/src/agent.rs @@ -0,0 +1,257 @@ +//! Agent entity, its origin and the project agent manifest. + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; +use crate::ids::{AgentId, ProfileId, TemplateId}; +use crate::template::TemplateVersion; + +/// Origin of an agent: created from scratch, or derived from a template. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum AgentOrigin { + /// Created from scratch; no template link. + Scratch, + /// Derived from a template, tracking the last synced version. + #[serde(rename_all = "camelCase")] + FromTemplate { + /// Source template. + template_id: TemplateId, + /// Template version recorded at the last successful sync. + synced_template_version: TemplateVersion, + }, +} + +impl AgentOrigin { + /// Returns the source template id, if any. + #[must_use] + pub fn template_id(&self) -> Option { + match self { + Self::Scratch => None, + Self::FromTemplate { template_id, .. } => Some(*template_id), + } + } + + /// Returns `true` if this origin is a template. + #[must_use] + pub fn is_from_template(&self) -> bool { + matches!(self, Self::FromTemplate { .. }) + } +} + +/// A project-scoped agent. +/// +/// Invariants enforced here: +/// - `name` non-empty, +/// - `context_path` is a relative, safe path (the `.md` lives under `.ideai/`), +/// - `synchronized == true` ⇒ `origin == FromTemplate { .. }`. +/// +/// Note: "`context` must exist at activation" and "`profile_id` must reference a +/// known profile" are *runtime/cross-aggregate* invariants checked by the +/// application layer (they require I/O or the profile registry), not by this +/// pure constructor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Agent { + /// Stable identifier. + pub id: AgentId, + /// Display name. + pub name: String, + /// Relative path of the agent's `.md` within `.ideai/` (e.g. `agents/foo.md`). + pub context_path: String, + /// Runtime profile reference. + pub profile_id: ProfileId, + /// Origin of the agent. + pub origin: AgentOrigin, + /// Whether the agent tracks its template (only valid for template origins). + pub synchronized: bool, +} + +impl Agent { + /// Builds a validated agent. + /// + /// # Errors + /// - [`DomainError::EmptyField`] if `name` is empty, + /// - [`DomainError::PathNotRelativeSafe`] if `context_path` is absolute or + /// contains `..`, + /// - [`DomainError::SyncRequiresTemplate`] if `synchronized` is `true` while + /// `origin` is [`AgentOrigin::Scratch`]. + pub fn new( + id: AgentId, + name: impl Into, + context_path: impl Into, + profile_id: ProfileId, + origin: AgentOrigin, + synchronized: bool, + ) -> Result { + let name = name.into(); + let context_path = context_path.into(); + crate::validation::non_empty(&name, "agent.name")?; + crate::validation::relative_safe(&context_path)?; + if synchronized && !origin.is_from_template() { + return Err(DomainError::SyncRequiresTemplate); + } + Ok(Self { + id, + name, + context_path, + profile_id, + origin, + synchronized, + }) + } +} + +/// One entry in the project agent manifest (`.ideai/agents.json`). +/// +/// This is the **persisted form of an [`Agent`]** (ARCHITECTURE §9.1): the +/// manifest is the source of truth for a project's agents, so each entry carries +/// everything needed to reconstruct the agent — its `name` and `profile_id` +/// included (without them the IDE could not list agents or resolve the profile to +/// launch). The template link is kept flat (`template_id` + +/// `synced_template_version`) for a compact on-disk shape; [`to_agent`] folds it +/// back into an [`AgentOrigin`]. +/// +/// Invariants: +/// - `name` non-empty, +/// - `md_path` relative and safe, +/// - `synchronized == true` ⇒ `template_id.is_some() && synced_template_version.is_some()`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManifestEntry { + /// The agent this entry describes. + pub agent_id: AgentId, + /// Display name of the agent. + pub name: String, + /// Relative path of the agent's `.md`. + pub md_path: String, + /// Runtime profile reference. + pub profile_id: ProfileId, + /// Source template, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template_id: Option, + /// Whether the agent tracks its template. + pub synchronized: bool, + /// Template version recorded at the last sync. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub synced_template_version: Option, +} + +impl ManifestEntry { + /// Builds a validated manifest entry. + /// + /// # Errors + /// - [`DomainError::EmptyField`] if `name` is empty, + /// - [`DomainError::PathNotRelativeSafe`] if `md_path` is absolute or has `..`, + /// - [`DomainError::InconsistentManifest`] if `synchronized` is `true` while + /// `template_id` or `synced_template_version` is missing. + pub fn new( + agent_id: AgentId, + name: impl Into, + md_path: impl Into, + profile_id: ProfileId, + template_id: Option, + synchronized: bool, + synced_template_version: Option, + ) -> Result { + let name = name.into(); + let md_path = md_path.into(); + crate::validation::non_empty(&name, "manifestEntry.name")?; + crate::validation::relative_safe(&md_path)?; + if synchronized && (template_id.is_none() || synced_template_version.is_none()) { + return Err(DomainError::InconsistentManifest { + reason: "synchronized entry requires templateId and syncedTemplateVersion" + .to_string(), + }); + } + Ok(Self { + agent_id, + name, + md_path, + profile_id, + template_id, + synchronized, + synced_template_version, + }) + } + + /// Projects an [`Agent`] into its manifest entry (flattening the origin). + /// + /// Infallible: an [`Agent`] is already validated, and every entry invariant + /// is implied by the agent's invariants. + #[must_use] + pub fn from_agent(agent: &Agent) -> Self { + let (template_id, synced_template_version) = match &agent.origin { + AgentOrigin::Scratch => (None, None), + AgentOrigin::FromTemplate { + template_id, + synced_template_version, + } => (Some(*template_id), Some(*synced_template_version)), + }; + Self { + agent_id: agent.id, + name: agent.name.clone(), + md_path: agent.context_path.clone(), + profile_id: agent.profile_id, + template_id, + synchronized: agent.synchronized, + synced_template_version, + } + } + + /// Reconstructs the validated [`Agent`] this entry persists, folding the flat + /// template link back into an [`AgentOrigin`]. + /// + /// # Errors + /// Returns a [`DomainError`] if the persisted fields violate an [`Agent`] + /// invariant (e.g. a synchronized entry whose origin is not a template). + pub fn to_agent(&self) -> Result { + let origin = match (self.template_id, self.synced_template_version) { + (Some(template_id), Some(synced_template_version)) => AgentOrigin::FromTemplate { + template_id, + synced_template_version, + }, + _ => AgentOrigin::Scratch, + }; + Agent::new( + self.agent_id, + self.name.clone(), + self.md_path.clone(), + self.profile_id, + origin, + self.synchronized, + ) + } +} + +/// In-memory image of `.ideai/agents.json`. +/// +/// Invariant enforced here: `md_path` values are unique across entries. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentManifest { + /// Schema version of the manifest file. + pub version: u32, + /// Entries (one per project agent). + #[serde(rename = "agents")] + pub entries: Vec, +} + +impl AgentManifest { + /// Builds a validated manifest. + /// + /// # Errors + /// Returns [`DomainError::InconsistentManifest`] if two entries share the + /// same `md_path`. + pub fn new(version: u32, entries: Vec) -> Result { + let mut seen = std::collections::HashSet::new(); + for entry in &entries { + if !seen.insert(entry.md_path.as_str()) { + return Err(DomainError::InconsistentManifest { + reason: format!("duplicate md_path `{}`", entry.md_path), + }); + } + } + Ok(Self { version, entries }) + } +} diff --git a/crates/domain/src/error.rs b/crates/domain/src/error.rs new file mode 100644 index 0000000..65b146c --- /dev/null +++ b/crates/domain/src/error.rs @@ -0,0 +1,71 @@ +//! Domain-level validation errors. +//! +//! These errors are raised by validating constructors (`new`/`try_new`) when an +//! invariant documented in `ARCHITECTURE.md` §3.2 is violated. They are pure +//! data — no I/O, no platform coupling. + +use thiserror::Error; + +/// Error raised when an entity or value object invariant is violated at +/// construction time. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum DomainError { + /// A required string field was empty. + #[error("field `{field}` must not be empty")] + EmptyField { + /// Name of the offending field. + field: &'static str, + }, + + /// A path that must be absolute was relative. + #[error("path `{path}` must be absolute")] + PathNotAbsolute { + /// The offending path. + path: String, + }, + + /// A path that must be relative was absolute, or escaped its root via `..`. + #[error("path `{path}` must be relative and must not contain `..`")] + PathNotRelativeSafe { + /// The offending path. + path: String, + }, + + /// An environment variable name was not a valid identifier. + #[error("`{value}` is not a valid environment variable identifier")] + InvalidEnvVar { + /// The offending value. + value: String, + }, + + /// An SSH port was outside the valid `1..=65535` range. + #[error("ssh port must be in 1..=65535, got {port}")] + InvalidPort { + /// The offending port. + port: u32, + }, + + /// A PTY dimension was zero. + #[error("pty size must have rows>0 and cols>0, got rows={rows} cols={cols}")] + InvalidPtySize { + /// Requested rows. + rows: u16, + /// Requested cols. + cols: u16, + }, + + /// `synchronized == true` requires an agent originating from a template. + #[error("a synchronized agent must originate from a template")] + SyncRequiresTemplate, + + /// A manifest entry was inconsistent (e.g. synchronized without template metadata). + #[error("manifest entry inconsistent: {reason}")] + InconsistentManifest { + /// Human-readable reason. + reason: String, + }, + + /// A generic invariant violation with an explanatory message. + #[error("invariant violated: {0}")] + Invariant(String), +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs new file mode 100644 index 0000000..3c5f501 --- /dev/null +++ b/crates/domain/src/events.rs @@ -0,0 +1,79 @@ +//! Domain events published on the [`crate::ports::EventBus`] and relayed to the +//! presentation layer (ARCHITECTURE §3.2). + +use crate::ids::{AgentId, ProjectId, SessionId, TemplateId}; +use crate::template::TemplateVersion; + +/// Events emitted by the domain/application as state changes occur. +/// +/// Deliberately *not* `Serialize`/`Deserialize`: events are an in-process +/// concern relayed to IPC by an infrastructure adapter, which owns the wire +/// format. `PtyOutput` in particular is usually short-circuited to a Tauri +/// channel rather than serialised here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DomainEvent { + /// A project was created. + ProjectCreated { + /// The new project. + project_id: ProjectId, + }, + /// An agent was launched in a terminal. + AgentLaunched { + /// The agent. + agent_id: AgentId, + /// The session it runs in. + session_id: SessionId, + }, + /// An agent's process exited. + AgentExited { + /// The agent. + agent_id: AgentId, + /// Exit code. + code: i32, + }, + /// A template was updated (content changed, version bumped). + TemplateUpdated { + /// The template. + template_id: TemplateId, + /// New version. + version: TemplateVersion, + }, + /// A synchronized agent is behind its template. + AgentDriftDetected { + /// The drifting agent. + agent_id: AgentId, + /// Version the agent is currently at. + from: TemplateVersion, + /// Version available from the template. + to: TemplateVersion, + }, + /// A synchronized agent received its template update. + AgentSynced { + /// The agent. + agent_id: AgentId, + /// Version it was brought up to. + to: TemplateVersion, + }, + /// A tab's layout changed. + LayoutChanged { + /// The project whose layout changed. + project_id: ProjectId, + }, + /// A remote host connection was established. + RemoteConnected { + /// The project on that remote. + project_id: ProjectId, + }, + /// Git state for a project changed. + GitStateChanged { + /// The project. + project_id: ProjectId, + }, + /// Raw PTY output (usually routed to a dedicated channel, not this bus). + PtyOutput { + /// The session. + session_id: SessionId, + /// Output bytes. + bytes: Vec, + }, +} diff --git a/crates/domain/src/git.rs b/crates/domain/src/git.rs new file mode 100644 index 0000000..65ceb95 --- /dev/null +++ b/crates/domain/src/git.rs @@ -0,0 +1,45 @@ +//! Git repository entity (domain state image). +//! +//! This is the *entity* describing the derived git state of a project. The +//! git **port** (operations) lives in [`crate::ports`]. + +use serde::{Deserialize, Serialize}; + +use crate::ids::ProjectId; +use crate::project::ProjectPath; + +/// Derived git state for a project, refreshed via the git port. +/// +/// Invariant (operational, not enforced here): `root` contains — or will +/// contain after `init` — a `.git` directory. This is verified by the +/// infrastructure adapter, not by the pure domain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitRepository { + /// Owning project. + pub project_id: ProjectId, + /// Repository root. + pub root: ProjectPath, + /// Current branch name, if the repo is on a branch. + pub current_branch: Option, + /// Whether the working tree has uncommitted changes. + pub is_dirty: bool, +} + +impl GitRepository { + /// Builds a git-state snapshot. + #[must_use] + pub fn new( + project_id: ProjectId, + root: ProjectPath, + current_branch: Option, + is_dirty: bool, + ) -> Self { + Self { + project_id, + root, + current_branch, + is_dirty, + } + } +} diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs new file mode 100644 index 0000000..cf2260f --- /dev/null +++ b/crates/domain/src/ids.rs @@ -0,0 +1,90 @@ +//! Strongly-typed identifiers. +//! +//! Each identifier is a `newtype` around [`uuid::Uuid`]. Using distinct types +//! per concept makes it impossible to pass, say, an [`AgentId`] where a +//! [`ProjectId`] is expected (compile-time safety, SOLID/typing discipline). + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +macro_rules! typed_id { + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub Uuid); + + impl $name { + /// Wraps an existing [`Uuid`]. + #[must_use] + pub const fn from_uuid(id: Uuid) -> Self { + Self(id) + } + + /// Generates a fresh random (v4) identifier. + /// + /// Prefer injecting an [`crate::ports::IdGenerator`] in application + /// code for determinism; this convenience exists for tests and the + /// composition root. + #[must_use] + pub fn new_random() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the inner [`Uuid`]. + #[must_use] + pub const fn as_uuid(&self) -> Uuid { + self.0 + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + + impl From for $name { + fn from(id: Uuid) -> Self { + Self(id) + } + } + }; +} + +typed_id!( + /// Identifies a [`crate::project::Project`]. + ProjectId +); +typed_id!( + /// Identifies an [`crate::agent::Agent`]. + AgentId +); +typed_id!( + /// Identifies an [`crate::template::AgentTemplate`]. + TemplateId +); +typed_id!( + /// Identifies an [`crate::profile::AgentProfile`]. + ProfileId +); +typed_id!( + /// Identifies a [`crate::terminal::TerminalSession`]. + SessionId +); +typed_id!( + /// Identifies a [`crate::layout::WindowId`]-bearing OS window. + WindowId +); +typed_id!( + /// Identifies a [`crate::layout::Tab`]. + TabId +); +typed_id!( + /// Identifies one named terminal layout within a project (L10/#4). + LayoutId +); +typed_id!( + /// Identifies a node in a [`crate::layout::LayoutTree`]. + NodeId +); diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs new file mode 100644 index 0000000..2d02790 --- /dev/null +++ b/crates/domain/src/layout.rs @@ -0,0 +1,692 @@ +//! Pure, immutable terminal layout model (the "spreadsheet-like" recursive grid) +//! and its operations. +//! +//! See ARCHITECTURE.md §7. The model is a recursive split/grid tree. All +//! mutating operations are **pure functions** `&LayoutTree -> Result` returning a new tree, which makes them trivially testable and +//! enables undo/redo at the application layer. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId}; + +/// Direction of a [`SplitContainer`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Direction { + /// Children laid out left→right (columns). + Row, + /// Children laid out top→bottom (rows). + Column, +} + +/// A leaf cell hosting zero or one terminal session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LeafCell { + /// Node identifier. + pub id: NodeId, + /// The hosted session, if any (0 or 1). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session: Option, + /// The agent to launch automatically in this cell, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +/// A weighted child within a [`SplitContainer`]. The `weight` is a *relative* +/// resizable share; the UI normalises it for rendering. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WeightedChild { + /// The child node. + pub node: LayoutNode, + /// Relative weight; invariant: `> 0`. + pub weight: f32, +} + +/// A simple n-ary weighted split (rows or columns). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SplitContainer { + /// Node identifier. + pub id: NodeId, + /// Split direction. + pub direction: Direction, + /// Ordered children (left→right / top→bottom). + pub children: Vec, +} + +/// A grid cell placement with spans (spreadsheet-style merging). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GridCell { + /// The hosted node (may itself be a split/grid — recursive). + pub node: LayoutNode, + /// Zero-based row index. + pub row: u16, + /// Zero-based column index. + pub col: u16, + /// Row span; invariant: `>= 1`. + pub row_span: u16, + /// Column span; invariant: `>= 1`. + pub col_span: u16, +} + +/// A spreadsheet-like grid with per-column / per-row weights and span-based +/// merging. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GridContainer { + /// Node identifier. + pub id: NodeId, + /// Column widths (relative); length = number of columns. + pub col_weights: Vec, + /// Row heights (relative); length = number of rows. + pub row_weights: Vec, + /// Cell placements with spans. + pub cells: Vec, +} + +/// A node in the layout tree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type", content = "node")] +pub enum LayoutNode { + /// A terminal-hosting leaf. + Leaf(LeafCell), + /// A weighted split. + Split(SplitContainer), + /// A spreadsheet-style grid. + Grid(GridContainer), +} + +/// The root of a layout (one per tab). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LayoutTree { + /// Root node. + pub root: LayoutNode, +} + +/// Errors produced by layout validation and operations. +#[derive(Debug, Clone, PartialEq, Error)] +pub enum LayoutError { + /// A weight was not strictly positive. + #[error("weight must be > 0, got {weight}")] + NonPositiveWeight { + /// Offending weight. + weight: f32, + }, + /// A split had no children. + #[error("a split container must have at least one child")] + EmptySplit, + /// A grid span was less than one. + #[error("grid span must be >= 1")] + InvalidSpan, + /// A grid cell extends beyond the grid bounds. + #[error("grid cell at ({row},{col}) span ({row_span}x{col_span}) exceeds grid {rows}x{cols}")] + SpanOutOfBounds { + /// Cell row. + row: u16, + /// Cell column. + col: u16, + /// Row span. + row_span: u16, + /// Column span. + col_span: u16, + /// Grid rows. + rows: u16, + /// Grid cols. + cols: u16, + }, + /// Two grid cells overlap. + #[error("grid cells overlap at ({row},{col})")] + OverlappingCells { + /// Row of the overlap. + row: u16, + /// Column of the overlap. + col: u16, + }, + /// Part of the grid surface is not covered by any cell. + #[error("grid surface not fully covered: cell ({row},{col}) is empty")] + UncoveredCell { + /// Uncovered row. + row: u16, + /// Uncovered column. + col: u16, + }, + /// The same session appears in more than one leaf. + #[error("session {0} appears in more than one leaf")] + DuplicateSession(SessionId), + /// A referenced node id was not found in the tree. + #[error("node {0} not found")] + NodeNotFound(NodeId), + /// A merge/move spanned two distinct containers. + #[error("operation cannot span two distinct containers")] + CrossContainer, + /// A referenced tab id was not found in any window. + #[error("tab {0} not found")] + TabNotFound(TabId), +} + +impl LayoutTree { + /// Wraps a root node into a tree (without validation). + #[must_use] + pub fn new(root: LayoutNode) -> Self { + Self { root } + } + + /// Convenience: a single-leaf tree. + #[must_use] + pub fn single(leaf: LeafCell) -> Self { + Self { + root: LayoutNode::Leaf(leaf), + } + } + + /// Validates **all** layout invariants on the whole tree: + /// positive weights, valid/non-overlapping/fully-covering grid spans, and + /// at-most-one-leaf-per-session uniqueness. + /// + /// # Errors + /// Returns the first [`LayoutError`] encountered. + pub fn validate(&self) -> Result<(), LayoutError> { + let mut sessions = std::collections::HashSet::new(); + validate_node(&self.root, &mut sessions) + } + + /// Splits the leaf `target` into a [`SplitContainer`] with the original leaf + /// and a new leaf `new_leaf`, in the given `direction` with equal weights. + /// + /// Pure: returns a new validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree, + /// - any validation error of the resulting tree. + pub fn split( + &self, + target: NodeId, + direction: Direction, + new_leaf: LeafCell, + container_id: NodeId, + ) -> Result { + let mut found = false; + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::Leaf(leaf) = node { + if leaf.id == target { + found = true; + return LayoutNode::Split(SplitContainer { + id: container_id, + direction, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(leaf.clone()), + weight: 1.0, + }, + WeightedChild { + node: LayoutNode::Leaf(new_leaf.clone()), + weight: 1.0, + }, + ], + }); + } + } + node.clone() + }); + if !found { + return Err(LayoutError::NodeNotFound(target)); + } + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + + /// Merges a [`SplitContainer`] identified by `container` back into a single + /// node, keeping the child at `keep_index` and discarding the rest. + /// + /// Pure: returns a new validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if `container` is not a split in the tree, + /// - [`LayoutError::CrossContainer`] if `keep_index` is out of range, + /// - any validation error of the resulting tree. + pub fn merge(&self, container: NodeId, keep_index: usize) -> Result { + let mut result: Result<(), LayoutError> = Ok(()); + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::Split(split) = node { + if split.id == container { + match split.children.get(keep_index) { + Some(child) => return child.node.clone(), + None => result = Err(LayoutError::CrossContainer), + } + } + } + node.clone() + }); + result?; + if root == self.root { + return Err(LayoutError::NodeNotFound(container)); + } + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + + /// Resizes the children of a [`SplitContainer`] by assigning new `weights`. + /// + /// Pure: returns a new validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if `container` is not a split, + /// - [`LayoutError::CrossContainer`] if `weights.len()` differs from the + /// child count, + /// - [`LayoutError::NonPositiveWeight`] (via validation) if any weight ≤ 0. + pub fn resize(&self, container: NodeId, weights: &[f32]) -> Result { + let mut outcome: Option> = None; + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::Split(split) = node { + if split.id == container { + if split.children.len() != weights.len() { + outcome = Some(Err(LayoutError::CrossContainer)); + return node.clone(); + } + let children = split + .children + .iter() + .zip(weights.iter()) + .map(|(child, &w)| WeightedChild { + node: child.node.clone(), + weight: w, + }) + .collect(); + outcome = Some(Ok(())); + return LayoutNode::Split(SplitContainer { + id: split.id, + direction: split.direction, + children, + }); + } + } + node.clone() + }); + match outcome { + Some(Ok(())) => { + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + Some(Err(e)) => Err(e), + None => Err(LayoutError::NodeNotFound(container)), + } + } + + /// Moves the session currently hosted by leaf `from` to leaf `to`. + /// + /// `from` is left empty; `to` must currently be empty. This models dragging + /// a terminal between cells without duplicating it. Pure: returns a new + /// validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if either leaf is missing, + /// - [`LayoutError::CrossContainer`] if `from` has no session or `to` is + /// occupied, + /// - any validation error of the resulting tree. + pub fn move_session(&self, from: NodeId, to: NodeId) -> Result { + let session = self.session_in_leaf(from)?; + let Some(session) = session else { + return Err(LayoutError::CrossContainer); + }; + // Target must exist and be empty. + match self.session_in_leaf(to)? { + None => {} + Some(_) => return Err(LayoutError::CrossContainer), + } + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::Leaf(leaf) = node { + if leaf.id == from { + return LayoutNode::Leaf(LeafCell { + id: leaf.id, + session: None, + agent: leaf.agent, + }); + } + if leaf.id == to { + return LayoutNode::Leaf(LeafCell { + id: leaf.id, + session: Some(session), + agent: leaf.agent, + }); + } + } + node.clone() + }); + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + + /// Attaches (or, with `None`, detaches) a [`SessionId`] to the leaf `target`. + /// + /// This is the bridge between the layout and the terminal layer (L3/L4): when + /// [`crate::terminal::TerminalSession`] is opened for a cell, the application + /// records its id in the hosting leaf with this pure operation. + /// + /// Pure: returns a new validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree, + /// - [`LayoutError::DuplicateSession`] (via validation) if `session` is already + /// hosted by another leaf. + pub fn set_session( + &self, + target: NodeId, + session: Option, + ) -> Result { + let mut found = false; + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::Leaf(leaf) = node { + if leaf.id == target { + found = true; + return LayoutNode::Leaf(LeafCell { + id: leaf.id, + session, + agent: leaf.agent, + }); + } + } + node.clone() + }); + if !found { + return Err(LayoutError::NodeNotFound(target)); + } + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + + /// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`. + /// + /// Records which agent should be auto-launched in the cell (feature #3). + /// + /// Pure: returns a new validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree. + pub fn set_cell_agent( + &self, + target: NodeId, + agent: Option, + ) -> Result { + let mut found = false; + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::Leaf(leaf) = node { + if leaf.id == target { + found = true; + return LayoutNode::Leaf(LeafCell { + id: leaf.id, + session: leaf.session, + agent, + }); + } + } + node.clone() + }); + if !found { + return Err(LayoutError::NodeNotFound(target)); + } + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + + /// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf + /// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists. + fn session_in_leaf(&self, id: NodeId) -> Result, LayoutError> { + fn find(node: &LayoutNode, id: NodeId) -> Option> { + match node { + LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session), + LayoutNode::Leaf(_) => None, + LayoutNode::Split(split) => { + split.children.iter().find_map(|c| find(&c.node, id)) + } + LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)), + } + } + find(&self.root, id).ok_or(LayoutError::NodeNotFound(id)) + } +} + +/// Recursively rebuilds a node, applying `f` to every node (post-order: `f` sees +/// already-rebuilt children). +fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode { + let rebuilt = match node { + LayoutNode::Leaf(_) => node.clone(), + LayoutNode::Split(split) => LayoutNode::Split(SplitContainer { + id: split.id, + direction: split.direction, + children: split + .children + .iter() + .map(|c| WeightedChild { + node: map_node(&c.node, f), + weight: c.weight, + }) + .collect(), + }), + LayoutNode::Grid(grid) => LayoutNode::Grid(GridContainer { + id: grid.id, + col_weights: grid.col_weights.clone(), + row_weights: grid.row_weights.clone(), + cells: grid + .cells + .iter() + .map(|c| GridCell { + node: map_node(&c.node, f), + row: c.row, + col: c.col, + row_span: c.row_span, + col_span: c.col_span, + }) + .collect(), + }), + }; + f(&rebuilt) +} + +/// Recursive validation of a single node, accumulating seen sessions to enforce +/// global uniqueness. +fn validate_node( + node: &LayoutNode, + sessions: &mut std::collections::HashSet, +) -> Result<(), LayoutError> { + match node { + LayoutNode::Leaf(leaf) => { + if let Some(session) = leaf.session { + if !sessions.insert(session) { + return Err(LayoutError::DuplicateSession(session)); + } + } + Ok(()) + } + LayoutNode::Split(split) => { + if split.children.is_empty() { + return Err(LayoutError::EmptySplit); + } + for child in &split.children { + if child.weight <= 0.0 { + return Err(LayoutError::NonPositiveWeight { + weight: child.weight, + }); + } + validate_node(&child.node, sessions)?; + } + Ok(()) + } + LayoutNode::Grid(grid) => validate_grid(grid, sessions), + } +} + +/// Validates a grid: positive weights, in-bounds spans, no overlaps, full +/// coverage, and recursion into cell contents. +fn validate_grid( + grid: &GridContainer, + sessions: &mut std::collections::HashSet, +) -> Result<(), LayoutError> { + for &w in grid.col_weights.iter().chain(grid.row_weights.iter()) { + if w <= 0.0 { + return Err(LayoutError::NonPositiveWeight { weight: w }); + } + } + let rows = grid.row_weights.len(); + let cols = grid.col_weights.len(); + // Occupancy matrix to detect overlaps and gaps. + let mut occupied = vec![false; rows * cols]; + for cell in &grid.cells { + if cell.row_span < 1 || cell.col_span < 1 { + return Err(LayoutError::InvalidSpan); + } + let row_end = cell.row as usize + cell.row_span as usize; + let col_end = cell.col as usize + cell.col_span as usize; + if row_end > rows || col_end > cols { + return Err(LayoutError::SpanOutOfBounds { + row: cell.row, + col: cell.col, + row_span: cell.row_span, + col_span: cell.col_span, + rows: rows as u16, + cols: cols as u16, + }); + } + for r in cell.row as usize..row_end { + for c in cell.col as usize..col_end { + let idx = r * cols + c; + if occupied[idx] { + return Err(LayoutError::OverlappingCells { + row: r as u16, + col: c as u16, + }); + } + occupied[idx] = true; + } + } + validate_node(&cell.node, sessions)?; + } + // Full coverage. + for r in 0..rows { + for c in 0..cols { + if !occupied[r * cols + c] { + return Err(LayoutError::UncoveredCell { + row: r as u16, + col: c as u16, + }); + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Window / Tab / Workspace — persisted presentation entities (ARCHITECTURE §3.2) +// --------------------------------------------------------------------------- + +/// An open tab, bound 1:1 to a project. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tab { + /// Tab identifier. + pub id: TabId, + /// The project shown in this tab. + pub project_id: crate::ids::ProjectId, + /// The terminal layout of this tab. + pub layout: LayoutTree, +} + +/// An OS window holding one or more tabs. +/// +/// Invariant: a non-closed window holds at least one tab (see [`Window::new`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Window { + /// Window identifier. + pub id: WindowId, + /// Tabs in this window. + pub tabs: Vec, + /// Currently active tab. + pub active_tab: TabId, +} + +impl Window { + /// Builds a window, enforcing the "≥ 1 tab" invariant and that `active_tab` + /// refers to one of the tabs. + /// + /// # Errors + /// Returns [`LayoutError::CrossContainer`] if `tabs` is empty or `active_tab` + /// is not present. + pub fn new(id: WindowId, tabs: Vec, active_tab: TabId) -> Result { + if tabs.is_empty() || !tabs.iter().any(|t| t.id == active_tab) { + return Err(LayoutError::CrossContainer); + } + Ok(Self { + id, + tabs, + active_tab, + }) + } +} + +/// The set of windows for a user session. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Workspace { + /// All open OS windows. + pub windows: Vec, +} + +impl Workspace { + /// Detaches a tab into a brand-new window (ARCHITECTURE §10, L10). + /// + /// A **pure** transformation returning the next workspace state — the tab is + /// *moved*, never duplicated (the "a project is open in exactly one tab" + /// invariant). If the source window becomes empty it is removed; otherwise, if + /// the detached tab was the active one, the source's active tab falls back to + /// its first remaining tab. The new window holds the tab and makes it active. + /// + /// # Errors + /// - [`LayoutError::TabNotFound`] if no window contains `tab_id`, + /// - propagates [`Window::new`] invariants for the created window. + pub fn move_tab_to_new_window( + &self, + tab_id: TabId, + new_window_id: WindowId, + ) -> Result { + let mut windows = self.windows.clone(); + + // Locate the (window, tab) holding `tab_id`. + let (wi, ti) = windows + .iter() + .enumerate() + .find_map(|(wi, w)| { + w.tabs + .iter() + .position(|t| t.id == tab_id) + .map(|ti| (wi, ti)) + }) + .ok_or(LayoutError::TabNotFound(tab_id))?; + + let tab = windows[wi].tabs.remove(ti); + + if windows[wi].tabs.is_empty() { + // The source window is now empty: drop it (windows hold ≥ 1 tab). + windows.remove(wi); + } else if windows[wi].active_tab == tab_id { + // The moved tab was active: fall back to the first remaining tab. + windows[wi].active_tab = windows[wi].tabs[0].id; + } + + let detached = Window::new(new_window_id, vec![tab.clone()], tab.id)?; + windows.push(detached); + + Ok(Self { windows }) + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..7701ce1 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,88 @@ +//! # IdeA — Domain layer +//! +//! The **pure** hexagonal core (ARCHITECTURE.md §1.4, §3, §4, §7). It contains: +//! +//! - **Entities & value objects** with invariants enforced by validating +//! constructors (`new`/`try_new` returning `Result`), +//! - the **pure layout logic** (`split`/`merge`/`resize`/`move` as immutable +//! `&LayoutTree -> Result` functions), +//! - **ports** (traits) the infrastructure implements, +//! - **domain events** and **errors**. +//! +//! ## Dependency rule +//! +//! This crate depends on **no I/O**: no `tokio`, no `std::fs`, no +//! `std::process`, no `git2`/`portable-pty`/`russh`. The only third-party +//! dependencies are `uuid`, `serde` (allowed solely to derive (de)serialisation +//! of *persisted* domain types — a metier format constraint, not I/O), +//! `thiserror`, and `async-trait`. +//! +//! ## Async strategy for ports +//! +//! I/O-touching ports (`PtyPort`, `FileSystem`, `ProcessSpawner`, `RemoteHost`, +//! the stores, `GitPort`) are `#[async_trait]`. They are injected as +//! `Arc` trait objects at the composition root, which native +//! `async fn`-in-trait does not yet support dyn-compatibly without boxing; +//! `async_trait` boxes the returned future and keeps the ports object-safe. +//! Non-blocking ports (`Clock`, `IdGenerator`, `EventBus`, `AgentRuntime`) +//! remain plain synchronous traits. See [`ports`] for details. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod agent; +pub mod error; +pub mod events; +pub mod git; +pub mod ids; +pub mod layout; +pub mod markdown; +pub mod ports; +pub mod profile; +pub mod project; +pub mod remote; +pub mod template; +pub mod terminal; + +mod validation; + +// --------------------------------------------------------------------------- +// Curated re-exports for ergonomic downstream use. +// --------------------------------------------------------------------------- + +pub use error::DomainError; + +pub use ids::{ + AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, TabId, TemplateId, WindowId, +}; + +pub use project::{Project, ProjectPath}; + +pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; + +pub use template::{AgentTemplate, TemplateVersion}; + +pub use profile::{AgentProfile, ContextInjection}; + +pub use markdown::MarkdownDoc; + +pub use remote::{RemoteKind, RemoteRef, SshAuth}; + +pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; + +pub use git::GitRepository; + +pub use layout::{ + Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell, + SplitContainer, Tab, WeightedChild, Window, Workspace, +}; + +pub use events::DomainEvent; + +pub use ports::{ + AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, EventBus, EventStream, + ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, + IdGenerator, Output, OutputStream, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, + ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, + SpawnSpec, StoreError, TemplateStore, +}; diff --git a/crates/domain/src/markdown.rs b/crates/domain/src/markdown.rs new file mode 100644 index 0000000..37cfa75 --- /dev/null +++ b/crates/domain/src/markdown.rs @@ -0,0 +1,44 @@ +//! Markdown content value object. + +use serde::{Deserialize, Serialize}; + +/// A Markdown document — the content of an agent context (`.md`) or a template. +/// +/// This is a thin newtype: the domain treats Markdown as opaque text and does +/// not parse it. It exists to give the content a meaningful type at API +/// boundaries (vs. a bare `String`). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MarkdownDoc(String); + +impl MarkdownDoc { + /// Wraps raw Markdown content (empty content is permitted). + #[must_use] + pub fn new(content: impl Into) -> Self { + Self(content.into()) + } + + /// Returns the content as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consumes the document, returning its content. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } + + /// Returns `true` if the document is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl From for MarkdownDoc { + fn from(s: String) -> Self { + Self(s) + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs new file mode 100644 index 0000000..376d5b6 --- /dev/null +++ b/crates/domain/src/ports.rs @@ -0,0 +1,663 @@ +//! Ports (traits) — the boundary the domain defines and the infrastructure +//! implements (the **D** of SOLID, materialised). +//! +//! # Async decision +//! +//! Ports that touch the outside world (PTY, filesystem, process, stores, git, +//! remote connect) are inherently asynchronous in every realistic adapter +//! (tokio fs/process, russh, sftp). We therefore make those traits `async` via +//! [`async_trait`]. The rationale for `#[async_trait]` over native +//! `async fn` in traits / `-> impl Future`: +//! +//! - These ports are consumed as **trait objects** (`Arc`, +//! injected at the composition root). Native `async fn` in traits is not yet +//! dyn-compatible without boxing, so `async_trait` (which boxes the future) +//! is the pragmatic, stable choice and keeps the call sites object-safe. +//! - It keeps signatures readable and uniform across all adapters. +//! +//! Purely synchronous, non-blocking ports ([`Clock`], [`IdGenerator`], +//! [`EventBus`], the CPU-bound part of [`AgentRuntime`]) stay plain `fn` — no +//! need to pay the boxing cost. +//! +//! Each port is **fine-grained** (Interface Segregation): `FileSystem`, +//! `PtyPort`, `ProcessSpawner` are separate, never a `System` god-trait. + +use std::sync::Arc; + +use async_trait::async_trait; +use thiserror::Error; + +use crate::agent::AgentManifest; +use crate::events::DomainEvent; +use crate::ids::{AgentId, SessionId}; +use crate::markdown::MarkdownDoc; +use crate::profile::AgentProfile; +use crate::project::{Project, ProjectPath}; +use crate::remote::RemoteKind; +use crate::template::AgentTemplate; +use crate::terminal::PtySize; + +// --------------------------------------------------------------------------- +// Support value types shared across ports +// --------------------------------------------------------------------------- + +/// How the `.md` context should be delivered to a spawned process, resolved +/// from a profile's [`crate::profile::ContextInjection`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContextInjectionPlan { + /// Materialise the context at a (relative) file path before launch. + File { + /// Relative target path inside the cwd. + target: String, + }, + /// Append the given already-rendered arguments to the command line. + Args { + /// Extra arguments carrying the context path. + args: Vec, + }, + /// Pipe the content on stdin. + Stdin, + /// Provide the content (or its path) via an environment variable. + Env { + /// Variable name. + var: String, + }, +} + +/// A fully-resolved process invocation: command, args, cwd, environment, and the +/// plan for delivering the agent context. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpawnSpec { + /// Executable to run. + pub command: String, + /// Arguments. + pub args: Vec, + /// Working directory. + pub cwd: ProjectPath, + /// Extra environment variables. + pub env: Vec<(String, String)>, + /// How the context is injected, if any. + pub context_plan: Option, +} + +/// The agent context prepared for injection (content + the on-disk path it maps +/// to within the project). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedContext { + /// Rendered Markdown content. + pub content: MarkdownDoc, + /// Relative path of the `.md` inside the project. + pub relative_path: String, +} + +/// An opaque handle to a live PTY, owned by the adapter. +/// +/// The domain only needs an identity to address the PTY in subsequent calls; +/// the actual OS handle lives in infrastructure. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PtyHandle { + /// The session this PTY backs. + pub session_id: SessionId, +} + +/// Exit status of a process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExitStatus { + /// Exit code (`None` if terminated by a signal). + pub code: Option, +} + +/// Captured output of a non-interactive process run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Output { + /// Exit status. + pub status: ExitStatus, + /// Captured stdout. + pub stdout: Vec, + /// Captured stderr. + pub stderr: Vec, +} + +/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RemotePath(pub String); + +impl RemotePath { + /// Wraps a raw path. + #[must_use] + pub fn new(p: impl Into) -> Self { + Self(p.into()) + } + + /// Returns the path as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A single directory entry returned by [`FileSystem::list`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirEntry { + /// Entry name (basename). + pub name: String, + /// Whether the entry is a directory. + pub is_dir: bool, +} + +/// An owned, boxed stream of PTY output chunks. +/// +/// Concrete adapters decide the underlying transport; the domain only sees a +/// dynamically-dispatched iterator of byte chunks. +pub type OutputStream = Box> + Send>; + +/// A boxed stream of domain events, returned by [`EventBus::subscribe`]. +pub type EventStream = Box + Send>; + +// --------------------------------------------------------------------------- +// Per-port error types +// --------------------------------------------------------------------------- + +/// Errors from [`AgentRuntime`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RuntimeError { + /// The configured command could not be resolved/detected. + #[error("agent runtime: {0}")] + Detection(String), + /// The invocation could not be prepared (bad profile, etc.). + #[error("agent runtime: invalid invocation: {0}")] + Invocation(String), +} + +/// Errors from [`PtyPort`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PtyError { + /// Failed to spawn the PTY. + #[error("pty spawn failed: {0}")] + Spawn(String), + /// Read/write failure. + #[error("pty io failed: {0}")] + Io(String), + /// The handle referred to no live PTY. + #[error("pty handle not found")] + NotFound, +} + +/// Errors from [`ProcessSpawner`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ProcessError { + /// The process could not be started. + #[error("process spawn failed: {0}")] + Spawn(String), + /// I/O failure while running. + #[error("process io failed: {0}")] + Io(String), +} + +/// Errors from [`FileSystem`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum FsError { + /// Path not found. + #[error("path not found: {0}")] + NotFound(String), + /// Permission denied. + #[error("permission denied: {0}")] + PermissionDenied(String), + /// Other I/O error. + #[error("filesystem io failed: {0}")] + Io(String), +} + +/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`], +/// [`AgentContextStore`]). +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum StoreError { + /// The requested item was not found. + #[error("not found")] + NotFound, + /// (De)serialisation failed. + #[error("serialization failed: {0}")] + Serialization(String), + /// Underlying I/O error. + #[error("store io failed: {0}")] + Io(String), +} + +/// Errors from [`RemoteHost`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RemoteError { + /// Connection failed. + #[error("remote connection failed: {0}")] + Connection(String), + /// Authentication failed. + #[error("remote authentication failed: {0}")] + Auth(String), +} + +/// Errors from the git [`GitPort`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum GitError { + /// The repository was not found / not initialised. + #[error("git repository not found")] + NotFound, + /// A git operation failed. + #[error("git operation failed: {0}")] + Operation(String), +} + +// --------------------------------------------------------------------------- +// Git port support types +// --------------------------------------------------------------------------- + +/// Status of a single path in the working tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitFileStatus { + /// Path relative to the repo root. + pub path: String, + /// Whether the change is staged. + pub staged: bool, +} + +/// A commit summary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitCommitInfo { + /// Commit hash. + pub hash: String, + /// Commit message summary. + pub summary: String, +} + +/// A commit enriched for graph display (DAG edges + refs + author + time). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphCommit { + /// Full commit hash (hex string). + pub hash: String, + /// First line of the commit message. + pub summary: String, + /// Parent commit hashes (0 for root, ≥2 for merges). + pub parents: Vec, + /// Human-readable ref labels pointing at this commit + /// (e.g. `"main"`, `"tag: v1.0"`). + pub refs: Vec, + /// Author name (empty string when unavailable). + pub author: String, + /// Author timestamp in Unix seconds. + pub timestamp: i64, +} + +// --------------------------------------------------------------------------- +// Ports +// --------------------------------------------------------------------------- + +/// Launch/drive an AI CLI according to an [`AgentProfile`], handling context +/// injection. CPU-bound and synchronous — kept plain `fn`. +pub trait AgentRuntime: Send + Sync { + /// Detects whether the profile's command is available. + /// + /// # Errors + /// [`RuntimeError`] on detection failure. + fn detect(&self, profile: &AgentProfile) -> Result; + + /// Builds a [`SpawnSpec`] (command + args + injection plan) for launching + /// the agent in `cwd` with the prepared context. + /// + /// # Errors + /// [`RuntimeError`] if the invocation cannot be prepared. + fn prepare_invocation( + &self, + profile: &AgentProfile, + ctx: &PreparedContext, + cwd: &ProjectPath, + ) -> Result; +} + +/// Open and drive pseudo-terminals. +#[async_trait] +pub trait PtyPort: Send + Sync { + /// Spawns a PTY running `spec` at the given `size`. + /// + /// # Errors + /// [`PtyError`] on failure. + async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result; + + /// Writes bytes to the PTY. + /// + /// # Errors + /// [`PtyError`] on failure. + fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError>; + + /// Resizes the PTY. + /// + /// # Errors + /// [`PtyError`] on failure. + fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError>; + + /// Subscribes to the PTY's byte output stream. + /// + /// # Errors + /// [`PtyError`] if the handle is unknown. + fn subscribe_output(&self, handle: &PtyHandle) -> Result; + + /// Kills the PTY's process, returning its exit status. + /// + /// # Errors + /// [`PtyError`] on failure. + async fn kill(&self, handle: &PtyHandle) -> Result; +} + +/// Run a non-interactive process and capture its output. +#[async_trait] +pub trait ProcessSpawner: Send + Sync { + /// Runs `spec` to completion. + /// + /// # Errors + /// [`ProcessError`] on failure. + async fn run(&self, spec: SpawnSpec) -> Result; +} + +/// Location-neutral filesystem access. +#[async_trait] +pub trait FileSystem: Send + Sync { + /// Reads a file. + /// + /// # Errors + /// [`FsError`] on failure. + async fn read(&self, path: &RemotePath) -> Result, FsError>; + + /// Writes a file (creating or truncating). + /// + /// # Errors + /// [`FsError`] on failure. + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError>; + + /// Returns whether the path exists. + /// + /// # Errors + /// [`FsError`] on failure. + async fn exists(&self, path: &RemotePath) -> Result; + + /// Creates a directory and all missing parents. + /// + /// # Errors + /// [`FsError`] on failure. + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError>; + + /// Lists the entries of a directory. + /// + /// # Errors + /// [`FsError`] on failure. + async fn list(&self, path: &RemotePath) -> Result, FsError>; + + /// Creates a symbolic link `dst` pointing at `src`. + /// + /// # Errors + /// [`FsError`] on failure. + async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>; +} + +/// Strategy abstracting *where* execution happens (local / SSH / WSL). Acts as a +/// factory for the location-appropriate fine-grained ports. +#[async_trait] +pub trait RemoteHost: Send + Sync { + /// The kind of this host. + fn kind(&self) -> RemoteKind; + + /// Establishes the connection (no-op for local). + /// + /// # Errors + /// [`RemoteError`] on failure. + async fn connect(&self) -> Result<(), RemoteError>; + + /// Returns the filesystem port for this host. + fn file_system(&self) -> Arc; + + /// Returns the process spawner for this host. + fn process_spawner(&self) -> Arc; + + /// Returns the PTY port for this host. + fn pty(&self) -> Arc; +} + +/// CRUD + versioning for agent templates in the global IDE store. +#[async_trait] +pub trait TemplateStore: Send + Sync { + /// Lists all templates. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn list(&self) -> Result, StoreError>; + + /// Gets a template by id. + /// + /// # Errors + /// [`StoreError::NotFound`] if absent. + async fn get(&self, id: crate::ids::TemplateId) -> Result; + + /// Saves (creates or replaces) a template. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError>; + + /// Deletes a template. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>; +} + +/// Persistence of the known-projects registry and the workspace. +#[async_trait] +pub trait ProjectStore: Send + Sync { + /// Lists all known projects. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn list_projects(&self) -> Result, StoreError>; + + /// Loads a single project. + /// + /// # Errors + /// [`StoreError::NotFound`] if absent. + async fn load_project(&self, id: crate::ids::ProjectId) -> Result; + + /// Saves (creates or replaces) a project. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn save_project(&self, project: &Project) -> Result<(), StoreError>; + + /// Saves the whole workspace (windows/tabs/layouts). + /// + /// # Errors + /// [`StoreError`] on failure. + async fn save_workspace(&self, workspace: &crate::layout::Workspace) -> Result<(), StoreError>; + + /// Loads the persisted workspace. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn load_workspace(&self) -> Result; +} + +/// CRUD for the configured [`AgentProfile`]s in the global IDE store +/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the +/// single generic [`AgentRuntime`] adapter (Open/Closed). +#[async_trait] +pub trait ProfileStore: Send + Sync { + /// Lists all configured profiles. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn list(&self) -> Result, StoreError>; + + /// Saves (creates or replaces by id) a profile. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError>; + + /// Deletes a profile by id. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn delete(&self, id: crate::ids::ProfileId) -> Result<(), StoreError>; + + /// Whether the profiles store has been initialised yet — used to detect the + /// first run of the IDE (no `profiles.json` ⇒ first run). + /// + /// # Errors + /// [`StoreError`] on failure. + async fn is_configured(&self) -> Result; + + /// Persists an (possibly empty) profiles store, recording that the first run + /// is complete even when the user kept no profile. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn mark_configured(&self) -> Result<(), StoreError>; +} + +/// Reads/writes agent `.md` contexts and the project manifest, within a project. +#[async_trait] +pub trait AgentContextStore: Send + Sync { + /// Reads an agent's context. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn read_context( + &self, + project: &Project, + agent: &AgentId, + ) -> Result; + + /// Writes an agent's context. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn write_context( + &self, + project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError>; + + /// Loads the project manifest. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn load_manifest(&self, project: &Project) -> Result; + + /// Saves the project manifest. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn save_manifest( + &self, + project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError>; +} + +/// Git operations for a project. Named `GitPort` to avoid clashing with the +/// [`crate::git::GitRepository`] *entity* (state image). +#[async_trait] +pub trait GitPort: Send + Sync { + /// Initialises a repository at the root. + /// + /// # Errors + /// [`GitError`] on failure. + async fn init(&self, root: &ProjectPath) -> Result<(), GitError>; + + /// Returns the status of changed paths. + /// + /// # Errors + /// [`GitError`] on failure. + async fn status(&self, root: &ProjectPath) -> Result, GitError>; + + /// Stages a path. + /// + /// # Errors + /// [`GitError`] on failure. + async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>; + + /// Unstages a path. + /// + /// # Errors + /// [`GitError`] on failure. + async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>; + + /// Creates a commit with the given message. + /// + /// # Errors + /// [`GitError`] on failure. + async fn commit(&self, root: &ProjectPath, message: &str) -> Result; + + /// Lists branches. + /// + /// # Errors + /// [`GitError`] on failure. + async fn branches(&self, root: &ProjectPath) -> Result, GitError>; + + /// Returns the current branch. + /// + /// # Errors + /// [`GitError`] on failure. + async fn current_branch(&self, root: &ProjectPath) -> Result, GitError>; + + /// Checks out a branch. + /// + /// # Errors + /// [`GitError`] on failure. + async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError>; + + /// Returns the recent commit log. + /// + /// # Errors + /// [`GitError`] on failure. + async fn log(&self, root: &ProjectPath, limit: usize) + -> Result, GitError>; + + /// Returns the commit graph (all local branches, topological + time sort). + /// + /// # Errors + /// [`GitError`] on failure. + async fn log_graph( + &self, + root: &ProjectPath, + limit: usize, + ) -> Result, GitError>; + + /// Pulls from the default remote. + /// + /// # Errors + /// [`GitError`] on failure. + async fn pull(&self, root: &ProjectPath) -> Result<(), GitError>; + + /// Pushes to the default remote. + /// + /// # Errors + /// [`GitError`] on failure. + async fn push(&self, root: &ProjectPath) -> Result<(), GitError>; +} + +/// Publish/subscribe domain events. Synchronous, in-process. +pub trait EventBus: Send + Sync { + /// Publishes an event. + fn publish(&self, event: DomainEvent); + + /// Subscribes to the event stream. + fn subscribe(&self) -> EventStream; +} + +/// Provides the current time (epoch milliseconds), abstracted for determinism. +pub trait Clock: Send + Sync { + /// Returns "now" as epoch milliseconds. + fn now_millis(&self) -> i64; +} + +/// Generates fresh UUIDs, abstracted for determinism in tests. +pub trait IdGenerator: Send + Sync { + /// Returns a fresh UUID. + fn new_uuid(&self) -> uuid::Uuid; +} diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs new file mode 100644 index 0000000..3481021 --- /dev/null +++ b/crates/domain/src/profile.rs @@ -0,0 +1,133 @@ +//! AI runtime profile and its context-injection strategy. +//! +//! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI +//! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`] +//! port is parameterised by an [`AgentProfile`]. + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; +use crate::ids::ProfileId; + +/// Strategy for injecting an agent's `.md` context into the launched CLI. +/// +/// Invariants: +/// - `ConventionFile.target` is a relative file name without `..` and not absolute, +/// - `Env.var` is a valid environment-variable identifier, +/// - `Flag.flag` is non-empty. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "strategy")] +pub enum ContextInjection { + /// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`). + ConventionFile { + /// Relative target file name. + target: String, + }, + /// Pass the context file path through a CLI flag. + Flag { + /// The flag template (e.g. `--context-file {path}` or `-f`). + flag: String, + }, + /// Pipe the Markdown content on stdin. + Stdin, + /// Pass the context via an environment variable. + Env { + /// Environment variable name. + var: String, + }, +} + +impl ContextInjection { + /// Validated `ConventionFile` constructor. + /// + /// # Errors + /// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or + /// contains `..`. + pub fn convention_file(target: impl Into) -> Result { + let target = target.into(); + crate::validation::relative_safe(&target)?; + Ok(Self::ConventionFile { target }) + } + + /// Validated `Flag` constructor. + /// + /// # Errors + /// Returns [`DomainError::EmptyField`] if `flag` is empty. + pub fn flag(flag: impl Into) -> Result { + let flag = flag.into(); + crate::validation::non_empty(&flag, "contextInjection.flag")?; + Ok(Self::Flag { flag }) + } + + /// `Stdin` constructor (no validation needed). + #[must_use] + pub const fn stdin() -> Self { + Self::Stdin + } + + /// Validated `Env` constructor. + /// + /// # Errors + /// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier. + pub fn env(var: impl Into) -> Result { + let var = var.into(); + crate::validation::valid_env_var(&var)?; + Ok(Self::Env { var }) + } +} + +/// Declarative runtime configuration for one AI CLI. +/// +/// Invariants: +/// - `name` and `command` non-empty, +/// - `context_injection` is itself valid (guaranteed by its constructors). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentProfile { + /// Stable identifier. + pub id: ProfileId, + /// Display name. + pub name: String, + /// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`). + pub command: String, + /// Static arguments. + pub args: Vec, + /// Context-injection strategy. + pub context_injection: ContextInjection, + /// Optional detection command (e.g. `claude --version`). + pub detect: Option, + /// Working-directory template (e.g. `"{projectRoot}"`). + pub cwd_template: String, +} + +impl AgentProfile { + /// Builds a validated profile. + /// + /// # Errors + /// Returns [`DomainError::EmptyField`] if `name` or `command` is empty. + #[allow(clippy::too_many_arguments)] + pub fn new( + id: ProfileId, + name: impl Into, + command: impl Into, + args: Vec, + context_injection: ContextInjection, + detect: Option, + cwd_template: impl Into, + ) -> Result { + let name = name.into(); + let command = command.into(); + let cwd_template = cwd_template.into(); + crate::validation::non_empty(&name, "profile.name")?; + crate::validation::non_empty(&command, "profile.command")?; + Ok(Self { + id, + name, + command, + args, + context_injection, + detect, + cwd_template, + }) + } +} diff --git a/crates/domain/src/project.rs b/crates/domain/src/project.rs new file mode 100644 index 0000000..d82c7a5 --- /dev/null +++ b/crates/domain/src/project.rs @@ -0,0 +1,115 @@ +//! Project aggregate root and its path value object. + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; +use crate::ids::ProjectId; +use crate::remote::RemoteRef; + +/// A normalized, absolute project path, aware of its target platform. +/// +/// Invariant: the path is **absolute** for its platform. We accept POSIX +/// absolute paths (`/...`), Windows absolute paths (`C:\...` or `\\server\...`) +/// and WSL mount paths (`/mnt/c/...`, themselves POSIX-absolute). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProjectPath(String); + +impl ProjectPath { + /// Builds a validated project path. + /// + /// # Errors + /// Returns [`DomainError::PathNotAbsolute`] if `raw` is not absolute, or + /// [`DomainError::EmptyField`] if it is empty. + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + crate::validation::non_empty(&raw, "project.root")?; + if Self::is_absolute(&raw) { + Ok(Self(raw)) + } else { + Err(DomainError::PathNotAbsolute { path: raw }) + } + } + + /// Returns whether `raw` is an absolute path on any supported platform. + fn is_absolute(raw: &str) -> bool { + let bytes = raw.as_bytes(); + // POSIX / WSL absolute. + if bytes.first() == Some(&b'/') { + return true; + } + // Windows UNC. + if raw.starts_with("\\\\") { + return true; + } + // Windows drive-letter absolute: `C:\` or `C:/`. + if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'\\' || bytes[2] == b'/') + { + return true; + } + false + } + + /// Returns the raw path string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ProjectPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Project aggregate root. +/// +/// Invariants enforced here: +/// - `name` non-empty, +/// - `root` is an absolute [`ProjectPath`]. +/// +/// The cross-aggregate uniqueness invariant — "no two projects share the same +/// `(remote, root)`" — is a *repository* concern (it requires knowledge of all +/// projects) and is enforced by the `ProjectStore`/use case layer, not here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Project { + /// Stable identifier. + pub id: ProjectId, + /// Display name. + pub name: String, + /// Absolute project root. + pub root: ProjectPath, + /// Where the project physically lives. + pub remote: RemoteRef, + /// Creation timestamp, as epoch milliseconds (supplied via a `Clock` port). + pub created_at: i64, +} + +impl Project { + /// Builds a validated project. + /// + /// # Errors + /// Returns [`DomainError::EmptyField`] if `name` is empty. + pub fn new( + id: ProjectId, + name: impl Into, + root: ProjectPath, + remote: RemoteRef, + created_at: i64, + ) -> Result { + let name = name.into(); + crate::validation::non_empty(&name, "project.name")?; + Ok(Self { + id, + name, + root, + remote, + created_at, + }) + } +} diff --git a/crates/domain/src/remote.rs b/crates/domain/src/remote.rs new file mode 100644 index 0000000..8232cdf --- /dev/null +++ b/crates/domain/src/remote.rs @@ -0,0 +1,128 @@ +//! Remote-location value objects: the strategy that locates where a project's +//! filesystem and processes actually live (local, SSH, or WSL). + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; + +/// SSH authentication strategy. +/// +/// The domain only models *which* strategy is selected; resolving keys, +/// agents or known-hosts is an infrastructure concern. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum SshAuth { + /// Use the running SSH agent. + Agent, + /// Use a private-key file at the given (remote-machine-agnostic) path. + Key { + /// Path to the private key on the local machine. + path: String, + }, + /// Interactive / stored password authentication. + Password, +} + +/// Strategy describing where a project is physically located and executed. +/// +/// Invariants: +/// - `Ssh.port` ∈ `1..=65535`, +/// - `Ssh.host` / `Ssh.user` / `Ssh.remote_root` non-empty, +/// - `Wsl.distro` non-empty. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum RemoteRef { + /// The local machine. + Local, + /// A remote host reached over SSH. + #[serde(rename_all = "camelCase")] + Ssh { + /// Hostname or IP. + host: String, + /// TCP port (`1..=65535`). + port: u16, + /// Remote user. + user: String, + /// Authentication strategy. + auth: SshAuth, + /// Absolute root path on the remote machine. + remote_root: String, + }, + /// A Windows Subsystem for Linux distribution. + Wsl { + /// Distribution name (e.g. `Ubuntu-22.04`). + distro: String, + }, +} + +impl RemoteRef { + /// Convenience constructor for the local machine. + #[must_use] + pub const fn local() -> Self { + Self::Local + } + + /// Validated constructor for an SSH remote. + /// + /// # Errors + /// Returns [`DomainError`] if the port is `0`, or if `host`, `user` or + /// `remote_root` is empty. + pub fn ssh( + host: impl Into, + port: u16, + user: impl Into, + auth: SshAuth, + remote_root: impl Into, + ) -> Result { + let host = host.into(); + let user = user.into(); + let remote_root = remote_root.into(); + crate::validation::non_empty(&host, "ssh.host")?; + crate::validation::non_empty(&user, "ssh.user")?; + crate::validation::non_empty(&remote_root, "ssh.remote_root")?; + // u16 already bounds the upper end; only port 0 is invalid. + if port == 0 { + return Err(DomainError::InvalidPort { port: 0 }); + } + Ok(Self::Ssh { + host, + port, + user, + auth, + remote_root, + }) + } + + /// Validated constructor for a WSL remote. + /// + /// # Errors + /// Returns [`DomainError`] if `distro` is empty. + pub fn wsl(distro: impl Into) -> Result { + let distro = distro.into(); + crate::validation::non_empty(&distro, "wsl.distro")?; + Ok(Self::Wsl { distro }) + } + + /// Returns the coarse kind of this remote, for adapter selection. + #[must_use] + pub fn kind(&self) -> RemoteKind { + match self { + Self::Local => RemoteKind::Local, + Self::Ssh { .. } => RemoteKind::Ssh, + Self::Wsl { .. } => RemoteKind::Wsl, + } + } +} + +/// Coarse discriminant used by the [`crate::ports::RemoteHost`] port to advertise +/// its kind without exposing connection details. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RemoteKind { + /// Local execution. + Local, + /// SSH remote. + Ssh, + /// WSL distribution. + Wsl, +} diff --git a/crates/domain/src/template.rs b/crates/domain/src/template.rs new file mode 100644 index 0000000..588625e --- /dev/null +++ b/crates/domain/src/template.rs @@ -0,0 +1,92 @@ +//! Agent templates (global IDE store) and their monotonic versioning. + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; +use crate::ids::{ProfileId, TemplateId}; +use crate::markdown::MarkdownDoc; + +/// Monotonically increasing template version. +/// +/// Invariant (enforced via [`TemplateVersion::next`]): a version only ever +/// increases by one when the template content changes (ARCHITECTURE §8.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TemplateVersion(pub u64); + +impl TemplateVersion { + /// The initial version assigned to a freshly created template. + pub const INITIAL: Self = Self(1); + + /// Returns the next version (current + 1). + #[must_use] + pub const fn next(self) -> Self { + Self(self.0 + 1) + } + + /// Returns the raw version number. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +impl Default for TemplateVersion { + fn default() -> Self { + Self::INITIAL + } +} + +/// A reusable agent template stored in the global IDE store. +/// +/// Invariants: +/// - `name` non-empty, +/// - `version` is monotonic; bumping is done via [`AgentTemplate::with_updated_content`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTemplate { + /// Stable identifier. + pub id: TemplateId, + /// Display name. + pub name: String, + /// Markdown content. + pub content_md: MarkdownDoc, + /// Current version. + pub version: TemplateVersion, + /// Default runtime profile for agents created from this template. + pub default_profile_id: ProfileId, +} + +impl AgentTemplate { + /// Builds a validated template at [`TemplateVersion::INITIAL`]. + /// + /// # Errors + /// Returns [`DomainError::EmptyField`] if `name` is empty. + pub fn new( + id: TemplateId, + name: impl Into, + content_md: MarkdownDoc, + default_profile_id: ProfileId, + ) -> Result { + let name = name.into(); + crate::validation::non_empty(&name, "template.name")?; + Ok(Self { + id, + name, + content_md, + version: TemplateVersion::INITIAL, + default_profile_id, + }) + } + + /// Returns a copy of this template with new content and the version bumped + /// by one, preserving the monotonic-version invariant. + #[must_use] + pub fn with_updated_content(&self, content_md: MarkdownDoc) -> Self { + Self { + content_md, + version: self.version.next(), + ..self.clone() + } + } +} diff --git a/crates/domain/src/terminal.rs b/crates/domain/src/terminal.rs new file mode 100644 index 0000000..a4fe558 --- /dev/null +++ b/crates/domain/src/terminal.rs @@ -0,0 +1,105 @@ +//! Terminal session entity and supporting value objects. + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; +use crate::ids::{AgentId, NodeId, SessionId}; +use crate::project::ProjectPath; + +/// Dimensions of a pseudo-terminal, in character cells. +/// +/// Invariant: `rows > 0 && cols > 0`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PtySize { + /// Number of rows. + pub rows: u16, + /// Number of columns. + pub cols: u16, +} + +impl PtySize { + /// Builds a validated PTY size. + /// + /// # Errors + /// Returns [`DomainError::InvalidPtySize`] if either dimension is zero. + pub fn new(rows: u16, cols: u16) -> Result { + if rows == 0 || cols == 0 { + return Err(DomainError::InvalidPtySize { rows, cols }); + } + Ok(Self { rows, cols }) + } +} + +/// What a terminal session is running. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum SessionKind { + /// A plain shell terminal. + Plain, + /// A terminal launched for a specific agent. + #[serde(rename_all = "camelCase")] + Agent { + /// The agent driving this session. + agent_id: AgentId, + }, +} + +/// Lifecycle status of a terminal session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum SessionStatus { + /// Spawn requested, not yet confirmed running. + Starting, + /// Running. + Running, + /// Exited with a code. + Exited { + /// Process exit code. + code: i32, + }, +} + +/// A terminal session hosted by a layout leaf cell. +/// +/// Invariants: +/// - `pty_size` is valid (`rows>0 && cols>0`), +/// - "at most one active session per leaf" is a *layout* invariant enforced in +/// [`crate::layout`], not here (a session does not own the leaf). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSession { + /// Stable identifier. + pub id: SessionId, + /// Layout leaf hosting this session. + pub node_id: NodeId, + /// Working directory. + pub cwd: ProjectPath, + /// What the session runs. + pub kind: SessionKind, + /// Current terminal size. + pub pty_size: PtySize, + /// Lifecycle status. + pub status: SessionStatus, +} + +impl TerminalSession { + /// Builds a terminal session (in `Starting` state). + #[must_use] + pub fn starting( + id: SessionId, + node_id: NodeId, + cwd: ProjectPath, + kind: SessionKind, + pty_size: PtySize, + ) -> Self { + Self { + id, + node_id, + cwd, + kind, + pty_size, + status: SessionStatus::Starting, + } + } +} diff --git a/crates/domain/src/validation.rs b/crates/domain/src/validation.rs new file mode 100644 index 0000000..2ec6628 --- /dev/null +++ b/crates/domain/src/validation.rs @@ -0,0 +1,59 @@ +//! Small pure validation helpers shared across value objects. + +use crate::error::DomainError; + +/// Returns the trimmed string if non-empty, otherwise [`DomainError::EmptyField`]. +pub(crate) fn non_empty(value: &str, field: &'static str) -> Result<(), DomainError> { + if value.trim().is_empty() { + Err(DomainError::EmptyField { field }) + } else { + Ok(()) + } +} + +/// Validates that `var` is a syntactically valid environment-variable identifier: +/// non-empty, starts with a letter or `_`, and contains only ASCII alphanumeric +/// characters or `_`. +pub(crate) fn valid_env_var(var: &str) -> Result<(), DomainError> { + let invalid = || DomainError::InvalidEnvVar { + value: var.to_string(), + }; + let mut chars = var.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return Err(invalid()), + } + if chars.all(|c| c.is_ascii_alphanumeric() || c == '_') { + Ok(()) + } else { + Err(invalid()) + } +} + +/// Validates that a path is relative and does not escape its root via `..`. +/// +/// Used for [`crate::profile::ContextInjection::ConventionFile`] targets and +/// manifest `.md` paths, which must stay inside the project / `.ideai/` tree. +pub(crate) fn relative_safe(path: &str) -> Result<(), DomainError> { + let err = || DomainError::PathNotRelativeSafe { + path: path.to_string(), + }; + if path.is_empty() { + return Err(err()); + } + // Reject absolute POSIX paths and Windows drive / UNC paths. + let bytes = path.as_bytes(); + if bytes[0] == b'/' || bytes[0] == b'\\' { + return Err(err()); + } + if path.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() { + return Err(err()); + } + // Reject any `..` traversal component (handle both separators). + for component in path.split(['/', '\\']) { + if component == ".." { + return Err(err()); + } + } + Ok(()) +} diff --git a/crates/domain/tests/context_injection.rs b/crates/domain/tests/context_injection.rs new file mode 100644 index 0000000..cb60ace --- /dev/null +++ b/crates/domain/tests/context_injection.rs @@ -0,0 +1,104 @@ +//! Validation of the four `ContextInjection` variants (ARCHITECTURE §3.2). + +use domain::{ContextInjection, DomainError}; + +// --- ConventionFile ------------------------------------------------------- + +#[test] +fn convention_file_relative_ok() { + let ci = ContextInjection::convention_file("CLAUDE.md").unwrap(); + assert!(matches!(ci, ContextInjection::ConventionFile { target } if target == "CLAUDE.md")); +} + +#[test] +fn convention_file_nested_relative_ok() { + assert!(ContextInjection::convention_file("docs/AGENTS.md").is_ok()); +} + +#[test] +fn convention_file_rejects_absolute() { + assert!(matches!( + ContextInjection::convention_file("/etc/CLAUDE.md").unwrap_err(), + DomainError::PathNotRelativeSafe { .. } + )); +} + +#[test] +fn convention_file_rejects_dotdot() { + assert!(matches!( + ContextInjection::convention_file("../CLAUDE.md").unwrap_err(), + DomainError::PathNotRelativeSafe { .. } + )); +} + +#[test] +fn convention_file_rejects_windows_drive() { + assert!(matches!( + ContextInjection::convention_file("C:\\CLAUDE.md").unwrap_err(), + DomainError::PathNotRelativeSafe { .. } + )); +} + +// --- Flag ----------------------------------------------------------------- + +#[test] +fn flag_non_empty_ok() { + let ci = ContextInjection::flag("--context-file {path}").unwrap(); + assert!(matches!(ci, ContextInjection::Flag { flag } if flag == "--context-file {path}")); +} + +#[test] +fn flag_rejects_empty() { + assert!(matches!( + ContextInjection::flag("").unwrap_err(), + DomainError::EmptyField { .. } + )); + assert!(matches!( + ContextInjection::flag(" ").unwrap_err(), + DomainError::EmptyField { .. } + )); +} + +// --- Stdin ---------------------------------------------------------------- + +#[test] +fn stdin_variant() { + assert_eq!(ContextInjection::stdin(), ContextInjection::Stdin); +} + +// --- Env ------------------------------------------------------------------ + +#[test] +fn env_valid_identifier_ok() { + assert!(ContextInjection::env("AGENT_CONTEXT_FILE").is_ok()); + assert!(ContextInjection::env("_private").is_ok()); + assert!(ContextInjection::env("X1").is_ok()); +} + +#[test] +fn env_rejects_leading_digit() { + assert!(matches!( + ContextInjection::env("1BAD").unwrap_err(), + DomainError::InvalidEnvVar { .. } + )); +} + +#[test] +fn env_rejects_invalid_chars() { + assert!(matches!( + ContextInjection::env("BAD-VAR").unwrap_err(), + DomainError::InvalidEnvVar { .. } + )); + assert!(matches!( + ContextInjection::env("HAS SPACE").unwrap_err(), + DomainError::InvalidEnvVar { .. } + )); +} + +#[test] +fn env_rejects_empty() { + assert!(matches!( + ContextInjection::env("").unwrap_err(), + DomainError::InvalidEnvVar { .. } + )); +} diff --git a/crates/domain/tests/entities.rs b/crates/domain/tests/entities.rs new file mode 100644 index 0000000..272ed5b --- /dev/null +++ b/crates/domain/tests/entities.rs @@ -0,0 +1,411 @@ +//! Entity & value-object invariant tests: valid construction plus the expected +//! rejections (ARCHITECTURE §3.2). + +mod helpers; + +use domain::{ + Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError, + ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SshAuth, + TemplateId, TemplateVersion, +}; +use helpers::{AtomicSeqIdGenerator, FixedClock}; +use uuid::Uuid; + +fn profile_id() -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(42)) +} + +fn template_id() -> TemplateId { + TemplateId::from_uuid(Uuid::from_u128(7)) +} + +// --------------------------------------------------------------------------- +// ProjectPath +// --------------------------------------------------------------------------- + +#[test] +fn project_path_accepts_posix_absolute() { + assert!(ProjectPath::new("/home/user/proj").is_ok()); +} + +#[test] +fn project_path_accepts_windows_drive_and_unc() { + assert!(ProjectPath::new("C:\\Users\\x").is_ok()); + assert!(ProjectPath::new("C:/Users/x").is_ok()); + assert!(ProjectPath::new("\\\\server\\share").is_ok()); +} + +#[test] +fn project_path_accepts_wsl_mount() { + assert!(ProjectPath::new("/mnt/c/code").is_ok()); +} + +#[test] +fn project_path_rejects_relative() { + let err = ProjectPath::new("relative/path").unwrap_err(); + assert!(matches!(err, DomainError::PathNotAbsolute { .. })); +} + +#[test] +fn project_path_rejects_empty() { + let err = ProjectPath::new("").unwrap_err(); + assert!(matches!(err, DomainError::EmptyField { .. })); +} + +// --------------------------------------------------------------------------- +// Project (also exercises the Clock/IdGenerator port fakes for determinism) +// --------------------------------------------------------------------------- + +#[test] +fn project_valid_with_fixed_clock_and_seq_ids() { + use domain::{ports::Clock, ports::IdGenerator, ProjectId}; + let clock = FixedClock(1_700_000_000_000); + let ids = AtomicSeqIdGenerator::new(); + let id = ProjectId::from_uuid(ids.new_uuid()); + let p = Project::new( + id, + "demo", + ProjectPath::new("/srv/demo").unwrap(), + RemoteRef::local(), + clock.now_millis(), + ) + .unwrap(); + assert_eq!(p.created_at, 1_700_000_000_000); + assert_eq!(p.id.as_uuid(), Uuid::from_u128(1)); +} + +#[test] +fn project_rejects_empty_name() { + let err = Project::new( + domain::ProjectId::from_uuid(Uuid::nil()), + " ", + ProjectPath::new("/x").unwrap(), + RemoteRef::local(), + 0, + ) + .unwrap_err(); + assert!(matches!(err, DomainError::EmptyField { .. })); +} + +// --------------------------------------------------------------------------- +// Agent invariants +// --------------------------------------------------------------------------- + +#[test] +fn agent_scratch_not_synchronized_is_ok() { + let a = Agent::new( + domain::AgentId::from_uuid(Uuid::from_u128(1)), + "scratch", + "agents/foo.md", + profile_id(), + AgentOrigin::Scratch, + false, + ); + assert!(a.is_ok()); +} + +#[test] +fn agent_from_template_synchronized_is_ok() { + let a = Agent::new( + domain::AgentId::from_uuid(Uuid::from_u128(1)), + "tpl", + "agents/foo.md", + profile_id(), + AgentOrigin::FromTemplate { + template_id: template_id(), + synced_template_version: TemplateVersion::INITIAL, + }, + true, + ); + assert!(a.is_ok()); +} + +#[test] +fn agent_synchronized_without_template_is_rejected() { + let err = Agent::new( + domain::AgentId::from_uuid(Uuid::from_u128(1)), + "bad", + "agents/foo.md", + profile_id(), + AgentOrigin::Scratch, + true, + ) + .unwrap_err(); + assert_eq!(err, DomainError::SyncRequiresTemplate); +} + +#[test] +fn agent_rejects_absolute_context_path() { + let err = Agent::new( + domain::AgentId::from_uuid(Uuid::from_u128(1)), + "x", + "/etc/passwd", + profile_id(), + AgentOrigin::Scratch, + false, + ) + .unwrap_err(); + assert!(matches!(err, DomainError::PathNotRelativeSafe { .. })); +} + +#[test] +fn agent_rejects_dotdot_context_path() { + let err = Agent::new( + domain::AgentId::from_uuid(Uuid::from_u128(1)), + "x", + "agents/../../secret.md", + profile_id(), + AgentOrigin::Scratch, + false, + ) + .unwrap_err(); + assert!(matches!(err, DomainError::PathNotRelativeSafe { .. })); +} + +// --------------------------------------------------------------------------- +// AgentProfile: command non-empty +// --------------------------------------------------------------------------- + +fn ci_stdin() -> ContextInjection { + ContextInjection::stdin() +} + +#[test] +fn profile_valid() { + let p = AgentProfile::new( + profile_id(), + "Claude", + "claude", + vec!["--yolo".into()], + ci_stdin(), + Some("claude --version".into()), + "{projectRoot}", + ); + assert!(p.is_ok()); +} + +#[test] +fn profile_rejects_empty_command() { + let err = AgentProfile::new( + profile_id(), + "Name", + "", + vec![], + ci_stdin(), + None, + "{projectRoot}", + ) + .unwrap_err(); + assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.command")); +} + +#[test] +fn profile_rejects_empty_name() { + let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}") + .unwrap_err(); + assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name")); +} + +// --------------------------------------------------------------------------- +// RemoteRef invariants +// --------------------------------------------------------------------------- + +#[test] +fn ssh_valid() { + let r = RemoteRef::ssh("host", 22, "me", SshAuth::Agent, "/srv"); + assert!(r.is_ok()); + assert_eq!(r.unwrap().kind(), domain::RemoteKind::Ssh); +} + +#[test] +fn ssh_port_zero_rejected() { + let err = RemoteRef::ssh("host", 0, "me", SshAuth::Agent, "/srv").unwrap_err(); + assert!(matches!(err, DomainError::InvalidPort { port: 0 })); +} + +#[test] +fn ssh_max_port_accepted() { + // 65535 is the upper bound of the 1..=65535 range; u16 prevents anything higher. + assert!(RemoteRef::ssh("h", 65535, "u", SshAuth::Password, "/r").is_ok()); +} + +#[test] +fn ssh_rejects_empty_host_user_root() { + assert!(RemoteRef::ssh("", 22, "u", SshAuth::Agent, "/r").is_err()); + assert!(RemoteRef::ssh("h", 22, "", SshAuth::Agent, "/r").is_err()); + assert!(RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "").is_err()); +} + +#[test] +fn wsl_valid() { + assert!(RemoteRef::wsl("Ubuntu-22.04").is_ok()); +} + +#[test] +fn wsl_empty_distro_rejected() { + let err = RemoteRef::wsl("").unwrap_err(); + assert!(matches!(err, DomainError::EmptyField { .. })); +} + +// --------------------------------------------------------------------------- +// PtySize invariants +// --------------------------------------------------------------------------- + +#[test] +fn pty_size_valid() { + assert!(PtySize::new(24, 80).is_ok()); +} + +#[test] +fn pty_size_zero_rows_rejected() { + assert!(matches!( + PtySize::new(0, 80).unwrap_err(), + DomainError::InvalidPtySize { rows: 0, cols: 80 } + )); +} + +#[test] +fn pty_size_zero_cols_rejected() { + assert!(matches!( + PtySize::new(24, 0).unwrap_err(), + DomainError::InvalidPtySize { rows: 24, cols: 0 } + )); +} + +// --------------------------------------------------------------------------- +// AgentTemplate version monotonicity (via with_updated_content / next) +// --------------------------------------------------------------------------- + +#[test] +fn template_starts_at_initial() { + let t = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap(); + assert_eq!(t.version, TemplateVersion::INITIAL); + assert_eq!(t.version.get(), 1); +} + +#[test] +fn template_update_bumps_version_monotonically() { + let t0 = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap(); + let t1 = t0.with_updated_content(MarkdownDoc::new("b")); + let t2 = t1.with_updated_content(MarkdownDoc::new("c")); + assert!(t1.version > t0.version); + assert!(t2.version > t1.version); + assert_eq!(t2.version.get(), 3); + assert_eq!(t2.content_md.as_str(), "c"); + // id and profile preserved. + assert_eq!(t2.id, t0.id); + assert_eq!(t2.default_profile_id, t0.default_profile_id); +} + +#[test] +fn template_version_next_increments() { + assert_eq!(TemplateVersion(5).next(), TemplateVersion(6)); +} + +#[test] +fn template_rejects_empty_name() { + let err = AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err(); + assert!(matches!(err, DomainError::EmptyField { .. })); +} + +// --------------------------------------------------------------------------- +// ManifestEntry / AgentManifest invariants +// --------------------------------------------------------------------------- + +fn agent_id(n: u128) -> domain::AgentId { + domain::AgentId::from_uuid(Uuid::from_u128(n)) +} + +#[test] +fn manifest_entry_synchronized_requires_template_metadata() { + let err = + ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None) + .unwrap_err(); + assert!(matches!(err, DomainError::InconsistentManifest { .. })); + + // template id present but version missing → still rejected. + let err = ManifestEntry::new( + agent_id(1), + "A", + "agents/a.md", + profile_id(), + Some(template_id()), + true, + None, + ) + .unwrap_err(); + assert!(matches!(err, DomainError::InconsistentManifest { .. })); +} + +#[test] +fn manifest_entry_rejects_empty_name() { + let err = + ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None) + .unwrap_err(); + assert!(matches!(err, DomainError::EmptyField { .. })); +} + +#[test] +fn manifest_entry_synchronized_with_metadata_ok() { + assert!(ManifestEntry::new( + agent_id(1), + "A", + "agents/a.md", + profile_id(), + Some(template_id()), + true, + Some(TemplateVersion::INITIAL) + ) + .is_ok()); +} + +#[test] +fn manifest_entry_rejects_absolute_md_path() { + assert!(matches!( + ManifestEntry::new(agent_id(1), "A", "/abs.md", profile_id(), None, false, None) + .unwrap_err(), + DomainError::PathNotRelativeSafe { .. } + )); +} + +#[test] +fn manifest_entry_agent_roundtrip() { + // from_agent ∘ to_agent preserves a template-backed, synchronized agent. + let agent = Agent::new( + agent_id(9), + "Backend", + "agents/backend.md", + profile_id(), + AgentOrigin::FromTemplate { + template_id: template_id(), + synced_template_version: TemplateVersion(4), + }, + true, + ) + .unwrap(); + let entry = ManifestEntry::from_agent(&agent); + assert_eq!(entry.to_agent().unwrap(), agent); +} + +#[test] +fn manifest_rejects_duplicate_md_path() { + let e1 = + ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None) + .unwrap(); + let e2 = + ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None) + .unwrap(); + let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err(); + assert!(matches!(err, DomainError::InconsistentManifest { .. })); +} + +#[test] +fn manifest_unique_md_paths_ok() { + let e1 = + ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None) + .unwrap(); + let e2 = + ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None) + .unwrap(); + assert!(AgentManifest::new(1, vec![e1, e2]).is_ok()); +} diff --git a/crates/domain/tests/helpers/mod.rs b/crates/domain/tests/helpers/mod.rs new file mode 100644 index 0000000..4a320ae --- /dev/null +++ b/crates/domain/tests/helpers/mod.rs @@ -0,0 +1,92 @@ +//! Shared test helpers: deterministic fakes for the `Clock` / `IdGenerator` +//! ports, plus small id constructors. Kept in `tests/` so they never ship in +//! the crate proper. +#![allow(dead_code)] + +use std::cell::Cell; + +use domain::ports::{Clock, IdGenerator}; +use uuid::Uuid; + +/// A clock that always returns the same fixed millisecond value. +pub struct FixedClock(pub i64); + +impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } +} + +/// An id generator producing a deterministic, monotonically increasing sequence +/// of UUIDs (`0000...0001`, `0000...0002`, ...). +pub struct SeqIdGenerator { + next: Cell, +} + +impl SeqIdGenerator { + #[must_use] + pub fn new() -> Self { + Self { next: Cell::new(1) } + } +} + +impl Default for SeqIdGenerator { + fn default() -> Self { + Self::new() + } +} + +// `IdGenerator` only requires `&self`, so we use a `Cell` for interior +// mutability. The trait demands `Send + Sync`; `Cell` is `!Sync`, so for the +// test fake we wrap calls behind `&self` single-threaded usage only. +// To satisfy the bound we instead expose a plain method used directly in tests. +impl SeqIdGenerator { + /// Returns the next UUID in the deterministic sequence. + pub fn next_uuid(&self) -> Uuid { + let n = self.next.get(); + self.next.set(n + 1); + Uuid::from_u128(n) + } +} + +/// A `Send + Sync` deterministic id generator suitable for the `IdGenerator` +/// port (uses an atomic counter). +pub struct AtomicSeqIdGenerator { + next: std::sync::atomic::AtomicU64, +} + +impl AtomicSeqIdGenerator { + #[must_use] + pub fn new() -> Self { + Self { + next: std::sync::atomic::AtomicU64::new(1), + } + } +} + +impl Default for AtomicSeqIdGenerator { + fn default() -> Self { + Self::new() + } +} + +impl IdGenerator for AtomicSeqIdGenerator { + fn new_uuid(&self) -> Uuid { + let n = self + .next + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Uuid::from_u128(u128::from(n)) + } +} + +/// Builds a `NodeId` from a small integer (handy, readable test ids). +#[must_use] +pub fn node(n: u128) -> domain::NodeId { + domain::NodeId::from_uuid(Uuid::from_u128(n)) +} + +/// Builds a `SessionId` from a small integer. +#[must_use] +pub fn session(n: u128) -> domain::SessionId { + domain::SessionId::from_uuid(Uuid::from_u128(n)) +} diff --git a/crates/domain/tests/layout.rs b/crates/domain/tests/layout.rs new file mode 100644 index 0000000..7c2e4e8 --- /dev/null +++ b/crates/domain/tests/layout.rs @@ -0,0 +1,495 @@ +//! Pure layout logic: split / merge / resize / move_session, nominal and error +//! paths, grid validation, and immutability of the source tree (ARCHITECTURE §7). + +mod helpers; + +use domain::{ + Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell, + SplitContainer, WeightedChild, +}; +use domain::ids::AgentId; +use helpers::{node, session}; + +fn agent_id(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) +} + +fn leaf(id: u128, sess: Option) -> LeafCell { + LeafCell { + id: node(id), + session: sess.map(session), + agent: None, + } +} + +fn single(id: u128, sess: Option) -> LayoutTree { + LayoutTree::single(leaf(id, sess)) +} + +// --------------------------------------------------------------------------- +// split +// --------------------------------------------------------------------------- + +#[test] +fn split_nominal_produces_two_children() { + let tree = single(1, Some(100)); + let out = tree + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + match out.root { + LayoutNode::Split(s) => { + assert_eq!(s.id, node(9)); + assert_eq!(s.direction, Direction::Row); + assert_eq!(s.children.len(), 2); + assert!(s.children.iter().all(|c| c.weight > 0.0)); + } + _ => panic!("expected a split at the root"), + } +} + +#[test] +fn split_is_immutable_source_unchanged() { + let tree = single(1, Some(100)); + let before = tree.clone(); + let _ = tree + .split(node(1), Direction::Column, leaf(2, None), node(9)) + .unwrap(); + assert_eq!(tree, before, "source tree must not be mutated"); +} + +#[test] +fn split_missing_target_is_node_not_found() { + let tree = single(1, None); + let err = tree + .split(node(404), Direction::Row, leaf(2, None), node(9)) + .unwrap_err(); + assert_eq!(err, LayoutError::NodeNotFound(node(404))); +} + +#[test] +fn split_into_a_session_already_present_elsewhere_is_duplicate() { + // root leaf 1 holds session 100; we split it adding a NEW leaf that reuses + // the same session id → duplicate must be rejected by validation. + let tree = single(1, Some(100)); + let err = tree + .split(node(1), Direction::Row, leaf(2, Some(100)), node(9)) + .unwrap_err(); + assert_eq!(err, LayoutError::DuplicateSession(session(100))); +} + +// --------------------------------------------------------------------------- +// merge +// --------------------------------------------------------------------------- + +#[test] +fn merge_keeps_selected_child() { + let tree = single(1, Some(100)) + .split(node(1), Direction::Row, leaf(2, Some(200)), node(9)) + .unwrap(); + // keep index 1 (the new leaf with session 200). + let merged = tree.merge(node(9), 1).unwrap(); + assert_eq!(merged.root, LayoutNode::Leaf(leaf(2, Some(200)))); +} + +#[test] +fn merge_is_immutable() { + let tree = single(1, Some(100)) + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + let before = tree.clone(); + let _ = tree.merge(node(9), 0).unwrap(); + assert_eq!(tree, before); +} + +#[test] +fn merge_unknown_container_is_node_not_found() { + let tree = single(1, None) + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + let err = tree.merge(node(404), 0).unwrap_err(); + assert_eq!(err, LayoutError::NodeNotFound(node(404))); +} + +#[test] +fn merge_index_out_of_range_is_cross_container() { + let tree = single(1, None) + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + let err = tree.merge(node(9), 5).unwrap_err(); + assert_eq!(err, LayoutError::CrossContainer); +} + +// --------------------------------------------------------------------------- +// resize +// --------------------------------------------------------------------------- + +#[test] +fn resize_nominal_updates_weights() { + let tree = single(1, None) + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + let out = tree.resize(node(9), &[2.0, 3.0]).unwrap(); + match out.root { + LayoutNode::Split(s) => { + assert_eq!(s.children[0].weight, 2.0); + assert_eq!(s.children[1].weight, 3.0); + } + _ => panic!("expected split"), + } +} + +#[test] +fn resize_is_immutable() { + let tree = single(1, None) + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + let before = tree.clone(); + let _ = tree.resize(node(9), &[2.0, 3.0]).unwrap(); + assert_eq!(tree, before); +} + +#[test] +fn resize_nonpositive_weight_rejected() { + let tree = single(1, None) + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + let err = tree.resize(node(9), &[0.0, 1.0]).unwrap_err(); + assert_eq!(err, LayoutError::NonPositiveWeight { weight: 0.0 }); + + let err = tree.resize(node(9), &[-1.0, 1.0]).unwrap_err(); + assert_eq!(err, LayoutError::NonPositiveWeight { weight: -1.0 }); +} + +#[test] +fn resize_wrong_arity_is_cross_container() { + let tree = single(1, None) + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + let err = tree.resize(node(9), &[1.0, 2.0, 3.0]).unwrap_err(); + assert_eq!(err, LayoutError::CrossContainer); +} + +#[test] +fn resize_unknown_container_is_node_not_found() { + let tree = single(1, None) + .split(node(1), Direction::Row, leaf(2, None), node(9)) + .unwrap(); + let err = tree.resize(node(404), &[1.0, 2.0]).unwrap_err(); + assert_eq!(err, LayoutError::NodeNotFound(node(404))); +} + +// --------------------------------------------------------------------------- +// move_session +// --------------------------------------------------------------------------- + +fn two_leaves(from_sess: Option, to_sess: Option) -> LayoutTree { + LayoutTree::new(LayoutNode::Split(SplitContainer { + id: node(9), + direction: Direction::Row, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(leaf(1, from_sess)), + weight: 1.0, + }, + WeightedChild { + node: LayoutNode::Leaf(leaf(2, to_sess)), + weight: 1.0, + }, + ], + })) +} + +/// Looks up the session held by the leaf `id` in a tree (test-only helper that +/// walks the public structure, since the domain's lookup is private). +fn session_for(tree: &LayoutTree, id: domain::NodeId) -> Option { + fn walk(n: &LayoutNode, id: domain::NodeId) -> Option> { + match n { + LayoutNode::Leaf(l) if l.id == id => Some(l.session), + LayoutNode::Leaf(_) => None, + LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)), + LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)), + } + } + walk(&tree.root, id).flatten() +} + +#[test] +fn move_session_nominal() { + let tree = two_leaves(Some(100), None); + let out = tree.move_session(node(1), node(2)).unwrap(); + assert_eq!(session_for(&out, node(1)), None); + assert_eq!(session_for(&out, node(2)), Some(session(100))); +} + +#[test] +fn move_session_is_immutable() { + let tree = two_leaves(Some(100), None); + let before = tree.clone(); + let _ = tree.move_session(node(1), node(2)).unwrap(); + assert_eq!(tree, before); +} + +#[test] +fn move_session_from_empty_rejected() { + let tree = two_leaves(None, None); + let err = tree.move_session(node(1), node(2)).unwrap_err(); + assert_eq!(err, LayoutError::CrossContainer); +} + +#[test] +fn move_session_to_occupied_rejected() { + let tree = two_leaves(Some(100), Some(200)); + let err = tree.move_session(node(1), node(2)).unwrap_err(); + assert_eq!(err, LayoutError::CrossContainer); +} + +#[test] +fn move_session_missing_leaf_is_node_not_found() { + let tree = two_leaves(Some(100), None); + assert_eq!( + tree.move_session(node(404), node(2)).unwrap_err(), + LayoutError::NodeNotFound(node(404)) + ); + assert_eq!( + tree.move_session(node(1), node(404)).unwrap_err(), + LayoutError::NodeNotFound(node(404)) + ); +} + +// --------------------------------------------------------------------------- +// validate(): grid invariants & duplicate sessions +// --------------------------------------------------------------------------- + +fn grid(col_w: Vec, row_w: Vec, cells: Vec) -> LayoutTree { + LayoutTree::new(LayoutNode::Grid(GridContainer { + id: node(50), + col_weights: col_w, + row_weights: row_w, + cells, + })) +} + +fn gcell(id: u128, row: u16, col: u16, rs: u16, cs: u16) -> GridCell { + GridCell { + node: LayoutNode::Leaf(leaf(id, None)), + row, + col, + row_span: rs, + col_span: cs, + } +} + +#[test] +fn grid_fully_covered_2x2_ok() { + let cells = vec![ + gcell(1, 0, 0, 1, 1), + gcell(2, 0, 1, 1, 1), + gcell(3, 1, 0, 1, 1), + gcell(4, 1, 1, 1, 1), + ]; + let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells); + assert!(t.validate().is_ok()); +} + +#[test] +fn grid_merged_span_full_coverage_ok() { + // one cell spanning the whole top row + two cells on the bottom row. + let cells = vec![ + gcell(1, 0, 0, 1, 2), // top row merged + gcell(2, 1, 0, 1, 1), + gcell(3, 1, 1, 1, 1), + ]; + let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells); + assert!(t.validate().is_ok()); +} + +#[test] +fn grid_overlap_rejected() { + let cells = vec![ + gcell(1, 0, 0, 1, 2), + gcell(2, 0, 1, 1, 1), // overlaps column 1 of the spanning cell + gcell(3, 1, 0, 1, 1), + gcell(4, 1, 1, 1, 1), + ]; + let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells); + assert!(matches!( + t.validate().unwrap_err(), + LayoutError::OverlappingCells { .. } + )); +} + +#[test] +fn grid_uncovered_surface_rejected() { + // 2x2 grid but only one cell → three cells uncovered. + let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], vec![gcell(1, 0, 0, 1, 1)]); + assert!(matches!( + t.validate().unwrap_err(), + LayoutError::UncoveredCell { .. } + )); +} + +#[test] +fn grid_span_out_of_bounds_rejected() { + let t = grid(vec![1.0], vec![1.0], vec![gcell(1, 0, 0, 2, 1)]); + assert!(matches!( + t.validate().unwrap_err(), + LayoutError::SpanOutOfBounds { .. } + )); +} + +#[test] +fn grid_zero_span_rejected() { + let t = grid(vec![1.0], vec![1.0], vec![gcell(1, 0, 0, 0, 1)]); + assert_eq!(t.validate().unwrap_err(), LayoutError::InvalidSpan); +} + +#[test] +fn grid_nonpositive_weight_rejected() { + let t = grid(vec![0.0], vec![1.0], vec![gcell(1, 0, 0, 1, 1)]); + assert_eq!( + t.validate().unwrap_err(), + LayoutError::NonPositiveWeight { weight: 0.0 } + ); +} + +#[test] +fn duplicate_session_across_leaves_rejected() { + let tree = two_leaves(Some(100), Some(100)); + assert_eq!( + tree.validate().unwrap_err(), + LayoutError::DuplicateSession(session(100)) + ); +} + +#[test] +fn empty_split_rejected() { + let t = LayoutTree::new(LayoutNode::Split(SplitContainer { + id: node(9), + direction: Direction::Row, + children: vec![], + })); + assert_eq!(t.validate().unwrap_err(), LayoutError::EmptySplit); +} + +// --------------------------------------------------------------------------- +// set_session (L4: cell ↔ terminal binding bridge) +// --------------------------------------------------------------------------- + +#[test] +fn set_session_attaches_to_leaf() { + let tree = single(1, None); + let out = tree.set_session(node(1), Some(session(100))).unwrap(); + match out.root { + LayoutNode::Leaf(l) => { + assert_eq!(l.id, node(1)); + assert_eq!(l.session, Some(session(100))); + } + _ => panic!("expected a leaf at the root"), + } +} + +#[test] +fn set_session_detaches_with_none() { + let tree = single(1, Some(100)); + let out = tree.set_session(node(1), None).unwrap(); + match out.root { + LayoutNode::Leaf(l) => assert_eq!(l.session, None), + _ => panic!("expected a leaf at the root"), + } +} + +#[test] +fn set_session_is_immutable_source_unchanged() { + let tree = single(1, None); + let before = tree.clone(); + let _ = tree.set_session(node(1), Some(session(100))).unwrap(); + assert_eq!(tree, before, "source tree must not be mutated"); +} + +#[test] +fn set_session_reaches_nested_leaf() { + // Attach onto the second leaf of a split, leaving the first empty. + let tree = two_leaves(None, None); + let out = tree.set_session(node(2), Some(session(7))).unwrap(); + match out.root { + LayoutNode::Split(s) => match &s.children[1].node { + LayoutNode::Leaf(l) => assert_eq!(l.session, Some(session(7))), + _ => panic!("expected a leaf child"), + }, + _ => panic!("expected a split root"), + } +} + +#[test] +fn set_session_missing_leaf_is_node_not_found() { + let tree = single(1, None); + let err = tree.set_session(node(404), Some(session(100))).unwrap_err(); + assert_eq!(err, LayoutError::NodeNotFound(node(404))); +} + +#[test] +fn set_session_duplicate_across_leaves_rejected() { + // Leaf 1 already hosts session 100; attaching the same session to leaf 2 + // must fail validation rather than producing a duplicate. + let tree = two_leaves(Some(100), None); + let err = tree.set_session(node(2), Some(session(100))).unwrap_err(); + assert_eq!(err, LayoutError::DuplicateSession(session(100))); +} + +// --------------------------------------------------------------------------- +// set_cell_agent (#3: per-cell agent) +// --------------------------------------------------------------------------- + +#[test] +fn set_cell_agent_attaches_agent_to_leaf() { + let tree = single(1, None); + let out = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap(); + match out.root { + LayoutNode::Leaf(l) => { + assert_eq!(l.id, node(1)); + assert_eq!(l.agent, Some(agent_id(42))); + } + _ => panic!("expected a leaf at root"), + } +} + +#[test] +fn set_cell_agent_detaches_with_none() { + // First attach, then detach. + let tree = single(1, None); + let with_agent = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap(); + let out = with_agent.set_cell_agent(node(1), None).unwrap(); + match out.root { + LayoutNode::Leaf(l) => assert_eq!(l.agent, None), + _ => panic!("expected a leaf at root"), + } +} + +#[test] +fn set_cell_agent_is_immutable_source_unchanged() { + let tree = single(1, None); + let before = tree.clone(); + let _ = tree.set_cell_agent(node(1), Some(agent_id(99))).unwrap(); + assert_eq!(tree, before, "source tree must not be mutated"); +} + +#[test] +fn set_cell_agent_missing_leaf_is_node_not_found() { + let tree = single(1, None); + let err = tree.set_cell_agent(node(404), Some(agent_id(1))).unwrap_err(); + assert_eq!(err, LayoutError::NodeNotFound(node(404))); +} + +#[test] +fn set_cell_agent_preserves_session() { + // Session must survive an agent attachment. + let tree = single(1, Some(100)); + let out = tree.set_cell_agent(node(1), Some(agent_id(7))).unwrap(); + match out.root { + LayoutNode::Leaf(l) => { + assert_eq!(l.session, Some(session(100))); + assert_eq!(l.agent, Some(agent_id(7))); + } + _ => panic!("expected leaf"), + } +} diff --git a/crates/domain/tests/serde_roundtrip.rs b/crates/domain/tests/serde_roundtrip.rs new file mode 100644 index 0000000..5e472db --- /dev/null +++ b/crates/domain/tests/serde_roundtrip.rs @@ -0,0 +1,295 @@ +//! JSON round-trip (serde) of persisted domain types, plus camelCase / tagging +//! checks (ARCHITECTURE §7.3, §9). + +mod helpers; + +use domain::{ + Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction, + LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef, + SplitContainer, SshAuth, TemplateVersion, WeightedChild, +}; +use helpers::{node, session}; +use uuid::Uuid; + +fn pid(n: u128) -> domain::ProjectId { + domain::ProjectId::from_uuid(Uuid::from_u128(n)) +} +fn profid(n: u128) -> domain::ProfileId { + domain::ProfileId::from_uuid(Uuid::from_u128(n)) +} +fn tid(n: u128) -> domain::TemplateId { + domain::TemplateId::from_uuid(Uuid::from_u128(n)) +} +fn aid(n: u128) -> domain::AgentId { + domain::AgentId::from_uuid(Uuid::from_u128(n)) +} + +fn roundtrip(value: &T) -> T +where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, +{ + let json = serde_json::to_string(value).expect("serialize"); + serde_json::from_str(&json).expect("deserialize") +} + +// --------------------------------------------------------------------------- +// Project +// --------------------------------------------------------------------------- + +#[test] +fn project_roundtrip() { + let p = Project::new( + pid(1), + "demo", + ProjectPath::new("/srv/demo").unwrap(), + RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap(), + 123, + ) + .unwrap(); + assert_eq!(roundtrip(&p), p); +} + +#[test] +fn project_uses_camel_case_and_tagged_remote() { + let p = Project::new( + pid(1), + "demo", + ProjectPath::new("/srv/demo").unwrap(), + RemoteRef::local(), + 123, + ) + .unwrap(); + let json = serde_json::to_string(&p).unwrap(); + assert!(json.contains("\"createdAt\":123"), "json was {json}"); + // RemoteRef tagged with `kind`, camelCased "local". + assert!(json.contains("\"kind\":\"local\""), "json was {json}"); +} + +// --------------------------------------------------------------------------- +// RemoteRef variants +// --------------------------------------------------------------------------- + +#[test] +fn remote_ssh_roundtrip_and_tags() { + let r = RemoteRef::ssh("host", 2222, "me", SshAuth::Key { path: "/k".into() }, "/srv").unwrap(); + assert_eq!(roundtrip(&r), r); + let json = serde_json::to_string(&r).unwrap(); + assert!(json.contains("\"kind\":\"ssh\""), "json was {json}"); + // SshAuth tagged with `type`. + assert!(json.contains("\"type\":\"key\""), "json was {json}"); + // Enum-variant fields must be camelCased on the wire (ARCHITECTURE §9). + assert!(json.contains("\"remoteRoot\""), "json was {json}"); + assert!(!json.contains("\"remote_root\""), "json was {json}"); +} + +#[test] +fn remote_wsl_roundtrip() { + let r = RemoteRef::wsl("Ubuntu").unwrap(); + assert_eq!(roundtrip(&r), r); +} + +// --------------------------------------------------------------------------- +// AgentProfile + ContextInjection (tagged with `strategy`) +// --------------------------------------------------------------------------- + +#[test] +fn profile_roundtrip_all_injection_variants() { + for ci in [ + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ContextInjection::flag("-f {path}").unwrap(), + ContextInjection::stdin(), + ContextInjection::env("CTX").unwrap(), + ] { + let p = AgentProfile::new( + profid(1), + "Name", + "claude", + vec!["a".into(), "b".into()], + ci, + Some("claude --version".into()), + "{projectRoot}", + ) + .unwrap(); + assert_eq!(roundtrip(&p), p); + } +} + +#[test] +fn context_injection_strategy_tag_is_camel_case() { + let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()) + .unwrap(); + assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}"); + let json = serde_json::to_string(&ContextInjection::stdin()).unwrap(); + assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}"); +} + +#[test] +fn profile_cwd_template_is_camel_case() { + let p = AgentProfile::new( + profid(1), + "n", + "c", + vec![], + ContextInjection::stdin(), + None, + "{projectRoot}", + ) + .unwrap(); + let json = serde_json::to_string(&p).unwrap(); + assert!(json.contains("\"cwdTemplate\""), "json was {json}"); +} + +// --------------------------------------------------------------------------- +// AgentTemplate +// --------------------------------------------------------------------------- + +#[test] +fn template_roundtrip() { + let t = AgentTemplate::new(tid(1), "T", MarkdownDoc::new("# hi"), profid(2)) + .unwrap() + .with_updated_content(MarkdownDoc::new("# bye")); + assert_eq!(roundtrip(&t), t); + let json = serde_json::to_string(&t).unwrap(); + assert!(json.contains("\"contentMd\""), "json was {json}"); + assert!(json.contains("\"defaultProfileId\""), "json was {json}"); +} + +// --------------------------------------------------------------------------- +// Agent + manifest +// --------------------------------------------------------------------------- + +#[test] +fn agent_roundtrip_from_template() { + let a = Agent::new( + aid(1), + "Backend", + "agents/backend.md", + profid(2), + AgentOrigin::FromTemplate { + template_id: tid(3), + synced_template_version: TemplateVersion(4), + }, + true, + ) + .unwrap(); + assert_eq!(roundtrip(&a), a); + let json = serde_json::to_string(&a).unwrap(); + assert!(json.contains("\"contextPath\""), "json was {json}"); + // AgentOrigin tagged with `type`, camelCased. + assert!(json.contains("\"type\":\"fromTemplate\""), "json was {json}"); + // Inner fields must be camelCased per ARCHITECTURE §9.1: + // { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }. + assert!(json.contains("\"templateId\""), "json was {json}"); + assert!(json.contains("\"syncedTemplateVersion\":4"), "json was {json}"); + assert!(!json.contains("\"template_id\""), "json was {json}"); + assert!(!json.contains("\"synced_template_version\""), "json was {json}"); + assert!(!json.contains("\"synced_version\""), "json was {json}"); +} + +// --------------------------------------------------------------------------- +// SessionKind (tagged enum: `type`, camelCased variant fields) +// --------------------------------------------------------------------------- + +#[test] +fn session_kind_agent_roundtrip_and_camel_case() { + use domain::SessionKind; + let k = SessionKind::Agent { agent_id: aid(7) }; + assert_eq!(roundtrip(&k), k); + let json = serde_json::to_string(&k).unwrap(); + assert!(json.contains("\"type\":\"agent\""), "json was {json}"); + assert!(json.contains("\"agentId\""), "json was {json}"); + assert!(!json.contains("\"agent_id\""), "json was {json}"); + + // Plain variant carries no fields. + let plain = serde_json::to_string(&SessionKind::Plain).unwrap(); + assert!(plain.contains("\"type\":\"plain\""), "json was {plain}"); +} + +#[test] +fn manifest_roundtrip_and_camel_case() { + let e1 = ManifestEntry::new( + aid(1), + "Alpha", + "agents/a.md", + profid(9), + Some(tid(2)), + true, + Some(TemplateVersion(5)), + ) + .unwrap(); + let e2 = ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap(); + let m = AgentManifest::new(1, vec![e1, e2]).unwrap(); + assert_eq!(roundtrip(&m), m); + let json = serde_json::to_string(&m).unwrap(); + // entries are serialized under "agents". + assert!(json.contains("\"agents\":["), "json was {json}"); + assert!(json.contains("\"mdPath\""), "json was {json}"); + assert!(json.contains("\"syncedTemplateVersion\":5"), "json was {json}"); + // Non-synchronized entry omits optional template fields (skip_serializing_if). + assert!(!json.contains("\"templateId\":null"), "json was {json}"); +} + +// --------------------------------------------------------------------------- +// LayoutTree (tagged enum: type/node) +// --------------------------------------------------------------------------- + +#[test] +fn layout_roundtrip() { + let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { + id: node(9), + direction: Direction::Column, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(LeafCell { + id: node(1), + session: Some(session(100)), + agent: None, + }), + weight: 1.5, + }, + WeightedChild { + node: LayoutNode::Leaf(LeafCell { + id: node(2), + session: None, + agent: None, + }), + weight: 2.5, + }, + ], + })); + assert_eq!(roundtrip(&tree), tree); + let json = serde_json::to_string(&tree).unwrap(); + // enum adjacently tagged: type + node ; direction camelCase. + assert!(json.contains("\"type\":\"split\""), "json was {json}"); + assert!(json.contains("\"type\":\"leaf\""), "json was {json}"); + assert!(json.contains("\"direction\":\"column\""), "json was {json}"); + // empty session leaf omits the field. + assert!(!json.contains("\"session\":null"), "json was {json}"); +} + +#[test] +fn leaf_with_agent_roundtrip_and_omits_null() { + use domain::ids::AgentId; + let agent_uuid = Uuid::from_u128(0xABC); + let tree = LayoutTree::new(LayoutNode::Leaf(LeafCell { + id: node(1), + session: None, + agent: Some(AgentId::from_uuid(agent_uuid)), + })); + let rt = roundtrip(&tree); + match rt.root { + LayoutNode::Leaf(l) => assert_eq!(l.agent, Some(AgentId::from_uuid(agent_uuid))), + _ => panic!("expected leaf"), + } + let json = serde_json::to_string(&tree).unwrap(); + // agent present when set + assert!(json.contains("\"agent\""), "agent field should be present when set; json was {json}"); + // null variant omitted + let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell { + id: node(2), + session: None, + agent: None, + })); + let json2 = serde_json::to_string(&tree_no_agent).unwrap(); + assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}"); +} diff --git a/crates/domain/tests/window.rs b/crates/domain/tests/window.rs new file mode 100644 index 0000000..bb5ab91 --- /dev/null +++ b/crates/domain/tests/window.rs @@ -0,0 +1,93 @@ +//! L10 tests for the pure `Workspace::move_tab_to_new_window` operation +//! (ARCHITECTURE §10): a tab is *moved*, never duplicated; an emptied source +//! window is dropped; an active moved tab hands activity back to a sibling. + +use domain::{ + LayoutNode, LayoutTree, LayoutError, LeafCell, NodeId, ProjectId, Tab, TabId, Window, + WindowId, Workspace, +}; +use uuid::Uuid; + +fn tid(n: u128) -> TabId { + TabId::from_uuid(Uuid::from_u128(n)) +} +fn wid(n: u128) -> WindowId { + WindowId::from_uuid(Uuid::from_u128(n)) +} +fn leaf_tree() -> LayoutTree { + LayoutTree::new(LayoutNode::Leaf(LeafCell { + id: NodeId::from_uuid(Uuid::from_u128(900)), + session: None, + agent: None, + })) +} +fn tab(n: u128) -> Tab { + Tab { + id: tid(n), + project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)), + layout: leaf_tree(), + } +} + +/// Count how many windows contain a tab with the given id. +fn occurrences(ws: &Workspace, tab: TabId) -> usize { + ws.windows + .iter() + .filter(|w| w.tabs.iter().any(|t| t.id == tab)) + .count() +} + +#[test] +fn move_tab_from_multi_tab_window_keeps_source_and_creates_new() { + let src = Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap(); + let ws = Workspace { windows: vec![src] }; + + let next = ws.move_tab_to_new_window(tid(1), wid(99)).unwrap(); + + assert_eq!(next.windows.len(), 2, "source kept + new window"); + // The moved tab appears exactly once (moved, not duplicated). + assert_eq!(occurrences(&next, tid(1)), 1); + // Source window kept tab 2 and fell back its active tab to it. + let source = next.windows.iter().find(|w| w.id == wid(1)).unwrap(); + assert_eq!(source.tabs.len(), 1); + assert_eq!(source.active_tab, tid(2)); + // New window holds the moved tab, active. + let detached = next.windows.iter().find(|w| w.id == wid(99)).unwrap(); + assert_eq!(detached.tabs.len(), 1); + assert_eq!(detached.active_tab, tid(1)); +} + +#[test] +fn move_only_tab_removes_the_emptied_source_window() { + let src = Window::new(wid(1), vec![tab(1)], tid(1)).unwrap(); + let ws = Workspace { windows: vec![src] }; + + let next = ws.move_tab_to_new_window(tid(1), wid(99)).unwrap(); + + assert_eq!(next.windows.len(), 1, "emptied source dropped"); + assert_eq!(next.windows[0].id, wid(99)); + assert_eq!(occurrences(&next, tid(1)), 1); +} + +#[test] +fn move_unknown_tab_is_rejected() { + let ws = Workspace { + windows: vec![Window::new(wid(1), vec![tab(1)], tid(1)).unwrap()], + }; + assert!(matches!( + ws.move_tab_to_new_window(tid(404), wid(99)).unwrap_err(), + LayoutError::TabNotFound(t) if t == tid(404) + )); +} + +#[test] +fn move_non_active_tab_leaves_source_active_unchanged() { + let src = Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap(); + let ws = Workspace { windows: vec![src] }; + + let next = ws.move_tab_to_new_window(tid(2), wid(99)).unwrap(); + + let source = next.windows.iter().find(|w| w.id == wid(1)).unwrap(); + assert_eq!(source.active_tab, tid(1), "active tab unchanged"); + assert_eq!(source.tabs.len(), 1); +} diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml new file mode 100644 index 0000000..450819f --- /dev/null +++ b/crates/infrastructure/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "infrastructure" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "IdeA — infrastructure layer: concrete adapters implementing the domain ports (fs, event bus, clock, id)." + +[dependencies] +domain = { workspace = true } +# `process` (additive) powers LocalProcessSpawner; the workspace baseline keeps +# rt/macros/sync/fs/io-util. +tokio = { workspace = true, features = ["process"] } +uuid = { workspace = true } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +portable-pty = "0.9" +git2 = { workspace = true } diff --git a/crates/infrastructure/src/clock/mod.rs b/crates/infrastructure/src/clock/mod.rs new file mode 100644 index 0000000..088a5b2 --- /dev/null +++ b/crates/infrastructure/src/clock/mod.rs @@ -0,0 +1,27 @@ +//! [`SystemClock`] — production [`Clock`] backed by the system wall clock. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use domain::ports::Clock; + +/// Real clock returning the current epoch time in milliseconds. +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemClock; + +impl SystemClock { + /// Creates a new [`SystemClock`]. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl Clock for SystemClock { + fn now_millis(&self) -> i64 { + // Saturating cast is fine: epoch millis fits in i64 until year 292M. + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } +} diff --git a/crates/infrastructure/src/eventbus/mod.rs b/crates/infrastructure/src/eventbus/mod.rs new file mode 100644 index 0000000..69b59b4 --- /dev/null +++ b/crates/infrastructure/src/eventbus/mod.rs @@ -0,0 +1,86 @@ +//! [`TokioBroadcastEventBus`] — in-process [`EventBus`] backed by +//! [`tokio::sync::broadcast`]. +//! +//! `publish` fans out to all current subscribers. `subscribe` returns the +//! domain's [`EventStream`] (a `Box`): a **blocking** iterator +//! that pulls events off the broadcast receiver. The Tauri event relay drives +//! it from a dedicated thread / blocking task, so blocking is acceptable and +//! keeps the domain port signature (`-> EventStream`) intact without forcing it +//! to become async. + +use domain::events::DomainEvent; +use domain::ports::{EventBus, EventStream}; +use tokio::sync::broadcast; + +/// Default capacity of the broadcast ring buffer. +const DEFAULT_CAPACITY: usize = 1024; + +/// An in-process event bus relaying [`DomainEvent`]s to all subscribers via a +/// Tokio broadcast channel. +#[derive(Clone)] +pub struct TokioBroadcastEventBus { + sender: broadcast::Sender, +} + +impl TokioBroadcastEventBus { + /// Creates a bus with the default buffer capacity. + #[must_use] + pub fn new() -> Self { + Self::with_capacity(DEFAULT_CAPACITY) + } + + /// Creates a bus with an explicit buffer capacity. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + let (sender, _rx) = broadcast::channel(capacity); + Self { sender } + } + + /// Returns a raw async receiver, useful for relays that prefer to consume + /// events on the Tokio runtime rather than via the blocking [`EventStream`]. + #[must_use] + pub fn raw_receiver(&self) -> broadcast::Receiver { + self.sender.subscribe() + } +} + +impl Default for TokioBroadcastEventBus { + fn default() -> Self { + Self::new() + } +} + +impl EventBus for TokioBroadcastEventBus { + fn publish(&self, event: DomainEvent) { + // A send error only means there are currently no subscribers; that is + // not an error condition for a fire-and-forget bus. + let _ = self.sender.send(event); + } + + fn subscribe(&self) -> EventStream { + Box::new(BroadcastIter { + rx: self.sender.subscribe(), + }) + } +} + +/// Blocking iterator adapter over a broadcast receiver. `next()` blocks until an +/// event arrives; it ends when the channel is closed, and skips past `Lagged` +/// notices (dropping the lagged count and continuing). +struct BroadcastIter { + rx: broadcast::Receiver, +} + +impl Iterator for BroadcastIter { + type Item = DomainEvent; + + fn next(&mut self) -> Option { + loop { + match self.rx.blocking_recv() { + Ok(event) => return Some(event), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + } +} diff --git a/crates/infrastructure/src/fs/mod.rs b/crates/infrastructure/src/fs/mod.rs new file mode 100644 index 0000000..126935c --- /dev/null +++ b/crates/infrastructure/src/fs/mod.rs @@ -0,0 +1,102 @@ +//! [`LocalFileSystem`] — minimal [`FileSystem`] adapter over [`tokio::fs`]. +//! +//! Maps [`RemotePath`] (a location-neutral string path) onto the local OS +//! filesystem. Only the operations needed to wire up the composition root and +//! later lots are implemented; richer behaviour (and SSH/WSL siblings) arrives +//! in L2/L9. + +use std::io; +use std::path::Path; + +use async_trait::async_trait; +use domain::ports::{DirEntry, FileSystem, FsError, RemotePath}; +use tokio::fs; + +/// Filesystem adapter backed by the local OS via `tokio::fs`. +#[derive(Debug, Default, Clone, Copy)] +pub struct LocalFileSystem; + +impl LocalFileSystem { + /// Creates a new [`LocalFileSystem`]. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +/// Maps a [`std::io::Error`] to the domain's [`FsError`], preserving the +/// not-found / permission distinctions the application layer cares about. +fn map_io(path: &RemotePath, err: &io::Error) -> FsError { + match err.kind() { + io::ErrorKind::NotFound => FsError::NotFound(path.as_str().to_owned()), + io::ErrorKind::PermissionDenied => FsError::PermissionDenied(path.as_str().to_owned()), + _ => FsError::Io(format!("{}: {err}", path.as_str())), + } +} + +#[async_trait] +impl FileSystem for LocalFileSystem { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + fs::read(path.as_str()) + .await + .map_err(|e| map_io(path, &e)) + } + + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + fs::write(path.as_str(), data) + .await + .map_err(|e| map_io(path, &e)) + } + + async fn exists(&self, path: &RemotePath) -> Result { + match fs::metadata(path.as_str()).await { + Ok(_) => Ok(true), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(map_io(path, &e)), + } + } + + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { + fs::create_dir_all(path.as_str()) + .await + .map_err(|e| map_io(path, &e)) + } + + async fn list(&self, path: &RemotePath) -> Result, FsError> { + let mut entries = Vec::new(); + let mut read_dir = fs::read_dir(path.as_str()) + .await + .map_err(|e| map_io(path, &e))?; + + while let Some(entry) = read_dir.next_entry().await.map_err(|e| map_io(path, &e))? { + let is_dir = entry + .file_type() + .await + .map(|t| t.is_dir()) + .unwrap_or(false); + entries.push(DirEntry { + name: entry.file_name().to_string_lossy().into_owned(), + is_dir, + }); + } + Ok(entries) + } + + async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError> { + symlink_impl(Path::new(src.as_str()), Path::new(dst.as_str())) + .await + .map_err(|e| map_io(dst, &e)) + } +} + +#[cfg(unix)] +async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> { + fs::symlink(src, dst).await +} + +#[cfg(windows)] +async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> { + // On Windows we default to a file symlink; directory symlinks require a + // different call and are handled when the store layer (L2/L6) needs them. + fs::symlink_file(src, dst).await +} diff --git a/crates/infrastructure/src/git/mod.rs b/crates/infrastructure/src/git/mod.rs new file mode 100644 index 0000000..f8fef85 --- /dev/null +++ b/crates/infrastructure/src/git/mod.rs @@ -0,0 +1,275 @@ +//! [`Git2Repository`] — local Git adapter implementing the [`GitPort`] port via +//! libgit2 (`git2`), ARCHITECTURE §5, L8. +//! +//! Scope is **local** operations (status, stage/unstage, commit, branches, +//! checkout, log, init). Network operations (`pull`/`push`) need remote + +//! credential handling and are deferred to L9 (`RemoteGitRepository` over SSH/WSL +//! and credential callbacks); here they return a clear [`GitError::Operation`]. +//! +//! # Async & `Send` +//! +//! [`GitPort`] is `#[async_trait]`, but libgit2 is synchronous and its handles +//! ([`git2::Repository`]) are `!Send`. Each method opens the repository, does all +//! its work, and drops it **within a single poll** — there is no `.await` while a +//! repo/handle is alive — so the returned futures are `Send` and the adapter is +//! safe behind `Arc` on the multi-threaded runtime. + +use async_trait::async_trait; +use git2::{BranchType, ErrorCode, Repository, Status, StatusOptions}; + +use std::collections::HashMap; + +use domain::ports::{GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit}; +use domain::project::ProjectPath; + +/// Local Git adapter backed by libgit2. +#[derive(Clone, Default)] +pub struct Git2Repository; + +impl Git2Repository { + /// Builds the adapter (stateless; a repository is opened per call from the + /// project root, so one instance serves every project). + #[must_use] + pub fn new() -> Self { + Self + } +} + +/// Maps a libgit2 error to a domain [`GitError`] (its message, not the raw type). +fn op(e: git2::Error) -> GitError { + GitError::Operation(e.message().to_owned()) +} + +/// Opens the repository at `root`, distinguishing "not a repo" from other errors. +fn open(root: &ProjectPath) -> Result { + Repository::open(root.as_str()).map_err(|e| { + if e.code() == ErrorCode::NotFound { + GitError::NotFound + } else { + op(e) + } + }) +} + +/// Index-side status flags (a path with any of these has staged changes). +const STAGED: Status = Status::INDEX_NEW + .union(Status::INDEX_MODIFIED) + .union(Status::INDEX_DELETED) + .union(Status::INDEX_RENAMED) + .union(Status::INDEX_TYPECHANGE); + +#[async_trait] +impl GitPort for Git2Repository { + async fn init(&self, root: &ProjectPath) -> Result<(), GitError> { + Repository::init(root.as_str()).map_err(op)?; + Ok(()) + } + + async fn status(&self, root: &ProjectPath) -> Result, GitError> { + let repo = open(root)?; + let mut opts = StatusOptions::new(); + opts.include_untracked(true).recurse_untracked_dirs(true); + let statuses = repo.statuses(Some(&mut opts)).map_err(op)?; + let mut out = Vec::new(); + for entry in statuses.iter() { + let s = entry.status(); + if s.is_ignored() { + continue; + } + if let Some(path) = entry.path() { + out.push(GitFileStatus { + path: path.to_owned(), + staged: s.intersects(STAGED), + }); + } + } + Ok(out) + } + + async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError> { + let repo = open(root)?; + let mut index = repo.index().map_err(op)?; + index.add_path(std::path::Path::new(path)).map_err(op)?; + index.write().map_err(op)?; + Ok(()) + } + + async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError> { + let repo = open(root)?; + match repo.head() { + // Reset the path in the index back to its HEAD state. + Ok(head) => { + let obj = head.peel(git2::ObjectType::Commit).map_err(op)?; + repo.reset_default(Some(&obj), [path]).map_err(op)?; + } + // Unborn HEAD (no commit yet): "unstage" means drop it from the index. + Err(_) => { + let mut index = repo.index().map_err(op)?; + index.remove_path(std::path::Path::new(path)).map_err(op)?; + index.write().map_err(op)?; + } + } + Ok(()) + } + + async fn commit(&self, root: &ProjectPath, message: &str) -> Result { + let repo = open(root)?; + let mut index = repo.index().map_err(op)?; + let tree_oid = index.write_tree().map_err(op)?; + let tree = repo.find_tree(tree_oid).map_err(op)?; + + // Prefer the configured identity; fall back to a stable local one so a + // fresh repo with no user.name/email can still commit. + let sig = repo + .signature() + .or_else(|_| git2::Signature::now("IdeA", "idea@localhost")) + .map_err(op)?; + + let parent = match repo.head() { + Ok(head) => Some(head.peel_to_commit().map_err(op)?), + Err(_) => None, + }; + let parents: Vec<&git2::Commit> = parent.iter().collect(); + + let oid = repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) + .map_err(op)?; + + Ok(GitCommitInfo { + hash: oid.to_string(), + summary: message.lines().next().unwrap_or("").to_owned(), + }) + } + + async fn branches(&self, root: &ProjectPath) -> Result, GitError> { + let repo = open(root)?; + let mut names = Vec::new(); + for branch in repo.branches(Some(BranchType::Local)).map_err(op)? { + let (branch, _) = branch.map_err(op)?; + if let Some(name) = branch.name().map_err(op)? { + names.push(name.to_owned()); + } + } + Ok(names) + } + + async fn current_branch(&self, root: &ProjectPath) -> Result, GitError> { + let repo = open(root)?; + let head = match repo.head() { + Ok(head) => head, + // Unborn branch (no commits yet): no current branch to report. + Err(e) if e.code() == ErrorCode::UnbornBranch => return Ok(None), + Err(e) => return Err(op(e)), + }; + // A detached HEAD (or a non-branch ref) has no branch name. + Ok(if head.is_branch() { + head.shorthand().map(ToOwned::to_owned) + } else { + None + }) + } + + async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError> { + let repo = open(root)?; + let obj = repo.revparse_single(branch).map_err(op)?; + repo.checkout_tree(&obj, None).map_err(op)?; + repo.set_head(&format!("refs/heads/{branch}")).map_err(op)?; + Ok(()) + } + + async fn log( + &self, + root: &ProjectPath, + limit: usize, + ) -> Result, GitError> { + let repo = open(root)?; + let mut revwalk = repo.revwalk().map_err(op)?; + // No commits yet ⇒ nothing to walk. + if revwalk.push_head().is_err() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for oid in revwalk.take(limit) { + let oid = oid.map_err(op)?; + let commit = repo.find_commit(oid).map_err(op)?; + out.push(GitCommitInfo { + hash: oid.to_string(), + summary: commit.summary().unwrap_or("").to_owned(), + }); + } + Ok(out) + } + + async fn log_graph( + &self, + root: &ProjectPath, + limit: usize, + ) -> Result, GitError> { + let repo = open(root)?; + + // Build a map from OID → ref short-names for label attachment. + let mut ref_map: HashMap> = HashMap::new(); + if let Ok(references) = repo.references() { + for reference in references.flatten() { + // Only handle symbolic and direct refs; skip errors. + let target_oid = reference.resolve().ok().and_then(|r| r.target()); + if let Some(oid) = target_oid { + let label = match reference.shorthand() { + Some(name) => { + // Distinguish tags from branches by prefix. + if reference.is_tag() { + format!("tag: {name}") + } else { + name.to_owned() + } + } + None => continue, + }; + ref_map.entry(oid).or_default().push(label); + } + } + } + + let mut revwalk = repo.revwalk().map_err(op)?; + revwalk + .set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) + .map_err(op)?; + + // Push all local branches; if the repo is empty this produces no OIDs. + let _ = revwalk.push_glob("refs/heads/*"); + + let mut out = Vec::new(); + for oid_result in revwalk.take(limit) { + let oid = oid_result.map_err(op)?; + let commit = repo.find_commit(oid).map_err(op)?; + let parents = commit + .parent_ids() + .map(|p| p.to_string()) + .collect::>(); + let author = commit.author().name().unwrap_or("").to_owned(); + let timestamp = commit.time().seconds(); + let refs = ref_map.get(&oid).cloned().unwrap_or_default(); + out.push(GraphCommit { + hash: oid.to_string(), + summary: commit.summary().unwrap_or("").to_owned(), + parents, + refs, + author, + timestamp, + }); + } + Ok(out) + } + + async fn pull(&self, _root: &ProjectPath) -> Result<(), GitError> { + Err(GitError::Operation( + "pull requires remote/credential configuration (L9)".to_owned(), + )) + } + + async fn push(&self, _root: &ProjectPath) -> Result<(), GitError> { + Err(GitError::Operation( + "push requires remote/credential configuration (L9)".to_owned(), + )) + } +} diff --git a/crates/infrastructure/src/id/mod.rs b/crates/infrastructure/src/id/mod.rs new file mode 100644 index 0000000..ee82247 --- /dev/null +++ b/crates/infrastructure/src/id/mod.rs @@ -0,0 +1,22 @@ +//! [`UuidGenerator`] — production [`IdGenerator`] producing random v4 UUIDs. + +use domain::ports::IdGenerator; +use uuid::Uuid; + +/// Real id generator producing random (v4) UUIDs. +#[derive(Debug, Default, Clone, Copy)] +pub struct UuidGenerator; + +impl UuidGenerator { + /// Creates a new [`UuidGenerator`]. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl IdGenerator for UuidGenerator { + fn new_uuid(&self) -> Uuid { + Uuid::new_v4() + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs new file mode 100644 index 0000000..2cb9cb4 --- /dev/null +++ b/crates/infrastructure/src/lib.rs @@ -0,0 +1,35 @@ +//! # IdeA — Infrastructure layer +//! +//! Concrete **adapters** implementing the domain ports (ARCHITECTURE §5). All +//! real-world I/O (`tokio::fs`, broadcast channels, system clock, UUIDs) lives +//! here, never in `domain` or `application`. +//! +//! L1 shipped the DI/event-relay adapters: [`LocalFileSystem`], +//! [`TokioBroadcastEventBus`], [`SystemClock`], [`UuidGenerator`]. L2 adds the +//! project persistence adapter [`FsProjectStore`]. L3 adds the local PTY adapter +//! [`PortablePtyAdapter`]. Git/remote/template adapters arrive in later lots. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod clock; +pub mod eventbus; +pub mod fs; +pub mod git; +pub mod id; +pub mod process; +pub mod pty; +pub mod remote; +pub mod runtime; +pub mod store; + +pub use clock::SystemClock; +pub use eventbus::TokioBroadcastEventBus; +pub use fs::LocalFileSystem; +pub use git::Git2Repository; +pub use id::UuidGenerator; +pub use process::LocalProcessSpawner; +pub use pty::PortablePtyAdapter; +pub use remote::{remote_host, LocalHost}; +pub use runtime::CliAgentRuntime; +pub use store::{FsProfileStore, FsProjectStore, FsTemplateStore, IdeaiContextStore}; diff --git a/crates/infrastructure/src/process/mod.rs b/crates/infrastructure/src/process/mod.rs new file mode 100644 index 0000000..ec62ec2 --- /dev/null +++ b/crates/infrastructure/src/process/mod.rs @@ -0,0 +1,53 @@ +//! [`LocalProcessSpawner`] — local [`ProcessSpawner`] over `tokio::process` +//! (ARCHITECTURE §5). +//! +//! Runs a **non-interactive** process to completion and captures +//! stdout/stderr/exit code. Used by `DetectProfiles` (via [`CliAgentRuntime`]) +//! and any future short-lived command (git fallbacks, scripts). Interactive +//! processes go through the PTY port instead. + +use async_trait::async_trait; +use tokio::process::Command; + +use domain::ports::{ExitStatus, Output, ProcessError, ProcessSpawner, SpawnSpec}; + +/// Process spawner backed by the local OS via `tokio::process::Command`. +#[derive(Debug, Default, Clone, Copy)] +pub struct LocalProcessSpawner; + +impl LocalProcessSpawner { + /// Creates a new [`LocalProcessSpawner`]. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +#[async_trait] +impl ProcessSpawner for LocalProcessSpawner { + async fn run(&self, spec: SpawnSpec) -> Result { + let mut cmd = Command::new(&spec.command); + cmd.args(&spec.args); + // `cwd` is "/" for detection probes; only set a real working directory + // when the spec points at a concrete project path. + if spec.cwd.as_str() != "/" { + cmd.current_dir(spec.cwd.as_str()); + } + for (key, value) in &spec.env { + cmd.env(key, value); + } + + let output = cmd + .output() + .await + .map_err(|e| ProcessError::Spawn(format!("{}: {e}", spec.command)))?; + + Ok(Output { + status: ExitStatus { + code: output.status.code(), + }, + stdout: output.stdout, + stderr: output.stderr, + }) + } +} diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs new file mode 100644 index 0000000..2018f17 --- /dev/null +++ b/crates/infrastructure/src/pty/mod.rs @@ -0,0 +1,238 @@ +//! [`PortablePtyAdapter`] — local [`PtyPort`] implementation over the +//! `portable-pty` crate (ARCHITECTURE §5, L3). +//! +//! # Design +//! +//! `portable-pty` is a blocking, thread-oriented API: a master PTY gives a +//! `Box` reader and a `Box` writer, and the child process is +//! waited on a thread. We bridge that to the domain [`PtyPort`] as follows: +//! +//! - [`PtyHandle`] only carries a [`SessionId`]; the *real* OS handles (master +//! PTY, writer, child) live in this adapter's registry keyed by that id. The +//! domain never sees an OS handle (ARCHITECTURE §4). +//! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the +//! slave, then start **one reader thread** that pumps bytes from the master +//! into an [`std::sync::mpsc`] channel. [`subscribe_output`](PtyPort::subscribe_output) +//! hands back the receiver wrapped as the domain's blocking [`OutputStream`] +//! iterator; the presentation layer drains it on its own thread and forwards +//! chunks to the per-session Tauri channel (the `PtyBridge`). +//! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored +//! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the +//! reader thread, and returns the [`ExitStatus`]. +//! +//! # Cross-platform note (spike, ARCHITECTURE §13.1) +//! +//! `portable-pty` abstracts ConPTY on Windows, but exit-code/signal semantics +//! differ: a Unix process killed by a signal reports `code: None` here, while +//! ConPTY surfaces a numeric code. Resize uses the same `PtySize` everywhere. +//! Points to validate on Windows: child `kill()` actually tears down ConPTY, and +//! the reader thread observes EOF promptly on exit. The code below avoids any +//! Unix-only assumption (no raw fds, no signals) so it should port as-is. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Mutex; +use std::thread::JoinHandle; + +use async_trait::async_trait; +use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem}; + +use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec}; +use domain::terminal::PtySize; +use domain::SessionId; + +/// Size of each read buffer pumped from the master PTY. +const READ_BUF: usize = 8 * 1024; + +/// A live PTY owned by the adapter. +struct LivePty { + /// Master side — used for resize. + master: Box, + /// Writer into the PTY (child stdin). + writer: Box, + /// The spawned child process. + child: Box, + /// Receiver end of the output channel; taken once by `subscribe_output`. + output_rx: Option>>, + /// Handle of the reader thread, joined on kill. + reader: Option>, +} + +/// Local PTY adapter backed by `portable-pty`'s native PTY system. +/// +/// Thread-safe: the registry of live PTYs is behind a [`Mutex`]; the adapter is +/// cloneable-as-`Arc` and injected as `Arc` at the composition root. +#[derive(Default)] +pub struct PortablePtyAdapter { + sessions: Mutex>, +} + +impl PortablePtyAdapter { + /// Creates an empty adapter. + #[must_use] + pub fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + } + } +} + +/// Maps the domain [`PtySize`] to the `portable-pty` one. +fn to_pty_size(size: PtySize) -> portable_pty::PtySize { + portable_pty::PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: 0, + pixel_height: 0, + } +} + +/// Builds the `portable-pty` command from a domain [`SpawnSpec`]. +fn to_command(spec: &SpawnSpec) -> CommandBuilder { + let mut cmd = CommandBuilder::new(&spec.command); + cmd.args(&spec.args); + cmd.cwd(spec.cwd.as_str()); + for (k, v) in &spec.env { + cmd.env(k, v); + } + cmd +} + +#[async_trait] +impl PtyPort for PortablePtyAdapter { + async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result { + let pty_system = NativePtySystem::default(); + let pair = pty_system + .openpty(to_pty_size(size)) + .map_err(|e| PtyError::Spawn(e.to_string()))?; + + let cmd = to_command(&spec); + let child = pair + .slave + .spawn_command(cmd) + .map_err(|e| PtyError::Spawn(e.to_string()))?; + // The slave is held by the child; drop our copy so EOF propagates on exit. + drop(pair.slave); + + let writer = pair + .master + .take_writer() + .map_err(|e| PtyError::Io(e.to_string()))?; + let mut reader = pair + .master + .try_clone_reader() + .map_err(|e| PtyError::Io(e.to_string()))?; + + // One reader thread per PTY pumps bytes into the output channel until EOF. + let (tx, rx): (Sender>, Receiver>) = mpsc::channel(); + let reader_handle = std::thread::spawn(move || { + let mut buf = [0u8; READ_BUF]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + // Receiver gone (session closed) → stop pumping. + if tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + // The PTY layer owns the handle identity: it mints a fresh session id and + // the caller (the `OpenTerminal` use case) adopts it as the + // `TerminalSession.id`. This keeps the OS handle out of the domain while + // giving everyone a single, agreed-upon id (ARCHITECTURE §4). + let handle = PtyHandle { + session_id: SessionId::new_random(), + }; + let live = LivePty { + master: pair.master, + writer, + child, + output_rx: Some(rx), + reader: Some(reader_handle), + }; + self.sessions + .lock() + .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))? + .insert(handle.session_id, live); + Ok(handle) + } + + fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { + let mut map = self + .sessions + .lock() + .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; + let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?; + live.writer + .write_all(data) + .map_err(|e| PtyError::Io(e.to_string()))?; + live.writer + .flush() + .map_err(|e| PtyError::Io(e.to_string())) + } + + fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> { + let map = self + .sessions + .lock() + .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; + let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?; + live.master + .resize(to_pty_size(size)) + .map_err(|e| PtyError::Io(e.to_string())) + } + + fn subscribe_output(&self, handle: &PtyHandle) -> Result { + let mut map = self + .sessions + .lock() + .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; + let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?; + let rx = live + .output_rx + .take() + .ok_or_else(|| PtyError::Io("output already subscribed".to_owned()))?; + Ok(Box::new(rx.into_iter())) + } + + async fn kill(&self, handle: &PtyHandle) -> Result { + // Remove from the registry so the writer/master drop and the child is + // fully owned here while we tear it down. + let mut live = { + let mut map = self + .sessions + .lock() + .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; + map.remove(&handle.session_id).ok_or(PtyError::NotFound)? + }; + + // Ask the child to terminate, then wait for its real status. + let _ = live.child.kill(); + let status = live + .child + .wait() + .map_err(|e| PtyError::Io(e.to_string()))?; + + // Dropping master/writer closes the PTY; the reader thread then sees EOF. + drop(live.output_rx.take()); + if let Some(reader) = live.reader.take() { + let _ = reader.join(); + } + + Ok(ExitStatus { + code: exit_code(&status), + }) + } +} + +/// Extracts a portable exit code. `portable-pty`'s `ExitStatus` exposes +/// `exit_code(): u32`; `0` is success. We surface it as `i32`. +fn exit_code(status: &portable_pty::ExitStatus) -> Option { + Some(status.exit_code() as i32) +} diff --git a/crates/infrastructure/src/remote/mod.rs b/crates/infrastructure/src/remote/mod.rs new file mode 100644 index 0000000..86ff8af --- /dev/null +++ b/crates/infrastructure/src/remote/mod.rs @@ -0,0 +1,95 @@ +//! Remote-host strategy adapters (ARCHITECTURE §5, L9). +//! +//! A [`RemoteHost`] is the **factory** that decides *where* a project's I/O +//! happens — it hands out the location-appropriate [`FileSystem`], +//! [`ProcessSpawner`] and [`PtyPort`]. Routing every use case through the +//! project's host is what makes local and remote execution transparent (Liskov, +//! ARCHITECTURE §1.2). +//! +//! This module ships [`LocalHost`] (the local strategy, fully wired and tested) +//! and the [`remote_host`] selector. The SSH and WSL strategies (`SshHost` via +//! russh/SFTP, `WslHost` via `wsl.exe`) are the remaining L9 work; their +//! integration tests are environment-gated (no SSH server / no WSL here), so they +//! are not yet wired in to avoid shipping unverified adapters. Until then the +//! selector reports them as unsupported rather than silently failing later. + +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::ports::{FileSystem, ProcessSpawner, PtyPort, RemoteError, RemoteHost}; +use domain::remote::{RemoteKind, RemoteRef}; + +use crate::{LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter}; + +/// The local execution strategy: the project lives on this machine, so the host +/// simply hands out the local adapters. +#[derive(Clone)] +pub struct LocalHost { + fs: Arc, + spawner: Arc, + pty: Arc, +} + +impl LocalHost { + /// Builds a local host wired to the local FS / process / PTY adapters. + #[must_use] + pub fn new() -> Self { + Self { + fs: Arc::new(LocalFileSystem::new()), + spawner: Arc::new(LocalProcessSpawner::new()), + pty: Arc::new(PortablePtyAdapter::new()), + } + } +} + +impl Default for LocalHost { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl RemoteHost for LocalHost { + fn kind(&self) -> RemoteKind { + RemoteKind::Local + } + + async fn connect(&self) -> Result<(), RemoteError> { + // Nothing to establish for the local machine. + Ok(()) + } + + fn file_system(&self) -> Arc { + Arc::clone(&self.fs) + } + + fn process_spawner(&self) -> Arc { + Arc::clone(&self.spawner) + } + + fn pty(&self) -> Arc { + Arc::clone(&self.pty) + } +} + +/// Selects and builds the [`RemoteHost`] for a [`RemoteRef`]. +/// +/// `Local` yields a [`LocalHost`]. `Ssh`/`Wsl` are not yet wired (their adapters +/// are the remaining, environment-gated L9 work) and return a clear +/// [`RemoteError::Connection`] so callers fail fast with an actionable message +/// instead of a confusing downstream error. +/// +/// # Errors +/// [`RemoteError::Connection`] for SSH/WSL remotes until their adapters land. +pub fn remote_host(remote: &RemoteRef) -> Result, RemoteError> { + match remote { + RemoteRef::Local => Ok(Arc::new(LocalHost::new())), + RemoteRef::Ssh { host, .. } => Err(RemoteError::Connection(format!( + "SSH remote ({host}) is not yet supported" + ))), + RemoteRef::Wsl { distro } => Err(RemoteError::Connection(format!( + "WSL remote ({distro}) is not yet supported" + ))), + } +} diff --git a/crates/infrastructure/src/runtime/mod.rs b/crates/infrastructure/src/runtime/mod.rs new file mode 100644 index 0000000..c30a6d4 --- /dev/null +++ b/crates/infrastructure/src/runtime/mod.rs @@ -0,0 +1,189 @@ +//! [`CliAgentRuntime`] — the single, generic [`AgentRuntime`] adapter driven by +//! an [`AgentProfile`] (ARCHITECTURE §5, §4; CONTEXT §9). +//! +//! There is **one** adapter for *every* AI CLI: the diversity (Claude, Codex, +//! Gemini, Aider, custom) lives entirely in the declarative [`AgentProfile`] +//! data, never in code. Adding an AI = adding a profile (the **Open/Closed** +//! principle made literal). +//! +//! # Responsibilities +//! +//! - [`detect`](AgentRuntime::detect): run the profile's detection command via +//! the injected [`ProcessSpawner`] and report whether the CLI is installed +//! (exit code 0). When `profile.detect` is `None`, fall back to +//! ` --version` (see [`detection_spec`]). +//! - [`prepare_invocation`](AgentRuntime::prepare_invocation): a **pure** +//! function (no I/O) that builds the [`SpawnSpec`] — command, args, resolved +//! `cwd` (template substitution), and the context-injection plan derived from +//! the profile's [`ContextInjection`]. This is the testable core of L5. + +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::ports::{ + AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SpawnSpec, +}; +use domain::profile::{AgentProfile, ContextInjection}; +use domain::project::ProjectPath; + +/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only +/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure. +#[derive(Clone)] +pub struct CliAgentRuntime { + spawner: Arc, +} + +impl CliAgentRuntime { + /// Builds the runtime from an injected [`ProcessSpawner`] (the composition + /// root passes a [`crate::LocalProcessSpawner`], or a remote one later). + #[must_use] + pub fn new(spawner: Arc) -> Self { + Self { spawner } + } + + /// Builds the [`SpawnSpec`] used to *detect* a profile's CLI. + /// + /// - If `profile.detect` is set, it is parsed as a whitespace-delimited + /// command line (`"claude --version"` → command `claude`, args + /// `["--version"]`). The first token is the executable. + /// - Otherwise we fall back to ` --version`, the near-universal + /// "is it installed?" probe. + /// + /// Detection runs in the current working directory and injects nothing. + /// + /// This is pure (no I/O) and therefore unit-testable without a spawner. + /// + /// # Errors + /// [`RuntimeError::Detection`] if the detection command is blank. + pub fn detection_spec(profile: &AgentProfile) -> Result { + let line = profile + .detect + .clone() + .unwrap_or_else(|| format!("{} --version", profile.command)); + let mut tokens = line.split_whitespace(); + let command = tokens + .next() + .ok_or_else(|| RuntimeError::Detection("empty detection command".to_owned()))? + .to_owned(); + let args = tokens.map(str::to_owned).collect(); + // Detection runs in a neutral cwd; "." is a safe relative placeholder the + // spawner resolves against the process cwd. + let cwd = ProjectPath::new("/") + .map_err(|e| RuntimeError::Detection(e.to_string()))?; + Ok(SpawnSpec { + command, + args, + cwd, + env: Vec::new(), + context_plan: None, + }) + } + + /// Resolves the profile's `cwd_template` against the project root. + /// + /// The only recognised placeholder is `{projectRoot}` (CONTEXT §9). An empty + /// template defaults to the project root itself. + fn resolve_cwd(profile: &AgentProfile, root: &ProjectPath) -> Result { + let template = profile.cwd_template.trim(); + if template.is_empty() { + return Ok(root.clone()); + } + let resolved = template.replace("{projectRoot}", root.as_str()); + ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string())) + } + + /// Builds the [`ContextInjectionPlan`] for a given [`ContextInjection`] and + /// the prepared context. **Pure** — the testable heart of L5. + /// + /// | `ContextInjection` | `ContextInjectionPlan` | + /// |---------------------------|----------------------------------------------------------| + /// | `ConventionFile { target }` | `File { target }` — caller writes the `.md` there | + /// | `Flag { flag }` | `Args { args }` — `{path}` substituted with the ctx path | + /// | `Stdin` | `Stdin` — content piped on stdin | + /// | `Env { var }` | `Env { var }` — content/path delivered via env var | + /// + /// For `Flag`, the flag template may contain `{path}` (replaced by the + /// context's relative path). A flag *without* `{path}` is treated as a + /// standalone switch followed by the path as a separate argument + /// (e.g. `-f` → `["-f", ""]`); a flag *with* `{path}` is split on + /// whitespace after substitution (e.g. `--context-file {path}` → + /// `["--context-file", ""]`). + fn injection_plan( + injection: &ContextInjection, + ctx: &PreparedContext, + ) -> ContextInjectionPlan { + match injection { + ContextInjection::ConventionFile { target } => ContextInjectionPlan::File { + target: target.clone(), + }, + ContextInjection::Flag { flag } => { + let path = &ctx.relative_path; + let args = if flag.contains("{path}") { + flag.replace("{path}", path) + .split_whitespace() + .map(str::to_owned) + .collect() + } else { + vec![flag.clone(), path.clone()] + }; + ContextInjectionPlan::Args { args } + } + ContextInjection::Stdin => ContextInjectionPlan::Stdin, + ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() }, + } + } +} + +#[async_trait] +impl AgentRuntime for CliAgentRuntime { + fn detect(&self, profile: &AgentProfile) -> Result { + let spec = Self::detection_spec(profile)?; + // The port is synchronous but the spawner is async; block on it. The + // adapter is invoked off the Tauri async runtime (detection is a + // short-lived probe), so a transient runtime here is acceptable and keeps + // the `AgentRuntime` port plain-`fn` as the domain declares it. + let spawner = Arc::clone(&self.spawner); + let output = futures_block_on(async move { spawner.run(spec).await }) + .map_err(|e| RuntimeError::Detection(e.to_string()))?; + Ok(output.status.code == Some(0)) + } + + fn prepare_invocation( + &self, + profile: &AgentProfile, + ctx: &PreparedContext, + cwd: &ProjectPath, + ) -> Result { + let resolved_cwd = Self::resolve_cwd(profile, cwd)?; + let plan = Self::injection_plan(&profile.context_injection, ctx); + + // For the `Flag` strategy the context path travels *on the command line*, + // so fold those args into the spec's args (after the profile's static + // args). The other strategies leave args untouched; the plan tells the + // launcher (L6) what else to do (write file / pipe stdin / set env). + let mut args = profile.args.clone(); + if let ContextInjectionPlan::Args { args: extra } = &plan { + args.extend(extra.iter().cloned()); + } + + Ok(SpawnSpec { + command: profile.command.clone(), + args, + cwd: resolved_cwd, + env: Vec::new(), + context_plan: Some(plan), + }) + } +} + +/// Minimal block-on helper that drives a future to completion on a fresh +/// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose +/// port signature is synchronous while the [`ProcessSpawner`] it calls is async. +fn futures_block_on(fut: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build a current-thread runtime for detection") + .block_on(fut) +} diff --git a/crates/infrastructure/src/store/context.rs b/crates/infrastructure/src/store/context.rs new file mode 100644 index 0000000..5309905 --- /dev/null +++ b/crates/infrastructure/src/store/context.rs @@ -0,0 +1,172 @@ +//! [`IdeaiContextStore`] — file implementation of the [`AgentContextStore`] port +//! (ARCHITECTURE §5, §9.1). +//! +//! Persists, **inside the project root**, the agent contexts and the manifest +//! that together make a project's agents travel with the code: +//! +//! ```text +//! / +//! └── .ideai/ +//! ├── agents.json # AgentManifest { version, agents: [ManifestEntry, ...] } +//! └── agents/ +//! ├── backend-dev.md # an agent's context (md_path = "agents/backend-dev.md") +//! └── ... +//! ``` +//! +//! All I/O goes through the [`FileSystem`] port (here the `RemoteHost`'s +//! filesystem — [`crate::LocalFileSystem`] locally, an SFTP/WSL one later), so the +//! store is **location-neutral**: the very same adapter serves a local project or +//! a project hosted over SSH/WSL (Liskov, ARCHITECTURE §1.2). +//! +//! `md_path` values are interpreted **relative to `.ideai/`** (so a manifest +//! `md_path` of `agents/backend-dev.md` maps to +//! `/.ideai/agents/backend-dev.md`), matching the documented schema. + +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::agent::AgentManifest; +use domain::ids::AgentId; +use domain::markdown::MarkdownDoc; +use domain::ports::{AgentContextStore, FileSystem, FsError, RemotePath, StoreError}; +use domain::project::{Project, ProjectPath}; + +/// The `.ideai/` directory name inside a project root. +const IDEAI_DIR: &str = ".ideai"; + +/// The agent-manifest file name inside `.ideai/`. +const AGENTS_FILE: &str = "agents.json"; + +/// Current schema version written into a freshly-created manifest. +const MANIFEST_VERSION: u32 = 1; + +/// File-backed [`AgentContextStore`], composing a [`FileSystem`] port. +/// +/// Cheap to clone (everything is behind `Arc`); the composition root constructs +/// one per resolved `RemoteHost` and shares it across the agent use cases. +#[derive(Clone)] +pub struct IdeaiContextStore { + fs: Arc, +} + +impl IdeaiContextStore { + /// Builds the store from an injected [`FileSystem`] (the project's + /// `RemoteHost` filesystem). + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + /// Joins a project root with a relative segment using a POSIX separator + /// (valid on every target; `tokio::fs` accepts `/` on Windows too). + fn join(root: &ProjectPath, rel: &str) -> String { + let base = root.as_str().trim_end_matches(['/', '\\']); + format!("{base}/{rel}") + } + + /// Absolute path of the manifest file for a project. + fn manifest_path(project: &Project) -> RemotePath { + RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{AGENTS_FILE}"))) + } + + /// Absolute path of an agent context `.md` from its (`.ideai/`-relative) + /// `md_path`. + fn context_path(project: &Project, md_path: &str) -> RemotePath { + RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{md_path}"))) + } + + /// Resolves the `md_path` of an agent from the manifest, or + /// [`StoreError::NotFound`] if the agent is unknown to this project. + async fn md_path_of(&self, project: &Project, agent: &AgentId) -> Result { + let manifest = self.load_manifest(project).await?; + manifest + .entries + .into_iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.md_path) + .ok_or(StoreError::NotFound) + } + + /// Ensures the directory holding `path` exists (creates `.ideai/` and any + /// nested `agents/` parent before a write). + async fn ensure_parent(&self, path: &RemotePath) -> Result<(), StoreError> { + let raw = path.as_str(); + let dir = match raw.rfind(['/', '\\']) { + Some(idx) => &raw[..idx], + None => return Ok(()), + }; + self.fs + .create_dir_all(&RemotePath::new(dir.to_owned())) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} + +#[async_trait] +impl AgentContextStore for IdeaiContextStore { + async fn read_context( + &self, + project: &Project, + agent: &AgentId, + ) -> Result { + let md_path = self.md_path_of(project, agent).await?; + let path = Self::context_path(project, &md_path); + match self.fs.read(&path).await { + Ok(bytes) => { + let content = String::from_utf8(bytes) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + Ok(MarkdownDoc::new(content)) + } + Err(FsError::NotFound(_)) => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + async fn write_context( + &self, + project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + let md_path = self.md_path_of(project, agent).await?; + let path = Self::context_path(project, &md_path); + self.ensure_parent(&path).await?; + self.fs + .write(&path, md.as_str().as_bytes()) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + async fn load_manifest(&self, project: &Project) -> Result { + let path = Self::manifest_path(project); + match self.fs.read(&path).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + // No manifest yet: a project simply has no agents — return the empty + // default rather than erroring (mirrors the project-store policy). + Err(FsError::NotFound(_)) => Ok(AgentManifest { + version: MANIFEST_VERSION, + entries: Vec::new(), + }), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + async fn save_manifest( + &self, + project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError> { + let path = Self::manifest_path(project); + self.ensure_parent(&path).await?; + let mut bytes = serde_json::to_vec_pretty(manifest) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&path, &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs new file mode 100644 index 0000000..da7e097 --- /dev/null +++ b/crates/infrastructure/src/store/mod.rs @@ -0,0 +1,15 @@ +//! Filesystem-backed persistence stores (ARCHITECTURE §5, §9.2). +//! +//! L2 ships [`FsProjectStore`], implementing the domain [`ProjectStore`] port: +//! the known-projects **registry** and the **workspace** are stored as plain +//! JSON files in the app data directory (machine-local, outside any project). + +mod context; +mod profile; +mod project; +mod template; + +pub use context::IdeaiContextStore; +pub use profile::FsProfileStore; +pub use project::FsProjectStore; +pub use template::FsTemplateStore; diff --git a/crates/infrastructure/src/store/profile.rs b/crates/infrastructure/src/store/profile.rs new file mode 100644 index 0000000..c9bec61 --- /dev/null +++ b/crates/infrastructure/src/store/profile.rs @@ -0,0 +1,149 @@ +//! [`FsProfileStore`] — JSON file implementation of the [`ProfileStore`] port. +//! +//! Persists the configured [`AgentProfile`]s in the global IDE store +//! (ARCHITECTURE §9.2): +//! +//! ```text +//! / +//! └── profiles.json # { version, profiles: [AgentProfile, ...] } +//! ``` +//! +//! Each profile item is exactly the declarative profile of CONTEXT §9 +//! (`id, name, command, args, contextInjection{strategy,…}, detect, cwd`). The +//! existence of `profiles.json` is what marks the first run as *done* (see +//! [`ProfileStore::is_configured`]). +//! +//! Like [`super::FsProjectStore`], the store is Tauri-agnostic: the app-data +//! directory is resolved by the composition root and injected as a plain path, +//! and all I/O goes through the [`FileSystem`] port. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::ids::ProfileId; +use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError}; +use domain::profile::AgentProfile; + +/// File name of the profiles store inside the app-data dir. +const PROFILES_FILE: &str = "profiles.json"; + +/// Current schema version of the profiles file. +const PROFILES_VERSION: u32 = 1; + +/// On-disk shape of `profiles.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProfilesDoc { + /// Schema version. + version: u32, + /// All configured profiles. + profiles: Vec, +} + +impl Default for ProfilesDoc { + fn default() -> Self { + Self { + version: PROFILES_VERSION, + profiles: Vec::new(), + } + } +} + +/// JSON-file implementation of the [`ProfileStore`] port. +/// +/// Cheap to clone (everything is behind `Arc`); built once at the composition +/// root and shared across use cases. +#[derive(Clone)] +pub struct FsProfileStore { + fs: Arc, + app_data_dir: String, +} + +impl FsProfileStore { + /// Builds the store from an injected [`FileSystem`] and the app-data + /// directory path (resolved by the composition root). The directory is + /// created lazily on first write. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + /// Joins the app-data dir with the profiles file name (POSIX separator, valid + /// on every target — `tokio::fs` accepts `/` on Windows too). + fn path(&self) -> RemotePath { + let base = self.app_data_dir.trim_end_matches(['/', '\\']); + RemotePath::new(format!("{base}/{PROFILES_FILE}")) + } + + /// Reads and parses the doc, returning an empty default if the file is absent. + async fn read_doc(&self) -> Result { + match self.fs.read(&self.path()).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + Err(domain::ports::FsError::NotFound(_)) => Ok(ProfilesDoc::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + /// Writes the doc, ensuring the app-data dir exists first. + async fn write_doc(&self, doc: &ProfilesDoc) -> Result<(), StoreError> { + let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); + self.fs + .create_dir_all(&dir) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + let bytes = + serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?; + self.fs + .write(&self.path(), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} + +#[async_trait] +impl ProfileStore for FsProfileStore { + async fn list(&self) -> Result, StoreError> { + Ok(self.read_doc().await?.profiles) + } + + async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> { + let mut doc = self.read_doc().await?; + if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) { + *slot = profile.clone(); + } else { + doc.profiles.push(profile.clone()); + } + self.write_doc(&doc).await + } + + async fn delete(&self, id: ProfileId) -> Result<(), StoreError> { + let mut doc = self.read_doc().await?; + let before = doc.profiles.len(); + doc.profiles.retain(|p| p.id != id); + if doc.profiles.len() == before { + return Err(StoreError::NotFound); + } + self.write_doc(&doc).await + } + + async fn is_configured(&self) -> Result { + self.fs + .exists(&self.path()) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + async fn mark_configured(&self) -> Result<(), StoreError> { + // Write the current doc back (an empty default when nothing exists), + // which materialises `profiles.json` and records first-run completion. + let doc = self.read_doc().await?; + self.write_doc(&doc).await + } +} diff --git a/crates/infrastructure/src/store/project.rs b/crates/infrastructure/src/store/project.rs new file mode 100644 index 0000000..9f10565 --- /dev/null +++ b/crates/infrastructure/src/store/project.rs @@ -0,0 +1,156 @@ +//! [`FsProjectStore`] — JSON file implementation of the [`ProjectStore`] port. +//! +//! Persistence layout (under the injected app-data directory, ARCHITECTURE §9.2): +//! +//! ```text +//! / +//! ├── projects.json # the known-projects registry { version, projects: [Project, ...] } +//! └── workspace.json # the persisted Workspace (windows/tabs/layouts) +//! ``` +//! +//! The store does **not** know about Tauri: the app-data directory is resolved +//! by the composition root and handed in as a plain path (Dependency Inversion). +//! All I/O goes through the [`FileSystem`] port (here [`LocalFileSystem`]) so the +//! store stays decoupled from `tokio::fs` directly and is reusable as-is. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::ids::ProjectId; +use domain::layout::Workspace; +use domain::ports::{FileSystem, ProjectStore, RemotePath, StoreError}; +use domain::project::Project; + +/// File name of the known-projects registry inside the app-data dir. +const REGISTRY_FILE: &str = "projects.json"; + +/// File name of the persisted workspace inside the app-data dir. +const WORKSPACE_FILE: &str = "workspace.json"; + +/// Current schema version of the registry file. +const REGISTRY_VERSION: u32 = 1; + +/// On-disk shape of the registry file. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Registry { + /// Schema version. + version: u32, + /// All known projects. + projects: Vec, +} + +/// JSON-file implementation of the [`ProjectStore`] port. +/// +/// Cheap to clone (everything is behind `Arc`); the composition root constructs +/// it once and shares it across use cases. +#[derive(Clone)] +pub struct FsProjectStore { + fs: Arc, + app_data_dir: String, +} + +impl FsProjectStore { + /// Builds the store from an injected [`FileSystem`] and the app-data + /// directory path (resolved by the composition root, e.g. via the Tauri path + /// API). The directory is created lazily on first write. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + /// Joins the app-data dir with a file name using a POSIX separator (valid on + /// every target — `tokio::fs` accepts `/` on Windows too). + fn path(&self, file: &str) -> RemotePath { + let base = self.app_data_dir.trim_end_matches(['/', '\\']); + RemotePath::new(format!("{base}/{file}")) + } + + /// Reads and parses the registry, returning an empty one if the file does + /// not exist yet. + async fn read_registry(&self) -> Result { + let path = self.path(REGISTRY_FILE); + match self.fs.read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| StoreError::Serialization(e.to_string())), + Err(domain::ports::FsError::NotFound(_)) => Ok(Registry { + version: REGISTRY_VERSION, + projects: Vec::new(), + }), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + /// Writes the registry, ensuring the app-data dir exists first. + async fn write_registry(&self, registry: &Registry) -> Result<(), StoreError> { + self.ensure_dir().await?; + let bytes = serde_json::to_vec_pretty(registry) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + self.fs + .write(&self.path(REGISTRY_FILE), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + /// Creates the app-data directory and all missing parents. + async fn ensure_dir(&self) -> Result<(), StoreError> { + let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); + self.fs + .create_dir_all(&dir) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} + +#[async_trait] +impl ProjectStore for FsProjectStore { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.read_registry().await?.projects) + } + + async fn load_project(&self, id: ProjectId) -> Result { + self.read_registry() + .await? + .projects + .into_iter() + .find(|p| p.id == id) + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + let mut registry = self.read_registry().await?; + if let Some(slot) = registry.projects.iter_mut().find(|p| p.id == project.id) { + *slot = project.clone(); + } else { + registry.projects.push(project.clone()); + } + self.write_registry(®istry).await + } + + async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> { + self.ensure_dir().await?; + let bytes = serde_json::to_vec_pretty(workspace) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + self.fs + .write(&self.path(WORKSPACE_FILE), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + async fn load_workspace(&self) -> Result { + let path = self.path(WORKSPACE_FILE); + match self.fs.read(&path).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + // No workspace persisted yet: return the empty default. + Err(domain::ports::FsError::NotFound(_)) => Ok(Workspace::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } +} diff --git a/crates/infrastructure/src/store/template.rs b/crates/infrastructure/src/store/template.rs new file mode 100644 index 0000000..662856b --- /dev/null +++ b/crates/infrastructure/src/store/template.rs @@ -0,0 +1,225 @@ +//! [`FsTemplateStore`] — file implementation of the [`TemplateStore`] port +//! (ARCHITECTURE §5, §9.2). +//! +//! Templates live in the **global IDE store** (machine-local app-data dir, *not* +//! inside any project): the Markdown content travels as a diffable `.md`, with a +//! small JSON index carrying the metadata (version, hash) needed to list and +//! version templates without parsing every `.md`: +//! +//! ```text +//! /templates/ +//! ├── index.json # { version, templates: [{ id, name, version, contentHash, defaultProfileId }] } +//! └── md/ +//! └── .md # a template's Markdown content +//! ``` +//! +//! `contentHash` is a stable digest of the `.md` content, recorded so a future +//! spike can detect **out-of-app edits** (ARCHITECTURE §13.9); it is not part of +//! the [`AgentTemplate`] domain entity. Like the other stores, all I/O goes +//! through the [`FileSystem`] port, so the adapter is Tauri-agnostic. + +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::ids::{ProfileId, TemplateId}; +use domain::markdown::MarkdownDoc; +use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore}; +use domain::template::{AgentTemplate, TemplateVersion}; + +/// Directory (under app-data) holding the templates store. +const TEMPLATES_DIR: &str = "templates"; + +/// Index file name inside the templates dir. +const INDEX_FILE: &str = "index.json"; + +/// Current schema version of the index file. +const INDEX_VERSION: u32 = 1; + +/// One metadata row in `index.json` (the `.md` content lives separately). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IndexEntry { + id: TemplateId, + name: String, + version: TemplateVersion, + content_hash: String, + default_profile_id: ProfileId, +} + +/// On-disk shape of `templates/index.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IndexDoc { + version: u32, + templates: Vec, +} + +impl Default for IndexDoc { + fn default() -> Self { + Self { + version: INDEX_VERSION, + templates: Vec::new(), + } + } +} + +/// A stable, dependency-free digest of Markdown content for out-of-app edit +/// detection. `DefaultHasher::new()` uses fixed keys, so this is deterministic +/// across runs and platforms (unlike a `RandomState`-seeded hasher). +fn content_hash(md: &MarkdownDoc) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + md.as_str().hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +/// File-backed [`TemplateStore`], composing a [`FileSystem`] port. +#[derive(Clone)] +pub struct FsTemplateStore { + fs: Arc, + app_data_dir: String, +} + +impl FsTemplateStore { + /// Builds the store from an injected [`FileSystem`] and the app-data dir + /// (resolved by the composition root). Directories are created on first write. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + /// `/templates`. + fn dir(&self) -> String { + let base = self.app_data_dir.trim_end_matches(['/', '\\']); + format!("{base}/{TEMPLATES_DIR}") + } + + /// `/templates/index.json`. + fn index_path(&self) -> RemotePath { + RemotePath::new(format!("{}/{INDEX_FILE}", self.dir())) + } + + /// `/templates/md/.md`. + fn md_path(&self, id: TemplateId) -> RemotePath { + RemotePath::new(format!("{}/md/{id}.md", self.dir())) + } + + /// Reads the index, returning an empty default if absent. + async fn read_index(&self) -> Result { + match self.fs.read(&self.index_path()).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + /// Writes the index, ensuring `templates/` exists. + async fn write_index(&self, doc: &IndexDoc) -> Result<(), StoreError> { + self.fs + .create_dir_all(&RemotePath::new(self.dir())) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + let bytes = + serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?; + self.fs + .write(&self.index_path(), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + /// Reconstructs the [`AgentTemplate`] for an index entry by reading its `.md`. + async fn load(&self, entry: &IndexEntry) -> Result { + let bytes = self + .fs + .read(&self.md_path(entry.id)) + .await + .map_err(|e| match e { + domain::ports::FsError::NotFound(_) => StoreError::NotFound, + other => StoreError::Io(other.to_string()), + })?; + let content = + String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?; + // The domain entity carries the authoritative version/name from the index; + // we reconstruct it directly (no public mutator needed) since every field + // is known and already validated when it was first saved. + Ok(AgentTemplate { + id: entry.id, + name: entry.name.clone(), + content_md: MarkdownDoc::new(content), + version: entry.version, + default_profile_id: entry.default_profile_id, + }) + } +} + +#[async_trait] +impl TemplateStore for FsTemplateStore { + async fn list(&self) -> Result, StoreError> { + let index = self.read_index().await?; + let mut out = Vec::with_capacity(index.templates.len()); + for entry in &index.templates { + out.push(self.load(entry).await?); + } + Ok(out) + } + + async fn get(&self, id: TemplateId) -> Result { + let index = self.read_index().await?; + let entry = index + .templates + .iter() + .find(|e| e.id == id) + .ok_or(StoreError::NotFound)?; + self.load(entry).await + } + + async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> { + // (1) Write the Markdown content. + self.fs + .create_dir_all(&RemotePath::new(format!("{}/md", self.dir()))) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + self.fs + .write( + &self.md_path(template.id), + template.content_md.as_str().as_bytes(), + ) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + + // (2) Upsert the index metadata. + let mut index = self.read_index().await?; + let row = IndexEntry { + id: template.id, + name: template.name.clone(), + version: template.version, + content_hash: content_hash(&template.content_md), + default_profile_id: template.default_profile_id, + }; + if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) { + *slot = row; + } else { + index.templates.push(row); + } + self.write_index(&index).await + } + + async fn delete(&self, id: TemplateId) -> Result<(), StoreError> { + let mut index = self.read_index().await?; + let before = index.templates.len(); + index.templates.retain(|e| e.id != id); + if index.templates.len() == before { + return Err(StoreError::NotFound); + } + // The orphaned `md/.md` is left on disk (the FileSystem port exposes no + // delete); the index no longer references it, so it is effectively gone. + self.write_index(&index).await + } +} diff --git a/crates/infrastructure/tests/agent_runtime.rs b/crates/infrastructure/tests/agent_runtime.rs new file mode 100644 index 0000000..2ec975c --- /dev/null +++ b/crates/infrastructure/tests/agent_runtime.rs @@ -0,0 +1,317 @@ +//! L5 tests for [`CliAgentRuntime`]. +//! +//! Covers: +//! - `prepare_invocation` (the **pure** core): for every [`ContextInjection`] +//! strategy, the produced [`SpawnSpec`] (command, args order, resolved cwd, +//! `context_plan`) is asserted. +//! - `detection_spec` (pure): custom `detect` tokenisation vs `--version` +//! fallback. +//! - `detect` driven by a **mocked** [`ProcessSpawner`]: exit 0 ⇒ `true`, +//! non-zero ⇒ `false`, spawner error ⇒ propagated as `RuntimeError`. + +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::ports::{ + AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError, + ProcessSpawner, RuntimeError, SpawnSpec, +}; +use domain::profile::{AgentProfile, ContextInjection}; +use domain::project::ProjectPath; +use domain::ids::ProfileId; +use domain::MarkdownDoc; +use infrastructure::CliAgentRuntime; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn profile(injection: ContextInjection, cwd_template: &str) -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(uuid::Uuid::from_u128(1)), + "Test", + "mycli", + vec!["--static".to_owned(), "arg".to_owned()], + injection, + Some("mycli probe --json".to_owned()), + cwd_template, + ) + .unwrap() +} + +fn ctx() -> PreparedContext { + PreparedContext { + content: MarkdownDoc::new("# hi"), + relative_path: ".ideai/agent.md".to_owned(), + } +} + +/// A [`ProcessSpawner`] that returns a fixed outcome regardless of the spec. +struct FixedSpawner(Result); + +#[async_trait] +impl ProcessSpawner for FixedSpawner { + async fn run(&self, _spec: SpawnSpec) -> Result { + self.0.clone() + } +} + +fn runtime_with(outcome: Result) -> CliAgentRuntime { + CliAgentRuntime::new(Arc::new(FixedSpawner(outcome))) +} + +/// A spawner that just records the spec it was handed (for detect-spec assertions). +struct RecordingSpawner(std::sync::Mutex>); + +#[async_trait] +impl ProcessSpawner for RecordingSpawner { + async fn run(&self, spec: SpawnSpec) -> Result { + *self.0.lock().unwrap() = Some(spec); + Ok(Output { + status: ExitStatus { code: Some(0) }, + stdout: Vec::new(), + stderr: Vec::new(), + }) + } +} + +fn pure_runtime() -> CliAgentRuntime { + runtime_with(Ok(Output { + status: ExitStatus { code: Some(0) }, + stdout: Vec::new(), + stderr: Vec::new(), + })) +} + +// --------------------------------------------------------------------------- +// prepare_invocation — ConventionFile +// --------------------------------------------------------------------------- + +#[test] +fn prepare_convention_file_keeps_args_and_plans_file() { + let rt = pure_runtime(); + let p = profile( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + "{projectRoot}", + ); + let root = ProjectPath::new("/home/me/proj").unwrap(); + + let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); + + assert_eq!(spec.command, "mycli"); + assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged"); + assert_eq!(spec.cwd.as_str(), "/home/me/proj"); + assert_eq!( + spec.context_plan, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned() + }) + ); +} + +// --------------------------------------------------------------------------- +// prepare_invocation — Flag with {path} +// --------------------------------------------------------------------------- + +#[test] +fn prepare_flag_with_path_substitutes_and_splits() { + let rt = pure_runtime(); + let p = profile( + ContextInjection::flag("--context-file {path}").unwrap(), + "{projectRoot}", + ); + let root = ProjectPath::new("/p").unwrap(); + + let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); + + // static args first, then the substituted+split flag args. + assert_eq!( + spec.args, + vec!["--static", "arg", "--context-file", ".ideai/agent.md"] + ); + assert_eq!( + spec.context_plan, + Some(ContextInjectionPlan::Args { + args: vec!["--context-file".to_owned(), ".ideai/agent.md".to_owned()] + }) + ); +} + +// --------------------------------------------------------------------------- +// prepare_invocation — Flag without {path} (switch + path) +// --------------------------------------------------------------------------- + +#[test] +fn prepare_flag_without_path_is_switch_then_path() { + let rt = pure_runtime(); + let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}"); + let root = ProjectPath::new("/p").unwrap(); + + let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); + + assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]); + assert_eq!( + spec.context_plan, + Some(ContextInjectionPlan::Args { + args: vec!["-f".to_owned(), ".ideai/agent.md".to_owned()] + }) + ); +} + +// --------------------------------------------------------------------------- +// prepare_invocation — Stdin +// --------------------------------------------------------------------------- + +#[test] +fn prepare_stdin_keeps_args_and_plans_stdin() { + let rt = pure_runtime(); + let p = profile(ContextInjection::stdin(), "{projectRoot}"); + let root = ProjectPath::new("/p").unwrap(); + + let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); + + assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for stdin"); + assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin)); +} + +// --------------------------------------------------------------------------- +// prepare_invocation — Env +// --------------------------------------------------------------------------- + +#[test] +fn prepare_env_keeps_args_and_plans_env() { + let rt = pure_runtime(); + let p = profile( + ContextInjection::env("AGENT_CONTEXT").unwrap(), + "{projectRoot}", + ); + let root = ProjectPath::new("/p").unwrap(); + + let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); + + assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env"); + assert_eq!( + spec.context_plan, + Some(ContextInjectionPlan::Env { + var: "AGENT_CONTEXT".to_owned() + }) + ); +} + +// --------------------------------------------------------------------------- +// prepare_invocation — cwd template substitution +// --------------------------------------------------------------------------- + +#[test] +fn prepare_substitutes_project_root_in_cwd_template() { + let rt = pure_runtime(); + let p = profile( + ContextInjection::stdin(), + "{projectRoot}/subdir", + ); + let root = ProjectPath::new("/home/me/proj").unwrap(); + + let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); + assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir"); +} + +#[test] +fn prepare_empty_cwd_template_defaults_to_root() { + let rt = pure_runtime(); + let p = profile(ContextInjection::stdin(), ""); + let root = ProjectPath::new("/home/me/proj").unwrap(); + + let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); + assert_eq!(spec.cwd.as_str(), "/home/me/proj"); +} + +// --------------------------------------------------------------------------- +// detection_spec (pure) +// --------------------------------------------------------------------------- + +#[test] +fn detection_spec_uses_custom_detect_tokenised() { + let p = profile(ContextInjection::stdin(), "{projectRoot}"); + let spec = CliAgentRuntime::detection_spec(&p).unwrap(); + assert_eq!(spec.command, "mycli"); + assert_eq!(spec.args, vec!["probe", "--json"]); + assert!(spec.context_plan.is_none()); + assert!(spec.env.is_empty()); +} + +#[test] +fn detection_spec_falls_back_to_command_version() { + let p = AgentProfile::new( + ProfileId::from_uuid(uuid::Uuid::from_u128(2)), + "NoDetect", + "somecli", + Vec::new(), + ContextInjection::stdin(), + None, + "{projectRoot}", + ) + .unwrap(); + + let spec = CliAgentRuntime::detection_spec(&p).unwrap(); + assert_eq!(spec.command, "somecli"); + assert_eq!(spec.args, vec!["--version"]); +} + +// --------------------------------------------------------------------------- +// detect (mocked spawner) +// --------------------------------------------------------------------------- + +#[test] +fn detect_true_on_exit_zero() { + let rt = runtime_with(Ok(Output { + status: ExitStatus { code: Some(0) }, + stdout: Vec::new(), + stderr: Vec::new(), + })); + let p = profile(ContextInjection::stdin(), "{projectRoot}"); + assert!(rt.detect(&p).unwrap()); +} + +#[test] +fn detect_false_on_nonzero_exit() { + let rt = runtime_with(Ok(Output { + status: ExitStatus { code: Some(127) }, + stdout: Vec::new(), + stderr: Vec::new(), + })); + let p = profile(ContextInjection::stdin(), "{projectRoot}"); + assert!(!rt.detect(&p).unwrap()); +} + +#[test] +fn detect_false_on_signal_terminated() { + let rt = runtime_with(Ok(Output { + status: ExitStatus { code: None }, + stdout: Vec::new(), + stderr: Vec::new(), + })); + let p = profile(ContextInjection::stdin(), "{projectRoot}"); + assert!(!rt.detect(&p).unwrap()); +} + +#[test] +fn detect_propagates_spawner_error() { + let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned()))); + let p = profile(ContextInjection::stdin(), "{projectRoot}"); + let err = rt.detect(&p).expect_err("spawner error surfaces"); + assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}"); +} + +#[test] +fn detect_runs_the_detection_spec_command() { + let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None))); + let rt = CliAgentRuntime::new(recorder.clone()); + let p = profile(ContextInjection::stdin(), "{projectRoot}"); + + rt.detect(&p).unwrap(); + + let spec = recorder.0.lock().unwrap().clone().expect("spec recorded"); + assert_eq!(spec.command, "mycli"); + assert_eq!(spec.args, vec!["probe", "--json"]); +} diff --git a/crates/infrastructure/tests/context_store.rs b/crates/infrastructure/tests/context_store.rs new file mode 100644 index 0000000..686b210 --- /dev/null +++ b/crates/infrastructure/tests/context_store.rs @@ -0,0 +1,147 @@ +//! L6 integration tests for [`IdeaiContextStore`] against a real temp directory +//! and a real [`LocalFileSystem`], exercising the full `.ideai/` persistence path +//! (manifest JSON, context `.md` round-trip, tolerant reads, NotFound). + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; +use domain::ids::{AgentId, ProfileId}; +use domain::markdown::MarkdownDoc; +use domain::ports::{AgentContextStore, FileSystem, RemotePath, StoreError}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use infrastructure::{IdeaiContextStore, LocalFileSystem}; +use uuid::Uuid; + +/// A unique scratch directory under the OS temp dir, cleaned up on drop. +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l6-ctx-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn root(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn child(&self, rel: &str) -> RemotePath { + RemotePath::new(self.0.join(rel).to_string_lossy().into_owned()) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store() -> IdeaiContextStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + IdeaiContextStore::new(fs) +} + +fn project(root: &str) -> Project { + Project::new( + domain::ids::ProjectId::new_random(), + "demo", + ProjectPath::new(root).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} +fn pid(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} + +fn agent(id: AgentId, name: &str, md: &str, profile: ProfileId) -> Agent { + Agent::new(id, name, md, profile, AgentOrigin::Scratch, false).unwrap() +} + +#[tokio::test] +async fn missing_manifest_loads_empty() { + let tmp = TempDir::new(); + let store = store(); + let manifest = store.load_manifest(&project(&tmp.root())).await.unwrap(); + assert!(manifest.entries.is_empty()); + assert_eq!(manifest.version, 1); +} + +#[tokio::test] +async fn manifest_save_then_load_roundtrips() { + let tmp = TempDir::new(); + let store = store(); + let p = project(&tmp.root()); + + let a = agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap(); + store.save_manifest(&p, &manifest).await.unwrap(); + + let back = store.load_manifest(&p).await.unwrap(); + assert_eq!(back, manifest); +} + +#[tokio::test] +async fn context_write_then_read_roundtrips() { + let tmp = TempDir::new(); + let store = store(); + let p = project(&tmp.root()); + + // The manifest must know the agent before its context can be addressed. + let a = agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap(); + store.save_manifest(&p, &manifest).await.unwrap(); + + let md = MarkdownDoc::new("# Backend\nYou are the backend agent."); + store.write_context(&p, &a.id, &md).await.unwrap(); + + let back = store.read_context(&p, &a.id).await.unwrap(); + assert_eq!(back, md); + + // The `.md` actually landed at `.ideai/agents/backend.md`. + let fs = LocalFileSystem::new(); + let bytes = fs + .read(&tmp.child(".ideai/agents/backend.md")) + .await + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), md.as_str()); +} + +#[tokio::test] +async fn read_context_for_unknown_agent_is_not_found() { + let tmp = TempDir::new(); + let store = store(); + let p = project(&tmp.root()); + let err = store.read_context(&p, &aid(404)).await.unwrap_err(); + assert!(matches!(err, StoreError::NotFound), "got {err:?}"); +} + +#[tokio::test] +async fn manifest_file_is_camelcase_json_under_ideai() { + let tmp = TempDir::new(); + let store = store(); + let p = project(&tmp.root()); + + let a = agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap(); + store.save_manifest(&p, &manifest).await.unwrap(); + + let fs = LocalFileSystem::new(); + let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + let agents = json + .get("agents") + .and_then(|v| v.as_array()) + .expect("top-level `agents` array"); + assert_eq!(agents.len(), 1); + let entry = &agents[0]; + assert_eq!(entry.get("mdPath").and_then(|v| v.as_str()), Some("agents/backend.md")); + assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend")); + assert!(entry.get("profileId").is_some(), "camelCase profileId present"); + assert!(entry.get("md_path").is_none(), "no snake_case leak"); +} diff --git a/crates/infrastructure/tests/eventbus.rs b/crates/infrastructure/tests/eventbus.rs new file mode 100644 index 0000000..db836ed --- /dev/null +++ b/crates/infrastructure/tests/eventbus.rs @@ -0,0 +1,53 @@ +//! L1 tests for [`TokioBroadcastEventBus`]: a published [`DomainEvent`] is +//! received both through the blocking `subscribe()` [`EventStream`] and through +//! the async `raw_receiver()` used by the Tauri event relay. + +use domain::events::DomainEvent; +use domain::ports::EventBus; +use domain::ProjectId; +use infrastructure::TokioBroadcastEventBus; +use uuid::Uuid; + +fn sample_event() -> DomainEvent { + DomainEvent::ProjectCreated { + project_id: ProjectId::from_uuid(Uuid::nil()), + } +} + +#[tokio::test] +async fn raw_receiver_gets_published_event() { + let bus = TokioBroadcastEventBus::new(); + let mut rx = bus.raw_receiver(); + + bus.publish(sample_event()); + + let got = rx.recv().await.expect("event received"); + assert_eq!(got, sample_event()); +} + +#[tokio::test] +async fn raw_receiver_fans_out_to_multiple_subscribers() { + let bus = TokioBroadcastEventBus::new(); + let mut rx1 = bus.raw_receiver(); + let mut rx2 = bus.raw_receiver(); + + bus.publish(sample_event()); + + assert_eq!(rx1.recv().await.unwrap(), sample_event()); + assert_eq!(rx2.recv().await.unwrap(), sample_event()); +} + +#[test] +fn subscribe_blocking_stream_yields_published_event() { + let bus = TokioBroadcastEventBus::new(); + let mut stream = bus.subscribe(); + bus.publish(sample_event()); + assert_eq!(stream.next(), Some(sample_event())); +} + +#[tokio::test] +async fn publish_without_subscribers_is_noop() { + let bus = TokioBroadcastEventBus::new(); + // No receiver registered: publish must not panic. + bus.publish(sample_event()); +} diff --git a/crates/infrastructure/tests/git_repository.rs b/crates/infrastructure/tests/git_repository.rs new file mode 100644 index 0000000..a4eb16b --- /dev/null +++ b/crates/infrastructure/tests/git_repository.rs @@ -0,0 +1,108 @@ +//! L8 integration tests for [`Git2Repository`] against a real temporary repo, +//! exercising the local flow end to end: init → status → stage → commit → +//! branch/current_branch → log, plus the not-a-repo error path. + +use std::path::PathBuf; + +use domain::ports::GitPort; +use domain::ports::GitError; +use domain::project::ProjectPath; +use infrastructure::Git2Repository; +use uuid::Uuid; + +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l8-git-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn root(&self) -> ProjectPath { + ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap() + } + fn write(&self, name: &str, content: &str) { + std::fs::write(self.0.join(name), content).unwrap(); + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::test] +async fn init_status_stage_commit_branch_log_flow() { + let tmp = TempDir::new(); + let root = tmp.root(); + let git = Git2Repository::new(); + + git.init(&root).await.expect("init"); + + // A new untracked file shows up as not-staged. + tmp.write("a.txt", "hello"); + let status = git.status(&root).await.unwrap(); + let a = status + .iter() + .find(|s| s.path == "a.txt") + .expect("a.txt appears in status"); + assert!(!a.staged, "untracked file is not staged"); + + // Staging flips the staged flag. + git.stage(&root, "a.txt").await.unwrap(); + let staged = git.status(&root).await.unwrap(); + assert!( + staged.iter().find(|s| s.path == "a.txt").unwrap().staged, + "file is staged after stage()" + ); + + // Commit the index. + let commit = git.commit(&root, "first commit").await.unwrap(); + assert!(!commit.hash.is_empty()); + assert_eq!(commit.summary, "first commit"); + + // After committing, the tree is clean. + assert!( + git.status(&root).await.unwrap().is_empty(), + "no changes after commit" + ); + + // A current branch exists and is listed among local branches. + let current = git.current_branch(&root).await.unwrap(); + let current = current.expect("a current branch after the first commit"); + let branches = git.branches(&root).await.unwrap(); + assert!(branches.contains(¤t), "current branch is listed"); + + // The log has exactly our commit. + let log = git.log(&root, 10).await.unwrap(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].summary, "first commit"); + assert_eq!(log[0].hash, commit.hash); +} + +#[tokio::test] +async fn status_on_non_repo_is_not_found() { + let tmp = TempDir::new(); + let err = Git2Repository::new().status(&tmp.root()).await.unwrap_err(); + assert!(matches!(err, GitError::NotFound), "got {err:?}"); +} + +#[tokio::test] +async fn unstage_after_first_commit_resets_index() { + let tmp = TempDir::new(); + let root = tmp.root(); + let git = Git2Repository::new(); + git.init(&root).await.unwrap(); + tmp.write("a.txt", "v1"); + git.stage(&root, "a.txt").await.unwrap(); + git.commit(&root, "c1").await.unwrap(); + + // Modify + stage, then unstage → the change is no longer staged. + tmp.write("a.txt", "v2"); + git.stage(&root, "a.txt").await.unwrap(); + assert!(git.status(&root).await.unwrap().iter().any(|s| s.path == "a.txt" && s.staged)); + + git.unstage(&root, "a.txt").await.unwrap(); + let st = git.status(&root).await.unwrap(); + let a = st.iter().find(|s| s.path == "a.txt").unwrap(); + assert!(!a.staged, "unstaged change is no longer in the index"); +} diff --git a/crates/infrastructure/tests/local_fs.rs b/crates/infrastructure/tests/local_fs.rs new file mode 100644 index 0000000..c3f4bca --- /dev/null +++ b/crates/infrastructure/tests/local_fs.rs @@ -0,0 +1,81 @@ +//! L1 integration tests for [`LocalFileSystem`] against a real temp directory. + +use std::path::PathBuf; + +use domain::ports::{FileSystem, FsError, RemotePath}; +use infrastructure::LocalFileSystem; +use uuid::Uuid; + +/// A unique scratch directory under the OS temp dir, cleaned up on drop. +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l1-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn child(&self, name: &str) -> RemotePath { + RemotePath::new(self.0.join(name).to_string_lossy().into_owned()) + } + fn path(&self) -> RemotePath { + RemotePath::new(self.0.to_string_lossy().into_owned()) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::test] +async fn write_then_read_roundtrips() { + let tmp = TempDir::new(); + let fs = LocalFileSystem::new(); + let file = tmp.child("hello.txt"); + + fs.write(&file, b"bonjour").await.unwrap(); + let back = fs.read(&file).await.unwrap(); + assert_eq!(back, b"bonjour"); +} + +#[tokio::test] +async fn exists_reflects_presence() { + let tmp = TempDir::new(); + let fs = LocalFileSystem::new(); + let file = tmp.child("maybe.txt"); + + assert!(!fs.exists(&file).await.unwrap()); + fs.write(&file, b"x").await.unwrap(); + assert!(fs.exists(&file).await.unwrap()); +} + +#[tokio::test] +async fn create_dir_all_and_list() { + let tmp = TempDir::new(); + let fs = LocalFileSystem::new(); + + let nested = tmp.child("a/b/c"); + fs.create_dir_all(&nested).await.unwrap(); + assert!(fs.exists(&nested).await.unwrap()); + + // Put a file and a dir at the top level, then list them. + fs.write(&tmp.child("file.txt"), b"y").await.unwrap(); + let entries = fs.list(&tmp.path()).await.unwrap(); + + let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + assert!(names.contains(&"file.txt")); + assert!(names.contains(&"a")); + + let dir_entry = entries.iter().find(|e| e.name == "a").unwrap(); + assert!(dir_entry.is_dir); + let file_entry = entries.iter().find(|e| e.name == "file.txt").unwrap(); + assert!(!file_entry.is_dir); +} + +#[tokio::test] +async fn read_missing_maps_to_not_found() { + let tmp = TempDir::new(); + let fs = LocalFileSystem::new(); + let err = fs.read(&tmp.child("nope.txt")).await.unwrap_err(); + assert!(matches!(err, FsError::NotFound(_)), "got {err:?}"); +} diff --git a/crates/infrastructure/tests/profile_store.rs b/crates/infrastructure/tests/profile_store.rs new file mode 100644 index 0000000..6e4dae9 --- /dev/null +++ b/crates/infrastructure/tests/profile_store.rs @@ -0,0 +1,169 @@ +//! L5 integration tests for [`FsProfileStore`] against a real temp directory, +//! using a real [`LocalFileSystem`] so the full persistence path (camelCase +//! `profiles.json`, upsert, delete, first-run marker) is exercised end-to-end. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::ids::ProfileId; +use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError}; +use domain::profile::{AgentProfile, ContextInjection}; +use infrastructure::{FsProfileStore, LocalFileSystem}; +use uuid::Uuid; + +/// A unique scratch directory under the OS temp dir, cleaned up on drop. +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l5-profile-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn app_data_dir(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn child(&self, name: &str) -> RemotePath { + RemotePath::new(self.0.join(name).to_string_lossy().into_owned()) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store(tmp: &TempDir) -> FsProfileStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + FsProfileStore::new(fs, tmp.app_data_dir()) +} + +fn sample(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() +} + +#[tokio::test] +async fn save_then_list_roundtrips() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let p = sample(1, "Claude", "claude"); + store.save(&p).await.unwrap(); + + let listed = store.list().await.unwrap(); + assert_eq!(listed, vec![p]); +} + +#[tokio::test] +async fn save_upserts_by_id_without_duplicating() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let first = sample(1, "before", "claude"); + store.save(&first).await.unwrap(); + + let updated = sample(1, "after", "claude-renamed"); + store.save(&updated).await.unwrap(); + + let listed = store.list().await.unwrap(); + assert_eq!(listed.len(), 1, "upsert must not duplicate by id"); + assert_eq!(listed[0], updated); + assert_eq!(listed[0].name, "after"); +} + +#[tokio::test] +async fn delete_removes_profile() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let a = sample(1, "A", "a"); + let b = sample(2, "B", "b"); + store.save(&a).await.unwrap(); + store.save(&b).await.unwrap(); + + store.delete(a.id).await.unwrap(); + + let listed = store.list().await.unwrap(); + assert_eq!(listed, vec![b]); +} + +#[tokio::test] +async fn delete_unknown_is_not_found() { + let tmp = TempDir::new(); + let store = store(&tmp); + store.save(&sample(1, "A", "a")).await.unwrap(); + + let err = store + .delete(ProfileId::from_uuid(Uuid::from_u128(999))) + .await + .expect_err("deleting unknown id fails"); + assert!(matches!(err, StoreError::NotFound), "got {err:?}"); +} + +#[tokio::test] +async fn is_configured_false_before_any_write() { + let tmp = TempDir::new(); + let store = store(&tmp); + // First run: no profiles.json yet. + assert!(!store.is_configured().await.unwrap()); + assert!(store.list().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn is_configured_true_after_save() { + let tmp = TempDir::new(); + let store = store(&tmp); + store.save(&sample(1, "A", "a")).await.unwrap(); + assert!(store.is_configured().await.unwrap()); +} + +#[tokio::test] +async fn mark_configured_creates_file_with_empty_profiles() { + let tmp = TempDir::new(); + let store = store(&tmp); + + assert!(!store.is_configured().await.unwrap()); + store.mark_configured().await.unwrap(); + assert!(store.is_configured().await.unwrap(), "marker materialised"); + assert!( + store.list().await.unwrap().is_empty(), + "empty profile list recorded" + ); +} + +#[tokio::test] +async fn profiles_file_is_camelcase_versioned() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let p = sample(1, "Claude", "claude"); + store.save(&p).await.unwrap(); + + let fs = LocalFileSystem::new(); + let bytes = fs.read(&tmp.child("profiles.json")).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(json["version"], 1); + let profiles = json + .get("profiles") + .and_then(|v| v.as_array()) + .expect("top-level `profiles` array"); + assert_eq!(profiles.len(), 1); + + let entry = &profiles[0]; + assert_eq!(entry["name"], "Claude"); + assert_eq!(entry["command"], "claude"); + // camelCase fields, tagged contextInjection. + assert!(entry.get("cwdTemplate").is_some(), "camelCase cwdTemplate"); + assert!(entry.get("cwd_template").is_none(), "no snake_case leak"); + assert_eq!(entry["contextInjection"]["strategy"], "conventionFile"); + assert_eq!(entry["contextInjection"]["target"], "CLAUDE.md"); +} diff --git a/crates/infrastructure/tests/project_store.rs b/crates/infrastructure/tests/project_store.rs new file mode 100644 index 0000000..c331b58 --- /dev/null +++ b/crates/infrastructure/tests/project_store.rs @@ -0,0 +1,139 @@ +//! L2 integration tests for [`FsProjectStore`] against a real temp directory, +//! using a real [`LocalFileSystem`] so the full persistence path (JSON layout, +//! tolerant reads, upsert) is exercised end-to-end. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::ids::ProjectId; +use domain::layout::Workspace; +use domain::ports::{FileSystem, ProjectStore, RemotePath}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use infrastructure::{FsProjectStore, LocalFileSystem}; +use uuid::Uuid; + +/// A unique scratch directory under the OS temp dir, cleaned up on drop. +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l2-store-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + /// The app-data dir as a plain string, as the composition root would pass it. + fn app_data_dir(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn child(&self, name: &str) -> RemotePath { + RemotePath::new(self.0.join(name).to_string_lossy().into_owned()) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store(tmp: &TempDir) -> FsProjectStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + FsProjectStore::new(fs, tmp.app_data_dir()) +} + +fn sample_project(id: ProjectId, name: &str, root: &str) -> Project { + Project::new( + id, + name, + ProjectPath::new(root).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +#[tokio::test] +async fn save_then_list_roundtrips() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let p = sample_project(ProjectId::new_random(), "alpha", "/home/me/alpha"); + store.save_project(&p).await.unwrap(); + + let listed = store.list_projects().await.unwrap(); + assert_eq!(listed, vec![p]); +} + +#[tokio::test] +async fn save_upserts_by_id_without_duplicating() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let id = ProjectId::new_random(); + let first = sample_project(id, "before", "/home/me/proj"); + store.save_project(&first).await.unwrap(); + + // Same id, changed fields: must update in place, not append. + let updated = sample_project(id, "after", "/home/me/proj-renamed"); + store.save_project(&updated).await.unwrap(); + + let listed = store.list_projects().await.unwrap(); + assert_eq!(listed.len(), 1, "upsert must not duplicate by id"); + assert_eq!(listed[0], updated); + assert_eq!(listed[0].name, "after"); +} + +#[tokio::test] +async fn missing_registry_lists_empty() { + let tmp = TempDir::new(); + let store = store(&tmp); + + // No projects.json written yet: tolerant read returns an empty list. + let listed = store.list_projects().await.unwrap(); + assert!(listed.is_empty()); +} + +#[tokio::test] +async fn workspace_save_then_load_roundtrips() { + let tmp = TempDir::new(); + let store = store(&tmp); + + // Missing workspace returns the default. + let loaded = store.load_workspace().await.unwrap(); + assert_eq!(loaded, Workspace::default()); + + let ws = Workspace::default(); + store.save_workspace(&ws).await.unwrap(); + let back = store.load_workspace().await.unwrap(); + assert_eq!(back, ws); +} + +#[tokio::test] +async fn registry_file_is_camelcase_json() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let p = sample_project(ProjectId::new_random(), "jsoncheck", "/srv/app"); + store.save_project(&p).await.unwrap(); + + // Read the raw bytes the store wrote and assert the camelCase shape. + let fs = LocalFileSystem::new(); + let bytes = fs.read(&tmp.child("projects.json")).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + assert!(json.get("version").is_some(), "top-level `version` present"); + let projects = json + .get("projects") + .and_then(|v| v.as_array()) + .expect("top-level `projects` array"); + assert_eq!(projects.len(), 1); + + let entry = &projects[0]; + // camelCase serialization of `created_at`. + assert!( + entry.get("createdAt").is_some(), + "project uses camelCase `createdAt`, got {entry}" + ); + assert!(entry.get("created_at").is_none(), "no snake_case leak"); + assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("jsoncheck")); + assert_eq!(entry.get("root").and_then(|v| v.as_str()), Some("/srv/app")); +} diff --git a/crates/infrastructure/tests/pty_adapter.rs b/crates/infrastructure/tests/pty_adapter.rs new file mode 100644 index 0000000..44a9725 --- /dev/null +++ b/crates/infrastructure/tests/pty_adapter.rs @@ -0,0 +1,169 @@ +//! L3 integration tests for [`PortablePtyAdapter`] — exercising a **real** OS +//! PTY on Linux. We spawn tiny `/bin/sh` programs whose output is deterministic, +//! drain the blocking output stream on a dedicated thread, and assert on the +//! bytes / exit code. +//! +//! Robustness: every blocking drain runs on its own thread joined with a bounded +//! timeout so a misbehaving PTY can never hang the test suite/CI. + +#![cfg(unix)] + +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use domain::ports::{PtyPort, SpawnSpec}; +use domain::{ProjectPath, PtySize}; +use infrastructure::PortablePtyAdapter; + +/// Hard ceiling for any single PTY interaction in these tests. +const TIMEOUT: Duration = Duration::from_secs(10); + +fn sh_spec(script: &str) -> SpawnSpec { + SpawnSpec { + command: "/bin/sh".to_owned(), + args: vec!["-c".to_owned(), script.to_owned()], + cwd: ProjectPath::new("/").unwrap(), + env: Vec::new(), + context_plan: None, + } +} + +fn size() -> PtySize { + PtySize::new(24, 80).unwrap() +} + +/// Drains an output stream to a single `Vec` on a worker thread, returning +/// the collected bytes or panicking if it does not finish within `TIMEOUT`. +fn drain_with_timeout( + stream: domain::ports::OutputStream, + timeout: Duration, +) -> Vec { + let (tx, rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let mut all = Vec::new(); + for chunk in stream { + all.extend_from_slice(&chunk); + } + let _ = tx.send(all); + }); + let bytes = rx + .recv_timeout(timeout) + .expect("output stream drained within timeout"); + worker.join().expect("drain thread joined"); + bytes +} + +#[tokio::test] +async fn spawn_printf_streams_expected_bytes_and_exits_zero() { + let pty = PortablePtyAdapter::new(); + let handle = pty + .spawn(sh_spec("printf hello-pty"), size()) + .await + .expect("spawn succeeds"); + + let stream = pty.subscribe_output(&handle).expect("subscribe once"); + let bytes = drain_with_timeout(stream, TIMEOUT); + let text = String::from_utf8_lossy(&bytes); + assert!( + text.contains("hello-pty"), + "expected output to contain 'hello-pty', got {text:?}" + ); + + // Process already exited; kill collects the status. `sh` exiting cleanly → 0. + let status = pty.kill(&handle).await.expect("kill succeeds"); + assert_eq!(status.code, Some(0), "clean exit reports code 0"); +} + +#[tokio::test] +async fn write_is_echoed_back_through_output_stream() { + // `cat` echoes its stdin back to stdout; we feed it a line then close stdin + // by killing it, and assert we saw the echoed bytes. + let pty = PortablePtyAdapter::new(); + let handle = pty + .spawn(sh_spec("cat"), size()) + .await + .expect("spawn cat"); + + let stream = pty.subscribe_output(&handle).expect("subscribe once"); + + // Look for the marker on a worker thread, with a timeout, so we don't block + // forever if `cat` never echoes. + let (found_tx, found_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let mut all = Vec::new(); + for chunk in stream { + all.extend_from_slice(&chunk); + if String::from_utf8_lossy(&all).contains("marker-123") { + let _ = found_tx.send(true); + // Keep draining until EOF so the thread can exit on kill. + } + } + }); + + pty.write(&handle, b"marker-123\n").expect("write to cat"); + + let found = found_rx + .recv_timeout(TIMEOUT) + .expect("echoed marker observed within timeout"); + assert!(found, "cat echoed the written bytes back"); + + pty.kill(&handle).await.expect("kill cat"); + worker.join().expect("drain thread joined after kill"); +} + +#[tokio::test] +async fn subscribe_output_twice_is_an_error() { + let pty = PortablePtyAdapter::new(); + let handle = pty + .spawn(sh_spec("sleep 0.2"), size()) + .await + .expect("spawn"); + + let first = pty.subscribe_output(&handle); + assert!(first.is_ok(), "first subscribe succeeds"); + + let second = pty.subscribe_output(&handle); + assert!( + second.is_err(), + "second subscribe on the same session must error" + ); + + // Drain the first stream so the reader thread can finish, then tidy up. + let stream = first.unwrap(); + drain_with_timeout(stream, TIMEOUT); + let _ = pty.kill(&handle).await; +} + +#[tokio::test] +async fn write_resize_kill_on_unknown_handle_are_not_found() { + use domain::ports::{PtyError, PtyHandle}; + use domain::SessionId; + + let pty = PortablePtyAdapter::new(); + let ghost = PtyHandle { + session_id: SessionId::new_random(), + }; + + assert_eq!(pty.write(&ghost, b"x"), Err(PtyError::NotFound)); + assert_eq!(pty.resize(&ghost, size()), Err(PtyError::NotFound)); + assert!(pty.subscribe_output(&ghost).is_err()); + assert_eq!(pty.kill(&ghost).await, Err(PtyError::NotFound)); +} + +#[tokio::test] +async fn resize_on_live_pty_succeeds() { + let pty = PortablePtyAdapter::new(); + let handle = pty + .spawn(sh_spec("sleep 0.2"), size()) + .await + .expect("spawn"); + + pty.resize(&handle, PtySize::new(40, 120).unwrap()) + .expect("resize a live pty succeeds"); + + // Drain + reap so the test leaves no live process/thread behind. + let stream = pty.subscribe_output(&handle).expect("subscribe"); + let _ = thread::spawn(move || stream.count()); + let _ = pty.kill(&handle).await; +} diff --git a/crates/infrastructure/tests/remote_host.rs b/crates/infrastructure/tests/remote_host.rs new file mode 100644 index 0000000..3da4c26 --- /dev/null +++ b/crates/infrastructure/tests/remote_host.rs @@ -0,0 +1,70 @@ +//! L9 tests for the local remote-host strategy and the host selector. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::ports::{RemoteError, RemoteHost, RemotePath}; +use domain::remote::{RemoteKind, RemoteRef, SshAuth}; +use infrastructure::{remote_host, LocalHost}; +use uuid::Uuid; + +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l9-host-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn path(&self) -> String { + self.0.to_string_lossy().into_owned() + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::test] +async fn local_host_connects_and_exposes_local_fs() { + let tmp = TempDir::new(); + let host = LocalHost::new(); + assert_eq!(host.kind(), RemoteKind::Local); + host.connect().await.expect("local connect is a no-op"); + + let fs = host.file_system(); + assert!(fs.exists(&RemotePath::new(tmp.path())).await.unwrap()); + assert!(!fs + .exists(&RemotePath::new(format!("{}/nope", tmp.path()))) + .await + .unwrap()); +} + +#[tokio::test] +async fn selector_builds_local_host() { + let host = remote_host(&RemoteRef::local()).expect("local host builds"); + assert_eq!(host.kind(), RemoteKind::Local); +} + +#[test] +fn selector_rejects_ssh_and_wsl_for_now() { + let ssh = RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap(); + assert!(matches!( + remote_host(&ssh), + Err(RemoteError::Connection(_)) + )); + let wsl = RemoteRef::wsl("Ubuntu").unwrap(); + assert!(matches!( + remote_host(&wsl), + Err(RemoteError::Connection(_)) + )); +} + +/// Local host PTY/spawner handles are cloneable port objects (Arc-backed). +#[test] +fn local_host_hands_out_ports() { + let host: Arc = Arc::new(LocalHost::new()); + let _fs = host.file_system(); + let _sp = host.process_spawner(); + let _pty = host.pty(); +} diff --git a/crates/infrastructure/tests/template_store.rs b/crates/infrastructure/tests/template_store.rs new file mode 100644 index 0000000..8b8e21c --- /dev/null +++ b/crates/infrastructure/tests/template_store.rs @@ -0,0 +1,139 @@ +//! L7 integration tests for [`FsTemplateStore`] against a real temp directory and +//! a real [`LocalFileSystem`]: md + `index.json` round-trip, version persistence, +//! upsert, delete, tolerant reads, and the on-disk layout (`templates/md/.md`). + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::ids::{ProfileId, TemplateId}; +use domain::markdown::MarkdownDoc; +use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore}; +use domain::template::AgentTemplate; +use infrastructure::{FsTemplateStore, LocalFileSystem}; +use uuid::Uuid; + +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l7-tpl-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn app_data_dir(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn child(&self, rel: &str) -> RemotePath { + RemotePath::new(self.0.join(rel).to_string_lossy().into_owned()) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store(tmp: &TempDir) -> FsTemplateStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + FsTemplateStore::new(fs, tmp.app_data_dir()) +} + +fn tid(n: u128) -> TemplateId { + TemplateId::from_uuid(Uuid::from_u128(n)) +} +fn pid(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} + +fn template(id: TemplateId, name: &str, content: &str) -> AgentTemplate { + AgentTemplate::new(id, name, MarkdownDoc::new(content), pid(1)).unwrap() +} + +#[tokio::test] +async fn missing_index_lists_empty() { + let tmp = TempDir::new(); + assert!(store(&tmp).list().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn save_then_get_and_list_roundtrip() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let t = template(tid(1), "Backend", "# Backend template"); + store.save(&t).await.unwrap(); + + assert_eq!(store.get(tid(1)).await.unwrap(), t); + assert_eq!(store.list().await.unwrap(), vec![t.clone()]); + + // The Markdown actually landed at templates/md/.md. + let fs = LocalFileSystem::new(); + let bytes = fs + .child_read(&tmp, &format!("templates/md/{}.md", tid(1))) + .await; + assert_eq!(String::from_utf8(bytes).unwrap(), t.content_md.as_str()); +} + +#[tokio::test] +async fn save_upserts_and_persists_bumped_version() { + let tmp = TempDir::new(); + let store = store(&tmp); + + let t0 = template(tid(1), "Backend", "v1"); + store.save(&t0).await.unwrap(); + let t1 = t0.with_updated_content(MarkdownDoc::new("v2")); + store.save(&t1).await.unwrap(); + + let back = store.get(tid(1)).await.unwrap(); + assert_eq!(back.version.get(), 2, "bumped version persisted"); + assert_eq!(back.content_md.as_str(), "v2"); + assert_eq!(store.list().await.unwrap().len(), 1, "upsert, not append"); +} + +#[tokio::test] +async fn get_unknown_is_not_found() { + let tmp = TempDir::new(); + assert!(matches!( + store(&tmp).get(tid(404)).await.unwrap_err(), + StoreError::NotFound + )); +} + +#[tokio::test] +async fn delete_removes_from_index() { + let tmp = TempDir::new(); + let store = store(&tmp); + store.save(&template(tid(1), "T", "x")).await.unwrap(); + + store.delete(tid(1)).await.unwrap(); + assert!(store.list().await.unwrap().is_empty()); + assert!(matches!( + store.delete(tid(1)).await.unwrap_err(), + StoreError::NotFound + )); +} + +#[tokio::test] +async fn index_is_camelcase_with_content_hash() { + let tmp = TempDir::new(); + let store = store(&tmp); + store.save(&template(tid(1), "Backend", "hello")).await.unwrap(); + + let fs = LocalFileSystem::new(); + let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let entry = &json.get("templates").unwrap().as_array().unwrap()[0]; + assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend")); + assert!(entry.get("contentHash").is_some(), "camelCase contentHash present"); + assert!(entry.get("defaultProfileId").is_some()); + assert!(entry.get("content_hash").is_none(), "no snake_case leak"); +} + +/// Tiny read helper so the md-path assertion stays readable. +trait ChildRead { + async fn child_read(&self, tmp: &TempDir, rel: &str) -> Vec; +} +impl ChildRead for LocalFileSystem { + async fn child_read(&self, tmp: &TempDir, rel: &str) -> Vec { + self.read(&tmp.child(rel)).await.unwrap() + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1c0d567 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + IdeA + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7c2cd1c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5805 @@ +{ + "name": "idea-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "idea-frontend", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2.1.1", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@tauri-apps/cli": "^2.11.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^20.19.41", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.0", + "typescript": "^5.6.3", + "vite": "^5.4.11", + "vitest": "^4.1.8" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", + "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.367", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz", + "integrity": "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c730040 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "idea-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@tauri-apps/api": "^2.1.1", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@tauri-apps/cli": "^2.11.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^20.19.41", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.0", + "typescript": "^5.6.3", + "vite": "^5.4.11", + "vitest": "^4.1.8" + } +} diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts new file mode 100644 index 0000000..9079134 --- /dev/null +++ b/frontend/src/adapters/agent.test.ts @@ -0,0 +1,62 @@ +/** + * Contract tests for `TauriAgentGateway`: they assert the exact `invoke()` + * payload shape, which the mock gateway (used by feature tests) does NOT + * exercise. This guards the class of "works in mock, broken in the real app" + * bugs — e.g. forgetting to nest `projectId` inside the command's `request` DTO. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), + Channel: class { + onmessage: ((c: number[]) => void) | null = null; + }, +})); + +import { TauriAgentGateway } from "./agent"; + +describe("TauriAgentGateway invoke payloads", () => { + beforeEach(() => invoke.mockReset().mockResolvedValue({})); + + it("create_agent nests projectId inside the request DTO", async () => { + await new TauriAgentGateway().createAgent("proj-1", { + name: "Backend", + profileId: "prof-9", + }); + expect(invoke).toHaveBeenCalledWith("create_agent", { + request: { + projectId: "proj-1", + name: "Backend", + profileId: "prof-9", + initialContent: null, + }, + }); + }); + + it("update_agent_context wraps fields in the request DTO", async () => { + await new TauriAgentGateway().updateContext("proj-1", "agent-2", "# ctx"); + expect(invoke).toHaveBeenCalledWith("update_agent_context", { + request: { projectId: "proj-1", agentId: "agent-2", content: "# ctx" }, + }); + }); + + it("list_agents / read / delete pass top-level args (no request wrapper)", async () => { + const gw = new TauriAgentGateway(); + await gw.listAgents("p"); + expect(invoke).toHaveBeenCalledWith("list_agents", { projectId: "p" }); + + await gw.readContext("p", "a"); + expect(invoke).toHaveBeenCalledWith("read_agent_context", { + projectId: "p", + agentId: "a", + }); + + await gw.deleteAgent("p", "a"); + expect(invoke).toHaveBeenCalledWith("delete_agent", { + projectId: "p", + agentId: "a", + }); + }); +}); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts new file mode 100644 index 0000000..593594a --- /dev/null +++ b/frontend/src/adapters/agent.ts @@ -0,0 +1,108 @@ +/** + * Tauri adapter for {@link AgentGateway} (L6). + * + * NOTE: The Tauri commands wired here (`list_agents`, `create_agent`, …) are + * defined in the backend `app-tauri` crate and will be registered in a + * subsequent lot. This adapter is complete on the frontend side; the mock + * gateway covers tests and offline dev today. The real mode will work + * transparently once the commands are registered. + * + * Commands use snake_case (Tauri convention); payload keys are camelCase + * (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent + * with the other adapters in this directory. + */ + +import { Channel, invoke } from "@tauri-apps/api/core"; + +import type { Agent } from "@/domain"; +import type { + AgentGateway, + CreateAgentInput, + OpenTerminalOptions, + TerminalHandle, +} from "@/ports"; + +/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */ +interface LaunchAgentResponse { + sessionId: string; + cwd: string; + rows: number; + cols: number; +} + +export class TauriAgentGateway implements AgentGateway { + listAgents(projectId: string): Promise { + return invoke("list_agents", { projectId }); + } + + createAgent(projectId: string, input: CreateAgentInput): Promise { + // The `create_agent` command takes a single `request` DTO; `projectId` must + // live *inside* it (camelCase), not at the top level. + return invoke("create_agent", { + request: { + projectId, + name: input.name, + profileId: input.profileId, + initialContent: input.initialContent ?? null, + }, + }); + } + + readContext(projectId: string, agentId: string): Promise { + return invoke("read_agent_context", { projectId, agentId }); + } + + async updateContext( + projectId: string, + agentId: string, + content: string, + ): Promise { + // `update_agent_context` takes a single `request` DTO. + await invoke("update_agent_context", { + request: { projectId, agentId, content }, + }); + } + + async deleteAgent(projectId: string, agentId: string): Promise { + await invoke("delete_agent", { projectId, agentId }); + } + + async launchAgent( + projectId: string, + agentId: string, + options: OpenTerminalOptions, + onData: (bytes: Uint8Array) => void, + ): Promise { + // Per-session output channel. The backend serialises chunks as byte arrays. + const channel = new Channel(); + channel.onmessage = (chunk) => onData(Uint8Array.from(chunk)); + + const res = await invoke("launch_agent", { + request: { + projectId, + agentId, + rows: options.rows, + cols: options.cols, + }, + onOutput: channel, + }); + + const sessionId = res.sessionId; + return { + sessionId, + async write(data: Uint8Array): Promise { + await invoke("write_terminal", { + request: { sessionId, data: Array.from(data) }, + }); + }, + async resize(rows: number, cols: number): Promise { + await invoke("resize_terminal", { + request: { sessionId, rows, cols }, + }); + }, + async close(): Promise { + await invoke("close_terminal", { sessionId }); + }, + }; + } +} diff --git a/frontend/src/adapters/git.ts b/frontend/src/adapters/git.ts new file mode 100644 index 0000000..9f6472a --- /dev/null +++ b/frontend/src/adapters/git.ts @@ -0,0 +1,53 @@ +/** + * Tauri adapter for {@link GitGateway} (L8). + * + * Commands use snake_case (Tauri convention); payload keys are camelCase + * (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent + * with the other adapters in this directory. + * + * NOTE: The Tauri commands wired here are defined in the backend `app-tauri` + * crate. The mock gateway covers tests and offline dev today. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { GitBranches, GitCommit, GitFileStatus, GraphCommit } from "@/domain"; +import type { GitGateway } from "@/ports"; + +export class TauriGitGateway implements GitGateway { + status(projectId: string): Promise { + return invoke("git_status", { projectId }); + } + + async stage(projectId: string, path: string): Promise { + await invoke("git_stage", { request: { projectId, path } }); + } + + async unstage(projectId: string, path: string): Promise { + await invoke("git_unstage", { request: { projectId, path } }); + } + + commit(projectId: string, message: string): Promise { + return invoke("git_commit", { request: { projectId, message } }); + } + + branches(projectId: string): Promise { + return invoke("git_branches", { projectId }); + } + + async checkout(projectId: string, branch: string): Promise { + await invoke("git_checkout", { request: { projectId, branch } }); + } + + log(projectId: string, limit: number): Promise { + return invoke("git_log", { projectId, limit }); + } + + async init(projectId: string): Promise { + await invoke("git_init", { projectId }); + } + + graph(projectId: string, limit: number): Promise { + return invoke("git_graph", { projectId, limit }); + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts new file mode 100644 index 0000000..fd8441c --- /dev/null +++ b/frontend/src/adapters/index.ts @@ -0,0 +1,62 @@ +/** + * Real Tauri adapters wiring the UI ports to the backend via `@tauri-apps/api`. + * + * Only {@link TauriSystemGateway} is fully wired in L1 (to the `health` + * command). The remaining gateways are skeletons that throw `NOT_IMPLEMENTED` + * until their lots (L2–L9) land — they exist so the DI surface is complete and + * the app can run real-mode without the mock substituting everything. + */ + +import type { GatewayError } from "@/domain"; +import type { + Gateways, + RemoteGateway, +} from "@/ports"; +import { TauriSystemGateway } from "./system"; +import { TauriAgentGateway } from "./agent"; +import { TauriProjectGateway } from "./project"; +import { TauriTerminalGateway } from "./terminal"; +import { TauriLayoutGateway } from "./layout"; +import { TauriProfileGateway } from "./profile"; +import { TauriTemplateGateway } from "./template"; +import { TauriGitGateway } from "./git"; + +function notImplemented(what: string): never { + const err: GatewayError = { + code: "NOT_IMPLEMENTED", + message: `${what} is not implemented yet (pending its lot).`, + }; + throw err; +} + +class TauriRemoteGateway implements RemoteGateway { + connect(): Promise { + return notImplemented("RemoteGateway.connect"); + } +} + +/** Builds the full set of real Tauri-backed gateways. */ +export function createTauriGateways(): Gateways { + return { + system: new TauriSystemGateway(), + agent: new TauriAgentGateway(), + terminal: new TauriTerminalGateway(), + project: new TauriProjectGateway(), + layout: new TauriLayoutGateway(), + git: new TauriGitGateway(), + remote: new TauriRemoteGateway(), + profile: new TauriProfileGateway(), + template: new TauriTemplateGateway(), + }; +} + +export { + TauriSystemGateway, + TauriAgentGateway, + TauriProjectGateway, + TauriTerminalGateway, + TauriLayoutGateway, + TauriProfileGateway, + TauriTemplateGateway, + TauriGitGateway, +}; diff --git a/frontend/src/adapters/layout.ts b/frontend/src/adapters/layout.ts new file mode 100644 index 0000000..d223bf4 --- /dev/null +++ b/frontend/src/adapters/layout.ts @@ -0,0 +1,56 @@ +/** + * Tauri adapter for {@link LayoutGateway} (L4). Together with the sibling + * adapters this is the *only* place that calls `invoke()`; components reach it + * exclusively through the port. + * + * Commands and payload keys are camelCase, matching the backend DTO convention. + * The `load_layout`/`mutate_layout` commands return the layout tree directly + * (the backend `LayoutDto` is `#[serde(transparent)]` over the tree). + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { LayoutKind, LayoutList, LayoutOperation, LayoutTree } from "@/domain"; +import type { LayoutGateway } from "@/ports"; + +export class TauriLayoutGateway implements LayoutGateway { + loadLayout(projectId: string, layoutId?: string): Promise { + return invoke("load_layout", { projectId, layoutId }); + } + + mutateLayout( + projectId: string, + operation: LayoutOperation, + layoutId?: string, + ): Promise { + return invoke("mutate_layout", { projectId, layoutId, operation }); + } + + listLayouts(projectId: string): Promise { + return invoke("list_layouts", { projectId }); + } + + createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> { + return invoke<{ layoutId: string }>("create_layout", { + request: { projectId, name, kind }, + }); + } + + renameLayout(projectId: string, layoutId: string, name: string): Promise { + return invoke("rename_layout", { + request: { projectId, layoutId, name }, + }); + } + + deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }> { + return invoke<{ activeId: string }>("delete_layout", { + request: { projectId, layoutId }, + }); + } + + setActiveLayout(projectId: string, layoutId: string): Promise { + return invoke("set_active_layout", { + request: { projectId, layoutId }, + }); + } +} diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts new file mode 100644 index 0000000..26c7f43 --- /dev/null +++ b/frontend/src/adapters/mock/index.ts @@ -0,0 +1,925 @@ +/** + * Mock gateways implementing every UI port in-memory, with no backend. They let + * the frontend run and be tested fully offline (ARCHITECTURE §1.3, §11) and are + * selected by the DI provider when `VITE_USE_MOCK` is set. + */ + +import type { + Agent, + AgentDrift, + AgentProfile, + DomainEvent, + FirstRunState, + GatewayError, + GitBranches, + GitCommit, + GitFileStatus, + GraphCommit, + HealthReport, + LayoutInfo, + LayoutKind, + LayoutList, + LayoutOperation, + LayoutTree, + Project, + ProfileAvailability, + Template, + Unsubscribe, +} from "@/domain"; +import type { + AgentGateway, + CreateAgentInput, + CreateTemplateInput, + Gateways, + GitGateway, + LayoutGateway, + OpenTerminalOptions, + ProfileGateway, + ProjectGateway, + RemoteGateway, + SystemGateway, + TemplateGateway, + TerminalGateway, + TerminalHandle, +} from "@/ports"; +import { applyOperation, singleLeafTree } from "@/features/layout/layout"; + +export class MockSystemGateway implements SystemGateway { + private listeners = new Set<(e: DomainEvent) => void>(); + + async health(note?: string): Promise { + const report: HealthReport = { + version: "0.1.0-mock", + alive: true, + timeMillis: Date.now(), + correlationId: `mock-${Math.random().toString(36).slice(2, 10)}`, + note: note ?? null, + }; + // Emit a smoke event so subscribers can be exercised offline too. + this.emit({ type: "projectCreated", projectId: report.correlationId }); + return report; + } + + async onDomainEvent( + handler: (event: DomainEvent) => void, + ): Promise { + this.listeners.add(handler); + return () => { + this.listeners.delete(handler); + }; + } + + /** Test/dev helper to push an event to all subscribers. */ + emit(event: DomainEvent): void { + for (const l of this.listeners) l(event); + } + + /** Returns a deterministic fake path — never opens a native dialog. */ + async pickFolder(): Promise { + return "/home/user/mock-project"; + } +} + +/** + * Slugifies a display name into a safe file stem (`[a-z0-9-]`), collapsing + * runs of non-alphanumeric characters into a single dash, mirroring the + * backend `slugify` logic. + */ +function slugify(name: string): string { + let out = ""; + let prevDash = false; + for (const ch of name.trim()) { + if (/[a-zA-Z0-9]/.test(ch)) { + out += ch.toLowerCase(); + prevDash = false; + } else if (!prevDash) { + out += "-"; + prevDash = true; + } + } + return out.replace(/^-+|-+$/g, ""); +} + +/** + * Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`, + * `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and + * `LaunchAgent` use cases, keyed per `projectId` (ARCHITECTURE §11). + * + * Exported so tests can instantiate it directly (same pattern as + * {@link MockProjectGateway}). + */ +export class MockAgentGateway implements AgentGateway { + /** Agents indexed by projectId. */ + private agents = new Map(); + /** Context content indexed by `${projectId}::${agentId}`. */ + private contexts = new Map(); + /** Monotonic session counter for deterministic session ids in tests. */ + private sessionSeq = 0; + + private getAgents(projectId: string): Agent[] { + if (!this.agents.has(projectId)) this.agents.set(projectId, []); + return this.agents.get(projectId)!; + } + + private contextKey(projectId: string, agentId: string): string { + return `${projectId}::${agentId}`; + } + + async listAgents(projectId: string): Promise { + return structuredClone(this.getAgents(projectId)); + } + + async createAgent(projectId: string, input: CreateAgentInput): Promise { + const list = this.getAgents(projectId); + const slug = slugify(input.name) || "agent"; + // Derive a unique agents/.md path, disambiguating with -2, -3, … + const existingPaths = new Set(list.map((a) => a.contextPath)); + let candidate = `agents/${slug}.md`; + let n = 2; + while (existingPaths.has(candidate)) { + candidate = `agents/${slug}-${n}.md`; + n += 1; + } + const agent: Agent = { + id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`, + name: input.name, + contextPath: candidate, + profileId: input.profileId, + origin: { type: "scratch" }, + synchronized: false, + }; + list.push(agent); + this.contexts.set( + this.contextKey(projectId, agent.id), + input.initialContent ?? "", + ); + return structuredClone(agent); + } + + async readContext(projectId: string, agentId: string): Promise { + const list = this.getAgents(projectId); + if (!list.some((a) => a.id === agentId)) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + return this.contexts.get(this.contextKey(projectId, agentId)) ?? ""; + } + + async updateContext( + projectId: string, + agentId: string, + content: string, + ): Promise { + const list = this.getAgents(projectId); + if (!list.some((a) => a.id === agentId)) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + this.contexts.set(this.contextKey(projectId, agentId), content); + } + + async deleteAgent(projectId: string, agentId: string): Promise { + const list = this.getAgents(projectId); + const idx = list.findIndex((a) => a.id === agentId); + if (idx === -1) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + list.splice(idx, 1); + this.contexts.delete(this.contextKey(projectId, agentId)); + } + + // ── Internal helpers for MockTemplateGateway (same-package use only) ── + + /** + * Inserts a pre-built agent record into the registry. + * Used by `MockTemplateGateway.createAgentFromTemplate` so both gateways + * share the same in-memory store. + */ + _insertAgent(projectId: string, agent: Agent, context: string): void { + const list = this.getAgents(projectId); + list.push(agent); + this.contexts.set(this.contextKey(projectId, agent.id), context); + } + + /** + * Updates an agent record in-place (origin + synchronized flag). + * Used by `MockTemplateGateway.syncAgent`. + */ + _updateAgent( + projectId: string, + agentId: string, + patch: Partial>, + newContext?: string, + ): void { + const list = this.getAgents(projectId); + const idx = list.findIndex((a) => a.id === agentId); + if (idx === -1) return; + list[idx] = { ...list[idx], ...patch }; + if (newContext !== undefined) { + this.contexts.set(this.contextKey(projectId, agentId), newContext); + } + } + + /** + * Returns a **live** (not cloned) reference to the agent list so + * `MockTemplateGateway` can read agent origins without triggering async overhead. + */ + _rawAgents(projectId: string): Agent[] { + return this.getAgents(projectId); + } + + async launchAgent( + projectId: string, + agentId: string, + options: OpenTerminalOptions, + onData: (bytes: Uint8Array) => void, + ): Promise { + const list = this.getAgents(projectId); + if (!list.some((a) => a.id === agentId)) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + this.sessionSeq += 1; + const sessionId = `mock-agent-session-${this.sessionSeq}`; + const cwd = options.cwd; + const enc = new TextEncoder(); + // Greet so something is visible immediately (mirrors MockTerminalGateway). + queueMicrotask(() => + onData(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), + ); + let closed = false; + return { + sessionId, + async write(data: Uint8Array): Promise { + if (closed) return; + // Echo back, translating CR to CRLF like a cooked terminal. + const out: number[] = []; + for (const b of data) { + if (b === 0x0d) out.push(0x0d, 0x0a); + else out.push(b); + } + onData(Uint8Array.from(out)); + }, + async resize(): Promise {}, + async close(): Promise { + closed = true; + }, + }; + } +} + +/** + * In-memory fake terminal: a shell-less PTY that **echoes** whatever is written + * back to `onData` (so the xterm wrapper renders typed input) and greets on + * open. Lets the terminal feature run and be tested fully offline. + */ +export class MockTerminalGateway implements TerminalGateway { + private seq = 0; + + async openTerminal( + options: OpenTerminalOptions, + onData: (bytes: Uint8Array) => void, + ): Promise { + this.seq += 1; + const sessionId = `mock-session-${this.seq}`; + const enc = new TextEncoder(); + // Greet so something is visible immediately. + queueMicrotask(() => + onData(enc.encode(`mock terminal @ ${options.cwd}\r\n`)), + ); + let closed = false; + return { + sessionId, + async write(data: Uint8Array): Promise { + if (closed) return; + // Echo back, translating CR to CRLF like a cooked terminal. + const out: number[] = []; + for (const b of data) { + if (b === 0x0d) out.push(0x0d, 0x0a); + else out.push(b); + } + onData(Uint8Array.from(out)); + }, + async resize(): Promise {}, + async close(): Promise { + closed = true; + }, + }; + } +} + +export class MockProjectGateway implements ProjectGateway { + private projects: Project[] = []; + + async listProjects(): Promise { + return [...this.projects]; + } + + async createProject(name: string, root: string): Promise { + if (this.projects.some((p) => p.root === root)) { + const err: GatewayError = { + code: "INVALID", + message: `a project already exists at ${root} for this remote`, + }; + throw err; + } + const project: Project = { + id: `mock-project-${Math.random().toString(36).slice(2, 10)}`, + name, + root, + remote: { kind: "local" }, + createdAt: Date.now(), + }; + this.projects.push(project); + return project; + } + + async openProject(projectId: string): Promise { + const project = this.projects.find((p) => p.id === projectId); + if (!project) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `project ${projectId} not found`, + }; + throw err; + } + return project; + } + + async closeProject(): Promise {} +} + +/** Internal per-project layout store entry. */ +interface MockLayoutEntry { + id: string; + name: string; + kind: LayoutKind; + tree: LayoutTree; +} + +/** Internal per-project layout state: list of named layouts + active id. */ +interface MockProjectLayouts { + activeId: string; + layouts: MockLayoutEntry[]; +} + +/** + * In-memory layout store: keeps multiple named {@link LayoutTree}s per project, + * applying operations with the same pure logic as the backend (`applyOperation`). + * Lets the grid feature run and be tested fully offline. + * + * Each project starts with a single "Default" layout. The old `loadLayout` / + * `mutateLayout` signatures (no `layoutId`) default to the active layout, + * preserving backward-compat with existing tests. + */ +export class MockLayoutGateway implements LayoutGateway { + private store = new Map(); + + private getProjectLayouts(projectId: string): MockProjectLayouts { + if (!this.store.has(projectId)) { + const defaultId = `layout-default-${Math.random().toString(36).slice(2, 8)}`; + this.store.set(projectId, { + activeId: defaultId, + layouts: [{ id: defaultId, name: "Default", kind: "terminal", tree: singleLeafTree() }], + }); + } + return this.store.get(projectId)!; + } + + private getActiveTree(projectId: string): LayoutTree { + const ps = this.getProjectLayouts(projectId); + const entry = ps.layouts.find((l) => l.id === ps.activeId); + return entry ? entry.tree : singleLeafTree(); + } + + private getTree(projectId: string, layoutId?: string): LayoutTree { + if (!layoutId) return this.getActiveTree(projectId); + const ps = this.getProjectLayouts(projectId); + const entry = ps.layouts.find((l) => l.id === layoutId); + return entry ? entry.tree : singleLeafTree(); + } + + private setTree(projectId: string, tree: LayoutTree, layoutId?: string): void { + const ps = this.getProjectLayouts(projectId); + const id = layoutId ?? ps.activeId; + const entry = ps.layouts.find((l) => l.id === id); + if (entry) entry.tree = tree; + } + + async loadLayout(projectId: string, layoutId?: string): Promise { + const tree = this.getTree(projectId, layoutId); + return structuredClone(tree); + } + + async mutateLayout( + projectId: string, + operation: LayoutOperation, + layoutId?: string, + ): Promise { + const current = this.getTree(projectId, layoutId); + const next = applyOperation(current, operation); + this.setTree(projectId, next, layoutId); + return structuredClone(next); + } + + async listLayouts(projectId: string): Promise { + const ps = this.getProjectLayouts(projectId); + const layouts: LayoutInfo[] = ps.layouts.map((l) => ({ id: l.id, name: l.name, kind: l.kind })); + return { layouts, activeId: ps.activeId }; + } + + async createLayout(projectId: string, name: string, kind: LayoutKind = "terminal"): Promise<{ layoutId: string }> { + const ps = this.getProjectLayouts(projectId); + const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`; + ps.layouts.push({ id: layoutId, name, kind, tree: singleLeafTree() }); + return { layoutId }; + } + + async renameLayout(projectId: string, layoutId: string, name: string): Promise { + const ps = this.getProjectLayouts(projectId); + const entry = ps.layouts.find((l) => l.id === layoutId); + if (entry) entry.name = name; + } + + async deleteLayout( + projectId: string, + layoutId: string, + ): Promise<{ activeId: string }> { + const ps = this.getProjectLayouts(projectId); + const idx = ps.layouts.findIndex((l) => l.id === layoutId); + if (idx !== -1) ps.layouts.splice(idx, 1); + // If the deleted layout was active, switch to the first remaining layout. + if (ps.activeId === layoutId && ps.layouts.length > 0) { + ps.activeId = ps.layouts[0].id; + } + return { activeId: ps.activeId }; + } + + async setActiveLayout(projectId: string, layoutId: string): Promise { + const ps = this.getProjectLayouts(projectId); + if (ps.layouts.some((l) => l.id === layoutId)) { + ps.activeId = layoutId; + } + } +} + +/** Per-project git state kept in the mock. */ +interface MockGitProjectState { + files: Map; // path → staged + branches: string[]; + current: string; + log: GitCommit[]; + commitSeq: number; +} + +/** + * A small demo DAG that exercises branches, a merge commit, and a tag. + * + * Topology (newest first, as git log returns): + * + * e (main, HEAD) — merge commit from feature + * ├─ d (feature) + * │ └─ c + * └─ b + * └─ a (tag: v1.0) + * + * In list form (parents reference earlier hashes): + * e parents=[b,d] + * d parents=[c] + * c parents=[a] (feature branch diverges from a) + * b parents=[a] + * a parents=[] (initial commit, tag v1.0) + */ +const DEMO_GRAPH_COMMITS: GraphCommit[] = [ + { + hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + summary: "Merge feature into main", + parents: [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "dddddddddddddddddddddddddddddddddddddddd", + ], + refs: ["main", "HEAD"], + author: "Alice", + timestamp: 1_717_200_000, + }, + { + hash: "dddddddddddddddddddddddddddddddddddddddd", + summary: "Implement feature (step 2)", + parents: ["cccccccccccccccccccccccccccccccccccccccc"], + refs: ["feature"], + author: "Bob", + timestamp: 1_717_100_000, + }, + { + hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + summary: "Hotfix on main", + parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + refs: [], + author: "Alice", + timestamp: 1_717_090_000, + }, + { + hash: "cccccccccccccccccccccccccccccccccccccccc", + summary: "Implement feature (step 1)", + parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + refs: [], + author: "Bob", + timestamp: 1_717_080_000, + }, + { + hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + summary: "Initial commit", + parents: [], + refs: ["tag: v1.0"], + author: "Alice", + timestamp: 1_717_000_000, + }, +]; + +/** + * Stateful in-memory git gateway — simulates a git repository per project + * (keyed by projectId). Seeded with demo files so the panel renders something + * on first render. + * + * Exported so tests can instantiate it directly. + */ +export class MockGitGateway implements GitGateway { + private projects = new Map(); + + private getState(projectId: string): MockGitProjectState { + if (!this.projects.has(projectId)) { + this.projects.set(projectId, this._seedState()); + } + return this.projects.get(projectId)!; + } + + private _seedState(): MockGitProjectState { + const files = new Map(); + files.set("src/main.rs", false); + files.set("README.md", false); + return { + files, + branches: ["main"], + current: "main", + log: [], + commitSeq: 0, + }; + } + + async init(projectId: string): Promise { + if (!this.projects.has(projectId)) { + this.projects.set(projectId, this._seedState()); + } + } + + async status(projectId: string): Promise { + const state = this.getState(projectId); + return Array.from(state.files.entries()).map(([path, staged]) => ({ + path, + staged, + })); + } + + async stage(projectId: string, path: string): Promise { + const state = this.getState(projectId); + if (state.files.has(path)) { + state.files.set(path, true); + } + } + + async unstage(projectId: string, path: string): Promise { + const state = this.getState(projectId); + if (state.files.has(path)) { + state.files.set(path, false); + } + } + + async commit(projectId: string, message: string): Promise { + const state = this.getState(projectId); + state.commitSeq += 1; + const gitCommit: GitCommit = { + hash: `mock-${state.commitSeq}`, + summary: message.split("\n")[0], + }; + // Remove staged files from the working tree status. + for (const [path, staged] of Array.from(state.files.entries())) { + if (staged) state.files.delete(path); + } + // Push commit to the top of the log. + state.log.unshift(gitCommit); + return gitCommit; + } + + async branches(projectId: string): Promise { + const state = this.getState(projectId); + return { branches: [...state.branches], current: state.current }; + } + + async checkout(projectId: string, branch: string): Promise { + const state = this.getState(projectId); + if (!state.branches.includes(branch)) { + state.branches.push(branch); + } + state.current = branch; + } + + async log(projectId: string, limit: number): Promise { + const state = this.getState(projectId); + return state.log.slice(0, limit); + } + + async graph(_projectId: string, limit: number): Promise { + return DEMO_GRAPH_COMMITS.slice(0, limit); + } +} + +class MockRemoteGateway implements RemoteGateway { + async connect(): Promise {} +} + +/** The pre-filled reference catalogue the mock serves (mirror of the backend). */ +export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ + { + id: "mock-claude", + name: "Claude Code", + command: "claude", + args: [], + contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" }, + detect: "claude --version", + cwdTemplate: "{projectRoot}", + }, + { + id: "mock-codex", + name: "OpenAI Codex CLI", + command: "codex", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: "codex --version", + cwdTemplate: "{projectRoot}", + }, + { + id: "mock-gemini", + name: "Gemini CLI", + command: "gemini", + args: [], + contextInjection: { strategy: "conventionFile", target: "GEMINI.md" }, + detect: "gemini --version", + cwdTemplate: "{projectRoot}", + }, + { + id: "mock-aider", + name: "Aider", + command: "aider", + args: [], + contextInjection: { strategy: "flag", flag: "--message-file {path}" }, + detect: "aider --version", + cwdTemplate: "{projectRoot}", + }, +]; + +/** + * In-memory profiles gateway. Tracks configured profiles and a first-run flag so + * the wizard can be driven and tested fully offline. By default it reports the + * first run as *not done* until {@link configureProfiles} is called. Detection + * marks a fixed subset (claude) as installed so ✓/✗ rendering is exercised. + */ +export class MockProfileGateway implements ProfileGateway { + private profiles: AgentProfile[] = []; + private configured = false; + + async firstRunState(): Promise { + return { + isFirstRun: !this.configured, + referenceProfiles: structuredClone(MOCK_REFERENCE_PROFILES), + }; + } + + async referenceProfiles(): Promise { + return structuredClone(MOCK_REFERENCE_PROFILES); + } + + async detectProfiles( + candidates: AgentProfile[], + ): Promise { + // Pretend only `claude` is installed, so the wizard shows a mix of ✓/✗. + return candidates.map((profile) => ({ + profile, + available: profile.command === "claude", + })); + } + + async listProfiles(): Promise { + return structuredClone(this.profiles); + } + + async saveProfile(profile: AgentProfile): Promise { + const i = this.profiles.findIndex((p) => p.id === profile.id); + if (i >= 0) this.profiles[i] = profile; + else this.profiles.push(profile); + this.configured = true; + return structuredClone(profile); + } + + async deleteProfile(profileId: string): Promise { + this.profiles = this.profiles.filter((p) => p.id !== profileId); + } + + async configureProfiles(profiles: AgentProfile[]): Promise { + this.profiles = structuredClone(profiles); + this.configured = true; + return structuredClone(profiles); + } +} + +/** + * Stateful in-memory template gateway. + * + * Shares the `MockAgentGateway` instance passed at construction time so that + * `createAgentFromTemplate` / `detectDrift` / `syncAgent` operate on the same + * agent registry as the rest of the UI (ARCHITECTURE §11). + * + * Exported so tests can instantiate it directly and inject a shared + * `MockAgentGateway`. + */ +export class MockTemplateGateway implements TemplateGateway { + private templates: Template[] = []; + private seq = 0; + + constructor(private readonly agentGateway: MockAgentGateway) {} + + async listTemplates(): Promise { + return structuredClone(this.templates); + } + + async createTemplate(input: CreateTemplateInput): Promise