1use crate::context::{build_env_prompt_context, build_workspace_prompt_context};
4use crate::runner::refresh_prompt_snapshot;
5use bamboo_agent_core::{Role, Session};
6use bamboo_config::paths::path_to_display_string;
7use bamboo_domain::Message;
8use bamboo_skills::selection::normalize_selected_skill_ids;
9use bamboo_skills::{
10 ActiveWorkflow, WorkflowActivationStatus, WorkflowSelection, ACTIVE_WORKFLOW_METADATA_KEY,
11 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY, WORKFLOW_ACTIVATION_EVENT_METADATA_KEY,
12 WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY, WORKFLOW_SELECTION_METADATA_KEY,
13};
14use sha2::{Digest, Sha256};
15use std::path::{Path, PathBuf};
16
17use super::errors::ChatError;
18use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
19use super::repository::SessionAccess;
20use super::types::ChatTurnInput;
21
22const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
24const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
25const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
26const COPILOT_CONCLUSION_KEY: &str = "copilot_conclusion_with_options_enhancement_enabled";
27const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
28const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
29const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
30const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
31
32const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v2";
33
34pub async fn prepare_chat_turn(
42 repo: &dyn SessionAccess,
43 input: ChatTurnInput,
44 global_default_prompt: &str,
45 builtin_fallback_prompt: &str,
46) -> Result<Session, ChatError> {
47 let mut session = repo.load_or_create(&input.session_id, &input.model).await?;
48
49 let base_prompt = resolve_base_prompt(
51 &mut session,
52 input.system_prompt.as_deref(),
53 global_default_prompt,
54 builtin_fallback_prompt,
55 );
56
57 resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
59 let enhance_prompt = session.enhance_prompt();
60
61 resolve_copilot_conclusion_with_options_enhancement(
63 &mut session,
64 input.copilot_conclusion_with_options_enhancement_enabled,
65 );
66
67 let workspace_path = resolve_workspace_path(
69 &mut session,
70 input.workspace_path.as_deref(),
71 input.data_dir.as_deref(),
72 );
73
74 resolve_workflow_selection(
76 &mut session,
77 input.workflow_selection.as_ref(),
78 input.selected_skill_ids.as_deref(),
79 &input.message,
80 )?;
81 if let Some(opted_in) = input.orchestration_opt_in {
82 session.metadata.insert(
83 WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY.to_string(),
84 opted_in.to_string(),
85 );
86 }
87
88 let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
90 &base_prompt,
91 enhance_prompt.as_deref(),
92 workspace_path.as_deref(),
93 );
94
95 session.metadata.insert(
96 PROMPT_COMPOSER_VERSION_KEY.to_string(),
97 prompt_profile.version.to_string(),
98 );
99 session.metadata.insert(
100 PROMPT_FINGERPRINT_KEY.to_string(),
101 prompt_profile.fingerprint.clone(),
102 );
103 session.metadata.insert(
104 PROMPT_COMPONENT_FLAGS_KEY.to_string(),
105 prompt_profile.component_flags_value(),
106 );
107 session.metadata.insert(
108 PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
109 prompt_profile.component_lengths_value(),
110 );
111
112 session
114 .messages
115 .retain(|message| !matches!(message.role, Role::System));
116 session.messages.insert(0, Message::system(system_prompt));
117 refresh_prompt_snapshot(&mut session);
118
119 let request_model_ref = derive_model_ref(
121 input.model_ref.as_ref(),
122 input.provider.as_deref(),
123 Some(input.model.as_str()),
124 );
125 if let Some(model_ref) = request_model_ref.as_ref() {
126 persist_model_ref(&mut session, model_ref);
127 } else {
128 persist_legacy_model_provider(
129 &mut session,
130 Some(input.model.as_str()),
131 input.provider.as_deref(),
132 );
133 }
134
135 repo.save_and_cache(&mut session).await?;
137
138 Ok(session)
139}
140
141pub fn resolve_base_prompt(
144 session: &mut Session,
145 base_prompt_from_request: Option<&str>,
146 global_default_template: &str,
147 builtin_fallback: &str,
148) -> String {
149 let resolved = base_prompt_from_request
150 .map(ToString::to_string)
151 .or_else(|| {
152 session
153 .metadata
154 .get(BASE_SYSTEM_PROMPT_KEY)
155 .map(String::as_str)
156 .map(str::trim)
157 .filter(|value| !value.is_empty())
158 .map(ToString::to_string)
159 })
160 .or_else(|| {
161 session
162 .messages
163 .iter()
164 .find(|message| matches!(message.role, Role::System))
165 .map(|message| message.content.trim().to_string())
166 .filter(|value| !value.is_empty())
167 })
168 .unwrap_or_else(|| {
169 let trimmed = global_default_template.trim();
170 if trimmed.is_empty() {
171 builtin_fallback.to_string()
172 } else {
173 trimmed.to_string()
174 }
175 });
176
177 session
178 .metadata
179 .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
180 resolved
181}
182
183pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
184 if let Some(prompt) = enhance_prompt_from_request {
185 session.set_enhance_prompt(prompt);
186 } else {
187 session.clear_enhance_prompt();
188 }
189}
190
191pub fn resolve_copilot_conclusion_with_options_enhancement(
192 session: &mut Session,
193 enabled_from_request: Option<bool>,
194) {
195 if let Some(enabled) = enabled_from_request {
196 session
197 .metadata
198 .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
199 } else {
200 session.metadata.remove(COPILOT_CONCLUSION_KEY);
201 }
202}
203
204pub fn resolve_workspace_path(
205 session: &mut Session,
206 workspace_path_from_request: Option<&str>,
207 data_dir: Option<&Path>,
208) -> Option<String> {
209 if let Some(path) = workspace_path_from_request {
210 session.set_workspace_path_meta(path);
211 }
212
213 workspace_path_from_request
214 .map(ToString::to_string)
215 .or_else(|| session.workspace_path_meta())
216 .or_else(|| resolve_default_workspace(data_dir))
217}
218
219fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
230 let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
231 bamboo_agent_core::workspace_state::get_configured_default_workspace()
232 } else {
233 default_workspace_from_data_dir(data_dir)
234 };
235 configured.map(|path| path_to_display_string(&path))
236}
237
238fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
241 bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
242}
243
244pub fn resolve_selected_skill_ids(
245 session: &mut Session,
246 selected_skill_ids_from_request: Option<&[String]>,
247 message: &str,
248) {
249 if let Some(request_ids) = selected_skill_ids_from_request {
250 let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
251 persist_selected_skill_ids_metadata(session, normalized.as_deref());
252 return;
253 }
254
255 let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
256 if let Some(ids) = from_hint.as_ref() {
257 persist_selected_skill_ids_metadata(session, Some(ids));
258 return;
259 }
260
261 session.clear_selected_skill_ids();
262}
263
264pub fn resolve_workflow_selection(
265 session: &mut Session,
266 workflow_selection: Option<&WorkflowSelection>,
267 selected_skill_ids_from_request: Option<&[String]>,
268 message: &str,
269) -> Result<(), ChatError> {
270 if let Some(selection) = workflow_selection {
271 let id = selection.id.trim();
272 if id.is_empty() || selection.revision == 0 || !selection.args.is_object() {
273 return Err(ChatError::InvalidWorkflowSelection(
274 "id must be non-empty, revision must be positive, and args must be an object"
275 .to_string(),
276 ));
277 }
278 let previous = session
279 .metadata
280 .get(WORKFLOW_SELECTION_METADATA_KEY)
281 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok());
282 let selection_changed = previous.as_ref() != Some(selection);
283 session.metadata.insert(
284 WORKFLOW_SELECTION_METADATA_KEY.to_string(),
285 serde_json::to_string(selection).map_err(|_| {
286 ChatError::InvalidWorkflowSelection("selection cannot be serialized".to_string())
287 })?,
288 );
289 persist_selected_skill_ids_metadata(session, Some(&[id.to_string()]));
290 if selection_changed {
291 deactivate_active_workflow(session);
292 clear_skill_runtime_state(session);
293 }
294 return Ok(());
295 }
296
297 if selected_skill_ids_from_request.is_some() {
298 session.metadata.remove(WORKFLOW_SELECTION_METADATA_KEY);
301 deactivate_active_workflow(session);
302 resolve_selected_skill_ids(session, selected_skill_ids_from_request, message);
303 clear_skill_runtime_state(session);
304 return Ok(());
305 }
306
307 if let Some(active) = session
308 .metadata
309 .get(ACTIVE_WORKFLOW_METADATA_KEY)
310 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
311 .filter(|active| active.status == WorkflowActivationStatus::Active)
312 {
313 persist_selected_skill_ids_metadata(session, Some(&[active.id]));
314 return Ok(());
315 }
316
317 resolve_selected_skill_ids(session, None, message);
320 Ok(())
321}
322
323fn deactivate_active_workflow(session: &mut Session) {
324 if let Some(active) = session
325 .metadata
326 .get(ACTIVE_WORKFLOW_METADATA_KEY)
327 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
328 .filter(|active| active.status == WorkflowActivationStatus::Active)
329 {
330 session.metadata.insert(
331 WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
332 serde_json::json!({
333 "type": "workflow.deactivated",
334 "workflow_id": active.id,
335 "revision": active.revision,
336 "deactivated_at": chrono::Utc::now(),
337 })
338 .to_string(),
339 );
340 }
341 session.metadata.remove(ACTIVE_WORKFLOW_METADATA_KEY);
342 session
343 .metadata
344 .remove(ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY);
345}
346
347pub fn clear_skill_runtime_state(session: &mut Session) {
349 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
350 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
351}
352
353fn persist_selected_skill_ids_metadata(
354 session: &mut Session,
355 selected_skill_ids: Option<&[String]>,
356) {
357 match selected_skill_ids {
358 Some(ids) if !ids.is_empty() => {
359 session.set_selected_skill_ids(ids.to_vec());
360 }
361 _ => {
362 session.clear_selected_skill_ids();
363 }
364 }
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
371pub enum GoalCommand {
372 Status,
374 Off,
376 Clear,
378 On,
380 SetPrompt(String),
382}
383
384pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
387 let trimmed = message.trim();
388 if !trimmed.to_ascii_lowercase().starts_with("/goal") {
389 return None;
390 }
391 let rest = &trimmed[5..]; if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
394 return None;
395 }
396
397 let arg = rest.trim().to_ascii_lowercase();
398
399 if arg.is_empty() {
400 return Some(GoalCommand::Status);
401 }
402
403 match arg.as_str() {
404 "status" => Some(GoalCommand::Status),
405 "off" | "disable" | "disabled" => Some(GoalCommand::Off),
406 "clear" | "reset" => Some(GoalCommand::Clear),
407 "on" | "enable" | "enabled" => Some(GoalCommand::On),
408 _ => {
409 let prompt = trimmed
412 .strip_prefix("/goal")
413 .unwrap_or(trimmed)
414 .trim()
415 .to_string();
416 if prompt.is_empty() {
417 Some(GoalCommand::Status)
418 } else {
419 Some(GoalCommand::SetPrompt(prompt))
420 }
421 }
422 }
423}
424
425fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
426 const HINT_PREFIX: &str = "[User explicitly selected skill:";
427 let mut extracted = Vec::new();
428
429 for line in message.lines() {
430 let trimmed = line.trim();
431 if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
432 continue;
433 }
434
435 let Some(id_marker_index) = trimmed.rfind("(ID:") else {
436 continue;
437 };
438 let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
439 let Some(close_paren_index) = id_segment.find(')') else {
440 continue;
441 };
442 let id = id_segment[..close_paren_index].trim();
443 if !id.is_empty() {
444 extracted.push(id.to_string());
445 }
446 }
447
448 extracted
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
454struct PromptCompositionProfile {
455 version: &'static str,
456 fingerprint: String,
457 has_enhancement: bool,
458 has_workspace_context: bool,
459 has_env_context: bool,
460 base_len: usize,
461 enhancement_len: usize,
462 workspace_context_len: usize,
463 env_context_len: usize,
464 final_len: usize,
465}
466
467impl PromptCompositionProfile {
468 fn component_flags_value(&self) -> String {
469 format!(
470 "enhance={};workspace={};env={}",
471 self.has_enhancement as u8,
472 self.has_workspace_context as u8,
473 self.has_env_context as u8,
474 )
475 }
476
477 fn component_lengths_value(&self) -> String {
478 format!(
479 "base={};enhance={};workspace={};env={};final={}",
480 self.base_len,
481 self.enhancement_len,
482 self.workspace_context_len,
483 self.env_context_len,
484 self.final_len
485 )
486 }
487}
488
489fn build_prompt_fingerprint(
490 base_prompt: &str,
491 enhancement: Option<&str>,
492 workspace: Option<&str>,
493 env_context: Option<&str>,
494) -> String {
495 let mut hasher = Sha256::new();
496 hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
497 hasher.update([0u8]);
498 hasher.update(base_prompt.as_bytes());
499 hasher.update([0u8]);
500 hasher.update(enhancement.unwrap_or_default().as_bytes());
501 hasher.update([0u8]);
502 hasher.update(workspace.unwrap_or_default().as_bytes());
503 hasher.update([0u8]);
504 hasher.update(env_context.unwrap_or_default().as_bytes());
505 hex::encode(hasher.finalize())
506}
507
508fn build_enhanced_system_prompt_with_profile(
509 base_prompt: &str,
510 enhance_prompt: Option<&str>,
511 workspace_path: Option<&str>,
512) -> (String, PromptCompositionProfile) {
513 let mut merged_prompt = base_prompt.to_string();
514
515 let enhancement = enhance_prompt
516 .map(str::trim)
517 .filter(|enhancement| !enhancement.is_empty())
518 .map(ToString::to_string);
519 if let Some(enhancement) = enhancement.as_ref() {
520 merged_prompt.push_str("\n\n");
521 merged_prompt.push_str(enhancement.as_str());
522 }
523
524 let workspace_context = workspace_path
525 .map(str::trim)
526 .filter(|workspace_path| !workspace_path.is_empty())
527 .and_then(build_workspace_prompt_context);
528 if let Some(workspace_context) = workspace_context.as_ref() {
529 merged_prompt.push_str("\n\n");
530 merged_prompt.push_str(workspace_context.as_str());
531 }
532
533 let env_context = build_env_prompt_context();
534 if let Some(env_context) = env_context.as_ref() {
535 merged_prompt.push_str("\n\n");
536 merged_prompt.push_str(env_context.as_str());
537 }
538
539 let profile = PromptCompositionProfile {
540 version: PROMPT_COMPOSER_VERSION,
541 fingerprint: build_prompt_fingerprint(
542 base_prompt,
543 enhancement.as_deref(),
544 workspace_context.as_deref(),
545 env_context.as_deref(),
546 ),
547 has_enhancement: enhancement.is_some(),
548 has_workspace_context: workspace_context.is_some(),
549 has_env_context: env_context.is_some(),
550 base_len: base_prompt.len(),
551 enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
552 workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
553 env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
554 final_len: merged_prompt.len(),
555 };
556
557 (merged_prompt, profile)
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use crate::session_app::errors::{SessionLoadError, SessionSaveError};
564 use async_trait::async_trait;
565
566 struct InMemorySessionAccess;
567
568 #[async_trait]
569 impl SessionAccess for InMemorySessionAccess {
570 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
571 Ok(None)
572 }
573
574 async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
575 Ok(Session::new(id, model))
576 }
577
578 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
579 Ok(None)
580 }
581
582 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
583 Ok(())
584 }
585
586 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
587 Ok(())
588 }
589 }
590
591 fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
592 super::super::types::ChatTurnInput {
593 session_id: "session-enhance".to_string(),
594 model: "gpt-5".to_string(),
595 model_ref: None,
596 provider: None,
597 message: "hello".to_string(),
598 system_prompt: Some("Base prompt".to_string()),
599 enhance_prompt: enhance_prompt.map(ToString::to_string),
600 workspace_path: None,
601 selected_skill_ids: None,
602 workflow_selection: None,
603 orchestration_opt_in: None,
604 copilot_conclusion_with_options_enhancement_enabled: None,
605 data_dir: None,
606 }
607 }
608
609 fn system_message_content(session: &Session) -> String {
610 session
611 .messages
612 .iter()
613 .find(|message| matches!(message.role, Role::System))
614 .map(|message| message.content.clone())
615 .expect("session should have a system message")
616 }
617
618 fn active_workflow(id: &str, revision: u64) -> ActiveWorkflow {
619 ActiveWorkflow {
620 id: id.to_string(),
621 source: bamboo_skills::WorkflowSource::User,
622 revision,
623 kind: bamboo_skills::WorkflowKind::Instruction,
624 args: serde_json::json!({}),
625 invoked_by: bamboo_skills::WorkflowInvokedBy::User,
626 activated_at: chrono::Utc::now(),
627 status: WorkflowActivationStatus::Active,
628 diagnostic: None,
629 context_fingerprint: Some("fingerprint".to_string()),
630 dynamic_context: Vec::new(),
631 }
632 }
633
634 #[test]
635 fn typed_workflow_selection_is_authoritative_over_legacy_ids_and_hint() {
636 let mut session = Session::new("typed-selection", "model");
637 let selection = WorkflowSelection {
638 id: "review".to_string(),
639 source: bamboo_skills::WorkflowSource::User,
640 revision: 7,
641 args: serde_json::json!({"depth": "full"}),
642 };
643 resolve_workflow_selection(
644 &mut session,
645 Some(&selection),
646 Some(&["plan".to_string()]),
647 "use skill plan",
648 )
649 .expect("typed selection");
650 assert_eq!(
651 session.selected_skill_ids(),
652 Some(vec!["review".to_string()])
653 );
654 assert_eq!(
655 session
656 .metadata
657 .get(WORKFLOW_SELECTION_METADATA_KEY)
658 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
659 Some(selection)
660 );
661 }
662
663 #[test]
664 fn active_workflow_survives_turn_without_new_selection() {
665 let mut session = Session::new("active-selection", "model");
666 session.metadata.insert(
667 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
668 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
669 );
670 resolve_workflow_selection(&mut session, None, None, "use skill plan")
671 .expect("preserve active");
672 assert_eq!(
673 session.selected_skill_ids(),
674 Some(vec!["review".to_string()])
675 );
676 assert!(session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
677 }
678
679 #[test]
680 fn explicit_empty_legacy_selection_deactivates_active_workflow() {
681 let mut session = Session::new("deactivate-selection", "model");
682 session.metadata.insert(
683 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
684 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
685 );
686 resolve_workflow_selection(&mut session, None, Some(&[]), "plain message")
687 .expect("deactivate");
688 assert!(session.selected_skill_ids().is_none());
689 assert!(!session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
690 assert!(session
691 .metadata
692 .get(WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)
693 .is_some_and(|event| event.contains("workflow.deactivated")));
694 }
695
696 #[tokio::test]
699 async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
700 let session = prepare_chat_turn(
701 &InMemorySessionAccess,
702 chat_turn_input(Some("Extra enhancement guidance")),
703 "",
704 "Builtin fallback",
705 )
706 .await
707 .expect("prepare_chat_turn should succeed");
708
709 let system_prompt = system_message_content(&session);
710 assert!(system_prompt.starts_with("Base prompt"));
711 assert!(system_prompt.contains("Extra enhancement guidance"));
712 assert_eq!(
713 session.enhance_prompt().as_deref(),
714 Some("Extra enhancement guidance")
715 );
716 assert!(session
717 .metadata
718 .get(PROMPT_COMPONENT_FLAGS_KEY)
719 .is_some_and(|flags| flags.contains("enhance=1")));
720 }
721
722 #[tokio::test]
723 async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
724 let session = prepare_chat_turn(
725 &InMemorySessionAccess,
726 chat_turn_input(None),
727 "",
728 "Builtin fallback",
729 )
730 .await
731 .expect("prepare_chat_turn should succeed");
732
733 let system_prompt = system_message_content(&session);
734 assert!(system_prompt.starts_with("Base prompt"));
735 assert!(session.enhance_prompt().is_none());
736 assert!(session
737 .metadata
738 .get(PROMPT_COMPONENT_FLAGS_KEY)
739 .is_some_and(|flags| flags.contains("enhance=0")));
740 }
741
742 #[test]
748 fn default_workspace_from_data_dir_reads_configured_work_area() {
749 let temp = tempfile::tempdir().expect("temp dir");
750 let workspace = temp.path().join("default-workspace");
751 std::fs::create_dir_all(&workspace).expect("workspace dir");
752 std::fs::write(
753 temp.path().join("config.json"),
754 serde_json::json!({
755 "default_work_area": { "path": workspace.to_string_lossy() }
756 })
757 .to_string(),
758 )
759 .expect("write config.json");
760
761 let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
762 assert_eq!(
766 resolved.canonicalize().unwrap(),
767 workspace.canonicalize().unwrap()
768 );
769 }
770
771 #[test]
772 fn default_workspace_from_data_dir_is_none_without_config() {
773 let temp = tempfile::tempdir().expect("temp dir");
774 assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
775 }
776}