Skip to main content

pyro_artifacts/
build.rs

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        // Create all build slot directories
161        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        // Acquire a file-locked build slot
279        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        // Save to cache
434        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}