genfile 0.6.0

CLI for genfile_core template archive management - create, manage, and materialize code generation templates.
Documentation
//! Materialize command handler
//!
//! Renders template archives to destination directories with parameter substitution.
//! Implements FR6: Template Materialization.
//!
//! ## Design Decisions
//!
//! **Mandatory Parameter Validation:**
//! Validates all mandatory parameters have values before materialization to prevent
//! partial/broken output. This ensures output quality and clear error messages.
//!
//! **`MaterializationReport` Usage:**
//! Uses `genfile_core`'s `MaterializationReport` to provide detailed feedback about
//! files created, updated, or skipped during materialization.
//!
//! **Dry Run Safety:**
//! Dry run mode previews what would be done without creating files, preventing
//! accidental overwrites. Critical for user confidence.

// Handler functions are registered via unilang::CommandRegistry::command_add_runtime,
// which requires fn(VerifiedCommand, ExecutionContext) -> ... by value.
#![ allow( clippy::needless_pass_by_value ) ]

use unilang::semantic::VerifiedCommand;
use unilang::data::{ OutputData, ErrorData };
use unilang::interpreter::ExecutionContext;
use core::fmt::Write as _;

/// Handler for .materialize command
///
/// Renders template archive to destination directory with parameter substitution.
///
/// # Parameters
/// - `destination` - Output directory path
/// - `verbosity` - Output verbosity (0-5, default: 1)
/// - `dry` - Dry run mode (default: 0)
///
/// # Errors
/// Returns usage error if required parameters are missing.
/// Returns state error if no archive is loaded.
/// Returns validation error if mandatory parameter values are missing.
/// Returns format error if materialization fails.
#[ allow( clippy::too_many_lines ) ]
pub fn materialize_handler(
  cmd : VerifiedCommand,
  _ctx : ExecutionContext
) -> Result< OutputData, ErrorData >
{
  // Extract arguments
  let destination = cmd.get_path( "destination" )
    .ok_or_else( || crate::error::usage_error( "Missing required parameter: destination" ) )?;
  let verbosity = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
  let dry = cmd.get_boolean( "dry" ).unwrap_or( false );

  // Get loaded archive from shared state
  let archive = crate::handlers::shared_state::get_current_archive()
    .ok_or_else( || crate::error::state_error( "No archive loaded. Use .archive.load first." ) )?;

  // Validate mandatory parameters have values
  let mandatory_params = archive.parameters.list_mandatory();
  if !mandatory_params.is_empty()
  {
    let missing : Vec< &str > = mandatory_params
      .iter()
      .filter( | p |
      {
        archive
          .values
          .as_ref()
          .is_none_or( | v | !v.has_value( p ) )
      })
      .copied()
      .collect();

    if !missing.is_empty()
    {
      return Err( crate::error::validation_error( format!(
        "Missing mandatory parameter values: {}. Use .value.set to provide values.",
        missing.join( ", " )
      ) ) );
    }
  }

  // Dry run preview
  if dry
  {
    let file_count = archive.files.len();
    let param_count = archive.values.as_ref().map_or( 0, genfile_core::Values::len );

    let output_content = match verbosity
    {
      0 => String::new(),
      1 => format!( "Dry run: Would materialize {} files to {}", file_count, destination.display() ),
      _ =>
      {
        let files_preview = archive
          .files
          .iter()
          .take( 5 )
          .map( | f | format!( "  - {}", f.path.display() ) )
          .collect::< Vec< _ > >()
          .join( "\n" );

        let more = if file_count > 5 { format!( "\n  ... and {} more files", file_count - 5 ) } else { String::new() };

        format!(
          "Dry run: Would materialize templates\n\
          Destination: {}\n\
          Archive: {}\n\
          Files: {}\n\
          Parameters: {}\n\
          Files to create:\n\
          {}{}",
          destination.display(),
          archive.name,
          file_count,
          param_count,
          files_preview,
          more
        )
      }
    };

    return Ok( OutputData
    {
      content : output_content,
      format : "text".to_string(),
      execution_time_ms : None,
    } );
  }

  // Materialize templates to destination
  let report = archive.materialize( destination )
    .map_err( | e | crate::error::format_error( &e, "MATERIALIZE" ) )?;

  let total_files = report.files_created.len() + report.files_updated.len() + report.files_skipped.len();

  // Format output based on verbosity
  let output_content = match verbosity
  {
    0 => String::new(),
    1 => format!( "Materialized {} files to {}", total_files, destination.display() ),
    _ =>
    {
      let mut details = format!(
        "Materialized templates\n\
        Archive: {}\n\
        Destination: {}\n\
        Created: {}\n\
        Updated: {}\n\
        Skipped: {}",
        archive.name,
        destination.display(),
        report.files_created.len(),
        report.files_updated.len(),
        report.files_skipped.len()
      );

      if verbosity >= 3 && !report.files_created.is_empty()
      {
        details.push_str( "\n\nCreated files:\n" );
        for file in &report.files_created
        {
          let _ = writeln!( &mut details, "  - {}", file.display() );
        }
      }

      if verbosity >= 3 && !report.files_updated.is_empty()
      {
        details.push_str( "\nUpdated files:\n" );
        for file in &report.files_updated
        {
          let _ = writeln!( &mut details, "  - {}", file.display() );
        }
      }

      details
    }
  };

  Ok( OutputData
  {
    content : output_content,
    format : "text".to_string(),
    execution_time_ms : None,
  } )
}

/// Handler for .unpack command
///
/// Unpacks raw template files to destination without rendering.
/// Preserves {{variable}} placeholders intact.
///
/// # Parameters
/// - `destination` - Output directory path
/// - `verbosity` - Output verbosity (0-5, default: 1)
/// - `dry` - Dry run mode (default: 0)
///
/// # Errors
/// Returns usage error if required parameters are missing.
/// Returns state error if no archive is loaded.
/// Returns file error if the destination directory cannot be created or files cannot be written.
#[ allow( clippy::too_many_lines ) ]
pub fn unpack_handler(
  cmd : VerifiedCommand,
  _ctx : ExecutionContext
) -> Result< OutputData, ErrorData >
{
  use std::fs;

  // Extract arguments
  let destination = cmd.get_path( "destination" )
    .ok_or_else( || crate::error::usage_error( "Missing required parameter: destination" ) )?;
  let verbosity = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
  let dry = cmd.get_boolean( "dry" ).unwrap_or( false );

  // Get loaded archive from shared state
  let archive = crate::handlers::shared_state::get_current_archive()
    .ok_or_else( || crate::error::state_error( "No archive loaded. Use .archive.load first." ) )?;

  let file_count = archive.files.len();

  // Dry run preview
  if dry
  {
    let output_content = match verbosity
    {
      0 => String::new(),
      1 => format!( "Dry run: Would unpack {} files to {}", file_count, destination.display() ),
      _ =>
      {
        let files_preview = archive
          .files
          .iter()
          .take( 5 )
          .map( | f | format!( "  - {}", f.path.display() ) )
          .collect::< Vec< _ > >()
          .join( "\n" );

        let more = if file_count > 5 { format!( "\n  ... and {} more files", file_count - 5 ) } else { String::new() };

        format!(
          "Dry run: Would unpack templates\n\
          Destination: {}\n\
          Archive: {}\n\
          Files: {}\n\
          Mode: raw (no rendering)\n\
          Files to create:\n\
          {}{}",
          destination.display(),
          archive.name,
          file_count,
          files_preview,
          more
        )
      }
    };

    return Ok( OutputData
    {
      content : output_content,
      format : "text".to_string(),
      execution_time_ms : None,
    } );
  }

  // Create destination directory if needed
  fs::create_dir_all( destination )
    .map_err( | e | crate::error::file_error( format!( "Failed to create destination directory: {e}" ) ) )?;

  // Unpack files without rendering
  let mut files_created = Vec::new();

  for file in &archive.files
  {
    let file_path = destination.join( &file.path );

    // Create parent directories if needed
    if let Some( parent ) = file_path.parent()
    {
      fs::create_dir_all( parent )
        .map_err( | e | crate::error::file_error( format!( "Failed to create directory {}: {}", parent.display(), e ) ) )?;
    }

    // Write file content directly without rendering
    match &file.content
    {
      genfile_core::FileContent::Text( text ) =>
      {
        fs::write( &file_path, text )
          .map_err( | e | crate::error::file_error( format!( "Failed to write file {}: {}", file_path.display(), e ) ) )?;
      }
      genfile_core::FileContent::Binary( bytes ) =>
      {
        fs::write( &file_path, bytes )
          .map_err( | e | crate::error::file_error( format!( "Failed to write file {}: {}", file_path.display(), e ) ) )?;
      }
    }

    files_created.push( file_path );
  }

  // Format output based on verbosity
  let output_content = match verbosity
  {
    0 => String::new(),
    1 => format!( "Unpacked {} files to {}", files_created.len(), destination.display() ),
    _ =>
    {
      let mut details = format!(
        "Unpacked templates\n\
        Archive: {}\n\
        Destination: {}\n\
        Files: {}\n\
        Mode: raw (no rendering)",
        archive.name,
        destination.display(),
        files_created.len()
      );

      if verbosity >= 3 && !files_created.is_empty()
      {
        details.push_str( "\n\nCreated files:\n" );
        for file in &files_created
        {
          let _ = writeln!( &mut details, "  - {}", file.display() );
        }
      }

      details
    }
  };

  Ok( OutputData
  {
    content : output_content,
    format : "text".to_string(),
    execution_time_ms : None,
  } )
}