#![cfg_attr(coverage_nightly, coverage(off))]
use super::context::AgentContext;
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
#[async_trait]
pub trait TemplateGenerator: Send + Sync {
fn generate(&self, ctx: &AgentContext) -> Result<GeneratedFiles>;
fn validate_context(&self, ctx: &AgentContext) -> Result<()>;
fn post_generation_hooks(&self, _path: &Path) -> Result<()> {
Ok(())
}
fn name(&self) -> &str;
fn description(&self) -> &str;
}
pub struct GeneratedFiles {
pub files: HashMap<PathBuf, FileContent>,
pub permissions: HashMap<PathBuf, u32>,
pub symlinks: Vec<(PathBuf, PathBuf)>,
}
impl GeneratedFiles {
#[must_use]
pub fn new() -> Self {
Self {
files: HashMap::new(),
permissions: HashMap::new(),
symlinks: Vec::new(),
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn add_text_file(&mut self, path: impl Into<PathBuf>, content: impl Into<String>) {
self.files
.insert(path.into(), FileContent::Text(content.into()));
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn add_binary_file(&mut self, path: impl Into<PathBuf>, content: Vec<u8>) {
self.files.insert(path.into(), FileContent::Binary(content));
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn add_template_file(&mut self, path: impl Into<PathBuf>, template: impl Into<String>) {
self.files
.insert(path.into(), FileContent::Template(template.into()));
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn set_permissions(&mut self, path: impl Into<PathBuf>, permissions: u32) {
self.permissions.insert(path.into(), permissions);
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn add_symlink(&mut self, source: impl Into<PathBuf>, target: impl Into<PathBuf>) {
self.symlinks.push((source.into(), target.into()));
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn write_to_disk(&self, base_path: &Path) -> Result<()> {
fs::create_dir_all(base_path).await?;
for (relative_path, content) in &self.files {
let full_path = base_path.join(relative_path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?;
}
match content {
FileContent::Text(text) | FileContent::Template(text) => {
fs::write(&full_path, text).await?;
}
FileContent::Binary(bytes) => {
fs::write(&full_path, bytes).await?;
}
}
#[cfg(unix)]
if let Some(&perms) = self.permissions.get(relative_path) {
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(perms);
fs::set_permissions(&full_path, permissions).await?;
}
}
#[cfg(unix)]
for (source, target) in &self.symlinks {
let source_path = base_path.join(source);
let target_path = base_path.join(target);
if source_path.exists() {
std::os::unix::fs::symlink(&source_path, &target_path)?;
}
}
Ok(())
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
#[must_use]
pub fn contains_file(&self, path: &Path) -> bool {
self.files.contains_key(path)
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
#[must_use]
pub fn file_count(&self) -> usize {
self.files.len()
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn merge(&mut self, other: GeneratedFiles) {
self.files.extend(other.files);
self.permissions.extend(other.permissions);
self.symlinks.extend(other.symlinks);
}
}
impl Default for GeneratedFiles {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub enum FileContent {
Text(String),
Binary(Vec<u8>),
Template(String),
}
impl FileContent {
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Text(s) | Self::Template(s) => Some(s),
Self::Binary(_) => None,
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
match self {
Self::Text(s) | Self::Template(s) => s.as_bytes(),
Self::Binary(b) => b,
}
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_generated_files_creation() {
let mut files = GeneratedFiles::new();
files.add_text_file("src/main.rs", "fn main() {}");
files.add_binary_file("data.bin", vec![0, 1, 2, 3]);
files.set_permissions("script.sh", 0o755);
assert_eq!(files.file_count(), 2);
assert!(files.contains_file(Path::new("src/main.rs")));
assert!(files.contains_file(Path::new("data.bin")));
}
#[tokio::test]
async fn test_write_to_disk() {
let temp_dir = TempDir::new().unwrap();
let mut files = GeneratedFiles::new();
files.add_text_file("src/main.rs", "fn main() { println!(\"Hello\"); }");
files.add_text_file("Cargo.toml", "[package]\nname = \"test\"");
files.write_to_disk(temp_dir.path()).await.unwrap();
let main_path = temp_dir.path().join("src/main.rs");
assert!(main_path.exists());
let content = fs::read_to_string(main_path).await.unwrap();
assert_eq!(content, "fn main() { println!(\"Hello\"); }");
let cargo_path = temp_dir.path().join("Cargo.toml");
assert!(cargo_path.exists());
}
#[test]
fn test_file_content() {
let text = FileContent::Text("hello".to_string());
assert_eq!(text.as_str(), Some("hello"));
assert_eq!(text.as_bytes(), b"hello");
let binary = FileContent::Binary(vec![1, 2, 3]);
assert_eq!(binary.as_str(), None);
assert_eq!(binary.as_bytes(), &[1, 2, 3]);
}
#[test]
fn test_merge_files() {
let mut files1 = GeneratedFiles::new();
files1.add_text_file("file1.txt", "content1");
files1.set_permissions("file1.txt", 0o644);
let mut files2 = GeneratedFiles::new();
files2.add_text_file("file2.txt", "content2");
files2.set_permissions("file2.txt", 0o755);
files1.merge(files2);
assert_eq!(files1.file_count(), 2);
assert!(files1.contains_file(Path::new("file1.txt")));
assert!(files1.contains_file(Path::new("file2.txt")));
assert_eq!(files1.permissions.len(), 2);
}
struct MinimalGenerator;
#[async_trait]
impl TemplateGenerator for MinimalGenerator {
fn generate(&self, _ctx: &AgentContext) -> Result<GeneratedFiles> {
Ok(GeneratedFiles::new())
}
fn validate_context(&self, _ctx: &AgentContext) -> Result<()> {
Ok(())
}
fn name(&self) -> &str {
"minimal"
}
fn description(&self) -> &str {
"minimal test generator"
}
}
#[test]
fn test_post_generation_hooks_default_returns_ok() {
let g = MinimalGenerator;
let tmp = TempDir::new().unwrap();
g.post_generation_hooks(tmp.path()).expect("default Ok");
}
#[test]
fn test_generated_files_default_matches_new() {
let a = GeneratedFiles::default();
let b = GeneratedFiles::new();
assert_eq!(a.file_count(), b.file_count());
assert_eq!(a.permissions.len(), b.permissions.len());
assert_eq!(a.symlinks.len(), b.symlinks.len());
}
#[test]
fn test_empty_collection_file_count_and_contains() {
let files = GeneratedFiles::new();
assert_eq!(files.file_count(), 0);
assert!(!files.contains_file(Path::new("missing.txt")));
}
#[test]
fn test_add_template_file_stores_as_template_variant() {
let mut files = GeneratedFiles::new();
files.add_template_file("tpl.md.tmpl", "hello {{name}}");
let content = files.files.get(Path::new("tpl.md.tmpl")).unwrap();
assert!(matches!(content, FileContent::Template(_)));
assert_eq!(content.as_str(), Some("hello {{name}}"));
}
#[test]
fn test_add_symlink_appends_pair() {
let mut files = GeneratedFiles::new();
files.add_symlink("real.txt", "link.txt");
files.add_symlink("src/a.rs", "src/b.rs");
assert_eq!(files.symlinks.len(), 2);
assert_eq!(
files.symlinks[0],
(PathBuf::from("real.txt"), PathBuf::from("link.txt"))
);
assert_eq!(
files.symlinks[1],
(PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs"))
);
}
#[test]
fn test_file_content_template_as_str_and_bytes() {
let tpl = FileContent::Template("tpl".to_string());
assert_eq!(tpl.as_str(), Some("tpl"));
assert_eq!(tpl.as_bytes(), b"tpl");
}
#[tokio::test]
async fn test_write_to_disk_writes_binary_and_template_variants() {
let tmp = TempDir::new().unwrap();
let mut files = GeneratedFiles::new();
files.add_binary_file("data.bin", vec![0xDE, 0xAD, 0xBE, 0xEF]);
files.add_template_file("tpl.txt", "raw template content");
files.write_to_disk(tmp.path()).await.expect("write");
let bin = fs::read(tmp.path().join("data.bin"))
.await
.expect("read bin");
assert_eq!(bin, vec![0xDE, 0xAD, 0xBE, 0xEF]);
let tpl = fs::read_to_string(tmp.path().join("tpl.txt"))
.await
.expect("read tpl");
assert_eq!(tpl, "raw template content");
}
#[cfg(unix)]
#[tokio::test]
async fn test_write_to_disk_applies_permissions_on_unix() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let mut files = GeneratedFiles::new();
files.add_text_file("run.sh", "#!/bin/sh\necho hi");
files.set_permissions("run.sh", 0o755);
files.write_to_disk(tmp.path()).await.expect("write");
let meta = std::fs::metadata(tmp.path().join("run.sh")).expect("meta");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "expected 0o755, got 0o{:o}", mode);
}
#[cfg(unix)]
#[tokio::test]
async fn test_write_to_disk_creates_symlinks_when_source_exists() {
let tmp = TempDir::new().unwrap();
let mut files = GeneratedFiles::new();
files.add_text_file("real.txt", "the real file");
files.add_symlink("real.txt", "alias.txt");
files.write_to_disk(tmp.path()).await.expect("write");
let alias = tmp.path().join("alias.txt");
let sym_meta = std::fs::symlink_metadata(&alias).expect("symlink metadata");
assert!(
sym_meta.file_type().is_symlink(),
"alias.txt is not a symlink: {:?}",
sym_meta.file_type()
);
let read_through = std::fs::read_to_string(&alias).expect("read through symlink");
assert_eq!(read_through, "the real file");
}
#[cfg(unix)]
#[tokio::test]
async fn test_write_to_disk_skips_symlink_when_source_missing() {
let tmp = TempDir::new().unwrap();
let mut files = GeneratedFiles::new();
files.add_symlink("does-not-exist", "alias.txt");
files.write_to_disk(tmp.path()).await.expect("write");
assert!(!tmp.path().join("alias.txt").exists());
}
#[test]
fn test_merge_preserves_symlinks_and_overwrites_duplicates() {
let mut a = GeneratedFiles::new();
a.add_text_file("shared.txt", "from a");
a.add_symlink("real_a", "link_a");
let mut b = GeneratedFiles::new();
b.add_text_file("shared.txt", "from b"); b.add_symlink("real_b", "link_b");
b.set_permissions("shared.txt", 0o644);
a.merge(b);
match a.files.get(Path::new("shared.txt")).unwrap() {
FileContent::Text(s) => assert_eq!(s, "from b"),
other => panic!("expected Text, got {other:?}"),
}
assert_eq!(a.symlinks.len(), 2);
assert_eq!(a.permissions.len(), 1);
}
#[tokio::test]
async fn test_write_to_disk_on_existing_directory_is_ok() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join("sub")).unwrap();
let mut files = GeneratedFiles::new();
files.add_text_file("sub/hello.txt", "hi");
files
.write_to_disk(tmp.path())
.await
.expect("write over existing dir");
let content = fs::read_to_string(tmp.path().join("sub/hello.txt"))
.await
.expect("read");
assert_eq!(content, "hi");
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod property_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn basic_property_stability(_input in ".*") {
prop_assert!(true);
}
#[test]
fn module_consistency_check(_x in 0u32..1000) {
prop_assert!(_x < 1001);
}
}
}