1use std::path::{Path, PathBuf};
16
17use crate::error::ZigError;
18use crate::paths;
19
20fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ResourceScope {
47 Global,
49 Cwd,
51 Both,
53}
54
55impl ResourceScope {
56 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#[derive(Debug, Clone)]
71pub enum ResourceTarget {
72 GlobalShared,
74 GlobalWorkflow(String),
76 Cwd,
79}
80
81impl ResourceTarget {
82 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 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 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 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#[derive(Debug, Clone)]
155pub struct ListedResource {
156 pub tier: String,
157 pub name: String,
158 pub path: PathBuf,
159}
160
161pub 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 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(¤t) {
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
287pub 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
317pub 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
371pub 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
386pub 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
408pub 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
430pub 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;