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>, name: Option<String>,
441    version: Option<String>, description: Option<String>,
442) -> VerbResult<InitOutput> {
443    // Thin CLI layer: parse arguments and delegate to helper
444    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    // Prepare template variables
449    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    // Delegate to initialization logic
455    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
466/// Helper function that performs the actual initialization.
467///
468/// This function contains all the file I/O and directory creation logic,
469/// keeping the verb function thin and following the CLI architecture pattern.
470///
471/// ## Atomic Initialization Strategy
472///
473/// 1. Pre-flight checks (directory exists, artifacts present, permissions)
474/// 2. Create FileTransaction for atomic file operations
475/// 3. Create directories (tracked separately, not part of transaction)
476/// 4. Write all files via transaction (automatic backup of existing files)
477/// 5. Set permissions on startup.sh
478/// 6. Commit transaction (point of no return)
479/// 7. Build InitOutput from TransactionReceipt
480///
481/// Any error before commit triggers automatic rollback via Drop trait.
482fn perform_init(
483    project_dir: &str, force: bool, skip_hooks: bool, name: &str, version: &str, description: &str,
484) -> std::result::Result<InitOutput, GgenError> {
485    // Convert to Path for easier manipulation
486    let base_path = Path::new(project_dir);
487
488    // List of ggen-specific artifacts to check for
489    let ggen_artifacts = [
490        "ggen.toml",
491        "Makefile",
492        "schema/domain.ttl",
493        "scripts/startup.sh",
494    ];
495
496    // Check if ggen has already been initialized in this directory
497    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    // Ensure base directory exists
521    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    // Pre-flight validation: Check disk space and basic environment
538    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    // Validate write permissions early - try creating a temp file
559    let temp_test = base_path.join(".ggen_write_test");
560    match fs::write(&temp_test, "") {
561        Ok(()) => {
562            let _ = fs::remove_file(&temp_test); // Clean up test file
563        }
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    // Create FileTransaction for atomic file operations
584    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    // Create directories - tracked separately (not part of transaction)
592    // These won't be automatically rolled back, but that's acceptable for empty directories
593    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    // Check which conditional files to preserve
606    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    // Write all files via transaction
619    // Any error here will trigger automatic rollback via Drop
620
621    // Create ggen.toml
622    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    // Create schema/domain.ttl
638    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    // Create Makefile
644    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    // Create example template (templates/example.txt.tera)
649    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    // Create scripts/startup.sh
656    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    // Create .gitignore (only if it doesn't exist - preserve user's gitignore)
662    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    // Create README.md (only if it doesn't exist - preserve user's README)
669    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    // Set executable permissions on startup.sh before commit
707    #[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    // Commit transaction - this is the point of no return
721    // After this, all changes are permanent and rollback is disabled
722    let receipt = tx.commit().map_err(|e| {
723        GgenError::CommandError(format!("Failed to commit file transaction: {}", e))
724    })?;
725
726    // Install git hooks after successful file creation
727    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
728
729    // Build InitOutput from TransactionReceipt
730    let files_created: Vec<String> = receipt
731        .files_created
732        .iter()
733        .filter_map(|p| {
734            // Convert absolute paths to relative paths for display
735            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    // Build warning message with details about what happened
764    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        // Verify success status
823        assert_eq!(result.status, "success");
824        assert!(result.error.is_none());
825
826        // Verify transaction info
827        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        // Verify files were created
833        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        // Verify actual files exist on disk
843        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        // Create existing .gitignore and README.md with custom content
867        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        // Verify files were preserved
884        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        // Verify original content is intact
899        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        // First init
920        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        // Modify a file
931        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        // Second init without force should fail
936        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        // Second init with force should succeed
950        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        // Verify original content was restored
966        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        // Verify transaction info is present and accurate
989        assert!(result.transaction.is_some());
990        let tx_info = result.transaction.unwrap();
991
992        // Total files should match created files count
993        assert_eq!(
994            tx_info.total_files,
995            result.files_created.len(),
996            "Transaction total_files should match files_created count"
997        );
998
999        // No backups should be created on fresh init
1000        assert_eq!(tx_info.backups_created, 0, "No backups on fresh init");
1001
1002        // Transaction should be committed
1003        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        // First init
1012        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        // Force re-init
1023        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        // Verify backups were created
1034        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        // Verify all required directories exist
1059        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        // Verify directories_created list
1064        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        // Verify startup.sh has executable permissions
1097        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        // Verify InitOutput structure
1121        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        // Verify serialization works (for JSON output)
1129        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}