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_run_index_path() -> PathBuf {
55    global_runs_dir().join("index.jsonl")
56}
57
58pub fn global_indexes_dir() -> PathBuf {
59    global_root().join("indexes")
60}
61
62pub fn global_session_index_path() -> PathBuf {
63    global_indexes_dir().join("session_index.db")
64}
65
66pub fn global_skills_dir() -> PathBuf {
67    global_root().join("skills")
68}
69
70pub fn global_prompts_dir() -> PathBuf {
71    global_root().join("prompts")
72}
73
74pub fn global_tools_dir() -> PathBuf {
75    global_root().join("tools")
76}
77
78pub fn global_lua_dir() -> PathBuf {
79    global_root().join("lua")
80}
81
82pub fn global_imports_dir() -> PathBuf {
83    global_root().join("imports")
84}
85
86pub fn project_config_path(project_dir: &Path) -> PathBuf {
87    project_root(project_dir).join("config.toml")
88}
89
90pub fn project_soul_path(project_dir: &Path) -> PathBuf {
91    project_root(project_dir).join("soul.md")
92}
93
94pub fn project_agents_path(project_dir: &Path) -> PathBuf {
95    project_root(project_dir).join("agents.md")
96}
97
98pub fn project_skills_dir(project_dir: &Path) -> PathBuf {
99    project_root(project_dir).join("skills")
100}
101
102pub fn project_prompts_dir(project_dir: &Path) -> PathBuf {
103    project_root(project_dir).join("prompts")
104}
105
106pub fn project_tools_dir(project_dir: &Path) -> PathBuf {
107    project_root(project_dir).join("tools")
108}
109
110pub fn project_lua_dir(project_dir: &Path) -> PathBuf {
111    project_root(project_dir).join("lua")
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct RunArtifacts {
116    root: PathBuf,
117}
118
119impl RunArtifacts {
120    pub fn new(root: PathBuf) -> Self {
121        Self { root }
122    }
123
124    pub fn create(root: PathBuf) -> io::Result<Self> {
125        fs::create_dir_all(&root)?;
126        Ok(Self { root })
127    }
128
129    pub fn root(&self) -> &Path {
130        &self.root
131    }
132
133    pub fn workflow_contract_path(&self) -> PathBuf {
134        self.root.join("workflow-contract.json")
135    }
136
137    pub fn trace_path(&self) -> PathBuf {
138        self.root.join("trace.jsonl")
139    }
140
141    pub fn evidence_path(&self) -> PathBuf {
142        self.root.join("evidence.md")
143    }
144
145    pub fn diff_path(&self) -> PathBuf {
146        self.root.join("diff.patch")
147    }
148
149    pub fn verify_log_path(&self) -> PathBuf {
150        self.root.join("verify.log")
151    }
152
153    pub fn policy_log_path(&self) -> PathBuf {
154        self.root.join("policy.jsonl")
155    }
156}
157
158pub fn project_runs_dir(project_dir: &Path) -> PathBuf {
159    project_root(project_dir).join("runs")
160}
161
162pub fn global_runs_dir() -> PathBuf {
163    global_root().join("runs")
164}
165
166pub fn project_run_artifacts(project_dir: &Path, run_id: &str) -> io::Result<RunArtifacts> {
167    run_artifacts_under(project_runs_dir(project_dir), run_id)
168}
169
170pub fn global_run_artifacts(run_id: &str) -> io::Result<RunArtifacts> {
171    run_artifacts_under(global_runs_dir(), run_id)
172}
173
174pub fn run_artifacts_under(base: PathBuf, run_id: &str) -> io::Result<RunArtifacts> {
175    let safe_run_id = sanitize_run_id(run_id)?;
176    let root = base.join(safe_run_id);
177    ensure_child_path(&base, &root)?;
178    RunArtifacts::create(root)
179}
180
181fn sanitize_run_id(run_id: &str) -> io::Result<&str> {
182    let valid = !run_id.is_empty()
183        && run_id
184            .bytes()
185            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
186    if valid {
187        Ok(run_id)
188    } else {
189        Err(io::Error::new(
190            io::ErrorKind::InvalidInput,
191            "run id must contain only ascii letters, numbers, '-' or '_'",
192        ))
193    }
194}
195
196fn ensure_child_path(base: &Path, child: &Path) -> io::Result<()> {
197    if child
198        .components()
199        .any(|component| matches!(component, std::path::Component::ParentDir))
200    {
201        return Err(io::Error::new(
202            io::ErrorKind::InvalidInput,
203            "run artifact path must not contain parent components",
204        ));
205    }
206    if !child.starts_with(base) {
207        return Err(io::Error::new(
208            io::ErrorKind::InvalidInput,
209            "run artifact path escapes base directory",
210        ));
211    }
212    Ok(())
213}
214
215pub fn legacy_config_roots() -> Vec<PathBuf> {
216    let mut roots = Vec::new();
217    if let Some(root) = xdg_config_root() {
218        roots.push(root);
219    }
220    dedupe(roots)
221}
222
223pub fn legacy_data_roots() -> Vec<PathBuf> {
224    let mut roots = Vec::new();
225    if let Some(root) = xdg_data_root() {
226        roots.push(root);
227    }
228    if cfg!(target_os = "macos") {
229        if let Some(root) = macos_application_support_root() {
230            roots.push(root);
231        }
232    }
233    dedupe(roots)
234}
235
236pub fn global_config_roots_for_read() -> Vec<PathBuf> {
237    let mut roots = vec![global_root()];
238    roots.extend(legacy_config_roots());
239    dedupe(roots)
240}
241
242pub fn global_data_roots_for_read() -> Vec<PathBuf> {
243    let mut roots = vec![global_root()];
244    roots.extend(legacy_data_roots());
245    dedupe(roots)
246}
247
248pub fn existing_global_file(path_fn: fn() -> PathBuf, legacy_subpath: &str) -> Option<PathBuf> {
249    let canonical = path_fn();
250    if canonical.exists() {
251        return Some(canonical);
252    }
253
254    for root in global_config_roots_for_read() {
255        let path = root.join(legacy_subpath);
256        if path.exists() {
257            return Some(path);
258        }
259    }
260
261    for root in global_data_roots_for_read() {
262        let path = root.join(legacy_subpath);
263        if path.exists() {
264            return Some(path);
265        }
266    }
267
268    None
269}
270
271pub fn existing_global_auth_path() -> Option<PathBuf> {
272    let canonical = global_auth_path();
273    if canonical.exists() {
274        return Some(canonical);
275    }
276    legacy_config_roots()
277        .into_iter()
278        .map(|root| root.join("auth.json"))
279        .find(|path| path.exists())
280}
281
282pub fn existing_global_config_path() -> Option<PathBuf> {
283    let canonical = global_config_path();
284    if canonical.exists() {
285        return Some(canonical);
286    }
287    legacy_config_roots()
288        .into_iter()
289        .map(|root| root.join("config.toml"))
290        .find(|path| path.exists())
291}
292
293pub fn reconcile_legacy_into_global_root() -> io::Result<Vec<PathBuf>> {
294    let mut migrated = Vec::new();
295
296    migrated.extend(reconcile_file_candidates(
297        global_config_path(),
298        legacy_config_roots()
299            .into_iter()
300            .map(|root| root.join("config.toml"))
301            .collect(),
302    )?);
303    migrated.extend(reconcile_file_candidates(
304        global_auth_path(),
305        legacy_config_roots()
306            .into_iter()
307            .map(|root| root.join("auth.json"))
308            .collect(),
309    )?);
310    migrated.extend(reconcile_file_candidates(
311        global_soul_path(),
312        legacy_config_roots()
313            .into_iter()
314            .map(|root| root.join("soul.md"))
315            .collect(),
316    )?);
317    migrated.extend(reconcile_file_candidates(
318        global_memory_path(),
319        legacy_config_roots()
320            .into_iter()
321            .map(|root| root.join("memory.md"))
322            .collect(),
323    )?);
324    migrated.extend(reconcile_file_candidates(
325        global_user_path(),
326        legacy_config_roots()
327            .into_iter()
328            .map(|root| root.join("user.md"))
329            .collect(),
330    )?);
331    migrated.extend(reconcile_file_candidates(
332        global_agents_path(),
333        legacy_config_roots()
334            .into_iter()
335            .flat_map(|root| {
336                [
337                    root.join("agents.md"),
338                    root.join("AGENTS.md"),
339                    root.join("CLAUDE.md"),
340                ]
341            })
342            .collect(),
343    )?);
344    migrated.extend(reconcile_dir_candidates(
345        global_skills_dir(),
346        legacy_config_roots()
347            .into_iter()
348            .map(|root| root.join("skills"))
349            .collect(),
350    )?);
351    migrated.extend(reconcile_dir_candidates(
352        global_prompts_dir(),
353        legacy_config_roots()
354            .into_iter()
355            .map(|root| root.join("prompts"))
356            .collect(),
357    )?);
358    migrated.extend(reconcile_dir_candidates(
359        global_tools_dir(),
360        legacy_config_roots()
361            .into_iter()
362            .map(|root| root.join("tools"))
363            .collect(),
364    )?);
365    migrated.extend(reconcile_dir_candidates(
366        global_lua_dir(),
367        legacy_config_roots()
368            .into_iter()
369            .map(|root| root.join("lua"))
370            .collect(),
371    )?);
372    migrated.extend(reconcile_dir_candidates(
373        global_sessions_dir(),
374        legacy_data_roots()
375            .into_iter()
376            .map(|root| root.join("sessions"))
377            .collect(),
378    )?);
379    migrated.extend(reconcile_file_candidates(
380        global_session_index_path(),
381        legacy_data_roots()
382            .into_iter()
383            .flat_map(|root| {
384                [
385                    root.join("indexes").join("session_index.db"),
386                    root.join("session_index.db"),
387                ]
388            })
389            .collect(),
390    )?);
391
392    Ok(migrated)
393}
394
395fn reconcile_file_candidates(
396    target: PathBuf,
397    candidates: Vec<PathBuf>,
398) -> io::Result<Vec<PathBuf>> {
399    if target.exists() {
400        return Ok(Vec::new());
401    }
402
403    let Some(source) = candidates.into_iter().find(|path| path.exists()) else {
404        return Ok(Vec::new());
405    };
406
407    if let Some(parent) = target.parent() {
408        fs::create_dir_all(parent)?;
409    }
410    fs::copy(&source, &target)?;
411    Ok(vec![target])
412}
413
414fn reconcile_dir_candidates(target: PathBuf, candidates: Vec<PathBuf>) -> io::Result<Vec<PathBuf>> {
415    if target.exists() {
416        return Ok(Vec::new());
417    }
418
419    let Some(source) = candidates.into_iter().find(|path| path.exists()) else {
420        return Ok(Vec::new());
421    };
422
423    copy_dir_recursive(&source, &target)?;
424    Ok(vec![target])
425}
426
427fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
428    fs::create_dir_all(dst)?;
429
430    for entry in fs::read_dir(src)? {
431        let entry = entry?;
432        let entry_path = entry.path();
433        let dest_path = dst.join(entry.file_name());
434
435        if entry_path.is_dir() {
436            copy_dir_recursive(&entry_path, &dest_path)?;
437        } else if !dest_path.exists() {
438            if let Some(parent) = dest_path.parent() {
439                fs::create_dir_all(parent)?;
440            }
441            fs::copy(&entry_path, &dest_path)?;
442        }
443    }
444
445    Ok(())
446}
447
448fn xdg_config_root() -> Option<PathBuf> {
449    if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") {
450        return Some(PathBuf::from(dir).join(LEGACY_APP_NAME));
451    }
452    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join(LEGACY_APP_NAME))
453}
454
455fn xdg_data_root() -> Option<PathBuf> {
456    if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
457        return Some(PathBuf::from(dir).join(LEGACY_APP_NAME));
458    }
459    std::env::var_os("HOME").map(|home| {
460        PathBuf::from(home)
461            .join(".local")
462            .join("share")
463            .join(LEGACY_APP_NAME)
464    })
465}
466
467fn macos_application_support_root() -> Option<PathBuf> {
468    std::env::var_os("HOME").map(|home| {
469        PathBuf::from(home)
470            .join("Library")
471            .join("Application Support")
472            .join(LEGACY_APP_NAME)
473    })
474}
475
476fn dedupe(paths: Vec<PathBuf>) -> Vec<PathBuf> {
477    let mut deduped = Vec::new();
478    for path in paths {
479        if !deduped.iter().any(|existing| existing == &path) {
480            deduped.push(path);
481        }
482    }
483    deduped
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use tempfile::TempDir;
490
491    #[test]
492    fn run_artifacts_create_expected_project_paths() {
493        let temp = TempDir::new().unwrap();
494        let artifacts = project_run_artifacts(temp.path(), "run_1").unwrap();
495
496        assert_eq!(
497            artifacts.root(),
498            temp.path().join(".imp").join("runs").join("run_1")
499        );
500        assert!(artifacts.root().exists());
501        assert_eq!(artifacts.trace_path(), artifacts.root().join("trace.jsonl"));
502        assert_eq!(
503            artifacts.evidence_path(),
504            artifacts.root().join("evidence.md")
505        );
506        assert_eq!(artifacts.diff_path(), artifacts.root().join("diff.patch"));
507        assert_eq!(
508            artifacts.verify_log_path(),
509            artifacts.root().join("verify.log")
510        );
511        assert_eq!(
512            artifacts.policy_log_path(),
513            artifacts.root().join("policy.jsonl")
514        );
515        assert_eq!(
516            artifacts.workflow_contract_path(),
517            artifacts.root().join("workflow-contract.json")
518        );
519    }
520
521    #[test]
522    fn run_artifacts_reject_path_traversal_run_ids() {
523        let temp = TempDir::new().unwrap();
524        assert!(project_run_artifacts(temp.path(), "../escape").is_err());
525        assert!(project_run_artifacts(temp.path(), "bad/slash").is_err());
526        assert!(project_run_artifacts(temp.path(), "").is_err());
527    }
528
529    #[test]
530    fn run_artifacts_under_keeps_root_inside_base() {
531        let temp = TempDir::new().unwrap();
532        let base = temp.path().join("runs");
533        let artifacts = run_artifacts_under(base.clone(), "run-abc_123").unwrap();
534        assert!(artifacts.root().starts_with(&base));
535    }
536
537    #[test]
538    fn global_root_prefers_home_imp_directory() {
539        let path = global_root_from_env(Some("/tmp/home".into()), None);
540        assert_eq!(path, PathBuf::from("/tmp/home/.imp"));
541    }
542
543    #[test]
544    fn global_root_falls_back_to_userprofile_when_home_missing() {
545        let path = global_root_from_env(None, Some("C:/Users/test".into()));
546        assert_eq!(path, PathBuf::from("C:/Users/test/.imp"));
547    }
548
549    #[test]
550    fn project_root_uses_dot_imp_directory() {
551        assert_eq!(
552            project_root(Path::new("/tmp/project")),
553            PathBuf::from("/tmp/project/.imp")
554        );
555    }
556
557    #[test]
558    fn global_session_index_lives_under_indexes() {
559        let old_home = std::env::var_os("HOME");
560        std::env::set_var("HOME", "/tmp/home");
561        assert_eq!(
562            global_session_index_path(),
563            PathBuf::from("/tmp/home/.imp/indexes/session_index.db")
564        );
565        match old_home {
566            Some(value) => std::env::set_var("HOME", value),
567            None => std::env::remove_var("HOME"),
568        }
569    }
570
571    #[test]
572    fn reconcile_file_candidates_copies_first_existing_legacy_file() {
573        let temp = TempDir::new().unwrap();
574        let target = temp.path().join(".imp").join("config.toml");
575        let legacy = temp.path().join("legacy").join("config.toml");
576        fs::create_dir_all(legacy.parent().unwrap()).unwrap();
577        fs::write(&legacy, "model = \"sonnet\"\n").unwrap();
578
579        let migrated = reconcile_file_candidates(target.clone(), vec![legacy.clone()]).unwrap();
580        assert_eq!(migrated, vec![target.clone()]);
581        assert_eq!(fs::read_to_string(target).unwrap(), "model = \"sonnet\"\n");
582    }
583
584    #[test]
585    fn reconcile_dir_candidates_copies_directory_tree_when_target_missing() {
586        let temp = TempDir::new().unwrap();
587        let target = temp.path().join(".imp").join("skills");
588        let legacy = temp.path().join("legacy").join("skills").join("my-skill");
589        fs::create_dir_all(&legacy).unwrap();
590        fs::write(legacy.join("SKILL.md"), "# Skill\n").unwrap();
591
592        let migrated = reconcile_dir_candidates(
593            target.clone(),
594            vec![temp.path().join("legacy").join("skills")],
595        )
596        .unwrap();
597        assert_eq!(migrated, vec![target.clone()]);
598        assert!(target.join("my-skill").join("SKILL.md").exists());
599    }
600}