genfile 0.6.0

CLI for genfile_core template archive management - create, manage, and materialize code generation templates.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
//! Integration tests for materialization commands
//!
//! Tests FR6: Template Materialization - .materialize and .unpack commands
//!
//! ## Why These Tests Exist
//!
//! Materialization is the core purpose of genfile - transforming template archives
//! into actual files with parameter substitution. These tests ensure:
//! 1. Templates render correctly with parameter values
//! 2. File structure preserved from archive to output
//! 3. Mandatory parameters validated before materialization
//! 4. Dry run previews work without creating files
//!
//! ## Test Approach
//!
//! Uses REPL mode (piping commands) to test stateful workflow:
//! 1. Create/load archive with template files
//! 2. Set parameter values
//! 3. Materialize to destination
//!
//! This mirrors real user workflow from quick start example (lib.rs:16-18).
//!
//! ## Implementation Lessons
//!
//! **Destination Parameter Type (Critical):**
//! The `.materialize` command uses `Kind::Path` (not `Kind::Directory`) for the
//! destination parameter. WHY: Users expect materialization to create the output
//! directory if it doesn't exist. Using `Kind::Directory` causes validation errors
//! when the path doesn't exist yet, breaking the natural workflow.
//!
//! **Mandatory Parameter Validation:**
//! Validation must occur BEFORE calling `archive.materialize()` to prevent partial
//! output. Use `archive.values.as_ref().is_none_or(|v| !v.has_value(p))` pattern
//! to check if mandatory parameters have values set.

use std::fs;

mod cli_runner;

// FR6: Template Materialization Tests

#[ test ]
fn materialize_renders_templates_with_parameters()
{
  // Test: Basic materialization workflow - pack → load → set values → materialize
  //
  // WHY: Validates the documented quick start workflow actually works end-to-end.
  // This is the primary use case for genfile.
  //
  // VALIDATES:
  // - Template variable substitution via Handlebars
  // - File creation in destination directory
  // - Parameter values correctly applied
  // - Archive state persistence across REPL commands

  let temp_dir = std::env::temp_dir();
  let source_dir = temp_dir.join( "test_materialize_source" );
  let archive_path = temp_dir.join( "test_materialize_archive.json" );
  let destination = temp_dir.join( "test_materialize_output" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
  let _ = fs::remove_dir_all( &destination );

  // Create source template directory
  fs::create_dir_all( &source_dir ).expect( "Should create source dir" );
  fs::write(
    source_dir.join( "readme.md" ),
    "# {{project_name}}\n\nCreated by {{author}}"
  ).expect( "Should write template file" );
  fs::write(
    source_dir.join( "config.toml" ),
    "name = \"{{project_name}}\"\nversion = \"{{version}}\""
  ).expect( "Should write config template" );

  // REPL workflow: pack → set values → materialize
  let script = format!(
    ".pack input::{} output::{}\n\
     .archive.load path::{}\n\
     .parameter.add name::project_name mandatory::1\n\
     .parameter.add name::author mandatory::1\n\
     .parameter.add name::version mandatory::0\n\
     .value.set name::project_name value::\"my-project\"\n\
     .value.set name::author value::\"Test User\"\n\
     .value.set name::version value::\"1.0.0\"\n\
     .materialize destination::{}\n\
     exit",
    source_dir.display(),
    archive_path.display(),
    archive_path.display(),
    destination.display()
  );

  let output = cli_runner::repl_command( &script )
    .output()
    .expect( "Materialize workflow should execute" );

  let stdout = String::from_utf8_lossy( &output.stdout );
  let stderr = String::from_utf8_lossy( &output.stderr );

  assert!( output.status.success(), "Workflow should succeed. stdout: {stdout}, stderr: {stderr}" );
  assert!( stdout.contains( "Materialized" ) || stdout.contains( "Created" ), "Should show success message" );

  // Verify output files exist and contain rendered content
  let readme_path = destination.join( "readme.md" );
  let config_path = destination.join( "config.toml" );

  assert!( readme_path.exists(), "readme.md should be created" );
  assert!( config_path.exists(), "config.toml should be created" );

  let readme_content = fs::read_to_string( &readme_path ).expect( "Should read readme" );
  let config_content = fs::read_to_string( &config_path ).expect( "Should read config" );

  // Verify templates rendered (no {{}} left, values substituted)
  assert!( readme_content.contains( "my-project" ), "Should substitute project_name in readme" );
  assert!( readme_content.contains( "Test User" ), "Should substitute author in readme" );
  assert!( !readme_content.contains( "{{" ), "Should not contain unreplaced variables in readme" );

  assert!( config_content.contains( "my-project" ), "Should substitute project_name in config" );
  assert!( config_content.contains( "1.0.0" ), "Should substitute version in config" );
  assert!( !config_content.contains( "{{" ), "Should not contain unreplaced variables in config" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
  let _ = fs::remove_dir_all( &destination );
}

#[ test ]
fn materialize_fails_without_mandatory_parameters()
{
  // Test: Materialization must validate mandatory parameters before rendering
  //
  // WHY: Prevents partial/broken output if user forgets to set required values.
  // Critical for maintaining output quality.
  //
  // EXPECTATION: Clear error message listing missing mandatory parameters

  let temp_dir = std::env::temp_dir();
  let source_dir = temp_dir.join( "test_materialize_mandatory_source" );
  let archive_path = temp_dir.join( "test_materialize_mandatory.json" );
  let destination = temp_dir.join( "test_materialize_mandatory_output" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
  let _ = fs::remove_dir_all( &destination );

  // Create template with mandatory parameter
  fs::create_dir_all( &source_dir ).expect( "Should create source dir" );
  fs::write( source_dir.join( "file.txt" ), "Value: {{mandatory_param}}" )
    .expect( "Should write template" );

  // Workflow: pack → add mandatory param → materialize WITHOUT setting value
  let script = format!(
    ".pack input::{} output::{}\n\
     .archive.load path::{}\n\
     .parameter.add name::mandatory_param mandatory::1\n\
     .materialize destination::{}\n\
     exit",
    source_dir.display(),
    archive_path.display(),
    archive_path.display(),
    destination.display()
  );

  let output = cli_runner::repl_command( &script )
    .output()
    .expect( "Command should execute" );

  let combined = format!( "{}{}", String::from_utf8_lossy( &output.stdout ), String::from_utf8_lossy( &output.stderr ) );

  // Should fail with validation error
  assert!(
    !output.status.success() || combined.contains( "ERROR" ) || combined.contains( "mandatory" ),
    "Should fail or error for missing mandatory parameter. output: {combined}"
  );

  // Destination should NOT be created for failed materialize
  assert!( !destination.exists(), "Destination should not exist after failed materialize" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
}

#[ test ]
fn materialize_dry_run_preview()
{
  // Test: Dry run shows what would be done without creating files
  //
  // WHY: Safety feature - users can preview output before committing.
  // Prevents accidental overwrites of existing files.
  //
  // CRITICAL: No files should be created in destination directory

  let temp_dir = std::env::temp_dir();
  let source_dir = temp_dir.join( "test_materialize_dry_source" );
  let archive_path = temp_dir.join( "test_materialize_dry.json" );
  let destination = temp_dir.join( "test_materialize_dry_output" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
  let _ = fs::remove_dir_all( &destination );

  // Create simple template
  fs::create_dir_all( &source_dir ).expect( "Should create source dir" );
  fs::write( source_dir.join( "test.txt" ), "Hello {{name}}" )
    .expect( "Should write template" );

  // Workflow with dry::1
  let script = format!(
    ".pack input::{} output::{}\n\
     .archive.load path::{}\n\
     .value.set name::name value::\"World\"\n\
     .materialize destination::{} dry::1\n\
     exit",
    source_dir.display(),
    archive_path.display(),
    archive_path.display(),
    destination.display()
  );

  let output = cli_runner::repl_command( &script )
    .output()
    .expect( "Dry run should execute" );

  let stdout = String::from_utf8_lossy( &output.stdout );

  assert!( output.status.success(), "Dry run should succeed" );
  assert!( stdout.contains( "Dry run" ) || stdout.contains( "Would" ), "Should indicate dry run mode" );

  // CRITICAL: No files should be created
  assert!( !destination.exists(), "Destination should NOT exist after dry run" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
}

#[ test ]
fn materialize_without_archive_returns_error()
{
  // Test: Materialize requires loaded archive in REPL state
  //
  // WHY: Common user error - running materialize before loading archive.
  // Must provide clear, actionable error message.

  let temp_dir = std::env::temp_dir();
  let destination = temp_dir.join( "test_materialize_no_archive" );

  // Clean up
  let _ = fs::remove_dir_all( &destination );

  // Try to materialize without loading archive first
  let output = cli_runner::cargo_run_command( &[ ".materialize",
      &format!( "destination::{}", destination.display() ),
    ] )
    .output()
    .expect( "Command should execute" );

  assert!( !output.status.success(), "Should fail without loaded archive" );

  let combined = format!(
    "{}{}",
    String::from_utf8_lossy( &output.stdout ),
    String::from_utf8_lossy( &output.stderr )
  );

  assert!(
    combined.contains( "No archive" ) || combined.contains( "ERROR" ) || combined.contains( "load" ),
    "Should show clear error about missing archive"
  );

  // Clean up
  let _ = fs::remove_dir_all( &destination );
}

// FR6: Unpack Tests - Raw extraction without template rendering

#[ test ]
fn unpack_preserves_template_variables()
{
  // Test: Unpack writes raw template files WITHOUT parameter substitution
  //
  // WHY: This is the CRITICAL difference between .unpack and .materialize.
  // Users need .unpack to get the raw template files with {{}} placeholders intact,
  // for editing templates or distributing them to others.
  //
  // VALIDATES:
  // - Template variables {{}} are NOT replaced
  // - Files created in destination directory
  // - File content matches archive content exactly (no rendering)
  //
  // CRITICAL VERIFICATION:
  // Output files MUST contain {{variable}} placeholders, NOT substituted values

  let temp_dir = std::env::temp_dir();
  let source_dir = temp_dir.join( "test_unpack_source" );
  let archive_path = temp_dir.join( "test_unpack_archive.json" );
  let destination = temp_dir.join( "test_unpack_output" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
  let _ = fs::remove_dir_all( &destination );

  // Create source template directory with template variables
  fs::create_dir_all( &source_dir ).expect( "Should create source dir" );
  fs::write(
    source_dir.join( "template.txt" ),
    "Project: {{project_name}}\nAuthor: {{author}}"
  ).expect( "Should write template file" );

  // REPL workflow: pack → load → unpack (NO value setting)
  let script = format!(
    ".pack input::{} output::{}\n\
     .archive.load path::{}\n\
     .unpack destination::{}\n\
     exit",
    source_dir.display(),
    archive_path.display(),
    archive_path.display(),
    destination.display()
  );

  let output = cli_runner::repl_command( &script )
    .output()
    .expect( "Unpack workflow should execute" );

  let stdout = String::from_utf8_lossy( &output.stdout );
  let stderr = String::from_utf8_lossy( &output.stderr );

  assert!( output.status.success(), "Workflow should succeed. stdout: {stdout}, stderr: {stderr}" );
  assert!( stdout.contains( "Unpacked" ) || stdout.contains( "files" ), "Should show success message" );

  // Verify output file exists
  let template_path = destination.join( "template.txt" );
  assert!( template_path.exists(), "template.txt should be created" );

  let content = fs::read_to_string( &template_path ).expect( "Should read unpacked file" );

  // CRITICAL: Template variables must be preserved, NOT substituted
  assert!( content.contains( "{{project_name}}" ), "Should preserve {{project_name}} placeholder" );
  assert!( content.contains( "{{author}}" ), "Should preserve {{author}} placeholder" );
  assert!( !content.contains( "my-project" ), "Should NOT contain substituted values" );
  assert!( !content.contains( "Test User" ), "Should NOT contain substituted values" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
  let _ = fs::remove_dir_all( &destination );
}

#[ test ]
fn unpack_dry_run_preview()
{
  // Test: Dry run shows what would be unpacked without creating files
  //
  // WHY: Consistent with other commands - users should be able to preview
  // unpack operation before committing to filesystem changes.
  //
  // CRITICAL: No files should be created in destination directory

  let temp_dir = std::env::temp_dir();
  let source_dir = temp_dir.join( "test_unpack_dry_source" );
  let archive_path = temp_dir.join( "test_unpack_dry.json" );
  let destination = temp_dir.join( "test_unpack_dry_output" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
  let _ = fs::remove_dir_all( &destination );

  // Create simple template
  fs::create_dir_all( &source_dir ).expect( "Should create source dir" );
  fs::write( source_dir.join( "file.txt" ), "Hello {{name}}" )
    .expect( "Should write template" );

  // Workflow with dry::1
  let script = format!(
    ".pack input::{} output::{}\n\
     .archive.load path::{}\n\
     .unpack destination::{} dry::1\n\
     exit",
    source_dir.display(),
    archive_path.display(),
    archive_path.display(),
    destination.display()
  );

  let output = cli_runner::repl_command( &script )
    .output()
    .expect( "Dry run should execute" );

  let stdout = String::from_utf8_lossy( &output.stdout );

  assert!( output.status.success(), "Dry run should succeed" );
  assert!( stdout.contains( "Dry run" ) || stdout.contains( "Would" ), "Should indicate dry run mode" );

  // CRITICAL: No files should be created
  assert!( !destination.exists(), "Destination should NOT exist after dry run" );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
}

#[ test ]
fn unpack_without_archive_returns_error()
{
  // Test: Unpack requires loaded archive in REPL state
  //
  // WHY: Common user error - running unpack before loading archive.
  // Must provide clear, actionable error message.

  let temp_dir = std::env::temp_dir();
  let destination = temp_dir.join( "test_unpack_no_archive" );

  // Clean up
  let _ = fs::remove_dir_all( &destination );

  // Try to unpack without loading archive first
  let output = cli_runner::cargo_run_command( &[ ".unpack",
      &format!( "destination::{}", destination.display() ),
    ] )
    .output()
    .expect( "Command should execute" );

  assert!( !output.status.success(), "Should fail without loaded archive" );

  let combined = format!(
    "{}{}",
    String::from_utf8_lossy( &output.stdout ),
    String::from_utf8_lossy( &output.stderr )
  );

  assert!(
    combined.contains( "No archive" ) || combined.contains( "ERROR" ) || combined.contains( "load" ),
    "Should show clear error about missing archive"
  );

  // Clean up
  let _ = fs::remove_dir_all( &destination );
}

// FT-05 (feature/006): Path traversal sequences in destination cause failure
//
// WHY: Materialization must not allow `..` in the user-supplied destination to
// escape the intended output directory and overwrite arbitrary paths.
// validate_path() is called on file paths within the archive, but NOT on the
// destination argument itself. A destination like `/tmp/safe/../../../etc/target`
// resolves outside `/tmp` and either causes a permission-denied error or touches
// paths the user did not intend. Either way the command must fail.
//
// SECURITY NOTE: This is a defense-in-depth test. The OS-level permission check
// already prevents writing to `/etc` as a non-root user. The test verifies that
// the materialization operation fails AND that no file escapes to `/etc`.
#[ test ]
fn test_path_traversal_destination_rejected()
{
  let temp_dir = std::env::temp_dir();
  let source_dir = temp_dir.join( "test_traversal_source" );
  let archive_path = temp_dir.join( "test_traversal_archive.json" );
  // Path that traverses up from /tmp and attempts to write to /etc
  let traversal_destination = "/tmp/traversal_safe/../../../etc/mat_traversal_test";

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
  let _ = std::fs::remove_dir_all( "/etc/mat_traversal_test" );

  // Create a minimal template archive
  fs::create_dir_all( &source_dir ).expect( "Should create source dir" );
  fs::write( source_dir.join( "file.txt" ), "hello" ).expect( "Should write template" );

  let script = format!(
    ".pack input::{} output::{}\n\
     .archive.load path::{}\n\
     .materialize destination::{}\n\
     exit",
    source_dir.display(),
    archive_path.display(),
    archive_path.display(),
    traversal_destination
  );

  let output = cli_runner::repl_command( &script )
    .output()
    .expect( "Command should execute" );

  // The operation must fail (either validation error or permission denied)
  assert!(
    !output.status.success(),
    "Path traversal in destination must cause failure. stdout: {}",
    String::from_utf8_lossy( &output.stdout )
  );

  // Verify no files escaped to /etc
  assert!(
    !std::path::Path::new( "/etc/mat_traversal_test" ).exists(),
    "Should not create directories under /etc via path traversal"
  );

  // Clean up
  let _ = fs::remove_dir_all( &source_dir );
  let _ = fs::remove_file( &archive_path );
}