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