1use std::sync::Arc;
2
3use crate::daemon::domain::{
4 AttachInput, AttachOutput, CleanupFailure, CleanupInput, CleanupOutput, KillOutput,
5 ResizeInput, ResizeOutput, RestartOutput, SessionInput, SessionsOutput, SpawnInput,
6 SpawnOutput,
7};
8use crate::daemon::error::{DomainError, SessionError};
9use crate::daemon::repository::SessionRepository;
10use crate::daemon::session::SessionId;
11
12pub trait SpawnUseCase: Send + Sync {
19 fn execute(&self, input: SpawnInput) -> Result<SpawnOutput, DomainError>;
20}
21
22pub struct SpawnUseCaseImpl<R: SessionRepository> {
23 repository: Arc<R>,
24}
25
26impl<R: SessionRepository> SpawnUseCaseImpl<R> {
27 pub fn new(repository: Arc<R>) -> Self {
28 Self { repository }
29 }
30}
31
32impl<R: SessionRepository> SpawnUseCase for SpawnUseCaseImpl<R> {
33 fn execute(&self, input: SpawnInput) -> Result<SpawnOutput, DomainError> {
34 let session_id_str = input.session_id.map(|id| id.to_string());
36 let command = input.command.clone();
37
38 match self.repository.spawn(
39 &input.command,
40 &input.args,
41 input.cwd.as_deref(),
42 input.env.as_ref(),
43 session_id_str,
44 input.cols,
45 input.rows,
46 ) {
47 Ok((session_id, pid)) => Ok(SpawnOutput { session_id, pid }),
48 Err(SessionError::LimitReached(max)) => Err(DomainError::SessionLimitReached { max }),
49 Err(e) => {
50 let err_str = e.to_string();
52 if err_str.contains("No such file") || err_str.contains("not found") {
53 Err(DomainError::CommandNotFound { command })
54 } else if err_str.contains("Permission denied") {
55 Err(DomainError::PermissionDenied { command })
56 } else {
57 Err(DomainError::PtyError {
58 operation: "spawn".to_string(),
59 reason: err_str,
60 })
61 }
62 }
63 }
64 }
65}
66
67pub trait KillUseCase: Send + Sync {
68 fn execute(&self, input: SessionInput) -> Result<KillOutput, SessionError>;
69}
70
71pub struct KillUseCaseImpl<R: SessionRepository> {
72 repository: Arc<R>,
73}
74
75impl<R: SessionRepository> KillUseCaseImpl<R> {
76 pub fn new(repository: Arc<R>) -> Self {
77 Self { repository }
78 }
79}
80
81impl<R: SessionRepository> KillUseCase for KillUseCaseImpl<R> {
82 fn execute(&self, input: SessionInput) -> Result<KillOutput, SessionError> {
83 let session = self.repository.resolve(input.session_id.as_deref())?;
84 let id = {
85 let guard = session.lock().unwrap();
86 SessionId::from(guard.id.as_str())
87 };
88
89 self.repository.kill(id.as_str())?;
90
91 Ok(KillOutput {
92 session_id: id,
93 success: true,
94 })
95 }
96}
97
98pub trait SessionsUseCase: Send + Sync {
99 fn execute(&self) -> SessionsOutput;
100}
101
102pub struct SessionsUseCaseImpl<R: SessionRepository> {
103 repository: Arc<R>,
104}
105
106impl<R: SessionRepository> SessionsUseCaseImpl<R> {
107 pub fn new(repository: Arc<R>) -> Self {
108 Self { repository }
109 }
110}
111
112impl<R: SessionRepository> SessionsUseCase for SessionsUseCaseImpl<R> {
113 fn execute(&self) -> SessionsOutput {
114 let sessions = self.repository.list();
115 let active_session = self.repository.active_session_id();
116
117 SessionsOutput {
118 sessions,
119 active_session,
120 }
121 }
122}
123
124pub trait RestartUseCase: Send + Sync {
125 fn execute(&self, input: SessionInput) -> Result<RestartOutput, SessionError>;
126}
127
128pub struct RestartUseCaseImpl<R: SessionRepository> {
129 repository: Arc<R>,
130}
131
132impl<R: SessionRepository> RestartUseCaseImpl<R> {
133 pub fn new(repository: Arc<R>) -> Self {
134 Self { repository }
135 }
136}
137
138impl<R: SessionRepository> RestartUseCase for RestartUseCaseImpl<R> {
139 fn execute(&self, input: SessionInput) -> Result<RestartOutput, SessionError> {
140 let session = self.repository.resolve(input.session_id.as_deref())?;
141
142 let (old_id, command, cols, rows) = {
143 let guard = session.lock().unwrap();
144 let (c, r) = guard.size();
145 (
146 SessionId::from(guard.id.as_str()),
147 guard.command.clone(),
148 c,
149 r,
150 )
151 };
152
153 self.repository.kill(old_id.as_str())?;
154
155 let (new_session_id, pid) =
156 self.repository
157 .spawn(&command, &[], None, None, None, cols, rows)?;
158
159 Ok(RestartOutput {
160 old_session_id: old_id,
161 new_session_id,
162 command,
163 pid,
164 })
165 }
166}
167
168pub trait AttachUseCase: Send + Sync {
169 fn execute(&self, input: AttachInput) -> Result<AttachOutput, SessionError>;
170}
171
172pub struct AttachUseCaseImpl<R: SessionRepository> {
173 repository: Arc<R>,
174}
175
176impl<R: SessionRepository> AttachUseCaseImpl<R> {
177 pub fn new(repository: Arc<R>) -> Self {
178 Self { repository }
179 }
180}
181
182impl<R: SessionRepository> AttachUseCase for AttachUseCaseImpl<R> {
183 fn execute(&self, input: AttachInput) -> Result<AttachOutput, SessionError> {
184 let session_id_str = input.session_id.to_string();
185 let session = self.repository.resolve(Some(&session_id_str))?;
186
187 let is_running = {
188 let mut guard = session.lock().unwrap();
189 guard.is_running()
190 };
191
192 if !is_running {
193 return Err(SessionError::NotFound(format!(
194 "{} (session not running)",
195 session_id_str
196 )));
197 }
198
199 self.repository.set_active(&session_id_str)?;
200
201 Ok(AttachOutput {
202 session_id: input.session_id,
203 success: true,
204 message: format!("Now attached to session {}", session_id_str),
205 })
206 }
207}
208
209pub trait ResizeUseCase: Send + Sync {
210 fn execute(&self, input: ResizeInput) -> Result<ResizeOutput, SessionError>;
211}
212
213pub struct ResizeUseCaseImpl<R: SessionRepository> {
214 repository: Arc<R>,
215}
216
217impl<R: SessionRepository> ResizeUseCaseImpl<R> {
218 pub fn new(repository: Arc<R>) -> Self {
219 Self { repository }
220 }
221}
222
223impl<R: SessionRepository> ResizeUseCase for ResizeUseCaseImpl<R> {
224 fn execute(&self, input: ResizeInput) -> Result<ResizeOutput, SessionError> {
225 let session = self.repository.resolve(input.session_id.as_deref())?;
226 let mut guard = session.lock().unwrap();
227
228 guard.resize(input.cols, input.rows)?;
229
230 Ok(ResizeOutput {
231 session_id: SessionId::from(guard.id.as_str()),
232 success: true,
233 cols: input.cols,
234 rows: input.rows,
235 })
236 }
237}
238
239pub trait CleanupUseCase: Send + Sync {
245 fn execute(&self, input: CleanupInput) -> CleanupOutput;
246}
247
248pub struct CleanupUseCaseImpl<R: SessionRepository> {
249 repository: Arc<R>,
250}
251
252impl<R: SessionRepository> CleanupUseCaseImpl<R> {
253 pub fn new(repository: Arc<R>) -> Self {
254 Self { repository }
255 }
256}
257
258impl<R: SessionRepository> CleanupUseCase for CleanupUseCaseImpl<R> {
259 fn execute(&self, input: CleanupInput) -> CleanupOutput {
260 let sessions = self.repository.list();
261 let mut cleaned = 0;
262 let mut failures = Vec::new();
263
264 for info in sessions {
265 let should_cleanup = input.all || !info.is_active();
267
268 if should_cleanup {
269 match self.repository.kill(info.id.as_str()) {
270 Ok(_) => cleaned += 1,
271 Err(e) => failures.push(CleanupFailure {
272 session_id: info.id.clone(),
273 error: e.to_string(),
274 }),
275 }
276 }
277 }
278
279 CleanupOutput { cleaned, failures }
280 }
281}
282
283use crate::daemon::domain::{AssertConditionType, AssertInput, AssertOutput};
284
285pub trait AssertUseCase: Send + Sync {
292 fn execute(&self, input: AssertInput) -> Result<AssertOutput, SessionError>;
293}
294
295pub struct AssertUseCaseImpl<R: SessionRepository> {
296 repository: Arc<R>,
297}
298
299impl<R: SessionRepository> AssertUseCaseImpl<R> {
300 pub fn new(repository: Arc<R>) -> Self {
301 Self { repository }
302 }
303}
304
305impl<R: SessionRepository> AssertUseCase for AssertUseCaseImpl<R> {
306 fn execute(&self, input: AssertInput) -> Result<AssertOutput, SessionError> {
307 let condition = format!("{}:{}", input.condition_type.as_str(), input.value);
308
309 let passed = match input.condition_type {
310 AssertConditionType::Text => {
311 let session = self.repository.resolve(input.session_id.as_deref())?;
313 let mut guard = session.lock().unwrap();
314 guard.update()?;
315 let screen = guard.screen_text();
316 screen.contains(&input.value)
317 }
318 AssertConditionType::Element => {
319 let session = self.repository.resolve(input.session_id.as_deref())?;
321 let mut guard = session.lock().unwrap();
322 guard.update()?;
323 guard.detect_elements();
324 guard.find_element(&input.value).is_some()
325 }
326 AssertConditionType::Session => {
327 let sessions = self.repository.list();
329 sessions
330 .iter()
331 .any(|s| s.id.as_str() == input.value && s.is_active())
332 }
333 };
334
335 Ok(AssertOutput { passed, condition })
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::daemon::domain::SessionId;
343 use crate::daemon::session::SessionInfo;
344 use crate::daemon::test_support::{MockError, MockSessionRepository};
345 use std::collections::HashMap;
346
347 #[test]
352 fn test_spawn_usecase_forwards_all_parameters_to_repository() {
353 let repo = Arc::new(
354 MockSessionRepository::builder()
355 .with_spawn_result("new-session", 12345)
356 .build(),
357 );
358 let usecase = SpawnUseCaseImpl::new(repo.clone());
359
360 let mut env = HashMap::new();
361 env.insert("FOO".to_string(), "bar".to_string());
362
363 let input = SpawnInput {
364 command: "bash".to_string(),
365 args: vec!["-c".to_string(), "echo hello".to_string()],
366 cwd: Some("/tmp".to_string()),
367 env: Some(env.clone()),
368 session_id: Some(SessionId::new("custom-id")),
369 cols: 120,
370 rows: 40,
371 };
372
373 let result = usecase.execute(input);
374 assert!(result.is_ok());
375
376 let params = repo.spawn_params();
377 assert_eq!(params.len(), 1);
378 assert_eq!(params[0].command, "bash");
379 assert_eq!(params[0].args, vec!["-c", "echo hello"]);
380 assert_eq!(params[0].cwd, Some("/tmp".to_string()));
381 assert_eq!(params[0].env, Some(env));
382 assert_eq!(params[0].session_id, Some("custom-id".to_string()));
383 assert_eq!(params[0].cols, 120);
384 assert_eq!(params[0].rows, 40);
385 }
386
387 #[test]
388 fn test_spawn_usecase_returns_session_id_and_pid() {
389 let repo = Arc::new(
390 MockSessionRepository::builder()
391 .with_spawn_result("test-session-123", 54321)
392 .build(),
393 );
394 let usecase = SpawnUseCaseImpl::new(repo);
395
396 let input = SpawnInput {
397 command: "vim".to_string(),
398 args: vec![],
399 cwd: None,
400 env: None,
401 session_id: None,
402 cols: 80,
403 rows: 24,
404 };
405
406 let result = usecase.execute(input).unwrap();
407 assert_eq!(result.session_id.as_str(), "test-session-123");
408 assert_eq!(result.pid, 54321);
409 }
410
411 #[test]
412 fn test_spawn_usecase_uses_default_cols_and_rows() {
413 let repo = Arc::new(
414 MockSessionRepository::builder()
415 .with_spawn_result("session", 1000)
416 .build(),
417 );
418 let usecase = SpawnUseCaseImpl::new(repo.clone());
419
420 let input = SpawnInput {
421 command: "cat".to_string(),
422 args: vec![],
423 cwd: None,
424 env: None,
425 session_id: None,
426 cols: 80,
427 rows: 24,
428 };
429
430 let _ = usecase.execute(input);
431
432 let params = repo.spawn_params();
433 assert_eq!(params[0].cols, 80);
434 assert_eq!(params[0].rows, 24);
435 }
436
437 #[test]
438 fn test_spawn_usecase_propagates_limit_reached_error() {
439 let repo = Arc::new(
440 MockSessionRepository::builder()
441 .with_spawn_error(MockError::LimitReached(16))
442 .build(),
443 );
444 let usecase = SpawnUseCaseImpl::new(repo);
445
446 let input = SpawnInput {
447 command: "bash".to_string(),
448 args: vec![],
449 cwd: None,
450 env: None,
451 session_id: None,
452 cols: 80,
453 rows: 24,
454 };
455
456 let result = usecase.execute(input);
457 assert!(matches!(
458 result,
459 Err(DomainError::SessionLimitReached { max: 16 })
460 ));
461 }
462
463 #[test]
464 fn test_spawn_usecase_custom_session_id_respected() {
465 let repo = Arc::new(
466 MockSessionRepository::builder()
467 .with_spawn_result("my-custom-session", 1)
468 .build(),
469 );
470 let usecase = SpawnUseCaseImpl::new(repo.clone());
471
472 let input = SpawnInput {
473 command: "bash".to_string(),
474 args: vec![],
475 cwd: None,
476 env: None,
477 session_id: Some(SessionId::new("my-custom-session")),
478 cols: 80,
479 rows: 24,
480 };
481
482 let result = usecase.execute(input).unwrap();
483 assert_eq!(result.session_id.as_str(), "my-custom-session");
484
485 let params = repo.spawn_params();
486 assert_eq!(params[0].session_id, Some("my-custom-session".to_string()));
487 }
488
489 #[test]
494 fn test_spawn_usecase_classifies_command_not_found_error() {
495 let repo = Arc::new(
496 MockSessionRepository::builder()
497 .with_spawn_error(MockError::Pty("No such file or directory".to_string()))
498 .build(),
499 );
500 let usecase = SpawnUseCaseImpl::new(repo);
501
502 let input = SpawnInput {
503 command: "nonexistent-command".to_string(),
504 args: vec![],
505 cwd: None,
506 env: None,
507 session_id: None,
508 cols: 80,
509 rows: 24,
510 };
511
512 let result = usecase.execute(input);
513 assert!(matches!(
514 result,
515 Err(DomainError::CommandNotFound { command }) if command == "nonexistent-command"
516 ));
517 }
518
519 #[test]
520 fn test_spawn_usecase_classifies_not_found_variant_error() {
521 let repo = Arc::new(
522 MockSessionRepository::builder()
523 .with_spawn_error(MockError::Pty("command not found".to_string()))
524 .build(),
525 );
526 let usecase = SpawnUseCaseImpl::new(repo);
527
528 let input = SpawnInput {
529 command: "missing-cmd".to_string(),
530 args: vec![],
531 cwd: None,
532 env: None,
533 session_id: None,
534 cols: 80,
535 rows: 24,
536 };
537
538 let result = usecase.execute(input);
539 assert!(matches!(
540 result,
541 Err(DomainError::CommandNotFound { command }) if command == "missing-cmd"
542 ));
543 }
544
545 #[test]
546 fn test_spawn_usecase_classifies_permission_denied_error() {
547 let repo = Arc::new(
548 MockSessionRepository::builder()
549 .with_spawn_error(MockError::Pty("Permission denied".to_string()))
550 .build(),
551 );
552 let usecase = SpawnUseCaseImpl::new(repo);
553
554 let input = SpawnInput {
555 command: "/etc/shadow".to_string(),
556 args: vec![],
557 cwd: None,
558 env: None,
559 session_id: None,
560 cols: 80,
561 rows: 24,
562 };
563
564 let result = usecase.execute(input);
565 assert!(matches!(
566 result,
567 Err(DomainError::PermissionDenied { command }) if command == "/etc/shadow"
568 ));
569 }
570
571 #[test]
572 fn test_spawn_usecase_classifies_generic_pty_error() {
573 let repo = Arc::new(
574 MockSessionRepository::builder()
575 .with_spawn_error(MockError::Pty("unknown error occurred".to_string()))
576 .build(),
577 );
578 let usecase = SpawnUseCaseImpl::new(repo);
579
580 let input = SpawnInput {
581 command: "some-command".to_string(),
582 args: vec![],
583 cwd: None,
584 env: None,
585 session_id: None,
586 cols: 80,
587 rows: 24,
588 };
589
590 let result = usecase.execute(input);
591 match result {
592 Err(DomainError::PtyError { operation, reason }) => {
593 assert_eq!(operation, "spawn");
594 assert!(reason.contains("unknown error"));
595 }
596 _ => panic!("Expected PtyError but got {:?}", result),
597 }
598 }
599
600 #[test]
605 fn test_sessions_usecase_returns_empty_list_when_no_sessions() {
606 let repo = Arc::new(MockSessionRepository::new());
607 let usecase = SessionsUseCaseImpl::new(repo);
608
609 let result = usecase.execute();
610 assert!(result.sessions.is_empty());
611 assert!(result.active_session.is_none());
612 }
613
614 #[test]
615 fn test_sessions_usecase_returns_configured_sessions() {
616 let sessions = vec![
617 SessionInfo {
618 id: SessionId::new("session1"),
619 command: "bash".to_string(),
620 pid: 1001,
621 running: true,
622 created_at: "2024-01-01T00:00:00Z".to_string(),
623 size: (80, 24),
624 },
625 SessionInfo {
626 id: SessionId::new("session2"),
627 command: "vim".to_string(),
628 pid: 1002,
629 running: true,
630 created_at: "2024-01-01T01:00:00Z".to_string(),
631 size: (120, 40),
632 },
633 ];
634
635 let repo = Arc::new(
636 MockSessionRepository::builder()
637 .with_sessions(sessions)
638 .with_active_session("session1")
639 .build(),
640 );
641 let usecase = SessionsUseCaseImpl::new(repo);
642
643 let result = usecase.execute();
644 assert_eq!(result.sessions.len(), 2);
645 assert_eq!(result.sessions[0].id.as_str(), "session1");
646 assert_eq!(result.sessions[0].command, "bash");
647 assert_eq!(result.sessions[1].id.as_str(), "session2");
648 assert_eq!(result.sessions[1].command, "vim");
649 assert_eq!(result.active_session.unwrap().as_str(), "session1");
650 }
651
652 #[test]
653 fn test_sessions_usecase_returns_active_session_none_when_not_set() {
654 let sessions = vec![SessionInfo {
655 id: SessionId::new("orphan"),
656 command: "sleep".to_string(),
657 pid: 999,
658 running: true,
659 created_at: "2024-01-01T00:00:00Z".to_string(),
660 size: (80, 24),
661 }];
662
663 let repo = Arc::new(
664 MockSessionRepository::builder()
665 .with_sessions(sessions)
666 .build(),
667 );
668 let usecase = SessionsUseCaseImpl::new(repo);
669
670 let result = usecase.execute();
671 assert_eq!(result.sessions.len(), 1);
672 assert!(result.active_session.is_none());
673 }
674
675 #[test]
680 fn test_kill_usecase_returns_error_when_no_active_session() {
681 let repo = Arc::new(MockSessionRepository::new());
682 let usecase = KillUseCaseImpl::new(repo);
683
684 let input = SessionInput { session_id: None };
685 let result = usecase.execute(input);
686 assert!(matches!(result, Err(SessionError::NoActiveSession)));
687 }
688
689 #[test]
690 fn test_kill_usecase_returns_error_when_session_not_found() {
691 let repo = Arc::new(
692 MockSessionRepository::builder()
693 .with_resolve_error(MockError::NotFound("nonexistent".to_string()))
694 .build(),
695 );
696 let usecase = KillUseCaseImpl::new(repo);
697
698 let input = SessionInput {
699 session_id: Some(SessionId::new("nonexistent")),
700 };
701 let result = usecase.execute(input);
702 assert!(matches!(result, Err(SessionError::NotFound(_))));
703 }
704
705 #[test]
710 fn test_restart_usecase_returns_error_when_no_active_session() {
711 let repo = Arc::new(MockSessionRepository::new());
712 let usecase = RestartUseCaseImpl::new(repo);
713
714 let input = SessionInput { session_id: None };
715 let result = usecase.execute(input);
716 assert!(matches!(result, Err(SessionError::NoActiveSession)));
717 }
718
719 #[test]
720 fn test_restart_usecase_returns_error_when_session_not_found() {
721 let repo = Arc::new(
722 MockSessionRepository::builder()
723 .with_resolve_error(MockError::NotFound("missing".to_string()))
724 .build(),
725 );
726 let usecase = RestartUseCaseImpl::new(repo);
727
728 let input = SessionInput {
729 session_id: Some(SessionId::new("missing")),
730 };
731 let result = usecase.execute(input);
732 assert!(matches!(result, Err(SessionError::NotFound(id)) if id == "missing"));
733 }
734
735 #[test]
740 fn test_attach_usecase_returns_error_when_session_not_found() {
741 let repo = Arc::new(MockSessionRepository::new());
742 let usecase = AttachUseCaseImpl::new(repo);
743
744 let input = AttachInput {
745 session_id: SessionId::new("nonexistent"),
746 };
747 let result = usecase.execute(input);
748 assert!(matches!(result, Err(SessionError::NotFound(_))));
749 }
750
751 #[test]
752 fn test_attach_usecase_returns_error_with_configured_error() {
753 let repo = Arc::new(
754 MockSessionRepository::builder()
755 .with_resolve_error(MockError::NotFound("target-session".to_string()))
756 .build(),
757 );
758 let usecase = AttachUseCaseImpl::new(repo);
759
760 let input = AttachInput {
761 session_id: SessionId::new("target-session"),
762 };
763 let result = usecase.execute(input);
764 assert!(matches!(result, Err(SessionError::NotFound(id)) if id == "target-session"));
765 }
766
767 #[test]
772 fn test_resize_usecase_returns_error_when_no_active_session() {
773 let repo = Arc::new(MockSessionRepository::new());
774 let usecase = ResizeUseCaseImpl::new(repo);
775
776 let input = ResizeInput {
777 session_id: None,
778 cols: 120,
779 rows: 40,
780 };
781
782 let result = usecase.execute(input);
783 assert!(matches!(result, Err(SessionError::NoActiveSession)));
784 }
785
786 #[test]
787 fn test_resize_usecase_returns_error_when_session_not_found() {
788 let repo = Arc::new(
789 MockSessionRepository::builder()
790 .with_resolve_error(MockError::NotFound("unknown".to_string()))
791 .build(),
792 );
793 let usecase = ResizeUseCaseImpl::new(repo);
794
795 let input = ResizeInput {
796 session_id: Some(SessionId::new("unknown")),
797 cols: 80,
798 rows: 24,
799 };
800
801 let result = usecase.execute(input);
802 assert!(matches!(result, Err(SessionError::NotFound(_))));
803 }
804}