use std::collections::{ HashMap, HashSet };
use std::path::{ Path, PathBuf };
use crate::
{
ParameterDescriptor,
Parameters,
Value,
Values,
WriteMode,
Error,
TemplateRenderer,
HandlebarsRenderer,
FileSystem,
RealFileSystem,
validate_path,
};
#[derive(Debug, Clone)]
#[cfg_attr(any(feature = "json", feature = "yaml"), derive(serde::Serialize, serde::Deserialize))]
pub struct TemplateArchive
{
pub name: String,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(default = "default_version"))]
pub version: String,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub description: Option< String >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(default))]
pub files: Vec< TemplateFile >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(default))]
pub parameters: Parameters,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub values: Option< Values< Value > >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub metadata: Option< ArchiveMetadata >,
}
fn default_version() -> String
{
"0.1.0".to_string()
}
#[derive(Debug, Clone)]
#[cfg_attr(any(feature = "json", feature = "yaml"), derive(serde::Serialize, serde::Deserialize))]
pub struct ArchiveMetadata
{
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub author: Option< String >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub license: Option< String >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(default, skip_serializing_if = "Vec::is_empty"))]
pub tags: Vec< String >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub created_at: Option< String >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub modified_at: Option< String >,
}
#[derive(Debug, Clone)]
#[cfg_attr(any(feature = "json", feature = "yaml"), derive(serde::Serialize, serde::Deserialize))]
pub struct TemplateFile
{
pub path: PathBuf,
pub content: FileContent,
pub write_mode: WriteMode,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub metadata: Option< FileMetadata >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
#[cfg(feature = "external_content")]
pub content_source: Option< crate::ContentSource >,
}
#[derive(Debug, Clone)]
#[cfg_attr(any(feature = "json", feature = "yaml"), derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(tag = "type", content = "data"))]
pub enum FileContent
{
Text( String ),
#[ cfg_attr( all( any( feature = "json", feature = "yaml" ), feature = "binary" ), serde( with = "base64_encoding" ) ) ]
Binary( Vec< u8 > ),
}
#[ cfg( all( any( feature = "json", feature = "yaml" ), feature = "binary" ) ) ]
mod base64_encoding
{
use serde::{ Deserialize, Deserializer, Serializer };
pub fn serialize< S >( bytes: &Vec< u8 >, serializer: S ) -> Result< S::Ok, S::Error >
where
S: Serializer,
{
use base64::Engine;
let base64_string = base64::engine::general_purpose::STANDARD.encode( bytes );
serializer.serialize_str( &base64_string )
}
pub fn deserialize< 'de, D >( deserializer: D ) -> Result< Vec< u8 >, D::Error >
where
D: Deserializer< 'de >,
{
use base64::Engine;
let base64_string = String::deserialize( deserializer )?;
base64::engine::general_purpose::STANDARD
.decode( base64_string )
.map_err( serde::de::Error::custom )
}
}
#[derive(Debug, Clone)]
#[cfg_attr(any(feature = "json", feature = "yaml"), derive(serde::Serialize, serde::Deserialize))]
pub struct FileMetadata
{
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub permissions: Option< u32 >,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(default))]
pub is_template: bool,
#[cfg_attr(any(feature = "json", feature = "yaml"), serde(skip_serializing_if = "Option::is_none"))]
pub comment: Option< String >,
}
impl TemplateArchive
{
pub fn new( name: impl Into< String > ) -> Self
{
Self
{
name: name.into(),
version: default_version(),
description: None,
files: Vec::new(),
parameters: Parameters::default(),
values: None,
metadata: None,
}
}
pub fn set_version( &mut self, version: impl Into< String > ) -> &mut Self
{
self.version = version.into();
self
}
pub fn set_description( &mut self, desc: impl Into< String > ) -> &mut Self
{
self.description = Some( desc.into() );
self
}
pub fn set_metadata( &mut self, metadata: ArchiveMetadata ) -> &mut Self
{
self.metadata = Some( metadata );
self
}
pub fn add_file(
&mut self,
path: PathBuf,
content: FileContent,
write_mode: WriteMode
) -> &mut Self
{
self.files.push( TemplateFile
{
path,
content,
write_mode,
metadata: None,
#[cfg(feature = "external_content")]
content_source: None,
});
self
}
pub fn add_text_file(
&mut self,
path: PathBuf,
content: impl Into< String >,
write_mode: WriteMode
) -> &mut Self
{
self.add_file( path, FileContent::Text( content.into() ), write_mode )
}
pub fn add_binary_file( &mut self, path: PathBuf, content: Vec< u8 > ) -> &mut Self
{
self.add_file( path, FileContent::Binary( content ), WriteMode::Rewrite )
}
#[cfg(feature = "external_content")]
pub fn add_file_from< S >(
&mut self,
path: PathBuf,
source: S,
write_mode: WriteMode
) -> &mut Self
where
S: crate::IntoContentSource,
{
let content_source = source.into_content_source();
self.files.push( TemplateFile
{
path,
content: FileContent::Text( String::new() ), write_mode,
metadata: None,
content_source: Some( content_source ),
});
self
}
pub fn remove_file( &mut self, path: &Path ) -> Option< TemplateFile >
{
self.files
.iter()
.position( | f | f.path == path )
.map( | idx | self.files.remove( idx ) )
}
#[must_use]
pub fn get_file( &self, path: &Path ) -> Option< &TemplateFile >
{
self.files.iter().find( | f | f.path == path )
}
pub fn get_file_mut( &mut self, path: &Path ) -> Option< &mut TemplateFile >
{
self.files.iter_mut().find( | f | f.path == path )
}
#[must_use]
pub fn has_file( &self, path: &Path ) -> bool
{
self.files.iter().any( | f | f.path == path )
}
#[must_use]
pub fn list_files( &self ) -> Vec< &Path >
{
self.files.iter().map( | f | f.path.as_path() ).collect()
}
#[must_use]
pub fn list_directories( &self ) -> Vec< PathBuf >
{
let mut dirs = HashSet::new();
for file in &self.files
{
let mut current = file.path.as_path();
while let Some( parent ) = current.parent()
{
if parent.as_os_str().is_empty()
{
break;
}
dirs.insert( parent.to_path_buf() );
current = parent;
}
}
let mut result: Vec< _ > = dirs.into_iter().collect();
result.sort();
result
}
pub fn add_parameter( &mut self, param: ParameterDescriptor ) -> &mut Self
{
self.parameters.descriptors.push( param );
self
}
pub fn remove_parameter( &mut self, name: &str ) -> Option< ParameterDescriptor >
{
self.parameters
.descriptors
.iter()
.position( | p | p.parameter == name )
.map( | idx | self.parameters.descriptors.remove( idx ) )
}
#[must_use]
pub fn get_parameter( &self, name: &str ) -> Option< &ParameterDescriptor >
{
self.parameters.descriptors.iter().find( | p | p.parameter == name )
}
#[must_use]
pub fn list_parameters( &self ) -> Vec< &str >
{
self.parameters.descriptors.iter().map( | p | p.parameter.as_str() ).collect()
}
#[must_use]
pub fn list_mandatory_parameters( &self ) -> Vec< &str >
{
self.parameters.list_mandatory()
}
#[cfg(feature = "parameter_discovery")]
#[must_use]
pub fn discover_parameters( &self ) -> HashSet< String >
{
let mut params = HashSet::new();
let pattern = regex::Regex::new( r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}" ).unwrap();
for file in &self.files
{
if let FileContent::Text( content ) = &file.content
{
for cap in pattern.captures_iter( content )
{
if let Some( param_name ) = cap.get( 1 )
{
params.insert( param_name.as_str().to_string() );
}
}
}
}
params
}
#[cfg(feature = "parameter_discovery")]
#[must_use]
pub fn get_undefined_parameters( &self ) -> Vec< String >
{
let discovered = self.discover_parameters();
let defined: HashSet< _ > = self.list_parameters().into_iter().collect();
discovered
.into_iter()
.filter( | p | !defined.contains( p.as_str() ) )
.collect()
}
#[cfg(feature = "parameter_discovery")]
pub fn get_unused_parameters( &self ) -> Vec< String >
{
let discovered = self.discover_parameters();
let defined = self.list_parameters();
defined
.into_iter()
.filter( | p | !discovered.contains( *p ) )
.map( String::from )
.collect()
}
#[cfg(feature = "parameter_discovery")]
#[must_use]
pub fn analyze_parameter_usage( &self ) -> HashMap< String, Vec< PathBuf > >
{
let mut usage: HashMap< String, Vec< PathBuf > > = HashMap::new();
let pattern = regex::Regex::new( r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}" ).unwrap();
for file in &self.files
{
if let FileContent::Text( content ) = &file.content
{
for cap in pattern.captures_iter( content )
{
if let Some( param_name ) = cap.get( 1 )
{
usage
.entry( param_name.as_str().to_string() )
.or_default()
.push( file.path.clone() );
}
}
}
}
usage
}
pub fn set_value( &mut self, name: impl Into< String >, value: Value ) -> &mut Self
{
let name_string = name.into();
if self.values.is_none()
{
self.values = Some( Values::new() );
}
self.values.as_mut().unwrap().insert( &name_string, value );
self
}
#[must_use]
pub fn get_value( &self, name: &str ) -> Option< &Value >
{
self.values.as_ref().and_then( | v | v.get( name ) )
}
pub fn set_values( &mut self, values: HashMap< String, Value > ) -> &mut Self
{
for ( name, value ) in values
{
self.set_value( name, value );
}
self
}
pub fn values_mut( &mut self ) -> &mut Values< Value >
{
if self.values.is_none()
{
self.values = Some( Values::new() );
}
self.values.as_mut().unwrap()
}
pub fn clear_values( &mut self )
{
self.values = None;
}
#[must_use]
pub fn file_count( &self ) -> usize
{
self.files.len()
}
#[must_use]
pub fn text_file_count( &self ) -> usize
{
self.files.iter().filter( | f | matches!( f.content, FileContent::Text( _ ) ) ).count()
}
#[must_use]
pub fn binary_file_count( &self ) -> usize
{
self.files.iter().filter( | f | matches!( f.content, FileContent::Binary( _ ) ) ).count()
}
#[must_use]
pub fn total_size( &self ) -> usize
{
self.files.iter().map( | f |
{
match &f.content
{
FileContent::Text( s ) => s.len(),
FileContent::Binary( b ) => b.len(),
}
}).sum()
}
#[must_use]
pub fn max_directory_depth( &self ) -> usize
{
self.files
.iter()
.map( | f | f.path.components().count().saturating_sub( 1 ) )
.max()
.unwrap_or( 0 )
}
#[cfg(feature = "json")]
pub fn to_json( &self ) -> Result< String, Error >
{
serde_json::to_string( self )
.map_err( | e | Error::Render( format!( "JSON serialization failed: {e}" ) ) )
}
#[cfg(feature = "json")]
pub fn to_json_pretty( &self ) -> Result< String, Error >
{
serde_json::to_string_pretty( self )
.map_err( | e | Error::Render( format!( "JSON serialization failed: {e}" ) ) )
}
#[cfg(feature = "json")]
pub fn from_json( json: &str ) -> Result< Self, Error >
{
serde_json::from_str( json )
.map_err( | e | Error::Render( format!( "JSON deserialization failed: {e}" ) ) )
}
#[cfg(feature = "yaml")]
pub fn to_yaml( &self ) -> Result< String, Error >
{
serde_yaml::to_string( self )
.map_err( | e | Error::Render( format!( "YAML serialization failed: {e}" ) ) )
}
#[cfg(feature = "yaml")]
pub fn from_yaml( yaml: &str ) -> Result< Self, Error >
{
serde_yaml::from_str( yaml )
.map_err( | e | Error::Render( format!( "YAML deserialization failed: {e}" ) ) )
}
pub fn materialize( &self, base_path: &Path ) -> Result< MaterializationReport, Error >
{
let renderer = HandlebarsRenderer::new();
let mut filesystem = RealFileSystem::new();
self.materialize_with_components( base_path, &renderer, &mut filesystem )
}
pub fn materialize_with_components<R, FS>(
&self,
base_path: &Path,
renderer: &R,
filesystem: &mut FS
) -> Result< MaterializationReport, Error >
where
R: TemplateRenderer,
FS: FileSystem,
{
let mut report = MaterializationReport::default();
let values = self.values.as_ref().map( super::values::Values::to_serializable ).unwrap_or_default();
for dir in self.list_directories()
{
report.directories_created.push( dir );
}
for file in &self.files
{
validate_path( &file.path )?;
let full_path = base_path.join( &file.path );
let final_content = match &file.content
{
FileContent::Text( template ) =>
{
renderer.render( template, &values )?
}
FileContent::Binary( bytes ) =>
{
String::from_utf8_lossy( bytes ).to_string()
}
};
let existed = filesystem.exists( &full_path );
filesystem.write( &full_path, &final_content )?;
report.total_bytes_written += final_content.len();
if existed
{
report.files_updated.push( file.path.clone() );
}
else
{
report.files_created.push( file.path.clone() );
}
}
Ok( report )
}
#[cfg(feature = "external_content")]
pub fn materialize_with_resolver<R, FS, CR>(
&self,
base_path: &Path,
renderer: &R,
filesystem: &mut FS,
resolver: &CR
) -> Result< MaterializationReport, Error >
where
R: TemplateRenderer,
FS: FileSystem,
CR: crate::ContentResolver,
{
let mut report = MaterializationReport::default();
let values = self.values.as_ref().map( super::values::Values::to_serializable ).unwrap_or_default();
for dir in self.list_directories()
{
report.directories_created.push( dir );
}
for file in &self.files
{
validate_path( &file.path )?;
let full_path = base_path.join( &file.path );
let content = if let Some( source ) = &file.content_source
{
resolver.resolve( source )?
}
else
{
file.content.clone()
};
let final_content = match &content
{
FileContent::Text( template ) =>
{
renderer.render( template, &values )?
}
FileContent::Binary( bytes ) =>
{
String::from_utf8_lossy( bytes ).to_string()
}
};
let existed = filesystem.exists( &full_path );
filesystem.write( &full_path, &final_content )?;
report.total_bytes_written += final_content.len();
if existed
{
report.files_updated.push( file.path.clone() );
}
else
{
report.files_created.push( file.path.clone() );
}
}
Ok( report )
}
#[cfg(feature = "external_content")]
pub fn materialize_with_storage<R, CS, CR>(
&self,
base_path: &Path,
renderer: &R,
storage: &mut CS,
resolver: &CR
) -> Result< MaterializationReport, Error >
where
R: TemplateRenderer,
CS: crate::ContentStorage,
CR: crate::ContentResolver,
{
let mut report = MaterializationReport::default();
let values = self.values.as_ref().map( super::values::Values::to_serializable ).unwrap_or_default();
for dir in self.list_directories()
{
report.directories_created.push( dir );
}
for file in &self.files
{
validate_path( &file.path )?;
let full_path = base_path.join( &file.path );
let content = if let Some( source ) = &file.content_source
{
resolver.resolve( source )?
}
else
{
file.content.clone()
};
let rendered_content = match &content
{
FileContent::Text( template ) =>
{
FileContent::Text( renderer.render( template, &values )? )
}
FileContent::Binary( bytes ) =>
{
FileContent::Binary( bytes.clone() )
}
};
storage.store( &full_path, &rendered_content )?;
report.total_bytes_written += match &rendered_content
{
FileContent::Text( s ) => s.len(),
FileContent::Binary( b ) => b.len(),
};
report.files_created.push( file.path.clone() );
}
Ok( report )
}
pub fn pack_from_dir( name: impl Into< String >, base_path: &Path ) -> Result< Self, Error >
{
let mut archive = Self::new( name );
fn visit_dir( archive: &mut TemplateArchive, base: &Path, current: &Path ) -> Result< (), Error >
{
for entry in std::fs::read_dir( current )?
{
let entry = entry?;
let path = entry.path();
if path.is_dir()
{
visit_dir( archive, base, &path )?;
}
else if path.is_file()
{
let rel_path = path.strip_prefix( base )
.map_err( | e | Error::Render( format!( "Path error: {e}" ) ) )?
.to_path_buf();
let data = std::fs::read( &path )?;
let content = match String::from_utf8( data.clone() )
{
Ok( text ) => FileContent::Text( text ),
Err( _ ) => FileContent::Binary( data ),
};
archive.add_file( rel_path, content, WriteMode::Rewrite );
}
}
Ok( () )
}
visit_dir( &mut archive, base_path, base_path )?;
Ok( archive )
}
#[cfg(feature = "external_content")]
pub fn internalize< CR >( &mut self, resolver: &CR ) -> Result< (), Error >
where
CR: crate::ContentResolver,
{
for file in &mut self.files
{
if let Some( source ) = &file.content_source
{
let content = resolver.resolve( source )?;
file.content = content;
file.content_source = None;
}
}
Ok( () )
}
#[cfg(feature = "external_content")]
pub fn externalize( &mut self, base_path: &Path ) -> Result< (), Error >
{
std::fs::create_dir_all( base_path )?;
for file in &mut self.files
{
if file.content_source.is_none()
{
let content_filename = format!( "{}.content", file.path.display() ).replace( '/', "_" );
let content_path = base_path.join( &content_filename );
match &file.content
{
FileContent::Text( text ) =>
{
std::fs::write( &content_path, text )?;
}
FileContent::Binary( bytes ) =>
{
std::fs::write( &content_path, bytes )?;
}
}
file.content_source = Some( crate::ContentSource::File { path: content_path } );
file.content = FileContent::Text( String::new() );
}
}
Ok( () )
}
#[cfg(feature = "json")]
pub fn save_to_file( &self, path: &Path ) -> Result< (), Error >
{
let json = self.to_json_pretty()?;
std::fs::write( path, json )?;
Ok( () )
}
#[cfg(feature = "json")]
pub fn load_from_file( path: &Path ) -> Result< Self, Error >
{
let json = std::fs::read_to_string( path )?;
Self::from_json( &json )
}
}
#[derive(Debug, Clone, Default)]
pub struct MaterializationReport
{
pub files_created: Vec< PathBuf >,
pub files_updated: Vec< PathBuf >,
pub files_skipped: Vec< PathBuf >,
pub directories_created: Vec< PathBuf >,
pub total_bytes_written: usize,
pub errors: Vec< ( PathBuf, String ) >,
}