Skip to main content

spool/desktop/
tauri.rs

1use crate::desktop::{
2    DesktopContextResponse, DesktopDaemonRequest, DesktopErrorEnvelope, DesktopHistoryResponse,
3    DesktopImportSessionRequest, DesktopImportSessionResponse, DesktopLifecycleActionDto,
4    DesktopMemoryActionRequest, DesktopMemoryDraftRequest, DesktopMetadataDto,
5    DesktopPromptOptimizeRequest, DesktopPromptOptimizeResponse, DesktopRecordLookupRequest,
6    DesktopRecordResponse, DesktopRouteRequest, DesktopService, DesktopSessionActionRequest,
7    DesktopSessionActionResponse, DesktopSessionBrowserRequest, DesktopSessionBrowserResponse,
8    DesktopSessionDetailRequest, DesktopSessionDetailResponse, DesktopStatusRequest,
9    DesktopStatusResponse, DesktopWakeupRequest, DesktopWakeupResponse, DesktopWikiIndexRequest,
10    DesktopWikiIndexResponse, DesktopWikiLintRequest, DesktopWikiLintResponse,
11    DesktopWorkbenchRequest, DesktopWorkbenchResponse, DesktopWriteResponse,
12};
13use crate::domain::{MemoryScope, OutputFormat, TargetTool, WakeupProfile};
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16
17pub type TauriCommandResult<T> = Result<T, DesktopErrorEnvelope>;
18
19#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
20pub struct TauriContextCommand {
21    pub config_path: PathBuf,
22    pub vault_root_override: Option<PathBuf>,
23    pub cwd: PathBuf,
24    pub task: String,
25    #[serde(default)]
26    pub files: Vec<String>,
27    pub target: TargetTool,
28    pub format: OutputFormat,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
32pub struct TauriWakeupCommand {
33    pub config_path: PathBuf,
34    pub vault_root_override: Option<PathBuf>,
35    pub cwd: PathBuf,
36    pub task: String,
37    #[serde(default)]
38    pub files: Vec<String>,
39    pub target: TargetTool,
40    pub profile: WakeupProfile,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
44pub struct TauriPromptOptimizeCommand {
45    pub config_path: PathBuf,
46    pub vault_root_override: Option<PathBuf>,
47    pub cwd: PathBuf,
48    pub task: String,
49    #[serde(default)]
50    pub files: Vec<String>,
51    pub target: TargetTool,
52    pub profile: WakeupProfile,
53    pub provider: Option<String>,
54    pub session_id: Option<String>,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
58pub struct TauriDaemonCommand {
59    pub enabled: bool,
60    pub daemon_bin: Option<PathBuf>,
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
64pub struct TauriWorkbenchCommand {
65    pub config_path: PathBuf,
66    pub daemon: Option<TauriDaemonCommand>,
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
70pub struct TauriRecordCommand {
71    pub config_path: PathBuf,
72    pub record_id: String,
73    pub daemon: Option<TauriDaemonCommand>,
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
77pub struct TauriSessionBrowserCommand {
78    pub config_path: PathBuf,
79    pub page: Option<usize>,
80    pub per_page: Option<usize>,
81    pub provider: Option<String>,
82}
83
84#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
85pub struct TauriSessionDetailCommand {
86    pub config_path: PathBuf,
87    pub session_id: String,
88    pub message_offset: Option<usize>,
89    pub message_limit: Option<usize>,
90}
91
92#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
93pub struct TauriSessionActionCommand {
94    pub config_path: PathBuf,
95    pub session_id: String,
96}
97
98#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
99pub struct TauriStatusCommand {
100    pub config_path: PathBuf,
101    pub vault_root_override: Option<PathBuf>,
102    pub cwd: PathBuf,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
106pub struct TauriImportSessionCommand {
107    pub config_path: PathBuf,
108    pub provider: String,
109    pub session_id: String,
110    #[serde(default)]
111    pub apply: bool,
112    #[serde(default)]
113    pub actor: Option<String>,
114}
115
116#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
117pub struct TauriMemoryDraftCommand {
118    pub config_path: PathBuf,
119    pub title: String,
120    pub summary: String,
121    pub memory_type: String,
122    pub scope: MemoryScope,
123    pub source_ref: String,
124    pub project_id: Option<String>,
125    pub user_id: Option<String>,
126    pub sensitivity: Option<String>,
127    #[serde(default)]
128    pub metadata: DesktopMetadataDto,
129    // Structured retrieval signals
130    #[serde(default)]
131    pub entities: Vec<String>,
132    #[serde(default)]
133    pub tags: Vec<String>,
134    #[serde(default)]
135    pub triggers: Vec<String>,
136    #[serde(default)]
137    pub related_files: Vec<String>,
138    #[serde(default)]
139    pub related_records: Vec<String>,
140    #[serde(default)]
141    pub supersedes: Option<String>,
142    #[serde(default)]
143    pub applies_to: Vec<String>,
144    #[serde(default)]
145    pub valid_until: Option<String>,
146}
147
148#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
149pub struct TauriMemoryActionCommand {
150    pub config_path: PathBuf,
151    pub record_id: String,
152    pub action: DesktopLifecycleActionDto,
153    #[serde(default)]
154    pub metadata: DesktopMetadataDto,
155}
156
157pub fn desktop_run_context(
158    command: TauriContextCommand,
159) -> TauriCommandResult<DesktopContextResponse> {
160    DesktopService::new().run_context(command.into_request())
161}
162
163pub fn desktop_build_wakeup(
164    command: TauriWakeupCommand,
165) -> TauriCommandResult<DesktopWakeupResponse> {
166    DesktopService::new().build_wakeup(command.into_request())
167}
168
169pub fn desktop_optimize_prompt(
170    command: TauriPromptOptimizeCommand,
171) -> TauriCommandResult<DesktopPromptOptimizeResponse> {
172    DesktopService::new().optimize_prompt(command.into_request())
173}
174
175pub fn desktop_load_workbench(
176    command: TauriWorkbenchCommand,
177) -> TauriCommandResult<DesktopWorkbenchResponse> {
178    DesktopService::new().load_workbench(command.into_request())
179}
180
181pub fn desktop_get_record(
182    command: TauriRecordCommand,
183) -> TauriCommandResult<Option<DesktopRecordResponse>> {
184    DesktopService::new().get_record(command.into_request())
185}
186
187pub fn desktop_get_history(
188    command: TauriRecordCommand,
189) -> TauriCommandResult<DesktopHistoryResponse> {
190    DesktopService::new().get_history(command.into_request())
191}
192
193pub fn desktop_browse_sessions(
194    command: TauriSessionBrowserCommand,
195) -> TauriCommandResult<DesktopSessionBrowserResponse> {
196    DesktopService::new().browse_sessions(command.into_request())
197}
198
199pub fn desktop_get_session(
200    command: TauriSessionDetailCommand,
201) -> TauriCommandResult<Option<DesktopSessionDetailResponse>> {
202    DesktopService::new().get_session(command.into_request())
203}
204
205pub fn desktop_collect_status(
206    command: TauriStatusCommand,
207) -> TauriCommandResult<DesktopStatusResponse> {
208    DesktopService::new().collect_status(command.into_request())
209}
210
211pub fn desktop_continue_session(
212    command: TauriSessionActionCommand,
213) -> TauriCommandResult<DesktopSessionActionResponse> {
214    DesktopService::new().continue_session(command.into_request())
215}
216
217pub fn desktop_delete_session(
218    command: TauriSessionActionCommand,
219) -> TauriCommandResult<DesktopSessionActionResponse> {
220    DesktopService::new().delete_session(command.into_request())
221}
222
223pub fn desktop_record_manual(
224    command: TauriMemoryDraftCommand,
225) -> TauriCommandResult<DesktopWriteResponse> {
226    DesktopService::new().record_manual(command.into_request())
227}
228
229pub fn desktop_propose_memory(
230    command: TauriMemoryDraftCommand,
231) -> TauriCommandResult<DesktopWriteResponse> {
232    DesktopService::new().propose_memory(command.into_request())
233}
234
235pub fn desktop_apply_memory_action(
236    command: TauriMemoryActionCommand,
237) -> TauriCommandResult<DesktopWriteResponse> {
238    DesktopService::new().apply_memory_action(command.into_request())
239}
240
241pub fn desktop_import_session(
242    command: TauriImportSessionCommand,
243) -> TauriCommandResult<DesktopImportSessionResponse> {
244    DesktopService::new().import_session(command.into_request())
245}
246
247pub fn desktop_wiki_lint(
248    request: DesktopWikiLintRequest,
249) -> TauriCommandResult<DesktopWikiLintResponse> {
250    DesktopService::new().wiki_lint(request)
251}
252
253pub fn desktop_read_wiki_index(
254    request: DesktopWikiIndexRequest,
255) -> TauriCommandResult<DesktopWikiIndexResponse> {
256    DesktopService::new().read_wiki_index(request)
257}
258
259impl TauriContextCommand {
260    fn into_request(self) -> DesktopRouteRequest {
261        DesktopRouteRequest {
262            config_path: self.config_path,
263            vault_root_override: self.vault_root_override,
264            cwd: self.cwd,
265            task: self.task,
266            files: self.files,
267            target: self.target,
268            format: self.format,
269        }
270    }
271}
272
273impl TauriWakeupCommand {
274    fn into_request(self) -> DesktopWakeupRequest {
275        DesktopWakeupRequest {
276            config_path: self.config_path,
277            vault_root_override: self.vault_root_override,
278            cwd: self.cwd,
279            task: self.task,
280            files: self.files,
281            target: self.target,
282            profile: self.profile,
283        }
284    }
285}
286
287impl TauriPromptOptimizeCommand {
288    fn into_request(self) -> DesktopPromptOptimizeRequest {
289        DesktopPromptOptimizeRequest {
290            config_path: self.config_path,
291            vault_root_override: self.vault_root_override,
292            cwd: self.cwd,
293            task: self.task,
294            files: self.files,
295            target: self.target,
296            profile: self.profile,
297            provider: self.provider,
298            session_id: self.session_id,
299        }
300    }
301}
302
303impl TauriWorkbenchCommand {
304    fn into_request(self) -> DesktopWorkbenchRequest {
305        DesktopWorkbenchRequest {
306            config_path: self.config_path,
307            daemon: self.daemon.map(TauriDaemonCommand::into_request),
308        }
309    }
310}
311
312impl TauriRecordCommand {
313    fn into_request(self) -> DesktopRecordLookupRequest {
314        DesktopRecordLookupRequest {
315            config_path: self.config_path,
316            record_id: self.record_id,
317            daemon: self.daemon.map(TauriDaemonCommand::into_request),
318        }
319    }
320}
321
322impl TauriDaemonCommand {
323    fn into_request(self) -> DesktopDaemonRequest {
324        DesktopDaemonRequest {
325            enabled: self.enabled,
326            daemon_bin: self.daemon_bin,
327        }
328    }
329}
330
331impl TauriSessionBrowserCommand {
332    fn into_request(self) -> DesktopSessionBrowserRequest {
333        DesktopSessionBrowserRequest {
334            config_path: self.config_path,
335            page: self.page.unwrap_or(1),
336            per_page: self.per_page.unwrap_or(10),
337            provider: self.provider,
338        }
339    }
340}
341
342impl TauriSessionDetailCommand {
343    fn into_request(self) -> DesktopSessionDetailRequest {
344        DesktopSessionDetailRequest {
345            config_path: self.config_path,
346            session_id: self.session_id,
347            message_offset: self.message_offset,
348            message_limit: self.message_limit,
349        }
350    }
351}
352
353impl TauriStatusCommand {
354    fn into_request(self) -> DesktopStatusRequest {
355        DesktopStatusRequest {
356            config_path: self.config_path,
357            vault_root_override: self.vault_root_override,
358            cwd: self.cwd,
359        }
360    }
361}
362
363impl TauriSessionActionCommand {
364    fn into_request(self) -> DesktopSessionActionRequest {
365        DesktopSessionActionRequest {
366            config_path: self.config_path,
367            session_id: self.session_id,
368        }
369    }
370}
371
372impl TauriMemoryDraftCommand {
373    fn into_request(self) -> DesktopMemoryDraftRequest {
374        DesktopMemoryDraftRequest {
375            config_path: self.config_path,
376            title: self.title,
377            summary: self.summary,
378            memory_type: self.memory_type,
379            scope: self.scope,
380            source_ref: self.source_ref,
381            project_id: self.project_id,
382            user_id: self.user_id,
383            sensitivity: self.sensitivity,
384            metadata: self.metadata,
385            entities: self.entities,
386            tags: self.tags,
387            triggers: self.triggers,
388            related_files: self.related_files,
389            related_records: self.related_records,
390            supersedes: self.supersedes,
391            applies_to: self.applies_to,
392            valid_until: self.valid_until,
393        }
394    }
395}
396
397impl TauriMemoryActionCommand {
398    fn into_request(self) -> DesktopMemoryActionRequest {
399        DesktopMemoryActionRequest {
400            config_path: self.config_path,
401            record_id: self.record_id,
402            action: self.action,
403            metadata: self.metadata,
404        }
405    }
406}
407
408impl TauriImportSessionCommand {
409    fn into_request(self) -> DesktopImportSessionRequest {
410        DesktopImportSessionRequest {
411            config_path: self.config_path,
412            provider: self.provider,
413            session_id: self.session_id,
414            apply: self.apply,
415            actor: self.actor,
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::{
423        TauriContextCommand, TauriDaemonCommand, TauriMemoryActionCommand, TauriMemoryDraftCommand,
424        TauriRecordCommand, TauriWakeupCommand, TauriWorkbenchCommand, desktop_apply_memory_action,
425        desktop_build_wakeup, desktop_get_history, desktop_get_record, desktop_load_workbench,
426        desktop_propose_memory, desktop_run_context,
427    };
428    use crate::desktop::{DesktopLifecycleActionDto, DesktopMetadataDto};
429    use crate::domain::{
430        MemoryLifecycleState, MemoryScope, OutputFormat, TargetTool, WakeupProfile,
431    };
432    use std::fs;
433    use tempfile::tempdir;
434
435    fn setup_workspace() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
436        let temp = tempdir().unwrap();
437        let vault_root = temp.path().join("vault");
438        let repo_root = temp.path().join("repo");
439        fs::create_dir_all(vault_root.join("10-Projects")).unwrap();
440        fs::create_dir_all(&repo_root).unwrap();
441        fs::write(
442            vault_root.join("10-Projects/spool.md"),
443            "---\ntitle: spool\nmemory_type: project\n---\n\nDesktop tauri command test note.\n",
444        )
445        .unwrap();
446        let config_path = temp.path().join("spool.toml");
447        fs::write(
448            &config_path,
449            format!(
450                "[vault]\nroot = \"{}\"\n\n[output]\ndefault_format = \"markdown\"\nmax_chars = 4000\nmax_notes = 8\n\n[[projects]]\nid = \"spool\"\nname = \"spool\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
451                vault_root.display(),
452                repo_root.display(),
453            ),
454        )
455        .unwrap();
456        (temp, config_path, repo_root)
457    }
458
459    #[test]
460    fn tauri_commands_should_parse_inputs_and_run_context_and_wakeup() {
461        let (_temp, config_path, repo_root) = setup_workspace();
462
463        let context = desktop_run_context(TauriContextCommand {
464            config_path: config_path.clone(),
465            vault_root_override: None,
466            cwd: repo_root.clone(),
467            task: "summarize current project context".to_string(),
468            files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
469            target: TargetTool::Claude,
470            format: OutputFormat::Markdown,
471        })
472        .unwrap();
473        assert_eq!(context.bundle.route.debug.note_count, 1);
474
475        let wakeup = desktop_build_wakeup(TauriWakeupCommand {
476            config_path,
477            vault_root_override: None,
478            cwd: repo_root,
479            task: "prepare restart packet".to_string(),
480            files: Vec::new(),
481            target: TargetTool::Claude,
482            profile: WakeupProfile::Project,
483        })
484        .unwrap();
485        assert_eq!(wakeup.packet.profile, WakeupProfile::Project);
486    }
487
488    #[test]
489    fn tauri_commands_should_cover_lifecycle_read_and_write_flows() {
490        let (_temp, config_path, _repo_root) = setup_workspace();
491
492        let created = desktop_propose_memory(TauriMemoryDraftCommand {
493            config_path: config_path.clone(),
494            title: "测试偏好".to_string(),
495            summary: "先 smoke 再收口".to_string(),
496            memory_type: "workflow".to_string(),
497            scope: MemoryScope::User,
498            source_ref: "session:1".to_string(),
499            project_id: None,
500            user_id: Some("long".to_string()),
501            sensitivity: None,
502            metadata: DesktopMetadataDto {
503                actor: Some("desktop".to_string()),
504                reason: Some("captured from tauri command".to_string()),
505                evidence_refs: vec!["session:1".to_string(), "obsidian://workflow".to_string()],
506            },
507            entities: Vec::new(),
508            tags: Vec::new(),
509            triggers: Vec::new(),
510            related_files: Vec::new(),
511            related_records: Vec::new(),
512            supersedes: None,
513            applies_to: Vec::new(),
514            valid_until: None,
515        })
516        .unwrap();
517        assert_eq!(created.entry.record.state, MemoryLifecycleState::Candidate);
518
519        let workbench = desktop_load_workbench(TauriWorkbenchCommand {
520            config_path: config_path.clone(),
521            daemon: Some(TauriDaemonCommand {
522                enabled: false,
523                daemon_bin: None,
524            }),
525        })
526        .unwrap();
527        assert_eq!(workbench.snapshot.pending_review.len(), 1);
528
529        let record = desktop_get_record(TauriRecordCommand {
530            config_path: config_path.clone(),
531            record_id: created.entry.record_id.clone(),
532            daemon: None,
533        })
534        .unwrap()
535        .unwrap();
536        assert_eq!(record.record.record.title, "测试偏好");
537
538        let action = desktop_apply_memory_action(TauriMemoryActionCommand {
539            config_path: config_path.clone(),
540            record_id: created.entry.record_id.clone(),
541            action: DesktopLifecycleActionDto::Accept,
542            metadata: DesktopMetadataDto::default(),
543        })
544        .unwrap();
545        assert_eq!(action.entry.record.state, MemoryLifecycleState::Accepted);
546
547        let history = desktop_get_history(TauriRecordCommand {
548            config_path,
549            record_id: created.entry.record_id,
550            daemon: None,
551        })
552        .unwrap();
553        assert_eq!(history.history.len(), 2);
554    }
555}