1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5const IMP_DIR_NAME: &str = ".imp";
6const LEGACY_APP_NAME: &str = "imp";
7
8pub fn global_root() -> PathBuf {
9 global_root_from_env(std::env::var_os("HOME"), std::env::var_os("USERPROFILE"))
10}
11
12fn global_root_from_env(
13 home: Option<std::ffi::OsString>,
14 userprofile: Option<std::ffi::OsString>,
15) -> PathBuf {
16 home.or(userprofile)
17 .map(PathBuf::from)
18 .unwrap_or_else(|| PathBuf::from("."))
19 .join(IMP_DIR_NAME)
20}
21
22pub fn project_root(project_dir: &Path) -> PathBuf {
23 project_dir.join(IMP_DIR_NAME)
24}
25
26pub fn global_config_path() -> PathBuf {
27 global_root().join("config.toml")
28}
29
30pub fn global_auth_path() -> PathBuf {
31 global_root().join("auth.json")
32}
33
34pub fn global_soul_path() -> PathBuf {
35 global_root().join("soul.md")
36}
37
38pub fn global_agents_path() -> PathBuf {
39 global_root().join("agents.md")
40}
41
42pub fn global_memory_path() -> PathBuf {
43 global_root().join("memory.md")
44}
45
46pub fn global_user_path() -> PathBuf {
47 global_root().join("user.md")
48}
49
50pub fn global_sessions_dir() -> PathBuf {
51 global_root().join("sessions")
52}
53
54pub fn global_indexes_dir() -> PathBuf {
55 global_root().join("indexes")
56}
57
58pub fn global_session_index_path() -> PathBuf {
59 global_indexes_dir().join("session_index.db")
60}
61
62pub fn global_skills_dir() -> PathBuf {
63 global_root().join("skills")
64}
65
66pub fn global_prompts_dir() -> PathBuf {
67 global_root().join("prompts")
68}
69
70pub fn global_tools_dir() -> PathBuf {
71 global_root().join("tools")
72}
73
74pub fn global_lua_dir() -> PathBuf {
75 global_root().join("lua")
76}
77
78pub fn global_imports_dir() -> PathBuf {
79 global_root().join("imports")
80}
81
82pub fn project_config_path(project_dir: &Path) -> PathBuf {
83 project_root(project_dir).join("config.toml")
84}
85
86pub fn project_soul_path(project_dir: &Path) -> PathBuf {
87 project_root(project_dir).join("soul.md")
88}
89
90pub fn project_agents_path(project_dir: &Path) -> PathBuf {
91 project_root(project_dir).join("agents.md")
92}
93
94pub fn project_skills_dir(project_dir: &Path) -> PathBuf {
95 project_root(project_dir).join("skills")
96}
97
98pub fn project_prompts_dir(project_dir: &Path) -> PathBuf {
99 project_root(project_dir).join("prompts")
100}
101
102pub fn project_tools_dir(project_dir: &Path) -> PathBuf {
103 project_root(project_dir).join("tools")
104}
105
106pub fn project_lua_dir(project_dir: &Path) -> PathBuf {
107 project_root(project_dir).join("lua")
108}
109
110pub fn legacy_config_roots() -> Vec<PathBuf> {
111 let mut roots = Vec::new();
112 if let Some(root) = xdg_config_root() {
113 roots.push(root);
114 }
115 dedupe(roots)
116}
117
118pub fn legacy_data_roots() -> Vec<PathBuf> {
119 let mut roots = Vec::new();
120 if let Some(root) = xdg_data_root() {
121 roots.push(root);
122 }
123 if cfg!(target_os = "macos") {
124 if let Some(root) = macos_application_support_root() {
125 roots.push(root);
126 }
127 }
128 dedupe(roots)
129}
130
131pub fn global_config_roots_for_read() -> Vec<PathBuf> {
132 let mut roots = vec![global_root()];
133 roots.extend(legacy_config_roots());
134 dedupe(roots)
135}
136
137pub fn global_data_roots_for_read() -> Vec<PathBuf> {
138 let mut roots = vec![global_root()];
139 roots.extend(legacy_data_roots());
140 dedupe(roots)
141}
142
143pub fn existing_global_file(path_fn: fn() -> PathBuf, legacy_subpath: &str) -> Option<PathBuf> {
144 let canonical = path_fn();
145 if canonical.exists() {
146 return Some(canonical);
147 }
148
149 for root in global_config_roots_for_read() {
150 let path = root.join(legacy_subpath);
151 if path.exists() {
152 return Some(path);
153 }
154 }
155
156 for root in global_data_roots_for_read() {
157 let path = root.join(legacy_subpath);
158 if path.exists() {
159 return Some(path);
160 }
161 }
162
163 None
164}
165
166pub fn existing_global_auth_path() -> Option<PathBuf> {
167 let canonical = global_auth_path();
168 if canonical.exists() {
169 return Some(canonical);
170 }
171 legacy_config_roots()
172 .into_iter()
173 .map(|root| root.join("auth.json"))
174 .find(|path| path.exists())
175}
176
177pub fn existing_global_config_path() -> Option<PathBuf> {
178 let canonical = global_config_path();
179 if canonical.exists() {
180 return Some(canonical);
181 }
182 legacy_config_roots()
183 .into_iter()
184 .map(|root| root.join("config.toml"))
185 .find(|path| path.exists())
186}
187
188pub fn reconcile_legacy_into_global_root() -> io::Result<Vec<PathBuf>> {
189 let mut migrated = Vec::new();
190
191 migrated.extend(reconcile_file_candidates(
192 global_config_path(),
193 legacy_config_roots()
194 .into_iter()
195 .map(|root| root.join("config.toml"))
196 .collect(),
197 )?);
198 migrated.extend(reconcile_file_candidates(
199 global_auth_path(),
200 legacy_config_roots()
201 .into_iter()
202 .map(|root| root.join("auth.json"))
203 .collect(),
204 )?);
205 migrated.extend(reconcile_file_candidates(
206 global_soul_path(),
207 legacy_config_roots()
208 .into_iter()
209 .map(|root| root.join("soul.md"))
210 .collect(),
211 )?);
212 migrated.extend(reconcile_file_candidates(
213 global_memory_path(),
214 legacy_config_roots()
215 .into_iter()
216 .map(|root| root.join("memory.md"))
217 .collect(),
218 )?);
219 migrated.extend(reconcile_file_candidates(
220 global_user_path(),
221 legacy_config_roots()
222 .into_iter()
223 .map(|root| root.join("user.md"))
224 .collect(),
225 )?);
226 migrated.extend(reconcile_file_candidates(
227 global_agents_path(),
228 legacy_config_roots()
229 .into_iter()
230 .flat_map(|root| {
231 [
232 root.join("agents.md"),
233 root.join("AGENTS.md"),
234 root.join("CLAUDE.md"),
235 ]
236 })
237 .collect(),
238 )?);
239 migrated.extend(reconcile_dir_candidates(
240 global_skills_dir(),
241 legacy_config_roots()
242 .into_iter()
243 .map(|root| root.join("skills"))
244 .collect(),
245 )?);
246 migrated.extend(reconcile_dir_candidates(
247 global_prompts_dir(),
248 legacy_config_roots()
249 .into_iter()
250 .map(|root| root.join("prompts"))
251 .collect(),
252 )?);
253 migrated.extend(reconcile_dir_candidates(
254 global_tools_dir(),
255 legacy_config_roots()
256 .into_iter()
257 .map(|root| root.join("tools"))
258 .collect(),
259 )?);
260 migrated.extend(reconcile_dir_candidates(
261 global_lua_dir(),
262 legacy_config_roots()
263 .into_iter()
264 .map(|root| root.join("lua"))
265 .collect(),
266 )?);
267 migrated.extend(reconcile_dir_candidates(
268 global_sessions_dir(),
269 legacy_data_roots()
270 .into_iter()
271 .map(|root| root.join("sessions"))
272 .collect(),
273 )?);
274 migrated.extend(reconcile_file_candidates(
275 global_session_index_path(),
276 legacy_data_roots()
277 .into_iter()
278 .flat_map(|root| {
279 [
280 root.join("indexes").join("session_index.db"),
281 root.join("session_index.db"),
282 ]
283 })
284 .collect(),
285 )?);
286
287 Ok(migrated)
288}
289
290fn reconcile_file_candidates(
291 target: PathBuf,
292 candidates: Vec<PathBuf>,
293) -> io::Result<Vec<PathBuf>> {
294 if target.exists() {
295 return Ok(Vec::new());
296 }
297
298 let Some(source) = candidates.into_iter().find(|path| path.exists()) else {
299 return Ok(Vec::new());
300 };
301
302 if let Some(parent) = target.parent() {
303 fs::create_dir_all(parent)?;
304 }
305 fs::copy(&source, &target)?;
306 Ok(vec![target])
307}
308
309fn reconcile_dir_candidates(target: PathBuf, candidates: Vec<PathBuf>) -> io::Result<Vec<PathBuf>> {
310 if target.exists() {
311 return Ok(Vec::new());
312 }
313
314 let Some(source) = candidates.into_iter().find(|path| path.exists()) else {
315 return Ok(Vec::new());
316 };
317
318 copy_dir_recursive(&source, &target)?;
319 Ok(vec![target])
320}
321
322fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
323 fs::create_dir_all(dst)?;
324
325 for entry in fs::read_dir(src)? {
326 let entry = entry?;
327 let entry_path = entry.path();
328 let dest_path = dst.join(entry.file_name());
329
330 if entry_path.is_dir() {
331 copy_dir_recursive(&entry_path, &dest_path)?;
332 } else if !dest_path.exists() {
333 if let Some(parent) = dest_path.parent() {
334 fs::create_dir_all(parent)?;
335 }
336 fs::copy(&entry_path, &dest_path)?;
337 }
338 }
339
340 Ok(())
341}
342
343fn xdg_config_root() -> Option<PathBuf> {
344 if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") {
345 return Some(PathBuf::from(dir).join(LEGACY_APP_NAME));
346 }
347 std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join(LEGACY_APP_NAME))
348}
349
350fn xdg_data_root() -> Option<PathBuf> {
351 if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
352 return Some(PathBuf::from(dir).join(LEGACY_APP_NAME));
353 }
354 std::env::var_os("HOME").map(|home| {
355 PathBuf::from(home)
356 .join(".local")
357 .join("share")
358 .join(LEGACY_APP_NAME)
359 })
360}
361
362fn macos_application_support_root() -> Option<PathBuf> {
363 std::env::var_os("HOME").map(|home| {
364 PathBuf::from(home)
365 .join("Library")
366 .join("Application Support")
367 .join(LEGACY_APP_NAME)
368 })
369}
370
371fn dedupe(paths: Vec<PathBuf>) -> Vec<PathBuf> {
372 let mut deduped = Vec::new();
373 for path in paths {
374 if !deduped.iter().any(|existing| existing == &path) {
375 deduped.push(path);
376 }
377 }
378 deduped
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use tempfile::TempDir;
385
386 #[test]
387 fn global_root_prefers_home_imp_directory() {
388 let path = global_root_from_env(Some("/tmp/home".into()), None);
389 assert_eq!(path, PathBuf::from("/tmp/home/.imp"));
390 }
391
392 #[test]
393 fn global_root_falls_back_to_userprofile_when_home_missing() {
394 let path = global_root_from_env(None, Some("C:/Users/test".into()));
395 assert_eq!(path, PathBuf::from("C:/Users/test/.imp"));
396 }
397
398 #[test]
399 fn project_root_uses_dot_imp_directory() {
400 assert_eq!(
401 project_root(Path::new("/tmp/project")),
402 PathBuf::from("/tmp/project/.imp")
403 );
404 }
405
406 #[test]
407 fn global_session_index_lives_under_indexes() {
408 let old_home = std::env::var_os("HOME");
409 std::env::set_var("HOME", "/tmp/home");
410 assert_eq!(
411 global_session_index_path(),
412 PathBuf::from("/tmp/home/.imp/indexes/session_index.db")
413 );
414 match old_home {
415 Some(value) => std::env::set_var("HOME", value),
416 None => std::env::remove_var("HOME"),
417 }
418 }
419
420 #[test]
421 fn reconcile_file_candidates_copies_first_existing_legacy_file() {
422 let temp = TempDir::new().unwrap();
423 let target = temp.path().join(".imp").join("config.toml");
424 let legacy = temp.path().join("legacy").join("config.toml");
425 fs::create_dir_all(legacy.parent().unwrap()).unwrap();
426 fs::write(&legacy, "model = \"sonnet\"\n").unwrap();
427
428 let migrated = reconcile_file_candidates(target.clone(), vec![legacy.clone()]).unwrap();
429 assert_eq!(migrated, vec![target.clone()]);
430 assert_eq!(fs::read_to_string(target).unwrap(), "model = \"sonnet\"\n");
431 }
432
433 #[test]
434 fn reconcile_dir_candidates_copies_directory_tree_when_target_missing() {
435 let temp = TempDir::new().unwrap();
436 let target = temp.path().join(".imp").join("skills");
437 let legacy = temp.path().join("legacy").join("skills").join("my-skill");
438 fs::create_dir_all(&legacy).unwrap();
439 fs::write(legacy.join("SKILL.md"), "# Skill\n").unwrap();
440
441 let migrated = reconcile_dir_candidates(
442 target.clone(),
443 vec![temp.path().join("legacy").join("skills")],
444 )
445 .unwrap();
446 assert_eq!(migrated, vec![target.clone()]);
447 assert!(target.join("my-skill").join("SKILL.md").exists());
448 }
449}