zig_core/paths.rs
1use std::path::{Path, PathBuf};
2
3use crate::error::ZigError;
4
5/// Return the global workflows directory derived from a given home directory.
6pub fn global_workflows_dir_from(home: &Path) -> PathBuf {
7 home.join(".zig").join("workflows")
8}
9
10/// Return the global workflows directory: `~/.zig/workflows/`.
11/// Returns `None` if the HOME environment variable is not set.
12pub fn global_workflows_dir() -> Option<PathBuf> {
13 std::env::var("HOME")
14 .ok()
15 .map(|home| global_workflows_dir_from(Path::new(&home)))
16}
17
18/// Ensure the global workflows directory exists, creating it if necessary.
19pub fn ensure_global_workflows_dir() -> Result<PathBuf, ZigError> {
20 let dir = global_workflows_dir()
21 .ok_or_else(|| ZigError::Io("HOME environment variable not set".into()))?;
22 if !dir.exists() {
23 std::fs::create_dir_all(&dir)
24 .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
25 }
26 Ok(dir)
27}
28
29// =====================================================================
30// Local (project-level) workflow directories.
31//
32// Layout: `<git-root>/.zig/workflows/` — discovered by walking up from
33// the current working directory to the git root, matching the convention
34// used by resources and memory.
35// =====================================================================
36
37/// Walk up from `start` looking for a `.zig/workflows` directory. Stops at the
38/// containing git repository root, or returns the directory in `start` itself
39/// if it exists.
40pub fn cwd_workflows_dir_from(start: &Path) -> Option<PathBuf> {
41 let mut current = start;
42 let stop = find_git_root(start);
43
44 loop {
45 let candidate = current.join(".zig").join("workflows");
46 if candidate.is_dir() {
47 return Some(candidate);
48 }
49 if let Some(ref root) = stop {
50 if current == root.as_path() {
51 return None;
52 }
53 }
54 match current.parent() {
55 Some(p) => current = p,
56 None => return None,
57 }
58 }
59}
60
61/// Walk up from the process's current working directory looking for a
62/// `.zig/workflows` directory. See [`cwd_workflows_dir_from`].
63pub fn cwd_workflows_dir() -> Option<PathBuf> {
64 let cwd = std::env::current_dir().ok()?;
65 cwd_workflows_dir_from(&cwd)
66}
67
68// =====================================================================
69// Resource directories.
70//
71// Layout under the global base directory:
72//
73// ~/.zig/
74// resources/
75// _shared/ files advertised to every workflow
76// <workflow-name>/ files advertised to a single named workflow
77//
78// Project-local resources live under `<git-root>/.zig/resources/` and are
79// discovered by walking up from the current working directory until a git
80// root is found (matching the convention used for session storage).
81// =====================================================================
82
83/// Return the global resources directory derived from a given home directory.
84pub fn global_resources_dir_from(home: &Path) -> PathBuf {
85 home.join(".zig").join("resources")
86}
87
88/// Return the global resources directory: `~/.zig/resources/`.
89/// Returns `None` if the HOME environment variable is not set.
90pub fn global_resources_dir() -> Option<PathBuf> {
91 std::env::var("HOME")
92 .ok()
93 .map(|h| global_resources_dir_from(Path::new(&h)))
94}
95
96/// Return the per-workflow global resources directory: `~/.zig/resources/<name>/`.
97pub fn global_resources_for(workflow: &str) -> Option<PathBuf> {
98 global_resources_dir().map(|d| d.join(workflow))
99}
100
101/// Return the shared global resources directory: `~/.zig/resources/_shared/`.
102///
103/// Files placed here are advertised to every workflow regardless of name.
104pub fn global_shared_resources_dir() -> Option<PathBuf> {
105 global_resources_dir().map(|d| d.join("_shared"))
106}
107
108/// Ensure the global resources directory (or a child of it) exists.
109pub fn ensure_global_resources_dir(child: Option<&str>) -> Result<PathBuf, ZigError> {
110 let mut dir = global_resources_dir()
111 .ok_or_else(|| ZigError::Io("HOME environment variable not set".into()))?;
112 if let Some(c) = child {
113 dir = dir.join(c);
114 }
115 if !dir.exists() {
116 std::fs::create_dir_all(&dir)
117 .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
118 }
119 Ok(dir)
120}
121
122/// Walk up from `start` looking for a `.zig/resources` directory. Stops at the
123/// containing git repository root (matching `find_git_root`'s discovery
124/// boundary), or returns the directory in `start` itself if it exists.
125///
126/// Returns `None` if no such directory is found before hitting the git root
127/// or the filesystem root.
128pub fn cwd_resources_dir_from(start: &Path) -> Option<PathBuf> {
129 let mut current = start;
130 let stop = find_git_root(start);
131
132 loop {
133 let candidate = current.join(".zig").join("resources");
134 if candidate.is_dir() {
135 return Some(candidate);
136 }
137 if let Some(ref root) = stop {
138 if current == root.as_path() {
139 return None;
140 }
141 }
142 match current.parent() {
143 Some(p) => current = p,
144 None => return None,
145 }
146 }
147}
148
149/// Walk up from the process's current working directory looking for a
150/// `.zig/resources` directory. See [`cwd_resources_dir_from`].
151pub fn cwd_resources_dir() -> Option<PathBuf> {
152 let cwd = std::env::current_dir().ok()?;
153 cwd_resources_dir_from(&cwd)
154}
155
156// =====================================================================
157// Memory directories — same tiered layout as resources, under `memory/`.
158// =====================================================================
159
160/// Return the global memory directory: `~/.zig/memory/`.
161pub fn global_memory_dir() -> Option<PathBuf> {
162 std::env::var("HOME")
163 .ok()
164 .map(|h| Path::new(&h).join(".zig").join("memory"))
165}
166
167/// Return the per-workflow global memory directory: `~/.zig/memory/<name>/`.
168pub fn global_memory_for(workflow: &str) -> Option<PathBuf> {
169 global_memory_dir().map(|d| d.join(workflow))
170}
171
172/// Return the shared global memory directory: `~/.zig/memory/_shared/`.
173pub fn global_shared_memory_dir() -> Option<PathBuf> {
174 global_memory_dir().map(|d| d.join("_shared"))
175}
176
177/// Ensure the global memory directory (or a child of it) exists.
178pub fn ensure_global_memory_dir(child: Option<&str>) -> Result<PathBuf, ZigError> {
179 let mut dir = global_memory_dir()
180 .ok_or_else(|| ZigError::Io("HOME environment variable not set".into()))?;
181 if let Some(c) = child {
182 dir = dir.join(c);
183 }
184 if !dir.exists() {
185 std::fs::create_dir_all(&dir)
186 .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
187 }
188 Ok(dir)
189}
190
191/// Walk up from `start` looking for a `.zig/memory` directory. Stops at the
192/// containing git repository root, or returns the directory in `start` itself
193/// if it exists.
194pub fn cwd_memory_dir_from(start: &Path) -> Option<PathBuf> {
195 let mut current = start;
196 let stop = find_git_root(start);
197
198 loop {
199 let candidate = current.join(".zig").join("memory");
200 if candidate.is_dir() {
201 return Some(candidate);
202 }
203 if let Some(ref root) = stop {
204 if current == root.as_path() {
205 return None;
206 }
207 }
208 match current.parent() {
209 Some(p) => current = p,
210 None => return None,
211 }
212 }
213}
214
215/// Walk up from the process's current working directory looking for a
216/// `.zig/memory` directory. See [`cwd_memory_dir_from`].
217pub fn cwd_memory_dir() -> Option<PathBuf> {
218 let cwd = std::env::current_dir().ok()?;
219 cwd_memory_dir_from(&cwd)
220}
221
222// =====================================================================
223// Example files directory.
224// =====================================================================
225
226/// Return the global examples directory: `~/.zig/examples/`.
227pub fn global_examples_dir() -> Option<PathBuf> {
228 global_base_dir().map(|d| d.join("examples"))
229}
230
231/// Ensure the global examples directory exists, creating it if necessary.
232pub fn ensure_global_examples_dir() -> Result<PathBuf, ZigError> {
233 let dir = global_examples_dir()
234 .ok_or_else(|| ZigError::Io("HOME environment variable not set".into()))?;
235 if !dir.exists() {
236 std::fs::create_dir_all(&dir)
237 .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
238 }
239 Ok(dir)
240}
241
242// =====================================================================
243// Session storage paths.
244//
245// Layout mirrors zag (`zag-agent/src/config.rs:183` `resolve_project_dir`):
246//
247// ~/.zig/
248// projects/<sanitized-project-path>/logs/
249// index.json
250// sessions/<id>.jsonl
251// sessions_index.json (global cross-project index)
252//
253// Keeping this layout byte-for-byte aligned with `~/.zag/` is intentional
254// so future changes to zag's session/listen architecture can be mirrored
255// into zig with minimal churn.
256// =====================================================================
257
258/// Return the global zig base directory: `~/.zig/`.
259pub fn global_base_dir() -> Option<PathBuf> {
260 std::env::var("HOME")
261 .ok()
262 .map(|h| Path::new(&h).join(".zig"))
263}
264
265/// Resolve the user's home directory from the environment.
266///
267/// Checks `HOME` first (the Unix convention zig workflows are written
268/// against), then falls back to `USERPROFILE` on Windows where `HOME`
269/// is typically not set. Returns `None` if neither variable is set.
270pub(crate) fn env_home() -> Option<String> {
271 std::env::var("HOME")
272 .ok()
273 .or_else(|| std::env::var("USERPROFILE").ok())
274}
275
276/// Expand `~/` and `$HOME` / `${HOME}` in a path string.
277///
278/// Handles three forms:
279/// - Leading `~/` → replaced with the home directory
280/// - `~` alone → replaced with the home directory
281/// - `$HOME` or `${HOME}` anywhere in the string → replaced with the home dir
282///
283/// The home directory is resolved from `HOME`, falling back to `USERPROFILE`
284/// on Windows. Other environment variables and absolute paths are returned
285/// unchanged. Returns the input unchanged if no home variable is set.
286pub fn expand_path(path: &str) -> String {
287 let home = match env_home() {
288 Some(h) => h,
289 None => return path.to_string(),
290 };
291
292 let s = if path == "~" {
293 home.clone()
294 } else if let Some(rest) = path.strip_prefix("~/") {
295 format!("{home}/{rest}")
296 } else {
297 path.to_string()
298 };
299
300 s.replace("${HOME}", &home).replace("$HOME", &home)
301}
302
303/// Replace the home directory prefix with `~` for display purposes.
304///
305/// This is the inverse of [`expand_path`]: given an absolute path like
306/// `/Users/alice/.zig/workflows/foo.zwf`, returns `~/.zig/workflows/foo.zwf`.
307/// Paths that don't start with the home directory are returned unchanged.
308pub fn collapse_home(path: &str) -> String {
309 let home = match env_home() {
310 Some(h) => h,
311 None => return path.to_string(),
312 };
313
314 if path == home {
315 "~".to_string()
316 } else if let Some(rest) = path.strip_prefix(&format!("{home}/")) {
317 format!("~/{rest}")
318 } else {
319 path.to_string()
320 }
321}
322
323/// Sanitize an absolute path into a directory name.
324///
325/// Strips leading `/` and replaces remaining `/` with `-`. Mirrors zag's
326/// `Config::sanitize_path` (`zag-agent/src/config.rs:179`).
327pub fn sanitize_project_path(path: &str) -> String {
328 path.trim_start_matches('/').replace('/', "-")
329}
330
331/// Find the git repository root containing `start`, walking parents.
332fn find_git_root(start: &Path) -> Option<PathBuf> {
333 let mut current = start;
334 loop {
335 if current.join(".git").exists() {
336 return Some(current.to_path_buf());
337 }
338 current = current.parent()?;
339 }
340}
341
342/// Resolve the project directory for session storage.
343///
344/// Mirrors zag's `Config::resolve_project_dir` (`zag-agent/src/config.rs:188`):
345/// 1. If `root` is provided, sanitize it directly.
346/// 2. Otherwise locate the git repository root containing `cwd`.
347/// 3. Otherwise fall back to the global base directory (no project subdir).
348pub fn project_dir(root: Option<&str>) -> Option<PathBuf> {
349 let base = global_base_dir()?;
350 if let Some(r) = root {
351 return Some(base.join("projects").join(sanitize_project_path(r)));
352 }
353 let cwd = std::env::current_dir().ok()?;
354 if let Some(git_root) = find_git_root(&cwd) {
355 let sanitized = sanitize_project_path(&git_root.to_string_lossy());
356 return Some(base.join("projects").join(sanitized));
357 }
358 Some(base)
359}
360
361/// Return the per-project logs directory: `<project>/logs/`.
362pub fn project_logs_dir(root: Option<&str>) -> Option<PathBuf> {
363 project_dir(root).map(|p| p.join("logs"))
364}
365
366/// Return the per-project sessions directory: `<project>/logs/sessions/`.
367pub fn project_sessions_dir(root: Option<&str>) -> Option<PathBuf> {
368 project_logs_dir(root).map(|p| p.join("sessions"))
369}
370
371/// Return the per-project index file: `<project>/logs/index.json`.
372pub fn project_index_path(root: Option<&str>) -> Option<PathBuf> {
373 project_logs_dir(root).map(|p| p.join("index.json"))
374}
375
376/// Return the global cross-project session index: `~/.zig/sessions_index.json`.
377pub fn global_sessions_index_path() -> Option<PathBuf> {
378 global_base_dir().map(|p| p.join("sessions_index.json"))
379}
380
381/// Ensure the per-project sessions directory exists, creating it if needed.
382pub fn ensure_project_sessions_dir(root: Option<&str>) -> Result<PathBuf, ZigError> {
383 let dir = project_sessions_dir(root)
384 .ok_or_else(|| ZigError::Io("HOME environment variable not set".into()))?;
385 if !dir.exists() {
386 std::fs::create_dir_all(&dir)
387 .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
388 }
389 Ok(dir)
390}
391
392#[cfg(test)]
393#[path = "paths_tests.rs"]
394mod tests;