1use std::sync::Arc;
5
6use async_trait::async_trait;
7use bamboo_domain::ProjectId;
8use bamboo_engine::project_context::{
9 ProjectContextError, ProjectContextSource, ProjectDescriptor, ProjectMemoryReadRoots,
10};
11use bamboo_memory::memory_store::LegacyProjectMemoryReadRoot;
12use bamboo_projects::{ProjectStore, ProjectStoreError};
13
14#[derive(Debug, thiserror::Error)]
15pub enum ProjectWorkspaceValidationError {
16 #[error("{message}")]
17 Invalid {
18 code: &'static str,
19 workspace: String,
20 message: String,
21 },
22 #[error(
23 "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
24 )]
25 Conflict {
26 workspace: String,
27 owner_project_id: ProjectId,
28 session_project_id: String,
29 },
30 #[error("failed to resolve workspace Project ownership: {0}")]
31 Store(#[from] ProjectStoreError),
32}
33
34#[derive(Debug, thiserror::Error)]
35pub(crate) enum SessionWorkspaceValidationError {
36 #[error("{message}")]
37 Invalid { workspace: String, message: String },
38 #[error(
39 "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
40 )]
41 Conflict {
42 workspace: String,
43 owner_project_id: ProjectId,
44 session_project_id: String,
45 },
46 #[error("workspace '{workspace}' is not bound to assigned Project '{session_project_id}'")]
47 Unbound {
48 workspace: String,
49 session_project_id: ProjectId,
50 },
51 #[error("assigned Project '{project_id}' is archived")]
52 ProjectArchived { project_id: ProjectId },
53 #[error("assigned Project '{project_id}' is unavailable")]
54 ProjectUnavailable { project_id: ProjectId },
55 #[error("failed to resolve workspace Project ownership: {0}")]
56 Store(#[from] ProjectStoreError),
57}
58
59pub(crate) fn session_workspace_error_response(
60 error: SessionWorkspaceValidationError,
61) -> actix_web::HttpResponse {
62 match error {
63 SessionWorkspaceValidationError::Invalid { workspace, message } => {
64 actix_web::HttpResponse::BadRequest().json(serde_json::json!({
65 "error": {
66 "type": "api_error",
67 "code": "workspace_invalid",
68 "message": message
69 },
70 "workspace": workspace,
71 }))
72 }
73 SessionWorkspaceValidationError::Conflict {
74 workspace,
75 owner_project_id,
76 session_project_id,
77 } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
78 "error": {
79 "type": "api_error",
80 "code": "project_workspace_conflict",
81 "message": "Workspace belongs to another Project"
82 },
83 "workspace": workspace,
84 "owner_project_id": owner_project_id,
85 "session_project_id": session_project_id,
86 })),
87 SessionWorkspaceValidationError::Unbound {
88 workspace,
89 session_project_id,
90 } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
91 "error": {
92 "type": "api_error",
93 "code": "project_workspace_unbound",
94 "message": "Workspace must be bound to the session Project before switching"
95 },
96 "workspace": workspace,
97 "session_project_id": session_project_id,
98 })),
99 SessionWorkspaceValidationError::ProjectArchived { project_id } => {
100 actix_web::HttpResponse::Conflict().json(serde_json::json!({
101 "error": {
102 "type": "api_error",
103 "code": "project_archived",
104 "message": "Archived Projects cannot switch session workspaces"
105 },
106 "project_id": project_id,
107 }))
108 }
109 SessionWorkspaceValidationError::ProjectUnavailable { project_id } => {
110 actix_web::HttpResponse::Conflict().json(serde_json::json!({
111 "error": {
112 "type": "api_error",
113 "code": "project_unavailable",
114 "message": "Assigned Project is unavailable"
115 },
116 "project_id": project_id,
117 }))
118 }
119 SessionWorkspaceValidationError::Store(error) => {
120 tracing::error!(%error, "failed to validate session workspace Project ownership");
121 crate::error::json_error(
122 actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
123 "Failed to validate workspace Project ownership",
124 )
125 }
126 }
127}
128
129pub(crate) fn project_context_error_response(
130 error: ProjectContextError,
131) -> actix_web::HttpResponse {
132 match error {
133 ProjectContextError::WorkspaceConflict {
134 workspace,
135 owner_project_id,
136 session_project_id,
137 } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
138 "error": {
139 "type": "api_error",
140 "code": "project_workspace_conflict",
141 "message": "Workspace belongs to another Project"
142 },
143 "workspace": workspace,
144 "owner_project_id": owner_project_id,
145 "session_project_id": session_project_id,
146 })),
147 ProjectContextError::UnassignedWorkspaceConflict {
148 workspace,
149 owner_project_id,
150 } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
151 "error": {
152 "type": "api_error",
153 "code": "project_workspace_conflict",
154 "message": "Workspace belongs to another Project"
155 },
156 "workspace": workspace,
157 "owner_project_id": owner_project_id,
158 "session_project_id": "unassigned",
159 })),
160 ProjectContextError::WorkspaceInvalid { workspace, message } => {
161 actix_web::HttpResponse::BadRequest().json(serde_json::json!({
162 "error": {
163 "type": "api_error",
164 "code": "workspace_invalid",
165 "message": message
166 },
167 "workspace": workspace,
168 }))
169 }
170 ProjectContextError::InvalidProjectIdentity { raw, message } => {
171 actix_web::HttpResponse::BadRequest().json(serde_json::json!({
172 "error": {
173 "type": "api_error",
174 "code": "invalid_project_identity",
175 "message": format!(
176 "Session carries an invalid Project identity '{raw}': {message}"
177 )
178 }
179 }))
180 }
181 ProjectContextError::ProjectUnavailable { project_id } => {
182 actix_web::HttpResponse::Conflict().json(serde_json::json!({
183 "error": {
184 "type": "api_error",
185 "code": "project_unavailable",
186 "message": "Assigned Project is unavailable"
187 },
188 "project_id": project_id,
189 }))
190 }
191 ProjectContextError::ProjectPathMissing { project_id } => {
192 actix_web::HttpResponse::Conflict().json(serde_json::json!({
193 "error": {
194 "type": "api_error",
195 "code": "project_path_missing",
196 "message": "Assigned Project has no configured project_path"
197 },
198 "project_id": project_id,
199 }))
200 }
201 ProjectContextError::ProjectPathUnavailable {
202 project_id,
203 project_path,
204 message,
205 } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
206 "error": {
207 "type": "api_error",
208 "code": "project_path_unavailable",
209 "message": message
210 },
211 "project_id": project_id,
212 "project_path": project_path,
213 })),
214 error @ (ProjectContextError::Source(_) | ProjectContextError::IdentityMismatch { .. }) => {
215 tracing::error!(%error, "failed to resolve Project context");
216 crate::error::json_error(
217 actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
218 "Failed to resolve Project context",
219 )
220 }
221 }
222}
223
224pub fn validate_workspace_assignment(
228 store: &ProjectStore,
229 session_project_id: Option<&ProjectId>,
230 requested_workspace: Option<&str>,
231) -> Result<Option<std::path::PathBuf>, ProjectWorkspaceValidationError> {
232 validate_workspace_assignment_with(
233 store,
234 session_project_id,
235 requested_workspace,
236 bamboo_agent_core::workspace_state::preview_workspace_path,
237 false,
238 )
239}
240
241pub fn validate_workspace_assignment_with_resolver(
242 store: &ProjectStore,
243 session_project_id: Option<&ProjectId>,
244 requested_workspace: Option<&str>,
245 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
246) -> Result<Option<std::path::PathBuf>, ProjectWorkspaceValidationError> {
247 validate_workspace_assignment_with(
248 store,
249 session_project_id,
250 requested_workspace,
251 |workspace| workspace_resolver.preview_workspace_path(workspace),
252 false,
253 )
254}
255
256pub(crate) fn validate_explicit_session_workspace_with_resolver(
263 store: &ProjectStore,
264 session_project_id: Option<&ProjectId>,
265 requested_workspace: &str,
266 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
267) -> Result<std::path::PathBuf, SessionWorkspaceValidationError> {
268 let requested_workspace = requested_workspace.trim();
269 if requested_workspace.is_empty() {
270 return Err(SessionWorkspaceValidationError::Invalid {
271 workspace: String::new(),
272 message: "Workspace path must be a non-empty existing directory".to_string(),
273 });
274 }
275
276 if let Some(project_id) = session_project_id {
277 match store.get(project_id) {
278 Ok(project) if project.status == bamboo_domain::ProjectStatus::Active => {}
279 Ok(_) => {
280 return Err(SessionWorkspaceValidationError::ProjectArchived {
281 project_id: project_id.clone(),
282 });
283 }
284 Err(ProjectStoreError::NotFound(_)) => {
285 return Err(SessionWorkspaceValidationError::ProjectUnavailable {
286 project_id: project_id.clone(),
287 });
288 }
289 Err(error) => return Err(SessionWorkspaceValidationError::Store(error)),
290 }
291 }
292
293 let final_workspace = validate_workspace_assignment_with_resolver(
294 store,
295 session_project_id,
296 Some(requested_workspace),
297 workspace_resolver,
298 )
299 .map_err(|error| match error {
300 ProjectWorkspaceValidationError::Invalid {
301 workspace, message, ..
302 } => SessionWorkspaceValidationError::Invalid { workspace, message },
303 ProjectWorkspaceValidationError::Conflict {
304 workspace,
305 owner_project_id,
306 session_project_id,
307 } => SessionWorkspaceValidationError::Conflict {
308 workspace,
309 owner_project_id,
310 session_project_id,
311 },
312 ProjectWorkspaceValidationError::Store(error) => {
313 SessionWorkspaceValidationError::Store(error)
314 }
315 })?
316 .ok_or_else(|| SessionWorkspaceValidationError::Invalid {
317 workspace: requested_workspace.to_string(),
318 message: "Workspace path must be a non-empty existing directory".to_string(),
319 })?;
320
321 let display = bamboo_config::paths::path_to_display_string(&final_workspace);
322 let owner = match store.find_workspace_owner_for_path(&display) {
323 Ok(owner) => owner,
324 Err(ProjectStoreError::Validation(message))
325 | Err(ProjectStoreError::InvalidPathComponent(message)) => {
326 return Err(SessionWorkspaceValidationError::Invalid {
327 workspace: display,
328 message,
329 });
330 }
331 Err(error) => return Err(SessionWorkspaceValidationError::Store(error)),
332 };
333 match (session_project_id, owner) {
334 (Some(session_project_id), Some(owner)) if owner.id == *session_project_id => {
335 Ok(final_workspace)
336 }
337 (Some(session_project_id), Some(owner)) => Err(SessionWorkspaceValidationError::Conflict {
338 workspace: display,
339 owner_project_id: owner.id,
340 session_project_id: session_project_id.to_string(),
341 }),
342 (Some(session_project_id), None) => Err(SessionWorkspaceValidationError::Unbound {
343 workspace: display,
344 session_project_id: session_project_id.clone(),
345 }),
346 (None, Some(owner)) => Err(SessionWorkspaceValidationError::Conflict {
347 workspace: display,
348 owner_project_id: owner.id,
349 session_project_id: "unassigned".to_string(),
350 }),
351 (None, None) => Ok(final_workspace),
352 }
353}
354
355pub fn validate_project_path_candidate_with_resolver(
360 store: &ProjectStore,
361 project_id: Option<&ProjectId>,
362 project_path: &str,
363 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
364) -> Result<std::path::PathBuf, ProjectWorkspaceValidationError> {
365 validate_workspace_assignment_with(
366 store,
367 project_id,
368 Some(project_path),
369 |workspace| workspace_resolver.preview_workspace_path(workspace),
370 true,
371 )?
372 .ok_or_else(|| ProjectWorkspaceValidationError::Invalid {
373 code: "project_path_missing",
374 workspace: project_path.to_string(),
375 message: "Project path must be a non-empty existing directory".to_string(),
376 })
377}
378
379fn validate_workspace_assignment_with(
380 store: &ProjectStore,
381 session_project_id: Option<&ProjectId>,
382 requested_workspace: Option<&str>,
383 resolve_workspace: impl FnOnce(std::path::PathBuf) -> std::path::PathBuf,
384 explicit_is_project_path: bool,
385) -> Result<Option<std::path::PathBuf>, ProjectWorkspaceValidationError> {
386 let explicit_workspace = requested_workspace
387 .map(str::trim)
388 .filter(|workspace| !workspace.is_empty());
389 if explicit_is_project_path && explicit_workspace.is_none() {
390 return Err(ProjectWorkspaceValidationError::Invalid {
391 code: "project_path_missing",
392 workspace: requested_workspace.unwrap_or_default().to_string(),
393 message: "Project path must be a non-empty existing directory".to_string(),
394 });
395 }
396 let (requested_workspace, is_project_default, is_persisted_project_default) =
397 if let Some(workspace) = explicit_workspace {
398 (workspace.to_string(), explicit_is_project_path, false)
399 } else if let Some(project_id) = session_project_id {
400 let project = store.get(project_id)?;
401 let Some(project_path) = project.project_path else {
402 return Err(ProjectWorkspaceValidationError::Invalid {
403 code: "project_path_missing",
404 workspace: String::new(),
405 message: format!("Project '{project_id}' has no configured project_path"),
406 });
407 };
408 (project_path, true, true)
409 } else {
410 return Ok(None);
411 };
412 let requested_path = std::path::Path::new(&requested_workspace);
413 if !requested_path.exists() {
414 return Err(ProjectWorkspaceValidationError::Invalid {
415 code: if is_project_default {
416 "project_path_unavailable"
417 } else {
418 "workspace_not_found"
419 },
420 workspace: requested_workspace,
421 message: if is_project_default {
422 "Project path does not exist".to_string()
423 } else {
424 "Workspace path does not exist".to_string()
425 },
426 });
427 }
428 if !requested_path.is_dir() {
429 return Err(ProjectWorkspaceValidationError::Invalid {
430 code: if is_project_default {
431 "project_path_unavailable"
432 } else {
433 "workspace_not_directory"
434 },
435 workspace: requested_workspace,
436 message: if is_project_default {
437 "Project path is not a directory".to_string()
438 } else {
439 "Workspace path is not a directory".to_string()
440 },
441 });
442 }
443 if is_persisted_project_default {
444 let metadata = std::fs::symlink_metadata(requested_path).map_err(|error| {
445 ProjectWorkspaceValidationError::Invalid {
446 code: "project_path_unavailable",
447 workspace: requested_workspace.clone(),
448 message: format!("Project path metadata is unavailable: {error}"),
449 }
450 })?;
451 if metadata.file_type().is_symlink() || !metadata.is_dir() {
452 return Err(ProjectWorkspaceValidationError::Invalid {
453 code: "project_path_unavailable",
454 workspace: requested_workspace,
455 message: "Configured Project path is no longer a plain directory".to_string(),
456 });
457 }
458 }
459 let canonical =
460 requested_path
461 .canonicalize()
462 .map_err(|_| ProjectWorkspaceValidationError::Invalid {
463 code: if is_project_default {
464 "project_path_unavailable"
465 } else {
466 "workspace_invalid"
467 },
468 workspace: requested_workspace.clone(),
469 message: if is_project_default {
470 "Project path could not be canonicalized".to_string()
471 } else {
472 "Workspace path could not be canonicalized".to_string()
473 },
474 })?;
475 if is_persisted_project_default && canonical != requested_path {
476 return Err(ProjectWorkspaceValidationError::Invalid {
477 code: "project_path_unavailable",
478 workspace: requested_workspace,
479 message:
480 "Configured Project path no longer resolves to its registered canonical directory"
481 .to_string(),
482 });
483 }
484 let final_workspace = resolve_workspace(canonical.clone());
485 let final_workspace = final_workspace.canonicalize().unwrap_or(final_workspace);
486 let display = bamboo_config::paths::path_to_display_string(&final_workspace);
487 if is_project_default && final_workspace != canonical {
488 return Err(ProjectWorkspaceValidationError::Invalid {
489 code: "project_path_unavailable",
490 workspace: requested_workspace,
491 message: format!(
492 "Workspace confinement redirected the Project path to '{}'",
493 final_workspace.display()
494 ),
495 });
496 }
497 let owner = match store.find_workspace_owner_for_path(&display) {
498 Ok(owner) => owner,
499 Err(ProjectStoreError::Validation(message))
500 | Err(ProjectStoreError::InvalidPathComponent(message)) => {
501 return Err(ProjectWorkspaceValidationError::Invalid {
502 code: if is_project_default {
503 "project_path_unavailable"
504 } else {
505 "workspace_invalid"
506 },
507 workspace: display,
508 message,
509 });
510 }
511 Err(error) => return Err(ProjectWorkspaceValidationError::Store(error)),
512 };
513 let Some(owner) = owner else {
514 return Ok(Some(final_workspace));
515 };
516 if session_project_id == Some(&owner.id) {
517 return Ok(Some(final_workspace));
518 }
519 Err(ProjectWorkspaceValidationError::Conflict {
520 workspace: display,
521 owner_project_id: owner.id,
522 session_project_id: session_project_id
523 .map(ToString::to_string)
524 .unwrap_or_else(|| "unassigned".to_string()),
525 })
526}
527
528pub struct ProjectStoreContextSource {
529 store: Arc<ProjectStore>,
530}
531
532impl ProjectStoreContextSource {
533 pub fn new(store: Arc<ProjectStore>) -> Self {
534 Self { store }
535 }
536}
537
538#[async_trait]
539impl ProjectContextSource for ProjectStoreContextSource {
540 async fn find_project(
541 &self,
542 project_id: &ProjectId,
543 ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
544 let manifest = match self.store.get(project_id) {
545 Ok(project) => project,
546 Err(ProjectStoreError::NotFound(_)) => return Ok(None),
547 Err(error) => return Err(ProjectContextError::Source(error.to_string())),
548 };
549 let resources = self
550 .store
551 .resource_summary(project_id)
552 .map_err(|error| ProjectContextError::Source(error.to_string()))?;
553 let memory_read_roots = self
554 .store
555 .project_memory_read_roots(project_id)
556 .map_err(|error| ProjectContextError::Source(error.to_string()))?;
557 Ok(Some(ProjectDescriptor {
558 id: manifest.id.clone(),
559 name: manifest.name,
560 project_path: manifest.project_path.map(std::path::PathBuf::from),
561 home: self.store.paths().project_home(project_id),
562 workspace_bindings: manifest.workspace_bindings,
563 resources,
564 memory_read_roots: ProjectMemoryReadRoots {
565 primary: memory_read_roots.primary,
566 legacy_aliases: memory_read_roots
567 .legacy_aliases
568 .into_iter()
569 .map(|legacy| LegacyProjectMemoryReadRoot {
570 project_key: legacy.legacy_project_key,
571 root: legacy.root,
572 })
573 .collect(),
574 },
575 }))
576 }
577
578 async fn list_projects(&self) -> Result<Vec<ProjectDescriptor>, ProjectContextError> {
579 let project_ids = self
580 .store
581 .list()
582 .map_err(|error| ProjectContextError::Source(error.to_string()))?
583 .into_iter()
584 .map(|project| project.id)
585 .collect::<Vec<_>>();
586 let mut projects = Vec::with_capacity(project_ids.len());
587 for project_id in project_ids {
588 if let Some(project) = self.find_project(&project_id).await? {
589 projects.push(project);
590 }
591 }
592 Ok(projects)
593 }
594
595 async fn find_workspace_owner(
596 &self,
597 workspace: &std::path::Path,
598 ) -> Result<Option<ProjectId>, ProjectContextError> {
599 let display = bamboo_config::paths::path_to_display_string(workspace);
600 self.store
601 .find_workspace_owner_for_path(&display)
602 .map(|owner| owner.map(|project| project.id))
603 .map_err(|error| ProjectContextError::Source(error.to_string()))
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 #[test]
612 fn confinement_final_path_is_the_authority_for_ownership_and_persistence() {
613 let data = tempfile::tempdir().expect("data");
614 let raw = tempfile::tempdir().expect("raw workspace");
615 let confined = tempfile::tempdir().expect("confined workspace");
616 std::fs::write(raw.path().join("raw-only.txt"), "RAW MUST NOT BE USED")
617 .expect("raw fixture");
618 std::fs::write(confined.path().join("final-only.txt"), "FINAL").expect("final fixture");
619 let store = ProjectStore::open(data.path()).expect("Project store");
620 let owner = store
621 .create_with_bindings(
622 "Owner",
623 None,
624 vec![bamboo_domain::WorkspaceBinding {
625 path: confined.path().to_string_lossy().into_owned(),
626 label: None,
627 git_common_dir: None,
628 }],
629 )
630 .expect("owner Project");
631 let other = store.create("Other", None).expect("other Project");
632
633 let resolved = validate_workspace_assignment_with(
634 &store,
635 Some(&owner.id),
636 Some(raw.path().to_string_lossy().as_ref()),
637 |_| confined.path().to_path_buf(),
638 false,
639 )
640 .expect("same owner");
641 assert_eq!(
642 resolved.as_deref(),
643 Some(confined.path().canonicalize().unwrap().as_path())
644 );
645 assert!(resolved
646 .as_ref()
647 .is_some_and(|path| path.join("final-only.txt").exists()));
648 assert!(!resolved
649 .as_ref()
650 .is_some_and(|path| path.join("raw-only.txt").exists()));
651
652 let conflict = validate_workspace_assignment_with(
653 &store,
654 Some(&other.id),
655 Some(raw.path().to_string_lossy().as_ref()),
656 |_| confined.path().to_path_buf(),
657 false,
658 )
659 .expect_err("final workspace owner must win over the raw request");
660 assert!(matches!(
661 conflict,
662 ProjectWorkspaceValidationError::Conflict {
663 owner_project_id,
664 ..
665 } if owner_project_id == owner.id
666 ));
667 }
668
669 #[test]
670 fn assigned_omission_uses_project_path_and_reports_unconfigured_legacy_project() {
671 let data = tempfile::tempdir().expect("data");
672 let project_path = tempfile::tempdir().expect("Project path");
673 let foreign_default = tempfile::tempdir().expect("foreign default");
674 let store = ProjectStore::open(data.path()).expect("Project store");
675 let project = store
676 .create_with_project_path(
677 "Configured",
678 None,
679 project_path.path().to_string_lossy(),
680 Vec::new(),
681 )
682 .expect("configured Project");
683
684 let resolved = validate_workspace_assignment_with(
685 &store,
686 Some(&project.id),
687 None,
688 |_| foreign_default.path().to_path_buf(),
689 false,
690 )
691 .expect_err("confinement must not silently relocate project_path");
692 assert!(matches!(
693 resolved,
694 ProjectWorkspaceValidationError::Invalid {
695 code: "project_path_unavailable",
696 ..
697 }
698 ));
699
700 let resolved = validate_workspace_assignment_with(
701 &store,
702 Some(&project.id),
703 None,
704 |workspace| workspace,
705 false,
706 )
707 .expect("Project path fallback");
708 assert_eq!(
709 resolved.as_deref(),
710 Some(project_path.path().canonicalize().unwrap().as_path())
711 );
712
713 let legacy = store.create("Legacy", None).expect("legacy Project");
714 let error = validate_workspace_assignment_with(
715 &store,
716 Some(&legacy.id),
717 None,
718 |workspace| workspace,
719 false,
720 )
721 .expect_err("unconfigured Project must fail closed");
722 assert!(matches!(
723 error,
724 ProjectWorkspaceValidationError::Invalid {
725 code: "project_path_missing",
726 ..
727 }
728 ));
729 }
730
731 #[cfg(unix)]
732 #[test]
733 fn assigned_omission_rejects_project_path_replaced_by_symlink() {
734 use std::os::unix::fs::symlink;
735
736 let root = tempfile::tempdir().expect("root");
737 let data = root.path().join("data");
738 let project_path = root.path().join("project");
739 let replacement = root.path().join("replacement");
740 std::fs::create_dir_all(&project_path).expect("Project path");
741 std::fs::create_dir_all(&replacement).expect("replacement");
742 let store = ProjectStore::open(&data).expect("Project store");
743 let project = store
744 .create_with_project_path(
745 "Configured",
746 None,
747 project_path.to_string_lossy(),
748 Vec::new(),
749 )
750 .expect("configured Project");
751
752 std::fs::remove_dir(&project_path).expect("remove original Project path");
753 symlink(&replacement, &project_path).expect("replace Project path with symlink");
754
755 let error = validate_workspace_assignment_with(
756 &store,
757 Some(&project.id),
758 None,
759 |workspace| workspace,
760 false,
761 )
762 .expect_err("persisted Project path must not follow a replacement symlink");
763 assert!(matches!(
764 error,
765 ProjectWorkspaceValidationError::Invalid {
766 code: "project_path_unavailable",
767 ..
768 }
769 ));
770 }
771}