use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use rmcp::{
ErrorData as McpError, ServerHandler, ServiceExt,
handler::server::{tool::ToolRouter, wrapper::Parameters},
model::{
CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult,
PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo,
},
service::{RequestContext, RoleServer},
tool, tool_router,
transport::stdio,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::config::Config;
use crate::just;
use crate::template;
enum GroupMatcher {
Exact(String),
Glob(regex::Regex),
}
impl GroupMatcher {
fn new(pattern: &str) -> Self {
if !pattern.contains('*') && !pattern.contains('?') {
return Self::Exact(pattern.to_string());
}
let mut re = String::with_capacity(pattern.len() + 2);
re.push('^');
let mut literal = String::new();
let flush = |re: &mut String, literal: &mut String| {
if !literal.is_empty() {
re.push_str(®ex::escape(literal));
literal.clear();
}
};
for c in pattern.chars() {
match c {
'*' => {
flush(&mut re, &mut literal);
re.push_str(".*");
}
'?' => {
flush(&mut re, &mut literal);
re.push('.');
}
c => literal.push(c),
}
}
flush(&mut re, &mut literal);
re.push('$');
match regex::Regex::new(&re) {
Ok(r) => Self::Glob(r),
Err(_) => Self::Exact(pattern.to_string()),
}
}
fn is_match(&self, group: &str) -> bool {
match self {
Self::Exact(s) => s == group,
Self::Glob(r) => r.is_match(group),
}
}
}
pub async fn run() -> anyhow::Result<()> {
let config = Config::load();
let server_cwd = std::env::current_dir()?;
let server = TaskMcpServer::new(config, server_cwd);
let service = server.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
#[derive(Clone)]
pub struct TaskMcpServer {
tool_router: ToolRouter<Self>,
config: Config,
log_store: Arc<just::TaskLogStore>,
workdir: Arc<RwLock<Option<PathBuf>>>,
server_cwd: PathBuf,
}
#[derive(Debug)]
pub(crate) enum AutoStartOutcome {
Started(SessionStartResponse, PathBuf),
AlreadyStarted(PathBuf),
NotProjectRoot,
CanonicalizeFailed(std::io::Error),
NotAllowed(PathBuf),
}
impl TaskMcpServer {
pub fn new(config: Config, server_cwd: PathBuf) -> Self {
Self {
tool_router: Self::tool_router(),
config,
log_store: Arc::new(just::TaskLogStore::new(10)),
workdir: Arc::new(RwLock::new(None)),
server_cwd,
}
}
pub(crate) async fn try_auto_session_start(&self) -> AutoStartOutcome {
let has_git = tokio::fs::try_exists(self.server_cwd.join(".git"))
.await
.unwrap_or(false);
let has_justfile = tokio::fs::try_exists(self.server_cwd.join("justfile"))
.await
.unwrap_or(false);
if !has_git && !has_justfile {
return AutoStartOutcome::NotProjectRoot;
}
let canonical = match tokio::fs::canonicalize(&self.server_cwd).await {
Ok(p) => p,
Err(e) => return AutoStartOutcome::CanonicalizeFailed(e),
};
if !self.config.is_workdir_allowed(&canonical) {
return AutoStartOutcome::NotAllowed(canonical);
}
let mut guard = self.workdir.write().await;
if let Some(ref existing) = *guard {
return AutoStartOutcome::AlreadyStarted(existing.clone());
}
*guard = Some(canonical.clone());
drop(guard);
let justfile =
resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
AutoStartOutcome::Started(
SessionStartResponse {
workdir: canonical.to_string_lossy().into_owned(),
justfile: justfile.to_string_lossy().into_owned(),
mode: mode_label(&self.config),
},
canonical,
)
}
pub(crate) async fn workdir_or_auto(
&self,
) -> Result<(PathBuf, Option<SessionStartResponse>), McpError> {
{
let guard = self.workdir.read().await;
if let Some(ref wd) = *guard {
return Ok((wd.clone(), None));
}
}
match self.try_auto_session_start().await {
AutoStartOutcome::Started(resp, wd) => Ok((wd, Some(resp))),
AutoStartOutcome::AlreadyStarted(wd) => Ok((wd, None)),
AutoStartOutcome::NotProjectRoot => Err(McpError::internal_error(
format!(
"session not started. server startup CWD {:?} is not a ProjectRoot (no .git or justfile). Call session_start with an explicit workdir.",
self.server_cwd
),
None,
)),
AutoStartOutcome::CanonicalizeFailed(e) => Err(McpError::internal_error(
format!(
"session not started. failed to canonicalize server startup CWD {:?}: {e}. Call session_start with an explicit workdir.",
self.server_cwd
),
None,
)),
AutoStartOutcome::NotAllowed(path) => Err(McpError::internal_error(
format!(
"session not started. server startup CWD {:?} is not in allowed_dirs. Call session_start with an allowed workdir.",
path
),
None,
)),
}
}
}
impl ServerHandler for TaskMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: "task-mcp".to_string(),
title: Some("Task MCP — Agent-safe Task Runner".to_string()),
description: Some(
"Execute predefined justfile tasks safely. \
6 tools: session_start, info, init, list, run, logs."
.to_string(),
),
version: env!("CARGO_PKG_VERSION").to_string(),
icons: None,
website_url: None,
},
instructions: Some(
"Agent-safe task runner backed by just.\n\
\n\
- `session_start`: Set working directory explicitly. Optional when the \
server was launched inside a ProjectRoot (a directory containing `.git` \
or `justfile`) — in that case the first `init`/`list`/`run` call auto-starts \
the session using the server's startup CWD. Call `session_start` explicitly \
only when you need a different workdir (e.g. a subdirectory in a monorepo).\n\
- `info`: Show current session state (workdir, mode, etc).\n\
- `init`: Generate a justfile in the working directory.\n\
- `list`: Show available tasks filtered by the allow-agent marker.\n\
- `run`: Execute a named task. Supports `content` arguments for raw text (newlines allowed).\n\
- `logs`: Retrieve execution logs of recent runs.\n\
\n\
When a call auto-starts the session, the response includes an \
`auto_session_start` field with the chosen workdir, justfile, and mode. \
Subsequent calls in the same session do not include this field.\n\
\n\
Allow-agent is a security boundary: in the default `agent-only` mode, \
recipes without the `[group('allow-agent')]` attribute (or the legacy \
`# [allow-agent]` doc comment) are NEVER exposed via MCP. The mode is \
controlled by the `TASK_MCP_MODE` environment variable, set OUTSIDE \
the MCP. Reading the justfile directly bypasses this guard, but is \
not the canonical path."
.to_string(),
),
}
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult {
tools: self.tool_router.list_all(),
next_cursor: None,
meta: None,
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let tool_ctx = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
self.tool_router.call(tool_ctx).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct SessionStartRequest {
pub workdir: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct SessionStartResponse {
pub workdir: String,
pub justfile: String,
pub mode: String,
}
#[derive(Debug, Clone, Serialize)]
struct InfoResponse {
pub session_started: bool,
pub workdir: Option<String>,
pub justfile: Option<String>,
pub mode: String,
pub server_cwd: String,
pub load_global: bool,
pub global_justfile: Option<String>,
pub docs: InfoDocs,
}
#[derive(Debug, Clone, Serialize)]
struct InfoDocs {
pub execution_model: &'static str,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct InitRequest {
pub project_type: Option<String>,
pub template_file: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct InitResponse {
pub justfile: String,
pub project_type: String,
pub custom_template: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_session_start: Option<SessionStartResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct ListRequest {
pub filter: Option<String>,
pub justfile: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct RunRequest {
pub task_name: String,
pub args: Option<HashMap<String, String>>,
pub content: Option<HashMap<String, String>>,
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct LogsRequest {
pub task_id: Option<String>,
pub tail: Option<usize>,
}
fn resolve_justfile_with_workdir(
override_path: Option<&str>,
workdir: &std::path::Path,
) -> PathBuf {
match override_path {
Some(p) => PathBuf::from(p),
None => workdir.join("justfile"),
}
}
fn tail_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
if n >= lines.len() {
return text.to_string();
}
lines[lines.len() - n..].join("\n")
}
fn mode_label(config: &Config) -> String {
use crate::config::TaskMode;
match config.mode {
TaskMode::AgentOnly => "agent-only".to_string(),
TaskMode::All => "all".to_string(),
}
}
#[tool_router]
impl TaskMcpServer {
#[tool(
name = "session_start",
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.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn session_start(
&self,
Parameters(req): Parameters<SessionStartRequest>,
) -> Result<CallToolResult, McpError> {
let raw_path = match req.workdir.as_deref() {
Some(s) if !s.trim().is_empty() => PathBuf::from(s),
_ => self.server_cwd.clone(),
};
let canonical = tokio::fs::canonicalize(&raw_path).await.map_err(|e| {
McpError::invalid_params(
format!(
"workdir {:?} does not exist or is not accessible: {e}",
raw_path
),
None,
)
})?;
if !self.config.is_workdir_allowed(&canonical) {
return Err(McpError::invalid_params(
format!(
"workdir {:?} is not in the allowed directories list",
canonical
),
None,
));
}
*self.workdir.write().await = Some(canonical.clone());
let justfile =
resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), &canonical);
let response = SessionStartResponse {
workdir: canonical.to_string_lossy().into_owned(),
justfile: justfile.to_string_lossy().into_owned(),
mode: mode_label(&self.config),
};
let output = serde_json::to_string_pretty(&response)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult {
content: vec![Content::text(output)],
structured_content: None,
is_error: Some(false),
meta: None,
})
}
#[tool(
name = "init",
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.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn init(
&self,
Parameters(req): Parameters<InitRequest>,
) -> Result<CallToolResult, McpError> {
let (workdir, auto) = self.workdir_or_auto().await?;
let project_type = match req.project_type.as_deref() {
Some(s) => s
.parse::<template::ProjectType>()
.map_err(|e| McpError::invalid_params(e, None))?,
None => template::ProjectType::default(),
};
let justfile_path = workdir.join("justfile");
if justfile_path.exists() {
return Err(McpError::invalid_params(
format!(
"justfile already exists at {}. Delete it first if you want to regenerate.",
justfile_path.display()
),
None,
));
}
if let Some(ref tf) = req.template_file {
let template_path = std::fs::canonicalize(tf).map_err(|e| {
McpError::invalid_params(
format!("template_file {tf:?} is not accessible: {e}"),
None,
)
})?;
if !template_path.starts_with(&workdir) {
return Err(McpError::invalid_params(
format!(
"template_file must be under session workdir ({}). Got: {}",
workdir.display(),
template_path.display()
),
None,
));
}
}
let custom_template_used =
req.template_file.is_some() || self.config.init_template_file.is_some();
let content = template::resolve_template(
project_type,
req.template_file.as_deref(),
self.config.init_template_file.as_deref(),
)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
tokio::fs::write(&justfile_path, &content)
.await
.map_err(|e| {
McpError::internal_error(
format!(
"failed to write justfile at {}: {e}",
justfile_path.display()
),
None,
)
})?;
let response = InitResponse {
justfile: justfile_path.to_string_lossy().into_owned(),
project_type: project_type.to_string(),
custom_template: custom_template_used,
auto_session_start: auto,
};
let output = serde_json::to_string_pretty(&response)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult {
content: vec![Content::text(output)],
structured_content: None,
is_error: Some(false),
meta: None,
})
}
#[tool(
name = "info",
description = "Show current session state: workdir, justfile path, mode, and server startup CWD.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn info(&self) -> Result<CallToolResult, McpError> {
let current_workdir = self.workdir.read().await.clone();
let (session_started, workdir_str, justfile_str) = match current_workdir {
Some(ref wd) => {
let justfile =
resolve_justfile_with_workdir(self.config.justfile_path.as_deref(), wd);
(
true,
Some(wd.to_string_lossy().into_owned()),
Some(justfile.to_string_lossy().into_owned()),
)
}
None => (false, None, None),
};
let global_justfile_str = self
.config
.global_justfile_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
let response = InfoResponse {
session_started,
workdir: workdir_str,
justfile: justfile_str,
mode: mode_label(&self.config),
server_cwd: self.server_cwd.to_string_lossy().into_owned(),
load_global: self.config.load_global,
global_justfile: global_justfile_str,
docs: InfoDocs {
execution_model: "https://github.com/ynishi/task-mcp/blob/master/docs/execution-model.md",
},
};
let output = serde_json::to_string_pretty(&response)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult {
content: vec![Content::text(output)],
structured_content: None,
is_error: Some(false),
meta: None,
})
}
#[tool(
name = "list",
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.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn list(
&self,
Parameters(req): Parameters<ListRequest>,
) -> Result<CallToolResult, McpError> {
let (justfile_path, workdir_opt, auto) = if req.justfile.is_some() {
let jp = just::resolve_justfile_path(
req.justfile
.as_deref()
.or(self.config.justfile_path.as_deref()),
None,
);
(jp, None, None)
} else {
let (wd, auto) = self.workdir_or_auto().await?;
let jp = just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&wd));
(jp, Some(wd), auto)
};
let recipes = if self.config.load_global {
just::list_recipes_merged(
&justfile_path,
self.config.global_justfile_path.as_deref(),
&self.config.mode,
workdir_opt.as_deref(),
)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?
} else {
just::list_recipes(&justfile_path, &self.config.mode, workdir_opt.as_deref())
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?
};
let filtered: Vec<_> = match &req.filter {
Some(pattern) => {
let matcher = GroupMatcher::new(pattern);
recipes
.into_iter()
.filter(|r| r.groups.iter().any(|g| matcher.is_match(g)))
.collect()
}
None => recipes,
};
let mut wrapped = serde_json::json!({ "recipes": filtered });
if let Some(auto_response) = auto {
wrapped.as_object_mut().expect("json object").insert(
"auto_session_start".to_string(),
serde_json::to_value(auto_response)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
);
}
let output = serde_json::to_string_pretty(&wrapped)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult {
content: vec![Content::text(output)],
structured_content: None,
is_error: Some(false),
meta: None,
})
}
#[tool(
name = "run",
description = "Execute a predefined task. Only tasks visible in `list` can be run. Pass `content` for raw text arguments (newlines allowed) — delivered as env vars (`TASK_MCP_CONTENT_*`) to the recipe.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn run(
&self,
Parameters(req): Parameters<RunRequest>,
) -> Result<CallToolResult, McpError> {
let (workdir, auto) = self.workdir_or_auto().await?;
let justfile_path =
just::resolve_justfile_path(self.config.justfile_path.as_deref(), Some(&workdir));
let args = req.args.unwrap_or_default();
let content = req.content.unwrap_or_default();
let timeout = Duration::from_secs(req.timeout_secs.unwrap_or(60));
let execution = if self.config.load_global {
just::execute_recipe_merged(
&req.task_name,
&args,
&content,
&justfile_path,
self.config.global_justfile_path.as_deref(),
timeout,
&self.config.mode,
Some(&workdir),
)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?
} else {
just::execute_recipe(
&req.task_name,
&args,
&content,
&justfile_path,
timeout,
&self.config.mode,
Some(&workdir),
)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?
};
self.log_store.push(execution.clone());
let is_error = execution.exit_code.map(|c| c != 0).unwrap_or(true);
let output = match auto {
Some(auto_response) => {
let mut val = serde_json::to_value(&execution)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
if let Some(obj) = val.as_object_mut() {
obj.insert(
"auto_session_start".to_string(),
serde_json::to_value(auto_response)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
);
}
serde_json::to_string_pretty(&val)
.map_err(|e| McpError::internal_error(e.to_string(), None))?
}
None => serde_json::to_string_pretty(&execution)
.map_err(|e| McpError::internal_error(e.to_string(), None))?,
};
Ok(CallToolResult {
content: vec![Content::text(output)],
structured_content: None,
is_error: Some(is_error),
meta: None,
})
}
#[tool(
name = "logs",
description = "Retrieve execution logs. Returns recent task execution results.",
annotations(
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn logs(
&self,
Parameters(req): Parameters<LogsRequest>,
) -> Result<CallToolResult, McpError> {
let output = match req.task_id.as_deref() {
Some(id) => {
match self.log_store.get(id) {
None => {
return Err(McpError::internal_error(
format!("execution not found: {id}"),
None,
));
}
Some(mut execution) => {
if let Some(n) = req.tail {
execution.stdout = tail_lines(&execution.stdout, n);
}
serde_json::to_string_pretty(&execution)
.map_err(|e| McpError::internal_error(e.to_string(), None))?
}
}
}
None => {
let summaries = self.log_store.recent(10);
serde_json::to_string_pretty(&summaries)
.map_err(|e| McpError::internal_error(e.to_string(), None))?
}
};
Ok(CallToolResult {
content: vec![Content::text(output)],
structured_content: None,
is_error: Some(false),
meta: None,
})
}
}
#[cfg(test)]
impl TaskMcpServer {
pub(crate) async fn set_workdir_for_test(&self, path: PathBuf) {
*self.workdir.write().await = Some(path);
}
pub(crate) async fn current_workdir(&self) -> Option<PathBuf> {
self.workdir.read().await.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn group_matcher_exact() {
let m = GroupMatcher::new("profile");
assert!(m.is_match("profile"));
assert!(!m.is_match("profiler"));
assert!(!m.is_match("agent"));
}
#[test]
fn group_matcher_star_prefix() {
let m = GroupMatcher::new("prof*");
assert!(m.is_match("profile"));
assert!(m.is_match("profiler"));
assert!(m.is_match("prof"));
assert!(!m.is_match("agent"));
}
#[test]
fn group_matcher_star_suffix() {
let m = GroupMatcher::new("*-release");
assert!(m.is_match("build-release"));
assert!(m.is_match("test-release"));
assert!(!m.is_match("release-build"));
}
#[test]
fn group_matcher_star_middle() {
let m = GroupMatcher::new("ci-*-fast");
assert!(m.is_match("ci-build-fast"));
assert!(m.is_match("ci--fast"));
assert!(!m.is_match("ci-build-slow"));
}
#[test]
fn group_matcher_question_mark() {
let m = GroupMatcher::new("ci-?");
assert!(m.is_match("ci-1"));
assert!(m.is_match("ci-a"));
assert!(!m.is_match("ci-"));
assert!(!m.is_match("ci-12"));
}
#[test]
fn group_matcher_special_chars_escaped() {
let m = GroupMatcher::new("ci.release+1");
assert!(m.is_match("ci.release+1"));
assert!(!m.is_match("ciXreleaseX1"));
}
fn make_server(server_cwd: PathBuf) -> TaskMcpServer {
TaskMcpServer::new(Config::default(), server_cwd)
}
fn make_server_with_allowed_dirs(
server_cwd: PathBuf,
allowed_dirs: Vec<PathBuf>,
) -> TaskMcpServer {
let config = Config {
allowed_dirs,
..Config::default()
};
TaskMcpServer::new(config, server_cwd)
}
#[tokio::test]
async fn test_try_auto_session_start_in_project_root() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
let server = make_server(tmpdir.path().to_path_buf());
let outcome = server.try_auto_session_start().await;
match outcome {
AutoStartOutcome::Started(resp, _wd) => {
assert_eq!(resp.mode, "agent-only");
}
other => panic!("auto-start should succeed in a ProjectRoot (.git), got {other:?}"),
}
assert!(
server.current_workdir().await.is_some(),
"workdir should be set after auto-start"
);
}
#[tokio::test]
async fn test_second_call_no_auto_start() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
let server = make_server(tmpdir.path().to_path_buf());
let (_, auto1) = server
.workdir_or_auto()
.await
.expect("first call should succeed");
assert!(auto1.is_some(), "first call should trigger auto-start");
let (_, auto2) = server
.workdir_or_auto()
.await
.expect("second call should succeed");
assert!(
auto2.is_none(),
"second call must NOT return auto_session_start"
);
}
#[tokio::test]
async fn test_no_auto_start_in_non_project_root() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let server = make_server(tmpdir.path().to_path_buf());
let result = server.workdir_or_auto().await;
let err = result.expect_err("should fail when no ProjectRoot marker");
assert!(
err.message.contains("not a ProjectRoot"),
"error message should identify 'not a ProjectRoot': {err:?}"
);
}
#[tokio::test]
async fn test_justfile_marker_also_triggers() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
std::fs::write(tmpdir.path().join("justfile"), "").expect("create justfile");
let server = make_server(tmpdir.path().to_path_buf());
let outcome = server.try_auto_session_start().await;
assert!(
matches!(outcome, AutoStartOutcome::Started(_, _)),
"auto-start should succeed with only justfile marker, got {outcome:?}"
);
}
#[tokio::test]
async fn test_allowed_dirs_violation_no_auto_start() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
let other_dir = tempfile::tempdir().expect("create other tempdir");
let allowed = vec![other_dir.path().to_path_buf()];
let server = make_server_with_allowed_dirs(tmpdir.path().to_path_buf(), allowed);
let err = server
.workdir_or_auto()
.await
.expect_err("should fail when server_cwd is not in allowed_dirs");
assert!(
err.message.contains("allowed_dirs"),
"error message should identify the allowed_dirs violation: {err:?}"
);
}
#[tokio::test]
async fn test_auto_start_already_started_variant() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
let server = make_server(tmpdir.path().to_path_buf());
let pre_set = tmpdir.path().join("pre-set");
std::fs::create_dir(&pre_set).expect("create pre-set dir");
server.set_workdir_for_test(pre_set.clone()).await;
let outcome = server.try_auto_session_start().await;
match outcome {
AutoStartOutcome::AlreadyStarted(wd) => assert_eq!(wd, pre_set),
other => panic!("expected AlreadyStarted, got {other:?}"),
}
}
#[tokio::test]
async fn test_explicit_session_start_overrides() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
std::fs::create_dir(tmpdir.path().join(".git")).expect("create .git dir");
let subdir = tmpdir.path().join("subdir");
std::fs::create_dir(&subdir).expect("create subdir");
let server = make_server(tmpdir.path().to_path_buf());
server.set_workdir_for_test(subdir.clone()).await;
let result = server.workdir_or_auto().await;
assert!(result.is_ok());
let (wd, auto) = result.unwrap();
assert!(
auto.is_none(),
"after explicit session_start, auto_session_start must be None"
);
assert_eq!(
wd, subdir,
"workdir should be the explicitly set subdir, not server_cwd"
);
}
}