Skip to main content

burn_central_workspace/entity/projects/
mod.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, Mutex};
3
4use cargo_metadata::TargetKind;
5
6use crate::entity::projects::burn_dir::{BurnDir, project::BurnCentralProject};
7use crate::event::Reporter;
8use crate::execution::cancellable::CancellationToken;
9use crate::tools::function_discovery::{
10    DiscoveryConfig, DiscoveryError, DiscoveryEvent, FunctionDiscovery, PkgId,
11};
12use crate::tools::functions_registry::FunctionRegistry;
13
14pub mod burn_dir;
15
16#[derive(Debug)]
17pub enum ErrorKind {
18    ManifestNotFound,
19    Parsing,
20    BurnDirInitialization,
21    BurnDirNotInitialized,
22    Unexpected,
23}
24
25#[derive(thiserror::Error, Debug)]
26pub struct ProjectContextError {
27    message: String,
28    kind: ErrorKind,
29    #[source]
30    source: Option<anyhow::Error>,
31}
32
33impl ProjectContextError {
34    pub fn new(message: String, kind: ErrorKind, source: Option<anyhow::Error>) -> Self {
35        Self {
36            message,
37            kind,
38            source,
39        }
40    }
41
42    pub fn kind(&self) -> &ErrorKind {
43        &self.kind
44    }
45
46    pub fn is_burn_dir_not_initialized(&self) -> bool {
47        matches!(self.kind, ErrorKind::BurnDirNotInitialized)
48    }
49}
50
51impl std::fmt::Display for ProjectContextError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "{}", self.message)
54    }
55}
56
57pub struct ProjectContext {
58    pub workspace_info: WorkspaceInfo,
59    pub build_profile: String,
60    pub burn_dir: BurnDir,
61    pub project: BurnCentralProject,
62    function_registry: Mutex<FunctionRegistry>,
63}
64
65pub struct WorkspaceInfo {
66    pub workspace_name: String,
67    pub workspace_root: PathBuf,
68    pub metadata: cargo_metadata::Metadata,
69}
70
71impl WorkspaceInfo {
72    pub fn load_from_path(manifest_path: &Path) -> Result<Self, ProjectContextError> {
73        if !manifest_path.is_file() {
74            return Err(ProjectContextError::new(
75                format!(
76                    "Cargo.toml not found at specified path '{}'",
77                    manifest_path.display()
78                ),
79                ErrorKind::ManifestNotFound,
80                None,
81            ));
82        }
83        let metadata = cargo_metadata::MetadataCommand::new()
84            .manifest_path(manifest_path)
85            .no_deps()
86            .exec()
87            .map_err(|e| {
88                ProjectContextError::new(
89                    format!(
90                        "Failed to load cargo metadata for manifest at '{}': {}",
91                        manifest_path.display(),
92                        e
93                    ),
94                    ErrorKind::Parsing,
95                    Some(anyhow::anyhow!(e)),
96                )
97            })?;
98
99        let workspace_root = metadata.workspace_root.clone().into_std_path_buf();
100
101        // Determine workspace name from workspace Cargo.toml
102        let workspace_toml_path = workspace_root.join("Cargo.toml");
103        if !workspace_toml_path.exists() {
104            return Err(ProjectContextError::new(
105                format!(
106                    "Cargo.toml not found at workspace root '{}'. This is not a valid cargo project.",
107                    workspace_toml_path.display()
108                ),
109                ErrorKind::ManifestNotFound,
110                None,
111            ));
112        }
113
114        let toml_str = std::fs::read_to_string(&workspace_toml_path).map_err(|e| {
115            ProjectContextError::new(
116                format!(
117                    "Failed to read Cargo.toml at '{}': {}",
118                    workspace_toml_path.display(),
119                    e
120                ),
121                ErrorKind::Parsing,
122                Some(anyhow::anyhow!(e)),
123            )
124        })?;
125
126        let workspace_name = extract_workspace_name_from_toml(&toml_str).unwrap_or_else(|| {
127            workspace_root
128                .file_name()
129                .and_then(|name| name.to_str())
130                .unwrap_or("workspace")
131                .to_string()
132        });
133
134        Ok(WorkspaceInfo {
135            workspace_name,
136            workspace_root,
137            metadata,
138        })
139    }
140
141    pub fn get_ws_root(&self) -> PathBuf {
142        self.metadata.workspace_root.clone().into_std_path_buf()
143    }
144
145    pub fn get_manifest_path(&self) -> PathBuf {
146        self.workspace_root.join(PathBuf::from("Cargo.toml"))
147    }
148}
149
150impl ProjectContext {
151    pub fn load_workspace_info(manifest_path: &Path) -> Result<WorkspaceInfo, ProjectContextError> {
152        WorkspaceInfo::load_from_path(manifest_path)
153    }
154
155    pub fn load(manifest_path: &Path, burn_dir_name: &str) -> Result<Self, ProjectContextError> {
156        let workspace_info = WorkspaceInfo::load_from_path(manifest_path)?;
157        let burn_dir_root = workspace_info
158            .workspace_root
159            .join(PathBuf::from(burn_dir_name));
160        let burn_dir = BurnDir::new(burn_dir_root);
161        burn_dir.init().map_err(|e| {
162            ProjectContextError::new(
163                "Failed to initialize Burn directory".to_string(),
164                ErrorKind::BurnDirInitialization,
165                Some(e.into()),
166            )
167        })?;
168
169        let project = burn_dir
170            .load_project()
171            .map_err(|e| {
172                ProjectContextError::new(
173                    "Failed to load project metadata from Burn directory".to_string(),
174                    ErrorKind::BurnDirNotInitialized,
175                    Some(e.into()),
176                )
177            })?
178            .ok_or_else(|| {
179                ProjectContextError::new(
180                    "No Burn Central project linked to this repository".to_string(),
181                    ErrorKind::BurnDirNotInitialized,
182                    None,
183                )
184            })?;
185
186        Ok(Self {
187            workspace_info,
188            build_profile: "release".to_string(),
189            burn_dir,
190            project,
191            function_registry: Mutex::new(Default::default()),
192        })
193    }
194
195    pub fn init(
196        project: BurnCentralProject,
197        manifest_path: &Path,
198        burn_dir_name: &str,
199    ) -> Result<Self, ProjectContextError> {
200        let workspace_info = WorkspaceInfo::load_from_path(manifest_path)?;
201
202        let burn_dir_root = workspace_info
203            .workspace_root
204            .join(PathBuf::from(burn_dir_name));
205        let burn_dir = BurnDir::new(burn_dir_root);
206        burn_dir.init().map_err(|e| {
207            ProjectContextError::new(
208                "Failed to initialize Burn directory".to_string(),
209                ErrorKind::BurnDirInitialization,
210                Some(e.into()),
211            )
212        })?;
213
214        burn_dir.save_project(&project).map_err(|e| {
215            ProjectContextError::new(
216                "Failed to save project metadata to Burn directory".to_string(),
217                ErrorKind::BurnDirInitialization,
218                Some(e.into()),
219            )
220        })?;
221
222        Ok(Self {
223            workspace_info,
224            build_profile: "release".to_string(),
225            burn_dir,
226            project: project.clone(),
227            function_registry: Mutex::new(Default::default()),
228        })
229    }
230
231    pub fn unlink(manifest_path: &Path, burn_dir_name: &str) -> Result<(), ProjectContextError> {
232        let workspace_info = WorkspaceInfo::load_from_path(manifest_path)?;
233
234        let burn_dir_root = workspace_info
235            .workspace_root
236            .join(PathBuf::from(burn_dir_name));
237        let burn_dir = BurnDir::new(burn_dir_root);
238
239        std::fs::remove_dir_all(burn_dir.root()).map_err(|e| {
240            ProjectContextError::new(
241                "Failed to remove Burn directory".to_string(),
242                ErrorKind::Unexpected,
243                Some(e.into()),
244            )
245        })?;
246
247        Ok(())
248    }
249
250    pub fn get_project(&self) -> &BurnCentralProject {
251        &self.project
252    }
253
254    pub fn get_workspace_name(&self) -> &str {
255        &self.workspace_info.workspace_name
256    }
257
258    pub fn get_workspace_path(&self) -> &Path {
259        &self.workspace_info.workspace_root
260    }
261
262    pub fn get_workspace_root(&self) -> &Path {
263        &self.workspace_info.workspace_root
264    }
265
266    pub fn get_manifest_path(&self) -> PathBuf {
267        self.workspace_info.get_manifest_path()
268    }
269
270    pub fn burn_dir(&self) -> &BurnDir {
271        &self.burn_dir
272    }
273
274    pub fn cwd(&self) -> &Path {
275        &self.workspace_info.workspace_root
276    }
277
278    pub fn load_functions(
279        &self,
280        reporter: Option<Arc<dyn Reporter<DiscoveryEvent>>>,
281    ) -> Result<FunctionRegistry, DiscoveryError> {
282        let token = CancellationToken::new();
283        self.load_functions_cancellable(&token, reporter)
284    }
285
286    pub fn load_functions_cancellable(
287        &self,
288        cancel_token: &CancellationToken,
289        reporter: Option<Arc<dyn Reporter<DiscoveryEvent>>>,
290    ) -> Result<FunctionRegistry, DiscoveryError> {
291        let mut functions = self.function_registry.lock().unwrap();
292        if functions.is_empty() {
293            // Discover all workspace packages
294            let workspace_pkgids = self.workspace_info.metadata.workspace_members.clone();
295            let workspace_packages: Vec<_> = self
296                .workspace_info
297                .metadata
298                .packages
299                .iter()
300                .filter(|pkg| workspace_pkgids.contains(&pkg.id))
301                .collect();
302
303            let pkgids = workspace_packages
304                .iter()
305                .filter(|pkg| {
306                    pkg.targets
307                        .iter()
308                        .any(|t| t.kind.iter().any(|k| matches!(k, TargetKind::Lib)))
309                })
310                .map(|pkg| PkgId {
311                    name: pkg.name.to_string(),
312                    version: Some(pkg.version.to_string()),
313                })
314                .collect::<Vec<_>>();
315
316            let config = DiscoveryConfig {
317                packages: pkgids,
318                target_dir: Some(self.burn_dir.target_dir()),
319            };
320
321            let result = FunctionDiscovery::new(self.get_workspace_root()).discover_functions(
322                &config,
323                cancel_token,
324                reporter,
325            )?;
326
327            let mut registry = FunctionRegistry::new();
328            for (pkgid, funcs) in result.functions.into_iter() {
329                let package = workspace_packages
330                    .iter()
331                    .find(|pkg| pkg.name.as_str() == pkgid.name)
332                    .expect("Discovered package should be in workspace");
333                registry
334                    .get_or_create_package_entry((*package).clone())
335                    .extend(funcs);
336            }
337
338            *functions = registry;
339        }
340        Ok(functions.clone())
341    }
342
343    pub fn get_workspace_packages(&self) -> Vec<&cargo_metadata::Package> {
344        self.workspace_info
345            .metadata
346            .packages
347            .iter()
348            .filter(|pkg| {
349                self.workspace_info
350                    .metadata
351                    .workspace_members
352                    .contains(&pkg.id)
353            })
354            .collect()
355    }
356
357    pub fn find_package_by_name(&self, name: &str) -> Option<&cargo_metadata::Package> {
358        self.get_workspace_packages()
359            .into_iter()
360            .find(|pkg| pkg.name.as_str() == name)
361    }
362}
363
364/// Extract workspace name from TOML content
365/// Returns None if:
366/// - TOML content cannot be parsed
367/// - Neither workspace.package.name nor package.name exists
368fn extract_workspace_name_from_toml(toml_content: &str) -> Option<String> {
369    let workspace_document = toml_content.parse::<toml::Value>().ok()?;
370
371    workspace_document
372        .get("workspace")
373        .and_then(|ws| ws.get("package"))
374        .and_then(|pkg| pkg.get("name"))
375        .and_then(|name| name.as_str())
376        .or_else(|| {
377            workspace_document
378                .get("package")
379                .and_then(|pkg| pkg.get("name"))
380                .and_then(|name| name.as_str())
381        })
382        .map(|s| s.to_string())
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_extract_workspace_name_from_workspace_package() {
391        let toml_content = r#"
392[workspace]
393members = ["crate1", "crate2"]
394
395[workspace.package]
396name = "my-awesome-workspace"
397version = "0.1.0"
398"#;
399        let result = extract_workspace_name_from_toml(toml_content);
400        assert_eq!(result, Some("my-awesome-workspace".to_string()));
401    }
402
403    #[test]
404    fn test_extract_workspace_name_from_package() {
405        let toml_content = r#"
406[package]
407name = "single-crate-project"
408version = "0.1.0"
409"#;
410        let result = extract_workspace_name_from_toml(toml_content);
411        assert_eq!(result, Some("single-crate-project".to_string()));
412    }
413
414    #[test]
415    fn test_extract_workspace_name_prefers_workspace_over_package() {
416        let toml_content = r#"
417[workspace]
418members = ["crate1"]
419
420[workspace.package]
421name = "workspace-name"
422
423[package]
424name = "package-name"
425"#;
426        let result = extract_workspace_name_from_toml(toml_content);
427        assert_eq!(result, Some("workspace-name".to_string()));
428    }
429
430    #[test]
431    fn test_extract_workspace_name_returns_none_when_no_name() {
432        let toml_content = r#"
433[workspace]
434members = ["crate1", "crate2"]
435"#;
436        let result = extract_workspace_name_from_toml(toml_content);
437        assert_eq!(result, None);
438    }
439
440    #[test]
441    fn test_extract_workspace_name_returns_none_for_invalid_toml() {
442        let toml_content = "this is not valid toml { [ }";
443        let result = extract_workspace_name_from_toml(toml_content);
444        assert_eq!(result, None);
445    }
446
447    #[test]
448    fn test_extract_workspace_name_returns_none_for_empty_toml() {
449        let toml_content = "";
450        let result = extract_workspace_name_from_toml(toml_content);
451        assert_eq!(result, None);
452    }
453}