Skip to main content

zig_core/
resources_manage.rs

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