Skip to main content

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;