1use crate::artifacts::{PlaybookBinary, PlaybookSource, PlaybookSpec};
2use crate::cache::{CacheError, CacheManager, PyroductConfig};
3use crate::cargo::{ConfiguredCapability, ensure_cdylib};
4use crate::command::{CommandError, format_syn_error, run_command};
5use cargo_toml::Dependency;
6use pyro_macro::module::generate_module_spec;
7
8use crate::artifacts::PlaybookIdent;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct AnonPlaybook {
12 pub package: String,
13 pub dependencies: std::collections::BTreeMap<String, Dependency>,
14 pub configurations: Vec<ConfiguredCapability>,
15 pub source: String,
16 pub interconnect: std::collections::BTreeMap<String, PlaybookIdent>,
17}
18use std::io;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use tokio::fs as tfs;
22
23#[derive(Debug, thiserror::Error)]
24pub enum BuildError {
25 #[error("IO error — {context}: {error}")]
26 Io {
27 context: &'static str,
28 #[source]
29 error: std::io::Error,
30 },
31
32 #[error("Cargo error: {0}")]
33 Command(#[from] CommandError),
34
35 #[error("Manifest parse error: {0}")]
36 Manifest(String),
37
38 #[error("Documentation error: {0}")]
39 Documentation(String),
40
41 #[error("No build slot available: {0}")]
42 NoSlot(String),
43}
44
45impl From<std::io::Error> for BuildError {
46 fn from(e: std::io::Error) -> Self {
47 BuildError::Io {
48 context: "unexpected IO error",
49 error: e,
50 }
51 }
52}
53
54impl BuildError {
55 pub fn io(context: &'static str, error: std::io::Error) -> Self {
56 BuildError::Io { context, error }
57 }
58}
59
60pub struct Builder {
61 pub root: PathBuf,
62 pub target_dir: PathBuf,
63 pub pyroduct_dep: Dependency,
64 pub config: PyroductConfig,
65 pub build_slots: usize,
66 pub cache_manager: Arc<CacheManager>,
67}
68
69impl Builder {
70 #[tracing::instrument(skip(root, cache_manager), fields(root = %root.display()))]
71 pub async fn new(
72 root: &Path,
73 mut config: PyroductConfig,
74 cache_manager: Arc<CacheManager>,
75 ) -> Result<Self, CacheError> {
76 tracing::debug!("Creating Builder instance");
77 tfs::create_dir_all(root).await.map_err(|e| {
78 let err = CacheError::Io {
79 context: "Failed to create build root".to_string(),
80 error: e,
81 };
82 tracing::error!(error = ?err, "Failed to create build root directory");
83 err
84 })?;
85
86 let pyroduct_dep = if let Some(dep) = &mut config.pyroduct {
87 crate::cache::resolve_dependency_path(dep, root);
88 dep.clone()
89 } else {
90 Dependency::Simple("*".to_string())
91 };
92
93 let target_dir = if let Some(target) = &config.target {
94 if target.is_relative() {
95 root.join(target)
96 } else {
97 target.clone()
98 }
99 } else {
100 root.join("target")
101 };
102
103 let build_slots = config.build_slots.unwrap_or(4).max(1);
104 tracing::debug!(?root, "Setup Build directory");
105
106 let builder = Self {
107 root: root.to_path_buf(),
108 target_dir,
109 pyroduct_dep,
110 config,
111 build_slots,
112 cache_manager,
113 };
114
115 builder.init().await?;
116 Ok(builder)
117 }
118
119 #[tracing::instrument(skip(cache_manager))]
120 pub async fn from_env(cache_manager: Arc<CacheManager>) -> Result<Self, CacheError> {
121 tracing::debug!("Loading Builder from environment");
122 let root = std::env::var("PYRODUCT")
123 .map(PathBuf::from)
124 .unwrap_or_else(|_| {
125 let home = std::env::var("HOME")
126 .or_else(|_| std::env::var("USERPROFILE"))
127 .map(PathBuf::from)
128 .unwrap_or_else(|_| PathBuf::from("."));
129 home.join(".pyroduct")
130 });
131
132 let config_path = root.join("config.toml");
133 let content = tfs::read_to_string(&config_path).await.map_err(|error| {
134 let err = CacheError::Io {
135 context: "Failed to read the configuration".to_string(),
136 error,
137 };
138 tracing::error!(error = ?err, "Failed to read config file at {:?}", config_path);
139 err
140 })?;
141 let config = toml::from_str::<PyroductConfig>(&content).map_err(|error| {
142 let err = CacheError::Io {
143 context: "Failed to parse the configuration".to_string(),
144 error: io::Error::new(io::ErrorKind::InvalidData, error),
145 };
146 tracing::error!(error = ?err, "Failed to parse configuration toml");
147 err
148 })?;
149
150 Self::new(&root, config, cache_manager).await
151 }
152
153 fn build_base_dir(&self) -> &Path {
154 &self.root
155 }
156
157 #[tracing::instrument(skip(self))]
158 async fn init(&self) -> Result<(), CacheError> {
159 tracing::debug!("Initializing Builder directories");
160 let build_base = self.build_base_dir();
162 for i in 0..self.build_slots {
163 let slot_dir = build_base.join(i.to_string());
164 tfs::create_dir_all(&slot_dir).await.map_err(|error| {
165 let err = CacheError::Io {
166 context: format!("Failed to create build slot dir {}", i),
167 error,
168 };
169 tracing::error!(error = ?err, "Failed to create build slot directory {}", i);
170 err
171 })?;
172 }
173
174 let cargo_dir = self.root.join(".cargo");
175 tfs::create_dir_all(&cargo_dir).await.map_err(|error| {
176 let err = CacheError::Io {
177 context: "Failed to create .cargo dir".to_string(),
178 error,
179 };
180 tracing::error!(error = ?err, "Failed to create .cargo directory");
181 err
182 })?;
183
184 tfs::write(
185 cargo_dir.join("config.toml"),
186 format!("[build]\ntarget-dir = \"{}\"", self.target_dir.display()),
187 )
188 .await
189 .map_err(|error| {
190 let err = CacheError::Io {
191 context: "Failed to write target config.toml".to_string(),
192 error,
193 };
194 tracing::error!(error = ?err, "Failed to write .cargo/config.toml");
195 err
196 })?;
197 Ok(())
198 }
199
200 #[cfg(feature = "compiler")]
201 #[tracing::instrument(skip(self, playbook))]
202 pub async fn compile_anon(
203 &self,
204 playbook: &AnonPlaybook,
205 ) -> Result<PlaybookBinary, BuildError> {
206 let source = self.cache_manager.convert_anon_playbook(playbook.clone());
207 self.compile(&source).await
208 }
209
210 #[cfg(feature = "compiler")]
211 #[tracing::instrument(skip(self, source), fields(source_hash = %source.hash()))]
212 pub async fn compile(&self, source: &PlaybookSource) -> Result<PlaybookBinary, BuildError> {
213 let hash = source.hash();
214 let source_ident = source.ident();
215 if source_ident.package == "anon" {
216 return Err(BuildError::Manifest(
217 "Playbook name cannot be 'anon'".to_string(),
218 ));
219 }
220
221 let mut resolved_version = source_ident.version.clone();
222 let mut found_existing = false;
223
224 if source_ident.author == "anon" {
225 let mut version_num = 1;
226 loop {
227 let version_str = format!("0.{}.0", version_num);
228 match self
229 .cache_manager
230 .get_named_source("anon", &source_ident.package, &version_str)
231 .await
232 {
233 Ok(existing_source) => {
234 if existing_source.hash() == hash {
235 resolved_version = version_str;
236 found_existing = true;
237 break;
238 } else {
239 version_num += 1;
240 }
241 }
242 Err(_) => {
243 resolved_version = version_str;
244 break;
245 }
246 }
247 }
248 } else {
249 if let Ok(binary) = self
250 .cache_manager
251 .get_named_binary(
252 &source_ident.author,
253 &source_ident.package,
254 &source_ident.version,
255 )
256 .await
257 {
258 if binary.spec.hash == hash {
259 tracing::debug!("Named playbook binary found in cache, skipping compilation");
260 return Ok(binary);
261 }
262 }
263 }
264
265 if found_existing {
266 if let Ok(binary) = self
267 .cache_manager
268 .get_named_binary("anon", &source_ident.package, &resolved_version)
269 .await
270 {
271 tracing::debug!(
272 "Playbook binary found in cache (conflict resolved), skipping compilation"
273 );
274 return Ok(binary);
275 }
276 }
277
278 let slot = BuildSlot::acquire_any(self.build_base_dir(), self.build_slots).await?;
280 tracing::info!(slot = slot.index, hash = %hash, "Compiling in build slot");
281
282 let build_dir = &slot.dir;
283 let src_dir = build_dir.join("src");
284 tfs::create_dir_all(&src_dir).await.map_err(|e| {
285 let err = BuildError::io("create src dir", e);
286 tracing::error!(error = ?err, "Failed to create src directory in slot");
287 err
288 })?;
289 tfs::write(src_dir.join("lib.rs"), &source.source)
290 .await
291 .map_err(|e| {
292 let err = BuildError::io("write lib.rs", e);
293 tracing::error!(error = ?err, "Failed to write lib.rs in slot");
294 err
295 })?;
296
297 let crate_name = format!("mod_slot{}", slot.index);
298 let author = &source_ident.author;
299 let basic_toml = format!(
300 r#"
301[package]
302name = "{crate_name}"
303version = "{resolved_version}"
304authors = ["{author}"]
305edition = "2024"
306
307[workspace]
308
309[lib]
310name = "mod_slot"
311
312[dependencies]
313"#
314 );
315
316 let mut manifest: cargo_toml::Manifest = toml::from_str(&basic_toml).map_err(|e| {
317 let err = BuildError::Manifest(format!("Couldn't build base manifest: {}", e));
318 tracing::error!(error = ?err, "Failed to parse base basic_toml");
319 err
320 })?;
321 let mut pyro_dep = self.pyroduct_dep.clone();
322 pyro_dep.detail_mut().features.push("module".to_string());
323 manifest
324 .dependencies
325 .insert("pyroduct".to_string(), pyro_dep);
326 for (dep_name, dep) in source.dependencies().dependencies.iter() {
327 manifest.dependencies.insert(dep_name.clone(), dep.clone());
328 }
329 for cap in source.dependencies().capabilities.iter() {
330 let path = self
331 .cache_manager
332 .interface_dir(&cap.author, &cap.package, &cap.version)
333 .to_string_lossy()
334 .into();
335 let dep = Dependency::Detailed(Box::new(cargo_toml::DependencyDetail {
336 path: Some(path),
337 ..Default::default()
338 }));
339 manifest.dependencies.insert(cap.package.clone(), dep);
340 }
341 manifest.lib = ensure_cdylib(manifest.lib.take());
342
343 let cargo_toml_content = toml::to_string_pretty(&manifest).map_err(|e| {
344 let err = BuildError::Manifest(e.to_string());
345 tracing::error!(error = ?err, "Failed to serialize slot Cargo.toml");
346 err
347 })?;
348 tfs::write(build_dir.join("Cargo.toml"), &cargo_toml_content)
349 .await
350 .map_err(|e| {
351 let err = BuildError::io("write Cargo.toml", e);
352 tracing::error!(error = ?err, "Failed to write slot Cargo.toml");
353 err
354 })?;
355
356 tracing::debug!("Running cargo compilation command in slot {}", slot.index);
357 run_command(
358 build_dir,
359 &["build", "--release", "--target", "wasm32-unknown-unknown"],
360 true,
361 )
362 .await
363 .map_err(|e| {
364 tracing::error!(error = ?e, "Cargo compilation failed");
365 BuildError::Command(e)
366 })?;
367
368 let wasm_path = self
369 .target_dir
370 .join("wasm32-unknown-unknown")
371 .join("release")
372 .join("mod_slot.wasm");
373
374 let wasm = tfs::read(&wasm_path)
375 .await
376 .map_err(|e| {
377 let err = BuildError::io("read compiled wasm", e);
378 tracing::error!(error = ?err, "Failed to read compiled WASM artifact at {:?}", wasm_path);
379 err
380 })?;
381
382 drop(slot);
383
384 let mut dep_interfaces = Vec::new();
385 for cap in source.dependencies().capabilities.iter() {
386 if let Ok(spec_str) = self
387 .cache_manager
388 .capability_interface_spec(&cap.author, &cap.package, &cap.version)
389 .await
390 {
391 if let Ok(spec) = serde_json::from_str::<pyro_spec::InterfaceSpec>(&spec_str) {
392 dep_interfaces.push(spec);
393 }
394 }
395 }
396
397 let func = generate_module_spec(&source.source, &dep_interfaces)
398 .map_err(|s| {
399 let err = BuildError::Documentation(format_syn_error("Cannot generate docstring", s));
400 tracing::error!(error = ?err, "Failed to generate module spec from source docstrings");
401 err
402 })?
403 .ok_or_else(|| {
404 let err = BuildError::Documentation("Module main functions is missing".to_string());
405 tracing::error!(error = ?err, "Module main function is missing");
406 err
407 })?;
408 let spec = PlaybookSpec {
409 ident: crate::artifacts::PlaybookIdent {
410 author: source_ident.author.clone(),
411 package: source_ident.package.clone(),
412 version: resolved_version.clone(),
413 },
414 hash,
415 func,
416 capabilities: source.dependencies().capabilities.clone(),
417 interconnect: source.manifest.interconnect.clone(),
418 };
419
420 let binary = PlaybookBinary {
421 wasm,
422 spec,
423 configurations: source.configurations().clone(),
424 };
425
426 let mut updated_source = source.clone();
427 updated_source.manifest.module = crate::cargo::CapabilityIdent {
428 author: source_ident.author.clone(),
429 package: source_ident.package.clone(),
430 version: resolved_version.clone(),
431 };
432
433 tracing::debug!("Saving source and compiled binary to CacheManager");
435 if let Err(e) = self
436 .cache_manager
437 .write_artifacts(&updated_source.into())
438 .await
439 {
440 tracing::error!(error = ?e, "Failed to save playbook source to cache");
441 }
442 if let Err(e) = self
443 .cache_manager
444 .write_artifacts(&binary.clone().into())
445 .await
446 {
447 tracing::error!(error = ?e, "Failed to save playbook binary to cache");
448 }
449
450 tracing::info!("Playbook compilation completed successfully");
451 Ok(binary)
452 }
453}
454
455pub struct BuildSlot {
456 pub index: usize,
457 pub dir: PathBuf,
458 _lock_file: std::fs::File,
459}
460
461impl BuildSlot {
462 #[tracing::instrument(skip(build_base))]
463 fn try_acquire(build_base: &Path, index: usize) -> io::Result<Option<Self>> {
464 use fs2::FileExt;
465
466 tracing::debug!("Probing build slot lock file");
467 let slot_dir = build_base.join(index.to_string());
468 std::fs::create_dir_all(&slot_dir)?;
469
470 let lock_path = slot_dir.join(".lock");
471 let lock_file = std::fs::OpenOptions::new()
472 .create(true)
473 .write(true)
474 .truncate(false)
475 .open(&lock_path)?;
476
477 if lock_file.try_lock_exclusive().is_ok() {
478 tracing::debug!("Lock acquired successfully for build slot {}", index);
479 Ok(Some(BuildSlot {
480 index,
481 dir: slot_dir,
482 _lock_file: lock_file,
483 }))
484 } else {
485 tracing::debug!("Build slot {} lock is already held", index);
486 Ok(None)
487 }
488 }
489
490 #[tracing::instrument(skip(build_base))]
491 async fn acquire_any(build_base: &Path, slot_count: usize) -> Result<Self, BuildError> {
492 tracing::debug!("Acquiring any available build slot...");
493 loop {
494 for i in 0..slot_count {
495 match Self::try_acquire(build_base, i) {
496 Ok(Some(slot)) => {
497 tracing::debug!(slot = i, "Acquired build slot");
498 return Ok(slot);
499 }
500 Ok(None) => continue,
501 Err(e) => {
502 let err = BuildError::NoSlot(format!("Failed to probe slot {}: {}", i, e));
503 tracing::error!(error = ?err, "Failed to probe build slot lock file");
504 return Err(err);
505 }
506 }
507 }
508 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
509 }
510 }
511}