Skip to main content

imp_core/
storage.rs

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}