1use std::borrow::Cow;
9use std::env;
10use std::path::{Component, Path, PathBuf};
11
12use url::Url;
13
14pub const PACKAGE_NAME: &str = "@earendil-works/pi-coding-agent";
16
17pub const APP_NAME: &str = "pi";
19
20pub const APP_TITLE: &str = "π";
22pub const VERSION: &str = env!("CARGO_PKG_VERSION");
24
25pub const CONFIG_DIR_NAME: &str = ".pi";
27
28#[must_use]
36pub fn is_official_distribution() -> bool {
37 PACKAGE_NAME == "@earendil-works/pi-coding-agent"
38 && APP_NAME == "pi"
39 && CONFIG_DIR_NAME == ".pi"
40}
41
42pub const ENV_AGENT_DIR: &str = "PI_CODING_AGENT_DIR";
44
45pub const ENV_SESSION_DIR: &str = "PI_CODING_AGENT_SESSION_DIR";
47
48pub const ENV_PACKAGE_DIR: &str = "PI_PACKAGE_DIR";
50
51pub const ENV_SHARE_VIEWER_URL: &str = "PI_SHARE_VIEWER_URL";
53
54pub const DEFAULT_SHARE_VIEWER_URL: &str = "https://pi.dev/session/";
56
57#[derive(Clone, Copy, Debug)]
62pub struct PathInputOptions<'a> {
63 trim_flag: u8,
64 expand_tilde_flag: u8,
65 pub home_dir: Option<&'a Path>,
68 strip_at_prefix_flag: u8,
69 normalize_unicode_spaces_flag: u8,
70}
71
72impl Default for PathInputOptions<'_> {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78impl<'a> PathInputOptions<'a> {
79 #[must_use]
81 pub const fn new() -> Self {
82 Self {
83 trim_flag: 0,
84 expand_tilde_flag: 1,
85 home_dir: None,
86 strip_at_prefix_flag: 0,
87 normalize_unicode_spaces_flag: 0,
88 }
89 }
90
91 #[must_use]
93 pub const fn trim(mut self, trim: bool) -> Self {
94 self.trim_flag = if trim { 1 } else { 0 };
95 self
96 }
97
98 #[must_use]
100 pub const fn trims_input(self) -> bool {
101 self.trim_flag != 0
102 }
103
104 #[must_use]
106 pub const fn expand_tilde(mut self, expand_tilde: bool) -> Self {
107 self.expand_tilde_flag = if expand_tilde { 1 } else { 0 };
108 self
109 }
110
111 #[must_use]
113 pub const fn expands_tilde(self) -> bool {
114 self.expand_tilde_flag != 0
115 }
116
117 #[must_use]
119 pub const fn home_dir(mut self, home_dir: Option<&'a Path>) -> Self {
120 self.home_dir = home_dir;
121 self
122 }
123
124 #[must_use]
126 pub const fn strip_at_prefix(mut self, strip_at_prefix: bool) -> Self {
127 self.strip_at_prefix_flag = if strip_at_prefix { 1 } else { 0 };
128 self
129 }
130
131 #[must_use]
133 pub const fn strips_at_prefix(self) -> bool {
134 self.strip_at_prefix_flag != 0
135 }
136
137 #[must_use]
139 pub const fn normalize_unicode_spaces(mut self, normalize_unicode_spaces: bool) -> Self {
140 self.normalize_unicode_spaces_flag = if normalize_unicode_spaces { 1 } else { 0 };
141 self
142 }
143
144 #[must_use]
146 pub const fn normalizes_unicode_spaces(self) -> bool {
147 self.normalize_unicode_spaces_flag != 0
148 }
149}
150
151#[must_use]
153pub fn expand_tilde_path(path: impl AsRef<str>) -> PathBuf {
154 expand_tilde_path_with(path.as_ref(), process_home_dir().as_deref())
155}
156
157#[must_use]
162pub fn expand_tilde_path_with(path: &str, home_dir: Option<&Path>) -> PathBuf {
163 normalize_path(path, PathInputOptions::new().home_dir(home_dir))
164}
165
166#[must_use]
178pub fn normalize_path(input: &str, options: PathInputOptions<'_>) -> PathBuf {
179 let mut normalized = if options.trims_input() {
180 input.trim().to_owned()
181 } else {
182 input.to_owned()
183 };
184
185 if options.normalizes_unicode_spaces() {
186 normalized = replace_unicode_spaces(&normalized);
187 }
188
189 if options.strips_at_prefix() && normalized.starts_with('@') {
190 normalized.remove(0);
191 }
192
193 if options.expands_tilde() {
194 let home = options.home_dir.map_or_else(
195 || process_home_dir().map(Cow::Owned),
196 |home| Some(Cow::Borrowed(home)),
197 );
198 if let Some(home) = home.as_deref() {
199 if normalized == "~" {
200 return home.to_path_buf();
201 }
202 if let Some(rest) = normalized.strip_prefix("~/") {
203 return home.join(rest);
204 }
205 if cfg!(windows)
206 && let Some(rest) = normalized.strip_prefix("~\\")
207 {
208 return home.join(rest);
209 }
210 }
211 }
212
213 if normalized.starts_with("file://")
214 && let Ok(url) = Url::parse(&normalized)
215 && let Ok(path) = url.to_file_path()
216 {
217 return path;
218 }
219
220 PathBuf::from(normalized)
221}
222
223#[must_use]
228pub fn is_local_path(value: &str) -> bool {
229 let trimmed = value.trim();
230 !(trimmed.starts_with("npm:")
231 || trimmed.starts_with("git:")
232 || trimmed.starts_with("github:")
233 || trimmed.starts_with("http:")
234 || trimmed.starts_with("https:")
235 || trimmed.starts_with("ssh:"))
236}
237
238#[must_use]
240pub fn resolve_path(input: impl AsRef<str>) -> PathBuf {
241 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
242 resolve_path_with(
243 input.as_ref(),
244 &cwd,
245 PathInputOptions::new().home_dir(process_home_dir().as_deref()),
246 )
247}
248
249#[must_use]
255pub fn resolve_path_with(input: &str, base_dir: &Path, options: PathInputOptions<'_>) -> PathBuf {
256 let normalized = normalize_path(input, options);
257 let base_options = PathInputOptions::new().home_dir(options.home_dir);
259 let normalized_base = if let Some(base_str) = base_dir.to_str() {
260 normalize_path(base_str, base_options)
261 } else {
262 base_dir.to_path_buf()
263 };
264
265 if normalized.is_absolute() {
266 resolve_like_node(None, &normalized)
267 } else {
268 resolve_like_node(Some(&normalized_base), &normalized)
269 }
270}
271
272#[must_use]
277pub fn canonicalize_path(path: impl AsRef<Path>) -> PathBuf {
278 let path = path.as_ref();
279 match path.canonicalize() {
280 Ok(canonical) => canonical,
281 Err(_) => path.to_path_buf(),
282 }
283}
284
285#[must_use]
291pub fn get_cwd_relative_path(file_path: impl AsRef<str>, cwd: impl AsRef<str>) -> Option<PathBuf> {
292 get_cwd_relative_path_with(
293 file_path.as_ref(),
294 cwd.as_ref(),
295 process_home_dir().as_deref(),
296 )
297}
298
299#[must_use]
301pub fn get_cwd_relative_path_with(
302 file_path: &str,
303 cwd: &str,
304 home_dir: Option<&Path>,
305) -> Option<PathBuf> {
306 let options = PathInputOptions::new().home_dir(home_dir);
307 let process_cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
308 let resolved_cwd = resolve_path_with(cwd, &process_cwd, options);
310 let resolved_path = resolve_path_with(file_path, &resolved_cwd, options);
311 let relative_path = pathdiff_relative(&resolved_cwd, &resolved_path);
312
313 let relative_str = relative_path.to_string_lossy();
314 let is_inside_cwd = relative_str.is_empty()
315 || (relative_str != ".."
316 && !relative_str.starts_with(&format!("..{}", std::path::MAIN_SEPARATOR))
317 && !relative_path.is_absolute());
318
319 if !is_inside_cwd {
320 return None;
321 }
322 if relative_str.is_empty() {
323 Some(PathBuf::from("."))
324 } else {
325 Some(relative_path)
326 }
327}
328
329#[must_use]
334pub fn format_path_relative_to_cwd_or_absolute(
335 file_path: impl AsRef<str>,
336 cwd: impl AsRef<str>,
337) -> String {
338 format_path_relative_to_cwd_or_absolute_with(
339 file_path.as_ref(),
340 cwd.as_ref(),
341 process_home_dir().as_deref(),
342 )
343}
344
345#[must_use]
347pub fn format_path_relative_to_cwd_or_absolute_with(
348 file_path: &str,
349 cwd: &str,
350 home_dir: Option<&Path>,
351) -> String {
352 let options = PathInputOptions::new().home_dir(home_dir);
353 let absolute_path = resolve_path_with(file_path, Path::new(cwd), options);
354 let absolute_str = absolute_path.to_string_lossy();
355 let display = get_cwd_relative_path_with(absolute_str.as_ref(), cwd, home_dir).map_or_else(
356 || absolute_str.into_owned(),
357 |path| path.to_string_lossy().into_owned(),
358 );
359 display.replace('\\', "/")
360}
361
362#[must_use]
365pub fn get_package_dir() -> PathBuf {
366 get_package_dir_with(
367 env::var_os(ENV_PACKAGE_DIR).map(PathBuf::from).as_deref(),
368 env::current_exe().ok().as_deref(),
369 process_home_dir().as_deref(),
370 )
371}
372
373#[must_use]
380pub fn get_package_dir_with(
381 package_dir_override: Option<&Path>,
382 executable: Option<&Path>,
383 home_dir: Option<&Path>,
384) -> PathBuf {
385 if let Some(override_dir) = package_dir_override {
386 if let Some(text) = override_dir.to_str() {
387 return expand_tilde_path_with(text, home_dir);
388 }
389 return override_dir.to_path_buf();
390 }
391
392 if let Some(exe) = executable {
393 if let Some(parent) = exe.parent()
394 && !parent.as_os_str().is_empty()
395 {
396 return parent.to_path_buf();
397 }
398 return PathBuf::from(".");
399 }
400
401 PathBuf::new()
402}
403
404#[must_use]
406pub fn get_themes_dir() -> PathBuf {
407 get_themes_dir_with(&get_package_dir())
408}
409
410#[must_use]
412pub fn get_themes_dir_with(package_dir: &Path) -> PathBuf {
413 package_dir.join("theme")
414}
415
416#[must_use]
418pub fn get_export_template_dir() -> PathBuf {
419 get_export_template_dir_with(&get_package_dir())
420}
421
422#[must_use]
424pub fn get_export_template_dir_with(package_dir: &Path) -> PathBuf {
425 package_dir.join("export-html")
426}
427
428#[must_use]
430pub fn get_package_json_path() -> PathBuf {
431 get_package_json_path_with(&get_package_dir())
432}
433
434#[must_use]
436pub fn get_package_json_path_with(package_dir: &Path) -> PathBuf {
437 package_dir.join("package.json")
438}
439
440#[must_use]
442pub fn get_readme_path() -> PathBuf {
443 get_readme_path_with(&get_package_dir())
444}
445
446#[must_use]
448pub fn get_readme_path_with(package_dir: &Path) -> PathBuf {
449 resolve_existing_join(package_dir, "README.md")
450}
451
452#[must_use]
454pub fn get_docs_path() -> PathBuf {
455 get_docs_path_with(&get_package_dir())
456}
457
458#[must_use]
460pub fn get_docs_path_with(package_dir: &Path) -> PathBuf {
461 resolve_existing_join(package_dir, "docs")
462}
463
464#[must_use]
466pub fn get_examples_path() -> PathBuf {
467 get_examples_path_with(&get_package_dir())
468}
469
470#[must_use]
472pub fn get_examples_path_with(package_dir: &Path) -> PathBuf {
473 resolve_existing_join(package_dir, "examples")
474}
475
476#[must_use]
478pub fn get_changelog_path() -> PathBuf {
479 get_changelog_path_with(&get_package_dir())
480}
481
482#[must_use]
484pub fn get_changelog_path_with(package_dir: &Path) -> PathBuf {
485 resolve_existing_join(package_dir, "CHANGELOG.md")
486}
487
488#[must_use]
490pub fn get_interactive_assets_dir() -> PathBuf {
491 get_interactive_assets_dir_with(&get_package_dir())
492}
493
494#[must_use]
496pub fn get_interactive_assets_dir_with(package_dir: &Path) -> PathBuf {
497 package_dir.join("assets")
498}
499
500#[must_use]
502pub fn get_bundled_interactive_asset_path(name: impl AsRef<Path>) -> PathBuf {
503 get_bundled_interactive_asset_path_with(&get_package_dir(), name.as_ref())
504}
505
506#[must_use]
508pub fn get_bundled_interactive_asset_path_with(package_dir: &Path, name: &Path) -> PathBuf {
509 get_interactive_assets_dir_with(package_dir).join(name)
510}
511
512#[must_use]
514pub fn get_agent_dir() -> PathBuf {
515 get_agent_dir_with(
516 env::var_os(ENV_AGENT_DIR)
517 .and_then(|value| value.into_string().ok())
518 .as_deref(),
519 process_home_dir().as_deref(),
520 )
521}
522
523#[must_use]
528pub fn get_agent_dir_with(env_agent_dir: Option<&str>, home_dir: Option<&Path>) -> PathBuf {
529 if let Some(env_dir) = env_agent_dir {
530 return expand_tilde_path_with(env_dir, home_dir);
531 }
532 match home_dir {
533 Some(home) => home.join(CONFIG_DIR_NAME).join("agent"),
534 None => PathBuf::from(CONFIG_DIR_NAME).join("agent"),
535 }
536}
537
538#[must_use]
540pub fn get_custom_themes_dir() -> PathBuf {
541 get_custom_themes_dir_with(&get_agent_dir())
542}
543
544#[must_use]
546pub fn get_custom_themes_dir_with(agent_dir: &Path) -> PathBuf {
547 agent_dir.join("themes")
548}
549
550#[must_use]
552pub fn get_models_path() -> PathBuf {
553 get_models_path_with(&get_agent_dir())
554}
555
556#[must_use]
558pub fn get_models_path_with(agent_dir: &Path) -> PathBuf {
559 agent_dir.join("models.json")
560}
561
562#[must_use]
564pub fn get_auth_path() -> PathBuf {
565 get_auth_path_with(&get_agent_dir())
566}
567
568#[must_use]
570pub fn get_auth_path_with(agent_dir: &Path) -> PathBuf {
571 agent_dir.join("auth.json")
572}
573
574#[must_use]
576pub fn get_settings_path() -> PathBuf {
577 get_settings_path_with(&get_agent_dir())
578}
579
580#[must_use]
582pub fn get_settings_path_with(agent_dir: &Path) -> PathBuf {
583 agent_dir.join("settings.json")
584}
585
586#[must_use]
588pub fn get_tools_dir() -> PathBuf {
589 get_tools_dir_with(&get_agent_dir())
590}
591
592#[must_use]
594pub fn get_tools_dir_with(agent_dir: &Path) -> PathBuf {
595 agent_dir.join("tools")
596}
597
598#[must_use]
600pub fn get_bin_dir() -> PathBuf {
601 get_bin_dir_with(&get_agent_dir())
602}
603
604#[must_use]
606pub fn get_bin_dir_with(agent_dir: &Path) -> PathBuf {
607 agent_dir.join("bin")
608}
609
610#[must_use]
612pub fn get_prompts_dir() -> PathBuf {
613 get_prompts_dir_with(&get_agent_dir())
614}
615
616#[must_use]
618pub fn get_prompts_dir_with(agent_dir: &Path) -> PathBuf {
619 agent_dir.join("prompts")
620}
621
622#[must_use]
624pub fn get_sessions_dir() -> PathBuf {
625 get_sessions_dir_with(&get_agent_dir())
626}
627
628#[must_use]
630pub fn get_sessions_dir_with(agent_dir: &Path) -> PathBuf {
631 agent_dir.join("sessions")
632}
633
634#[must_use]
636pub fn get_debug_log_path() -> PathBuf {
637 get_debug_log_path_with(&get_agent_dir())
638}
639
640#[must_use]
642pub fn get_debug_log_path_with(agent_dir: &Path) -> PathBuf {
643 agent_dir.join(format!("{APP_NAME}-debug.log"))
644}
645
646#[must_use]
648pub fn get_share_viewer_url(gist_id: impl AsRef<str>) -> String {
649 get_share_viewer_url_with(
650 gist_id.as_ref(),
651 env::var(ENV_SHARE_VIEWER_URL).ok().as_deref(),
652 )
653}
654
655#[must_use]
660pub fn get_share_viewer_url_with(gist_id: &str, base_override: Option<&str>) -> String {
661 let base = base_override.unwrap_or(DEFAULT_SHARE_VIEWER_URL);
662 format!("{base}#{gist_id}")
663}
664
665fn process_home_dir() -> Option<PathBuf> {
666 dirs::home_dir()
667}
668
669fn resolve_existing_join(package_dir: &Path, child: &str) -> PathBuf {
670 let joined = package_dir.join(child);
671 resolve_like_node(None, &joined)
672}
673
674fn replace_unicode_spaces(input: &str) -> String {
675 input
676 .chars()
677 .map(|ch| match ch {
678 '\u{00A0}' | '\u{2000}' | '\u{2001}' | '\u{2002}' | '\u{2003}' | '\u{2004}'
679 | '\u{2005}' | '\u{2006}' | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}'
680 | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
681 other => other,
682 })
683 .collect()
684}
685
686fn normalize_dot_segments(path: &Path) -> PathBuf {
688 let mut out = PathBuf::new();
689 let mut absolute = false;
690 for component in path.components() {
691 match component {
692 Component::Prefix(prefix) => {
693 out.push(prefix.as_os_str());
694 }
695 Component::RootDir => {
696 out.push(component.as_os_str());
697 absolute = true;
698 }
699 Component::CurDir => {}
700 Component::ParentDir => match out.components().next_back() {
701 Some(Component::Normal(_)) => {
702 out.pop();
703 }
704 Some(Component::ParentDir) | None if !absolute => {
705 out.push("..");
706 }
707 _ => {}
708 },
709 Component::Normal(part) => out.push(part),
710 }
711 }
712 if out.as_os_str().is_empty() {
713 if absolute {
714 PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
715 } else {
716 PathBuf::from(".")
717 }
718 } else {
719 out
720 }
721}
722
723fn resolve_like_node(base: Option<&Path>, path: &Path) -> PathBuf {
725 let joined = if path.is_absolute() {
726 path.to_path_buf()
727 } else if let Some(base) = base {
728 if base.is_absolute() {
729 base.join(path)
730 } else if let Ok(cwd) = env::current_dir() {
731 cwd.join(base).join(path)
732 } else {
733 base.join(path)
734 }
735 } else if let Ok(cwd) = env::current_dir() {
736 cwd.join(path)
737 } else {
738 path.to_path_buf()
739 };
740 normalize_dot_segments(&joined)
741}
742
743fn pathdiff_relative(from: &Path, to: &Path) -> PathBuf {
745 let from_components: Vec<Component<'_>> = from.components().collect();
746 let to_components: Vec<Component<'_>> = to.components().collect();
747
748 let mut common = 0usize;
749 while common < from_components.len()
750 && common < to_components.len()
751 && from_components[common] == to_components[common]
752 {
753 common += 1;
754 }
755
756 let mut out = PathBuf::new();
757 for component in from_components.iter().skip(common) {
758 match component {
759 Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
760 Component::ParentDir | Component::Normal(_) => out.push(".."),
761 }
762 }
763 for component in to_components.iter().skip(common) {
764 out.push(component.as_os_str());
765 }
766 out
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772 use std::fs;
773 use std::sync::atomic::{AtomicU64, Ordering};
774 use std::time::{SystemTime, UNIX_EPOCH};
775
776 type TestResult = Result<(), String>;
777
778 fn unique_temp_dir(label: &str) -> Result<PathBuf, String> {
779 static NEXT: AtomicU64 = AtomicU64::new(0);
780 let nanos = SystemTime::now()
781 .duration_since(UNIX_EPOCH)
782 .map_err(|error| error.to_string())?
783 .as_nanos();
784 let sequence = NEXT.fetch_add(1, Ordering::Relaxed);
785 let dir = env::temp_dir().join(format!(
786 "pi-config-{label}-{}-{nanos}-{sequence}",
787 std::process::id()
788 ));
789 fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
790 Ok(dir)
791 }
792
793 #[test]
794 fn constants_match_reference_defaults() {
795 assert_eq!(PACKAGE_NAME, "@earendil-works/pi-coding-agent");
796 assert_eq!(APP_NAME, "pi");
797 assert_eq!(APP_TITLE, "π");
798 assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
799 assert_eq!(CONFIG_DIR_NAME, ".pi");
800 assert_eq!(ENV_AGENT_DIR, "PI_CODING_AGENT_DIR");
801 assert_eq!(ENV_SESSION_DIR, "PI_CODING_AGENT_SESSION_DIR");
802 assert_eq!(ENV_PACKAGE_DIR, "PI_PACKAGE_DIR");
803 assert_eq!(ENV_SHARE_VIEWER_URL, "PI_SHARE_VIEWER_URL");
804 assert_eq!(DEFAULT_SHARE_VIEWER_URL, "https://pi.dev/session/");
805 }
806
807 #[test]
808 fn agent_dir_defaults_under_home_config() -> TestResult {
809 let home = unique_temp_dir("home")?;
810 let agent = get_agent_dir_with(None, Some(&home));
811 assert_eq!(agent, home.join(".pi").join("agent"));
812 let _ = fs::remove_dir_all(home);
813 Ok(())
814 }
815
816 #[test]
817 fn agent_dir_env_override_is_tilde_expanded() -> TestResult {
818 let home = unique_temp_dir("home-override")?;
819 let agent = get_agent_dir_with(Some("~/custom-agent"), Some(&home));
820 assert_eq!(agent, home.join("custom-agent"));
821
822 let explicit = unique_temp_dir("explicit-agent")?;
823 let explicit_str = explicit.to_string_lossy().into_owned();
824 let absolute = get_agent_dir_with(Some(&explicit_str), Some(&home));
825 assert_eq!(absolute, explicit);
826
827 let _ = fs::remove_dir_all(home);
828 let _ = fs::remove_dir_all(explicit);
829 Ok(())
830 }
831
832 #[test]
833 fn expand_tilde_only_handles_bare_home() -> TestResult {
834 let home = unique_temp_dir("tilde")?;
835 assert_eq!(expand_tilde_path_with("~", Some(&home)), home);
836 assert_eq!(
837 expand_tilde_path_with("~/agent", Some(&home)),
838 home.join("agent")
839 );
840 assert_eq!(
841 expand_tilde_path_with("~user/agent", Some(&home)),
842 PathBuf::from("~user/agent")
843 );
844
845 let abs = unique_temp_dir("abs-path")?;
846 let abs_str = abs.to_string_lossy().into_owned();
847 assert_eq!(expand_tilde_path_with(&abs_str, Some(&home)), abs);
848
849 let _ = fs::remove_dir_all(home);
850 let _ = fs::remove_dir_all(abs);
851 Ok(())
852 }
853
854 #[test]
855 fn package_dir_override_and_executable_fallback() -> TestResult {
856 let home = unique_temp_dir("pkg-home")?;
857 let override_dir = unique_temp_dir("pkg-override")?;
858 let exe_parent = unique_temp_dir("exe-parent")?;
859 let exe = exe_parent.join("pi");
860
861 let from_override = get_package_dir_with(Some(&override_dir), Some(&exe), Some(&home));
862 assert_eq!(from_override, override_dir);
863
864 let tilde_override =
865 get_package_dir_with(Some(Path::new("~/packaged")), Some(&exe), Some(&home));
866 assert_eq!(tilde_override, home.join("packaged"));
867
868 let from_exe = get_package_dir_with(None, Some(&exe), Some(&home));
869 assert_eq!(from_exe, exe_parent);
870
871 let missing = get_package_dir_with(None, None, Some(&home));
872 assert_eq!(missing, PathBuf::new());
873
874 let _ = fs::remove_dir_all(home);
875 let _ = fs::remove_dir_all(override_dir);
876 let _ = fs::remove_dir_all(exe_parent);
877 Ok(())
878 }
879
880 #[test]
881 fn package_child_layout_matches_binary_shipped_paths() -> TestResult {
882 let package = unique_temp_dir("package-layout")?;
883 assert_eq!(get_themes_dir_with(&package), package.join("theme"));
884 assert_eq!(
885 get_export_template_dir_with(&package),
886 package.join("export-html")
887 );
888 assert_eq!(
889 get_package_json_path_with(&package),
890 package.join("package.json")
891 );
892 assert_eq!(
893 get_interactive_assets_dir_with(&package),
894 package.join("assets")
895 );
896 assert_eq!(
897 get_bundled_interactive_asset_path_with(&package, Path::new("icon.png")),
898 package.join("assets").join("icon.png")
899 );
900
901 let readme = get_readme_path_with(&package);
902 assert!(readme.ends_with("README.md"));
903 assert!(readme.is_absolute());
904 assert_eq!(
905 readme.file_name().and_then(|name| name.to_str()),
906 Some("README.md")
907 );
908 assert!(get_docs_path_with(&package).ends_with("docs"));
909 assert!(get_examples_path_with(&package).ends_with("examples"));
910 assert!(get_changelog_path_with(&package).ends_with("CHANGELOG.md"));
911
912 let _ = fs::remove_dir_all(package);
913 Ok(())
914 }
915
916 #[test]
917 fn agent_child_layout_matches_reference() -> TestResult {
918 let agent = unique_temp_dir("agent-layout")?;
919 assert_eq!(get_custom_themes_dir_with(&agent), agent.join("themes"));
920 assert_eq!(get_models_path_with(&agent), agent.join("models.json"));
921 assert_eq!(get_auth_path_with(&agent), agent.join("auth.json"));
922 assert_eq!(get_settings_path_with(&agent), agent.join("settings.json"));
923 assert_eq!(get_tools_dir_with(&agent), agent.join("tools"));
924 assert_eq!(get_bin_dir_with(&agent), agent.join("bin"));
925 assert_eq!(get_prompts_dir_with(&agent), agent.join("prompts"));
926 assert_eq!(get_sessions_dir_with(&agent), agent.join("sessions"));
927 assert_eq!(get_debug_log_path_with(&agent), agent.join("pi-debug.log"));
928 let _ = fs::remove_dir_all(agent);
929 Ok(())
930 }
931
932 #[test]
933 fn share_viewer_url_uses_fragment_and_override() {
934 assert_eq!(
935 get_share_viewer_url_with("abc123", None),
936 "https://pi.dev/session/#abc123"
937 );
938 assert_eq!(
939 get_share_viewer_url_with("gist-id", Some("https://example.test/view/")),
940 "https://example.test/view/#gist-id"
941 );
942 assert_eq!(
944 get_share_viewer_url_with("x", Some("https://example.test/view")),
945 "https://example.test/view#x"
946 );
947 }
948
949 #[test]
950 fn canonicalize_falls_back_for_missing_path() -> TestResult {
951 let root = unique_temp_dir("canon")?;
952 let missing = root.join("does-not-exist").join("nested");
953 let canonical = canonicalize_path(&missing);
954 assert_eq!(canonical, missing);
955
956 let existing = root.join("present.txt");
957 fs::write(&existing, b"ok").map_err(|error| error.to_string())?;
958 let canonical_existing = canonicalize_path(&existing);
959 assert!(canonical_existing.is_absolute());
960 assert_eq!(
961 canonical_existing
962 .file_name()
963 .and_then(|name| name.to_str()),
964 Some("present.txt")
965 );
966
967 let _ = fs::remove_dir_all(root);
968 Ok(())
969 }
970
971 #[test]
972 fn resolve_path_joins_relative_against_base() -> TestResult {
973 let base = unique_temp_dir("resolve-base")?;
974 let home = unique_temp_dir("resolve-home")?;
975 let abs_root = unique_temp_dir("resolve-abs")?;
976 let abs_file = abs_root.join("abs.txt");
977 let abs_str = abs_file.to_string_lossy().into_owned();
978
979 let resolved = resolve_path_with(
980 "child/file.txt",
981 &base,
982 PathInputOptions::new().home_dir(Some(&home)),
983 );
984 assert_eq!(resolved, base.join("child").join("file.txt"));
985
986 let absolute = resolve_path_with(
987 &abs_str,
988 &base,
989 PathInputOptions::new().home_dir(Some(&home)),
990 );
991 assert_eq!(absolute, abs_file);
992
993 let tilde = resolve_path_with(
994 "~/rel.txt",
995 &base,
996 PathInputOptions::new().home_dir(Some(&home)),
997 );
998 assert_eq!(tilde, home.join("rel.txt"));
999
1000 let _ = fs::remove_dir_all(base);
1001 let _ = fs::remove_dir_all(home);
1002 let _ = fs::remove_dir_all(abs_root);
1003 Ok(())
1004 }
1005
1006 #[test]
1007 fn normalize_path_options_and_file_url() -> TestResult {
1008 let home = unique_temp_dir("normalize-home")?;
1009 let strip_target = unique_temp_dir("strip-target")?.join("file.txt");
1010 let strip_str = strip_target.to_string_lossy().into_owned();
1011
1012 let trimmed = normalize_path(
1013 " ~/agent ",
1014 PathInputOptions::new().trim(true).home_dir(Some(&home)),
1015 );
1016 assert_eq!(trimmed, home.join("agent"));
1017
1018 let at_prefixed = format!("@{strip_str}");
1019 let stripped = normalize_path(
1020 &at_prefixed,
1021 PathInputOptions::new()
1022 .strip_at_prefix(true)
1023 .expand_tilde(false),
1024 );
1025 assert_eq!(stripped, strip_target);
1026
1027 let unicode = normalize_path(
1028 "a\u{00A0}b\u{2003}c\u{3000}d",
1029 PathInputOptions::new()
1030 .normalize_unicode_spaces(true)
1031 .expand_tilde(false),
1032 );
1033 assert_eq!(unicode, PathBuf::from("a b c d"));
1034
1035 let relative = normalize_path("rel/path", PathInputOptions::new().expand_tilde(false));
1037 assert_eq!(relative, PathBuf::from("rel/path"));
1038 assert!(!relative.is_absolute());
1039
1040 assert_eq!(
1042 normalize_path("~user/x", PathInputOptions::new().home_dir(Some(&home))),
1043 PathBuf::from("~user/x")
1044 );
1045
1046 let file_target = unique_temp_dir("file-url")?.join("example file.txt");
1047 let file_url = Url::from_file_path(&file_target)
1048 .map_err(|()| "failed to build file URL".to_owned())?
1049 .to_string();
1050 let decoded = normalize_path(&file_url, PathInputOptions::new().expand_tilde(false));
1051 assert_eq!(decoded, file_target);
1052
1053 let _ = fs::remove_dir_all(home);
1054 if let Some(parent) = strip_target.parent() {
1055 let _ = fs::remove_dir_all(parent);
1056 }
1057 if let Some(parent) = file_target.parent() {
1058 let _ = fs::remove_dir_all(parent);
1059 }
1060 Ok(())
1061 }
1062
1063 #[test]
1064 fn cwd_relative_and_format_helpers() -> TestResult {
1065 let root = unique_temp_dir("cwd-rel")?;
1066 let outside_root = unique_temp_dir("cwd-outside")?;
1067 let nested = root.join("src").join("main.rs");
1068 fs::create_dir_all(nested.parent().ok_or("parent")?).map_err(|error| error.to_string())?;
1069 fs::write(&nested, b"fn main() {}").map_err(|error| error.to_string())?;
1070
1071 let root_str = root.to_string_lossy().into_owned();
1072 let nested_str = nested.to_string_lossy().into_owned();
1073 let outside = outside_root.join("other");
1074 let outside_str = outside.to_string_lossy().into_owned();
1075
1076 let relative = get_cwd_relative_path_with(&nested_str, &root_str, None)
1077 .ok_or("expected inside cwd")?;
1078 assert_eq!(relative, PathBuf::from("src").join("main.rs"));
1079
1080 let equal =
1081 get_cwd_relative_path_with(&root_str, &root_str, None).ok_or("expected equal cwd")?;
1082 assert_eq!(equal, PathBuf::from("."));
1083
1084 assert!(get_cwd_relative_path_with(&outside_str, &root_str, None).is_none());
1085
1086 let formatted = format_path_relative_to_cwd_or_absolute_with(&nested_str, &root_str, None);
1087 assert_eq!(formatted, "src/main.rs");
1088
1089 let absolute_formatted =
1090 format_path_relative_to_cwd_or_absolute_with(&outside_str, &root_str, None);
1091 assert_eq!(absolute_formatted, outside_str.replace('\\', "/"));
1092
1093 assert!(!is_local_path("npm:foo"));
1094 assert!(!is_local_path("https://example.com"));
1095 assert!(is_local_path("file:///tmp/x"));
1096 assert!(is_local_path("./local"));
1097
1098 let _ = fs::remove_dir_all(root);
1099 let _ = fs::remove_dir_all(outside_root);
1100 Ok(())
1101 }
1102}