Skip to main content

bake_agent_context/
package.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use super::document::Document;
5use super::installer::{Definition, FileDefinition, Specification};
6use super::skill::SkillDefinition;
7#[cfg(test)]
8use super::test_filesystem as fs;
9use bake::{Error, Result};
10#[cfg(not(test))]
11use std::fs;
12use std::path::{Component, Path};
13
14/// A loaded provider and its context definitions.
15pub struct Package {
16    pub specification: Specification,
17    pub definitions: Vec<Definition>,
18}
19
20impl Package {
21    /// Classify root documents before descending into companion resource directories.
22    pub fn load(specification: Specification) -> Result<Self> {
23        let mut package = Self {
24            specification,
25            definitions: Vec::new(),
26        };
27        let context_path = package.specification.context_path.clone();
28        Self::load_directory(
29            &package.specification,
30            &mut package.definitions,
31            &context_path,
32        )?;
33        package
34            .definitions
35            .sort_by(|left, right| left.path().cmp(right.path()));
36        Ok(package)
37    }
38
39    fn load_directory(
40        specification: &Specification,
41        definitions: &mut Vec<Definition>,
42        directory: &Path,
43    ) -> Result<()> {
44        // Load Markdown declarations before interpreting subdirectories:
45        let entries = fs::read_dir(directory)
46            .map_err(|error| Error::new(format!("cannot read {}: {error}", directory.display())))?;
47        let mut directories = Vec::new();
48        for entry in entries {
49            let entry = entry?;
50            let kind = entry.file_type()?;
51            let path = entry.path();
52            if kind.is_dir() {
53                directories.push(path);
54            } else if kind.is_file() {
55                let document = if path
56                    .extension()
57                    .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
58                {
59                    Some(Document::load(&path)?)
60                } else {
61                    None
62                };
63                if let Some(document) = document.as_ref()
64                    && document.is_skill()
65                {
66                    definitions.push(Definition::Skill(SkillDefinition::from_document(
67                        specification,
68                        document,
69                    )?));
70                } else {
71                    let relative = path
72                        .strip_prefix(&specification.context_path)
73                        .expect("context files belong to their package")
74                        .to_owned();
75                    definitions.push(Definition::File(FileDefinition {
76                        path: relative,
77                        source_path: path,
78                        document,
79                    }));
80                }
81            }
82        }
83
84        // Companion resources belong to the skill and remain opaque:
85        for directory in directories {
86            if !definitions.iter().any(|definition| match definition {
87                Definition::Skill(skill) => {
88                    directory
89                        == specification
90                            .context_path
91                            .join(skill.path.file_stem().unwrap())
92                }
93                Definition::File(_) => false,
94            }) {
95                Self::load_directory(specification, definitions, &directory)?;
96            }
97        }
98        Ok(())
99    }
100
101    /// Find a source path, optionally omitting .md, then an installed skill name.
102    pub fn find_definition(&self, file: &str) -> Result<Option<&Definition>> {
103        let requested = Path::new(file);
104        if file.is_empty()
105            || requested.is_absolute()
106            || requested
107                .components()
108                .any(|part| !matches!(part, Component::Normal(_)))
109        {
110            return Err(Error::new(
111                "context file must be a relative path inside context/",
112            ));
113        }
114
115        let mut candidates = vec![requested.to_owned()];
116        if requested.extension().is_none() {
117            candidates.push(requested.with_extension("md"));
118        }
119        for path in candidates {
120            if let Some(definition) = self.definitions.iter().find(|item| item.path() == path) {
121                return Ok(Some(definition));
122            }
123        }
124        Ok(self
125            .definitions
126            .iter()
127            .find(|item| matches!(item, Definition::Skill(skill) if skill.name == file)))
128    }
129
130    /// Refresh ordinary definitions from loaded Markdown and opaque resources.
131    pub(crate) fn install_files(&self, destination: &Path) -> Result<bool> {
132        super::installer::remove_existing(destination)?;
133        let files: Vec<_> = self
134            .definitions
135            .iter()
136            .filter_map(|definition| match definition {
137                Definition::File(file) => Some(file),
138                Definition::Skill(_) => None,
139            })
140            .collect();
141
142        for file in &files {
143            let target = destination.join(&file.path);
144            fs::create_dir_all(target.parent().unwrap()).map_err(|error| {
145                Error::new(format!("cannot create {}: {error}", target.display()))
146            })?;
147            if let Some(document) = &file.document {
148                document.write(&target)?;
149            } else {
150                fs::copy(&file.source_path, &target).map_err(|error| {
151                    Error::new(format!(
152                        "cannot copy {} to {}: {error}",
153                        file.source_path.display(),
154                        target.display()
155                    ))
156                })?;
157            }
158        }
159        Ok(!files.is_empty())
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::super::test_filesystem::{Operation, fail_once};
166    use super::*;
167    use std::fs;
168    use tempfile::tempdir;
169
170    #[test]
171    fn installs_loaded_documents_and_opaque_assets() {
172        let directory = tempdir().unwrap();
173        let source = directory.path().join("context");
174        fs::create_dir(&source).unwrap();
175        fs::write(source.join("guide.md"), "# Original\n").unwrap();
176        fs::write(source.join("image.bin"), [0, 255]).unwrap();
177        #[cfg(unix)]
178        std::os::unix::fs::symlink("guide.md", source.join("linked.md")).unwrap();
179        let specification = Specification::for_test("provider", "1.0.0", source.clone());
180        let loaded = Package::load(specification).unwrap();
181        assert_eq!(loaded.specification.name, "provider");
182        fs::write(source.join("guide.md"), "# Changed\n").unwrap();
183        let destination = directory.path().join("installed");
184        loaded.install_files(&destination).unwrap();
185        assert_eq!(
186            fs::read_to_string(destination.join("guide.md")).unwrap(),
187            "# Original\n"
188        );
189        assert_eq!(fs::read(destination.join("image.bin")).unwrap(), [0, 255]);
190        assert!(!destination.join("linked.md").exists());
191        for (operation, path) in [
192            (Operation::CreateDirectoryTree, destination.clone()),
193            (Operation::Write, destination.join("guide.md")),
194            (Operation::Copy, source.join("image.bin")),
195        ] {
196            let _failure = fail_once(operation, move |candidate| candidate == path);
197            assert!(
198                loaded
199                    .install_files(&destination)
200                    .unwrap_err()
201                    .to_string()
202                    .contains("injected filesystem failure")
203            );
204        }
205    }
206
207    #[test]
208    fn preserves_skill_source_paths_and_empty_providers() {
209        let directory = tempdir().unwrap();
210        let context = directory.path().join("context");
211        fs::create_dir(&context).unwrap();
212        let specification = Specification::for_test("provider", "1.0.0", context.clone());
213        assert!(
214            Package::load(specification.clone())
215                .unwrap()
216                .definitions
217                .is_empty()
218        );
219        let source = "---\nmetadata: {type: skill}\nname: installed-name\ndescription: Workflow.\n---\n# Workflow\n";
220        fs::write(context.join("workflow.MD"), source).unwrap();
221        let package = Package::load(specification).unwrap();
222        let definition = package.find_definition("workflow.MD").unwrap().unwrap();
223        assert_eq!(definition.path(), Path::new("workflow.MD"));
224        assert_eq!(definition.content().unwrap(), source);
225        assert_eq!(
226            package
227                .find_definition("installed-name")
228                .unwrap()
229                .unwrap()
230                .content()
231                .unwrap(),
232            source
233        );
234    }
235
236    #[test]
237    fn reports_provider_traversal_errors() {
238        let directory = tempdir().unwrap();
239        let source = directory.path().join("context");
240        fs::create_dir(&source).unwrap();
241        fs::write(source.join("guide.md"), "# Guide\n").unwrap();
242        let specification = Specification::for_test("provider", "1.0.0", source.clone());
243        for (operation, path) in [
244            (Operation::ReadDirectory, source.clone()),
245            (Operation::ReadDirectoryEntry, source.clone()),
246            (Operation::FileType, source.join("guide.md")),
247        ] {
248            let _failure = fail_once(operation, move |candidate| candidate == path);
249            assert!(
250                Package::load(specification.clone())
251                    .err()
252                    .unwrap()
253                    .to_string()
254                    .contains("injected filesystem failure")
255            );
256        }
257    }
258}