1use std::path::{Path, PathBuf};
25
26use serde::Serialize;
27
28use super::agent_config::{is_agentmux_managed_instructions, AGENTMUX_MEMORY_FILENAME};
29use super::memory_record::sha256_hex;
30use super::providers;
31
32pub const MAX_INSTRUCTION_FILE_BYTES: u64 = 10 * 1024 * 1024;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "lowercase")]
45pub enum InstructionOwner {
46 Agentmux,
48 Foreign,
50}
51
52#[derive(Debug, Clone, Serialize)]
54pub struct ProjectInstructionFile {
55 pub path: String,
57 pub exists: bool,
61 pub size_bytes: u64,
62 pub content_hash: String,
66 pub owner: InstructionOwner,
67 pub content: String,
69 pub truncated: bool,
71 pub error: Option<String>,
79}
80
81fn paths_for_provider(provider_id: &str, working_dir: &Path) -> Vec<String> {
92 let Some(provider) = providers::get_provider(provider_id) else {
93 return Vec::new();
94 };
95 let mut paths: Vec<String> = provider
96 .native_instruction_sources
97 .iter()
98 .map(|s| s.to_string())
99 .collect();
100
101 if provider.startup_instructions_filename == Some("CLAUDE.md") {
111 let import_needle = format!("@{AGENTMUX_MEMORY_FILENAME}");
112 let imported = std::fs::read_to_string(working_dir.join("CLAUDE.md"))
113 .map(|c| c.contains(&import_needle))
114 .unwrap_or(false);
115 let side_file = AGENTMUX_MEMORY_FILENAME.to_string();
116 if imported && !paths.contains(&side_file) {
117 paths.push(side_file);
118 }
119 }
120 paths
121}
122
123pub fn effective_working_dir(working_directory: &str, agent_name: &str) -> String {
133 let raw = if working_directory.trim().is_empty() {
134 crate::backend::storage::agents::default_agent_working_dir(agent_name)
135 } else {
136 working_directory.to_string()
137 };
138 if raw.starts_with("~/") || raw == "~" {
139 if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
140 return format!("{}/{}", home, raw.trim_start_matches("~/").trim_start_matches('~'));
141 }
142 }
143 raw
144}
145
146fn classify_owner(rel_path: &str, content: &str) -> InstructionOwner {
163 if rel_path == AGENTMUX_MEMORY_FILENAME {
164 return InstructionOwner::Agentmux;
165 }
166 if is_agentmux_managed_instructions(content) {
167 InstructionOwner::Agentmux
168 } else {
169 InstructionOwner::Foreign
170 }
171}
172
173fn read_one(working_dir: &Path, rel_path: &str) -> ProjectInstructionFile {
175 let full = working_dir.join(rel_path);
176 let mut out = ProjectInstructionFile {
177 path: rel_path.to_string(),
178 exists: false,
179 size_bytes: 0,
180 content_hash: String::new(),
181 owner: InstructionOwner::Foreign,
182 content: String::new(),
183 truncated: false,
184 error: None,
185 };
186
187 let meta = match std::fs::metadata(&full) {
188 Ok(m) => m,
189 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
190 Err(e) => {
191 out.exists = true;
192 out.error = Some(format!("stat failed: {e}"));
193 return out;
194 }
195 };
196 if !meta.is_file() {
197 out.exists = true;
200 out.error = Some("not a regular file".to_string());
201 return out;
202 }
203
204 out.exists = true;
205 out.size_bytes = meta.len();
206 if meta.len() > MAX_INSTRUCTION_FILE_BYTES {
207 out.truncated = true;
208 out.error = Some(format!(
209 "file is {} bytes, over the {MAX_INSTRUCTION_FILE_BYTES}-byte read limit",
210 meta.len()
211 ));
212 return out;
213 }
214
215 match std::fs::read(&full) {
216 Ok(bytes) => {
217 out.content_hash = sha256_hex(&bytes);
221 match String::from_utf8(bytes) {
222 Ok(text) => {
223 out.owner = classify_owner(rel_path, &text);
224 out.content = text;
225 }
226 Err(_) => {
227 out.error = Some("not valid UTF-8".to_string());
228 }
229 }
230 }
231 Err(e) => out.error = Some(format!("read failed: {e}")),
232 }
233 out
234}
235
236pub const MAX_SCANNED_INSTRUCTION_FILES: usize = 200;
245
246pub const MAX_TOTAL_INSTRUCTION_BYTES: u64 = 8 * 1024 * 1024;
253
254const MAX_SCANNED_DIRECTORIES: usize = 500;
265
266fn scan_dir(
289 working_dir: &Path,
290 scan: &providers::InstructionDirScan,
291 out: &mut Vec<String>,
292) -> ScanOutcome {
293 fn walk(
294 root: &Path,
295 dir: &Path,
296 suffix: &str,
297 recursive: bool,
298 found: &mut Vec<String>,
299 dirs_visited: &mut usize,
300 ) {
301 if found.len() > MAX_SCANNED_INSTRUCTION_FILES || *dirs_visited >= MAX_SCANNED_DIRECTORIES {
302 return;
303 }
304 *dirs_visited += 1;
305
306 let Ok(entries) = std::fs::read_dir(dir) else {
307 return;
310 };
311 let mut entries: Vec<std::fs::DirEntry> = entries.flatten().collect();
313 entries.sort_by_key(|e| e.file_name());
314
315 for entry in entries {
316 if found.len() > MAX_SCANNED_INSTRUCTION_FILES {
317 return;
318 }
319 let path = entry.path();
320 let Ok(file_type) = entry.file_type() else { continue };
321 if file_type.is_symlink() {
322 continue;
323 }
324 if file_type.is_dir() {
325 if recursive {
326 walk(root, &path, suffix, recursive, found, dirs_visited);
327 }
328 continue;
329 }
330 if !path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(suffix)) {
331 continue;
332 }
333 if let Ok(rel) = path.strip_prefix(root) {
334 found.push(rel.to_string_lossy().replace('\\', "/"));
337 }
338 }
339 }
340
341 let scan_root = working_dir.join(scan.dir);
342
343 let Ok(root_canon) = working_dir.canonicalize() else {
353 return ScanOutcome { hit_cap: false, escaped: false };
354 };
355 let Ok(scan_canon) = scan_root.canonicalize() else {
356 return ScanOutcome { hit_cap: false, escaped: false };
358 };
359 if !scan_canon.starts_with(&root_canon) {
360 return ScanOutcome { hit_cap: false, escaped: true };
361 }
362
363 let mut found = Vec::new();
364 let mut dirs_visited = 0usize;
365 walk(
366 working_dir,
367 &scan_root,
368 scan.suffix,
369 scan.recursive,
370 &mut found,
371 &mut dirs_visited,
372 );
373 let hit_cap = found.len() > MAX_SCANNED_INSTRUCTION_FILES;
374 found.truncate(MAX_SCANNED_INSTRUCTION_FILES);
375 found.sort();
376 out.extend(found);
377 ScanOutcome { hit_cap, escaped: false }
378}
379
380struct ScanOutcome {
382 hit_cap: bool,
384 escaped: bool,
388}
389
390pub fn resolve_project_instructions(
400 provider_id: &str,
401 working_directory: &str,
402) -> Vec<ProjectInstructionFile> {
403 if working_directory.trim().is_empty() {
404 return Vec::new();
405 }
406 let root = PathBuf::from(working_directory);
407 let mut files: Vec<ProjectInstructionFile> = paths_for_provider(provider_id, &root)
408 .into_iter()
409 .map(|rel| read_one(&root, &rel))
410 .collect();
411
412 let Some(provider) = providers::get_provider(provider_id) else {
413 return files;
414 };
415 for scan in provider.native_instruction_dirs {
416 let mut scanned: Vec<String> = Vec::new();
417 let outcome = scan_dir(&root, scan, &mut scanned);
422
423 for rel in scanned {
424 if files.iter().any(|f| f.path == rel) {
426 continue;
427 }
428 let mut file = read_one(&root, &rel);
429 let used: u64 = files.iter().map(|f| f.content.len() as u64).sum();
437 if used.saturating_add(file.content.len() as u64) > MAX_TOTAL_INSTRUCTION_BYTES {
438 file.content = String::new();
439 file.truncated = true;
440 file.error = Some(format!(
441 "content omitted — the {MAX_TOTAL_INSTRUCTION_BYTES}-byte total budget for this response is exhausted"
442 ));
443 }
444 files.push(file);
445 }
446 if outcome.escaped {
447 files.push(ProjectInstructionFile {
448 path: scan.dir.to_string(),
449 exists: true,
450 size_bytes: 0,
451 content_hash: String::new(),
452 owner: InstructionOwner::Foreign,
453 content: String::new(),
454 truncated: false,
455 error: Some(format!(
456 "{} resolves outside the working directory — not read",
457 scan.dir
458 )),
459 });
460 }
461 if outcome.hit_cap {
462 files.push(ProjectInstructionFile {
463 path: format!("{}/…", scan.dir),
464 exists: true,
465 size_bytes: 0,
466 content_hash: String::new(),
467 owner: InstructionOwner::Foreign,
468 content: String::new(),
469 truncated: true,
470 error: Some(format!(
471 "more than {MAX_SCANNED_INSTRUCTION_FILES} files match {}/**/*{} — only the first {MAX_SCANNED_INSTRUCTION_FILES} are listed",
472 scan.dir, scan.suffix
473 )),
474 });
475 }
476 }
477 files
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::backend::agent_config::CLAUDE_MD_MANAGED_MARKER;
484
485 fn write(dir: &Path, rel: &str, content: &str) {
486 let full = dir.join(rel);
487 if let Some(parent) = full.parent() {
488 std::fs::create_dir_all(parent).unwrap();
489 }
490 std::fs::write(full, content).unwrap();
491 }
492
493 #[test]
494 fn copilot_resolves_all_four_of_its_sources_not_just_the_write_target() {
495 let dir = tempfile::tempdir().unwrap();
498 write(dir.path(), "AGENTS.md", "agents");
499 write(dir.path(), ".github/copilot-instructions.md", "copilot");
500 write(dir.path(), "CLAUDE.md", "claude");
501 let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
504 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
505 assert!(paths.contains(&"AGENTS.md"), "{paths:?}");
506 assert!(paths.contains(&".github/copilot-instructions.md"), "{paths:?}");
507 assert!(paths.contains(&"CLAUDE.md"), "{paths:?}");
508 assert!(paths.contains(&"GEMINI.md"), "{paths:?}");
509
510 let gemini = files.iter().find(|f| f.path == "GEMINI.md").unwrap();
511 assert!(!gemini.exists, "an absent source must be reported, not dropped");
512 assert!(gemini.content_hash.is_empty());
513 }
514
515 #[test]
516 fn a_repo_owned_file_reads_as_foreign_and_an_agentmux_one_does_not() {
517 let dir = tempfile::tempdir().unwrap();
518 write(dir.path(), "CLAUDE.md", "# My project\n\nHouse rules.\n");
519 let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
520 let claude_md = files.iter().find(|f| f.path == "CLAUDE.md").unwrap();
521 assert_eq!(claude_md.owner, InstructionOwner::Foreign);
522 assert_eq!(claude_md.content, "# My project\n\nHouse rules.\n");
523 assert!(!claude_md.content_hash.is_empty());
524
525 write(
526 dir.path(),
527 "CLAUDE.md",
528 &format!("{CLAUDE_MD_MANAGED_MARKER}\n\ngenerated\n"),
529 );
530 let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
531 let claude_md = files.iter().find(|f| f.path == "CLAUDE.md").unwrap();
532 assert_eq!(claude_md.owner, InstructionOwner::Agentmux);
533 }
534
535 #[test]
536 fn claude_includes_the_agentmux_side_file_the_import_line_points_at() {
537 let dir = tempfile::tempdir().unwrap();
538 write(dir.path(), AGENTMUX_MEMORY_FILENAME, "side file");
539 write(
542 dir.path(),
543 "CLAUDE.md",
544 &format!("# Repo rules
545
546@{AGENTMUX_MEMORY_FILENAME}
547"),
548 );
549 let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
550 let side = files
551 .iter()
552 .find(|f| f.path == AGENTMUX_MEMORY_FILENAME)
553 .expect("the @-imported side file is genuinely read");
554 assert!(side.exists);
555 assert_eq!(side.content, "side file");
556 assert_eq!(
561 side.owner,
562 InstructionOwner::Agentmux,
563 "the side file is AgentMux's by construction, marker or not"
564 );
565 }
566
567 #[test]
568 fn only_the_side_file_gets_ownership_by_path() {
569 let dir = tempfile::tempdir().unwrap();
573 write(dir.path(), "CLAUDE.md", "no marker here");
574 let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
575 assert_eq!(
576 files.iter().find(|f| f.path == "CLAUDE.md").unwrap().owner,
577 InstructionOwner::Foreign
578 );
579 }
580
581 #[test]
582 fn a_provider_without_a_native_convention_resolves_to_nothing() {
583 let dir = tempfile::tempdir().unwrap();
584 write(dir.path(), "CLAUDE.md", "not read by kimi");
585 assert!(resolve_project_instructions("kimi", dir.path().to_str().unwrap()).is_empty());
587 }
588
589 #[test]
590 fn a_blank_working_directory_or_unknown_provider_is_empty_not_an_error() {
591 assert!(resolve_project_instructions("claude", "").is_empty());
592 assert!(resolve_project_instructions("claude", " ").is_empty());
593 let dir = tempfile::tempdir().unwrap();
594 assert!(resolve_project_instructions("nope", dir.path().to_str().unwrap()).is_empty());
595 }
596
597 #[test]
598 fn the_hash_changes_when_the_file_does() {
599 let dir = tempfile::tempdir().unwrap();
600 write(dir.path(), "CLAUDE.md", "before");
601 let before = resolve_project_instructions("claude", dir.path().to_str().unwrap());
602 let h1 = before.iter().find(|f| f.path == "CLAUDE.md").unwrap().content_hash.clone();
603
604 write(dir.path(), "CLAUDE.md", "after");
605 let after = resolve_project_instructions("claude", dir.path().to_str().unwrap());
606 let h2 = &after.iter().find(|f| f.path == "CLAUDE.md").unwrap().content_hash;
607
608 assert_ne!(&h1, h2, "a changed file must produce a different hash");
609 }
610
611 #[test]
612 fn copilots_path_scoped_instructions_are_found_by_scan() {
613 let dir = tempfile::tempdir().unwrap();
616 write(dir.path(), ".github/instructions/rust.instructions.md", "rust rules");
617 write(dir.path(), ".github/instructions/deep/ts.instructions.md", "ts rules");
618 write(dir.path(), ".github/instructions/README.md", "readme");
620
621 let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
622 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
623 assert!(
624 paths.contains(&".github/instructions/rust.instructions.md"),
625 "{paths:?}"
626 );
627 assert!(
628 paths.contains(&".github/instructions/deep/ts.instructions.md"),
629 "recursive, per the `**`: {paths:?}"
630 );
631 assert!(
632 !paths.contains(&".github/instructions/README.md"),
633 "only the declared suffix qualifies: {paths:?}"
634 );
635
636 let rust = files
637 .iter()
638 .find(|f| f.path.ends_with("rust.instructions.md"))
639 .unwrap();
640 assert_eq!(rust.content, "rust rules");
641 assert!(!rust.content_hash.is_empty());
642 }
643
644 #[test]
645 fn a_provider_with_no_scan_directory_ignores_one_that_exists() {
646 let dir = tempfile::tempdir().unwrap();
647 write(dir.path(), ".github/instructions/x.instructions.md", "not for claude");
648 let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
649 assert!(
650 !files.iter().any(|f| f.path.contains(".github/instructions")),
651 "claude declares no scan directory"
652 );
653 }
654
655 #[test]
656 fn an_oversized_scan_reports_the_overflow_instead_of_a_silent_prefix() {
657 let dir = tempfile::tempdir().unwrap();
658 for i in 0..(MAX_SCANNED_INSTRUCTION_FILES + 5) {
659 write(
660 dir.path(),
661 &format!(".github/instructions/f{i}.instructions.md"),
662 "x",
663 );
664 }
665 let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
666 let scanned = files
667 .iter()
668 .filter(|f| f.path.starts_with(".github/instructions/") && !f.path.ends_with('…'))
669 .count();
670 assert_eq!(scanned, MAX_SCANNED_INSTRUCTION_FILES);
671 let marker = files
672 .iter()
673 .find(|f| f.path.ends_with('…'))
674 .expect("overflow must be reported, not silently truncated");
675 assert!(marker.truncated);
676 assert!(marker.error.as_deref().unwrap_or("").contains("only the first"));
677 }
678
679 #[test]
680 fn scan_results_are_ordered_the_same_way_every_time() {
681 let dir = tempfile::tempdir().unwrap();
682 for name in ["c", "a", "b"] {
683 write(
684 dir.path(),
685 &format!(".github/instructions/{name}.instructions.md"),
686 "x",
687 );
688 }
689 let first = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
690 let second = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
691 let p1: Vec<&str> = first.iter().map(|f| f.path.as_str()).collect();
692 let p2: Vec<&str> = second.iter().map(|f| f.path.as_str()).collect();
693 assert_eq!(p1, p2, "directory iteration order must not leak through");
694 }
695
696 #[test]
697 fn a_directory_in_the_files_place_is_reported_not_silently_absent() {
698 let dir = tempfile::tempdir().unwrap();
699 std::fs::create_dir_all(dir.path().join("CLAUDE.md")).unwrap();
700 let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
701 let claude_md = files.iter().find(|f| f.path == "CLAUDE.md").unwrap();
702 assert!(claude_md.exists);
703 assert!(claude_md.error.is_some(), "must not read as absent");
704 assert!(claude_md.content.is_empty());
705 }
706}
707
708#[cfg(test)]
709mod review_fix_tests {
710 use super::*;
711 use crate::backend::agent_config::CLAUDE_MD_MANAGED_MARKER;
712
713 fn write(dir: &Path, rel: &str, content: &str) {
714 let full = dir.join(rel);
715 if let Some(parent) = full.parent() {
716 std::fs::create_dir_all(parent).unwrap();
717 }
718 std::fs::write(full, content).unwrap();
719 }
720
721 #[test]
722 fn the_side_file_is_only_reported_when_claude_md_actually_imports_it() {
723 let dir = tempfile::tempdir().unwrap();
728 write(dir.path(), AGENTMUX_MEMORY_FILENAME, "stale side file");
729 write(
730 dir.path(),
731 "CLAUDE.md",
732 &format!("{CLAUDE_MD_MANAGED_MARKER}\n\nowned by agentmux, no import\n"),
733 );
734
735 let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
736 assert!(
737 !files.iter().any(|f| f.path == AGENTMUX_MEMORY_FILENAME),
738 "no @-import line means the agent does not read it"
739 );
740
741 write(
743 dir.path(),
744 "CLAUDE.md",
745 &format!("# Repo rules\n\n@{AGENTMUX_MEMORY_FILENAME}\n"),
746 );
747 let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
748 let side = files
749 .iter()
750 .find(|f| f.path == AGENTMUX_MEMORY_FILENAME)
751 .expect("an active import means it is read");
752 assert_eq!(side.owner, InstructionOwner::Agentmux);
753 }
754
755 #[test]
756 fn a_symlinked_directory_inside_a_scan_is_not_followed() {
757 let dir = tempfile::tempdir().unwrap();
761 let outside = tempfile::tempdir().unwrap();
762 write(outside.path(), "secret.instructions.md", "not part of this project");
763 std::fs::create_dir_all(dir.path().join(".github/instructions")).unwrap();
764 write(dir.path(), ".github/instructions/own.instructions.md", "ours");
765
766 let link = dir.path().join(".github/instructions/elsewhere");
767 #[cfg(unix)]
768 let made = std::os::unix::fs::symlink(outside.path(), &link).is_ok();
769 #[cfg(windows)]
770 let made = std::os::windows::fs::symlink_dir(outside.path(), &link).is_ok();
771 if !made {
772 return;
775 }
776
777 let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
778 assert!(
779 files.iter().any(|f| f.path.ends_with("own.instructions.md")),
780 "the real file must still be found"
781 );
782 assert!(
783 !files.iter().any(|f| f.path.contains("secret")),
784 "a symlink must not carry the scan outside the working directory"
785 );
786 }
787
788 #[test]
789 fn a_symlinked_scan_root_is_refused_and_reported() {
790 let dir = tempfile::tempdir().unwrap();
796 let outside = tempfile::tempdir().unwrap();
797 write(outside.path(), "secret.instructions.md", "not part of this project");
798
799 std::fs::create_dir_all(dir.path().join(".github")).unwrap();
800 let link = dir.path().join(".github/instructions");
801 #[cfg(unix)]
802 let made = std::os::unix::fs::symlink(outside.path(), &link).is_ok();
803 #[cfg(windows)]
804 let made = std::os::windows::fs::symlink_dir(outside.path(), &link).is_ok();
805 if !made {
806 return; }
808
809 let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
810 assert!(
811 !files.iter().any(|f| f.path.contains("secret")),
812 "a symlinked scan root must not be read: {:?}",
813 files.iter().map(|f| &f.path).collect::<Vec<_>>()
814 );
815 let refused = files
816 .iter()
817 .find(|f| f.path == ".github/instructions")
818 .expect("the refusal must be reported, not silent");
819 assert!(refused
820 .error
821 .as_deref()
822 .unwrap_or("")
823 .contains("outside the working directory"));
824 }
825
826 #[test]
827 fn an_overflowing_scan_stops_early_and_still_returns_the_same_set() {
828 let dir = tempfile::tempdir().unwrap();
834 for i in 0..(MAX_SCANNED_INSTRUCTION_FILES + 25) {
835 write(
836 dir.path(),
837 &format!(".github/instructions/f{i:04}.instructions.md"),
838 "x",
839 );
840 }
841
842 let first = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
843 let second = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
844 let p1: Vec<&str> = first.iter().map(|f| f.path.as_str()).collect();
845 let p2: Vec<&str> = second.iter().map(|f| f.path.as_str()).collect();
846 assert_eq!(p1, p2, "an early stop must still be reproducible");
847
848 assert!(
849 first.iter().any(|f| f.path.ends_with('…')),
850 "hitting the cap must be reported"
851 );
852 let listed = first
853 .iter()
854 .filter(|f| f.path.starts_with(".github/instructions/") && !f.path.ends_with('…'))
855 .count();
856 assert_eq!(listed, MAX_SCANNED_INSTRUCTION_FILES);
857 }
858
859 #[test]
860 fn a_blank_working_directory_resolves_to_the_launch_default() {
861 let resolved = effective_working_dir("", "My Agent");
865 assert!(!resolved.trim().is_empty(), "must not resolve to nothing");
866 let default_raw = crate::backend::storage::agents::default_agent_working_dir("My Agent");
869 assert!(default_raw.starts_with('~'), "precondition: the default is a tilde path");
870 assert!(!resolved.starts_with('~'), "must be expanded: {resolved}");
871 assert!(
872 resolved.ends_with(default_raw.trim_start_matches("~/")),
873 "must be the same directory, expanded: {resolved} vs {default_raw}"
874 );
875 }
876
877 #[test]
878 fn a_tilde_path_is_expanded_the_way_launch_expands_it() {
879 let expanded = effective_working_dir("~/projects/x", "ignored");
880 assert!(!expanded.starts_with('~'), "got {expanded}");
881 assert!(expanded.ends_with("projects/x"), "got {expanded}");
882 }
883
884 #[test]
885 fn an_explicit_working_directory_is_left_alone() {
886 let dir = tempfile::tempdir().unwrap();
887 let p = dir.path().to_str().unwrap();
888 assert_eq!(effective_working_dir(p, "ignored"), p);
889 }
890}