use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use bamboo_agent_core::Session;
use bamboo_domain::{ProjectId, ProjectResourceKind, ProjectResourceSummary, WorkspaceBinding};
use serde::{Deserialize, Serialize};
pub const PROJECT_ID_METADATA_KEY: &str = "project_id";
pub const PROJECT_RESOURCES_RENDERED_KEY: &str = "project_resources_rendered";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceBindingStatus {
Registered,
Unregistered,
}
impl WorkspaceBindingStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Registered => "registered",
Self::Unregistered => "unregistered",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectDescriptor {
pub id: ProjectId,
pub name: String,
pub home: PathBuf,
pub workspace_bindings: Vec<WorkspaceBinding>,
pub resources: ProjectResourceSummary,
pub memory_read_roots: ProjectMemoryReadRoots,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectMemoryReadRoots {
pub primary: PathBuf,
pub legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedProjectContext {
pub project: ProjectDescriptor,
pub workspace: Option<PathBuf>,
pub binding_status: WorkspaceBindingStatus,
}
impl ResolvedProjectContext {
pub fn resource_scope(&self) -> ProjectResourceScope {
ProjectResourceScope {
project_id: self.project.id.clone(),
project_home: self.project.home.clone(),
workspace: self.workspace.clone(),
binding_status: self.binding_status,
resource_revision: self.project.resources.resource_revision,
}
}
pub fn render_resource_inventory(&self) -> String {
let mut entries = self.project.resources.resources.clone();
entries.sort_by_key(|entry| entry.kind);
let rendered = entries
.into_iter()
.map(|entry| {
format!(
"- {:?}: status={}, items={}",
entry.kind,
if entry.present { "available" } else { "empty" },
entry.item_count
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
"Project ID: {}\nResource revision: {}\n{}",
self.project.id,
self.project.resources.resource_revision,
if rendered.is_empty() {
"No Project-shared resources are currently advertised.".to_string()
} else {
rendered
}
)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectContextError {
#[error("project context source failed: {0}")]
Source(String),
#[error("project context source returned '{actual}' for requested project '{requested}'")]
IdentityMismatch { requested: String, actual: String },
#[error(
"workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
)]
WorkspaceConflict {
workspace: String,
owner_project_id: ProjectId,
session_project_id: ProjectId,
},
#[error("workspace '{workspace}' belongs to Project '{owner_project_id}', but the session is Unassigned")]
UnassignedWorkspaceConflict {
workspace: String,
owner_project_id: ProjectId,
},
#[error("session carries an invalid Project identity '{raw}': {message}")]
InvalidProjectIdentity { raw: String, message: String },
#[error("assigned Project '{project_id}' is unavailable")]
ProjectUnavailable { project_id: ProjectId },
#[error("workspace '{workspace}' is invalid: {message}")]
WorkspaceInvalid { workspace: String, message: String },
}
#[async_trait]
pub trait ProjectContextSource: Send + Sync {
async fn find_project(
&self,
project_id: &ProjectId,
) -> Result<Option<ProjectDescriptor>, ProjectContextError>;
async fn list_projects(&self) -> Result<Vec<ProjectDescriptor>, ProjectContextError> {
Ok(Vec::new())
}
async fn find_workspace_owner(
&self,
_workspace: &Path,
) -> Result<Option<ProjectId>, ProjectContextError> {
Ok(None)
}
}
#[derive(Clone)]
pub struct ProjectContextResolver {
source: Arc<dyn ProjectContextSource>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionProjectIdentity {
Unassigned,
Assigned(ProjectId),
Invalid { raw: String, message: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectMemoryScope {
Assigned {
project_id: ProjectId,
legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
},
LegacyReadOnly(String),
}
impl ProjectMemoryScope {
pub fn key(&self) -> &str {
match self {
Self::Assigned { project_id, .. } => project_id.as_str(),
Self::LegacyReadOnly(project_key) => project_key,
}
}
pub fn scoped_store(
&self,
store: &bamboo_memory::memory_store::MemoryStore,
) -> bamboo_memory::memory_store::MemoryStore {
match self {
Self::Assigned {
project_id,
legacy_aliases,
} => store.for_project_with_legacy_read_roots(project_id, legacy_aliases.clone()),
Self::LegacyReadOnly(_) => store.clone(),
}
}
}
impl ProjectContextResolver {
pub fn new(source: Arc<dyn ProjectContextSource>) -> Self {
Self { source }
}
pub fn project_id_from_session(session: &Session) -> Option<ProjectId> {
match Self::session_project_identity(session) {
SessionProjectIdentity::Assigned(project_id) => Some(project_id),
SessionProjectIdentity::Invalid { raw, message } => {
tracing::warn!(
session_id = %session.id,
"ignoring invalid persisted Project id '{raw}': {message}"
);
None
}
SessionProjectIdentity::Unassigned => None,
}
}
pub fn session_project_identity(session: &Session) -> SessionProjectIdentity {
let Some(raw) = session.project_id_meta() else {
return SessionProjectIdentity::Unassigned;
};
let normalized = raw.trim();
match ProjectId::parse(normalized) {
Ok(project_id) => SessionProjectIdentity::Assigned(project_id),
Err(error) => SessionProjectIdentity::Invalid {
raw,
message: error.to_string(),
},
}
}
pub fn memory_read_scope_for_session(session: &Session) -> Option<String> {
Self::memory_read_identity_for_session(session).map(|scope| scope.key().to_string())
}
pub fn memory_read_identity_for_session(session: &Session) -> Option<ProjectMemoryScope> {
match Self::session_project_identity(session) {
SessionProjectIdentity::Assigned(project_id) => Some(ProjectMemoryScope::Assigned {
project_id,
legacy_aliases: Vec::new(),
}),
SessionProjectIdentity::Unassigned => session
.workspace_path_meta()
.map(PathBuf::from)
.or_else(|| {
bamboo_tools::tools::workspace_state::get_workspace(session.id.as_str())
})
.map(|path| {
ProjectMemoryScope::LegacyReadOnly(
bamboo_memory::memory_store::project_key_from_path(&path),
)
}),
SessionProjectIdentity::Invalid { .. } => None,
}
}
pub async fn resolve_memory_read_scope(
&self,
session: &Session,
workspace: Option<&Path>,
) -> Result<Option<ProjectMemoryScope>, ProjectContextError> {
match Self::session_project_identity(session) {
SessionProjectIdentity::Unassigned => {
return Ok(Self::memory_read_identity_for_session(session));
}
SessionProjectIdentity::Invalid { raw, message } => {
return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
}
SessionProjectIdentity::Assigned(_) => {}
}
Ok(self
.resolve(session, workspace)
.await?
.map(|context| ProjectMemoryScope::Assigned {
project_id: context.project.id,
legacy_aliases: context.project.memory_read_roots.legacy_aliases,
}))
}
pub async fn list_memory_read_scopes(
&self,
) -> Result<Vec<ProjectMemoryScope>, ProjectContextError> {
Ok(self
.source
.list_projects()
.await?
.into_iter()
.map(|project| ProjectMemoryScope::Assigned {
project_id: project.id,
legacy_aliases: project.memory_read_roots.legacy_aliases,
})
.collect())
}
pub fn memory_write_scope_for_session(session: &Session) -> Option<String> {
match Self::session_project_identity(session) {
SessionProjectIdentity::Assigned(project_id) => Some(project_id.into_string()),
SessionProjectIdentity::Unassigned | SessionProjectIdentity::Invalid { .. } => None,
}
}
pub async fn resolve(
&self,
session: &Session,
workspace: Option<&Path>,
) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
let workspace = Self::resolve_workspace_candidate(session, workspace)?;
self.resolve_with_final_workspace(session, workspace).await
}
pub fn resolve_workspace_candidate(
session: &Session,
workspace: Option<&Path>,
) -> Result<Option<PathBuf>, ProjectContextError> {
let preferred = workspace
.map(Path::to_path_buf)
.or_else(|| session.workspace_path_meta().map(PathBuf::from));
bamboo_agent_core::workspace_state::resolve_session_workspace_candidate(
&session.id,
preferred,
)
.map(|candidate| resolve_final_workspace(&candidate))
.transpose()
}
async fn resolve_with_final_workspace(
&self,
session: &Session,
workspace: Option<PathBuf>,
) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
let project_id = match Self::session_project_identity(session) {
SessionProjectIdentity::Assigned(project_id) => project_id,
SessionProjectIdentity::Invalid { raw, message } => {
return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
}
SessionProjectIdentity::Unassigned => {
if let Some(candidate) = workspace.as_deref() {
if let Some(owner_project_id) =
self.source.find_workspace_owner(candidate).await?
{
return Err(ProjectContextError::UnassignedWorkspaceConflict {
workspace: candidate.to_string_lossy().into_owned(),
owner_project_id,
});
}
}
return Ok(None);
}
};
let project = self
.source
.find_project(&project_id)
.await?
.ok_or_else(|| ProjectContextError::ProjectUnavailable {
project_id: project_id.clone(),
})?;
if project.id != project_id {
return Err(ProjectContextError::IdentityMismatch {
requested: project_id.to_string(),
actual: project.id.to_string(),
});
}
let binding_status = match workspace.as_deref() {
Some(candidate) => match self.source.find_workspace_owner(candidate).await? {
Some(owner) if owner == project.id => WorkspaceBindingStatus::Registered,
Some(owner) => {
return Err(ProjectContextError::WorkspaceConflict {
workspace: candidate.to_string_lossy().into_owned(),
owner_project_id: owner,
session_project_id: project.id.clone(),
});
}
None if project
.workspace_bindings
.iter()
.any(|binding| path_is_within_binding(Path::new(&binding.path), candidate)) =>
{
WorkspaceBindingStatus::Registered
}
None => WorkspaceBindingStatus::Unregistered,
},
None => WorkspaceBindingStatus::Unregistered,
};
Ok(Some(ResolvedProjectContext {
project,
workspace,
binding_status,
}))
}
pub async fn workspace_owner(
&self,
workspace: &Path,
) -> Result<Option<ProjectId>, ProjectContextError> {
self.source.find_workspace_owner(workspace).await
}
pub async fn refresh_session_prompt(
&self,
session: &mut Session,
) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
self.refresh_session_prompt_inner(session, true).await
}
pub async fn refresh_session_prompt_read_only(
&self,
session: &mut Session,
) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
self.refresh_session_prompt_inner(session, false).await
}
async fn refresh_session_prompt_inner(
&self,
session: &mut Session,
sync_runtime_workspace: bool,
) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
let workspace = Self::resolve_workspace_candidate(session, None)?;
let resolved = self
.resolve_with_final_workspace(session, workspace.clone())
.await?;
if let Some(workspace) = workspace.as_deref() {
let final_workspace = if sync_runtime_workspace {
bamboo_tools::tools::workspace_state::publish_resolved_workspace(
&session.id,
workspace.into(),
)
} else {
workspace.to_path_buf()
};
session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
&final_workspace,
));
}
let current = session
.messages
.iter()
.find(|message| matches!(message.role, bamboo_agent_core::Role::System))
.map(|message| message.content.clone())
.or_else(|| session.metadata.get("base_system_prompt").cloned())
.unwrap_or_default();
let mut updated =
crate::runtime::context::upsert_project_prompt_context(¤t, resolved.as_ref());
if let Some(context) = resolved.as_ref() {
session.metadata.insert(
PROJECT_RESOURCES_RENDERED_KEY.to_string(),
context.render_resource_inventory(),
);
} else {
session.metadata.remove(PROJECT_RESOURCES_RENDERED_KEY);
}
let workspace_display = workspace
.as_deref()
.map(bamboo_config::paths::path_to_display_string);
updated = crate::runtime::context::upsert_workspace_prompt_context(
&updated,
workspace_display.as_deref(),
resolved
.as_ref()
.map(|context| context.binding_status)
.unwrap_or(WorkspaceBindingStatus::Unregistered),
);
if let Some(system_message) = session
.messages
.iter_mut()
.find(|message| matches!(message.role, bamboo_agent_core::Role::System))
{
system_message.content = updated;
} else if !updated.trim().is_empty() {
session
.messages
.insert(0, bamboo_agent_core::Message::system(updated));
}
crate::runner::refresh_prompt_snapshot(session);
Ok(resolved)
}
}
fn resolve_final_workspace(workspace: &Path) -> Result<PathBuf, ProjectContextError> {
if workspace.exists() && !workspace.is_dir() {
return Err(ProjectContextError::WorkspaceInvalid {
workspace: workspace.to_string_lossy().into_owned(),
message: "path is not a directory".to_string(),
});
}
let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
let final_workspace = bamboo_agent_core::workspace_state::preview_workspace_path(canonical);
if final_workspace.exists() && !final_workspace.is_dir() {
return Err(ProjectContextError::WorkspaceInvalid {
workspace: final_workspace.to_string_lossy().into_owned(),
message: "resolved path is not a directory".to_string(),
});
}
Ok(std::fs::canonicalize(&final_workspace).unwrap_or(final_workspace))
}
fn path_is_within_binding(binding: &Path, candidate: &Path) -> bool {
match (
std::fs::canonicalize(binding),
std::fs::canonicalize(candidate),
) {
(Ok(binding), Ok(candidate)) => candidate == binding || candidate.starts_with(binding),
_ => candidate == binding || candidate.starts_with(binding),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectResourceLayer {
Project,
Workspace,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectResourceCandidate {
pub kind: ProjectResourceKind,
pub layer: ProjectResourceLayer,
pub path: PathBuf,
pub exists: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectResourceDiagnostic {
pub project_id: ProjectId,
pub resource_revision: u64,
pub workspace_binding_status: WorkspaceBindingStatus,
pub candidates: Vec<ProjectResourceCandidate>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectResourceScope {
pub project_id: ProjectId,
pub project_home: PathBuf,
pub workspace: Option<PathBuf>,
pub binding_status: WorkspaceBindingStatus,
pub resource_revision: u64,
}
impl ProjectResourceScope {
pub fn project_memory_root(&self) -> PathBuf {
self.project_home.join("memory").join("v1")
}
pub fn project_skills_dir(&self) -> PathBuf {
self.project_home.join("skills")
}
pub fn project_mode_skills_dir(&self, mode: &str) -> PathBuf {
self.project_home.join(format!("skills-{mode}"))
}
pub fn workspace_skills_dir(&self) -> Option<PathBuf> {
self.workspace
.as_ref()
.map(|workspace| workspace.join(".bamboo").join("skills"))
}
pub fn workspace_mode_skills_dir(&self, mode: &str) -> Option<PathBuf> {
self.workspace
.as_ref()
.map(|workspace| workspace.join(".bamboo").join(format!("skills-{mode}")))
}
pub fn project_commands_dir(&self) -> PathBuf {
self.project_home.join("commands")
}
pub fn workspace_commands_dir(&self) -> Option<PathBuf> {
let workspace = self.workspace.as_deref()?;
let boundary = nearest_git_boundary(workspace).unwrap_or_else(|| workspace.to_path_buf());
Some(boundary.join(".bamboo").join("commands"))
}
pub fn candidates(&self, kind: ProjectResourceKind) -> Vec<ProjectResourceCandidate> {
let mut paths = match kind {
ProjectResourceKind::Settings => vec![(
ProjectResourceLayer::Project,
self.project_home.join("settings.json"),
)],
ProjectResourceKind::Memory => {
vec![(ProjectResourceLayer::Project, self.project_memory_root())]
}
ProjectResourceKind::Skills => {
let mut values = vec![(ProjectResourceLayer::Project, self.project_skills_dir())];
if let Some(path) = self.workspace_skills_dir() {
values.push((ProjectResourceLayer::Workspace, path));
}
values
}
ProjectResourceKind::Commands => {
let mut values = vec![(ProjectResourceLayer::Project, self.project_commands_dir())];
if let Some(path) = self.workspace_commands_dir() {
values.push((ProjectResourceLayer::Workspace, path));
}
values
}
ProjectResourceKind::Artifacts => vec![(
ProjectResourceLayer::Project,
self.project_home.join("artifacts"),
)],
ProjectResourceKind::State => vec![(
ProjectResourceLayer::Project,
self.project_home.join("state"),
)],
};
paths
.drain(..)
.map(|(layer, path)| ProjectResourceCandidate {
kind,
layer,
exists: path.exists(),
path,
})
.collect()
}
pub fn diagnostic(&self) -> ProjectResourceDiagnostic {
let mut candidates = Vec::new();
for kind in [
ProjectResourceKind::Settings,
ProjectResourceKind::Memory,
ProjectResourceKind::Skills,
ProjectResourceKind::Commands,
ProjectResourceKind::Artifacts,
ProjectResourceKind::State,
] {
candidates.extend(self.candidates(kind));
}
ProjectResourceDiagnostic {
project_id: self.project_id.clone(),
resource_revision: self.resource_revision,
workspace_binding_status: self.binding_status,
candidates,
}
}
}
fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|candidate| {
let git = candidate.join(".git");
git.is_dir() || git.is_file()
})
.map(Path::to_path_buf)
}
#[cfg(test)]
mod tests {
use super::*;
struct StaticSource(ProjectDescriptor);
#[async_trait]
impl ProjectContextSource for StaticSource {
async fn find_project(
&self,
project_id: &ProjectId,
) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
Ok((&self.0.id == project_id).then(|| self.0.clone()))
}
}
struct OwnedWorkspaceSource {
descriptor: ProjectDescriptor,
owner: ProjectId,
}
#[async_trait]
impl ProjectContextSource for OwnedWorkspaceSource {
async fn find_project(
&self,
project_id: &ProjectId,
) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
Ok((&self.descriptor.id == project_id).then(|| self.descriptor.clone()))
}
async fn find_workspace_owner(
&self,
_workspace: &Path,
) -> Result<Option<ProjectId>, ProjectContextError> {
Ok(Some(self.owner.clone()))
}
}
#[tokio::test]
async fn workspace_changes_do_not_change_project_identity_or_home() {
let directory = tempfile::tempdir().expect("tempdir");
let main = directory.path().join("main");
let worktree = directory.path().join("worktree");
std::fs::create_dir_all(&main).expect("main");
std::fs::create_dir_all(&worktree).expect("worktree");
let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
let descriptor = ProjectDescriptor {
id: project_id.clone(),
name: "Zenith".to_string(),
home: directory
.path()
.join("projects/01JPROJECT00000000000000000"),
workspace_bindings: vec![
WorkspaceBinding {
path: main.to_string_lossy().to_string(),
label: Some("main".to_string()),
git_common_dir: None,
},
WorkspaceBinding {
path: worktree.to_string_lossy().to_string(),
label: Some("worktree".to_string()),
git_common_dir: None,
},
],
resources: bamboo_domain::ProjectResourceSummary {
project_id: project_id.clone(),
resource_revision: 7,
resources: Vec::new(),
},
memory_read_roots: ProjectMemoryReadRoots {
primary: directory
.path()
.join("projects/01JPROJECT00000000000000000/memory/v1"),
legacy_aliases: Vec::new(),
},
};
let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
let mut session = Session::new("session-1", "test");
session.set_project_id_meta(project_id.to_string());
let first = resolver
.resolve(&session, Some(&main))
.await
.expect("resolve")
.expect("assigned");
let second = resolver
.resolve(&session, Some(&worktree))
.await
.expect("resolve")
.expect("assigned");
assert_eq!(first.project.id, second.project.id);
assert_eq!(first.project.home, second.project.home);
assert_eq!(second.binding_status, WorkspaceBindingStatus::Registered);
}
#[tokio::test]
async fn prompt_refresh_removes_stale_project_and_updates_unassigned_workspace() {
let directory = tempfile::tempdir().expect("tempdir");
let workspace = directory.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace");
let project_id = ProjectId::parse("project-prompt-refresh").expect("Project id");
let descriptor = ProjectDescriptor {
id: project_id.clone(),
name: "Prompt Project".to_string(),
home: directory.path().join("projects/project-prompt-refresh"),
workspace_bindings: vec![WorkspaceBinding {
path: workspace.to_string_lossy().into_owned(),
label: None,
git_common_dir: None,
}],
resources: bamboo_domain::ProjectResourceSummary {
project_id: project_id.clone(),
resource_revision: 1,
resources: Vec::new(),
},
memory_read_roots: ProjectMemoryReadRoots {
primary: directory
.path()
.join("projects/project-prompt-refresh/memory/v1"),
legacy_aliases: Vec::new(),
},
};
let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
let mut session = Session::new("prompt-project-switch", "test");
session
.messages
.insert(0, bamboo_agent_core::Message::system("base"));
session.set_project_id_meta(project_id.to_string());
session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
resolver
.refresh_session_prompt(&mut session)
.await
.expect("assigned refresh");
let assigned = &session.messages[0].content;
assert_eq!(assigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(), 1);
assert!(assigned.contains("Binding status: registered"));
session.clear_project_id_meta();
resolver
.refresh_session_prompt(&mut session)
.await
.expect("unassigned refresh");
let unassigned = &session.messages[0].content;
assert_eq!(
unassigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(),
0
);
assert_eq!(
unassigned.matches("BAMBOO_WORKSPACE_CONTEXT_START").count(),
1
);
assert!(unassigned.contains("Binding status: unregistered"));
assert!(!session
.metadata
.contains_key(PROJECT_RESOURCES_RENDERED_KEY));
}
#[tokio::test]
async fn workspace_owned_by_another_project_fails_closed() {
let directory = tempfile::tempdir().expect("tempdir");
let workspace = directory.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace");
let project_id = ProjectId::parse("project-a").expect("project id");
let owner = ProjectId::parse("project-b").expect("owner id");
let descriptor = ProjectDescriptor {
id: project_id.clone(),
name: "Project A".to_string(),
home: directory.path().join("projects/project-a"),
workspace_bindings: vec![WorkspaceBinding {
path: workspace.to_string_lossy().into_owned(),
label: None,
git_common_dir: None,
}],
resources: bamboo_domain::ProjectResourceSummary {
project_id: project_id.clone(),
resource_revision: 1,
resources: Vec::new(),
},
memory_read_roots: ProjectMemoryReadRoots {
primary: directory.path().join("projects/project-a/memory/v1"),
legacy_aliases: Vec::new(),
},
};
let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
descriptor,
owner: owner.clone(),
}));
let mut session = Session::new("session-conflict", "test");
session.set_project_id_meta(project_id.to_string());
let error = resolver
.resolve(&session, Some(&workspace))
.await
.expect_err("cross-Project workspace must fail closed");
assert!(matches!(
error,
ProjectContextError::WorkspaceConflict {
owner_project_id,
session_project_id,
..
} if owner_project_id == owner && session_project_id == project_id
));
let safe_workspace = directory.path().join("safe");
std::fs::create_dir_all(&safe_workspace).expect("safe workspace");
let safe_canonical = safe_workspace.canonicalize().expect("canonical safe");
bamboo_tools::tools::workspace_state::set_workspace(&session.id, safe_canonical.clone());
session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
let error = resolver
.refresh_session_prompt(&mut session)
.await
.expect_err("refresh must validate before publishing workspace");
assert!(matches!(
error,
ProjectContextError::WorkspaceConflict { .. }
));
assert_eq!(
bamboo_tools::tools::workspace_state::get_workspace(&session.id).as_deref(),
Some(safe_canonical.as_path()),
"a post-preflight ownership change must not publish the rejected workspace"
);
}
#[tokio::test]
async fn malformed_project_identity_never_falls_back_to_legacy_workspace_scope() {
let directory = tempfile::tempdir().expect("tempdir");
let workspace = directory.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace");
let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
let descriptor = ProjectDescriptor {
id: descriptor_id.clone(),
name: "Descriptor".to_string(),
home: directory.path().join("projects/descriptor"),
workspace_bindings: Vec::new(),
resources: bamboo_domain::ProjectResourceSummary {
project_id: descriptor_id.clone(),
resource_revision: 1,
resources: Vec::new(),
},
memory_read_roots: ProjectMemoryReadRoots {
primary: directory.path().join("projects/descriptor/memory/v1"),
legacy_aliases: Vec::new(),
},
};
let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
let mut session = Session::new("malformed", "test");
session.set_project_id_meta("../malformed");
session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
assert!(ProjectContextResolver::memory_read_identity_for_session(&session).is_none());
assert!(matches!(
resolver.resolve(&session, Some(&workspace)).await,
Err(ProjectContextError::InvalidProjectIdentity { .. })
));
assert!(matches!(
resolver
.resolve_memory_read_scope(&session, Some(&workspace))
.await,
Err(ProjectContextError::InvalidProjectIdentity { .. })
));
}
#[tokio::test]
async fn unassigned_session_cannot_resolve_a_project_owned_workspace() {
let directory = tempfile::tempdir().expect("tempdir");
let workspace = directory.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace");
let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
let owner = ProjectId::parse("owner").expect("owner Project id");
let descriptor = ProjectDescriptor {
id: descriptor_id.clone(),
name: "Descriptor".to_string(),
home: directory.path().join("projects/descriptor"),
workspace_bindings: Vec::new(),
resources: bamboo_domain::ProjectResourceSummary {
project_id: descriptor_id,
resource_revision: 1,
resources: Vec::new(),
},
memory_read_roots: ProjectMemoryReadRoots {
primary: directory.path().join("projects/descriptor/memory/v1"),
legacy_aliases: Vec::new(),
},
};
let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
descriptor,
owner: owner.clone(),
}));
let session = Session::new("unassigned", "test");
assert!(matches!(
resolver.resolve(&session, Some(&workspace)).await,
Err(ProjectContextError::UnassignedWorkspaceConflict {
owner_project_id,
..
}) if owner_project_id == owner
));
}
#[test]
fn project_identity_parser_trims_like_the_storage_index() {
let mut session = Session::new("whitespace", "test");
session.set_project_id_meta(" project-1 ");
assert_eq!(
ProjectContextResolver::session_project_identity(&session),
SessionProjectIdentity::Assigned(ProjectId::parse("project-1").unwrap())
);
}
#[test]
fn resource_diagnostic_distinguishes_project_and_workspace_layers() {
let directory = tempfile::tempdir().expect("tempdir");
let project_home = directory.path().join("project-home");
let workspace = directory.path().join("workspace");
std::fs::create_dir_all(project_home.join("skills")).expect("project skills");
std::fs::create_dir_all(workspace.join(".bamboo/skills")).expect("workspace skills");
let scope = ProjectResourceScope {
project_id: ProjectId::parse("project-1").expect("project id"),
project_home,
workspace: Some(workspace),
binding_status: WorkspaceBindingStatus::Registered,
resource_revision: 4,
};
let candidates = scope.candidates(ProjectResourceKind::Skills);
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].layer, ProjectResourceLayer::Project);
assert_eq!(candidates[1].layer, ProjectResourceLayer::Workspace);
assert!(candidates.iter().all(|candidate| candidate.exists));
}
#[test]
fn workspace_commands_resolve_from_nearest_git_boundary() {
let directory = tempfile::tempdir().expect("tempdir");
let repository = directory.path().join("repo");
let nested = repository.join("nested/path");
std::fs::create_dir_all(repository.join(".git")).expect("git");
std::fs::create_dir_all(&nested).expect("nested");
let scope = ProjectResourceScope {
project_id: ProjectId::parse("project-1").expect("project id"),
project_home: directory.path().join("project-home"),
workspace: Some(nested),
binding_status: WorkspaceBindingStatus::Registered,
resource_revision: 1,
};
assert_eq!(
scope.workspace_commands_dir(),
Some(repository.join(".bamboo/commands"))
);
}
#[test]
fn unassigned_legacy_scope_is_read_only() {
let mut session = Session::new("legacy-session", "legacy");
session.set_workspace_path_meta("/tmp/legacy-workspace");
assert!(ProjectContextResolver::memory_read_scope_for_session(&session).is_some());
assert!(ProjectContextResolver::memory_write_scope_for_session(&session).is_none());
}
}