1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use bamboo_agent_core::Session;
13use bamboo_domain::{ProjectId, ProjectResourceKind, ProjectResourceSummary, WorkspaceBinding};
14use serde::{Deserialize, Serialize};
15
16pub const PROJECT_ID_METADATA_KEY: &str = "project_id";
17pub const PROJECT_CONTEXT_RENDERED_KEY: &str = "project_context_rendered";
18pub const PROJECT_RESOURCES_RENDERED_KEY: &str = "project_resources_rendered";
19pub const WORKSPACE_BINDING_STATUS_METADATA_KEY: &str = "workspace_binding_status";
20pub const WORKSPACE_SOURCE_METADATA_KEY: &str = "workspace_source";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum WorkspaceBindingStatus {
25 Registered,
26 Unregistered,
27}
28
29impl WorkspaceBindingStatus {
30 pub const fn as_str(self) -> &'static str {
31 match self {
32 Self::Registered => "registered",
33 Self::Unregistered => "unregistered",
34 }
35 }
36
37 pub fn from_metadata(value: Option<&str>) -> Self {
38 match value {
39 Some("registered") => Self::Registered,
40 _ => Self::Unregistered,
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ProjectDescriptor {
47 pub id: ProjectId,
48 pub name: String,
49 pub project_path: Option<PathBuf>,
51 pub home: PathBuf,
53 pub workspace_bindings: Vec<WorkspaceBinding>,
54 pub resources: ProjectResourceSummary,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ResolvedProjectContext {
59 pub project: ProjectDescriptor,
60 pub workspace: Option<PathBuf>,
61 pub workspace_source: WorkspaceSource,
62 pub binding_status: WorkspaceBindingStatus,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum WorkspaceSource {
68 Explicit,
69 Session,
70 ProjectDefault,
71}
72
73impl WorkspaceSource {
74 pub const fn as_str(self) -> &'static str {
75 match self {
76 Self::Explicit => "explicit",
77 Self::Session => "session",
78 Self::ProjectDefault => "project_default",
79 }
80 }
81
82 pub fn from_metadata(value: Option<&str>) -> Self {
83 match value {
84 Some("explicit") => Self::Explicit,
85 Some("project_default") => Self::ProjectDefault,
86 _ => Self::Session,
87 }
88 }
89}
90
91impl ResolvedProjectContext {
92 pub fn resource_scope(&self) -> ProjectResourceScope {
93 ProjectResourceScope {
94 project_id: self.project.id.clone(),
95 project_home: self.project.home.clone(),
96 workspace: self.workspace.clone(),
97 binding_status: self.binding_status,
98 resource_revision: self.project.resources.resource_revision,
99 }
100 }
101
102 pub fn render_resource_inventory(&self) -> String {
103 let mut entries = self.project.resources.resources.clone();
104 entries.sort_by_key(|entry| entry.kind);
105 let rendered = entries
106 .into_iter()
107 .map(|entry| {
108 format!(
109 "- {:?}: status={}, items={}",
110 entry.kind,
111 if entry.present { "available" } else { "empty" },
112 entry.item_count
113 )
114 })
115 .collect::<Vec<_>>()
116 .join("\n");
117 format!(
118 "Project ID: {}\nResource revision: {}\n{}",
119 self.project.id,
120 self.project.resources.resource_revision,
121 if rendered.is_empty() {
122 "No Project-shared resources are currently advertised.".to_string()
123 } else {
124 rendered
125 }
126 )
127 }
128}
129
130#[derive(Debug, thiserror::Error)]
131pub enum ProjectContextError {
132 #[error("project context source failed: {0}")]
133 Source(String),
134 #[error("project context source returned '{actual}' for requested project '{requested}'")]
135 IdentityMismatch { requested: String, actual: String },
136 #[error(
137 "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
138 )]
139 WorkspaceConflict {
140 workspace: String,
141 owner_project_id: ProjectId,
142 session_project_id: ProjectId,
143 },
144 #[error("workspace '{workspace}' belongs to Project '{owner_project_id}', but the session is Unassigned")]
145 UnassignedWorkspaceConflict {
146 workspace: String,
147 owner_project_id: ProjectId,
148 },
149 #[error("session carries an invalid Project identity '{raw}': {message}")]
150 InvalidProjectIdentity { raw: String, message: String },
151 #[error("assigned Project '{project_id}' is unavailable")]
152 ProjectUnavailable { project_id: ProjectId },
153 #[error("assigned Project '{project_id}' has no configured project_path")]
154 ProjectPathMissing { project_id: ProjectId },
155 #[error("assigned Project '{project_id}' path '{project_path}' is unavailable: {message}")]
156 ProjectPathUnavailable {
157 project_id: ProjectId,
158 project_path: String,
159 message: String,
160 },
161 #[error("workspace '{workspace}' is invalid: {message}")]
162 WorkspaceInvalid { workspace: String, message: String },
163}
164
165#[async_trait]
171pub trait ProjectContextSource: Send + Sync {
172 async fn find_project(
173 &self,
174 project_id: &ProjectId,
175 ) -> Result<Option<ProjectDescriptor>, ProjectContextError>;
176
177 async fn list_projects(&self) -> Result<Vec<ProjectDescriptor>, ProjectContextError> {
178 Ok(Vec::new())
179 }
180
181 async fn find_workspace_owner(
184 &self,
185 _workspace: &Path,
186 ) -> Result<Option<ProjectId>, ProjectContextError> {
187 Ok(None)
188 }
189}
190
191#[derive(Clone)]
192pub struct ProjectContextResolver {
193 source: Arc<dyn ProjectContextSource>,
194 workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum SessionProjectIdentity {
199 Unassigned,
200 Assigned(ProjectId),
201 Invalid { raw: String, message: String },
202}
203
204impl ProjectContextResolver {
205 pub fn new(source: Arc<dyn ProjectContextSource>) -> Self {
206 Self {
207 source,
208 workspace_resolver:
209 bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
210 }
211 }
212
213 pub fn new_with_workspace_resolver(
219 source: Arc<dyn ProjectContextSource>,
220 workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
221 ) -> Self {
222 Self {
223 source,
224 workspace_resolver,
225 }
226 }
227
228 pub fn project_id_from_session(session: &Session) -> Option<ProjectId> {
234 match Self::session_project_identity(session) {
235 SessionProjectIdentity::Assigned(project_id) => Some(project_id),
236 SessionProjectIdentity::Invalid { raw, message } => {
237 tracing::warn!(
238 session_id = %session.id,
239 "ignoring invalid persisted Project id '{raw}': {message}"
240 );
241 None
242 }
243 SessionProjectIdentity::Unassigned => None,
244 }
245 }
246
247 pub fn session_project_identity(session: &Session) -> SessionProjectIdentity {
252 let Some(raw) = session.project_id_meta() else {
253 return SessionProjectIdentity::Unassigned;
254 };
255 let normalized = raw.trim();
256 match ProjectId::parse(normalized) {
257 Ok(project_id) => SessionProjectIdentity::Assigned(project_id),
258 Err(error) => SessionProjectIdentity::Invalid {
259 raw,
260 message: error.to_string(),
261 },
262 }
263 }
264
265 pub fn memory_read_identity_for_session(session: &Session) -> Option<ProjectId> {
266 match Self::session_project_identity(session) {
267 SessionProjectIdentity::Assigned(project_id) => Some(project_id),
268 SessionProjectIdentity::Unassigned | SessionProjectIdentity::Invalid { .. } => None,
269 }
270 }
271
272 pub async fn resolve_memory_read_scope(
273 &self,
274 session: &Session,
275 workspace: Option<&Path>,
276 ) -> Result<Option<ProjectId>, ProjectContextError> {
277 match Self::session_project_identity(session) {
278 SessionProjectIdentity::Unassigned => {
279 return Ok(None);
280 }
281 SessionProjectIdentity::Invalid { raw, message } => {
282 return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
283 }
284 SessionProjectIdentity::Assigned(_) => {}
285 }
286 Ok(self
287 .resolve(session, workspace)
288 .await?
289 .map(|context| context.project.id))
290 }
291
292 pub async fn list_project_ids(&self) -> Result<Vec<ProjectId>, ProjectContextError> {
293 Ok(self
294 .source
295 .list_projects()
296 .await?
297 .into_iter()
298 .map(|project| project.id)
299 .collect())
300 }
301
302 pub async fn resolve(
303 &self,
304 session: &Session,
305 workspace: Option<&Path>,
306 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
307 let project_id = match Self::session_project_identity(session) {
308 SessionProjectIdentity::Assigned(project_id) => project_id,
309 SessionProjectIdentity::Invalid { raw, message } => {
310 return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
311 }
312 SessionProjectIdentity::Unassigned => {
313 let workspace =
314 self.resolve_workspace_candidate_for_instance(session, workspace)?;
315 if let Some(candidate) = workspace.as_deref() {
316 if let Some(owner_project_id) =
317 self.source.find_workspace_owner(candidate).await?
318 {
319 return Err(ProjectContextError::UnassignedWorkspaceConflict {
320 workspace: candidate.to_string_lossy().into_owned(),
321 owner_project_id,
322 });
323 }
324 }
325 return Ok(None);
326 }
327 };
328 let project = self
329 .source
330 .find_project(&project_id)
331 .await?
332 .ok_or_else(|| ProjectContextError::ProjectUnavailable {
333 project_id: project_id.clone(),
334 })?;
335 if project.id != project_id {
336 return Err(ProjectContextError::IdentityMismatch {
337 requested: project_id.to_string(),
338 actual: project.id.to_string(),
339 });
340 }
341
342 let (workspace, workspace_source) = if let Some(workspace) = workspace {
343 (
344 resolve_existing_workspace_with_resolver(workspace, &self.workspace_resolver)?,
345 WorkspaceSource::Explicit,
346 )
347 } else if session
348 .metadata
349 .get(WORKSPACE_SOURCE_METADATA_KEY)
350 .map(String::as_str)
351 == Some(WorkspaceSource::ProjectDefault.as_str())
352 {
353 (
354 resolve_project_default_workspace(&project, &self.workspace_resolver)?,
355 WorkspaceSource::ProjectDefault,
356 )
357 } else if let Some(workspace) = session.workspace_path_meta() {
358 let persisted_source = session
359 .metadata
360 .get(WORKSPACE_SOURCE_METADATA_KEY)
361 .map(String::as_str);
362 let workspace = resolve_existing_workspace_with_resolver(
363 Path::new(&workspace),
364 &self.workspace_resolver,
365 )?;
366 let source = match persisted_source {
367 Some("explicit") => WorkspaceSource::Explicit,
368 Some("session") => WorkspaceSource::Session,
369 _ => WorkspaceSource::Session,
370 };
371 (workspace, source)
372 } else {
373 (
374 resolve_project_default_workspace(&project, &self.workspace_resolver)?,
375 WorkspaceSource::ProjectDefault,
376 )
377 };
378
379 self.resolve_assigned_project(project, workspace, workspace_source)
380 .await
381 .map(Some)
382 }
383
384 pub fn resolve_workspace_candidate(
390 session: &Session,
391 workspace: Option<&Path>,
392 ) -> Result<Option<PathBuf>, ProjectContextError> {
393 Self::resolve_workspace_candidate_with(
394 &bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
395 session,
396 workspace,
397 )
398 }
399
400 fn resolve_workspace_candidate_for_instance(
401 &self,
402 session: &Session,
403 workspace: Option<&Path>,
404 ) -> Result<Option<PathBuf>, ProjectContextError> {
405 Self::resolve_workspace_candidate_with(&self.workspace_resolver, session, workspace)
406 }
407
408 fn resolve_workspace_candidate_with(
409 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
410 session: &Session,
411 workspace: Option<&Path>,
412 ) -> Result<Option<PathBuf>, ProjectContextError> {
413 let preferred = workspace
414 .map(Path::to_path_buf)
415 .or_else(|| session.workspace_path_meta().map(PathBuf::from));
416 if preferred.is_none()
417 && matches!(
418 Self::session_project_identity(session),
419 SessionProjectIdentity::Assigned(_)
420 )
421 {
422 return Ok(None);
427 }
428 workspace_resolver
429 .resolve_session_workspace_candidate(&session.id, preferred)
430 .map(|candidate| resolve_final_workspace_with(&candidate, workspace_resolver))
431 .transpose()
432 }
433
434 async fn resolve_assigned_project(
435 &self,
436 project: ProjectDescriptor,
437 workspace: PathBuf,
438 workspace_source: WorkspaceSource,
439 ) -> Result<ResolvedProjectContext, ProjectContextError> {
440 let binding_status = match self.source.find_workspace_owner(&workspace).await? {
441 Some(owner) if owner == project.id => WorkspaceBindingStatus::Registered,
442 Some(owner) => {
443 return Err(ProjectContextError::WorkspaceConflict {
444 workspace: workspace.to_string_lossy().into_owned(),
445 owner_project_id: owner,
446 session_project_id: project.id.clone(),
447 });
448 }
449 None if project
450 .project_path
451 .iter()
452 .map(PathBuf::as_path)
453 .chain(
454 project
455 .workspace_bindings
456 .iter()
457 .map(|binding| Path::new(&binding.path)),
458 )
459 .any(|root| path_is_within_binding(root, &workspace)) =>
460 {
461 WorkspaceBindingStatus::Registered
462 }
463 None => WorkspaceBindingStatus::Unregistered,
464 };
465
466 Ok(ResolvedProjectContext {
467 project,
468 workspace: Some(workspace),
469 workspace_source,
470 binding_status,
471 })
472 }
473
474 pub async fn workspace_owner(
475 &self,
476 workspace: &Path,
477 ) -> Result<Option<ProjectId>, ProjectContextError> {
478 self.source.find_workspace_owner(workspace).await
479 }
480
481 pub async fn refresh_session_prompt(
491 &self,
492 session: &mut Session,
493 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
494 self.refresh_session_prompt_inner(session, true).await
495 }
496
497 pub async fn refresh_session_prompt_read_only(
505 &self,
506 session: &mut Session,
507 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
508 self.refresh_session_prompt_inner(session, false).await
509 }
510
511 async fn refresh_session_prompt_inner(
512 &self,
513 session: &mut Session,
514 sync_runtime_workspace: bool,
515 ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
516 crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
519 let resolved = self.resolve(session, None).await?;
520 let workspace = if let Some(context) = resolved.as_ref() {
521 context.workspace.clone()
522 } else {
523 self.resolve_workspace_candidate_for_instance(session, None)?
524 };
525 if let Some(workspace) = workspace.as_deref() {
526 let final_workspace = if sync_runtime_workspace {
527 self.workspace_resolver.publish_resolved_workspace(
528 &session.id,
529 workspace.into(),
530 "project_context_refresh",
531 )
532 } else {
533 workspace.to_path_buf()
534 };
535 session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
536 &final_workspace,
537 ));
538 }
539 if let Some(context) = resolved.as_ref() {
540 session.metadata.insert(
541 WORKSPACE_SOURCE_METADATA_KEY.to_string(),
542 context.workspace_source.as_str().to_string(),
543 );
544 session.metadata.insert(
545 WORKSPACE_BINDING_STATUS_METADATA_KEY.to_string(),
546 context.binding_status.as_str().to_string(),
547 );
548 } else if workspace.is_some() {
549 session
550 .metadata
551 .entry(WORKSPACE_SOURCE_METADATA_KEY.to_string())
552 .or_insert_with(|| WorkspaceSource::Session.as_str().to_string());
553 session.metadata.insert(
554 WORKSPACE_BINDING_STATUS_METADATA_KEY.to_string(),
555 WorkspaceBindingStatus::Unregistered.as_str().to_string(),
556 );
557 } else {
558 session.metadata.remove(WORKSPACE_SOURCE_METADATA_KEY);
559 session
560 .metadata
561 .remove(WORKSPACE_BINDING_STATUS_METADATA_KEY);
562 }
563
564 if let Some(context) = resolved.as_ref() {
565 session.metadata.insert(
566 PROJECT_CONTEXT_RENDERED_KEY.to_string(),
567 crate::runtime::context::build_project_model_context(context),
568 );
569 session.metadata.insert(
570 PROJECT_RESOURCES_RENDERED_KEY.to_string(),
571 context.render_resource_inventory(),
572 );
573 } else {
574 session.metadata.remove(PROJECT_CONTEXT_RENDERED_KEY);
575 session.metadata.remove(PROJECT_RESOURCES_RENDERED_KEY);
576 }
577 crate::runner::refresh_prompt_snapshot(session);
578
579 Ok(resolved)
580 }
581}
582
583fn resolve_final_workspace_with(
584 workspace: &Path,
585 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
586) -> Result<PathBuf, ProjectContextError> {
587 if workspace.exists() && !workspace.is_dir() {
588 return Err(ProjectContextError::WorkspaceInvalid {
589 workspace: workspace.to_string_lossy().into_owned(),
590 message: "path is not a directory".to_string(),
591 });
592 }
593 let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
594 let final_workspace = workspace_resolver.preview_workspace_path(canonical);
595 if final_workspace.exists() && !final_workspace.is_dir() {
596 return Err(ProjectContextError::WorkspaceInvalid {
597 workspace: final_workspace.to_string_lossy().into_owned(),
598 message: "resolved path is not a directory".to_string(),
599 });
600 }
601 Ok(std::fs::canonicalize(&final_workspace).unwrap_or(final_workspace))
602}
603
604pub fn resolve_existing_workspace_with_resolver(
610 workspace: &Path,
611 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
612) -> Result<PathBuf, ProjectContextError> {
613 if !workspace.exists() {
614 return Err(ProjectContextError::WorkspaceInvalid {
615 workspace: workspace.to_string_lossy().into_owned(),
616 message: "path does not exist".to_string(),
617 });
618 }
619 resolve_final_workspace_with(workspace, workspace_resolver)
620}
621
622fn resolve_project_default_workspace(
623 project: &ProjectDescriptor,
624 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
625) -> Result<PathBuf, ProjectContextError> {
626 let project_path =
627 project
628 .project_path
629 .as_deref()
630 .ok_or_else(|| ProjectContextError::ProjectPathMissing {
631 project_id: project.id.clone(),
632 })?;
633 let display = project_path.to_string_lossy().into_owned();
634 if !project_path.exists() {
635 return Err(ProjectContextError::ProjectPathUnavailable {
636 project_id: project.id.clone(),
637 project_path: display,
638 message: "path does not exist".to_string(),
639 });
640 }
641 let metadata = std::fs::symlink_metadata(project_path).map_err(|error| {
642 ProjectContextError::ProjectPathUnavailable {
643 project_id: project.id.clone(),
644 project_path: display.clone(),
645 message: format!("path metadata is unavailable: {error}"),
646 }
647 })?;
648 if metadata.file_type().is_symlink() || !metadata.is_dir() {
649 return Err(ProjectContextError::ProjectPathUnavailable {
650 project_id: project.id.clone(),
651 project_path: display,
652 message: "configured path is no longer a plain directory".to_string(),
653 });
654 }
655 let canonical = std::fs::canonicalize(project_path).map_err(|error| {
656 ProjectContextError::ProjectPathUnavailable {
657 project_id: project.id.clone(),
658 project_path: display.clone(),
659 message: format!("path could not be canonicalized: {error}"),
660 }
661 })?;
662 if canonical != project_path {
663 return Err(ProjectContextError::ProjectPathUnavailable {
664 project_id: project.id.clone(),
665 project_path: display,
666 message: "configured path no longer resolves to its registered canonical directory"
667 .to_string(),
668 });
669 }
670 let final_workspace = workspace_resolver.preview_workspace_path(canonical.clone());
671 let final_workspace = std::fs::canonicalize(&final_workspace).map_err(|error| {
672 ProjectContextError::ProjectPathUnavailable {
673 project_id: project.id.clone(),
674 project_path: display.clone(),
675 message: format!("confinement target is unavailable: {error}"),
676 }
677 })?;
678 if final_workspace != canonical {
679 return Err(ProjectContextError::ProjectPathUnavailable {
680 project_id: project.id.clone(),
681 project_path: display,
682 message: format!(
683 "workspace confinement redirected the Project path to '{}'",
684 final_workspace.display()
685 ),
686 });
687 }
688 Ok(canonical)
689}
690
691fn path_is_within_binding(binding: &Path, candidate: &Path) -> bool {
692 let Ok(metadata) = std::fs::symlink_metadata(binding) else {
693 return false;
694 };
695 if metadata.file_type().is_symlink() || !metadata.is_dir() {
696 return false;
697 }
698 let Ok(canonical_binding) = std::fs::canonicalize(binding) else {
699 return false;
700 };
701 if canonical_binding != binding {
702 return false;
703 }
704 candidate == binding || candidate.starts_with(binding)
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
708#[serde(rename_all = "snake_case")]
709pub enum ProjectResourceLayer {
710 Project,
711 Workspace,
712}
713
714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
715pub struct ProjectResourceCandidate {
716 pub kind: ProjectResourceKind,
717 pub layer: ProjectResourceLayer,
718 pub path: PathBuf,
719 pub exists: bool,
720}
721
722#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
723pub struct ProjectResourceDiagnostic {
724 pub project_id: ProjectId,
725 pub resource_revision: u64,
726 pub workspace_binding_status: WorkspaceBindingStatus,
727 pub candidates: Vec<ProjectResourceCandidate>,
728}
729
730#[derive(Debug, Clone, PartialEq, Eq)]
732pub struct ProjectResourceScope {
733 pub project_id: ProjectId,
734 pub project_home: PathBuf,
735 pub workspace: Option<PathBuf>,
736 pub binding_status: WorkspaceBindingStatus,
737 pub resource_revision: u64,
738}
739
740impl ProjectResourceScope {
741 pub fn project_memory_root(&self) -> PathBuf {
742 self.project_home.join("memory").join("v1")
743 }
744
745 pub fn project_skills_dir(&self) -> PathBuf {
746 self.project_home.join("skills")
747 }
748
749 pub fn project_mode_skills_dir(&self, mode: &str) -> PathBuf {
750 self.project_home.join(format!("skills-{mode}"))
751 }
752
753 pub fn workspace_skills_dir(&self) -> Option<PathBuf> {
754 self.workspace
755 .as_ref()
756 .map(|workspace| workspace.join(".bamboo").join("skills"))
757 }
758
759 pub fn workspace_mode_skills_dir(&self, mode: &str) -> Option<PathBuf> {
760 self.workspace
761 .as_ref()
762 .map(|workspace| workspace.join(".bamboo").join(format!("skills-{mode}")))
763 }
764
765 pub fn project_commands_dir(&self) -> PathBuf {
766 self.project_home.join("commands")
767 }
768
769 pub fn workspace_commands_dir(&self) -> Option<PathBuf> {
770 let workspace = self.workspace.as_deref()?;
771 let boundary = nearest_git_boundary(workspace).unwrap_or_else(|| workspace.to_path_buf());
772 Some(boundary.join(".bamboo").join("commands"))
773 }
774
775 pub fn candidates(&self, kind: ProjectResourceKind) -> Vec<ProjectResourceCandidate> {
779 let mut paths = match kind {
780 ProjectResourceKind::Settings => vec![(
781 ProjectResourceLayer::Project,
782 self.project_home.join("settings.json"),
783 )],
784 ProjectResourceKind::Memory => {
785 vec![(ProjectResourceLayer::Project, self.project_memory_root())]
786 }
787 ProjectResourceKind::Skills => {
788 let mut values = vec![(ProjectResourceLayer::Project, self.project_skills_dir())];
789 if let Some(path) = self.workspace_skills_dir() {
790 values.push((ProjectResourceLayer::Workspace, path));
791 }
792 values
793 }
794 ProjectResourceKind::Commands => {
795 let mut values = vec![(ProjectResourceLayer::Project, self.project_commands_dir())];
796 if let Some(path) = self.workspace_commands_dir() {
797 values.push((ProjectResourceLayer::Workspace, path));
798 }
799 values
800 }
801 ProjectResourceKind::Artifacts => vec![(
802 ProjectResourceLayer::Project,
803 self.project_home.join("artifacts"),
804 )],
805 ProjectResourceKind::State => vec![(
806 ProjectResourceLayer::Project,
807 self.project_home.join("state"),
808 )],
809 };
810
811 paths
812 .drain(..)
813 .map(|(layer, path)| ProjectResourceCandidate {
814 kind,
815 layer,
816 exists: path.exists(),
817 path,
818 })
819 .collect()
820 }
821
822 pub fn diagnostic(&self) -> ProjectResourceDiagnostic {
823 let mut candidates = Vec::new();
824 for kind in [
825 ProjectResourceKind::Settings,
826 ProjectResourceKind::Memory,
827 ProjectResourceKind::Skills,
828 ProjectResourceKind::Commands,
829 ProjectResourceKind::Artifacts,
830 ProjectResourceKind::State,
831 ] {
832 candidates.extend(self.candidates(kind));
833 }
834 ProjectResourceDiagnostic {
835 project_id: self.project_id.clone(),
836 resource_revision: self.resource_revision,
837 workspace_binding_status: self.binding_status,
838 candidates,
839 }
840 }
841}
842
843fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
844 start
845 .ancestors()
846 .find(|candidate| {
847 let git = candidate.join(".git");
848 git.is_dir() || git.is_file()
849 })
850 .map(Path::to_path_buf)
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856
857 struct StaticSource(ProjectDescriptor);
858
859 #[async_trait]
860 impl ProjectContextSource for StaticSource {
861 async fn find_project(
862 &self,
863 project_id: &ProjectId,
864 ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
865 Ok((&self.0.id == project_id).then(|| self.0.clone()))
866 }
867 }
868
869 struct OwnedWorkspaceSource {
870 descriptor: ProjectDescriptor,
871 owner: ProjectId,
872 }
873
874 #[async_trait]
875 impl ProjectContextSource for OwnedWorkspaceSource {
876 async fn find_project(
877 &self,
878 project_id: &ProjectId,
879 ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
880 Ok((&self.descriptor.id == project_id).then(|| self.descriptor.clone()))
881 }
882
883 async fn find_workspace_owner(
884 &self,
885 _workspace: &Path,
886 ) -> Result<Option<ProjectId>, ProjectContextError> {
887 Ok(Some(self.owner.clone()))
888 }
889 }
890
891 #[tokio::test]
892 async fn workspace_changes_do_not_change_project_identity_or_home() {
893 let directory = tempfile::tempdir().expect("tempdir");
894 let main = directory.path().join("main");
895 let worktree = directory.path().join("worktree");
896 std::fs::create_dir_all(&main).expect("main");
897 std::fs::create_dir_all(&worktree).expect("worktree");
898 let main = main.canonicalize().expect("canonical main");
899 let worktree = worktree.canonicalize().expect("canonical worktree");
900 let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
901 let descriptor = ProjectDescriptor {
902 id: project_id.clone(),
903 name: "Zenith".to_string(),
904 project_path: Some(main.clone()),
905 home: directory
906 .path()
907 .join("projects/01JPROJECT00000000000000000"),
908 workspace_bindings: vec![
909 WorkspaceBinding {
910 path: main.to_string_lossy().to_string(),
911 label: Some("main".to_string()),
912 git_common_dir: None,
913 },
914 WorkspaceBinding {
915 path: worktree.to_string_lossy().to_string(),
916 label: Some("worktree".to_string()),
917 git_common_dir: None,
918 },
919 ],
920 resources: bamboo_domain::ProjectResourceSummary {
921 project_id: project_id.clone(),
922 resource_revision: 7,
923 resources: Vec::new(),
924 },
925 };
926 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
927 let mut session = Session::new("session-1", "test");
928 session.set_project_id_meta(project_id.to_string());
929
930 let first = resolver
931 .resolve(&session, Some(&main))
932 .await
933 .expect("resolve")
934 .expect("assigned");
935 let second = resolver
936 .resolve(&session, Some(&worktree))
937 .await
938 .expect("resolve")
939 .expect("assigned");
940 assert_eq!(first.project.id, second.project.id);
941 assert_eq!(first.project.home, second.project.home);
942 assert_eq!(second.binding_status, WorkspaceBindingStatus::Registered);
943 }
944
945 #[tokio::test]
946 async fn assigned_session_uses_project_path_before_global_or_temp_fallback() {
947 let directory = tempfile::tempdir().expect("tempdir");
948 let project_path = directory.path().join("project");
949 let foreign_default = directory.path().join("foreign-default");
950 let workspace_root = directory.path().join("session-workspaces");
951 std::fs::create_dir_all(&project_path).expect("project path");
952 std::fs::create_dir_all(&foreign_default).expect("foreign default");
953 std::fs::create_dir_all(&workspace_root).expect("workspace root");
954 let project_id = ProjectId::parse("project-default").expect("Project id");
955 let descriptor = ProjectDescriptor {
956 id: project_id.clone(),
957 name: "Project Default".to_string(),
958 project_path: Some(project_path.canonicalize().unwrap()),
959 home: directory.path().join("projects/project-default"),
960 workspace_bindings: Vec::new(),
961 resources: bamboo_domain::ProjectResourceSummary {
962 project_id: project_id.clone(),
963 resource_revision: 1,
964 resources: Vec::new(),
965 },
966 };
967 let resolver = ProjectContextResolver::new_with_workspace_resolver(
968 Arc::new(StaticSource(descriptor)),
969 bamboo_agent_core::workspace_state::WorkspaceResolver::new(
970 {
971 let foreign_default = foreign_default.clone();
972 move || Some(foreign_default.clone())
973 },
974 {
975 let workspace_root = workspace_root.clone();
976 move || bamboo_agent_core::workspace_state::WorkspaceRootConfig {
977 root: workspace_root.clone(),
978 confine: false,
979 }
980 },
981 ),
982 );
983 let mut session = Session::new("project-default-session", "test");
984 session.set_project_id_meta(project_id.to_string());
985 session
986 .messages
987 .insert(0, bamboo_agent_core::Message::system("base"));
988
989 let resolved = resolver
990 .refresh_session_prompt_read_only(&mut session)
991 .await
992 .expect("Project default must resolve")
993 .expect("assigned Project");
994 assert_eq!(
995 resolved.workspace.as_deref(),
996 Some(project_path.canonicalize().unwrap().as_path())
997 );
998 assert_eq!(resolved.workspace_source, WorkspaceSource::ProjectDefault);
999 assert_eq!(
1000 session.workspace_path_meta().as_deref(),
1001 Some(
1002 bamboo_config::paths::path_to_display_string(&project_path.canonicalize().unwrap())
1003 .as_str()
1004 )
1005 );
1006 let prompt = &session.messages[0].content;
1007 assert_eq!(prompt, "base");
1008 let project_context = session
1009 .metadata
1010 .get(PROJECT_CONTEXT_RENDERED_KEY)
1011 .expect("redacted Project context");
1012 assert!(project_context.contains("Project ID: project-default"));
1013 assert!(!project_context.contains("Project path:"));
1014 assert!(!project_context.contains("Project home"));
1015 assert_eq!(
1016 session
1017 .metadata
1018 .get(WORKSPACE_SOURCE_METADATA_KEY)
1019 .map(String::as_str),
1020 Some("project_default")
1021 );
1022 assert!(!prompt.contains(foreign_default.to_string_lossy().as_ref()));
1023 assert!(!workspace_root.join(&session.id).exists());
1024
1025 let moved_project_path = directory.path().join("project-moved");
1026 std::fs::create_dir_all(&moved_project_path).expect("moved Project path");
1027 let moved_descriptor = ProjectDescriptor {
1028 id: project_id,
1029 name: "Project Default".to_string(),
1030 project_path: Some(moved_project_path.canonicalize().unwrap()),
1031 home: directory.path().join("projects/project-default"),
1032 workspace_bindings: Vec::new(),
1033 resources: bamboo_domain::ProjectResourceSummary {
1034 project_id: ProjectId::parse("project-default").unwrap(),
1035 resource_revision: 2,
1036 resources: Vec::new(),
1037 },
1038 };
1039 let moved_resolver = ProjectContextResolver::new(Arc::new(StaticSource(moved_descriptor)));
1040 let moved = moved_resolver
1041 .resolve(&session, None)
1042 .await
1043 .expect("moved Project path")
1044 .expect("assigned Project");
1045 assert_eq!(
1046 moved.workspace.as_deref(),
1047 Some(moved_project_path.canonicalize().unwrap().as_path())
1048 );
1049 assert_eq!(moved.workspace_source, WorkspaceSource::ProjectDefault);
1050 }
1051
1052 #[tokio::test]
1053 async fn legacy_persisted_workspace_equal_to_project_path_remains_session_owned_after_path_cas()
1054 {
1055 let directory = tempfile::tempdir().expect("tempdir");
1056 let project_path = directory.path().join("project");
1057 let moved_project_path = directory.path().join("project-moved");
1058 std::fs::create_dir_all(&project_path).expect("Project path");
1059 std::fs::create_dir_all(&moved_project_path).expect("moved Project path");
1060 let project_path = project_path.canonicalize().expect("canonical Project path");
1061 let moved_project_path = moved_project_path
1062 .canonicalize()
1063 .expect("canonical moved Project path");
1064 let project_id = ProjectId::parse("legacy-persisted-workspace").expect("Project id");
1065 let descriptor = ProjectDescriptor {
1066 id: project_id.clone(),
1067 name: "Legacy persisted workspace".to_string(),
1068 project_path: Some(project_path.clone()),
1069 home: directory.path().join("projects/legacy-persisted-workspace"),
1070 workspace_bindings: Vec::new(),
1071 resources: bamboo_domain::ProjectResourceSummary {
1072 project_id: project_id.clone(),
1073 resource_revision: 1,
1074 resources: Vec::new(),
1075 },
1076 };
1077 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1078 let mut session = Session::new("legacy-persisted-workspace", "test");
1079 session.set_project_id_meta(project_id.to_string());
1080 session
1081 .set_workspace_path_meta(bamboo_config::paths::path_to_display_string(&project_path));
1082
1083 let initial = resolver
1084 .refresh_session_prompt_read_only(&mut session)
1085 .await
1086 .expect("legacy persisted workspace must resolve")
1087 .expect("assigned Project");
1088 assert_eq!(initial.workspace.as_deref(), Some(project_path.as_path()));
1089 assert_eq!(initial.workspace_source, WorkspaceSource::Session);
1090 assert_eq!(
1091 session
1092 .metadata
1093 .get(WORKSPACE_SOURCE_METADATA_KEY)
1094 .map(String::as_str),
1095 Some("session")
1096 );
1097
1098 let moved_descriptor = ProjectDescriptor {
1099 id: project_id.clone(),
1100 name: "Legacy persisted workspace".to_string(),
1101 project_path: Some(moved_project_path),
1102 home: directory.path().join("projects/legacy-persisted-workspace"),
1103 workspace_bindings: Vec::new(),
1104 resources: bamboo_domain::ProjectResourceSummary {
1105 project_id,
1106 resource_revision: 2,
1107 resources: Vec::new(),
1108 },
1109 };
1110 let moved_resolver = ProjectContextResolver::new(Arc::new(StaticSource(moved_descriptor)));
1111 let after_cas = moved_resolver
1112 .resolve(&session, None)
1113 .await
1114 .expect("persisted workspace must remain authoritative")
1115 .expect("assigned Project");
1116 assert_eq!(after_cas.workspace.as_deref(), Some(project_path.as_path()));
1117 assert_eq!(after_cas.workspace_source, WorkspaceSource::Session);
1118 }
1119
1120 #[cfg(unix)]
1121 #[tokio::test]
1122 async fn project_default_rejects_configured_path_replaced_by_symlink() {
1123 use std::os::unix::fs::symlink;
1124
1125 let directory = tempfile::tempdir().expect("tempdir");
1126 let project_path = directory.path().join("project");
1127 let replacement = directory.path().join("replacement");
1128 std::fs::create_dir_all(&project_path).expect("Project path");
1129 std::fs::create_dir_all(&replacement).expect("replacement");
1130 let configured_path = project_path.canonicalize().expect("canonical Project path");
1131 let project_id = ProjectId::parse("symlink-replaced-project").expect("Project id");
1132 let descriptor = ProjectDescriptor {
1133 id: project_id.clone(),
1134 name: "Symlink replacement".to_string(),
1135 project_path: Some(configured_path),
1136 home: directory.path().join("projects/symlink-replaced-project"),
1137 workspace_bindings: Vec::new(),
1138 resources: bamboo_domain::ProjectResourceSummary {
1139 project_id: project_id.clone(),
1140 resource_revision: 1,
1141 resources: Vec::new(),
1142 },
1143 };
1144 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1145 let mut session = Session::new("symlink-replaced-project", "test");
1146 session.set_project_id_meta(project_id.to_string());
1147
1148 std::fs::remove_dir(&project_path).expect("remove configured Project path");
1149 symlink(&replacement, &project_path).expect("replace Project path with symlink");
1150
1151 assert!(matches!(
1152 resolver.resolve(&session, None).await,
1153 Err(ProjectContextError::ProjectPathUnavailable { .. })
1154 ));
1155 assert!(session.workspace_path_meta().is_none());
1156 }
1157
1158 #[cfg(unix)]
1159 #[tokio::test]
1160 async fn explicit_workspace_is_not_registered_through_a_replaced_project_path_symlink() {
1161 use std::os::unix::fs::symlink;
1162
1163 let directory = tempfile::tempdir().expect("tempdir");
1164 let project_path = directory.path().join("project");
1165 let replacement = directory.path().join("replacement");
1166 let explicit_workspace = replacement.join("explicit");
1167 std::fs::create_dir_all(&project_path).expect("Project path");
1168 std::fs::create_dir_all(&explicit_workspace).expect("explicit workspace");
1169 let configured_path = project_path.canonicalize().expect("canonical Project path");
1170 let project_id = ProjectId::parse("symlink-explicit-project").expect("Project id");
1171 let descriptor = ProjectDescriptor {
1172 id: project_id.clone(),
1173 name: "Symlink explicit workspace".to_string(),
1174 project_path: Some(configured_path),
1175 home: directory.path().join("projects/symlink-explicit-project"),
1176 workspace_bindings: Vec::new(),
1177 resources: bamboo_domain::ProjectResourceSummary {
1178 project_id: project_id.clone(),
1179 resource_revision: 1,
1180 resources: Vec::new(),
1181 },
1182 };
1183 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1184 let mut session = Session::new("symlink-explicit-project", "test");
1185 session.set_project_id_meta(project_id.to_string());
1186
1187 std::fs::remove_dir(&project_path).expect("remove configured Project path");
1188 symlink(&replacement, &project_path).expect("replace Project path with symlink");
1189
1190 let resolved = resolver
1191 .resolve(&session, Some(&explicit_workspace))
1192 .await
1193 .expect("explicit workspace remains independently resolvable")
1194 .expect("assigned Project");
1195 assert_eq!(
1196 resolved.workspace.as_deref(),
1197 Some(explicit_workspace.canonicalize().unwrap().as_path())
1198 );
1199 assert_eq!(
1200 resolved.binding_status,
1201 WorkspaceBindingStatus::Unregistered
1202 );
1203 assert_eq!(resolved.workspace_source, WorkspaceSource::Explicit);
1204 }
1205
1206 #[tokio::test]
1207 async fn missing_or_unavailable_project_path_fails_closed() {
1208 let directory = tempfile::tempdir().expect("tempdir");
1209 let project_id = ProjectId::parse("unconfigured-project").expect("Project id");
1210 let descriptor = ProjectDescriptor {
1211 id: project_id.clone(),
1212 name: "Unconfigured".to_string(),
1213 project_path: None,
1214 home: directory.path().join("projects/unconfigured-project"),
1215 workspace_bindings: Vec::new(),
1216 resources: bamboo_domain::ProjectResourceSummary {
1217 project_id: project_id.clone(),
1218 resource_revision: 1,
1219 resources: Vec::new(),
1220 },
1221 };
1222 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1223 let mut session = Session::new("unconfigured", "test");
1224 session.set_project_id_meta(project_id.to_string());
1225 assert!(matches!(
1226 resolver.resolve(&session, None).await,
1227 Err(ProjectContextError::ProjectPathMissing { .. })
1228 ));
1229
1230 let missing = directory.path().join("moved-away");
1231 let descriptor = ProjectDescriptor {
1232 id: project_id.clone(),
1233 name: "Unavailable".to_string(),
1234 project_path: Some(missing.clone()),
1235 home: directory.path().join("projects/unconfigured-project"),
1236 workspace_bindings: Vec::new(),
1237 resources: bamboo_domain::ProjectResourceSummary {
1238 project_id: project_id.clone(),
1239 resource_revision: 1,
1240 resources: Vec::new(),
1241 },
1242 };
1243 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1244 assert!(matches!(
1245 resolver.resolve(&session, None).await,
1246 Err(ProjectContextError::ProjectPathUnavailable {
1247 project_path,
1248 ..
1249 }) if project_path == missing.to_string_lossy()
1250 ));
1251 assert!(session.workspace_path_meta().is_none());
1252 }
1253
1254 #[tokio::test]
1255 async fn metadata_refresh_updates_project_and_unassigned_workspace_without_system_mutation() {
1256 let directory = tempfile::tempdir().expect("tempdir");
1257 let workspace = directory.path().join("workspace");
1258 std::fs::create_dir_all(&workspace).expect("workspace");
1259 let workspace = workspace.canonicalize().expect("canonical workspace");
1260 let project_id = ProjectId::parse("project-prompt-refresh").expect("Project id");
1261 let descriptor = ProjectDescriptor {
1262 id: project_id.clone(),
1263 name: "Prompt Project".to_string(),
1264 project_path: Some(workspace.clone()),
1265 home: directory.path().join("projects/project-prompt-refresh"),
1266 workspace_bindings: vec![WorkspaceBinding {
1267 path: workspace.to_string_lossy().into_owned(),
1268 label: None,
1269 git_common_dir: None,
1270 }],
1271 resources: bamboo_domain::ProjectResourceSummary {
1272 project_id: project_id.clone(),
1273 resource_revision: 1,
1274 resources: Vec::new(),
1275 },
1276 };
1277 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1278 let mut session = Session::new("prompt-project-switch", "test");
1279 session
1280 .messages
1281 .insert(0, bamboo_agent_core::Message::system("base"));
1282 session.set_project_id_meta(project_id.to_string());
1283 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1284
1285 resolver
1286 .refresh_session_prompt(&mut session)
1287 .await
1288 .expect("assigned refresh");
1289 let assigned = &session.messages[0].content;
1290 assert_eq!(assigned, "base");
1291 assert!(session
1292 .metadata
1293 .get(PROJECT_CONTEXT_RENDERED_KEY)
1294 .is_some_and(|context| context.contains("Project ID: project-prompt-refresh")));
1295 assert_eq!(
1296 session
1297 .metadata
1298 .get(WORKSPACE_BINDING_STATUS_METADATA_KEY)
1299 .map(String::as_str),
1300 Some("registered")
1301 );
1302
1303 session.clear_project_id_meta();
1304 resolver
1305 .refresh_session_prompt(&mut session)
1306 .await
1307 .expect("unassigned refresh");
1308 let unassigned = &session.messages[0].content;
1309 assert_eq!(unassigned, "base");
1310 assert!(!session.metadata.contains_key(PROJECT_CONTEXT_RENDERED_KEY));
1311 assert_eq!(
1312 session
1313 .metadata
1314 .get(WORKSPACE_BINDING_STATUS_METADATA_KEY)
1315 .map(String::as_str),
1316 Some("unregistered")
1317 );
1318 assert!(!session
1319 .metadata
1320 .contains_key(PROJECT_RESOURCES_RENDERED_KEY));
1321 }
1322
1323 #[tokio::test]
1324 async fn workspace_owned_by_another_project_fails_closed() {
1325 let directory = tempfile::tempdir().expect("tempdir");
1326 let workspace = directory.path().join("workspace");
1327 std::fs::create_dir_all(&workspace).expect("workspace");
1328 let project_id = ProjectId::parse("project-a").expect("project id");
1329 let owner = ProjectId::parse("project-b").expect("owner id");
1330 let descriptor = ProjectDescriptor {
1331 id: project_id.clone(),
1332 name: "Project A".to_string(),
1333 project_path: Some(workspace.clone()),
1334 home: directory.path().join("projects/project-a"),
1335 workspace_bindings: vec![WorkspaceBinding {
1336 path: workspace.to_string_lossy().into_owned(),
1337 label: None,
1338 git_common_dir: None,
1339 }],
1340 resources: bamboo_domain::ProjectResourceSummary {
1341 project_id: project_id.clone(),
1342 resource_revision: 1,
1343 resources: Vec::new(),
1344 },
1345 };
1346 let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
1347 descriptor,
1348 owner: owner.clone(),
1349 }));
1350 let mut session = Session::new("session-conflict", "test");
1351 session.set_project_id_meta(project_id.to_string());
1352
1353 let error = resolver
1354 .resolve(&session, Some(&workspace))
1355 .await
1356 .expect_err("cross-Project workspace must fail closed");
1357 assert!(matches!(
1358 error,
1359 ProjectContextError::WorkspaceConflict {
1360 owner_project_id,
1361 session_project_id,
1362 ..
1363 } if owner_project_id == owner && session_project_id == project_id
1364 ));
1365
1366 let safe_workspace = directory.path().join("safe");
1367 std::fs::create_dir_all(&safe_workspace).expect("safe workspace");
1368 let safe_canonical = safe_workspace.canonicalize().expect("canonical safe");
1369 bamboo_tools::tools::workspace_state::set_workspace(&session.id, safe_canonical.clone());
1370 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1371 let error = resolver
1372 .refresh_session_prompt(&mut session)
1373 .await
1374 .expect_err("refresh must validate before publishing workspace");
1375 assert!(matches!(
1376 error,
1377 ProjectContextError::WorkspaceConflict { .. }
1378 ));
1379 assert_eq!(
1380 bamboo_tools::tools::workspace_state::get_workspace(&session.id).as_deref(),
1381 Some(safe_canonical.as_path()),
1382 "a post-preflight ownership change must not publish the rejected workspace"
1383 );
1384 }
1385
1386 #[tokio::test]
1387 async fn malformed_project_identity_never_falls_back_to_legacy_workspace_scope() {
1388 let directory = tempfile::tempdir().expect("tempdir");
1389 let workspace = directory.path().join("workspace");
1390 std::fs::create_dir_all(&workspace).expect("workspace");
1391 let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
1392 let descriptor = ProjectDescriptor {
1393 id: descriptor_id.clone(),
1394 name: "Descriptor".to_string(),
1395 project_path: Some(workspace.clone()),
1396 home: directory.path().join("projects/descriptor"),
1397 workspace_bindings: Vec::new(),
1398 resources: bamboo_domain::ProjectResourceSummary {
1399 project_id: descriptor_id.clone(),
1400 resource_revision: 1,
1401 resources: Vec::new(),
1402 },
1403 };
1404 let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1405 let mut session = Session::new("malformed", "test");
1406 session.set_project_id_meta("../malformed");
1407 session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1408
1409 assert!(ProjectContextResolver::memory_read_identity_for_session(&session).is_none());
1410 assert!(matches!(
1411 resolver.resolve(&session, Some(&workspace)).await,
1412 Err(ProjectContextError::InvalidProjectIdentity { .. })
1413 ));
1414 assert!(matches!(
1415 resolver
1416 .resolve_memory_read_scope(&session, Some(&workspace))
1417 .await,
1418 Err(ProjectContextError::InvalidProjectIdentity { .. })
1419 ));
1420 }
1421
1422 #[tokio::test]
1423 async fn unassigned_session_cannot_resolve_a_project_owned_workspace() {
1424 let directory = tempfile::tempdir().expect("tempdir");
1425 let workspace = directory.path().join("workspace");
1426 std::fs::create_dir_all(&workspace).expect("workspace");
1427 let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
1428 let owner = ProjectId::parse("owner").expect("owner Project id");
1429 let descriptor = ProjectDescriptor {
1430 id: descriptor_id.clone(),
1431 name: "Descriptor".to_string(),
1432 project_path: None,
1433 home: directory.path().join("projects/descriptor"),
1434 workspace_bindings: Vec::new(),
1435 resources: bamboo_domain::ProjectResourceSummary {
1436 project_id: descriptor_id,
1437 resource_revision: 1,
1438 resources: Vec::new(),
1439 },
1440 };
1441 let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
1442 descriptor,
1443 owner: owner.clone(),
1444 }));
1445 let session = Session::new("unassigned", "test");
1446
1447 assert!(matches!(
1448 resolver.resolve(&session, Some(&workspace)).await,
1449 Err(ProjectContextError::UnassignedWorkspaceConflict {
1450 owner_project_id,
1451 ..
1452 }) if owner_project_id == owner
1453 ));
1454 }
1455
1456 #[test]
1457 fn project_identity_parser_trims_like_the_storage_index() {
1458 let mut session = Session::new("whitespace", "test");
1459 session.set_project_id_meta(" project-1 ");
1460 assert_eq!(
1461 ProjectContextResolver::session_project_identity(&session),
1462 SessionProjectIdentity::Assigned(ProjectId::parse("project-1").unwrap())
1463 );
1464 }
1465
1466 #[test]
1467 fn resource_diagnostic_distinguishes_project_and_workspace_layers() {
1468 let directory = tempfile::tempdir().expect("tempdir");
1469 let project_home = directory.path().join("project-home");
1470 let workspace = directory.path().join("workspace");
1471 std::fs::create_dir_all(project_home.join("skills")).expect("project skills");
1472 std::fs::create_dir_all(workspace.join(".bamboo/skills")).expect("workspace skills");
1473 let scope = ProjectResourceScope {
1474 project_id: ProjectId::parse("project-1").expect("project id"),
1475 project_home,
1476 workspace: Some(workspace),
1477 binding_status: WorkspaceBindingStatus::Registered,
1478 resource_revision: 4,
1479 };
1480
1481 let candidates = scope.candidates(ProjectResourceKind::Skills);
1482 assert_eq!(candidates.len(), 2);
1483 assert_eq!(candidates[0].layer, ProjectResourceLayer::Project);
1484 assert_eq!(candidates[1].layer, ProjectResourceLayer::Workspace);
1485 assert!(candidates.iter().all(|candidate| candidate.exists));
1486 }
1487
1488 #[test]
1489 fn workspace_commands_resolve_from_nearest_git_boundary() {
1490 let directory = tempfile::tempdir().expect("tempdir");
1491 let repository = directory.path().join("repo");
1492 let nested = repository.join("nested/path");
1493 std::fs::create_dir_all(repository.join(".git")).expect("git");
1494 std::fs::create_dir_all(&nested).expect("nested");
1495 let scope = ProjectResourceScope {
1496 project_id: ProjectId::parse("project-1").expect("project id"),
1497 project_home: directory.path().join("project-home"),
1498 workspace: Some(nested),
1499 binding_status: WorkspaceBindingStatus::Registered,
1500 resource_revision: 1,
1501 };
1502 assert_eq!(
1503 scope.workspace_commands_dir(),
1504 Some(repository.join(".bamboo/commands"))
1505 );
1506 }
1507
1508 #[test]
1509 fn unassigned_session_with_workspace_has_no_project_memory_scope() {
1510 let mut session = Session::new("unassigned-session", "unassigned");
1511 session.set_workspace_path_meta("/tmp/unassigned-workspace");
1512 assert!(ProjectContextResolver::memory_read_identity_for_session(&session).is_none());
1513 }
1514}