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    // Find main source file
485    let src_dir = path.join("src");
486    let main_file = if src_dir.join("main.sg").exists() {
487        src_dir.join("main.sg")
488    } else if src_dir.join("main.sigil").exists() {
489        src_dir.join("main.sigil")
490    } else if src_dir.join("lib.sg").exists() {
491        src_dir.join("lib.sg")
492    } else if src_dir.join("lib.sigil").exists() {
493        src_dir.join("lib.sigil")
494    } else {
495        return Err("No main.sg, main.sigil, lib.sg, or lib.sigil found in src/".to_string());
496    };
497
498    result.main_file = Some(main_file);
499    result.tome_name = grimoire.tome.name.clone();
500    result.version = grimoire.tome.version.clone();
501
502    Ok(result)
503}
504
505/// Result of forging
506#[derive(Debug, Default)]
507pub struct ForgeResult {
508    pub tome_name: String,
509    pub version: String,
510    pub main_file: Option<PathBuf>,
511    pub artifacts: Vec<PathBuf>,
512}
513
514// ============================================================================
515// Helper Functions
516// ============================================================================
517
518/// Parse a binding specification string
519fn parse_binding_spec(spec: &str) -> Result<Binding, String> {
520    // Check for path: prefix
521    if spec.starts_with("path:") {
522        let path = spec.strip_prefix("path:").unwrap().trim();
523        return Ok(Binding::Detailed(BindingSpec {
524            path: Some(path.to_string()),
525            ..Default::default()
526        }));
527    }
528
529    // Check for git: prefix
530    if spec.starts_with("git:") {
531        let url = spec.strip_prefix("git:").unwrap().trim();
532        return Ok(Binding::Detailed(BindingSpec {
533            git: Some(url.to_string()),
534            ..Default::default()
535        }));
536    }
537
538    // Otherwise, treat as version
539    Ok(Binding::Version(spec.to_string()))
540}
541
542/// Resolve a binding to a local path
543fn resolve_binding(
544    name: &str,
545    binding: &Binding,
546    tomes_dir: &Path,
547) -> Result<ResolvedBinding, String> {
548    match binding {
549        Binding::Version(version) => {
550            // Registry binding - placeholder for future Grimoire registry
551            Err(format!(
552                "Registry bindings not yet implemented. Use path: or git: for '{}'",
553                name
554            ))
555        }
556        Binding::Detailed(spec) => {
557            if let Some(path) = &spec.path {
558                // Local path binding
559                let resolved_path = PathBuf::from(path);
560                if !resolved_path.exists() {
561                    return Err(format!("Path does not exist: {}", path));
562                }
563                Ok(ResolvedBinding {
564                    name: name.to_string(),
565                    version: spec.version.clone().unwrap_or_else(|| "0.0.0".to_string()),
566                    path: resolved_path,
567                    source: BindingSource::Path,
568                })
569            } else if let Some(git_url) = &spec.git {
570                // Git binding
571                let reference = spec
572                    .branch
573                    .clone()
574                    .or_else(|| spec.tag.clone())
575                    .or_else(|| spec.rev.clone())
576                    .unwrap_or_else(|| "main".to_string());
577
578                let clone_dir = tomes_dir.join(name);
579                clone_git_repo(git_url, &reference, &clone_dir)?;
580
581                Ok(ResolvedBinding {
582                    name: name.to_string(),
583                    version: spec.version.clone().unwrap_or_else(|| "0.0.0-git".to_string()),
584                    path: clone_dir,
585                    source: BindingSource::Git {
586                        url: git_url.clone(),
587                        reference,
588                    },
589                })
590            } else if let Some(version) = &spec.version {
591                // Registry binding with version
592                Err(format!(
593                    "Registry bindings not yet implemented. Use path: or git: for '{}'",
594                    name
595                ))
596            } else {
597                Err(format!(
598                    "Invalid binding for '{}': must specify version, path, or git",
599                    name
600                ))
601            }
602        }
603    }
604}
605
606/// Clone a git repository
607fn clone_git_repo(url: &str, reference: &str, dest: &Path) -> Result<(), String> {
608    use std::process::Command;
609
610    if dest.exists() {
611        // Pull instead of clone
612        let output = Command::new("git")
613            .args(["pull", "--ff-only"])
614            .current_dir(dest)
615            .output()
616            .map_err(|e| format!("Failed to run git pull: {}", e))?;
617
618        if !output.status.success() {
619            let stderr = String::from_utf8_lossy(&output.stderr);
620            return Err(format!("git pull failed: {}", stderr));
621        }
622    } else {
623        // Clone
624        let output = Command::new("git")
625            .args(["clone", "--depth", "1", "--branch", reference, url])
626            .arg(dest)
627            .output()
628            .map_err(|e| format!("Failed to run git clone: {}", e))?;
629
630        if !output.status.success() {
631            let stderr = String::from_utf8_lossy(&output.stderr);
632            return Err(format!("git clone failed: {}", stderr));
633        }
634    }
635
636    Ok(())
637}
638
639/// Generate a lock file from resolved bindings
640fn generate_lock_file(resolved: &[ResolvedBinding]) -> GrimoireLock {
641    let bindings = resolved
642        .iter()
643        .map(|r| LockedBinding {
644            name: r.name.clone(),
645            version: r.version.clone(),
646            source: match &r.source {
647                BindingSource::Registry => "registry".to_string(),
648                BindingSource::Path => format!("path:{}", r.path.display()),
649                BindingSource::Git { url, reference } => format!("git:{}#{}", url, reference),
650            },
651            checksum: None,
652            dependencies: vec![],
653        })
654        .collect();
655
656    GrimoireLock {
657        version: 1,
658        bindings,
659    }
660}
661
662/// Get git author from git config
663fn get_git_author() -> Option<String> {
664    use std::process::Command;
665
666    let name = Command::new("git")
667        .args(["config", "user.name"])
668        .output()
669        .ok()
670        .filter(|o| o.status.success())
671        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
672
673    let email = Command::new("git")
674        .args(["config", "user.email"])
675        .output()
676        .ok()
677        .filter(|o| o.status.success())
678        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
679
680    if name.is_empty() {
681        None
682    } else if email.is_empty() {
683        Some(name)
684    } else {
685        Some(format!("{} <{}>", name, email))
686    }
687}
688
689/// List available rites (scripts)
690pub fn list_rites(path: &Path) -> Result<Vec<(String, String)>, String> {
691    let grimoire = Grimoire::load(path)?;
692    Ok(grimoire.rites.into_iter().collect())
693}
694
695/// Run a rite (script)
696pub fn invoke_rite(path: &Path, rite_name: &str) -> Result<(), String> {
697    let grimoire = Grimoire::load(path)?;
698
699    let command = grimoire
700        .rites
701        .get(rite_name)
702        .ok_or_else(|| format!("Unknown rite: {}", rite_name))?;
703
704    use std::process::Command;
705
706    let status = if cfg!(windows) {
707        Command::new("cmd")
708            .args(["/C", command])
709            .current_dir(path)
710            .status()
711    } else {
712        Command::new("sh")
713            .args(["-c", command])
714            .current_dir(path)
715            .status()
716    };
717
718    match status {
719        Ok(s) if s.success() => Ok(()),
720        Ok(s) => Err(format!("Rite failed with exit code: {:?}", s.code())),
721        Err(e) => Err(format!("Failed to invoke rite: {}", e)),
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    #[test]
730    fn test_parse_binding_version() {
731        let binding = parse_binding_spec("0.1.0").unwrap();
732        assert!(matches!(binding, Binding::Version(v) if v == "0.1.0"));
733    }
734
735    #[test]
736    fn test_parse_binding_path() {
737        let binding = parse_binding_spec("path:../chorus").unwrap();
738        assert!(binding.is_path());
739        assert_eq!(binding.path(), Some("../chorus"));
740    }
741
742    #[test]
743    fn test_parse_binding_git() {
744        let binding = parse_binding_spec("git:https://github.com/example/repo").unwrap();
745        assert!(binding.is_git());
746        assert_eq!(binding.git(), Some("https://github.com/example/repo"));
747    }
748
749    #[test]
750    fn test_grimoire_serialization() {
751        let grimoire = Grimoire {
752            tome: TomeMetadata {
753                name: "test-tome".to_string(),
754                version: "0.1.0".to_string(),
755                ..Default::default()
756            },
757            bindings: {
758                let mut b = HashMap::new();
759                b.insert("aegis".to_string(), Binding::Version("0.1".to_string()));
760                b
761            },
762            ..Default::default()
763        };
764
765        let toml = toml::to_string_pretty(&grimoire).unwrap();
766        assert!(toml.contains("[tome]"));
767        assert!(toml.contains("name = \"test-tome\""));
768        assert!(toml.contains("[bindings]"));
769    }
770}