Skip to main content

pi/core/
config.rs

1//! Product path layout, package identity, and environment constants.
2//!
3//! Port of `coding-agent/src/config.ts` path/env surfaces plus the full
4//! `utils/paths.ts` helpers those surfaces depend on (`PathInputOptions`,
5//! normalize/resolve/canonicalize, cwd-relative formatting). Install and
6//! self-update detection is intentionally omitted until a callsite needs it.
7
8use std::borrow::Cow;
9use std::env;
10use std::path::{Component, Path, PathBuf};
11
12use url::Url;
13
14/// npm package name used by the TypeScript coding-agent package.
15pub const PACKAGE_NAME: &str = "@earendil-works/pi-coding-agent";
16
17/// Short application name used for env prefixes and file names.
18pub const APP_NAME: &str = "pi";
19
20/// Display title used when no custom `piConfig.name` is configured.
21pub const APP_TITLE: &str = "π";
22/// Rust package version exposed through the product config surface.
23pub const VERSION: &str = env!("CARGO_PKG_VERSION");
24
25/// Project-local config directory name (for example `{cwd}/.pi`).
26pub const CONFIG_DIR_NAME: &str = ".pi";
27
28/// Whether this build is an official distribution.
29///
30/// The TypeScript reference checks the runtime `package.json` identity
31/// (`@earendil-works/pi-coding-agent`, app name `pi`, config dir `.pi`). In
32/// the native binary these are compile-time constants baked into the crate,
33/// so this is resolved without filesystem I/O. First-run setup and any other
34/// official-only surface gate on it.
35#[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
42/// Environment variable that overrides the agent config directory.
43pub const ENV_AGENT_DIR: &str = "PI_CODING_AGENT_DIR";
44
45/// Environment variable that overrides the session root directory.
46pub const ENV_SESSION_DIR: &str = "PI_CODING_AGENT_SESSION_DIR";
47
48/// Environment variable that overrides the shipped package asset root.
49pub const ENV_PACKAGE_DIR: &str = "PI_PACKAGE_DIR";
50
51/// Environment variable that overrides the share viewer base URL.
52pub const ENV_SHARE_VIEWER_URL: &str = "PI_SHARE_VIEWER_URL";
53
54/// Default share viewer base URL, including the trailing slash.
55pub const DEFAULT_SHARE_VIEWER_URL: &str = "https://pi.dev/session/";
56
57/// Options controlling path normalization.
58///
59/// Mirrors TypeScript `PathInputOptions`. [`PathInputOptions::new`] and
60/// [`Default`] both default `expand_tilde` to `true`.
61#[derive(Clone, Copy, Debug)]
62pub struct PathInputOptions<'a> {
63    trim_flag: u8,
64    expand_tilde_flag: u8,
65    /// Home directory used for `~` expansion. When `None` and expansion is
66    /// enabled, the process home directory is used.
67    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    /// Defaults matching TypeScript `normalizePath` option defaults.
80    #[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    /// Set whether leading/trailing whitespace is trimmed.
92    #[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    /// Return whether leading/trailing whitespace is trimmed.
99    #[must_use]
100    pub const fn trims_input(self) -> bool {
101        self.trim_flag != 0
102    }
103
104    /// Set whether a leading `~` is expanded.
105    #[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    /// Return whether a leading `~` is expanded.
112    #[must_use]
113    pub const fn expands_tilde(self) -> bool {
114        self.expand_tilde_flag != 0
115    }
116
117    /// Set the home directory used for `~` expansion.
118    #[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    /// Set whether a leading `@` is stripped.
125    #[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    /// Return whether a leading `@` is stripped.
132    #[must_use]
133    pub const fn strips_at_prefix(self) -> bool {
134        self.strip_at_prefix_flag != 0
135    }
136
137    /// Set whether Unicode space variants are normalized.
138    #[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    /// Return whether Unicode space variants are normalized.
145    #[must_use]
146    pub const fn normalizes_unicode_spaces(self) -> bool {
147        self.normalize_unicode_spaces_flag != 0
148    }
149}
150
151/// Expand a leading bare `~` using process home, matching `expandTildePath`.
152#[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/// Expand a leading bare `~` using an explicit home directory.
158///
159/// Only `~`, `~/…`, and (on Windows) `~\…` are expanded. `~user` is left
160/// unchanged, matching the TypeScript helper.
161#[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/// Normalize a path string according to [`PathInputOptions`].
167///
168/// Order matches TypeScript `normalizePath`:
169/// 1. optional trim
170/// 2. optional Unicode-space normalization
171/// 3. optional leading-`@` strip
172/// 4. optional tilde expansion (`~`, `~/`, Windows `~\` only — not `~user`)
173/// 5. `file://` conversion via WHATWG `Url::to_file_path`
174///
175/// Relative paths stay relative. This does not join against a base directory;
176/// use [`resolve_path`] / [`resolve_path_with`] for that.
177#[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/// Returns true when `value` is not a remote package source or URL protocol.
224///
225/// Bare names, relative paths, and `file:` URLs are local. Matches
226/// TypeScript `isLocalPath`.
227#[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/// Resolve `input` against the process working directory after normalization.
239#[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/// Resolve `input` against `base_dir` after normalization.
250///
251/// Both `input` and `base_dir` are normalized (base uses default option flags
252/// with the same home directory seam as `options`). Absolute inputs ignore
253/// the base; relative inputs are joined and cleaned like Node `path.resolve`.
254#[must_use]
255pub fn resolve_path_with(input: &str, base_dir: &Path, options: PathInputOptions<'_>) -> PathBuf {
256    let normalized = normalize_path(input, options);
257    // TypeScript always normalizes the base with default options (tilde on).
258    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/// Canonicalize `path` by following symlinks.
273///
274/// Falls back to the original path when the target does not exist or cannot
275/// be resolved, matching TypeScript `canonicalizePath`.
276#[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/// Return a cwd-relative path when `file_path` is inside `cwd`.
286///
287/// Matches TypeScript `getCwdRelativePath`: returns `None` when the path is
288/// outside `cwd`, `"."` when equal, otherwise a relative path using the
289/// platform separator.
290#[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/// [`get_cwd_relative_path`] with an explicit home directory seam.
300#[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    // TypeScript: resolvePath(cwd) uses process.cwd() as the default base.
309    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/// Format `file_path` relative to `cwd` when possible, otherwise absolute.
330///
331/// Path separators in the result are always `/`, matching TypeScript
332/// `formatPathRelativeToCwdOrAbsolute`.
333#[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/// [`format_path_relative_to_cwd_or_absolute`] with an explicit home seam.
346#[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/// Resolve the shipped package asset root from process environment and
363/// `current_exe`.
364#[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/// Resolve the shipped package asset root with explicit seams.
374///
375/// Precedence:
376/// 1. `package_dir_override` (from `PI_PACKAGE_DIR`), tilde-expanded
377/// 2. parent directory of `executable`
378/// 3. empty path (caller should treat as unresolved)
379#[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/// Path to shipped themes for the native binary layout (`{package}/theme`).
405#[must_use]
406pub fn get_themes_dir() -> PathBuf {
407    get_themes_dir_with(&get_package_dir())
408}
409
410/// Path to shipped themes under an explicit package directory.
411#[must_use]
412pub fn get_themes_dir_with(package_dir: &Path) -> PathBuf {
413    package_dir.join("theme")
414}
415
416/// Path to the HTML export template directory (`{package}/export-html`).
417#[must_use]
418pub fn get_export_template_dir() -> PathBuf {
419    get_export_template_dir_with(&get_package_dir())
420}
421
422/// Path to the HTML export template directory under an explicit package root.
423#[must_use]
424pub fn get_export_template_dir_with(package_dir: &Path) -> PathBuf {
425    package_dir.join("export-html")
426}
427
428/// Path to `package.json` beside the package root.
429#[must_use]
430pub fn get_package_json_path() -> PathBuf {
431    get_package_json_path_with(&get_package_dir())
432}
433
434/// Path to `package.json` under an explicit package root.
435#[must_use]
436pub fn get_package_json_path_with(package_dir: &Path) -> PathBuf {
437    package_dir.join("package.json")
438}
439
440/// Absolute-resolved path to shipped `README.md`.
441#[must_use]
442pub fn get_readme_path() -> PathBuf {
443    get_readme_path_with(&get_package_dir())
444}
445
446/// Absolute-resolved path to `README.md` under an explicit package root.
447#[must_use]
448pub fn get_readme_path_with(package_dir: &Path) -> PathBuf {
449    resolve_existing_join(package_dir, "README.md")
450}
451
452/// Absolute-resolved path to the shipped `docs` directory.
453#[must_use]
454pub fn get_docs_path() -> PathBuf {
455    get_docs_path_with(&get_package_dir())
456}
457
458/// Absolute-resolved path to `docs` under an explicit package root.
459#[must_use]
460pub fn get_docs_path_with(package_dir: &Path) -> PathBuf {
461    resolve_existing_join(package_dir, "docs")
462}
463
464/// Absolute-resolved path to the shipped `examples` directory.
465#[must_use]
466pub fn get_examples_path() -> PathBuf {
467    get_examples_path_with(&get_package_dir())
468}
469
470/// Absolute-resolved path to `examples` under an explicit package root.
471#[must_use]
472pub fn get_examples_path_with(package_dir: &Path) -> PathBuf {
473    resolve_existing_join(package_dir, "examples")
474}
475
476/// Absolute-resolved path to shipped `CHANGELOG.md`.
477#[must_use]
478pub fn get_changelog_path() -> PathBuf {
479    get_changelog_path_with(&get_package_dir())
480}
481
482/// Absolute-resolved path to `CHANGELOG.md` under an explicit package root.
483#[must_use]
484pub fn get_changelog_path_with(package_dir: &Path) -> PathBuf {
485    resolve_existing_join(package_dir, "CHANGELOG.md")
486}
487
488/// Path to built-in interactive assets (`{package}/assets`).
489#[must_use]
490pub fn get_interactive_assets_dir() -> PathBuf {
491    get_interactive_assets_dir_with(&get_package_dir())
492}
493
494/// Path to built-in interactive assets under an explicit package root.
495#[must_use]
496pub fn get_interactive_assets_dir_with(package_dir: &Path) -> PathBuf {
497    package_dir.join("assets")
498}
499
500/// Path to a single bundled interactive asset.
501#[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/// Path to a single bundled interactive asset under an explicit package root.
507#[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/// Agent config directory from process environment and home directory.
513#[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/// Agent config directory with explicit env/home seams.
524///
525/// When `env_agent_dir` is set it is tilde-expanded; otherwise the default is
526/// `{home}/{CONFIG_DIR_NAME}/agent` (for example `~/.pi/agent`).
527#[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/// User custom themes directory (`{agent}/themes`).
539#[must_use]
540pub fn get_custom_themes_dir() -> PathBuf {
541    get_custom_themes_dir_with(&get_agent_dir())
542}
543
544/// User custom themes directory under an explicit agent directory.
545#[must_use]
546pub fn get_custom_themes_dir_with(agent_dir: &Path) -> PathBuf {
547    agent_dir.join("themes")
548}
549
550/// Path to `models.json`.
551#[must_use]
552pub fn get_models_path() -> PathBuf {
553    get_models_path_with(&get_agent_dir())
554}
555
556/// Path to `models.json` under an explicit agent directory.
557#[must_use]
558pub fn get_models_path_with(agent_dir: &Path) -> PathBuf {
559    agent_dir.join("models.json")
560}
561
562/// Path to `auth.json`.
563#[must_use]
564pub fn get_auth_path() -> PathBuf {
565    get_auth_path_with(&get_agent_dir())
566}
567
568/// Path to `auth.json` under an explicit agent directory.
569#[must_use]
570pub fn get_auth_path_with(agent_dir: &Path) -> PathBuf {
571    agent_dir.join("auth.json")
572}
573
574/// Path to `settings.json`.
575#[must_use]
576pub fn get_settings_path() -> PathBuf {
577    get_settings_path_with(&get_agent_dir())
578}
579
580/// Path to `settings.json` under an explicit agent directory.
581#[must_use]
582pub fn get_settings_path_with(agent_dir: &Path) -> PathBuf {
583    agent_dir.join("settings.json")
584}
585
586/// Path to the tools directory.
587#[must_use]
588pub fn get_tools_dir() -> PathBuf {
589    get_tools_dir_with(&get_agent_dir())
590}
591
592/// Path to the tools directory under an explicit agent directory.
593#[must_use]
594pub fn get_tools_dir_with(agent_dir: &Path) -> PathBuf {
595    agent_dir.join("tools")
596}
597
598/// Path to the managed binaries directory.
599#[must_use]
600pub fn get_bin_dir() -> PathBuf {
601    get_bin_dir_with(&get_agent_dir())
602}
603
604/// Path to the managed binaries directory under an explicit agent directory.
605#[must_use]
606pub fn get_bin_dir_with(agent_dir: &Path) -> PathBuf {
607    agent_dir.join("bin")
608}
609
610/// Path to the prompt templates directory.
611#[must_use]
612pub fn get_prompts_dir() -> PathBuf {
613    get_prompts_dir_with(&get_agent_dir())
614}
615
616/// Path to the prompt templates directory under an explicit agent directory.
617#[must_use]
618pub fn get_prompts_dir_with(agent_dir: &Path) -> PathBuf {
619    agent_dir.join("prompts")
620}
621
622/// Path to the sessions directory.
623#[must_use]
624pub fn get_sessions_dir() -> PathBuf {
625    get_sessions_dir_with(&get_agent_dir())
626}
627
628/// Path to the sessions directory under an explicit agent directory.
629#[must_use]
630pub fn get_sessions_dir_with(agent_dir: &Path) -> PathBuf {
631    agent_dir.join("sessions")
632}
633
634/// Path to the debug log file (`{agent}/{APP_NAME}-debug.log`).
635#[must_use]
636pub fn get_debug_log_path() -> PathBuf {
637    get_debug_log_path_with(&get_agent_dir())
638}
639
640/// Path to the debug log file under an explicit agent directory.
641#[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/// Share viewer URL for `gist_id` using process environment.
647#[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/// Share viewer URL for `gist_id` with an explicit base override.
656///
657/// Wire shape is `{base}#{gistId}` with no extra slash insertion. Missing
658/// override falls back to [`DEFAULT_SHARE_VIEWER_URL`].
659#[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
686/// Clean `.` / `..` like Node `path.resolve` output (absolute-aware).
687fn 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
723/// Approximate Node `path.resolve` for one optional base and one path.
724fn 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
743/// Approximate Node `path.relative(from, to)`.
744fn 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        // No slash is injected between base and fragment.
943        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        // Relative paths stay relative.
1036        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        // ~user is not expanded.
1041        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}