Skip to main content

zig_core/
resources.rs

1//! Resource file advertisement.
2//!
3//! Resources are reference files (a CV, a style guide, reference docs, ...) that
4//! the agent is *told about* through its system prompt so it can choose to read
5//! them with its file tools on demand. Unlike `Step.files`, resources are never
6//! inlined into the user message — only their absolute paths are advertised,
7//! keeping context cheap and letting the agent decide what to pull in.
8//!
9//! Resources come from four tiers, merged at run time in this order:
10//!
11//! 1. **Global shared** — every file under `~/.zig/resources/_shared/`
12//! 2. **Global per-workflow** — every file under `~/.zig/resources/<workflow-name>/`
13//! 3. **Project (cwd)** — every file under `<git-root>/.zig/resources/`
14//! 4. **Inline workflow** — `resources = [...]` in `[workflow]` of the `.zwf` file
15//! 5. **Inline step** — `resources = [...]` on a single `[[step]]`
16//!
17//! Entries are deduplicated by canonicalized absolute path; the first tier to
18//! discover a path wins for display ordering. Name collisions across different
19//! paths are *not* dropped — both files are advertised so the agent can see
20//! everything that's actually on disk.
21
22use std::path::{Path, PathBuf};
23
24use crate::error::ZigError;
25use crate::paths::expand_path;
26use crate::workflow::model::ResourceSpec;
27
28/// Where a resource was discovered. Tiers are emitted in declaration order;
29/// the first tier to register a given canonical path wins.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ResourceOrigin {
32    /// `~/.zig/resources/_shared/`
33    GlobalShared,
34    /// `~/.zig/resources/<workflow-name>/`
35    GlobalWorkflow,
36    /// `<git-root>/.zig/resources/`
37    Cwd,
38    /// `resources = [...]` in `[workflow]`
39    Workflow,
40    /// `resources = [...]` on a `[[step]]`
41    Step,
42}
43
44impl ResourceOrigin {
45    /// Short human-readable label, used by the `zig resources list` command.
46    pub fn label(self) -> &'static str {
47        match self {
48            ResourceOrigin::GlobalShared => "global:_shared",
49            ResourceOrigin::GlobalWorkflow => "global:workflow",
50            ResourceOrigin::Cwd => "cwd",
51            ResourceOrigin::Workflow => "inline:workflow",
52            ResourceOrigin::Step => "inline:step",
53        }
54    }
55}
56
57/// A single resolved resource entry — the form that gets rendered into the
58/// system prompt.
59#[derive(Debug, Clone)]
60pub struct Resource {
61    /// Canonicalized absolute path on disk. This is the string the agent will
62    /// use when calling its file-read tool.
63    pub abs_path: PathBuf,
64    /// Display name — the explicit `name` from a detailed spec, or the file's
65    /// basename when discovered from a directory or a bare path.
66    pub name: String,
67    /// Optional description, shown in the rendered block after the path.
68    pub description: Option<String>,
69    /// Which tier this resource came from.
70    pub origin: ResourceOrigin,
71}
72
73/// An ordered, deduplicated collection of resources.
74#[derive(Debug, Clone, Default)]
75pub struct ResourceSet {
76    entries: Vec<Resource>,
77}
78
79impl ResourceSet {
80    /// Create an empty set.
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Returns true when no resources were collected.
86    pub fn is_empty(&self) -> bool {
87        self.entries.is_empty()
88    }
89
90    /// Number of resolved resources in the set.
91    pub fn len(&self) -> usize {
92        self.entries.len()
93    }
94
95    /// Iterate over the resolved resources in insertion order.
96    pub fn iter(&self) -> impl Iterator<Item = &Resource> {
97        self.entries.iter()
98    }
99
100    /// Push a resource, deduplicating by canonical absolute path.
101    ///
102    /// The first occurrence wins — this prevents double-advertising the same
103    /// file when it's listed at multiple tiers (e.g. inline workflow + cwd).
104    fn push(&mut self, res: Resource) {
105        if self.entries.iter().any(|e| e.abs_path == res.abs_path) {
106            return;
107        }
108        self.entries.push(res);
109    }
110}
111
112/// Run-time configuration for resource collection, built once per workflow
113/// invocation in [`crate::run::run_workflow`] and threaded through to every
114/// step.
115///
116/// Holds pre-resolved tier directories so tests can construct collectors
117/// directly without mutating `$HOME`. Use [`ResourceCollector::from_env`]
118/// at runtime to populate the directories from `crate::paths`.
119pub struct ResourceCollector<'a> {
120    /// Inline workflow-level resources from the `.zwf` file.
121    pub workflow_resources: &'a [ResourceSpec],
122    /// Directory the `.zwf` file lives in — relative paths in inline specs
123    /// are resolved against this.
124    pub workflow_dir: &'a Path,
125    /// `~/.zig/resources/_shared/`, when present.
126    pub global_shared_dir: Option<PathBuf>,
127    /// `~/.zig/resources/<workflow-name>/`, when present.
128    pub global_workflow_dir: Option<PathBuf>,
129    /// `<git-root>/.zig/resources/`, when present.
130    pub cwd_resources_dir: Option<PathBuf>,
131    /// When true, all tiers are skipped and `collect_for_step` returns an
132    /// empty set. Set by `--no-resources` on `zig run`.
133    pub disabled: bool,
134}
135
136impl<'a> ResourceCollector<'a> {
137    /// Build a collector for a workflow at runtime, populating tier
138    /// directories from the user's `$HOME` and current working directory.
139    pub fn from_env(
140        workflow_name: &str,
141        workflow_resources: &'a [ResourceSpec],
142        workflow_dir: &'a Path,
143        disabled: bool,
144    ) -> Self {
145        Self {
146            workflow_resources,
147            workflow_dir,
148            global_shared_dir: crate::paths::global_shared_resources_dir(),
149            global_workflow_dir: crate::paths::global_resources_for(workflow_name),
150            cwd_resources_dir: crate::paths::cwd_resources_dir(),
151            disabled,
152        }
153    }
154
155    /// Build the merged resource set for a single step, walking all tiers in
156    /// declaration order.
157    pub fn collect_for_step(
158        &self,
159        step_resources: &[ResourceSpec],
160    ) -> Result<ResourceSet, ZigError> {
161        let mut set = ResourceSet::new();
162        if self.disabled {
163            return Ok(set);
164        }
165
166        // Tier 1: ~/.zig/resources/_shared/
167        if let Some(dir) = self.global_shared_dir.as_deref() {
168            scan_directory_into(dir, ResourceOrigin::GlobalShared, &mut set);
169        }
170
171        // Tier 2: ~/.zig/resources/<workflow-name>/
172        if let Some(dir) = self.global_workflow_dir.as_deref() {
173            scan_directory_into(dir, ResourceOrigin::GlobalWorkflow, &mut set);
174        }
175
176        // Tier 3: <git-root>/.zig/resources/
177        if let Some(dir) = self.cwd_resources_dir.as_deref() {
178            scan_directory_into(dir, ResourceOrigin::Cwd, &mut set);
179        }
180
181        // Tier 4: inline workflow resources
182        for spec in self.workflow_resources {
183            if let Some(res) = resolve_inline(spec, self.workflow_dir, ResourceOrigin::Workflow)? {
184                set.push(res);
185            }
186        }
187
188        // Tier 5: inline step resources
189        for spec in step_resources {
190            if let Some(res) = resolve_inline(spec, self.workflow_dir, ResourceOrigin::Step)? {
191                set.push(res);
192            }
193        }
194
195        Ok(set)
196    }
197}
198
199/// Recursively scan a directory and add every regular file to the set as a
200/// resource of the given origin. Symlinks and entries that fail to canonicalize
201/// are silently skipped — this is best-effort discovery, not a hard contract.
202fn scan_directory_into(dir: &Path, origin: ResourceOrigin, set: &mut ResourceSet) {
203    if !dir.is_dir() {
204        return;
205    }
206
207    let mut stack = vec![dir.to_path_buf()];
208    while let Some(current) = stack.pop() {
209        let entries = match std::fs::read_dir(&current) {
210            Ok(e) => e,
211            Err(_) => continue,
212        };
213        for entry in entries.flatten() {
214            let path = entry.path();
215            // `entry.metadata()` returns symlink metadata, which would silently
216            // drop linked-in resource files. Follow symlinks via `fs::metadata`.
217            let metadata = match std::fs::metadata(&path) {
218                Ok(m) => m,
219                Err(_) => continue,
220            };
221            if metadata.is_dir() {
222                stack.push(path);
223                continue;
224            }
225            if !metadata.is_file() {
226                continue;
227            }
228            let abs_path = match std::fs::canonicalize(&path) {
229                Ok(p) => p,
230                Err(_) => continue,
231            };
232            let name = abs_path
233                .file_name()
234                .map(|n| n.to_string_lossy().into_owned())
235                .unwrap_or_else(|| abs_path.display().to_string());
236            set.push(Resource {
237                abs_path,
238                name,
239                description: None,
240                origin,
241            });
242        }
243    }
244}
245
246/// Resolve a single inline `ResourceSpec` against `workflow_dir`. Returns
247/// `Ok(None)` when the file is absent and `required = false`; returns `Err`
248/// when a required file is missing.
249fn resolve_inline(
250    spec: &ResourceSpec,
251    workflow_dir: &Path,
252    origin: ResourceOrigin,
253) -> Result<Option<Resource>, ZigError> {
254    let raw_path = spec.path();
255    if raw_path.is_empty() {
256        return Err(ZigError::Execution(
257            "resource entry has an empty path".into(),
258        ));
259    }
260
261    let joined = workflow_dir.join(expand_path(raw_path));
262    let abs_path = match std::fs::canonicalize(&joined) {
263        Ok(p) => p,
264        Err(_) => {
265            if spec.required() {
266                return Err(ZigError::Execution(format!(
267                    "required resource '{}' not found (looked at {})",
268                    raw_path,
269                    joined.display()
270                )));
271            }
272            eprintln!(
273                "  warning: resource '{}' not found at {} — skipping",
274                raw_path,
275                joined.display()
276            );
277            return Ok(None);
278        }
279    };
280
281    let name = spec
282        .name()
283        .map(str::to_string)
284        .or_else(|| {
285            abs_path
286                .file_name()
287                .map(|n| n.to_string_lossy().into_owned())
288        })
289        .unwrap_or_else(|| raw_path.to_string());
290
291    Ok(Some(Resource {
292        abs_path,
293        name,
294        description: spec.description().map(str::to_string),
295        origin,
296    }))
297}
298
299/// Convenience for tests and the original PR 1 API: collect inline-only
300/// resources without walking any global/cwd tiers.
301pub fn collect_inline_resources(
302    workflow_resources: &[ResourceSpec],
303    step_resources: &[ResourceSpec],
304    workflow_dir: &Path,
305) -> Result<ResourceSet, ZigError> {
306    let mut set = ResourceSet::new();
307    for spec in workflow_resources {
308        if let Some(res) = resolve_inline(spec, workflow_dir, ResourceOrigin::Workflow)? {
309            set.push(res);
310        }
311    }
312    for spec in step_resources {
313        if let Some(res) = resolve_inline(spec, workflow_dir, ResourceOrigin::Step)? {
314            set.push(res);
315        }
316    }
317    Ok(set)
318}
319
320/// Render a `<resources>` XML-ish block to prepend to a system prompt.
321///
322/// Returns an empty string when the set is empty, so callers can concatenate
323/// unconditionally without producing stray whitespace.
324pub fn render_system_block(set: &ResourceSet) -> String {
325    if set.is_empty() {
326        return String::new();
327    }
328
329    let mut out = String::from("<resources>\n");
330    out.push_str(
331        "You have access to the following reference files. Read them with your file tools when the user's request relates to them.\n\n",
332    );
333    for res in set.iter() {
334        out.push_str("- ");
335        out.push_str(&res.abs_path.display().to_string());
336        if let Some(desc) = res.description.as_deref() {
337            out.push_str(" — ");
338            out.push_str(desc);
339        } else {
340            out.push_str(" (");
341            out.push_str(&res.name);
342            out.push(')');
343        }
344        out.push('\n');
345    }
346    out.push_str("</resources>\n\n");
347    out
348}
349
350#[cfg(test)]
351#[path = "resources_tests.rs"]
352mod tests;