Skip to main content

ggen_cli_lib/cmds/
init.rs

1//! Init Command - Initialize a new ggen project
2//!
3//! `ggen init` scaffolds a minimal, working ggen project with hardcoded files.
4//! Creates directory structure and seed files that can be customized.
5//!
6//! ## BIG BANG 80/20 Screening Gate
7//!
8//! Before initialization, users must confirm they have:
9//! 1. **Real user data** (CSV/JSON, not promised)
10//! 2. **Found an existing standard ontology** (schema.org, FOAF, Dublin Core, SKOS)
11//! 3. **Clear problem articulation** (one sentence, no 100-page docs)
12//! 4. **Market signal** (email list, beta users, commitment - not enthusiasm)
13//! 5. **Speed validation plan** (can validate with 10 users in 48 hours)
14//!
15//! This prevents Seth-like patterns: custom ontologies, 3-month research cycles,
16//! zero market validation.
17//!
18//! ## Atomic Initialization
19//!
20//! Uses FileTransaction for atomic file operations with automatic rollback on failure.
21//! Either all files are created successfully, or no changes are made.
22
23#![allow(clippy::unused_unit)] // clap-noun-verb macro generates this
24
25use 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// ============================================================================
36// Output Types
37// ============================================================================
38
39/// Output for the `ggen init` command
40#[derive(Debug, Clone, Serialize)]
41pub struct InitOutput {
42    /// Overall status: "success" or "partial" or "error"
43    pub status: String,
44
45    /// Project directory initialized
46    pub project_dir: String,
47
48    /// Files created (new files)
49    pub files_created: Vec<String>,
50
51    /// Files overwritten (replaced existing files)
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub files_overwritten: Option<Vec<String>>,
54
55    /// Files preserved (user files not touched)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub files_preserved: Option<Vec<String>>,
58
59    /// Directories created
60    pub directories_created: Vec<String>,
61
62    /// Error message (if failed)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub error: Option<String>,
65
66    /// Warning message (if partial success)
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub warning: Option<String>,
69
70    /// Next steps
71    pub next_steps: Vec<String>,
72
73    /// Transaction details (if atomic operation succeeded)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub transaction: Option<TransactionInfo>,
76
77    /// Git hooks installation result
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub git_hooks: Option<super::git_hooks::HooksInstallOutput>,
80}
81
82/// Transaction information for atomic init operation
83#[derive(Debug, Clone, Serialize)]
84pub struct TransactionInfo {
85    /// Total files affected by transaction
86    pub total_files: usize,
87
88    /// Number of backups created
89    pub backups_created: usize,
90
91    /// Whether transaction was committed successfully
92    pub committed: bool,
93}
94
95// ============================================================================
96// Hardcoded File Contents
97// ============================================================================
98
99const 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// ============================================================================
396// The Init Command
397// ============================================================================
398
399/// Initialize a new ggen project with default structure and scripts.
400///
401/// Creates a minimal, working ggen project scaffold with:
402/// - ggen.toml configuration
403/// - schema/domain.ttl (RDF ontology with example)
404/// - Makefile (setup, build, clean targets)
405/// - scripts/startup.sh (project initialization script)
406/// - templates/ (empty, ready for custom Tera templates)
407/// - output directory (current directory by default)
408///
409/// ## Usage
410///
411/// ```bash
412/// # Initialize in current directory
413/// ggen init
414///
415/// # Initialize in specific directory
416/// ggen init --path my-project
417/// ```
418///
419/// ## Flags
420///
421/// --path PATH               Project directory (default: current directory)
422/// --force                   Overwrite existing files
423/// --skip-hooks              Skip git hooks installation
424///
425/// ## Output
426///
427/// Returns JSON with created files and next steps.
428///
429/// ## Next Steps
430///
431/// After initialization:
432/// 1. Run `make setup` to prepare your environment
433/// 2. Edit schema/domain.ttl with your domain model
434/// 3. Create Tera templates in templates/ for your target languages
435/// 4. Run `make build` to generate code from your ontology
436///
437#[allow(clippy::unused_unit)]
438#[verb("init", "root")]
439pub fn init(
440    path: Option<String>, force: Option<bool>, skip_hooks: Option<bool>,
441) -> VerbResult<InitOutput> {
442    // Thin CLI layer: parse arguments and delegate to helper
443    let project_dir = path.unwrap_or_else(|| ".".to_string());
444    let force = force.unwrap_or(false);
445    let skip_hooks = skip_hooks.unwrap_or(false);
446
447    // Delegate to initialization logic
448    perform_init(&project_dir, force, skip_hooks).map_err(|e| e.into())
449}
450
451/// Helper function that performs the actual initialization.
452///
453/// This function contains all the file I/O and directory creation logic,
454/// keeping the verb function thin and following the CLI architecture pattern.
455///
456/// ## Atomic Initialization Strategy
457///
458/// 1. Pre-flight checks (directory exists, artifacts present, permissions)
459/// 2. Create FileTransaction for atomic file operations
460/// 3. Create directories (tracked separately, not part of transaction)
461/// 4. Write all files via transaction (automatic backup of existing files)
462/// 5. Set permissions on startup.sh
463/// 6. Commit transaction (point of no return)
464/// 7. Build InitOutput from TransactionReceipt
465///
466/// Any error before commit triggers automatic rollback via Drop trait.
467fn perform_init(
468    project_dir: &str, force: bool, skip_hooks: bool,
469) -> std::result::Result<InitOutput, GgenError> {
470    // Convert to Path for easier manipulation
471    let base_path = Path::new(project_dir);
472
473    // List of ggen-specific artifacts to check for
474    let ggen_artifacts = [
475        "ggen.toml",
476        "Makefile",
477        "schema/domain.ttl",
478        "scripts/startup.sh",
479    ];
480
481    // Check if ggen has already been initialized in this directory
482    let has_ggen_artifacts = ggen_artifacts.iter().any(|artifact| {
483        let path = base_path.join(artifact);
484        path.exists()
485    });
486
487    if has_ggen_artifacts && !force {
488        return Ok(InitOutput {
489            status: "error".to_string(),
490            project_dir: project_dir.to_string(),
491            files_created: vec![],
492            files_overwritten: None,
493            files_preserved: None,
494            directories_created: vec![],
495            error: Some(
496                "ggen project already initialized here. Use --force to reinitialize.".to_string(),
497            ),
498            warning: None,
499            next_steps: vec!["Run 'make build' to regenerate code".to_string()],
500            transaction: None,
501            git_hooks: None,
502        });
503    }
504
505    // Ensure base directory exists
506    if let Err(e) = fs::create_dir_all(base_path) {
507        return Ok(InitOutput {
508            status: "error".to_string(),
509            project_dir: project_dir.to_string(),
510            files_created: vec![],
511            files_overwritten: None,
512            files_preserved: None,
513            directories_created: vec![],
514            error: Some(format!("Failed to create project directory: {}", e)),
515            warning: None,
516            next_steps: vec![],
517            transaction: None,
518            git_hooks: None,
519        });
520    }
521
522    // Pre-flight validation: Check disk space and basic environment
523    let preflight = ggen_core::validation::PreFlightValidator::for_init(base_path);
524    if let Err(e) = preflight.validate(None) {
525        return Ok(InitOutput {
526            status: "error".to_string(),
527            project_dir: project_dir.to_string(),
528            files_created: vec![],
529            files_overwritten: None,
530            files_preserved: None,
531            directories_created: vec![],
532            error: Some(format!("{}", e)),
533            warning: None,
534            next_steps: vec![
535                "Ensure you have at least 100MB of free disk space".to_string(),
536                "Verify write permissions to the target directory".to_string(),
537            ],
538            transaction: None,
539            git_hooks: None,
540        });
541    }
542
543    // Validate write permissions early - try creating a temp file
544    let temp_test = base_path.join(".ggen_write_test");
545    match fs::write(&temp_test, "") {
546        Ok(_) => {
547            let _ = fs::remove_file(&temp_test); // Clean up test file
548        }
549        Err(e) => {
550            return Ok(InitOutput {
551                status: "error".to_string(),
552                project_dir: project_dir.to_string(),
553                files_created: vec![],
554                files_overwritten: None,
555                files_preserved: None,
556                directories_created: vec![],
557                error: Some(format!("No write permission in project directory: {}", e)),
558                warning: None,
559                next_steps: vec![
560                    "Check directory permissions or try a different location".to_string()
561                ],
562                transaction: None,
563                git_hooks: None,
564            });
565        }
566    }
567
568    // Create FileTransaction for atomic file operations
569    let mut tx = FileTransaction::new().map_err(|e| {
570        GgenError::CommandError(format!("Failed to initialize file transaction: {}", e))
571    })?;
572
573    let mut directories_created = vec![];
574    let mut files_preserved = vec![];
575
576    // Create directories - tracked separately (not part of transaction)
577    // These won't be automatically rolled back, but that's acceptable for empty directories
578    let dirs = vec!["schema", "templates", "scripts"];
579    for dir in &dirs {
580        let dir_path = base_path.join(dir);
581        let existed = dir_path.exists();
582        fs::create_dir_all(&dir_path).map_err(|e| {
583            GgenError::CommandError(format!("Failed to create directory {}: {}", dir, e))
584        })?;
585        if !existed {
586            directories_created.push(dir.to_string());
587        }
588    }
589
590    // Check which conditional files to preserve
591    let gitignore_path = base_path.join(".gitignore");
592    let gitignore_exists = gitignore_path.exists();
593    if gitignore_exists {
594        files_preserved.push(".gitignore".to_string());
595    }
596
597    let readme_path = base_path.join("README.md");
598    let readme_exists = readme_path.exists();
599    if readme_exists {
600        files_preserved.push("README.md".to_string());
601    }
602
603    // Write all files via transaction
604    // Any error here will trigger automatic rollback via Drop
605
606    // Create ggen.toml
607    let toml_path = base_path.join("ggen.toml");
608    tx.write_file(&toml_path, GGEN_TOML)
609        .map_err(|e| GgenError::CommandError(format!("Failed to write ggen.toml: {}", e)))?;
610
611    // Create schema/domain.ttl
612    let schema_path = base_path.join("schema").join("domain.ttl");
613    tx.write_file(&schema_path, DOMAIN_TTL).map_err(|e| {
614        GgenError::CommandError(format!("Failed to write schema/domain.ttl: {}", e))
615    })?;
616
617    // Create Makefile
618    let makefile_path = base_path.join("Makefile");
619    tx.write_file(&makefile_path, MAKEFILE)
620        .map_err(|e| GgenError::CommandError(format!("Failed to write Makefile: {}", e)))?;
621
622    // Create example template (templates/example.txt.tera)
623    let template_path = base_path.join("templates").join("example.txt.tera");
624    tx.write_file(&template_path, EXAMPLE_TEMPLATE)
625        .map_err(|e| {
626            GgenError::CommandError(format!("Failed to write templates/example.txt.tera: {}", e))
627        })?;
628
629    // Create scripts/startup.sh
630    let startup_sh_path = base_path.join("scripts").join("startup.sh");
631    tx.write_file(&startup_sh_path, STARTUP_SH).map_err(|e| {
632        GgenError::CommandError(format!("Failed to write scripts/startup.sh: {}", e))
633    })?;
634
635    // Create .gitignore (only if it doesn't exist - preserve user's gitignore)
636    if !gitignore_exists {
637        let gitignore_content = "# ggen outputs\n.ggen/\n";
638        tx.write_file(&gitignore_path, gitignore_content)
639            .map_err(|e| GgenError::CommandError(format!("Failed to write .gitignore: {}", e)))?;
640    }
641
642    // Create README.md (only if it doesn't exist - preserve user's README)
643    let readme_content = r#"# My ggen Project
644
645Generated by `ggen init`.
646
647## Getting Started
648
6491. **Initialize project**: Run `make setup` to prepare your environment
6502. **Define schema**: Edit `schema/domain.ttl` with your domain model
6513. **Create templates**: Add Tera templates in `templates/` for your target languages
6524. **Generate code**: Run `make build` to generate code from your ontology
653
654## MCP/A2A Configuration
655
656This project includes MCP (Model Context Protocol) and A2A (Agent-to-Agent) configuration files:
657
658- `.mcp.json` - MCP server configuration
659- `a2a.toml` - A2A agent configuration
660
661To reinitialize these configs:
662```bash
663ggen mcp init-config --force
664```
665
666To validate configuration:
667```bash
668ggen mcp validate-config
669```
670
671To set up Claude Desktop integration:
672```bash
673ggen mcp setup
674```"#;
675    if !readme_exists {
676        tx.write_file(&readme_path, readme_content)
677            .map_err(|e| GgenError::CommandError(format!("Failed to write README.md: {}", e)))?;
678    }
679
680    // Set executable permissions on startup.sh before commit
681    #[cfg(unix)]
682    {
683        use std::os::unix::fs::PermissionsExt;
684        fs::set_permissions(&startup_sh_path, std::fs::Permissions::from_mode(0o755)).map_err(
685            |e| {
686                GgenError::CommandError(format!(
687                    "Failed to set execute permissions on startup.sh: {}",
688                    e
689                ))
690            },
691        )?;
692    }
693
694    // Commit transaction - this is the point of no return
695    // After this, all changes are permanent and rollback is disabled
696    let receipt = tx.commit().map_err(|e| {
697        GgenError::CommandError(format!("Failed to commit file transaction: {}", e))
698    })?;
699
700    // Install git hooks after successful file creation
701    let git_hooks_result = super::git_hooks::install_git_hooks(base_path, skip_hooks).ok(); // Convert to Option, don't fail init if hooks fail
702
703    // Build InitOutput from TransactionReceipt
704    let files_created: Vec<String> = receipt
705        .files_created
706        .iter()
707        .filter_map(|p| {
708            // Convert absolute paths to relative paths for display
709            p.strip_prefix(base_path)
710                .ok()
711                .map(|rel| rel.display().to_string())
712        })
713        .collect();
714
715    let files_modified: Vec<String> = receipt
716        .files_modified
717        .iter()
718        .filter_map(|p| {
719            p.strip_prefix(base_path)
720                .ok()
721                .map(|rel| rel.display().to_string())
722        })
723        .collect();
724
725    let files_overwritten_opt = if files_modified.is_empty() {
726        None
727    } else {
728        Some(files_modified.clone())
729    };
730
731    let files_preserved_opt = if files_preserved.is_empty() {
732        None
733    } else {
734        Some(files_preserved.clone())
735    };
736
737    // Build warning message with details about what happened
738    let warning = if !files_modified.is_empty() || !files_preserved.is_empty() {
739        let mut msgs = vec![];
740        if !files_modified.is_empty() {
741            msgs.push(format!("Overwrote {} file(s)", files_modified.len()));
742        }
743        if !files_preserved.is_empty() {
744            msgs.push(format!("Preserved {} user file(s)", files_preserved.len()));
745        }
746        Some(msgs.join("; ") + ".")
747    } else {
748        None
749    };
750
751    Ok(InitOutput {
752        status: "success".to_string(),
753        project_dir: project_dir.to_string(),
754        files_created,
755        files_overwritten: files_overwritten_opt,
756        files_preserved: files_preserved_opt,
757        directories_created,
758        error: None,
759        warning,
760        next_steps: vec![
761            "Run 'make setup' to initialize your project".to_string(),
762            "Edit schema/domain.ttl to define your domain model".to_string(),
763            "Create Tera templates in templates/ for your target languages".to_string(),
764            "Run 'make build' to generate code from your ontology".to_string(),
765        ],
766        transaction: Some(TransactionInfo {
767            total_files: receipt.total_files(),
768            backups_created: receipt.backups.len(),
769            committed: true,
770        }),
771        git_hooks: git_hooks_result,
772    })
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use std::path::PathBuf;
779    use tempfile::tempdir;
780
781    #[test]
782    fn test_atomic_init_success() {
783        let temp_dir = tempdir().expect("Failed to create temp dir");
784        let project_path = temp_dir.path().to_str().expect("Invalid path");
785
786        let result = perform_init(project_path, false, true).expect("Init should succeed");
787
788        // Verify success status
789        assert_eq!(result.status, "success");
790        assert!(result.error.is_none());
791
792        // Verify transaction info
793        assert!(result.transaction.is_some());
794        let tx_info = result.transaction.unwrap();
795        assert!(tx_info.total_files > 0, "Should have created files");
796        assert!(tx_info.committed, "Transaction should be committed");
797
798        // Verify files were created
799        assert!(
800            !result.files_created.is_empty(),
801            "Should have created files"
802        );
803        assert!(
804            result.directories_created.len() >= 3,
805            "Should have created at least 3 directories"
806        );
807
808        // Verify actual files exist on disk
809        let base = PathBuf::from(project_path);
810        assert!(base.join("ggen.toml").exists(), "ggen.toml should exist");
811        assert!(
812            base.join("schema/domain.ttl").exists(),
813            "domain.ttl should exist"
814        );
815        assert!(base.join("Makefile").exists(), "Makefile should exist");
816        assert!(
817            base.join("scripts/startup.sh").exists(),
818            "startup.sh should exist"
819        );
820        assert!(
821            base.join("templates/example.txt.tera").exists(),
822            "example.txt.tera should exist"
823        );
824    }
825
826    #[test]
827    fn test_init_preserves_existing_files() {
828        let temp_dir = tempdir().expect("Failed to create temp dir");
829        let project_path = temp_dir.path().to_str().expect("Invalid path");
830        let base = PathBuf::from(project_path);
831
832        // Create existing .gitignore and README.md with custom content
833        fs::create_dir_all(&base).expect("Failed to create base dir");
834        let gitignore_content = "# Custom gitignore\n*.log\n";
835        let readme_content = "# Custom README\n\nMy project\n";
836        fs::write(base.join(".gitignore"), gitignore_content).expect("Failed to write .gitignore");
837        fs::write(base.join("README.md"), readme_content).expect("Failed to write README.md");
838
839        let result = perform_init(project_path, false, true).expect("Init should succeed");
840
841        // Verify files were preserved
842        assert!(
843            result.files_preserved.is_some(),
844            "Should have preserved files"
845        );
846        let preserved = result.files_preserved.unwrap();
847        assert!(
848            preserved.contains(&".gitignore".to_string()),
849            "Should preserve .gitignore"
850        );
851        assert!(
852            preserved.contains(&"README.md".to_string()),
853            "Should preserve README.md"
854        );
855
856        // Verify original content is intact
857        let gitignore_after =
858            fs::read_to_string(base.join(".gitignore")).expect("Failed to read .gitignore");
859        let readme_after =
860            fs::read_to_string(base.join("README.md")).expect("Failed to read README.md");
861        assert_eq!(
862            gitignore_after, gitignore_content,
863            ".gitignore should be unchanged"
864        );
865        assert_eq!(
866            readme_after, readme_content,
867            "README.md should be unchanged"
868        );
869    }
870
871    #[test]
872    fn test_init_force_overwrites_files() {
873        let temp_dir = tempdir().expect("Failed to create temp dir");
874        let project_path = temp_dir.path().to_str().expect("Invalid path");
875        let base = PathBuf::from(project_path);
876
877        // First init
878        perform_init(project_path, false, true).expect("First init should succeed");
879
880        // Modify a file
881        let toml_path = base.join("ggen.toml");
882        let original_content = fs::read_to_string(&toml_path).expect("Failed to read ggen.toml");
883        fs::write(&toml_path, "# Modified\n").expect("Failed to modify ggen.toml");
884
885        // Second init without force should fail
886        let result = perform_init(project_path, false, true).expect("Should return result");
887        assert_eq!(result.status, "error");
888        assert!(result.error.is_some());
889        assert!(result.error.unwrap().contains("already initialized"));
890
891        // Second init with force should succeed
892        let result = perform_init(project_path, true, true).expect("Force init should succeed");
893        assert_eq!(result.status, "success");
894        assert!(
895            result.files_overwritten.is_some(),
896            "Should have overwritten files"
897        );
898
899        // Verify original content was restored
900        let restored_content = fs::read_to_string(&toml_path).expect("Failed to read ggen.toml");
901        assert_eq!(
902            restored_content, original_content,
903            "Content should be restored"
904        );
905    }
906
907    #[test]
908    fn test_transaction_receipt_tracking() {
909        let temp_dir = tempdir().expect("Failed to create temp dir");
910        let project_path = temp_dir.path().to_str().expect("Invalid path");
911
912        let result = perform_init(project_path, false, true).expect("Init should succeed");
913
914        // Verify transaction info is present and accurate
915        assert!(result.transaction.is_some());
916        let tx_info = result.transaction.unwrap();
917
918        // Total files should match created files count
919        assert_eq!(
920            tx_info.total_files,
921            result.files_created.len(),
922            "Transaction total_files should match files_created count"
923        );
924
925        // No backups should be created on fresh init
926        assert_eq!(tx_info.backups_created, 0, "No backups on fresh init");
927
928        // Transaction should be committed
929        assert!(tx_info.committed, "Transaction should be committed");
930    }
931
932    #[test]
933    fn test_transaction_creates_backups_on_overwrite() {
934        let temp_dir = tempdir().expect("Failed to create temp dir");
935        let project_path = temp_dir.path().to_str().expect("Invalid path");
936
937        // First init
938        perform_init(project_path, false, true).expect("First init should succeed");
939
940        // Force re-init
941        let result = perform_init(project_path, true, true).expect("Force init should succeed");
942
943        // Verify backups were created
944        assert!(result.transaction.is_some());
945        let tx_info = result.transaction.unwrap();
946        assert!(
947            tx_info.backups_created > 0,
948            "Should have created backups on overwrite"
949        );
950    }
951
952    #[test]
953    fn test_init_creates_all_required_directories() {
954        let temp_dir = tempdir().expect("Failed to create temp dir");
955        let project_path = temp_dir.path().to_str().expect("Invalid path");
956        let base = PathBuf::from(project_path);
957
958        let result = perform_init(project_path, false, true).expect("Init should succeed");
959
960        // Verify all required directories exist
961        assert!(base.join("schema").is_dir(), "schema/ should exist");
962        assert!(base.join("templates").is_dir(), "templates/ should exist");
963        assert!(base.join("scripts").is_dir(), "scripts/ should exist");
964
965        // Verify directories_created list
966        let dirs = &result.directories_created;
967        assert!(
968            dirs.contains(&"schema".to_string()),
969            "Should report schema/ created"
970        );
971        assert!(
972            dirs.contains(&"templates".to_string()),
973            "Should report templates/ created"
974        );
975        assert!(
976            dirs.contains(&"scripts".to_string()),
977            "Should report scripts/ created"
978        );
979    }
980
981    #[test]
982    #[cfg(unix)]
983    fn test_startup_sh_is_executable() {
984        let temp_dir = tempdir().expect("Failed to create temp dir");
985        let project_path = temp_dir.path().to_str().expect("Invalid path");
986        let base = PathBuf::from(project_path);
987
988        perform_init(project_path, false, true).expect("Init should succeed");
989
990        // Verify startup.sh has executable permissions
991        let startup_path = base.join("scripts/startup.sh");
992        let metadata = fs::metadata(&startup_path).expect("Failed to get startup.sh metadata");
993
994        use std::os::unix::fs::PermissionsExt;
995        let mode = metadata.permissions().mode();
996        assert!(mode & 0o111 != 0, "startup.sh should be executable");
997    }
998
999    #[test]
1000    fn test_init_output_structure() {
1001        let temp_dir = tempdir().expect("Failed to create temp dir");
1002        let project_path = temp_dir.path().to_str().expect("Invalid path");
1003
1004        let result = perform_init(project_path, false, true).expect("Init should succeed");
1005
1006        // Verify InitOutput structure
1007        assert_eq!(result.status, "success");
1008        assert_eq!(result.project_dir, project_path);
1009        assert!(!result.files_created.is_empty());
1010        assert!(result.error.is_none());
1011        assert!(!result.next_steps.is_empty());
1012        assert!(result.transaction.is_some());
1013
1014        // Verify serialization works (for JSON output)
1015        let json = serde_json::to_string(&result).expect("Should serialize to JSON");
1016        assert!(json.contains("\"status\":\"success\""));
1017        assert!(json.contains("\"transaction\""));
1018    }
1019}