Skip to main content

zig_core/
resources_manage.rs

1//! Management commands for resource files: list / add / delete / show / where.
2//!
3//! These functions back the `zig resources …` subcommands. They operate on the
4//! same tiered layout that [`crate::resources::ResourceCollector`] consumes at
5//! run time:
6//!
7//! * `~/.zig/resources/_shared/` — the global shared tier
8//! * `~/.zig/resources/<workflow>/` — the global per-workflow tier
9//! * `<git-root>/.zig/resources/` — the project (cwd) tier
10//!
11//! Inline resources declared in `.zwf` files are *not* manipulated by these
12//! commands — they live inside the workflow file itself and the user edits
13//! them by hand.
14
15use std::path::{Path, PathBuf};
16
17use crate::error::ZigError;
18use crate::paths;
19
20/// Reject resource names that include path separators or traversal
21/// segments. API/CLI callers must only supply plain filenames — otherwise
22/// `dir.join(name)` lets `../../` escape the tier directory.
23fn validate_resource_filename(name: &str) -> Result<(), ZigError> {
24    if name.is_empty() {
25        return Err(ZigError::Validation("name must not be empty".into()));
26    }
27    if name.contains('/')
28        || name.contains('\\')
29        || name.contains('\0')
30        || name == "."
31        || name == ".."
32        || name.starts_with('-')
33    {
34        return Err(ZigError::Validation(format!(
35            "name '{name}' must not contain path separators or traversal segments"
36        )));
37    }
38    Ok(())
39}
40
41/// Which tier(s) a `list` or `where` command should consider.
42///
43/// `Both` (the default when neither `--global` nor `--cwd` is passed) walks
44/// every tier that exists on disk.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ResourceScope {
47    /// Show only the global tiers under `~/.zig/resources/`.
48    Global,
49    /// Show only the project tier under `<git-root>/.zig/resources/`.
50    Cwd,
51    /// Show every tier (the default).
52    Both,
53}
54
55impl ResourceScope {
56    /// Build a scope from the mutually-exclusive `--global` / `--cwd` flags.
57    pub fn from_flags(global: bool, cwd: bool) -> Self {
58        match (global, cwd) {
59            (true, false) => ResourceScope::Global,
60            (false, true) => ResourceScope::Cwd,
61            _ => ResourceScope::Both,
62        }
63    }
64}
65
66/// Where an `add` or `delete` command should place / look for a resource.
67///
68/// Constructed with [`ResourceTarget::from_flags`], which enforces the
69/// mutually-exclusive flag rules (a target must be unambiguous).
70#[derive(Debug, Clone)]
71pub enum ResourceTarget {
72    /// `~/.zig/resources/_shared/`
73    GlobalShared,
74    /// `~/.zig/resources/<workflow>/`
75    GlobalWorkflow(String),
76    /// `<git-root>/.zig/resources/` — falls back to `./.zig/resources/` when
77    /// not inside a git repo.
78    Cwd,
79}
80
81impl ResourceTarget {
82    /// Resolve a target from the CLI flag combination passed by the user.
83    ///
84    /// Rules:
85    /// * `--workflow <name>` always means the global per-workflow tier.
86    /// * `--cwd` means the project tier.
87    /// * `--global` (without `--workflow`) means the global shared tier.
88    /// * No flags at all is treated as `--cwd` (the most local tier).
89    pub fn from_flags(workflow: Option<&str>, global: bool, cwd: bool) -> Result<Self, ZigError> {
90        if let Some(name) = workflow {
91            if cwd {
92                return Err(ZigError::Validation(
93                    "--workflow cannot be combined with --cwd".into(),
94                ));
95            }
96            return Ok(ResourceTarget::GlobalWorkflow(name.to_string()));
97        }
98        if cwd {
99            return Ok(ResourceTarget::Cwd);
100        }
101        if global {
102            return Ok(ResourceTarget::GlobalShared);
103        }
104        Ok(ResourceTarget::Cwd)
105    }
106
107    /// Resolve to an absolute directory path, creating it if it doesn't exist.
108    pub fn ensure_dir(&self) -> Result<PathBuf, ZigError> {
109        let dir = match self {
110            ResourceTarget::GlobalShared => paths::ensure_global_resources_dir(Some("_shared"))?,
111            ResourceTarget::GlobalWorkflow(name) => paths::ensure_global_resources_dir(Some(name))?,
112            ResourceTarget::Cwd => ensure_cwd_resources_dir()?,
113        };
114        Ok(dir)
115    }
116
117    /// Resolve to an absolute directory path *without* creating it. Returns
118    /// `None` when the directory cannot be derived (e.g. `$HOME` unset).
119    pub fn existing_dir(&self) -> Option<PathBuf> {
120        match self {
121            ResourceTarget::GlobalShared => paths::global_shared_resources_dir(),
122            ResourceTarget::GlobalWorkflow(name) => paths::global_resources_for(name),
123            ResourceTarget::Cwd => paths::cwd_resources_dir().or_else(|| {
124                std::env::current_dir()
125                    .ok()
126                    .map(|p| p.join(".zig").join("resources"))
127            }),
128        }
129    }
130
131    /// Short label for diagnostic messages.
132    pub fn label(&self) -> String {
133        match self {
134            ResourceTarget::GlobalShared => "global:_shared".to_string(),
135            ResourceTarget::GlobalWorkflow(n) => format!("global:{n}"),
136            ResourceTarget::Cwd => "cwd".to_string(),
137        }
138    }
139}
140
141fn ensure_cwd_resources_dir() -> Result<PathBuf, ZigError> {
142    if let Some(existing) = paths::cwd_resources_dir() {
143        return Ok(existing);
144    }
145    let cwd = std::env::current_dir()
146        .map_err(|e| ZigError::Io(format!("failed to read current directory: {e}")))?;
147    let dir = cwd.join(".zig").join("resources");
148    std::fs::create_dir_all(&dir)
149        .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
150    Ok(dir)
151}
152
153/// A single resource entry returned by [`list_resources`].
154#[derive(Debug, Clone)]
155pub struct ListedResource {
156    pub tier: String,
157    pub name: String,
158    pub path: PathBuf,
159}
160
161/// List resources discovered under the requested scope.
162///
163/// Prints a human-readable table to stdout. When `workflow` is `Some`, the
164/// global tier is restricted to that workflow's directory; otherwise every
165/// `~/.zig/resources/<name>/` subdirectory is walked.
166pub fn list_resources(workflow: Option<&str>, scope: ResourceScope) -> Result<(), ZigError> {
167    let mut entries: Vec<ListedResource> = Vec::new();
168
169    let walk_global = matches!(scope, ResourceScope::Global | ResourceScope::Both);
170    let walk_cwd = matches!(scope, ResourceScope::Cwd | ResourceScope::Both);
171
172    if walk_global {
173        if let Some(shared) = paths::global_shared_resources_dir() {
174            collect_listing(&shared, "global:_shared", &mut entries);
175        }
176        if let Some(name) = workflow {
177            if let Some(wf_dir) = paths::global_resources_for(name) {
178                collect_listing(&wf_dir, &format!("global:{name}"), &mut entries);
179            }
180        } else if let Some(root) = paths::global_resources_dir() {
181            // Walk every immediate subdirectory of ~/.zig/resources/ (skipping
182            // _shared which we already covered) and treat each one as a
183            // workflow-scoped tier.
184            if let Ok(read) = std::fs::read_dir(&root) {
185                for entry in read.flatten() {
186                    let path = entry.path();
187                    if !path.is_dir() {
188                        continue;
189                    }
190                    let name = match path.file_name().and_then(|n| n.to_str()) {
191                        Some(n) => n,
192                        None => continue,
193                    };
194                    if name == "_shared" {
195                        continue;
196                    }
197                    collect_listing(&path, &format!("global:{name}"), &mut entries);
198                }
199            }
200        }
201    }
202
203    if walk_cwd {
204        if let Some(cwd_dir) = paths::cwd_resources_dir() {
205            collect_listing(&cwd_dir, "cwd", &mut entries);
206        }
207    }
208
209    if entries.is_empty() {
210        println!("No resources found.");
211        println!(
212            "Hint: add one with `zig resources add <file> [--global|--cwd|--workflow <name>]`"
213        );
214        return Ok(());
215    }
216
217    let tier_w = entries
218        .iter()
219        .map(|e| e.tier.len())
220        .max()
221        .unwrap_or(4)
222        .max(4);
223    let name_w = entries
224        .iter()
225        .map(|e| e.name.len())
226        .max()
227        .unwrap_or(4)
228        .max(4);
229
230    println!(
231        "{:<tier_w$}  {:<name_w$}  PATH",
232        "TIER",
233        "NAME",
234        tier_w = tier_w,
235        name_w = name_w,
236    );
237    for e in &entries {
238        println!(
239            "{:<tier_w$}  {:<name_w$}  {}",
240            e.tier,
241            e.name,
242            e.path.display(),
243            tier_w = tier_w,
244            name_w = name_w,
245        );
246    }
247
248    Ok(())
249}
250
251fn collect_listing(dir: &Path, tier: &str, out: &mut Vec<ListedResource>) {
252    if !dir.is_dir() {
253        return;
254    }
255    let mut stack = vec![dir.to_path_buf()];
256    while let Some(current) = stack.pop() {
257        let read = match std::fs::read_dir(&current) {
258            Ok(r) => r,
259            Err(_) => continue,
260        };
261        for entry in read.flatten() {
262            let path = entry.path();
263            let metadata = match std::fs::metadata(&path) {
264                Ok(m) => m,
265                Err(_) => continue,
266            };
267            if metadata.is_dir() {
268                stack.push(path);
269                continue;
270            }
271            if !metadata.is_file() {
272                continue;
273            }
274            let name = path
275                .file_name()
276                .map(|n| n.to_string_lossy().into_owned())
277                .unwrap_or_else(|| path.display().to_string());
278            out.push(ListedResource {
279                tier: tier.to_string(),
280                name,
281                path: path.clone(),
282            });
283        }
284    }
285}
286
287/// Copy a file into the chosen tier directory, optionally renaming it.
288///
289/// Returns the absolute path of the destination file. Refuses to overwrite an
290/// existing file (the user must `zig resources delete <name>` first).
291pub fn add_resource(
292    file: &str,
293    target: ResourceTarget,
294    name: Option<&str>,
295) -> Result<PathBuf, ZigError> {
296    let src = Path::new(file);
297    if !src.exists() {
298        return Err(ZigError::Io(format!("source file not found: {file}")));
299    }
300    if !src.is_file() {
301        return Err(ZigError::Io(format!("not a regular file: {file}")));
302    }
303
304    let dir = target.ensure_dir()?;
305    let dest = add_to_dir(src, &dir, name)?;
306    println!(
307        "added resource '{}' to {} ({})",
308        dest.file_name()
309            .map(|n| n.to_string_lossy())
310            .unwrap_or_default(),
311        target.label(),
312        dest.display()
313    );
314    Ok(dest)
315}
316
317/// Lower-level helper: copy `src` into `dir`, optionally renaming it.
318///
319/// Refuses to overwrite. Used internally by [`add_resource`] and exposed for
320/// tests that want to operate on an explicit directory without touching the
321/// global `$HOME` layout.
322pub fn add_to_dir(src: &Path, dir: &Path, name: Option<&str>) -> Result<PathBuf, ZigError> {
323    if !src.exists() {
324        return Err(ZigError::Io(format!(
325            "source file not found: {}",
326            src.display()
327        )));
328    }
329    if !src.is_file() {
330        return Err(ZigError::Io(format!(
331            "not a regular file: {}",
332            src.display()
333        )));
334    }
335
336    if !dir.exists() {
337        std::fs::create_dir_all(dir)
338            .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
339    }
340
341    let dest_name = name
342        .map(str::to_string)
343        .or_else(|| src.file_name().map(|n| n.to_string_lossy().into_owned()))
344        .ok_or_else(|| {
345            ZigError::Io(format!(
346                "could not derive a destination name from {}",
347                src.display()
348            ))
349        })?;
350    validate_resource_filename(&dest_name)?;
351
352    let dest = dir.join(&dest_name);
353    if dest.exists() {
354        return Err(ZigError::Io(format!(
355            "resource '{}' already exists at {} — delete it first",
356            dest_name,
357            dest.display()
358        )));
359    }
360
361    std::fs::copy(src, &dest).map_err(|e| {
362        ZigError::Io(format!(
363            "failed to copy {} → {}: {e}",
364            src.display(),
365            dest.display()
366        ))
367    })?;
368    Ok(dest)
369}
370
371/// Delete a resource by name from the chosen tier.
372pub fn delete_resource(name: &str, target: ResourceTarget) -> Result<(), ZigError> {
373    let dir = target
374        .existing_dir()
375        .ok_or_else(|| ZigError::Io("could not resolve target directory (HOME unset?)".into()))?;
376    let path = delete_from_dir(name, &dir)?;
377    println!(
378        "deleted resource '{}' from {} ({})",
379        name,
380        target.label(),
381        path.display()
382    );
383    Ok(())
384}
385
386/// Lower-level helper: delete a single resource from an explicit directory.
387pub fn delete_from_dir(name: &str, dir: &Path) -> Result<PathBuf, ZigError> {
388    validate_resource_filename(name)?;
389    if !dir.is_dir() {
390        return Err(ZigError::Io(format!(
391            "tier directory does not exist: {}",
392            dir.display()
393        )));
394    }
395    let path = dir.join(name);
396    if !path.exists() {
397        return Err(ZigError::Io(format!(
398            "resource '{}' not found in {}",
399            name,
400            dir.display()
401        )));
402    }
403    std::fs::remove_file(&path)
404        .map_err(|e| ZigError::Io(format!("failed to delete {}: {e}", path.display())))?;
405    Ok(path)
406}
407
408/// Print the absolute path and contents of a resource discovered in any tier.
409pub fn show_resource(name: &str, workflow: Option<&str>) -> Result<(), ZigError> {
410    validate_resource_filename(name)?;
411    let candidates = candidate_dirs(workflow);
412    for (label, dir) in &candidates {
413        let path = dir.join(name);
414        if path.is_file() {
415            let contents = std::fs::read_to_string(&path)
416                .map_err(|e| ZigError::Io(format!("failed to read {}: {e}", path.display())))?;
417            println!("# {} ({})", path.display(), label);
418            print!("{contents}");
419            if !contents.ends_with('\n') {
420                println!();
421            }
422            return Ok(());
423        }
424    }
425    Err(ZigError::Io(format!(
426        "resource '{name}' not found in any tier"
427    )))
428}
429
430/// Print the directories the collector would search for the current
431/// invocation, in tier order.
432pub fn print_search_paths(workflow: Option<&str>) -> Result<(), ZigError> {
433    println!("Resource search paths (in collection order):");
434    for (label, dir) in candidate_dirs(workflow) {
435        let exists = if dir.is_dir() { "" } else { " (missing)" };
436        println!("  {label:<16}  {}{exists}", dir.display());
437    }
438    Ok(())
439}
440
441fn candidate_dirs(workflow: Option<&str>) -> Vec<(String, PathBuf)> {
442    let mut out: Vec<(String, PathBuf)> = Vec::new();
443    if let Some(d) = paths::global_shared_resources_dir() {
444        out.push(("global:_shared".into(), d));
445    }
446    if let Some(name) = workflow {
447        if let Some(d) = paths::global_resources_for(name) {
448            out.push((format!("global:{name}"), d));
449        }
450    }
451    if let Some(d) = paths::cwd_resources_dir() {
452        out.push(("cwd".into(), d));
453    } else if let Ok(cwd) = std::env::current_dir() {
454        out.push(("cwd".into(), cwd.join(".zig").join("resources")));
455    }
456    out
457}
458
459#[cfg(test)]
460#[path = "resources_manage_tests.rs"]
461mod tests;