1#![allow(clippy::unused_unit)] use crate::error::GgenError;
26use clap_noun_verb::Result as VerbResult;
27use clap_noun_verb_macros::verb;
28use ggen_core::codegen::FileTransaction;
29use serde::Serialize;
30use std::fs;
31use std::path::Path;
32
33pub type Result<T> = std::result::Result<T, GgenError>;
34
35#[derive(Debug, Clone, Serialize)]
41pub struct InitOutput {
42 pub status: String,
44
45 pub project_dir: String,
47
48 pub files_created: Vec<String>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub files_overwritten: Option<Vec<String>>,
54
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub files_preserved: Option<Vec<String>>,
58
59 pub directories_created: Vec<String>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub error: Option<String>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub warning: Option<String>,
69
70 pub next_steps: Vec<String>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub transaction: Option<TransactionInfo>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub git_hooks: Option<super::git_hooks::HooksInstallOutput>,
80}
81
82#[derive(Debug, Clone, Serialize)]
84pub struct TransactionInfo {
85 pub total_files: usize,
87
88 pub backups_created: usize,
90
91 pub committed: bool,
93}
94
95const GGEN_TOML: &str = r#"[project]
100name = "my-ggen-project"
101version = "0.1.0"
102description = "A ggen project initialized with default templates"
103authors = ["ggen init"]
104license = "MIT"
105
106# BIG BANG 80/20: Specification Closure First
107# Before using ggen, confirm:
108# 1. Do you have real user data (CSV/JSON)? Not promised—actual files.
109# 2. Can you find one existing standard ontology (schema.org, FOAF, Dublin Core, SKOS)?
110# Should take 5 minutes. If it takes 3 months, you're building custom (wrong path).
111# 3. Can you explain your problem in one sentence? No 100-page documents.
112# 4. Has anyone (not a friend, not a co-founder) committed to this?
113# Email, contract, payment—proof, not enthusiasm.
114# 5. Can you validate with 10 real users in 48 hours?
115#
116# If you answered NO to any of these, stop. Talk to Sean before proceeding.
117
118[ontology]
119# REQUIRED: Path to your RDF ontology file (Turtle format)
120# Approved: schema.org, FOAF, Dublin Core, SKOS, Big Five
121# Replace with your chosen standard ontology below.
122source = "schema/domain.ttl"
123# Use standard ontologies only (BIG BANG 80/20 gate)
124standard_only = true
125
126# Example: Using schema.org for e-commerce domain
127# [[ontology.pack]]
128# name = "schema-org"
129# version = "^3.13.0"
130# namespace = "https://schema.org/"
131
132[generation]
133output_dir = "."
134
135# Define inference rules (required for DMAIC quality gates)
136[inference]
137rules = [
138 { name = "standard-normalization", construct = "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" }
139]
140
141# Define at least one generation rule (required)
142# This example rule generates Rust structs from the Person ontology
143[[generation.rules]]
144name = "example-rule"
145# SPARQL SELECT query to extract ontology concepts
146query = { inline = """
147PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
148PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
149PREFIX schema: <https://schema.org/>
150
151SELECT ?class ?label ?comment
152WHERE {
153 ?class a rdfs:Class ;
154 rdfs:label ?label .
155 OPTIONAL { ?class rdfs:comment ?comment . }
156}
157LIMIT 10
158""" }
159# Template file to render (relative to templates directory)
160template = { file = "templates/example.txt.tera" }
161# Output file path (relative to output_dir)
162output_file = "ontology-summary.txt"
163# How to handle existing output files
164mode = "Overwrite"
165
166[sync]
167enabled = true
168on_change = "manual"
169validate_after = true
170conflict_mode = "fail"
171
172[rdf]
173formats = ["turtle"]
174default_format = "turtle"
175strict_validation = false
176
177[templates]
178enable_caching = true
179auto_reload = true
180
181[output]
182formatting = "default"
183line_length = 100
184indent = 2
185"#;
186
187const DOMAIN_TTL: &str = r#"# ggen v26_5_19: Schema.org Example Ontology
188# Uses schema.org (standard ontology) instead of custom namespace.
189#
190# In BIG BANG 80/20 mode, replace this with:
191# 1. Your actual user data (CSV → RDF conversion)
192# 2. A chosen standard ontology (schema.org, FOAF, Dublin Core, SKOS)
193# 3. Only custom triples if schema.org + standard combo doesn't fit
194#
195# Why? Because Seth built 3-month custom ontology for what schema.org does in 5 minutes.
196
197@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
198@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
199@prefix schema: <https://schema.org/> .
200
201# Example: Using schema.org Person and properties
202# This is a real, standard vocabulary used by Google, Microsoft, Yahoo, Yandex
203# See: https://schema.org/Person
204
205schema:Person a rdfs:Class ;
206 rdfs:label "Person" ;
207 rdfs:comment "Schema.org Person type - standard vocabulary for person data" .
208
209# Properties from schema.org
210schema:name a rdf:Property ;
211 rdfs:domain schema:Person ;
212 rdfs:label "name" ;
213 rdfs:comment "The name of the person (from schema.org)" .
214
215schema:email a rdf:Property ;
216 rdfs:domain schema:Person ;
217 rdfs:label "email" ;
218 rdfs:comment "The email address (from schema.org)" .
219
220schema:age a rdf:Property ;
221 rdfs:domain schema:Person ;
222 rdfs:label "age" ;
223 rdfs:comment "The age of the person (from schema.org)" .
224
225# NEXT STEPS:
226# 1. Load your actual CSV/JSON user data
227# 2. Validate with 10 real users (not friends)
228# 3. Only extend schema.org if needed (stay standard-first)
229# 4. Run: ggen sync --validate-only
230"#;
231
232const MAKEFILE: &str = r#".PHONY: help setup build clean
233
234help:
235 @echo "ggen project - Available targets:"
236 @echo " make setup - Run startup.sh to initialize project"
237 @echo " make build - Generate code from ontology (ggen sync)"
238 @echo " make clean - Remove generated artifacts"
239 @echo ""
240 @echo "See scripts/startup.sh for custom initialization steps"
241
242setup:
243 @bash scripts/startup.sh
244
245build:
246 ggen sync
247
248clean:
249 rm -rf .ggen/
250"#;
251
252const EXAMPLE_TEMPLATE: &str = r#"# Example Tera Template for ggen
253# This template is called by the generation.rules in ggen.toml
254# It receives data from the SPARQL query and renders output
255
256# Ontology Classes from schema.org
257{% for row in results %}
258## Class: {{ row.label | default(value="Unknown") }}
259
260{% if row.comment %}
261{{ row.comment }}
262{% else %}
263(No description provided)
264{% endif %}
265
266**Identifier**: `{{ row.class }}`
267
268---
269{% endfor %}
270
271*Generated by ggen v26_5_19*
272"#;
273
274const STARTUP_SH: &str = r#"#!/bin/bash
275# Startup script for ggen project initialization
276# Implements BIG BANG 80/20 screening gate before project can proceed
277#
278# Purpose: Prevent Seth-like patterns (custom ontologies, 3-month research, zero validation)
279# by enforcing execution discipline: real data, standard ontologies, quick validation.
280
281set -e
282
283echo "🚀 ggen v26_5_19: BIG BANG 80/20 Screening Gate"
284echo ""
285echo "Before initializing, you must answer 5 questions about execution readiness."
286echo "If you answer NO to any, stop and talk to Sean."
287echo ""
288
289# Screening Question 1: User Data
290echo "❓ Question 1/5: Do you have real user data (CSV/JSON)?"
291echo " (Not promised. Actual files. If building a feature, do you have beta users' data?)"
292echo " Answer (yes/no):"
293read -r q1
294if [[ "$q1" != "yes" ]]; then
295 echo "❌ STOP. You need real data to validate with. Build MVP first, use ggen after."
296 exit 1
297fi
298
299# Screening Question 2: Standard Ontology
300echo ""
301echo "❓ Question 2/5: Can you find ONE existing standard ontology for your domain?"
302echo " (schema.org, FOAF, Dublin Core, SKOS - should take 5 min, not 3 months)"
303echo " Answer (yes/no):"
304read -r q2
305if [[ "$q2" != "yes" ]]; then
306 echo "❌ STOP. You're about to build a custom ontology (Seth's mistake)."
307 echo " 5 min: Find schema.org. 3 months: Build custom. Which path?"
308 exit 1
309fi
310
311# Screening Question 3: Problem Articulation
312echo ""
313echo "❓ Question 3/5: Can you explain your problem in ONE sentence?"
314echo " (No 100-page documents. Just the core job-to-be-done.)"
315echo " Say it out loud, then answer (yes/no):"
316read -r q3
317if [[ "$q3" != "yes" ]]; then
318 echo "❌ STOP. You don't have clarity. Write it down. One sentence. Try again."
319 exit 1
320fi
321
322# Screening Question 4: Market Signal
323echo ""
324echo "❓ Question 4/5: Has anyone (not friends, not co-founders) committed to this?"
325echo " (Email list, signed beta contract, payment - PROOF, not enthusiasm)"
326echo " Answer (yes/no):"
327read -r q4
328if [[ "$q4" != "yes" ]]; then
329 echo "⚠️ WARNING: Zero external validation. You're building in a vacuum."
330 echo " Proceed? (yes/no):"
331 read -r q4_confirm
332 if [[ "$q4_confirm" != "yes" ]]; then
333 exit 1
334 fi
335fi
336
337# Screening Question 5: Validation Speed
338echo ""
339echo "❓ Question 5/5: Can you validate with 10 real users in 48 hours?"
340echo " Answer (yes/no):"
341read -r q5
342if [[ "$q5" != "yes" ]]; then
343 echo "⚠️ WARNING: You don't have a validation plan. How will you know if it works?"
344 echo " Proceed? (yes/no):"
345 read -r q5_confirm
346 if [[ "$q5_confirm" != "yes" ]]; then
347 exit 1
348 fi
349fi
350
351echo ""
352echo "✅ Screening complete. You passed the litmus test."
353echo ""
354
355# Create directories if they don't exist
356mkdir -p schema
357mkdir -p templates
358mkdir -p scripts
359mkdir -p data
360
361echo "📁 Project structure created:"
362echo " schema/ - Your ontology files (use standard bases, not custom)"
363echo " data/ - Your real user data (CSV/JSON)"
364echo " templates/ - Tera templates for code generation"
365echo " scripts/ - Custom scripts"
366
367echo ""
368echo "📋 Next steps (in order):"
369echo " 1. Add your actual user data to data/ (CSV or JSON)"
370echo " 2. Edit schema/domain.ttl with standard ontology (schema.org, FOAF, Dublin Core, SKOS)"
371echo " 3. Create Tera templates in templates/ for your target language"
372echo " 4. Run 'ggen sync --validate-only' to test without writing"
373echo " 5. Run 'ggen sync' to generate code"
374echo " 6. Validate with your 10 real users (not friends)"
375echo ""
376echo "⚡ Speed targets:"
377echo " - Data upload: 1 hour"
378echo " - Ontology selection: 1 hour (use standard, don't build custom)"
379echo " - Template creation: 2-4 hours"
380echo " - First user validation: 24 hours"
381echo ""
382echo "📚 Resources:"
383echo " - schema.org: https://schema.org/ (Google, Microsoft, Yahoo - trusted)"
384echo " - FOAF: http://xmlns.com/foaf/spec/ (Social networks)"
385echo " - Dublin Core: http://dublincore.org/ (Metadata)"
386echo " - SKOS: https://www.w3.org/2004/02/skos/ (Controlled vocabularies)"
387echo " - ggen Docs: https://docs.ggen.io"
388echo " - RDF/Turtle: https://www.w3.org/TR/turtle/"
389echo " - Tera: https://keats.github.io/tera/"
390echo ""
391echo "💡 Remember: Seth's problem was building a custom 100-page ontology instead of"
392echo " using schema.org in 5 minutes. Stay disciplined. Use standards first."
393"#;
394
395#[allow(clippy::unused_unit)]
438#[verb("init", "root")]
439pub fn init(
440 path: Option<String>, force: Option<bool>, skip_hooks: Option<bool>, name: Option<String>,
441 version: Option<String>, description: Option<String>,
442) -> VerbResult<InitOutput> {
443 let project_dir = path.unwrap_or_else(|| ".".to_string());
445 let force = force.unwrap_or(false);
446 let skip_hooks = skip_hooks.unwrap_or(false);
447
448 let project_name = name.unwrap_or_else(|| "my-ggen-project".to_string());
450 let project_version = version.unwrap_or_else(|| "0.1.0".to_string());
451 let project_desc = description
452 .unwrap_or_else(|| "A ggen project initialized with default templates".to_string());
453
454 perform_init(
456 &project_dir,
457 force,
458 skip_hooks,
459 &project_name,
460 &project_version,
461 &project_desc,
462 )
463 .map_err(|e| e.into())
464}
465
466fn perform_init(
483 project_dir: &str, force: bool, skip_hooks: bool, name: &str, version: &str, description: &str,
484) -> std::result::Result<InitOutput, GgenError> {
485 let base_path = Path::new(project_dir);
487
488 let ggen_artifacts = [
490 "ggen.toml",
491 "Makefile",
492 "schema/domain.ttl",
493 "scripts/startup.sh",
494 ];
495
496 let has_ggen_artifacts = ggen_artifacts.iter().any(|artifact| {
498 let path = base_path.join(artifact);
499 path.exists()
500 });
501
502 if has_ggen_artifacts && !force {
503 return Ok(InitOutput {
504 status: "error".to_string(),
505 project_dir: project_dir.to_string(),
506 files_created: vec![],
507 files_overwritten: None,
508 files_preserved: None,
509 directories_created: vec![],
510 error: Some(
511 "ggen project already initialized here. Use --force to reinitialize.".to_string(),
512 ),
513 warning: None,
514 next_steps: vec!["Run 'make build' to regenerate code".to_string()],
515 transaction: None,
516 git_hooks: None,
517 });
518 }
519
520 if let Err(e) = fs::create_dir_all(base_path) {
522 return Ok(InitOutput {
523 status: "error".to_string(),
524 project_dir: project_dir.to_string(),
525 files_created: vec![],
526 files_overwritten: None,
527 files_preserved: None,
528 directories_created: vec![],
529 error: Some(format!("Failed to create project directory: {}", e)),
530 warning: None,
531 next_steps: vec![],
532 transaction: None,
533 git_hooks: None,
534 });
535 }
536
537 let preflight = ggen_core::validation::PreFlightValidator::for_init(base_path);
539 if let Err(e) = preflight.validate(None) {
540 return Ok(InitOutput {
541 status: "error".to_string(),
542 project_dir: project_dir.to_string(),
543 files_created: vec![],
544 files_overwritten: None,
545 files_preserved: None,
546 directories_created: vec![],
547 error: Some(format!("{}", e)),
548 warning: None,
549 next_steps: vec![
550 "Ensure you have at least 100MB of free disk space".to_string(),
551 "Verify write permissions to the target directory".to_string(),
552 ],
553 transaction: None,
554 git_hooks: None,
555 });
556 }
557
558 let temp_test = base_path.join(".ggen_write_test");
560 match fs::write(&temp_test, "") {
561 Ok(()) => {
562 let _ = fs::remove_file(&temp_test); }
564 Err(e) => {
565 return Ok(InitOutput {
566 status: "error".to_string(),
567 project_dir: project_dir.to_string(),
568 files_created: vec![],
569 files_overwritten: None,
570 files_preserved: None,
571 directories_created: vec![],
572 error: Some(format!("No write permission in project directory: {}", e)),
573 warning: None,
574 next_steps: vec![
575 "Check directory permissions or try a different location".to_string()
576 ],
577 transaction: None,
578 git_hooks: None,
579 });
580 }
581 }
582
583 let tx = FileTransaction::new().map_err(|e| {
585 GgenError::CommandError(format!("Failed to initialize file transaction: {}", e))
586 })?;
587
588 let mut directories_created = vec![];
589 let mut files_preserved = vec![];
590
591 let dirs = vec!["schema", "templates", "scripts"];
594 for dir in &dirs {
595 let dir_path = base_path.join(dir);
596 let existed = dir_path.exists();
597 fs::create_dir_all(&dir_path).map_err(|e| {
598 GgenError::CommandError(format!("Failed to create directory {}: {}", dir, e))
599 })?;
600 if !existed {
601 directories_created.push(dir.to_string());
602 }
603 }
604
605 let gitignore_path = base_path.join(".gitignore");
607 let gitignore_exists = gitignore_path.exists();
608 if gitignore_exists {
609 files_preserved.push(".gitignore".to_string());
610 }
611
612 let readme_path = base_path.join("README.md");
613 let readme_exists = readme_path.exists();
614 if readme_exists {
615 files_preserved.push("README.md".to_string());
616 }
617
618 let toml_path = base_path.join("ggen.toml");
623 let manifest_content = GGEN_TOML
624 .replace(
625 "name = \"my-ggen-project\"",
626 &format!("name = \"{}\"", name),
627 )
628 .replace("version = \"0.1.0\"", &format!("version = \"{}\"", version))
629 .replace(
630 "description = \"A ggen project initialized with default templates\"",
631 &format!("description = \"{}\"", description),
632 );
633
634 tx.write_file(&toml_path, &manifest_content)
635 .map_err(|e| GgenError::CommandError(format!("Failed to write ggen.toml: {}", e)))?;
636
637 let schema_path = base_path.join("schema").join("domain.ttl");
639 tx.write_file(&schema_path, DOMAIN_TTL).map_err(|e| {
640 GgenError::CommandError(format!("Failed to write schema/domain.ttl: {}", e))
641 })?;
642
643 let makefile_path = base_path.join("Makefile");
645 tx.write_file(&makefile_path, MAKEFILE)
646 .map_err(|e| GgenError::CommandError(format!("Failed to write Makefile: {}", e)))?;
647
648 let template_path = base_path.join("templates").join("example.txt.tera");
650 tx.write_file(&template_path, EXAMPLE_TEMPLATE)
651 .map_err(|e| {
652 GgenError::CommandError(format!("Failed to write templates/example.txt.tera: {}", e))
653 })?;
654
655 let startup_sh_path = base_path.join("scripts").join("startup.sh");
657 tx.write_file(&startup_sh_path, STARTUP_SH).map_err(|e| {
658 GgenError::CommandError(format!("Failed to write scripts/startup.sh: {}", e))
659 })?;
660
661 if !gitignore_exists {
663 let gitignore_content = "# ggen outputs\n.ggen/\n";
664 tx.write_file(&gitignore_path, gitignore_content)
665 .map_err(|e| GgenError::CommandError(format!("Failed to write .gitignore: {}", e)))?;
666 }
667
668 let readme_content = r"# My ggen Project
670
671Generated by `ggen init`.
672
673## Getting Started
674
6751. **Initialize project**: Run `make setup` to prepare your environment
6762. **Define schema**: Edit `schema/domain.ttl` with your domain model
6773. **Create templates**: Add Tera templates in `templates/` for your target languages
6784. **Generate code**: Run `make build` to generate code from your ontology
679
680## MCP/A2A Configuration
681
682This project includes MCP (Model Context Protocol) and A2A (Agent-to-Agent) configuration files:
683
684- `.mcp.json` - MCP server configuration
685- `a2a.toml` - A2A agent configuration
686
687To reinitialize these configs:
688```bash
689ggen mcp init-config --force
690```
691
692To validate configuration:
693```bash
694ggen mcp validate-config
695```
696
697To set up Claude Desktop integration:
698```bash
699ggen mcp setup
700```";
701 if !readme_exists {
702 tx.write_file(&readme_path, readme_content)
703 .map_err(|e| GgenError::CommandError(format!("Failed to write README.md: {}", e)))?;
704 }
705
706 #[cfg(unix)]
708 {
709 use std::os::unix::fs::PermissionsExt;
710 fs::set_permissions(&startup_sh_path, std::fs::Permissions::from_mode(0o755)).map_err(
711 |e| {
712 GgenError::CommandError(format!(
713 "Failed to set execute permissions on startup.sh: {}",
714 e
715 ))
716 },
717 )?;
718 }
719
720 let receipt = tx.commit().map_err(|e| {
723 GgenError::CommandError(format!("Failed to commit file transaction: {}", e))
724 })?;
725
726 let git_hooks_result = super::git_hooks::install_git_hooks(base_path, skip_hooks).ok(); let files_created: Vec<String> = receipt
731 .files_created
732 .iter()
733 .filter_map(|p| {
734 p.strip_prefix(base_path)
736 .ok()
737 .map(|rel| rel.display().to_string())
738 })
739 .collect();
740
741 let files_modified: Vec<String> = receipt
742 .files_modified
743 .iter()
744 .filter_map(|p| {
745 p.strip_prefix(base_path)
746 .ok()
747 .map(|rel| rel.display().to_string())
748 })
749 .collect();
750
751 let files_overwritten_opt = if files_modified.is_empty() {
752 None
753 } else {
754 Some(files_modified.clone())
755 };
756
757 let files_preserved_opt = if files_preserved.is_empty() {
758 None
759 } else {
760 Some(files_preserved.clone())
761 };
762
763 let warning = if !files_modified.is_empty() || !files_preserved.is_empty() {
765 let mut msgs = vec![];
766 if !files_modified.is_empty() {
767 msgs.push(format!("Overwrote {} file(s)", files_modified.len()));
768 }
769 if !files_preserved.is_empty() {
770 msgs.push(format!("Preserved {} user file(s)", files_preserved.len()));
771 }
772 Some(msgs.join("; ") + ".")
773 } else {
774 None
775 };
776
777 Ok(InitOutput {
778 status: "success".to_string(),
779 project_dir: project_dir.to_string(),
780 files_created,
781 files_overwritten: files_overwritten_opt,
782 files_preserved: files_preserved_opt,
783 directories_created,
784 error: None,
785 warning,
786 next_steps: vec![
787 "Run 'make setup' to initialize your project".to_string(),
788 "Edit schema/domain.ttl to define your domain model".to_string(),
789 "Create Tera templates in templates/ for your target languages".to_string(),
790 "Run 'make build' to generate code from your ontology".to_string(),
791 ],
792 transaction: Some(TransactionInfo {
793 total_files: receipt.total_files(),
794 backups_created: receipt.backups.len(),
795 committed: true,
796 }),
797 git_hooks: git_hooks_result,
798 })
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804 use std::path::PathBuf;
805 use tempfile::tempdir;
806
807 #[test]
808 fn test_atomic_init_success() {
809 let temp_dir = tempdir().expect("Failed to create temp dir");
810 let project_path = temp_dir.path().to_str().expect("Invalid path");
811
812 let result = perform_init(
813 project_path,
814 false,
815 true,
816 "test-project",
817 "0.1.0",
818 "test desc",
819 )
820 .expect("Init should succeed");
821
822 assert_eq!(result.status, "success");
824 assert!(result.error.is_none());
825
826 assert!(result.transaction.is_some());
828 let tx_info = result.transaction.unwrap();
829 assert!(tx_info.total_files > 0, "Should have created files");
830 assert!(tx_info.committed, "Transaction should be committed");
831
832 assert!(
834 !result.files_created.is_empty(),
835 "Should have created files"
836 );
837 assert!(
838 result.directories_created.len() >= 3,
839 "Should have created at least 3 directories"
840 );
841
842 let base = PathBuf::from(project_path);
844 assert!(base.join("ggen.toml").exists(), "ggen.toml should exist");
845 assert!(
846 base.join("schema/domain.ttl").exists(),
847 "domain.ttl should exist"
848 );
849 assert!(base.join("Makefile").exists(), "Makefile should exist");
850 assert!(
851 base.join("scripts/startup.sh").exists(),
852 "startup.sh should exist"
853 );
854 assert!(
855 base.join("templates/example.txt.tera").exists(),
856 "example.txt.tera should exist"
857 );
858 }
859
860 #[test]
861 fn test_init_preserves_existing_files() {
862 let temp_dir = tempdir().expect("Failed to create temp dir");
863 let project_path = temp_dir.path().to_str().expect("Invalid path");
864 let base = PathBuf::from(project_path);
865
866 fs::create_dir_all(&base).expect("Failed to create base dir");
868 let gitignore_content = "# Custom gitignore\n*.log\n";
869 let readme_content = "# Custom README\n\nMy project\n";
870 fs::write(base.join(".gitignore"), gitignore_content).expect("Failed to write .gitignore");
871 fs::write(base.join("README.md"), readme_content).expect("Failed to write README.md");
872
873 let result = perform_init(
874 project_path,
875 false,
876 true,
877 "test-project",
878 "0.1.0",
879 "test desc",
880 )
881 .expect("Init should succeed");
882
883 assert!(
885 result.files_preserved.is_some(),
886 "Should have preserved files"
887 );
888 let preserved = result.files_preserved.unwrap();
889 assert!(
890 preserved.contains(&".gitignore".to_string()),
891 "Should preserve .gitignore"
892 );
893 assert!(
894 preserved.contains(&"README.md".to_string()),
895 "Should preserve README.md"
896 );
897
898 let gitignore_after =
900 fs::read_to_string(base.join(".gitignore")).expect("Failed to read .gitignore");
901 let readme_after =
902 fs::read_to_string(base.join("README.md")).expect("Failed to read README.md");
903 assert_eq!(
904 gitignore_after, gitignore_content,
905 ".gitignore should be unchanged"
906 );
907 assert_eq!(
908 readme_after, readme_content,
909 "README.md should be unchanged"
910 );
911 }
912
913 #[test]
914 fn test_init_force_overwrites_files() {
915 let temp_dir = tempdir().expect("Failed to create temp dir");
916 let project_path = temp_dir.path().to_str().expect("Invalid path");
917 let base = PathBuf::from(project_path);
918
919 perform_init(
921 project_path,
922 false,
923 true,
924 "test-project",
925 "0.1.0",
926 "test desc",
927 )
928 .expect("First init should succeed");
929
930 let toml_path = base.join("ggen.toml");
932 let original_content = fs::read_to_string(&toml_path).expect("Failed to read ggen.toml");
933 fs::write(&toml_path, "# Modified\n").expect("Failed to modify ggen.toml");
934
935 let result = perform_init(
937 project_path,
938 false,
939 true,
940 "test-project",
941 "0.1.0",
942 "test desc",
943 )
944 .expect("Should return result");
945 assert_eq!(result.status, "error");
946 assert!(result.error.is_some());
947 assert!(result.error.unwrap().contains("already initialized"));
948
949 let result = perform_init(
951 project_path,
952 true,
953 true,
954 "test-project",
955 "0.1.0",
956 "test desc",
957 )
958 .expect("Force init should succeed");
959 assert_eq!(result.status, "success");
960 assert!(
961 result.files_overwritten.is_some(),
962 "Should have overwritten files"
963 );
964
965 let restored_content = fs::read_to_string(&toml_path).expect("Failed to read ggen.toml");
967 assert_eq!(
968 restored_content, original_content,
969 "Content should be restored"
970 );
971 }
972
973 #[test]
974 fn test_transaction_receipt_tracking() {
975 let temp_dir = tempdir().expect("Failed to create temp dir");
976 let project_path = temp_dir.path().to_str().expect("Invalid path");
977
978 let result = perform_init(
979 project_path,
980 false,
981 true,
982 "test-project",
983 "0.1.0",
984 "test desc",
985 )
986 .expect("Init should succeed");
987
988 assert!(result.transaction.is_some());
990 let tx_info = result.transaction.unwrap();
991
992 assert_eq!(
994 tx_info.total_files,
995 result.files_created.len(),
996 "Transaction total_files should match files_created count"
997 );
998
999 assert_eq!(tx_info.backups_created, 0, "No backups on fresh init");
1001
1002 assert!(tx_info.committed, "Transaction should be committed");
1004 }
1005
1006 #[test]
1007 fn test_transaction_creates_backups_on_overwrite() {
1008 let temp_dir = tempdir().expect("Failed to create temp dir");
1009 let project_path = temp_dir.path().to_str().expect("Invalid path");
1010
1011 perform_init(
1013 project_path,
1014 false,
1015 true,
1016 "test-project",
1017 "0.1.0",
1018 "test desc",
1019 )
1020 .expect("First init should succeed");
1021
1022 let result = perform_init(
1024 project_path,
1025 true,
1026 true,
1027 "test-project",
1028 "0.1.0",
1029 "test desc",
1030 )
1031 .expect("Force init should succeed");
1032
1033 assert!(result.transaction.is_some());
1035 let tx_info = result.transaction.unwrap();
1036 assert!(
1037 tx_info.backups_created > 0,
1038 "Should have created backups on overwrite"
1039 );
1040 }
1041
1042 #[test]
1043 fn test_init_creates_all_required_directories() {
1044 let temp_dir = tempdir().expect("Failed to create temp dir");
1045 let project_path = temp_dir.path().to_str().expect("Invalid path");
1046 let base = PathBuf::from(project_path);
1047
1048 let result = perform_init(
1049 project_path,
1050 false,
1051 true,
1052 "test-project",
1053 "0.1.0",
1054 "test desc",
1055 )
1056 .expect("Init should succeed");
1057
1058 assert!(base.join("schema").is_dir(), "schema/ should exist");
1060 assert!(base.join("templates").is_dir(), "templates/ should exist");
1061 assert!(base.join("scripts").is_dir(), "scripts/ should exist");
1062
1063 let dirs = &result.directories_created;
1065 assert!(
1066 dirs.contains(&"schema".to_string()),
1067 "Should report schema/ created"
1068 );
1069 assert!(
1070 dirs.contains(&"templates".to_string()),
1071 "Should report templates/ created"
1072 );
1073 assert!(
1074 dirs.contains(&"scripts".to_string()),
1075 "Should report scripts/ created"
1076 );
1077 }
1078
1079 #[test]
1080 #[cfg(unix)]
1081 fn test_startup_sh_is_executable() {
1082 let temp_dir = tempdir().expect("Failed to create temp dir");
1083 let project_path = temp_dir.path().to_str().expect("Invalid path");
1084 let base = PathBuf::from(project_path);
1085
1086 perform_init(
1087 project_path,
1088 false,
1089 true,
1090 "test-project",
1091 "0.1.0",
1092 "test desc",
1093 )
1094 .expect("Init should succeed");
1095
1096 let startup_path = base.join("scripts/startup.sh");
1098 let metadata = fs::metadata(&startup_path).expect("Failed to get startup.sh metadata");
1099
1100 use std::os::unix::fs::PermissionsExt;
1101 let mode = metadata.permissions().mode();
1102 assert!(mode & 0o111 != 0, "startup.sh should be executable");
1103 }
1104
1105 #[test]
1106 fn test_init_output_structure() {
1107 let temp_dir = tempdir().expect("Failed to create temp dir");
1108 let project_path = temp_dir.path().to_str().expect("Invalid path");
1109
1110 let result = perform_init(
1111 project_path,
1112 false,
1113 true,
1114 "test-project",
1115 "0.1.0",
1116 "test desc",
1117 )
1118 .expect("Init should succeed");
1119
1120 assert_eq!(result.status, "success");
1122 assert_eq!(result.project_dir, project_path);
1123 assert!(!result.files_created.is_empty());
1124 assert!(result.error.is_none());
1125 assert!(!result.next_steps.is_empty());
1126 assert!(result.transaction.is_some());
1127
1128 let json = serde_json::to_string(&result).expect("Should serialize to JSON");
1130 assert!(json.contains("\"status\":\"success\""));
1131 assert!(json.contains("\"transaction\""));
1132 }
1133}