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}