1use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23
24pub const GRIMOIRE_TOML: &str = "Grimoire.toml";
26
27pub const GRIMOIRE_LOCK: &str = "Grimoire.lock";
29
30pub const TOMES_DIR: &str = ".tomes";
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct Grimoire {
40 pub tome: TomeMetadata,
42 #[serde(default)]
44 pub bindings: HashMap<String, Binding>,
45 #[serde(default)]
47 pub dev_bindings: HashMap<String, Binding>,
48 #[serde(default)]
50 pub rites: HashMap<String, String>,
51 #[serde(default)]
53 pub workspace: Option<Workspace>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct TomeMetadata {
59 pub name: String,
61 pub version: String,
63 #[serde(default)]
65 pub authors: Vec<String>,
66 #[serde(default)]
68 pub edition: Option<String>,
69 #[serde(default)]
71 pub description: Option<String>,
72 #[serde(default)]
74 pub license: Option<String>,
75 #[serde(default)]
77 pub repository: Option<String>,
78 #[serde(default)]
80 pub homepage: Option<String>,
81 #[serde(default)]
83 pub keywords: Vec<String>,
84 #[serde(default)]
86 pub categories: Vec<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(untagged)]
92pub enum Binding {
93 Version(String),
95 Detailed(BindingSpec),
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct BindingSpec {
102 #[serde(default)]
104 pub version: Option<String>,
105 #[serde(default)]
107 pub path: Option<String>,
108 #[serde(default)]
110 pub git: Option<String>,
111 #[serde(default)]
113 pub branch: Option<String>,
114 #[serde(default)]
116 pub tag: Option<String>,
117 #[serde(default)]
119 pub rev: Option<String>,
120 #[serde(default)]
122 pub optional: bool,
123 #[serde(default)]
125 pub features: Vec<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct Workspace {
131 #[serde(default)]
133 pub members: Vec<String>,
134 #[serde(default)]
136 pub exclude: Vec<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
145pub struct GrimoireLock {
146 pub version: u32,
148 #[serde(default)]
150 pub bindings: Vec<LockedBinding>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct LockedBinding {
156 pub name: String,
158 pub version: String,
160 pub source: String,
162 #[serde(default)]
164 pub checksum: Option<String>,
165 #[serde(default)]
167 pub dependencies: Vec<String>,
168}
169
170impl Grimoire {
175 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 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 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 pub fn summon(&mut self, name: &str, binding: Binding) {
221 self.bindings.insert(name.to_string(), binding);
222 }
223
224 pub fn banish(&mut self, name: &str) -> Option<Binding> {
226 self.bindings.remove(name)
227 }
228
229 pub fn has_binding(&self, name: &str) -> bool {
231 self.bindings.contains_key(name)
232 }
233}
234
235impl Binding {
236 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 pub fn is_path(&self) -> bool {
246 matches!(self, Binding::Detailed(spec) if spec.path.is_some())
247 }
248
249 pub fn is_git(&self) -> bool {
251 matches!(self, Binding::Detailed(spec) if spec.git.is_some())
252 }
253
254 pub fn path(&self) -> Option<&str> {
256 match self {
257 Binding::Detailed(spec) => spec.path.as_deref(),
258 _ => None,
259 }
260 }
261
262 pub fn git(&self) -> Option<&str> {
264 match self {
265 Binding::Detailed(spec) => spec.git.as_deref(),
266 _ => None,
267 }
268 }
269}
270
271pub 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 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 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 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 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
343pub 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 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 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
383pub 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
394pub 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
406pub fn attune(path: &Path) -> Result<AttuneResult, String> {
408 let grimoire = Grimoire::load(path)?;
409 let mut result = AttuneResult::default();
410
411 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 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 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#[derive(Debug, Default)]
444pub struct AttuneResult {
445 pub resolved: Vec<ResolvedBinding>,
446 pub errors: Vec<(String, String)>,
447}
448
449#[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#[derive(Debug, Clone)]
460pub enum BindingSource {
461 Registry,
462 Path,
463 Git { url: String, reference: String },
464}
465
466pub fn forge(path: &Path) -> Result<ForgeResult, String> {
468 let grimoire = Grimoire::load(path)?;
469 let mut result = ForgeResult::default();
470
471 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 if let Some(workspace) = &grimoire.workspace {
486 if !workspace.members.is_empty() {
487 return forge_workspace(path, &grimoire, workspace);
488 }
489 }
490
491 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
501fn find_main_source(path: &Path) -> Result<PathBuf, String> {
503 let src_dir = path.join("src");
504
505 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
519fn 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 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 match Grimoire::load(&member_full_path) {
542 Ok(member_grimoire) => {
543 eprintln!(" Forging {}...", member_grimoire.tome.name);
544
545 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#[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
578fn parse_binding_spec(spec: &str) -> Result<Binding, String> {
584 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 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 Ok(Binding::Version(spec.to_string()))
604}
605
606fn resolve_binding(
608 name: &str,
609 binding: &Binding,
610 tomes_dir: &Path,
611) -> Result<ResolvedBinding, String> {
612 match binding {
613 Binding::Version(version) => {
614 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 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 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 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
670fn clone_git_repo(url: &str, reference: &str, dest: &Path) -> Result<(), String> {
672 use std::process::Command;
673
674 if dest.exists() {
675 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 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
703fn 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
726fn 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
753pub 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
759pub 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}