bake_agent_context/
package.rs1use 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
14pub struct Package {
16 pub specification: Specification,
17 pub definitions: Vec<Definition>,
18}
19
20impl Package {
21 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 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 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 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 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}