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#![allow(clippy::unused_unit)] // clap-noun-verb macro generates this
7
8use clap_noun_verb_macros::verb;
9use serde::Serialize;
10use std::fs;
11use std::path::Path;
12
13// ============================================================================
14// Output Types
15// ============================================================================
16
17/// Output for the `ggen init` command
18#[derive(Debug, Clone, Serialize)]
19pub struct InitOutput {
20    /// Overall status: "success" or "partial" or "error"
21    pub status: String,
22
23    /// Project directory initialized
24    pub project_dir: String,
25
26    /// Files created (new files)
27    pub files_created: Vec<String>,
28
29    /// Files overwritten (replaced existing files)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub files_overwritten: Option<Vec<String>>,
32
33    /// Files preserved (user files not touched)
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub files_preserved: Option<Vec<String>>,
36
37    /// Directories created
38    pub directories_created: Vec<String>,
39
40    /// Error message (if failed)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub error: Option<String>,
43
44    /// Warning message (if partial success)
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub warning: Option<String>,
47
48    /// Next steps
49    pub next_steps: Vec<String>,
50}
51
52// ============================================================================
53// Hardcoded File Contents
54// ============================================================================
55
56const GGEN_TOML: &str = r#"[project]
57name = "my-ggen-project"
58version = "0.1.0"
59description = "A ggen project initialized with default templates"
60authors = ["ggen init"]
61license = "MIT"
62
63[generation]
64ontology_dir = "schema/"
65templates_dir = "templates/"
66output_dir = "src/generated/"
67incremental = true
68overwrite = false
69
70[sync]
71enabled = true
72on_change = "manual"
73validate_after = true
74conflict_mode = "fail"
75
76[rdf]
77formats = ["turtle"]
78default_format = "turtle"
79strict_validation = false
80
81[templates]
82enable_caching = true
83auto_reload = true
84
85[output]
86formatting = "default"
87line_length = 100
88indent = 2
89"#;
90
91const DOMAIN_TTL: &str = r#"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
92@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
93@prefix ex: <https://example.com/> .
94
95# Example Person class
96ex:Person a rdfs:Class ;
97    rdfs:label "Person" ;
98    rdfs:comment "Represents a person in the system" .
99
100# Example properties for Person
101ex:name a rdf:Property ;
102    rdfs:domain ex:Person ;
103    rdfs:range rdfs:Literal ;
104    rdfs:label "name" ;
105    rdfs:comment "The name of the person" .
106
107ex:email a rdf:Property ;
108    rdfs:domain ex:Person ;
109    rdfs:range rdfs:Literal ;
110    rdfs:label "email" ;
111    rdfs:comment "The email address of the person" .
112
113ex:age a rdf:Property ;
114    rdfs:domain ex:Person ;
115    rdfs:range rdfs:Literal ;
116    rdfs:label "age" ;
117    rdfs:comment "The age of the person" .
118"#;
119
120const MAKEFILE: &str = r#".PHONY: help setup build clean
121
122help:
123	@echo "ggen project - Available targets:"
124	@echo "  make setup   - Run startup.sh to initialize project"
125	@echo "  make build   - Generate code from ontology (ggen sync)"
126	@echo "  make clean   - Remove generated artifacts"
127	@echo ""
128	@echo "See scripts/startup.sh for custom initialization steps"
129
130setup:
131	@bash scripts/startup.sh
132
133build:
134	ggen sync
135
136clean:
137	rm -rf src/generated/
138	rm -rf .ggen/
139"#;
140
141const STARTUP_SH: &str = r#"#!/bin/bash
142# Startup script for ggen project initialization
143# Run this to prepare your environment for development
144
145set -e
146
147echo "🚀 Initializing ggen project..."
148
149# Create directories if they don't exist
150mkdir -p schema
151mkdir -p templates
152mkdir -p src/generated
153mkdir -p scripts
154
155echo "✅ Project structure created"
156
157# Display next steps
158echo ""
159echo "📋 Next steps:"
160echo "  1. Edit schema/domain.ttl to define your domain model"
161echo "  2. Create Tera templates in templates/ for your target languages"
162echo "  3. Run 'ggen sync' to generate code from your ontology"
163echo "  4. Run 'make build' to regenerate after changes"
164echo ""
165echo "📚 Learn more:"
166echo "  - ggen Documentation: https://docs.ggen.io"
167echo "  - RDF/Turtle Syntax: https://www.w3.org/TR/turtle/"
168echo "  - Tera Template Language: https://keats.github.io/tera/"
169"#;
170
171// ============================================================================
172// The Init Command
173// ============================================================================
174
175/// Initialize a new ggen project with default structure and scripts.
176///
177/// Creates a minimal, working ggen project scaffold with:
178/// - ggen.toml configuration
179/// - schema/domain.ttl (RDF ontology with example)
180/// - Makefile (setup, build, clean targets)
181/// - scripts/startup.sh (project initialization script)
182/// - templates/ (empty, ready for custom Tera templates)
183/// - src/generated/ (output directory)
184///
185/// ## Usage
186///
187/// ```bash
188/// # Initialize in current directory
189/// ggen init
190///
191/// # Initialize in specific directory
192/// ggen init --path my-project
193/// ```
194///
195/// ## Flags
196///
197/// --path PATH               Project directory (default: current directory)
198/// --force                   Overwrite existing files
199///
200/// ## Output
201///
202/// Returns JSON with created files and next steps.
203///
204/// ## Next Steps
205///
206/// After initialization:
207/// 1. Run `make setup` to prepare your environment
208/// 2. Edit schema/domain.ttl with your domain model
209/// 3. Create Tera templates in templates/ for your target languages
210/// 4. Run `make build` to generate code from your ontology
211///
212#[allow(clippy::unused_unit)]
213#[verb("init", "root")]
214pub fn init(path: Option<String>, force: Option<bool>) -> clap_noun_verb::Result<InitOutput> {
215    // Thin CLI layer: parse arguments and delegate to helper
216    let project_dir = path.unwrap_or_else(|| ".".to_string());
217    let force = force.unwrap_or(false);
218
219    // Delegate to initialization logic
220    perform_init(&project_dir, force)
221}
222
223/// Helper function that performs the actual initialization.
224///
225/// This function contains all the file I/O and directory creation logic,
226/// keeping the verb function thin and following the CLI architecture pattern.
227///
228/// Strategy:
229/// 1. Check if ggen artifacts already exist (not just any directory content)
230/// 2. If they do and --force is not used, error
231/// 3. Track which files are created vs overwritten
232/// 4. Report both clearly to user
233fn perform_init(
234    project_dir: &str,
235    force: bool,
236) -> clap_noun_verb::Result<InitOutput> {
237    // Convert to Path for easier manipulation
238    let base_path = Path::new(project_dir);
239
240    // List of ggen-specific artifacts to check for
241    let ggen_artifacts = vec!["ggen.toml", "Makefile", "schema/domain.ttl", "scripts/startup.sh"];
242
243    // Check if ggen has already been initialized in this directory
244    let has_ggen_artifacts = ggen_artifacts.iter().any(|artifact| {
245        let path = base_path.join(artifact);
246        path.exists()
247    });
248
249    if has_ggen_artifacts && !force {
250        return Ok(InitOutput {
251            status: "error".to_string(),
252            project_dir: project_dir.to_string(),
253            files_created: vec![],
254            files_overwritten: None,
255            files_preserved: None,
256            directories_created: vec![],
257            error: Some(
258                "ggen project already initialized here. Use --force to reinitialize.".to_string(),
259            ),
260            warning: None,
261            next_steps: vec!["Run 'make build' to regenerate code".to_string()],
262        });
263    }
264
265    // Ensure base directory exists
266    if let Err(e) = fs::create_dir_all(base_path) {
267        return Ok(InitOutput {
268            status: "error".to_string(),
269            project_dir: project_dir.to_string(),
270            files_created: vec![],
271            files_overwritten: None,
272            files_preserved: None,
273            directories_created: vec![],
274            error: Some(format!("Failed to create project directory: {}", e)),
275            warning: None,
276            next_steps: vec![],
277        });
278    }
279
280    // Validate write permissions early - try creating a temp file
281    let temp_test = base_path.join(".ggen_write_test");
282    match fs::write(&temp_test, "") {
283        Ok(_) => {
284            let _ = fs::remove_file(&temp_test); // Clean up test file
285        }
286        Err(e) => {
287            return Ok(InitOutput {
288                status: "error".to_string(),
289                project_dir: project_dir.to_string(),
290                files_created: vec![],
291                files_overwritten: None,
292                files_preserved: None,
293                directories_created: vec![],
294                error: Some(format!(
295                    "No write permission in project directory: {}",
296                    e
297                )),
298                warning: None,
299                next_steps: vec!["Check directory permissions or try a different location"
300                    .to_string()],
301            });
302        }
303    }
304
305    let mut files_created = vec![];
306    let mut files_overwritten = vec![];
307    let mut files_preserved = vec![];
308    let mut directories_created = vec![];
309    let mut errors = vec![];
310
311    // Create directories - only track as "created" if they didn't exist
312    let dirs = vec!["schema", "templates", "src/generated", "scripts"];
313    for dir in &dirs {
314        let dir_path = base_path.join(dir);
315        let existed = dir_path.exists();
316        match fs::create_dir_all(&dir_path) {
317            Ok(_) => {
318                if !existed {
319                    directories_created.push(dir.to_string());
320                }
321            }
322            Err(e) => {
323                errors.push(format!("Failed to create directory {}: {}", dir, e));
324            }
325        }
326    }
327
328    // Helper closure to write file and track if it was created or overwritten
329    let mut write_file = |path: &Path, content: &str, filename: &str| {
330        let exists = path.exists();
331        match fs::write(path, content) {
332            Ok(_) => {
333                if exists {
334                    files_overwritten.push(filename.to_string());
335                } else {
336                    files_created.push(filename.to_string());
337                }
338            }
339            Err(e) => errors.push(format!("Failed to write {}: {}", filename, e)),
340        }
341    };
342
343    // Create ggen.toml
344    let toml_path = base_path.join("ggen.toml");
345    write_file(&toml_path, GGEN_TOML, "ggen.toml");
346
347    // Create schema/domain.ttl
348    let schema_path = base_path.join("schema").join("domain.ttl");
349    write_file(&schema_path, DOMAIN_TTL, "schema/domain.ttl");
350
351    // Create Makefile
352    let makefile_path = base_path.join("Makefile");
353    write_file(&makefile_path, MAKEFILE, "Makefile");
354
355    // Create scripts/startup.sh
356    let startup_sh_path = base_path.join("scripts").join("startup.sh");
357    write_file(&startup_sh_path, STARTUP_SH, "scripts/startup.sh");
358
359    // Create .gitignore (only if it doesn't exist - preserve user's gitignore)
360    let gitignore_path = base_path.join(".gitignore");
361    if !gitignore_path.exists() {
362        let gitignore_content = "# ggen outputs\nsrc/generated/\n.ggen/\n";
363        write_file(&gitignore_path, gitignore_content, ".gitignore");
364    } else {
365        files_preserved.push(".gitignore".to_string());
366    }
367
368    // Create README.md (only if it doesn't exist - preserve user's README)
369    let readme_path = base_path.join("README.md");
370    let readme_content = r#"# My ggen Project
371
372Generated by `ggen init`.
373
374## Getting Started
375
3761. **Initialize project**: Run `make setup` to prepare your environment
3772. **Define schema**: Edit `schema/domain.ttl` with your domain model
3783. **Create templates**: Add Tera templates in `templates/` for your target languages
3794. **Generate code**: Run `make build` to generate code from your ontology
380
381## Project Structure
382
383```
384.
385├── Makefile                     # Build targets (setup, build, clean)
386├── ggen.toml                    # ggen project configuration
387├── README.md                    # This file
388├── schema/
389│   └── domain.ttl               # RDF ontology (Turtle format)
390├── templates/                   # Tera templates for code generation
391│   └── (create your templates here)
392├── scripts/
393│   └── startup.sh               # Project initialization script
394└── src/
395    └── generated/               # Generated code output
396```
397
398## Makefile Targets
399
400```bash
401make help       # Show available targets
402make setup      # Run scripts/startup.sh to initialize project
403make build      # Generate code from ontology (ggen sync)
404make clean      # Remove generated artifacts
405```
406
407## Manual Commands
408
409```bash
410# Generate code from ontology
411ggen sync
412
413# Dry-run: preview changes without writing
414ggen sync --dry-run
415
416# Watch mode: regenerate on file changes
417ggen sync --watch
418
419# Validate without generating
420ggen sync --validate-only
421```
422
423## Adding Templates
424
425Create Tera templates in the `templates/` directory. Each template can generate code in your target language.
426
427Example template structure:
428```
429templates/
430├── rust.tera          # Rust code generator
431├── typescript.tera    # TypeScript code generator
432└── python.tera        # Python code generator
433```
434
435See `ggen.toml` to configure which templates to run and where they output.
436
437## Learn More
438
439- [ggen Documentation](https://docs.ggen.io)
440- [RDF/Turtle Syntax](https://www.w3.org/TR/turtle/)
441- [Tera Template Language](https://keats.github.io/tera/)
442- [Custom Startup Scripts](scripts/startup.sh)
443"#;
444    // Create README.md (only if it doesn't exist - preserve user's README)
445    if !readme_path.exists() {
446        write_file(&readme_path, readme_content, "README.md");
447    } else {
448        files_preserved.push("README.md".to_string());
449    }
450
451    // Drop the closure so we can borrow errors again
452    drop(write_file);
453
454    // Always ensure startup.sh is executable on Unix systems
455    // (important for both new files AND overwrites)
456    #[cfg(unix)]
457    {
458        use std::os::unix::fs::PermissionsExt;
459        if let Err(e) = fs::set_permissions(
460            &startup_sh_path,
461            std::fs::Permissions::from_mode(0o755),
462        ) {
463            errors.push(format!(
464                "Warning: Could not set execute permissions on startup.sh: {}",
465                e
466            ));
467        }
468    }
469
470    // Determine final status and warnings
471    let status = if errors.is_empty() {
472        "success".to_string()
473    } else {
474        "partial".to_string()
475    };
476
477    let error = if errors.is_empty() {
478        None
479    } else {
480        Some(errors.join("; "))
481    };
482
483    // Build warning message with details about what happened
484    let warning = if !files_overwritten.is_empty() || !files_preserved.is_empty() {
485        let mut msgs = vec![];
486        if !files_overwritten.is_empty() {
487            msgs.push(format!("Overwrote {} file(s)", files_overwritten.len()));
488        }
489        if !files_preserved.is_empty() {
490            msgs.push(format!("Preserved {} user file(s)", files_preserved.len()));
491        }
492        Some(msgs.join("; ") + ".")
493    } else {
494        None
495    };
496
497    let files_overwritten_opt = if files_overwritten.is_empty() {
498        None
499    } else {
500        Some(files_overwritten)
501    };
502
503    let files_preserved_opt = if files_preserved.is_empty() {
504        None
505    } else {
506        Some(files_preserved)
507    };
508
509    Ok(InitOutput {
510        status,
511        project_dir: project_dir.to_string(),
512        files_created,
513        files_overwritten: files_overwritten_opt,
514        files_preserved: files_preserved_opt,
515        directories_created,
516        error,
517        warning,
518        next_steps: vec![
519            "Run 'make setup' to initialize your project".to_string(),
520            "Edit schema/domain.ttl to define your domain model".to_string(),
521            "Create Tera templates in templates/ for your target languages".to_string(),
522            "Run 'make build' to generate code from your ontology".to_string(),
523        ],
524    })
525}