Skip to main content

pyro_artifacts/
build.rs

1
2use std::path::{Path, PathBuf};
3use std::io;
4use tokio::fs as tfs;
5use cargo_toml::Dependency;
6use std::sync::Arc;
7use crate::cache::{CacheManager, PyroductConfig, CacheError};
8use crate::artifacts::{ModuleSource, ModuleBinary, ModuleSpec};
9use crate::command::{run_command, format_syn_error, CommandError};
10use pyro_macro::module::generate_module_spec;
11use crate::cargo::ensure_cdylib;
12
13#[derive(Debug, thiserror::Error)]
14pub enum BuildError {
15    #[error("IO error — {context}: {error}")]
16    Io {
17        context: &'static str,
18        #[source]
19        error: std::io::Error,
20    },
21
22    #[error("Cargo error: {0}")]
23    Command(#[from] CommandError),
24
25    #[error("Manifest parse error: {0}")]
26    Manifest(String),
27
28    #[error("Documentation error: {0}")]
29    Documentation(String),
30
31    #[error("No build slot available: {0}")]
32    NoSlot(String),
33}
34
35impl From<std::io::Error> for BuildError {
36    fn from(e: std::io::Error) -> Self {
37        BuildError::Io {
38            context: "unexpected IO error",
39            error: e,
40        }
41    }
42}
43
44impl BuildError {
45    pub fn io(context: &'static str, error: std::io::Error) -> Self {
46        BuildError::Io { context, error }
47    }
48}
49
50pub struct Builder {
51    pub root: PathBuf,
52    pub target_dir: PathBuf,
53    pub pyroduct_dep: Dependency,
54    pub config: PyroductConfig,
55    pub build_slots: usize,
56    pub cache_manager: Arc<CacheManager>,
57}
58
59impl Builder {
60    pub async fn new(root: &Path, mut config: PyroductConfig, cache_manager: Arc<CacheManager>) -> Result<Self, CacheError> {
61        tfs::create_dir_all(root).await.map_err(|e| CacheError {
62            context: "Failed to create build root".to_string(),
63            error: e,
64        })?;
65
66
67        let pyroduct_dep = if let Some(dep) = &mut config.pyroduct {
68            crate::cache::resolve_dependency_path(dep, root);
69            dep.clone()
70        } else {
71            Dependency::Simple("*".to_string())
72        };
73
74        let target_dir = if let Some(target) = &config.target {
75            if target.is_relative() {
76                root.join(target)
77            } else {
78                target.clone()
79            }
80        } else {
81            root.join("target")
82        };
83
84        let build_slots = config.build_slots.unwrap_or(4).max(1);
85        tracing::info!(?root, "Setup Build directory");
86
87        let builder = Self {
88            root: root.to_path_buf(),
89            target_dir,
90            pyroduct_dep,
91            config,
92            build_slots,
93            cache_manager,
94        };
95
96        builder.init().await?;
97        Ok(builder)
98    }
99
100    pub async fn from_env(cache_manager: Arc<CacheManager>) -> Result<Self, CacheError> {
101        let root = std::env::var("PYRODUCT")
102            .map(PathBuf::from)
103            .unwrap_or_else(|_| {
104                let home = std::env::var("HOME")
105                    .or_else(|_| std::env::var("USERPROFILE"))
106                    .map(PathBuf::from)
107                    .unwrap_or_else(|_| PathBuf::from("."));
108                home.join(".pyroduct")
109            });
110
111        let config_path = root.join("config.toml");
112        let content = tfs::read_to_string(&config_path)
113            .await
114            .map_err(|error| CacheError {
115                context: format!("Failed to read the configuration"),
116                error,
117            })?;
118        let config = toml::from_str::<PyroductConfig>(&content).map_err(|error| CacheError {
119            context: format!("Failed to parse the configuration"),
120            error: io::Error::new(io::ErrorKind::InvalidData, error),
121        })?;
122
123        Self::new(&root, config, cache_manager).await
124    }
125
126    fn build_base_dir(&self) -> &Path {
127        &self.root
128    }
129
130    async fn init(&self) -> Result<(), CacheError> {
131        // Create all build slot directories
132        let build_base = self.build_base_dir();
133        for i in 0..self.build_slots {
134            let slot_dir = build_base.join(i.to_string());
135            tfs::create_dir_all(&slot_dir)
136                .await
137                .map_err(|error| CacheError {
138                    context: format!("Failed to create build slot dir {}", i),
139                    error,
140                })?;
141        }
142
143        let cargo_dir = self.root.join(".cargo");
144        tfs::create_dir_all(&cargo_dir)
145            .await
146            .map_err(|error| CacheError {
147                context: "Failed to create .cargo dir".to_string(),
148                error,
149            })?;
150
151        tfs::write(
152            cargo_dir.join("config.toml"),
153            format!("[build]\ntarget-dir = \"{}\"", self.target_dir.display()),
154        )
155        .await
156        .map_err(|error| CacheError {
157            context: "Failed to write target config.toml".to_string(),
158            error,
159        })?;
160        Ok(())
161    }
162
163    #[cfg(feature = "compiler")]
164    pub async fn compile(&self, source: &ModuleSource) -> Result<ModuleBinary, BuildError> {
165        let hash = source.hash();
166        
167        // Check if binary is already in cache
168        if let Ok(binary) = self.cache_manager.get_binary(&hash).await {
169            return Ok(binary);
170        }
171
172        // Acquire a file-locked build slot
173        let slot = BuildSlot::acquire_any(&self.build_base_dir(), self.build_slots).await?;
174        tracing::info!(slot = slot.index, hash = %hash, "Compiling in build slot");
175
176        let build_dir = &slot.dir;
177        let src_dir = build_dir.join("src");
178        tfs::create_dir_all(&src_dir)
179            .await
180            .map_err(|e| BuildError::io("create src dir", e))?;
181        tfs::write(src_dir.join("lib.rs"), &source.source)
182            .await
183            .map_err(|e| BuildError::io("write lib.rs", e))?;
184
185        let crate_name = format!("mod_slot{}", slot.index);
186        let basic_toml = format!(
187            r#"
188[package]
189name = "{crate_name}"
190version = "0.1.0"
191author = "anon"
192edition = "2024"
193
194[workspace]
195
196[lib]
197name = "mod_slot"
198
199[dependencies]
200"#
201        );
202
203        let mut manifest: cargo_toml::Manifest = toml::from_str(&basic_toml)
204            .map_err(|e| BuildError::Manifest(format!("Couldn't build base manifest: {}", e)))?;
205        let mut pyro_dep = self.pyroduct_dep.clone();
206        pyro_dep.detail_mut().features.push("module".to_string());
207        manifest
208            .dependencies
209            .insert("pyroduct".to_string(), pyro_dep);
210        for (dep_name, dep) in source.dependencies.dependencies.iter() {
211            manifest.dependencies.insert(dep_name.clone(), dep.clone());
212        }
213        for cap in source.dependencies.capabilities.iter() {
214            let path = self.cache_manager.interface_dir(&cap.author, &cap.package, &cap.version)
215                .to_string_lossy()
216                .into();
217            let dep = Dependency::Detailed(Box::new(cargo_toml::DependencyDetail {
218                path: Some(path),
219                ..Default::default()
220            }));
221            manifest.dependencies.insert(cap.package.clone(), dep);
222        }
223        manifest.lib = ensure_cdylib(manifest.lib.take());
224
225        // Patch pyroduct to redirect all dependencies to the builder's local path
226        let mut crates_io_patch = std::collections::BTreeMap::new();
227        crates_io_patch.insert("pyroduct".to_string(), self.pyroduct_dep.clone());
228        manifest.patch.insert("crates-io".to_string(), crates_io_patch);
229
230        let cargo_toml_content =
231            toml::to_string_pretty(&manifest).map_err(|e| BuildError::Manifest(e.to_string()))?;
232        tfs::write(build_dir.join("Cargo.toml"), &cargo_toml_content)
233            .await
234            .map_err(|e| BuildError::io("write Cargo.toml", e))?;
235
236        run_command(
237            build_dir,
238            &["build", "--release", "--target", "wasm32-unknown-unknown"],
239            true,
240        )
241        .await?;
242
243        let wasm_path = self
244            .target_dir
245            .join("wasm32-unknown-unknown")
246            .join("release")
247            .join("mod_slot.wasm");
248
249        let wasm = tfs::read(wasm_path)
250            .await
251            .map_err(|e| BuildError::io("read compiled wasm", e))?;
252
253        drop(slot);
254
255        let func = generate_module_spec(&source.source)
256            .map_err(|s| {
257                BuildError::Documentation(format_syn_error("Cannot generate docstring", s))
258            })?
259            .ok_or(BuildError::Documentation(
260                "Module main functions is missing".to_string(),
261            ))?;
262        let spec = ModuleSpec {
263            hash,
264            func,
265            capabilities: source.dependencies.capabilities.clone(),
266        };
267
268        let binary = ModuleBinary { wasm, spec };
269
270        // Save to cache
271        let _ = self.cache_manager.write_artifacts(&source.clone().into()).await;
272        let _ = self.cache_manager.write_artifacts(&binary.clone().into()).await;
273
274        Ok(binary)
275    }
276}
277
278pub struct BuildSlot {
279    pub index: usize,
280    pub dir: PathBuf,
281    _lock_file: std::fs::File,
282}
283
284impl BuildSlot {
285    fn try_acquire(build_base: &Path, index: usize) -> io::Result<Option<Self>> {
286        use fs2::FileExt;
287
288        let slot_dir = build_base.join(index.to_string());
289        std::fs::create_dir_all(&slot_dir)?;
290
291        let lock_path = slot_dir.join(".lock");
292        let lock_file = std::fs::OpenOptions::new()
293            .create(true)
294            .write(true)
295            .truncate(false)
296            .open(&lock_path)?;
297
298        if lock_file.try_lock_exclusive().is_ok() {
299            Ok(Some(BuildSlot {
300                index,
301                dir: slot_dir,
302                _lock_file: lock_file,
303            }))
304        } else {
305            Ok(None)
306        }
307    }
308
309    async fn acquire_any(build_base: &Path, slot_count: usize) -> Result<Self, BuildError> {
310        loop {
311            for i in 0..slot_count {
312                match Self::try_acquire(build_base, i) {
313                    Ok(Some(slot)) => {
314                        tracing::info!(slot = i, "Acquired build slot");
315                        return Ok(slot);
316                    }
317                    Ok(None) => continue,
318                    Err(e) => {
319                        return Err(BuildError::NoSlot(format!(
320                            "Failed to probe slot {}: {}",
321                            i, e
322                        )));
323                    }
324                }
325            }
326            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
327        }
328    }
329}