Skip to main content

sigil_parser/
tome.rs

1//! Tome - The Sigil Package Manager
2//!
3//! Manages Sigil tomes (packages) through ritual commands:
4//!
5//! Commands:
6//!   sigil conjure <name>     Summon a new tome into existence
7//!   sigil inscribe           Mark current directory as a tome
8//!   sigil summon <tome>      Call forth a dependency
9//!   sigil banish <tome>      Cast out a dependency
10//!   sigil attune             Realign with latest binding versions
11//!   sigil forge              Shape the tome into being
12//!   sigil consecrate         Enshrine tome in the Grimoire registry
13//!
14//! Manifest: Grimoire.toml
15//!
16//! The Grimoire is the central registry of all consecrated tomes.
17
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23
24/// The manifest file name
25pub const GRIMOIRE_TOML: &str = "Grimoire.toml";
26
27/// Lock file for reproducible builds
28pub const GRIMOIRE_LOCK: &str = "Grimoire.lock";
29
30/// Directory for cached tomes
31pub const TOMES_DIR: &str = ".tomes";
32
33// ============================================================================
34// Grimoire.toml Structure
35// ============================================================================
36
37/// Root structure of Grimoire.toml
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct Grimoire {
40    /// Tome metadata
41    pub tome: TomeMetadata,
42    /// Dependencies (bindings)
43    #[serde(default)]
44    pub bindings: HashMap<String, Binding>,
45    /// Dev dependencies
46    #[serde(default)]
47    pub dev_bindings: HashMap<String, Binding>,
48    /// Custom rites (scripts)
49    #[serde(default)]
50    pub rites: HashMap<String, String>,
51    /// Workspace configuration (for multi-tome projects)
52    #[serde(default)]
53    pub workspace: Option<Workspace>,
54}
55
56/// Tome metadata section
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct TomeMetadata {
59    /// Tome name
60    pub name: String,
61    /// Version (semver)
62    pub version: String,
63    /// Authors
64    #[serde(default)]
65    pub authors: Vec<String>,
66    /// Edition year
67    #[serde(default)]
68    pub edition: Option<String>,
69    /// Description
70    #[serde(default)]
71    pub description: Option<String>,
72    /// License
73    #[serde(default)]
74    pub license: Option<String>,
75    /// Repository URL
76    #[serde(default)]
77    pub repository: Option<String>,
78    /// Homepage URL
79    #[serde(default)]
80    pub homepage: Option<String>,
81    /// Keywords for discovery
82    #[serde(default)]
83    pub keywords: Vec<String>,
84    /// Categories
85    #[serde(default)]
86    pub categories: Vec<String>,
87}
88
89/// A binding (dependency) specification
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(untagged)]
92pub enum Binding {
93    /// Simple version string: aegis = "0.1"
94    Version(String),
95    /// Detailed specification
96    Detailed(BindingSpec),
97}
98
99/// Detailed binding specification
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct BindingSpec {
102    /// Version requirement
103    #[serde(default)]
104    pub version: Option<String>,
105    /// Local path
106    #[serde(default)]
107    pub path: Option<String>,
108    /// Git repository
109    #[serde(default)]
110    pub git: Option<String>,
111    /// Git branch
112    #[serde(default)]
113    pub branch: Option<String>,
114    /// Git tag
115    #[serde(default)]
116    pub tag: Option<String>,
117    /// Git revision
118    #[serde(default)]
119    pub rev: Option<String>,
120    /// Optional dependency
121    #[serde(default)]
122    pub optional: bool,
123    /// Features to enable
124    #[serde(default)]
125    pub features: Vec<String>,
126}
127
128/// Workspace configuration for multi-tome projects
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct Workspace {
131    /// Member tome paths
132    #[serde(default)]
133    pub members: Vec<String>,
134    /// Excluded paths
135    #[serde(default)]
136    pub exclude: Vec<String>,
137}
138
139// ============================================================================
140// Lock File Structure
141// ============================================================================
142
143/// Lock file for reproducible builds
144#[derive(Debug, Clone, Serialize, Deserialize, Default)]
145pub struct GrimoireLock {
146    /// Lock file version
147    pub version: u32,
148    /// Locked bindings
149    #[serde(default)]
150    pub bindings: Vec<LockedBinding>,
151}
152
153/// A locked binding with exact version/source
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct LockedBinding {
156    /// Tome name
157    pub name: String,
158    /// Exact version
159    pub version: String,
160    /// Source (registry, git, path)
161    pub source: String,
162    /// Checksum for verification
163    #[serde(default)]
164    pub checksum: Option<String>,
165    /// Dependencies of this binding
166    #[serde(default)]
167    pub dependencies: Vec<String>,
168}
169
170// ============================================================================
171// Implementation
172// ============================================================================
173
174impl Grimoire {
175    /// Load Grimoire.toml from a path
176    pub fn load(path: &Path) -> Result<Self, String> {
177        let grimoire_path = if path.is_dir() {
178            path.join(GRIMOIRE_TOML)
179        } else {
180            path.to_path_buf()
181        };
182
183        let content = fs::read_to_string(&grimoire_path)
184            .map_err(|e| format!("Failed to read {}: {}", grimoire_path.display(), e))?;
185
186        toml::from_str(&content)
187            .map_err(|e| format!("Failed to parse {}: {}", grimoire_path.display(), e))
188    }
189
190    /// Save Grimoire.toml to a path
191    pub fn save(&self, path: &Path) -> Result<(), String> {
192        let grimoire_path = if path.is_dir() {
193            path.join(GRIMOIRE_TOML)
194        } else {
195            path.to_path_buf()
196        };
197
198        let content = toml::to_string_pretty(self)
199            .map_err(|e| format!("Failed to serialize Grimoire: {}", e))?;
200
201        fs::write(&grimoire_path, content)
202            .map_err(|e| format!("Failed to write {}: {}", grimoire_path.display(), e))
203    }
204
205    /// Find Grimoire.toml in current or ancestor directories
206    pub fn find() -> Option<PathBuf> {
207        let mut current = std::env::current_dir().ok()?;
208        loop {
209            let grimoire_path = current.join(GRIMOIRE_TOML);
210            if grimoire_path.exists() {
211                return Some(grimoire_path);
212            }
213            if !current.pop() {
214                return None;
215            }
216        }
217    }
218
219    /// Add a binding to the grimoire
220    pub fn summon(&mut self, name: &str, binding: Binding) {
221        self.bindings.insert(name.to_string(), binding);
222    }
223
224    /// Remove a binding from the grimoire
225    pub fn banish(&mut self, name: &str) -> Option<Binding> {
226        self.bindings.remove(name)
227    }
228
229    /// Check if a binding exists
230    pub fn has_binding(&self, name: &str) -> bool {
231        self.bindings.contains_key(name)
232    }
233}
234
235impl Binding {
236    /// Get the version requirement
237    pub fn version(&self) -> Option<&str> {
238        match self {
239            Binding::Version(v) => Some(v),
240            Binding::Detailed(spec) => spec.version.as_deref(),
241        }
242    }
243
244    /// Check if this is a path binding
245    pub fn is_path(&self) -> bool {
246        matches!(self, Binding::Detailed(spec) if spec.path.is_some())
247    }
248
249    /// Check if this is a git binding
250    pub fn is_git(&self) -> bool {
251        matches!(self, Binding::Detailed(spec) if spec.git.is_some())
252    }
253
254    /// Get the path if this is a path binding
255    pub fn path(&self) -> Option<&str> {
256        match self {
257            Binding::Detailed(spec) => spec.path.as_deref(),
258            _ => None,
259        }
260    }
261
262    /// Get the git URL if this is a git binding
263    pub fn git(&self) -> Option<&str> {
264        match self {
265            Binding::Detailed(spec) => spec.git.as_deref(),
266            _ => None,
267        }
268    }
269}
270
271// ============================================================================
272// Tome Operations
273// ============================================================================
274
275/// Conjure a new tome (create new project)
276pub fn conjure(name: &str, path: Option<&Path>) -> Result<PathBuf, String> {
277    let project_path = path
278        .map(|p| p.to_path_buf())
279        .unwrap_or_else(|| PathBuf::from(name));
280
281    if project_path.exists() {
282        return Err(format!(
283            "Cannot conjure '{}': path already exists",
284            project_path.display()
285        ));
286    }
287
288    // Create directory structure
289    fs::create_dir_all(&project_path)
290        .map_err(|e| format!("Failed to create directory: {}", e))?;
291    fs::create_dir_all(project_path.join("src"))
292        .map_err(|e| format!("Failed to create src directory: {}", e))?;
293
294    // Create Grimoire.toml
295    let grimoire = Grimoire {
296        tome: TomeMetadata {
297            name: name.to_string(),
298            version: "0.1.0".to_string(),
299            authors: get_git_author().map(|a| vec![a]).unwrap_or_default(),
300            edition: Some("2026".to_string()),
301            description: None,
302            license: None,
303            repository: None,
304            homepage: None,
305            keywords: vec![],
306            categories: vec![],
307        },
308        bindings: HashMap::new(),
309        dev_bindings: HashMap::new(),
310        rites: HashMap::new(),
311        workspace: None,
312    };
313    grimoire.save(&project_path)?;
314
315    // Create main.sg
316    let main_content = format!(
317        r#"// {name} - A Sigil Tome
318//
319// Conjured with `sigil conjure {name}`
320
321fn main() {{
322    println("Hello from {name}!");
323}}
324"#
325    );
326    fs::write(project_path.join("src/main.sg"), main_content)
327        .map_err(|e| format!("Failed to create main.sg: {}", e))?;
328
329    // Create .gitignore
330    let gitignore = r#"# Sigil build artifacts
331/target/
332/.tomes/
333
334# Lock file (include for applications, exclude for libraries)
335# Grimoire.lock
336"#;
337    fs::write(project_path.join(".gitignore"), gitignore)
338        .map_err(|e| format!("Failed to create .gitignore: {}", e))?;
339
340    Ok(project_path)
341}
342
343/// Inscribe current directory as a tome (init in existing directory)
344pub fn inscribe(path: &Path) -> Result<(), String> {
345    let grimoire_path = path.join(GRIMOIRE_TOML);
346    if grimoire_path.exists() {
347        return Err(format!(
348            "Directory already inscribed: {} exists",
349            GRIMOIRE_TOML
350        ));
351    }
352
353    // Derive name from directory
354    let name = path
355        .file_name()
356        .and_then(|n| n.to_str())
357        .unwrap_or("unnamed")
358        .to_string();
359
360    let grimoire = Grimoire {
361        tome: TomeMetadata {
362            name,
363            version: "0.1.0".to_string(),
364            authors: get_git_author().map(|a| vec![a]).unwrap_or_default(),
365            edition: Some("2026".to_string()),
366            ..Default::default()
367        },
368        ..Default::default()
369    };
370
371    grimoire.save(path)?;
372
373    // Create src directory if it doesn't exist
374    let src_dir = path.join("src");
375    if !src_dir.exists() {
376        fs::create_dir_all(&src_dir)
377            .map_err(|e| format!("Failed to create src directory: {}", e))?;
378    }
379
380    Ok(())
381}
382
383/// Summon a binding (add dependency)
384pub fn summon(path: &Path, name: &str, spec: &str) -> Result<(), String> {
385    let mut grimoire = Grimoire::load(path)?;
386
387    let binding = parse_binding_spec(spec)?;
388    grimoire.summon(name, binding);
389    grimoire.save(path)?;
390
391    Ok(())
392}
393
394/// Banish a binding (remove dependency)
395pub fn banish(path: &Path, name: &str) -> Result<(), String> {
396    let mut grimoire = Grimoire::load(path)?;
397
398    if grimoire.banish(name).is_none() {
399        return Err(format!("Binding '{}' not found in Grimoire", name));
400    }
401
402    grimoire.save(path)?;
403    Ok(())
404}
405
406/// Attune bindings (update/resolve dependencies)
407pub fn attune(path: &Path) -> Result<AttuneResult, String> {
408    let grimoire = Grimoire::load(path)?;
409    let mut result = AttuneResult::default();
410
411    // Create .tomes directory
412    let tomes_dir = path.join(TOMES_DIR);
413    if !tomes_dir.exists() {
414        fs::create_dir_all(&tomes_dir)
415            .map_err(|e| format!("Failed to create .tomes directory: {}", e))?;
416    }
417
418    // Resolve each binding
419    for (name, binding) in &grimoire.bindings {
420        match resolve_binding(name, binding, &tomes_dir) {
421            Ok(resolved) => {
422                result.resolved.push(resolved);
423            }
424            Err(e) => {
425                result.errors.push((name.clone(), e));
426            }
427        }
428    }
429
430    // Generate lock file
431    if result.errors.is_empty() {
432        let lock = generate_lock_file(&result.resolved);
433        let lock_content = toml::to_string_pretty(&lock)
434            .map_err(|e| format!("Failed to serialize lock file: {}", e))?;
435        fs::write(path.join(GRIMOIRE_LOCK), lock_content)
436            .map_err(|e| format!("Failed to write lock file: {}", e))?;
437    }
438
439    Ok(result)
440}
441
442/// Result of attunement
443#[derive(Debug, Default)]
444pub struct AttuneResult {
445    pub resolved: Vec<ResolvedBinding>,
446    pub errors: Vec<(String, String)>,
447}
448
449/// A resolved binding with its location
450#[derive(Debug, Clone)]
451pub struct ResolvedBinding {
452    pub name: String,
453    pub version: String,
454    pub path: PathBuf,
455    pub source: BindingSource,
456}
457
458/// Source type for a binding
459#[derive(Debug, Clone)]
460pub enum BindingSource {
461    Registry,
462    Path,
463    Git { url: String, reference: String },
464}
465
466/// Forge the tome (build with dependencies)
467pub fn forge(path: &Path) -> Result<ForgeResult, String> {
468    let grimoire = Grimoire::load(path)?;
469    let mut result = ForgeResult::default();
470
471    // First, attune if needed
472    let lock_path = path.join(GRIMOIRE_LOCK);
473    if !lock_path.exists() {
474        eprintln!("Attuning bindings...");
475        let attune_result = attune(path)?;
476        if !attune_result.errors.is_empty() {
477            for (name, err) in &attune_result.errors {
478                eprintln!("  Failed to resolve {}: {}", name, err);
479            }
480            return Err("Failed to attune bindings".to_string());
481        }
482    }
483
484    // Check if this is a workspace
485    if let Some(workspace) = &grimoire.workspace {
486        if !workspace.members.is_empty() {
487            return forge_workspace(path, &grimoire, workspace);
488        }
489    }
490
491    // Find main source file for single-tome project
492    let main_file = find_main_source(path)?;
493
494    result.main_file = Some(main_file);
495    result.tome_name = grimoire.tome.name.clone();
496    result.version = grimoire.tome.version.clone();
497
498    Ok(result)
499}
500
501/// Find the main source file in a tome directory
502fn find_main_source(path: &Path) -> Result<PathBuf, String> {
503    let src_dir = path.join("src");
504
505    // Check standard locations
506    for filename in &["main.sg", "main.sigil", "lib.sg", "lib.sigil"] {
507        let file_path = src_dir.join(filename);
508        if file_path.exists() {
509            return Ok(file_path);
510        }
511    }
512
513    Err(format!(
514        "No main.sg, main.sigil, lib.sg, or lib.sigil found in {}/src/",
515        path.display()
516    ))
517}
518
519/// Forge a workspace with multiple member tomes
520fn forge_workspace(
521    workspace_path: &Path,
522    _grimoire: &Grimoire,
523    workspace: &Workspace,
524) -> Result<ForgeResult, String> {
525    let mut result = ForgeResult::default();
526    result.tome_name = "workspace".to_string();
527
528    eprintln!("Forging workspace with {} members...", workspace.members.len());
529
530    for member_path in &workspace.members {
531        let member_full_path = workspace_path.join(member_path);
532
533        // Check if member has a Grimoire.toml
534        let member_grimoire_path = member_full_path.join(GRIMOIRE_TOML);
535        if !member_grimoire_path.exists() {
536            eprintln!("  Skipping {}: no Grimoire.toml found", member_path);
537            continue;
538        }
539
540        // Load and forge the member
541        match Grimoire::load(&member_full_path) {
542            Ok(member_grimoire) => {
543                eprintln!("  Forging {}...", member_grimoire.tome.name);
544
545                // Find the main source file for this member
546                match find_main_source(&member_full_path) {
547                    Ok(main_file) => {
548                        result.artifacts.push(main_file);
549                    }
550                    Err(e) => {
551                        eprintln!("    Warning: {}", e);
552                    }
553                }
554            }
555            Err(e) => {
556                eprintln!("  Warning: Failed to load {}: {}", member_path, e);
557            }
558        }
559    }
560
561    if result.artifacts.is_empty() {
562        return Err("No buildable members found in workspace".to_string());
563    }
564
565    eprintln!("Found {} buildable members", result.artifacts.len());
566    Ok(result)
567}
568
569/// Result of forging
570#[derive(Debug, Default)]
571pub struct ForgeResult {
572    pub tome_name: String,
573    pub version: String,
574    pub main_file: Option<PathBuf>,
575    pub artifacts: Vec<PathBuf>,
576}
577
578// ============================================================================
579// Helper Functions
580// ============================================================================
581
582/// Parse a binding specification string
583fn parse_binding_spec(spec: &str) -> Result<Binding, String> {
584    // Check for path: prefix
585    if spec.starts_with("path:") {
586        let path = spec.strip_prefix("path:").unwrap().trim();
587        return Ok(Binding::Detailed(BindingSpec {
588            path: Some(path.to_string()),
589            ..Default::default()
590        }));
591    }
592
593    // Check for git: prefix
594    if spec.starts_with("git:") {
595        let url = spec.strip_prefix("git:").unwrap().trim();
596        return Ok(Binding::Detailed(BindingSpec {
597            git: Some(url.to_string()),
598            ..Default::default()
599        }));
600    }
601
602    // Otherwise, treat as version
603    Ok(Binding::Version(spec.to_string()))
604}
605
606/// Resolve a binding to a local path
607fn resolve_binding(
608    name: &str,
609    binding: &Binding,
610    tomes_dir: &Path,
611) -> Result<ResolvedBinding, String> {
612    match binding {
613        Binding::Version(version) => {
614            // Registry binding - placeholder for future Grimoire registry
615            Err(format!(
616                "Registry bindings not yet implemented. Use path: or git: for '{}'",
617                name
618            ))
619        }
620        Binding::Detailed(spec) => {
621            if let Some(path) = &spec.path {
622                // Local path binding
623                let resolved_path = PathBuf::from(path);
624                if !resolved_path.exists() {
625                    return Err(format!("Path does not exist: {}", path));
626                }
627                Ok(ResolvedBinding {
628                    name: name.to_string(),
629                    version: spec.version.clone().unwrap_or_else(|| "0.0.0".to_string()),
630                    path: resolved_path,
631                    source: BindingSource::Path,
632                })
633            } else if let Some(git_url) = &spec.git {
634                // Git binding
635                let reference = spec
636                    .branch
637                    .clone()
638                    .or_else(|| spec.tag.clone())
639                    .or_else(|| spec.rev.clone())
640                    .unwrap_or_else(|| "main".to_string());
641
642                let clone_dir = tomes_dir.join(name);
643                clone_git_repo(git_url, &reference, &clone_dir)?;
644
645                Ok(ResolvedBinding {
646                    name: name.to_string(),
647                    version: spec.version.clone().unwrap_or_else(|| "0.0.0-git".to_string()),
648                    path: clone_dir,
649                    source: BindingSource::Git {
650                        url: git_url.clone(),
651                        reference,
652                    },
653                })
654            } else if let Some(version) = &spec.version {
655                // Registry binding with version
656                Err(format!(
657                    "Registry bindings not yet implemented. Use path: or git: for '{}'",
658                    name
659                ))
660            } else {
661                Err(format!(
662                    "Invalid binding for '{}': must specify version, path, or git",
663                    name
664                ))
665            }
666        }
667    }
668}
669
670/// Clone a git repository
671fn clone_git_repo(url: &str, reference: &str, dest: &Path) -> Result<(), String> {
672    use std::process::Command;
673
674    if dest.exists() {
675        // Pull instead of clone
676        let output = Command::new("git")
677            .args(["pull", "--ff-only"])
678            .current_dir(dest)
679            .output()
680            .map_err(|e| format!("Failed to run git pull: {}", e))?;
681
682        if !output.status.success() {
683            let stderr = String::from_utf8_lossy(&output.stderr);
684            return Err(format!("git pull failed: {}", stderr));
685        }
686    } else {
687        // Clone
688        let output = Command::new("git")
689            .args(["clone", "--depth", "1", "--branch", reference, url])
690            .arg(dest)
691            .output()
692            .map_err(|e| format!("Failed to run git clone: {}", e))?;
693
694        if !output.status.success() {
695            let stderr = String::from_utf8_lossy(&output.stderr);
696            return Err(format!("git clone failed: {}", stderr));
697        }
698    }
699
700    Ok(())
701}
702
703/// Generate a lock file from resolved bindings
704fn generate_lock_file(resolved: &[ResolvedBinding]) -> GrimoireLock {
705    let bindings = resolved
706        .iter()
707        .map(|r| LockedBinding {
708            name: r.name.clone(),
709            version: r.version.clone(),
710            source: match &r.source {
711                BindingSource::Registry => "registry".to_string(),
712                BindingSource::Path => format!("path:{}", r.path.display()),
713                BindingSource::Git { url, reference } => format!("git:{}#{}", url, reference),
714            },
715            checksum: None,
716            dependencies: vec![],
717        })
718        .collect();
719
720    GrimoireLock {
721        version: 1,
722        bindings,
723    }
724}
725
726/// Get git author from git config
727fn get_git_author() -> Option<String> {
728    use std::process::Command;
729
730    let name = Command::new("git")
731        .args(["config", "user.name"])
732        .output()
733        .ok()
734        .filter(|o| o.status.success())
735        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
736
737    let email = Command::new("git")
738        .args(["config", "user.email"])
739        .output()
740        .ok()
741        .filter(|o| o.status.success())
742        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
743
744    if name.is_empty() {
745        None
746    } else if email.is_empty() {
747        Some(name)
748    } else {
749        Some(format!("{} <{}>", name, email))
750    }
751}
752
753/// List available rites (scripts)
754pub fn list_rites(path: &Path) -> Result<Vec<(String, String)>, String> {
755    let grimoire = Grimoire::load(path)?;
756    Ok(grimoire.rites.into_iter().collect())
757}
758
759/// Run a rite (script)
760pub fn invoke_rite(path: &Path, rite_name: &str) -> Result<(), String> {
761    let grimoire = Grimoire::load(path)?;
762
763    let command = grimoire
764        .rites
765        .get(rite_name)
766        .ok_or_else(|| format!("Unknown rite: {}", rite_name))?;
767
768    use std::process::Command;
769
770    let status = if cfg!(windows) {
771        Command::new("cmd")
772            .args(["/C", command])
773            .current_dir(path)
774            .status()
775    } else {
776        Command::new("sh")
777            .args(["-c", command])
778            .current_dir(path)
779            .status()
780    };
781
782    match status {
783        Ok(s) if s.success() => Ok(()),
784        Ok(s) => Err(format!("Rite failed with exit code: {:?}", s.code())),
785        Err(e) => Err(format!("Failed to invoke rite: {}", e)),
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn test_parse_binding_version() {
795        let binding = parse_binding_spec("0.1.0").unwrap();
796        assert!(matches!(binding, Binding::Version(v) if v == "0.1.0"));
797    }
798
799    #[test]
800    fn test_parse_binding_path() {
801        let binding = parse_binding_spec("path:../chorus").unwrap();
802        assert!(binding.is_path());
803        assert_eq!(binding.path(), Some("../chorus"));
804    }
805
806    #[test]
807    fn test_parse_binding_git() {
808        let binding = parse_binding_spec("git:https://github.com/example/repo").unwrap();
809        assert!(binding.is_git());
810        assert_eq!(binding.git(), Some("https://github.com/example/repo"));
811    }
812
813    #[test]
814    fn test_grimoire_serialization() {
815        let grimoire = Grimoire {
816            tome: TomeMetadata {
817                name: "test-tome".to_string(),
818                version: "0.1.0".to_string(),
819                ..Default::default()
820            },
821            bindings: {
822                let mut b = HashMap::new();
823                b.insert("aegis".to_string(), Binding::Version("0.1".to_string()));
824                b
825            },
826            ..Default::default()
827        };
828
829        let toml = toml::to_string_pretty(&grimoire).unwrap();
830        assert!(toml.contains("[tome]"));
831        assert!(toml.contains("name = \"test-tome\""));
832        assert!(toml.contains("[bindings]"));
833    }
834}