1use 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
24pub 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
34pub 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
42pub 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
50pub fn data_dir() -> PathBuf {
52 instance_root()
53}
54
55pub fn config_dir() -> PathBuf {
57 instance_root()
58}
59
60pub fn config_file() -> PathBuf {
62 config_dir().join("config.toml")
63}
64
65pub 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
97pub fn project_dir(working_dir: &Path) -> PathBuf {
99 working_dir.join(PROJECT_DIR)
100}
101
102pub fn project_scratch_dir(working_dir: &Path) -> PathBuf {
104 project_dir(working_dir).join("scratch")
105}
106
107pub const SCRATCH_GITIGNORE: &str = ".gitignore";
109
110const SCRATCH_GITIGNORE_BODY: &str = "# WhyCodes scratch files; safe to delete.\n*\n";
111
112pub fn create_scratch_dir(working_dir: &Path) -> std::io::Result<PathBuf> {
116 let dir = project_scratch_dir(working_dir);
117 std::fs::create_dir_all(&dir)?;
118 let ignore = dir.join(SCRATCH_GITIGNORE);
119 if !ignore.exists() {
120 std::fs::write(&ignore, SCRATCH_GITIGNORE_BODY)?;
121 }
122 Ok(dir)
123}
124
125pub fn project_rule_dirs(working_dir: &Path) -> [(PathBuf, &'static str); 4] {
129 [
130 (project_dir(working_dir).join("rules"), "md"),
131 (working_dir.join(".cursor").join("rules"), "mdc"),
132 (working_dir.join(".clinerules"), "md"),
133 (working_dir.join(".github").join("instructions"), "md"),
134 ]
135}
136
137pub fn display_path(path: &Path) -> String {
144 strip_windows_verbatim_prefix(&path.to_string_lossy()).into_owned()
145}
146
147pub(crate) fn strip_windows_verbatim_prefix(s: &str) -> Cow<'_, str> {
150 const VERBATIM: &str = r"\\?\";
151 const UNC: &str = r"UNC\";
152 let Some(rest) = s.strip_prefix(VERBATIM) else {
153 return Cow::Borrowed(s);
154 };
155 if let Some(unc) = rest.strip_prefix(UNC) {
156 let mut out = String::from(r"\\");
157 out.push_str(unc);
158 return Cow::Owned(out);
159 }
160 let b = rest.as_bytes();
161 if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
162 return Cow::Borrowed(rest);
163 }
164 Cow::Borrowed(s)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn display_strip_project_dir_and_or_dot() {
173 assert_eq!(
174 strip_windows_verbatim_prefix(r"\\?\C:\foo").as_ref(),
175 r"C:\foo"
176 );
177 assert_eq!(
178 strip_windows_verbatim_prefix(r"\\?\d:\bar").as_ref(),
179 r"d:\bar"
180 );
181 assert_eq!(
182 strip_windows_verbatim_prefix(r"\\?\UNC\srv\share").as_ref(),
183 r"\\srv\share"
184 );
185 assert_eq!(
186 strip_windows_verbatim_prefix(r"\\?\pipe\name").as_ref(),
187 r"\\?\pipe\name"
188 );
189 assert_eq!(strip_windows_verbatim_prefix("/tmp/a").as_ref(), "/tmp/a");
190 assert_eq!(display_path(Path::new("/tmp/a")), "/tmp/a");
191 assert_eq!(project_dir(Path::new("/w")), PathBuf::from("/w/.whycodes"));
192 assert_eq!(
193 project_scratch_dir(Path::new("/w")),
194 PathBuf::from("/w/.whycodes/scratch")
195 );
196 let rules = project_rule_dirs(Path::new("/w"));
197 assert_eq!(rules[0], (PathBuf::from("/w/.whycodes/rules"), "md"));
198 assert_eq!(rules[1], (PathBuf::from("/w/.cursor/rules"), "mdc"));
199 assert_eq!(rules[2], (PathBuf::from("/w/.clinerules"), "md"));
200 assert_eq!(rules[3], (PathBuf::from("/w/.github/instructions"), "md"));
201 assert_eq!(or_dot(None), PathBuf::from("."));
202 assert_eq!(or_dot(Some(PathBuf::from("/x"))), PathBuf::from("/x"));
203 assert_eq!(config_file().file_name().unwrap(), "config.toml");
204 let roots = legacy_instance_roots();
205 assert!(
206 roots.len() <= 2,
207 "legacy roots are config and/or data: {roots:?}"
208 );
209 assert_eq!(
210 unique_legacy_roots(PathBuf::from("/a"), PathBuf::from("/a")),
211 vec![PathBuf::from("/a")]
212 );
213 assert_eq!(
214 unique_legacy_roots(PathBuf::from("/a"), PathBuf::from("/b")),
215 vec![PathBuf::from("/a"), PathBuf::from("/b")]
216 );
217 assert!(legacy_roots_from(None).is_empty());
218 }
219}