Skip to main content

whycodes_core/
paths.rs

1//! User-visible WhyCodes directories.
2//!
3//! `WHYCODES_HOME` (if set and non-empty) is the instance root: config is
4//! `$WHYCODES_HOME/config.toml` and session/auth/memory/browser data live
5//! under `$WHYCODES_HOME`. Otherwise the default is `$HOME/.whycodes`
6//! (`%USERPROFILE%\.whycodes` on Windows) — the usual short
7//! `~/.<tool>` shape.
8//!
9//! Pre-0.7 installs used `directories::ProjectDirs` (`com.whycorporation.whycodes`
10//! / XDG). [`legacy_instance_roots`] lists those paths so config load can
11//! copy `config.toml` / `auth.json` / `whycodes.db` into `~/.whycodes`.
12//!
13//! Project-local state lives under `.whycodes/` next to a repo.
14
15use std::borrow::Cow;
16use std::path::{Path, PathBuf};
17
18const QUALIFIER: &str = "com";
19const ORG: &str = "whycorporation";
20const APP: &str = "whycodes";
21const PROJECT_DIR: &str = ".whycodes";
22const INSTANCE_DIR: &str = ".whycodes";
23
24/// Isolated instance root from the environment.
25pub fn whycodes_home() -> Option<PathBuf> {
26    let raw = std::env::var_os("WHYCODES_HOME")?;
27    if raw.is_empty() {
28        None
29    } else {
30        Some(PathBuf::from(raw))
31    }
32}
33
34/// User home (`$HOME`, then `%USERPROFILE%`).
35pub fn user_home() -> Option<PathBuf> {
36    std::env::var_os("HOME")
37        .filter(|v| !v.is_empty())
38        .or_else(|| std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()))
39        .map(PathBuf::from)
40}
41
42/// Config + data live together under this directory.
43pub fn instance_root() -> PathBuf {
44    if let Some(home) = whycodes_home() {
45        return home;
46    }
47    or_dot(user_home().map(|h| h.join(INSTANCE_DIR)))
48}
49
50/// Sessions, auth store, memory banks, browser profile.
51pub fn data_dir() -> PathBuf {
52    instance_root()
53}
54
55/// `config.toml`, `skills/`, `plugins.toml`.
56pub fn config_dir() -> PathBuf {
57    instance_root()
58}
59
60/// `$config_dir/config.toml`.
61pub fn config_file() -> PathBuf {
62    config_dir().join("config.toml")
63}
64
65/// Former ProjectDirs locations (config and/or data). Used only to migrate
66/// an existing install into [`instance_root`].
67pub fn legacy_instance_roots() -> Vec<PathBuf> {
68    legacy_roots_from(
69        directories::ProjectDirs::from(QUALIFIER, ORG, APP).map(|d| {
70            (
71                d.config_dir().to_path_buf(),
72                d.data_local_dir().to_path_buf(),
73            )
74        }),
75    )
76}
77
78pub(crate) fn legacy_roots_from(dirs: Option<(PathBuf, PathBuf)>) -> Vec<PathBuf> {
79    match dirs {
80        Some((config, data)) => unique_legacy_roots(config, data),
81        None => Vec::new(),
82    }
83}
84
85pub(crate) fn unique_legacy_roots(config: PathBuf, data: PathBuf) -> Vec<PathBuf> {
86    if config == data {
87        vec![config]
88    } else {
89        vec![config, data]
90    }
91}
92
93pub(crate) fn or_dot(p: Option<PathBuf>) -> PathBuf {
94    p.unwrap_or_else(|| PathBuf::from("."))
95}
96
97/// Project-local WhyCodes directory: `.whycodes`.
98pub fn project_dir(working_dir: &Path) -> PathBuf {
99    working_dir.join(PROJECT_DIR)
100}
101
102/// Throwaway agent files (`bg-*.log`, detached output). Not the OS temp dir.
103pub fn project_scratch_dir(working_dir: &Path) -> PathBuf {
104    project_dir(working_dir).join("scratch")
105}
106
107/// Directories of per-topic project rule files, with the extension each one
108/// holds, in precedence order. The context loader appends every file in them
109/// to the system prompt and `read rule://<name>` serves one by file stem.
110pub fn project_rule_dirs(working_dir: &Path) -> [(PathBuf, &'static str); 4] {
111    [
112        (project_dir(working_dir).join("rules"), "md"),
113        (working_dir.join(".cursor").join("rules"), "mdc"),
114        (working_dir.join(".clinerules"), "md"),
115        (working_dir.join(".github").join("instructions"), "md"),
116    ]
117}
118
119/// Format a path for humans (status bar, toasts, copy-to-clipboard).
120///
121/// `std::fs::canonicalize` on Windows returns a Win32 extended-length path
122/// (`\\?\C:\…` or `\\?\UNC\server\share`). Those prefixes are correct for
123/// filesystem APIs and long-path support, but look wrong in the TUI. Other
124/// platforms (and already-normal Windows paths) are a no-op.
125pub fn display_path(path: &Path) -> String {
126    strip_windows_verbatim_prefix(&path.to_string_lossy()).into_owned()
127}
128
129/// Strip `\\?\` / `\\?\UNC\` when the remainder is a drive or UNC path.
130/// Device namespace paths (`\\?\pipe\…`, `\\?\Volume{guid}\…`) are left alone.
131pub(crate) fn strip_windows_verbatim_prefix(s: &str) -> Cow<'_, str> {
132    const VERBATIM: &str = r"\\?\";
133    const UNC: &str = r"UNC\";
134    let Some(rest) = s.strip_prefix(VERBATIM) else {
135        return Cow::Borrowed(s);
136    };
137    if let Some(unc) = rest.strip_prefix(UNC) {
138        let mut out = String::from(r"\\");
139        out.push_str(unc);
140        return Cow::Owned(out);
141    }
142    let b = rest.as_bytes();
143    if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
144        return Cow::Borrowed(rest);
145    }
146    Cow::Borrowed(s)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn display_strip_project_dir_and_or_dot() {
155        assert_eq!(
156            strip_windows_verbatim_prefix(r"\\?\C:\foo").as_ref(),
157            r"C:\foo"
158        );
159        assert_eq!(
160            strip_windows_verbatim_prefix(r"\\?\d:\bar").as_ref(),
161            r"d:\bar"
162        );
163        assert_eq!(
164            strip_windows_verbatim_prefix(r"\\?\UNC\srv\share").as_ref(),
165            r"\\srv\share"
166        );
167        assert_eq!(
168            strip_windows_verbatim_prefix(r"\\?\pipe\name").as_ref(),
169            r"\\?\pipe\name"
170        );
171        assert_eq!(strip_windows_verbatim_prefix("/tmp/a").as_ref(), "/tmp/a");
172        assert_eq!(display_path(Path::new("/tmp/a")), "/tmp/a");
173        assert_eq!(project_dir(Path::new("/w")), PathBuf::from("/w/.whycodes"));
174        assert_eq!(
175            project_scratch_dir(Path::new("/w")),
176            PathBuf::from("/w/.whycodes/scratch")
177        );
178        let rules = project_rule_dirs(Path::new("/w"));
179        assert_eq!(rules[0], (PathBuf::from("/w/.whycodes/rules"), "md"));
180        assert_eq!(rules[1], (PathBuf::from("/w/.cursor/rules"), "mdc"));
181        assert_eq!(rules[2], (PathBuf::from("/w/.clinerules"), "md"));
182        assert_eq!(rules[3], (PathBuf::from("/w/.github/instructions"), "md"));
183        assert_eq!(or_dot(None), PathBuf::from("."));
184        assert_eq!(or_dot(Some(PathBuf::from("/x"))), PathBuf::from("/x"));
185        assert_eq!(config_file().file_name().unwrap(), "config.toml");
186        let roots = legacy_instance_roots();
187        assert!(
188            roots.len() <= 2,
189            "legacy roots are config and/or data: {roots:?}"
190        );
191        assert_eq!(
192            unique_legacy_roots(PathBuf::from("/a"), PathBuf::from("/a")),
193            vec![PathBuf::from("/a")]
194        );
195        assert_eq!(
196            unique_legacy_roots(PathBuf::from("/a"), PathBuf::from("/b")),
197            vec![PathBuf::from("/a"), PathBuf::from("/b")]
198        );
199        assert!(legacy_roots_from(None).is_empty());
200    }
201}