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 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#[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
514fn parse_binding_spec(spec: &str) -> Result<Binding, String> {
520 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 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 Ok(Binding::Version(spec.to_string()))
540}
541
542fn resolve_binding(
544 name: &str,
545 binding: &Binding,
546 tomes_dir: &Path,
547) -> Result<ResolvedBinding, String> {
548 match binding {
549 Binding::Version(version) => {
550 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 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 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 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
606fn clone_git_repo(url: &str, reference: &str, dest: &Path) -> Result<(), String> {
608 use std::process::Command;
609
610 if dest.exists() {
611 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 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
639fn 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
662fn 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
689pub 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
695pub 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}