1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use rmcp::{
7 ErrorData as McpError, ServerHandler, ServiceExt,
8 handler::server::{tool::ToolRouter, wrapper::Parameters},
9 model::{
10 CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult,
11 PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo,
12 },
13 service::{RequestContext, RoleServer},
14 tool, tool_router,
15 transport::stdio,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use tokio::sync::RwLock;
20
21use crate::config::Config;
22use crate::just;
23use crate::template;
24
25pub async fn run() -> anyhow::Result<()> {
30 let config = Config::load();
31 let server_cwd = std::env::current_dir()?;
32 let server = TaskMcpServer::new(config, server_cwd);
33 let service = server.serve(stdio()).await?;
34 service.waiting().await?;
35 Ok(())
36}
37
38#[derive(Clone)]
43pub struct TaskMcpServer {
44 tool_router: ToolRouter<Self>,
45 config: Config,
46 log_store: Arc<just::TaskLogStore>,
47 workdir: Arc<RwLock<Option<PathBuf>>>,
49 server_cwd: PathBuf,
51}
52
53#[derive(Debug)]
59pub(crate) enum AutoStartOutcome {
60 Started(SessionStartResponse, PathBuf),
62 AlreadyStarted(PathBuf),
66 NotProjectRoot,
68 CanonicalizeFailed(std::io::Error),
70 NotAllowed(PathBuf),
72}
73
74impl TaskMcpServer {
75 pub fn new(config: Config, server_cwd: PathBuf) -> Self {
76 Self {
77 tool_router: Self::tool_router(),
78 config,
79 log_store: Arc::new(just::TaskLogStore::new(10)),
80 workdir: Arc::new(RwLock::new(None)),
81 server_cwd,
82 }
83 }
84
85 pub(crate) async fn try_auto_session_start(&self) -> AutoStartOutcome {
89 let has_git = tokio::fs::try_exists(self.server_cwd.join(".git"))
91 .await
92 .unwrap_or(false);
93 let has_justfile = tokio::fs::try_exists(self.server_cwd.join("justfile"))
94 .await
95 .unwrap_or(false);
96 if !has_git && !has_justfile {
97 return AutoStartOutcome::NotProjectRoot;
98 }
99
100 let canonical = match tokio::fs::canonicalize(&self.server_cwd).await {
102 Ok(p) => p,
103 Err(e) => return AutoStartOutcome::CanonicalizeFailed(e),
104 };
105
106 if !self.config.is_workdir_allowed(&canonical) {
108 return AutoStartOutcome::NotAllowed(canonical);
109 }
110
111 let mut guard = self.workdir.write().await;
116 if let Some(ref existing) = *guard {
117 return AutoStartOutcome::AlreadyStarted(existing.clone());
118 }
119 *guard = Some(canonical.clone());
120 drop(guard);
121
122 let justfile =
123 resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
124
125 AutoStartOutcome::Started(
126 SessionStartResponse {
127 workdir: canonical.to_string_lossy().into_owned(),
128 justfile: justfile.to_string_lossy().into_owned(),
129 mode: mode_label(&self.config),
130 },
131 canonical,
132 )
133 }
134
135 pub(crate) async fn workdir_or_auto(
142 &self,
143 ) -> Result<(PathBuf, Option<SessionStartResponse>), McpError> {
144 {
146 let guard = self.workdir.read().await;
147 if let Some(ref wd) = *guard {
148 return Ok((wd.clone(), None));
149 }
150 }
151
152 match self.try_auto_session_start().await {
154 AutoStartOutcome::Started(resp, wd) => Ok((wd, Some(resp))),
155 AutoStartOutcome::AlreadyStarted(wd) => Ok((wd, None)),
156 AutoStartOutcome::NotProjectRoot => Err(McpError::internal_error(
157 format!(
158 "session not started. server startup CWD {:?} is not a ProjectRoot (no .git or justfile). Call session_start with an explicit workdir.",
159 self.server_cwd
160 ),
161 None,
162 )),
163 AutoStartOutcome::CanonicalizeFailed(e) => Err(McpError::internal_error(
164 format!(
165 "session not started. failed to canonicalize server startup CWD {:?}: {e}. Call session_start with an explicit workdir.",
166 self.server_cwd
167 ),
168 None,
169 )),
170 AutoStartOutcome::NotAllowed(path) => Err(McpError::internal_error(
171 format!(
172 "session not started. server startup CWD {:?} is not in allowed_dirs. Call session_start with an allowed workdir.",
173 path
174 ),
175 None,
176 )),
177 }
178 }
179}
180
181impl ServerHandler for TaskMcpServer {
186 fn get_info(&self) -> ServerInfo {
187 ServerInfo {
188 protocol_version: ProtocolVersion::V_2025_03_26,
189 capabilities: ServerCapabilities::builder().enable_tools().build(),
190 server_info: Implementation {
191 name: "task-mcp".to_string(),
192 title: Some("Task MCP — Agent-safe Task Runner".to_string()),
193 description: Some(
194 "Execute predefined justfile tasks safely. \
195 6 tools: session_start, info, init, list, run, logs."
196 .to_string(),
197 ),
198 version: env!("CARGO_PKG_VERSION").to_string(),
199 icons: None,
200 website_url: None,
201 },
202 instructions: Some(
203 "Agent-safe task runner backed by just.\n\
204 \n\
205 - `session_start`: Set working directory explicitly. Optional when the \
206 server was launched inside a ProjectRoot (a directory containing `.git` \
207 or `justfile`) — in that case the first `init`/`list`/`run` call auto-starts \
208 the session using the server's startup CWD. Call `session_start` explicitly \
209 only when you need a different workdir (e.g. a subdirectory in a monorepo).\n\
210 - `info`: Show current session state (workdir, mode, etc).\n\
211 - `init`: Generate a justfile in the working directory.\n\
212 - `list`: Show available tasks filtered by [allow-agent] tag.\n\
213 - `run`: Execute a named task.\n\
214 - `logs`: Retrieve execution logs of recent runs.\n\
215 \n\
216 When a call auto-starts the session, the response includes an \
217 `auto_session_start` field with the chosen workdir, justfile, and mode. \
218 Subsequent calls in the same session do not include this field.\n\
219 \n\
220 Only tasks explicitly marked agent-safe are exposed in default mode."
221 .to_string(),
222 ),
223 }
224 }
225
226 async fn list_tools(
227 &self,
228 _request: Option<PaginatedRequestParams>,
229 _context: RequestContext<RoleServer>,
230 ) -> Result<ListToolsResult, McpError> {
231 Ok(ListToolsResult {
232 tools: self.tool_router.list_all(),
233 next_cursor: None,
234 meta: None,
235 })
236 }
237
238 async fn call_tool(
239 &self,
240 request: CallToolRequestParams,
241 context: RequestContext<RoleServer>,
242 ) -> Result<CallToolResult, McpError> {
243 let tool_ctx = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
244 self.tool_router.call(tool_ctx).await
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253struct SessionStartRequest {
254 pub workdir: Option<String>,
256}
257
258#[derive(Debug, Clone, Serialize)]
263pub(crate) struct SessionStartResponse {
264 pub workdir: String,
266 pub justfile: String,
268 pub mode: String,
270}
271
272#[derive(Debug, Clone, Serialize)]
273struct InfoResponse {
274 pub session_started: bool,
276 pub workdir: Option<String>,
278 pub justfile: Option<String>,
280 pub mode: String,
282 pub server_cwd: String,
284 pub load_global: bool,
286 pub global_justfile: Option<String>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
291struct InitRequest {
292 pub project_type: Option<String>,
294 pub template_file: Option<String>,
296}
297
298#[derive(Debug, Clone, Serialize)]
299struct InitResponse {
300 pub justfile: String,
302 pub project_type: String,
304 pub custom_template: bool,
306 #[serde(skip_serializing_if = "Option::is_none")]
311 pub auto_session_start: Option<SessionStartResponse>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
315struct ListRequest {
316 pub filter: Option<String>,
318 pub justfile: Option<String>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
323struct RunRequest {
324 pub task_name: String,
326 pub args: Option<HashMap<String, String>>,
328 pub timeout_secs: Option<u64>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
333struct LogsRequest {
334 pub task_id: Option<String>,
337 pub tail: Option<usize>,
340}
341
342fn resolve_justfile_with_workdir(
351 override_path: Option<&str>,
352 workdir: &std::path::Path,
353) -> PathBuf {
354 match override_path {
355 Some(p) => PathBuf::from(p),
356 None => workdir.join("justfile"),
357 }
358}
359
360fn tail_lines(text: &str, n: usize) -> String {
363 let lines: Vec<&str> = text.lines().collect();
364 if n >= lines.len() {
365 return text.to_string();
366 }
367 lines[lines.len() - n..].join("\n")
368}
369
370fn mode_label(config: &Config) -> String {
371 use crate::config::TaskMode;
372 match config.mode {
373 TaskMode::AgentOnly => "agent-only".to_string(),
374 TaskMode::All => "all".to_string(),
375 }
376}
377
378#[tool_router]
383impl TaskMcpServer {
384 #[tool(
385 name = "session_start",
386 description = "Set the working directory for this session explicitly. Optional when the server was launched inside a ProjectRoot (directory containing `.git` or `justfile`): the first `init`/`list`/`run` call will auto-start the session using the server's startup CWD. Call this tool to override that default, e.g. when working in a monorepo subdirectory. Subsequent `run` and `list` (without justfile param) use the configured directory.",
387 annotations(
388 read_only_hint = false,
389 destructive_hint = false,
390 idempotent_hint = true,
391 open_world_hint = false
392 )
393 )]
394 async fn session_start(
395 &self,
396 Parameters(req): Parameters<SessionStartRequest>,
397 ) -> Result<CallToolResult, McpError> {
398 let raw_path = match req.workdir.as_deref() {
400 Some(s) if !s.trim().is_empty() => PathBuf::from(s),
401 _ => self.server_cwd.clone(),
402 };
403
404 let canonical = tokio::fs::canonicalize(&raw_path).await.map_err(|e| {
406 McpError::invalid_params(
407 format!(
408 "workdir {:?} does not exist or is not accessible: {e}",
409 raw_path
410 ),
411 None,
412 )
413 })?;
414
415 if !self.config.is_workdir_allowed(&canonical) {
417 return Err(McpError::invalid_params(
418 format!(
419 "workdir {:?} is not in the allowed directories list",
420 canonical
421 ),
422 None,
423 ));
424 }
425
426 *self.workdir.write().await = Some(canonical.clone());
428
429 let justfile =
430 resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
431
432 let response = SessionStartResponse {
433 workdir: canonical.to_string_lossy().into_owned(),
434 justfile: justfile.to_string_lossy().into_owned(),
435 mode: mode_label(&self.config),
436 };
437
438 let output = serde_json::to_string_pretty(&response)
439 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
440
441 Ok(CallToolResult {
442 content: vec![Content::text(output)],
443 structured_content: None,
444 is_error: Some(false),
445 meta: None,
446 })
447 }
448
449 #[tool(
450 name = "init",
451 description = "Generate a justfile with agent-safe recipes in the session working directory. The session is auto-started if the server was launched inside a ProjectRoot; otherwise call `session_start` first. Supports project types: rust (default), vite-react. Custom template files can also be specified. Fails if justfile already exists — delete it first to regenerate.",
452 annotations(
453 read_only_hint = false,
454 destructive_hint = false,
455 idempotent_hint = false,
456 open_world_hint = false
457 )
458 )]
459 async fn init(
460 &self,
461 Parameters(req): Parameters<InitRequest>,
462 ) -> Result<CallToolResult, McpError> {
463 let (workdir, auto) = self.workdir_or_auto().await?;
464
465 let project_type = match req.project_type.as_deref() {
467 Some(s) => s
468 .parse::<template::ProjectType>()
469 .map_err(|e| McpError::invalid_params(e, None))?,
470 None => template::ProjectType::default(),
471 };
472
473 let justfile_path = workdir.join("justfile");
474
475 if justfile_path.exists() {
477 return Err(McpError::invalid_params(
478 format!(
479 "justfile already exists at {}. Delete it first if you want to regenerate.",
480 justfile_path.display()
481 ),
482 None,
483 ));
484 }
485
486 if let Some(ref tf) = req.template_file {
488 let template_path = std::fs::canonicalize(tf).map_err(|e| {
489 McpError::invalid_params(
490 format!("template_file {tf:?} is not accessible: {e}"),
491 None,
492 )
493 })?;
494 if !template_path.starts_with(&workdir) {
495 return Err(McpError::invalid_params(
496 format!(
497 "template_file must be under session workdir ({}). Got: {}",
498 workdir.display(),
499 template_path.display()
500 ),
501 None,
502 ));
503 }
504 }
505
506 let custom_template_used =
508 req.template_file.is_some() || self.config.init_template_file.is_some();
509
510 let content = template::resolve_template(
511 project_type,
512 req.template_file.as_deref(),
513 self.config.init_template_file.as_deref(),
514 )
515 .await
516 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
517
518 tokio::fs::write(&justfile_path, &content)
520 .await
521 .map_err(|e| {
522 McpError::internal_error(
523 format!(
524 "failed to write justfile at {}: {e}",
525 justfile_path.display()
526 ),
527 None,
528 )
529 })?;
530
531 let response = InitResponse {
532 justfile: justfile_path.to_string_lossy().into_owned(),
533 project_type: project_type.to_string(),
534 custom_template: custom_template_used,
535 auto_session_start: auto,
536 };
537
538 let output = serde_json::to_string_pretty(&response)
539 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
540
541 Ok(CallToolResult {
542 content: vec![Content::text(output)],
543 structured_content: None,
544 is_error: Some(false),
545 meta: None,
546 })
547 }
548
549 #[tool(
550 name = "info",
551 description = "Show current session state: workdir, justfile path, mode, and server startup CWD.",
552 annotations(
553 read_only_hint = true,
554 destructive_hint = false,
555 idempotent_hint = true,
556 open_world_hint = false
557 )
558 )]
559 async fn info(&self) -> Result<CallToolResult, McpError> {
560 let current_workdir = self.workdir.read().await.clone();
561
562 let (session_started, workdir_str, justfile_str) = match current_workdir {
563 Some(ref wd) => {
564 let justfile =
565 resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), wd);
566 (
567 true,
568 Some(wd.to_string_lossy().into_owned()),
569 Some(justfile.to_string_lossy().into_owned()),
570 )
571 }
572 None => (false, None, None),
573 };
574
575 let global_justfile_str = self
576 .config
577 .global_justfile_path
578 .as_ref()
579 .map(|p| p.to_string_lossy().into_owned());
580
581 let response = InfoResponse {
582 session_started,
583 workdir: workdir_str,
584 justfile: justfile_str,
585 mode: mode_label(&self.config),
586 server_cwd: self.server_cwd.to_string_lossy().into_owned(),
587 load_global: self.config.load_global,
588 global_justfile: global_justfile_str,
589 };
590
591 let output = serde_json::to_string_pretty(&response)
592 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
593
594 Ok(CallToolResult {
595 content: vec![Content::text(output)],
596 structured_content: None,
597 is_error: Some(false),
598 meta: None,
599 })
600 }
601
602 #[tool(
603 name = "list",
604 description = "List available tasks from justfile. Returns an object `{\"recipes\": [...]}` containing task names, descriptions, parameters, and groups. When this call triggers an automatic session_start, the response also includes an `auto_session_start` field.",
605 annotations(
606 read_only_hint = true,
607 destructive_hint = false,
608 idempotent_hint = true,
609 open_world_hint = false
610 )
611 )]
612 async fn list(
613 &self,
614 Parameters(req): Parameters<ListRequest>,
615 ) -> Result<CallToolResult, McpError> {
616 let (justfile_path, workdir_opt, auto) = if req.justfile.is_some() {
617 let jp = just::resolve_justfile_path(
619 req.justfile
620 .as_deref()
621 .or(self.config.justfile_path.as_deref()),
622 None,
623 );
624 (jp, None, None)
625 } else {
626 let (wd, auto) = self.workdir_or_auto().await?;
628 let jp = just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&wd));
629 (jp, Some(wd), auto)
630 };
631
632 let recipes = if self.config.load_global {
633 just::list_recipes_merged(
634 &justfile_path,
635 self.config.global_justfile_path.as_deref(),
636 &self.config.mode,
637 workdir_opt.as_deref(),
638 )
639 .await
640 .map_err(|e| McpError::internal_error(e.to_string(), None))?
641 } else {
642 just::list_recipes(&justfile_path, &self.config.mode, workdir_opt.as_deref())
643 .await
644 .map_err(|e| McpError::internal_error(e.to_string(), None))?
645 };
646
647 let filtered: Vec<_> = match &req.filter {
649 Some(group) => recipes
650 .into_iter()
651 .filter(|r| r.groups.iter().any(|g| g == group))
652 .collect(),
653 None => recipes,
654 };
655
656 let mut wrapped = serde_json::json!({ "recipes": filtered });
657 if let Some(auto_response) = auto {
658 wrapped.as_object_mut().expect("json object").insert(
659 "auto_session_start".to_string(),
660 serde_json::to_value(auto_response)
661 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
662 );
663 }
664 let output = serde_json::to_string_pretty(&wrapped)
665 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
666
667 Ok(CallToolResult {
668 content: vec![Content::text(output)],
669 structured_content: None,
670 is_error: Some(false),
671 meta: None,
672 })
673 }
674
675 #[tool(
676 name = "run",
677 description = "Execute a predefined task. Only tasks visible in `list` can be run.",
678 annotations(
679 read_only_hint = false,
680 destructive_hint = true,
681 idempotent_hint = false,
682 open_world_hint = false
683 )
684 )]
685 async fn run(
686 &self,
687 Parameters(req): Parameters<RunRequest>,
688 ) -> Result<CallToolResult, McpError> {
689 let (workdir, auto) = self.workdir_or_auto().await?;
691 let justfile_path =
692 just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&workdir));
693 let args = req.args.unwrap_or_default();
694 let timeout = Duration::from_secs(req.timeout_secs.unwrap_or(60));
695
696 let execution = if self.config.load_global {
697 just::execute_recipe_merged(
698 &req.task_name,
699 &args,
700 &justfile_path,
701 self.config.global_justfile_path.as_deref(),
702 timeout,
703 &self.config.mode,
704 Some(&workdir),
705 )
706 .await
707 .map_err(|e| McpError::internal_error(e.to_string(), None))?
708 } else {
709 just::execute_recipe(
710 &req.task_name,
711 &args,
712 &justfile_path,
713 timeout,
714 &self.config.mode,
715 Some(&workdir),
716 )
717 .await
718 .map_err(|e| McpError::internal_error(e.to_string(), None))?
719 };
720
721 self.log_store.push(execution.clone());
723
724 let is_error = execution.exit_code.map(|c| c != 0).unwrap_or(true);
725
726 let output = match auto {
727 Some(auto_response) => {
728 let mut val = serde_json::to_value(&execution)
729 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
730 if let Some(obj) = val.as_object_mut() {
731 obj.insert(
732 "auto_session_start".to_string(),
733 serde_json::to_value(auto_response)
734 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
735 );
736 }
737 serde_json::to_string_pretty(&val)
738 .map_err(|e| McpError::internal_error(e.to_string(), None))?
739 }
740 None => serde_json::to_string_pretty(&execution)
741 .map_err(|e| McpError::internal_error(e.to_string(), None))?,
742 };
743
744 Ok(CallToolResult {
745 content: vec![Content::text(output)],
746 structured_content: None,
747 is_error: Some(is_error),
748 meta: None,
749 })
750 }
751
752 #[tool(
753 name = "logs",
754 description = "Retrieve execution logs. Returns recent task execution results.",
755 annotations(
756 read_only_hint = true,
757 destructive_hint = false,
758 idempotent_hint = true,
759 open_world_hint = false
760 )
761 )]
762 async fn logs(
763 &self,
764 Parameters(req): Parameters<LogsRequest>,
765 ) -> Result<CallToolResult, McpError> {
766 let output = match req.task_id.as_deref() {
767 Some(id) => {
768 match self.log_store.get(id) {
769 None => {
770 return Err(McpError::internal_error(
771 format!("execution not found: {id}"),
772 None,
773 ));
774 }
775 Some(mut execution) => {
776 if let Some(n) = req.tail {
778 execution.stdout = tail_lines(&execution.stdout, n);
779 }
780 serde_json::to_string_pretty(&execution)
781 .map_err(|e| McpError::internal_error(e.to_string(), None))?
782 }
783 }
784 }
785 None => {
786 let summaries = self.log_store.recent(10);
787 serde_json::to_string_pretty(&summaries)
788 .map_err(|e| McpError::internal_error(e.to_string(), None))?
789 }
790 };
791
792 Ok(CallToolResult {
793 content: vec![Content::text(output)],
794 structured_content: None,
795 is_error: Some(false),
796 meta: None,
797 })
798 }
799}
800
801#[cfg(test)]
806impl TaskMcpServer {
807 pub(crate) async fn set_workdir_for_test(&self, path: PathBuf) {
809 *self.workdir.write().await = Some(path);
810 }
811
812 pub(crate) async fn current_workdir(&self) -> Option<PathBuf> {
814 self.workdir.read().await.clone()
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 fn make_server(server_cwd: PathBuf) -> TaskMcpServer {
823 TaskMcpServer::new(Config::default(), server_cwd)
824 }
825
826 fn make_server_with_allowed_dirs(
827 server_cwd: PathBuf,
828 allowed_dirs: Vec<PathBuf>,
829 ) -> TaskMcpServer {
830 let config = Config {
831 allowed_dirs,
832 ..Config::default()
833 };
834 TaskMcpServer::new(config, server_cwd)
835 }
836
837 #[tokio::test]
843 async fn test_try_auto_session_start_in_project_root() {
844 let tmpdir = tempfile::tempdir().expect("create tempdir");
845 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
846
847 let server = make_server(tmpdir.path().to_path_buf());
848 let outcome = server.try_auto_session_start().await;
849
850 match outcome {
851 AutoStartOutcome::Started(resp, _wd) => {
852 assert_eq!(resp.mode, "agent-only");
853 }
854 other => panic!("auto-start should succeed in a ProjectRoot (.git), got {other:?}"),
855 }
856 assert!(
857 server.current_workdir().await.is_some(),
858 "workdir should be set after auto-start"
859 );
860 }
861
862 #[tokio::test]
864 async fn test_second_call_no_auto_start() {
865 let tmpdir = tempfile::tempdir().expect("create tempdir");
866 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
867
868 let server = make_server(tmpdir.path().to_path_buf());
869
870 let (_, auto1) = server
872 .workdir_or_auto()
873 .await
874 .expect("first call should succeed");
875 assert!(auto1.is_some(), "first call should trigger auto-start");
876
877 let (_, auto2) = server
879 .workdir_or_auto()
880 .await
881 .expect("second call should succeed");
882 assert!(
883 auto2.is_none(),
884 "second call must NOT return auto_session_start"
885 );
886 }
887
888 #[tokio::test]
890 async fn test_no_auto_start_in_non_project_root() {
891 let tmpdir = tempfile::tempdir().expect("create tempdir");
892 let server = make_server(tmpdir.path().to_path_buf());
895 let result = server.workdir_or_auto().await;
896
897 let err = result.expect_err("should fail when no ProjectRoot marker");
898 assert!(
899 err.message.contains("not a ProjectRoot"),
900 "error message should identify 'not a ProjectRoot': {err:?}"
901 );
902 }
903
904 #[tokio::test]
906 async fn test_justfile_marker_also_triggers() {
907 let tmpdir = tempfile::tempdir().expect("create tempdir");
908 std::fs::write(tmpdir.path().join("justfile"), "").expect("create justfile");
910
911 let server = make_server(tmpdir.path().to_path_buf());
912 let outcome = server.try_auto_session_start().await;
913
914 assert!(
915 matches!(outcome, AutoStartOutcome::Started(_, _)),
916 "auto-start should succeed with only justfile marker, got {outcome:?}"
917 );
918 }
919
920 #[tokio::test]
922 async fn test_allowed_dirs_violation_no_auto_start() {
923 let tmpdir = tempfile::tempdir().expect("create tempdir");
924 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
925
926 let other_dir = tempfile::tempdir().expect("create other tempdir");
927 let allowed = vec![other_dir.path().to_path_buf()];
928
929 let server = make_server_with_allowed_dirs(tmpdir.path().to_path_buf(), allowed);
930 let err = server
931 .workdir_or_auto()
932 .await
933 .expect_err("should fail when server_cwd is not in allowed_dirs");
934 assert!(
935 err.message.contains("allowed_dirs"),
936 "error message should identify the allowed_dirs violation: {err:?}"
937 );
938 }
939
940 #[tokio::test]
943 async fn test_auto_start_already_started_variant() {
944 let tmpdir = tempfile::tempdir().expect("create tempdir");
945 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
946
947 let server = make_server(tmpdir.path().to_path_buf());
948
949 let pre_set = tmpdir.path().join("pre-set");
951 std::fs::create_dir(&pre_set).expect("create pre-set dir");
952 server.set_workdir_for_test(pre_set.clone()).await;
953
954 let outcome = server.try_auto_session_start().await;
955 match outcome {
956 AutoStartOutcome::AlreadyStarted(wd) => assert_eq!(wd, pre_set),
957 other => panic!("expected AlreadyStarted, got {other:?}"),
958 }
959 }
960
961 #[tokio::test]
963 async fn test_explicit_session_start_overrides() {
964 let tmpdir = tempfile::tempdir().expect("create tempdir");
965 std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
966
967 let subdir = tmpdir.path().join("subdir");
969 std::fs::create_dir(&subdir).expect("create subdir");
970
971 let server = make_server(tmpdir.path().to_path_buf());
972 server.set_workdir_for_test(subdir.clone()).await;
974
975 let result = server.workdir_or_auto().await;
977 assert!(result.is_ok());
978 let (wd, auto) = result.unwrap();
979 assert!(
980 auto.is_none(),
981 "after explicit session_start, auto_session_start must be None"
982 );
983 assert_eq!(
985 wd, subdir,
986 "workdir should be the explicitly set subdir, not server_cwd"
987 );
988 }
989}