#![allow(clippy::panic)]
use super::*;
use crate::mcp::types::{
ListRepositoriesParams, WorkspaceAttachAction, WorkspaceAttachIndexMode,
WorkspacePreciseGenerationAction, WorkspacePreciseGenerationStatus,
WorkspacePreciseLifecyclePhase,
};
#[tokio::test]
async fn list_files_auto_adopts_single_known_repository_without_repository_id() {
let workspace_root = temp_workspace_root("list-files-auto-adopt-single-repo");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("README.md"), "# List Files\n")
.expect("failed to write README fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Listed;\n")
.expect("failed to write source fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("startup roots should register globally known workspaces");
assert!(server.attached_workspaces().is_empty());
let response = server
.list_files(Parameters(ListFilesParams {
repository_id: None,
path_regex: Some("^src/".to_owned()),
glob: None,
language: None,
path_class: None,
include_hidden: None,
limit: Some(10),
resume_from: None,
}))
.await
.expect("list_files should auto-adopt the only known repository")
.0;
assert_eq!(response.total_files, 1);
assert!(!response.truncated);
assert_eq!(response.files[0].repository_id, workspace.repository_id);
assert_eq!(response.files[0].path, "src/lib.rs");
assert_eq!(
server.current_repository_id().as_deref(),
Some(workspace.repository_id.as_str())
);
assert_eq!(server.attached_workspaces().len(), 1);
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn list_files_supports_rg_aligned_filters_and_resume_from() {
let workspace_root = temp_workspace_root("list-files-rg-filters");
fs::create_dir_all(workspace_root.join("src")).expect("failed to create src fixture");
fs::create_dir_all(workspace_root.join("tests")).expect("failed to create tests fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Lib;\n")
.expect("failed to write lib fixture");
fs::write(workspace_root.join("src/main.rs"), "fn main() {}\n")
.expect("failed to write main fixture");
fs::write(
workspace_root.join("src/.hidden.rs"),
"pub struct Hidden;\n",
)
.expect("failed to write hidden fixture");
fs::write(
workspace_root.join("tests/lib_test.rs"),
"#[test] fn test_it() {}\n",
)
.expect("failed to write test fixture");
fs::write(workspace_root.join("README.md"), "# fixture\n")
.expect("failed to write readme fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let first_page = server
.list_files(Parameters(ListFilesParams {
repository_id: None,
path_regex: Some("^src/".to_owned()),
glob: Some("*.rs".to_owned()),
language: Some("rust".to_owned()),
path_class: Some(crate::mcp::types::SearchSymbolPathClass::Runtime),
include_hidden: Some(false),
limit: Some(1),
resume_from: None,
}))
.await
.expect("list_files should support rg-shaped filters")
.0;
assert_eq!(first_page.total_files, 2);
assert!(first_page.truncated);
assert_eq!(first_page.files.len(), 1);
assert_eq!(first_page.files[0].path, "src/lib.rs");
assert_eq!(first_page.resume_from.as_deref(), Some("1"));
let second_page = server
.list_files(Parameters(ListFilesParams {
repository_id: None,
path_regex: Some("^src/".to_owned()),
glob: Some("*.rs".to_owned()),
language: Some("rust".to_owned()),
path_class: Some(crate::mcp::types::SearchSymbolPathClass::Runtime),
include_hidden: Some(false),
limit: Some(10),
resume_from: first_page.resume_from,
}))
.await
.expect("list_files should resume from returned cursor")
.0;
assert_eq!(second_page.total_files, 2);
assert!(!second_page.truncated);
assert_eq!(second_page.resume_from, None);
assert_eq!(second_page.files.len(), 1);
assert_eq!(second_page.files[0].path, "src/main.rs");
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn search_text_supports_rg_aligned_options() {
let workspace_root = temp_workspace_root("search-text-rg-options");
fs::create_dir_all(workspace_root.join("src")).expect("failed to create src fixture");
fs::write(
workspace_root.join("src/lib.rs"),
"Alpha beta\nalpha_beta\nALPHA beta\n",
)
.expect("failed to write lib fixture");
fs::write(workspace_root.join("src/other.rs"), "Alpha beta\n")
.expect("failed to write other fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("startup roots should register globally known workspaces");
server
.adopt_workspace(&workspace, true)
.expect("server should adopt known workspace");
let files_with_matches = server
.search_text_impl(crate::mcp::types::SearchTextParams {
query: "alpha".to_owned(),
pattern_type: None,
repository_id: None,
path_regex: Some("^src/".to_owned()),
limit: Some(10),
context_lines: None,
case_sensitive: None,
ignore_case: Some(true),
word: Some(true),
files_with_matches: Some(true),
count_only: None,
glob: Some("*.rs".to_owned()),
exclude_glob: Some("*other.rs".to_owned()),
include_hidden: Some(false),
max_count_per_file: None,
collapse_by_file: None,
response_mode: None,
include_context_efficiency: None,
})
.await
.expect("search_text should support rg-shaped flags")
.0;
assert_eq!(files_with_matches.total_matches, 2);
assert_eq!(files_with_matches.matches.len(), 1);
assert_eq!(files_with_matches.matches[0].path, "src/lib.rs");
assert_eq!(files_with_matches.matches[0].line, 1);
assert!(files_with_matches.result_handle.is_some());
let count_only = server
.search_text_impl(crate::mcp::types::SearchTextParams {
query: "alpha".to_owned(),
pattern_type: None,
repository_id: None,
path_regex: Some("^src/".to_owned()),
limit: Some(10),
context_lines: None,
case_sensitive: None,
ignore_case: Some(true),
word: Some(true),
files_with_matches: None,
count_only: Some(true),
glob: Some("*.rs".to_owned()),
exclude_glob: Some("*other.rs".to_owned()),
include_hidden: Some(false),
max_count_per_file: None,
collapse_by_file: None,
response_mode: None,
include_context_efficiency: None,
})
.await
.expect("search_text should support count_only")
.0;
assert_eq!(count_only.total_matches, 2);
assert!(count_only.matches.is_empty());
assert!(count_only.result_handle.is_none());
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn read_file_uses_start_end_and_line_count_names() {
let workspace_root = temp_workspace_root("read-file-rg-line-names");
fs::create_dir_all(workspace_root.join("src")).expect("failed to create src fixture");
fs::write(
workspace_root.join("src/lib.rs"),
"line one\nline two\nline three\nline four\n",
)
.expect("failed to write source fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("startup roots should register globally known workspaces");
server
.adopt_workspace(&workspace, true)
.expect("server should adopt known workspace");
let response = server
.read_file_impl(crate::mcp::types::ReadFileParams {
path: "src/lib.rs".to_owned(),
repository_id: None,
max_bytes: None,
start_line: Some(2),
end_line: None,
line_count: Some(2),
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await
.expect("read_file should accept line_count");
assert_eq!(response.content, "line two\nline three");
let error = server
.read_file_impl(crate::mcp::types::ReadFileParams {
path: "src/lib.rs".to_owned(),
repository_id: None,
max_bytes: None,
start_line: Some(2),
end_line: Some(3),
line_count: Some(2),
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await
.expect_err("end_line and line_count must be mutually exclusive");
assert_eq!(
error.message,
"end_line and line_count are mutually exclusive"
);
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn workspace_attach_can_adopt_known_repository_id_for_new_session() {
let workspace_root = temp_workspace_root("attach-known-repository-id");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Adopted;\n")
.expect("failed to write workspace root fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("startup roots should register globally known workspaces");
let session = server.clone_for_new_session();
assert!(server.attached_workspaces().is_empty());
assert!(session.attached_workspaces().is_empty());
let response = session
.workspace_attach(Parameters(WorkspaceAttachParams {
path: None,
repository_id: Some(workspace.repository_id.clone()),
set_default: Some(true),
resolve_mode: None,
wait_for_precise: None,
}))
.await
.expect("workspace_attach should adopt a known repository id")
.0;
assert_eq!(response.repository.repository_id, workspace.repository_id);
assert!(response.session_default);
assert_eq!(session.attached_workspaces().len(), 1);
assert_eq!(
session.current_repository_id().as_deref(),
Some(workspace.repository_id.as_str())
);
assert_eq!(session.known_workspaces().len(), 1);
assert!(server.attached_workspaces().is_empty());
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn workspace_detach_clears_session_default_and_preserves_known_workspace() {
let workspace_root = temp_workspace_root("detach-preserves-known-workspace");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Detached;\n")
.expect("failed to write workspace root fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("startup roots should register globally known workspaces");
let session = server.clone_for_new_session();
session
.workspace_attach(Parameters(WorkspaceAttachParams {
path: None,
repository_id: Some(workspace.repository_id.clone()),
set_default: Some(true),
resolve_mode: None,
wait_for_precise: None,
}))
.await
.expect("workspace_attach should adopt a known repository id");
let response = session
.workspace_detach(Parameters(WorkspaceDetachParams {
repository_id: None,
}))
.await
.expect("workspace_detach should detach the session default repository")
.0;
assert_eq!(response.repository_id, workspace.repository_id);
assert!(response.detached);
assert!(!response.session_default);
assert!(session.current_repository_id().is_none());
assert!(session.attached_workspaces().is_empty());
assert_eq!(session.known_workspaces().len(), 1);
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn workspace_detach_prunes_ephemeral_known_workspace_after_last_session() {
let workspace_root = authorized_temp_workspace_root("detach-prunes-ephemeral-workspace");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Ephemeral;\n")
.expect("failed to write workspace root fixture");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let attach_response = server
.workspace_attach(Parameters(WorkspaceAttachParams {
path: Some(workspace_root.display().to_string()),
repository_id: None,
set_default: Some(true),
resolve_mode: Some(WorkspaceResolveMode::Direct),
wait_for_precise: None,
}))
.await
.expect("workspace_attach should adopt an ad hoc path")
.0;
assert_eq!(server.known_workspaces().len(), 1);
let detach_response = server
.workspace_detach(Parameters(WorkspaceDetachParams {
repository_id: Some(attach_response.repository.repository_id.clone()),
}))
.await
.expect("workspace_detach should detach the ad hoc repository")
.0;
assert_eq!(
detach_response.repository_id,
attach_response.repository.repository_id
);
assert!(detach_response.detached);
assert!(server.attached_workspaces().is_empty());
assert!(
server.known_workspaces().is_empty(),
"ad hoc repositories should be pruned from the process registry after the last session detaches"
);
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn provisional_path_workspace_can_be_pruned_after_pre_adoption_failure() {
let workspace_root = authorized_temp_workspace_root("pre-adoption-failure-prunes-workspace");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(
workspace_root.join("src/lib.rs"),
"pub struct Provisional;\n",
)
.expect("failed to write workspace root fixture");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let (workspace, _, _, resolution_guard) = server
.resolve_workspace_target(
Some(
workspace_root
.to_str()
.expect("workspace root path is utf-8"),
),
None,
WorkspaceResolveMode::Direct,
)
.expect("path resolution should create a provisional workspace");
let resolution_guard = resolution_guard.expect("path resolution should hold a pending guard");
assert_eq!(server.known_workspaces().len(), 1);
server
.runtime_state
.workspace_registry
.write()
.expect("workspace registry should not be poisoned")
.prune_inactive_ephemeral_workspace(&workspace.repository_id);
assert_eq!(
server.known_workspaces().len(),
1,
"pending path work should protect a provisional workspace from concurrent pruning"
);
drop(resolution_guard);
assert!(
server.known_workspaces().is_empty(),
"dropping pending path work should remove provisional ad hoc workspaces"
);
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn adopted_path_workspace_survives_pending_guard_drop() {
let workspace_root = authorized_temp_workspace_root("adopted-path-survives-pending-drop");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Adopted;\n")
.expect("failed to write workspace root fixture");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let (workspace, _, _, resolution_guard) = server
.resolve_workspace_target(
Some(
workspace_root
.to_str()
.expect("workspace root path is utf-8"),
),
None,
WorkspaceResolveMode::Direct,
)
.expect("path resolution should create a provisional workspace");
let resolution_guard = resolution_guard.expect("path resolution should hold a pending guard");
server
.adopt_workspace(&workspace, true)
.expect("adopting a provisional workspace should succeed");
drop(resolution_guard);
assert_eq!(server.known_workspaces().len(), 1);
assert_eq!(server.attached_workspaces().len(), 1);
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn workspace_target_relative_path_falls_back_to_current_workspace_before_direct_resolution() {
let workspace_root = authorized_temp_workspace_root("relative-path-current-workspace-root");
let nested_dir = workspace_root.join("fallback-only/src");
fs::create_dir_all(&nested_dir).expect("failed to create nested workspace fixture");
fs::write(nested_dir.join("lib.rs"), "pub struct RelativeAttach;\n")
.expect("failed to write nested workspace fixture");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let attach_response = server
.attach_workspace_internal(&workspace_root, true, WorkspaceResolveMode::Direct)
.expect("absolute path attach should establish a session default");
let (workspace, resolved_from, resolution, _resolution_guard) = server
.resolve_workspace_target(
Some("fallback-only/src/lib.rs"),
None,
WorkspaceResolveMode::Direct,
)
.expect("relative path should resolve from the current workspace root");
assert_ne!(
workspace.repository_id, attach_response.repository.repository_id,
"direct resolution should attach the resolved file parent as its own workspace"
);
assert_eq!(resolution, Some(WorkspaceResolveMode::Direct));
let expected_resolved_from = nested_dir
.canonicalize()
.expect("nested directory should canonicalize")
.display()
.to_string();
assert_eq!(
resolved_from.as_deref(),
Some(expected_resolved_from.as_str())
);
assert_eq!(
workspace.root,
nested_dir
.canonicalize()
.expect("nested directory should canonicalize")
);
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn workspace_attach_rejects_absolute_path_outside_authorized_roots() {
let workspace_root = temp_workspace_root("attach-outside-authorized-roots");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create outside workspace fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Outside;\n")
.expect("failed to write outside workspace fixture");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let err = match server.resolve_workspace_target(
Some(
workspace_root
.to_str()
.expect("workspace root path is utf-8"),
),
None,
WorkspaceResolveMode::Direct,
) {
Ok(_) => panic!("outside absolute attach path should be rejected"),
Err(err) => err,
};
assert!(
err.message.contains("outside authorized workspace roots"),
"unexpected error: {err:?}"
);
assert!(
server.known_workspaces().is_empty(),
"rejected paths must not enter the process registry"
);
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn workspace_attach_rejects_relative_parent_directory_components() {
let workspace_root = authorized_temp_workspace_root("attach-relative-parent");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Root;\n")
.expect("failed to write workspace source");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
server
.attach_workspace_internal(&workspace_root, true, WorkspaceResolveMode::Direct)
.expect("absolute path attach should establish a session default");
let err = match server.resolve_workspace_target(
Some("../outside"),
None,
WorkspaceResolveMode::Direct,
) {
Ok(_) => panic!("relative parent traversal should be rejected"),
Err(err) => err,
};
assert!(
err.message.contains("parent directory components"),
"unexpected error: {err:?}"
);
assert_eq!(
server.known_workspaces().len(),
1,
"rejected relative paths must not create provisional workspaces"
);
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn ephemeral_workspace_is_hidden_from_other_sessions_by_repository_id() {
let workspace_root = authorized_temp_workspace_root("cross-session-ephemeral-hidden");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Hidden;\n")
.expect("failed to write workspace source");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let first_session = server.clone_for_new_session();
let second_session = server.clone_for_new_session();
let attach = first_session
.workspace_attach(Parameters(WorkspaceAttachParams {
path: Some(workspace_root.display().to_string()),
repository_id: None,
set_default: Some(true),
resolve_mode: Some(WorkspaceResolveMode::Direct),
wait_for_precise: Some(false),
}))
.await
.expect("first session should attach the ad hoc workspace")
.0;
let repository_id = attach.repository.repository_id.clone();
let listed = second_session
.list_repositories(Parameters(ListRepositoriesParams::default()))
.await
.expect("list_repositories should succeed")
.0;
assert!(
listed
.repositories
.iter()
.all(|repository| repository.repository_id != repository_id),
"another session must not list a peer session's ad hoc repository"
);
let attach_by_id = second_session
.workspace_attach(Parameters(WorkspaceAttachParams {
path: None,
repository_id: Some(repository_id.clone()),
set_default: Some(true),
resolve_mode: None,
wait_for_precise: Some(false),
}))
.await;
assert!(
attach_by_id.is_err(),
"another session must not adopt a peer ad hoc workspace by repository_id"
);
let read_by_id = second_session
.read_file_impl(crate::mcp::types::ReadFileParams {
path: "src/lib.rs".to_owned(),
repository_id: Some(repository_id),
max_bytes: None,
start_line: None,
end_line: None,
line_count: None,
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await;
assert!(
read_by_id.is_err(),
"another session must not read a peer ad hoc workspace by repository_id"
);
assert!(second_session.attached_workspaces().is_empty());
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn workspace_attach_path_rollback_guard_releases_fresh_adoption_on_cancellation() {
let first_workspace_root = authorized_temp_workspace_root("attach-rollback-preserves-default");
let second_workspace_root = authorized_temp_workspace_root("attach-rollback-prunes-fresh-path");
for workspace_root in [&first_workspace_root, &second_workspace_root] {
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Rollback;\n")
.expect("failed to write workspace root fixture");
}
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let first_response = server
.attach_workspace_internal(&first_workspace_root, true, WorkspaceResolveMode::Direct)
.expect("first path attach should establish a session default");
let first_repository_id = first_response.repository.repository_id.clone();
assert_eq!(
server.current_repository_id().as_deref(),
Some(first_repository_id.as_str())
);
let second_path = second_workspace_root
.to_str()
.expect("workspace root path is utf-8");
let mut second_outcome = server
.attach_workspace_target_internal(
Some(second_path),
None,
true,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("second path attach should create a fresh adoption");
let rollback_guard = second_outcome
.rollback_guard
.take()
.expect("fresh path attach should install a rollback guard");
let second_response = second_outcome.response;
let second_repository_id = second_response.repository.repository_id.clone();
assert_eq!(second_response.action, WorkspaceAttachAction::AttachedFresh);
assert_eq!(
server.current_repository_id().as_deref(),
Some(second_repository_id.as_str())
);
assert_eq!(server.attached_workspaces().len(), 2);
assert_eq!(server.known_workspaces().len(), 2);
drop(rollback_guard);
assert_eq!(
server.current_repository_id().as_deref(),
Some(first_repository_id.as_str()),
"cancelled attach rollback should restore the previous session default"
);
assert!(
!server
.attached_workspaces()
.iter()
.any(|workspace| workspace.repository_id == second_repository_id),
"cancelled fresh path attach should not remain adopted"
);
assert!(
!server
.known_workspaces()
.iter()
.any(|workspace| workspace.repository_id == second_repository_id),
"cancelled fresh path attach should be pruned from the process registry"
);
let _ = fs::remove_dir_all(first_workspace_root);
let _ = fs::remove_dir_all(second_workspace_root);
}
#[test]
fn workspace_attach_path_rollback_guard_preserves_later_completed_same_session_reuse() {
let workspace_root =
authorized_temp_workspace_root("attach-rollback-preserves-completed-reuse");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Reused;\n")
.expect("failed to write workspace root fixture");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let workspace_path = workspace_root
.to_str()
.expect("workspace root path is utf-8");
let mut first_outcome = server
.attach_workspace_target_internal(
Some(workspace_path),
None,
true,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("first path attach should create a fresh adoption");
let rollback_guard = first_outcome
.rollback_guard
.take()
.expect("fresh path attach should install a rollback guard");
let first_response = first_outcome.response;
let repository_id = first_response.repository.repository_id.clone();
assert_eq!(first_response.action, WorkspaceAttachAction::AttachedFresh);
let mut reused_outcome = server
.attach_workspace_target_internal(
Some(workspace_path),
None,
true,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("same-session path attach should reuse the adoption");
assert_eq!(
reused_outcome.response.action,
WorkspaceAttachAction::ReusedWorkspace
);
let reused_guard = reused_outcome
.rollback_guard
.take()
.expect("same-session path reuse should install an in-flight guard");
drop(rollback_guard);
assert!(
server
.attached_workspaces()
.iter()
.any(|workspace| workspace.repository_id == repository_id),
"an in-flight same-session reuse should defer the original rollback"
);
reused_guard.disarm();
assert!(
server
.attached_workspaces()
.iter()
.any(|workspace| workspace.repository_id == repository_id),
"an older cancelled attach must not detach a later same-session reuse"
);
assert!(
server
.known_workspaces()
.iter()
.any(|workspace| workspace.repository_id == repository_id),
"an older cancelled attach must not prune a reused workspace"
);
assert_eq!(
server.current_repository_id().as_deref(),
Some(repository_id.as_str())
);
server
.detach_workspace(&repository_id)
.expect("cleanup detach should succeed");
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn workspace_attach_completed_reuse_without_default_restores_cancelled_default_change() {
let first_workspace_root = authorized_temp_workspace_root("attach-rollback-default-first");
let second_workspace_root = authorized_temp_workspace_root("attach-rollback-default-second");
for workspace_root in [&first_workspace_root, &second_workspace_root] {
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Default;\n")
.expect("failed to write workspace root fixture");
}
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let first_response = server
.attach_workspace_internal(&first_workspace_root, true, WorkspaceResolveMode::Direct)
.expect("first path attach should establish a session default");
let first_repository_id = first_response.repository.repository_id.clone();
let second_path = second_workspace_root
.to_str()
.expect("workspace root path is utf-8");
let mut fresh_outcome = server
.attach_workspace_target_internal(
Some(second_path),
None,
true,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("second path attach should create a fresh adoption");
let fresh_guard = fresh_outcome
.rollback_guard
.take()
.expect("fresh path attach should install a rollback guard");
let second_repository_id = fresh_outcome.response.repository.repository_id.clone();
assert_eq!(
server.current_repository_id().as_deref(),
Some(second_repository_id.as_str())
);
let mut reused_outcome = server
.attach_workspace_target_internal(
Some(second_path),
None,
false,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("same-session path attach should reuse the adoption");
assert_eq!(
reused_outcome.response.action,
WorkspaceAttachAction::ReusedWorkspace
);
let reused_guard = reused_outcome
.rollback_guard
.take()
.expect("same-session path reuse should install an in-flight guard");
drop(fresh_guard);
reused_guard.disarm();
assert!(
server
.attached_workspaces()
.iter()
.any(|workspace| workspace.repository_id == second_repository_id),
"completed reuse should preserve the reused adoption"
);
assert_eq!(
server.current_repository_id().as_deref(),
Some(first_repository_id.as_str()),
"completed set_default=false reuse should not confirm a cancelled default change"
);
let _ = server.detach_workspace(&second_repository_id);
let _ = server.detach_workspace(&first_repository_id);
let _ = fs::remove_dir_all(first_workspace_root);
let _ = fs::remove_dir_all(second_workspace_root);
}
#[test]
fn workspace_attach_completed_reuse_before_fresh_cancel_restores_cancelled_default_change() {
let first_workspace_root =
authorized_temp_workspace_root("attach-rollback-default-first-complete-first");
let second_workspace_root =
authorized_temp_workspace_root("attach-rollback-default-second-complete-first");
for workspace_root in [&first_workspace_root, &second_workspace_root] {
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Default;\n")
.expect("failed to write workspace root fixture");
}
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let first_response = server
.attach_workspace_internal(&first_workspace_root, true, WorkspaceResolveMode::Direct)
.expect("first path attach should establish a session default");
let first_repository_id = first_response.repository.repository_id.clone();
let second_path = second_workspace_root
.to_str()
.expect("workspace root path is utf-8");
let mut fresh_outcome = server
.attach_workspace_target_internal(
Some(second_path),
None,
true,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("second path attach should create a fresh adoption");
let fresh_guard = fresh_outcome
.rollback_guard
.take()
.expect("fresh path attach should install a rollback guard");
let second_repository_id = fresh_outcome.response.repository.repository_id.clone();
assert_eq!(
server.current_repository_id().as_deref(),
Some(second_repository_id.as_str())
);
let mut reused_outcome = server
.attach_workspace_target_internal(
Some(second_path),
None,
false,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("same-session path attach should reuse the adoption");
let reused_guard = reused_outcome
.rollback_guard
.take()
.expect("same-session path reuse should install an in-flight guard");
reused_guard.disarm();
drop(fresh_guard);
assert!(
server
.attached_workspaces()
.iter()
.any(|workspace| workspace.repository_id == second_repository_id),
"completed reuse should preserve the reused adoption"
);
assert_eq!(
server.current_repository_id().as_deref(),
Some(first_repository_id.as_str()),
"set_default=false reuse completion before fresh cancellation should still restore the cancelled default change"
);
let _ = server.detach_workspace(&second_repository_id);
let _ = server.detach_workspace(&first_repository_id);
let _ = fs::remove_dir_all(first_workspace_root);
let _ = fs::remove_dir_all(second_workspace_root);
}
#[test]
fn workspace_attach_path_rollback_guard_ignores_cancelled_same_session_reuse() {
let workspace_root = authorized_temp_workspace_root("attach-rollback-ignores-cancelled-reuse");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Cancelled;\n")
.expect("failed to write workspace root fixture");
let config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(config, false);
let workspace_path = workspace_root
.to_str()
.expect("workspace root path is utf-8");
let mut first_outcome = server
.attach_workspace_target_internal(
Some(workspace_path),
None,
true,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("first path attach should create a fresh adoption");
let rollback_guard = first_outcome
.rollback_guard
.take()
.expect("fresh path attach should install a rollback guard");
let first_response = first_outcome.response;
let repository_id = first_response.repository.repository_id.clone();
assert_eq!(first_response.action, WorkspaceAttachAction::AttachedFresh);
let mut cancelled_reuse = server
.attach_workspace_target_internal(
Some(workspace_path),
None,
true,
WorkspaceResolveMode::Direct,
WorkspaceAttachIndexMode::Defer,
)
.expect("same-session path attach should reuse the adoption");
assert_eq!(
cancelled_reuse.response.action,
WorkspaceAttachAction::ReusedWorkspace
);
let cancelled_reuse_guard = cancelled_reuse
.rollback_guard
.take()
.expect("same-session path reuse should install an in-flight guard");
drop(rollback_guard);
assert!(
server
.attached_workspaces()
.iter()
.any(|workspace| workspace.repository_id == repository_id),
"an in-flight same-session reuse should defer the original rollback"
);
drop(cancelled_reuse_guard);
assert!(
!server
.attached_workspaces()
.iter()
.any(|workspace| workspace.repository_id == repository_id),
"a cancelled same-session reuse must not suppress the original rollback"
);
assert!(
!server
.known_workspaces()
.iter()
.any(|workspace| workspace.repository_id == repository_id),
"a cancelled same-session reuse must not keep an ad hoc workspace in the registry"
);
assert!(server.current_repository_id().is_none());
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test(flavor = "current_thread")]
async fn workspace_attach_failed_watch_lease_rolls_back_and_prunes_ephemeral_workspace() {
let workspace_root =
authorized_temp_workspace_root("attach-watch-lease-failure-prunes-workspace");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Rollback;\n")
.expect("failed to write workspace root fixture");
let server_config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty serving config should be valid");
let server = FriggMcpServer::new_with_runtime_options(server_config, false);
let mut watch_config = FriggConfig::from_optional_workspace_roots(Vec::new())
.expect("empty watch config should be valid");
watch_config.watch = WatchConfig {
mode: WatchMode::On,
..WatchConfig::default()
};
let watch_runtime = maybe_start_watch_runtime(
&watch_config,
RuntimeTransportKind::Stdio,
Arc::new(RwLock::new(RuntimeTaskRegistry::new())),
Arc::new(RwLock::new(ValidatedManifestCandidateCache::default())),
None,
)
.expect("watch runtime startup should succeed")
.expect("watch runtime should be enabled");
server.set_watch_runtime(Some(Arc::new(watch_runtime)));
let (workspace, _, _, resolution_guard) = server
.resolve_workspace_target(
Some(
workspace_root
.to_str()
.expect("workspace root path is utf-8"),
),
None,
WorkspaceResolveMode::Direct,
)
.expect("path resolution should create a provisional workspace");
let resolution_guard = resolution_guard.expect("path resolution should hold a pending guard");
assert_eq!(server.known_workspaces().len(), 1);
fs::remove_dir_all(&workspace_root)
.expect("removing workspace root should force watch lease acquisition to fail");
let adoption_error = server
.adopt_workspace(&workspace, true)
.expect_err("adoption should fail when the watcher cannot lease the removed root");
assert!(
adoption_error
.message
.contains("failed to register watcher for root"),
"unexpected adoption error: {}",
adoption_error.message
);
assert!(server.attached_workspaces().is_empty());
drop(resolution_guard);
assert!(
server.known_workspaces().is_empty(),
"failed watch lease adoption should rollback session state and prune the ad hoc workspace"
);
}
#[tokio::test]
async fn workspace_attach_wait_for_precise_reports_completed_lifecycle() {
let workspace_root = temp_workspace_root("attach-wait-for-precise");
fs::create_dir_all(workspace_root.join("src")).expect("failed to create python src fixture");
fs::create_dir_all(workspace_root.join("node_modules/.bin"))
.expect("failed to create local node bin directory");
fs::write(
workspace_root.join("pyproject.toml"),
"[project]\nname = \"demo\"\n",
)
.expect("failed to write pyproject fixture");
fs::write(
workspace_root.join("src/app.py"),
"def alpha():\n return 1\n",
)
.expect("failed to write python source fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("server should register workspace");
let expected_project_name = FriggMcpServer::derived_python_precise_project_name(&workspace);
let _local_scip_python = write_fake_precise_generator_script_with_body(
&workspace_root.join("node_modules/.bin"),
"scip-python",
&format!(
r#"#!/bin/sh
if [ "${{1:-}}" = "--version" ] || [ "${{1:-}}" = "version" ]; then
printf '%s\n' "scip-python 0.6.6"
exit 0
fi
if [ "${{1:-}}" = "index" ] && [ "${{2:-}}" = "--help" ]; then
printf '%s\n' "usage: scip-python index"
exit 0
fi
if [ "${{1:-}}" != "index" ] || [ "${{2:-}}" != "--quiet" ] || [ "${{3:-}}" != "--project-name" ] || [ "${{4:-}}" != "{expected_project_name}" ] || [ "${{5:-}}" != "--output" ] || [ -z "${{6:-}}" ] || [ -n "${{7:-}}" ]; then
printf '%s\n' "unexpected python args: $*" >&2
exit 81
fi
printf '%s' "local-python-scip" > "${{6}}"
"#
),
);
let response = server
.workspace_attach(Parameters(WorkspaceAttachParams {
path: Some(workspace_root.display().to_string()),
repository_id: None,
set_default: Some(true),
resolve_mode: Some(WorkspaceResolveMode::Direct),
wait_for_precise: Some(true),
}))
.await
.expect("workspace_attach should succeed")
.0;
assert!(response.precise_lifecycle.waited_for_completion);
assert_eq!(
response.precise_lifecycle.generation_action,
WorkspacePreciseGenerationAction::Triggered
);
assert_eq!(
response.precise_lifecycle.phase,
WorkspacePreciseLifecyclePhase::Succeeded
);
let last_generation = response
.precise_lifecycle
.last_generation
.as_ref()
.expect("waited attach should return the latest precise generation summary");
assert_eq!(
last_generation.status,
WorkspacePreciseGenerationStatus::Succeeded
);
assert!(last_generation.artifact_path.is_some());
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn workspace_attach_wait_for_precise_false_still_schedules_precise_generation() {
let workspace_root = temp_workspace_root("attach-no-wait-precise-schedules");
fs::create_dir_all(workspace_root.join("src")).expect("failed to create python src fixture");
fs::create_dir_all(workspace_root.join("node_modules/.bin"))
.expect("failed to create local node bin directory");
fs::write(
workspace_root.join("pyproject.toml"),
"[project]\nname = \"demo\"\n",
)
.expect("failed to write pyproject fixture");
fs::write(
workspace_root.join("src/app.py"),
"def alpha():\n return 1\n",
)
.expect("failed to write python source fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("server should register workspace");
let expected_project_name = FriggMcpServer::derived_python_precise_project_name(&workspace);
let _local_scip_python = write_fake_precise_generator_script_with_body(
&workspace_root.join("node_modules/.bin"),
"scip-python",
&format!(
r#"#!/bin/sh
if [ "${{1:-}}" = "--version" ] || [ "${{1:-}}" = "version" ]; then
printf '%s\n' "scip-python 0.6.6"
exit 0
fi
if [ "${{1:-}}" = "index" ] && [ "${{2:-}}" = "--help" ]; then
printf '%s\n' "usage: scip-python index"
exit 0
fi
if [ "${{1:-}}" != "index" ] || [ "${{2:-}}" != "--quiet" ] || [ "${{3:-}}" != "--project-name" ] || [ "${{4:-}}" != "{expected_project_name}" ] || [ "${{5:-}}" != "--output" ] || [ -z "${{6:-}}" ] || [ -n "${{7:-}}" ]; then
printf '%s\n' "unexpected python args: $*" >&2
exit 81
fi
printf '%s' "nonblocking-python-scip" > "${{6}}"
"#
),
);
let response = server
.workspace_attach(Parameters(WorkspaceAttachParams {
path: Some(workspace_root.display().to_string()),
repository_id: None,
set_default: Some(true),
resolve_mode: Some(WorkspaceResolveMode::Direct),
wait_for_precise: Some(false),
}))
.await
.expect("workspace_attach should succeed")
.0;
assert!(!response.precise_lifecycle.waited_for_completion);
assert_eq!(
response.precise_lifecycle.generation_action,
WorkspacePreciseGenerationAction::Triggered
);
let expected_artifact = workspace_root.join(".frigg/scip/python.scip");
for _ in 0..200 {
let ready = fs::read(&expected_artifact)
.map(|contents| contents == b"nonblocking-python-scip")
.unwrap_or(false);
if ready {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert_eq!(
fs::read(&expected_artifact).expect("non-blocking precise generation should publish"),
b"nonblocking-python-scip"
);
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn repository_active_runtime_work_ignores_precise_generation_but_still_blocks_index() {
let workspace_root = temp_workspace_root("index-allows-active-precise-generation");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(
workspace_root.join("src/lib.rs"),
"pub struct WarmPrecise;\n",
)
.expect("failed to write workspace root fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("startup roots should register globally known workspaces");
let task_id = server
.runtime_state
.runtime_task_registry
.write()
.expect("runtime task registry should not be poisoned")
.start_task(
RuntimeTaskKind::PreciseGenerate,
workspace.repository_id.clone(),
"precise_generation",
Some("background precise generation".to_owned()),
);
assert!(
!server.repository_has_active_runtime_work(&workspace.repository_id),
"background precise generation should not block workspace_prepare/workspace_index"
);
server
.runtime_state
.runtime_task_registry
.write()
.expect("runtime task registry should not be poisoned")
.finish_task(&task_id, RuntimeTaskStatus::Succeeded, None);
let blocking_task_id = server
.runtime_state
.runtime_task_registry
.write()
.expect("runtime task registry should not be poisoned")
.start_task(
RuntimeTaskKind::WorkspaceIndex,
workspace.repository_id.clone(),
"workspace_index",
Some("active lexical index".to_owned()),
);
assert!(
server.repository_has_active_runtime_work(&workspace.repository_id),
"workspace_index should continue to block overlapping workspace writes"
);
server
.runtime_state
.runtime_task_registry
.write()
.expect("runtime task registry should not be poisoned")
.finish_task(&blocking_task_id, RuntimeTaskStatus::Succeeded, None);
let _ = fs::remove_dir_all(workspace_root);
}
#[test]
fn repository_runtime_task_atomic_start_rejects_alias_conflict() {
let workspace_root = temp_workspace_root("atomic-runtime-task-alias-conflict");
fs::create_dir_all(workspace_root.join("src"))
.expect("failed to create workspace root fixture");
fs::write(workspace_root.join("src/lib.rs"), "pub struct Atomic;\n")
.expect("failed to write workspace root fixture");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root.clone()])
.expect("workspace root must produce valid config"),
);
let workspace = server
.known_workspaces()
.into_iter()
.next()
.expect("startup roots should register globally known workspaces");
let active_task_id = server
.runtime_state
.runtime_task_registry
.write()
.expect("runtime task registry should not be poisoned")
.start_task(
RuntimeTaskKind::WorkspaceIndex,
workspace.runtime_repository_id.clone(),
"workspace_index",
Some("active alias index".to_owned()),
);
let rejected = match server.try_start_repository_runtime_task(
&workspace,
RuntimeTaskKind::WorkspacePrepare,
"workspace_prepare",
Some("prepare during active index".to_owned()),
) {
Ok(_) => panic!("atomic start should reject active alias work"),
Err(active_tasks) => active_tasks,
};
assert_eq!(rejected.len(), 1);
assert_eq!(rejected[0].task_id, active_task_id);
assert_eq!(
server
.runtime_state
.runtime_task_registry
.read()
.expect("runtime task registry should not be poisoned")
.active_tasks()
.len(),
1,
"failed atomic start must not insert a second active task"
);
server
.runtime_state
.runtime_task_registry
.write()
.expect("runtime task registry should not be poisoned")
.finish_task(&active_task_id, RuntimeTaskStatus::Succeeded, None);
let mut guard = server
.try_start_repository_runtime_task(
&workspace,
RuntimeTaskKind::WorkspacePrepare,
"workspace_prepare",
Some("prepare after active index".to_owned()),
)
.expect("atomic start should succeed after alias conflict finishes");
guard.finish(RuntimeTaskStatus::Succeeded, None);
let _ = fs::remove_dir_all(workspace_root);
}
#[tokio::test]
async fn read_file_requires_explicit_workspace_adoption_for_repository_id() {
let workspace_root_a = temp_workspace_root("adoption-gate-repo-a");
let workspace_root_b = temp_workspace_root("adoption-gate-repo-b");
fs::create_dir_all(workspace_root_a.join("src")).expect("failed to create repo A fixture root");
fs::create_dir_all(workspace_root_b.join("src")).expect("failed to create repo B fixture root");
fs::write(workspace_root_a.join("src/lib.rs"), "pub struct A;\n")
.expect("failed to write repo A source");
fs::write(
workspace_root_b.join("src/secret.rs"),
"pub struct Secret;\n",
)
.expect("failed to write repo B secret source");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root_a.clone(), workspace_root_b.clone()])
.expect("workspace roots must produce valid config"),
);
let canonical_a = workspace_root_a
.canonicalize()
.expect("repo A root should canonicalize");
let canonical_b = workspace_root_b
.canonicalize()
.expect("repo B root should canonicalize");
let workspaces = server.known_workspaces();
let workspace_a = workspaces
.iter()
.find(|workspace| {
workspace
.root
.canonicalize()
.map(|root| root == canonical_a)
.unwrap_or(false)
})
.cloned()
.expect("repo A should be globally known at startup");
let workspace_b = workspaces
.iter()
.find(|workspace| {
workspace
.root
.canonicalize()
.map(|root| root == canonical_b)
.unwrap_or(false)
})
.cloned()
.expect("repo B should be globally known at startup");
let session = server.clone_for_new_session();
session
.adopt_workspace(&workspace_a, true)
.expect("session should adopt repo A");
let absolute_secret = workspace_root_b
.join("src/secret.rs")
.to_string_lossy()
.into_owned();
let absolute = session
.read_file_impl(crate::mcp::types::ReadFileParams {
path: absolute_secret,
repository_id: None,
max_bytes: None,
start_line: None,
end_line: None,
line_count: None,
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await;
assert!(
absolute.is_err(),
"session scoped to repo A must not read repo B by absolute path before repo B is explicitly adopted"
);
let explicit_before_adoption = session
.read_file_impl(crate::mcp::types::ReadFileParams {
path: "src/secret.rs".to_owned(),
repository_id: Some(workspace_b.repository_id.clone()),
max_bytes: None,
start_line: None,
end_line: None,
line_count: None,
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await;
assert!(
explicit_before_adoption.is_err(),
"repository_id reads must not auto-adopt another visible repository"
);
assert_eq!(
session.current_repository_id().as_deref(),
Some(workspace_a.repository_id.as_str()),
"failed repository_id reads must not replace the session default"
);
session
.workspace_attach(Parameters(WorkspaceAttachParams {
path: None,
repository_id: Some(workspace_b.repository_id.clone()),
set_default: Some(false),
resolve_mode: None,
wait_for_precise: Some(false),
}))
.await
.expect("workspace_attach should explicitly adopt repo B without making it default");
assert_eq!(
session.current_repository_id().as_deref(),
Some(workspace_a.repository_id.as_str()),
"set_default=false adoption must preserve repo A as the session default"
);
let explicit = session
.read_file_impl(crate::mcp::types::ReadFileParams {
path: "src/secret.rs".to_owned(),
repository_id: Some(workspace_b.repository_id.clone()),
max_bytes: None,
start_line: None,
end_line: None,
line_count: None,
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await
.expect("explicitly adopted repo B should be readable by repository_id");
assert!(explicit.content.contains("pub struct Secret"));
let absolute_a = workspace_root_a
.join("src/lib.rs")
.to_string_lossy()
.into_owned();
let allowed = session
.read_file_impl(crate::mcp::types::ReadFileParams {
path: absolute_a,
repository_id: None,
max_bytes: None,
start_line: None,
end_line: None,
line_count: None,
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await
.expect("adopted repo A must remain readable");
assert!(allowed.content.contains("pub struct A"));
let _ = fs::remove_dir_all(workspace_root_a);
let _ = fs::remove_dir_all(workspace_root_b);
}
#[tokio::test]
async fn read_file_rejects_ambiguous_relative_path_across_adopted_repositories() {
let workspace_root_a = temp_workspace_root("ambiguous-read-repo-a");
let workspace_root_b = temp_workspace_root("ambiguous-read-repo-b");
fs::create_dir_all(workspace_root_a.join("src")).expect("failed to create repo A fixture root");
fs::create_dir_all(workspace_root_b.join("src")).expect("failed to create repo B fixture root");
fs::write(workspace_root_a.join("src/shared.rs"), "pub struct A;\n")
.expect("failed to write repo A source");
fs::write(workspace_root_b.join("src/shared.rs"), "pub struct B;\n")
.expect("failed to write repo B source");
let server = FriggMcpServer::new(
FriggConfig::from_workspace_roots(vec![workspace_root_a.clone(), workspace_root_b.clone()])
.expect("workspace roots must produce valid config"),
);
let canonical_b = workspace_root_b
.canonicalize()
.expect("repo B root should canonicalize");
let workspaces = server.known_workspaces();
let workspace_b = workspaces
.iter()
.find(|workspace| {
workspace
.root
.canonicalize()
.map(|root| root == canonical_b)
.unwrap_or(false)
})
.cloned()
.expect("repo B should be globally known at startup");
for workspace in &workspaces {
server
.adopt_workspace(workspace, false)
.expect("workspace should adopt");
}
server.set_current_repository_id(None);
let ambiguous = server
.read_file_impl(crate::mcp::types::ReadFileParams {
path: "src/shared.rs".to_owned(),
repository_id: None,
max_bytes: None,
start_line: None,
end_line: None,
line_count: None,
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await;
assert!(
ambiguous.is_err(),
"relative reads that match multiple adopted repositories should require repository_id"
);
let explicit = server
.read_file_impl(crate::mcp::types::ReadFileParams {
path: "src/shared.rs".to_owned(),
repository_id: Some(workspace_b.repository_id.clone()),
max_bytes: None,
start_line: None,
end_line: None,
line_count: None,
presentation_mode: Some(crate::mcp::types::ReadPresentationMode::Json),
include_context_efficiency: None,
})
.await
.expect("explicit repository_id should disambiguate");
assert!(explicit.content.contains("pub struct B"));
let _ = fs::remove_dir_all(workspace_root_a);
let _ = fs::remove_dir_all(workspace_root_b);
}