elf_magic/
io.rs

1use serde::Deserialize;
2use std::fs;
3use std::path::Path;
4use std::process::{Command, Stdio};
5use tempfile::NamedTempFile;
6
7use crate::{
8    codegen,
9    domain::{ElfMagicError, ManifestConfig, Package, SolanaProgram, Workspace},
10};
11
12/// Represents the structure of cargo metadata JSON output
13#[derive(Debug, Deserialize)]
14struct CargoMetadata {
15    // root_path: PathBuf,
16    packages: Vec<Package>,
17}
18
19/// Represents the structure of a Cargo.toml file for parsing metadata
20#[derive(Debug, Deserialize)]
21struct CargoToml {
22    package: Option<PackageSection>,
23}
24
25#[derive(Debug, Deserialize)]
26struct PackageSection {
27    metadata: Option<Metadata>,
28}
29
30#[derive(Debug, Deserialize)]
31struct Metadata {
32    #[serde(rename = "elf-magic")]
33    elf_magic: Option<ManifestConfig>,
34}
35
36/// Parse manifest configuration from package.metadata.elf-magic
37///
38/// Reads the include/exclude patterns from the Cargo.toml metadata section.
39/// If no configuration is found, returns a safe default that includes nothing.
40pub fn parse_manifest_config(cargo_manifest_dir: &Path) -> Result<ManifestConfig, ElfMagicError> {
41    let manifest_path = cargo_manifest_dir.join("Cargo.toml");
42
43    // Read the Cargo.toml file
44    let content = fs::read_to_string(&manifest_path).map_err(|e| {
45        ElfMagicError::Metadata(format!(
46            "Failed to read Cargo.toml at {}: {}",
47            manifest_path.display(),
48            e
49        ))
50    })?;
51
52    // Parse the TOML
53    let cargo_toml: CargoToml = toml::from_str(&content)
54        .map_err(|e| ElfMagicError::Metadata(format!("Failed to parse Cargo.toml: {}", e)))?;
55
56    // Extract elf-magic configuration or use permissive default (aka: magic ✨)
57    let config = cargo_toml
58        .package
59        .and_then(|p| p.metadata)
60        .and_then(|m| m.elf_magic)
61        .unwrap_or_else(ManifestConfig::allow_all);
62
63    Ok(config)
64}
65
66/// Discover workspace using the provided manifest configuration
67///
68/// This function finds the workspace root and all workspace members,
69/// then parses each member's Cargo.toml to extract crate types.
70pub fn discover_workspace(cargo_manifest_dir: &Path) -> Result<Workspace, ElfMagicError> {
71    let manifest_path = cargo_manifest_dir.join("Cargo.toml");
72
73    let output = Command::new("cargo")
74        .args([
75            "metadata",
76            "--format-version",
77            "1",
78            "--no-deps",
79            "--manifest-path",
80            manifest_path.to_str().unwrap(),
81        ])
82        .stderr(Stdio::inherit())
83        .output()
84        .map_err(|e| {
85            ElfMagicError::WorkspaceDiscovery(format!("Failed to execute cargo metadata: {}", e))
86        })?;
87
88    // Parse the JSON output
89    let mut metadata: CargoMetadata = serde_json::from_slice(&output.stdout).map_err(|e| {
90        ElfMagicError::WorkspaceDiscovery(format!("Failed to parse cargo metadata JSON: {}", e))
91    })?;
92
93    // Sort members by name for stable, predictable output
94    metadata.packages.sort_by(|a, b| a.name.cmp(&b.name));
95
96    Ok(Workspace {
97        packages: metadata.packages,
98    })
99}
100
101/// Write the generated code to src/lib.rs
102///
103/// Creates the generated lib.rs file using atomic writes with proper temp file handling.
104/// Formats the code before final placement.
105pub fn write_lib_file(
106    cargo_manifest_dir: &Path,
107    programs: &[SolanaProgram],
108) -> Result<(), ElfMagicError> {
109    // Render the template content using codegen
110    let rendered_content = codegen::render_lib_file(programs)?;
111
112    // Create target path relative to the cargo manifest directory
113    let target_path = cargo_manifest_dir.join("src/lib.rs");
114    let target_dir = target_path.parent().unwrap_or(cargo_manifest_dir);
115
116    let temp_file = NamedTempFile::new_in(target_dir).map_err(ElfMagicError::Io)?;
117
118    // Write content to temporary file
119    fs::write(temp_file.path(), &rendered_content).map_err(ElfMagicError::Io)?;
120
121    // Run cargo fmt on the temporary file before moving
122    // Get the path as a string to avoid borrow issues
123    let temp_path = temp_file.path().to_path_buf();
124    let fmt_result = Command::new("cargo")
125        .arg("fmt")
126        .arg("--")
127        .arg(&temp_path)
128        .output();
129
130    match fmt_result {
131        Ok(output) if !output.status.success() => {
132            eprintln!(
133                "Warning: cargo fmt failed: {}",
134                String::from_utf8_lossy(&output.stderr)
135            );
136        }
137        Err(e) => {
138            eprintln!("Warning: failed to run cargo fmt: {}", e);
139        }
140        _ => {} // Success
141    }
142
143    // Atomically move the formatted temporary file to final location
144    temp_file
145        .persist(target_path)
146        .map_err(|e| ElfMagicError::Io(e.error))?;
147
148    Ok(())
149}
150
151/// Set up incremental build dependencies
152///
153/// Tells cargo to rerun this build script whenever any of the
154/// Solana program source files change.
155pub fn setup_incremental_builds(programs: &[SolanaProgram]) -> Result<(), ElfMagicError> {
156    for program in programs {
157        // Watch the program's src directory for changes
158        let src_path = program.path.join("src");
159        if src_path.exists() {
160            println!("cargo:rerun-if-changed={}", src_path.display());
161        }
162
163        // Watch the program's Cargo.toml file
164        println!("cargo:rerun-if-changed={}", program.manifest_path.display());
165    }
166
167    Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172    use std::path::PathBuf;
173
174    use crate::domain::ProgramFilter;
175
176    use super::*;
177
178    fn create_test_cargo_toml(content: &str) -> tempfile::TempDir {
179        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
180        let cargo_toml_path = temp_dir.path().join("Cargo.toml");
181        std::fs::write(&cargo_toml_path, content).expect("Failed to write test Cargo.toml");
182        temp_dir
183    }
184
185    fn create_test_workspace() -> tempfile::TempDir {
186        let temp_dir = tempfile::tempdir().expect("Failed to create temp workspace");
187        let workspace_root = temp_dir.path();
188
189        // Create root Cargo.toml with workspace
190        let root_cargo_toml = r#"
191[workspace]
192members = ["program-a", "program-b", "lib-crate"]
193"#;
194        std::fs::write(workspace_root.join("Cargo.toml"), root_cargo_toml)
195            .expect("Failed to write root Cargo.toml");
196
197        // Create program-a (Solana program with cdylib)
198        let program_a_dir = workspace_root.join("program-a");
199        std::fs::create_dir_all(program_a_dir.join("src")).expect("Failed to create program-a dir");
200        let program_a_cargo_toml = r#"
201[package]
202name = "program-a"
203version = "0.1.0"
204edition = "2021"
205
206[lib]
207crate-type = ["cdylib"]
208name = "program_a"
209"#;
210        std::fs::write(program_a_dir.join("Cargo.toml"), program_a_cargo_toml)
211            .expect("Failed to write program-a Cargo.toml");
212        std::fs::write(program_a_dir.join("src/lib.rs"), "// Test Solana program A")
213            .expect("Failed to write program-a lib.rs");
214
215        // Create program-b (Solana program with cdylib)
216        let program_b_dir = workspace_root.join("program-b");
217        std::fs::create_dir_all(program_b_dir.join("src")).expect("Failed to create program-b dir");
218        let program_b_cargo_toml = r#"
219[package]
220name = "program-b"
221version = "0.1.0"
222edition = "2021"
223
224[lib]
225crate-type = ["cdylib"]
226"#;
227        std::fs::write(program_b_dir.join("Cargo.toml"), program_b_cargo_toml)
228            .expect("Failed to write program-b Cargo.toml");
229        std::fs::write(program_b_dir.join("src/lib.rs"), "// Test Solana program B")
230            .expect("Failed to write program-b lib.rs");
231
232        // Create lib-crate (regular library)
233        let lib_crate_dir = workspace_root.join("lib-crate");
234        std::fs::create_dir_all(lib_crate_dir.join("src")).expect("Failed to create lib-crate dir");
235        let lib_crate_cargo_toml = r#"
236[package]
237name = "lib-crate"
238version = "0.1.0"
239edition = "2021"
240
241[lib]
242crate-type = ["lib"]
243"#;
244        std::fs::write(lib_crate_dir.join("Cargo.toml"), lib_crate_cargo_toml)
245            .expect("Failed to write lib-crate Cargo.toml");
246        std::fs::write(lib_crate_dir.join("src/lib.rs"), "// Test regular library")
247            .expect("Failed to write lib-crate lib.rs");
248
249        temp_dir
250    }
251
252    #[test]
253    fn test_parse_manifest_config_with_full_config() {
254        let cargo_toml_content = r#"
255[package]
256name = "test-package"
257version = "0.1.0"
258edition = "2021"
259
260[package.metadata.elf-magic]
261include = ["programs/*", "contracts/*"]
262exclude = ["programs/deprecated-*", "contracts/old-*"]
263"#;
264
265        let temp_dir = create_test_cargo_toml(cargo_toml_content);
266        let result = parse_manifest_config(temp_dir.path()).unwrap();
267
268        assert_eq!(result.include, vec!["programs/*", "contracts/*"]);
269        assert_eq!(
270            result.exclude,
271            vec!["programs/deprecated-*", "contracts/old-*"]
272        );
273    }
274
275    #[test]
276    fn test_parse_manifest_config_with_partial_config() {
277        let cargo_toml_content = r#"
278[package]
279name = "test-package"
280version = "0.1.0"
281edition = "2021"
282
283[package.metadata.elf-magic]
284include = ["programs/*"]
285"#;
286
287        let temp_dir = create_test_cargo_toml(cargo_toml_content);
288        let result = parse_manifest_config(temp_dir.path()).unwrap();
289
290        assert_eq!(result.include, vec!["programs/*"]);
291        assert_eq!(result.exclude, Vec::<String>::new());
292    }
293
294    #[test]
295    fn test_parse_manifest_config_missing_elf_magic_section() {
296        let cargo_toml_content = r#"
297[package]
298name = "test-package"
299version = "0.1.0"
300edition = "2021"
301"#;
302
303        let temp_dir = create_test_cargo_toml(cargo_toml_content);
304        let result = parse_manifest_config(temp_dir.path()).unwrap();
305
306        // Should return permissive default (allow_all)
307        assert_eq!(result.include, vec!["**/*"]);
308        assert_eq!(result.exclude, Vec::<String>::new());
309    }
310
311    #[test]
312    fn test_parse_manifest_config_missing_metadata_section() {
313        let cargo_toml_content = r#"
314[package]
315name = "test-package"
316version = "0.1.0"
317edition = "2021"
318"#;
319
320        let temp_dir = create_test_cargo_toml(cargo_toml_content);
321        let result = parse_manifest_config(temp_dir.path()).unwrap();
322
323        // Should return permissive default (allow_all)
324        assert_eq!(result.include, vec!["**/*"]);
325        assert_eq!(result.exclude, Vec::<String>::new());
326    }
327
328    #[test]
329    fn test_parse_manifest_config_missing_package_section() {
330        let cargo_toml_content = r#"
331[workspace]
332members = ["programs/*"]
333"#;
334
335        let temp_dir = create_test_cargo_toml(cargo_toml_content);
336        let result = parse_manifest_config(temp_dir.path()).unwrap();
337
338        // Should return permissive default (allow_all)
339        assert_eq!(result.include, vec!["**/*"]);
340        assert_eq!(result.exclude, Vec::<String>::new());
341    }
342
343    #[test]
344    fn test_parse_manifest_config_file_not_found() {
345        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
346        // Don't create Cargo.toml, so it doesn't exist
347
348        let result = parse_manifest_config(temp_dir.path());
349
350        assert!(result.is_err());
351        let error_message = result.unwrap_err().to_string();
352        assert!(error_message.contains("Failed to read Cargo.toml"));
353    }
354
355    #[test]
356    fn test_parse_manifest_config_invalid_toml() {
357        let invalid_cargo_toml_content = r#"
358[package
359name = "test-package"  # Missing closing bracket
360"#;
361
362        let temp_dir = create_test_cargo_toml(invalid_cargo_toml_content);
363        let result = parse_manifest_config(temp_dir.path());
364
365        assert!(result.is_err());
366        let error_message = result.unwrap_err().to_string();
367        assert!(error_message.contains("Failed to parse Cargo.toml"));
368    }
369
370    #[test]
371    fn test_parse_manifest_config_empty_arrays() {
372        let cargo_toml_content = r#"
373[package]
374name = "test-package"
375version = "0.1.0"
376edition = "2021"
377
378[package.metadata.elf-magic]
379include = []
380exclude = []
381"#;
382
383        let temp_dir = create_test_cargo_toml(cargo_toml_content);
384        let result = parse_manifest_config(temp_dir.path()).unwrap();
385
386        assert_eq!(result.include, Vec::<String>::new());
387        assert_eq!(result.exclude, Vec::<String>::new());
388    }
389
390    #[test]
391    fn test_discover_workspace_integration() {
392        let temp_workspace = create_test_workspace();
393
394        let result = discover_workspace(temp_workspace.path());
395
396        // Verify the workspace discovery worked
397        let workspace = result.expect("Should successfully discover test workspace");
398
399        // Check basic workspace properties (canonicalize paths to handle symlinks on macOS)
400        assert_eq!(
401            workspace
402                .packages
403                .iter()
404                .map(|p| p.manifest_path.parent().unwrap().canonicalize().unwrap())
405                .collect::<Vec<_>>(),
406            vec![
407                temp_workspace
408                    .path()
409                    .canonicalize()
410                    .unwrap()
411                    .join("lib-crate"),
412                temp_workspace
413                    .path()
414                    .canonicalize()
415                    .unwrap()
416                    .join("program-a"),
417                temp_workspace
418                    .path()
419                    .canonicalize()
420                    .unwrap()
421                    .join("program-b"),
422            ]
423        );
424
425        // Should find all 3 packages
426        assert_eq!(workspace.packages.len(), 3);
427
428        // Members should be sorted by name
429        let package_names: Vec<&String> = workspace.packages.iter().map(|m| &m.name).collect();
430        assert_eq!(package_names, vec!["lib-crate", "program-a", "program-b"]);
431
432        // Verify crate types are correctly extracted
433        for package in &workspace.packages {
434            match package.name.as_str() {
435                "lib-crate" => {
436                    assert_eq!(
437                        package
438                            .targets
439                            .iter()
440                            .map(|t| t.crate_types.clone())
441                            .collect::<Vec<_>>(),
442                        vec![vec!["lib"]]
443                    );
444                }
445                "program-a" => {
446                    assert_eq!(
447                        package
448                            .targets
449                            .iter()
450                            .map(|t| t.crate_types.clone())
451                            .collect::<Vec<_>>(),
452                        vec![vec!["cdylib"]]
453                    );
454                }
455                "program-b" => {
456                    assert_eq!(
457                        package
458                            .targets
459                            .iter()
460                            .map(|t| t.crate_types.clone())
461                            .collect::<Vec<_>>(),
462                        vec![vec!["cdylib"]]
463                    );
464                }
465                _ => {
466                    panic!("Unexpected package name: {}", package.name);
467                }
468            }
469        }
470    }
471
472    #[test]
473    fn test_discover_workspace_with_filtering() {
474        let temp_workspace = create_test_workspace();
475
476        // Test filtering that should exclude program-a
477        let config = ManifestConfig {
478            include: vec!["**/*".to_string()],
479            exclude: vec!["program-a".to_string()],
480        };
481
482        let result = discover_workspace(temp_workspace.path());
483
484        let workspace = result.expect("Should successfully discover test workspace");
485
486        // Find Solana programs using the workspace's filtering logic
487        let filter = ProgramFilter::from(&config);
488        let solana_programs = workspace.find_solana_programs(&filter);
489
490        // Should find only program-b (program-a excluded, lib-crate not a Solana program)
491        assert_eq!(solana_programs.len(), 1);
492        assert_eq!(solana_programs[0].name, "program_b");
493    }
494
495    #[test]
496    fn test_discover_workspace_find_solana_programs() {
497        let temp_workspace = create_test_workspace();
498
499        let config = ManifestConfig::allow_all();
500        let result = discover_workspace(temp_workspace.path());
501
502        let workspace = result.expect("Should successfully discover test workspace");
503
504        // Find Solana programs
505        let filter = ProgramFilter::from(&config);
506        let solana_programs = workspace.find_solana_programs(&filter);
507
508        // Should find exactly 2 Solana programs (cdylib crates)
509        assert_eq!(solana_programs.len(), 2);
510
511        // Should be sorted by name
512        assert_eq!(solana_programs[0].name, "program_a");
513        assert_eq!(solana_programs[1].name, "program_b");
514
515        // Verify they're properly configured
516        for program in &solana_programs {
517            assert!(program.name.starts_with("program_"));
518            assert_eq!(
519                program.env_var_name(),
520                format!("PROGRAM_{}_ELF_MAGIC_PATH", program.name.to_uppercase())
521            );
522            assert_eq!(
523                program.constant_name(),
524                format!("{}_ELF", program.name.to_uppercase())
525            );
526        }
527    }
528
529    #[test]
530    fn test_discover_workspace() {
531        // This test runs against the real workspace as a sanity check
532        let current_dir = std::env::current_dir().unwrap();
533        match discover_workspace(&current_dir) {
534            Ok(workspace) => {
535                // Basic sanity checks
536                assert!(!workspace
537                    .packages
538                    .iter()
539                    .map(|p| p.manifest_path.parent().unwrap().as_os_str())
540                    .collect::<Vec<_>>()
541                    .is_empty());
542
543                // Members should be sorted by name
544                let member_names: Vec<&String> =
545                    workspace.packages.iter().map(|m| &m.name).collect();
546                let mut sorted_names = member_names.clone();
547                sorted_names.sort();
548                assert_eq!(member_names, sorted_names);
549            }
550            Err(e) => {
551                // If it fails, it should be a reasonable error
552                println!("Expected error in test environment: {}", e);
553                assert!(
554                    e.to_string().contains("cargo metadata") || e.to_string().contains("workspace")
555                );
556            }
557        }
558    }
559
560    #[test]
561    fn test_discover_workspace_sorted() {
562        // Test the sorting behavior specifically
563        let current_dir = std::env::current_dir().unwrap();
564        match discover_workspace(&current_dir) {
565            Ok(workspace) => {
566                // Verify members are sorted by name
567                for i in 1..workspace.packages.len() {
568                    assert!(workspace.packages[i - 1].name <= workspace.packages[i].name);
569                }
570            }
571            Err(_) => {
572                // Expected to fail in some test environments - that's ok
573                // The important thing is that when it works, sorting happens
574            }
575        }
576    }
577
578    #[test]
579    fn test_write_lib_file() {
580        use tempfile::TempDir;
581
582        // Create test programs
583        let programs = vec![SolanaProgram {
584            name: "test_program".to_string(),
585            path: PathBuf::from("programs/test-program"),
586            manifest_path: PathBuf::from("programs/test-program/Cargo.toml"),
587        }];
588
589        // Create a completely isolated temporary workspace
590        let temp_workspace = TempDir::new().expect("Failed to create temp workspace");
591        let temp_src_dir = temp_workspace.path().join("src");
592        std::fs::create_dir_all(&temp_src_dir).expect("Failed to create temp src dir");
593
594        // Save the original working directory
595        let original_dir = std::env::current_dir().expect("Failed to get current dir");
596
597        // Change to the isolated temp workspace
598        std::env::set_current_dir(temp_workspace.path())
599            .expect("Failed to change to temp workspace");
600
601        // Test the write_lib_file function in isolation
602        let result = write_lib_file(temp_workspace.path(), &programs);
603
604        // CRITICAL: Restore original directory BEFORE any assertions that might panic
605        std::env::set_current_dir(original_dir).expect("Failed to restore original dir");
606
607        // Now it's safe to run assertions
608        assert!(result.is_ok(), "write_lib_file should succeed");
609
610        // Check that the file was created in the temp workspace
611        let lib_path = temp_src_dir.join("lib.rs");
612        assert!(lib_path.exists(), "lib.rs should be created");
613
614        let content = std::fs::read_to_string(&lib_path).expect("Failed to read generated file");
615
616        // Verify key content is present
617        assert!(content.contains("auto-generated by elf-magic"));
618        assert!(content.contains("TEST_PROGRAM_ELF"));
619        assert!(content.contains("pub fn elves()"));
620
621        // Verify no temporary files left behind
622        let temp_files: Vec<_> = std::fs::read_dir(&temp_src_dir)
623            .expect("Failed to read temp src dir")
624            .filter_map(|entry| entry.ok())
625            .filter(|entry| {
626                let file_name = entry.file_name();
627                let name = file_name.to_string_lossy();
628                name.starts_with(".tmp") || name.ends_with(".tmp")
629            })
630            .collect();
631
632        assert!(
633            temp_files.is_empty(),
634            "Temporary files should be cleaned up: found {:?}",
635            temp_files
636        );
637    }
638
639    #[test]
640    fn test_setup_incremental_builds() {
641        use std::path::PathBuf;
642
643        let programs = vec![
644            SolanaProgram {
645                name: "token-manager".to_string(),
646                path: PathBuf::from("programs/token-manager"),
647                manifest_path: PathBuf::from("programs/token-manager/Cargo.toml"),
648            },
649            SolanaProgram {
650                name: "governance".to_string(),
651                path: PathBuf::from("programs/governance"),
652                manifest_path: PathBuf::from("programs/governance/Cargo.toml"),
653            },
654        ];
655
656        // This function outputs to stdout via println! which is hard to capture in tests
657        // But we can at least verify it doesn't error
658        let result = setup_incremental_builds(&programs);
659        assert!(result.is_ok());
660
661        // Note: In a real build.rs context, this would output:
662        // cargo:rerun-if-changed=programs/token-manager/src
663        // cargo:rerun-if-changed=programs/token-manager/Cargo.toml
664        // cargo:rerun-if-changed=programs/governance/src
665        // cargo:rerun-if-changed=programs/governance/Cargo.toml
666    }
667}