1use std::path::{Path, PathBuf};
16
17use crate::error::ZigError;
18use crate::paths;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ResourceScope {
26 Global,
28 Cwd,
30 Both,
32}
33
34impl ResourceScope {
35 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#[derive(Debug, Clone)]
50pub enum ResourceTarget {
51 GlobalShared,
53 GlobalWorkflow(String),
55 Cwd,
58}
59
60impl ResourceTarget {
61 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 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 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 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#[derive(Debug, Clone)]
134pub struct ListedResource {
135 pub tier: String,
136 pub name: String,
137 pub path: PathBuf,
138}
139
140pub 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 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(¤t) {
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
266pub 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
296pub 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
349pub 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
364pub 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
385pub 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
406pub 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;